Pepguy commited on
Commit
a7f83b5
·
verified ·
1 Parent(s): 7116561

Update app.js

Browse files
Files changed (1) hide show
  1. app.js +199 -82
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; // 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
- if (FIREBASE_CREDENTIALS) {
37
- try {
 
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
- } catch (err) {
44
- console.error('Failed to parse FIREBASE_CREDENTIALS JSON:', err);
45
- process.exit(1);
 
 
 
 
46
  }
47
- } else {
48
- console.warn('FIREBASE_CREDENTIALS not provided. Firebase admin not initialized.');
 
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
- const hmac = crypto.createHmac('sha512', PAYSTACK_SECRET);
65
- hmac.update(rawBodyBuffer);
66
- const expected = hmac.digest('hex');
67
  try {
68
- return crypto.timingSafeEqual(Buffer.from(expected, 'utf8'), Buffer.from(signatureHeader || '', 'utf8'));
 
 
 
 
 
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(db, slug) {
160
- if (!slug) return null;
161
  try {
162
- const doc = await db.collection('paystack-page-mappings').doc(slug).get();
163
- if (!doc.exists) return null;
164
- const data = doc.data();
165
- return data?.userId || null;
 
 
 
 
 
 
 
 
 
 
 
166
  } catch (e) {
167
- console.error('Failed to read page mapping for slug:', slug, e);
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, db) {
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 slug = data.slug || data.page?.slug || (data.authorization?.reference && null);
210
- if (slug) {
211
- const mappedUserId = await getUserIdFromSlug(db, slug);
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
- async function findUserDocRef(db, { metadataUserId, email }) {
224
- const usersRef = db.collection('users');
 
 
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
- // Existing webhook endpoint (modified to update subscription/entitlements and write paystack_customer_id)
265
  // ------------------------
266
- app.post('/webhook/paystack', async (req, res) => {
267
- const raw = req.body; // Buffer because we used express.raw
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
- const db = admin.firestore();
 
 
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
- console.log('payload:', JSON.stringify(payload, null, 2));
297
-
298
- // persist webhook for auditing
299
  try {
300
- await db.collection('paystack-webhooks').add({
301
- event,
302
- payload,
303
- receivedAt: admin.firestore.FieldValue.serverTimestamp(),
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 only and paystack_customer_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
- // New: Create Payment Page (link) for a plan
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 admin.firestore().collection('paystack-page-mappings').doc(String(slug)).set({
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', (_req, res) => res.json({ ok: true }));
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}`);