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 (v2) * * 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", "SHOPDM_MERCHANT_SECRET_DEV"], invoker: "public" }, 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 using sorted keys + HMAC-SHA256 (per Shopdm Pay docs) 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"); console.error("Received signature:", signature); console.error("Payload keys:", Object.keys(payload).sort().join(", ")); console.error("Secrets tried:", secrets.length); res.status(401).send("Unauthorized"); return; } console.log("Webhook signature verified successfully"); } // Only process successful payments if (payload.event !== "payment.successful" && payload.event !== "payment.success") { res.status(200).send("Event ignored"); return; } // Determine environment from the live flag in the payload const isLive = payload.live === true; const webhookEnv = isLive ? "production" : "dev"; console.log(`Webhook environment: ${webhookEnv} (live=${payload.live})`); 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(); // Ensure the webhook environment matches the pending donation environment const pendingEnv = pending.env || "production"; if (pendingEnv !== webhookEnv) { console.warn( `Environment mismatch: pending donation is ${pendingEnv} but webhook is ${webhookEnv}. Skipping.` ); res.status(200).send("OK - environment mismatch"); return; } // 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", env: webhookEnv, 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"); } });