Next.jsReactServer ActionsTypeScript

Pagamentos em USDT no Next.js App Router: Server Actions + Webhooks

Guia completo de como integrar pagamentos cripto no Next.js 14+ usando Server Actions, Route Handlers e React Server Components.

Equipe Tech Infi Pulse

Autor

15 de janeiro de 2025
9 min read
Pagamentos em USDT no Next.js App Router: Server Actions + Webhooks

Next.js 14+ mudou tudo com App Router. Veja como integrar pagamentos em USDT usando as novas features.

Stack

  • Next.js 14+ (App Router)
  • Server Actions (form handling)
  • Route Handlers (webhooks)
  • React Server Components
  • TypeScript
  • Pulse SDK

Setup

npx create-next-app@latest my-store
cd my-store
npm install @infi/sdk zod
// lib/pulse.ts
import { Pulse } from '@infi/sdk'

export const pulse = new Pulse({
  apiKey: process.env.PULSE_API_KEY!,
  environment: process.env.NODE_ENV === 'production' ? 'production' : 'test'
})

Exemplo 1: Checkout Simples

1. Página de Produto (Server Component)

// app/products/[id]/page.tsx
import { CheckoutForm } from './checkout-form'

async function getProduct(id: string) {
  // Fetch do seu DB/API
  return {
    id,
    name: 'Curso Full Stack',
    price: 299.90,
    description: 'Aprenda Next.js, TypeScript, PostgreSQL e Deploy'
  }
}

export default async function ProductPage({
  params
}: {
  params: { id: string }
}) {
  const product = await getProduct(params.id)

  return (
    <div className="max-w-2xl mx-auto p-8">
      <h1 className="text-3xl font-bold mb-4">{product.name}</h1>
      <p className="text-gray-600 mb-6">{product.description}</p>
      <p className="text-2xl font-bold mb-8">R$ {product.price}</p>

      <CheckoutForm product={product} />
    </div>
  )
}

2. Form com Server Action

// app/products/[id]/checkout-form.tsx
'use client'

import { useFormStatus } from 'react-dom'
import { createPayment } from './actions'

function SubmitButton() {
  const { pending } = useFormStatus()

  return (
    <button
      type="submit"
      disabled={pending}
      className="w-full bg-purple-600 text-white py-3 rounded-lg hover:bg-purple-700 disabled:opacity-50"
    >
      {pending ? 'Criando pagamento...' : 'Pagar com PIX'}
    </button>
  )
}

export function CheckoutForm({ product }: { product: Product }) {
  return (
    <form action={createPayment} className="space-y-4">
      <input type="hidden" name="productId" value={product.id} />
      <input type="hidden" name="amount" value={product.price} />

      <div>
        <label className="block text-sm font-medium mb-2">Email</label>
        <input
          type="email"
          name="email"
          required
          className="w-full border rounded-lg p-3"
          placeholder="seu@email.com"
        />
      </div>

      <div>
        <label className="block text-sm font-medium mb-2">Nome completo</label>
        <input
          type="text"
          name="name"
          required
          className="w-full border rounded-lg p-3"
        />
      </div>

      <SubmitButton />
    </form>
  )
}

3. Server Action

// app/products/[id]/actions.ts
'use server'

import { pulse } from '@/lib/pulse'
import { redirect } from 'next/navigation'
import { z } from 'zod'

const checkoutSchema = z.object({
  productId: z.string(),
  amount: z.coerce.number().positive(),
  email: z.string().email(),
  name: z.string().min(3)
})

export async function createPayment(formData: FormData) {
  // Validar input
  const data = checkoutSchema.parse({
    productId: formData.get('productId'),
    amount: formData.get('amount'),
    email: formData.get('email'),
    name: formData.get('name')
  })

  // Criar pagamento
  const payment = await pulse.payments.create({
    amount: data.amount,
    currency: 'BRL',
    description: `Produto ${data.productId}`,
    metadata: {
      productId: data.productId,
      customerEmail: data.email,
      customerName: data.name
    }
  })

  // Salvar no DB (opcional)
  await db.payments.create({
    id: payment.id,
    productId: data.productId,
    customerEmail: data.email,
    status: 'pending',
    amount: payment.amount
  })

  // Redirecionar para página de pagamento
  redirect(`/checkout/${payment.id}`)
}

4. Página de Checkout

// app/checkout/[id]/page.tsx
import { pulse } from '@/lib/pulse'
import { QRCode } from './qr-code'
import { PaymentStatus } from './payment-status'

export default async function CheckoutPage({
  params
}: {
  params: { id: string }
}) {
  const payment = await pulse.payments.retrieve(params.id)

  if (payment.status === 'completed') {
    return <SuccessMessage payment={payment} />
  }

  return (
    <div className="max-w-md mx-auto p-8">
      <h1 className="text-2xl font-bold mb-4">Finalizar Pagamento</h1>

      <div className="bg-gray-100 rounded-lg p-6 mb-6">
        <p className="text-sm text-gray-600 mb-2">Valor</p>
        <p className="text-3xl font-bold">R$ {payment.amount}</p>
      </div>

      <div className="mb-6">
        <p className="text-sm text-gray-600 mb-4">
          Escaneie o QR Code com app do banco:
        </p>
        <QRCode value={payment.qrCode} />
      </div>

      <div className="border-t pt-6">
        <p className="text-sm text-gray-600 mb-2">
          Ou copie o código PIX:
        </p>
        <CopyButton text={payment.qrCode} />
      </div>

      <PaymentStatus paymentId={payment.id} />
    </div>
  )
}

5. Status em Tempo Real

// app/checkout/[id]/payment-status.tsx
'use client'

import { useEffect, useState } from 'react'

export function PaymentStatus({ paymentId }: { paymentId: string }) {
  const [status, setStatus] = useState<'pending' | 'processing' | 'completed'>('pending')

  useEffect(() => {
    // Poll status a cada 3 segundos
    const interval = setInterval(async () => {
      const res = await fetch(`/api/payments/${paymentId}/status`)
      const data = await res.json()

      setStatus(data.status)

      if (data.status === 'completed') {
        clearInterval(interval)
        // Redirecionar para success page
        window.location.href = `/success?payment=${paymentId}`
      }
    }, 3000)

    return () => clearInterval(interval)
  }, [paymentId])

  return (
    <div className="mt-6 p-4 bg-yellow-50 rounded-lg">
      {status === 'pending' && (
        <>
          <p className="text-sm font-medium">⏳ Aguardando pagamento...</p>
          <p className="text-xs text-gray-600 mt-1">
            Não feche esta página
          </p>
        </>
      )}
      {status === 'processing' && (
        <>
          <p className="text-sm font-medium">⚡ PIX confirmado!</p>
          <p className="text-xs text-gray-600 mt-1">
            Processando conversão para USDT...
          </p>
        </>
      )}
    </div>
  )
}

6. Route Handler para Status

// app/api/payments/[id]/status/route.ts
import { pulse } from '@/lib/pulse'
import { NextResponse } from 'next/server'

export async function GET(
  req: Request,
  { params }: { params: { id: string } }
) {
  const payment = await pulse.payments.retrieve(params.id)

  return NextResponse.json({
    status: payment.status
  })
}

Exemplo 2: Webhooks

1. Route Handler

// app/api/webhooks/pulse/route.ts
import { pulse } from '@/lib/pulse'
import { headers } from 'next/headers'
import { NextResponse } from 'next/server'
import crypto from 'crypto'

export async function POST(req: Request) {
  const body = await req.text()
  const signature = headers().get('pulse-signature')

  // Validar signature
  const expectedSignature = crypto
    .createHmac('sha256', process.env.PULSE_WEBHOOK_SECRET!)
    .update(body)
    .digest('hex')

  if (signature !== expectedSignature) {
    return new NextResponse('Invalid signature', { status: 401 })
  }

  const event = JSON.parse(body)

  // Processar evento
  await handleWebhook(event)

  return new NextResponse('OK', { status: 200 })
}

async function handleWebhook(event: WebhookEvent) {
  switch (event.type) {
    case 'payment.completed':
      await fulfillOrder(event.data)
      break

    case 'payment.failed':
      await handleFailedPayment(event.data)
      break
  }
}

async function fulfillOrder(payment: Payment) {
  // 1. Atualizar DB
  await db.payments.update(payment.id, {
    status: 'completed',
    completedAt: new Date()
  })

  // 2. Liberar produto
  const product = await db.products.findById(payment.metadata.productId)

  if (product.type === 'digital') {
    // Enviar email com link de download
    await sendDownloadEmail({
      to: payment.metadata.customerEmail,
      downloadUrl: product.downloadUrl
    })
  } else if (product.type === 'course') {
    // Dar acesso ao curso
    await db.courseAccess.create({
      userId: payment.metadata.userId,
      courseId: product.id,
      grantedAt: new Date()
    })
  }

  // 3. Email de confirmação
  await sendConfirmationEmail({
    to: payment.metadata.customerEmail,
    paymentId: payment.id,
    amount: payment.amount
  })

  // 4. Analytics
  await track('Purchase Completed', {
    paymentId: payment.id,
    amount: payment.amount,
    amountUSDT: payment.amountUSDT
  })
}

2. Testar Localmente

# Terminal 1: Dev server
npm run dev

# Terminal 2: ngrok
ngrok http 3000

# Dashboard Pulse > Webhooks
# URL: https://abc123.ngrok.io/api/webhooks/pulse

Exemplo 3: SaaS Subscription

1. Server Action para Criar Assinatura

// app/pricing/actions.ts
'use server'

import { pulse } from '@/lib/pulse'
import { auth } from '@/lib/auth'
import { redirect } from 'next/navigation'

export async function subscribeAction(planId: string) {
  const user = await auth.getUser()
  if (!user) {
    redirect('/login')
  }

  const plan = await db.plans.findById(planId)

  // Criar pagamento mensal
  const payment = await pulse.payments.create({
    amount: plan.price,
    currency: 'BRL',
    description: `Assinatura ${plan.name} - ${getCurrentMonth()}`,
    metadata: {
      userId: user.id,
      planId: plan.id,
      subscriptionType: 'monthly'
    }
  })

  // Criar subscription no DB
  await db.subscriptions.create({
    userId: user.id,
    planId: plan.id,
    status: 'pending',
    paymentId: payment.id,
    nextBillingDate: getNextMonth()
  })

  redirect(`/checkout/${payment.id}`)
}

2. Cron para Cobranças Recorrentes

// app/api/cron/billing/route.ts
import { pulse } from '@/lib/pulse'
import { NextResponse } from 'next/server'

export async function GET(req: Request) {
  // Verificar Vercel Cron secret
  const authHeader = req.headers.get('authorization')
  if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
    return new NextResponse('Unauthorized', { status: 401 })
  }

  // Buscar subscriptions que precisam ser cobradas hoje
  const dueSubscriptions = await db.subscriptions.findWhere({
    status: 'active',
    nextBillingDate: { lte: new Date() }
  })

  for (const subscription of dueSubscriptions) {
    const user = await db.users.findById(subscription.userId)
    const plan = await db.plans.findById(subscription.planId)

    // Criar novo pagamento
    const payment = await pulse.payments.create({
      amount: plan.price,
      currency: 'BRL',
      description: `Renovação ${plan.name} - ${getCurrentMonth()}`,
      metadata: {
        userId: user.id,
        subscriptionId: subscription.id,
        planId: plan.id
      }
    })

    // Enviar email
    await sendEmail({
      to: user.email,
      subject: 'Renovação da Assinatura',
      template: 'subscription-renewal',
      data: {
        planName: plan.name,
        amount: plan.price,
        paymentUrl: payment.paymentUrl
      }
    })

    // Atualizar subscription
    await db.subscriptions.update(subscription.id, {
      nextBillingDate: getNextMonth(),
      lastPaymentId: payment.id
    })
  }

  return NextResponse.json({ processed: dueSubscriptions.length })
}

3. Vercel Cron Config

// vercel.json
{
  "crons": [
    {
      "path": "/api/cron/billing",
      "schedule": "0 9 * * *"
    }
  ]
}

Exemplo 4: Marketplace Multi-Vendor

1. Split Payment

// app/products/[id]/actions.ts
'use server'

export async function checkoutAction(formData: FormData) {
  const productId = formData.get('productId') as string
  const product = await db.products.findById(productId)
  const vendor = await db.users.findById(product.vendorId)

  // Criar pagamento
  const payment = await pulse.payments.create({
    amount: product.price,
    currency: 'BRL',
    description: product.name,
    metadata: {
      productId: product.id,
      vendorId: vendor.id,
      platformFee: calculateFee(product.price) // 10%
    }
  })

  redirect(`/checkout/${payment.id}`)
}

2. Webhook Split Distribution

async function fulfillMarketplaceOrder(payment: Payment) {
  const platformFee = parseFloat(payment.metadata.platformFee)
  const vendorAmount = payment.amountUSDT - (payment.amountUSDT * 0.10)

  // 1. Registrar split no DB
  await db.paymentSplits.create({
    paymentId: payment.id,
    vendorId: payment.metadata.vendorId,
    vendorAmount,
    platformFee,
    totalAmount: payment.amountUSDT
  })

  // 2. Enviar USDT para vendor (via sua lógica de payout)
  await queuePayout({
    vendorId: payment.metadata.vendorId,
    amount: vendorAmount,
    currency: 'USDT'
  })

  // 3. Notificar vendor
  await sendVendorEmail({
    vendorId: payment.metadata.vendorId,
    subject: 'Nova venda!',
    amount: vendorAmount
  })
}

Performance Tips

1. Cache Payment Status

import { unstable_cache } from 'next/cache'

const getPayment = unstable_cache(
  async (id: string) => {
    return await pulse.payments.retrieve(id)
  },
  ['payment'],
  {
    revalidate: 10, // Cache por 10 segundos
    tags: [`payment-${id}`]
  }
)

2. Revalidate no Webhook

import { revalidateTag } from 'next/cache'

async function handleWebhook(event: WebhookEvent) {
  if (event.type === 'payment.completed') {
    // Invalidar cache desse payment
    revalidateTag(`payment-${event.data.id}`)
  }
}

3. Streaming UI

import { Suspense } from 'react'

export default function CheckoutPage({ params }: { params: { id: string } }) {
  return (
    <div>
      <Suspense fallback={<PaymentSkeleton />}>
        <PaymentDetails paymentId={params.id} />
      </Suspense>
    </div>
  )
}

async function PaymentDetails({ paymentId }: { paymentId: string }) {
  const payment = await pulse.payments.retrieve(paymentId)
  return <PaymentCard payment={payment} />
}

Type Safety

// types/payment.ts
import { z } from 'zod'

export const PaymentMetadataSchema = z.object({
  productId: z.string(),
  userId: z.string().optional(),
  customerEmail: z.string().email(),
  subscriptionId: z.string().optional()
})

export type PaymentMetadata = z.infer<typeof PaymentMetadataSchema>

// Uso em Server Action
export async function createPayment(formData: FormData) {
  const metadata = PaymentMetadataSchema.parse({
    productId: formData.get('productId'),
    customerEmail: formData.get('email')
  })

  const payment = await pulse.payments.create({
    // TypeScript sabe que metadata tem estrutura correta
    metadata
  })
}

Deploy na Vercel

1. Environment Variables

# .env.local
PULSE_API_KEY=live_xxx
PULSE_WEBHOOK_SECRET=whsec_xxx
DATABASE_URL=postgresql://...

2. Vercel Dashboard

Settings > Environment Variables
└─ Adicionar todas as variáveis
   ├─ PULSE_API_KEY
   ├─ PULSE_WEBHOOK_SECRET
   └─ DATABASE_URL

3. Deploy

git push origin main
# Vercel auto-deploy

# Atualizar webhook URL no dashboard Pulse
https://seu-app.vercel.app/api/webhooks/pulse

Conclusão

Next.js App Router + Pulse SDK = Stack perfeita para aceitar pagamentos em cripto:

  • Server Actions: Forms sem API routes
  • RSC: Zero JS no cliente para páginas estáticas
  • Route Handlers: Webhooks simples
  • TypeScript: Type safety end-to-end
  • Vercel: Deploy sem config

Código completo: github.com/pulse/examples/nextjs

Docs Next.js: docs.pulse.infinitum.com/frameworks/nextjs