mercadopago-integration

📁 martinacostadev/mercadopago-integration 📅 6 days ago
47
总安装量
47
周安装量
#4504
全站排名
安装命令
npx skills add https://github.com/martinacostadev/mercadopago-integration --skill mercadopago-integration

Agent 安装分布

gemini-cli 43
opencode 43
github-copilot 42
kimi-cli 42
amp 41

Skill 文档

MercadoPago Checkout Pro – Next.js Integration

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.

Quick Start

For a minimal integration, just tell Claude:

Integrar MercadoPago en mi app

Claude will automatically explore your codebase to detect:

  • Database adapter (Supabase, Prisma, or raw pg)
  • Cart store location
  • Existing routes and patterns
  • Currency based on context

For more control, provide details:

Integrate MercadoPago Checkout Pro.
Database: Prisma. Currency: ARS. Success route: /pago-exitoso.

See references/usage-examples.md for more prompt templates.

Payment Flow

┌─────────────────────────────────────────────────────────────────────────────┐
│                              PAYMENT FLOW                                    │
└─────────────────────────────────────────────────────────────────────────────┘

User clicks "Pay"
       │
       ▼
┌──────────────────┐
│ POST /api/checkout│
└────────┬─────────┘
         │
         ▼
┌──────────────────────────────────┐
│ 1. Create purchase in DB         │
│    (status: pending)             │
│ 2. Create preference in MP API   │
│ 3. Save preference_id in DB      │
│ 4. Return init_point URL         │
└────────┬─────────────────────────┘
         │
         ▼
┌──────────────────┐      ┌─────────────────────┐
│ Redirect to MP   │─────▶│ User pays on MP     │
└──────────────────┘      └──────────┬──────────┘
                                     │
         ┌───────────────────────────┴───────────────────────────┐
         │                                                       │
         ▼                                                       ▼
┌─────────────────────────┐                        ┌─────────────────────────┐
│ Redirect back to app    │                        │ MP sends webhook        │
│ /payment-success?id=... │                        │ POST /api/webhooks/mp   │
└──────────┬──────────────┘                        └──────────┬──────────────┘
           │                                                  │
           ▼                                                  ▼
┌─────────────────────────┐                        ┌─────────────────────────┐
│ Verify status via API   │                        │ Update purchase status  │
│ GET /api/purchases/[id] │                        │ in database             │
└──────────┬──────────────┘                        └─────────────────────────┘
           │
           ▼
┌─────────────────────────┐
│ Show UI based on status │
│ approved/pending/rejected│
└─────────────────────────┘

Before Starting

  1. Determine the database adapter. Explore the codebase or ask the user:

    • Supabase? See references/database-supabase.md
    • Prisma? See references/database-prisma.md
    • Raw PostgreSQL (pg, Drizzle, etc.)? See references/database-postgresql.md
  2. Gather or infer from the codebase:

Detail Why Example
Currency Preference creation ARS, BRL, MXN (see references/countries.md)
Success/failure routes back_urls in preference /payment-success, /pago-exitoso
Brand name Card statement descriptor MY_STORE (max 22 chars)
Product/item table FK in purchase_items products, photos, courses
Cart store location Hook reads items from it src/store/cart.ts
DB client path API routes import it src/lib/supabase/server.ts, src/lib/prisma.ts

Prerequisites

  1. Install dependencies: npm install mercadopago zod
  2. Set environment variables (never prefix access token with NEXT_PUBLIC_):
    MERCADOPAGO_ACCESS_TOKEN=TEST-xxxx   # from https://www.mercadopago.com/developers/panel/app
    NEXT_PUBLIC_APP_URL=http://localhost:3000  # HTTPS in production
    
  3. Run database migration from assets/migration.sql (works on any PostgreSQL database).

Production Requirements

  • SSL Certificate: Required for auto_return and secure webhooks
  • Active MercadoPago seller account: Create here
  • Publicly accessible webhook URL: MercadoPago must reach your /api/webhooks/mercadopago

Implementation Steps

Step 1: Database Helper

Create: src/lib/db/purchases.ts

This 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:

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>;

Step 2: MercadoPago Client

Create: src/lib/mercadopago/client.ts

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 (max 22 chars)
      expires: true,
      expiration_date_from: new Date().toISOString(),
      expiration_date_to: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
    },
    // Optional: Prevent duplicate preferences on retry
    requestOptions: {
      idempotencyKey: purchaseId,
    },
  });
}

export async function getPayment(paymentId: string) {
  return payment.get({ id: paymentId });
}

Step 3: Checkout API Route

Create: src/app/api/checkout/route.ts

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().min(1),
    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

Create: src/app/api/webhooks/mercadopago/route.ts

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();

    // Handle both IPN and webhook formats
    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);
    // Always return 200 to prevent MercadoPago from retrying indefinitely
    return NextResponse.json({ received: true });
  }
}

// GET endpoint for MercadoPago verification pings
export async function GET() {
  return NextResponse.json({ status: 'ok' });
}

Step 5: Purchase Status API

Create: src/app/api/purchases/[id]/route.ts

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)

Create: src/hooks/useCheckout.ts

Double-click prevention uses useRef (survives re-renders, unlike useState).

'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

Create: src/app/payment-success/page.tsx (adjust route name)

Always verify purchase status server-side. Never trust the redirect URL alone. Wrap useSearchParams in <Suspense> (Next.js App Router requirement).

'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]);

  if (status === 'loading') {
    return <div>Verifying payment...</div>;
  }

  if (status === 'approved') {
    return (
      <div>
        <h1>Payment Successful!</h1>
        <p>Thank you for your purchase.</p>
      </div>
    );
  }

  if (status === 'pending') {
    return (
      <div>
        <h1>Payment Pending</h1>
        <p>Your payment is being processed.</p>
        <p>You'll receive an email when confirmed.</p>
        <p>This may take 1-3 business days for offline payment methods.</p>
      </div>
    );
  }

  if (status === 'rejected') {
    return (
      <div>
        <h1>Payment Failed</h1>
        <p>Your payment could not be processed.</p>
        <p>Please try again or use a different payment method.</p>
      </div>
    );
  }

  return (
    <div>
      <h1>Error</h1>
      <p>Could not verify payment status. Please contact support.</p>
    </div>
  );
}

export default function PaymentSuccessPage() {
  return <Suspense fallback={<div>Loading...</div>}><PaymentResult /></Suspense>;
}

Checklist

Configuration

  • mercadopago + zod installed
  • MERCADOPAGO_ACCESS_TOKEN in .env (TEST token for dev, never NEXT_PUBLIC_)
  • NEXT_PUBLIC_APP_URL in .env (HTTPS in production)
  • Database migration run (purchases + purchase_items)

Backend Implementation

  • DB helper implemented (src/lib/db/purchases.ts)
  • MercadoPago client with createPreference and getPayment
  • /api/checkout with Zod validation
  • /api/webhooks/mercadopago with idempotency check
  • /api/purchases/[id] for status verification

Frontend Implementation

  • Checkout hook with useRef guard (prevents double submit)
  • Success page verifies status via API (not trusting redirect)
  • Pending status UI for offline payments
  • useSearchParams wrapped in <Suspense>
  • Hydration guard for localStorage-based cart

Production Readiness

  • NEXT_PUBLIC_APP_URL uses HTTPS
  • auto_return enabled (only works with HTTPS)
  • Webhook URL publicly accessible
  • Production credentials (not TEST-)
  • Statement descriptor set (max 22 chars)
  • Error logging configured

Critical Gotchas

For detailed solutions, see references/troubleshooting.md.

Gotcha Fix
auto_return + localhost = 400 error Only set when URL starts with https
user_email NOT NULL + no email = 500 Use 'pending@checkout' placeholder; webhook updates it
currency_id doesn’t match account country Use correct currency (ARS for Argentina, BRL for Brazil, etc.)
Hydration mismatch (localStorage cart) Add mounted state guard before rendering cart content
Double purchase on double-click Use useRef guard, not just useState
Success page trusts redirect URL Always verify via /api/purchases/[id]
Webhook duplicate updates Check if purchase is already terminal before updating
Webhooks can’t reach localhost Use ngrok: ngrok http 3000
useSearchParams error Wrap component in <Suspense>
Payment stuck in pending Normal for offline methods (OXXO, Rapipago, Boleto)
Mixed test/production credentials Never mix – use all TEST or all PROD

References

Database Adapters

  • references/database-supabase.md – Supabase DB helper implementation
  • references/database-prisma.md – Prisma DB helper implementation
  • references/database-postgresql.md – Raw PostgreSQL (pg, Drizzle, etc.) DB helper implementation

Configuration

  • references/countries.md – Currencies, test cards, payment methods by country
  • references/testing.md – Complete testing guide with test cards and simulated results
  • references/mcp-server.md – MercadoPago MCP Server for AI integration

Help

  • references/troubleshooting.md – 20+ common errors and solutions
  • references/usage-examples.md – Ready-to-use prompt templates

Assets

  • assets/migration.sql – Database schema template (standard PostgreSQL)

External Links