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 unBooking. 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
| Entidad | Responsabilidad |
|---|---|
PaymentIntent | Orquestación del pago de un booking. Lifecycle propio. 1:1 con Booking. |
Payment | Cobro capturado (o intento de cobro). Puede haber más de uno por PaymentIntent si hubo reintentos exitosos. |
Refund | Devolución total o parcial. 0..N por Payment. |
Commission | Snapshot de la comisión aplicada a un booking (tasa + monto). |
Payout | Transferencia agregada hacia un Business en una ventana temporal. |
LedgerEntry | Asiento contable (doble entrada) para cada movimiento. Auditoría inmutable. |
BillingAccount | Cuenta 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_atToda 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/payout3. 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
holdhasta 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
PaymentProviderimplementada 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 —
capturecobra el monto autorizado completo. Cuando se necesita cobrar solo una fracción (cancelación con penalidad, no-show <100%), el flujo obligatorio escapture(total) → refund(exceso). El adapter encapsula esta diferencia; el dominio nunca llamapartial_capture.
Creación de PaymentIntent (en flujo de booking)
- RF-3 Consume
booking.hold_created→ creaPaymentIntent(status=created, amount=sum(BookingItem.price), currency). Asocia albooking_id. Responde conclient_secretdel provider para que la app complete el flujo. - RF-4 La app cliente usa el
client_secretpara 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.authorized→POST /webhooks/billingactualizaPaymentIntent.status=authorized. Emite evento internopayment.authorized { booking_id }. - RF-6
POST /bookings/{id}/confirm(endpoint debooking) requierePaymentIntent.status=authorizedpara permitir la transición aconfirmed(salvo modosin_personycash_only— ver RF-15).
Captura al completar el servicio
- RF-7 Consume
booking.completed→ llamaadapter.capture(PaymentIntent).- Éxito: emite
payment.captured { booking_id, amount }. ActualizaPaymentIntent.status=captured. CreaPaymentyCommission. EscribeLedgerEntrys. - Fallo:
PaymentIntent.status=failed. Emitepayment.failed. Alerta al negocio (víanotifications).
- Éxito: emite
- 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.
- Platform commission =
- RF-9
Commission.ratese congela al momento depayment.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 == total→adapter.void(). Libera el hold sin cobrar.status=voided.refund_amount < total(penalidad parcial) →adapter.capture(total)+ inmediatamenteadapter.refund(refund_amount). Net: cliente pagatotal - refund_amount.status=partially_refunded.
- Si
PaymentIntent.status=captured→adapter.refund(amount=refund_amount). RegistraRefund. El monto sale delbusiness_payable(no delplatform_revenue— salvo cancelacionessystem_*, ver RN-3).
- Si
- RF-11
refund.issued { booking_id, amount, reason }→ emitido anotifications. - RF-12 Refunds parciales dejan
PaymentIntent.status=partially_refunded. Pueden acumularse varios refunds pero la suma nunca excedePayment.amount.
No-show
- RF-13 Consume
booking.no_show→ aplicaNoShowPolicy.charge_percent_on_no_show:- Si
100%:adapter.capture(total)— igual quebooking.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.
- Si
- RF-14 El no-show charge va al
business_payablecon comisión aplicada normalmente. MVP: no hay tarifa "penalty" distinta.
Modos sin pago online
- RF-15
PaymentModepor Branch:{online | in_person | hybrid}.in_person: no se creaPaymentIntent. El booking confirma sin authorize.notificationsavisa al cliente que paga en sede.hybrid: cliente elige. Default online. Enin_personaplica lo anterior.billingno rastrea transacciones en efectivo (fuera del sistema financiero), pero queda un marcadorpayment_mode=in_personen 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-schedulercorre el lunes 04:00 local:- Por cada Business con
BillingAccount.kyc_status=approvedy balance disponible > umbral (default $50.000 COP):- Agrega
business_payableentries desde último payout hasta el corte. - Crea
Payout(status=scheduled, amount, from_date, to_date). - Llama
adapter.transfer_to_connected_account(). - Emite
payout.scheduled.
- Agrega
- Webhook del provider confirma transfer →
Payout.status=completed, emitepayout.completed.
- Por cada Business con
- RF-18 Si
kyc_status != approved: fondos quedan enbusiness_payable(no se paga). Cuando el KYC se complete, el siguiente ciclo los desembolsa. - RF-19
GET /businesses/{id}/payoutslista payouts. Requiere ser owner o admin. - RF-20
GET /businesses/{id}/balancedevuelve balance pendiente, desglose por fecha, próximo corte estimado.
KYC y onboarding
- RF-21 Al crear
Business(de tiporegular— los virtuales se onboardean al primer cobro),billingcrea unBillingAccount(status=pending_kyc). - RF-22
POST /businesses/{id}/billing/onboarddevuelve una URL firmada del provider (Wompi/Mercado Pago) para que el dueño complete KYC + cuenta bancaria. Al volver, webhook actualizakyc_status. - RF-23 Ningún booking se puede capturar contra un
BillingAccount.kyc_status != approved— se deja enauthorizedesperando. 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 concan_view_billing. - RF-25
GET /admin/ledger?business_id=&from=&to=— ledger completo por business. Soloplatform_admin.
5. Reglas de negocio
- RN-1 Dinero solo se mueve en respuesta a eventos de
booking. Ningún endpoint debillingcobra/refunda fuera del ciclo booking (salvo reenvíos de payout manuales por admin). - RN-2 Ledger append-only.
LedgerEntrynunca 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_clientcon 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únNoShowPolicy. 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.rateal 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) enPaymentconstatus=captured. - RN-9 Autorizaciones expiran según el provider (típicamente 7 días). Para bookings con
start_at > authorize_at + 7d, el workerreauth-schedulerre-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 }→notificationsavisa 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 }→bookingcancela conreason_code = payment_authorization_expired(política de cancelación: sin penalidad al cliente — fue fallo de pago, no de voluntad).billingno tiene nada que cobrar; solo registra el intento fallido en el ledger.
- T-48h: intento de re-autorización. Si falla → emite
- 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
BillingAccountes 1:1 con Business. Transferir titularidad entre Accounts no está permitido en MVP (fase 2+). - RN-12
platform_adminpuede leer cualquier ledger/report pero no mutar. Correcciones van por flujo de nuevos asientos (RN-2). - RN-13 Branches
virtual(profesional independiente) tienen su propioBillingAccountdelBusinessvirtual. 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 semanalCancelació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.issuedCancelació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.issuedNo-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 payout7. Dependencias
| Dominio | Tipo |
|---|---|
authz | can_view_billing (business), can_manage_billing_account (business). platform_admin para auditoría del ledger. |
booking | Trigger principal: consume booking.hold_created, .confirmed, .cancelled, .completed, .no_show. |
accounts | Lee Business, BillingAccount. Al crear Business crea BillingAccount(status=pending_kyc). |
notifications | Recibe payment.captured, .failed, refund.issued, payout.scheduled, .completed, kyc.required. |
catalog | Solo 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_personsin 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
Paymentcapturados 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)ysum(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.