Traducción de
docs/en/runtime/banking.md, que es el original. Si discrepan, manda el inglés.
El libro mayor incrustado modela las partes de la contabilidad de una pasarela que las pruebas de integración necesitan. No es un banco de producción: no tiene dinero real, ni números de cuenta completos, ni PAN, ni CVV.
#Por qué existe
Una pasarela de pagos no transfiere la cartera de un cliente directamente a un comercio. Recibe o compromete fondos, los compensa, se queda una comisión, retiene el neto del comercio durante un periodo y por fin lo hace retirable. Las devoluciones y los contracargos tienen que revertir esos hechos sin corromper los libros.
apps/api/src/modules/ledger/ implementa ese comportamiento una vez para todos
los proveedores. Los plugins declaran los números de comisión y de plazo; nunca
escriben asientos.
#Primitivas contables
- Cuenta: pertenece a una aplicación y a una moneda, con lado normal de debe o de haber.
- Asiento: suceso económico inmutable, con referencia y metadatos.
- Apunte: debe o haber positivo en unidades mínimas, dentro de un asiento.
- Retención: compromiso contra una cartera por un pago autorizado y todavía sin capturar.
- Saldo: derivado de todos los apuntes de una cuenta; nunca guardado en una columna mutable.
LedgerService.postWithEntityManager() rechaza, antes de volcar: menos de dos
apuntes, importes no positivos, cuentas de aplicaciones o monedas mezcladas, y
totales de debe y haber que no cuadren.
#Plan de cuentas
Las cuentas se provisionan en su primer uso, por aplicación y moneda, en
apps/api/src/modules/ledger/accounts.ts.
| Código | Qué es | Lado normal |
|---|---|---|
asset:cash:{moneda} |
Efectivo dentro de la pasarela; por aquí entran los pagos externos y salen los retiros | Debe |
liability:wallet:customer:{id} |
Saldo que se le debe a un cliente | Haber |
clearing:{proveedor}:{moneda} |
Dinero en tránsito dentro de la pasarela | Haber |
liability:reserve:merchant:{id} |
Neto capturado del comercio, todavía retenido | Haber |
liability:payable:merchant:{id} |
Dinero del comercio liberado y disponible para retirar | Haber |
revenue:fee:{proveedor} |
Ingresos por comisión de la pasarela | Haber |
#Movimientos de un pago
| Suceso | Debe | Haber |
|---|---|---|
| Captura desde saldo | cartera del cliente | clearing |
| Captura por método externo | efectivo del sistema | clearing |
| Acreditación | clearing | reserva del comercio + ingreso por comisión |
| Liquidación T+N | reserva del comercio | pagadero del comercio |
| Retiro | pagadero del comercio | efectivo del sistema |
| Reverso de devolución | reserva o pagadero del comercio (+ comisión si se devuelve) | clearing |
| Pago de la devolución | clearing | cartera original o efectivo del sistema |
| Contracargo | pagadero del comercio | clearing + ingreso por comisión de disputa |
El saldo de un comercio puede quedar negativo tras una devolución o un contracargo. Negarse a anotar esa deuda haría que el libro mayor pareciera más seguro y fuera menos veraz. Sólo los retiros exigen saldo pagadero suficiente.
#Autorización y captura
Un pago con captura manual llega a authorized y crea una LedgerHoldEntity
contra la cartera del pagador. Todavía no se mueve dinero. La captura cierra la
retención como capturada y escribe los asientos normales de captura y
acreditación; la cancelación la libera. El saldo disponible de una cartera es el
saldo derivado de los apuntes menos las retenciones activas.
#Liquidación
Al capturar se calcula releaseAt a partir del perfil contable efectivo del
proveedor. El worker llama a SettlementService.runDue() cada 60 segundos por
defecto. Desde el plano de control se puede mover el reloj de pruebas con
POST /applications/{applicationId}/ledger/run-settlement y un valor asOf.
Cada liberación toma un lock consultivo de PostgreSQL y relee el intento dentro de la transacción, así que dos workers solapados no pueden liberar la misma reserva dos veces.
#Valores del proveedor y ajustes de la instalación
Los plugins declaran fee.percentBasisPoints, fee.fixedMinor, releaseDays,
refundReturnsFee, disputeFeeMinor, las monedas y una moneda por defecto.
GatewaysService puede sobrescribir sólo los números contables y las monedas de
una instalación. refundReturnsFee sigue siendo comportamiento y no se puede
editar desde la base de datos.
#Bancos
banks es el catálogo de instituciones donde se puede abrir una cuenta. Un banco
lleva tres identificadores con tres trabajos, y no son intercambiables:
| Campo | Para |
|---|---|
id |
La referencia opaca a la que apunta la clave ajena (bnk_…) |
code |
Lo que escribe una persona, una semilla o una prueba: banco-ribera |
prefix |
La cabeza de todo número de cuenta que ese banco emite |
code y prefix son identidad, no datos: ninguno aparece en UpdateBankDto. El
prefijo en particular no se puede editar porque de una cuenta sólo se guardan
sus cuatro últimos dígitos — el prefijo no es recuperable de nada de lo
escrito, así que cambiarlo reescribiría lo que toda cuenta existente afirma sobre
sí misma, sin que quede nada capaz de contradecirlo.
Un número de cuenta se comprueba contra su banco una vez, en el único momento
en que el número entero existe: tiene que empezar por el prefijo y medir
accountNumberLength. No hay dígito de control, a propósito — quien
desarrolla tiene que poder teclear un número a mano, y una suma de comprobación
haría inválido todo lo tecleado. Tanto el generador que hay detrás de
POST /banks/:id/account-number como la comprobación que acepta una cuenta nueva
salen de modules/banks/account-number.ts, así que no pueden desviarse.
El catálogo inicial viaja en la migración y no en la semilla, porque
bank_accounts.bank_id es not null: una instalación migrada sin bancos tiene un
formulario que nadie puede enviar. Sus marcas se dibujan en la consola
(app/assets/images/banks/) y no se guardan en la API — el mismo reparto que
mantiene el comportamiento de un proveedor aquí y su identidad visual en el
checkout.
Escribir es sólo de administrador; leer está abierto a todos los roles, porque
elegir dónde está tu cuenta significa ver la lista. DELETE /banks/:id apaga en
vez de borrar, y la clave ajena es restrict.
#Cuentas bancarias externas
bank_accounts son contrapartes de personas, no cuentas del libro mayor. Cada
una pertenece a un banco. Los asientos de fondeo y de retiro apuntan a su
identificador, al identificador y al nombre de su banco, y a los cuatro últimos
caracteres, en los metadatos — el nombre como instantánea, para que renombrar un
banco después no reescriba lo que dice un retiro antiguo. El historial de
movimientos se deriva de los asientos, no se duplica en un segundo registro.
Borrar desde la API cierra la cuenta, para que los asientos viejos conserven una
referencia válida.
Ni el banco ni el número se pueden editar después: mover una cuenta a otra institución no es una edición, es otra cuenta, y el número que se comprobó contra el prefijo antiguo ya no existe para comprobarlo contra uno nuevo.
#Dónde tocar
- Motor genérico de apuntes cuadrados:
ledger.service.ts. - Movimientos de dinero de la pasarela:
gateway-ledger.service.ts. - Códigos de cuenta y lados normales:
accounts.ts. - Liberación por reloj:
settlement.service.tsyapps/api/src/worker.ts. - Vistas y retiros del plano de control:
ledger.controller.ts. - Propiedad de las cuentas externas:
modules/bank-accounts/. - El catálogo de instituciones y la forma de sus números:
modules/banks/. - Declaraciones de comisión y plazo:
src/providers/<id>/<id>.plugin.ts.
Nunca escribas ledger_entries ni ledger_postings directamente, ni añadas
una columna de saldo mutable, ni dupliques los movimientos bancarios fuera del
libro mayor.
#Pruebas
apps/api/test/ledger-invariants.spec.ts usa fast-check para los invariantes de
comisión y de apuntes. test/integration/gateway-ledger.spec.ts,
bank-accounts.spec.ts, banks.spec.ts, checkout.spec.ts y la conformidad de
proveedores cubren flujos reales contra la base, concurrencia, retenciones,
liquidación, devoluciones y retiros.