Pepguy commited on
Commit
a9b2951
·
verified ·
1 Parent(s): 717df7e

Update app.js

Browse files
Files changed (1) hide show
  1. app.js +285 -33
app.js CHANGED
@@ -4,13 +4,13 @@
4
 
5
  const express = require('express');
6
  const crypto = require('crypto');
7
- const admin = require('firebase-admin');
8
- const axios = require('axios');
9
 
10
  const app = express();
11
 
12
  // -------------------------------------------------
13
- // Environment variables (set these in your env/secrets)
14
  // -------------------------------------------------
15
  // - FIREBASE_CREDENTIALS: stringified JSON service account
16
  // - PAYSTACK_DEMO_SECRET
@@ -80,10 +80,145 @@ function safeParseJSON(raw) {
80
  }
81
  }
82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  // ------------------------
84
- // Existing webhook endpoint
85
  // ------------------------
86
- app.post('/webhook/paystack', (req, res) => {
87
  const raw = req.body; // Buffer because we used express.raw
88
  const signature = req.get('x-paystack-signature') || req.get('X-Paystack-Signature');
89
 
@@ -106,22 +241,156 @@ app.post('/webhook/paystack', (req, res) => {
106
  const isRefund = /refund/i.test(event);
107
  const isChargeSuccess = /charge\.success/i.test(event);
108
  const isInvoiceOrSubscription = /invoice\.(payment_succeeded|paid)|subscription\./i.test(event);
109
- const isPayment = isChargeSuccess; // || isInvoiceOrSubscription;
 
 
 
 
110
 
111
- if (isRefund || isPayment) {
112
  console.log('--- PAYSTACK WEBHOOK (INTERESTING EVENT) ---');
113
  console.log('event:', event);
114
  console.log('payload:', JSON.stringify(payload, null, 2));
115
 
116
- // Example: persist webhook to Firestore (uncomment if you want)
117
- // const db = admin.firestore();
118
- // db.collection('paystack-webhooks').add({
119
- // event,
120
- // payload,
121
- // receivedAt: admin.firestore.FieldValue.serverTimestamp(),
122
- // });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  } else {
124
- console.log(`Received Paystack webhook for event "${event}" — ignored (not refund/payment).`);
125
  }
126
 
127
  return res.status(200).json({ ok: true });
@@ -143,19 +412,14 @@ app.post('/create-payment-link', express.json(), async (req, res) => {
143
  return res.status(400).json({ ok: false, message: 'Invalid JSON' });
144
  }
145
  }
146
-
147
  const { planId, userId, name, amount, redirect_url, collect_phone = false, fixed_amount = false } = body || {};
148
 
149
-
150
-
151
  if (!planId) return res.status(400).json({ ok: false, message: 'planId is required' });
152
  if (!userId) return res.status(400).json({ ok: false, message: 'userId is required' });
153
  if (!name) return res.status(400).json({ ok: false, message: 'name is required (page title)' });
154
 
155
  // Build the body per Paystack's Payment Pages API.
156
- // We'll set type to "subscription" (so it links to a plan) and pass metadata.userId
157
- // Also add a custom_fields array so the dashboard shows the user id if you open the transaction.
158
- // See: Paystack Payment Pages and Metadata docs. 1
159
  const payload = {
160
  name,
161
  type: 'subscription',
@@ -163,16 +427,6 @@ app.post('/create-payment-link', express.json(), async (req, res) => {
163
  metadata: {
164
  userId,
165
  },
166
-
167
- /* custom_fields: [
168
- {
169
- display_name: 'User ID',
170
- variable_name: 'user_id',
171
- value: userId,
172
- },
173
- ],
174
- */
175
-
176
  // optional properties
177
  collect_phone,
178
  fixed_amount,
@@ -193,8 +447,6 @@ app.post('/create-payment-link', express.json(), async (req, res) => {
193
  // Paystack returns a response with data object. Return minimal info to caller.
194
  const pageData = response.data && response.data.data ? response.data.data : response.data;
195
 
196
- // The created page usually has a slug; you can build public url with https://paystack.com/pay/[slug]
197
- // Return the page data so caller can redirect or store details.
198
  return res.status(201).json({ ok: true, page: pageData });
199
  } catch (err) {
200
  console.error('Error creating Paystack payment page:', err?.response?.data || err.message || err);
 
4
 
5
  const express = require('express');
6
  const crypto = require('crypto');
7
+ const admin = require('firebase-admin');
8
+ const axios = require('axios');
9
 
10
  const app = express();
11
 
12
  // -------------------------------------------------
13
+ // Environment variables (set these in your env/secrets)
14
  // -------------------------------------------------
15
  // - FIREBASE_CREDENTIALS: stringified JSON service account
16
  // - PAYSTACK_DEMO_SECRET
 
80
  }
81
  }
82
 
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;
117
+
118
+ if (!planInput) {
119
+ interval = null;
120
+ } else if (typeof planInput === 'object') {
121
+ planId = planInput.id ?? planInput.plan_id ?? null;
122
+ planCode = planInput.plan_code ?? planInput.planCode ?? null;
123
+ interval = (planInput.interval || planInput.billing_interval || null);
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')) {
137
+ const expires = Date.now() + 7 * 24 * 60 * 60 * 1000 + EXTRA_FREE_MS;
138
+ return { expiresAtMs: expires, expiresAtIso: new Date(expires).toISOString() };
139
+ }
140
+ if (interval.includes('month')) {
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() };
156
+ }
157
+ if (planCode && WEEKLY_PLAN_IDS.has(planCode)) {
158
+ const expires = Date.now() + 7 * 24 * 60 * 60 * 1000 + EXTRA_FREE_MS;
159
+ return { expiresAtMs: expires, expiresAtIso: new Date(expires).toISOString() };
160
+ }
161
+ if (planId && MONTHLY_PLAN_IDS.has(planId)) {
162
+ const expires = Date.now() + 30 * 24 * 60 * 60 * 1000 + EXTRA_FREE_MS;
163
+ return { expiresAtMs: expires, expiresAtIso: new Date(expires).toISOString() };
164
+ }
165
+ if (planCode && MONTHLY_PLAN_IDS.has(planCode)) {
166
+ const expires = Date.now() + 30 * 24 * 60 * 60 * 1000 + EXTRA_FREE_MS;
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
194
+ const idFields = ['userId', 'uid', 'id'];
195
+ for (const field of idFields) {
196
+ try {
197
+ const qSnap = await usersRef.where(field, '==', metadataUserId).limit(1).get();
198
+ if (!qSnap.empty) return qSnap.docs[0].ref;
199
+ } catch (e) {
200
+ // ignore and proceed
201
+ }
202
+ }
203
+ }
204
+
205
+ if (email) {
206
+ try {
207
+ const qSnap = await usersRef.where('email', '==', email).limit(1).get();
208
+ if (!qSnap.empty) return qSnap.docs[0].ref;
209
+ } catch (e) {
210
+ // ignore
211
+ }
212
+ }
213
+
214
+ // Not found
215
+ return null;
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
223
  const signature = req.get('x-paystack-signature') || req.get('X-Paystack-Signature');
224
 
 
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)) {
251
  console.log('--- PAYSTACK WEBHOOK (INTERESTING EVENT) ---');
252
  console.log('event:', event);
253
  console.log('payload:', JSON.stringify(payload, null, 2));
254
 
255
+ // persist webhook for auditing
256
+ try {
257
+ await db.collection('paystack-webhooks').add({
258
+ event,
259
+ payload,
260
+ receivedAt: admin.firestore.FieldValue.serverTimestamp(),
261
+ });
262
+ } catch (err) {
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 });
277
+ } catch (err) {
278
+ console.error('Error finding user doc:', err);
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,
297
+ status,
298
+ plan: planObj ? { id: planObj.id, code: planObj.plan_code, amount: planObj.amount, interval: planObj.interval } : null,
299
+ createdAt: data.createdAt ? admin.firestore.Timestamp.fromDate(new Date(data.createdAt)) : admin.firestore.FieldValue.serverTimestamp(),
300
+ expiresAtMs: expiry.expiresAtMs,
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
+ }
309
+ } else {
310
+ console.warn('subscription.create received but user not found or subscriptionCode missing — skipping user update.');
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',
329
+ amount: data.amount || null,
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(),
337
+ };
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(),
345
+ });
346
+ console.log('Added entitlement to user:', entitlement.id || entitlement.reference, 'expiry:', expiry.expiresAtIso);
347
+ } catch (err) {
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.');
355
+ }
356
+ }
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
+ });
376
+
377
+ await userDocRef.update({
378
+ entitlements: filtered,
379
+ updatedAt: admin.firestore.FieldValue.serverTimestamp(),
380
+ });
381
+ console.log('Removed entitlements matching refunded reference:', refundedReference);
382
+ } else {
383
+ console.warn('Refund handling: user doc disappeared between lookup and removal.');
384
+ }
385
+ } catch (err) {
386
+ console.error('Failed to remove entitlement on refund:', err);
387
+ }
388
+ } else {
389
+ console.warn('Refund received but user or refundedReference not found — skipping entitlement removal.');
390
+ }
391
+ }
392
  } else {
393
+ console.log(`Received Paystack webhook for event "${event}" — ignored (not refund/payment/subscription.create).`);
394
  }
395
 
396
  return res.status(200).json({ ok: true });
 
412
  return res.status(400).json({ ok: false, message: 'Invalid JSON' });
413
  }
414
  }
415
+
416
  const { planId, userId, name, amount, redirect_url, collect_phone = false, fixed_amount = false } = body || {};
417
 
 
 
418
  if (!planId) return res.status(400).json({ ok: false, message: 'planId is required' });
419
  if (!userId) return res.status(400).json({ ok: false, message: 'userId is required' });
420
  if (!name) return res.status(400).json({ ok: false, message: 'name is required (page title)' });
421
 
422
  // Build the body per Paystack's Payment Pages API.
 
 
 
423
  const payload = {
424
  name,
425
  type: 'subscription',
 
427
  metadata: {
428
  userId,
429
  },
 
 
 
 
 
 
 
 
 
 
430
  // optional properties
431
  collect_phone,
432
  fixed_amount,
 
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);