@ar-agents/mercadopago 0.17.1 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,298 @@
1
+ /**
2
+ * Recipe 21 — Cross-jurisdictional commerce w/ AP2 mandate verification.
3
+ *
4
+ * # Pattern
5
+ *
6
+ * A USA-LLC agent (e.g., a Wyoming DAO LLC operating an Etsy-style
7
+ * marketplace) wants to sell to AR consumers via MP, but doesn't have AR
8
+ * fiscal residency. The AR-side is a sociedad-IA (or pre-launch SAS) that
9
+ * (a) issues factura A/B/C to the AR consumer, (b) cobra MP, (c) settles
10
+ * inter-entity to the USA-LLC weekly.
11
+ *
12
+ * Each cross-entity transaction is gated by an AP2 (Google Agent Payments
13
+ * Protocol) mandate the USA-LLC signs and the AR sociedad verifies before
14
+ * acting. This is what RFC-001 § 7 sketches as the contract surface for
15
+ * "agent commerce that crosses jurisdictions".
16
+ *
17
+ * The mandate has 3 critical fields:
18
+ * - issuer: USA-LLC's stable identifier (DID, DAO LLC EIN, etc.)
19
+ * - subject: the AR sociedad's CUIT
20
+ * - claims: { action: "factura.emit", amount, currency: "ARS",
21
+ * consumer: { dni, email }, allowance: { capPerMonth, capPerOp } }
22
+ *
23
+ * Verification:
24
+ * 1. AP2 verify chain — JWS ES256 signature against the issuer's pinned key.
25
+ * 2. Mandate hasn't expired (`exp` claim).
26
+ * 3. Operation respects the cap (cumulative against the audit log).
27
+ * 4. Consumer-side checks (CUIT validity, BCRA situation if amount large).
28
+ *
29
+ * If any check fails, the AR sociedad refuses + logs the refusal in the
30
+ * audit log. RFC-001 § 9.2 makes that audit entry probative — the USA-LLC
31
+ * can later challenge "you said no, prove the rule".
32
+ *
33
+ * # Edge Runtime
34
+ *
35
+ * Yes — @ar-agents/ap2 + @ar-agents/incorporate are both fetch-only.
36
+ * Verification uses Web Crypto's verify() against the issuer's pinned JWK.
37
+ *
38
+ * # Production caveats
39
+ *
40
+ * - The AR sociedad MUST emit the factura under its own CUIT, not the
41
+ * USA-LLC's. Cross-CUIT factura is not legal in AR.
42
+ * - Settlement between entities is an SLA between the operator and the
43
+ * USA-LLC. The toolkit doesn't dictate the cadence; recipe 22 (planned)
44
+ * covers nightly reconciliation.
45
+ * - For amounts > certain BCRA thresholds, AR sociedad needs to hold
46
+ * funds for ~2 days while AFIP IBPP processes. The agent should surface
47
+ * this to the consumer at checkout.
48
+ */
49
+
50
+ import { fetchAudit } from "@ar-agents/incorporate";
51
+ import { verifyMandate, type Mandate } from "@ar-agents/ap2";
52
+ import {
53
+ identityTools,
54
+ UnconfiguredAfipPadronAdapter,
55
+ } from "@ar-agents/identity";
56
+ import { facturacionTools } from "@ar-agents/facturacion";
57
+ import { bankingTools } from "@ar-agents/banking";
58
+
59
+ // ─────────────────────────────────────────────────────────────────────────────
60
+ // Mandate the USA-LLC sends to the AR sociedad
61
+ // ─────────────────────────────────────────────────────────────────────────────
62
+
63
+ interface CrossJurisdictionalClaims {
64
+ action: "factura.emit" | "mp.charge" | "shipment.create";
65
+ amountArs: number;
66
+ currency: "ARS";
67
+ consumer: { cuit: string; email: string };
68
+ allowance: {
69
+ capPerOpArs: number;
70
+ capPerMonthArs: number;
71
+ };
72
+ /** ISO 8601. Mandate expires here. */
73
+ exp: string;
74
+ /** USA-LLC's transaction reference for reconciliation. */
75
+ externalId: string;
76
+ }
77
+
78
+ // ─────────────────────────────────────────────────────────────────────────────
79
+ // AR sociedad's verify-then-act handler
80
+ // ─────────────────────────────────────────────────────────────────────────────
81
+
82
+ interface VerifyAndActOptions {
83
+ /**
84
+ * The AR sociedad's audit-log session id. All operations under this
85
+ * mandate land in the same forensic timeline.
86
+ */
87
+ sessionId: string;
88
+ /**
89
+ * The USA-LLC's JWK that issued the mandate. In production: pinned
90
+ * via the platform's vendor table.
91
+ */
92
+ issuerJwk: JsonWebKey;
93
+ /** The mandate the USA-LLC delivered. */
94
+ mandate: Mandate<CrossJurisdictionalClaims>;
95
+ }
96
+
97
+ interface ActResult {
98
+ ok: boolean;
99
+ reason?: string;
100
+ facturaCae?: string;
101
+ auditDashboardUrl: string;
102
+ }
103
+
104
+ export async function verifyAndAct(
105
+ options: VerifyAndActOptions,
106
+ ): Promise<ActResult> {
107
+ const { sessionId, issuerJwk, mandate } = options;
108
+
109
+ // ─── 1. Verify the AP2 mandate ──────────────────────────────────────
110
+ const verification = await verifyMandate(mandate, { issuerJwk });
111
+ if (!verification.valid) {
112
+ return {
113
+ ok: false,
114
+ reason: `Mandate verification failed: ${verification.reason}`,
115
+ auditDashboardUrl: `https://ar-agents.ar/dashboard/${sessionId}`,
116
+ };
117
+ }
118
+
119
+ // ─── 2. Expiry check ────────────────────────────────────────────────
120
+ if (Date.parse(mandate.claims.exp) < Date.now()) {
121
+ return {
122
+ ok: false,
123
+ reason: "Mandate expired (exp claim in the past)",
124
+ auditDashboardUrl: `https://ar-agents.ar/dashboard/${sessionId}`,
125
+ };
126
+ }
127
+
128
+ // ─── 3. Per-op cap ──────────────────────────────────────────────────
129
+ if (mandate.claims.amountArs > mandate.claims.allowance.capPerOpArs) {
130
+ return {
131
+ ok: false,
132
+ reason: `Amount $${mandate.claims.amountArs} exceeds per-op cap $${mandate.claims.allowance.capPerOpArs}.`,
133
+ auditDashboardUrl: `https://ar-agents.ar/dashboard/${sessionId}`,
134
+ };
135
+ }
136
+
137
+ // ─── 4. Cumulative cap (querying the audit log) ─────────────────────
138
+ // Pull this month's prior emissions on the same sessionId + sum.
139
+ const audit = (await fetchAudit(sessionId, { verify: false })) as {
140
+ entries: Array<{
141
+ tool: string;
142
+ ts: string;
143
+ input: { amountArs?: number; externalId?: string };
144
+ }>;
145
+ };
146
+ const monthStart = new Date();
147
+ monthStart.setDate(1);
148
+ monthStart.setHours(0, 0, 0, 0);
149
+ const cumulative = audit.entries
150
+ .filter(
151
+ (e) =>
152
+ e.tool === "cross_jurisdictional_factura_emit" &&
153
+ Date.parse(e.ts) >= monthStart.getTime(),
154
+ )
155
+ .reduce((sum, e) => sum + (e.input.amountArs ?? 0), 0);
156
+
157
+ if (cumulative + mandate.claims.amountArs > mandate.claims.allowance.capPerMonthArs) {
158
+ return {
159
+ ok: false,
160
+ reason: `Cumulative monthly emissions ($${cumulative} + $${mandate.claims.amountArs}) exceed cap $${mandate.claims.allowance.capPerMonthArs}.`,
161
+ auditDashboardUrl: `https://ar-agents.ar/dashboard/${sessionId}`,
162
+ };
163
+ }
164
+
165
+ // ─── 5. Idempotency: did we already process this externalId? ────────
166
+ if (
167
+ audit.entries.some(
168
+ (e) =>
169
+ e.tool === "cross_jurisdictional_factura_emit" &&
170
+ e.input.externalId === mandate.claims.externalId,
171
+ )
172
+ ) {
173
+ return {
174
+ ok: false,
175
+ reason: `externalId ${mandate.claims.externalId} already processed (idempotency).`,
176
+ auditDashboardUrl: `https://ar-agents.ar/dashboard/${sessionId}`,
177
+ };
178
+ }
179
+
180
+ // ─── 6. Validate the consumer's CUIT ────────────────────────────────
181
+ // (Real prod would have an AfipPadronAdapter wired — using the
182
+ // unconfigured shim here just to demonstrate the flow.)
183
+ const idTools = identityTools({ afip: new UnconfiguredAfipPadronAdapter() });
184
+ const cuitCheck = await idTools.validate_cuit.execute({
185
+ cuit: mandate.claims.consumer.cuit,
186
+ });
187
+ if (!cuitCheck.valid) {
188
+ return {
189
+ ok: false,
190
+ reason: `Consumer CUIT ${mandate.claims.consumer.cuit} is malformed.`,
191
+ auditDashboardUrl: `https://ar-agents.ar/dashboard/${sessionId}`,
192
+ };
193
+ }
194
+
195
+ // ─── 7. For amounts above $500k ARS, BCRA credit-situation check ────
196
+ if (mandate.claims.amountArs > 500_000) {
197
+ const bk = bankingTools();
198
+ // In production this hits BCRA Central de Deudores for real; mock here.
199
+ const credit = await bk.lookup_credit_situation.execute({
200
+ cuit: mandate.claims.consumer.cuit,
201
+ });
202
+ if (
203
+ credit.available === false ||
204
+ (credit.worstSituation && credit.worstSituation > 2)
205
+ ) {
206
+ return {
207
+ ok: false,
208
+ reason: `Consumer has BCRA situation ${credit.worstSituation} (>2 = high risk).`,
209
+ auditDashboardUrl: `https://ar-agents.ar/dashboard/${sessionId}`,
210
+ };
211
+ }
212
+ }
213
+
214
+ // ─── 8. Emit the factura ────────────────────────────────────────────
215
+ // (Real prod requires AFIP cert; using mock returns CAE here.)
216
+ const fact = facturacionTools();
217
+ // Note: in real code, you'd select cbteTipo ("FACTURA_B" for
218
+ // CONSUMIDOR_FINAL etc.) based on AFIP padron lookup of the consumer.
219
+ // Demo just hardcodes Factura B + 21% IVA.
220
+ const result = await fact.crear_factura.execute({
221
+ cbteTipo: "FACTURA_B",
222
+ docTipo: "CUIT",
223
+ docNro: mandate.claims.consumer.cuit,
224
+ impTotal: mandate.claims.amountArs,
225
+ impNeto: Math.round(mandate.claims.amountArs / 1.21),
226
+ impIVA: mandate.claims.amountArs - Math.round(mandate.claims.amountArs / 1.21),
227
+ });
228
+
229
+ // ─── 9. Audit log entry happens automatically via the tool wrapper.
230
+ // Surface the result + dashboard URL for the USA-LLC's records.
231
+
232
+ return {
233
+ ok: true,
234
+ facturaCae: result.cae ?? undefined,
235
+ auditDashboardUrl: `https://ar-agents.ar/dashboard/${sessionId}`,
236
+ };
237
+ }
238
+
239
+ // ─────────────────────────────────────────────────────────────────────────────
240
+ // Example: handle a cross-jurisdictional factura request
241
+ // ─────────────────────────────────────────────────────────────────────────────
242
+
243
+ async function main() {
244
+ const sessionId = "tenant-clawbank-llc-ar"; // pinned per USA-LLC tenant
245
+
246
+ // Mandate the USA-LLC produced (signed JWS; pretend it's already verified
247
+ // structurally — in real code, JWS parsing happens inside verifyMandate).
248
+ const mandate = {
249
+ issuer: "wyoming-dao-llc:claw-bank",
250
+ subject: "ar-sociedad:30123456789",
251
+ claims: {
252
+ action: "factura.emit" as const,
253
+ amountArs: 75_000,
254
+ currency: "ARS" as const,
255
+ consumer: { cuit: "20-12345678-9", email: "consumidor@example.com" },
256
+ allowance: {
257
+ capPerOpArs: 100_000,
258
+ capPerMonthArs: 2_000_000,
259
+ },
260
+ exp: new Date(Date.now() + 3600 * 1000).toISOString(),
261
+ externalId: "claw-bank:tx_42",
262
+ },
263
+ signature: "<JWS-ES256-signature>", // produced by USA-LLC
264
+ } as unknown as Mandate<CrossJurisdictionalClaims>;
265
+
266
+ // The USA-LLC's pinned issuer key. In production: pulled from the
267
+ // platform's vendor table at signup time.
268
+ const issuerJwk: JsonWebKey = {
269
+ kty: "EC",
270
+ crv: "P-256",
271
+ x: "<base64url-x>",
272
+ y: "<base64url-y>",
273
+ };
274
+
275
+ const result = await verifyAndAct({ sessionId, issuerJwk, mandate });
276
+
277
+ if (!result.ok) {
278
+ console.error("Refused:", result.reason);
279
+ console.error("Audit dashboard:", result.auditDashboardUrl);
280
+ process.exit(0);
281
+ }
282
+
283
+ console.log("Factura emitted:", result.facturaCae);
284
+ console.log("Audit dashboard:", result.auditDashboardUrl);
285
+ console.log(
286
+ "Settlement reference:",
287
+ `clawbank-llc:${mandate.claims.externalId}`,
288
+ );
289
+ }
290
+
291
+ if (typeof require !== "undefined" && require.main === module) {
292
+ main().catch((err) => {
293
+ console.error("Recipe 21 failed:", err);
294
+ process.exit(1);
295
+ });
296
+ }
297
+
298
+ export { main };
@@ -0,0 +1,374 @@
1
+ /**
2
+ * Recipe 22 — Nightly MP webhook ↔ AFIP CAE reconciliation.
3
+ *
4
+ * # Pattern
5
+ *
6
+ * Production sociedad-IA emits factura via AFIP WSFE, gets CAE back,
7
+ * stores it. Mercado Pago webhook fires when payment lands, stores the
8
+ * MP payment id linked to the order. Each independent system has its
9
+ * own clock, its own retry semantics, its own failure modes. Things
10
+ * drift:
11
+ *
12
+ * - MP payment lands but factura emission failed silently → unhappy
13
+ * customer + AFIP-compliance issue.
14
+ * - Factura CAE issued but the corresponding MP payment never
15
+ * showed up → orphan factura that needs to be cancelled by NC/ND.
16
+ * - Same MP payment received twice (legit refund-then-recharge), but
17
+ * only one factura issued → revenue under-reported.
18
+ *
19
+ * Recipe 22 is the nightly cron that catches all three drift cases by
20
+ * reconciling MP's payment search against AFIP's solicited CAEs against
21
+ * the local audit log. Output: a digest the contador signs off on every
22
+ * morning, plus auto-corrections where they're safe (e.g., re-emit
23
+ * factura if MP shows paid + AFIP has no record).
24
+ *
25
+ * # When to use
26
+ *
27
+ * - Multi-tenant marketplace: one digest per tenant, fan out via the
28
+ * recipe 19 + recipe 20 patterns.
29
+ * - Production sociedad-IA emitting >50 facturas/day. At lower volume
30
+ * the manual scan is fine.
31
+ * - Anywhere AFIP cert + MP token are wired (see /api/play/audit/{id}
32
+ * to confirm `auto_incorporate.tipo === "SAS" || "SOCIEDAD-IA"` and
33
+ * the operating env has both clients live).
34
+ *
35
+ * # Edge Runtime
36
+ *
37
+ * Yes — calls /api/play/audit (read), MP REST (search), AFIP WSFE
38
+ * (consultarComprobante). All three are fetch-based.
39
+ *
40
+ * # Schedule
41
+ *
42
+ * Wire to Vercel Cron. Suggested cron: 0 4 * * * (4am AR time).
43
+ * Run window: previous day 00:00 → 23:59:59 in AR time.
44
+ */
45
+
46
+ import { fetchAudit } from "@ar-agents/incorporate";
47
+ import {
48
+ type MercadoPagoClient,
49
+ // The real-life recipe constructs MP via @ar-agents/mercadopago's client;
50
+ // we'll alias the type here for clarity.
51
+ } from "@ar-agents/mercadopago";
52
+ import { WsfeClient, validateSolicitarCae } from "@ar-agents/facturacion";
53
+
54
+ // ─────────────────────────────────────────────────────────────────────────────
55
+ // Types — what the reconciliation produces
56
+ // ─────────────────────────────────────────────────────────────────────────────
57
+
58
+ interface ReconciliationInput {
59
+ sessionId: string;
60
+ mp: MercadoPagoClient;
61
+ wsfe: WsfeClient;
62
+ ptoVta: number;
63
+ /** Window start, inclusive. ISO 8601. */
64
+ rangeStart: string;
65
+ /** Window end, exclusive. ISO 8601. */
66
+ rangeEnd: string;
67
+ }
68
+
69
+ type DriftKind =
70
+ | "mp_paid_no_factura"
71
+ | "factura_no_mp_payment"
72
+ | "duplicate_mp_payment_one_factura";
73
+
74
+ interface Drift {
75
+ kind: DriftKind;
76
+ detail: string;
77
+ severity: "warn" | "alert";
78
+ /** Suggested auto-correction, if any. */
79
+ suggestedAction:
80
+ | "emit_factura"
81
+ | "cancel_factura_via_nc"
82
+ | "review_manually"
83
+ | "no_action";
84
+ /** External refs to cross-look up. */
85
+ refs: {
86
+ mpPaymentId?: string;
87
+ facturaCae?: string;
88
+ auditEntryId?: string;
89
+ };
90
+ }
91
+
92
+ interface ReconciliationReport {
93
+ rangeStart: string;
94
+ rangeEnd: string;
95
+ generatedAt: string;
96
+ totals: {
97
+ mpPayments: number;
98
+ facturasIssued: number;
99
+ drifts: number;
100
+ autoCorrected: number;
101
+ };
102
+ drifts: Drift[];
103
+ }
104
+
105
+ // ─────────────────────────────────────────────────────────────────────────────
106
+ // Core reconciliation logic
107
+ // ─────────────────────────────────────────────────────────────────────────────
108
+
109
+ interface MpPayment {
110
+ id: string;
111
+ status: string;
112
+ transaction_amount: number;
113
+ external_reference: string | null;
114
+ date_approved: string | null;
115
+ }
116
+
117
+ interface AuditEntry {
118
+ id: string;
119
+ ts: string;
120
+ tool: string;
121
+ input: { externalReference?: string; impTotal?: number };
122
+ output: { cae?: string | null; cbteNro?: number; resultado?: string };
123
+ }
124
+
125
+ export async function reconcile(
126
+ input: ReconciliationInput,
127
+ ): Promise<ReconciliationReport> {
128
+ // 1. Pull MP payments in the window.
129
+ const mpPayments = await searchMpPayments(input.mp, input.rangeStart, input.rangeEnd);
130
+
131
+ // 2. Pull factura emissions from the audit log in the window.
132
+ const auditEntries = await pullFacturaEntriesFromAudit(
133
+ input.sessionId,
134
+ input.rangeStart,
135
+ input.rangeEnd,
136
+ );
137
+
138
+ // 3. Build cross-reference indices.
139
+ const mpByExternalRef = new Map<string, MpPayment[]>();
140
+ for (const p of mpPayments) {
141
+ if (!p.external_reference) continue;
142
+ const list = mpByExternalRef.get(p.external_reference) ?? [];
143
+ list.push(p);
144
+ mpByExternalRef.set(p.external_reference, list);
145
+ }
146
+ const facturaByExternalRef = new Map<string, AuditEntry[]>();
147
+ for (const e of auditEntries) {
148
+ if (!e.input.externalReference) continue;
149
+ const list = facturaByExternalRef.get(e.input.externalReference) ?? [];
150
+ list.push(e);
151
+ facturaByExternalRef.set(e.input.externalReference, list);
152
+ }
153
+
154
+ const drifts: Drift[] = [];
155
+
156
+ // 4. Drift class 1: MP paid + no factura.
157
+ for (const [ref, payments] of mpByExternalRef) {
158
+ const facturas = facturaByExternalRef.get(ref) ?? [];
159
+ if (
160
+ payments.some((p) => p.status === "approved") &&
161
+ facturas.length === 0
162
+ ) {
163
+ const paid = payments.find((p) => p.status === "approved")!;
164
+ drifts.push({
165
+ kind: "mp_paid_no_factura",
166
+ severity: "alert",
167
+ 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.`,
168
+ suggestedAction: "emit_factura",
169
+ refs: { mpPaymentId: paid.id },
170
+ });
171
+ }
172
+ }
173
+
174
+ // 5. Drift class 2: Factura emitted + no MP payment.
175
+ for (const [ref, facturas] of facturaByExternalRef) {
176
+ const payments = mpByExternalRef.get(ref) ?? [];
177
+ if (
178
+ facturas.length > 0 &&
179
+ !payments.some((p) => p.status === "approved")
180
+ ) {
181
+ const fact = facturas[0]!;
182
+ drifts.push({
183
+ kind: "factura_no_mp_payment",
184
+ severity: "warn",
185
+ 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.`,
186
+ suggestedAction: "review_manually",
187
+ refs: {
188
+ facturaCae: fact.output.cae ?? undefined,
189
+ auditEntryId: fact.id,
190
+ },
191
+ });
192
+ }
193
+ }
194
+
195
+ // 6. Drift class 3: Duplicate MP payments + one factura.
196
+ for (const [ref, payments] of mpByExternalRef) {
197
+ const approved = payments.filter((p) => p.status === "approved");
198
+ const facturas = facturaByExternalRef.get(ref) ?? [];
199
+ if (approved.length > 1 && facturas.length === 1) {
200
+ drifts.push({
201
+ kind: "duplicate_mp_payment_one_factura",
202
+ severity: "alert",
203
+ 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.`,
204
+ suggestedAction: "review_manually",
205
+ refs: { mpPaymentId: approved[0]!.id, facturaCae: facturas[0]!.output.cae ?? undefined },
206
+ });
207
+ }
208
+ }
209
+
210
+ // 7. Auto-correction pass (only the safe ones).
211
+ let autoCorrected = 0;
212
+ for (const drift of drifts) {
213
+ if (drift.kind === "mp_paid_no_factura" && drift.refs.mpPaymentId) {
214
+ // Pull the payment to extract amount + customer details, then
215
+ // emit factura. Run validate_solicitar_cae locally first to
216
+ // catch the ~30% mechanical AFIP rejection rate.
217
+ try {
218
+ const payment = mpPayments.find((p) => p.id === drift.refs.mpPaymentId);
219
+ if (!payment) continue;
220
+ const preflight = validateSolicitarCae({
221
+ ptoVta: input.ptoVta,
222
+ cbteTipo: 6, // Factura B (CONSUMIDOR_FINAL); production picks per receptor
223
+ concepto: 2, // SERVICIOS
224
+ docTipo: 99, // CONSUMIDOR_FINAL
225
+ docNro: "0",
226
+ cbteDesde: 1,
227
+ cbteHasta: 1,
228
+ cbteFch: payment.date_approved
229
+ ? payment.date_approved.replace(/-/g, "").slice(0, 8)
230
+ : new Date().toISOString().replace(/[-T:]/g, "").slice(0, 8),
231
+ impTotal: payment.transaction_amount,
232
+ impNeto: Math.round(payment.transaction_amount / 1.21),
233
+ impIVA:
234
+ payment.transaction_amount -
235
+ Math.round(payment.transaction_amount / 1.21),
236
+ });
237
+ if (!preflight.valid) {
238
+ drift.suggestedAction = "review_manually";
239
+ drift.detail += ` Pre-flight rejected: ${preflight.findings.map((f) => f.message).join("; ")}`;
240
+ continue;
241
+ }
242
+ // Emit. In real production: idempotency key on (mpPaymentId)
243
+ // so repeated cron runs don't double-emit.
244
+ // (Skipped here — the recipe focuses on the reconciliation
245
+ // pattern; emission belongs in a separate job triggered by
246
+ // the digest output.)
247
+ autoCorrected++;
248
+ } catch {
249
+ drift.suggestedAction = "review_manually";
250
+ }
251
+ }
252
+ }
253
+
254
+ return {
255
+ rangeStart: input.rangeStart,
256
+ rangeEnd: input.rangeEnd,
257
+ generatedAt: new Date().toISOString(),
258
+ totals: {
259
+ mpPayments: mpPayments.length,
260
+ facturasIssued: auditEntries.length,
261
+ drifts: drifts.length,
262
+ autoCorrected,
263
+ },
264
+ drifts,
265
+ };
266
+ }
267
+
268
+ // ─────────────────────────────────────────────────────────────────────────────
269
+ // Helpers
270
+ // ─────────────────────────────────────────────────────────────────────────────
271
+
272
+ async function searchMpPayments(
273
+ _mp: MercadoPagoClient,
274
+ _start: string,
275
+ _end: string,
276
+ ): Promise<MpPayment[]> {
277
+ // Real implementation: paginate /v1/payments/search?range=date_created&begin_date=...
278
+ // Demo returns synthetic.
279
+ return [];
280
+ }
281
+
282
+ async function pullFacturaEntriesFromAudit(
283
+ sessionId: string,
284
+ rangeStart: string,
285
+ rangeEnd: string,
286
+ ): Promise<AuditEntry[]> {
287
+ const data = (await fetchAudit(sessionId, { verify: false })) as {
288
+ entries: Array<AuditEntry & { ts: string; tool: string }>;
289
+ };
290
+ return data.entries.filter(
291
+ (e) =>
292
+ (e.tool === "crear_factura" || e.tool === "solicitar_cae") &&
293
+ e.ts >= rangeStart &&
294
+ e.ts < rangeEnd,
295
+ );
296
+ }
297
+
298
+ // ─────────────────────────────────────────────────────────────────────────────
299
+ // Render for the contador
300
+ // ─────────────────────────────────────────────────────────────────────────────
301
+
302
+ export function renderForContador(report: ReconciliationReport): string {
303
+ const date = report.rangeEnd.slice(0, 10);
304
+ const lines = [
305
+ `RECONCILIACIÓN MP ↔ AFIP · ${date}`,
306
+ `Window: ${report.rangeStart} → ${report.rangeEnd}`,
307
+ "",
308
+ "TOTALES",
309
+ ` MP payments procesados: ${report.totals.mpPayments}`,
310
+ ` Facturas emitidas: ${report.totals.facturasIssued}`,
311
+ ` Drifts detectados: ${report.totals.drifts}`,
312
+ ` Auto-correcciones aplicadas: ${report.totals.autoCorrected}`,
313
+ ];
314
+ if (report.drifts.length > 0) {
315
+ lines.push("", "DRIFTS A REVISAR");
316
+ for (const d of report.drifts) {
317
+ lines.push(
318
+ ` [${d.severity.toUpperCase()}] ${d.kind}`,
319
+ ` ${d.detail}`,
320
+ ` Acción sugerida: ${d.suggestedAction}`,
321
+ "",
322
+ );
323
+ }
324
+ } else {
325
+ lines.push("", "Día limpio — nada para revisar.");
326
+ }
327
+ return lines.join("\n");
328
+ }
329
+
330
+ // ─────────────────────────────────────────────────────────────────────────────
331
+ // Cron entrypoint (drop in /api/cron/reconcile in your Next.js app)
332
+ // ─────────────────────────────────────────────────────────────────────────────
333
+
334
+ async function main() {
335
+ const sid = process.argv[2];
336
+ if (!sid) {
337
+ console.error(
338
+ "usage: pnpm tsx 22-mp-webhook-afip-reconciliation.ts <sessionId>",
339
+ );
340
+ process.exit(1);
341
+ }
342
+ // In production, construct mp + wsfe from env. Here we placeholder.
343
+ const mp = {} as unknown as MercadoPagoClient;
344
+ const wsfe = new WsfeClient({
345
+ certPem: process.env.AFIP_CERT_PEM ?? "",
346
+ keyPem: process.env.AFIP_KEY_PEM ?? "",
347
+ cuit: process.env.AFIP_CUIT ?? "20000000007",
348
+ env: (process.env.AFIP_ENV ?? "homo") as "prod" | "homo",
349
+ });
350
+ const today = new Date();
351
+ const rangeEnd = today.toISOString();
352
+ const rangeStart = new Date(today.getTime() - 86_400_000).toISOString();
353
+
354
+ const report = await reconcile({
355
+ sessionId: sid,
356
+ mp,
357
+ wsfe,
358
+ ptoVta: Number(process.env.AFIP_PTO_VTA ?? 1),
359
+ rangeStart,
360
+ rangeEnd,
361
+ });
362
+
363
+ console.log(JSON.stringify(report));
364
+ console.log(renderForContador(report));
365
+ }
366
+
367
+ if (typeof require !== "undefined" && require.main === module) {
368
+ main().catch((err) => {
369
+ console.error("Recipe 22 failed:", err);
370
+ process.exit(1);
371
+ });
372
+ }
373
+
374
+ export { main };