Files
pagodera/app/api/webhooks/abacatepay/route.ts
T
2025-11-11 03:08:36 -03:00

156 lines
5.1 KiB
TypeScript

import { type NextRequest, NextResponse } from "next/server";
import { markAsPaid } from "@/lib/payment-cache";
import {
checkRateLimit,
createRateLimitResponse,
getClientIP,
} from "@/lib/rate-limiter";
import { secureLogger } from "@/lib/secure-logger";
// Whitelist de IPs do AbacatePay (configurável via env)
const ABACATEPAY_IPS = process.env.ABACATEPAY_WHITELIST_IPS?.split(",") || [];
export async function POST(request: NextRequest) {
try {
// Rate limiting
const ip = getClientIP(request);
const rateLimit = checkRateLimit(ip, "/api/webhooks/abacatepay");
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit.resetAt);
}
// Validar IP de origem (se configurado)
if (ABACATEPAY_IPS.length > 0 && !ABACATEPAY_IPS.includes(ip)) {
secureLogger.warn("Webhook rejeitado: IP não autorizado", { ip });
return NextResponse.json({ error: "IP não autorizado" }, { status: 403 });
}
const payload = await request.text();
// AbacatePay envia webhookSecret como query param
const { searchParams } = new URL(request.url);
const webhookSecret = searchParams.get("webhookSecret");
const expectedSecret = process.env.ABACATEPAY_WEBHOOK_SECRET;
// Validar secret
if (!webhookSecret || !expectedSecret || webhookSecret !== expectedSecret) {
secureLogger.warn("Webhook rejeitado: secret inválido ou ausente");
return NextResponse.json({ error: "Secret inválido" }, { status: 401 });
}
secureLogger.info("Webhook autenticado com sucesso");
const event = JSON.parse(payload);
secureLogger.debug("Webhook recebido", event);
// Validar timestamp do evento (prevenir replay attacks)
const eventTimestamp = event.timestamp || event.data?.pixQrCode?.createdAt;
if (eventTimestamp) {
const eventTime = new Date(eventTimestamp).getTime();
const now = Date.now();
const timeDiff = Math.abs(now - eventTime);
// Rejeitar eventos com mais de 5 minutos de diferença
if (timeDiff > 5 * 60 * 1000) {
secureLogger.warn("Webhook rejeitado: timestamp inválido", {
timeDiff: `${timeDiff / 1000}s`,
});
return NextResponse.json(
{ error: "Timestamp inválido" },
{ status: 400 },
);
}
}
// Verificar se é evento de pagamento confirmado
const isPaidEvent =
event.event === "billing.paid" ||
event.event === "pix.paid" ||
event.data?.pixQrCode?.status === "PAID";
if (isPaidEvent) {
// Extrair dados do PIX
const pixQrCode = event.data.pixQrCode;
const payment = event.data.payment;
if (!pixQrCode || pixQrCode.status !== "PAID") {
secureLogger.info("Webhook recebido mas status não é PAID");
return NextResponse.json({ success: true });
}
secureLogger.info("Doação confirmada via webhook", {
pixId: pixQrCode.id,
amount: pixQrCode.amount,
});
// Detectar se é doação anônima ou identificada
const isAnonymous = !pixQrCode.customer;
// Log estruturado para futura persistência em banco de dados
const donationRecord: any = {
type: "donation_paid",
pixId: pixQrCode.id,
amount: pixQrCode.amount,
fee: payment?.fee,
method: payment?.method,
anonymous: isAnonymous,
paidAt: new Date().toISOString(),
createdAt: new Date().toISOString(),
devMode: event.devMode || false,
};
// Adicionar dados do customer se disponível (doação identificada)
if (!isAnonymous && pixQrCode.customer?.metadata) {
donationRecord.customer = {
id: pixQrCode.customer.id,
name: pixQrCode.customer.metadata.name,
email: pixQrCode.customer.metadata.email,
cellphone: pixQrCode.customer.metadata.cellphone,
taxId: pixQrCode.customer.metadata.taxId,
};
secureLogger.info("Email disponível para agradecimento", {
maskedEmail: pixQrCode.customer.metadata.email,
});
}
secureLogger.info(
"Registro de pagamento criado",
secureLogger.mask(donationRecord),
);
// Salvar no cache para frontend detectar via polling
markAsPaid(pixQrCode.id, donationRecord);
// TODO: Quando implementar banco de dados, salvar aqui:
// await db.donations.create(donationRecord);
// TODO: Quando implementar email, enviar aqui:
// if (!isAnonymous && donationRecord.customer?.email) {
// await sendThankYouEmail({
// to: donationRecord.customer.email,
// donorName: donationRecord.customer.name,
// amount: donationRecord.amount,
// });
// }
// TODO: Atualizar estatísticas
// await updateDonationStats(donationRecord.amount);
} else {
secureLogger.info("Evento de webhook (não é pagamento)", {
event: event.event,
});
}
return NextResponse.json({ success: true });
} catch (error) {
secureLogger.error("Webhook error", error);
return NextResponse.json(
{ error: "Erro ao processar webhook" },
{ status: 500 },
);
}
}