@ar-agents/mercadopago 0.16.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 +46 -0
- package/README.md +8 -0
- package/bin/mercadopago.js +13 -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/README.md +3 -0
- package/dist/cli.cjs +341 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.cts +15 -0
- package/dist/cli.d.ts +15 -0
- package/dist/cli.js +338 -0
- package/dist/cli.js.map +1 -0
- package/package.json +5 -1
- package/tools.manifest.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,51 @@
|
|
|
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
|
+
|
|
15
|
+
## 0.17.0
|
|
16
|
+
|
|
17
|
+
### Minor Changes
|
|
18
|
+
|
|
19
|
+
- Add `mercadopago doctor` CLI for environment diagnosis.
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npx @ar-agents/mercadopago doctor
|
|
23
|
+
pnpm exec mercadopago doctor
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Reports:
|
|
27
|
+
|
|
28
|
+
- Node version (must be ≥ 20)
|
|
29
|
+
- `MP_ACCESS_TOKEN` presence + format + sandbox/prod prefix detection
|
|
30
|
+
- Live token validation against `GET /users/me` (free, no charge)
|
|
31
|
+
- `NEXT_PUBLIC_BACK_URL` presence + HTTPS check (MP rejects localhost server-side)
|
|
32
|
+
- `MP_WEBHOOK_SECRET` presence + length sanity
|
|
33
|
+
- Peer-dependency installation: `ai`, `zod`, `@vercel/kv`, `@opentelemetry/api`
|
|
34
|
+
- Tool count grouped by inferred category (auto-derived from `tools.manifest.json`)
|
|
35
|
+
- The 8 irreversible ops gated by `requireConfirmation()`
|
|
36
|
+
|
|
37
|
+
Pass `--probe` to additionally dry-call `validate_tax_id` against your sandbox token (also free):
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npx @ar-agents/mercadopago doctor --probe
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Exit codes follow the convention: `0` = ok or warn-only, `1` = at least one fail. CI scripts can `npx @ar-agents/mercadopago doctor` to gate deploys on having credentials wired up.
|
|
44
|
+
|
|
45
|
+
Also exposes `mercadopago help` and `mercadopago version`. Output respects `NO_COLOR`.
|
|
46
|
+
|
|
47
|
+
10 new subprocess tests (test/cli.test.ts), 328 tests total.
|
|
48
|
+
|
|
3
49
|
## 0.16.0
|
|
4
50
|
|
|
5
51
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -95,6 +95,14 @@ console.log(result.text);
|
|
|
95
95
|
// https://www.mercadopago.com.ar/subscriptions/checkout?preapproval_id=..."
|
|
96
96
|
```
|
|
97
97
|
|
|
98
|
+
## Diagnose your setup
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
npx @ar-agents/mercadopago doctor
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Validates `MP_ACCESS_TOKEN` against the live API, checks peer deps, lists all 89 tools, surfaces the 8 irreversible operations gated by `requireConfirmation`, and warns on common misconfigurations (missing `NEXT_PUBLIC_BACK_URL`, non-HTTPS back URL, suspiciously short webhook secret, trailing-newline tokens). Pass `--probe` to also dry-call `validate_tax_id` against your sandbox. CI-friendly exit codes (`0` ok, `1` fail).
|
|
105
|
+
|
|
98
106
|
## Webhooks
|
|
99
107
|
|
|
100
108
|
MP notifies your endpoint whenever a subscription's status changes. The
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Thin shim: import the bundled CLI and forward argv. Compiled output lives
|
|
3
|
+
// at dist/cli.js. Keeping this file small means we don't have to rebuild it
|
|
4
|
+
// when CLI logic changes — only the bundle changes.
|
|
5
|
+
import { runCli } from "../dist/cli.js";
|
|
6
|
+
|
|
7
|
+
runCli(process.argv).then(
|
|
8
|
+
(code) => process.exit(code),
|
|
9
|
+
(err) => {
|
|
10
|
+
console.error(err);
|
|
11
|
+
process.exit(1);
|
|
12
|
+
},
|
|
13
|
+
);
|
|
@@ -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
|
+
}
|