@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.
- package/CHANGELOG.md +47 -0
- package/README.md +63 -1
- 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 +109 -12
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +28 -49
- package/dist/index.d.ts +28 -49
- package/dist/index.js +108 -13
- 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 +25 -2
- package/tools.manifest.json +1 -1
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|