@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,162 @@
1
+ /**
2
+ * Recipe 03 — Production-grade webhook handler.
3
+ *
4
+ * # The 3-line summary
5
+ *
6
+ * - Verify HMAC-SHA256 signature → reject with 401 if invalid (replay protection included)
7
+ * - Parse the event (topic + dataId) from query/body (MP sends in both)
8
+ * - Auto-fetch the underlying resource (Payment / Preapproval / Order)
9
+ * - Dispatch by topic to your business logic
10
+ *
11
+ * Without HMAC verify, ANYONE can POST to your webhook URL and forge
12
+ * payments/cancellations. The lib's `verifyWebhookSignature` rejects
13
+ * stale signatures (>5min old) too — replay attack protection.
14
+ *
15
+ * # Why use the agent's `handle_webhook` tool vs calling primitives manually
16
+ *
17
+ * The tool consolidates verify + parse + auto-fetch + dispatch into one
18
+ * call. Saves ~30 lines per webhook handler vs the manual chain.
19
+ *
20
+ * # Edge Runtime
21
+ *
22
+ * This recipe is fully Edge-compatible. Webhook handlers benefit from Edge
23
+ * (lower cold-start = faster MP-acked, fewer 500s during traffic spikes).
24
+ */
25
+
26
+ import {
27
+ MercadoPagoClient,
28
+ parseWebhookEvent,
29
+ verifyWebhookSignature,
30
+ } from "@ar-agents/mercadopago";
31
+
32
+ export const runtime = "edge";
33
+
34
+ const mp = new MercadoPagoClient({
35
+ accessToken: process.env.MP_ACCESS_TOKEN!,
36
+ });
37
+
38
+ const WEBHOOK_SECRET = process.env.MP_WEBHOOK_SECRET!;
39
+
40
+ // ─────────────────────────────────────────────────────────────────────────────
41
+ // Approach A — Manual primitives (more control, more code)
42
+ // ─────────────────────────────────────────────────────────────────────────────
43
+
44
+ export async function POST(req: Request) {
45
+ // 1. Read the RAW body — DO NOT use req.json() before HMAC verify, as
46
+ // JSON.stringify changes whitespace and breaks the signature.
47
+ const rawBody = await req.text();
48
+
49
+ const signatureHeader = req.headers.get("x-signature");
50
+ const requestId = req.headers.get("x-request-id");
51
+ const url = new URL(req.url);
52
+
53
+ // 2. Parse the event from body or query (MP sends in both).
54
+ let parsedBody: unknown;
55
+ try {
56
+ parsedBody = JSON.parse(rawBody);
57
+ } catch {
58
+ return new Response("invalid JSON", { status: 400 });
59
+ }
60
+ const event = parseWebhookEvent(parsedBody, url.searchParams);
61
+ if (!event) {
62
+ return new Response("unrecognized webhook shape", { status: 400 });
63
+ }
64
+
65
+ // 3. Verify HMAC + replay-tolerance window.
66
+ const verified = await verifyWebhookSignature({
67
+ requestId,
68
+ dataId: event.dataId,
69
+ signatureHeader,
70
+ secret: WEBHOOK_SECRET,
71
+ });
72
+ if (!verified) {
73
+ return new Response("unauthorized", { status: 401 });
74
+ }
75
+
76
+ // 4. Dispatch by topic.
77
+ try {
78
+ switch (event.topic) {
79
+ case "payment":
80
+ case "payment.created":
81
+ case "payment.updated": {
82
+ const payment = await mp.getPayment(event.dataId);
83
+ await handlePayment(payment);
84
+ break;
85
+ }
86
+ case "subscription_preapproval":
87
+ case "preapproval": {
88
+ const sub = await mp.getPreapproval(event.dataId);
89
+ await handleSubscription(sub);
90
+ break;
91
+ }
92
+ case "subscription_authorized_payment": {
93
+ // The dataId IS the authorized_payment id — list under parent
94
+ // preapproval to get full context.
95
+ await handleRecurringCharge(event.dataId);
96
+ break;
97
+ }
98
+ case "merchant_order": {
99
+ const mo = await mp.getMerchantOrder(event.dataId);
100
+ await handleMerchantOrder(mo);
101
+ break;
102
+ }
103
+ case "point_integration_wh": {
104
+ const intent = await mp.getPointPaymentIntent(event.dataId);
105
+ await handlePointPaymentIntent(intent);
106
+ break;
107
+ }
108
+ default:
109
+ // Unknown topic — log and acknowledge so MP doesn't retry forever.
110
+ console.warn(`Unhandled webhook topic: ${event.topic}`);
111
+ }
112
+ } catch (err) {
113
+ // Return 5xx so MP retries (it has built-in exponential backoff).
114
+ console.error("webhook handler failed:", err);
115
+ return new Response("internal error", { status: 500 });
116
+ }
117
+
118
+ return new Response("ok", { status: 200 });
119
+ }
120
+
121
+ // ─────────────────────────────────────────────────────────────────────────────
122
+ // Approach B — Agent tool (let the agent dispatch)
123
+ // ─────────────────────────────────────────────────────────────────────────────
124
+
125
+ /**
126
+ * Alternative: pass everything to an agent + the `handle_webhook` tool.
127
+ * Useful when your business logic varies by webhook content and an LLM
128
+ * makes the decision (e.g., "if this payment is for an old SKU, refund it").
129
+ *
130
+ * Note: this is HIGHER LATENCY than approach A and uses LLM tokens. Only
131
+ * use when LLM reasoning is genuinely required.
132
+ */
133
+ // export async function POST_via_agent(req: Request) {
134
+ // const rawBody = await req.text();
135
+ // const result = await agent.generate({
136
+ // prompt: `Procesá este webhook de MP. Topic + body:\n${rawBody}`,
137
+ // toolChoice: "required",
138
+ // tools: mercadoPagoTools(mp, {
139
+ // state, backUrl, webhookSecret: WEBHOOK_SECRET, oauth: {...}
140
+ // }),
141
+ // });
142
+ // }
143
+
144
+ // ─────────────────────────────────────────────────────────────────────────────
145
+ // Business logic stubs
146
+ // ─────────────────────────────────────────────────────────────────────────────
147
+
148
+ async function handlePayment(payment: unknown) {
149
+ // Update your DB, fire shipping flow, send notification, etc.
150
+ }
151
+ async function handleSubscription(sub: unknown) {
152
+ /* ... */
153
+ }
154
+ async function handleRecurringCharge(authorizedPaymentId: string) {
155
+ /* ... */
156
+ }
157
+ async function handleMerchantOrder(mo: unknown) {
158
+ /* ... */
159
+ }
160
+ async function handlePointPaymentIntent(intent: unknown) {
161
+ /* ... */
162
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Recipe 04 — Marketplace platform with seller OAuth + split payments.
3
+ *
4
+ * # The Rappi/Tienda Nube pattern
5
+ *
6
+ * Your platform aggregates sellers. Each seller has their own MP account.
7
+ * Buyers pay through your platform; you take a marketplace fee; the rest
8
+ * goes to the seller's MP account.
9
+ *
10
+ * # Flow
11
+ *
12
+ * **One-time per seller**:
13
+ * 1. Seller clicks "Conectar Mercado Pago" on your dashboard
14
+ * 2. Your server redirects them to MP's OAuth authorize URL
15
+ * (`oauth_authorize_url` tool)
16
+ * 3. Seller approves; MP redirects back with `?code=...&state=...`
17
+ * 4. Your server exchanges code → token bundle (`oauth_exchange_code`)
18
+ * 5. You PERSIST the bundle keyed by `token.user_id` (use OAuthTokenStore)
19
+ *
20
+ * **Per transaction**:
21
+ * 1. Buyer completes purchase on your platform
22
+ * 2. Your server fetches the seller's persisted token
23
+ * 3. (If expired) refresh via `oauth_refresh_token`
24
+ * 4. Instantiate `new MercadoPagoClient({ accessToken })` AS THE SELLER
25
+ * 5. Create a Preference / Order with `marketplace_fee` + `collector_id`
26
+ * 6. Funds route to the seller; fee splits off to your account
27
+ *
28
+ * # Key insight
29
+ *
30
+ * `marketplace_fee` is in ARS, NOT a percentage. Compute it from your
31
+ * commission rate using `compute_marketplace_fee` (PURE helper, no network).
32
+ */
33
+
34
+ import {
35
+ buildAuthorizeUrl,
36
+ computeMarketplaceFee,
37
+ exchangeCodeForToken,
38
+ expirationTimeMs,
39
+ InMemoryOAuthTokenStore,
40
+ isExpiringSoon,
41
+ MercadoPagoClient,
42
+ refreshAccessToken,
43
+ } from "@ar-agents/mercadopago";
44
+
45
+ // In production: VercelKVOAuthTokenStore
46
+ const oauthStore = new InMemoryOAuthTokenStore();
47
+
48
+ const CLIENT_ID = process.env.MP_CLIENT_ID!;
49
+ const CLIENT_SECRET = process.env.MP_CLIENT_SECRET!;
50
+ const REDIRECT_URI = "https://yourapp.com/api/mp/oauth/callback";
51
+ const MARKETPLACE_NAME = "MyMarketplace";
52
+
53
+ // ─────────────────────────────────────────────────────────────────────────────
54
+ // Step 1 — Send seller to MP for authorization
55
+ // ─────────────────────────────────────────────────────────────────────────────
56
+
57
+ export async function startSellerOAuthFlow(input: { sellerSessionId: string }) {
58
+ // `state` should be bound to the seller's session and verified on callback
59
+ // (CSRF protection). Use a secure random token + persist against the session.
60
+ const state = `${input.sellerSessionId}:${crypto.randomUUID()}`;
61
+ // ... persist state → sellerSessionId mapping in your DB ...
62
+ return buildAuthorizeUrl({
63
+ clientId: CLIENT_ID,
64
+ redirectUri: REDIRECT_URI,
65
+ state,
66
+ });
67
+ }
68
+
69
+ // ─────────────────────────────────────────────────────────────────────────────
70
+ // Step 2 — Handle the OAuth callback
71
+ // ─────────────────────────────────────────────────────────────────────────────
72
+
73
+ export async function handleOAuthCallback(req: Request) {
74
+ const url = new URL(req.url);
75
+ const code = url.searchParams.get("code");
76
+ const state = url.searchParams.get("state");
77
+ if (!code || !state) {
78
+ return new Response("missing code/state", { status: 400 });
79
+ }
80
+ // ... verify state matches the seller's session in your DB ...
81
+
82
+ const token = await exchangeCodeForToken({
83
+ clientId: CLIENT_ID,
84
+ clientSecret: CLIENT_SECRET,
85
+ code,
86
+ redirectUri: REDIRECT_URI,
87
+ });
88
+
89
+ // PERSIST the bundle. The user_id identifies which seller this is.
90
+ await oauthStore.set(token.user_id, {
91
+ user_id: token.user_id,
92
+ access_token: token.access_token,
93
+ refresh_token: token.refresh_token!,
94
+ expires_at: expirationTimeMs(Date.now(), token.expires_in),
95
+ });
96
+
97
+ return Response.json({ ok: true, sellerId: token.user_id });
98
+ }
99
+
100
+ // ─────────────────────────────────────────────────────────────────────────────
101
+ // Step 3 — Per-transaction: get a per-seller MP client
102
+ // ─────────────────────────────────────────────────────────────────────────────
103
+
104
+ async function getSellerMpClient(sellerUserId: string): Promise<MercadoPagoClient> {
105
+ let token = await oauthStore.get(sellerUserId);
106
+ if (!token) {
107
+ throw new Error(`Seller ${sellerUserId} hasn't connected MP yet.`);
108
+ }
109
+
110
+ // Proactive refresh: if within 5 min of expiration, refresh ahead of time.
111
+ if (isExpiringSoon(token.expires_at)) {
112
+ const fresh = await refreshAccessToken({
113
+ clientId: CLIENT_ID,
114
+ clientSecret: CLIENT_SECRET,
115
+ refreshToken: token.refresh_token,
116
+ });
117
+ token = {
118
+ user_id: fresh.user_id,
119
+ access_token: fresh.access_token,
120
+ refresh_token: fresh.refresh_token!,
121
+ expires_at: expirationTimeMs(Date.now(), fresh.expires_in),
122
+ };
123
+ await oauthStore.set(token.user_id, token);
124
+ }
125
+
126
+ return new MercadoPagoClient({ accessToken: token.access_token });
127
+ }
128
+
129
+ // ─────────────────────────────────────────────────────────────────────────────
130
+ // Step 4 — Create a marketplace preference with fee split
131
+ // ─────────────────────────────────────────────────────────────────────────────
132
+
133
+ export async function createMarketplacePreference(input: {
134
+ sellerUserId: string; // from oauth_exchange_code earlier
135
+ buyerEmail: string;
136
+ productTitle: string;
137
+ productPriceArs: number;
138
+ externalReference: string;
139
+ }) {
140
+ // Compute the exact fee to charge (5% with $50 floor and $5000 ceiling)
141
+ const marketplaceFee = computeMarketplaceFee(input.productPriceArs, {
142
+ percent: 5,
143
+ minArs: 50,
144
+ maxArs: 5000,
145
+ });
146
+
147
+ const sellerClient = await getSellerMpClient(input.sellerUserId);
148
+
149
+ const preference = await sellerClient.createPreference({
150
+ items: [
151
+ {
152
+ title: input.productTitle,
153
+ quantity: 1,
154
+ unit_price: input.productPriceArs,
155
+ currency_id: "ARS",
156
+ },
157
+ ],
158
+ payer: { email: input.buyerEmail },
159
+ backUrls: {
160
+ success: "https://yourapp.com/payment-success",
161
+ failure: "https://yourapp.com/payment-failure",
162
+ },
163
+ autoReturn: "approved",
164
+ externalReference: input.externalReference,
165
+ notificationUrl: "https://yourapp.com/api/mp/webhook",
166
+
167
+ // The marketplace fields — these split the funds:
168
+ marketplace: MARKETPLACE_NAME,
169
+ marketplaceFee, // in ARS, NOT %
170
+ collectorId: input.sellerUserId, // funds route here; fee splits off to your platform
171
+ });
172
+
173
+ return {
174
+ preferenceId: preference.id,
175
+ initPoint: preference.init_point,
176
+ sellerReceives: input.productPriceArs - marketplaceFee,
177
+ platformFee: marketplaceFee,
178
+ };
179
+ }
180
+
181
+ // ─────────────────────────────────────────────────────────────────────────────
182
+ // Step 5 — Reconciliation: query merchant_orders to verify split
183
+ // ─────────────────────────────────────────────────────────────────────────────
184
+
185
+ export async function reconcileMarketplaceSale(input: {
186
+ sellerUserId: string;
187
+ preferenceId: string;
188
+ }) {
189
+ const sellerClient = await getSellerMpClient(input.sellerUserId);
190
+ const result = await sellerClient.searchMerchantOrders({
191
+ preferenceId: input.preferenceId,
192
+ });
193
+ return result.elements;
194
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Recipe 05 — In-store QR payment with WhatsApp notification.
3
+ *
4
+ * # Use case
5
+ *
6
+ * Brick-and-mortar shop. Buyer scans a dynamic QR with any AR wallet (MP,
7
+ * Modo, BNA+, Cuenta DNI, Naranja X — interop is mandated by Transferencias 3.0).
8
+ *
9
+ * # Flow
10
+ *
11
+ * **One-time setup**:
12
+ * 1. `create_store` (per branch)
13
+ * 2. `create_pos` per cash register / agent
14
+ *
15
+ * **Per sale**:
16
+ * 1. Cashier triggers a QR for $X via the agent
17
+ * 2. `create_qr_payment` → returns base64 PNG + qr_data string
18
+ * 3. Display QR on screen (or print)
19
+ * 4. Buyer scans → MP fires `point_integration_wh` then `payment` webhooks
20
+ * 5. Cashier receives notification on WhatsApp ("✓ Cobro $X de Juan")
21
+ *
22
+ * # Why two webhooks (`point_integration_wh` + `payment`)
23
+ *
24
+ * - `point_integration_wh`: fires when the QR is scanned, BEFORE payment confirmation
25
+ * - `payment`: fires when the payment is approved
26
+ *
27
+ * Listen for both — the first is your "cashier knows it's being scanned"
28
+ * heads-up, the second is the source of truth for "money landed".
29
+ */
30
+
31
+ import {
32
+ InMemoryStateAdapter,
33
+ MercadoPagoClient,
34
+ } from "@ar-agents/mercadopago";
35
+ import { WhatsAppClient, sendWhatsAppText } from "@ar-agents/whatsapp"; // hypothetical import path
36
+
37
+ const mp = new MercadoPagoClient({
38
+ accessToken: process.env.MP_ACCESS_TOKEN!,
39
+ });
40
+
41
+ const wa = new WhatsAppClient({
42
+ // ... WhatsApp Business credentials ...
43
+ } as never);
44
+
45
+ // ─────────────────────────────────────────────────────────────────────────────
46
+ // Step 1 — One-time setup: store + POS
47
+ // ─────────────────────────────────────────────────────────────────────────────
48
+
49
+ export async function setupStoreAndPos(input: {
50
+ userId: string; // your MP user id (from get_account_info)
51
+ storeName: string;
52
+ storeExternalId: string; // your-system branch id
53
+ posExternalId: string; // your-system cash register id
54
+ }) {
55
+ const store = await mp.createStore(input.userId, {
56
+ name: input.storeName,
57
+ external_id: input.storeExternalId,
58
+ location: { street_name: "—", street_number: 0, city: "Buenos Aires" },
59
+ } as never);
60
+
61
+ const pos = await mp.createPos({
62
+ name: `Caja ${input.posExternalId}`,
63
+ external_id: input.posExternalId,
64
+ store_id: store.id,
65
+ category: 621102, // "Other Food and Beverage Services" — adjust per MCC
66
+ } as never);
67
+
68
+ return { storeId: store.id, posId: pos.id, posExternalId: input.posExternalId };
69
+ }
70
+
71
+ // ─────────────────────────────────────────────────────────────────────────────
72
+ // Step 2 — Cashier triggers a QR for $X
73
+ // ─────────────────────────────────────────────────────────────────────────────
74
+
75
+ export async function generateQrForSale(input: {
76
+ userId: string;
77
+ posExternalId: string; // unique per POS
78
+ amountArs: number;
79
+ description: string;
80
+ externalReference: string; // your-system order id
81
+ cashierWhatsAppNumber: string; // for notifications
82
+ }) {
83
+ const qr = await mp.createQrPayment(input.userId, {
84
+ external_pos_id: input.posExternalId,
85
+ title: input.description,
86
+ description: input.description,
87
+ total_amount: input.amountArs,
88
+ items: [
89
+ {
90
+ sku_number: input.externalReference,
91
+ category: "marketplace",
92
+ title: input.description,
93
+ description: input.description,
94
+ unit_price: input.amountArs,
95
+ quantity: 1,
96
+ unit_measure: "unit",
97
+ total_amount: input.amountArs,
98
+ },
99
+ ],
100
+ expires_in_seconds: 600,
101
+ notification_url: "https://yourapp.com/api/mp/webhook",
102
+ } as never);
103
+
104
+ return {
105
+ qrDataUrl: qr.qr_data_url, // base64 PNG ready to display
106
+ qrString: qr.qr_data, // raw QR string (alt: emit your own image)
107
+ expiresInSeconds: 600,
108
+ instructions:
109
+ "Mostrale al cliente este QR. Tiene 10 minutos para escanear.",
110
+ };
111
+ }
112
+
113
+ // ─────────────────────────────────────────────────────────────────────────────
114
+ // Step 3 — Webhook: payment approved → notify cashier via WhatsApp
115
+ // ─────────────────────────────────────────────────────────────────────────────
116
+
117
+ export async function onPaymentApproved(input: {
118
+ paymentId: string;
119
+ cashierWhatsAppNumber: string;
120
+ }) {
121
+ const payment = await mp.getPayment(input.paymentId);
122
+
123
+ if (payment.status !== "approved") return; // only notify on success
124
+
125
+ const buyerName = (payment.payer as { first_name?: string } | undefined)?.first_name ?? "cliente";
126
+ const amount = payment.transaction_amount;
127
+
128
+ await sendWhatsAppText({
129
+ waClient: wa,
130
+ to: input.cashierWhatsAppNumber,
131
+ text: `✓ Cobro confirmado\nMonto: $${amount.toLocaleString("es-AR")}\nCliente: ${buyerName}\nID: ${payment.id}`,
132
+ } as never);
133
+ }
134
+
135
+ // ─────────────────────────────────────────────────────────────────────────────
136
+ // Step 4 — Cancel a pending QR (buyer didn't scan)
137
+ // ─────────────────────────────────────────────────────────────────────────────
138
+
139
+ export async function cancelStaleQr(userId: string, posExternalId: string) {
140
+ await mp.cancelQrPayment(userId, posExternalId);
141
+ return { ok: true };
142
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Recipe 06 — 3DS challenge flow with detect → redirect → recover.
3
+ *
4
+ * # Background
5
+ *
6
+ * 3DS (Strong Customer Authentication) is the issuer-side 2FA layer for
7
+ * card payments. MP triggers it automatically when:
8
+ * - The card's issuer requires it (driven by MCC + amount + risk).
9
+ * - The buyer's country mandates it.
10
+ *
11
+ * In Argentina, 3DS is OPTIONAL but strongly recommended for high-value
12
+ * transactions. When triggered, the payment stays in `pending` until the
13
+ * buyer completes the issuer's challenge.
14
+ *
15
+ * # Flow
16
+ *
17
+ * 1. `create_payment` returns `status: "pending"` + `status_detail: "pending_challenge"`
18
+ * 2. Run `analyze_payment_3ds` (or call `analyze3DS(payment)` directly) to extract
19
+ * the `challengeUrl` from `payment.three_ds_info.external_resource_url`
20
+ * 3. Redirect the buyer to `challengeUrl`
21
+ * 4. Buyer completes the challenge on the issuer's page
22
+ * 5. Issuer redirects buyer back to your `back_url`
23
+ * 6. MP fires a `payment` webhook with the final status (approved/rejected)
24
+ *
25
+ * # Critical
26
+ *
27
+ * Without redirecting to the challenge URL, the payment stays in `pending`
28
+ * INDEFINITELY. This is the most common cause of "stuck" payments.
29
+ */
30
+
31
+ import { analyze3DS, MercadoPagoClient } from "@ar-agents/mercadopago";
32
+
33
+ const mp = new MercadoPagoClient({
34
+ accessToken: process.env.MP_ACCESS_TOKEN!,
35
+ });
36
+
37
+ // ─────────────────────────────────────────────────────────────────────────────
38
+ // Step 1 — Create payment + immediately analyze 3DS
39
+ // ─────────────────────────────────────────────────────────────────────────────
40
+
41
+ export async function createPaymentAndCheck3DS(input: {
42
+ amountArs: number;
43
+ cardToken: string;
44
+ payerEmail: string;
45
+ externalReference: string;
46
+ }) {
47
+ const payment = await mp.createPayment({
48
+ transactionAmount: input.amountArs,
49
+ paymentMethodId: "visa", // get from list_payment_methods if uncertain
50
+ payerEmail: input.payerEmail,
51
+ token: input.cardToken,
52
+ description: "Compra " + input.externalReference,
53
+ externalReference: input.externalReference,
54
+ installments: 1,
55
+ });
56
+
57
+ const threeDs = analyze3DS(payment);
58
+
59
+ if (threeDs.status === "challenge_required" && threeDs.challengeUrl) {
60
+ return {
61
+ paymentId: payment.id,
62
+ status: "challenge_required" as const,
63
+ action: "redirect",
64
+ challengeUrl: threeDs.challengeUrl,
65
+ message:
66
+ "Redirigir al comprador a challengeUrl. El pago queda pending hasta que complete el desafío.",
67
+ };
68
+ }
69
+
70
+ if (threeDs.status === "rejected") {
71
+ return {
72
+ paymentId: payment.id,
73
+ status: "rejected" as const,
74
+ action: "show_error",
75
+ message: threeDs.description,
76
+ };
77
+ }
78
+
79
+ return {
80
+ paymentId: payment.id,
81
+ status: payment.status,
82
+ action: "done",
83
+ message:
84
+ threeDs.status === "frictionless"
85
+ ? "3DS aprobado sin desafiar al comprador."
86
+ : "Pago procesado.",
87
+ };
88
+ }
89
+
90
+ // ─────────────────────────────────────────────────────────────────────────────
91
+ // Step 2 — Render the redirect page (Next.js Server Component example)
92
+ // ─────────────────────────────────────────────────────────────────────────────
93
+
94
+ /**
95
+ * Hypothetical Next.js page that handles the challenge URL redirect:
96
+ *
97
+ * ```tsx
98
+ * // app/checkout/3ds/[paymentId]/page.tsx
99
+ * export default async function ChallengePage({
100
+ * params: { paymentId },
101
+ * }: { params: { paymentId: string } }) {
102
+ * const payment = await mp.getPayment(paymentId);
103
+ * const threeDs = analyze3DS(payment);
104
+ *
105
+ * if (threeDs.status === "challenge_required" && threeDs.challengeUrl) {
106
+ * redirect(threeDs.challengeUrl);
107
+ * }
108
+ *
109
+ * // If we land here, the challenge is over — show final status.
110
+ * return <PaymentResultPage paymentId={paymentId} />;
111
+ * }
112
+ * ```
113
+ */
114
+
115
+ // ─────────────────────────────────────────────────────────────────────────────
116
+ // Step 3 — Webhook: payment.updated → check final 3DS state
117
+ // ─────────────────────────────────────────────────────────────────────────────
118
+
119
+ export async function on3DSPaymentWebhook(paymentId: string) {
120
+ const payment = await mp.getPayment(paymentId);
121
+ const threeDs = analyze3DS(payment);
122
+
123
+ // Possible end states:
124
+ // - approved + frictionless: 3DS was on, buyer wasn't challenged
125
+ // - approved (no 3DS info): 3DS not required, normal payment
126
+ // - rejected (status_detail with "3ds"): authentication failed
127
+ // - approved (after challenge): buyer completed the challenge successfully
128
+
129
+ if (payment.status === "approved") {
130
+ // Provision the order
131
+ return { ok: true, threeDs: threeDs.status };
132
+ }
133
+ if (payment.status === "rejected") {
134
+ // Show the user the failure reason; offer alternative payment method
135
+ return { ok: false, reason: threeDs.description };
136
+ }
137
+ // Still pending — webhook will fire again
138
+ return { ok: false, reason: "still pending" };
139
+ }