Files
whsfund/functions/index.js
2026-06-03 16:59:15 -04:00

296 lines
7.8 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();
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",
});
}
});