@ar-agents/mercadopago 0.15.2 → 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.
- package/AGENTS.md +30 -30
- package/CHANGELOG.md +43 -1
- package/MIGRATION.md +8 -8
- package/README.md +33 -32
- package/README.skeleton.md +21 -21
- package/cookbook/10-cross-package-billing.ts +172 -0
- package/cookbook/11-dunning-sequence.ts +305 -0
- package/cookbook/12-reconciliation-pipeline.ts +277 -0
- package/cookbook/README.md +3 -0
- package/dist/index.d.cts +4 -1084
- package/dist/index.d.ts +4 -1084
- package/dist/testing.cjs +281 -0
- package/dist/testing.cjs.map +1 -0
- package/dist/testing.d.cts +188 -0
- package/dist/testing.d.ts +188 -0
- package/dist/testing.js +270 -0
- package/dist/testing.js.map +1 -0
- package/dist/types-BaOjfcOt.d.cts +1085 -0
- package/dist/types-BaOjfcOt.d.ts +1085 -0
- package/package.json +30 -16
- package/tools.manifest.json +1 -1
|
@@ -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
|
+
}
|
package/cookbook/README.md
CHANGED
|
@@ -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
|
|