Skip to content

Instantly share code, notes, and snippets.

@jluisflo
Created December 22, 2025 21:35
Show Gist options
  • Select an option

  • Save jluisflo/91c242eb2ce64623f4340580140faa3c to your computer and use it in GitHub Desktop.

Select an option

Save jluisflo/91c242eb2ce64623f4340580140faa3c to your computer and use it in GitHub Desktop.
Sistema de Contabilidad y Pagos MPAY - Guía para Desarrolladores

Sistema de Contabilidad y Pagos - MPAY

Guía para Desarrolladores: Entendimiento, Riesgos y Mantenimiento

Versión: 1.0 Fecha: 2025-12-22 Audiencia: Desarrolladores Backend, DevOps, QA


Tabla de Contenidos

  1. Resumen Ejecutivo
  2. Modelo de Datos Financieros
  3. Flujo de Procesamiento de Pagos
  4. Sistema de Amortización
  5. Registro Contable (CapitalRegister)
  6. Gestión de Mora y Cobranza
  7. Vistas SQL y Cálculos
  8. Riesgos Identificados y Mitigaciones
  9. Guía de Testing
  10. Checklist de Mantenimiento

1. Resumen Ejecutivo

1.1 Propósito del Sistema

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

1.2 Stack Tecnológico

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)

1.3 Archivos Clave

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

2. Modelo de Datos Financieros

2.1 Diagrama Entidad-Relación

┌─────────────────────────────────────────────────────────────────────────────┐
│                         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                  │
└─────────────────────────────────────────────────────────────────────────────┘

2.2 Estructura de Tablas Detallada

2.2.1 Tabla: payments

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)
);

2.2.2 Tabla: installments

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
);

2.2.3 Tabla: order_repayments

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
);

2.2.4 Tabla: capital_registers

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
);

2.3 Estados del Sistema

Estados de Payment (PaymentState)

┌─────────────────────────────────────────────────────────────────┐
│                    CICLO DE VIDA DE PAYMENT                     │
└─────────────────────────────────────────────────────────────────┘

    ┌──────────┐         ┌──────────┐         ┌──────────┐
    │ PENDING  │────────▶│CONFIRMED │────────▶│ SUCCESS  │
    │          │         │          │         │          │
    │ Creado,  │         │ Tesorero │         │ Dinero   │
    │ esperando│         │ confirmó │         │ aplicado │
    │ confirm. │         │ el monto │         │ a cuotas │
    └──────────┘         └──────────┘         └──────────┘

Estados de Installment (InstallmentState)

┌─────────────────────────────────────────────────────────────────┐
│                  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)

Estados de Order (OrderState)

┌─────────────────────────────────────────────────────────────────┐
│                    CICLO DE VIDA DE ORDER                       │
└─────────────────────────────────────────────────────────────────┘

    ┌──────────┐
    │  ACTIVE  │  Orden con cuotas pendientes
    └────┬─────┘
         │
    ┌────┴────────────────────────────────┐
    │                                     │
    ▼                                     ▼
┌──────────┐                         ┌──────────┐
│   PAID   │  Todas las cuotas       │   DUE    │  Alguna cuota vencida
│          │  pagadas                └────┬─────┘
└──────────┘                              │
                                          ▼
                                     ┌──────────┐
                                     │IN_ARREARS│  Cuotas en mora
                                     └──────────┘

2.4 Cast de Money

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);
}

3. Flujo de Procesamiento de Pagos

3.1 Diagrama de Flujo Completo

┌─────────────────────────────────────────────────────────────────────────────┐
│                      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)           │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

3.2 Código del Payment Manager

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();
    }
}

3.3 Ejemplo Práctico de Distribución

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

4. Sistema de Amortización

4.1 Cálculo de Cuotas

Archivo: /api-laravel/app/Managers/Amortization.php

El sistema soporta 3 tipos de fee:

4.1.1 COMPOUND_INTEREST (Interés Compuesto)

FeeType::COMPOUND_INTEREST => Finance::ipmt($feeValue, $number,
                                            $installmentCount, $amount)
  • Usa fórmulas financieras estándar (MathPHP)
  • ipmt() - Calcula interés por período
  • ppmt() - 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

4.1.2 PERCENTAGE (Porcentaje del Monto)

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

4.1.3 AMOUNT (Monto Fijo)

FeeType::AMOUNT => Finance::pmt(0, $installmentCount, $feeValue)
  • Fee = valor fijo dividido entre cuotas
  • Principal = monto / número_cuotas

4.2 Manejo de IVA

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;
}

4.3 Ajuste de Redondeo

// 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);
}

5. Registro Contable (CapitalRegister)

5.1 Flujo de Registros

┌─────────────────────────────────────────────────────────────────────────────┐
│                         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                                         │
└─────────────────────────────────────────────────────────────────────────────┘

5.2 CapitalRegisterObserver

// 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)));
    }
}

5.3 Fórmula de Balance

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)

6. Gestión de Mora y Cobranza

6.1 Cronograma de Jobs

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

6.2 Flujo de Mora

┌─────────────────────────────────────────────────────────────────────────────┐
│                         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

6.3 Cálculo de 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];
}

6.4 Período de Gracia

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_days

7. Vistas SQL y Cálculos

7.1 Vista: installment_summary

Propó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_arrears

7.2 Vista: order_summary

Propó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

7.3 Vista: customer_computed_amounts

Propósito: Calcular monto disponible por cliente

actual_available_amount =
    approved_amount - (total_amount - total_paid_amount)

8. Riesgos Identificados y Mitigaciones

8.1 Riesgos Críticos

Riesgo 1: Race Condition en ProcessingPayments

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 LOCK

Impacto: Dinero aplicado múltiples veces, registros duplicados.

Mitigación recomendada:

$payments = ModelsPayment::lockForUpdate()
    ->where('status', PaymentState::CONFIRMED)
    ->where('is_processed', false)
    ->take(10)
    ->get();

Riesgo 2: Mora Duplicada

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();

Riesgo 3: Pago Mayor al Total

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.");
        }
    })
]

8.2 Riesgos Altos

Riesgo 4: Sin Transacción en confirmPayment

Ubicación: /api-laravel/app/Services/PaymentService.php

Mitigación:

public function confirmPayment(...) {
    return DB::transaction(function() use (...) {
        $payment->lockForUpdate()->refresh();
        // ... actualizar
    });
}

Riesgo 5: Múltiples Pagos del Mismo Order

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());

Riesgo 6: Webhooks sin Reintento

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();

8.3 Matriz de Priorización

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

9. Guía de Testing

9.1 Tests Existentes

tests/Feature/API/V1/
├── Customer/Customer/PayOrderTest.php      # Tests básicos de pago
├── Unauthorized/Internal/
│   └── AmortizationControllerTest.php      # Tests de amortización

9.2 Tests que Faltan (Recomendados)

Test de Concurrencia

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);
});

Test de Overpayment

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']);
});

Test de Mora Única

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);
});

Test de Distribución de Pago

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
});

10. Checklist de Mantenimiento

10.1 Antes de Modificar el Sistema de Pagos

  • Revisar si hay pagos en estado PENDING o CONFIRMED
  • Verificar que no haya jobs de ProcessingPayments en ejecución
  • Revisar failed_jobs para pagos fallidos
  • Hacer backup de tablas: payments, order_repayments, capital_registers

10.2 Después de Desplegar Cambios

  • Monitorear failed_jobs por 24 horas
  • Verificar que total_capital en settings es consistente
  • Revisar logs de ProcessingPayments
  • Validar que webhooks se están enviando

10.3 Auditoría Mensual

  • Reconciliar capital_registers vs payments
  • Verificar que no hay OrderRepayment huérfanos
  • Revisar cuotas en estado DUE o IN_ARREARS prolongado
  • Validar que installment_summary y order_summary están actualizadas

10.4 Queries de Diagnóstico

-- 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;

Apéndice A: Glosario

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

Apéndice B: Flujo de Dinero Completo

┌─────────────────────────────────────────────────────────────────────────────┐
│                      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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment