@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 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
+ }