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(); function logJson(level, fields) { const entry = { component: "shopdmPayWebhook", timestamp: new Date().toISOString(), ...fields, }; const line = JSON.stringify(entry); if (level === "error") { console.error(line); } else if (level === "warn") { console.warn(line); } else { console.log(line); } } function sendJson(res, status, body) { res.status(status).json({ component: "shopdmPayWebhook", ...body, }); } /** * 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") { sendJson(res, 405, { received: false, signatureMatched: false, status: "method_not_allowed", }); return; } const payload = req.body; const invoiceId = payload.metadata?.invoice_id || null; const isLive = payload.live === true; const webhookEnv = isLive ? "production" : "dev"; // Verify webhook signature using HMAC-SHA256 // Try both raw body and sorted-key approaches (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) { logJson("error", { received: true, event: payload.event || null, invoiceId, environment: webhookEnv, live: payload.live, signatureHeaderPresent: false, signatureMatched: false, status: "missing_signature", }); sendJson(res, 401, { received: true, event: payload.event || null, invoiceId, environment: webhookEnv, live: payload.live, signatureHeaderPresent: false, signatureMatched: false, status: "unauthorized", }); return; } const sortedPayload = JSON.stringify(payload, Object.keys(payload).sort()); const rawBody = req.rawBody ? req.rawBody.toString("utf8") : JSON.stringify(payload); const isValid = secrets.some((secret) => { // Try sorted keys const sortedHash = crypto .createHmac("sha256", secret) .update(sortedPayload, "utf8") .digest("hex"); if (signature === sortedHash) return true; // Try raw body const rawHash = crypto .createHmac("sha256", secret) .update(rawBody, "utf8") .digest("hex"); return signature === rawHash; }); if (!isValid) { logJson("error", { received: true, event: payload.event || null, invoiceId, environment: webhookEnv, live: payload.live, signatureHeaderPresent: true, signatureMatched: false, status: "invalid_signature", }); sendJson(res, 401, { received: true, event: payload.event || null, invoiceId, environment: webhookEnv, live: payload.live, signatureHeaderPresent: true, signatureMatched: false, status: "unauthorized", }); return; } logJson("info", { received: true, event: payload.event || null, invoiceId, environment: webhookEnv, live: payload.live, signatureHeaderPresent: true, signatureMatched: true, signatureAlgorithm: "HMAC-SHA256", status: "signature_verified", }); } // Only process successful payments if (payload.event !== "payment.successful" && payload.event !== "payment.success") { sendJson(res, 200, { received: true, event: payload.event || null, invoiceId, environment: webhookEnv, live: payload.live, signatureMatched: true, status: "event_ignored", }); return; } if (!invoiceId) { logJson("warn", { received: true, event: payload.event, invoiceId, environment: webhookEnv, live: payload.live, signatureMatched: true, status: "missing_invoice_id", }); sendJson(res, 200, { received: true, event: payload.event, invoiceId, environment: webhookEnv, live: payload.live, signatureMatched: true, status: "no_invoice_id", }); return; } try { const pendingRef = db.collection("pendingDonations").doc(invoiceId); const donorRef = db.collection("donors").doc(invoiceId); const paidAmount = payload.data?.amount_xcd; const paymentId = payload.data?.object_id || ""; const result = await db.runTransaction(async (transaction) => { const pendingDoc = await transaction.get(pendingRef); if (!pendingDoc.exists) { return { action: "no_matching_pending_donation" }; } const pending = pendingDoc.data(); const pendingEnv = pending.env || "production"; // Ensure the webhook environment matches the pending donation environment if (pendingEnv !== webhookEnv) { return { action: "environment_mismatch", pendingEnv, }; } // Idempotency: once pending donation is confirmed, duplicate events are no-ops. if (pending.status === "confirmed") { return { action: "already_processed", pendingEnv, amount: Number(paidAmount || pending.amount), }; } const amount = Number(paidAmount || pending.amount); // Deterministic donor document ID prevents duplicate donor records for one invoice. transaction.set(donorRef, { name: pending.name, amount, classYear: pending.classYear || "", message: pending.message || "", anonymous: !!pending.anonymous, status: "confirmed", env: webhookEnv, pendingDonationId: invoiceId, paymentId, date: FieldValue.serverTimestamp(), }); transaction.update(pendingRef, { status: "confirmed", confirmedAt: FieldValue.serverTimestamp(), paymentId, }); return { action: "confirmed", pendingEnv, amount, }; }); const status = result.action === "confirmed" ? "donation_confirmed" : result.action; logJson("info", { received: true, event: payload.event, invoiceId, paymentId, environment: webhookEnv, pendingEnvironment: result.pendingEnv || null, live: payload.live, signatureMatched: true, idempotencyKey: invoiceId, idempotencyAction: result.action, amount: result.amount || null, status, }); sendJson(res, 200, { received: true, event: payload.event, invoiceId, paymentId, environment: webhookEnv, pendingEnvironment: result.pendingEnv || null, live: payload.live, signatureMatched: true, idempotencyKey: invoiceId, idempotencyAction: result.action, amount: result.amount || null, status, }); } catch (error) { logJson("error", { received: true, event: payload.event || null, invoiceId, environment: webhookEnv, live: payload.live, signatureMatched: true, status: "processing_error", errorMessage: error.message, }); sendJson(res, 500, { received: true, event: payload.event || null, invoiceId, environment: webhookEnv, live: payload.live, signatureMatched: true, status: "processing_error", }); } });