@ar-agents/mercadopago 0.7.0 → 0.9.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,127 @@
1
+ /**
2
+ * Recipe 07 — Auth-only Order with manual capture (ride-share / hotel pattern).
3
+ *
4
+ * # Use case
5
+ *
6
+ * You want to ESTIMATE the final amount upfront (e.g., taxi ride: max
7
+ * possible cost) but only CAPTURE the actual amount once the service
8
+ * completes. This is the "preauthorization + capture" pattern used by:
9
+ *
10
+ * - Ride-share: authorize at trip start, capture exact amount at end
11
+ * - Hotels: authorize for full stay at check-in, capture nightly
12
+ * - Marketplaces with delivery: authorize at order, capture at delivery
13
+ *
14
+ * # Flow
15
+ *
16
+ * 1. Buyer pays via your app — but it's an Order with `capture_mode: "manual"`
17
+ * 2. The funds are HELD on the buyer's card (auth-only) — they see it as
18
+ * a pending charge
19
+ * 3. When the service completes, you call `capture_order(order_id, amount)`
20
+ * with the FINAL amount (≤ the originally authorized amount)
21
+ * 4. If you don't capture within 7 days, the auth expires automatically
22
+ * (funds released to the buyer)
23
+ * 5. To cancel before capture: `cancel_order(order_id)` releases the auth
24
+ *
25
+ * # Why Order instead of Payment?
26
+ *
27
+ * - Order has explicit lifecycle (created → action_required → processed/canceled)
28
+ * - Order can aggregate multiple Payments (partial captures, retries)
29
+ * - Order is MP's modern API for new flows
30
+ *
31
+ * Use Preference (Checkout Pro) when you just need a hosted pay-link.
32
+ * Use Order when you need this auth-only or multi-payment-per-order semantics.
33
+ */
34
+
35
+ import {
36
+ explainPaymentStatus,
37
+ MercadoPagoClient,
38
+ } from "@ar-agents/mercadopago";
39
+
40
+ const mp = new MercadoPagoClient({
41
+ accessToken: process.env.MP_ACCESS_TOKEN!,
42
+ });
43
+
44
+ // ─────────────────────────────────────────────────────────────────────────────
45
+ // Step 1 — Create the auth-only Order at "service start"
46
+ // ─────────────────────────────────────────────────────────────────────────────
47
+
48
+ export async function authorizeRideStart(input: {
49
+ rideId: string;
50
+ buyerEmail: string;
51
+ estimatedMaxArs: number; // upper bound — what you can capture up to
52
+ }) {
53
+ const order = await mp.createOrder({
54
+ type: "online",
55
+ currency_id: "ARS",
56
+ total_amount: input.estimatedMaxArs,
57
+ external_reference: input.rideId,
58
+ capture_mode: "manual", // <-- THE KEY FIELD
59
+ payer: { email: input.buyerEmail },
60
+ notification_url: "https://yourapp.com/api/mp/webhook",
61
+ });
62
+
63
+ return {
64
+ orderId: order.id,
65
+ status: order.status, // "action_required"
66
+ note: "Funds authorized but not captured. Capture within 7 days or auth expires.",
67
+ };
68
+ }
69
+
70
+ // ─────────────────────────────────────────────────────────────────────────────
71
+ // Step 2 — Capture the exact final amount when service completes
72
+ // ─────────────────────────────────────────────────────────────────────────────
73
+
74
+ export async function captureRideOnComplete(input: {
75
+ orderId: string;
76
+ finalAmountArs: number; // must be ≤ originally authorized amount
77
+ }) {
78
+ const captured = await mp.captureOrder(input.orderId, input.finalAmountArs);
79
+
80
+ if (captured.status !== "processed") {
81
+ // Capture didn't succeed — surface the reason
82
+ throw new Error(
83
+ `Capture failed: status=${captured.status}, status_detail=${captured.status_detail}`,
84
+ );
85
+ }
86
+
87
+ return {
88
+ orderId: captured.id,
89
+ capturedAmount: input.finalAmountArs,
90
+ status: "captured",
91
+ };
92
+ }
93
+
94
+ // ─────────────────────────────────────────────────────────────────────────────
95
+ // Step 3 — Cancel before capture (e.g., buyer cancels the trip)
96
+ // ─────────────────────────────────────────────────────────────────────────────
97
+
98
+ export async function cancelRide(input: { orderId: string }) {
99
+ const canceled = await mp.cancelOrder(input.orderId);
100
+ return {
101
+ orderId: canceled.id,
102
+ status: canceled.status, // "canceled"
103
+ note: "Auth released. Buyer's card is no longer hold.",
104
+ };
105
+ }
106
+
107
+ // ─────────────────────────────────────────────────────────────────────────────
108
+ // Step 4 — Recovery: handle a stuck Order (rare but real)
109
+ // ─────────────────────────────────────────────────────────────────────────────
110
+
111
+ export async function checkOrderHealth(orderId: string) {
112
+ const order = await mp.getOrder(orderId);
113
+
114
+ // If the underlying payment was rejected, surface why
115
+ const transactions = (order as { transactions?: { payments?: Array<{ id: string }> } }).transactions;
116
+ if (transactions?.payments && transactions.payments.length > 0) {
117
+ const lastPayment = await mp.getPayment(String(transactions.payments[0]!.id));
118
+ const explanation = explainPaymentStatus(lastPayment);
119
+ return {
120
+ orderStatus: order.status,
121
+ paymentStatus: lastPayment.status,
122
+ explanation,
123
+ };
124
+ }
125
+
126
+ return { orderStatus: order.status };
127
+ }
@@ -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.