diff --git a/.env.development.example b/.env.development.example new file mode 100644 index 0000000..28eddb9 --- /dev/null +++ b/.env.development.example @@ -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 diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..89c4969 --- /dev/null +++ b/.env.production @@ -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 diff --git a/.firebaserc b/.firebaserc index e53d434..09e449e 100644 --- a/.firebaserc +++ b/.firebaserc @@ -1,5 +1,7 @@ { "projects": { - "default": "whsfund-c5e40" + "default": "whsfund-c5e40", + "production": "whsfund-c5e40", + "development": "whsfund-dev-c5e40" } } diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..392fd74 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +functions/node_modules/ + +.env +.env.local +.env.*.local +.env.development diff --git a/FIREBASE_ENV.md b/FIREBASE_ENV.md new file mode 100644 index 0000000..e6e2e5f --- /dev/null +++ b/FIREBASE_ENV.md @@ -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 +``` diff --git a/functions/index.js b/functions/index.js index ba7496f..5fe8fda 100644 --- a/functions/index.js +++ b/functions/index.js @@ -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", + }); } }); diff --git a/src/donors.js b/src/donors.js index a58b8ae..b3b5661 100644 --- a/src/donors.js +++ b/src/donors.js @@ -25,7 +25,7 @@ export function subscribeToDonors(callback) { const donors = []; snapshot.forEach((doc) => { 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 }); }); callback(donors); diff --git a/src/firebase.js b/src/firebase.js index 889757c..501a84f 100644 --- a/src/firebase.js +++ b/src/firebase.js @@ -3,14 +3,24 @@ import { getAuth } from 'firebase/auth'; import { getFirestore } from 'firebase/firestore'; const firebaseConfig = { - apiKey: "AIzaSyAA7nmvia_CrPUnbong7xTF7vcoRdhXbyw", - authDomain: "whsfund-c5e40.firebaseapp.com", - projectId: "whsfund-c5e40", - storageBucket: "whsfund-c5e40.firebasestorage.app", - messagingSenderId: "395581871999", - appId: "1:395581871999:web:919c94680146e45fd06c4a", + apiKey: import.meta.env.VITE_FIREBASE_API_KEY, + authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN, + projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID, + storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET, + messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID, + 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); export const auth = getAuth(app); export const db = getFirestore(app); diff --git a/src/pending-donations.js b/src/pending-donations.js index 91adef0..b857988 100644 --- a/src/pending-donations.js +++ b/src/pending-donations.js @@ -6,7 +6,7 @@ const pendingRef = collection(db, 'pendingDonations'); const IS_DEV = import.meta.env.DEV; const MERCHANT_HANDLE = IS_DEV - ? 'dominica-methodist-circuit-dev' + ? 'wesley-high-school' : 'dominica-methodist-circuit'; const PAYMENT_BASE_URL = IS_DEV