@ar-agents/mercadopago 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Recipe 08 — Recovery patterns: retry, recover stuck payments, handle expirations.
3
+ *
4
+ * # Common stuck states and how to recover
5
+ *
6
+ * 1. **Subscription card expired → recurring charge rejected**
7
+ * Recover by: capture fresh card token from buyer + `update_subscription({ card_token_id })`
8
+ *
9
+ * 2. **Payment stuck in `pending_challenge` (3DS not completed)**
10
+ * Recover by: redirect buyer back to the challenge URL via
11
+ * `analyze_payment_3ds(payment_id).challengeUrl`
12
+ *
13
+ * 3. **Payment in `pending_review_manual` (MP fraud team review)**
14
+ * Recover by: WAIT — MP processes within 24-72h. Don't retry.
15
+ *
16
+ * 4. **Subscription auto-cancelled because first payment failed**
17
+ * Recover by: create a fresh subscription (the original is dead, MP doesn't
18
+ * let you "reactivate" — that's documented in `MercadoPagoPaymentRejectedError`).
19
+ *
20
+ * 5. **`pending_waiting_payment` for cash methods (Rapipago, Pago Fácil)**
21
+ * Recover by: NOTHING — the buyer must complete payment within the
22
+ * timeout (typically 3-5 days). Polling or push-webhooks notify when done.
23
+ *
24
+ * 6. **Webhook arrived but payment not in your DB**
25
+ * Recover by: idempotent upsert via `searchPayments({ external_reference })`
26
+ * instead of trusting the webhook payload alone.
27
+ */
28
+
29
+ import {
30
+ classifyError,
31
+ explainPaymentStatus,
32
+ MercadoPagoClient,
33
+ MercadoPagoPaymentRejectedError,
34
+ type PaymentStatusExplanation,
35
+ } from "@ar-agents/mercadopago";
36
+
37
+ const mp = new MercadoPagoClient({
38
+ accessToken: process.env.MP_ACCESS_TOKEN!,
39
+ });
40
+
41
+ // ─────────────────────────────────────────────────────────────────────────────
42
+ // Pattern 1 — Subscription card swap on rejection
43
+ // ─────────────────────────────────────────────────────────────────────────────
44
+
45
+ export async function recoverFromCardRejection(input: {
46
+ subscriptionId: string;
47
+ buyerWhatsAppNumber: string;
48
+ }) {
49
+ const sub = await mp.getPreapproval(input.subscriptionId);
50
+ if (sub.status !== "paused" && sub.status !== "cancelled") {
51
+ return { ok: true, action: "none" };
52
+ }
53
+
54
+ // Send buyer a link to update their card via MP frontend SDK
55
+ const updateUrl = `https://yourapp.com/billing/update-card?sub=${input.subscriptionId}`;
56
+ // ... send via WhatsApp with the toolkit's whatsappTools ...
57
+
58
+ return {
59
+ ok: false,
60
+ action: "card_swap_required",
61
+ sentTo: input.buyerWhatsAppNumber,
62
+ updateUrl,
63
+ };
64
+ }
65
+
66
+ // ─────────────────────────────────────────────────────────────────────────────
67
+ // Pattern 2 — Recover stuck-pending payment with status explanation
68
+ // ─────────────────────────────────────────────────────────────────────────────
69
+
70
+ export async function inspectStuckPayment(paymentId: string): Promise<{
71
+ paymentId: string;
72
+ status: string;
73
+ explanation: PaymentStatusExplanation;
74
+ nextAction: string;
75
+ }> {
76
+ const payment = await mp.getPayment(paymentId);
77
+ const explanation = explainPaymentStatus(payment);
78
+
79
+ let nextAction = explanation.recommendedAction;
80
+ if (explanation.retryable) {
81
+ nextAction = `Reintentar con otra tarjeta. Razón: ${explanation.summary}`;
82
+ } else if (!explanation.final) {
83
+ nextAction = `Esperar webhook (${explanation.summary}). Sin acción de tu parte.`;
84
+ } else if (explanation.paid) {
85
+ nextAction = `Acreditado. Continuar con flujo posterior.`;
86
+ }
87
+
88
+ return {
89
+ paymentId,
90
+ status: payment.status as string,
91
+ explanation,
92
+ nextAction,
93
+ };
94
+ }
95
+
96
+ // ─────────────────────────────────────────────────────────────────────────────
97
+ // Pattern 3 — Idempotent upsert via search (don't trust webhook payload alone)
98
+ // ─────────────────────────────────────────────────────────────────────────────
99
+
100
+ export async function reconcilePaymentByExternalRef(externalReference: string) {
101
+ // Search MP for ALL payments under this external_reference. There may be
102
+ // multiple if the buyer retried.
103
+ const result = await mp.searchPayments({ external_reference: externalReference });
104
+
105
+ // Find the latest approved one (winning attempt)
106
+ const approved = result.results
107
+ ?.filter((p) => p.status === "approved")
108
+ .sort((a, b) => (b.date_created ?? "").localeCompare(a.date_created ?? ""))[0];
109
+
110
+ if (approved) {
111
+ return { found: true, paymentId: approved.id, amount: approved.transaction_amount };
112
+ }
113
+
114
+ // No approved payment — find the latest attempt (could be pending or rejected)
115
+ const latest = result.results
116
+ ?.sort((a, b) => (b.date_created ?? "").localeCompare(a.date_created ?? ""))[0];
117
+
118
+ return { found: false, lastAttempt: latest };
119
+ }
120
+
121
+ // ─────────────────────────────────────────────────────────────────────────────
122
+ // Pattern 4 — Handle MercadoPagoPaymentRejectedError explicitly
123
+ // ─────────────────────────────────────────────────────────────────────────────
124
+
125
+ export async function chargeWithRetry(input: {
126
+ cardId: string;
127
+ customerId: string;
128
+ amountArs: number;
129
+ cvv: string;
130
+ externalReference: string;
131
+ }): Promise<
132
+ { ok: true; paymentId: string } | { ok: false; reason: string; recoverable: boolean }
133
+ > {
134
+ try {
135
+ const payment = await mp.chargeSavedCard({
136
+ cardId: input.cardId,
137
+ customerId: input.customerId,
138
+ transactionAmount: input.amountArs,
139
+ securityCode: input.cvv,
140
+ payerEmail: "—", // populated server-side from customer
141
+ description: "Recurring charge",
142
+ externalReference: input.externalReference,
143
+ });
144
+ return { ok: true, paymentId: payment.id };
145
+ } catch (err) {
146
+ if (err instanceof MercadoPagoPaymentRejectedError) {
147
+ // The lib's MercadoPagoPaymentRejectedError carries status_detail —
148
+ // use it to drive recovery.
149
+ const detail = (err as MercadoPagoPaymentRejectedError & { statusDetail?: string }).statusDetail;
150
+ const recoverable =
151
+ detail === "cc_rejected_call_for_authorize" ||
152
+ detail === "cc_rejected_insufficient_amount" ||
153
+ detail === "cc_rejected_bad_filled_security_code";
154
+ return { ok: false, reason: detail ?? "rejected", recoverable };
155
+ }
156
+ const classified = classifyError(err);
157
+ throw classified; // Re-throw for ops/observability
158
+ }
159
+ }
160
+
161
+ // ─────────────────────────────────────────────────────────────────────────────
162
+ // Pattern 5 — Cron-driven monitoring (Vercel Cron Job)
163
+ // ─────────────────────────────────────────────────────────────────────────────
164
+
165
+ /**
166
+ * Hypothetical Vercel Cron Job (`vercel.json`):
167
+ * ```json
168
+ * { "crons": [{ "path": "/api/cron/mp-monitor", "schedule": "0 *\/4 * * *" }] }
169
+ * ```
170
+ *
171
+ * Runs every 4 hours; surfaces:
172
+ * - Subscriptions that haven't auto-charged in >35 days (probably broken)
173
+ * - Stuck-pending payments older than 24h (need investigation)
174
+ * - Disputes opened in the last 24h (need response)
175
+ */
176
+ export async function cronMonitorMpHealth() {
177
+ const since = new Date(Date.now() - 24 * 3600 * 1000).toISOString();
178
+ const stuck = await mp.searchPayments({
179
+ status: "pending",
180
+ range: "date_created",
181
+ begin_date: since.slice(0, 10),
182
+ } as never);
183
+
184
+ // Surface to ops via Slack/email/Sentry
185
+ const stuckCount = stuck.results?.length ?? 0;
186
+ if (stuckCount > 5) {
187
+ // alertOps(...)
188
+ }
189
+
190
+ return { stuckCount };
191
+ }
@@ -0,0 +1,36 @@
1
+ # Cookbook — `@ar-agents/mercadopago`
2
+
3
+ Real, copy-pasteable recipes for the most common MP integration flows. Every
4
+ recipe is a self-contained Next.js route handler or agent loop you can
5
+ deploy on Vercel as-is.
6
+
7
+ ## Recipes
8
+
9
+ | # | File | Pattern |
10
+ | --- | --------------------------------- | ----------------------------------------------------------------------- |
11
+ | 01 | `01-checkout-pro-basic.ts` | First-time hosted-checkout sale (Checkout Pro preference + back URLs) |
12
+ | 02 | `02-saas-subscription.ts` | Reusable plan + subscription with first-payment + card swap on failure |
13
+ | 03 | `03-webhook-handler.ts` | Vercel route handler with HMAC verify + auto-fetch + dispatch by topic |
14
+ | 04 | `04-marketplace-split.ts` | OAuth seller link → preference with `marketplace_fee` → reconciliation |
15
+ | 05 | `05-qr-in-store.ts` | Create POS → generate QR → poll status → notify buyer via WhatsApp |
16
+ | 06 | `06-3ds-challenge.ts` | Detect challenge → redirect buyer → recover via webhook |
17
+ | 07 | `07-auth-only-order.ts` | `Order` with manual capture → capture later when service completes |
18
+ | 08 | `08-recovery-patterns.ts` | Retry expired subscriptions, recover stuck-pending payments, etc. |
19
+
20
+ ## Conventions
21
+
22
+ - All recipes assume `MP_ACCESS_TOKEN` is set (TEST- prefix in sandbox).
23
+ - All recipes show the agent path AND the manual-client path side by side
24
+ where relevant.
25
+ - Recipes that need state use `VercelKVSubscriptionStateAdapter` —
26
+ swap for `InMemoryStateAdapter` in tests.
27
+ - Recipes that need OAuth credentials assume `MP_CLIENT_ID` + `MP_CLIENT_SECRET`.
28
+ - Recipes that need webhook secrets assume `MP_WEBHOOK_SECRET`.
29
+ - All `Edge Runtime` compatible (Web Crypto only — no `node:crypto`).
30
+
31
+ ## Deploying as Vercel functions
32
+
33
+ Each recipe is a standalone TypeScript file you can drop into
34
+ `apps/your-app/src/app/api/mp/{route}.ts` (App Router) or
35
+ `apps/your-app/pages/api/mp/{route}.ts` (Pages Router). Add `export const runtime = "edge"` if
36
+ you want Edge Runtime; the toolkit is fully Edge-compatible.
package/dist/index.cjs CHANGED
@@ -1,6 +1,5 @@
1
1
  'use strict';
2
2
 
3
- var crypto = require('crypto');
4
3
  var ai = require('ai');
5
4
  var zod = require('zod');
6
5
 
@@ -1235,6 +1234,54 @@ var MercadoPagoClient = class {
1235
1234
  return { id: intentId, canceled: true };
1236
1235
  }
1237
1236
  };
1237
+
1238
+ // src/crypto.ts
1239
+ var subtle = (() => {
1240
+ const c = globalThis.crypto;
1241
+ if (!c?.subtle) {
1242
+ throw new Error(
1243
+ "@ar-agents/mercadopago: Web Crypto API is not available in this runtime. Use Node 18+, Vercel Edge Runtime, Cloudflare Workers, or any modern browser."
1244
+ );
1245
+ }
1246
+ return c.subtle;
1247
+ })();
1248
+ var encoder = new TextEncoder();
1249
+ async function hmacSha256Hex(secret, message) {
1250
+ const keyMaterial = await subtle.importKey(
1251
+ "raw",
1252
+ encoder.encode(secret),
1253
+ { name: "HMAC", hash: "SHA-256" },
1254
+ false,
1255
+ ["sign"]
1256
+ );
1257
+ const sigBuf = await subtle.sign(
1258
+ "HMAC",
1259
+ keyMaterial,
1260
+ encoder.encode(message)
1261
+ );
1262
+ return bufferToHex(sigBuf);
1263
+ }
1264
+ async function sha256Hex(input) {
1265
+ const digest = await subtle.digest("SHA-256", encoder.encode(input));
1266
+ return bufferToHex(digest);
1267
+ }
1268
+ function timingSafeEqualHex(a, b) {
1269
+ if (a.length !== b.length) return false;
1270
+ let diff = 0;
1271
+ for (let i = 0; i < a.length; i++) {
1272
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
1273
+ }
1274
+ return diff === 0;
1275
+ }
1276
+ function bufferToHex(buf) {
1277
+ const bytes = new Uint8Array(buf);
1278
+ let hex = "";
1279
+ for (let i = 0; i < bytes.length; i++) {
1280
+ const b = bytes[i];
1281
+ hex += (b < 16 ? "0" : "") + b.toString(16);
1282
+ }
1283
+ return hex;
1284
+ }
1238
1285
  zod.z.enum(["MLA", "MLB", "MLM", "MCO", "MLC", "MLU"]);
1239
1286
  var CurrencyIdSchema = zod.z.enum(["ARS", "USD", "BRL", "MXN"]);
1240
1287
  var FrequencyTypeSchema = zod.z.enum(["months", "days"]);
@@ -2102,6 +2149,8 @@ function analyze3DS(payment) {
2102
2149
  description: "No se pudo determinar el estado 3DS \u2014 revisar payment.three_d_secure_mode + payment.status_detail manualmente."
2103
2150
  };
2104
2151
  }
2152
+
2153
+ // src/webhook.ts
2105
2154
  function parseWebhookEvent(body, searchParams) {
2106
2155
  const parseResult = WebhookBodySchema.safeParse(body ?? {});
2107
2156
  const parsedBody = parseResult.success ? parseResult.data : {};
@@ -2117,7 +2166,8 @@ function parseWebhookEvent(body, searchParams) {
2117
2166
  raw: parsedBody
2118
2167
  };
2119
2168
  }
2120
- function verifyWebhookSignature(params) {
2169
+ var DEFAULT_REPLAY_TOLERANCE_SECONDS = 300;
2170
+ async function verifyWebhookSignature(params) {
2121
2171
  if (!params.signatureHeader || !params.requestId) return false;
2122
2172
  const parts = Object.fromEntries(
2123
2173
  params.signatureHeader.split(",").map((segment) => segment.trim().split("="))
@@ -2125,16 +2175,20 @@ function verifyWebhookSignature(params) {
2125
2175
  const ts = parts.ts;
2126
2176
  const v1 = parts.v1;
2127
2177
  if (!ts || !v1) return false;
2178
+ const tolerance = params.replayToleranceSeconds ?? DEFAULT_REPLAY_TOLERANCE_SECONDS;
2179
+ const tsNumber = Number(ts);
2180
+ if (!Number.isFinite(tsNumber)) return false;
2181
+ const ageSeconds = Math.abs(Math.floor(Date.now() / 1e3) - tsNumber);
2182
+ if (ageSeconds > tolerance) return false;
2128
2183
  const manifest = `id:${params.dataId};request-id:${params.requestId};ts:${ts};`;
2129
- const expected = crypto.createHmac("sha256", params.secret).update(manifest).digest("hex");
2130
- if (expected.length !== v1.length) return false;
2131
- return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
2184
+ const expected = await hmacSha256Hex(params.secret, manifest);
2185
+ return timingSafeEqualHex(expected, v1);
2132
2186
  }
2133
2187
 
2134
2188
  // src/tools.ts
2135
- function deterministicIdempotencyKey(...parts) {
2189
+ async function deterministicIdempotencyKey(...parts) {
2136
2190
  const payload = parts.filter((p) => p !== void 0 && p !== null).map(String).join("|");
2137
- return crypto.createHash("sha256").update(payload).digest("hex").slice(0, 32);
2191
+ return (await sha256Hex(payload)).slice(0, 32);
2138
2192
  }
2139
2193
  var DEFAULT_DESCRIPTIONS = {
2140
2194
  // ── Subscriptions ────────────────────────────────────────────────────────
@@ -2390,7 +2444,7 @@ function mercadoPagoTools(client, options) {
2390
2444
  ...options.notificationUrl !== void 0 ? { notificationUrl: options.notificationUrl } : {},
2391
2445
  // Deterministic idempotency key — safe to retry, same inputs always
2392
2446
  // produce the same key (MP dedupes on its side).
2393
- idempotencyKey: deterministicIdempotencyKey(
2447
+ idempotencyKey: await deterministicIdempotencyKey(
2394
2448
  "create_payment",
2395
2449
  input.external_reference ?? input.payer_email,
2396
2450
  input.amount_ars,
@@ -2513,7 +2567,7 @@ function mercadoPagoTools(client, options) {
2513
2567
  const refund = await client.createRefund({
2514
2568
  paymentId: payment_id,
2515
2569
  ...amount_ars !== void 0 ? { amount: amount_ars } : {},
2516
- idempotencyKey: deterministicIdempotencyKey("refund", payment_id, amount_ars ?? "full")
2570
+ idempotencyKey: await deterministicIdempotencyKey("refund", payment_id, amount_ars ?? "full")
2517
2571
  });
2518
2572
  return {
2519
2573
  refund_id: refund.id,
@@ -2779,7 +2833,7 @@ function mercadoPagoTools(client, options) {
2779
2833
  ...input.installments !== void 0 ? { installments: input.installments } : {},
2780
2834
  ...input.external_reference !== void 0 ? { externalReference: input.external_reference } : {},
2781
2835
  ...input.statement_descriptor !== void 0 ? { statementDescriptor: input.statement_descriptor } : {},
2782
- idempotencyKey: deterministicIdempotencyKey(
2836
+ idempotencyKey: await deterministicIdempotencyKey(
2783
2837
  "charge_saved_card",
2784
2838
  input.card_id,
2785
2839
  input.amount_ars,
@@ -3290,7 +3344,7 @@ function mercadoPagoTools(client, options) {
3290
3344
  resource: null
3291
3345
  };
3292
3346
  }
3293
- const verified = verifyWebhookSignature({
3347
+ const verified = await verifyWebhookSignature({
3294
3348
  requestId: request_id_header,
3295
3349
  dataId: event.dataId,
3296
3350
  signatureHeader: signature_header,
@@ -3488,7 +3542,7 @@ function mercadoPagoTools(client, options) {
3488
3542
  if (input.marketplace_fee !== void 0) params.marketplace_fee = input.marketplace_fee;
3489
3543
  if (input.collector_id !== void 0) params.collector_id = input.collector_id;
3490
3544
  const order = await client.createOrder(params, {
3491
- idempotencyKey: deterministicIdempotencyKey(
3545
+ idempotencyKey: await deterministicIdempotencyKey(
3492
3546
  "create_order",
3493
3547
  input.external_reference,
3494
3548
  input.total_amount,
@@ -4052,7 +4106,50 @@ var InMemoryStateAdapter = class {
4052
4106
  this.store.clear();
4053
4107
  }
4054
4108
  };
4109
+ var InMemoryOAuthTokenStore = class {
4110
+ store = /* @__PURE__ */ new Map();
4111
+ async set(userId, token) {
4112
+ this.store.set(userId, token);
4113
+ }
4114
+ async get(userId) {
4115
+ return this.store.get(userId) ?? null;
4116
+ }
4117
+ async delete(userId) {
4118
+ this.store.delete(userId);
4119
+ }
4120
+ async list() {
4121
+ return Array.from(this.store.keys());
4122
+ }
4123
+ /** Test helper. */
4124
+ reset() {
4125
+ this.store.clear();
4126
+ }
4127
+ };
4128
+ var InMemoryIdempotencyCache = class {
4129
+ store = /* @__PURE__ */ new Map();
4130
+ async get(key) {
4131
+ const entry = this.store.get(key);
4132
+ if (!entry) return null;
4133
+ if (Date.now() > entry.expiresAt) {
4134
+ this.store.delete(key);
4135
+ return null;
4136
+ }
4137
+ return entry.value;
4138
+ }
4139
+ async set(key, value, ttlSeconds = 86400) {
4140
+ this.store.set(key, { value, expiresAt: Date.now() + ttlSeconds * 1e3 });
4141
+ }
4142
+ async delete(key) {
4143
+ this.store.delete(key);
4144
+ }
4145
+ /** Test helper. */
4146
+ reset() {
4147
+ this.store.clear();
4148
+ }
4149
+ };
4055
4150
 
4151
+ exports.InMemoryIdempotencyCache = InMemoryIdempotencyCache;
4152
+ exports.InMemoryOAuthTokenStore = InMemoryOAuthTokenStore;
4056
4153
  exports.InMemoryStateAdapter = InMemoryStateAdapter;
4057
4154
  exports.MercadoPagoAccountTypeMismatchError = MercadoPagoAccountTypeMismatchError;
4058
4155
  exports.MercadoPagoAuthError = MercadoPagoAuthError;