// 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 if (FIREBASE_CREDENTIALS) { try { const serviceAccount = JSON.parse(FIREBASE_CREDENTIALS); admin.initializeApp({ credential: admin.credential.cert(serviceAccount), }); console.log('Firebase admin initialized from FIREBASE_CREDENTIALS env var.'); } catch (err) { console.error('Failed to parse FIREBASE_CREDENTIALS JSON:', err); process.exit(1); } } else { console.warn('FIREBASE_CREDENTIALS not provided. Firebase admin not initialized.'); } // ------------------------------------------------- // Webhook raw parser (required to verify Paystack signature) // ------------------------------------------------- app.use( express.raw({ type: 'application/json', limit: '1mb', }) ); // Utility: verify x-paystack-signature function verifyPaystackSignature(rawBodyBuffer, signatureHeader) { if (!PAYSTACK_SECRET) return false; const hmac = crypto.createHmac('sha512', PAYSTACK_SECRET); hmac.update(rawBodyBuffer); const expected = hmac.digest('hex'); try { return crypto.timingSafeEqual(Buffer.from(expected, 'utf8'), Buffer.from(signatureHeader || '', 'utf8')); } catch (e) { return false; } } // Helper: safe JSON parse function safeParseJSON(raw) { try { return JSON.parse(raw); } catch (err) { return null; } } // ------------------------------ // Expiry helper: getExpiryFromPlan // ------------------------------ // Returns an object: // { expiresAtMs: , expiresAtIso: } // Rules: // - If planObj.interval exists use that (weekly -> +7 days, monthly -> +30 days) // - Else fallback to matching plan id or code against WEEKLY_PLAN_IDS / MONTHLY_PLAN_IDS // - Always add EXTRA_FREE_MS (2 hours) to the computed expiry // - If no match, default to 30 days + 2 hours const EXTRA_FREE_MS = 2 * 60 * 60 * 1000; // 2 hours in ms // EDIT THESE SETS to include any numeric plan ids or plan codes you consider weekly/monthly const WEEKLY_PLAN_IDS = new Set([ // numeric ids 3311892, // demo, remove in prod 3305738, // string plan codes (if your API sometimes returns the PLN_xxx code) 'PLN_ngz4l76whecrpkv, 'PLN_f7a3oagrpt47d5f', // demo, remove in prod ]); const MONTHLY_PLAN_IDS = new Set([ 3305739, 'PLN_584ck56g65xhkum', // replace/extend as needed ]); function getExpiryFromPlan(planInput) { // planInput may be: undefined | number | string | object { id, plan_code, interval } 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') { // could be "PLN_xxx" or numeric string planCode = planInput; const asNum = Number(planInput); if (!Number.isNaN(asNum)) planId = asNum; } // Prefer explicit interval if provided 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 'hourly' or other intervals -- you can treat hourly as 7 days for weekly test example or handle explicitly if (interval.includes('hour')) { // Treat hourly plans as weekly-like (example from your payload that used hourly). You can adjust. const expires = Date.now() + 7 * 24 * 60 * 60 * 1000 + EXTRA_FREE_MS; return { expiresAtMs: expires, expiresAtIso: new Date(expires).toISOString() }; } } // Fallback: check known id/code sets 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() }; } // Final fallback: default to 30 days + extra 2 hours const expires = Date.now() + 30 * 24 * 60 * 60 * 1000 + EXTRA_FREE_MS; return { expiresAtMs: expires, expiresAtIso: new Date(expires).toISOString() }; } // Helper: find user doc reference in Firestore // Strategy: // 1) If metadata.userId exists and matches a doc ID, use that doc // 2) Try queries by email, uid, userId fields (most common naming) // Returns a DocumentReference or null async function findUserDocRef(db, { metadataUserId, email }) { const usersRef = db.collection('users'); if (metadataUserId) { // Try doc id first try { const docRef = usersRef.doc(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 (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; } // ------------------------ // Existing webhook endpoint (modified to update subscription/entitlements) // ------------------------ app.post('/webhook/paystack', async (req, res) => { const raw = req.body; // Buffer because we used express.raw 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); // We only care about refunds and payments (charge.success) as per earlier note const isPayment = isChargeSuccess; // we intentionally only act on charge.success // Short names const db = admin.firestore(); if (isRefund || isPayment || /subscription\.create/i.test(event) || /subscription\.update/i.test(event)) { console.log('--- PAYSTACK WEBHOOK (INTERESTING EVENT) ---'); console.log('event:', event); console.log('payload:', JSON.stringify(payload, null, 2)); // persist webhook for auditing try { await db.collection('paystack-webhooks').add({ event, payload, receivedAt: admin.firestore.FieldValue.serverTimestamp(), }); } catch (err) { console.error('Failed to persist webhook audit:', err); } // Common data paths const data = payload.data || {}; // metadata could be on data.metadata or data.customer.metadata depending on event const metadata = data.metadata || data.customer?.metadata || null; const metadataUserId = metadata?.userId || metadata?.user_id || null; const customerEmail = data.customer?.email || (metadata && metadata.email) || null; // Try to find the user doc let userDocRef = null; try { userDocRef = await findUserDocRef(db, { metadataUserId, email: customerEmail }); } catch (err) { console.error('Error finding user doc:', err); userDocRef = null; } // Handler: subscription.create - store subscription id only 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'; // compute expiry from plan if possible const planObj = data.plan || data.subscription?.plan || null; const expiry = getExpiryFromPlan(planObj); if (userDocRef && subscriptionCode) { try { await userDocRef.update({ 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(), }); console.log(`User doc updated with subscription ${subscriptionCode} and expiry ${expiry.expiresAtIso}`); } 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 (or one-off if you want) if (isPayment) { // Determine if this is a recurring/subscription payment: const recurringMarker = (data.metadata && (data.metadata.custom_filters?.recurring || data.metadata.recurring)) || false; const hasSubscription = !!(data.subscription || data.subscriptions || data.plan); // defensive const isLikelySubscriptionPayment = recurringMarker || hasSubscription; // attempt to derive plan object for expiry calc const planObj = data.plan || (data.subscription ? data.subscription.plan : null) || null; const expiry = getExpiryFromPlan(planObj); // Create an entitlement object 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, // add computed expiry ms/iso if available expiresAtMs: expiry.expiresAtMs, expiresAtIso: expiry.expiresAtIso, createdAt: admin.firestore.FieldValue.serverTimestamp(), }; if (userDocRef && isLikelySubscriptionPayment) { try { // Append entitlement await userDocRef.update({ entitlements: admin.firestore.FieldValue.arrayUnion(entitlement), updatedAt: admin.firestore.FieldValue.serverTimestamp(), }); console.log('Added entitlement to user:', entitlement.id || entitlement.reference, 'expiry:', expiry.expiresAtIso); } catch (err) { console.error('Failed to add entitlement to user:', err); } } else if (userDocRef && !isLikelySubscriptionPayment) { // If you want to also give entitlement for one-off payments, you can add here. 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) { // Refund payload shapes vary. Try to extract refund reference or linked transaction const refund = data; // top-level refund object in many webhook shapes const refundedReference = refund.reference || refund.transaction?.reference || refund.transaction?.id || null; if (userDocRef && refundedReference) { try { // Read current entitlements, filter out any matching the refunded reference, and write back 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 => { // compare by reference or id if present 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 }); }); // ------------------------ // New: Create Payment Page (link) for a plan // ------------------------ // This endpoint expects JSON body, so we attach express.json() for this route. // Required fields in body: planId, userId, name (friendly page name) optionally: amount, redirect_url, collect_phone, fixed_amount app.post('/create-payment-link', express.json(), async (req, res) => { // If req.body was left as a Buffer (because express.raw ran), parse it: let body = req.body; if (Buffer.isBuffer(body)) { try { body = JSON.parse(body.toString('utf8')); } catch (err) { return res.status(400).json({ ok: false, message: 'Invalid JSON' }); } } const { planId, userId, name, amount, redirect_url, collect_phone = false, fixed_amount = false } = 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, }, // optional properties 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, }); // Paystack returns a response with data object. Return minimal info to caller. const pageData = response.data && response.data.data ? response.data.data : response.data; 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).'); });