// 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).'); });