feat: initial commit — asaas-checkout template white-label

Template genérico de checkout com ASAAS, parametrizado via env vars.
Inclui fluxo completo: checkout → pedido → polling → webhook → admin.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-16 06:40:41 +02:00
commit 038ce3f556
103 changed files with 20709 additions and 0 deletions

View File

@@ -0,0 +1,28 @@
import { NextResponse } from "next/server"
import { createServiceClient } from "@/lib/supabase"
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const body = await request.json().catch(() => ({}))
const { status } = body as { status?: string }
const STATUSES_VALIDOS = ["pendente", "confirmado", "realizado", "cancelado"]
if (!status || !STATUSES_VALIDOS.includes(status)) {
return NextResponse.json({ error: "Status inválido" }, { status: 400 })
}
const supabase = createServiceClient()
const { error } = await supabase
.from("agendamentos")
.update({ status, updated_at: new Date().toISOString() })
.eq("id", id)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ ok: true, status })
}

View File

@@ -0,0 +1,102 @@
import { NextResponse } from "next/server"
import { createServiceClient } from "@/lib/supabase"
import { parseWebhookPayload, mapearStatus } from "@/lib/asaas"
const N8N_WEBHOOK_URL = process.env.N8N_WEBHOOK_URL ?? ""
export async function POST(request: Request) {
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 })
}
const supabase = createServiceClient()
// Salva log do webhook para auditoria
await supabase.from("webhook_logs").insert({
evento: (body as { event?: string }).event ?? "unknown",
payload: body,
processado: false,
})
try {
const event = parseWebhookPayload(body)
// Processa apenas eventos de pagamento
if (!event.event.startsWith("PAYMENT_")) {
return NextResponse.json({ ok: true })
}
const novoStatus = mapearStatus(event.payment.status)
const isPago =
event.payment.status === "RECEIVED" || event.payment.status === "CONFIRMED"
const paidAt = isPago ? new Date().toISOString() : null
const updateData: Record<string, unknown> = { status: novoStatus }
if (paidAt) updateData.paid_at = paidAt
// Atualiza pedido e busca dados completos para notificação
const { data: pedido } = await supabase
.from("pedidos")
.update(updateData)
.eq("asaas_payment_id", event.payment.id)
.select("id, valor_centavos, metodo_pagamento, cliente_id, produto_id")
.single()
if (pedido) {
// Marca webhook como processado
await supabase
.from("webhook_logs")
.update({ processado: true })
.eq("evento", event.event)
.order("created_at", { ascending: false })
.limit(1)
// Dispara automação n8n apenas em pagamentos confirmados
if (isPago && N8N_WEBHOOK_URL) {
// Busca cliente e produto para enriquecer o payload
const [{ data: cliente }, { data: produto }] = await Promise.all([
supabase.from("clientes").select("nome, email, telefone, cpf_cnpj").eq("id", pedido.cliente_id).single(),
supabase.from("produtos").select("nome, tipo, validade, midia").eq("id", pedido.produto_id).single(),
])
const notificacao = {
evento: event.event,
pedido_id: pedido.id,
asaas_payment_id: event.payment.id,
valor: (pedido.valor_centavos / 100).toLocaleString("pt-BR", { minimumFractionDigits: 2, style: "currency", currency: "BRL" }),
metodo: pedido.metodo_pagamento,
cliente: {
nome: cliente?.nome ?? "—",
email: cliente?.email ?? "—",
telefone: cliente?.telefone ?? "—",
cpf_cnpj: cliente?.cpf_cnpj ?? "—",
},
produto: {
nome: produto?.nome ?? "—",
tipo: produto?.tipo ?? "—",
validade: produto?.validade ?? "—",
midia: produto?.midia ?? "—",
},
pago_em: paidAt,
link_agendamento: process.env.NEXT_PUBLIC_AFTER_PAYMENT_REDIRECT ?? "/",
}
// Fire-and-forget — não bloqueia a resposta ao ASAAS
fetch(N8N_WEBHOOK_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(notificacao),
}).catch((err) => console.error("n8n webhook error:", err))
}
}
return NextResponse.json({ ok: true })
} catch (error) {
console.error("Webhook processing error:", error)
return NextResponse.json({ error: "Processing failed" }, { status: 500 })
}
}

View File

@@ -0,0 +1,6 @@
// Rota legada do Stripe — substituída por /api/asaas-webhook
import { NextResponse } from "next/server"
export async function GET() {
return NextResponse.json({ message: "Use ASAAS checkout via Server Actions" }, { status: 410 })
}

View File

@@ -0,0 +1,48 @@
import { NextResponse } from "next/server"
import { createServiceClient } from "@/lib/supabase"
import { buscarPagamentoAsaas, mapearStatus } from "@/lib/asaas"
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const supabase = createServiceClient()
const { data, error } = await supabase
.from("pedidos")
.select(
"id, status, valor_centavos, metodo_pagamento, pix_copia_cola, pix_qrcode_url, asaas_invoice_url, asaas_payment_id, paid_at, clientes(nome, email), produtos(nome, validade, midia)"
)
.eq("id", id)
.single()
if (error || !data) {
return NextResponse.json({ error: "Pedido não encontrado" }, { status: 404 })
}
// Se ainda está pendente, consulta o ASAAS diretamente para pegar status atualizado
if (data.status === "PENDING" && data.asaas_payment_id) {
try {
const asaasPayment = await buscarPagamentoAsaas(data.asaas_payment_id)
const novoStatus = mapearStatus(asaasPayment.status)
if (novoStatus !== "PENDING") {
const isPago = novoStatus === "RECEIVED" || novoStatus === "CONFIRMED"
const updateData: Record<string, unknown> = { status: novoStatus }
if (isPago) updateData.paid_at = new Date().toISOString()
await supabase
.from("pedidos")
.update(updateData)
.eq("id", id)
return NextResponse.json({ ...data, status: novoStatus, paid_at: isPago ? updateData.paid_at : null })
}
} catch {
// Falha silenciosa — retorna o que tem no banco
}
}
return NextResponse.json(data)
}

View File

@@ -0,0 +1,45 @@
import { NextResponse } from "next/server"
import { createServiceClient } from "@/lib/supabase"
import { buscarPagamentoAsaas, mapearStatus } from "@/lib/asaas"
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const supabase = createServiceClient()
const { data: pedido } = await supabase
.from("pedidos")
.select("id, status, asaas_payment_id")
.eq("id", id)
.single()
if (!pedido) {
return NextResponse.json({ error: "Pedido não encontrado" }, { status: 404 })
}
if (!pedido.asaas_payment_id) {
return NextResponse.json({ error: "Pedido sem ID ASAAS" }, { status: 400 })
}
try {
const asaasPayment = await buscarPagamentoAsaas(pedido.asaas_payment_id)
const novoStatus = mapearStatus(asaasPayment.status)
const isPago = novoStatus === "RECEIVED" || novoStatus === "CONFIRMED"
const updateData: Record<string, unknown> = { status: novoStatus }
if (isPago && !pedido.status.includes("RECEIVED") && !pedido.status.includes("CONFIRMED")) {
updateData.paid_at = new Date().toISOString()
}
await supabase.from("pedidos").update(updateData).eq("id", id)
return NextResponse.json({ status: novoStatus, asaas_status: asaasPayment.status })
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : "Erro ao consultar ASAAS" },
{ status: 500 }
)
}
}

6
app/api/webhook/route.ts Normal file
View File

@@ -0,0 +1,6 @@
// Rota legada do Stripe — substituída por /api/asaas-webhook
import { NextResponse } from "next/server"
export async function POST() {
return NextResponse.json({ message: "Use /api/asaas-webhook" }, { status: 410 })
}