updated prod secret

This commit is contained in:
2026-06-03 16:59:15 -04:00
parent 78ddcddba5
commit 1af42b790c
9 changed files with 318 additions and 76 deletions

6
.env.development.example Normal file
View File

@@ -0,0 +1,6 @@
VITE_FIREBASE_API_KEY=replace-with-dev-api-key
VITE_FIREBASE_AUTH_DOMAIN=replace-with-dev-project.firebaseapp.com
VITE_FIREBASE_PROJECT_ID=replace-with-dev-project-id
VITE_FIREBASE_STORAGE_BUCKET=replace-with-dev-project.firebasestorage.app
VITE_FIREBASE_MESSAGING_SENDER_ID=replace-with-dev-sender-id
VITE_FIREBASE_APP_ID=replace-with-dev-app-id

6
.env.production Normal file
View File

@@ -0,0 +1,6 @@
VITE_FIREBASE_API_KEY=AIzaSyAA7nmvia_CrPUnbong7xTF7vcoRdhXbyw
VITE_FIREBASE_AUTH_DOMAIN=whsfund-c5e40.firebaseapp.com
VITE_FIREBASE_PROJECT_ID=whsfund-c5e40
VITE_FIREBASE_STORAGE_BUCKET=whsfund-c5e40.firebasestorage.app
VITE_FIREBASE_MESSAGING_SENDER_ID=395581871999
VITE_FIREBASE_APP_ID=1:395581871999:web:919c94680146e45fd06c4a

View File

@@ -1,5 +1,7 @@
{ {
"projects": { "projects": {
"default": "whsfund-c5e40" "default": "whsfund-c5e40",
"production": "whsfund-c5e40",
"development": "whsfund-dev-c5e40"
} }
} }

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
functions/node_modules/
.env
.env.local
.env.*.local
.env.development

57
FIREBASE_ENV.md Normal file
View File

@@ -0,0 +1,57 @@
# Firebase Environments
The frontend Firebase project is selected by Vite mode.
## Production
`npm run build` uses `.env.production`.
The current production Firebase project is:
```text
whsfund-c5e40
```
## Development
`npm run dev` uses `.env.development`.
The development Firebase project is:
```text
whsfund-dev-c5e40
```
The local `.env.development` file should contain the web app config for this
project. If it needs to be recreated:
```bash
cp .env.development.example .env.development
npm run dev
```
The Shopdm Pay integration already uses the Vite mode too:
- development: `wesley-high-school` merchant at `https://pay-dm-dev.web.app/wesley-high-school`, pending donations marked `env: "dev"`
- production: production merchant, production payment URL, pending donations marked `env: "production"`
## Firebase CLI
The Firebase CLI has aliases for both projects:
```bash
firebase deploy --project development
firebase deploy --project production
```
Local development writes pending donations to the development Firebase project.
To test full Shopdm Pay confirmation, deploy the Cloud Function to the
development project, set `SHOPDM_MERCHANT_SECRET_DEV` in that project, and
configure the Shopdm dev merchant webhook to call that development function URL.
Firebase secrets require the development project to be on the Blaze plan.
Development webhook URL:
```text
https://us-central1-whsfund-dev-c5e40.cloudfunctions.net/shopdmPayWebhook
```

View File

@@ -6,6 +6,29 @@ const crypto = require("crypto");
initializeApp(); initializeApp();
const db = getFirestore(); 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) * Shopdm Pay Webhook Handler (v2)
* *
@@ -18,13 +41,21 @@ exports.shopdmPayWebhook = onRequest(
async (req, res) => { async (req, res) => {
// Only accept POST requests // Only accept POST requests
if (req.method !== "POST") { if (req.method !== "POST") {
res.status(405).send("Method not allowed"); sendJson(res, 405, {
received: false,
signatureMatched: false,
status: "method_not_allowed",
});
return; return;
} }
const payload = req.body; 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 = [ const secrets = [
process.env.SHOPDM_MERCHANT_SECRET, process.env.SHOPDM_MERCHANT_SECRET,
process.env.SHOPDM_MERCHANT_SECRET_DEV, process.env.SHOPDM_MERCHANT_SECRET_DEV,
@@ -33,109 +64,232 @@ exports.shopdmPayWebhook = onRequest(
if (secrets.length > 0) { if (secrets.length > 0) {
const signature = req.headers["x-shopdm-signature"]; const signature = req.headers["x-shopdm-signature"];
if (!signature) { if (!signature) {
console.error("Missing X-Shopdm-Signature header"); logJson("error", {
res.status(401).send("Unauthorized"); 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; return;
} }
const sortedPayload = JSON.stringify(payload, Object.keys(payload).sort()); 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 isValid = secrets.some((secret) => {
const expected = crypto // Try sorted keys
const sortedHash = crypto
.createHmac("sha256", secret) .createHmac("sha256", secret)
.update(sortedPayload, "utf8") .update(sortedPayload, "utf8")
.digest("hex"); .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) { if (!isValid) {
console.error("Invalid webhook signature"); logJson("error", {
console.error("Received signature:", signature); received: true,
console.error("Payload keys:", Object.keys(payload).sort().join(", ")); event: payload.event || null,
console.error("Secrets tried:", secrets.length); invoiceId,
res.status(401).send("Unauthorized"); 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; 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 // Only process successful payments
if (payload.event !== "payment.successful" && payload.event !== "payment.success") { 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; 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) { if (!invoiceId) {
console.log("No invoice_id in webhook payload, skipping donor creation"); logJson("warn", {
res.status(200).send("OK - no invoice_id"); 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; return;
} }
try { try {
// Look up the pending donation
const pendingRef = db.collection("pendingDonations").doc(invoiceId); 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) { const result = await db.runTransaction(async (transaction) => {
console.log(`No pending donation found for invoice_id: ${invoiceId}`); const pendingDoc = await transaction.get(pendingRef);
res.status(200).send("OK - no matching pending donation");
return;
}
const pending = pendingDoc.data(); if (!pendingDoc.exists) {
return { action: "no_matching_pending_donation" };
}
// Ensure the webhook environment matches the pending donation environment const pending = pendingDoc.data();
const pendingEnv = pending.env || "production"; 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) // Ensure the webhook environment matches the pending donation environment
if (pending.status === "confirmed") { if (pendingEnv !== webhookEnv) {
console.log(`Donation ${invoiceId} already confirmed, skipping`); return {
res.status(200).send("OK - already processed"); action: "environment_mismatch",
return; pendingEnv,
} };
}
// Get the actual payment amount from the webhook // Idempotency: once pending donation is confirmed, duplicate events are no-ops.
const paidAmount = payload.data?.amount_xcd || pending.amount; if (pending.status === "confirmed") {
return {
action: "already_processed",
pendingEnv,
amount: Number(paidAmount || pending.amount),
};
}
// Create the confirmed donor record const amount = Number(paidAmount || pending.amount);
await db.collection("donors").add({
name: pending.name, // Deterministic donor document ID prevents duplicate donor records for one invoice.
amount: Number(paidAmount), transaction.set(donorRef, {
classYear: pending.classYear || "", name: pending.name,
message: pending.message || "", amount,
anonymous: !!pending.anonymous, classYear: pending.classYear || "",
status: "confirmed", message: pending.message || "",
env: webhookEnv, anonymous: !!pending.anonymous,
pendingDonationId: invoiceId, status: "confirmed",
paymentId: payload.data?.object_id || "", env: webhookEnv,
date: FieldValue.serverTimestamp(), 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 const status = result.action === "confirmed" ? "donation_confirmed" : result.action;
await pendingRef.update({ logJson("info", {
status: "confirmed", received: true,
confirmedAt: FieldValue.serverTimestamp(), event: payload.event,
paymentId: payload.data?.object_id || "", 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}`); sendJson(res, 200, {
res.status(200).send("OK"); 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) { } catch (error) {
console.error("Error processing webhook:", error); logJson("error", {
res.status(500).send("Internal server 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",
});
} }
}); });

View File

@@ -25,7 +25,7 @@ export function subscribeToDonors(callback) {
const donors = []; const donors = [];
snapshot.forEach((doc) => { snapshot.forEach((doc) => {
const data = doc.data(); const data = doc.data();
if (data.env === 'dev') return; // skip test donations if (import.meta.env.PROD && data.env === 'dev') return; // skip test donations on production
donors.push({ id: doc.id, ...data }); donors.push({ id: doc.id, ...data });
}); });
callback(donors); callback(donors);

View File

@@ -3,14 +3,24 @@ import { getAuth } from 'firebase/auth';
import { getFirestore } from 'firebase/firestore'; import { getFirestore } from 'firebase/firestore';
const firebaseConfig = { const firebaseConfig = {
apiKey: "AIzaSyAA7nmvia_CrPUnbong7xTF7vcoRdhXbyw", apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
authDomain: "whsfund-c5e40.firebaseapp.com", authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
projectId: "whsfund-c5e40", projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
storageBucket: "whsfund-c5e40.firebasestorage.app", storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
messagingSenderId: "395581871999", messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
appId: "1:395581871999:web:919c94680146e45fd06c4a", appId: import.meta.env.VITE_FIREBASE_APP_ID,
}; };
const missingConfig = Object.entries(firebaseConfig)
.filter(([, value]) => !value)
.map(([key]) => key);
if (missingConfig.length > 0) {
throw new Error(
`Missing Firebase config for ${import.meta.env.MODE}: ${missingConfig.join(', ')}`
);
}
const app = initializeApp(firebaseConfig); const app = initializeApp(firebaseConfig);
export const auth = getAuth(app); export const auth = getAuth(app);
export const db = getFirestore(app); export const db = getFirestore(app);

View File

@@ -6,7 +6,7 @@ const pendingRef = collection(db, 'pendingDonations');
const IS_DEV = import.meta.env.DEV; const IS_DEV = import.meta.env.DEV;
const MERCHANT_HANDLE = IS_DEV const MERCHANT_HANDLE = IS_DEV
? 'dominica-methodist-circuit-dev' ? 'wesley-high-school'
: 'dominica-methodist-circuit'; : 'dominica-methodist-circuit';
const PAYMENT_BASE_URL = IS_DEV const PAYMENT_BASE_URL = IS_DEV