Pagos¶
El módulo payment (app/modules/payment/) gestiona el cobro al cliente al final de la
sesión: lee el ticket de la mesa, lo convierte en una factura (Bill), cobra contra una
pasarela de pago (PSP) y, cuando la factura queda totalmente pagada, cierra la sesión.
Es un módulo hexagonal independiente de comanda y de session: no importa sus entidades.
Toda la comunicación cruzada ocurre a través de puertos compartidos (lectura del ticket,
cobro) y de un evento de dominio (PaymentConfirmed), respetando ADR-004 (ver
Arquitectura hexagonal).
Glosario rápido
- PSP (Payment Service Provider): la pasarela que cobra la tarjeta (Stripe, Redsys, Adyen…). Hoy se usa un stub:
FakePaymentGateway. - Ticket: el conjunto de comandas activas de una sesión, agregado por el módulo
comanda. - Bill (factura): la representación de cobro que el módulo
paymentconstruye a partir del ticket. - Céntimos: toda cantidad monetaria es un
inten céntimos (12,50 € =1250).
Por qué existe como módulo aparte¶
El cobro tiene reglas propias (pagos parciales, propinas, idempotencia, fail-closed frente al PSP) que no pertenecen ni a la comanda ni a la sesión. Sacarlo a su propio módulo permite:
- Aislar la lógica de facturación y su máquina de estados.
- Cambiar de PSP sin tocar el dominio ni el caso de uso.
- Que
comandaysessionreaccionen al pago como suscriptores de un evento, sin quepaymentlos conozca.
Los endpoints (/sessions/{token}/bill)¶
El router (app/modules/payment/interface/router.py, prefijo /sessions, tag payment)
expone cuatro endpoints sobre la factura de una sesión:
| Método | Ruta | Qué hace |
|---|---|---|
POST |
/sessions/{token}/bill |
Abre o refresca la factura desde el ticket vivo. Idempotente: una sesión tiene como máximo una factura activa. 200 OK. |
GET |
/sessions/{token}/bill |
Lee la factura activa (con shares). 404 si no existe. |
PATCH |
/sessions/{token}/bill |
Configura split_mode y propina (tip_mode + tip_cents). |
POST |
/sessions/{token}/bill/payments |
Cobra un pago. Acepta cabecera Idempotency-Key. 201 Created. |
El POST /bill solo refresca antes de pagar
Si la factura ya existe y no se ha registrado ningún pago, sus totales se recalculan
desde el ticket vivo. Si ya hay pagos, se devuelve sin cambios (los totales quedan
congelados — ver Reglas de producto). Si la sesión
no tiene ticket que facturar, devuelve 404 (SessionNotPayableError).
Mapeo de errores HTTP¶
El caso de uso lanza excepciones de dominio; el router las traduce a códigos HTTP (las que no
mapea explícitamente caen en los handlers globales de main.py):
| Excepción de dominio | HTTP | Cuándo |
|---|---|---|
BillNotFoundError / SessionNotPayableError |
404 |
No hay factura activa / la sesión no tiene ticket. |
BillTotalMismatchError |
409 |
El expected_total_cents enviado ya no coincide con amount_due_cents (total cambió entre lectura y pago). |
BillAlreadyPaidError |
409 |
Intento de pagar o reconfigurar una factura ya PAID. |
PaymentExceedsRemainingError |
402 |
El importe supera lo que queda por cobrar. |
PaymentGatewayError |
502 |
El PSP lanzó o devolvió success=False (FAIL-CLOSED). |
InvalidTipError |
422 |
tip_cents inválido / tip_mode desconocido. |
split_mode desconocido |
400 |
Valor que no casa con SplitMode. |
Idempotency-Key en vuelo |
409 |
Otra petición con la misma key está aún procesándose (IdempotencyInProgress). |
Las dos pasarelas: leer el ticket y cobrar¶
El módulo depende de dos puertos distintos. No los confundas: uno lee datos, el otro
mueve dinero. Ambos viven en app/shared/application/ports.py (no en el módulo), de modo
que la implementación concreta se registra en arranque vía una fábrica perezosa (register_* /
get_*).
IBillingGateway — leer el ticket de la sesión¶
Permite al módulo payment obtener el estado facturable de una sesión sin importar
comanda ni session.
- Lo implementa un adapter dentro del módulo
comanda(el único que ya agrega las comandas activas de una sesión). - Está ligado a una
AsyncSessionen construcción; sus métodos no reciben sesión. - Método único:
get_bill_snapshot(session_token) -> BillSnapshot | None. DevuelveNonecuando la sesión no tiene ticket activo (sin comandas, o sesión cerrada).
El DTO BillSnapshot es de solo lectura y trae las cantidades ya en céntimos:
| Campo | Tipo | Significado |
|---|---|---|
restaurant_id |
int |
Restaurante de la sesión. |
table_id |
int |
Mesa de la sesión. |
guest_count |
int |
Número de comensales. |
subtotal_cents |
int |
Suma de los totales de línea (céntimos). |
tax_cents |
int |
Suma de impuestos (céntimos). |
total_cents |
int |
Subtotal + impuestos (céntimos). |
Dirección de la dependencia
payment lee del ticket vía IBillingGateway. El flujo inverso (cerrar la comanda
cuando se paga) no es una llamada de payment a comanda: ocurre por el evento
PaymentConfirmed, al que comanda está suscrito.
IPaymentGateway — cobrar contra el PSP¶
Es el puerto del PSP: abstrae el cargo de una tarjeta contra una factura.
Por qué el puerto del PSP vive en shared y no en payment
Inicialmente el ABC se definió en app/modules/payment/application/ports.py. make lint-arch
detectó dos violaciones de capa: la use-case lazy-importaba FakePaymentGateway desde
infrastructure, y el adapter importaba el puerto de su propia application. La solución fue
mover el ABC (y sus DTOs ChargeRequest / ChargeResult) a app.shared.application.ports,
igual que IBillingGateway. Detalle en
backend/docs/findings/2026-06-04-rd06-payment-module.md (finding F6).
El fichero app/modules/payment/domain/ports.py re-exporta el puerto y los DTOs, y mantiene
un pequeño registro local de fábrica (register_payment_gateway / get_payment_gateway) para
que el caso de uso resuelva la pasarela sin importar infrastructure directamente
(PayBillUseCase._resolve_gateway()).
El PSP es stateless: la fábrica registrada no recibe argumentos (no necesita la AsyncSession
de la request para cobrar una tarjeta). En arranque, main.py ejecuta
register_payment_gateway(lambda: FakePaymentGateway()).
Contrato de cobro:
- Entrada:
ChargeRequest—amount_cents(siempre > 0),currency(ISO-4217; el caso de uso envía siempre"EUR", pero el campo se conserva para un PSP real),bill_id(correlación en logs) eidempotency_keyopcional (token de idempotencia del lado del PSP). - Salida:
ChargeResult—success(bool),reference(referencia de transacción del PSP; siempre presente en éxito, puede venir también en declive para trazabilidad) yfailure_reason(texto legible cuandosuccessesFalse;Noneen éxito).
FakePaymentGateway (PSP simulado)¶
Hoy el puerto lo implementa FakePaymentGateway
(infrastructure/gateways/fake_payment_gateway.py):
- Aprueba siempre el cargo (
success=True) con una referencia sintéticafake_<idempotency_key>_<amount_cents>; con la misma key produce la misma referencia (un reintento se distingue de un segundo cargo real a nivel de log). Sin key, mina una referencia derivada de UUID4. - Acepta
delay_secondsen el constructor (default0): los tests pueden inyectar un retardo para ejercitar el camino de timeout; producción corre con0. - Para simular un fallo real se intercambia por otro adapter, o se hace que
charge()lance, ejercitando el camino FAIL-CLOSED.
Un adapter real de Stripe / Redsys / Adyen puede sustituirlo sin tocar el dominio ni el caso de uso.
FAIL-CLOSED: nunca auto-confirmar ante error del PSP¶
Regla innegociable del módulo (alineada con la política FAIL-CLOSED del backend; ver convenciones del backend):
Si la pasarela lanza una excepción o devuelve
success=False, la filaPaymentse marca comoFAILED, se adjunta alBillpara historia, se haceflush, se publica el evento internoPaymentFailedy se lanzaPaymentGatewayError(→ HTTP502). ElBillnunca se marca como pagado.
Detalle de implementación en PayBillUseCase.execute:
- Se crea el
PaymentenPENDINGy se pasa aPROCESSINGantes de llamar al PSP. - Si
gateway.charge()lanza o devuelvesuccess=False:payment.fail(), se adjunta abill.payments(historia, sin sumar aamount_paid_cents),repository.save(bill)+flush, se publicaPaymentFailed(best-effort) y se relanza comoPaymentGatewayError. - Solo si el PSP confirma:
payment.confirm(reference)→bill.register_payment(payment)→repository.save(bill).
El dinero solo se da por cobrado con confirmación explícita
No hay timeouts que "asuman éxito" ni reintentos que confirmen por su cuenta. register_payment
exige que el Payment esté en estado CONFIRMED o lanza ValueError: el dominio jamás suma
un pago no confirmado. Un fallo del PSP deja la factura abierta y la sesión activa; el cliente
puede reintentar el pago.
Idempotencia (Idempotency-Key)¶
Los POST /bill/payments aceptan la cabecera Idempotency-Key (máx. 255 caracteres). Un POST
repetido con la misma key dentro de 24 h (_TTL = timedelta(hours=24)) devuelve la
misma respuesta HTTP cacheada, sin volver a llamar al PSP. Es una regla dura del contrato
con el frontend.
La idempotencia opera en dos niveles independientes (finding F2):
- Claim de la key (
PaymentIdempotencyStore+ tablapayment_idempotency_keys): el router reclama la key con claim-first (SAVEPOINT víabegin_nested()+ unique constraint, capturandoIntegrityError) antes del caso de uso, y la completa (cacheando la respuesta) después del commit. Garantiza idempotencia de respuesta. - Unicidad de la fila
Payment: garantiza no-doble-cargo al PSP.
Flujo en pay_bill:
_claim_or_cached(...): si la key ya está completada y vigente, devuelve la respuesta cacheada (JSONResponseconstatus_code+body); si está reclamada pero en vuelo, lanza409(IdempotencyInProgress); si es nueva, reclama y deja proceder.- Tras
db.commit(),store.complete(scope, key, status_code=201, body=...)graba la respuesta sobre la fila del claim (best-effort: si falla, se loguea y no rompe el cobro). Las entradas concreated_atfuera del TTL se tratan como caducadas y se re-reclaman.
Son niveles distintos a propósito: un mismo cliente puede querer pagar la misma cantidad dos veces
seguidas con dos keys diferentes — son dos cargos legítimos. La unicidad de Payment evita el
doble cargo; el claim de la key garantiza que un reintento con la misma key no vuelve a cobrar.
Evento de dominio PaymentConfirmed¶
Cuando una factura queda totalmente pagada, el módulo payment publica PaymentConfirmed
(definido en app/shared/domain/events.py). Es el mecanismo por el que la comanda y la sesión se
cierran sin que payment las importe.
PaymentConfirmed (frozen dataclass) lleva session_token, bill_id, amount_paid_cents y
occurred_at. El caso de uso señala con su valor de retorno (BillDTO, just_fully_paid) cuándo
toca publicarlo: register_payment devuelve True cuando ese pago completó la factura.
Publicado por el router, post-commit y vía ContextVar compartido¶
- Lo publica el router, no el caso de uso.
PayBillUseCaseno publica el evento: solo devuelvejust_fully_paid. El endpointpay_bill, tras hacer commit, publicaPaymentConfirmeddentro dewith use_session(db)(best-effort: cualquier fallo se loguea y no rompe la respuesta). - Post-commit: el evento se publica después del commit de la transacción, nunca antes (convención de dominio del backend). Patrón: shared session binding + commit-then-publish.
ContextVarcompartido: se publica usandoapp.shared.infrastructure.runtime_context(use_session), no laContextVarper-module decomanda/session. El evento lo consumen subscribers en otros módulos y unaContextVarper-module no se propaga entre módulos (finding F3 — primer evento cross-module del proyecto).
Suscriptores: qué pasa al confirmar el pago¶
Reaccionan dos subscribers, ambos suscritos a PaymentConfirmed en el DomainEventBus:
comanda(_on_payment_confirmed): cierra las comandas activas de la sesión. La transición respeta la máquina de estados (finding F4):READY→ primeroSERVED(conserved_at), luegoCLOSED(conclosed_at).SERVED→ directamenteCLOSED.CLOSED/CANCELED→ se omiten.- Comandas que aún no llegaron a
READY(p. ej.IN_PROGRESS/PENDING) quedan intactas: cerrar algo que aún no está listo sería un side-effect incorrecto desde la perspectiva de cocina.
session(_on_payment_confirmed): invalida laTableSession(la sesión deja de estar activa) reutilizando el caso de usoinvalidate_session.
PSP confirma cobro → bill.register_payment ⇒ just_fully_paid=True
│ (db.commit en el router)
▼
router publica PaymentConfirmed (use_session compartido, post-commit)
├── comanda → READY ⇒ SERVED ⇒ CLOSED ; SERVED ⇒ CLOSED ; resto intacto
└── session → TableSession invalidada
Sin eventos SSE nuevos
El módulo payment no emite eventos SSE (finding F9). PaymentConfirmed es interno:
se consume dentro del mismo proceso vía el DomainEventBus. El frontend conoce el resultado
del pago por la respuesta HTTP del POST y por el refetch del bill.
Eventos internos del módulo¶
app/modules/payment/domain/events.py define dos eventos module-internal (hooks de
log / SSE futuros, sin transporte cableado aún):
PaymentInitiated: tras un cargo exitoso (pagoCONFIRMEDy registrado), antes de que la factura llegue aPAID.PaymentFailed: cuando el PSP declina o el cargo lanza (pago enFAILED; los totales del bill no cambian). Es el que publica el camino FAIL-CLOSED.
PaymentConfirmed no se define aquí a propósito: es cross-module y vive en shared.
Modelo de dominio: Bill y Payment¶
El agregado tiene una sola raíz, Bill; las filas Payment se crean solo a través de
Bill.register_payment (los hijos se modifican solo vía la raíz).
Bill (raíz del agregado)¶
Guarda el snapshot de totales tomado del ticket más el estado de cobro. Campos relevantes:
subtotal_cents, tax_cents, base_total_cents, tip_cents, amount_paid_cents, split_mode,
status, version (optimistic locking) y la lista payments. Propiedades derivadas:
amount_due_cents=base_total_cents + tip_cents.remaining_cents=max(0, amount_due_cents - amount_paid_cents).is_fully_paid=amount_paid_cents >= amount_due_cents.
register_payment solo acepta pagos CONFIRMED, suma amount_cents, y al alcanzar el total pasa
el bill a PAID devolviendo True. El sobrepago se clampa:
amount_paid_cents = min(amount_paid_cents, amount_due_cents) para mantener la contabilidad en
sincronía con el amount_due visible (un reembolso, si hace falta, se gestiona aparte). Un pago
parcial deja el bill en PARTIALLY_PAID y devuelve False.
Máquinas de estado¶
BillStatus (OPEN → PARTIALLY_PAID → PAID, terminal) lo conduce solo register_payment.
PaymentStatus (PENDING → PROCESSING → (CONFIRMED | FAILED), con CONFIRMED/FAILED
terminales) lo conduce PaymentStateMachine; las transiciones son métodos del entity
(mark_processing, confirm, fail).
Decisiones de producto relevantes¶
Confirmadas en RD06 (backend/docs/findings/2026-06-04-rd06-payment-module.md):
- Pagos parciales reales: el cliente puede pagar menos que
amount_due_cents. La sesión permanece activa hasta queamount_paid_cents == amount_due_cents. - Propinas (
TipMode):none,percent_5,percent_10,percent_15,round_up(redondeo al alza al euro: el extra es la propina) ycustom(usatip_centstal cual). Los porcentajes son regla de negocio del dominio (TIP_PERCENT_MAPPING). - Modos de reparto (
SplitMode):none/whole_table(factura como un único bloque) yequal/per_person(dividir entreguest_count;per_persones el término de cara al cliente,equalel atajo interno; ambos calculan igual). ElBillDTO/BillResponseexponeshares: list[int]con los importes ya repartidos. refresh_totalsse bloquea con pagos:Bill.refresh_totals()solo se permite mientras el bill estáOPENy antes de registrar cualquierPayment. Reconfigurar propinas o totales a media de pagos dejaría los pagos ya confirmados apuntando a importes viejos; cambiar el desglose obliga a cancelar primero los pagos pendientes (finding F5).- Guard de total obsoleto (
expected_total_cents): el cliente puede enviarexpected_total_centsen el pago; si no coincide conamount_due_cents(propina/split cambiados entre la lectura y el cobro), se lanzaBillTotalMismatchError→409para que refresque antes de cargar (RDFIX03).
Money en céntimos¶
Toda cantidad monetaria es un int en céntimos en BD y en los DTOs (subtotal_cents,
tax_cents, total_cents, amount_cents, amount_due_cents, amount_paid_cents,
remaining_cents, tip_cents). El value object Money vive en app.shared.domain (ver
convenciones). Los primitivos viven solo en el borde; nunca se usan floats para dinero.
Mapa del módulo¶
app/modules/payment/
domain/
entities.py # Bill (raíz) + Payment
value_objects.py # SplitMode, BillStatus, PaymentStatus, TipMode + presets de propina
state_machine.py # PaymentStateMachine
events.py # PaymentInitiated, PaymentFailed (module-internal)
exceptions.py # BillNotFound, BillAlreadyPaid, PaymentExceedsRemaining,
# PaymentGateway, SessionNotPayable, BillTotalMismatch, InvalidTip
ports.py # re-export de IPaymentGateway + registro local de fábrica
repository.py # IBillRepository
services/
calculations.py # compute_shares / compute_tip / round_up (puros)
application/
use_cases/ # request_bill, get_bill, configure_bill, pay_bill
commands.py # PayBillCommand, ConfigureBillCommand, RequestBillCommand
dtos.py # BillDTO (con shares), PaymentDTO (frozen dataclasses)
infrastructure/
gateways/ # fake_payment_gateway (PSP stub)
repositories/ # SqlAlchemyBillRepository
models/ # bill_model, payment_model (tabla bill_payments),
# payment_idempotency_model
idempotency_store.py # PaymentIdempotencyStore (claim/complete, TTL 24h)
interface/
router.py # /sessions/{token}/bill[/payments]
schemas.py # BillResponse, PayBillRequest, ConfigureBillRequest, …
Nombre de tabla bill_payments
La fila Payment del módulo usa __tablename__="bill_payments" para no colisionar con la
Payment legacy (tracker a nivel de comanda) ya mapeada por Alembic. El nombre deja claro
que la fila es de la factura, no de la comanda (finding F1).
Páginas relacionadas¶
- Arquitectura hexagonal — capas, puertos y ADR-004.
- Comanda — el ticket que
IBillingGatewayagrega y el subscriber que cierra las comandas al pagar. - Sesión y carrito — la
TableSessionque se invalida al pagar. - Referencia API — contrato HTTP de los endpoints de bill.
- Convenciones — FAIL-CLOSED, money en céntimos, eventos post-commit.