Pepguy's picture
Update app.js
4556eaf verified
// 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).');
});