@ar-agents/mercadopago 0.17.0 → 0.17.1
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
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.17.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [`687aa10`](https://github.com/ar-agents/ar-agents/commit/687aa1017a665ed9b3414b9f92db634a9329ac4e) - Add 3 production-grade cookbook recipes:
|
|
8
|
+
|
|
9
|
+
- **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.
|
|
10
|
+
- **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`.
|
|
11
|
+
- **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.
|
|
12
|
+
|
|
13
|
+
12 → 15 cookbook recipes total. README updated.
|
|
14
|
+
|
|
3
15
|
## 0.17.0
|
|
4
16
|
|
|
5
17
|
### 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
|
+
}
|
package/cookbook/README.md
CHANGED
|
@@ -20,6 +20,9 @@ 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 |
|
|
23
26
|
|
|
24
27
|
## Conventions
|
|
25
28
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ar-agents/mercadopago",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.1",
|
|
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",
|