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:
28
app/api/agendamento/[id]/status/route.ts
Normal file
28
app/api/agendamento/[id]/status/route.ts
Normal 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 })
|
||||
}
|
||||
102
app/api/asaas-webhook/route.ts
Normal file
102
app/api/asaas-webhook/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
6
app/api/checkout/route.ts
Normal file
6
app/api/checkout/route.ts
Normal 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 })
|
||||
}
|
||||
48
app/api/pedido/[id]/route.ts
Normal file
48
app/api/pedido/[id]/route.ts
Normal 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)
|
||||
}
|
||||
45
app/api/pedido/[id]/sync/route.ts
Normal file
45
app/api/pedido/[id]/sync/route.ts
Normal 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
6
app/api/webhook/route.ts
Normal 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 })
|
||||
}
|
||||
Reference in New Issue
Block a user