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

Update app.js

Browse files
Files changed (1) hide show
  1. app.js +143 -66
app.js CHANGED
@@ -83,34 +83,21 @@ function safeParseJSON(raw) {
83
  // ------------------------------
84
  // Expiry helper: getExpiryFromPlan
85
  // ------------------------------
86
- // Returns an object:
87
- // { expiresAtMs: <Number>, expiresAtIso: <String> }
88
- // Rules:
89
- // - If planObj.interval exists use that (weekly -> +7 days, monthly -> +30 days)
90
- // - Else fallback to matching plan id or code against WEEKLY_PLAN_IDS / MONTHLY_PLAN_IDS
91
- // - Always add EXTRA_FREE_MS (2 hours) to the computed expiry
92
- // - If no match, default to 30 days + 2 hours
93
  const EXTRA_FREE_MS = 2 * 60 * 60 * 1000; // 2 hours in ms
94
 
95
- // EDIT THESE SETS to include any numeric plan ids or plan codes you consider weekly/monthly
96
  const WEEKLY_PLAN_IDS = new Set([
97
- // numeric ids
98
- 3311892, // demo, remove in prod
99
  3305738,
100
- // string plan codes (if your API sometimes returns the PLN_xxx code)
101
-
102
  'PLN_ngz4l76whecrpkv',
103
-
104
- 'PLN_f7a3oagrpt47d5f', // demo, remove in prod
105
  ]);
106
 
107
  const MONTHLY_PLAN_IDS = new Set([
108
  3305739,
109
- 'PLN_584ck56g65xhkum', // replace/extend as needed
110
  ]);
111
 
112
  function getExpiryFromPlan(planInput) {
113
- // planInput may be: undefined | number | string | object { id, plan_code, interval }
114
  let interval = null;
115
  let planId = null;
116
  let planCode = null;
@@ -124,13 +111,11 @@ function getExpiryFromPlan(planInput) {
124
  } else if (typeof planInput === 'number') {
125
  planId = planInput;
126
  } else if (typeof planInput === 'string') {
127
- // could be "PLN_xxx" or numeric string
128
  planCode = planInput;
129
  const asNum = Number(planInput);
130
  if (!Number.isNaN(asNum)) planId = asNum;
131
  }
132
 
133
- // Prefer explicit interval if provided
134
  if (interval) {
135
  interval = String(interval).toLowerCase();
136
  if (interval.includes('week')) {
@@ -141,15 +126,12 @@ function getExpiryFromPlan(planInput) {
141
  const expires = Date.now() + 30 * 24 * 60 * 60 * 1000 + EXTRA_FREE_MS;
142
  return { expiresAtMs: expires, expiresAtIso: new Date(expires).toISOString() };
143
  }
144
- // if 'hourly' or other intervals -- you can treat hourly as 7 days for weekly test example or handle explicitly
145
  if (interval.includes('hour')) {
146
- // Treat hourly plans as weekly-like (example from your payload that used hourly). You can adjust.
147
  const expires = Date.now() + 7 * 24 * 60 * 60 * 1000 + EXTRA_FREE_MS;
148
  return { expiresAtMs: expires, expiresAtIso: new Date(expires).toISOString() };
149
  }
150
  }
151
 
152
- // Fallback: check known id/code sets
153
  if (planId && WEEKLY_PLAN_IDS.has(planId)) {
154
  const expires = Date.now() + 7 * 24 * 60 * 60 * 1000 + EXTRA_FREE_MS;
155
  return { expiresAtMs: expires, expiresAtIso: new Date(expires).toISOString() };
@@ -167,27 +149,90 @@ function getExpiryFromPlan(planInput) {
167
  return { expiresAtMs: expires, expiresAtIso: new Date(expires).toISOString() };
168
  }
169
 
170
- // Final fallback: default to 30 days + extra 2 hours
171
  const expires = Date.now() + 30 * 24 * 60 * 60 * 1000 + EXTRA_FREE_MS;
172
  return { expiresAtMs: expires, expiresAtIso: new Date(expires).toISOString() };
173
  }
174
 
175
- // Helper: find user doc reference in Firestore
176
- // Strategy:
177
- // 1) If metadata.userId exists and matches a doc ID, use that doc
178
- // 2) Try queries by email, uid, userId fields (most common naming)
179
- // Returns a DocumentReference or null
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
  async function findUserDocRef(db, { metadataUserId, email }) {
181
  const usersRef = db.collection('users');
182
 
183
  if (metadataUserId) {
184
- // Try doc id first
185
- try {
186
- const docRef = usersRef.doc(metadataUserId);
187
- const snap = await docRef.get();
188
- if (snap.exists) return docRef;
189
- } catch (e) {
190
- // continue to queries
 
 
191
  }
192
 
193
  // try equality queries on commonly used id fields
@@ -216,7 +261,7 @@ async function findUserDocRef(db, { metadataUserId, email }) {
216
  }
217
 
218
  // ------------------------
219
- // Existing webhook endpoint (modified to update subscription/entitlements)
220
  // ------------------------
221
  app.post('/webhook/paystack', async (req, res) => {
222
  const raw = req.body; // Buffer because we used express.raw
@@ -241,10 +286,8 @@ app.post('/webhook/paystack', async (req, res) => {
241
  const isRefund = /refund/i.test(event);
242
  const isChargeSuccess = /charge\.success/i.test(event);
243
  const isInvoiceOrSubscription = /invoice\.(payment_succeeded|paid)|subscription\./i.test(event);
244
- // We only care about refunds and payments (charge.success) as per earlier note
245
- const isPayment = isChargeSuccess; // we intentionally only act on charge.success
246
 
247
- // Short names
248
  const db = admin.firestore();
249
 
250
  if (isRefund || isPayment || /subscription\.create/i.test(event) || /subscription\.update/i.test(event)) {
@@ -263,14 +306,29 @@ app.post('/webhook/paystack', async (req, res) => {
263
  console.error('Failed to persist webhook audit:', err);
264
  }
265
 
266
- // Common data paths
267
  const data = payload.data || {};
268
- // metadata could be on data.metadata or data.customer.metadata depending on event
269
- const metadata = data.metadata || data.customer?.metadata || null;
270
- const metadataUserId = metadata?.userId || metadata?.user_id || null;
271
- const customerEmail = data.customer?.email || (metadata && metadata.email) || null;
272
 
273
- // Try to find the user doc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
274
  let userDocRef = null;
275
  try {
276
  userDocRef = await findUserDocRef(db, { metadataUserId, email: customerEmail });
@@ -279,18 +337,17 @@ app.post('/webhook/paystack', async (req, res) => {
279
  userDocRef = null;
280
  }
281
 
282
- // Handler: subscription.create - store subscription id only
283
  if (/subscription\.create/i.test(event) || event === 'subscription.create') {
284
  const subscriptionCode = data.subscription_code || data.subscription?.subscription_code || (data.id ? String(data.id) : null);
285
  const status = data.status || 'active';
286
-
287
- // compute expiry from plan if possible
288
  const planObj = data.plan || data.subscription?.plan || null;
289
  const expiry = getExpiryFromPlan(planObj);
 
290
 
291
  if (userDocRef && subscriptionCode) {
292
  try {
293
- await userDocRef.update({
294
  subscriptionId: subscriptionCode,
295
  subscription: {
296
  id: subscriptionCode,
@@ -301,8 +358,15 @@ app.post('/webhook/paystack', async (req, res) => {
301
  expiresAtIso: expiry.expiresAtIso,
302
  },
303
  updatedAt: admin.firestore.FieldValue.serverTimestamp(),
304
- });
305
- console.log(`User doc updated with subscription ${subscriptionCode} and expiry ${expiry.expiresAtIso}`);
 
 
 
 
 
 
 
306
  } catch (err) {
307
  console.error('Failed to update user subscription info:', err);
308
  }
@@ -311,18 +375,15 @@ app.post('/webhook/paystack', async (req, res) => {
311
  }
312
  }
313
 
314
- // Handler: charge.success - add entitlement on successful subscription charge (or one-off if you want)
315
  if (isPayment) {
316
- // Determine if this is a recurring/subscription payment:
317
  const recurringMarker = (data.metadata && (data.metadata.custom_filters?.recurring || data.metadata.recurring)) || false;
318
- const hasSubscription = !!(data.subscription || data.subscriptions || data.plan); // defensive
319
  const isLikelySubscriptionPayment = recurringMarker || hasSubscription;
320
 
321
- // attempt to derive plan object for expiry calc
322
  const planObj = data.plan || (data.subscription ? data.subscription.plan : null) || null;
323
  const expiry = getExpiryFromPlan(planObj);
324
 
325
- // Create an entitlement object
326
  const entitlement = {
327
  id: data.reference || data.id ? String(data.reference || data.id) : null,
328
  source: 'paystack',
@@ -330,7 +391,6 @@ app.post('/webhook/paystack', async (req, res) => {
330
  reference: data.reference || null,
331
  paidAt: data.paid_at || data.paidAt || data.created_at || null,
332
  plan: planObj ? { id: planObj.id, code: planObj.plan_code } : null,
333
- // add computed expiry ms/iso if available
334
  expiresAtMs: expiry.expiresAtMs,
335
  expiresAtIso: expiry.expiresAtIso,
336
  createdAt: admin.firestore.FieldValue.serverTimestamp(),
@@ -338,7 +398,6 @@ app.post('/webhook/paystack', async (req, res) => {
338
 
339
  if (userDocRef && isLikelySubscriptionPayment) {
340
  try {
341
- // Append entitlement
342
  await userDocRef.update({
343
  entitlements: admin.firestore.FieldValue.arrayUnion(entitlement),
344
  updatedAt: admin.firestore.FieldValue.serverTimestamp(),
@@ -348,7 +407,6 @@ app.post('/webhook/paystack', async (req, res) => {
348
  console.error('Failed to add entitlement to user:', err);
349
  }
350
  } else if (userDocRef && !isLikelySubscriptionPayment) {
351
- // If you want to also give entitlement for one-off payments, you can add here.
352
  console.log('charge.success received but not marked recurring/subscription - skipping entitlement add by default.');
353
  } else {
354
  console.warn('charge.success: user not found, skipping entitlement update.');
@@ -357,19 +415,16 @@ app.post('/webhook/paystack', async (req, res) => {
357
 
358
  // Handler: refunds - remove entitlement(s) associated with refunded transaction
359
  if (isRefund) {
360
- // Refund payload shapes vary. Try to extract refund reference or linked transaction
361
- const refund = data; // top-level refund object in many webhook shapes
362
  const refundedReference = refund.reference || refund.transaction?.reference || refund.transaction?.id || null;
363
 
364
  if (userDocRef && refundedReference) {
365
  try {
366
- // Read current entitlements, filter out any matching the refunded reference, and write back
367
  const snap = await userDocRef.get();
368
  if (snap.exists) {
369
  const userData = snap.data();
370
  const currentEntitlements = Array.isArray(userData.entitlements) ? userData.entitlements : [];
371
  const filtered = currentEntitlements.filter(e => {
372
- // compare by reference or id if present
373
  const ref = e.reference || e.id || '';
374
  return ref !== refundedReference && ref !== String(refundedReference);
375
  });
@@ -399,8 +454,6 @@ app.post('/webhook/paystack', async (req, res) => {
399
  // ------------------------
400
  // New: Create Payment Page (link) for a plan
401
  // ------------------------
402
- // This endpoint expects JSON body, so we attach express.json() for this route.
403
- // Required fields in body: planId, userId, name (friendly page name) optionally: amount, redirect_url, collect_phone, fixed_amount
404
  app.post('/create-payment-link', express.json(), async (req, res) => {
405
 
406
  // If req.body was left as a Buffer (because express.raw ran), parse it:
@@ -427,7 +480,16 @@ app.post('/create-payment-link', express.json(), async (req, res) => {
427
  metadata: {
428
  userId,
429
  },
430
- // optional properties
 
 
 
 
 
 
 
 
 
431
  collect_phone,
432
  fixed_amount,
433
  };
@@ -444,9 +506,24 @@ app.post('/create-payment-link', express.json(), async (req, res) => {
444
  timeout: 10_000,
445
  });
446
 
447
- // Paystack returns a response with data object. Return minimal info to caller.
448
  const pageData = response.data && response.data.data ? response.data.data : response.data;
449
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
450
  return res.status(201).json({ ok: true, page: pageData });
451
  } catch (err) {
452
  console.error('Error creating Paystack payment page:', err?.response?.data || err.message || err);
 
83
  // ------------------------------
84
  // Expiry helper: getExpiryFromPlan
85
  // ------------------------------
 
 
 
 
 
 
 
86
  const EXTRA_FREE_MS = 2 * 60 * 60 * 1000; // 2 hours in ms
87
 
 
88
  const WEEKLY_PLAN_IDS = new Set([
89
+ 3311892,
 
90
  3305738,
 
 
91
  'PLN_ngz4l76whecrpkv',
92
+ 'PLN_f7a3oagrpt47d5f',
 
93
  ]);
94
 
95
  const MONTHLY_PLAN_IDS = new Set([
96
  3305739,
97
+ 'PLN_584ck56g65xhkum',
98
  ]);
99
 
100
  function getExpiryFromPlan(planInput) {
 
101
  let interval = null;
102
  let planId = null;
103
  let planCode = null;
 
111
  } else if (typeof planInput === 'number') {
112
  planId = planInput;
113
  } else if (typeof planInput === 'string') {
 
114
  planCode = planInput;
115
  const asNum = Number(planInput);
116
  if (!Number.isNaN(asNum)) planId = asNum;
117
  }
118
 
 
119
  if (interval) {
120
  interval = String(interval).toLowerCase();
121
  if (interval.includes('week')) {
 
126
  const expires = Date.now() + 30 * 24 * 60 * 60 * 1000 + EXTRA_FREE_MS;
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
  }
133
  }
134
 
 
135
  if (planId && WEEKLY_PLAN_IDS.has(planId)) {
136
  const expires = Date.now() + 7 * 24 * 60 * 60 * 1000 + EXTRA_FREE_MS;
137
  return { expiresAtMs: expires, expiresAtIso: new Date(expires).toISOString() };
 
149
  return { expiresAtMs: expires, expiresAtIso: new Date(expires).toISOString() };
150
  }
151
 
 
152
  const expires = Date.now() + 30 * 24 * 60 * 60 * 1000 + EXTRA_FREE_MS;
153
  return { expiresAtMs: expires, expiresAtIso: new Date(expires).toISOString() };
154
  }
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
+ }
171
+
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' };
179
+
180
+ // 2) customer.metadata
181
+ const custMeta = data.customer?.metadata || {};
182
+ if (custMeta.userId || custMeta.user_id) return { userId: custMeta.userId || custMeta.user_id, source: 'customer.metadata' };
183
+
184
+ // 3) top-level custom_fields (array)
185
+ if (Array.isArray(data.custom_fields)) {
186
+ for (const f of data.custom_fields) {
187
+ const name = (f.variable_name || f.display_name || '').toString().toLowerCase();
188
+ if ((name.includes('user') || name.includes('user_id') || name.includes('userid')) && f.value) {
189
+ return { userId: f.value, source: 'custom_fields' };
190
+ }
191
+ }
192
+ }
193
+
194
+ // 4) metadata.custom_fields
195
+ if (metadata.custom_fields) {
196
+ if (Array.isArray(metadata.custom_fields)) {
197
+ for (const f of metadata.custom_fields) {
198
+ const name = (f.variable_name || f.display_name || '').toString().toLowerCase();
199
+ if (name.includes('user') && f.value) return { userId: f.value, source: 'metadata.custom_fields' };
200
+ }
201
+ } else if (typeof metadata.custom_fields === 'object') {
202
+ for (const k of Object.keys(metadata.custom_fields)) {
203
+ if (k.toLowerCase().includes('user')) return { userId: metadata.custom_fields[k], source: 'metadata.custom_fields_object' };
204
+ }
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
+
215
+ // 6) email fallback (return email so caller can search)
216
+ if (data.customer?.email) return { userId: data.customer.email, source: 'email' };
217
+
218
+ // nothing found
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) {
234
+ // continue to queries
235
+ }
236
  }
237
 
238
  // try equality queries on commonly used id fields
 
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
 
286
  const isRefund = /refund/i.test(event);
287
  const isChargeSuccess = /charge\.success/i.test(event);
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)) {
 
306
  console.error('Failed to persist webhook audit:', err);
307
  }
308
 
 
309
  const data = payload.data || {};
 
 
 
 
310
 
311
+ // Try to extract userId robustly (metadata/custom_fields/slug/email)
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;
318
+ extractorSource = extracted.source || null;
319
+ // If extractor returned an email, set as customerEmail
320
+ if (metadataUserId && String(metadataUserId).includes('@')) {
321
+ customerEmail = String(metadataUserId);
322
+ metadataUserId = null;
323
+ }
324
+ } catch (e) {
325
+ console.error('Error extracting userId from payload:', e);
326
+ }
327
+
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 });
 
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';
 
 
344
  const planObj = data.plan || data.subscription?.plan || null;
345
  const expiry = getExpiryFromPlan(planObj);
346
+ const paystackCustomerId = data.customer?.id ?? (data.customer?.customer_code ? data.customer.customer_code : null);
347
 
348
  if (userDocRef && subscriptionCode) {
349
  try {
350
+ const updateObj = {
351
  subscriptionId: subscriptionCode,
352
  subscription: {
353
  id: subscriptionCode,
 
358
  expiresAtIso: expiry.expiresAtIso,
359
  },
360
  updatedAt: admin.firestore.FieldValue.serverTimestamp(),
361
+ };
362
+
363
+ // attach paystack_customer_id if present
364
+ if (paystackCustomerId) {
365
+ updateObj.paystack_customer_id = String(paystackCustomerId);
366
+ }
367
+
368
+ await userDocRef.update(updateObj);
369
+ console.log(`User doc updated with subscription ${subscriptionCode} and expiry ${expiry.expiresAtIso} (source: ${extractorSource})`);
370
  } catch (err) {
371
  console.error('Failed to update user subscription info:', err);
372
  }
 
375
  }
376
  }
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
 
 
384
  const planObj = data.plan || (data.subscription ? data.subscription.plan : null) || null;
385
  const expiry = getExpiryFromPlan(planObj);
386
 
 
387
  const entitlement = {
388
  id: data.reference || data.id ? String(data.reference || data.id) : null,
389
  source: 'paystack',
 
391
  reference: data.reference || null,
392
  paidAt: data.paid_at || data.paidAt || data.created_at || null,
393
  plan: planObj ? { id: planObj.id, code: planObj.plan_code } : null,
 
394
  expiresAtMs: expiry.expiresAtMs,
395
  expiresAtIso: expiry.expiresAtIso,
396
  createdAt: admin.firestore.FieldValue.serverTimestamp(),
 
398
 
399
  if (userDocRef && isLikelySubscriptionPayment) {
400
  try {
 
401
  await userDocRef.update({
402
  entitlements: admin.firestore.FieldValue.arrayUnion(entitlement),
403
  updatedAt: admin.firestore.FieldValue.serverTimestamp(),
 
407
  console.error('Failed to add entitlement to user:', err);
408
  }
409
  } else if (userDocRef && !isLikelySubscriptionPayment) {
 
410
  console.log('charge.success received but not marked recurring/subscription - skipping entitlement add by default.');
411
  } else {
412
  console.warn('charge.success: user not found, skipping entitlement update.');
 
415
 
416
  // Handler: refunds - remove entitlement(s) associated with refunded transaction
417
  if (isRefund) {
418
+ const refund = data;
 
419
  const refundedReference = refund.reference || refund.transaction?.reference || refund.transaction?.id || null;
420
 
421
  if (userDocRef && refundedReference) {
422
  try {
 
423
  const snap = await userDocRef.get();
424
  if (snap.exists) {
425
  const userData = snap.data();
426
  const currentEntitlements = Array.isArray(userData.entitlements) ? userData.entitlements : [];
427
  const filtered = currentEntitlements.filter(e => {
 
428
  const ref = e.reference || e.id || '';
429
  return ref !== refundedReference && ref !== String(refundedReference);
430
  });
 
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:
 
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
  };
 
506
  timeout: 10_000,
507
  });
508
 
 
509
  const pageData = response.data && response.data.data ? response.data.data : response.data;
510
 
511
+ // Persist slug -> userId mapping so webhooks can recover missing metadata later
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);
525
+ }
526
+
527
  return res.status(201).json({ ok: true, page: pageData });
528
  } catch (err) {
529
  console.error('Error creating Paystack payment page:', err?.response?.data || err.message || err);