Spaces:
Sleeping
Sleeping
| // 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 -------------------- */ | |
| // update mapping doc(s) by slug/pageId with authoritative identifiers. merge=true | |
| async function updateMappingWithIdentifiers(dbInstance, { slug, pageId, userId, customerId, payerEmail, subscriptionCode, authorizationCode } = {}) { | |
| if (!dbInstance) return; | |
| try { | |
| const writeObj = {}; | |
| if (userId !== undefined) writeObj.userId = String(userId); | |
| 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; | |
| if (slug) { | |
| await dbInstance.collection('paystack-page-mappings').doc(String(slug)).set({ | |
| ...writeObj, | |
| updatedAt: admin.firestore.FieldValue.serverTimestamp(), | |
| }, { merge: true }); | |
| console.log('Updated mapping doc (slug) with identifiers:', slug, writeObj); | |
| } | |
| if (pageId) { | |
| await dbInstance.collection('paystack-page-mappings').doc(String(pageId)).set({ | |
| ...writeObj, | |
| updatedAt: admin.firestore.FieldValue.serverTimestamp(), | |
| }, { merge: true }); | |
| console.log('Updated mapping doc (pageId) with identifiers:', pageId, writeObj); | |
| } | |
| } catch (e) { | |
| console.error('updateMappingWithIdentifiers failed:', e); | |
| } | |
| } | |
| // get userId string from mapping doc (doc id == slug or where pageId==slug) | |
| async function getUserIdFromSlug(dbInstance, slugOrPageId) { | |
| if (!dbInstance || !slugOrPageId) return null; | |
| try { | |
| // doc id lookup | |
| const docRef = dbInstance.collection('paystack-page-mappings').doc(String(slugOrPageId)); | |
| const snap = await docRef.get(); | |
| if (snap.exists) { | |
| const d = snap.data(); | |
| if (d?.userId) { | |
| console.log('Found mapping doc (by docId) for', slugOrPageId, ':', { userId: d.userId, customerId: d.customerId, payerEmail: d.payerEmail, subscriptionCode: d.subscriptionCode }); | |
| return d.userId; | |
| } | |
| // If doc exists but doesn't have userId, still return null (we'll use other mapping queries later) | |
| } | |
| // fallback query by pageId | |
| const q = await dbInstance.collection('paystack-page-mappings').where('pageId', '==', String(slugOrPageId)).limit(1).get(); | |
| if (!q.empty) { | |
| const d = q.docs[0].data(); | |
| console.log('Found mapping doc (by pageId) for', slugOrPageId, ':', { userId: d.userId, customerId: d.customerId, payerEmail: d.payerEmail, subscriptionCode: d.subscriptionCode }); | |
| if (d?.userId) return d.userId; | |
| } | |
| return null; | |
| } catch (e) { | |
| console.error('getUserIdFromSlug error:', e); | |
| return null; | |
| } | |
| } | |
| // If direct slug/pageId mapping didn't resolve, search mapping collection by 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; | |
| } | |
| } | |
| // Attempt resolution: given mapping key (slug), return a users/<docRef> or null | |
| async function resolveUserDocFromMapping(dbInstance, { key = null, customerId = null, payerEmail = null, subscriptionCode = null } = {}) { | |
| if (!dbInstance) return null; | |
| const usersRef = dbInstance.collection('users'); | |
| try { | |
| if (key) { | |
| // first try direct mapping doc by key (slug or pageId) | |
| const mappedUserId = await getUserIdFromSlug(dbInstance, key); | |
| if (mappedUserId) { | |
| // Try doc lookup | |
| try { | |
| const directRef = usersRef.doc(String(mappedUserId)); | |
| const ds = await directRef.get(); | |
| if (ds.exists) return directRef; | |
| } catch (e) {} | |
| // fallback queries by common id/email fields | |
| 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) {} | |
| } | |
| } | |
| } | |
| // if not resolved by key, try mapping collection queries (customerId/payerEmail/subscriptionCode) | |
| const mappingFound = await findMappingByIdentifiers(dbInstance, { customerId, payerEmail, subscriptionCode }); | |
| if (mappingFound && mappingFound.data) { | |
| const mappedUserId = mappingFound.data.userId; | |
| if (mappedUserId) { | |
| try { | |
| const directRef = usersRef.doc(String(mappedUserId)); | |
| const ds = await directRef.get(); | |
| if (ds.exists) return directRef; | |
| } catch (e) {} | |
| // fallback queries | |
| 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) {} | |
| } | |
| } | |
| } | |
| return null; | |
| } catch (e) { | |
| console.error('resolveUserDocFromMapping 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 (MAPPING ONLY) -------------------- */ | |
| async function cleanUpAfterSuccessfulCharge(dbInstance, userDocRef, { slug, pageId, reference, email } = {}) { | |
| if (!dbInstance || !userDocRef) return; | |
| try { | |
| const tryDelete = async (docId) => { | |
| if (!docId) return; | |
| try { | |
| const ref = dbInstance.collection('paystack-page-mappings').doc(String(docId)); | |
| const s = await ref.get(); | |
| if (!s.exists) return; | |
| const map = s.data(); | |
| const owner = String(map.userId || ''); | |
| if (owner === String(userDocRef.id) || (email && owner === String(email))) { | |
| await ref.delete(); | |
| console.log('Deleted mapping doc:', docId); | |
| } else { | |
| console.log('Mapping owner mismatch, not deleting:', docId, 'owner=', owner, 'user=', userDocRef.id); | |
| } | |
| } catch (e) { | |
| console.error('Error deleting mapping doc', docId, e); | |
| } | |
| }; | |
| await tryDelete(slug); | |
| await tryDelete(pageId); | |
| } catch (e) { | |
| console.error('Unexpected cleanup error:', 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 (only this extra collection is kept) | |
| 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 maybePageId = data.page?.id || data.page_id || null; | |
| let maybeSlug = data.page?.slug || data.slug || null; | |
| const referrer = data.metadata?.referrer || (data.metadata && data.metadata.referrer) || null; | |
| if (!maybeSlug && 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); | |
| } | |
| } | |
| // extract authoritative identifiers from payload | |
| const paystackCustomerId = data.customer?.id ?? data.customer?.customer_code ?? null; | |
| const payerEmail = data.customer?.email ?? null; // save as payerEmail in mapping to decouple from user's app email | |
| const subscriptionCode = data.subscription_code || data.subscription?.subscription_code || null; | |
| const authorizationCode = data.authorization?.authorization_code || data.authorization_code || null; | |
| // quick metadata extraction (keeps prior behavior) | |
| let metadataUserId = null; | |
| let customerEmail = null; | |
| let extractorSource = null; | |
| try { | |
| const metadata = data.metadata || {}; | |
| if (metadata.userId || metadata.user_id) { | |
| metadataUserId = metadata.userId || metadata.user_id; | |
| extractorSource = 'metadata'; | |
| } else if (data.customer?.metadata) { | |
| const cm = data.customer.metadata; | |
| if (cm.userId || cm.user_id) { | |
| metadataUserId = cm.userId || cm.user_id; | |
| extractorSource = 'customer.metadata'; | |
| } | |
| } 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; | |
| extractorSource = 'custom_fields'; | |
| break; | |
| } | |
| } | |
| } | |
| } catch (e) { | |
| console.error('Error during quick metadata extraction:', e); | |
| } | |
| if (!customerEmail && data.customer?.email) customerEmail = data.customer.email; | |
| // Resolve user: mapping-first (key), then mapping-by-identifiers, then fallback findUserDocRef | |
| let userDocRef = null; | |
| try { | |
| // try mapping with slug/pageId first (fast) | |
| const mappingKey = maybeSlug || maybePageId || null; | |
| if (mappingKey && db) { | |
| userDocRef = await resolveUserDocFromMapping(db, { key: mappingKey, customerId: paystackCustomerId, payerEmail, subscriptionCode }); | |
| if (userDocRef) console.log('Resolved user from mapping (key):', userDocRef.path, 'key=', mappingKey); | |
| else console.log('No mapping resolved from key:', mappingKey); | |
| } | |
| // if not found via key, try mapping by identifiers (customerId / payerEmail / subscriptionCode) | |
| if (!userDocRef && db && (paystackCustomerId || payerEmail || subscriptionCode)) { | |
| userDocRef = await resolveUserDocFromMapping(db, { key: null, customerId: paystackCustomerId, payerEmail, subscriptionCode }); | |
| if (userDocRef) console.log('Resolved user from mapping by identifiers ->', userDocRef.path); | |
| } | |
| // fallback: resolve user document using direct user fields | |
| if (!userDocRef) { | |
| userDocRef = await findUserDocRef(db, { metadataUserId, email: customerEmail, paystackCustomerId }); | |
| if (userDocRef) console.log('Resolved user via fallback queries:', userDocRef.path); | |
| } | |
| } catch (e) { | |
| console.error('Error resolving userDocRef (mapping-first):', e); | |
| userDocRef = null; | |
| } | |
| // If we have mapping slug present and payload contains authoritative identifiers, update mapping immediately | |
| try { | |
| await updateMappingWithIdentifiers(db, { | |
| slug: maybeSlug, | |
| pageId: maybePageId, | |
| userId: metadataUserId || (userDocRef ? userDocRef.id : undefined), | |
| customerId: paystackCustomerId || undefined, | |
| payerEmail: payerEmail || undefined, | |
| subscriptionCode: subscriptionCode || undefined, | |
| authorizationCode: authorizationCode || undefined, | |
| }); | |
| } catch (e) { | |
| console.error('Failed updateMappingWithIdentifiers:', e); | |
| } | |
| // --------------------------- | |
| // subscription.create handler | |
| // --------------------------- | |
| if (/subscription\.create/i.test(event) || event === 'subscription.create') { | |
| const subscriptionCode = 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 && 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(), | |
| }; | |
| if (paystackCustomerId) updateObj.paystack_customer_id = String(paystackCustomerId); | |
| await userDocRef.update(updateObj); | |
| console.log('subscription.create: updated user with subscription:', subscriptionCode); | |
| } 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 handler - entitlement add + mapping updates | |
| // --------------------------- | |
| if (isPayment) { | |
| console.log('charge.success identifiers:', { metadataUserId, customerEmail, maybeSlug, maybePageId, paystackCustomerId, subscriptionCode, extractorSource }); | |
| 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 (if not already present) | |
| try { | |
| await updateMappingWithIdentifiers(db, { | |
| slug: maybeSlug, | |
| pageId: maybePageId, | |
| userId: metadataUserId || (userDocRef ? userDocRef.id : undefined), | |
| 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) { | |
| // add entitlement & save customer id on user | |
| 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 docs (if owner matches) | |
| try { | |
| await cleanUpAfterSuccessfulCharge(db, userDocRef, { slug: maybeSlug, pageId: maybePageId, reference: entitlement.reference || entitlement.id, email: payerEmail }); | |
| } catch (e) { | |
| console.error('Cleanup 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); | |
| } 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 -------------------- */ | |
| 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 docs: docId = slug and docId = pageId if available | |
| try { | |
| if (db) { | |
| const slug = pageData.slug || pageData.data?.slug || null; | |
| const pageId = pageData.id || pageData.data?.id || null; | |
| if (slug) { | |
| await db.collection('paystack-page-mappings').doc(String(slug)).set({ | |
| userId: String(userId), | |
| userIdType: String(userId).includes('@') ? 'email' : 'uid', | |
| pageId: pageId ? String(pageId) : null, | |
| slug: String(slug), | |
| createdAt: admin.firestore.FieldValue.serverTimestamp(), | |
| }, { merge: true }); | |
| console.log('Saved page slug mapping for', slug); | |
| } | |
| if (pageId) { | |
| await db.collection('paystack-page-mappings').doc(String(pageId)).set({ | |
| userId: String(userId), | |
| userIdType: String(userId).includes('@') ? 'email' : 'uid', | |
| pageId: String(pageId), | |
| slug: slug || null, | |
| createdAt: admin.firestore.FieldValue.serverTimestamp(), | |
| }, { merge: true }); | |
| console.log('Saved pageId mapping for', pageId); | |
| } | |
| } | |
| } catch (e) { | |
| console.error('Failed to persist mapping docs:', 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.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).'); | |
| }); |