@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.
- package/CHANGELOG.md +125 -0
- package/README.md +162 -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 +407 -34
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +278 -50
- package/dist/index.d.ts +278 -50
- package/dist/index.js +404 -35
- 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 +32 -3
- package/tools.manifest.json +1 -1
|
@@ -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
|
+
}
|