Spaces:
Sleeping
Sleeping
Update app.js
Browse files
app.js
CHANGED
|
@@ -22,51 +22,54 @@ const {
|
|
| 22 |
FIREBASE_CREDENTIALS,
|
| 23 |
PAYSTACK_DEMO_SECRET,
|
| 24 |
PAYSTACK_LIVE_SECRET,
|
| 25 |
-
PORT,
|
| 26 |
} = process.env;
|
| 27 |
|
| 28 |
// choose which secret to use (dev/demo by default)
|
| 29 |
-
const PAYSTACK_SECRET = PAYSTACK_DEMO_SECRET
|
| 30 |
|
| 31 |
if (!PAYSTACK_SECRET) {
|
| 32 |
console.warn('WARNING: PAYSTACK_SECRET is not set. Outgoing Paystack calls and webhook verification will fail.');
|
| 33 |
}
|
| 34 |
|
| 35 |
// Initialize Firebase Admin using credentials read from env
|
| 36 |
-
|
| 37 |
-
|
|
|
|
| 38 |
const serviceAccount = JSON.parse(FIREBASE_CREDENTIALS);
|
| 39 |
admin.initializeApp({
|
| 40 |
credential: admin.credential.cert(serviceAccount),
|
| 41 |
});
|
|
|
|
| 42 |
console.log('Firebase admin initialized from FIREBASE_CREDENTIALS env var.');
|
| 43 |
-
}
|
| 44 |
-
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
}
|
| 47 |
-
}
|
| 48 |
-
console.
|
|
|
|
| 49 |
}
|
| 50 |
|
| 51 |
// -------------------------------------------------
|
| 52 |
-
// Webhook raw parser (required to verify Paystack signature)
|
| 53 |
-
// -------------------------------------------------
|
| 54 |
-
app.use(
|
| 55 |
-
express.raw({
|
| 56 |
-
type: 'application/json',
|
| 57 |
-
limit: '1mb',
|
| 58 |
-
})
|
| 59 |
-
);
|
| 60 |
-
|
| 61 |
// Utility: verify x-paystack-signature
|
|
|
|
| 62 |
function verifyPaystackSignature(rawBodyBuffer, signatureHeader) {
|
| 63 |
if (!PAYSTACK_SECRET) return false;
|
| 64 |
-
|
| 65 |
-
hmac.update(rawBodyBuffer);
|
| 66 |
-
const expected = hmac.digest('hex');
|
| 67 |
try {
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
} catch (e) {
|
|
|
|
| 70 |
return false;
|
| 71 |
}
|
| 72 |
}
|
|
@@ -127,6 +130,7 @@ function getExpiryFromPlan(planInput) {
|
|
| 127 |
return { expiresAtMs: expires, expiresAtIso: new Date(expires).toISOString() };
|
| 128 |
}
|
| 129 |
if (interval.includes('hour')) {
|
|
|
|
| 130 |
const expires = Date.now() + 7 * 24 * 60 * 60 * 1000 + EXTRA_FREE_MS;
|
| 131 |
return { expiresAtMs: expires, expiresAtIso: new Date(expires).toISOString() };
|
| 132 |
}
|
|
@@ -155,16 +159,29 @@ function getExpiryFromPlan(planInput) {
|
|
| 155 |
|
| 156 |
// ------------------------------
|
| 157 |
// Helper: get userId from stored slug mapping
|
|
|
|
|
|
|
| 158 |
// ------------------------------
|
| 159 |
-
async function getUserIdFromSlug(
|
| 160 |
-
if (!
|
| 161 |
try {
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
} catch (e) {
|
| 167 |
-
console.error('Failed to read page mapping for slug:',
|
| 168 |
return null;
|
| 169 |
}
|
| 170 |
}
|
|
@@ -172,7 +189,7 @@ async function getUserIdFromSlug(db, slug) {
|
|
| 172 |
// ------------------------------
|
| 173 |
// Robust extractor for userId (tries metadata, custom_fields, customer metadata, slug mapping, email fallback)
|
| 174 |
// ------------------------------
|
| 175 |
-
async function extractUserIdFromPayload(data,
|
| 176 |
// 1) direct metadata on data
|
| 177 |
const metadata = data.metadata || {};
|
| 178 |
if (metadata.userId || metadata.user_id) return { userId: metadata.userId || metadata.user_id, source: 'metadata' };
|
|
@@ -205,10 +222,10 @@ async function extractUserIdFromPayload(data, db) {
|
|
| 205 |
}
|
| 206 |
}
|
| 207 |
|
| 208 |
-
// 5) slug mapping fallback - many webhooks include data.slug
|
| 209 |
-
const
|
| 210 |
-
if (
|
| 211 |
-
const mappedUserId = await getUserIdFromSlug(
|
| 212 |
if (mappedUserId) return { userId: mappedUserId, source: 'slug_mapping' };
|
| 213 |
}
|
| 214 |
|
|
@@ -219,15 +236,18 @@ async function extractUserIdFromPayload(data, db) {
|
|
| 219 |
return { userId: null, source: null };
|
| 220 |
}
|
| 221 |
|
|
|
|
| 222 |
// Helper: find user doc reference in Firestore (tries doc id and queries by fields)
|
| 223 |
-
|
| 224 |
-
|
|
|
|
|
|
|
| 225 |
|
| 226 |
if (metadataUserId) {
|
| 227 |
-
// if metadataUserId looks like an email, skip docId check
|
| 228 |
if (!String(metadataUserId).includes('@')) {
|
| 229 |
try {
|
| 230 |
-
const docRef = usersRef.doc(metadataUserId);
|
| 231 |
const snap = await docRef.get();
|
| 232 |
if (snap.exists) return docRef;
|
| 233 |
} catch (e) {
|
|
@@ -247,6 +267,16 @@ async function findUserDocRef(db, { metadataUserId, email }) {
|
|
| 247 |
}
|
| 248 |
}
|
| 249 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
if (email) {
|
| 251 |
try {
|
| 252 |
const qSnap = await usersRef.where('email', '==', email).limit(1).get();
|
|
@@ -260,11 +290,75 @@ async function findUserDocRef(db, { metadataUserId, email }) {
|
|
| 260 |
return null;
|
| 261 |
}
|
| 262 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 263 |
// ------------------------
|
| 264 |
-
//
|
| 265 |
// ------------------------
|
| 266 |
-
app.post('/webhook/paystack', async (req, res) => {
|
| 267 |
-
const raw = req.body; // Buffer
|
| 268 |
const signature = req.get('x-paystack-signature') || req.get('X-Paystack-Signature');
|
| 269 |
|
| 270 |
// Verify signature
|
|
@@ -288,20 +382,22 @@ app.post('/webhook/paystack', async (req, res) => {
|
|
| 288 |
const isInvoiceOrSubscription = /invoice\.(payment_succeeded|paid)|subscription\./i.test(event);
|
| 289 |
const isPayment = isChargeSuccess;
|
| 290 |
|
| 291 |
-
|
|
|
|
|
|
|
| 292 |
|
| 293 |
if (isRefund || isPayment || /subscription\.create/i.test(event) || /subscription\.update/i.test(event)) {
|
| 294 |
console.log('--- PAYSTACK WEBHOOK (INTERESTING EVENT) ---');
|
| 295 |
console.log('event:', event);
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
// persist webhook for auditing
|
| 299 |
try {
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
|
|
|
|
|
|
| 305 |
} catch (err) {
|
| 306 |
console.error('Failed to persist webhook audit:', err);
|
| 307 |
}
|
|
@@ -312,6 +408,8 @@ app.post('/webhook/paystack', async (req, res) => {
|
|
| 312 |
let metadataUserId = null;
|
| 313 |
let customerEmail = null;
|
| 314 |
let extractorSource = null;
|
|
|
|
|
|
|
| 315 |
try {
|
| 316 |
const extracted = await extractUserIdFromPayload(data, db);
|
| 317 |
metadataUserId = extracted.userId || null;
|
|
@@ -328,16 +426,30 @@ app.post('/webhook/paystack', async (req, res) => {
|
|
| 328 |
// Also set explicit customer email if present
|
| 329 |
if (!customerEmail && data.customer?.email) customerEmail = data.customer.email;
|
| 330 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 331 |
// Find user doc reference
|
| 332 |
let userDocRef = null;
|
| 333 |
try {
|
| 334 |
-
userDocRef = await findUserDocRef(db, { metadataUserId, email: customerEmail });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 335 |
} catch (err) {
|
| 336 |
console.error('Error finding user doc:', err);
|
| 337 |
userDocRef = null;
|
| 338 |
}
|
| 339 |
|
| 340 |
-
// Handler: subscription.create - store subscription id
|
| 341 |
if (/subscription\.create/i.test(event) || event === 'subscription.create') {
|
| 342 |
const subscriptionCode = data.subscription_code || data.subscription?.subscription_code || (data.id ? String(data.id) : null);
|
| 343 |
const status = data.status || 'active';
|
|
@@ -377,7 +489,7 @@ app.post('/webhook/paystack', async (req, res) => {
|
|
| 377 |
|
| 378 |
// Handler: charge.success - add entitlement on successful subscription charge
|
| 379 |
if (isPayment) {
|
| 380 |
-
const recurringMarker = (data.metadata && (data.metadata.custom_filters?.recurring || data.metadata.recurring)) || false;
|
| 381 |
const hasSubscription = !!(data.subscription || data.subscriptions || data.plan);
|
| 382 |
const isLikelySubscriptionPayment = recurringMarker || hasSubscription;
|
| 383 |
|
|
@@ -385,7 +497,7 @@ app.post('/webhook/paystack', async (req, res) => {
|
|
| 385 |
const expiry = getExpiryFromPlan(planObj);
|
| 386 |
|
| 387 |
const entitlement = {
|
| 388 |
-
id: data.reference || data.id ? String(data.reference || data.id) : null,
|
| 389 |
source: 'paystack',
|
| 390 |
amount: data.amount || null,
|
| 391 |
reference: data.reference || null,
|
|
@@ -401,8 +513,24 @@ app.post('/webhook/paystack', async (req, res) => {
|
|
| 401 |
await userDocRef.update({
|
| 402 |
entitlements: admin.firestore.FieldValue.arrayUnion(entitlement),
|
| 403 |
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
|
|
|
|
|
|
|
|
|
|
| 404 |
});
|
| 405 |
console.log('Added entitlement to user:', entitlement.id || entitlement.reference, 'expiry:', expiry.expiresAtIso);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 406 |
} catch (err) {
|
| 407 |
console.error('Failed to add entitlement to user:', err);
|
| 408 |
}
|
|
@@ -452,21 +580,10 @@ app.post('/webhook/paystack', async (req, res) => {
|
|
| 452 |
});
|
| 453 |
|
| 454 |
// ------------------------
|
| 455 |
-
//
|
| 456 |
// ------------------------
|
| 457 |
app.post('/create-payment-link', express.json(), async (req, res) => {
|
| 458 |
-
|
| 459 |
-
// If req.body was left as a Buffer (because express.raw ran), parse it:
|
| 460 |
-
let body = req.body;
|
| 461 |
-
if (Buffer.isBuffer(body)) {
|
| 462 |
-
try {
|
| 463 |
-
body = JSON.parse(body.toString('utf8'));
|
| 464 |
-
} catch (err) {
|
| 465 |
-
return res.status(400).json({ ok: false, message: 'Invalid JSON' });
|
| 466 |
-
}
|
| 467 |
-
}
|
| 468 |
-
|
| 469 |
-
const { planId, userId, name, amount, redirect_url, collect_phone = false, fixed_amount = false } = body || {};
|
| 470 |
|
| 471 |
if (!planId) return res.status(400).json({ ok: false, message: 'planId is required' });
|
| 472 |
if (!userId) return res.status(400).json({ ok: false, message: 'userId is required' });
|
|
@@ -478,18 +595,11 @@ app.post('/create-payment-link', express.json(), async (req, res) => {
|
|
| 478 |
type: 'subscription',
|
| 479 |
plan: planId,
|
| 480 |
metadata: {
|
| 481 |
-
userId,
|
|
|
|
|
|
|
|
|
|
| 482 |
},
|
| 483 |
-
// Pre-fill the custom field so Paystack includes it in transaction if possible
|
| 484 |
-
/* custom_fields: [
|
| 485 |
-
{
|
| 486 |
-
display_name: 'User ID',
|
| 487 |
-
variable_name: 'user_id',
|
| 488 |
-
value: userId,
|
| 489 |
-
},
|
| 490 |
-
],
|
| 491 |
-
*/
|
| 492 |
-
|
| 493 |
collect_phone,
|
| 494 |
fixed_amount,
|
| 495 |
};
|
|
@@ -512,13 +622,20 @@ app.post('/create-payment-link', express.json(), async (req, res) => {
|
|
| 512 |
try {
|
| 513 |
const slug = pageData.slug || pageData.data?.slug || null;
|
| 514 |
const pageId = pageData.id || pageData.data?.id || null;
|
| 515 |
-
if (slug) {
|
| 516 |
-
await
|
| 517 |
-
userId,
|
| 518 |
pageId: pageId ? String(pageId) : null,
|
| 519 |
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
| 520 |
}, { merge: true });
|
| 521 |
console.log('Saved page slug mapping for', slug);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 522 |
}
|
| 523 |
} catch (e) {
|
| 524 |
console.error('Failed to persist page slug mapping:', e);
|
|
@@ -533,7 +650,7 @@ app.post('/create-payment-link', express.json(), async (req, res) => {
|
|
| 533 |
});
|
| 534 |
|
| 535 |
// Simple health check
|
| 536 |
-
app.get('/health', (
|
| 537 |
|
| 538 |
app.listen(PORT, () => {
|
| 539 |
console.log(`Paystack webhook server listening on port ${PORT}`);
|
|
|
|
| 22 |
FIREBASE_CREDENTIALS,
|
| 23 |
PAYSTACK_DEMO_SECRET,
|
| 24 |
PAYSTACK_LIVE_SECRET,
|
| 25 |
+
PORT = 3000,
|
| 26 |
} = process.env;
|
| 27 |
|
| 28 |
// choose which secret to use (dev/demo by default)
|
| 29 |
+
const PAYSTACK_SECRET = PAYSTACK_DEMO_SECRET || PAYSTACK_LIVE_SECRET;
|
| 30 |
|
| 31 |
if (!PAYSTACK_SECRET) {
|
| 32 |
console.warn('WARNING: PAYSTACK_SECRET is not set. Outgoing Paystack calls and webhook verification will fail.');
|
| 33 |
}
|
| 34 |
|
| 35 |
// Initialize Firebase Admin using credentials read from env
|
| 36 |
+
let db = null;
|
| 37 |
+
try {
|
| 38 |
+
if (FIREBASE_CREDENTIALS) {
|
| 39 |
const serviceAccount = JSON.parse(FIREBASE_CREDENTIALS);
|
| 40 |
admin.initializeApp({
|
| 41 |
credential: admin.credential.cert(serviceAccount),
|
| 42 |
});
|
| 43 |
+
db = admin.firestore();
|
| 44 |
console.log('Firebase admin initialized from FIREBASE_CREDENTIALS env var.');
|
| 45 |
+
} else {
|
| 46 |
+
// If admin was already initialized in the environment (e.g. GCP), this will succeed
|
| 47 |
+
if (!admin.apps.length) {
|
| 48 |
+
console.warn('FIREBASE_CREDENTIALS not provided. Firebase admin not initialized. Some operations will error if they require Firestore.');
|
| 49 |
+
} else {
|
| 50 |
+
db = admin.firestore();
|
| 51 |
+
}
|
| 52 |
}
|
| 53 |
+
} catch (err) {
|
| 54 |
+
console.error('Failed to initialize Firebase admin:', err);
|
| 55 |
+
// keep running — code paths that need db will guard and log appropriately
|
| 56 |
}
|
| 57 |
|
| 58 |
// -------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
// Utility: verify x-paystack-signature
|
| 60 |
+
// -------------------------------------------------
|
| 61 |
function verifyPaystackSignature(rawBodyBuffer, signatureHeader) {
|
| 62 |
if (!PAYSTACK_SECRET) return false;
|
| 63 |
+
if (!rawBodyBuffer) return false;
|
|
|
|
|
|
|
| 64 |
try {
|
| 65 |
+
const hmac = crypto.createHmac('sha512', PAYSTACK_SECRET);
|
| 66 |
+
hmac.update(rawBodyBuffer);
|
| 67 |
+
const expected = hmac.digest('hex');
|
| 68 |
+
// signatureHeader may be undefined - handle gracefully
|
| 69 |
+
if (!signatureHeader) return false;
|
| 70 |
+
return crypto.timingSafeEqual(Buffer.from(expected, 'utf8'), Buffer.from(String(signatureHeader), 'utf8'));
|
| 71 |
} catch (e) {
|
| 72 |
+
console.error('Error while verifying signature:', e);
|
| 73 |
return false;
|
| 74 |
}
|
| 75 |
}
|
|
|
|
| 130 |
return { expiresAtMs: expires, expiresAtIso: new Date(expires).toISOString() };
|
| 131 |
}
|
| 132 |
if (interval.includes('hour')) {
|
| 133 |
+
// treat hour interval as a week by default (matching original logic)
|
| 134 |
const expires = Date.now() + 7 * 24 * 60 * 60 * 1000 + EXTRA_FREE_MS;
|
| 135 |
return { expiresAtMs: expires, expiresAtIso: new Date(expires).toISOString() };
|
| 136 |
}
|
|
|
|
| 159 |
|
| 160 |
// ------------------------------
|
| 161 |
// Helper: get userId from stored slug mapping
|
| 162 |
+
// - try doc id == slug
|
| 163 |
+
// - fallback: query where pageId == slug
|
| 164 |
// ------------------------------
|
| 165 |
+
async function getUserIdFromSlug(dbInstance, slugOrPageId) {
|
| 166 |
+
if (!slugOrPageId || !dbInstance) return null;
|
| 167 |
try {
|
| 168 |
+
// try doc id first (we store mapping under doc id === slug)
|
| 169 |
+
const doc = await dbInstance.collection('paystack-page-mappings').doc(String(slugOrPageId)).get();
|
| 170 |
+
if (doc.exists) {
|
| 171 |
+
const data = doc.data();
|
| 172 |
+
if (data?.userId) return data.userId;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
// fallback: maybe caller passed pageId; search by pageId field
|
| 176 |
+
const q = await dbInstance.collection('paystack-page-mappings').where('pageId', '==', String(slugOrPageId)).limit(1).get();
|
| 177 |
+
if (!q.empty) {
|
| 178 |
+
const d = q.docs[0].data();
|
| 179 |
+
if (d?.userId) return d.userId;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
return null;
|
| 183 |
} catch (e) {
|
| 184 |
+
console.error('Failed to read page mapping for slug/pageId:', slugOrPageId, e);
|
| 185 |
return null;
|
| 186 |
}
|
| 187 |
}
|
|
|
|
| 189 |
// ------------------------------
|
| 190 |
// Robust extractor for userId (tries metadata, custom_fields, customer metadata, slug mapping, email fallback)
|
| 191 |
// ------------------------------
|
| 192 |
+
async function extractUserIdFromPayload(data = {}, dbInstance) {
|
| 193 |
// 1) direct metadata on data
|
| 194 |
const metadata = data.metadata || {};
|
| 195 |
if (metadata.userId || metadata.user_id) return { userId: metadata.userId || metadata.user_id, source: 'metadata' };
|
|
|
|
| 222 |
}
|
| 223 |
}
|
| 224 |
|
| 225 |
+
// 5) slug / page id mapping fallback - many Paystack page webhooks include data.page.slug or data.page.id
|
| 226 |
+
const slugCandidate = data.slug || data.page?.slug || data.page?.id || metadata?.slug || metadata?.page_slug || null;
|
| 227 |
+
if (slugCandidate && dbInstance) {
|
| 228 |
+
const mappedUserId = await getUserIdFromSlug(dbInstance, slugCandidate);
|
| 229 |
if (mappedUserId) return { userId: mappedUserId, source: 'slug_mapping' };
|
| 230 |
}
|
| 231 |
|
|
|
|
| 236 |
return { userId: null, source: null };
|
| 237 |
}
|
| 238 |
|
| 239 |
+
// ------------------------------
|
| 240 |
// Helper: find user doc reference in Firestore (tries doc id and queries by fields)
|
| 241 |
+
// ------------------------------
|
| 242 |
+
async function findUserDocRef(dbInstance, { metadataUserId, email, paystackCustomerId } = {}) {
|
| 243 |
+
if (!dbInstance) return null;
|
| 244 |
+
const usersRef = dbInstance.collection('users');
|
| 245 |
|
| 246 |
if (metadataUserId) {
|
| 247 |
+
// if metadataUserId looks like an email, skip docId check and search by email
|
| 248 |
if (!String(metadataUserId).includes('@')) {
|
| 249 |
try {
|
| 250 |
+
const docRef = usersRef.doc(String(metadataUserId));
|
| 251 |
const snap = await docRef.get();
|
| 252 |
if (snap.exists) return docRef;
|
| 253 |
} catch (e) {
|
|
|
|
| 267 |
}
|
| 268 |
}
|
| 269 |
|
| 270 |
+
// If paystack_customer_id was provided, try that too
|
| 271 |
+
if (paystackCustomerId) {
|
| 272 |
+
try {
|
| 273 |
+
const qSnap = await usersRef.where('paystack_customer_id', '==', String(paystackCustomerId)).limit(1).get();
|
| 274 |
+
if (!qSnap.empty) return qSnap.docs[0].ref;
|
| 275 |
+
} catch (e) {
|
| 276 |
+
// ignore
|
| 277 |
+
}
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
if (email) {
|
| 281 |
try {
|
| 282 |
const qSnap = await usersRef.where('email', '==', email).limit(1).get();
|
|
|
|
| 290 |
return null;
|
| 291 |
}
|
| 292 |
|
| 293 |
+
// ------------------------------
|
| 294 |
+
// Cleanup that should run after a successful charge for the user
|
| 295 |
+
// - Deletes page mapping doc if slug provided and mapping matches user
|
| 296 |
+
// - Removes any "pending-payments" documents for the user (optional)
|
| 297 |
+
// - Writes a paymentCleanup log
|
| 298 |
+
// Customize this per your app's needs.
|
| 299 |
+
// ------------------------------
|
| 300 |
+
async function cleanUpAfterSuccessfulCharge(dbInstance, userDocRef, { slug, pageId, reference, email } = {}) {
|
| 301 |
+
if (!dbInstance || !userDocRef) return;
|
| 302 |
+
try {
|
| 303 |
+
// 1) delete mapping doc if exists and mapping.userId === user's id
|
| 304 |
+
if (slug) {
|
| 305 |
+
try {
|
| 306 |
+
const mapRef = dbInstance.collection('paystack-page-mappings').doc(String(slug));
|
| 307 |
+
const mapSnap = await mapRef.get();
|
| 308 |
+
if (mapSnap.exists) {
|
| 309 |
+
const map = mapSnap.data();
|
| 310 |
+
// if mapping.userId equals user doc id or equals user's email or similar, delete.
|
| 311 |
+
const userIdCandidate = userDocRef.id;
|
| 312 |
+
if (String(map.userId) === String(userIdCandidate) || (email && String(map.userId) === String(email))) {
|
| 313 |
+
await mapRef.delete();
|
| 314 |
+
console.log('Deleted paystack-page-mappings doc for slug:', slug);
|
| 315 |
+
} else {
|
| 316 |
+
console.log('Skipping deletion of mapping for slug (owner mismatch):', slug);
|
| 317 |
+
}
|
| 318 |
+
}
|
| 319 |
+
} catch (e) {
|
| 320 |
+
console.error('Error deleting paystack-page-mappings doc for slug:', slug, e);
|
| 321 |
+
}
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
// 2) optional: remove pending-payments documents (if your app stores them)
|
| 325 |
+
try {
|
| 326 |
+
const pendingRef = dbInstance.collection('pending-payments');
|
| 327 |
+
const q = await pendingRef.where('userId', '==', userDocRef.id).limit(50).get();
|
| 328 |
+
if (!q.empty) {
|
| 329 |
+
const batch = dbInstance.batch();
|
| 330 |
+
q.docs.forEach(doc => batch.delete(doc.ref));
|
| 331 |
+
await batch.commit();
|
| 332 |
+
console.log('Deleted pending-payments for user:', userDocRef.id);
|
| 333 |
+
}
|
| 334 |
+
} catch (e) {
|
| 335 |
+
// not critical
|
| 336 |
+
console.error('Failed to delete pending-payments (non-fatal):', e);
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
// 3) log the cleanup
|
| 340 |
+
try {
|
| 341 |
+
await dbInstance.collection('paystack-cleanups').add({
|
| 342 |
+
userRef: userDocRef.path,
|
| 343 |
+
slug: slug || null,
|
| 344 |
+
pageId: pageId ? String(pageId) : null,
|
| 345 |
+
reference: reference || null,
|
| 346 |
+
email: email || null,
|
| 347 |
+
cleanedAt: admin.firestore.FieldValue.serverTimestamp(),
|
| 348 |
+
});
|
| 349 |
+
} catch (e) {
|
| 350 |
+
console.error('Failed to log cleanup:', e);
|
| 351 |
+
}
|
| 352 |
+
} catch (e) {
|
| 353 |
+
console.error('Unexpected error during cleanup:', e);
|
| 354 |
+
}
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
// ------------------------
|
| 358 |
+
// Webhook endpoint (only this route uses express.raw so other routes can use express.json)
|
| 359 |
// ------------------------
|
| 360 |
+
app.post('/webhook/paystack', express.raw({ type: 'application/json', limit: '1mb' }), async (req, res) => {
|
| 361 |
+
const raw = req.body; // Buffer
|
| 362 |
const signature = req.get('x-paystack-signature') || req.get('X-Paystack-Signature');
|
| 363 |
|
| 364 |
// Verify signature
|
|
|
|
| 382 |
const isInvoiceOrSubscription = /invoice\.(payment_succeeded|paid)|subscription\./i.test(event);
|
| 383 |
const isPayment = isChargeSuccess;
|
| 384 |
|
| 385 |
+
if (!db) {
|
| 386 |
+
console.error('Firestore (admin) not initialized; webhook will not persist data.');
|
| 387 |
+
}
|
| 388 |
|
| 389 |
if (isRefund || isPayment || /subscription\.create/i.test(event) || /subscription\.update/i.test(event)) {
|
| 390 |
console.log('--- PAYSTACK WEBHOOK (INTERESTING EVENT) ---');
|
| 391 |
console.log('event:', event);
|
| 392 |
+
// persist webhook for auditing (best-effort)
|
|
|
|
|
|
|
| 393 |
try {
|
| 394 |
+
if (db) {
|
| 395 |
+
await db.collection('paystack-webhooks').add({
|
| 396 |
+
event,
|
| 397 |
+
payload,
|
| 398 |
+
receivedAt: admin.firestore.FieldValue.serverTimestamp(),
|
| 399 |
+
});
|
| 400 |
+
}
|
| 401 |
} catch (err) {
|
| 402 |
console.error('Failed to persist webhook audit:', err);
|
| 403 |
}
|
|
|
|
| 408 |
let metadataUserId = null;
|
| 409 |
let customerEmail = null;
|
| 410 |
let extractorSource = null;
|
| 411 |
+
let maybeSlug = null;
|
| 412 |
+
let maybePageId = null;
|
| 413 |
try {
|
| 414 |
const extracted = await extractUserIdFromPayload(data, db);
|
| 415 |
metadataUserId = extracted.userId || null;
|
|
|
|
| 426 |
// Also set explicit customer email if present
|
| 427 |
if (!customerEmail && data.customer?.email) customerEmail = data.customer.email;
|
| 428 |
|
| 429 |
+
// Save possible slug/pageId for cleanup/connection
|
| 430 |
+
maybeSlug = data.slug || data.page?.slug || data.metadata?.slug || null;
|
| 431 |
+
maybePageId = data.page?.id || data.page_id || data.metadata?.page_id || null;
|
| 432 |
+
|
| 433 |
+
// Also try to get paystack customer id from payload (helps linking)
|
| 434 |
+
const paystackCustomerId = data.customer?.id ?? data.customer?.customer_code ?? null;
|
| 435 |
+
|
| 436 |
// Find user doc reference
|
| 437 |
let userDocRef = null;
|
| 438 |
try {
|
| 439 |
+
userDocRef = await findUserDocRef(db, { metadataUserId, email: customerEmail, paystackCustomerId });
|
| 440 |
+
// If still null and metadataUserId is present but looks like slug, try slug mapping as last resort
|
| 441 |
+
if (!userDocRef && metadataUserId && db) {
|
| 442 |
+
const mapped = await getUserIdFromSlug(db, metadataUserId);
|
| 443 |
+
if (mapped) {
|
| 444 |
+
userDocRef = await findUserDocRef(db, { metadataUserId: mapped, email: customerEmail, paystackCustomerId });
|
| 445 |
+
}
|
| 446 |
+
}
|
| 447 |
} catch (err) {
|
| 448 |
console.error('Error finding user doc:', err);
|
| 449 |
userDocRef = null;
|
| 450 |
}
|
| 451 |
|
| 452 |
+
// Handler: subscription.create - store subscription id and paystack_customer_id
|
| 453 |
if (/subscription\.create/i.test(event) || event === 'subscription.create') {
|
| 454 |
const subscriptionCode = data.subscription_code || data.subscription?.subscription_code || (data.id ? String(data.id) : null);
|
| 455 |
const status = data.status || 'active';
|
|
|
|
| 489 |
|
| 490 |
// Handler: charge.success - add entitlement on successful subscription charge
|
| 491 |
if (isPayment) {
|
| 492 |
+
const recurringMarker = Boolean((data.metadata && (data.metadata.custom_filters?.recurring || data.metadata.recurring)) || false);
|
| 493 |
const hasSubscription = !!(data.subscription || data.subscriptions || data.plan);
|
| 494 |
const isLikelySubscriptionPayment = recurringMarker || hasSubscription;
|
| 495 |
|
|
|
|
| 497 |
const expiry = getExpiryFromPlan(planObj);
|
| 498 |
|
| 499 |
const entitlement = {
|
| 500 |
+
id: (data.reference || data.id) ? String(data.reference || data.id) : null,
|
| 501 |
source: 'paystack',
|
| 502 |
amount: data.amount || null,
|
| 503 |
reference: data.reference || null,
|
|
|
|
| 513 |
await userDocRef.update({
|
| 514 |
entitlements: admin.firestore.FieldValue.arrayUnion(entitlement),
|
| 515 |
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
|
| 516 |
+
lastPaymentAt: admin.firestore.FieldValue.serverTimestamp(),
|
| 517 |
+
// Save paystack_customer_id if present and not already present
|
| 518 |
+
...(paystackCustomerId ? { paystack_customer_id: String(paystackCustomerId) } : {}),
|
| 519 |
});
|
| 520 |
console.log('Added entitlement to user:', entitlement.id || entitlement.reference, 'expiry:', expiry.expiresAtIso);
|
| 521 |
+
|
| 522 |
+
// Run cleanup now that the charge is successful for this user
|
| 523 |
+
try {
|
| 524 |
+
await cleanUpAfterSuccessfulCharge(db, userDocRef, {
|
| 525 |
+
slug: maybeSlug,
|
| 526 |
+
pageId: maybePageId,
|
| 527 |
+
reference: entitlement.reference || entitlement.id,
|
| 528 |
+
email: customerEmail,
|
| 529 |
+
});
|
| 530 |
+
} catch (cleanupErr) {
|
| 531 |
+
console.error('Cleanup after successful charge failed:', cleanupErr);
|
| 532 |
+
}
|
| 533 |
+
|
| 534 |
} catch (err) {
|
| 535 |
console.error('Failed to add entitlement to user:', err);
|
| 536 |
}
|
|
|
|
| 580 |
});
|
| 581 |
|
| 582 |
// ------------------------
|
| 583 |
+
// Create Payment Page (link) for a plan
|
| 584 |
// ------------------------
|
| 585 |
app.post('/create-payment-link', express.json(), async (req, res) => {
|
| 586 |
+
const { planId, userId, name, amount, redirect_url, collect_phone = false, fixed_amount = false } = req.body || {};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 587 |
|
| 588 |
if (!planId) return res.status(400).json({ ok: false, message: 'planId is required' });
|
| 589 |
if (!userId) return res.status(400).json({ ok: false, message: 'userId is required' });
|
|
|
|
| 595 |
type: 'subscription',
|
| 596 |
plan: planId,
|
| 597 |
metadata: {
|
| 598 |
+
userId: String(userId),
|
| 599 |
+
},
|
| 600 |
+
customer.metadata: {
|
| 601 |
+
userId: String(userId),
|
| 602 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 603 |
collect_phone,
|
| 604 |
fixed_amount,
|
| 605 |
};
|
|
|
|
| 622 |
try {
|
| 623 |
const slug = pageData.slug || pageData.data?.slug || null;
|
| 624 |
const pageId = pageData.id || pageData.data?.id || null;
|
| 625 |
+
if (slug && db) {
|
| 626 |
+
await db.collection('paystack-page-mappings').doc(String(slug)).set({
|
| 627 |
+
userId: String(userId),
|
| 628 |
pageId: pageId ? String(pageId) : null,
|
| 629 |
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
| 630 |
}, { merge: true });
|
| 631 |
console.log('Saved page slug mapping for', slug);
|
| 632 |
+
} else if (pageId && db) {
|
| 633 |
+
// also ensure a record exists by pageId lookup (useful if slug missing in some responses)
|
| 634 |
+
await db.collection('paystack-page-mappings').doc(String(pageId)).set({
|
| 635 |
+
userId: String(userId),
|
| 636 |
+
pageId: String(pageId),
|
| 637 |
+
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
| 638 |
+
}, { merge: true });
|
| 639 |
}
|
| 640 |
} catch (e) {
|
| 641 |
console.error('Failed to persist page slug mapping:', e);
|
|
|
|
| 650 |
});
|
| 651 |
|
| 652 |
// Simple health check
|
| 653 |
+
app.get('/health', (req, res) => res.json({ ok: true }));
|
| 654 |
|
| 655 |
app.listen(PORT, () => {
|
| 656 |
console.log(`Paystack webhook server listening on port ${PORT}`);
|