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

Update app.js

Browse files
Files changed (1) hide show
  1. app.js +174 -139
app.js CHANGED
@@ -58,23 +58,19 @@ function verifyPaystackSignature(rawBodyBuffer, signatureHeader) {
58
 
59
  function extractSlugFromReferrer(refUrl) {
60
  if (!refUrl || typeof refUrl !== 'string') return null;
61
- // common patterns: https://paystack.shop/pay/<slug>, https://example.com/pay/<slug>?...
62
  try {
63
- // quick regex: look for /pay/<slug> or /p/<slug>
64
  const m = refUrl.match(/\/(?:pay|p)\/([^\/\?\#]+)/i);
65
  if (m && m[1]) return m[1];
66
- // fallback: last path segment
67
  const parts = new URL(refUrl).pathname.split('/').filter(Boolean);
68
  if (parts.length) return parts[parts.length - 1];
69
  } catch (e) {
70
- // invalid URL string; try fallback heuristic
71
  const fallback = (refUrl.split('/').pop() || '').split('?')[0].split('#')[0];
72
  return fallback || null;
73
  }
74
  return null;
75
  }
76
 
77
- /* expiry helper (same as before) */
78
  const EXTRA_FREE_MS = 2 * 60 * 60 * 1000;
79
  const WEEKLY_PLAN_IDS = new Set([3311892, 3305738, 'PLN_ngz4l76whecrpkv', 'PLN_f7a3oagrpt47d5f']);
80
  const MONTHLY_PLAN_IDS = new Set([3305739, 'PLN_584ck56g65xhkum']);
@@ -125,7 +121,38 @@ function getExpiryFromPlan(planInput) {
125
  return { expiresAtMs: expires, expiresAtIso: new Date(expires).toISOString() };
126
  }
127
 
128
- /* -------------------- Mapping resolution helpers -------------------- */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
 
130
  // get userId string from mapping doc (doc id == slug or where pageId==slug)
131
  async function getUserIdFromSlug(dbInstance, slugOrPageId) {
@@ -137,15 +164,16 @@ async function getUserIdFromSlug(dbInstance, slugOrPageId) {
137
  if (snap.exists) {
138
  const d = snap.data();
139
  if (d?.userId) {
140
- console.log('Found mapping doc (by docId) for', slugOrPageId, ':', d);
141
  return d.userId;
142
  }
 
143
  }
144
  // fallback query by pageId
145
  const q = await dbInstance.collection('paystack-page-mappings').where('pageId', '==', String(slugOrPageId)).limit(1).get();
146
  if (!q.empty) {
147
  const d = q.docs[0].data();
148
- console.log('Found mapping doc (by pageId) for', slugOrPageId, ':', d);
149
  if (d?.userId) return d.userId;
150
  }
151
  return null;
@@ -155,50 +183,88 @@ async function getUserIdFromSlug(dbInstance, slugOrPageId) {
155
  }
156
  }
157
 
158
- // Attempt resolution: given mapping key (slug), return a users/<docRef> or null
159
- async function resolveUserDocFromMapping(dbInstance, key) {
160
- if (!dbInstance || !key) return null;
161
  try {
162
- // map to userId (string) first
163
- const mappedUserId = await getUserIdFromSlug(dbInstance, key);
164
- if (!mappedUserId) return null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
 
166
- const usersRef = dbInstance.collection('users');
 
 
 
167
 
168
- // try doc id first
169
- try {
170
- const directRef = usersRef.doc(String(mappedUserId));
171
- const ds = await directRef.get();
172
- if (ds.exists) {
173
- console.log('Resolved user by direct doc id:', directRef.path);
174
- return directRef;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  }
176
- } catch (e) {
177
- // continue to queries
178
  }
179
 
180
- // if mappedUserId looks like email, try email query
181
- const looksLikeEmail = String(mappedUserId).includes('@');
182
- if (looksLikeEmail) {
183
- try {
184
- const q = await usersRef.where('email', '==', String(mappedUserId)).limit(1).get();
185
- if (!q.empty) {
186
- console.log('Resolved user by email query for', mappedUserId);
187
- return q.docs[0].ref;
 
 
 
 
 
 
 
 
188
  }
189
- } catch (e) { /* ignore */ }
190
- }
191
-
192
- // fallback to common id fields
193
- const idFields = ['userId','uid','id'];
194
- for (const f of idFields) {
195
- try {
196
- const q = await usersRef.where(f,'==',mappedUserId).limit(1).get();
197
- if (!q.empty) {
198
- console.log('Resolved user by field', f, 'for', mappedUserId);
199
- return q.docs[0].ref;
200
  }
201
- } catch (e) { /* ignore */ }
202
  }
203
 
204
  return null;
@@ -249,7 +315,7 @@ async function findUserDocRef(dbInstance, { metadataUserId, email, paystackCusto
249
  return null;
250
  }
251
 
252
- /* -------------------- Cleanup -------------------- */
253
 
254
  async function cleanUpAfterSuccessfulCharge(dbInstance, userDocRef, { slug, pageId, reference, email } = {}) {
255
  if (!dbInstance || !userDocRef) return;
@@ -274,34 +340,6 @@ async function cleanUpAfterSuccessfulCharge(dbInstance, userDocRef, { slug, page
274
  };
275
  await tryDelete(slug);
276
  await tryDelete(pageId);
277
-
278
- // optional: clear pending-payments
279
- try {
280
- const pendingRef = dbInstance.collection('pending-payments');
281
- const q = await pendingRef.where('userId','==',userDocRef.id).limit(100).get();
282
- if (!q.empty) {
283
- const batch = dbInstance.batch();
284
- q.docs.forEach(d => batch.delete(d.ref));
285
- await batch.commit();
286
- console.log('Deleted pending-payments for user:', userDocRef.id);
287
- }
288
- } catch (e) {
289
- console.error('Failed deleting pending-payments (non-fatal):', e);
290
- }
291
-
292
- // log cleanup
293
- try {
294
- await dbInstance.collection('paystack-cleanups').add({
295
- userRef: userDocRef.path,
296
- slug: slug || null,
297
- pageId: pageId || null,
298
- reference: reference || null,
299
- email: email || null,
300
- cleanedAt: admin.firestore.FieldValue.serverTimestamp(),
301
- });
302
- } catch (e) {
303
- console.error('Failed logging cleanup:', e);
304
- }
305
  } catch (e) {
306
  console.error('Unexpected cleanup error:', e);
307
  }
@@ -329,7 +367,7 @@ app.post('/webhook/paystack', express.raw({ type: 'application/json', limit: '1m
329
 
330
  if (!db) console.error('Firestore admin not initialized — cannot persist webhook data.');
331
 
332
- // audit write
333
  try {
334
  if (db) {
335
  await db.collection('paystack-webhooks').add({ event, payload, receivedAt: admin.firestore.FieldValue.serverTimestamp() });
@@ -341,36 +379,34 @@ app.post('/webhook/paystack', express.raw({ type: 'application/json', limit: '1m
341
  if (/subscription\.create/i.test(event) || isRefund || isPayment || /subscription\.update/i.test(event)) {
342
  console.log('--- PAYSTACK WEBHOOK (INTERESTING EVENT) ---');
343
  console.log('event:', event);
344
- // payload.data is the object of interest
345
  const data = payload.data || {};
346
 
347
- // Extract slug candidate:
348
- // prefer: data.page.id / data.page.slug / data.slug
349
- // else: look into data.metadata.referrer URL and extract /pay/<slug>
350
  let maybePageId = data.page?.id || data.page_id || null;
351
  let maybeSlug = data.page?.slug || data.slug || null;
352
 
353
- // Look for metadata.referrer (primary for your case)
354
  const referrer = data.metadata?.referrer || (data.metadata && data.metadata.referrer) || null;
355
  if (!maybeSlug && referrer) {
356
  const extracted = extractSlugFromReferrer(String(referrer));
357
  if (extracted) {
358
  maybeSlug = extracted;
359
- console.log('Extracted slug from metadata.referrer:', maybeSlug, ' (referrer=', referrer, ')');
360
  } else {
361
  console.log('Could not extract slug from referrer:', referrer);
362
  }
363
  }
364
 
365
- // Also log the metadata object for debugging
366
- console.log('payload.data.metadata (raw):', data.metadata);
 
 
 
367
 
368
- // Try to extract userId from other places (metadata.custom_fields, customer.metadata, email)
369
  let metadataUserId = null;
370
  let customerEmail = null;
371
  let extractorSource = null;
372
  try {
373
- // quick extraction: metadata.userId etc
374
  const metadata = data.metadata || {};
375
  if (metadata.userId || metadata.user_id) {
376
  metadataUserId = metadata.userId || metadata.user_id;
@@ -396,33 +432,57 @@ app.post('/webhook/paystack', express.raw({ type: 'application/json', limit: '1m
396
  }
397
 
398
  if (!customerEmail && data.customer?.email) customerEmail = data.customer.email;
399
- const paystackCustomerId = data.customer?.id ?? data.customer?.customer_code ?? null;
400
 
401
- // Resolve user: mapping-first using maybeSlug (important)
402
  let userDocRef = null;
403
  try {
404
- const mappingKey = maybeSlug || maybePageId || metadataUserId || null;
 
405
  if (mappingKey && db) {
406
- userDocRef = await resolveUserDocFromMapping(db, mappingKey);
407
- if (userDocRef) console.log('resolveUserDocFromMapping found user:', userDocRef.path, 'using key:', mappingKey);
408
- else console.log('resolveUserDocFromMapping found nothing for key:', mappingKey);
 
 
 
 
 
 
409
  }
 
 
410
  if (!userDocRef) {
411
  userDocRef = await findUserDocRef(db, { metadataUserId, email: customerEmail, paystackCustomerId });
412
- if (userDocRef) console.log('findUserDocRef found user:', userDocRef.path);
413
  }
414
  } catch (e) {
415
  console.error('Error resolving userDocRef (mapping-first):', e);
416
  userDocRef = null;
417
  }
418
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
419
  // subscription.create handler
 
420
  if (/subscription\.create/i.test(event) || event === 'subscription.create') {
421
- const subscriptionCode = data.subscription_code || data.subscription?.subscription_code || (data.id ? String(data.id) : null);
422
  const status = data.status || 'active';
423
  const planObj = data.plan || data.subscription?.plan || null;
424
  const expiry = getExpiryFromPlan(planObj);
425
- const paystackCustomerIdLocal = data.customer?.id ?? data.customer?.customer_code ?? null;
426
 
427
  if (userDocRef && subscriptionCode) {
428
  try {
@@ -438,23 +498,25 @@ app.post('/webhook/paystack', express.raw({ type: 'application/json', limit: '1m
438
  },
439
  updatedAt: admin.firestore.FieldValue.serverTimestamp(),
440
  };
441
- if (paystackCustomerIdLocal) updateObj.paystack_customer_id = String(paystackCustomerIdLocal);
442
  await userDocRef.update(updateObj);
443
  console.log('subscription.create: updated user with subscription:', subscriptionCode);
444
  } catch (e) {
445
  console.error('subscription.create: failed to update user:', e);
446
  }
447
  } else {
448
- console.warn('subscription.create received but user not found or subscriptionCode missing — skipping user update.');
449
  }
450
  }
451
 
452
- // charge.success handler - entitlement add
 
 
453
  if (isPayment) {
454
- console.log('charge.success identifiers:', { metadataUserId, customerEmail, maybeSlug, maybePageId, paystackCustomerId, extractorSource });
455
 
456
  const recurringMarker = Boolean((data.metadata && (data.metadata.custom_filters?.recurring || data.metadata.recurring)) || false);
457
- const hasSubscription = !!(data.subscription || data.subscriptions || data.plan);
458
  const isLikelySubscriptionPayment = recurringMarker || hasSubscription;
459
 
460
  const planObj = data.plan || (data.subscription ? data.subscription.plan : null) || null;
@@ -472,25 +534,24 @@ app.post('/webhook/paystack', express.raw({ type: 'application/json', limit: '1m
472
  createdAt: admin.firestore.Timestamp.now(),
473
  };
474
 
475
- // debug record
476
  try {
477
- if (db) {
478
- await db.collection('paystack-debug').add({
479
- kind: 'charge-success',
480
- mappingKey: maybeSlug || maybePageId || null,
481
- identifiers: { metadataUserId, customerEmail, maybeSlug, maybePageId, paystackCustomerId, extractorSource },
482
- entitlement,
483
- raw: data,
484
- createdAt: admin.firestore.FieldValue.serverTimestamp(),
485
- });
486
- }
487
  } catch (e) {
488
- console.error('Failed writing paystack-debug:', e);
489
  }
490
 
491
  if (userDocRef && isLikelySubscriptionPayment) {
 
492
  try {
493
- console.log('Adding entitlement to', userDocRef.path);
494
  await userDocRef.update({
495
  entitlements: admin.firestore.FieldValue.arrayUnion(entitlement),
496
  lastPaymentAt: admin.firestore.FieldValue.serverTimestamp(),
@@ -500,7 +561,6 @@ app.post('/webhook/paystack', express.raw({ type: 'application/json', limit: '1m
500
  console.log('Entitlement added (arrayUnion) to', userDocRef.path);
501
  } catch (err) {
502
  console.error('arrayUnion failed, falling back:', err);
503
- // fallback: read-modify-write
504
  try {
505
  const snap = await userDocRef.get();
506
  const userData = snap.exists ? snap.data() : {};
@@ -520,26 +580,12 @@ app.post('/webhook/paystack', express.raw({ type: 'application/json', limit: '1m
520
  }
521
  } catch (err2) {
522
  console.error('Fallback persistence failed:', err2);
523
- try {
524
- if (db) {
525
- await db.collection('paystack-debug-failures').add({
526
- kind: 'entitlement-persist-failure',
527
- userRef: userDocRef.path,
528
- error: String(err2 && err2.message ? err2.message : err2),
529
- entitlement,
530
- raw: data,
531
- createdAt: admin.firestore.FieldValue.serverTimestamp(),
532
- });
533
- }
534
- } catch (e) {
535
- console.error('Failed writing failure debug doc:', e);
536
- }
537
  }
538
  }
539
 
540
- // cleanup (best effort)
541
  try {
542
- await cleanUpAfterSuccessfulCharge(db, userDocRef, { slug: maybeSlug, pageId: maybePageId, reference: entitlement.reference || entitlement.id, email: customerEmail });
543
  } catch (e) {
544
  console.error('Cleanup failed:', e);
545
  }
@@ -547,17 +593,6 @@ app.post('/webhook/paystack', express.raw({ type: 'application/json', limit: '1m
547
  console.log('charge.success received but not flagged subscription/recurring - skipping entitlement add.');
548
  } else {
549
  console.warn('charge.success: user not found, skipping entitlement update.');
550
- try {
551
- if (db) {
552
- await db.collection('paystack-unmatched').add({
553
- kind: 'charge.success.unmatched',
554
- mappingKey: maybeSlug || maybePageId || null,
555
- identifiers: { metadataUserId, customerEmail, maybeSlug, maybePageId, paystackCustomerId, extractorSource },
556
- raw: data,
557
- createdAt: admin.firestore.FieldValue.serverTimestamp(),
558
- });
559
- }
560
- } catch (e) { console.error('Failed creating unmatched debug doc:', e); }
561
  }
562
  }
563
 
 
58
 
59
  function extractSlugFromReferrer(refUrl) {
60
  if (!refUrl || typeof refUrl !== 'string') return null;
 
61
  try {
 
62
  const m = refUrl.match(/\/(?:pay|p)\/([^\/\?\#]+)/i);
63
  if (m && m[1]) return m[1];
 
64
  const parts = new URL(refUrl).pathname.split('/').filter(Boolean);
65
  if (parts.length) return parts[parts.length - 1];
66
  } catch (e) {
 
67
  const fallback = (refUrl.split('/').pop() || '').split('?')[0].split('#')[0];
68
  return fallback || null;
69
  }
70
  return null;
71
  }
72
 
73
+ /* expiry helper */
74
  const EXTRA_FREE_MS = 2 * 60 * 60 * 1000;
75
  const WEEKLY_PLAN_IDS = new Set([3311892, 3305738, 'PLN_ngz4l76whecrpkv', 'PLN_f7a3oagrpt47d5f']);
76
  const MONTHLY_PLAN_IDS = new Set([3305739, 'PLN_584ck56g65xhkum']);
 
121
  return { expiresAtMs: expires, expiresAtIso: new Date(expires).toISOString() };
122
  }
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 {
130
+ const writeObj = {};
131
+ if (userId !== undefined) writeObj.userId = String(userId);
132
+ if (customerId !== undefined && customerId !== null) writeObj.customerId = String(customerId);
133
+ if (payerEmail !== undefined && payerEmail !== null) writeObj.payerEmail = String(payerEmail);
134
+ if (subscriptionCode !== undefined && subscriptionCode !== null) writeObj.subscriptionCode = String(subscriptionCode);
135
+ if (authorizationCode !== undefined && authorizationCode !== null) writeObj.authorizationCode = String(authorizationCode);
136
+ if (Object.keys(writeObj).length === 0) return;
137
+
138
+ if (slug) {
139
+ await dbInstance.collection('paystack-page-mappings').doc(String(slug)).set({
140
+ ...writeObj,
141
+ updatedAt: admin.firestore.FieldValue.serverTimestamp(),
142
+ }, { merge: true });
143
+ console.log('Updated mapping doc (slug) with identifiers:', slug, writeObj);
144
+ }
145
+ if (pageId) {
146
+ await dbInstance.collection('paystack-page-mappings').doc(String(pageId)).set({
147
+ ...writeObj,
148
+ updatedAt: admin.firestore.FieldValue.serverTimestamp(),
149
+ }, { merge: true });
150
+ console.log('Updated mapping doc (pageId) with identifiers:', pageId, writeObj);
151
+ }
152
+ } catch (e) {
153
+ console.error('updateMappingWithIdentifiers failed:', e);
154
+ }
155
+ }
156
 
157
  // get userId string from mapping doc (doc id == slug or where pageId==slug)
158
  async function getUserIdFromSlug(dbInstance, slugOrPageId) {
 
164
  if (snap.exists) {
165
  const d = snap.data();
166
  if (d?.userId) {
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();
176
+ console.log('Found mapping doc (by pageId) for', slugOrPageId, ':', { userId: d.userId, customerId: d.customerId, payerEmail: d.payerEmail, subscriptionCode: d.subscriptionCode });
177
  if (d?.userId) return d.userId;
178
  }
179
  return null;
 
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 {
190
+ const coll = dbInstance.collection('paystack-page-mappings');
191
+ if (customerId) {
192
+ const q = await coll.where('customerId', '==', String(customerId)).limit(1).get();
193
+ if (!q.empty) return { mappingDoc: q.docs[0].ref, data: q.docs[0].data() };
194
+ }
195
+ if (subscriptionCode) {
196
+ const q2 = await coll.where('subscriptionCode', '==', String(subscriptionCode)).limit(1).get();
197
+ if (!q2.empty) return { mappingDoc: q2.docs[0].ref, data: q2.docs[0].data() };
198
+ }
199
+ if (payerEmail) {
200
+ const q3 = await coll.where('payerEmail', '==', String(payerEmail)).limit(1).get();
201
+ if (!q3.empty) return { mappingDoc: q3.docs[0].ref, data: q3.docs[0].data() };
202
+ }
203
+ return null;
204
+ } catch (e) {
205
+ console.error('findMappingByIdentifiers error:', e);
206
+ return null;
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();
230
+ if (!q.empty) return q.docs[0].ref;
231
+ } catch (e) {}
232
+ }
233
+ const idFields = ['userId','uid','id'];
234
+ for (const f of idFields) {
235
+ try {
236
+ const q = await usersRef.where(f, '==', mappedUserId).limit(1).get();
237
+ if (!q.empty) return q.docs[0].ref;
238
+ } catch (e) {}
239
+ }
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;
247
+ if (mappedUserId) {
248
+ try {
249
+ const directRef = usersRef.doc(String(mappedUserId));
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();
257
+ if (!q.empty) return q.docs[0].ref;
258
+ } catch (e) {}
259
  }
260
+ const idFields = ['userId','uid','id'];
261
+ for (const f of idFields) {
262
+ try {
263
+ const q = await usersRef.where(f, '==', mappedUserId).limit(1).get();
264
+ if (!q.empty) return q.docs[0].ref;
265
+ } catch (e) {}
 
 
 
 
 
266
  }
267
+ }
268
  }
269
 
270
  return null;
 
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;
 
340
  };
341
  await tryDelete(slug);
342
  await tryDelete(pageId);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
343
  } catch (e) {
344
  console.error('Unexpected cleanup error:', e);
345
  }
 
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() });
 
379
  if (/subscription\.create/i.test(event) || isRefund || isPayment || /subscription\.update/i.test(event)) {
380
  console.log('--- PAYSTACK WEBHOOK (INTERESTING EVENT) ---');
381
  console.log('event:', event);
 
382
  const data = payload.data || {};
383
 
384
+ // extract slug
 
 
385
  let maybePageId = data.page?.id || data.page_id || null;
386
  let maybeSlug = data.page?.slug || data.slug || null;
387
 
 
388
  const referrer = data.metadata?.referrer || (data.metadata && data.metadata.referrer) || null;
389
  if (!maybeSlug && referrer) {
390
  const extracted = extractSlugFromReferrer(String(referrer));
391
  if (extracted) {
392
  maybeSlug = extracted;
393
+ console.log('Extracted slug from metadata.referrer:', maybeSlug);
394
  } else {
395
  console.log('Could not extract slug from referrer:', referrer);
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 || {};
411
  if (metadata.userId || metadata.user_id) {
412
  metadataUserId = metadata.userId || metadata.user_id;
 
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 });
443
+ if (userDocRef) console.log('Resolved user from mapping (key):', userDocRef.path, 'key=', mappingKey);
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) {
459
  console.error('Error resolving userDocRef (mapping-first):', e);
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,
467
+ pageId: maybePageId,
468
+ userId: metadataUserId || (userDocRef ? userDocRef.id : undefined),
469
+ customerId: paystackCustomerId || undefined,
470
+ payerEmail: payerEmail || undefined,
471
+ subscriptionCode: subscriptionCode || undefined,
472
+ authorizationCode: authorizationCode || undefined,
473
+ });
474
+ } catch (e) {
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 {
 
498
  },
499
  updatedAt: admin.firestore.FieldValue.serverTimestamp(),
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
  }
507
  } else {
508
+ console.warn('subscription.create: user not found or subscriptionCode missing — skipping user update.');
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);
520
  const isLikelySubscriptionPayment = recurringMarker || hasSubscription;
521
 
522
  const planObj = data.plan || (data.subscription ? data.subscription.plan : null) || null;
 
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,
541
+ pageId: maybePageId,
542
+ userId: metadataUserId || (userDocRef ? userDocRef.id : undefined),
543
+ customerId: paystackCustomerId || undefined,
544
+ payerEmail: payerEmail || undefined,
545
+ subscriptionCode: subscriptionCode || undefined,
546
+ authorizationCode: authorizationCode || undefined,
547
+ });
 
548
  } catch (e) {
549
+ console.error('Failed updateMappingWithIdentifiers during charge.success:', e);
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),
557
  lastPaymentAt: admin.firestore.FieldValue.serverTimestamp(),
 
561
  console.log('Entitlement added (arrayUnion) to', userDocRef.path);
562
  } catch (err) {
563
  console.error('arrayUnion failed, falling back:', err);
 
564
  try {
565
  const snap = await userDocRef.get();
566
  const userData = snap.exists ? snap.data() : {};
 
580
  }
581
  } catch (err2) {
582
  console.error('Fallback persistence failed:', err2);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
583
  }
584
  }
585
 
586
+ // cleanup mapping docs (if owner matches)
587
  try {
588
+ await cleanUpAfterSuccessfulCharge(db, userDocRef, { slug: maybeSlug, pageId: maybePageId, reference: entitlement.reference || entitlement.id, email: payerEmail });
589
  } catch (e) {
590
  console.error('Cleanup failed:', e);
591
  }
 
593
  console.log('charge.success received but not flagged subscription/recurring - skipping entitlement add.');
594
  } else {
595
  console.warn('charge.success: user not found, skipping entitlement update.');
 
 
 
 
 
 
 
 
 
 
 
596
  }
597
  }
598