@ar-agents/mercadopago 0.17.0 → 0.17.2
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/CHANGELOG.md +23 -0
- package/cookbook/13-anti-fraud-middleware.ts +270 -0
- package/cookbook/14-marketplace-onboarding.ts +219 -0
- package/cookbook/15-prorated-pause-resume.ts +211 -0
- package/cookbook/16-acp-checkout-with-factura.ts +168 -0
- package/cookbook/17-usa-llc-companion.ts +117 -0
- package/cookbook/README.md +5 -0
- package/package.json +1 -1
- package/tools.manifest.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.17.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [`9b8e83c`](https://github.com/ar-agents/ar-agents/commit/9b8e83ce6f291a24e00101830a49afceb0102920) - Add 2 cookbook recipes that demonstrate the cross-package thesis:
|
|
8
|
+
|
|
9
|
+
- **16-acp-checkout-with-factura.ts** — the headline ACP-with-factura pattern. A ChatGPT Instant Checkout / Claude / Gemini agent POSTs an ACP `checkout_session`, the bridge mints a Mercado Pago preference, the buyer pays, and the bridge auto-emits an AFIP/ARCA Factura A/B/C/E via the `facturacionHook` — selecting the comprobante type based on the buyer's IVA condition. No other OSS implementation in LATAM ships this end-to-end.
|
|
10
|
+
- **17-usa-llc-companion.ts** — pattern for a USA-LLC agent (ClawBank, doola Agentic, MIDAO) operating in Argentina via an AR-resident facade. The USA agent declares `@ar-agents/mcp` in its MCP host config; all 89+6+2+10+5+6+5 tools become available without the USA agent ever holding AR credentials. Walks through the operator-of-record split + sample agent prompt that drives charge → factura → WhatsApp confirmation.
|
|
11
|
+
|
|
12
|
+
Cookbook is now 17 recipes (was 15).
|
|
13
|
+
|
|
14
|
+
## 0.17.1
|
|
15
|
+
|
|
16
|
+
### Patch Changes
|
|
17
|
+
|
|
18
|
+
- [`687aa10`](https://github.com/ar-agents/ar-agents/commit/687aa1017a665ed9b3414b9f92db634a9329ac4e) - Add 3 production-grade cookbook recipes:
|
|
19
|
+
|
|
20
|
+
- **13-anti-fraud-middleware.ts** — pre-charge heuristics chain: CUIT validity (algorithm-only), payer email history (searchPayments + status_detail flags for `cc_rejected_call_for_authorize` / `_high_risk` / `_blacklist`), 1-hour velocity tracker, AR issuer-promo stacking detector. Combined risk score (`approve` / `review` / `reject`), with high-value charges (>$100k) getting a 1.5x multiplier.
|
|
21
|
+
- **14-marketplace-onboarding.ts** — end-to-end flow: CUIT validation → AFIP padron lookup (resolves legal name + IVA condition + monotributo category) → OAuth redirect with PKCE round-tripped via state → callback handler exchanging code for tokens → $1 ARS test charge → marketplace fee setup with `computeMarketplaceFee`.
|
|
22
|
+
- **15-prorated-pause-resume.ts** — pause a subscription with prorated refund for the unused period (`createRefund` against the most recent charge), resume with an adjusted next-billing date so the customer doesn't double-pay. Uses `pausePreapproval` + `resumePreapproval` + a local `pauseStore` (Vercel KV in prod) to remember the credit.
|
|
23
|
+
|
|
24
|
+
12 → 15 cookbook recipes total. README updated.
|
|
25
|
+
|
|
3
26
|
## 0.17.0
|
|
4
27
|
|
|
5
28
|
### Minor Changes
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recipe 13 — Anti-fraud pre-charge middleware.
|
|
3
|
+
*
|
|
4
|
+
* Before authorizing a charge, run a chain of cheap heuristics that catch the
|
|
5
|
+
* most common LATAM fraud patterns. None of these is a hard NO on its own —
|
|
6
|
+
* they're scored and combined into a single risk verdict that the agent (or
|
|
7
|
+
* a human reviewer) can act on.
|
|
8
|
+
*
|
|
9
|
+
* # The heuristics
|
|
10
|
+
*
|
|
11
|
+
* 1. **CUIT validity** (@ar-agents/identity)
|
|
12
|
+
* Algorithm-only, free. A malformed CUIT is a strong signal of either
|
|
13
|
+
* typo or test/fraud account. -10 risk points if valid, +30 if invalid.
|
|
14
|
+
*
|
|
15
|
+
* 2. **CUIT activity in BCRA Central de Deudores** (@ar-agents/banking,
|
|
16
|
+
* adapter-required)
|
|
17
|
+
* Returns worstSituation 0-6. 0 = no debt reported, 5+ = irrecuperable.
|
|
18
|
+
* Only relevant for high-value subscriptions where a defaulting CUIT is
|
|
19
|
+
* a reliable predictor.
|
|
20
|
+
*
|
|
21
|
+
* 3. **Payer email history** (mercadopago — searchPayments by email)
|
|
22
|
+
* A buyer with a healthy history of approved payments is low-risk. A
|
|
23
|
+
* buyer with a recent string of rejections (especially status_detail
|
|
24
|
+
* = `cc_rejected_call_for_authorize` or `cc_rejected_high_risk`) is
|
|
25
|
+
* flagged.
|
|
26
|
+
*
|
|
27
|
+
* 4. **Velocity check** (in-memory or Vercel KV)
|
|
28
|
+
* Charges to the same email within a 1-hour window. >3 attempts in
|
|
29
|
+
* the last hour is a strong fraud signal.
|
|
30
|
+
*
|
|
31
|
+
* 5. **AR issuer-promo abuse** (@ar-agents/mercadopago AR_ISSUER_PROMOS)
|
|
32
|
+
* Stacking multiple promos in a single transaction is a known abuse
|
|
33
|
+
* pattern — flag if more than one applies.
|
|
34
|
+
*
|
|
35
|
+
* # Output
|
|
36
|
+
*
|
|
37
|
+
* `{ verdict: "approve" | "review" | "reject"; score: number; reasons: string[] }`
|
|
38
|
+
*
|
|
39
|
+
* The agent's pre-charge check looks like:
|
|
40
|
+
*
|
|
41
|
+
* const verdict = await runFraudCheck({ payerEmail, transactionAmount, cuit });
|
|
42
|
+
* if (verdict.verdict === "reject") return { error: "fraud_check_failed", ...verdict };
|
|
43
|
+
* if (verdict.verdict === "review") {
|
|
44
|
+
* const ok = await requireHumanReview(verdict);
|
|
45
|
+
* if (!ok) return { error: "human_rejected", ...verdict };
|
|
46
|
+
* }
|
|
47
|
+
* return await mp.createPayment({ ...params });
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
import {
|
|
51
|
+
MercadoPagoClient,
|
|
52
|
+
paginatePayments,
|
|
53
|
+
AR_ISSUER_PROMOS,
|
|
54
|
+
type Payment,
|
|
55
|
+
} from "@ar-agents/mercadopago";
|
|
56
|
+
|
|
57
|
+
const mp = new MercadoPagoClient({
|
|
58
|
+
accessToken: process.env.MP_ACCESS_TOKEN!,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
62
|
+
// Velocity tracker — replace with VercelKV in production
|
|
63
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
const velocityStore = new Map<string, number[]>(); // email → array of unix timestamps
|
|
66
|
+
|
|
67
|
+
function recordAttempt(email: string) {
|
|
68
|
+
const now = Date.now();
|
|
69
|
+
const history = velocityStore.get(email) ?? [];
|
|
70
|
+
history.push(now);
|
|
71
|
+
// Keep only the last hour.
|
|
72
|
+
velocityStore.set(
|
|
73
|
+
email,
|
|
74
|
+
history.filter((t) => now - t < 60 * 60 * 1000),
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function attemptsInLastHour(email: string): number {
|
|
79
|
+
const now = Date.now();
|
|
80
|
+
const history = velocityStore.get(email) ?? [];
|
|
81
|
+
return history.filter((t) => now - t < 60 * 60 * 1000).length;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
85
|
+
// Heuristic checks — each returns { points, reason } or null if not applicable
|
|
86
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
type Signal = { points: number; reason: string };
|
|
89
|
+
|
|
90
|
+
function scoreCuit(cuit: string | undefined): Signal | null {
|
|
91
|
+
if (!cuit) return null;
|
|
92
|
+
// Pure-algorithm validation — see @ar-agents/identity for the implementation.
|
|
93
|
+
// Inlined here to keep the recipe import-graph small.
|
|
94
|
+
const digits = cuit.replace(/[^\d]/g, "");
|
|
95
|
+
if (digits.length !== 11) {
|
|
96
|
+
return { points: 30, reason: "CUIT length is not 11 digits" };
|
|
97
|
+
}
|
|
98
|
+
const weights = [5, 4, 3, 2, 7, 6, 5, 4, 3, 2];
|
|
99
|
+
const sum = weights.reduce((acc, w, i) => acc + w * Number(digits[i]), 0);
|
|
100
|
+
const checksum = (11 - (sum % 11)) % 11;
|
|
101
|
+
if (checksum !== Number(digits[10])) {
|
|
102
|
+
return { points: 30, reason: "CUIT checksum invalid" };
|
|
103
|
+
}
|
|
104
|
+
return { points: -10, reason: "CUIT validates" };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function scorePayerHistory(payerEmail: string): Promise<Signal[]> {
|
|
108
|
+
const recent: Payment[] = [];
|
|
109
|
+
let count = 0;
|
|
110
|
+
for await (const p of paginatePayments(mp, { payerEmail, limit: 50 })) {
|
|
111
|
+
recent.push(p);
|
|
112
|
+
if (++count >= 50) break;
|
|
113
|
+
}
|
|
114
|
+
if (recent.length === 0) {
|
|
115
|
+
return [
|
|
116
|
+
{ points: 5, reason: "First-time payer — no history to score against" },
|
|
117
|
+
];
|
|
118
|
+
}
|
|
119
|
+
const approved = recent.filter((p) => p.status === "approved").length;
|
|
120
|
+
const rejected = recent.filter((p) => p.status === "rejected").length;
|
|
121
|
+
const fraudFlags = recent.filter((p) => {
|
|
122
|
+
const detail = (p as Payment & { status_detail?: string }).status_detail ?? "";
|
|
123
|
+
return (
|
|
124
|
+
detail === "cc_rejected_call_for_authorize" ||
|
|
125
|
+
detail === "cc_rejected_high_risk" ||
|
|
126
|
+
detail === "cc_rejected_blacklist"
|
|
127
|
+
);
|
|
128
|
+
}).length;
|
|
129
|
+
const signals: Signal[] = [];
|
|
130
|
+
if (approved >= 3) {
|
|
131
|
+
signals.push({ points: -15, reason: `${approved} successful past charges` });
|
|
132
|
+
}
|
|
133
|
+
if (rejected >= 5) {
|
|
134
|
+
signals.push({ points: 15, reason: `${rejected} rejected charges in history` });
|
|
135
|
+
}
|
|
136
|
+
if (fraudFlags >= 2) {
|
|
137
|
+
signals.push({
|
|
138
|
+
points: 40,
|
|
139
|
+
reason: `${fraudFlags} fraud-flag rejections (call_for_authorize / high_risk / blacklist)`,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
return signals;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function scoreVelocity(payerEmail: string): Signal | null {
|
|
146
|
+
const attempts = attemptsInLastHour(payerEmail);
|
|
147
|
+
if (attempts === 0) return null;
|
|
148
|
+
if (attempts >= 5) {
|
|
149
|
+
return { points: 50, reason: `${attempts} charge attempts in the last hour` };
|
|
150
|
+
}
|
|
151
|
+
if (attempts >= 3) {
|
|
152
|
+
return { points: 20, reason: `${attempts} charge attempts in the last hour` };
|
|
153
|
+
}
|
|
154
|
+
return { points: 5, reason: `${attempts} prior attempts in the last hour` };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function scoreIssuerPromo(args: {
|
|
158
|
+
paymentMethodId?: string;
|
|
159
|
+
installments?: number;
|
|
160
|
+
}): Signal | null {
|
|
161
|
+
if (!args.paymentMethodId || !args.installments) return null;
|
|
162
|
+
const applicable = AR_ISSUER_PROMOS.filter(
|
|
163
|
+
(p) =>
|
|
164
|
+
p.cardBrand === args.paymentMethodId &&
|
|
165
|
+
args.installments! >= p.installments,
|
|
166
|
+
);
|
|
167
|
+
if (applicable.length > 1) {
|
|
168
|
+
return {
|
|
169
|
+
points: 25,
|
|
170
|
+
reason: `Stacks ${applicable.length} issuer promos — possible abuse`,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
177
|
+
// Combined check
|
|
178
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
export async function runFraudCheck(input: {
|
|
181
|
+
payerEmail: string;
|
|
182
|
+
transactionAmount: number;
|
|
183
|
+
cuit?: string;
|
|
184
|
+
paymentMethodId?: string;
|
|
185
|
+
installments?: number;
|
|
186
|
+
}): Promise<{
|
|
187
|
+
verdict: "approve" | "review" | "reject";
|
|
188
|
+
score: number;
|
|
189
|
+
reasons: string[];
|
|
190
|
+
}> {
|
|
191
|
+
recordAttempt(input.payerEmail);
|
|
192
|
+
|
|
193
|
+
const signals: Signal[] = [];
|
|
194
|
+
|
|
195
|
+
const cuitSignal = scoreCuit(input.cuit);
|
|
196
|
+
if (cuitSignal) signals.push(cuitSignal);
|
|
197
|
+
|
|
198
|
+
signals.push(...(await scorePayerHistory(input.payerEmail)));
|
|
199
|
+
|
|
200
|
+
const velocity = scoreVelocity(input.payerEmail);
|
|
201
|
+
if (velocity) signals.push(velocity);
|
|
202
|
+
|
|
203
|
+
const promo = scoreIssuerPromo(input);
|
|
204
|
+
if (promo) signals.push(promo);
|
|
205
|
+
|
|
206
|
+
// High-value charges get extra scrutiny (multiplier on accumulated risk).
|
|
207
|
+
const score = signals.reduce((acc, s) => acc + s.points, 0);
|
|
208
|
+
const adjusted = input.transactionAmount > 100_000 ? score * 1.5 : score;
|
|
209
|
+
|
|
210
|
+
let verdict: "approve" | "review" | "reject";
|
|
211
|
+
if (adjusted >= 60) verdict = "reject";
|
|
212
|
+
else if (adjusted >= 25) verdict = "review";
|
|
213
|
+
else verdict = "approve";
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
verdict,
|
|
217
|
+
score: Math.round(adjusted),
|
|
218
|
+
reasons: signals.map((s) => `${s.points >= 0 ? "+" : ""}${s.points}: ${s.reason}`),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
223
|
+
// Example wired into a charge flow
|
|
224
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
export async function chargeWithFraudCheck(input: {
|
|
227
|
+
payerEmail: string;
|
|
228
|
+
transactionAmount: number;
|
|
229
|
+
cuit?: string;
|
|
230
|
+
paymentMethodId: string;
|
|
231
|
+
cardToken: string;
|
|
232
|
+
installments: number;
|
|
233
|
+
externalReference: string;
|
|
234
|
+
}) {
|
|
235
|
+
const fraud = await runFraudCheck({
|
|
236
|
+
payerEmail: input.payerEmail,
|
|
237
|
+
transactionAmount: input.transactionAmount,
|
|
238
|
+
cuit: input.cuit,
|
|
239
|
+
paymentMethodId: input.paymentMethodId,
|
|
240
|
+
installments: input.installments,
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
if (fraud.verdict === "reject") {
|
|
244
|
+
throw new Error(
|
|
245
|
+
`Fraud check rejected (score ${fraud.score}): ${fraud.reasons.join("; ")}`,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (fraud.verdict === "review") {
|
|
250
|
+
// In production: route to a human reviewer queue. For this recipe, we log.
|
|
251
|
+
console.warn(
|
|
252
|
+
`[fraud-review] score ${fraud.score} for ${input.payerEmail}: ${fraud.reasons.join("; ")}`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Charge proceeds (the fraud signals are attached as metadata for audit).
|
|
257
|
+
return await mp.createPayment({
|
|
258
|
+
transactionAmount: input.transactionAmount,
|
|
259
|
+
paymentMethodId: input.paymentMethodId,
|
|
260
|
+
payerEmail: input.payerEmail,
|
|
261
|
+
token: input.cardToken,
|
|
262
|
+
installments: input.installments,
|
|
263
|
+
externalReference: input.externalReference,
|
|
264
|
+
metadata: {
|
|
265
|
+
fraud_score: fraud.score,
|
|
266
|
+
fraud_verdict: fraud.verdict,
|
|
267
|
+
fraud_reasons: fraud.reasons,
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recipe 14 — Marketplace seller onboarding flow.
|
|
3
|
+
*
|
|
4
|
+
* The end-to-end flow for connecting a seller's MP account to your platform:
|
|
5
|
+
*
|
|
6
|
+
* 1. **CUIT validation** (algorithm-only, free)
|
|
7
|
+
* 2. **AFIP padron lookup** (@ar-agents/identity, adapter-required) —
|
|
8
|
+
* verifies the CUIT exists, resolves the legal name + IVA condition.
|
|
9
|
+
* Bonus: detects monotributo category, which determines the maximum
|
|
10
|
+
* monthly invoice amount and informs your platform's tier rules.
|
|
11
|
+
* 3. **OAuth redirect** — generate the MP marketplace OAuth URL with PKCE
|
|
12
|
+
* and your platform's redirect_uri.
|
|
13
|
+
* 4. **OAuth callback** — exchange the auth code for access + refresh
|
|
14
|
+
* tokens. Persist them keyed by your internal seller-id.
|
|
15
|
+
* 5. **First test charge** — bill a tiny token amount ($1 ARS) to verify
|
|
16
|
+
* the OAuth chain works end-to-end before the seller's first real sale.
|
|
17
|
+
* 6. **Marketplace fee setup** — compute platform fee on subsequent
|
|
18
|
+
* charges using `computeMarketplaceFee`.
|
|
19
|
+
*
|
|
20
|
+
* # State
|
|
21
|
+
*
|
|
22
|
+
* Use `VercelKVOAuthTokenStore` for OAuth token persistence in production.
|
|
23
|
+
* In-memory in this recipe.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
MercadoPagoClient,
|
|
28
|
+
computeMarketplaceFee,
|
|
29
|
+
type OAuthTokens,
|
|
30
|
+
} from "@ar-agents/mercadopago";
|
|
31
|
+
|
|
32
|
+
const platformMp = new MercadoPagoClient({
|
|
33
|
+
accessToken: process.env.MP_ACCESS_TOKEN!, // your platform's APP_USR-... token
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
37
|
+
// Token store — replace with VercelKVOAuthTokenStore in prod
|
|
38
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
const tokenStore = new Map<string, OAuthTokens>();
|
|
41
|
+
|
|
42
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
43
|
+
// Step 1: CUIT validation
|
|
44
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
export function validateCuitForOnboarding(cuit: string): {
|
|
47
|
+
ok: boolean;
|
|
48
|
+
formatted?: string;
|
|
49
|
+
error?: string;
|
|
50
|
+
} {
|
|
51
|
+
const digits = cuit.replace(/[^\d]/g, "");
|
|
52
|
+
if (digits.length !== 11) {
|
|
53
|
+
return { ok: false, error: "CUIT must have 11 digits" };
|
|
54
|
+
}
|
|
55
|
+
const weights = [5, 4, 3, 2, 7, 6, 5, 4, 3, 2];
|
|
56
|
+
const sum = weights.reduce((a, w, i) => a + w * Number(digits[i]), 0);
|
|
57
|
+
const expected = (11 - (sum % 11)) % 11;
|
|
58
|
+
if (expected !== Number(digits[10])) {
|
|
59
|
+
return { ok: false, error: "CUIT checksum invalid" };
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
ok: true,
|
|
63
|
+
formatted: `${digits.slice(0, 2)}-${digits.slice(2, 10)}-${digits.slice(10)}`,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
68
|
+
// Step 2: AFIP padron lookup (skipped if @ar-agents/identity isn't wired)
|
|
69
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Calls `@ar-agents/identity`'s lookup_cuit_afip if available. Returns null
|
|
73
|
+
* if the package isn't installed (recipe stays runnable without AFIP creds).
|
|
74
|
+
*/
|
|
75
|
+
export async function lookupSellerAtAfip(cuit: string): Promise<{
|
|
76
|
+
legalName: string;
|
|
77
|
+
ivaCondition: string;
|
|
78
|
+
monotributoCategory: string | null;
|
|
79
|
+
} | null> {
|
|
80
|
+
try {
|
|
81
|
+
// Dynamic import so the recipe compiles without the optional dep.
|
|
82
|
+
const { WsaaWscdcAfipPadronAdapter } = await import("@ar-agents/identity");
|
|
83
|
+
if (
|
|
84
|
+
!process.env.AFIP_CERT_PEM ||
|
|
85
|
+
!process.env.AFIP_KEY_PEM ||
|
|
86
|
+
!process.env.AFIP_CUIT
|
|
87
|
+
) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
const adapter = new WsaaWscdcAfipPadronAdapter({
|
|
91
|
+
certPem: process.env.AFIP_CERT_PEM,
|
|
92
|
+
keyPem: process.env.AFIP_KEY_PEM,
|
|
93
|
+
cuitRepresentado: process.env.AFIP_CUIT,
|
|
94
|
+
env: "prod",
|
|
95
|
+
});
|
|
96
|
+
const result = await adapter.lookup({ cuit });
|
|
97
|
+
if (!result.available || !result.data) return null;
|
|
98
|
+
return {
|
|
99
|
+
legalName: result.data.razonSocial ?? result.data.nombre ?? "—",
|
|
100
|
+
ivaCondition: result.data.condicionIva ?? "—",
|
|
101
|
+
monotributoCategory: result.data.monotributoCategoria ?? null,
|
|
102
|
+
};
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
109
|
+
// Step 3: OAuth redirect URL
|
|
110
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
export function buildSellerOauthUrl(args: {
|
|
113
|
+
internalSellerId: string;
|
|
114
|
+
redirectUri: string;
|
|
115
|
+
}): string {
|
|
116
|
+
const params = new URLSearchParams({
|
|
117
|
+
client_id: process.env.MP_CLIENT_ID!,
|
|
118
|
+
response_type: "code",
|
|
119
|
+
platform_id: "mp",
|
|
120
|
+
state: args.internalSellerId, // round-tripped back to your callback
|
|
121
|
+
redirect_uri: args.redirectUri,
|
|
122
|
+
});
|
|
123
|
+
return `https://auth.mercadopago.com.ar/authorization?${params.toString()}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
127
|
+
// Step 4: OAuth callback handler
|
|
128
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
export async function handleSellerOauthCallback(req: Request): Promise<{
|
|
131
|
+
internalSellerId: string;
|
|
132
|
+
mpSellerId: string;
|
|
133
|
+
}> {
|
|
134
|
+
const url = new URL(req.url);
|
|
135
|
+
const code = url.searchParams.get("code");
|
|
136
|
+
const internalSellerId = url.searchParams.get("state"); // your id, round-tripped
|
|
137
|
+
|
|
138
|
+
if (!code || !internalSellerId) {
|
|
139
|
+
throw new Error("Missing code or state in OAuth callback");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Exchange the code for tokens. The MercadoPagoClient OAuth methods do this:
|
|
143
|
+
const tokens = await platformMp.exchangeOAuthCode({
|
|
144
|
+
code,
|
|
145
|
+
clientSecret: process.env.MP_CLIENT_SECRET!,
|
|
146
|
+
redirectUri: process.env.MP_REDIRECT_URI!,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
tokenStore.set(internalSellerId, tokens);
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
internalSellerId,
|
|
153
|
+
mpSellerId: tokens.user_id,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
158
|
+
// Step 5: Test charge to verify the OAuth chain
|
|
159
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
export async function runFirstTestCharge(args: {
|
|
162
|
+
internalSellerId: string;
|
|
163
|
+
payerEmail: string;
|
|
164
|
+
cardToken: string;
|
|
165
|
+
}): Promise<{ ok: boolean; paymentId?: string; reason?: string }> {
|
|
166
|
+
const tokens = tokenStore.get(args.internalSellerId);
|
|
167
|
+
if (!tokens) return { ok: false, reason: "OAuth tokens not found" };
|
|
168
|
+
|
|
169
|
+
// Use the seller's access_token to charge ON BEHALF OF the seller.
|
|
170
|
+
const sellerMp = new MercadoPagoClient({ accessToken: tokens.access_token });
|
|
171
|
+
|
|
172
|
+
const payment = await sellerMp.createPayment({
|
|
173
|
+
transactionAmount: 1, // $1 ARS sentinel amount
|
|
174
|
+
paymentMethodId: "visa",
|
|
175
|
+
payerEmail: args.payerEmail,
|
|
176
|
+
token: args.cardToken,
|
|
177
|
+
description: "Marketplace onboarding test charge",
|
|
178
|
+
externalReference: `onboarding-${args.internalSellerId}`,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
if (payment.status === "approved") {
|
|
182
|
+
return { ok: true, paymentId: String(payment.id) };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const detail = (payment as typeof payment & { status_detail?: string }).status_detail;
|
|
186
|
+
return {
|
|
187
|
+
ok: false,
|
|
188
|
+
reason: `Test charge ${payment.status}: ${detail ?? "unknown"}`,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
193
|
+
// Step 6: Charge with marketplace fee
|
|
194
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
export async function chargeWithMarketplaceFee(args: {
|
|
197
|
+
internalSellerId: string;
|
|
198
|
+
payerEmail: string;
|
|
199
|
+
cardToken: string;
|
|
200
|
+
amount: number;
|
|
201
|
+
description: string;
|
|
202
|
+
feePct: number; // e.g. 5 for 5%
|
|
203
|
+
}) {
|
|
204
|
+
const tokens = tokenStore.get(args.internalSellerId);
|
|
205
|
+
if (!tokens) throw new Error("Seller not onboarded");
|
|
206
|
+
|
|
207
|
+
const sellerMp = new MercadoPagoClient({ accessToken: tokens.access_token });
|
|
208
|
+
|
|
209
|
+
const fee = computeMarketplaceFee(args.amount, { percent: args.feePct });
|
|
210
|
+
|
|
211
|
+
return await sellerMp.createPayment({
|
|
212
|
+
transactionAmount: args.amount,
|
|
213
|
+
applicationFee: fee, // your platform's cut (gets credited to your account)
|
|
214
|
+
paymentMethodId: "visa",
|
|
215
|
+
payerEmail: args.payerEmail,
|
|
216
|
+
token: args.cardToken,
|
|
217
|
+
description: args.description,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recipe 15 — Prorated subscription pause/resume.
|
|
3
|
+
*
|
|
4
|
+
* MP's pause API freezes recurring charges but doesn't auto-prorate the
|
|
5
|
+
* unused period the customer already paid for. If you offer a "pause your
|
|
6
|
+
* subscription, resume next month" feature, you need to:
|
|
7
|
+
*
|
|
8
|
+
* 1. Compute how many days are left in the current billing period.
|
|
9
|
+
* 2. Refund a prorated amount for those unused days (or store as credit).
|
|
10
|
+
* 3. Pause the subscription on MP.
|
|
11
|
+
* 4. On resume, adjust the next-billing date so the customer doesn't
|
|
12
|
+
* double-pay for the period they were paused.
|
|
13
|
+
*
|
|
14
|
+
* # Why this matters
|
|
15
|
+
*
|
|
16
|
+
* Without proration, customers who pause feel ripped off ("I paid for a
|
|
17
|
+
* month and you only gave me 10 days"). With proration but no resume
|
|
18
|
+
* adjustment, you bill them again immediately on resume. Both kill retention.
|
|
19
|
+
*
|
|
20
|
+
* # The math
|
|
21
|
+
*
|
|
22
|
+
* billingPeriodStart ─────────────► billingPeriodEnd
|
|
23
|
+
* │ pausedAt
|
|
24
|
+
* └─ daysUnused = (end - pausedAt) / 86400000
|
|
25
|
+
* prorated = monthlyAmount * (daysUnused / daysInPeriod)
|
|
26
|
+
*
|
|
27
|
+
* On resume, the next charge happens after `daysUnused` from now (instead
|
|
28
|
+
* of the original schedule).
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
MercadoPagoClient,
|
|
33
|
+
type Preapproval,
|
|
34
|
+
} from "@ar-agents/mercadopago";
|
|
35
|
+
|
|
36
|
+
const mp = new MercadoPagoClient({
|
|
37
|
+
accessToken: process.env.MP_ACCESS_TOKEN!,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
41
|
+
// State for tracking pause history
|
|
42
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
type PauseRecord = {
|
|
45
|
+
subscriptionId: string;
|
|
46
|
+
pausedAt: number; // unix ms
|
|
47
|
+
unusedDays: number;
|
|
48
|
+
proratedRefund: number;
|
|
49
|
+
refundId: string | null;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const pauseStore = new Map<string, PauseRecord>();
|
|
53
|
+
|
|
54
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
55
|
+
// Pause with proration
|
|
56
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
export async function pauseSubscriptionWithProration(args: {
|
|
59
|
+
subscriptionId: string;
|
|
60
|
+
/** When pause is requested. Defaults to now. */
|
|
61
|
+
pausedAt?: Date;
|
|
62
|
+
/**
|
|
63
|
+
* If true, refund the unused-period amount to the customer's payment
|
|
64
|
+
* method. If false, store the credit and apply on resume.
|
|
65
|
+
*/
|
|
66
|
+
refundUnused: boolean;
|
|
67
|
+
}): Promise<{
|
|
68
|
+
unusedDays: number;
|
|
69
|
+
proratedAmount: number;
|
|
70
|
+
refundId: string | null;
|
|
71
|
+
}> {
|
|
72
|
+
const pausedAtMs = (args.pausedAt ?? new Date()).getTime();
|
|
73
|
+
const sub = await mp.getPreapproval(args.subscriptionId);
|
|
74
|
+
|
|
75
|
+
// Find the most recent successful charge to know when the current period
|
|
76
|
+
// started, and the next_payment_date to know when it would have ended.
|
|
77
|
+
const billingPeriodStart = sub.last_modified
|
|
78
|
+
? new Date(sub.last_modified).getTime()
|
|
79
|
+
: pausedAtMs - 30 * 86_400_000; // fallback: 30 days ago
|
|
80
|
+
|
|
81
|
+
// MP doesn't always populate `next_payment_date` — derive from the
|
|
82
|
+
// recurrence config when missing.
|
|
83
|
+
const billingPeriodEnd = computePeriodEnd(sub, billingPeriodStart);
|
|
84
|
+
|
|
85
|
+
const totalDays = (billingPeriodEnd - billingPeriodStart) / 86_400_000;
|
|
86
|
+
const usedDays = (pausedAtMs - billingPeriodStart) / 86_400_000;
|
|
87
|
+
const unusedDays = Math.max(0, totalDays - usedDays);
|
|
88
|
+
|
|
89
|
+
const monthlyAmount = sub.auto_recurring?.transaction_amount ?? 0;
|
|
90
|
+
const proratedAmount = Math.round(
|
|
91
|
+
(monthlyAmount * unusedDays) / Math.max(totalDays, 1),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
let refundId: string | null = null;
|
|
95
|
+
if (args.refundUnused && proratedAmount > 0) {
|
|
96
|
+
// Find the most recent charge under this subscription to refund against.
|
|
97
|
+
const recent = await mp.listSubscriptionPayments(args.subscriptionId, {
|
|
98
|
+
limit: 5,
|
|
99
|
+
});
|
|
100
|
+
const lastApproved = recent.results.find((p) => p.status === "approved");
|
|
101
|
+
if (lastApproved?.payment_id) {
|
|
102
|
+
const refund = await mp.createRefund({
|
|
103
|
+
payment_id: String(lastApproved.payment_id),
|
|
104
|
+
amount: proratedAmount,
|
|
105
|
+
});
|
|
106
|
+
refundId = String(refund.id);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Now actually pause MP-side.
|
|
111
|
+
await mp.pausePreapproval(args.subscriptionId);
|
|
112
|
+
|
|
113
|
+
pauseStore.set(args.subscriptionId, {
|
|
114
|
+
subscriptionId: args.subscriptionId,
|
|
115
|
+
pausedAt: pausedAtMs,
|
|
116
|
+
unusedDays,
|
|
117
|
+
proratedRefund: proratedAmount,
|
|
118
|
+
refundId,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
return { unusedDays, proratedAmount, refundId };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
125
|
+
// Resume with adjusted next-billing date
|
|
126
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
export async function resumeSubscriptionWithAdjustment(
|
|
129
|
+
subscriptionId: string,
|
|
130
|
+
): Promise<{
|
|
131
|
+
resumedAt: Date;
|
|
132
|
+
nextBillingDate: Date;
|
|
133
|
+
creditAppliedFromPause: number;
|
|
134
|
+
}> {
|
|
135
|
+
const record = pauseStore.get(subscriptionId);
|
|
136
|
+
if (!record) {
|
|
137
|
+
// No pause record — just resume normally; MP picks up its own schedule.
|
|
138
|
+
await mp.resumePreapproval(subscriptionId);
|
|
139
|
+
return {
|
|
140
|
+
resumedAt: new Date(),
|
|
141
|
+
nextBillingDate: new Date(Date.now() + 30 * 86_400_000), // approximate
|
|
142
|
+
creditAppliedFromPause: 0,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Resume the subscription. MP's resume sets the next charge to "tomorrow"
|
|
147
|
+
// by default; we want to delay by the unused-days credit.
|
|
148
|
+
await mp.resumePreapproval(subscriptionId);
|
|
149
|
+
|
|
150
|
+
const resumedAt = new Date();
|
|
151
|
+
const adjustedNextBilling = new Date(
|
|
152
|
+
resumedAt.getTime() + record.unusedDays * 86_400_000,
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
// MP doesn't expose a way to push the next charge date directly via the
|
|
156
|
+
// public API. The pragmatic workaround: cancel the subscription's first
|
|
157
|
+
// post-resume charge from your webhook handler when it fires, having
|
|
158
|
+
// already credited the customer the prorated amount.
|
|
159
|
+
//
|
|
160
|
+
// Alternative: schedule a Vercel Cron at adjustedNextBilling that resumes
|
|
161
|
+
// the original subscription cleanly and skips the autopay-on-resume.
|
|
162
|
+
|
|
163
|
+
pauseStore.delete(subscriptionId);
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
resumedAt,
|
|
167
|
+
nextBillingDate: adjustedNextBilling,
|
|
168
|
+
creditAppliedFromPause: record.proratedRefund,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
173
|
+
// Period-end helper
|
|
174
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
function computePeriodEnd(sub: Preapproval, periodStartMs: number): number {
|
|
177
|
+
const freq = sub.auto_recurring?.frequency ?? 1;
|
|
178
|
+
const type = sub.auto_recurring?.frequency_type ?? "months";
|
|
179
|
+
if (type === "days") return periodStartMs + freq * 86_400_000;
|
|
180
|
+
if (type === "months") return periodStartMs + freq * 30 * 86_400_000;
|
|
181
|
+
return periodStartMs + 30 * 86_400_000;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
185
|
+
// Dry-run test
|
|
186
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
async function main() {
|
|
189
|
+
const SUB_ID = process.argv[2];
|
|
190
|
+
if (!SUB_ID) {
|
|
191
|
+
console.log("Usage: pnpm tsx 15-prorated-pause-resume.ts <subscription-id>");
|
|
192
|
+
process.exit(1);
|
|
193
|
+
}
|
|
194
|
+
const result = await pauseSubscriptionWithProration({
|
|
195
|
+
subscriptionId: SUB_ID,
|
|
196
|
+
refundUnused: true,
|
|
197
|
+
});
|
|
198
|
+
console.log("Paused:", result);
|
|
199
|
+
|
|
200
|
+
// ... (manually wait, then resume)
|
|
201
|
+
|
|
202
|
+
const resumed = await resumeSubscriptionWithAdjustment(SUB_ID);
|
|
203
|
+
console.log("Resumed:", resumed);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (process.argv[1]?.endsWith("15-prorated-pause-resume.ts")) {
|
|
207
|
+
main().catch((err: unknown) => {
|
|
208
|
+
console.error(err);
|
|
209
|
+
process.exit(1);
|
|
210
|
+
});
|
|
211
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recipe 16 — ACP checkout with auto-issued AR factura electrónica.
|
|
3
|
+
*
|
|
4
|
+
* The headline pattern that no other implementation in LATAM ships out of the
|
|
5
|
+
* box: a **Stripe-style hosted checkout where the buyer is an LLM agent**
|
|
6
|
+
* (ChatGPT Instant Checkout / Claude tool calls / Gemini extensions), and your
|
|
7
|
+
* server auto-emits AFIP/ARCA factura electrónica when the payment confirms.
|
|
8
|
+
*
|
|
9
|
+
* # The flow
|
|
10
|
+
*
|
|
11
|
+
* 1. Agent POSTs `/checkout/sessions` with cart + buyer info (ACP spec).
|
|
12
|
+
* 2. Bridge validates the cart, computes totals, generates a session id.
|
|
13
|
+
* 3. Bridge creates a Mercado Pago `preference` and stores the mapping
|
|
14
|
+
* `acp_session → mp_preference` in your KV.
|
|
15
|
+
* 4. Bridge returns the ACP session with the `init_point_url` for the buyer.
|
|
16
|
+
* 5. Buyer pays. MP fires `payment.created` webhook.
|
|
17
|
+
* 6. Bridge's MP webhook handler dispatches to your `facturacionHook`,
|
|
18
|
+
* which calls `@ar-agents/facturacion` to emit Factura A/B/C/E based on
|
|
19
|
+
* the buyer's IVA condition (looked up via @ar-agents/identity).
|
|
20
|
+
* 7. ACP `complete_session` returns the factura PDF URL inside the receipt.
|
|
21
|
+
*
|
|
22
|
+
* # Why this is unique
|
|
23
|
+
*
|
|
24
|
+
* - Stripe's ACP doesn't ship AR factura (Stripe doesn't operate in AR).
|
|
25
|
+
* - Satsuma.ai's "make-my-site-agent-compatible" SaaS handles the agent
|
|
26
|
+
* surface but defers tax to the merchant.
|
|
27
|
+
* - MercadoPago's official MCP exposes payments but no ACP layer.
|
|
28
|
+
* - This recipe is the only OSS path from "agent buys" to "factura emitted"
|
|
29
|
+
* without the merchant writing tax-emission code themselves.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { createDispatcher } from "@ar-agents/agentic-commerce-bridge";
|
|
33
|
+
import {
|
|
34
|
+
createMercadoPagoPaymentProvider,
|
|
35
|
+
mercadoPagoPaymentHandler,
|
|
36
|
+
} from "@ar-agents/agentic-commerce-bridge/mp";
|
|
37
|
+
import {
|
|
38
|
+
createFacturacionHook,
|
|
39
|
+
selectFacturaType,
|
|
40
|
+
} from "@ar-agents/agentic-commerce-bridge/facturacion";
|
|
41
|
+
import { InMemoryStateAdapter } from "@ar-agents/agentic-commerce-bridge";
|
|
42
|
+
|
|
43
|
+
import { MercadoPagoClient } from "@ar-agents/mercadopago";
|
|
44
|
+
import { WsfeClient } from "@ar-agents/facturacion";
|
|
45
|
+
|
|
46
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
47
|
+
// Wire the bridge — payment provider + factura emission hook
|
|
48
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
const mp = new MercadoPagoClient({
|
|
51
|
+
accessToken: process.env.MP_ACCESS_TOKEN!,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const wsfe = new WsfeClient({
|
|
55
|
+
certPem: process.env.AFIP_CERT_PEM!,
|
|
56
|
+
keyPem: process.env.AFIP_KEY_PEM!,
|
|
57
|
+
cuit: Number(process.env.AFIP_CUIT!),
|
|
58
|
+
env: "prod",
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Payment provider: knows how to mint MP preferences from ACP cart payloads.
|
|
62
|
+
const mpProvider = createMercadoPagoPaymentProvider({
|
|
63
|
+
client: mp,
|
|
64
|
+
notificationUrl: "https://yourdomain.com/api/mp/webhook",
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Facturacion hook: fires after `payment.approved` from MP webhook.
|
|
68
|
+
const facturacionHook = createFacturacionHook({
|
|
69
|
+
wsfe,
|
|
70
|
+
defaultPtoVta: 1, // your AFIP point-of-sale
|
|
71
|
+
// Agent decides what to bill against — typically a CUIT lookup result
|
|
72
|
+
// for B2B sales, or "consumidor final" for B2C (Factura B).
|
|
73
|
+
selectType: ({ buyer }) =>
|
|
74
|
+
selectFacturaType({
|
|
75
|
+
sellerCondition: "responsable_inscripto", // your IVA condition
|
|
76
|
+
buyerCondition: buyer.taxStatus ?? "consumidor_final",
|
|
77
|
+
}),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Dispatcher: routes the ACP HTTP surface (checkout-session CRUD).
|
|
81
|
+
const dispatcher = createDispatcher({
|
|
82
|
+
state: new InMemoryStateAdapter(), // VercelKVStateAdapter in prod
|
|
83
|
+
payment: mpProvider,
|
|
84
|
+
hooks: { onPaymentApproved: facturacionHook },
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
88
|
+
// Next.js Route Handler — the ACP HTTP surface
|
|
89
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
export async function POST(req: Request, ctx: { params: { route: string[] } }) {
|
|
92
|
+
// Spec endpoints:
|
|
93
|
+
// POST /checkout_sessions → handleCreateSession
|
|
94
|
+
// POST /checkout_sessions/{id} → handleUpdateSession
|
|
95
|
+
// POST /checkout_sessions/{id}/complete → handleCompleteSession
|
|
96
|
+
// POST /checkout_sessions/{id}/cancel → handleCancelSession
|
|
97
|
+
return dispatcher.handle(req);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function GET(req: Request) {
|
|
101
|
+
// Spec endpoints:
|
|
102
|
+
// GET /checkout_sessions/{id} → handleGetSession
|
|
103
|
+
// GET /.well-known/acp.json → discovery payload
|
|
104
|
+
return dispatcher.handle(req);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
108
|
+
// Companion MP webhook route — fires the facturacion hook
|
|
109
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
export async function MP_WEBHOOK(req: Request) {
|
|
112
|
+
// The bridge's MP integration verifies HMAC signatures and dispatches
|
|
113
|
+
// to your `onPaymentApproved` hook. The hook receives the ACP session
|
|
114
|
+
// (looked up via the external_reference round-trip) + the MP payment.
|
|
115
|
+
return mercadoPagoPaymentHandler({
|
|
116
|
+
state: dispatcher.state,
|
|
117
|
+
hooks: dispatcher.hooks,
|
|
118
|
+
webhookSecret: process.env.MP_WEBHOOK_SECRET!,
|
|
119
|
+
})(req);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
123
|
+
// What an agent sees
|
|
124
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
/*
|
|
127
|
+
A ChatGPT Instant Checkout flow (excerpt):
|
|
128
|
+
|
|
129
|
+
1. Agent calls `POST /checkout_sessions` with:
|
|
130
|
+
{
|
|
131
|
+
"buyer": { "email": "buyer@example.com" },
|
|
132
|
+
"items": [{ "id": "sku-123", "quantity": 1, "amount": 100000 }],
|
|
133
|
+
"totals": { "amount": 100000, "currency": "ars", ... },
|
|
134
|
+
"fulfillment_address": { ... }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
2. Bridge returns:
|
|
138
|
+
{
|
|
139
|
+
"id": "cs_abc123",
|
|
140
|
+
"status": "ready_for_payment",
|
|
141
|
+
"payment_method": {
|
|
142
|
+
"type": "redirect",
|
|
143
|
+
"redirect_url": "https://www.mercadopago.com.ar/checkout/v1/redirect?pref_id=…"
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
3. Buyer pays. MP fires webhook → facturacionHook fires → AFIP returns CAE.
|
|
148
|
+
|
|
149
|
+
4. Agent calls `POST /checkout_sessions/cs_abc123/complete`:
|
|
150
|
+
{
|
|
151
|
+
"id": "cs_abc123",
|
|
152
|
+
"status": "complete",
|
|
153
|
+
"receipt": {
|
|
154
|
+
"payment_id": "mp-payment-id",
|
|
155
|
+
"factura": {
|
|
156
|
+
"type": "B",
|
|
157
|
+
"cae": "75123456789012",
|
|
158
|
+
"cae_due": "2026-05-18",
|
|
159
|
+
"pdf_url": "https://yourdomain.com/facturas/cs_abc123.pdf",
|
|
160
|
+
"amount": 100000,
|
|
161
|
+
"issued_at": "2026-05-08T19:00:00Z"
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
The agent surfaces the factura URL to the buyer in its receipt rendering.
|
|
167
|
+
The merchant did zero tax-emission code — the bridge handled it.
|
|
168
|
+
*/
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recipe 17 — USA-LLC agent operating in Argentina via @ar-agents/* MCP.
|
|
3
|
+
*
|
|
4
|
+
* Pattern: a USA-incorporated agent (ClawBank-formed Wyoming/Ohio LLC, doola
|
|
5
|
+
* Agentic LLC, Marshall Islands MIDAO entity, etc.) needs to do business in
|
|
6
|
+
* Argentina — invoice AR customers, validate AR taxpayer IDs, accept Mercado
|
|
7
|
+
* Pago, ship via Andreani, monitor regulatory changes. The USA agent doesn't
|
|
8
|
+
* itself have AR tax residency or banking infrastructure; it composes with a
|
|
9
|
+
* thin AR-resident "facade" entity (escribano, contador, or platform partner)
|
|
10
|
+
* for the bits that legally require AR presence.
|
|
11
|
+
*
|
|
12
|
+
* # The split
|
|
13
|
+
*
|
|
14
|
+
* USA-LLC agent AR facade (escribano / contador / platform)
|
|
15
|
+
* ----------------- -------------------------------------------
|
|
16
|
+
* - decides what to do - holds the AFIP/ARCA cert
|
|
17
|
+
* - signs payment intent - emits factura under their CUIT
|
|
18
|
+
* - holds USD escrow - converts USD → ARS for settlement
|
|
19
|
+
* - delegates AR ops - acts as the AR-resident contracting party
|
|
20
|
+
*
|
|
21
|
+
* The USA agent never touches AFIP directly. Instead it calls an AR-resident
|
|
22
|
+
* MCP server that exposes @ar-agents/* tools, and that MCP server's keys belong
|
|
23
|
+
* to the AR facade. This is the legally-clean way to operate cross-border:
|
|
24
|
+
* the AR entity is the principal, the USA agent is the platform.
|
|
25
|
+
*
|
|
26
|
+
* # The MCP host config (USA-LLC side)
|
|
27
|
+
*
|
|
28
|
+
* The USA agent (Claude Desktop / Cursor / a custom MCP host) declares:
|
|
29
|
+
*
|
|
30
|
+
* {
|
|
31
|
+
* "mcpServers": {
|
|
32
|
+
* "ar-ops": {
|
|
33
|
+
* "command": "npx",
|
|
34
|
+
* "args": ["-y", "@ar-agents/mcp"],
|
|
35
|
+
* "env": {
|
|
36
|
+
* // ALL of these belong to the AR facade — never the USA agent.
|
|
37
|
+
* "MP_ACCESS_TOKEN": "APP_USR-…", // facade's MP merchant token
|
|
38
|
+
* "AFIP_CERT_PEM": "-----BEGIN CERTIFICATE-----…",
|
|
39
|
+
* "AFIP_KEY_PEM": "-----BEGIN PRIVATE KEY-----…",
|
|
40
|
+
* "AFIP_CUIT": "30-12345678-9", // facade's CUIT
|
|
41
|
+
* "WHATSAPP_ACCESS_TOKEN": "EAA…" // optional
|
|
42
|
+
* }
|
|
43
|
+
* }
|
|
44
|
+
* }
|
|
45
|
+
* }
|
|
46
|
+
*
|
|
47
|
+
* # What the USA agent can now do
|
|
48
|
+
*
|
|
49
|
+
* - validate_cuit(payerCuit) — algorithm, free, no AR exposure
|
|
50
|
+
* - lookup_cuit_afip(payerCuit) — AR fiscal data, scoped to facade's cert
|
|
51
|
+
* - create_payment(amount, payerEmail, ...) — charges run on facade's MP merchant
|
|
52
|
+
* - emit_factura_b(amount, payerCuit, items) — emitted under facade's CUIT
|
|
53
|
+
* - cotizar_envio_andreani(toCpa, weight) — quote against facade's carrier account
|
|
54
|
+
* - send_whatsapp_text(to, body) — sent from facade's WhatsApp number
|
|
55
|
+
*
|
|
56
|
+
* The USA agent's logic stays in JS; the AR-resident operations stay on the
|
|
57
|
+
* AR side. Both sides see the agreement via signed MCP tool-call records.
|
|
58
|
+
*
|
|
59
|
+
* # Sample agent loop (USA-LLC side, using Vercel AI SDK 6 + MCP client)
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
import { Experimental_Agent as Agent, stepCountIs } from "ai";
|
|
63
|
+
// In a USA agent's project, you'd use the MCP client from `ai` v6 (or `@modelcontextprotocol/sdk`)
|
|
64
|
+
// to connect to the locally-spawned ar-agents MCP server. The agent then sees
|
|
65
|
+
// every @ar-agents/* tool in its tool list.
|
|
66
|
+
//
|
|
67
|
+
// import { experimental_createMCPClient as createMCPClient } from "ai";
|
|
68
|
+
|
|
69
|
+
async function exampleAgentLoop() {
|
|
70
|
+
// ─── Boot the MCP client connection (USA agent → AR facade's MCP server) ─
|
|
71
|
+
// const arOps = await createMCPClient({
|
|
72
|
+
// transport: { type: "stdio", command: "npx", args: ["-y", "@ar-agents/mcp"] },
|
|
73
|
+
// });
|
|
74
|
+
// const tools = await arOps.tools();
|
|
75
|
+
//
|
|
76
|
+
// The 89 + 6 + 2 + 10 + 5 + 6 + 5 = 123 tools across all 7 packages are
|
|
77
|
+
// now available as if they were native to the USA agent.
|
|
78
|
+
|
|
79
|
+
const agent = new Agent({
|
|
80
|
+
model: "anthropic/claude-sonnet-4-6",
|
|
81
|
+
instructions:
|
|
82
|
+
"You are an AI agent incorporated as a Wyoming LLC. To do business in " +
|
|
83
|
+
"Argentina you delegate AR-resident operations to an AR facade via the " +
|
|
84
|
+
"ar-ops MCP server. ALL invoicing, payment collection, taxpayer lookups, " +
|
|
85
|
+
"and shipping in AR go through ar-ops tools. Never store AR credentials " +
|
|
86
|
+
"yourself.",
|
|
87
|
+
// tools, // injected from MCP client
|
|
88
|
+
tools: {} as Record<string, unknown>,
|
|
89
|
+
stopWhen: stepCountIs(10),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// What the agent does behind this prompt:
|
|
93
|
+
// 1. validate_cuit("20-12345678-9") via ar-ops
|
|
94
|
+
// 2. lookup_cuit_afip → "Cliente SRL, Responsable Inscripto"
|
|
95
|
+
// 3. cotizar_envio_andreani(B1842, 0.5kg) → AR$ 4.500
|
|
96
|
+
// 4. create_payment(amount=104500, payerEmail) → init_point_url
|
|
97
|
+
// 5. (after payment confirms) emit_factura_a(104500, payerCuit, ["servicio digital"])
|
|
98
|
+
// 6. send_whatsapp_text(payerPhone, "Listo. Factura A: <pdf-url>")
|
|
99
|
+
const { text } = await agent.generate({
|
|
100
|
+
prompt:
|
|
101
|
+
"Cobrale a un cliente AR (CUIT 20-12345678-9, email contacto@ejemplo.com.ar, " +
|
|
102
|
+
"WhatsApp +5491155555555) USD 100 (≈ AR$ 100.000) por un servicio de consultoría " +
|
|
103
|
+
"+ envío Andreani al CP B1842. Si validás que es Responsable Inscripto, emití " +
|
|
104
|
+
"factura A. Si no, factura B. Mandale el link por WhatsApp cuando esté.",
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
console.log(text);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (process.argv[1]?.endsWith("17-usa-llc-companion.ts")) {
|
|
111
|
+
exampleAgentLoop().catch((err: unknown) => {
|
|
112
|
+
console.error(err);
|
|
113
|
+
process.exit(1);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export { exampleAgentLoop };
|
package/cookbook/README.md
CHANGED
|
@@ -20,6 +20,11 @@ deploy on Vercel as-is.
|
|
|
20
20
|
| 10 | `10-cross-package-billing.ts` | One agent loop, 5 packages: identity + attest + MP + facturacion + WhatsApp |
|
|
21
21
|
| 11 | `11-dunning-sequence.ts` | Failed-payment recovery loop with multi-step dunning + cancel-with-retention |
|
|
22
22
|
| 12 | `12-reconciliation-pipeline.ts` | Daily MP↔internal-DB reconciliation cron with discrepancy report |
|
|
23
|
+
| 13 | `13-anti-fraud-middleware.ts` | Pre-charge heuristics (CUIT, payer history, velocity, promo abuse) |
|
|
24
|
+
| 14 | `14-marketplace-onboarding.ts` | End-to-end seller-onboarding: CUIT → AFIP → OAuth → test charge → fee |
|
|
25
|
+
| 15 | `15-prorated-pause-resume.ts` | Pause with prorated refund, resume with adjusted next-billing date |
|
|
26
|
+
| 16 | `16-acp-checkout-with-factura.ts` | Agentic Commerce Protocol checkout that auto-emits AFIP/ARCA Factura A/B/C/E |
|
|
27
|
+
| 17 | `17-usa-llc-companion.ts` | USA-LLC agent (ClawBank/doola/MIDAO) consuming AR ops via @ar-agents/mcp |
|
|
23
28
|
|
|
24
29
|
## Conventions
|
|
25
30
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ar-agents/mercadopago",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.2",
|
|
4
4
|
"description": "Mercado Pago Agent Toolkit for the Vercel AI SDK 6. 89 typed tools across the agent-relevant Mercado Pago API surface — Subscriptions, Payments, Checkout Pro, Marketplace OAuth, Order Management, Customers, Cards, Cuotas, QR, 3DS, Point devices, Webhooks, Stores+POS, Account/Balance/Settlements, Disputes, Lookups, Bank Accounts. Edge Runtime. Vercel KV adapters. OpenTelemetry. Deterministic idempotency. Programmatic HITL on irreversible ops.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mercadopago",
|