Pepguy's picture
Update app.js
175f3d8 verified
raw
history blame
25.8 kB
// paystack-webhook.js
// Node >= 14, install dependencies:
// npm install express firebase-admin axios
const express = require('express');
const crypto = require('crypto');
const admin = require('firebase-admin');
const axios = require('axios');
const app = express();
// -------------------------------------------------
// Environment variables (set these in your env/secrets)
// -------------------------------------------------
// - FIREBASE_CREDENTIALS: stringified JSON service account
// - PAYSTACK_DEMO_SECRET
// - PAYSTACK_LIVE_SECRET
// - PORT (optional)
// -------------------------------------------------
const {
FIREBASE_CREDENTIALS,
PAYSTACK_DEMO_SECRET,
PAYSTACK_LIVE_SECRET,
PORT,
} = process.env;
// choose which secret to use (dev/demo by default)
const PAYSTACK_SECRET = PAYSTACK_DEMO_SECRET || PAYSTACK_LIVE_SECRET;
if (!PAYSTACK_SECRET) {
console.warn('WARNING: PAYSTACK_SECRET is not set. Outgoing Paystack calls and webhook verification will fail.');
}
// Initialize Firebase Admin using credentials read from env
let db = null;
try {
if (FIREBASE_CREDENTIALS) {
const serviceAccount = JSON.parse(FIREBASE_CREDENTIALS);
admin.initializeApp({
credential: admin.credential.cert(serviceAccount),
});
db = admin.firestore();
console.log('Firebase admin initialized from FIREBASE_CREDENTIALS env var.');
} else {
// If admin was already initialized in the environment (e.g. GCP), this will succeed
if (!admin.apps.length) {
console.warn('FIREBASE_CREDENTIALS not provided. Firebase admin not initialized. Some operations will error if they require Firestore.');
} else {
db = admin.firestore();
}
}
} catch (err) {
console.error('Failed to initialize Firebase admin:', err);
// keep running — code paths that need db will guard and log appropriately
}
// -------------------------------------------------
// Utility: verify x-paystack-signature
// -------------------------------------------------
function verifyPaystackSignature(rawBodyBuffer, signatureHeader) {
if (!PAYSTACK_SECRET) return false;
if (!rawBodyBuffer) return false;
try {
const hmac = crypto.createHmac('sha512', PAYSTACK_SECRET);
hmac.update(rawBodyBuffer);
const expected = hmac.digest('hex');
// signatureHeader may be undefined - handle gracefully
if (!signatureHeader) return false;
return crypto.timingSafeEqual(Buffer.from(expected, 'utf8'), Buffer.from(String(signatureHeader), 'utf8'));
} catch (e) {
console.error('Error while verifying signature:', e);
return false;
}
}
// Helper: safe JSON parse
function safeParseJSON(raw) {
try {
return JSON.parse(raw);
} catch (err) {
return null;
}
}
// ------------------------------
// Expiry helper: getExpiryFromPlan
// ------------------------------
const EXTRA_FREE_MS = 2 * 60 * 60 * 1000; // 2 hours in ms
const WEEKLY_PLAN_IDS = new Set([
3311892,
3305738,
'PLN_ngz4l76whecrpkv',
'PLN_f7a3oagrpt47d5f',
]);
const MONTHLY_PLAN_IDS = new Set([
3305739,
'PLN_584ck56g65xhkum',
]);
function getExpiryFromPlan(planInput) {
let interval = null;
let planId = null;
let planCode = null;
if (!planInput) {
interval = null;
} else if (typeof planInput === 'object') {
planId = planInput.id ?? planInput.plan_id ?? null;
planCode = planInput.plan_code ?? planInput.planCode ?? null;
interval = (planInput.interval || planInput.billing_interval || null);
} else if (typeof planInput === 'number') {
planId = planInput;
} else if (typeof planInput === 'string') {
planCode = planInput;
const asNum = Number(planInput);
if (!Number.isNaN(asNum)) planId = asNum;
}
if (interval) {
interval = String(interval).toLowerCase();
if (interval.includes('week')) {
const expires = Date.now() + 7 * 24 * 60 * 60 * 1000 + EXTRA_FREE_MS;
return { expiresAtMs: expires, expiresAtIso: new Date(expires).toISOString() };
}
if (interval.includes('month')) {
const expires = Date.now() + 30 * 24 * 60 * 60 * 1000 + EXTRA_FREE_MS;
return { expiresAtMs: expires, expiresAtIso: new Date(expires).toISOString() };
}
if (interval.includes('hour')) {
// treat hour interval as a week by default (matching original logic)
const expires = Date.now() + 7 * 24 * 60 * 60 * 1000 + EXTRA_FREE_MS;
return { expiresAtMs: expires, expiresAtIso: new Date(expires).toISOString() };
}
}
if (planId && WEEKLY_PLAN_IDS.has(planId)) {
const expires = Date.now() + 7 * 24 * 60 * 60 * 1000 + EXTRA_FREE_MS;
return { expiresAtMs: expires, expiresAtIso: new Date(expires).toISOString() };
}
if (planCode && WEEKLY_PLAN_IDS.has(planCode)) {
const expires = Date.now() + 7 * 24 * 60 * 60 * 1000 + EXTRA_FREE_MS;
return { expiresAtMs: expires, expiresAtIso: new Date(expires).toISOString() };
}
if (planId && MONTHLY_PLAN_IDS.has(planId)) {
const expires = Date.now() + 30 * 24 * 60 * 60 * 1000 + EXTRA_FREE_MS;
return { expiresAtMs: expires, expiresAtIso: new Date(expires).toISOString() };
}
if (planCode && MONTHLY_PLAN_IDS.has(planCode)) {
const expires = Date.now() + 30 * 24 * 60 * 60 * 1000 + EXTRA_FREE_MS;
return { expiresAtMs: expires, expiresAtIso: new Date(expires).toISOString() };
}
const expires = Date.now() + 30 * 24 * 60 * 60 * 1000 + EXTRA_FREE_MS;
return { expiresAtMs: expires, expiresAtIso: new Date(expires).toISOString() };
}
// ------------------------------
// Helper: get userId from stored slug mapping
// - try doc id == slug
// - fallback: query where pageId == slug
// ------------------------------
async function getUserIdFromSlug(dbInstance, slugOrPageId) {
if (!slugOrPageId || !dbInstance) return null;
try {
// try doc id first (we store mapping under doc id === slug)
const doc = await dbInstance.collection('paystack-page-mappings').doc(String(slugOrPageId)).get();
if (doc.exists) {
const data = doc.data();
if (data?.userId) return data.userId;
}
// fallback: maybe caller passed pageId; search by pageId field
const q = await dbInstance.collection('paystack-page-mappings').where('pageId', '==', String(slugOrPageId)).limit(1).get();
if (!q.empty) {
const d = q.docs[0].data();
if (d?.userId) return d.userId;
}
return null;
} catch (e) {
console.error('Failed to read page mapping for slug/pageId:', slugOrPageId, e);
return null;
}
}
// ------------------------------
// Robust extractor for userId (tries metadata, custom_fields, customer metadata, slug mapping, email fallback)
// ------------------------------
async function extractUserIdFromPayload(data = {}, dbInstance) {
// 1) direct metadata on data
const metadata = data.metadata || {};
if (metadata.userId || metadata.user_id) return { userId: metadata.userId || metadata.user_id, source: 'metadata' };
// 2) customer.metadata
const custMeta = data.customer?.metadata || {};
if (custMeta.userId || custMeta.user_id) return { userId: custMeta.userId || custMeta.user_id, source: 'customer.metadata' };
// 3) top-level custom_fields (array)
if (Array.isArray(data.custom_fields)) {
for (const f of data.custom_fields) {
const name = (f.variable_name || f.display_name || '').toString().toLowerCase();
if ((name.includes('user') || name.includes('user_id') || name.includes('userid')) && f.value) {
return { userId: f.value, source: 'custom_fields' };
}
}
}
// 4) metadata.custom_fields
if (metadata.custom_fields) {
if (Array.isArray(metadata.custom_fields)) {
for (const f of metadata.custom_fields) {
const name = (f.variable_name || f.display_name || '').toString().toLowerCase();
if (name.includes('user') && f.value) return { userId: f.value, source: 'metadata.custom_fields' };
}
} else if (typeof metadata.custom_fields === 'object') {
for (const k of Object.keys(metadata.custom_fields)) {
if (k.toLowerCase().includes('user')) return { userId: metadata.custom_fields[k], source: 'metadata.custom_fields_object' };
}
}
}
// 5) slug / page id mapping fallback - many Paystack page webhooks include data.page.slug or data.page.id
const slugCandidate = data.slug || data.page?.slug || data.page?.id || metadata?.slug || metadata?.page_slug || null;
if (slugCandidate && dbInstance) {
const mappedUserId = await getUserIdFromSlug(dbInstance, slugCandidate);
if (mappedUserId) return { userId: mappedUserId, source: 'slug_mapping' };
}
// 6) email fallback (return email so caller can search)
if (data.customer?.email) return { userId: data.customer.email, source: 'email' };
// nothing found
return { userId: null, source: null };
}
// ------------------------------
// Helper: find user doc reference in Firestore (tries doc id and queries by fields)
// ------------------------------
async function findUserDocRef(dbInstance, { metadataUserId, email, paystackCustomerId } = {}) {
if (!dbInstance) return null;
const usersRef = dbInstance.collection('users');
if (metadataUserId) {
// if metadataUserId looks like an email, skip docId check and search by email
if (!String(metadataUserId).includes('@')) {
try {
const docRef = usersRef.doc(String(metadataUserId));
const snap = await docRef.get();
if (snap.exists) return docRef;
} catch (e) {
// continue to queries
}
}
// try equality queries on commonly used id fields
const idFields = ['userId', 'uid', 'id'];
for (const field of idFields) {
try {
const qSnap = await usersRef.where(field, '==', metadataUserId).limit(1).get();
if (!qSnap.empty) return qSnap.docs[0].ref;
} catch (e) {
// ignore and proceed
}
}
}
// If paystack_customer_id was provided, try that too
if (paystackCustomerId) {
try {
const qSnap = await usersRef.where('paystack_customer_id', '==', String(paystackCustomerId)).limit(1).get();
if (!qSnap.empty) return qSnap.docs[0].ref;
} catch (e) {
// ignore
}
}
if (email) {
try {
const qSnap = await usersRef.where('email', '==', email).limit(1).get();
if (!qSnap.empty) return qSnap.docs[0].ref;
} catch (e) {
// ignore
}
}
// Not found
return null;
}
// ------------------------------
// Cleanup that should run after a successful charge for the user
// - Deletes page mapping doc if slug provided and mapping matches user
// - Removes any "pending-payments" documents for the user (optional)
// - Writes a paymentCleanup log
// Customize this per your app's needs.
// ------------------------------
async function cleanUpAfterSuccessfulCharge(dbInstance, userDocRef, { slug, pageId, reference, email } = {}) {
if (!dbInstance || !userDocRef) return;
try {
// 1) delete mapping doc if exists and mapping.userId === user's id
if (slug) {
try {
const mapRef = dbInstance.collection('paystack-page-mappings').doc(String(slug));
const mapSnap = await mapRef.get();
if (mapSnap.exists) {
const map = mapSnap.data();
// if mapping.userId equals user doc id or equals user's email or similar, delete.
const userIdCandidate = userDocRef.id;
if (String(map.userId) === String(userIdCandidate) || (email && String(map.userId) === String(email))) {
await mapRef.delete();
console.log('Deleted paystack-page-mappings doc for slug:', slug);
} else {
console.log('Skipping deletion of mapping for slug (owner mismatch):', slug);
}
}
} catch (e) {
console.error('Error deleting paystack-page-mappings doc for slug:', slug, e);
}
}
// 2) optional: remove pending-payments documents (if your app stores them)
try {
const pendingRef = dbInstance.collection('pending-payments');
const q = await pendingRef.where('userId', '==', userDocRef.id).limit(50).get();
if (!q.empty) {
const batch = dbInstance.batch();
q.docs.forEach(doc => batch.delete(doc.ref));
await batch.commit();
console.log('Deleted pending-payments for user:', userDocRef.id);
}
} catch (e) {
// not critical
console.error('Failed to delete pending-payments (non-fatal):', e);
}
// 3) log the cleanup
try {
await dbInstance.collection('paystack-cleanups').add({
userRef: userDocRef.path,
slug: slug || null,
pageId: pageId ? String(pageId) : null,
reference: reference || null,
email: email || null,
cleanedAt: admin.firestore.FieldValue.serverTimestamp(),
});
} catch (e) {
console.error('Failed to log cleanup:', e);
}
} catch (e) {
console.error('Unexpected error during cleanup:', e);
}
}
// ------------------------
// Webhook endpoint (only this route uses express.raw so other routes can use express.json)
// ------------------------
app.post('/webhook/paystack', express.raw({ type: 'application/json', limit: '1mb' }), async (req, res) => {
const raw = req.body; // Buffer
const signature = req.get('x-paystack-signature') || req.get('X-Paystack-Signature');
// Verify signature
if (!signature || !verifyPaystackSignature(raw, signature)) {
console.warn('Paystack webhook signature verification failed. Signature header:', signature);
return res.status(400).json({ ok: false, message: 'Invalid signature' });
}
// Parse payload
const bodyStr = raw.toString('utf8');
const payload = safeParseJSON(bodyStr);
if (!payload) {
console.warn('Could not parse webhook JSON payload.');
return res.status(400).json({ ok: false, message: 'Invalid JSON' });
}
const event = (payload.event || payload.type || '').toString();
const isRefund = /refund/i.test(event);
const isChargeSuccess = /charge\.success/i.test(event);
const isInvoiceOrSubscription = /invoice\.(payment_succeeded|paid)|subscription\./i.test(event);
const isPayment = isChargeSuccess;
if (!db) {
console.error('Firestore (admin) not initialized; webhook will not persist data.');
}
if (isRefund || isPayment || /subscription\.create/i.test(event) || /subscription\.update/i.test(event)) {
console.log('--- PAYSTACK WEBHOOK (INTERESTING EVENT) ---');
console.log('event:', event);
// persist webhook for auditing (best-effort)
console.log(payload);
try {
if (db) {
await db.collection('paystack-webhooks').add({
event,
payload,
receivedAt: admin.firestore.FieldValue.serverTimestamp(),
});
}
} catch (err) {
console.error('Failed to persist webhook audit:', err);
}
const data = payload.data || {};
// Try to extract userId robustly (metadata/custom_fields/slug/email)
let metadataUserId = null;
let customerEmail = null;
let extractorSource = null;
let maybeSlug = null;
let maybePageId = null;
try {
const extracted = await extractUserIdFromPayload(data, db);
metadataUserId = extracted.userId || null;
extractorSource = extracted.source || null;
// If extractor returned an email, set as customerEmail
if (metadataUserId && String(metadataUserId).includes('@')) {
customerEmail = String(metadataUserId);
metadataUserId = null;
}
} catch (e) {
console.error('Error extracting userId from payload:', e);
}
// Also set explicit customer email if present
if (!customerEmail && data.customer?.email) customerEmail = data.customer.email;
// Save possible slug/pageId for cleanup/connection
maybeSlug = data.slug || data.page?.slug || data.metadata?.slug || null;
maybePageId = data.page?.id || data.page_id || data.metadata?.page_id || null;
// Also try to get paystack customer id from payload (helps linking)
const paystackCustomerId = data.customer?.id ?? data.customer?.customer_code ?? null;
// Find user doc reference
let userDocRef = null;
try {
userDocRef = await findUserDocRef(db, { metadataUserId, email: customerEmail, paystackCustomerId });
// If still null and metadataUserId is present but looks like slug, try slug mapping as last resort
if (!userDocRef && metadataUserId && db) {
const mapped = await getUserIdFromSlug(db, metadataUserId);
if (mapped) {
userDocRef = await findUserDocRef(db, { metadataUserId: mapped, email: customerEmail, paystackCustomerId });
}
}
} catch (err) {
console.error('Error finding user doc:', err);
userDocRef = null;
}
// Handler: subscription.create - store subscription id and paystack_customer_id
if (/subscription\.create/i.test(event) || event === 'subscription.create') {
const subscriptionCode = data.subscription_code || data.subscription?.subscription_code || (data.id ? String(data.id) : null);
const status = data.status || 'active';
const planObj = data.plan || data.subscription?.plan || null;
const expiry = getExpiryFromPlan(planObj);
const paystackCustomerId = data.customer?.id ?? (data.customer?.customer_code ? data.customer.customer_code : null);
if (userDocRef && subscriptionCode) {
try {
const updateObj = {
subscriptionId: subscriptionCode,
subscription: {
id: subscriptionCode,
status,
plan: planObj ? { id: planObj.id, code: planObj.plan_code, amount: planObj.amount, interval: planObj.interval } : null,
createdAt: data.createdAt ? admin.firestore.Timestamp.fromDate(new Date(data.createdAt)) : admin.firestore.FieldValue.serverTimestamp(),
expiresAtMs: expiry.expiresAtMs,
expiresAtIso: expiry.expiresAtIso,
},
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
};
// attach paystack_customer_id if present
if (paystackCustomerId) {
updateObj.paystack_customer_id = String(paystackCustomerId);
}
await userDocRef.update(updateObj);
console.log(`User doc updated with subscription ${subscriptionCode} and expiry ${expiry.expiresAtIso} (source: ${extractorSource})`);
} catch (err) {
console.error('Failed to update user subscription info:', err);
}
} else {
console.warn('subscription.create received but user not found or subscriptionCode missing — skipping user update.');
}
}
// Handler: charge.success - add entitlement on successful subscription charge
if (isPayment) {
const recurringMarker = Boolean((data.metadata && (data.metadata.custom_filters?.recurring || data.metadata.recurring)) || false);
const hasSubscription = !!(data.subscription || data.subscriptions || data.plan);
const isLikelySubscriptionPayment = recurringMarker || hasSubscription;
const planObj = data.plan || (data.subscription ? data.subscription.plan : null) || null;
const expiry = getExpiryFromPlan(planObj);
const entitlement = {
id: (data.reference || data.id) ? String(data.reference || data.id) : null,
source: 'paystack',
amount: data.amount || null,
reference: data.reference || null,
paidAt: data.paid_at || data.paidAt || data.created_at || null,
plan: planObj ? { id: planObj.id, code: planObj.plan_code } : null,
expiresAtMs: expiry.expiresAtMs,
expiresAtIso: expiry.expiresAtIso,
createdAt: admin.firestore.FieldValue.serverTimestamp(),
};
if (userDocRef && isLikelySubscriptionPayment) {
try {
await userDocRef.update({
entitlements: admin.firestore.FieldValue.arrayUnion(entitlement),
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
lastPaymentAt: admin.firestore.FieldValue.serverTimestamp(),
// Save paystack_customer_id if present and not already present
...(paystackCustomerId ? { paystack_customer_id: String(paystackCustomerId) } : {}),
});
console.log('Added entitlement to user:', entitlement.id || entitlement.reference, 'expiry:', expiry.expiresAtIso);
// Run cleanup now that the charge is successful for this user
try {
await cleanUpAfterSuccessfulCharge(db, userDocRef, {
slug: maybeSlug,
pageId: maybePageId,
reference: entitlement.reference || entitlement.id,
email: customerEmail,
});
} catch (cleanupErr) {
console.error('Cleanup after successful charge failed:', cleanupErr);
}
} catch (err) {
console.error('Failed to add entitlement to user:', err);
}
} else if (userDocRef && !isLikelySubscriptionPayment) {
console.log('charge.success received but not marked recurring/subscription - skipping entitlement add by default.');
} else {
console.warn('charge.success: user not found, skipping entitlement update.');
}
}
// Handler: refunds - remove entitlement(s) associated with refunded transaction
if (isRefund) {
const refund = data;
const refundedReference = refund.reference || refund.transaction?.reference || refund.transaction?.id || null;
if (userDocRef && refundedReference) {
try {
const snap = await userDocRef.get();
if (snap.exists) {
const userData = snap.data();
const currentEntitlements = Array.isArray(userData.entitlements) ? userData.entitlements : [];
const filtered = currentEntitlements.filter(e => {
const ref = e.reference || e.id || '';
return ref !== refundedReference && ref !== String(refundedReference);
});
await userDocRef.update({
entitlements: filtered,
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
});
console.log('Removed entitlements matching refunded reference:', refundedReference);
} else {
console.warn('Refund handling: user doc disappeared between lookup and removal.');
}
} catch (err) {
console.error('Failed to remove entitlement on refund:', err);
}
} else {
console.warn('Refund received but user or refundedReference not found — skipping entitlement removal.');
}
}
} else {
console.log(`Received Paystack webhook for event "${event}" — ignored (not refund/payment/subscription.create).`);
}
return res.status(200).json({ ok: true });
});
// ------------------------
// Create Payment Page (link) for a plan
// ------------------------
app.post('/create-payment-link', express.json(), async (req, res) => {
const { planId, userId, name, amount, redirect_url, collect_phone = false, fixed_amount = false } = req.body || {};
if (!planId) return res.status(400).json({ ok: false, message: 'planId is required' });
if (!userId) return res.status(400).json({ ok: false, message: 'userId is required' });
if (!name) return res.status(400).json({ ok: false, message: 'name is required (page title)' });
// Build the body per Paystack's Payment Pages API.
const payload = {
name,
type: 'subscription',
plan: planId,
metadata: {
userId: String(userId),
},
collect_phone,
fixed_amount,
};
if (amount) payload.amount = amount; // amount is optional (in kobo/cents)
if (redirect_url) payload.redirect_url = redirect_url;
try {
const response = await axios.post('https://api.paystack.co/page', payload, {
headers: {
Authorization: `Bearer ${PAYSTACK_SECRET}`,
'Content-Type': 'application/json',
},
timeout: 10_000,
});
const pageData = response.data && response.data.data ? response.data.data : response.data;
// Persist slug -> userId mapping so webhooks can recover missing metadata later
try {
const slug = pageData.slug || pageData.data?.slug || null;
const pageId = pageData.id || pageData.data?.id || null;
if (slug && db) {
await db.collection('paystack-page-mappings').doc(String(slug)).set({
userId: String(userId),
pageId: pageId ? String(pageId) : null,
createdAt: admin.firestore.FieldValue.serverTimestamp(),
}, { merge: true });
console.log('Saved page slug mapping for', slug);
} else if (pageId && db) {
// also ensure a record exists by pageId lookup (useful if slug missing in some responses)
await db.collection('paystack-page-mappings').doc(String(pageId)).set({
userId: String(userId),
pageId: String(pageId),
createdAt: admin.firestore.FieldValue.serverTimestamp(),
}, { merge: true });
}
} catch (e) {
console.error('Failed to persist page slug mapping:', e);
}
return res.status(201).json({ ok: true, page: pageData });
} catch (err) {
console.error('Error creating Paystack payment page:', err?.response?.data || err.message || err);
const errorDetail = err?.response?.data || { message: err.message };
return res.status(500).json({ ok: false, error: errorDetail });
}
});
// Simple health check
app.get('/health', (req, res) => res.json({ ok: true }));
app.listen(PORT, () => {
console.log(`Paystack webhook server listening on port ${PORT}`);
console.log('POST /webhook/paystack to receive Paystack callbacks');
console.log('POST /create-payment-link to create a subscription payment page (expects JSON body).');
});