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
Complement Upload
When a payment complement XML is uploaded, the system extracts all related invoice UUIDs from the DoctoRelacionado nodes.
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
},
});
Validation
The service validates that the payment data matches the invoice data (amounts, dates, currency).
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:
- Invoice Type: The invoice must be type PPD
- UUID Match: The UUID in the complement must exactly match the invoice UUID
- Payment Amount: Cannot exceed the invoice total
- Outstanding Balance: Cannot be negative
- Installment Number: Must be greater than zero
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:
- Manual Payments: Manually recorded payments (origin = ‘MANUAL’)
- 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:
- payment_complements: Main payment complement records
- 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