Pepguy's picture
Update app.js
a9b2951 verified
raw
history blame
18.1 kB
// 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: <Number>, expiresAtIso: <String> }
// 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).');
});