Skip to content

PRD — Dominio 10: billing

Pagos: cobra al cliente, retiene comisión de plataforma, desembolsa al negocio. Orquesta authorize → capture → (refund) según el lifecycle de un Booking. Es la única vía por la que dinero entra y sale del sistema.


1. Propósito

Resolver la mecánica financiera del marketplace:

  • Cobrar al cliente en el momento correcto (al confirmar reserva: autorización; al completar servicio: captura).
  • Retener la comisión de la plataforma antes de transferir el resto al negocio.
  • Reembolsar parcial o totalmente según la política de cancelación (booking.RN-4) o la política de no-show (booking.RN-6).
  • Desembolsar (payout) al negocio en ciclos previsibles (semanal en MVP).
  • Mantener un ledger auditable: para cualquier booking, se puede reconstruir qué se cobró, qué se retuvo, qué se reembolsó, qué se desembolsó.

billing no decide cuándo cancelar o marcar no-show — solo ejecuta las consecuencias monetarias de esos eventos que le llegan desde booking.

2. Entidades

EntidadResponsabilidad
PaymentIntentOrquestación del pago de un booking. Lifecycle propio. 1:1 con Booking.
PaymentCobro capturado (o intento de cobro). Puede haber más de uno por PaymentIntent si hubo reintentos exitosos.
RefundDevolución total o parcial. 0..N por Payment.
CommissionSnapshot de la comisión aplicada a un booking (tasa + monto).
PayoutTransferencia agregada hacia un Business en una ventana temporal.
LedgerEntryAsiento contable (doble entrada) para cada movimiento. Auditoría inmutable.
BillingAccountCuenta financiera vinculada al Business (credenciales hacia el provider, estado KYC, cuenta bancaria para payout).

Lifecycle de PaymentIntent

created → authorized → captured → (refunded | partially_refunded)
              ↓            ↓
           failed       failed

           voided  (antes de capture, si booking cancelled)
  • created: se creó la intención pero aún no hay hold del método de pago en el proveedor.
  • authorized: el proveedor retuvo el monto en la tarjeta del cliente (typical hold 7 días).
  • captured: la retención se cobró.
  • voided: se liberó la retención antes de capturar (cancelación pre-servicio con refund total).
  • refunded / partially_refunded: tras captura, se devolvió total o parcialmente.
  • failed: el proveedor rechazó.

Estructura de LedgerEntry (doble entrada simplificada)

id
booking_id?       (nullable — algunos entries son de payout, no de booking)
payment_id?
refund_id?
payout_id?
account           ('client_receivable' | 'business_payable' | 'platform_revenue' | 'processor_fee' | 'cash')
direction         ('debit' | 'credit')
amount            DECIMAL
currency
created_at

Toda operación produce dos entries (debit + credit) que suman cero. Permite reconstruir balances por business, por periodo, por cuenta.

Relaciones

Booking ──1:1── PaymentIntent ──< Payment ──< Refund
                              
Business ── 1:1 ── BillingAccount
Business ── 1:N ── Payout

Commission: snapshot por booking (rate, amount, business_id, created_at)

LedgerEntry: referencia opcional a booking/payment/refund/payout

3. Historias de usuario

  • U-1. Como Sofía, al confirmar mi reserva de $60.000 se retiene ese monto en mi tarjeta. No se cobra todavía.
  • U-2. Como Sofía, al completarse el servicio (María marca complete), se cobra la retención.
  • U-3. Como Sofía, si cancelo con >24h de anticipación, la retención se libera sin cargo.
  • U-4. Como Sofía, si cancelo con <24h, se cobra el 50% (según política de Glamour) y el resto se libera.
  • U-5. Como Sofía, si la sede cancela (block/ausencia), recupero el 100% sin importar la hora.
  • U-6. Como Carlos (dueño), todos los lunes recibo un payout con el neto de la semana anterior (cobrado – comisión – refunds).
  • U-7. Como Carlos, veo un reporte semanal de ingresos brutos, comisión retenida, refunds, y neto a pagar.
  • U-8. Como Carlos, antes de mi primer payout tengo que completar KYC + cuenta bancaria. Si falta, los cobros se retienen en "holding" hasta resolverse.
  • U-9. Como María (profesional independiente en su branch virtual), también recibo payouts (mi Business virtual tiene su BillingAccount).
  • U-10. Como Diego (platform admin), puedo consultar el ledger completo para auditoría. No puedo modificarlo (append-only).
  • U-11. Como Sofía, si el pago falla (tarjeta rechazada) al confirmar, mi booking queda hold hasta que reintente. Si el hold expira, se cancela.

4. Requerimientos funcionales

Integración con el proveedor de pagos

  • RF-1 Adapter pattern: el dominio expone una interfaz PaymentProvider implementada por un adapter específico (MVP: Wompi o Mercado Pago Split — decisión operativa). El resto del dominio no conoce detalles del proveedor.
  • RF-2 Operaciones abstractas del adapter: authorize, capture (siempre por el monto completo autorizado), void, refund (parcial o total post-captura), transfer_to_connected_account (split), create_onboarding_link (KYC), verify_webhook.

    Nota sobre proveedores colombianos (Wompi, Mercado Pago): no soportan captura parcial — capture cobra el monto autorizado completo. Cuando se necesita cobrar solo una fracción (cancelación con penalidad, no-show <100%), el flujo obligatorio es capture(total) → refund(exceso). El adapter encapsula esta diferencia; el dominio nunca llama partial_capture.

Creación de PaymentIntent (en flujo de booking)

  • RF-3 Consume booking.hold_created → crea PaymentIntent(status=created, amount=sum(BookingItem.price), currency). Asocia al booking_id. Responde con client_secret del provider para que la app complete el flujo.
  • RF-4 La app cliente usa el client_secret para autorizar el pago contra el provider (3DS, tokenización, etc.) sin pasar datos de tarjeta por el backend.
  • RF-5 Webhook del provider payment_intent.authorizedPOST /webhooks/billing actualiza PaymentIntent.status=authorized. Emite evento interno payment.authorized { booking_id }.
  • RF-6 POST /bookings/{id}/confirm (endpoint de booking) requiere PaymentIntent.status=authorized para permitir la transición a confirmed (salvo modos in_person y cash_only — ver RF-15).

Captura al completar el servicio

  • RF-7 Consume booking.completed → llama adapter.capture(PaymentIntent).
    • Éxito: emite payment.captured { booking_id, amount }. Actualiza PaymentIntent.status=captured. Crea Payment y Commission. Escribe LedgerEntrys.
    • Fallo: PaymentIntent.status=failed. Emite payment.failed. Alerta al negocio (vía notifications).
  • RF-8 La captura divide el monto en 3 partes:
    • Platform commission = amount * business.commission_rate (default 7%, configurable por Business en fase 2).
    • Processor fee (estimado o real según proveedor) → se registra como coste de plataforma.
    • Business payable = amount - platform_commission - processor_fee.
  • RF-9 Commission.rate se congela al momento de payment.captured (un cambio posterior en la tasa del Business no afecta bookings ya capturados).

Refunds

  • RF-10 Consume booking.cancelled { reason_code, refund_amount } → ejecuta según estado:
    • Si PaymentIntent.status=authorized:
      • refund_amount == totaladapter.void(). Libera el hold sin cobrar. status=voided.
      • refund_amount < total (penalidad parcial) → adapter.capture(total) + inmediatamente adapter.refund(refund_amount). Net: cliente paga total - refund_amount. status=partially_refunded.
    • Si PaymentIntent.status=capturedadapter.refund(amount=refund_amount). Registra Refund. El monto sale del business_payable (no del platform_revenue — salvo cancelaciones system_*, ver RN-3).
  • RF-11 refund.issued { booking_id, amount, reason } → emitido a notifications.
  • RF-12 Refunds parciales dejan PaymentIntent.status=partially_refunded. Pueden acumularse varios refunds pero la suma nunca excede Payment.amount.

No-show

  • RF-13 Consume booking.no_show → aplica NoShowPolicy.charge_percent_on_no_show:
    • Si 100%: adapter.capture(total) — igual que booking.completed.
    • Si <100%: adapter.capture(total) + adapter.refund(total - charge_amount). No hay captura parcial nativa — siempre se captura el total y se devuelve el exceso. El neto es el monto de penalidad.
  • RF-14 El no-show charge va al business_payable con comisión aplicada normalmente. MVP: no hay tarifa "penalty" distinta.

Modos sin pago online

  • RF-15 PaymentMode por Branch: {online | in_person | hybrid}.
    • in_person: no se crea PaymentIntent. El booking confirma sin authorize. notifications avisa al cliente que paga en sede.
    • hybrid: cliente elige. Default online. En in_person aplica lo anterior.
    • billing no rastrea transacciones en efectivo (fuera del sistema financiero), pero queda un marcador payment_mode=in_person en el booking para reporting. No hay payout por este concepto.

Payouts

  • RF-16 Ciclo MVP: semanal, corte los lunes 00:00 local del negocio.
  • RF-17 Worker payout-scheduler corre el lunes 04:00 local:
    • Por cada Business con BillingAccount.kyc_status=approved y balance disponible > umbral (default $50.000 COP):
      • Agrega business_payable entries desde último payout hasta el corte.
      • Crea Payout(status=scheduled, amount, from_date, to_date).
      • Llama adapter.transfer_to_connected_account().
      • Emite payout.scheduled.
    • Webhook del provider confirma transfer → Payout.status=completed, emite payout.completed.
  • RF-18 Si kyc_status != approved: fondos quedan en business_payable (no se paga). Cuando el KYC se complete, el siguiente ciclo los desembolsa.
  • RF-19 GET /businesses/{id}/payouts lista payouts. Requiere ser owner o admin.
  • RF-20 GET /businesses/{id}/balance devuelve balance pendiente, desglose por fecha, próximo corte estimado.

KYC y onboarding

  • RF-21 Al crear Business (de tipo regular — los virtuales se onboardean al primer cobro), billing crea un BillingAccount(status=pending_kyc).
  • RF-22 POST /businesses/{id}/billing/onboard devuelve una URL firmada del provider (Wompi/Mercado Pago) para que el dueño complete KYC + cuenta bancaria. Al volver, webhook actualiza kyc_status.
  • RF-23 Ningún booking se puede capturar contra un BillingAccount.kyc_status != approved — se deja en authorized esperando. Si el KYC no se resuelve en 14 días, se vuela (void) y se notifica.

Reporting

  • RF-24 GET /businesses/{id}/reports/revenue?from=&to= — agregados: gross, commission, refunds, net, payouts, bookings count. Solo owner o delegados con can_view_billing.
  • RF-25 GET /admin/ledger?business_id=&from=&to= — ledger completo por business. Solo platform_admin.

5. Reglas de negocio

  • RN-1 Dinero solo se mueve en respuesta a eventos de booking. Ningún endpoint de billing cobra/refunda fuera del ciclo booking (salvo reenvíos de payout manuales por admin).
  • RN-2 Ledger append-only. LedgerEntry nunca se modifica ni se borra. Corregir un error = nuevo asiento con signo contrario + nuevo asiento correcto.
  • RN-3 En cancelaciones sistémicas (system_branch_blocked, system_time_off, system_membership_ended): refund total. La comisión de plataforma de ese booking se anula (si se había capturado, se revierte — no se queda la plataforma con algo que no se prestó). Los costes del processor fee no se recuperan — queda como coste operativo de la plataforma.
  • RN-4 En cancelaciones by_client con política < full refund: el cliente paga la penalidad; el negocio la recibe íntegra (menos comisión y processor fee) — no se aplica comisión reducida.
  • RN-5 En no_show: se cobra según NoShowPolicy. Comisión de plataforma aplica normal.
  • RN-6 Moneda única por booking: Payment.currency == Booking.currency == BranchService.currency. Conversiones multi-moneda: fuera de MVP.
  • RN-7 Congelamiento de commission.rate al momento de captura. Cambios futuros en la tasa del Business no retroactuan.
  • RN-8 Un booking no puede ser capturado dos veces. UNIQUE(booking_id) en Payment con status=captured.
  • RN-9 Autorizaciones expiran según el provider (típicamente 7 días). Para bookings con start_at > authorize_at + 7d, el worker reauth-scheduler re-autoriza con anticipación. Política de fallback si falla:
    • T-48h: intento de re-autorización. Si falla → emite payment.reauthorization_failed { booking_id }notifications avisa al cliente urgentemente.
    • T-24h y T-6h: reintentos.
    • T-2h: si aún sin autorización válida → emite payment.authorization_expired { booking_id }booking cancela con reason_code = payment_authorization_expired (política de cancelación: sin penalidad al cliente — fue fallo de pago, no de voluntad). billing no tiene nada que cobrar; solo registra el intento fallido en el ledger.
  • RN-10 El balance nunca es negativo: si los refunds superan lo acumulado en el ciclo, la diferencia se descuenta del siguiente payout (no se retira del bolsillo del negocio). Si el negocio cierra con balance negativo, la plataforma absorbe (coste documentado para analytics).
  • RN-11 BillingAccount es 1:1 con Business. Transferir titularidad entre Accounts no está permitido en MVP (fase 2+).
  • RN-12 platform_admin puede leer cualquier ledger/report pero no mutar. Correcciones van por flujo de nuevos asientos (RN-2).
  • RN-13 Branches virtual (profesional independiente) tienen su propio BillingAccount del Business virtual. El profesional recibe payouts directamente.
  • RN-14 Payment provider maneja los datos sensibles (PCI). Backend nunca ve PAN / CVV / tokens sensibles.

6. Flujos críticos

Flujo feliz (reserva → cobro → payout):

(booking emite booking.hold_created)
  ↓ billing crea PaymentIntent(status=created, client_secret)
  → app cliente autoriza contra provider
  → webhook payment_intent.authorized → PaymentIntent.status=authorized
  → emite payment.authorized

(booking.confirmed — gate ya satisfecho por authorized)

(servicio ocurre; booking.completed)
  ↓ billing captura
  adapter.capture(PaymentIntent) → OK
  UPDATE PaymentIntent.status=captured
  INSERT Payment, Commission (rate=7%, amount=4200 sobre 60000)
  INSERT LedgerEntry:
    debit  client_receivable  60000
    credit cash              60000       (cobró el provider)
    debit  cash              60000
    credit platform_revenue  4200
    credit processor_fee      1800       (estimado)
    credit business_payable  54000
  Emite payment.captured

(lunes 04:00 corte semanal)
  Balance de Glamour: $540.000 (acumulado de 10 bookings)
  KYC approved
  Payout.status=scheduled
  adapter.transfer_to_connected_account(540000)
  Webhook payout.paid → status=completed
  INSERT LedgerEntry:
    debit  business_payable 540000
    credit cash             540000   (fondos enviados)
  Emite payout.completed
  notifications → Carlos recibe resumen semanal

Cancelación by_client <24h (política 50%):

booking.cancelled { reason=by_client, refund_amount=30000 }  // del 60000

  PaymentIntent.status==authorized → captura completa + refund del exceso
  (Wompi/Mercado Pago Colombia no soportan captura parcial)

  adapter.capture(60000) → OK
  adapter.refund(30000)  → OK  (devuelve el 50% libre de penalidad)

  INSERT Payment(amount=60000)
  INSERT Refund(amount=30000)
  Commission solo sobre el monto neto cobrado (60000 - 30000 = 30000):
    Commission(rate=7%, amount=2100)
  LedgerEntry:
    debit  client_receivable  60000
    credit cash               60000     (cobró el provider)
    debit  cash               30000     (refund al cliente)
    credit client_receivable  30000
    debit  cash               30000     (neto cobrado)
    credit platform_revenue    2100
    credit processor_fee        900
    credit business_payable   27000
  PaymentIntent.status = partially_refunded
  Emite payment.captured + refund.issued

Cancelación sistémica (full refund, comisión se anula):

booking.cancelled { reason=system_branch_blocked, refund_amount=60000 }

  Si PaymentIntent.status=authorized → void() directo. No se cobra nada.
    LedgerEntry (anulación del hold):
      debit  client_receivable 60000
      credit client_receivable 60000   (neteo — no hay movimiento de cash)
    Estado: PaymentIntent.status=voided
  
  Si PaymentIntent.status=captured ya (raro para cancel pre-servicio pero posible si hubo delay):
    adapter.refund(60000)
    INSERT Refund(amount=60000)
    LedgerEntry (reversión):
      debit  business_payable   54000  (devuelve al cliente lo que había ido al business)
      debit  platform_revenue    4200  (se anula la comisión — RN-3)
      debit  processor_fee       1800  (la plataforma absorbe este coste — RN-3)
      credit cash               60000
    PaymentIntent.status=refunded
  Emite refund.issued

No-show (charge 100%):

booking.no_show
  ↓ igual a booking.completed en términos de captura
  capturar full amount, aplicar comisión, escribir ledger.

KYC no completo → fondos en holding:

Business nuevo, BillingAccount.kyc_status=pending
  Sofía reserva, paga, María completa → PaymentIntent.status=captured
  Ledger:
    ... business_payable +27000
  Payout-scheduler lunes:
    kyc_status != approved → skip este payout, deja el balance acumulando
  
  Carlos completa KYC el miércoles
  Siguiente lunes:
    balance disponible (incluye bookings viejos) = 540000
    paga todo en un solo payout

7. Dependencias

DominioTipo
authzcan_view_billing (business), can_manage_billing_account (business). platform_admin para auditoría del ledger.
bookingTrigger principal: consume booking.hold_created, .confirmed, .cancelled, .completed, .no_show.
accountsLee Business, BillingAccount. Al crear Business crea BillingAccount(status=pending_kyc).
notificationsRecibe payment.captured, .failed, refund.issued, payout.scheduled, .completed, kyc.required.
catalogSolo snapshot de precio vía Booking — billing nunca lee BranchService directo.
Provider (Wompi / Mercado Pago / Stripe)Integración externa vía adapter.

billing no es leído por otros dominios durante la operación — su efecto se sabe por eventos que emite.

8. Fuera de alcance (MVP)

  • Multi-moneda (bookings en USD, conversiones) — fase 5+.
  • Comisión por Business configurable — fase 2. MVP: un único rate global.
  • Comisiones variables por categoría de servicio (haircuts 5%, balayage 8%) — fase 3.
  • Split entre profesional y negocio (si el profesional asociado recibe un % del cobro directo) — fase 3. MVP: 100% al Business, el Business liquida internamente con el profesional.
  • Depósitos / reservas con seña — fase 3.
  • Pagos en efectivo registrados — fase 2 (MVP solo marca modo in_person sin rastrear el flujo).
  • Facturación fiscal (DIAN, emisión de factura electrónica Colombia) — fase 2/3 crítica pero no MVP.
  • Chargebacks / disputes (gestión formal) — fase 2. MVP: bloquea Account tras dispute confirmado.
  • Frecuencia de payout configurable (diario / mensual) — fase 2. MVP: solo semanal.
  • Reporting contable exportable (CSV/Excel por período) — fase 2.
  • Múltiples cuentas bancarias por Business — no previsto. Una sola.
  • Precios dinámicos / surge pricing (comisión que cambia por hora/demanda) — no en roadmap.
  • Pagos recurrentes / suscripciones del negocio a la plataforma (premium) — fase 6 con módulo separado (subscriptions).

9. Métricas

  • p95 authorize → confirmed < 3s (experiencia de checkout fluida).
  • p95 booking.completed → payment.captured < 30s.
  • 0 Payment capturados dos veces por el mismo booking (violación RN-8).
  • 0 asientos del ledger que no cuadren (suma de debits == suma de credits por booking).
  • ≥ 99.5% de captures exitosas al primer intento.
  • < 0.5% de bookings con captura fallida que no reintentan dentro de 1h.
  • < 2% de refunds que requieren intervención manual (la mayoría pasa por el flujo automático).
  • 100% de businesses con KYC approved reciben payout dentro del ciclo siguiente.
  • < 0.1% de diferencia entre sum(business_payable entries) y sum(payouts completed) tras 24h de corte — si crece, alerta.
  • Chargeback rate < 0.3% (indicador de fraude sano).
  • Tiempo mediano de KYC approved < 48h desde el inicio del onboarding.

Documentación interna — BeautyHub