Versión: 1.0 Fecha: 2025-12-22 Audiencia: Desarrolladores Backend, DevOps, QA
- Resumen Ejecutivo
- Modelo de Datos Financieros
- Flujo de Procesamiento de Pagos
- Sistema de Amortización
- Registro Contable (CapitalRegister)
- Gestión de Mora y Cobranza
- Vistas SQL y Cálculos
- Riesgos Identificados y Mitigaciones
- Guía de Testing
- Checklist de Mantenimiento
MPAY es una plataforma BNPL (Buy Now Pay Later) que permite a clientes financiar compras en cuotas. El sistema de contabilidad maneja:
- Desembolsos: Dinero prestado al cliente
- Cuotas (Installments): Pagos programados con principal + interés
- Pagos (Payments): Dinero recibido del cliente
- Mora (Debt): Penalización por atraso
- Capital: Balance neto de la operación
| Componente | Tecnología |
|---|---|
| Backend | Laravel 10 + PHP 8.2 |
| Base de Datos | MySQL 8.0 |
| Queue | Redis + Laravel Horizon |
| Cálculos Financieros | MathPHP (markrogoyski/math-php) |
| Precisión Decimal | BCMath (2 decimales) |
api-laravel/
├── app/
│ ├── Models/
│ │ ├── Payment.php # Pagos recibidos
│ │ ├── Order.php # Órdenes de crédito
│ │ ├── Installment.php # Cuotas individuales
│ │ ├── OrderRepayment.php # Detalle de pago por componente
│ │ ├── CapitalRegister.php # Registro contable
│ │ ├── InstallmentSummary.php # Vista SQL (read-only)
│ │ └── OrderSummary.php # Vista SQL (read-only)
│ ├── Managers/
│ │ ├── Payment.php # Lógica de distribución de pagos
│ │ ├── Amortization.php # Cálculo de cuotas
│ │ └── OrderManager.php # Cálculos de mora y comisión
│ ├── Jobs/
│ │ ├── ProcessingPayments.php # Procesa pagos confirmados
│ │ ├── AllocateDebtToInstallment.php # Asigna mora
│ │ ├── CalculateInstallmentsInDue.php # Marca cuotas vencidas
│ │ └── CalculateInstallmentsInArrears.php # Marca cuotas en mora
│ ├── Observers/
│ │ ├── PaymentObserver.php # Crea CapitalRegister al pagar
│ │ ├── OrderObserver.php # Crea CapitalRegister al desembolsar
│ │ ├── InstallmentObserver.php # Transiciones de estado
│ │ └── CapitalRegisterObserver.php # Actualiza total_capital
│ └── Services/
│ └── PaymentService.php # Creación y confirmación de pagos
└── database-laravel/
└── database/migrations/
├── 2022_09_14_125521_create_payments_table.php
├── 2022_09_26_144350_create_capital_registers_table.php
├── 2022_10_04_141301_create_order_repayments_table.php
└── views/
├── 2023_10_09_154723_create_installment_summary_view.php
└── 2023_10_09_155321_create_order_summary_view.php
┌─────────────────────────────────────────────────────────────────────────────┐
│ MODELO DE DATOS FINANCIERO │
└─────────────────────────────────────────────────────────────────────────────┘
┌───────────────┐
│ ORDER │
│ (Préstamo) │
├───────────────┤
│ amount │
│ disbursement │
│ commission │
│ status │
└───────┬───────┘
│
┌──────────────────────────┼──────────────────────────┐
│ │ │
▼ ▼ ▼
┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐
│ INSTALLMENT │ │ PAYMENT │ │ CAPITAL_REGISTER │
│ (Cuota) │ │ (Pago) │ │ (Contabilidad) │
├───────────────────┤ ├───────────────────┤ ├───────────────────┤
│ number │ │ amount │ │ origin_type │
│ principal │ │ confirmed_amount │ │ origin_id │
│ interest │ │ status │ │ amount (+/-) │
│ vat_interest │ │ is_processed │ │ type (income/exp) │
│ debt │ │ type │ │ comment │
│ vat_debt │ └─────────┬─────────┘ └───────────────────┘
│ fee │ │
│ vat_fee │ │
│ status │ │
│ expired_at │ │
└─────────┬─────────┘ │
│ │
│ ┌───────────────────┘
│ │
▼ ▼
┌───────────────────────┐
│ ORDER_REPAYMENT │
│ (Detalle de Pago) │
├───────────────────────┤
│ installment_id │
│ payment_id │
│ principal │
│ interest │
│ vat_interest │
│ debt │
│ vat_debt │
│ fee │
│ vat_fee │
└───────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ TABLA PIVOTE │
├─────────────────────────────────────────────────────────────────────────────┤
│ PROCESSING_PAYMENTS │
│ ├── payment_id (FK) │
│ └── installment_id (FK) │
│ Propósito: Vincula pagos en proceso con cuotas asignadas │
└─────────────────────────────────────────────────────────────────────────────┘
CREATE TABLE payments (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_id BIGINT REFERENCES orders(id),
bank_id BIGINT REFERENCES banks(id),
-- Montos (almacenados en CENTAVOS)
amount BIGINT NOT NULL, -- Monto declarado
confirmed_amount BIGINT, -- Monto confirmado por tesorero
-- Estados
status VARCHAR(50), -- pending, confirmed, success
type VARCHAR(50), -- bank_transfer, manual_payment, api_payment
is_processed BOOLEAN DEFAULT FALSE, -- ¿Ya se distribuyó a cuotas?
-- Referencias
reference_number VARCHAR(255), -- Referencia del banco
transaction_number VARCHAR(255), -- Número de transacción
external_reference_number VARCHAR(255),
transaction_file VARCHAR(255), -- Archivo adjunto
-- Timestamps
confirmed_at TIMESTAMP,
created_at TIMESTAMP,
updated_at TIMESTAMP,
-- Auditoría
user_id BIGINT REFERENCES users(id), -- Usuario que registró
description TEXT,
comment VARCHAR(255)
);CREATE TABLE installments (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
uuid UUID NOT NULL,
order_id BIGINT REFERENCES orders(id),
-- Identificación
number INT NOT NULL, -- Número de cuota (1, 2, 3...)
status VARCHAR(50), -- pending, active, due, in_arrears, paid, cancel
-- Montos Base (en CENTAVOS)
amount BIGINT DEFAULT 0, -- Monto total de la cuota
balance BIGINT DEFAULT 0, -- Saldo (legacy)
-- Componentes de Pago
principal BIGINT DEFAULT 0, -- Capital a pagar
interest BIGINT DEFAULT 0, -- Interés base
vat_interest BIGINT DEFAULT 0, -- IVA sobre interés (13%)
-- Mora (se asigna cuando vence)
debt BIGINT DEFAULT 0, -- Mora base
vat_debt BIGINT DEFAULT 0, -- IVA sobre mora (13%)
-- Comisión/Fee
fee BIGINT DEFAULT 0, -- Fee base
vat_fee BIGINT DEFAULT 0, -- IVA sobre fee (13%)
-- Fechas Críticas
expired_at DATE, -- Fecha de vencimiento
expired_in DATE, -- Cuándo cambió a DUE
arrears_in DATE, -- Cuándo cambió a IN_ARREARS
paid_at DATETIME, -- Cuándo se pagó completamente
-- Tracking de pagos por componente
principal_paid_at DATETIME,
interest_paid_at DATETIME,
debt_paid_at DATETIME,
created_at TIMESTAMP,
updated_at TIMESTAMP
);CREATE TABLE order_repayments (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
installment_id BIGINT REFERENCES installments(id),
payment_id BIGINT REFERENCES payments(id),
-- Desglose del pago (en CENTAVOS)
principal BIGINT DEFAULT 0, -- Capital pagado
interest BIGINT DEFAULT 0, -- Interés pagado
vat_interest BIGINT DEFAULT 0, -- IVA interés pagado
debt BIGINT DEFAULT 0, -- Mora pagada
vat_debt BIGINT DEFAULT 0, -- IVA mora pagado
fee BIGINT DEFAULT 0, -- Fee pagado
vat_fee BIGINT DEFAULT 0, -- IVA fee pagado
created_at TIMESTAMP,
updated_at TIMESTAMP
);CREATE TABLE capital_registers (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
-- Relación polimórfica
origin_type VARCHAR(255), -- App\Models\Payment o App\Models\Order
origin_id BIGINT,
-- Monto (POSITIVO = ingreso, NEGATIVO = egreso)
type ENUM('income', 'expense'),
amount BIGINT NOT NULL, -- En CENTAVOS
comment VARCHAR(255),
created_at TIMESTAMP,
updated_at TIMESTAMP
);┌─────────────────────────────────────────────────────────────────┐
│ CICLO DE VIDA DE PAYMENT │
└─────────────────────────────────────────────────────────────────┘
┌──────────┐ ┌──────────┐ ┌──────────┐
│ PENDING │────────▶│CONFIRMED │────────▶│ SUCCESS │
│ │ │ │ │ │
│ Creado, │ │ Tesorero │ │ Dinero │
│ esperando│ │ confirmó │ │ aplicado │
│ confirm. │ │ el monto │ │ a cuotas │
└──────────┘ └──────────┘ └──────────┘
┌─────────────────────────────────────────────────────────────────┐
│ CICLO DE VIDA DE INSTALLMENT │
└─────────────────────────────────────────────────────────────────┘
┌──────────┐
│ PENDING │ Cuota futura, no activada
└────┬─────┘
│ (Cuando cuota anterior se paga o vence)
▼
┌──────────┐
│ ACTIVE │ Cuota actual, esperando pago
└────┬─────┘
│
┌────┴────────────────────────────────┐
│ │
▼ ▼
┌──────────┐ ┌──────────┐
│ PAID │ Pagada completamente │ DUE │ Vencida (expired_at < hoy)
└──────────┘ └────┬─────┘
│ (Pasó período de gracia)
▼
┌──────────┐
│IN_ARREARS│ En mora, se aplicó debt
└────┬─────┘
│ (Cliente paga)
▼
┌──────────┐
│ PAID │ Pagada con mora incluida
└──────────┘
Estado especial: PROCESSING_PAYMENT (durante procesamiento)
Estado terminal: CANCEL (orden cancelada)
┌─────────────────────────────────────────────────────────────────┐
│ CICLO DE VIDA DE ORDER │
└─────────────────────────────────────────────────────────────────┘
┌──────────┐
│ ACTIVE │ Orden con cuotas pendientes
└────┬─────┘
│
┌────┴────────────────────────────────┐
│ │
▼ ▼
┌──────────┐ ┌──────────┐
│ PAID │ Todas las cuotas │ DUE │ Alguna cuota vencida
│ │ pagadas └────┬─────┘
└──────────┘ │
▼
┌──────────┐
│IN_ARREARS│ Cuotas en mora
└──────────┘
El sistema almacena todos los montos en CENTAVOS (BIGINT) y usa un Cast personalizado para convertir:
// app/Casts/Money.php
class Money implements CastsAttributes
{
// Lectura: BIGINT (centavos) → float (dólares)
public function get($model, string $key, $value, array $attributes)
{
return dollars((int) $value); // 10000 → 100.00
}
// Escritura: float (dólares) → BIGINT (centavos)
public function set($model, string $key, $value, array $attributes)
{
return cents((float) $value); // 100.00 → 10000
}
}Helpers:
function dollars(int $cents): float {
return $cents / 100;
}
function cents(float $dollars): int {
return (int) round($dollars * 100);
}┌─────────────────────────────────────────────────────────────────────────────┐
│ FLUJO DE PROCESAMIENTO DE PAGOS │
└─────────────────────────────────────────────────────────────────────────────┘
FASE 1: CREACIÓN DEL PAGO
┌─────────────────────────────────────────────────────────────────────────────┐
│ │
│ Cliente/API ──▶ POST /payments ──▶ PaymentService::createPayment() │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Payment creado │ │
│ │ status=PENDING │ │
│ │ is_processed=F │ │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ Payment::processingPayment($payment) │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ processing_ │ Tabla pivote │
│ │ payments │ payment ↔ cuotas │
│ └─────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
FASE 2: CONFIRMACIÓN DEL PAGO
┌─────────────────────────────────────────────────────────────────────────────┐
│ │
│ Tesorero ──▶ PATCH /payments/{id} ──▶ PaymentService::confirmPayment() │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ Payment actualizado │ │
│ │ status=CONFIRMED │ │
│ │ confirmed_amount │ │
│ │ confirmed_at │ │
│ └─────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
FASE 3: PROCESAMIENTO ASÍNCRONO (Job: ProcessingPayments - cada minuto)
┌─────────────────────────────────────────────────────────────────────────────┐
│ │
│ Scheduler ──▶ ProcessingPayments Job │
│ │ │
│ ▼ │
│ SELECT * FROM payments │
│ WHERE status='confirmed' AND is_processed=false │
│ AND confirmed_at IS NOT NULL │
│ LIMIT 10 │
│ │ │
│ ▼ │
│ foreach ($payments as $payment) { │
│ Payment::forOrder($order)->pay($payment, $confirmed_amount) │
│ $payment->update(['is_processed' => true]) │
│ Notify customer (si no es manual) │
│ } │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
FASE 4: DISTRIBUCIÓN A CUOTAS (Payment Manager::pay())
┌─────────────────────────────────────────────────────────────────────────────┐
│ │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ ORDEN DE APLICACIÓN DEL PAGO │ │
│ │ │ │
│ │ El pago se distribuye en este ORDEN ESTRICTO: │ │
│ │ │ │
│ │ 1. vat_debt (IVA sobre mora) ──▶ Primero mora │ │
│ │ 2. debt (Mora base) ──┘ │ │
│ │ │ │
│ │ 3. vat_fee (IVA sobre comisión) ──▶ Segundo comisión │ │
│ │ 4. fee (Comisión base) ──┘ │ │
│ │ │ │
│ │ 5. vat_interest (IVA sobre interés) ──▶ Tercero interés │ │
│ │ 6. interest (Interés base) ──┘ │ │
│ │ │ │
│ │ 7. principal (Capital) ──▶ Último: principal │ │
│ │ │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
│ LÓGICA DE DISTRIBUCIÓN: │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ $amount = $payment->confirmed_amount; │ │
│ │ │ │
│ │ // Para cada componente: │ │
│ │ $pending = $installment->pending_{component}_amount; │ │
│ │ $paid = min($amount, $pending); │ │
│ │ $amount = bcsub($amount, $paid, 2); │ │
│ │ $repayment->{component} = $paid; │ │
│ │ │ │
│ │ // Si queda dinero y hay más cuotas: │ │
│ │ if ($amount > 0) { │ │
│ │ $this->pay($payment, $amount); // Recursión │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
FASE 5: ACTUALIZACIÓN DE ESTADOS
┌─────────────────────────────────────────────────────────────────────────────┐
│ │
│ Si installment.pending_amount == 0: │
│ installment.status = PAID │
│ installment.paid_at = now() │
│ │
│ InstallmentObserver::updated() ──▶ Activa siguiente cuota │
│ ──▶ Envía webhook │
│ │
│ Si todas las cuotas están PAID: │
│ order.status = PAID │
│ Job: UpgradeLevel (sube nivel crediticio del cliente) │
│ │
│ Payment.status = SUCCESS │
│ │
│ PaymentObserver::updated() ──▶ CapitalRegister::create(+amount) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Archivo: /api-laravel/app/Managers/Payment.php
<?php
namespace App\Managers;
use App\Models\Order;
use App\Models\Payment as PaymentModel;
use App\Models\OrderRepayment;
class Payment
{
private Order $order;
private int $installmentPaymentsCount = 0;
private int $maxInstallmentPayments = 100; // Límite de recursión
public static function forOrder(Order $order): self
{
$instance = new self();
$instance->order = $order;
return $instance;
}
public function pay(PaymentModel $payment, $amount)
{
// 1. Obtener primera cuota pagable
$installment = $this->order->installments()->payable()->first();
$this->installmentPaymentsCount++;
if (blank($installment)) {
return; // No hay cuotas para pagar
}
// 2. Crear registro de repago
$repayment = new OrderRepayment();
$repayment->installment_id = $installment->id;
$repayment->payment_id = $payment->id;
// 3. APLICAR EN ORDEN: debt → fee → interest → principal
// 3.1 Deuda (mora)
$totalPendingDebtAmount = $installment->pending_debt_amount;
if ($totalPendingDebtAmount > 0) {
// IVA primero
$pendingVatDebt = bcsub($installment->vat_debt,
$installment->vat_debt_paid_amount, 2);
$vatDebtPaid = min($amount, $pendingVatDebt);
$amount = bcsub($amount, $vatDebtPaid, 2);
$repayment->vat_debt = $vatDebtPaid;
// Deuda base
$pendingDebt = bcsub($installment->debt,
$installment->debt_paid_amount, 2);
$debtPaid = min($amount, $pendingDebt);
$amount = bcsub($amount, $debtPaid, 2);
$repayment->debt = $debtPaid;
}
// 3.2 Fee (comisión) - mismo patrón
// 3.3 Interest (interés) - mismo patrón
// 3.4 Principal - mismo patrón
// 4. Guardar repago
$repayment->save();
$installment->refresh();
// 5. Marcar cuota como pagada si corresponde
if ($installment->pending_amount == 0) {
$installment->update([
'status' => InstallmentState::PAID->value,
'paid_at' => $payment->created_at
]);
}
// 6. Verificar si toda la orden está pagada
if ($this->order->installments()->paid()->count()
=== $this->order->installments()->count()) {
$this->order->status = OrderState::PAID->value;
$this->order->save();
}
// 7. Si queda dinero, continuar con siguiente cuota (recursión)
elseif ($amount > 0 &&
$this->installmentPaymentsCount <= $this->maxInstallmentPayments) {
$this->pay($payment, $amount);
}
// 8. Marcar pago como exitoso
$payment->status = PaymentState::SUCCESS->value;
$payment->save();
}
}Escenario: Cliente paga $250 de una cuota con mora
CUOTA ANTES DEL PAGO:
├── principal: $400.00 (pendiente: $400.00)
├── interest: $50.00 (pendiente: $50.00)
├── vat_interest: $6.50 (pendiente: $6.50)
├── debt: $30.00 (pendiente: $30.00) ← Mora aplicada
├── vat_debt: $3.90 (pendiente: $3.90)
├── fee: $0.00
├── vat_fee: $0.00
└── TOTAL PENDIENTE: $490.40
DISTRIBUCIÓN DE PAGO $250:
┌────────────────────────────────────────────────────────────┐
│ Paso 1: vat_debt │
│ Pendiente: $3.90 │
│ Pago: $3.90 │
│ Restante: $250 - $3.90 = $246.10 │
├────────────────────────────────────────────────────────────┤
│ Paso 2: debt │
│ Pendiente: $30.00 │
│ Pago: $30.00 │
│ Restante: $246.10 - $30.00 = $216.10 │
├────────────────────────────────────────────────────────────┤
│ Paso 3: vat_fee (no hay) │
│ Paso 4: fee (no hay) │
├────────────────────────────────────────────────────────────┤
│ Paso 5: vat_interest │
│ Pendiente: $6.50 │
│ Pago: $6.50 │
│ Restante: $216.10 - $6.50 = $209.60 │
├────────────────────────────────────────────────────────────┤
│ Paso 6: interest │
│ Pendiente: $50.00 │
│ Pago: $50.00 │
│ Restante: $209.60 - $50.00 = $159.60 │
├────────────────────────────────────────────────────────────┤
│ Paso 7: principal │
│ Pendiente: $400.00 │
│ Pago: $159.60 (todo lo que queda) │
│ Restante: $0.00 │
└────────────────────────────────────────────────────────────┘
REGISTRO EN order_repayments:
{
"installment_id": 1,
"payment_id": 5,
"principal": 15960, // $159.60 en centavos
"interest": 5000, // $50.00
"vat_interest": 650, // $6.50
"debt": 3000, // $30.00
"vat_debt": 390, // $3.90
"fee": 0,
"vat_fee": 0
}
CUOTA DESPUÉS DEL PAGO:
├── principal: $400.00 (pendiente: $240.40) ← Aún debe
├── interest: $50.00 (pendiente: $0.00) ← Pagado
├── vat_interest: $6.50 (pendiente: $0.00) ← Pagado
├── debt: $30.00 (pendiente: $0.00) ← Pagado
├── vat_debt: $3.90 (pendiente: $0.00) ← Pagado
├── status: ACTIVE (no completamente pagada)
└── TOTAL PENDIENTE: $240.40
Archivo: /api-laravel/app/Managers/Amortization.php
El sistema soporta 3 tipos de fee:
FeeType::COMPOUND_INTEREST => Finance::ipmt($feeValue, $number,
$installmentCount, $amount)- Usa fórmulas financieras estándar (MathPHP)
ipmt()- Calcula interés por períodoppmt()- Calcula principal por período- El interés decrece y el principal aumenta en cada cuota
Ejemplo: 4 cuotas, $1000, fee=2%
Cuota 1: fee=$17.70, principal=$244.92, total=$262.62
Cuota 2: fee=$12.75, principal=$249.87, total=$262.62
Cuota 3: fee=$7.69, principal=$254.93, total=$262.62
Cuota 4: fee=$4.56, principal=$258.06, total=$262.62
FeeType::PERCENTAGE => bcmul($amount, $feeValue, 6)- Fee = monto * porcentaje (constante para todas las cuotas)
- Principal = monto / número_cuotas
Ejemplo: 4 cuotas, $1000, fee=2%
Cuota 1-4: fee=$20.00, principal=$250.00, total=$270.00
FeeType::AMOUNT => Finance::pmt(0, $installmentCount, $feeValue)- Fee = valor fijo dividido entre cuotas
- Principal = monto / número_cuotas
if ($this->period->use_vat_in_the_fee) {
// Fee incluye IVA del 13%
$newFee = $fee / 1.13; // Base sin IVA
$vatFee = $fee - $newFee; // IVA extraído
$fee = $newFee;
} else {
$vatFee = 0;
}// Suma de principals calculados
$calculatedTotalPrincipal = $this->installments->sum('principal');
// Diferencia por redondeo
$difference = bcsub($this->amount, $calculatedTotalPrincipal, 2);
// Ajustar última cuota
if ($difference != 0) {
$lastInstallment['principal'] = bcadd($lastInstallment['principal'],
$difference, 2);
}┌─────────────────────────────────────────────────────────────────────────────┐
│ FLUJO CONTABLE │
└─────────────────────────────────────────────────────────────────────────────┘
DESEMBOLSO (Creación de Orden)
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ OrderObserver::created() │
│ │
│ CapitalRegister::create([ │
│ 'origin_type' => Order::class, │
│ 'origin_id' => $order->id, │
│ 'amount' => -1 * $order->disbursement_amount, ◄── NEGATIVO (EGRESO) │
│ 'comment' => 'New order' │
│ ]); │
│ │
│ → type se determina automáticamente: EXPENSE │
│ → total_capital -= disbursement_amount │
└─────────────────────────────────────────────────────────────────────────────┘
│
│ (Cliente paga)
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ PaymentObserver::updated() (cuando status = SUCCESS) │
│ │
│ CapitalRegister::create([ │
│ 'origin_type' => Payment::class, │
│ 'origin_id' => $payment->id, │
│ 'amount' => $payment->confirmed_amount, ◄── POSITIVO (INGRESO) │
│ 'comment' => 'Payment for order: ' . $reference │
│ ]); │
│ │
│ → type se determina automáticamente: INCOME │
│ → total_capital += confirmed_amount │
└─────────────────────────────────────────────────────────────────────────────┘
// Determina tipo según signo del monto
public function creating(CapitalRegister $capitalRegister)
{
if ($capitalRegister->amount >= 0) {
$capitalRegister->type = CapitalRegisterType::INCOME;
} else {
$capitalRegister->type = CapitalRegisterType::EXPENSE;
}
}
// Actualiza el total de capital global
public function created(CapitalRegister $capitalRegister)
{
$currentCapital = get_setting('total_capital', 0);
if ($capitalRegister->amount >= 0) {
// Ingreso: suma
set_setting_value('total_capital',
$currentCapital + cents($capitalRegister->amount));
} else {
// Egreso: resta
set_setting_value('total_capital',
$currentCapital - abs(cents($capitalRegister->amount)));
}
}BALANCE_CAPITAL = SUM(ingresos) - ABS(SUM(egresos))
Donde:
- Ingresos = Pagos exitosos (CapitalRegister.amount > 0)
- Egresos = Desembolsos (CapitalRegister.amount < 0)
El valor se almacena en: settings.total_capital (en centavos)
| Hora | Job | Acción |
|---|---|---|
| 02:00 | CalculateInstallmentsInDue |
Marca cuotas vencidas como DUE |
| 06:00 | CalculateInstallmentsInArrears |
Aplica mora a cuotas con gracia vencida |
| 09:00 | SendPaymentReminders |
Recordatorios pre-vencimiento |
| 10:00 | SendGracePeriodReminder |
Recordatorios en período de gracia |
| 11:00 | SendLatePaymentReminder |
Recordatorios de mora |
| */min | ProcessingPayments |
Procesa pagos confirmados |
| */min | ProcessingOrderAdjustment |
Procesa ajustes de orden |
┌─────────────────────────────────────────────────────────────────────────────┐
│ FLUJO DE MORA │
└─────────────────────────────────────────────────────────────────────────────┘
Día 0: Cuota creada
├── status: PENDING
├── expired_at: 2024-02-15
├── debt: 0
└── vat_debt: 0
Día N: Cuota activada
├── status: ACTIVE
└── (esperando pago)
Día 15 (expired_at): Cuota vence
├── Job: CalculateInstallmentsInDue
├── status: ACTIVE → DUE
├── expired_in: 2024-02-15
└── (inicia período de gracia)
Día 18 (expired_at + days_to_charge_debt): Gracia termina
├── Job: CalculateInstallmentsInArrears
│ └── Dispatch: AllocateDebtToInstallment
├── status: DUE → IN_ARREARS
├── arrears_in: 2024-02-18
├── debt: $30.00 ◄── MORA APLICADA
└── vat_debt: $3.90 ◄── IVA SOBRE MORA
Archivo: /api-laravel/app/Managers/OrderManager.php
public static function getDebt($order, $installment)
{
$debt = match (DebtType::from($order->debt_type)) {
// Monto fijo configurado en la orden
DebtType::FIXED_AMOUNT => $order->debt_value,
// Porcentaje sobre el monto de la cuota
DebtType::PERCENTAGE_PER_INSTALLMENT => bcmul(
$installment->amount * ($order->debt_value / 100), 1, 2
),
};
// IVA sobre mora: siempre 13%
$newDebt = bcdiv($debt, 1.13, 2); // Base sin IVA
$vatDebt = bcsub($debt, $newDebt, 2); // IVA extraído
return [$newDebt, $vatDebt];
}Configurado en order.settings['days_to_charge_debt'] (default: 3 días)
-- Vista installment_summary calcula fecha real de vencimiento:
DATE_ADD(i.expired_at, INTERVAL o.settings->>'$.days_to_charge_debt' DAY)
AS expiration_date_with_grace_daysPropósito: Calcular montos pagados y pendientes por cuota en tiempo real
Columnas principales:
| Columna | Descripción |
|---|---|
installment_id |
ID de la cuota |
is_processing_payment |
¿Hay pago en proceso? (0/1) |
expiration_date_with_grace_days |
Fecha vencimiento + gracia |
days_in_arrears |
Días de mora |
debt_paid_amount |
Mora pagada |
pending_debt_amount |
Mora pendiente |
interest_paid_amount |
Interés pagado |
pending_interest_amount |
Interés pendiente |
principal_paid_amount |
Principal pagado |
pending_principal_amount |
Principal pendiente |
total_amount |
Total a pagar |
total_pending_amount |
Total pendiente |
Lógica de cálculo:
-- Montos pagados: suma de order_repayments
SUM(orr.debt) AS debt_paid_amount
-- Montos pendientes: original - pagado
(i.debt + i.vat_debt) - SUM(orr.debt + orr.vat_debt) AS pending_debt_amount
-- Días en mora
IF(NOW() > fecha_con_gracia,
IF(ISNULL(i.paid_at),
DATEDIFF(NOW(), fecha_con_gracia),
DATEDIFF(i.paid_at, fecha_con_gracia)
),
0
) AS days_in_arrearsPropósito: Agregar datos de cuotas a nivel de orden
Columnas principales:
| Columna | Descripción |
|---|---|
order_id |
ID de la orden |
is_processing_payment |
¿Hay pagos en proceso? |
days_in_arrears |
Suma de días en mora |
total_debt_amount |
Mora total |
total_amount |
Suma total de la orden |
total_pending_amount |
Total pendiente |
total_paid_amount |
Total pagado |
Propósito: Calcular monto disponible por cliente
actual_available_amount =
approved_amount - (total_amount - total_paid_amount)Descripción: Dos instancias del job pueden procesar el mismo pago simultáneamente.
Ubicación: /api-laravel/app/Jobs/ProcessingPayments.php
Código problemático:
$payments = ModelsPayment::where('status', PaymentState::CONFIRMED)
->where('is_processed', false)
->take(10)
->cursor(); // SIN LOCKImpacto: Dinero aplicado múltiples veces, registros duplicados.
Mitigación recomendada:
$payments = ModelsPayment::lockForUpdate()
->where('status', PaymentState::CONFIRMED)
->where('is_processed', false)
->take(10)
->get();Descripción: CalculateInstallmentsInArrears puede aplicar mora dos veces si se ejecuta antes de que AllocateDebtToInstallment termine.
Ubicación: /api-laravel/app/Jobs/CalculateInstallmentsInArrears.php
Mitigación recomendada:
$installments = Installment::where('status', InstallmentState::DUE)
->lockForUpdate() // Lock pessimista
->get();Descripción: No hay validación que impida pagos mayores al total adeudado.
Ubicación: /api-laravel/app/Http/Requests/Payment/PaymentRequest.php
Mitigación recomendada:
'amount' => [
'required',
'numeric',
'min:0.01',
Rule::custom(function ($attribute, $value, $fail) {
$order = $this->order;
if ($value > $order->total_pending_amount) {
$fail("El pago no puede exceder el total adeudado.");
}
})
]Ubicación: /api-laravel/app/Services/PaymentService.php
Mitigación:
public function confirmPayment(...) {
return DB::transaction(function() use (...) {
$payment->lockForUpdate()->refresh();
// ... actualizar
});
}Descripción: Si varios pagos del mismo order están en el batch, pueden causar conflictos.
Mitigación:
// Procesar un pago por order por ciclo
$payments = Payment::distinct('order_id')
->where('status', PaymentState::CONFIRMED)
->get()
->groupBy('order_id')
->map(fn ($group) => $group->first());Ubicación: /api-laravel/app/Observers/InstallmentObserver.php
Mitigación: Configurar reintentos en el servicio de webhooks:
$webhookCall->onError(function ($request, $response) {
Log::error("Webhook failed", [
'url' => $client->webhook_url,
'status' => $response->status()
]);
// Implementar cola de reintentos
})
->dispatch();| ID | Riesgo | Criticidad | Esfuerzo | Prioridad |
|---|---|---|---|---|
| 1 | Race condition en ProcessingPayments | CRÍTICO | 4h | P0 |
| 2 | Mora duplicada | CRÍTICO | 3h | P0 |
| 3 | Pago > total | CRÍTICO | 2h | P0 |
| 4 | Sin transacción | ALTO | 2h | P1 |
| 5 | Múltiples pagos mismo order | ALTO | 3h | P1 |
| 6 | Webhooks sin reintento | ALTO | 8h | P2 |
tests/Feature/API/V1/
├── Customer/Customer/PayOrderTest.php # Tests básicos de pago
├── Unauthorized/Internal/
│ └── AmortizationControllerTest.php # Tests de amortización
it('should handle concurrent payments safely', function () {
$order = Order::factory()->create();
$payment = Payment::factory()->create([
'order_id' => $order->id,
'status' => PaymentState::CONFIRMED,
'is_processed' => false,
]);
// Simular dos procesadores simultáneos
$job1 = new ProcessingPayments();
$job2 = new ProcessingPayments();
// Ejecutar en paralelo
$results = parallel([
fn() => $job1->handle(),
fn() => $job2->handle(),
]);
// Verificar que solo se procesó una vez
expect(OrderRepayment::where('payment_id', $payment->id)->count())
->toBe(1);
});it('should not allow overpayment', function () {
$order = Order::factory()->create();
Installment::factory()->create([
'order_id' => $order->id,
'principal' => 10000, // $100
]);
$response = postJson('/api/v1/payments', [
'order_id' => $order->id,
'amount' => 200.00, // Mayor que $100
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['amount']);
});it('should apply debt only once', function () {
$installment = Installment::factory()->create([
'status' => InstallmentState::DUE,
'debt' => 0,
]);
// Primera ejecución
$job1 = new AllocateDebtToInstallment($installment);
$job1->handle();
$debtAfterFirst = $installment->fresh()->debt;
// Segunda ejecución (no debería cambiar)
$job2 = new AllocateDebtToInstallment($installment);
$job2->handle();
expect($installment->fresh()->debt)->toBe($debtAfterFirst);
});it('should distribute payment in correct order', function () {
$installment = Installment::factory()->create([
'principal' => 40000, // $400
'interest' => 5000, // $50
'vat_interest' => 650, // $6.50
'debt' => 3000, // $30
'vat_debt' => 390, // $3.90
]);
$payment = Payment::factory()->create([
'confirmed_amount' => 10000, // $100
]);
Payment::forOrder($installment->order)->pay($payment, 100.00);
$repayment = OrderRepayment::where('payment_id', $payment->id)->first();
// Debe pagar primero debt, luego interest, luego principal
expect($repayment->vat_debt)->toBe(390); // Todo el vat_debt
expect($repayment->debt)->toBe(3000); // Todo el debt
expect($repayment->vat_interest)->toBe(650); // Todo el vat_interest
expect($repayment->interest)->toBe(5000); // Todo el interest
expect($repayment->principal)->toBe(960); // Lo que sobra
});- Revisar si hay pagos en estado PENDING o CONFIRMED
- Verificar que no haya jobs de
ProcessingPaymentsen ejecución - Revisar
failed_jobspara pagos fallidos - Hacer backup de tablas:
payments,order_repayments,capital_registers
- Monitorear
failed_jobspor 24 horas - Verificar que
total_capitalen settings es consistente - Revisar logs de
ProcessingPayments - Validar que webhooks se están enviando
- Reconciliar
capital_registersvspayments - Verificar que no hay
OrderRepaymenthuérfanos - Revisar cuotas en estado
DUEoIN_ARREARSprolongado - Validar que
installment_summaryyorder_summaryestán actualizadas
-- Pagos confirmados sin procesar (deberían procesarse en minutos)
SELECT * FROM payments
WHERE status = 'confirmed'
AND is_processed = false
AND confirmed_at < NOW() - INTERVAL 5 MINUTE;
-- Cuotas con mora excesiva (posible problema)
SELECT * FROM installments
WHERE status = 'in_arrears'
AND days_in_arrears > 90;
-- Verificar consistencia de capital
SELECT
SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) as ingresos,
ABS(SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END)) as egresos,
SUM(amount) as balance
FROM capital_registers;
-- Comparar con setting
SELECT value FROM nova_settings WHERE key = 'total_capital';
-- Órdenes con monto pendiente negativo (ERROR)
SELECT order_id, total_pending_amount
FROM order_summary
WHERE total_pending_amount < 0;| Término | Definición |
|---|---|
| Principal | Capital prestado sin intereses |
| Interest | Interés generado sobre el principal |
| VAT | IVA (13% en El Salvador) |
| Debt | Mora/penalización por atraso |
| Fee | Comisión del servicio |
| Disbursement | Monto desembolsado al cliente |
| Installment | Cuota individual de pago |
| Order | Préstamo/orden de crédito |
| Repayment | Registro de pago aplicado a una cuota |
| CapitalRegister | Movimiento contable (ingreso/egreso) |
| Grace Period | Días de gracia antes de aplicar mora |
┌─────────────────────────────────────────────────────────────────────────────┐
│ FLUJO DE DINERO EN MPAY │
└─────────────────────────────────────────────────────────────────────────────┘
ENTRADA DE DINERO (DESEMBOLSO):
┌─────────────────────────────────────────────────────────────────────────────┐
│ │
│ Cliente solicita: $1,000 │
│ │
│ Cálculos: │
│ ├── Comisión (10%): -$100 │
│ ├── IVA Comisión (13%): -$13 │
│ └── Desembolso: $887 │
│ │
│ CapitalRegister: -$887 (EXPENSE) │
│ total_capital: -$887 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
GENERACIÓN DE CUOTAS (4 cuotas mensuales):
┌─────────────────────────────────────────────────────────────────────────────┐
│ │
│ Cuota 1: $265.00 (principal: $230, interest: $30, vat_interest: $5) │
│ Cuota 2: $265.00 (principal: $235, interest: $25, vat_interest: $5) │
│ Cuota 3: $265.00 (principal: $240, interest: $20, vat_interest: $5) │
│ Cuota 4: $265.00 (principal: $295, interest: $15, vat_interest: $5) │
│ │
│ Total a cobrar: $1,060 (principal: $1000 + intereses: $60) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
SALIDA DE DINERO (PAGOS):
┌─────────────────────────────────────────────────────────────────────────────┐
│ │
│ Pago 1: $265.00 │
│ ├── CapitalRegister: +$265 (INCOME) │
│ └── total_capital: -$887 + $265 = -$622 │
│ │
│ Pago 2: $265.00 │
│ ├── CapitalRegister: +$265 (INCOME) │
│ └── total_capital: -$622 + $265 = -$357 │
│ │
│ Pago 3: $265.00 │
│ ├── CapitalRegister: +$265 (INCOME) │
│ └── total_capital: -$357 + $265 = -$92 │
│ │
│ Pago 4: $265.00 │
│ ├── CapitalRegister: +$265 (INCOME) │
│ └── total_capital: -$92 + $265 = +$173 │
│ │
│ GANANCIA NETA: $173 ($60 intereses + $113 comisiones) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Documento generado el 2025-12-22 Para uso interno de desarrollo Mantener actualizado con cada cambio significativo al sistema de pagos