Skip to main content

Overview

The Payment Matching service automatically matches payment complements (Complementos de Pago) with their corresponding PPD (Pago en Parcialidades o Diferido) invoices. This feature ensures accurate tracking of partial payments and automatically updates invoice payment status.
Payment matching only applies to PPD invoices. PUE (Pago en Una sola Exhibición) invoices are paid in full at the time of issuance and do not require payment complements.

How It Works

1

Complement Upload

When a payment complement XML is uploaded, the system extracts all related invoice UUIDs from the DoctoRelacionado nodes.
2

Database Search

The service searches for matching PPD invoices in the database by UUID and profile ID.
// From payment-matching.service.ts:60
const factura = await Invoice.findOne({
  where: {
    uuid: facturaRel.uuid,
    profile_id: profileId,
    tipo: "PPD", // Only PPD invoices can have payment complements
  },
});
3

Validation

The service validates that the payment data matches the invoice data (amounts, dates, currency).
4

Match Recording

Valid matches are recorded in the payment_complement_items table for tracking.

Matching Algorithm

Finding Matches

The buscarMatchesComplemento method processes all payments in a complement:
// From payment-matching.service.ts:13
async buscarMatchesComplemento(
  cfdi: CFDI,
  profileId: string
): Promise<MatchingResult> {
  if (!cfdi.complementoPago) {
    throw new Error("El CFDI no contiene un complemento de pago");
  }

  const complemento = cfdi.complementoPago;
  const matches: MatchResult[] = [];

  // Search for matches for each related invoice
  for (const pago of complemento.pagos) {
    for (const facturaRel of pago.facturasRelacionadas) {
      const match = await this.buscarMatchFactura(facturaRel, profileId);
      matches.push(match);
    }
  }

  return {
    complementoUUID: cfdi.uuid,
    matches,
    totalMatches: matches.length,
    matchesValidos: matches.filter((m) => m.coincidencia && m.encontrada).length,
    matchesInvalidos: matches.length - matchesValidos,
  };
}

Match Validation

The validation process checks multiple criteria:
// From payment-matching.service.ts:102
private validarMatch(
  factura: Invoice,
  facturaRel: FacturaRelacionada
): { esValido: boolean; errores: string[]; advertencias: string[] } {
  const errores: string[] = [];
  const advertencias: string[] = [];

  // 1. Validate invoice type is PPD
  if (factura.tipo !== "PPD") {
    errores.push(
      `La factura ${factura.uuid} no es de tipo PPD, no puede recibir complementos de pago`
    );
    return { esValido: false, errores, advertencias };
  }

  // 2. Validate UUID matches
  if (factura.uuid !== facturaRel.uuid) {
    errores.push(`El UUID no coincide`);
    return { esValido: false, errores, advertencias };
  }

  // 3. Validate amounts (with tolerance for rounding errors)
  const totalFactura = Number(factura.total);
  const saldoAnterior = facturaRel.impSaldoAnt;
  const pagado = facturaRel.impPagado;
  const saldoInsoluto = facturaRel.impSaldoInsoluto;

  // Check formula: SaldoAnterior - Pagado = SaldoInsoluto
  const diferencia = Math.abs(saldoAnterior - pagado - saldoInsoluto);
  if (diferencia > 0.01) {
    advertencias.push(
      `Diferencia en cálculos de parcialidad: ${diferencia.toFixed(2)}`
    );
  }

  // 4. Validate payment amount does not exceed invoice total
  if (pagado > totalFactura) {
    errores.push(
      `El monto pagado (${pagado}) excede el total de la factura (${totalFactura})`
    );
    return { esValido: false, errores, advertencias };
  }

  // 5. Validate outstanding balance is not negative
  if (saldoInsoluto < 0) {
    errores.push(`El saldo insoluto no puede ser negativo`);
    return { esValido: false, errores, advertencias };
  }

  // 6. Validate installment number is positive
  if (facturaRel.numParcialidad <= 0) {
    errores.push(`El número de parcialidad debe ser mayor a cero`);
    return { esValido: false, errores, advertencias };
  }

  return {
    esValido: errores.length === 0,
    errores,
    advertencias,
  };
}

Validation Rules

Required Validations

These validations will block the payment complement if they fail:
  1. Invoice Type: The invoice must be type PPD
  2. UUID Match: The UUID in the complement must exactly match the invoice UUID
  3. Payment Amount: Cannot exceed the invoice total
  4. Outstanding Balance: Cannot be negative
  5. Installment Number: Must be greater than zero

Formula Validation

The service validates the partial payment formula:
SaldoAnterior - ImpPagado = SaldoInsoluto
Where:
  • SaldoAnterior (impSaldoAnt): Previous outstanding balance
  • ImpPagado (impPagado): Amount paid in this installment (subtotal)
  • SaldoInsoluto (impSaldoInsoluto): Remaining outstanding balance
A tolerance of 0.01 (1 cent) is allowed for rounding differences. Discrepancies within this range generate warnings but don’t block the match.

Payment Status Calculation

The service can calculate the complete payment status for a PPD invoice:
// From payment-matching.service.ts:168
async calcularEstadoPagoFactura(
  facturaId: string,
  profileId: string
): Promise<{
  totalFactura: number;
  totalPagado: number;
  saldoPendiente: number;
  porcentajePagado: number;
  completamentePagado: boolean;
}> {
  const factura = await Invoice.findOne({
    where: { id: facturaId, profile_id: profileId },
  });

  if (factura.tipo !== "PPD") {
    throw new Error("Solo las facturas PPD pueden tener estado de pago calculado");
  }

  const totalFactura = Number(factura.total);
  
  // Sum manual payments
  const pagosManual = this.getPagosManual(factura.pagos);
  const totalPagadoManual = pagosManual.reduce(
    (sum, pago) => sum + Number(pago.monto || 0), 0
  );
  
  // Sum payments from complements
  const totalPagadoComplementos = await this.sumPagosComplementos(
    factura.uuid, profileId
  );
  
  const totalPagado = totalPagadoManual + totalPagadoComplementos;
  const saldoPendiente = totalFactura - totalPagado;
  const porcentajePagado = totalFactura > 0 ? (totalPagado / totalFactura) * 100 : 0;
  const completamentePagado = saldoPendiente <= 0.01; // 1 cent tolerance

  return {
    totalFactura,
    totalPagado,
    saldoPendiente,
    porcentajePagado: Math.round(porcentajePagado * 100) / 100,
    completamentePagado,
  };
}

Payment Sources

The system tracks payments from two sources:
  1. Manual Payments: Manually recorded payments (origin = ‘MANUAL’)
  2. Complement Payments: Automatic payments from payment complements
// From payment-matching.service.ts:212
private getPagosManual(pagos: PagoParcial[]): PagoParcial[] {
  return pagos.filter((pago) => (pago.origen ?? "MANUAL") === "MANUAL");
}

private async sumPagosComplementos(
  facturaUUID: string, 
  profileId: string
): Promise<number> {
  const items = await PaymentComplementItem.findAll({
    where: {
      profile_id: profileId,
      factura_uuid: facturaUUID,
    },
  });

  return items.reduce((sum, item) => sum + Number(item.monto_pago || 0), 0);
}

Match Result Structure

When processing a payment complement, the service returns a structured result:
interface MatchingResult {
  complementoUUID: string;      // UUID of the payment complement
  matches: MatchResult[];       // Array of match results
  totalMatches: number;         // Total number of matches attempted
  matchesValidos: number;       // Number of valid matches
  matchesInvalidos: number;     // Number of invalid matches
}

interface MatchResult {
  uuid: string;                 // UUID of the related invoice
  encontrada: boolean;          // Invoice found in database
  coincidencia: boolean;        // Match validation passed
  errores: string[];            // Validation errors
  advertencias: string[];       // Validation warnings
  factura?: {                   // Invoice data (if found)
    id: string;
    uuid: string;
    total: number;
    tipo: string;
    fecha: Date;
  };
}

Usage Examples

Processing a Payment Complement

import { PaymentMatchingService } from './services/payment-matching.service';
import { parseXML, extractComplementoPago } from './parsers/base.parser';

const service = new PaymentMatchingService();

// Parse the payment complement XML
const xml = parseXML(xmlBuffer);
const cfdi = {
  uuid: extractUUID(xml),
  complementoPago: extractComplementoPago(xml),
  // ... other CFDI fields
};

// Find matches
const result = await service.buscarMatchesComplemento(cfdi, profileId);

console.log(`Total matches: ${result.totalMatches}`);
console.log(`Valid matches: ${result.matchesValidos}`);
console.log(`Invalid matches: ${result.matchesInvalidos}`);

// Check each match
for (const match of result.matches) {
  if (match.coincidencia && match.encontrada) {
    console.log(`✓ Match found for invoice ${match.uuid}`);
  } else if (match.encontrada && !match.coincidencia) {
    console.log(`✗ Invalid match for invoice ${match.uuid}:`);
    console.log(match.errores);
  } else {
    console.log(`✗ Invoice ${match.uuid} not found`);
  }
}

Calculating Invoice Payment Status

const status = await service.calcularEstadoPagoFactura(invoiceId, profileId);

console.log(`Total: $${status.totalFactura}`);
console.log(`Paid: $${status.totalPagado} (${status.porcentajePagado}%)`);
console.log(`Outstanding: $${status.saldoPendiente}`);
console.log(`Fully paid: ${status.completamentePagado}`);

Database Storage

Matched payment complements are stored in two tables:
  1. payment_complements: Main payment complement records
  2. payment_complement_items: Individual payment items with related invoice tracking
-- payment_complement_items tracks each related invoice
CREATE TABLE payment_complement_items (
  id UUID PRIMARY KEY,
  complement_id UUID REFERENCES payment_complements(id),
  profile_id UUID,
  factura_uuid VARCHAR(36),
  monto_pago DECIMAL(15,2),
  num_parcialidad INTEGER,
  saldo_anterior DECIMAL(15,2),
  saldo_insoluto DECIMAL(15,2),
  created_at TIMESTAMP
);

Error Handling

Common errors and how to handle them:
  • “El CFDI no contiene un complemento de pago”: The XML is not a payment complement
  • “No se encontró factura PPD con UUID : The related invoice doesn’t exist or is not PPD type
  • “La factura no es de tipo PPD”: Attempting to apply a complement to a PUE invoice
  • “Error al buscar factura en la base de datos”: Database connection error

Implementation Details

Source Code Reference

  • Service: src/services/payment-matching.service.ts
  • Types: src/types/matching.types.ts
  • Database models: src/database/models/PaymentComplement.model.ts

API Integration

The payment matching service is used by:
  • POST /api/payment-complements - Upload and process payment complement
  • GET /api/invoices/:id/payment-status - Get payment status for an invoice
  • GET /api/payment-complements/:id/matches - Get match results for a complement

Build docs developers (and LLMs) love