156 lines
5.1 KiB
TypeScript
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 },
|
|
);
|
|
}
|
|
}
|