Pepguy commited on
Commit
5277fde
·
verified ·
1 Parent(s): d9aa318

Update app.js

Browse files
Files changed (1) hide show
  1. app.js +164 -177
app.js CHANGED
@@ -121,63 +121,47 @@ function getExpiryFromPlan(planInput) {
121
  return { expiresAtMs: expires, expiresAtIso: new Date(expires).toISOString() };
122
  }
123
 
124
- /* -------------------- Mapping helpers & updates -------------------- */
125
 
126
- async function updateMappingWithIdentifiers(dbInstance, { slug, pageId, userId, customerId, payerEmail, subscriptionCode, authorizationCode } = {}) {
127
- if (!dbInstance) return;
 
128
  try {
129
  const writeObj = {};
130
  if (userId !== undefined) writeObj.userId = String(userId);
 
131
  if (customerId !== undefined && customerId !== null) writeObj.customerId = String(customerId);
132
  if (payerEmail !== undefined && payerEmail !== null) writeObj.payerEmail = String(payerEmail);
133
  if (subscriptionCode !== undefined && subscriptionCode !== null) writeObj.subscriptionCode = String(subscriptionCode);
134
  if (authorizationCode !== undefined && authorizationCode !== null) writeObj.authorizationCode = String(authorizationCode);
135
  if (Object.keys(writeObj).length === 0) return;
136
 
137
- if (slug) {
138
- await dbInstance.collection('paystack-page-mappings').doc(String(slug)).set({
139
- ...writeObj,
140
- updatedAt: admin.firestore.FieldValue.serverTimestamp(),
141
- }, { merge: true });
142
- console.log('Updated mapping doc (slug) with identifiers:', slug, writeObj);
143
- }
144
- if (pageId) {
145
- await dbInstance.collection('paystack-page-mappings').doc(String(pageId)).set({
146
- ...writeObj,
147
- updatedAt: admin.firestore.FieldValue.serverTimestamp(),
148
- }, { merge: true });
149
- console.log('Updated mapping doc (pageId) with identifiers:', pageId, writeObj);
150
- }
151
  } catch (e) {
152
  console.error('updateMappingWithIdentifiers failed:', e);
153
  }
154
  }
155
 
156
- async function getUserIdFromSlug(dbInstance, slugOrPageId) {
157
- if (!dbInstance || !slugOrPageId) return null;
 
158
  try {
159
- const docRef = dbInstance.collection('paystack-page-mappings').doc(String(slugOrPageId));
160
- const snap = await docRef.get();
161
- if (snap.exists) {
162
- const d = snap.data();
163
- if (d?.userId) {
164
- console.log('Found mapping doc (by docId) for', slugOrPageId, ':', { userId: d.userId, customerId: d.customerId, payerEmail: d.payerEmail, subscriptionCode: d.subscriptionCode });
165
- return d.userId;
166
- }
167
- }
168
- const q = await dbInstance.collection('paystack-page-mappings').where('pageId', '==', String(slugOrPageId)).limit(1).get();
169
- if (!q.empty) {
170
- const d = q.docs[0].data();
171
- console.log('Found mapping doc (by pageId) for', slugOrPageId, ':', { userId: d.userId, customerId: d.customerId, payerEmail: d.payerEmail, subscriptionCode: d.subscriptionCode });
172
- if (d?.userId) return d.userId;
173
- }
174
- return null;
175
  } catch (e) {
176
- console.error('getUserIdFromSlug error:', e);
177
  return null;
178
  }
179
  }
180
 
 
181
  async function findMappingByIdentifiers(dbInstance, { customerId, payerEmail, subscriptionCode } = {}) {
182
  if (!dbInstance) return null;
183
  try {
@@ -201,65 +185,74 @@ async function findMappingByIdentifiers(dbInstance, { customerId, payerEmail, su
201
  }
202
  }
203
 
204
- async function resolveUserDocFromMapping(dbInstance, { key = null, customerId = null, payerEmail = null, subscriptionCode = null } = {}) {
 
205
  if (!dbInstance) return null;
206
  const usersRef = dbInstance.collection('users');
207
 
208
- try {
209
- if (key) {
210
- const mappedUserId = await getUserIdFromSlug(dbInstance, key);
211
- if (mappedUserId) {
 
 
 
212
  try {
213
  const directRef = usersRef.doc(String(mappedUserId));
214
  const ds = await directRef.get();
215
  if (ds.exists) return directRef;
216
  } catch (e) {}
 
217
  if (String(mappedUserId).includes('@')) {
218
  try {
219
  const q = await usersRef.where('email', '==', String(mappedUserId)).limit(1).get();
220
  if (!q.empty) return q.docs[0].ref;
221
  } catch (e) {}
222
  }
 
223
  const idFields = ['userId','uid','id'];
224
  for (const f of idFields) {
225
  try {
226
- const q = await usersRef.where(f, '==', mappedUserId).limit(1).get();
227
  if (!q.empty) return q.docs[0].ref;
228
  } catch (e) {}
229
  }
230
  }
 
 
231
  }
 
232
 
233
- const mappingFound = await findMappingByIdentifiers(dbInstance, { customerId, payerEmail, subscriptionCode });
234
- if (mappingFound && mappingFound.data) {
235
- const mappedUserId = mappingFound.data.userId;
236
- if (mappedUserId) {
 
 
 
 
 
 
 
 
237
  try {
238
- const directRef = usersRef.doc(String(mappedUserId));
239
- const ds = await directRef.get();
240
- if (ds.exists) return directRef;
 
 
 
 
 
 
241
  } catch (e) {}
242
- if (String(mappedUserId).includes('@')) {
243
- try {
244
- const q = await usersRef.where('email', '==', String(mappedUserId)).limit(1).get();
245
- if (!q.empty) return q.docs[0].ref;
246
- } catch (e) {}
247
- }
248
- const idFields = ['userId','uid','id'];
249
- for (const f of idFields) {
250
- try {
251
- const q = await usersRef.where(f, '==', mappedUserId).limit(1).get();
252
- if (!q.empty) return q.docs[0].ref;
253
- } catch (e) {}
254
- }
255
  }
256
  }
257
-
258
- return null;
259
  } catch (e) {
260
- console.error('resolveUserDocFromMapping error:', e);
261
- return null;
262
  }
 
 
263
  }
264
 
265
  /* -------------------- Fallback user resolution -------------------- */
@@ -303,62 +296,81 @@ async function findUserDocRef(dbInstance, { metadataUserId, email, paystackCusto
303
  return null;
304
  }
305
 
306
- /* -------------------- Prune helper (entitlements + old webhook audits) -------------------- */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
307
 
308
  async function pruneUserEntitlementsAndWebhooks(dbInstance, userDocRef, { paystackCustomerId = null, payerEmail = null } = {}) {
309
  if (!dbInstance || !userDocRef) return;
310
  try {
311
- // cutoff ~60 days (2 months approximated as 60 days)
312
  const cutoffMs = Date.now() - (60 * 24 * 60 * 60 * 1000);
313
-
314
- // 1) prune entitlements on the user doc
315
  const snap = await userDocRef.get();
316
  if (!snap.exists) return;
317
  const userData = snap.data() || {};
318
  const entitlements = Array.isArray(userData.entitlements) ? userData.entitlements : [];
319
-
320
  const cleaned = entitlements.filter(e => {
321
- // if expiresAtMs present -> keep only if still in future
322
  if (e && (e.expiresAtMs || e.expiresAtMs === 0)) {
323
  const exMs = Number(e.expiresAtMs) || 0;
324
- return exMs > Date.now(); // keep if expiry in future
325
  }
326
- // else if createdAt present -> keep only if createdAt within cutoff window
327
  if (e && e.createdAt) {
328
- // createdAt could be a Firestore Timestamp or a number/string
329
  let createdMs = 0;
330
  try {
331
  if (typeof e.createdAt === 'number') createdMs = e.createdAt;
332
  else if (e.createdAt && typeof e.createdAt.toMillis === 'function') createdMs = e.createdAt.toMillis();
333
  else if (typeof e.createdAt === 'string') createdMs = Number(e.createdAt) || 0;
334
  else if (e.createdAt._seconds) createdMs = (Number(e.createdAt._seconds) * 1000) + (Number(e.createdAt._nanoseconds || 0) / 1e6);
335
- } catch (ee) {
336
- createdMs = 0;
337
- }
338
  if (createdMs) return createdMs >= cutoffMs;
339
- // if no timestamps at all, conservatively keep
340
  return true;
341
  }
342
- // if no expiry and no createdAt -> keep (can't judge)
343
  return true;
344
  });
345
-
346
  if (cleaned.length !== entitlements.length) {
347
- await userDocRef.update({
348
- entitlements: cleaned,
349
- updatedAt: admin.firestore.FieldValue.serverTimestamp(),
350
- });
351
  console.log('Pruned entitlements: removed', entitlements.length - cleaned.length, 'entries for', userDocRef.path);
352
- } else {
353
- console.log('No entitlements to prune for', userDocRef.path);
354
  }
355
 
356
- // 2) prune paystack-webhooks audit docs for this user older than cutoff
357
  try {
358
  const coll = dbInstance.collection('paystack-webhooks');
359
  const toDeleteRefs = [];
360
-
361
- // Query by customer id
362
  if (paystackCustomerId) {
363
  try {
364
  const q = await coll.where('payload.data.customer.id', '==', paystackCustomerId).get();
@@ -369,13 +381,8 @@ async function pruneUserEntitlementsAndWebhooks(dbInstance, userDocRef, { paysta
369
  if (ts && typeof ts.toMillis === 'function') tsMs = ts.toMillis();
370
  if (tsMs && tsMs < cutoffMs) toDeleteRefs.push(d.ref);
371
  });
372
- } catch (e) {
373
- // May require index; ignore failure to avoid breaking webhook
374
- console.warn('Could not query paystack-webhooks by customer.id (skipping):', e);
375
- }
376
  }
377
-
378
- // Query by payer email
379
  if (payerEmail) {
380
  try {
381
  const q2 = await coll.where('payload.data.customer.email', '==', payerEmail).get();
@@ -388,22 +395,15 @@ async function pruneUserEntitlementsAndWebhooks(dbInstance, userDocRef, { paysta
388
  if (!toDeleteRefs.find(x => x.path === d.ref.path)) toDeleteRefs.push(d.ref);
389
  }
390
  });
391
- } catch (e) {
392
- console.warn('Could not query paystack-webhooks by customer.email (skipping):', e);
393
- }
394
  }
395
-
396
  if (toDeleteRefs.length) {
397
  const batch = dbInstance.batch();
398
  toDeleteRefs.forEach(r => batch.delete(r));
399
  await batch.commit();
400
  console.log('Deleted', toDeleteRefs.length, 'old paystack-webhooks audit docs for user', userDocRef.id);
401
- } else {
402
- console.log('No old paystack-webhooks audit docs to prune for user', userDocRef.id);
403
  }
404
- } catch (e) {
405
- console.error('Failed pruning paystack-webhooks (non-fatal):', e);
406
- }
407
  } catch (e) {
408
  console.error('pruneUserEntitlementsAndWebhooks failed:', e);
409
  }
@@ -446,17 +446,17 @@ app.post('/webhook/paystack', express.raw({ type: 'application/json', limit: '1m
446
  const data = payload.data || {};
447
 
448
  // extract slug
449
- let maybePageId = data.page?.id || data.page_id || null;
450
  let maybeSlug = data.page?.slug || data.slug || null;
451
-
452
- const referrer = data.metadata?.referrer || (data.metadata && data.metadata.referrer) || null;
453
- if (!maybeSlug && referrer) {
454
- const extracted = extractSlugFromReferrer(String(referrer));
455
- if (extracted) {
456
- maybeSlug = extracted;
457
- console.log('Extracted slug from metadata.referrer:', maybeSlug);
458
- } else {
459
- console.log('Could not extract slug from referrer:', referrer);
 
460
  }
461
  }
462
 
@@ -468,24 +468,17 @@ app.post('/webhook/paystack', express.raw({ type: 'application/json', limit: '1m
468
 
469
  // quick metadata extraction
470
  let metadataUserId = null;
471
- let extractorSource = null;
472
  try {
473
  const metadata = data.metadata || {};
474
- if (metadata.userId || metadata.user_id) {
475
- metadataUserId = metadata.userId || metadata.user_id;
476
- extractorSource = 'metadata';
477
- } else if (data.customer?.metadata) {
478
  const cm = data.customer.metadata;
479
- if (cm.userId || cm.user_id) {
480
- metadataUserId = cm.userId || cm.user_id;
481
- extractorSource = 'customer.metadata';
482
- }
483
  } else if (Array.isArray(data.custom_fields)) {
484
  for (const f of data.custom_fields) {
485
  const n = (f.variable_name || f.display_name || '').toString().toLowerCase();
486
  if ((n.includes('user') || n.includes('user_id') || n.includes('userid')) && f.value) {
487
  metadataUserId = f.value;
488
- extractorSource = 'custom_fields';
489
  break;
490
  }
491
  }
@@ -494,18 +487,17 @@ app.post('/webhook/paystack', express.raw({ type: 'application/json', limit: '1m
494
  console.error('Error during quick metadata extraction:', e);
495
  }
496
 
497
- // Resolve user: mapping-first (slug/pageId), then mapping by identifiers, then fallback queries
498
  let userDocRef = null;
499
  try {
500
- const mappingKey = maybeSlug || maybePageId || null;
501
- if (mappingKey && db) {
502
- userDocRef = await resolveUserDocFromMapping(db, { key: mappingKey, customerId: paystackCustomerId, payerEmail, subscriptionCode });
503
- if (userDocRef) console.log('Resolved user from mapping (key):', userDocRef.path, 'key=', mappingKey);
504
- else console.log('No mapping resolved from key:', mappingKey);
505
  }
506
 
507
  if (!userDocRef && db && (paystackCustomerId || payerEmail || subscriptionCode)) {
508
- userDocRef = await resolveUserDocFromMapping(db, { key: null, customerId: paystackCustomerId, payerEmail, subscriptionCode });
509
  if (userDocRef) console.log('Resolved user from mapping by identifiers ->', userDocRef.path);
510
  }
511
 
@@ -518,22 +510,26 @@ app.post('/webhook/paystack', express.raw({ type: 'application/json', limit: '1m
518
  userDocRef = null;
519
  }
520
 
521
- // update mapping with authoritative identifiers if available
522
  try {
523
- await updateMappingWithIdentifiers(db, {
524
- slug: maybeSlug,
525
- pageId: maybePageId,
526
- userId: metadataUserId || (userDocRef ? userDocRef.id : undefined),
527
- customerId: paystackCustomerId || undefined,
528
- payerEmail: payerEmail || undefined,
529
- subscriptionCode: subscriptionCode || undefined,
530
- authorizationCode: authorizationCode || undefined,
531
- });
 
 
 
 
532
  } catch (e) {
533
  console.error('Failed updateMappingWithIdentifiers:', e);
534
  }
535
 
536
- // subscription.create handler
537
  if (/subscription\.create/i.test(event) || event === 'subscription.create') {
538
  const subscriptionCodeLocal = subscriptionCode || (data.id ? String(data.id) : null);
539
  const status = data.status || 'active';
@@ -557,7 +553,6 @@ app.post('/webhook/paystack', express.raw({ type: 'application/json', limit: '1m
557
  if (paystackCustomerId) updateObj.paystack_customer_id = String(paystackCustomerId);
558
  await userDocRef.update(updateObj);
559
  console.log('subscription.create: updated user with subscription:', subscriptionCodeLocal);
560
- // prune after successful subscription update
561
  await pruneUserEntitlementsAndWebhooks(db, userDocRef, { paystackCustomerId, payerEmail });
562
  } catch (e) {
563
  console.error('subscription.create: failed to update user:', e);
@@ -567,9 +562,9 @@ app.post('/webhook/paystack', express.raw({ type: 'application/json', limit: '1m
567
  }
568
  }
569
 
570
- // charge.success handler - entitlement add + mapping updates
571
  if (isPayment) {
572
- console.log('charge.success identifiers:', { metadataUserId, payerEmail, maybeSlug, maybePageId, paystackCustomerId, subscriptionCode, extractorSource });
573
 
574
  const recurringMarker = Boolean((data.metadata && (data.metadata.custom_filters?.recurring || data.metadata.recurring)) || false);
575
  const hasSubscription = !!(data.subscription || data.subscriptions || data.plan || subscriptionCode);
@@ -590,17 +585,21 @@ app.post('/webhook/paystack', express.raw({ type: 'application/json', limit: '1m
590
  createdAt: admin.firestore.Timestamp.now(),
591
  };
592
 
593
- // Ensure mapping gets authoritative identifiers on first charge
594
  try {
595
- await updateMappingWithIdentifiers(db, {
596
- slug: maybeSlug,
597
- pageId: maybePageId,
598
- userId: metadataUserId || (userDocRef ? userDocRef.id : undefined),
599
- customerId: paystackCustomerId || undefined,
600
- payerEmail: payerEmail || undefined,
601
- subscriptionCode: subscriptionCode || undefined,
602
- authorizationCode: authorizationCode || undefined,
603
- });
 
 
 
 
604
  } catch (e) {
605
  console.error('Failed updateMappingWithIdentifiers during charge.success:', e);
606
  }
@@ -638,14 +637,14 @@ app.post('/webhook/paystack', express.raw({ type: 'application/json', limit: '1m
638
  }
639
  }
640
 
641
- // cleanup mapping docs (if owner matches)
642
  try {
643
- await cleanUpAfterSuccessfulCharge(db, userDocRef, { slug: maybeSlug, pageId: maybePageId, reference: entitlement.reference || entitlement.id, email: payerEmail });
644
  } catch (e) {
645
  console.error('Cleanup failed:', e);
646
  }
647
 
648
- // prune entitlements and old webhook audits for this user (runs after successful processing)
649
  try {
650
  await pruneUserEntitlementsAndWebhooks(db, userDocRef, { paystackCustomerId, payerEmail });
651
  } catch (e) {
@@ -674,7 +673,6 @@ app.post('/webhook/paystack', express.raw({ type: 'application/json', limit: '1m
674
  });
675
  await userDocRef.update({ entitlements: filtered, updatedAt: admin.firestore.FieldValue.serverTimestamp() });
676
  console.log('Removed entitlements matching refund:', refundedReference);
677
- // also prune (to remove other expired ones if present)
678
  await pruneUserEntitlementsAndWebhooks(db, userDocRef, { paystackCustomerId, payerEmail });
679
  } else {
680
  console.warn('Refund handling: user doc vanished.');
@@ -693,7 +691,7 @@ app.post('/webhook/paystack', express.raw({ type: 'application/json', limit: '1m
693
  return res.status(200).json({ ok: true });
694
  });
695
 
696
- /* -------------------- Create payment link endpoint -------------------- */
697
 
698
  app.post('/create-payment-link', express.json(), async (req, res) => {
699
  const { planId, userId, name, amount, redirect_url, collect_phone = false, fixed_amount = false } = req.body || {};
@@ -713,36 +711,25 @@ app.post('/create-payment-link', express.json(), async (req, res) => {
713
 
714
  const pageData = response.data && response.data.data ? response.data.data : response.data;
715
 
716
- // persist mapping docs: docId = slug and docId = pageId if available
717
  try {
718
  if (db) {
719
  const slug = pageData.slug || pageData.data?.slug || null;
720
- const pageId = pageData.id || pageData.data?.id || null;
721
-
722
  if (slug) {
723
  await db.collection('paystack-page-mappings').doc(String(slug)).set({
724
  userId: String(userId),
725
  userIdType: String(userId).includes('@') ? 'email' : 'uid',
726
- pageId: pageId ? String(pageId) : null,
727
  slug: String(slug),
728
  createdAt: admin.firestore.FieldValue.serverTimestamp(),
729
  }, { merge: true });
730
  console.log('Saved page slug mapping for', slug);
731
- }
732
-
733
- if (pageId) {
734
- await db.collection('paystack-page-mappings').doc(String(pageId)).set({
735
- userId: String(userId),
736
- userIdType: String(userId).includes('@') ? 'email' : 'uid',
737
- pageId: String(pageId),
738
- slug: slug || null,
739
- createdAt: admin.firestore.FieldValue.serverTimestamp(),
740
- }, { merge: true });
741
- console.log('Saved pageId mapping for', pageId);
742
  }
743
  }
744
  } catch (e) {
745
- console.error('Failed to persist mapping docs:', e);
746
  }
747
 
748
  return res.status(201).json({ ok: true, page: pageData });
 
121
  return { expiresAtMs: expires, expiresAtIso: new Date(expires).toISOString() };
122
  }
123
 
124
+ /* -------------------- Mapping helpers & updates (SLUG-ONLY) -------------------- */
125
 
126
+ // update mapping doc by slug only (merge)
127
+ async function updateMappingWithIdentifiers(dbInstance, { slug, userId, userIdType, customerId, payerEmail, subscriptionCode, authorizationCode } = {}) {
128
+ if (!dbInstance || !slug) return;
129
  try {
130
  const writeObj = {};
131
  if (userId !== undefined) writeObj.userId = String(userId);
132
+ if (userIdType !== undefined) writeObj.userIdType = String(userIdType);
133
  if (customerId !== undefined && customerId !== null) writeObj.customerId = String(customerId);
134
  if (payerEmail !== undefined && payerEmail !== null) writeObj.payerEmail = String(payerEmail);
135
  if (subscriptionCode !== undefined && subscriptionCode !== null) writeObj.subscriptionCode = String(subscriptionCode);
136
  if (authorizationCode !== undefined && authorizationCode !== null) writeObj.authorizationCode = String(authorizationCode);
137
  if (Object.keys(writeObj).length === 0) return;
138
 
139
+ await dbInstance.collection('paystack-page-mappings').doc(String(slug)).set({
140
+ slug: String(slug),
141
+ ...writeObj,
142
+ updatedAt: admin.firestore.FieldValue.serverTimestamp(),
143
+ }, { merge: true });
144
+ console.log('Updated mapping doc (slug) with identifiers:', slug, writeObj);
 
 
 
 
 
 
 
 
145
  } catch (e) {
146
  console.error('updateMappingWithIdentifiers failed:', e);
147
  }
148
  }
149
 
150
+ // get mapping doc data by slug (doc id)
151
+ async function getMappingBySlug(dbInstance, slug) {
152
+ if (!dbInstance || !slug) return null;
153
  try {
154
+ const ref = dbInstance.collection('paystack-page-mappings').doc(String(slug));
155
+ const snap = await ref.get();
156
+ if (!snap.exists) return null;
157
+ return { ref, data: snap.data() };
 
 
 
 
 
 
 
 
 
 
 
 
158
  } catch (e) {
159
+ console.error('getMappingBySlug error:', e);
160
  return null;
161
  }
162
  }
163
 
164
+ // find mapping by other authoritative identifiers (customerId / payerEmail / subscriptionCode)
165
  async function findMappingByIdentifiers(dbInstance, { customerId, payerEmail, subscriptionCode } = {}) {
166
  if (!dbInstance) return null;
167
  try {
 
185
  }
186
  }
187
 
188
+ // Resolve user doc from mapping: prefer slug doc, else mapping-by-identifiers
189
+ async function resolveUserDocFromMapping(dbInstance, { slug = null, customerId = null, payerEmail = null, subscriptionCode = null } = {}) {
190
  if (!dbInstance) return null;
191
  const usersRef = dbInstance.collection('users');
192
 
193
+ // 1) try slug doc => read mapping.userId and resolve to users doc
194
+ if (slug) {
195
+ try {
196
+ const mapping = await getMappingBySlug(dbInstance, slug);
197
+ if (mapping && mapping.data && mapping.data.userId) {
198
+ const mappedUserId = mapping.data.userId;
199
+ // try doc id first
200
  try {
201
  const directRef = usersRef.doc(String(mappedUserId));
202
  const ds = await directRef.get();
203
  if (ds.exists) return directRef;
204
  } catch (e) {}
205
+ // fallback: if mappedUserId looks like email
206
  if (String(mappedUserId).includes('@')) {
207
  try {
208
  const q = await usersRef.where('email', '==', String(mappedUserId)).limit(1).get();
209
  if (!q.empty) return q.docs[0].ref;
210
  } catch (e) {}
211
  }
212
+ // fallback to other id fields
213
  const idFields = ['userId','uid','id'];
214
  for (const f of idFields) {
215
  try {
216
+ const q = await usersRef.where(f,'==',mappedUserId).limit(1).get();
217
  if (!q.empty) return q.docs[0].ref;
218
  } catch (e) {}
219
  }
220
  }
221
+ } catch (e) {
222
+ console.error('resolveUserDocFromMapping slug branch error:', e);
223
  }
224
+ }
225
 
226
+ // 2) try mapping by identifiers
227
+ try {
228
+ const found = await findMappingByIdentifiers(dbInstance, { customerId, payerEmail, subscriptionCode });
229
+ if (found && found.data && found.data.userId) {
230
+ const mappedUserId = found.data.userId;
231
+ // same resolution logic
232
+ try {
233
+ const directRef = usersRef.doc(String(mappedUserId));
234
+ const ds = await directRef.get();
235
+ if (ds.exists) return directRef;
236
+ } catch (e) {}
237
+ if (String(mappedUserId).includes('@')) {
238
  try {
239
+ const q = await usersRef.where('email', '==', String(mappedUserId)).limit(1).get();
240
+ if (!q.empty) return q.docs[0].ref;
241
+ } catch (e) {}
242
+ }
243
+ const idFields = ['userId','uid','id'];
244
+ for (const f of idFields) {
245
+ try {
246
+ const q = await usersRef.where(f,'==',mappedUserId).limit(1).get();
247
+ if (!q.empty) return q.docs[0].ref;
248
  } catch (e) {}
 
 
 
 
 
 
 
 
 
 
 
 
 
249
  }
250
  }
 
 
251
  } catch (e) {
252
+ console.error('resolveUserDocFromMapping identifiers branch error:', e);
 
253
  }
254
+
255
+ return null;
256
  }
257
 
258
  /* -------------------- Fallback user resolution -------------------- */
 
296
  return null;
297
  }
298
 
299
+ /* -------------------- Cleanup (SLUG-only + smarter owner check) -------------------- */
300
+
301
+ async function cleanUpAfterSuccessfulCharge(dbInstance, userDocRef, { slug, reference, email } = {}) {
302
+ if (!dbInstance || !userDocRef) return;
303
+ try {
304
+ // load mapping only by slug (we no longer create pageId docs)
305
+ if (!slug) return;
306
+ const mapRef = dbInstance.collection('paystack-page-mappings').doc(String(slug));
307
+ const mapSnap = await mapRef.get();
308
+ if (!mapSnap.exists) return;
309
+ const map = mapSnap.data();
310
+
311
+ // determine mapping owner type and user identity
312
+ const ownerVal = String(map.userId || '');
313
+ const ownerType = map.userIdType || (ownerVal.includes('@') ? 'email' : 'uid');
314
+
315
+ // get user doc data to check email or id
316
+ const userSnap = await userDocRef.get();
317
+ if (!userSnap.exists) return;
318
+ const userData = userSnap.data() || {};
319
+ const userEmail = userData.email || null;
320
+ const userId = userDocRef.id;
321
+
322
+ let shouldDelete = false;
323
+ if (ownerType === 'uid' && ownerVal === userId) shouldDelete = true;
324
+ if (ownerType === 'email' && userEmail && ownerVal === userEmail) shouldDelete = true;
325
+
326
+ if (shouldDelete) {
327
+ await mapRef.delete();
328
+ console.log('Deleted mapping doc (slug) after successful charge:', slug);
329
+ } else {
330
+ console.log('Mapping owner mismatch — not deleting slug:', slug, 'mappingOwner=', ownerVal, 'ownerType=', ownerType, 'userId=', userId, 'userEmail=', userEmail);
331
+ }
332
+ } catch (e) {
333
+ console.error('Error in cleanUpAfterSuccessfulCharge:', e);
334
+ }
335
+ }
336
+
337
+ /* -------------------- Prune helper (optional, unchanged) -------------------- */
338
 
339
  async function pruneUserEntitlementsAndWebhooks(dbInstance, userDocRef, { paystackCustomerId = null, payerEmail = null } = {}) {
340
  if (!dbInstance || !userDocRef) return;
341
  try {
 
342
  const cutoffMs = Date.now() - (60 * 24 * 60 * 60 * 1000);
 
 
343
  const snap = await userDocRef.get();
344
  if (!snap.exists) return;
345
  const userData = snap.data() || {};
346
  const entitlements = Array.isArray(userData.entitlements) ? userData.entitlements : [];
 
347
  const cleaned = entitlements.filter(e => {
 
348
  if (e && (e.expiresAtMs || e.expiresAtMs === 0)) {
349
  const exMs = Number(e.expiresAtMs) || 0;
350
+ return exMs > Date.now();
351
  }
 
352
  if (e && e.createdAt) {
 
353
  let createdMs = 0;
354
  try {
355
  if (typeof e.createdAt === 'number') createdMs = e.createdAt;
356
  else if (e.createdAt && typeof e.createdAt.toMillis === 'function') createdMs = e.createdAt.toMillis();
357
  else if (typeof e.createdAt === 'string') createdMs = Number(e.createdAt) || 0;
358
  else if (e.createdAt._seconds) createdMs = (Number(e.createdAt._seconds) * 1000) + (Number(e.createdAt._nanoseconds || 0) / 1e6);
359
+ } catch (ee) { createdMs = 0; }
 
 
360
  if (createdMs) return createdMs >= cutoffMs;
 
361
  return true;
362
  }
 
363
  return true;
364
  });
 
365
  if (cleaned.length !== entitlements.length) {
366
+ await userDocRef.update({ entitlements: cleaned, updatedAt: admin.firestore.FieldValue.serverTimestamp() });
 
 
 
367
  console.log('Pruned entitlements: removed', entitlements.length - cleaned.length, 'entries for', userDocRef.path);
 
 
368
  }
369
 
370
+ // prune paystack-webhooks older than cutoff for this user (best-effort)
371
  try {
372
  const coll = dbInstance.collection('paystack-webhooks');
373
  const toDeleteRefs = [];
 
 
374
  if (paystackCustomerId) {
375
  try {
376
  const q = await coll.where('payload.data.customer.id', '==', paystackCustomerId).get();
 
381
  if (ts && typeof ts.toMillis === 'function') tsMs = ts.toMillis();
382
  if (tsMs && tsMs < cutoffMs) toDeleteRefs.push(d.ref);
383
  });
384
+ } catch (e) { console.warn('Could not query paystack-webhooks by customer.id (skipping):', e); }
 
 
 
385
  }
 
 
386
  if (payerEmail) {
387
  try {
388
  const q2 = await coll.where('payload.data.customer.email', '==', payerEmail).get();
 
395
  if (!toDeleteRefs.find(x => x.path === d.ref.path)) toDeleteRefs.push(d.ref);
396
  }
397
  });
398
+ } catch (e) { console.warn('Could not query paystack-webhooks by customer.email (skipping):', e); }
 
 
399
  }
 
400
  if (toDeleteRefs.length) {
401
  const batch = dbInstance.batch();
402
  toDeleteRefs.forEach(r => batch.delete(r));
403
  await batch.commit();
404
  console.log('Deleted', toDeleteRefs.length, 'old paystack-webhooks audit docs for user', userDocRef.id);
 
 
405
  }
406
+ } catch (e) { console.error('Failed pruning paystack-webhooks (non-fatal):', e); }
 
 
407
  } catch (e) {
408
  console.error('pruneUserEntitlementsAndWebhooks failed:', e);
409
  }
 
446
  const data = payload.data || {};
447
 
448
  // extract slug
 
449
  let maybeSlug = data.page?.slug || data.slug || null;
450
+ if (!maybeSlug) {
451
+ const referrer = data.metadata?.referrer || (data.metadata && data.metadata.referrer) || null;
452
+ if (referrer) {
453
+ const extracted = extractSlugFromReferrer(String(referrer));
454
+ if (extracted) {
455
+ maybeSlug = extracted;
456
+ console.log('Extracted slug from metadata.referrer:', maybeSlug);
457
+ } else {
458
+ console.log('Could not extract slug from referrer:', referrer);
459
+ }
460
  }
461
  }
462
 
 
468
 
469
  // quick metadata extraction
470
  let metadataUserId = null;
 
471
  try {
472
  const metadata = data.metadata || {};
473
+ if (metadata.userId || metadata.user_id) metadataUserId = metadata.userId || metadata.user_id;
474
+ else if (data.customer?.metadata) {
 
 
475
  const cm = data.customer.metadata;
476
+ if (cm.userId || cm.user_id) metadataUserId = cm.userId || cm.user_id;
 
 
 
477
  } else if (Array.isArray(data.custom_fields)) {
478
  for (const f of data.custom_fields) {
479
  const n = (f.variable_name || f.display_name || '').toString().toLowerCase();
480
  if ((n.includes('user') || n.includes('user_id') || n.includes('userid')) && f.value) {
481
  metadataUserId = f.value;
 
482
  break;
483
  }
484
  }
 
487
  console.error('Error during quick metadata extraction:', e);
488
  }
489
 
490
+ // Resolve user: mapping-first (slug), then mapping-by-identifiers, then fallback queries
491
  let userDocRef = null;
492
  try {
493
+ if (maybeSlug && db) {
494
+ userDocRef = await resolveUserDocFromMapping(db, { slug: maybeSlug, customerId: paystackCustomerId, payerEmail, subscriptionCode });
495
+ if (userDocRef) console.log('Resolved user from mapping (slug):', userDocRef.path, 'slug=', maybeSlug);
496
+ else console.log('No mapping resolved from slug:', maybeSlug);
 
497
  }
498
 
499
  if (!userDocRef && db && (paystackCustomerId || payerEmail || subscriptionCode)) {
500
+ userDocRef = await resolveUserDocFromMapping(db, { slug: null, customerId: paystackCustomerId, payerEmail, subscriptionCode });
501
  if (userDocRef) console.log('Resolved user from mapping by identifiers ->', userDocRef.path);
502
  }
503
 
 
510
  userDocRef = null;
511
  }
512
 
513
+ // update mapping with authoritative identifiers ONLY if we have a slug (we avoid creating mapping by pageId)
514
  try {
515
+ if (maybeSlug) {
516
+ const userIdToSave = metadataUserId || (userDocRef ? userDocRef.id : undefined);
517
+ const userIdType = userIdToSave && String(userIdToSave).includes('@') ? 'email' : 'uid';
518
+ await updateMappingWithIdentifiers(db, {
519
+ slug: maybeSlug,
520
+ userId: userIdToSave,
521
+ userIdType,
522
+ customerId: paystackCustomerId || undefined,
523
+ payerEmail: payerEmail || undefined,
524
+ subscriptionCode: subscriptionCode || undefined,
525
+ authorizationCode: authorizationCode || undefined,
526
+ });
527
+ }
528
  } catch (e) {
529
  console.error('Failed updateMappingWithIdentifiers:', e);
530
  }
531
 
532
+ // subscription.create
533
  if (/subscription\.create/i.test(event) || event === 'subscription.create') {
534
  const subscriptionCodeLocal = subscriptionCode || (data.id ? String(data.id) : null);
535
  const status = data.status || 'active';
 
553
  if (paystackCustomerId) updateObj.paystack_customer_id = String(paystackCustomerId);
554
  await userDocRef.update(updateObj);
555
  console.log('subscription.create: updated user with subscription:', subscriptionCodeLocal);
 
556
  await pruneUserEntitlementsAndWebhooks(db, userDocRef, { paystackCustomerId, payerEmail });
557
  } catch (e) {
558
  console.error('subscription.create: failed to update user:', e);
 
562
  }
563
  }
564
 
565
+ // charge.success
566
  if (isPayment) {
567
+ console.log('charge.success identifiers:', { metadataUserId, payerEmail, maybeSlug, paystackCustomerId, subscriptionCode });
568
 
569
  const recurringMarker = Boolean((data.metadata && (data.metadata.custom_filters?.recurring || data.metadata.recurring)) || false);
570
  const hasSubscription = !!(data.subscription || data.subscriptions || data.plan || subscriptionCode);
 
585
  createdAt: admin.firestore.Timestamp.now(),
586
  };
587
 
588
+ // Ensure mapping gets authoritative identifiers on first charge (only if slug present)
589
  try {
590
+ if (maybeSlug) {
591
+ const userIdToSave = metadataUserId || (userDocRef ? userDocRef.id : undefined);
592
+ const userIdType = userIdToSave && String(userIdToSave).includes('@') ? 'email' : 'uid';
593
+ await updateMappingWithIdentifiers(db, {
594
+ slug: maybeSlug,
595
+ userId: userIdToSave,
596
+ userIdType,
597
+ customerId: paystackCustomerId || undefined,
598
+ payerEmail: payerEmail || undefined,
599
+ subscriptionCode: subscriptionCode || undefined,
600
+ authorizationCode: authorizationCode || undefined,
601
+ });
602
+ }
603
  } catch (e) {
604
  console.error('Failed updateMappingWithIdentifiers during charge.success:', e);
605
  }
 
637
  }
638
  }
639
 
640
+ // cleanup mapping doc if slug exists (only slug doc)
641
  try {
642
+ await cleanUpAfterSuccessfulCharge(db, userDocRef, { slug: maybeSlug, reference: entitlement.reference || entitlement.id, email: payerEmail });
643
  } catch (e) {
644
  console.error('Cleanup failed:', e);
645
  }
646
 
647
+ // prune entitlements & old webhooks
648
  try {
649
  await pruneUserEntitlementsAndWebhooks(db, userDocRef, { paystackCustomerId, payerEmail });
650
  } catch (e) {
 
673
  });
674
  await userDocRef.update({ entitlements: filtered, updatedAt: admin.firestore.FieldValue.serverTimestamp() });
675
  console.log('Removed entitlements matching refund:', refundedReference);
 
676
  await pruneUserEntitlementsAndWebhooks(db, userDocRef, { paystackCustomerId, payerEmail });
677
  } else {
678
  console.warn('Refund handling: user doc vanished.');
 
691
  return res.status(200).json({ ok: true });
692
  });
693
 
694
+ /* -------------------- Create payment link endpoint (SLUG only) -------------------- */
695
 
696
  app.post('/create-payment-link', express.json(), async (req, res) => {
697
  const { planId, userId, name, amount, redirect_url, collect_phone = false, fixed_amount = false } = req.body || {};
 
711
 
712
  const pageData = response.data && response.data.data ? response.data.data : response.data;
713
 
714
+ // persist mapping doc keyed by slug only (no pageId doc creation)
715
  try {
716
  if (db) {
717
  const slug = pageData.slug || pageData.data?.slug || null;
 
 
718
  if (slug) {
719
  await db.collection('paystack-page-mappings').doc(String(slug)).set({
720
  userId: String(userId),
721
  userIdType: String(userId).includes('@') ? 'email' : 'uid',
722
+ pageId: pageData.id ? String(pageData.id) : null, // store pageId inside slug doc only
723
  slug: String(slug),
724
  createdAt: admin.firestore.FieldValue.serverTimestamp(),
725
  }, { merge: true });
726
  console.log('Saved page slug mapping for', slug);
727
+ } else {
728
+ console.warn('create-payment-link: Paystack response did not return a slug; no mapping saved.');
 
 
 
 
 
 
 
 
 
729
  }
730
  }
731
  } catch (e) {
732
+ console.error('Failed to persist mapping doc (slug only):', e);
733
  }
734
 
735
  return res.status(201).json({ ok: true, page: pageData });