@ar-agents/mercadopago 0.6.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.
- package/AGENTS.md +10 -1
- package/CHANGELOG.md +89 -0
- package/README.md +64 -2
- package/cookbook/01-checkout-pro-basic.ts +99 -0
- package/cookbook/02-saas-subscription.ts +137 -0
- package/cookbook/03-webhook-handler.ts +162 -0
- package/cookbook/04-marketplace-split.ts +194 -0
- package/cookbook/05-qr-in-store.ts +142 -0
- package/cookbook/06-3ds-challenge.ts +139 -0
- package/cookbook/07-auth-only-order.ts +127 -0
- package/cookbook/08-recovery-patterns.ts +191 -0
- package/cookbook/README.md +36 -0
- package/dist/index.cjs +1039 -13
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +319 -50
- package/dist/index.d.ts +319 -50
- package/dist/index.js +1036 -14
- package/dist/index.js.map +1 -1
- package/dist/state-C6Wzb_XX.d.cts +106 -0
- package/dist/state-C6Wzb_XX.d.ts +106 -0
- package/dist/vercel-kv.cjs +92 -0
- package/dist/vercel-kv.cjs.map +1 -0
- package/dist/vercel-kv.d.cts +107 -0
- package/dist/vercel-kv.d.ts +107 -0
- package/dist/vercel-kv.js +88 -0
- package/dist/vercel-kv.js.map +1 -0
- package/package.json +27 -4
- package/tools.manifest.json +1 -1
|
@@ -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.
|