updated prod secret
This commit is contained in:
6
.env.development.example
Normal file
6
.env.development.example
Normal 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
6
.env.production
Normal 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
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
{
|
{
|
||||||
"projects": {
|
"projects": {
|
||||||
"default": "whsfund-c5e40"
|
"default": "whsfund-c5e40",
|
||||||
|
"production": "whsfund-c5e40",
|
||||||
|
"development": "whsfund-dev-c5e40"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
functions/node_modules/
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
.env.development
|
||||||
57
FIREBASE_ENV.md
Normal file
57
FIREBASE_ENV.md
Normal 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
|
||||||
|
```
|
||||||
@@ -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 || "";
|
||||||
|
|
||||||
|
const result = await db.runTransaction(async (transaction) => {
|
||||||
|
const pendingDoc = await transaction.get(pendingRef);
|
||||||
|
|
||||||
if (!pendingDoc.exists) {
|
if (!pendingDoc.exists) {
|
||||||
console.log(`No pending donation found for invoice_id: ${invoiceId}`);
|
return { action: "no_matching_pending_donation" };
|
||||||
res.status(200).send("OK - no matching pending donation");
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const pending = pendingDoc.data();
|
const pending = pendingDoc.data();
|
||||||
|
const pendingEnv = pending.env || "production";
|
||||||
|
|
||||||
// Ensure the webhook environment matches the pending donation environment
|
// Ensure the webhook environment matches the pending donation environment
|
||||||
const pendingEnv = pending.env || "production";
|
|
||||||
if (pendingEnv !== webhookEnv) {
|
if (pendingEnv !== webhookEnv) {
|
||||||
console.warn(
|
return {
|
||||||
`Environment mismatch: pending donation is ${pendingEnv} but webhook is ${webhookEnv}. Skipping.`
|
action: "environment_mismatch",
|
||||||
);
|
pendingEnv,
|
||||||
res.status(200).send("OK - environment mismatch");
|
};
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if already processed (idempotency)
|
// Idempotency: once pending donation is confirmed, duplicate events are no-ops.
|
||||||
if (pending.status === "confirmed") {
|
if (pending.status === "confirmed") {
|
||||||
console.log(`Donation ${invoiceId} already confirmed, skipping`);
|
return {
|
||||||
res.status(200).send("OK - already processed");
|
action: "already_processed",
|
||||||
return;
|
pendingEnv,
|
||||||
|
amount: Number(paidAmount || pending.amount),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the actual payment amount from the webhook
|
const amount = Number(paidAmount || pending.amount);
|
||||||
const paidAmount = payload.data?.amount_xcd || pending.amount;
|
|
||||||
|
|
||||||
// Create the confirmed donor record
|
// Deterministic donor document ID prevents duplicate donor records for one invoice.
|
||||||
await db.collection("donors").add({
|
transaction.set(donorRef, {
|
||||||
name: pending.name,
|
name: pending.name,
|
||||||
amount: Number(paidAmount),
|
amount,
|
||||||
classYear: pending.classYear || "",
|
classYear: pending.classYear || "",
|
||||||
message: pending.message || "",
|
message: pending.message || "",
|
||||||
anonymous: !!pending.anonymous,
|
anonymous: !!pending.anonymous,
|
||||||
status: "confirmed",
|
status: "confirmed",
|
||||||
env: webhookEnv,
|
env: webhookEnv,
|
||||||
pendingDonationId: invoiceId,
|
pendingDonationId: invoiceId,
|
||||||
paymentId: payload.data?.object_id || "",
|
paymentId,
|
||||||
date: FieldValue.serverTimestamp(),
|
date: FieldValue.serverTimestamp(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mark the pending donation as confirmed
|
transaction.update(pendingRef, {
|
||||||
await pendingRef.update({
|
|
||||||
status: "confirmed",
|
status: "confirmed",
|
||||||
confirmedAt: FieldValue.serverTimestamp(),
|
confirmedAt: FieldValue.serverTimestamp(),
|
||||||
paymentId: payload.data?.object_id || "",
|
paymentId,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`Donation confirmed for invoice_id: ${invoiceId}, amount: ${paidAmount}`);
|
return {
|
||||||
res.status(200).send("OK");
|
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) {
|
} 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",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user