@ar-agents/mercadopago 0.15.3 → 0.16.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,277 @@
1
+ /**
2
+ * Recipe 12 — Reconciliation pipeline.
3
+ *
4
+ * Daily batch job that compares MP's settlement records against your internal
5
+ * billing DB and surfaces discrepancies. This is what every finance team
6
+ * eventually asks the dev team to build, and there's no good off-the-shelf
7
+ * tool for AR.
8
+ *
9
+ * # The four discrepancy classes
10
+ *
11
+ * 1. **In MP, not in our DB**: an MP payment exists with no matching
12
+ * internal invoice. Usually means a webhook was missed.
13
+ * → Action: backfill from MP into our DB.
14
+ *
15
+ * 2. **In our DB, not in MP**: we have an invoice marked paid, but no
16
+ * matching MP payment. Usually means we marked something paid
17
+ * manually or had a bug.
18
+ * → Action: flag for human review.
19
+ *
20
+ * 3. **Amount mismatch**: amounts differ between MP and our DB. Could be
21
+ * partial refund, dispute, currency conversion.
22
+ * → Action: classify (refund? dispute?) and update DB.
23
+ *
24
+ * 4. **Fee mismatch**: MP's reported `marketplace_fee` differs from what
25
+ * we calculated. Means our pricing logic is out of sync with the
26
+ * actual MP-side `application_fee`.
27
+ * → Action: recompute and surface for billing audit.
28
+ *
29
+ * # What this recipe shows
30
+ *
31
+ * - Pagination via `paginatePayments` AsyncIterable (Edge-Runtime safe).
32
+ * - Settlement-level reconciliation via `paginateSettlements`.
33
+ * - Composition with the marketplace fee calculator (`computeMarketplaceFee`).
34
+ * - A human-readable "discrepancy report" the agent can email the finance
35
+ * team via @ar-agents/whatsapp or Resend.
36
+ */
37
+
38
+ import {
39
+ MercadoPagoClient,
40
+ paginatePayments,
41
+ paginateSettlements,
42
+ computeMarketplaceFee,
43
+ type Payment,
44
+ type Settlement,
45
+ } from "@ar-agents/mercadopago";
46
+
47
+ const mp = new MercadoPagoClient({
48
+ accessToken: process.env.MP_ACCESS_TOKEN!,
49
+ });
50
+
51
+ // ─────────────────────────────────────────────────────────────────────────────
52
+ // Types — what your internal DB looks like (replace with your schema)
53
+ // ─────────────────────────────────────────────────────────────────────────────
54
+
55
+ type InternalInvoice = {
56
+ externalReference: string; // matches MP's external_reference
57
+ amount: number; // in ARS
58
+ status: "pending" | "paid" | "refunded";
59
+ mpPaymentId?: string;
60
+ expectedFee?: number; // what we expect MP to charge us
61
+ };
62
+
63
+ type Discrepancy =
64
+ | { type: "missing_in_db"; mpPayment: Payment }
65
+ | { type: "missing_in_mp"; invoice: InternalInvoice }
66
+ | { type: "amount_mismatch"; mpPayment: Payment; invoice: InternalInvoice }
67
+ | { type: "fee_mismatch"; mpPayment: Payment; expectedFee: number };
68
+
69
+ // ─────────────────────────────────────────────────────────────────────────────
70
+ // Reconciliation entry point
71
+ // ─────────────────────────────────────────────────────────────────────────────
72
+
73
+ export async function reconcileDay(args: {
74
+ date: Date;
75
+ fetchInternalInvoices: (
76
+ rangeStart: Date,
77
+ rangeEnd: Date,
78
+ ) => Promise<InternalInvoice[]>;
79
+ marketplaceFeePct?: number; // e.g. 5 for a 5% platform fee
80
+ }): Promise<{
81
+ matched: number;
82
+ discrepancies: Discrepancy[];
83
+ totals: { mpGross: number; mpNet: number; mpFees: number };
84
+ }> {
85
+ const dayStart = new Date(args.date);
86
+ dayStart.setHours(0, 0, 0, 0);
87
+ const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000);
88
+
89
+ // 1. Pull all approved payments from MP for the day. paginatePayments is
90
+ // an AsyncIterable<Payment> wrapped over MP's search endpoint, with
91
+ // bounded memory regardless of window size.
92
+ const mpPayments: Payment[] = [];
93
+ for await (const p of paginatePayments(mp, {
94
+ status: "approved",
95
+ beginDate: dayStart.toISOString(),
96
+ endDate: dayEnd.toISOString(),
97
+ })) {
98
+ mpPayments.push(p);
99
+ }
100
+
101
+ // 2. Pull internal invoices for the same window.
102
+ const invoices = await args.fetchInternalInvoices(dayStart, dayEnd);
103
+
104
+ // 3. Index by external_reference.
105
+ const byRef = new Map<string, InternalInvoice>();
106
+ for (const inv of invoices) byRef.set(inv.externalReference, inv);
107
+
108
+ const discrepancies: Discrepancy[] = [];
109
+ let matched = 0;
110
+
111
+ // 4. Walk MP payments, match against internal.
112
+ for (const p of mpPayments) {
113
+ const ref = p.external_reference;
114
+ if (!ref) continue; // payments without external_reference can't be matched
115
+ const inv = byRef.get(ref);
116
+
117
+ if (!inv) {
118
+ discrepancies.push({ type: "missing_in_db", mpPayment: p });
119
+ continue;
120
+ }
121
+
122
+ // Check 4a: amount.
123
+ if (Math.abs(p.transaction_amount - inv.amount) > 0.01) {
124
+ discrepancies.push({
125
+ type: "amount_mismatch",
126
+ mpPayment: p,
127
+ invoice: inv,
128
+ });
129
+ continue;
130
+ }
131
+
132
+ // Check 4b: fee, if marketplace.
133
+ const actualFee = (p as Payment & { marketplace_fee?: number }).marketplace_fee;
134
+ if (args.marketplaceFeePct != null && actualFee != null) {
135
+ const expected = computeMarketplaceFee(p.transaction_amount, {
136
+ percent: args.marketplaceFeePct,
137
+ });
138
+ if (Math.abs(actualFee - expected) > 0.01) {
139
+ discrepancies.push({
140
+ type: "fee_mismatch",
141
+ mpPayment: p,
142
+ expectedFee: expected,
143
+ });
144
+ continue;
145
+ }
146
+ }
147
+
148
+ matched += 1;
149
+ byRef.delete(ref); // remove so we can detect leftover (4c) below
150
+ }
151
+
152
+ // 4c: any invoice left in byRef has no matching MP payment.
153
+ for (const inv of byRef.values()) {
154
+ if (inv.status !== "paid") continue; // unpaid invoices wouldn't have an MP payment yet
155
+ discrepancies.push({ type: "missing_in_mp", invoice: inv });
156
+ }
157
+
158
+ // 5. Aggregate totals from MP-side settlements (the truth source for
159
+ // what was actually credited to your account today). The Settlement
160
+ // schema only exposes `amount` (the credited net total per settlement);
161
+ // gross/fee breakdowns live on individual payments. We sum payments
162
+ // above for gross + fees, and settlements here for the credited-net
163
+ // cross-check.
164
+ let mpGross = 0;
165
+ let mpFees = 0;
166
+ for (const p of mpPayments) {
167
+ mpGross += p.transaction_amount ?? 0;
168
+ const fee = (p as Payment & { marketplace_fee?: number }).marketplace_fee;
169
+ if (fee) mpFees += fee;
170
+ }
171
+ let mpNet = 0;
172
+ for await (const s of paginateSettlements(mp, {
173
+ date_from: dayStart.toISOString(),
174
+ date_to: dayEnd.toISOString(),
175
+ } as Parameters<typeof paginateSettlements>[1])) {
176
+ mpNet += (s as Settlement).amount ?? 0;
177
+ }
178
+
179
+ return {
180
+ matched,
181
+ discrepancies,
182
+ totals: { mpGross, mpNet, mpFees },
183
+ };
184
+ }
185
+
186
+ // ─────────────────────────────────────────────────────────────────────────────
187
+ // Discrepancy report formatter
188
+ // ─────────────────────────────────────────────────────────────────────────────
189
+
190
+ export function formatDiscrepancyReport(input: {
191
+ date: Date;
192
+ matched: number;
193
+ discrepancies: Discrepancy[];
194
+ totals: { mpGross: number; mpNet: number; mpFees: number };
195
+ }): string {
196
+ const lines: string[] = [];
197
+ lines.push(`# Reconciliation report — ${input.date.toISOString().slice(0, 10)}`);
198
+ lines.push("");
199
+ lines.push(`**Matched payments:** ${input.matched}`);
200
+ lines.push(`**Discrepancies:** ${input.discrepancies.length}`);
201
+ lines.push("");
202
+ lines.push(`**MP totals:**`);
203
+ lines.push(` - Gross: $${input.totals.mpGross.toLocaleString("es-AR")}`);
204
+ lines.push(` - Fees: $${input.totals.mpFees.toLocaleString("es-AR")}`);
205
+ lines.push(` - Net: $${input.totals.mpNet.toLocaleString("es-AR")}`);
206
+ lines.push("");
207
+
208
+ if (input.discrepancies.length === 0) {
209
+ lines.push("✓ All clean.");
210
+ return lines.join("\n");
211
+ }
212
+
213
+ lines.push("## Discrepancies");
214
+ lines.push("");
215
+ for (const d of input.discrepancies) {
216
+ switch (d.type) {
217
+ case "missing_in_db":
218
+ lines.push(
219
+ `- **Missing in DB**: MP payment ${d.mpPayment.id} (ref=${d.mpPayment.external_reference}, $${d.mpPayment.transaction_amount}). Action: backfill.`,
220
+ );
221
+ break;
222
+ case "missing_in_mp":
223
+ lines.push(
224
+ `- **Missing in MP**: internal invoice ${d.invoice.externalReference} marked paid but no MP record. Action: human review.`,
225
+ );
226
+ break;
227
+ case "amount_mismatch":
228
+ lines.push(
229
+ `- **Amount mismatch**: ref=${d.invoice.externalReference}: DB says $${d.invoice.amount}, MP says $${d.mpPayment.transaction_amount}. Action: classify (partial refund? dispute?).`,
230
+ );
231
+ break;
232
+ case "fee_mismatch":
233
+ lines.push(
234
+ `- **Fee mismatch**: payment ${d.mpPayment.id}: expected $${d.expectedFee}, MP charged $${d.mpPayment.marketplace_fee}. Action: audit pricing logic.`,
235
+ );
236
+ break;
237
+ }
238
+ }
239
+
240
+ return lines.join("\n");
241
+ }
242
+
243
+ // ─────────────────────────────────────────────────────────────────────────────
244
+ // Cron entry point
245
+ // ─────────────────────────────────────────────────────────────────────────────
246
+
247
+ /**
248
+ * Add to vercel.json:
249
+ *
250
+ * {
251
+ * "crons": [
252
+ * { "path": "/api/cron/reconcile", "schedule": "0 6 * * *" }
253
+ * ]
254
+ * }
255
+ *
256
+ * Runs at 06:00 every day, reconciles the previous day.
257
+ */
258
+ export async function GET() {
259
+ const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000);
260
+ const result = await reconcileDay({
261
+ date: yesterday,
262
+ fetchInternalInvoices: async (start, end) => {
263
+ // Replace with your DB query.
264
+ return [];
265
+ },
266
+ marketplaceFeePct: 0.05,
267
+ });
268
+
269
+ const report = formatDiscrepancyReport({ date: yesterday, ...result });
270
+ // Send via Resend, Slack, Email, or your channel of choice.
271
+ console.log(report);
272
+
273
+ return new Response(report, {
274
+ status: 200,
275
+ headers: { "content-type": "text/plain" },
276
+ });
277
+ }
@@ -17,6 +17,9 @@ deploy on Vercel as-is.
17
17
  | 07 | `07-auth-only-order.ts` | `Order` with manual capture → capture later when service completes |
18
18
  | 08 | `08-recovery-patterns.ts` | Retry expired subscriptions, recover stuck-pending payments, etc. |
19
19
  | 09 | `09-otel-wired.ts` | Full OpenTelemetry wiring — spans + metrics for every MP call + tool |
20
+ | 10 | `10-cross-package-billing.ts` | One agent loop, 5 packages: identity + attest + MP + facturacion + WhatsApp |
21
+ | 11 | `11-dunning-sequence.ts` | Failed-payment recovery loop with multi-step dunning + cancel-with-retention |
22
+ | 12 | `12-reconciliation-pipeline.ts` | Daily MP↔internal-DB reconciliation cron with discrepancy report |
20
23
 
21
24
  ## Conventions
22
25