/** * Recipe 22 — Nightly MP webhook ↔ AFIP CAE reconciliation. * * # Pattern * * Production sociedad-IA emits factura via AFIP WSFE, gets CAE back, * stores it. Mercado Pago webhook fires when payment lands, stores the * MP payment id linked to the order. Each independent system has its * own clock, its own retry semantics, its own failure modes. Things * drift: * * - MP payment lands but factura emission failed silently → unhappy * customer + AFIP-compliance issue. * - Factura CAE issued but the corresponding MP payment never * showed up → orphan factura that needs to be cancelled by NC/ND. * - Same MP payment received twice (legit refund-then-recharge), but * only one factura issued → revenue under-reported. * * Recipe 22 is the nightly cron that catches all three drift cases by * reconciling MP's payment search against AFIP's solicited CAEs against * the local audit log. Output: a digest the contador signs off on every * morning, plus auto-corrections where they're safe (e.g., re-emit * factura if MP shows paid + AFIP has no record). * * # When to use * * - Multi-tenant marketplace: one digest per tenant, fan out via the * recipe 19 + recipe 20 patterns. * - Production sociedad-IA emitting >50 facturas/day. At lower volume * the manual scan is fine. * - Anywhere AFIP cert + MP token are wired (see /api/play/audit/{id} * to confirm `auto_incorporate.tipo === "SAS" || "SOCIEDAD-IA"` and * the operating env has both clients live). * * # Edge Runtime * * Yes — calls /api/play/audit (read), MP REST (search), AFIP WSFE * (consultarComprobante). All three are fetch-based. * * # Schedule * * Wire to Vercel Cron. Suggested cron: 0 4 * * * (4am AR time). * Run window: previous day 00:00 → 23:59:59 in AR time. */ import { fetchAudit } from "@ar-agents/incorporate"; import { type MercadoPagoClient, // The real-life recipe constructs MP via @ar-agents/mercadopago's client; // we'll alias the type here for clarity. } from "@ar-agents/mercadopago"; import { WsfeClient, validateSolicitarCae } from "@ar-agents/facturacion"; // ───────────────────────────────────────────────────────────────────────────── // Types — what the reconciliation produces // ───────────────────────────────────────────────────────────────────────────── interface ReconciliationInput { sessionId: string; mp: MercadoPagoClient; wsfe: WsfeClient; ptoVta: number; /** Window start, inclusive. ISO 8601. */ rangeStart: string; /** Window end, exclusive. ISO 8601. */ rangeEnd: string; } type DriftKind = | "mp_paid_no_factura" | "factura_no_mp_payment" | "duplicate_mp_payment_one_factura"; interface Drift { kind: DriftKind; detail: string; severity: "warn" | "alert"; /** Suggested auto-correction, if any. */ suggestedAction: | "emit_factura" | "cancel_factura_via_nc" | "review_manually" | "no_action"; /** External refs to cross-look up. */ refs: { mpPaymentId?: string; facturaCae?: string; auditEntryId?: string; }; } interface ReconciliationReport { rangeStart: string; rangeEnd: string; generatedAt: string; totals: { mpPayments: number; facturasIssued: number; drifts: number; autoCorrected: number; }; drifts: Drift[]; } // ───────────────────────────────────────────────────────────────────────────── // Core reconciliation logic // ───────────────────────────────────────────────────────────────────────────── interface MpPayment { id: string; status: string; transaction_amount: number; external_reference: string | null; date_approved: string | null; } interface AuditEntry { id: string; ts: string; tool: string; input: { externalReference?: string; impTotal?: number }; output: { cae?: string | null; cbteNro?: number; resultado?: string }; } export async function reconcile( input: ReconciliationInput, ): Promise { // 1. Pull MP payments in the window. const mpPayments = await searchMpPayments(input.mp, input.rangeStart, input.rangeEnd); // 2. Pull factura emissions from the audit log in the window. const auditEntries = await pullFacturaEntriesFromAudit( input.sessionId, input.rangeStart, input.rangeEnd, ); // 3. Build cross-reference indices. const mpByExternalRef = new Map(); for (const p of mpPayments) { if (!p.external_reference) continue; const list = mpByExternalRef.get(p.external_reference) ?? []; list.push(p); mpByExternalRef.set(p.external_reference, list); } const facturaByExternalRef = new Map(); for (const e of auditEntries) { if (!e.input.externalReference) continue; const list = facturaByExternalRef.get(e.input.externalReference) ?? []; list.push(e); facturaByExternalRef.set(e.input.externalReference, list); } const drifts: Drift[] = []; // 4. Drift class 1: MP paid + no factura. for (const [ref, payments] of mpByExternalRef) { const facturas = facturaByExternalRef.get(ref) ?? []; if ( payments.some((p) => p.status === "approved") && facturas.length === 0 ) { const paid = payments.find((p) => p.status === "approved")!; drifts.push({ kind: "mp_paid_no_factura", severity: "alert", detail: `MP payment ${paid.id} approved at ${paid.date_approved} for $${paid.transaction_amount}. No factura emitted in this window. Likely WSFE silent failure or downstream bug.`, suggestedAction: "emit_factura", refs: { mpPaymentId: paid.id }, }); } } // 5. Drift class 2: Factura emitted + no MP payment. for (const [ref, facturas] of facturaByExternalRef) { const payments = mpByExternalRef.get(ref) ?? []; if ( facturas.length > 0 && !payments.some((p) => p.status === "approved") ) { const fact = facturas[0]!; drifts.push({ kind: "factura_no_mp_payment", severity: "warn", detail: `Factura CAE ${fact.output.cae} emitted at ${fact.ts}. MP shows no approved payment for this externalReference. Possible orphan — check if the customer paid via a non-MP channel before cancelling.`, suggestedAction: "review_manually", refs: { facturaCae: fact.output.cae ?? undefined, auditEntryId: fact.id, }, }); } } // 6. Drift class 3: Duplicate MP payments + one factura. for (const [ref, payments] of mpByExternalRef) { const approved = payments.filter((p) => p.status === "approved"); const facturas = facturaByExternalRef.get(ref) ?? []; if (approved.length > 1 && facturas.length === 1) { drifts.push({ kind: "duplicate_mp_payment_one_factura", severity: "alert", detail: `${approved.length} approved MP payments for ${ref} (ids: ${approved.map((p) => p.id).join(", ")}) but only 1 factura. Likely a refund-then-recharge cycle that wasn't matched. Issue NC for the duplicate or emit a second factura — review manually.`, suggestedAction: "review_manually", refs: { mpPaymentId: approved[0]!.id, facturaCae: facturas[0]!.output.cae ?? undefined }, }); } } // 7. Auto-correction pass (only the safe ones). let autoCorrected = 0; for (const drift of drifts) { if (drift.kind === "mp_paid_no_factura" && drift.refs.mpPaymentId) { // Pull the payment to extract amount + customer details, then // emit factura. Run validate_solicitar_cae locally first to // catch the ~30% mechanical AFIP rejection rate. try { const payment = mpPayments.find((p) => p.id === drift.refs.mpPaymentId); if (!payment) continue; const preflight = validateSolicitarCae({ ptoVta: input.ptoVta, cbteTipo: 6, // Factura B (CONSUMIDOR_FINAL); production picks per receptor concepto: 2, // SERVICIOS docTipo: 99, // CONSUMIDOR_FINAL docNro: "0", cbteDesde: 1, cbteHasta: 1, cbteFch: payment.date_approved ? payment.date_approved.replace(/-/g, "").slice(0, 8) : new Date().toISOString().replace(/[-T:]/g, "").slice(0, 8), impTotal: payment.transaction_amount, impNeto: Math.round(payment.transaction_amount / 1.21), impIVA: payment.transaction_amount - Math.round(payment.transaction_amount / 1.21), }); if (!preflight.valid) { drift.suggestedAction = "review_manually"; drift.detail += ` Pre-flight rejected: ${preflight.findings.map((f) => f.message).join("; ")}`; continue; } // Emit. In real production: idempotency key on (mpPaymentId) // so repeated cron runs don't double-emit. // (Skipped here — the recipe focuses on the reconciliation // pattern; emission belongs in a separate job triggered by // the digest output.) autoCorrected++; } catch { drift.suggestedAction = "review_manually"; } } } return { rangeStart: input.rangeStart, rangeEnd: input.rangeEnd, generatedAt: new Date().toISOString(), totals: { mpPayments: mpPayments.length, facturasIssued: auditEntries.length, drifts: drifts.length, autoCorrected, }, drifts, }; } // ───────────────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────────────── async function searchMpPayments( _mp: MercadoPagoClient, _start: string, _end: string, ): Promise { // Real implementation: paginate /v1/payments/search?range=date_created&begin_date=... // Demo returns synthetic. return []; } async function pullFacturaEntriesFromAudit( sessionId: string, rangeStart: string, rangeEnd: string, ): Promise { const data = (await fetchAudit(sessionId, { verify: false })) as { entries: Array; }; return data.entries.filter( (e) => (e.tool === "crear_factura" || e.tool === "solicitar_cae") && e.ts >= rangeStart && e.ts < rangeEnd, ); } // ───────────────────────────────────────────────────────────────────────────── // Render for the contador // ───────────────────────────────────────────────────────────────────────────── export function renderForContador(report: ReconciliationReport): string { const date = report.rangeEnd.slice(0, 10); const lines = [ `RECONCILIACIÓN MP ↔ AFIP · ${date}`, `Window: ${report.rangeStart} → ${report.rangeEnd}`, "", "TOTALES", ` MP payments procesados: ${report.totals.mpPayments}`, ` Facturas emitidas: ${report.totals.facturasIssued}`, ` Drifts detectados: ${report.totals.drifts}`, ` Auto-correcciones aplicadas: ${report.totals.autoCorrected}`, ]; if (report.drifts.length > 0) { lines.push("", "DRIFTS A REVISAR"); for (const d of report.drifts) { lines.push( ` [${d.severity.toUpperCase()}] ${d.kind}`, ` ${d.detail}`, ` Acción sugerida: ${d.suggestedAction}`, "", ); } } else { lines.push("", "Día limpio — nada para revisar."); } return lines.join("\n"); } // ───────────────────────────────────────────────────────────────────────────── // Cron entrypoint (drop in /api/cron/reconcile in your Next.js app) // ───────────────────────────────────────────────────────────────────────────── async function main() { const sid = process.argv[2]; if (!sid) { console.error( "usage: pnpm tsx 22-mp-webhook-afip-reconciliation.ts ", ); process.exit(1); } // In production, construct mp + wsfe from env. Here we placeholder. const mp = {} as unknown as MercadoPagoClient; const wsfe = new WsfeClient({ certPem: process.env.AFIP_CERT_PEM ?? "", keyPem: process.env.AFIP_KEY_PEM ?? "", cuit: process.env.AFIP_CUIT ?? "20000000007", env: (process.env.AFIP_ENV ?? "homo") as "prod" | "homo", }); const today = new Date(); const rangeEnd = today.toISOString(); const rangeStart = new Date(today.getTime() - 86_400_000).toISOString(); const report = await reconcile({ sessionId: sid, mp, wsfe, ptoVta: Number(process.env.AFIP_PTO_VTA ?? 1), rangeStart, rangeEnd, }); console.log(JSON.stringify(report)); console.log(renderForContador(report)); } if (typeof require !== "undefined" && require.main === module) { main().catch((err) => { console.error("Recipe 22 failed:", err); process.exit(1); }); } export { main };