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"); } });