Files
whsfund/functions/index.js
2026-03-23 15:29:13 -04:00

125 lines
3.5 KiB
JavaScript

const { onRequest } = require("firebase-functions/v2/https");
const { initializeApp } = require("firebase-admin/app");
const { getFirestore, FieldValue } = require("firebase-admin/firestore");
const crypto = require("crypto");
initializeApp();
const db = getFirestore();
/**
* Shopdm Pay Webhook Handler
*
* Receives payment.successful events from Shopdm Pay.
* Matches the invoice_id to a pending donation and creates
* a confirmed donor record.
*/
exports.shopdmPayWebhook = onRequest(
{ secrets: ["SHOPDM_MERCHANT_SECRET_DEV"] },
async (req, res) => {
// Only accept POST requests
if (req.method !== "POST") {
res.status(405).send("Method not allowed");
return;
}
const payload = req.body;
// Verify webhook signature
// Supports both dev and prod secrets
const secrets = [
process.env.SHOPDM_MERCHANT_SECRET,
process.env.SHOPDM_MERCHANT_SECRET_DEV,
].filter(Boolean);
if (secrets.length > 0) {
const signature = req.headers["x-shopdm-signature"];
if (!signature) {
console.error("Missing X-Shopdm-Signature header");
res.status(401).send("Unauthorized");
return;
}
const sortedPayload = JSON.stringify(
payload,
Object.keys(payload).sort()
);
const isValid = secrets.some((secret) => {
const expected = crypto
.createHmac("sha256", secret)
.update(sortedPayload, "utf8")
.digest("hex");
return signature === expected;
});
if (!isValid) {
console.error("Invalid webhook signature");
res.status(401).send("Unauthorized");
return;
}
}
// Only process successful payments
if (payload.event !== "payment.successful") {
res.status(200).send("Event ignored");
return;
}
const invoiceId = payload.metadata?.invoice_id;
if (!invoiceId) {
console.log("No invoice_id in webhook payload, skipping donor creation");
res.status(200).send("OK - no invoice_id");
return;
}
try {
// Look up the pending donation
const pendingRef = db.collection("pendingDonations").doc(invoiceId);
const pendingDoc = await pendingRef.get();
if (!pendingDoc.exists) {
console.log(`No pending donation found for invoice_id: ${invoiceId}`);
res.status(200).send("OK - no matching pending donation");
return;
}
const pending = pendingDoc.data();
// Check if already processed (idempotency)
if (pending.status === "confirmed") {
console.log(`Donation ${invoiceId} already confirmed, skipping`);
res.status(200).send("OK - already processed");
return;
}
// Get the actual payment amount from the webhook
const paidAmount = payload.data?.amount_xcd || pending.amount;
// Create the confirmed donor record
await db.collection("donors").add({
name: pending.name,
amount: Number(paidAmount),
classYear: pending.classYear || "",
message: pending.message || "",
anonymous: !!pending.anonymous,
status: "confirmed",
pendingDonationId: invoiceId,
paymentId: payload.data?.object_id || "",
date: FieldValue.serverTimestamp(),
});
// Mark the pending donation as confirmed
await pendingRef.update({
status: "confirmed",
confirmedAt: FieldValue.serverTimestamp(),
paymentId: payload.data?.object_id || "",
});
console.log(`Donation confirmed for invoice_id: ${invoiceId}, amount: ${paidAmount}`);
res.status(200).send("OK");
} catch (error) {
console.error("Error processing webhook:", error);
res.status(500).send("Internal server error");
}
});