// paystack-webhook.js // Node >= 14 // 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(); // ---- // ---- const { FIREBASE_CREDENTIALS, PAYSTACK_DEMO_SECRET, PAYSTACK_LIVE_SECRET, PORT = 7860, } = process.env; const PAYSTACK_SECRET = PAYSTACK_DEMO_SECRET || PAYSTACK_LIVE_SECRET; if (!PAYSTACK_SECRET) console.warn('WARNING: PAYSTACK secret not set.'); let db = null; try { if (FIREBASE_CREDENTIALS) { const svc = JSON.parse(FIREBASE_CREDENTIALS); admin.initializeApp({ credential: admin.credential.cert(svc) }); db = admin.firestore(); console.log('Firebase admin initialized from FIREBASE_CREDENTIALS env var.'); } else { if (!admin.apps.length) { console.warn('FIREBASE_CREDENTIALS not provided and admin not initialized.'); } else { db = admin.firestore(); } } } catch (e) { console.error('Failed to init Firebase admin:', e); } /* -------------------- Helpers -------------------- */ function safeParseJSON(raw) { try { return JSON.parse(raw); } catch (e) { return null; } } function verifyPaystackSignature(rawBodyBuffer, signatureHeader) { if (!PAYSTACK_SECRET || !rawBodyBuffer || !signatureHeader) return false; try { const hmac = crypto.createHmac('sha512', PAYSTACK_SECRET); hmac.update(rawBodyBuffer); const expected = hmac.digest('hex'); return crypto.timingSafeEqual(Buffer.from(expected, 'utf8'), Buffer.from(String(signatureHeader), 'utf8')); } catch (e) { console.error('Signature verification error:', e); return false; } } function extractSlugFromReferrer(refUrl) { if (!refUrl || typeof refUrl !== 'string') return null; try { const m = refUrl.match(/\/(?:pay|p)\/([^\/\?\#]+)/i); if (m && m[1]) return m[1]; const parts = new URL(refUrl).pathname.split('/').filter(Boolean); if (parts.length) return parts[parts.length - 1]; } catch (e) { const fallback = (refUrl.split('/').pop() || '').split('?')[0].split('#')[0]; return fallback || null; } return null; } /* expiry helper */ const EXTRA_FREE_MS = 2 * 60 * 60 * 1000; 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, planId = null, 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')) { 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() }; } /* -------------------- Mapping helpers & updates (SLUG-ONLY) -------------------- */ // update mapping doc by slug only (merge) async function updateMappingWithIdentifiers(dbInstance, { slug, userId, userIdType, customerId, payerEmail, subscriptionCode, authorizationCode } = {}) { if (!dbInstance || !slug) return; try { const writeObj = {}; if (userId !== undefined) writeObj.userId = String(userId); if (userIdType !== undefined) writeObj.userIdType = String(userIdType); if (customerId !== undefined && customerId !== null) writeObj.customerId = String(customerId); if (payerEmail !== undefined && payerEmail !== null) writeObj.payerEmail = String(payerEmail); if (subscriptionCode !== undefined && subscriptionCode !== null) writeObj.subscriptionCode = String(subscriptionCode); if (authorizationCode !== undefined && authorizationCode !== null) writeObj.authorizationCode = String(authorizationCode); if (Object.keys(writeObj).length === 0) return; await dbInstance.collection('paystack-page-mappings').doc(String(slug)).set({ slug: String(slug), ...writeObj, updatedAt: admin.firestore.FieldValue.serverTimestamp(), }, { merge: true }); console.log('Updated mapping doc (slug) with identifiers:', slug, writeObj); } catch (e) { console.error('updateMappingWithIdentifiers failed:', e); } } // get mapping doc data by slug (doc id) async function getMappingBySlug(dbInstance, slug) { if (!dbInstance || !slug) return null; try { const ref = dbInstance.collection('paystack-page-mappings').doc(String(slug)); const snap = await ref.get(); if (!snap.exists) return null; return { ref, data: snap.data() }; } catch (e) { console.error('getMappingBySlug error:', e); return null; } } // find mapping by other authoritative identifiers (customerId / payerEmail / subscriptionCode) async function findMappingByIdentifiers(dbInstance, { customerId, payerEmail, subscriptionCode } = {}) { if (!dbInstance) return null; try { const coll = dbInstance.collection('paystack-page-mappings'); if (customerId) { const q = await coll.where('customerId', '==', String(customerId)).limit(1).get(); if (!q.empty) return { mappingDoc: q.docs[0].ref, data: q.docs[0].data() }; } if (subscriptionCode) { const q2 = await coll.where('subscriptionCode', '==', String(subscriptionCode)).limit(1).get(); if (!q2.empty) return { mappingDoc: q2.docs[0].ref, data: q2.docs[0].data() }; } if (payerEmail) { const q3 = await coll.where('payerEmail', '==', String(payerEmail)).limit(1).get(); if (!q3.empty) return { mappingDoc: q3.docs[0].ref, data: q3.docs[0].data() }; } return null; } catch (e) { console.error('findMappingByIdentifiers error:', e); return null; } } // Resolve user doc from mapping: prefer slug doc, else mapping-by-identifiers async function resolveUserDocFromMapping(dbInstance, { slug = null, customerId = null, payerEmail = null, subscriptionCode = null } = {}) { if (!dbInstance) return null; const usersRef = dbInstance.collection('users'); // 1) try slug doc => read mapping.userId and resolve to users doc if (slug) { try { const mapping = await getMappingBySlug(dbInstance, slug); if (mapping && mapping.data && mapping.data.userId) { const mappedUserId = mapping.data.userId; // try doc id first try { const directRef = usersRef.doc(String(mappedUserId)); const ds = await directRef.get(); if (ds.exists) return directRef; } catch (e) {} // fallback: if mappedUserId looks like email if (String(mappedUserId).includes('@')) { try { const q = await usersRef.where('email', '==', String(mappedUserId)).limit(1).get(); if (!q.empty) return q.docs[0].ref; } catch (e) {} } // fallback to other id fields const idFields = ['userId','uid','id']; for (const f of idFields) { try { const q = await usersRef.where(f,'==',mappedUserId).limit(1).get(); if (!q.empty) return q.docs[0].ref; } catch (e) {} } } } catch (e) { console.error('resolveUserDocFromMapping slug branch error:', e); } } // 2) try mapping by identifiers try { const found = await findMappingByIdentifiers(dbInstance, { customerId, payerEmail, subscriptionCode }); if (found && found.data && found.data.userId) { const mappedUserId = found.data.userId; // same resolution logic try { const directRef = usersRef.doc(String(mappedUserId)); const ds = await directRef.get(); if (ds.exists) return directRef; } catch (e) {} if (String(mappedUserId).includes('@')) { try { const q = await usersRef.where('email', '==', String(mappedUserId)).limit(1).get(); if (!q.empty) return q.docs[0].ref; } catch (e) {} } const idFields = ['userId','uid','id']; for (const f of idFields) { try { const q = await usersRef.where(f,'==',mappedUserId).limit(1).get(); if (!q.empty) return q.docs[0].ref; } catch (e) {} } } } catch (e) { console.error('resolveUserDocFromMapping identifiers branch error:', e); } return null; } /* -------------------- Fallback user resolution -------------------- */ async function findUserDocRef(dbInstance, { metadataUserId, email, paystackCustomerId } = {}) { if (!dbInstance) return null; const usersRef = dbInstance.collection('users'); if (metadataUserId) { if (!String(metadataUserId).includes('@')) { try { const docRef = usersRef.doc(String(metadataUserId)); const s = await docRef.get(); if (s.exists) return docRef; } catch (e) {} } const idFields = ['userId','uid','id']; for (const f of idFields) { try { const q = await usersRef.where(f,'==',metadataUserId).limit(1).get(); if (!q.empty) return q.docs[0].ref; } catch (e) {} } } if (paystackCustomerId) { try { const q = await usersRef.where('paystack_customer_id','==',String(paystackCustomerId)).limit(1).get(); if (!q.empty) return q.docs[0].ref; } catch (e) {} } if (email) { try { const q = await usersRef.where('email','==',String(email)).limit(1).get(); if (!q.empty) return q.docs[0].ref; } catch (e) {} } return null; } /* -------------------- Cleanup (SLUG-only + smarter owner check) -------------------- */ async function cleanUpAfterSuccessfulCharge(dbInstance, userDocRef, { slug, reference, email } = {}) { if (!dbInstance || !userDocRef) return; try { // load mapping only by slug (we no longer create pageId docs) if (!slug) return; const mapRef = dbInstance.collection('paystack-page-mappings').doc(String(slug)); const mapSnap = await mapRef.get(); if (!mapSnap.exists) return; const map = mapSnap.data(); // determine mapping owner type and user identity const ownerVal = String(map.userId || ''); const ownerType = map.userIdType || (ownerVal.includes('@') ? 'email' : 'uid'); // get user doc data to check email or id const userSnap = await userDocRef.get(); if (!userSnap.exists) return; const userData = userSnap.data() || {}; const userEmail = userData.email || null; const userId = userDocRef.id; let shouldDelete = false; if (ownerType === 'uid' && ownerVal === userId) shouldDelete = true; if (ownerType === 'email' && userEmail && ownerVal === userEmail) shouldDelete = true; if (shouldDelete) { await mapRef.delete(); console.log('Deleted mapping doc (slug) after successful charge:', slug); } else { console.log('Mapping owner mismatch — not deleting slug:', slug, 'mappingOwner=', ownerVal, 'ownerType=', ownerType, 'userId=', userId, 'userEmail=', userEmail); } } catch (e) { console.error('Error in cleanUpAfterSuccessfulCharge:', e); } } /* -------------------- Prune helper (optional, unchanged) -------------------- */ async function pruneUserEntitlementsAndWebhooks(dbInstance, userDocRef, { paystackCustomerId = null, payerEmail = null } = {}) { if (!dbInstance || !userDocRef) return; try { const cutoffMs = Date.now() - (60 * 24 * 60 * 60 * 1000); const snap = await userDocRef.get(); if (!snap.exists) return; const userData = snap.data() || {}; const entitlements = Array.isArray(userData.entitlements) ? userData.entitlements : []; const cleaned = entitlements.filter(e => { if (e && (e.expiresAtMs || e.expiresAtMs === 0)) { const exMs = Number(e.expiresAtMs) || 0; return exMs > Date.now(); } if (e && e.createdAt) { let createdMs = 0; try { if (typeof e.createdAt === 'number') createdMs = e.createdAt; else if (e.createdAt && typeof e.createdAt.toMillis === 'function') createdMs = e.createdAt.toMillis(); else if (typeof e.createdAt === 'string') createdMs = Number(e.createdAt) || 0; else if (e.createdAt._seconds) createdMs = (Number(e.createdAt._seconds) * 1000) + (Number(e.createdAt._nanoseconds || 0) / 1e6); } catch (ee) { createdMs = 0; } if (createdMs) return createdMs >= cutoffMs; return true; } return true; }); if (cleaned.length !== entitlements.length) { await userDocRef.update({ entitlements: cleaned, updatedAt: admin.firestore.FieldValue.serverTimestamp() }); console.log('Pruned entitlements: removed', entitlements.length - cleaned.length, 'entries for', userDocRef.path); } // prune paystack-webhooks older than cutoff for this user (best-effort) try { const coll = dbInstance.collection('paystack-webhooks'); const toDeleteRefs = []; if (paystackCustomerId) { try { const q = await coll.where('payload.data.customer.id', '==', paystackCustomerId).get(); q.docs.forEach(d => { const r = d.data(); const ts = r.receivedAt; let tsMs = 0; if (ts && typeof ts.toMillis === 'function') tsMs = ts.toMillis(); if (tsMs && tsMs < cutoffMs) toDeleteRefs.push(d.ref); }); } catch (e) { console.warn('Could not query paystack-webhooks by customer.id (skipping):', e); } } if (payerEmail) { try { const q2 = await coll.where('payload.data.customer.email', '==', payerEmail).get(); q2.docs.forEach(d => { const r = d.data(); const ts = r.receivedAt; let tsMs = 0; if (ts && typeof ts.toMillis === 'function') tsMs = ts.toMillis(); if (tsMs && tsMs < cutoffMs) { if (!toDeleteRefs.find(x => x.path === d.ref.path)) toDeleteRefs.push(d.ref); } }); } catch (e) { console.warn('Could not query paystack-webhooks by customer.email (skipping):', e); } } if (toDeleteRefs.length) { const batch = dbInstance.batch(); toDeleteRefs.forEach(r => batch.delete(r)); await batch.commit(); console.log('Deleted', toDeleteRefs.length, 'old paystack-webhooks audit docs for user', userDocRef.id); } } catch (e) { console.error('Failed pruning paystack-webhooks (non-fatal):', e); } } catch (e) { console.error('pruneUserEntitlementsAndWebhooks failed:', e); } } /* -------------------- Webhook route -------------------- */ app.post('/webhook/paystack', express.raw({ type: 'application/json', limit: '1mb' }), async (req, res) => { const raw = req.body; const signature = req.get('x-paystack-signature') || req.get('X-Paystack-Signature'); if (!signature || !verifyPaystackSignature(raw, signature)) { console.warn('Paystack webhook signature verify failed. header:', signature); return res.status(400).json({ ok: false, message: 'Invalid signature' }); } const bodyStr = raw.toString('utf8'); const payload = safeParseJSON(bodyStr); if (!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 isPayment = isChargeSuccess; if (!db) console.error('Firestore admin not initialized — cannot persist webhook data.'); // persist webhook audit try { if (db) { await db.collection('paystack-webhooks').add({ event, payload, receivedAt: admin.firestore.FieldValue.serverTimestamp() }); } } catch (e) { console.error('Failed to persist webhook audit:', e); } if (/subscription\.create/i.test(event) || isRefund || isPayment || /subscription\.update/i.test(event)) { console.log('--- PAYSTACK WEBHOOK (INTERESTING EVENT) ---'); console.log('event:', event); const data = payload.data || {}; // extract slug let maybeSlug = data.page?.slug || data.slug || null; if (!maybeSlug) { const referrer = data.metadata?.referrer || (data.metadata && data.metadata.referrer) || null; if (referrer) { const extracted = extractSlugFromReferrer(String(referrer)); if (extracted) { maybeSlug = extracted; console.log('Extracted slug from metadata.referrer:', maybeSlug); } else { console.log('Could not extract slug from referrer:', referrer); } } } // authoritative identifiers const paystackCustomerId = data.customer?.id ?? data.customer?.customer_code ?? null; const payerEmail = data.customer?.email ?? null; // saved on mapping as payerEmail const subscriptionCode = data.subscription_code || data.subscription?.subscription_code || null; const authorizationCode = data.authorization?.authorization_code || data.authorization_code || null; // quick metadata extraction let metadataUserId = null; try { const metadata = data.metadata || {}; if (metadata.userId || metadata.user_id) metadataUserId = metadata.userId || metadata.user_id; else if (data.customer?.metadata) { const cm = data.customer.metadata; if (cm.userId || cm.user_id) metadataUserId = cm.userId || cm.user_id; } else if (Array.isArray(data.custom_fields)) { for (const f of data.custom_fields) { const n = (f.variable_name || f.display_name || '').toString().toLowerCase(); if ((n.includes('user') || n.includes('user_id') || n.includes('userid')) && f.value) { metadataUserId = f.value; break; } } } } catch (e) { console.error('Error during quick metadata extraction:', e); } // Resolve user: mapping-first (slug), then mapping-by-identifiers, then fallback queries let userDocRef = null; try { if (maybeSlug && db) { userDocRef = await resolveUserDocFromMapping(db, { slug: maybeSlug, customerId: paystackCustomerId, payerEmail, subscriptionCode }); if (userDocRef) console.log('Resolved user from mapping (slug):', userDocRef.path, 'slug=', maybeSlug); else console.log('No mapping resolved from slug:', maybeSlug); } if (!userDocRef && db && (paystackCustomerId || payerEmail || subscriptionCode)) { userDocRef = await resolveUserDocFromMapping(db, { slug: null, customerId: paystackCustomerId, payerEmail, subscriptionCode }); if (userDocRef) console.log('Resolved user from mapping by identifiers ->', userDocRef.path); } if (!userDocRef) { userDocRef = await findUserDocRef(db, { metadataUserId, email: payerEmail, paystackCustomerId }); if (userDocRef) console.log('Resolved user via fallback queries:', userDocRef.path); } } catch (e) { console.error('Error resolving userDocRef (mapping-first):', e); userDocRef = null; } // update mapping with authoritative identifiers ONLY if we have a slug (we avoid creating mapping by pageId) try { if (maybeSlug) { const userIdToSave = metadataUserId || (userDocRef ? userDocRef.id : undefined); const userIdType = userIdToSave && String(userIdToSave).includes('@') ? 'email' : 'uid'; await updateMappingWithIdentifiers(db, { slug: maybeSlug, userId: userIdToSave, userIdType, customerId: paystackCustomerId || undefined, payerEmail: payerEmail || undefined, subscriptionCode: subscriptionCode || undefined, authorizationCode: authorizationCode || undefined, }); } } catch (e) { console.error('Failed updateMappingWithIdentifiers:', e); } // subscription.create if (/subscription\.create/i.test(event) || event === 'subscription.create') { const subscriptionCodeLocal = subscriptionCode || (data.id ? String(data.id) : null); const status = data.status || 'active'; const planObj = data.plan || data.subscription?.plan || null; const expiry = getExpiryFromPlan(planObj); if (userDocRef && subscriptionCodeLocal) { try { const updateObj = { subscriptionId: subscriptionCodeLocal, subscription: { id: subscriptionCodeLocal, 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(), }; if (paystackCustomerId) updateObj.paystack_customer_id = String(paystackCustomerId); await userDocRef.update(updateObj); console.log('subscription.create: updated user with subscription:', subscriptionCodeLocal); await pruneUserEntitlementsAndWebhooks(db, userDocRef, { paystackCustomerId, payerEmail }); } catch (e) { console.error('subscription.create: failed to update user:', e); } } else { console.warn('subscription.create: user not found or subscriptionCode missing — skipping user update.'); } } // charge.success if (isPayment) { console.log('charge.success identifiers:', { metadataUserId, payerEmail, maybeSlug, paystackCustomerId, subscriptionCode }); const recurringMarker = Boolean((data.metadata && (data.metadata.custom_filters?.recurring || data.metadata.recurring)) || false); const hasSubscription = !!(data.subscription || data.subscriptions || data.plan || subscriptionCode); 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.Timestamp.now(), }; // Ensure mapping gets authoritative identifiers on first charge (only if slug present) try { if (maybeSlug) { const userIdToSave = metadataUserId || (userDocRef ? userDocRef.id : undefined); const userIdType = userIdToSave && String(userIdToSave).includes('@') ? 'email' : 'uid'; await updateMappingWithIdentifiers(db, { slug: maybeSlug, userId: userIdToSave, userIdType, customerId: paystackCustomerId || undefined, payerEmail: payerEmail || undefined, subscriptionCode: subscriptionCode || undefined, authorizationCode: authorizationCode || undefined, }); } } catch (e) { console.error('Failed updateMappingWithIdentifiers during charge.success:', e); } if (userDocRef && isLikelySubscriptionPayment) { try { await userDocRef.update({ entitlements: admin.firestore.FieldValue.arrayUnion(entitlement), lastPaymentAt: admin.firestore.FieldValue.serverTimestamp(), updatedAt: admin.firestore.FieldValue.serverTimestamp(), ...(paystackCustomerId ? { paystack_customer_id: String(paystackCustomerId) } : {}), }); console.log('Entitlement added (arrayUnion) to', userDocRef.path); } catch (err) { console.error('arrayUnion failed, falling back:', err); try { const snap = await userDocRef.get(); const userData = snap.exists ? snap.data() : {}; const current = Array.isArray(userData.entitlements) ? userData.entitlements : []; const exists = current.some(e => (e.reference || e.id) === (entitlement.reference || entitlement.id)); if (!exists) { current.push(entitlement); await userDocRef.update({ entitlements: current, lastPaymentAt: admin.firestore.FieldValue.serverTimestamp(), updatedAt: admin.firestore.FieldValue.serverTimestamp(), ...(paystackCustomerId ? { paystack_customer_id: String(paystackCustomerId) } : {}), }); console.log('Entitlement appended via fallback.'); } else { console.log('Entitlement already present, skipping append.'); } } catch (err2) { console.error('Fallback persistence failed:', err2); } } // cleanup mapping doc if slug exists (only slug doc) try { await cleanUpAfterSuccessfulCharge(db, userDocRef, { slug: maybeSlug, reference: entitlement.reference || entitlement.id, email: payerEmail }); } catch (e) { console.error('Cleanup failed:', e); } // prune entitlements & old webhooks try { await pruneUserEntitlementsAndWebhooks(db, userDocRef, { paystackCustomerId, payerEmail }); } catch (e) { console.error('Prune helper failed:', e); } } else if (userDocRef && !isLikelySubscriptionPayment) { console.log('charge.success received but not flagged subscription/recurring - skipping entitlement add.'); } else { console.warn('charge.success: user not found, skipping entitlement update.'); } } // refunds 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 current = Array.isArray(userData.entitlements) ? userData.entitlements : []; const filtered = current.filter(e => { const r = e.reference || e.id || ''; return r !== refundedReference && r !== String(refundedReference); }); await userDocRef.update({ entitlements: filtered, updatedAt: admin.firestore.FieldValue.serverTimestamp() }); console.log('Removed entitlements matching refund:', refundedReference); await pruneUserEntitlementsAndWebhooks(db, userDocRef, { paystackCustomerId, payerEmail }); } else { console.warn('Refund handling: user doc vanished.'); } } catch (e) { console.error('Refund entitlement removal failed:', e); } } else { console.warn('Refund: user or refundedReference missing; skipping.'); } } } else { console.log('Ignoring event:', event); } return res.status(200).json({ ok: true }); }); /* -------------------- Create payment link endpoint (SLUG only) -------------------- */ 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 required' }); if (!userId) return res.status(400).json({ ok: false, message: 'userId required' }); if (!name) return res.status(400).json({ ok: false, message: 'name required' }); const payload = { name, type: 'subscription', plan: planId, metadata: { userId: String(userId) }, collect_phone, fixed_amount }; if (amount) payload.amount = amount; 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 mapping doc keyed by slug only (no pageId doc creation) try { if (db) { const slug = pageData.slug || pageData.data?.slug || null; if (slug) { await db.collection('paystack-page-mappings').doc(String(slug)).set({ userId: String(userId), userIdType: String(userId).includes('@') ? 'email' : 'uid', pageId: pageData.id ? String(pageData.id) : null, // store pageId inside slug doc only slug: String(slug), createdAt: admin.firestore.FieldValue.serverTimestamp(), }, { merge: true }); console.log('Saved page slug mapping for', slug); } else { console.warn('create-payment-link: Paystack response did not return a slug; no mapping saved.'); } } } catch (e) { console.error('Failed to persist mapping doc (slug only):', e); } return res.status(201).json({ ok: true, page: pageData }); } catch (err) { console.error('Error creating Paystack page:', err?.response?.data || err.message || err); const errorDetail = err?.response?.data || { message: err.message }; return res.status(500).json({ ok: false, error: errorDetail }); } }); /* health */ app.get('/health', (_req, res) => res.status(200).json({ ok: true })); app.post('/health', (_req, res) => res.status(200).json({ ok: true })); app.get('/', (_req, res) => res.status(204).end()); app.post('/', (_req, res) => res.status(204).end()); 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).'); });