mercadopago-integration
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseMercadoPago Checkout Pro - Next.js Integration
MercadoPago Checkout Pro - Next.js 集成
Redirect-based payment flow: buyer clicks "Pay", is redirected to MercadoPago, completes payment, returns to the app. A webhook confirms the payment status in the background.
Pay button --> POST /api/checkout (create preference) --> Redirect to MercadoPago
|
MercadoPago --webhook--> /api/webhooks/mercadopago --> Update DB
|
+--redirect--> /payment-success?purchase=ID --> Verify via API基于重定向的支付流程:买家点击“支付”按钮,被重定向到MercadoPago完成支付,之后返回应用。Webhook会在后台确认支付状态。
Pay button --> POST /api/checkout (create preference) --> Redirect to MercadoPago
|
MercadoPago --webhook--> /api/webhooks/mercadopago --> Update DB
|
+--redirect--> /payment-success?purchase=ID --> Verify via APIBefore Starting
开始之前
-
Determine the database adapter. Explore the codebase or ask the user:
- Supabase? See for DB helper implementation
references/database-supabase.md - Prisma? See for DB helper implementation
references/database-prisma.md - Raw PostgreSQL (pg, Drizzle, or other)? See for DB helper implementation
references/database-postgresql.md
- Supabase? See
-
Gather or infer from the codebase:
| Detail | Why | Example |
|---|---|---|
| Currency | Preference creation | |
| Success/failure routes | | |
| Brand name | Card statement descriptor | |
| Product/item table | FK in | |
| Cart store location | Hook reads items from it | |
| DB client path | API routes import it | |
-
确定数据库适配器。查看代码库或询问用户:
- Supabase? 查看 获取数据库助手实现方案
references/database-supabase.md - Prisma? 查看 获取数据库助手实现方案
references/database-prisma.md - 原生PostgreSQL(pg、Drizzle或其他)? 查看 获取数据库助手实现方案
references/database-postgresql.md
- Supabase? 查看
-
从代码库中收集或推断以下信息:
| 详情 | 原因 | 示例 |
|---|---|---|
| 币种 | 创建支付偏好时需要 | |
| 成功/失败路由 | 支付偏好中的 | |
| 品牌名称 | 信用卡账单描述符 | |
| 产品/商品表 | | |
| 购物车存储位置 | Hook从该位置读取商品 | |
| 数据库客户端路径 | API路由会导入该文件 | |
Prerequisites
前置条件
- Install dependencies:
npm install mercadopago zod - Set environment variables (never prefix access token with ):
NEXT_PUBLIC_envMERCADOPAGO_ACCESS_TOKEN=TEST-xxxx # from https://www.mercadopago.com/developers/panel/app NEXT_PUBLIC_APP_URL=http://localhost:3000 # HTTPS in production - Run database migration from (works on any PostgreSQL database).
assets/migration.sql
- 安装依赖:
npm install mercadopago zod - 设置环境变量(绝对不要为访问令牌添加 前缀):
NEXT_PUBLIC_envMERCADOPAGO_ACCESS_TOKEN=TEST-xxxx # 来自 https://www.mercadopago.com/developers/panel/app NEXT_PUBLIC_APP_URL=http://localhost:3000 # 生产环境使用HTTPS - 运行 中的数据库迁移脚本(适用于任意PostgreSQL数据库)。
assets/migration.sql
Implementation Steps
实现步骤
Step 1: Database Helper
步骤1:数据库助手
Create:
src/lib/db/purchases.tsThis abstracts all purchase DB operations. Implement using your DB adapter.
See the reference file for your adapter:
- Supabase:
references/database-supabase.md - Prisma:
references/database-prisma.md - Raw pg / other:
references/database-postgresql.md
The helper must export these functions:
typescript
interface PurchaseInsert {
user_email: string;
status: 'pending';
total_amount: number;
}
interface PurchaseUpdate {
status?: 'pending' | 'approved' | 'rejected';
mercadopago_payment_id?: string;
mercadopago_preference_id?: string;
user_email?: string;
updated_at?: string;
}
// Required exports:
export async function createPurchase(data: PurchaseInsert): Promise<{ id: string }>;
export async function updatePurchase(id: string, data: PurchaseUpdate): Promise<void>;
export async function getPurchaseStatus(id: string): Promise<{ id: string; status: string } | null>;
export async function createPurchaseItems(purchaseId: string, items: { item_id: string; price: number }[]): Promise<void>;创建文件:
src/lib/db/purchases.ts该文件封装所有订单相关的数据库操作。根据你的数据库适配器实现对应逻辑。查看对应适配器的参考文件:
- Supabase:
references/database-supabase.md - Prisma:
references/database-prisma.md - 原生pg / 其他:
references/database-postgresql.md
该助手必须导出以下函数:
typescript
interface PurchaseInsert {
user_email: string;
status: 'pending';
total_amount: number;
}
interface PurchaseUpdate {
status?: 'pending' | 'approved' | 'rejected';
mercadopago_payment_id?: string;
mercadopago_preference_id?: string;
user_email?: string;
updated_at?: string;
}
// 必须导出的函数:
export async function createPurchase(data: PurchaseInsert): Promise<{ id: string }>;
export async function updatePurchase(id: string, data: PurchaseUpdate): Promise<void>;
export async function getPurchaseStatus(id: string): Promise<{ id: string; status: string } | null>;
export async function createPurchaseItems(purchaseId: string, items: { item_id: string; price: number }[]): Promise<void>;Step 2: MercadoPago Client
步骤2:MercadoPago客户端
Create:
src/lib/mercadopago/client.tstypescript
import { MercadoPagoConfig, Preference, Payment } from 'mercadopago';
const client = new MercadoPagoConfig({
accessToken: process.env.MERCADOPAGO_ACCESS_TOKEN!,
});
const preference = new Preference(client);
const payment = new Payment(client);
interface CreatePreferenceParams {
items: { id: string; title: string; quantity: number; unit_price: number }[];
purchaseId: string;
buyerEmail?: string;
}
export async function createPreference({
items, purchaseId, buyerEmail,
}: CreatePreferenceParams) {
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
return preference.create({
body: {
items: items.map((item) => ({
id: item.id,
title: item.title,
quantity: item.quantity,
unit_price: item.unit_price,
currency_id: 'ARS', // Change per references/countries.md
})),
...(buyerEmail ? { payer: { email: buyerEmail } } : {}),
back_urls: {
success: `${baseUrl}/payment-success?purchase=${purchaseId}`,
failure: `${baseUrl}/payment-failure?purchase=${purchaseId}`,
pending: `${baseUrl}/payment-success?purchase=${purchaseId}&status=pending`,
},
// CRITICAL: auto_return requires HTTPS. Omit on localhost or MP returns 400.
...(baseUrl.startsWith('https') ? { auto_return: 'approved' as const } : {}),
external_reference: purchaseId,
notification_url: `${baseUrl}/api/webhooks/mercadopago`,
statement_descriptor: 'YOUR_BRAND', // Replace with user's brand
expires: true,
expiration_date_from: new Date().toISOString(),
expiration_date_to: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
},
});
}
export async function getPayment(paymentId: string) {
return payment.get({ id: paymentId });
}创建文件:
src/lib/mercadopago/client.tstypescript
import { MercadoPagoConfig, Preference, Payment } from 'mercadopago';
const client = new MercadoPagoConfig({
accessToken: process.env.MERCADOPAGO_ACCESS_TOKEN!,
});
const preference = new Preference(client);
const payment = new Payment(client);
interface CreatePreferenceParams {
items: { id: string; title: string; quantity: number; unit_price: number }[];
purchaseId: string;
buyerEmail?: string;
}
export async function createPreference({
items, purchaseId, buyerEmail,
}: CreatePreferenceParams) {
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
return preference.create({
body: {
items: items.map((item) => ({
id: item.id,
title: item.title,
quantity: item.quantity,
unit_price: item.unit_price,
currency_id: 'ARS', // 根据 references/countries.md 修改
})),
...(buyerEmail ? { payer: { email: buyerEmail } } : {}),
back_urls: {
success: `${baseUrl}/payment-success?purchase=${purchaseId}`,
failure: `${baseUrl}/payment-failure?purchase=${purchaseId}`,
pending: `${baseUrl}/payment-success?purchase=${purchaseId}&status=pending`,
},
// 关键:auto_return 需要HTTPS。在本地环境请省略,否则MercadoPago会返回400错误。
...(baseUrl.startsWith('https') ? { auto_return: 'approved' as const } : {}),
external_reference: purchaseId,
notification_url: `${baseUrl}/api/webhooks/mercadopago`,
statement_descriptor: 'YOUR_BRAND', // 替换为用户的品牌名称
expires: true,
expiration_date_from: new Date().toISOString(),
expiration_date_to: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
},
});
}
export async function getPayment(paymentId: string) {
return payment.get({ id: paymentId });
}Step 3: Checkout API Route
步骤3:结账API路由
Create:
src/app/api/checkout/route.tstypescript
import { NextResponse } from 'next/server';
import { createPurchase, updatePurchase } from '@/lib/db/purchases';
import { createPreference } from '@/lib/mercadopago/client';
import { z } from 'zod';
const checkoutSchema = z.object({
items: z.array(z.object({
id: z.string(),
title: z.string(),
quantity: z.number().positive(),
unit_price: z.number().positive(),
})).min(1),
email: z.string().email().optional(),
});
export async function POST(request: Request) {
try {
const body = await request.json();
const validation = checkoutSchema.safeParse(body);
if (!validation.success) {
return NextResponse.json({ error: 'Invalid request data' }, { status: 400 });
}
const { items, email } = validation.data;
const totalAmount = items.reduce((sum, i) => sum + i.unit_price * i.quantity, 0);
const purchase = await createPurchase({
user_email: email || 'pending@checkout',
status: 'pending',
total_amount: totalAmount,
});
const mpPreference = await createPreference({
items, purchaseId: purchase.id, buyerEmail: email,
});
await updatePurchase(purchase.id, {
mercadopago_preference_id: mpPreference.id,
});
return NextResponse.json({
preferenceId: mpPreference.id,
initPoint: mpPreference.init_point,
purchaseId: purchase.id,
});
} catch (error) {
console.error('Checkout error:', error);
return NextResponse.json({ error: 'Checkout failed' }, { status: 500 });
}
}创建文件:
src/app/api/checkout/route.tstypescript
import { NextResponse } from 'next/server';
import { createPurchase, updatePurchase } from '@/lib/db/purchases';
import { createPreference } from '@/lib/mercadopago/client';
import { z } from 'zod';
const checkoutSchema = z.object({
items: z.array(z.object({
id: z.string(),
title: z.string(),
quantity: z.number().positive(),
unit_price: z.number().positive(),
})).min(1),
email: z.string().email().optional(),
});
export async function POST(request: Request) {
try {
const body = await request.json();
const validation = checkoutSchema.safeParse(body);
if (!validation.success) {
return NextResponse.json({ error: 'Invalid request data' }, { status: 400 });
}
const { items, email } = validation.data;
const totalAmount = items.reduce((sum, i) => sum + i.unit_price * i.quantity, 0);
const purchase = await createPurchase({
user_email: email || 'pending@checkout',
status: 'pending',
total_amount: totalAmount,
});
const mpPreference = await createPreference({
items, purchaseId: purchase.id, buyerEmail: email,
});
await updatePurchase(purchase.id, {
mercadopago_preference_id: mpPreference.id,
});
return NextResponse.json({
preferenceId: mpPreference.id,
initPoint: mpPreference.init_point,
purchaseId: purchase.id,
});
} catch (error) {
console.error('Checkout error:', error);
return NextResponse.json({ error: 'Checkout failed' }, { status: 500 });
}
}Step 4: Webhook Handler
步骤4:Webhook处理器
Create:
src/app/api/webhooks/mercadopago/route.tstypescript
import { NextResponse } from 'next/server';
import { getPurchaseStatus, updatePurchase } from '@/lib/db/purchases';
import { getPayment } from '@/lib/mercadopago/client';
export async function POST(request: Request) {
try {
const body = await request.json();
if (body.type !== 'payment' && body.action !== 'payment.created' && body.action !== 'payment.updated') {
return NextResponse.json({ received: true });
}
const paymentId = body.data?.id;
if (!paymentId) return NextResponse.json({ received: true });
const payment = await getPayment(paymentId.toString());
if (!payment?.external_reference) return NextResponse.json({ received: true });
let status: 'pending' | 'approved' | 'rejected' = 'pending';
if (payment.status === 'approved') status = 'approved';
else if (['rejected', 'cancelled', 'refunded'].includes(payment.status || '')) status = 'rejected';
// Idempotency: skip if already in terminal state
const existing = await getPurchaseStatus(payment.external_reference);
if (existing?.status === 'approved' || existing?.status === 'rejected') {
return NextResponse.json({ received: true });
}
const payerEmail = payment.payer?.email;
await updatePurchase(payment.external_reference, {
status,
mercadopago_payment_id: paymentId.toString(),
...(payerEmail ? { user_email: payerEmail } : {}),
updated_at: new Date().toISOString(),
});
return NextResponse.json({ received: true });
} catch (error) {
console.error('Webhook error:', error);
return NextResponse.json({ error: 'Webhook processing failed' }, { status: 500 });
}
}
// GET endpoint for MercadoPago verification pings
export async function GET() {
return NextResponse.json({ status: 'ok' });
}创建文件:
src/app/api/webhooks/mercadopago/route.tstypescript
import { NextResponse } from 'next/server';
import { getPurchaseStatus, updatePurchase } from '@/lib/db/purchases';
import { getPayment } from '@/lib/mercadopago/client';
export async function POST(request: Request) {
try {
const body = await request.json();
if (body.type !== 'payment' && body.action !== 'payment.created' && body.action !== 'payment.updated') {
return NextResponse.json({ received: true });
}
const paymentId = body.data?.id;
if (!paymentId) return NextResponse.json({ received: true });
const payment = await getPayment(paymentId.toString());
if (!payment?.external_reference) return NextResponse.json({ received: true });
let status: 'pending' | 'approved' | 'rejected' = 'pending';
if (payment.status === 'approved') status = 'approved';
else if (['rejected', 'cancelled', 'refunded'].includes(payment.status || '')) status = 'rejected';
// 幂等性处理:如果订单已处于最终状态则跳过更新
const existing = await getPurchaseStatus(payment.external_reference);
if (existing?.status === 'approved' || existing?.status === 'rejected') {
return NextResponse.json({ received: true });
}
const payerEmail = payment.payer?.email;
await updatePurchase(payment.external_reference, {
status,
mercadopago_payment_id: paymentId.toString(),
...(payerEmail ? { user_email: payerEmail } : {}),
updated_at: new Date().toISOString(),
});
return NextResponse.json({ received: true });
} catch (error) {
console.error('Webhook error:', error);
return NextResponse.json({ error: 'Webhook processing failed' }, { status: 500 });
}
}
// GET端点用于MercadoPago的验证请求
export async function GET() {
return NextResponse.json({ status: 'ok' });
}Step 5: Purchase Status API
步骤5:订单状态API
Create:
src/app/api/purchases/[id]/route.tstypescript
import { NextResponse } from 'next/server';
import { getPurchaseStatus } from '@/lib/db/purchases';
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const data = await getPurchaseStatus(id);
if (!data) return NextResponse.json({ error: 'Not found' }, { status: 404 });
return NextResponse.json({ id: data.id, status: data.status });
}创建文件:
src/app/api/purchases/[id]/route.tstypescript
import { NextResponse } from 'next/server';
import { getPurchaseStatus } from '@/lib/db/purchases';
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const data = await getPurchaseStatus(id);
if (!data) return NextResponse.json({ error: 'Not found' }, { status: 404 });
return NextResponse.json({ id: data.id, status: data.status });
}Step 6: Checkout Hook (Frontend)
步骤6:结账Hook(前端)
Create:
src/hooks/useCheckout.tsDouble-click prevention uses (survives re-renders, unlike ).
useRefuseStatetypescript
'use client';
import { useCallback, useRef, useState } from 'react';
export function useCheckout() {
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const guard = useRef(false);
const submitCheckout = useCallback(async (items: unknown[]) => {
if (guard.current) return;
setError(null);
guard.current = true;
setIsSubmitting(true);
try {
const res = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Checkout failed');
if (data.initPoint) window.location.href = data.initPoint;
else throw new Error('No payment link returned');
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
setIsSubmitting(false);
guard.current = false;
}
}, []);
return { submitCheckout, isSubmitting, error };
}创建文件:
src/hooks/useCheckout.ts使用 防止重复提交(它会在重渲染后保留状态,不像 )。
useRefuseStatetypescript
'use client';
import { useCallback, useRef, useState } from 'react';
export function useCheckout() {
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const guard = useRef(false);
const submitCheckout = useCallback(async (items: unknown[]) => {
if (guard.current) return;
setError(null);
guard.current = true;
setIsSubmitting(true);
try {
const res = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Checkout failed');
if (data.initPoint) window.location.href = data.initPoint;
else throw new Error('No payment link returned');
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
setIsSubmitting(false);
guard.current = false;
}
}, []);
return { submitCheckout, isSubmitting, error };
}Step 7: Success Page with Verification
步骤7:带验证的成功页面
Create: (adjust route name)
src/app/payment-success/page.tsxAlways verify purchase status server-side. Never trust the redirect URL alone.
Wrap in (Next.js App Router requirement).
useSearchParams<Suspense>tsx
'use client';
import { useSearchParams } from 'next/navigation';
import { Suspense, useCallback, useEffect, useState } from 'react';
type Status = 'loading' | 'approved' | 'pending' | 'rejected' | 'error';
function PaymentResult() {
const purchaseId = useSearchParams().get('purchase');
const [status, setStatus] = useState<Status>(purchaseId ? 'loading' : 'approved');
const verify = useCallback(async (id: string) => {
try {
const res = await fetch(`/api/purchases/${id}`);
if (!res.ok) { setStatus('error'); return; }
const { status } = await res.json();
setStatus(status === 'approved' ? 'approved'
: status === 'pending' ? 'pending' : 'rejected');
} catch { setStatus('error'); }
}, []);
useEffect(() => { if (purchaseId) verify(purchaseId); }, [purchaseId, verify]);
// Render UI based on status: loading/approved/pending/rejected/error
return <div>{/* Customize UI per status */}</div>;
}
export default function PaymentSuccessPage() {
return <Suspense fallback={<div>Loading...</div>}><PaymentResult /></Suspense>;
}创建文件: (根据需要调整路由名称)
src/app/payment-success/page.tsx始终通过服务端验证订单状态。绝对不要仅信任重定向URL。将 包裹在 中(Next.js App Router 的要求)。
useSearchParams<Suspense>tsx
'use client';
import { useSearchParams } from 'next/navigation';
import { Suspense, useCallback, useEffect, useState } from 'react';
type Status = 'loading' | 'approved' | 'pending' | 'rejected' | 'error';
function PaymentResult() {
const purchaseId = useSearchParams().get('purchase');
const [status, setStatus] = useState<Status>(purchaseId ? 'loading' : 'approved');
const verify = useCallback(async (id: string) => {
try {
const res = await fetch(`/api/purchases/${id}`);
if (!res.ok) { setStatus('error'); return; }
const { status } = await res.json();
setStatus(status === 'approved' ? 'approved'
: status === 'pending' ? 'pending' : 'rejected');
} catch { setStatus('error'); }
}, []);
useEffect(() => { if (purchaseId) verify(purchaseId); }, [purchaseId, verify]);
// 根据状态渲染UI:loading/approved/pending/rejected/error
return <div>{/* 根据状态自定义UI */}</div>;
}
export default function PaymentSuccessPage() {
return <Suspense fallback={<div>加载中...</div>}><PaymentResult /></Suspense>;
}Critical Gotchas
关键注意事项
For detailed solutions, see .
references/troubleshooting.md| Gotcha | Fix |
|---|---|
| Only set when URL starts with |
| Use |
| Hydration mismatch (localStorage cart) | Add |
| Double purchase on double-click | Use |
| Success page trusts redirect URL | Always verify via |
| Webhook duplicate updates | Check if purchase is already terminal before updating |
| Webhooks can't reach localhost | Use ngrok: |
| Wrap component in |
如需详细解决方案,请查看 。
references/troubleshooting.md| 问题 | 修复方案 |
|---|---|
| 仅当URL以 |
| 使用 |
| Hydration不匹配(本地存储购物车) | 在渲染购物车内容前添加 |
| 双击导致重复下单 | 使用 |
| 成功页面信任重定向URL | 始终通过 |
| Webhook重复更新 | 更新前检查订单是否已处于最终状态 |
| Webhook无法访问本地环境 | 使用ngrok: |
| 将组件包裹在 |
Checklist
检查清单
- +
mercadopagoinstalledzod - in
MERCADOPAGO_ACCESS_TOKEN(TEST token for dev, never.env)NEXT_PUBLIC_ - in
NEXT_PUBLIC_APP_URL(HTTPS in production).env - Database migration run (+
purchases)purchase_items - DB helper implemented ()
src/lib/db/purchases.ts - with Zod validation
/api/checkout - with idempotency check
/api/webhooks/mercadopago - for status verification
/api/purchases/[id] - Success page verifies status (not trusting redirect)
- only for HTTPS
auto_return - Checkout hook prevents double submit
- wrapped in
useSearchParams<Suspense>
- 已安装 +
mercadopagozod - 中已配置
.env(开发环境使用测试令牌,绝对不要添加MERCADOPAGO_ACCESS_TOKEN前缀)NEXT_PUBLIC_ - 中已配置
.env(生产环境使用HTTPS)NEXT_PUBLIC_APP_URL - 已运行数据库迁移脚本(创建 和
purchases表)purchase_items - 已实现数据库助手()
src/lib/db/purchases.ts - 已添加Zod验证
/api/checkout - 已添加幂等性检查
/api/webhooks/mercadopago - 已实现状态验证
/api/purchases/[id] - 成功页面会验证状态(不依赖重定向URL)
- 仅在HTTPS环境下启用
auto_return - 结账Hook已防止重复提交
- 已包裹在
useSearchParams中<Suspense>
References
参考资料
- - Supabase DB helper implementation
references/database-supabase.md - - Prisma DB helper implementation
references/database-prisma.md - - Raw PostgreSQL (pg, Drizzle, etc.) DB helper implementation
references/database-postgresql.md - - Detailed error fixes and solutions
references/troubleshooting.md - - Currencies, available countries, and MercadoPago specifics
references/countries.md - - Ready-to-use prompt templates
references/usage-examples.md - - Database schema template (standard PostgreSQL)
assets/migration.sql - MercadoPago Checkout Pro Docs
- MercadoPago Node SDK
- - Supabase数据库助手实现方案
references/database-supabase.md - - Prisma数据库助手实现方案
references/database-prisma.md - - 原生PostgreSQL(pg、Drizzle等)数据库助手实现方案
references/database-postgresql.md - - 详细的错误修复方案
references/troubleshooting.md - - 币种、支持国家及MercadoPago相关细节
references/countries.md - - 可直接使用的提示模板
references/usage-examples.md - - 数据库 schema 模板(标准PostgreSQL)
assets/migration.sql - MercadoPago Checkout Pro 文档
- MercadoPago Node SDK