Pepguy commited on
Commit
d9aa318
·
verified ·
1 Parent(s): 432a7e7

Update app.js

Browse files
Files changed (1) hide show
  1. app.js +122 -58
app.js CHANGED
@@ -123,7 +123,6 @@ function getExpiryFromPlan(planInput) {
123
 
124
  /* -------------------- Mapping helpers & updates -------------------- */
125
 
126
- // update mapping doc(s) by slug/pageId with authoritative identifiers. merge=true
127
  async function updateMappingWithIdentifiers(dbInstance, { slug, pageId, userId, customerId, payerEmail, subscriptionCode, authorizationCode } = {}) {
128
  if (!dbInstance) return;
129
  try {
@@ -154,11 +153,9 @@ async function updateMappingWithIdentifiers(dbInstance, { slug, pageId, userId,
154
  }
155
  }
156
 
157
- // get userId string from mapping doc (doc id == slug or where pageId==slug)
158
  async function getUserIdFromSlug(dbInstance, slugOrPageId) {
159
  if (!dbInstance || !slugOrPageId) return null;
160
  try {
161
- // doc id lookup
162
  const docRef = dbInstance.collection('paystack-page-mappings').doc(String(slugOrPageId));
163
  const snap = await docRef.get();
164
  if (snap.exists) {
@@ -167,9 +164,7 @@ async function getUserIdFromSlug(dbInstance, slugOrPageId) {
167
  console.log('Found mapping doc (by docId) for', slugOrPageId, ':', { userId: d.userId, customerId: d.customerId, payerEmail: d.payerEmail, subscriptionCode: d.subscriptionCode });
168
  return d.userId;
169
  }
170
- // If doc exists but doesn't have userId, still return null (we'll use other mapping queries later)
171
  }
172
- // fallback query by pageId
173
  const q = await dbInstance.collection('paystack-page-mappings').where('pageId', '==', String(slugOrPageId)).limit(1).get();
174
  if (!q.empty) {
175
  const d = q.docs[0].data();
@@ -183,7 +178,6 @@ async function getUserIdFromSlug(dbInstance, slugOrPageId) {
183
  }
184
  }
185
 
186
- // If direct slug/pageId mapping didn't resolve, search mapping collection by customerId/payerEmail/subscriptionCode
187
  async function findMappingByIdentifiers(dbInstance, { customerId, payerEmail, subscriptionCode } = {}) {
188
  if (!dbInstance) return null;
189
  try {
@@ -207,23 +201,19 @@ async function findMappingByIdentifiers(dbInstance, { customerId, payerEmail, su
207
  }
208
  }
209
 
210
- // Attempt resolution: given mapping key (slug), return a users/<docRef> or null
211
  async function resolveUserDocFromMapping(dbInstance, { key = null, customerId = null, payerEmail = null, subscriptionCode = null } = {}) {
212
  if (!dbInstance) return null;
213
  const usersRef = dbInstance.collection('users');
214
 
215
  try {
216
  if (key) {
217
- // first try direct mapping doc by key (slug or pageId)
218
  const mappedUserId = await getUserIdFromSlug(dbInstance, key);
219
  if (mappedUserId) {
220
- // Try doc lookup
221
  try {
222
  const directRef = usersRef.doc(String(mappedUserId));
223
  const ds = await directRef.get();
224
  if (ds.exists) return directRef;
225
  } catch (e) {}
226
- // fallback queries by common id/email fields
227
  if (String(mappedUserId).includes('@')) {
228
  try {
229
  const q = await usersRef.where('email', '==', String(mappedUserId)).limit(1).get();
@@ -240,7 +230,6 @@ async function resolveUserDocFromMapping(dbInstance, { key = null, customerId =
240
  }
241
  }
242
 
243
- // if not resolved by key, try mapping collection queries (customerId/payerEmail/subscriptionCode)
244
  const mappingFound = await findMappingByIdentifiers(dbInstance, { customerId, payerEmail, subscriptionCode });
245
  if (mappingFound && mappingFound.data) {
246
  const mappedUserId = mappingFound.data.userId;
@@ -250,7 +239,6 @@ async function resolveUserDocFromMapping(dbInstance, { key = null, customerId =
250
  const ds = await directRef.get();
251
  if (ds.exists) return directRef;
252
  } catch (e) {}
253
- // fallback queries
254
  if (String(mappedUserId).includes('@')) {
255
  try {
256
  const q = await usersRef.where('email', '==', String(mappedUserId)).limit(1).get();
@@ -315,33 +303,109 @@ async function findUserDocRef(dbInstance, { metadataUserId, email, paystackCusto
315
  return null;
316
  }
317
 
318
- /* -------------------- Cleanup (MAPPING ONLY) -------------------- */
319
 
320
- async function cleanUpAfterSuccessfulCharge(dbInstance, userDocRef, { slug, pageId, reference, email } = {}) {
321
  if (!dbInstance || !userDocRef) return;
322
  try {
323
- const tryDelete = async (docId) => {
324
- if (!docId) return;
325
- try {
326
- const ref = dbInstance.collection('paystack-page-mappings').doc(String(docId));
327
- const s = await ref.get();
328
- if (!s.exists) return;
329
- const map = s.data();
330
- const owner = String(map.userId || '');
331
- if (owner === String(userDocRef.id) || (email && owner === String(email))) {
332
- await ref.delete();
333
- console.log('Deleted mapping doc:', docId);
334
- } else {
335
- console.log('Mapping owner mismatch, not deleting:', docId, 'owner=', owner, 'user=', userDocRef.id);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
336
  }
337
- } catch (e) {
338
- console.error('Error deleting mapping doc', docId, e);
339
  }
340
- };
341
- await tryDelete(slug);
342
- await tryDelete(pageId);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
343
  } catch (e) {
344
- console.error('Unexpected cleanup error:', e);
345
  }
346
  }
347
 
@@ -367,7 +431,7 @@ app.post('/webhook/paystack', express.raw({ type: 'application/json', limit: '1m
367
 
368
  if (!db) console.error('Firestore admin not initialized — cannot persist webhook data.');
369
 
370
- // persist webhook audit (only this extra collection is kept)
371
  try {
372
  if (db) {
373
  await db.collection('paystack-webhooks').add({ event, payload, receivedAt: admin.firestore.FieldValue.serverTimestamp() });
@@ -396,15 +460,14 @@ app.post('/webhook/paystack', express.raw({ type: 'application/json', limit: '1m
396
  }
397
  }
398
 
399
- // extract authoritative identifiers from payload
400
  const paystackCustomerId = data.customer?.id ?? data.customer?.customer_code ?? null;
401
- const payerEmail = data.customer?.email ?? null; // save as payerEmail in mapping to decouple from user's app email
402
  const subscriptionCode = data.subscription_code || data.subscription?.subscription_code || null;
403
  const authorizationCode = data.authorization?.authorization_code || data.authorization_code || null;
404
 
405
- // quick metadata extraction (keeps prior behavior)
406
  let metadataUserId = null;
407
- let customerEmail = null;
408
  let extractorSource = null;
409
  try {
410
  const metadata = data.metadata || {};
@@ -431,12 +494,9 @@ app.post('/webhook/paystack', express.raw({ type: 'application/json', limit: '1m
431
  console.error('Error during quick metadata extraction:', e);
432
  }
433
 
434
- if (!customerEmail && data.customer?.email) customerEmail = data.customer.email;
435
-
436
- // Resolve user: mapping-first (key), then mapping-by-identifiers, then fallback findUserDocRef
437
  let userDocRef = null;
438
  try {
439
- // try mapping with slug/pageId first (fast)
440
  const mappingKey = maybeSlug || maybePageId || null;
441
  if (mappingKey && db) {
442
  userDocRef = await resolveUserDocFromMapping(db, { key: mappingKey, customerId: paystackCustomerId, payerEmail, subscriptionCode });
@@ -444,15 +504,13 @@ app.post('/webhook/paystack', express.raw({ type: 'application/json', limit: '1m
444
  else console.log('No mapping resolved from key:', mappingKey);
445
  }
446
 
447
- // if not found via key, try mapping by identifiers (customerId / payerEmail / subscriptionCode)
448
  if (!userDocRef && db && (paystackCustomerId || payerEmail || subscriptionCode)) {
449
  userDocRef = await resolveUserDocFromMapping(db, { key: null, customerId: paystackCustomerId, payerEmail, subscriptionCode });
450
  if (userDocRef) console.log('Resolved user from mapping by identifiers ->', userDocRef.path);
451
  }
452
 
453
- // fallback: resolve user document using direct user fields
454
  if (!userDocRef) {
455
- userDocRef = await findUserDocRef(db, { metadataUserId, email: customerEmail, paystackCustomerId });
456
  if (userDocRef) console.log('Resolved user via fallback queries:', userDocRef.path);
457
  }
458
  } catch (e) {
@@ -460,7 +518,7 @@ app.post('/webhook/paystack', express.raw({ type: 'application/json', limit: '1m
460
  userDocRef = null;
461
  }
462
 
463
- // If we have mapping slug present and payload contains authoritative identifiers, update mapping immediately
464
  try {
465
  await updateMappingWithIdentifiers(db, {
466
  slug: maybeSlug,
@@ -475,21 +533,19 @@ app.post('/webhook/paystack', express.raw({ type: 'application/json', limit: '1m
475
  console.error('Failed updateMappingWithIdentifiers:', e);
476
  }
477
 
478
- // ---------------------------
479
  // subscription.create handler
480
- // ---------------------------
481
  if (/subscription\.create/i.test(event) || event === 'subscription.create') {
482
- const subscriptionCode = subscriptionCode || (data.id ? String(data.id) : null);
483
  const status = data.status || 'active';
484
  const planObj = data.plan || data.subscription?.plan || null;
485
  const expiry = getExpiryFromPlan(planObj);
486
 
487
- if (userDocRef && subscriptionCode) {
488
  try {
489
  const updateObj = {
490
- subscriptionId: subscriptionCode,
491
  subscription: {
492
- id: subscriptionCode,
493
  status,
494
  plan: planObj ? { id: planObj.id, code: planObj.plan_code, amount: planObj.amount, interval: planObj.interval } : null,
495
  createdAt: data.createdAt ? admin.firestore.Timestamp.fromDate(new Date(data.createdAt)) : admin.firestore.FieldValue.serverTimestamp(),
@@ -500,7 +556,9 @@ app.post('/webhook/paystack', express.raw({ type: 'application/json', limit: '1m
500
  };
501
  if (paystackCustomerId) updateObj.paystack_customer_id = String(paystackCustomerId);
502
  await userDocRef.update(updateObj);
503
- console.log('subscription.create: updated user with subscription:', subscriptionCode);
 
 
504
  } catch (e) {
505
  console.error('subscription.create: failed to update user:', e);
506
  }
@@ -509,11 +567,9 @@ app.post('/webhook/paystack', express.raw({ type: 'application/json', limit: '1m
509
  }
510
  }
511
 
512
- // ---------------------------
513
  // charge.success handler - entitlement add + mapping updates
514
- // ---------------------------
515
  if (isPayment) {
516
- console.log('charge.success identifiers:', { metadataUserId, customerEmail, maybeSlug, maybePageId, paystackCustomerId, subscriptionCode, extractorSource });
517
 
518
  const recurringMarker = Boolean((data.metadata && (data.metadata.custom_filters?.recurring || data.metadata.recurring)) || false);
519
  const hasSubscription = !!(data.subscription || data.subscriptions || data.plan || subscriptionCode);
@@ -534,7 +590,7 @@ app.post('/webhook/paystack', express.raw({ type: 'application/json', limit: '1m
534
  createdAt: admin.firestore.Timestamp.now(),
535
  };
536
 
537
- // Ensure mapping gets authoritative identifiers on first charge (if not already present)
538
  try {
539
  await updateMappingWithIdentifiers(db, {
540
  slug: maybeSlug,
@@ -550,7 +606,6 @@ app.post('/webhook/paystack', express.raw({ type: 'application/json', limit: '1m
550
  }
551
 
552
  if (userDocRef && isLikelySubscriptionPayment) {
553
- // add entitlement & save customer id on user
554
  try {
555
  await userDocRef.update({
556
  entitlements: admin.firestore.FieldValue.arrayUnion(entitlement),
@@ -589,6 +644,13 @@ app.post('/webhook/paystack', express.raw({ type: 'application/json', limit: '1m
589
  } catch (e) {
590
  console.error('Cleanup failed:', e);
591
  }
 
 
 
 
 
 
 
592
  } else if (userDocRef && !isLikelySubscriptionPayment) {
593
  console.log('charge.success received but not flagged subscription/recurring - skipping entitlement add.');
594
  } else {
@@ -612,6 +674,8 @@ app.post('/webhook/paystack', express.raw({ type: 'application/json', limit: '1m
612
  });
613
  await userDocRef.update({ entitlements: filtered, updatedAt: admin.firestore.FieldValue.serverTimestamp() });
614
  console.log('Removed entitlements matching refund:', refundedReference);
 
 
615
  } else {
616
  console.warn('Refund handling: user doc vanished.');
617
  }
 
123
 
124
  /* -------------------- Mapping helpers & updates -------------------- */
125
 
 
126
  async function updateMappingWithIdentifiers(dbInstance, { slug, pageId, userId, customerId, payerEmail, subscriptionCode, authorizationCode } = {}) {
127
  if (!dbInstance) return;
128
  try {
 
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) {
 
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();
 
178
  }
179
  }
180
 
 
181
  async function findMappingByIdentifiers(dbInstance, { customerId, payerEmail, subscriptionCode } = {}) {
182
  if (!dbInstance) return null;
183
  try {
 
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();
 
230
  }
231
  }
232
 
 
233
  const mappingFound = await findMappingByIdentifiers(dbInstance, { customerId, payerEmail, subscriptionCode });
234
  if (mappingFound && mappingFound.data) {
235
  const mappedUserId = mappingFound.data.userId;
 
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();
 
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();
365
+ q.docs.forEach(d => {
366
+ const r = d.data();
367
+ const ts = r.receivedAt;
368
+ let tsMs = 0;
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();
382
+ q2.docs.forEach(d => {
383
+ const r = d.data();
384
+ const ts = r.receivedAt;
385
+ let tsMs = 0;
386
+ if (ts && typeof ts.toMillis === 'function') tsMs = ts.toMillis();
387
+ if (tsMs && tsMs < cutoffMs) {
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
  }
410
  }
411
 
 
431
 
432
  if (!db) console.error('Firestore admin not initialized — cannot persist webhook data.');
433
 
434
+ // persist webhook audit
435
  try {
436
  if (db) {
437
  await db.collection('paystack-webhooks').add({ event, payload, receivedAt: admin.firestore.FieldValue.serverTimestamp() });
 
460
  }
461
  }
462
 
463
+ // authoritative identifiers
464
  const paystackCustomerId = data.customer?.id ?? data.customer?.customer_code ?? null;
465
+ const payerEmail = data.customer?.email ?? null; // saved on mapping as payerEmail
466
  const subscriptionCode = data.subscription_code || data.subscription?.subscription_code || null;
467
  const authorizationCode = data.authorization?.authorization_code || data.authorization_code || null;
468
 
469
+ // quick metadata extraction
470
  let metadataUserId = null;
 
471
  let extractorSource = null;
472
  try {
473
  const metadata = data.metadata || {};
 
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 });
 
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
 
 
512
  if (!userDocRef) {
513
+ userDocRef = await findUserDocRef(db, { metadataUserId, email: payerEmail, paystackCustomerId });
514
  if (userDocRef) console.log('Resolved user via fallback queries:', userDocRef.path);
515
  }
516
  } catch (e) {
 
518
  userDocRef = null;
519
  }
520
 
521
+ // update mapping with authoritative identifiers if available
522
  try {
523
  await updateMappingWithIdentifiers(db, {
524
  slug: maybeSlug,
 
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';
540
  const planObj = data.plan || data.subscription?.plan || null;
541
  const expiry = getExpiryFromPlan(planObj);
542
 
543
+ if (userDocRef && subscriptionCodeLocal) {
544
  try {
545
  const updateObj = {
546
+ subscriptionId: subscriptionCodeLocal,
547
  subscription: {
548
+ id: subscriptionCodeLocal,
549
  status,
550
  plan: planObj ? { id: planObj.id, code: planObj.plan_code, amount: planObj.amount, interval: planObj.interval } : null,
551
  createdAt: data.createdAt ? admin.firestore.Timestamp.fromDate(new Date(data.createdAt)) : admin.firestore.FieldValue.serverTimestamp(),
 
556
  };
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);
564
  }
 
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
  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,
 
606
  }
607
 
608
  if (userDocRef && isLikelySubscriptionPayment) {
 
609
  try {
610
  await userDocRef.update({
611
  entitlements: admin.firestore.FieldValue.arrayUnion(entitlement),
 
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) {
652
+ console.error('Prune helper failed:', e);
653
+ }
654
  } else if (userDocRef && !isLikelySubscriptionPayment) {
655
  console.log('charge.success received but not flagged subscription/recurring - skipping entitlement add.');
656
  } else {
 
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.');
681
  }