Saltar a contenido

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 payment construye a partir del ticket.
  • Céntimos: toda cantidad monetaria es un int en 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 comanda y session reaccionen al pago como suscriptores de un evento, sin que payment los 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 AsyncSession en construcción; sus métodos no reciben sesión.
  • Método único: get_bill_snapshot(session_token) -> BillSnapshot | None. Devuelve None cuando 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) e idempotency_key opcional (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) y failure_reason (texto legible cuando success es False; None en é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ética fake_<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_seconds en el constructor (default 0): los tests pueden inyectar un retardo para ejercitar el camino de timeout; producción corre con 0.
  • 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 fila Payment se marca como FAILED, se adjunta al Bill para historia, se hace flush, se publica el evento interno PaymentFailed y se lanza PaymentGatewayError (→ HTTP 502). El Bill nunca se marca como pagado.

Detalle de implementación en PayBillUseCase.execute:

  1. Se crea el Payment en PENDING y se pasa a PROCESSING antes de llamar al PSP.
  2. Si gateway.charge() lanza o devuelve success=False: payment.fail(), se adjunta a bill.payments (historia, sin sumar a amount_paid_cents), repository.save(bill) + flush, se publica PaymentFailed (best-effort) y se relanza como PaymentGatewayError.
  3. 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):

  1. Claim de la key (PaymentIdempotencyStore + tabla payment_idempotency_keys): el router reclama la key con claim-first (SAVEPOINT vía begin_nested() + unique constraint, capturando IntegrityError) antes del caso de uso, y la completa (cacheando la respuesta) después del commit. Garantiza idempotencia de respuesta.
  2. 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 (JSONResponse con status_code + body); si está reclamada pero en vuelo, lanza 409 (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 con created_at fuera 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. PayBillUseCase no publica el evento: solo devuelve just_fully_paid. El endpoint pay_bill, tras hacer commit, publica PaymentConfirmed dentro de with 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.
  • ContextVar compartido: se publica usando app.shared.infrastructure.runtime_context (use_session), no la ContextVar per-module de comanda/session. El evento lo consumen subscribers en otros módulos y una ContextVar per-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 → primero SERVED (con served_at), luego CLOSED (con closed_at).
    • SERVED → directamente CLOSED.
    • 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 la TableSession (la sesión deja de estar activa) reutilizando el caso de uso invalidate_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 (pago CONFIRMED y registrado), antes de que la factura llegue a PAID.
  • PaymentFailed: cuando el PSP declina o el cargo lanza (pago en FAILED; 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 que amount_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) y custom (usa tip_cents tal cual). Los porcentajes son regla de negocio del dominio (TIP_PERCENT_MAPPING).
  • Modos de reparto (SplitMode): none / whole_table (factura como un único bloque) y equal / per_person (dividir entre guest_count; per_person es el término de cara al cliente, equal el atajo interno; ambos calculan igual). El BillDTO/BillResponse expone shares: list[int] con los importes ya repartidos.
  • refresh_totals se bloquea con pagos: Bill.refresh_totals() solo se permite mientras el bill está OPEN y antes de registrar cualquier Payment. 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 enviar expected_total_cents en el pago; si no coincide con amount_due_cents (propina/split cambiados entre la lectura y el cobro), se lanza BillTotalMismatchError → 409 para 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