updated prod secret
This commit is contained in:
@@ -6,6 +6,29 @@ 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)
|
||||
*
|
||||
@@ -18,13 +41,21 @@ exports.shopdmPayWebhook = onRequest(
|
||||
async (req, res) => {
|
||||
// Only accept POST requests
|
||||
if (req.method !== "POST") {
|
||||
res.status(405).send("Method not allowed");
|
||||
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 sorted keys + HMAC-SHA256 (per Shopdm Pay docs)
|
||||
// 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,
|
||||
@@ -33,109 +64,232 @@ exports.shopdmPayWebhook = onRequest(
|
||||
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");
|
||||
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) => {
|
||||
const expected = crypto
|
||||
// Try sorted keys
|
||||
const sortedHash = crypto
|
||||
.createHmac("sha256", secret)
|
||||
.update(sortedPayload, "utf8")
|
||||
.digest("hex");
|
||||
return signature === expected;
|
||||
if (signature === sortedHash) return true;
|
||||
|
||||
// Try raw body
|
||||
const rawHash = crypto
|
||||
.createHmac("sha256", secret)
|
||||
.update(rawBody, "utf8")
|
||||
.digest("hex");
|
||||
return signature === rawHash;
|
||||
});
|
||||
|
||||
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");
|
||||
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;
|
||||
}
|
||||
|
||||
console.log("Webhook signature verified successfully");
|
||||
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") {
|
||||
res.status(200).send("Event ignored");
|
||||
sendJson(res, 200, {
|
||||
received: true,
|
||||
event: payload.event || null,
|
||||
invoiceId,
|
||||
environment: webhookEnv,
|
||||
live: payload.live,
|
||||
signatureMatched: true,
|
||||
status: "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");
|
||||
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 {
|
||||
// Look up the pending donation
|
||||
const pendingRef = db.collection("pendingDonations").doc(invoiceId);
|
||||
const pendingDoc = await pendingRef.get();
|
||||
const donorRef = db.collection("donors").doc(invoiceId);
|
||||
const paidAmount = payload.data?.amount_xcd;
|
||||
const paymentId = payload.data?.object_id || "";
|
||||
|
||||
if (!pendingDoc.exists) {
|
||||
console.log(`No pending donation found for invoice_id: ${invoiceId}`);
|
||||
res.status(200).send("OK - no matching pending donation");
|
||||
return;
|
||||
}
|
||||
const result = await db.runTransaction(async (transaction) => {
|
||||
const pendingDoc = await transaction.get(pendingRef);
|
||||
|
||||
const pending = pendingDoc.data();
|
||||
if (!pendingDoc.exists) {
|
||||
return { action: "no_matching_pending_donation" };
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
const pending = pendingDoc.data();
|
||||
const pendingEnv = pending.env || "production";
|
||||
|
||||
// Check if already processed (idempotency)
|
||||
if (pending.status === "confirmed") {
|
||||
console.log(`Donation ${invoiceId} already confirmed, skipping`);
|
||||
res.status(200).send("OK - already processed");
|
||||
return;
|
||||
}
|
||||
// Ensure the webhook environment matches the pending donation environment
|
||||
if (pendingEnv !== webhookEnv) {
|
||||
return {
|
||||
action: "environment_mismatch",
|
||||
pendingEnv,
|
||||
};
|
||||
}
|
||||
|
||||
// Get the actual payment amount from the webhook
|
||||
const paidAmount = payload.data?.amount_xcd || pending.amount;
|
||||
// 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),
|
||||
};
|
||||
}
|
||||
|
||||
// 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(),
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
||||
// Mark the pending donation as confirmed
|
||||
await pendingRef.update({
|
||||
status: "confirmed",
|
||||
confirmedAt: FieldValue.serverTimestamp(),
|
||||
paymentId: payload.data?.object_id || "",
|
||||
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,
|
||||
});
|
||||
|
||||
console.log(`Donation confirmed for invoice_id: ${invoiceId}, amount: ${paidAmount}`);
|
||||
res.status(200).send("OK");
|
||||
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) {
|
||||
console.error("Error processing webhook:", error);
|
||||
res.status(500).send("Internal server 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",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user