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