Pepguy commited on
Commit
85475e1
·
verified ·
1 Parent(s): 4556eaf

Update app.js

Browse files
Files changed (1) hide show
  1. app.js +214 -722
app.js CHANGED
@@ -1,759 +1,251 @@
1
- // paystack-webhook.js
2
- // Node >= 14
3
- // npm install express firebase-admin axios
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
-
14
- // ----
15
-
16
- const {
17
- FIREBASE_CREDENTIALS,
18
- PAYSTACK_DEMO_SECRET,
19
- PAYSTACK_LIVE_SECRET,
20
- PORT = 7860,
21
- } = process.env;
22
- const PAYSTACK_SECRET = PAYSTACK_DEMO_SECRET || PAYSTACK_LIVE_SECRET;
23
-
24
- if (!PAYSTACK_SECRET) console.warn('WARNING: PAYSTACK secret not set.');
25
-
26
- let db = null;
27
- try {
28
- if (FIREBASE_CREDENTIALS) {
29
- const svc = JSON.parse(FIREBASE_CREDENTIALS);
30
- admin.initializeApp({ credential: admin.credential.cert(svc) });
31
- db = admin.firestore();
32
- console.log('Firebase admin initialized from FIREBASE_CREDENTIALS env var.');
33
- } else {
34
- if (!admin.apps.length) {
35
- console.warn('FIREBASE_CREDENTIALS not provided and admin not initialized.');
36
- } else {
37
- db = admin.firestore();
38
- }
39
- }
40
- } catch (e) {
41
- console.error('Failed to init Firebase admin:', e);
42
- }
43
-
44
- /* -------------------- Helpers -------------------- */
45
-
46
- function safeParseJSON(raw) {
47
- try { return JSON.parse(raw); } catch (e) { return null; }
48
- }
49
-
50
- function verifyPaystackSignature(rawBodyBuffer, signatureHeader) {
51
- if (!PAYSTACK_SECRET || !rawBodyBuffer || !signatureHeader) return false;
52
- try {
53
- const hmac = crypto.createHmac('sha512', PAYSTACK_SECRET);
54
- hmac.update(rawBodyBuffer);
55
- const expected = hmac.digest('hex');
56
- return crypto.timingSafeEqual(Buffer.from(expected, 'utf8'), Buffer.from(String(signatureHeader), 'utf8'));
57
- } catch (e) {
58
- console.error('Signature verification error:', e);
59
- return false;
60
- }
61
- }
62
-
63
- function extractSlugFromReferrer(refUrl) {
64
- if (!refUrl || typeof refUrl !== 'string') return null;
65
- try {
66
- const m = refUrl.match(/\/(?:pay|p)\/([^\/\?\#]+)/i);
67
- if (m && m[1]) return m[1];
68
- const parts = new URL(refUrl).pathname.split('/').filter(Boolean);
69
- if (parts.length) return parts[parts.length - 1];
70
- } catch (e) {
71
- const fallback = (refUrl.split('/').pop() || '').split('?')[0].split('#')[0];
72
- return fallback || null;
73
- }
74
- return null;
75
- }
76
-
77
- /* expiry helper */
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']);
81
- function getExpiryFromPlan(planInput) {
82
- let interval = null, planId = null, planCode = null;
83
- if (!planInput) { interval = null; }
84
- else if (typeof planInput === 'object') {
85
- planId = planInput.id ?? planInput.plan_id ?? null;
86
- planCode = planInput.plan_code ?? planInput.planCode ?? null;
87
- interval = planInput.interval || planInput.billing_interval || null;
88
- } else if (typeof planInput === 'number') planId = planInput;
89
- else if (typeof planInput === 'string') { planCode = planInput; const asNum = Number(planInput); if (!Number.isNaN(asNum)) planId = asNum; }
90
-
91
- if (interval) {
92
- interval = String(interval).toLowerCase();
93
- if (interval.includes('week')) {
94
- const expires = Date.now() + 7*24*60*60*1000 + EXTRA_FREE_MS;
95
- return { expiresAtMs: expires, expiresAtIso: new Date(expires).toISOString() };
96
- }
97
- if (interval.includes('month')) {
98
- const expires = Date.now() + 30*24*60*60*1000 + EXTRA_FREE_MS;
99
- return { expiresAtMs: expires, expiresAtIso: new Date(expires).toISOString() };
100
- }
101
- if (interval.includes('hour')) {
102
- const expires = Date.now() + 7*24*60*60*1000 + EXTRA_FREE_MS;
103
- return { expiresAtMs: expires, expiresAtIso: new Date(expires).toISOString() };
104
- }
105
- }
106
-
107
- if (planId && WEEKLY_PLAN_IDS.has(planId)) {
108
- const expires = Date.now() + 7*24*60*60*1000 + EXTRA_FREE_MS;
109
- return { expiresAtMs: expires, expiresAtIso: new Date(expires).toISOString() };
110
- }
111
- if (planCode && WEEKLY_PLAN_IDS.has(planCode)) {
112
- const expires = Date.now() + 7*24*60*60*1000 + EXTRA_FREE_MS;
113
- return { expiresAtMs: expires, expiresAtIso: new Date(expires).toISOString() };
114
- }
115
- if (planId && MONTHLY_PLAN_IDS.has(planId)) {
116
- const expires = Date.now() + 30*24*60*60*1000 + EXTRA_FREE_MS;
117
- return { expiresAtMs: expires, expiresAtIso: new Date(expires).toISOString() };
118
- }
119
- if (planCode && MONTHLY_PLAN_IDS.has(planCode)) {
120
- const expires = Date.now() + 30*24*60*60*1000 + EXTRA_FREE_MS;
121
- return { expiresAtMs: expires, expiresAtIso: new Date(expires).toISOString() };
122
- }
123
-
124
- const expires = Date.now() + 30*24*60*60*1000 + EXTRA_FREE_MS;
125
- return { expiresAtMs: expires, expiresAtIso: new Date(expires).toISOString() };
126
- }
127
-
128
- /* -------------------- Mapping helpers & updates (SLUG-ONLY) -------------------- */
129
-
130
- // update mapping doc by slug only (merge)
131
- async function updateMappingWithIdentifiers(dbInstance, { slug, userId, userIdType, customerId, payerEmail, subscriptionCode, authorizationCode } = {}) {
132
- if (!dbInstance || !slug) return;
133
- try {
134
- const writeObj = {};
135
- if (userId !== undefined) writeObj.userId = String(userId);
136
- if (userIdType !== undefined) writeObj.userIdType = String(userIdType);
137
- if (customerId !== undefined && customerId !== null) writeObj.customerId = String(customerId);
138
- if (payerEmail !== undefined && payerEmail !== null) writeObj.payerEmail = String(payerEmail);
139
- if (subscriptionCode !== undefined && subscriptionCode !== null) writeObj.subscriptionCode = String(subscriptionCode);
140
- if (authorizationCode !== undefined && authorizationCode !== null) writeObj.authorizationCode = String(authorizationCode);
141
- if (Object.keys(writeObj).length === 0) return;
142
-
143
- await dbInstance.collection('paystack-page-mappings').doc(String(slug)).set({
144
- slug: String(slug),
145
- ...writeObj,
146
- updatedAt: admin.firestore.FieldValue.serverTimestamp(),
147
- }, { merge: true });
148
- console.log('Updated mapping doc (slug) with identifiers:', slug, writeObj);
149
- } catch (e) {
150
- console.error('updateMappingWithIdentifiers failed:', e);
151
- }
152
- }
153
-
154
- // get mapping doc data by slug (doc id)
155
- async function getMappingBySlug(dbInstance, slug) {
156
- if (!dbInstance || !slug) return null;
157
- try {
158
- const ref = dbInstance.collection('paystack-page-mappings').doc(String(slug));
159
- const snap = await ref.get();
160
- if (!snap.exists) return null;
161
- return { ref, data: snap.data() };
162
- } catch (e) {
163
- console.error('getMappingBySlug error:', e);
164
- return null;
165
- }
166
- }
167
-
168
- // find mapping by other authoritative identifiers (customerId / payerEmail / subscriptionCode)
169
- async function findMappingByIdentifiers(dbInstance, { customerId, payerEmail, subscriptionCode } = {}) {
170
- if (!dbInstance) return null;
171
- try {
172
- const coll = dbInstance.collection('paystack-page-mappings');
173
- if (customerId) {
174
- const q = await coll.where('customerId', '==', String(customerId)).limit(1).get();
175
- if (!q.empty) return { mappingDoc: q.docs[0].ref, data: q.docs[0].data() };
176
- }
177
- if (subscriptionCode) {
178
- const q2 = await coll.where('subscriptionCode', '==', String(subscriptionCode)).limit(1).get();
179
- if (!q2.empty) return { mappingDoc: q2.docs[0].ref, data: q2.docs[0].data() };
180
- }
181
- if (payerEmail) {
182
- const q3 = await coll.where('payerEmail', '==', String(payerEmail)).limit(1).get();
183
- if (!q3.empty) return { mappingDoc: q3.docs[0].ref, data: q3.docs[0].data() };
184
- }
185
- return null;
186
- } catch (e) {
187
- console.error('findMappingByIdentifiers error:', e);
188
- return null;
189
- }
190
- }
191
-
192
- // Resolve user doc from mapping: prefer slug doc, else mapping-by-identifiers
193
- async function resolveUserDocFromMapping(dbInstance, { slug = null, customerId = null, payerEmail = null, subscriptionCode = null } = {}) {
194
- if (!dbInstance) return null;
195
- const usersRef = dbInstance.collection('users');
196
-
197
- // 1) try slug doc => read mapping.userId and resolve to users doc
198
- if (slug) {
199
- try {
200
- const mapping = await getMappingBySlug(dbInstance, slug);
201
- if (mapping && mapping.data && mapping.data.userId) {
202
- const mappedUserId = mapping.data.userId;
203
- // try doc id first
204
- try {
205
- const directRef = usersRef.doc(String(mappedUserId));
206
- const ds = await directRef.get();
207
- if (ds.exists) return directRef;
208
- } catch (e) {}
209
- // fallback: if mappedUserId looks like email
210
- if (String(mappedUserId).includes('@')) {
211
- try {
212
- const q = await usersRef.where('email', '==', String(mappedUserId)).limit(1).get();
213
- if (!q.empty) return q.docs[0].ref;
214
- } catch (e) {}
215
- }
216
- // fallback to other id fields
217
- const idFields = ['userId','uid','id'];
218
- for (const f of idFields) {
219
- try {
220
- const q = await usersRef.where(f,'==',mappedUserId).limit(1).get();
221
- if (!q.empty) return q.docs[0].ref;
222
- } catch (e) {}
223
- }
224
- }
225
- } catch (e) {
226
- console.error('resolveUserDocFromMapping slug branch error:', e);
227
- }
228
- }
229
-
230
- // 2) try mapping by identifiers
231
- try {
232
- const found = await findMappingByIdentifiers(dbInstance, { customerId, payerEmail, subscriptionCode });
233
- if (found && found.data && found.data.userId) {
234
- const mappedUserId = found.data.userId;
235
- // same resolution logic
236
- try {
237
- const directRef = usersRef.doc(String(mappedUserId));
238
- const ds = await directRef.get();
239
- if (ds.exists) return directRef;
240
- } catch (e) {}
241
- if (String(mappedUserId).includes('@')) {
242
- try {
243
- const q = await usersRef.where('email', '==', String(mappedUserId)).limit(1).get();
244
- if (!q.empty) return q.docs[0].ref;
245
- } catch (e) {}
246
- }
247
- const idFields = ['userId','uid','id'];
248
- for (const f of idFields) {
249
- try {
250
- const q = await usersRef.where(f,'==',mappedUserId).limit(1).get();
251
- if (!q.empty) return q.docs[0].ref;
252
- } catch (e) {}
253
- }
254
- }
255
- } catch (e) {
256
- console.error('resolveUserDocFromMapping identifiers branch error:', e);
257
- }
258
-
259
- return null;
260
- }
261
-
262
- /* -------------------- Fallback user resolution -------------------- */
263
-
264
- async function findUserDocRef(dbInstance, { metadataUserId, email, paystackCustomerId } = {}) {
265
- if (!dbInstance) return null;
266
- const usersRef = dbInstance.collection('users');
267
-
268
- if (metadataUserId) {
269
- if (!String(metadataUserId).includes('@')) {
270
- try {
271
- const docRef = usersRef.doc(String(metadataUserId));
272
- const s = await docRef.get();
273
- if (s.exists) return docRef;
274
- } catch (e) {}
275
- }
276
-
277
- const idFields = ['userId','uid','id'];
278
- for (const f of idFields) {
279
- try {
280
- const q = await usersRef.where(f,'==',metadataUserId).limit(1).get();
281
- if (!q.empty) return q.docs[0].ref;
282
- } catch (e) {}
283
- }
284
- }
285
-
286
- if (paystackCustomerId) {
287
- try {
288
- const q = await usersRef.where('paystack_customer_id','==',String(paystackCustomerId)).limit(1).get();
289
- if (!q.empty) return q.docs[0].ref;
290
- } catch (e) {}
291
- }
292
 
293
- if (email) {
294
- try {
295
- const q = await usersRef.where('email','==',String(email)).limit(1).get();
296
- if (!q.empty) return q.docs[0].ref;
297
- } catch (e) {}
298
- }
299
-
300
- return null;
301
- }
302
-
303
- /* -------------------- Cleanup (SLUG-only + smarter owner check) -------------------- */
304
-
305
- async function cleanUpAfterSuccessfulCharge(dbInstance, userDocRef, { slug, reference, email } = {}) {
306
- if (!dbInstance || !userDocRef) return;
307
- try {
308
- // load mapping only by slug (we no longer create pageId docs)
309
- if (!slug) return;
310
- const mapRef = dbInstance.collection('paystack-page-mappings').doc(String(slug));
311
- const mapSnap = await mapRef.get();
312
- if (!mapSnap.exists) return;
313
- const map = mapSnap.data();
314
-
315
- // determine mapping owner type and user identity
316
- const ownerVal = String(map.userId || '');
317
- const ownerType = map.userIdType || (ownerVal.includes('@') ? 'email' : 'uid');
318
-
319
- // get user doc data to check email or id
320
- const userSnap = await userDocRef.get();
321
- if (!userSnap.exists) return;
322
- const userData = userSnap.data() || {};
323
- const userEmail = userData.email || null;
324
- const userId = userDocRef.id;
325
-
326
- let shouldDelete = false;
327
- if (ownerType === 'uid' && ownerVal === userId) shouldDelete = true;
328
- if (ownerType === 'email' && userEmail && ownerVal === userEmail) shouldDelete = true;
329
-
330
- if (shouldDelete) {
331
- await mapRef.delete();
332
- console.log('Deleted mapping doc (slug) after successful charge:', slug);
333
- } else {
334
- console.log('Mapping owner mismatch — not deleting slug:', slug, 'mappingOwner=', ownerVal, 'ownerType=', ownerType, 'userId=', userId, 'userEmail=', userEmail);
335
- }
336
- } catch (e) {
337
- console.error('Error in cleanUpAfterSuccessfulCharge:', e);
338
- }
339
- }
340
-
341
- /* -------------------- Prune helper (optional, unchanged) -------------------- */
342
-
343
- async function pruneUserEntitlementsAndWebhooks(dbInstance, userDocRef, { paystackCustomerId = null, payerEmail = null } = {}) {
344
- if (!dbInstance || !userDocRef) return;
345
- try {
346
- const cutoffMs = Date.now() - (60 * 24 * 60 * 60 * 1000);
347
- const snap = await userDocRef.get();
348
- if (!snap.exists) return;
349
- const userData = snap.data() || {};
350
- const entitlements = Array.isArray(userData.entitlements) ? userData.entitlements : [];
351
- const cleaned = entitlements.filter(e => {
352
- if (e && (e.expiresAtMs || e.expiresAtMs === 0)) {
353
- const exMs = Number(e.expiresAtMs) || 0;
354
- return exMs > Date.now();
355
- }
356
- if (e && e.createdAt) {
357
- let createdMs = 0;
358
- try {
359
- if (typeof e.createdAt === 'number') createdMs = e.createdAt;
360
- else if (e.createdAt && typeof e.createdAt.toMillis === 'function') createdMs = e.createdAt.toMillis();
361
- else if (typeof e.createdAt === 'string') createdMs = Number(e.createdAt) || 0;
362
- else if (e.createdAt._seconds) createdMs = (Number(e.createdAt._seconds) * 1000) + (Number(e.createdAt._nanoseconds || 0) / 1e6);
363
- } catch (ee) { createdMs = 0; }
364
- if (createdMs) return createdMs >= cutoffMs;
365
- return true;
366
- }
367
- return true;
368
- });
369
- if (cleaned.length !== entitlements.length) {
370
- await userDocRef.update({ entitlements: cleaned, updatedAt: admin.firestore.FieldValue.serverTimestamp() });
371
- console.log('Pruned entitlements: removed', entitlements.length - cleaned.length, 'entries for', userDocRef.path);
372
- }
373
 
374
- // prune paystack-webhooks older than cutoff for this user (best-effort)
375
  try {
376
- const coll = dbInstance.collection('paystack-webhooks');
377
- const toDeleteRefs = [];
378
- if (paystackCustomerId) {
379
- try {
380
- const q = await coll.where('payload.data.customer.id', '==', paystackCustomerId).get();
381
- q.docs.forEach(d => {
382
- const r = d.data();
383
- const ts = r.receivedAt;
384
- let tsMs = 0;
385
- if (ts && typeof ts.toMillis === 'function') tsMs = ts.toMillis();
386
- if (tsMs && tsMs < cutoffMs) toDeleteRefs.push(d.ref);
387
- });
388
- } catch (e) { console.warn('Could not query paystack-webhooks by customer.id (skipping):', e); }
389
- }
390
- if (payerEmail) {
391
- try {
392
- const q2 = await coll.where('payload.data.customer.email', '==', payerEmail).get();
393
- q2.docs.forEach(d => {
394
- const r = d.data();
395
- const ts = r.receivedAt;
396
- let tsMs = 0;
397
- if (ts && typeof ts.toMillis === 'function') tsMs = ts.toMillis();
398
- if (tsMs && tsMs < cutoffMs) {
399
- if (!toDeleteRefs.find(x => x.path === d.ref.path)) toDeleteRefs.push(d.ref);
400
- }
401
- });
402
- } catch (e) { console.warn('Could not query paystack-webhooks by customer.email (skipping):', e); }
403
- }
404
- if (toDeleteRefs.length) {
405
- const batch = dbInstance.batch();
406
- toDeleteRefs.forEach(r => batch.delete(r));
407
- await batch.commit();
408
- console.log('Deleted', toDeleteRefs.length, 'old paystack-webhooks audit docs for user', userDocRef.id);
409
- }
410
- } catch (e) { console.error('Failed pruning paystack-webhooks (non-fatal):', e); }
411
- } catch (e) {
412
- console.error('pruneUserEntitlementsAndWebhooks failed:', e);
413
- }
414
- }
415
-
416
- /* -------------------- Webhook route -------------------- */
417
-
418
- app.post('/webhook/paystack', express.raw({ type: 'application/json', limit: '1mb' }), async (req, res) => {
419
- const raw = req.body;
420
- const signature = req.get('x-paystack-signature') || req.get('X-Paystack-Signature');
421
-
422
- if (!signature || !verifyPaystackSignature(raw, signature)) {
423
- console.warn('Paystack webhook signature verify failed. header:', signature);
424
- return res.status(400).json({ ok: false, message: 'Invalid signature' });
425
- }
426
-
427
- const bodyStr = raw.toString('utf8');
428
- const payload = safeParseJSON(bodyStr);
429
- if (!payload) return res.status(400).json({ ok: false, message: 'Invalid JSON' });
430
-
431
- const event = (payload.event || payload.type || '').toString();
432
- const isRefund = /refund/i.test(event);
433
- const isChargeSuccess = /charge\.success/i.test(event);
434
- const isPayment = isChargeSuccess;
435
-
436
- if (!db) console.error('Firestore admin not initialized — cannot persist webhook data.');
437
-
438
- // persist webhook audit
439
- try {
440
- if (db) {
441
- await db.collection('paystack-webhooks').add({ event, payload, receivedAt: admin.firestore.FieldValue.serverTimestamp() });
442
- }
443
- } catch (e) {
444
- console.error('Failed to persist webhook audit:', e);
445
- }
446
-
447
- if (/subscription\.create/i.test(event) || isRefund || isPayment || /subscription\.update/i.test(event)) {
448
- console.log('--- PAYSTACK WEBHOOK (INTERESTING EVENT) ---');
449
- console.log('event:', event);
450
- const data = payload.data || {};
451
-
452
- // extract slug
453
- let maybeSlug = data.page?.slug || data.slug || null;
454
- if (!maybeSlug) {
455
- const referrer = data.metadata?.referrer || (data.metadata && data.metadata.referrer) || null;
456
- if (referrer) {
457
- const extracted = extractSlugFromReferrer(String(referrer));
458
- if (extracted) {
459
- maybeSlug = extracted;
460
- console.log('Extracted slug from metadata.referrer:', maybeSlug);
461
- } else {
462
- console.log('Could not extract slug from referrer:', referrer);
463
- }
464
- }
465
- }
466
 
467
- // authoritative identifiers
468
- const paystackCustomerId = data.customer?.id ?? data.customer?.customer_code ?? null;
469
- const payerEmail = data.customer?.email ?? null; // saved on mapping as payerEmail
470
- const subscriptionCode = data.subscription_code || data.subscription?.subscription_code || null;
471
- const authorizationCode = data.authorization?.authorization_code || data.authorization_code || null;
 
 
 
472
 
473
- // quick metadata extraction
474
- let metadataUserId = null;
475
- try {
476
- const metadata = data.metadata || {};
477
- if (metadata.userId || metadata.user_id) metadataUserId = metadata.userId || metadata.user_id;
478
- else if (data.customer?.metadata) {
479
- const cm = data.customer.metadata;
480
- if (cm.userId || cm.user_id) metadataUserId = cm.userId || cm.user_id;
481
- } else if (Array.isArray(data.custom_fields)) {
482
- for (const f of data.custom_fields) {
483
- const n = (f.variable_name || f.display_name || '').toString().toLowerCase();
484
- if ((n.includes('user') || n.includes('user_id') || n.includes('userid')) && f.value) {
485
- metadataUserId = f.value;
486
- break;
487
- }
488
- }
489
- }
490
- } catch (e) {
491
- console.error('Error during quick metadata extraction:', e);
492
  }
 
493
 
494
- // Resolve user: mapping-first (slug), then mapping-by-identifiers, then fallback queries
495
- let userDocRef = null;
496
- try {
497
- if (maybeSlug && db) {
498
- userDocRef = await resolveUserDocFromMapping(db, { slug: maybeSlug, customerId: paystackCustomerId, payerEmail, subscriptionCode });
499
- if (userDocRef) console.log('Resolved user from mapping (slug):', userDocRef.path, 'slug=', maybeSlug);
500
- else console.log('No mapping resolved from slug:', maybeSlug);
501
- }
502
-
503
- if (!userDocRef && db && (paystackCustomerId || payerEmail || subscriptionCode)) {
504
- userDocRef = await resolveUserDocFromMapping(db, { slug: null, customerId: paystackCustomerId, payerEmail, subscriptionCode });
505
- if (userDocRef) console.log('Resolved user from mapping by identifiers ->', userDocRef.path);
506
- }
507
-
508
- if (!userDocRef) {
509
- userDocRef = await findUserDocRef(db, { metadataUserId, email: payerEmail, paystackCustomerId });
510
- if (userDocRef) console.log('Resolved user via fallback queries:', userDocRef.path);
511
- }
512
- } catch (e) {
513
- console.error('Error resolving userDocRef (mapping-first):', e);
514
- userDocRef = null;
515
- }
516
 
517
- // update mapping with authoritative identifiers ONLY if we have a slug (we avoid creating mapping by pageId)
518
  try {
519
- if (maybeSlug) {
520
- const userIdToSave = metadataUserId || (userDocRef ? userDocRef.id : undefined);
521
- const userIdType = userIdToSave && String(userIdToSave).includes('@') ? 'email' : 'uid';
522
- await updateMappingWithIdentifiers(db, {
523
- slug: maybeSlug,
524
- userId: userIdToSave,
525
- userIdType,
526
- customerId: paystackCustomerId || undefined,
527
- payerEmail: payerEmail || undefined,
528
- subscriptionCode: subscriptionCode || undefined,
529
- authorizationCode: authorizationCode || undefined,
530
  });
531
- }
532
- } catch (e) {
533
- console.error('Failed updateMappingWithIdentifiers:', e);
534
- }
535
 
536
- // subscription.create
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(),
552
- expiresAtMs: expiry.expiresAtMs,
553
- expiresAtIso: expiry.expiresAtIso,
554
- },
555
- updatedAt: 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
- await pruneUserEntitlementsAndWebhooks(db, userDocRef, { paystackCustomerId, payerEmail });
561
- } catch (e) {
562
- console.error('subscription.create: failed to update user:', e);
563
- }
564
- } else {
565
- console.warn('subscription.create: user not found or subscriptionCode missing — skipping user update.');
566
- }
567
- }
568
 
569
- // charge.success
570
- if (isPayment) {
571
- console.log('charge.success identifiers:', { metadataUserId, payerEmail, maybeSlug, paystackCustomerId, subscriptionCode });
572
-
573
- const recurringMarker = Boolean((data.metadata && (data.metadata.custom_filters?.recurring || data.metadata.recurring)) || false);
574
- const hasSubscription = !!(data.subscription || data.subscriptions || data.plan || subscriptionCode);
575
- const isLikelySubscriptionPayment = recurringMarker || hasSubscription;
576
-
577
- const planObj = data.plan || (data.subscription ? data.subscription.plan : null) || null;
578
- const expiry = getExpiryFromPlan(planObj);
579
-
580
- const entitlement = {
581
- id: (data.reference || data.id) ? String(data.reference || data.id) : null,
582
- source: 'paystack',
583
- amount: data.amount || null,
584
- reference: data.reference || null,
585
- paidAt: data.paid_at || data.paidAt || data.created_at || null,
586
- plan: planObj ? { id: planObj.id, code: planObj.plan_code } : null,
587
- expiresAtMs: expiry.expiresAtMs,
588
- expiresAtIso: expiry.expiresAtIso,
589
- createdAt: admin.firestore.Timestamp.now(),
590
- };
591
-
592
- // Ensure mapping gets authoritative identifiers on first charge (only if slug present)
593
- try {
594
- if (maybeSlug) {
595
- const userIdToSave = metadataUserId || (userDocRef ? userDocRef.id : undefined);
596
- const userIdType = userIdToSave && String(userIdToSave).includes('@') ? 'email' : 'uid';
597
- await updateMappingWithIdentifiers(db, {
598
- slug: maybeSlug,
599
- userId: userIdToSave,
600
- userIdType,
601
- customerId: paystackCustomerId || undefined,
602
- payerEmail: payerEmail || undefined,
603
- subscriptionCode: subscriptionCode || undefined,
604
- authorizationCode: authorizationCode || undefined,
605
- });
606
- }
607
- } catch (e) {
608
- console.error('Failed updateMappingWithIdentifiers during charge.success:', e);
609
- }
610
-
611
- if (userDocRef && isLikelySubscriptionPayment) {
612
- try {
613
- await userDocRef.update({
614
- entitlements: admin.firestore.FieldValue.arrayUnion(entitlement),
615
- lastPaymentAt: admin.firestore.FieldValue.serverTimestamp(),
616
- updatedAt: admin.firestore.FieldValue.serverTimestamp(),
617
- ...(paystackCustomerId ? { paystack_customer_id: String(paystackCustomerId) } : {}),
618
- });
619
- console.log('Entitlement added (arrayUnion) to', userDocRef.path);
620
- } catch (err) {
621
- console.error('arrayUnion failed, falling back:', err);
622
- try {
623
- const snap = await userDocRef.get();
624
- const userData = snap.exists ? snap.data() : {};
625
- const current = Array.isArray(userData.entitlements) ? userData.entitlements : [];
626
- const exists = current.some(e => (e.reference || e.id) === (entitlement.reference || entitlement.id));
627
- if (!exists) {
628
- current.push(entitlement);
629
- await userDocRef.update({
630
- entitlements: current,
631
- lastPaymentAt: admin.firestore.FieldValue.serverTimestamp(),
632
- updatedAt: admin.firestore.FieldValue.serverTimestamp(),
633
- ...(paystackCustomerId ? { paystack_customer_id: String(paystackCustomerId) } : {}),
634
- });
635
- console.log('Entitlement appended via fallback.');
636
- } else {
637
- console.log('Entitlement already present, skipping append.');
638
- }
639
- } catch (err2) {
640
- console.error('Fallback persistence failed:', err2);
641
- }
642
- }
643
 
644
- // cleanup mapping doc if slug exists (only slug doc)
645
- try {
646
- await cleanUpAfterSuccessfulCharge(db, userDocRef, { slug: maybeSlug, reference: entitlement.reference || entitlement.id, email: payerEmail });
647
- } catch (e) {
648
- console.error('Cleanup failed:', e);
649
- }
650
 
651
- // prune entitlements & old webhooks
652
- try {
653
- await pruneUserEntitlementsAndWebhooks(db, userDocRef, { paystackCustomerId, payerEmail });
654
- } catch (e) {
655
- console.error('Prune helper failed:', e);
656
- }
657
- } else if (userDocRef && !isLikelySubscriptionPayment) {
658
- console.log('charge.success received but not flagged subscription/recurring - skipping entitlement add.');
659
- } else {
660
- console.warn('charge.success: user not found, skipping entitlement update.');
661
- }
662
- }
663
-
664
- // refunds
665
- if (isRefund) {
666
- const refund = data;
667
- const refundedReference = refund.reference || refund.transaction?.reference || refund.transaction?.id || null;
668
- if (userDocRef && refundedReference) {
669
- try {
670
- const snap = await userDocRef.get();
671
- if (snap.exists) {
672
- const userData = snap.data();
673
- const current = Array.isArray(userData.entitlements) ? userData.entitlements : [];
674
- const filtered = current.filter(e => {
675
- const r = e.reference || e.id || '';
676
- return r !== refundedReference && r !== String(refundedReference);
677
- });
678
- await userDocRef.update({ entitlements: filtered, updatedAt: admin.firestore.FieldValue.serverTimestamp() });
679
- console.log('Removed entitlements matching refund:', refundedReference);
680
- await pruneUserEntitlementsAndWebhooks(db, userDocRef, { paystackCustomerId, payerEmail });
681
- } else {
682
- console.warn('Refund handling: user doc vanished.');
683
- }
684
- } catch (e) {
685
- console.error('Refund entitlement removal failed:', e);
686
  }
687
- } else {
688
- console.warn('Refund: user or refundedReference missing; skipping.');
689
- }
690
  }
691
- } else {
692
- console.log('Ignoring event:', event);
693
- }
694
 
695
- return res.status(200).json({ ok: true });
 
696
  });
697
 
698
- /* -------------------- Create payment link endpoint (SLUG only) -------------------- */
699
-
700
- app.post('/create-payment-link', express.json(), async (req, res) => {
701
- const { planId, userId, name, amount, redirect_url, collect_phone = false, fixed_amount = false } = req.body || {};
702
- if (!planId) return res.status(400).json({ ok: false, message: 'planId required' });
703
- if (!userId) return res.status(400).json({ ok: false, message: 'userId required' });
704
- if (!name) return res.status(400).json({ ok: false, message: 'name required' });
705
-
706
- const payload = { name, type: 'subscription', plan: planId, metadata: { userId: String(userId) }, collect_phone, fixed_amount };
707
- if (amount) payload.amount = amount;
708
- if (redirect_url) payload.redirect_url = redirect_url;
709
 
710
- try {
711
- const response = await axios.post('https://api.paystack.co/page', payload, {
712
- headers: { Authorization: `Bearer ${PAYSTACK_SECRET}`, 'Content-Type': 'application/json' },
713
- timeout: 10_000,
714
- });
715
 
716
- const pageData = response.data && response.data.data ? response.data.data : response.data;
717
 
718
- // persist mapping doc keyed by slug only (no pageId doc creation)
719
  try {
720
- if (db) {
721
- const slug = pageData.slug || pageData.data?.slug || null;
722
- if (slug) {
723
- await db.collection('paystack-page-mappings').doc(String(slug)).set({
724
- userId: String(userId),
725
- userIdType: String(userId).includes('@') ? 'email' : 'uid',
726
- pageId: pageData.id ? String(pageData.id) : null, // store pageId inside slug doc only
727
- slug: String(slug),
728
- createdAt: admin.firestore.FieldValue.serverTimestamp(),
729
- }, { merge: true });
730
- console.log('Saved page slug mapping for', slug);
 
 
731
  } else {
732
- console.warn('create-payment-link: Paystack response did not return a slug; no mapping saved.');
 
733
  }
734
- }
735
- } catch (e) {
736
- console.error('Failed to persist mapping doc (slug only):', e);
737
- }
738
 
739
- return res.status(201).json({ ok: true, page: pageData });
740
- } catch (err) {
741
- console.error('Error creating Paystack page:', err?.response?.data || err.message || err);
742
- const errorDetail = err?.response?.data || { message: err.message };
743
- return res.status(500).json({ ok: false, error: errorDetail });
744
- }
745
  });
746
 
747
- /* health */
748
- app.get('/health', (_req, res) => res.status(200).json({ ok: true }));
749
- app.post('/health', (_req, res) => res.status(200).json({ ok: true }));
750
-
751
- app.get('/', (_req, res) => res.status(204).end());
752
- app.post('/', (_req, res) => res.status(204).end());
753
 
 
 
 
 
 
754
 
 
755
  app.listen(PORT, () => {
756
- console.log(`Paystack webhook server listening on port ${PORT}`);
757
- console.log('POST /webhook/paystack to receive Paystack callbacks');
758
- console.log('POST /create-payment-link to create a subscription payment page (expects JSON body).');
759
  });
 
1
+ require('dotenv').config();
 
 
 
2
  const express = require('express');
 
 
3
  const axios = require('axios');
4
+ const bodyParser = require('body-parser');
5
 
6
  const app = express();
7
+ const PORT = 7860;
8
+
9
+ // ==========================================
10
+ // CONFIGURATION
11
+ // ==========================================
12
+ // REPLACE THIS WITH YOUR PAYSTACK SECRET KEY
13
+ const PAYSTACK_SECRET_KEY = 'sk_test_4aa42b4105d33d191eda94d543a2a82ad3c420c7';
14
+
15
+ // const PAYSTACK_SECRET_KEY = 'sk_live_31c44ef2b81a591d771202b7ffcbcaab765e977e';
16
+
17
+ // MOCK DATABASE
18
+ // In a real app, you would save these details to MongoDB, Postgres, etc.
19
+ let mockUserDB = {
20
+ email: null,
21
+ customer_code: null,
22
+ authorization_code: null, // This is the "Token" to charge the card later
23
+ last_log: "Server started. Waiting for user input..."
24
+ };
25
+
26
+ // Middleware
27
+ app.use(bodyParser.json());
28
+ app.use(bodyParser.urlencoded({ extended: true }));
29
+
30
+ // Helper to log to console and "DB"
31
+ const log = (msg) => {
32
+ const timestamp = new Date().toLocaleTimeString();
33
+ console.log(`[${timestamp}] ${msg}`);
34
+ mockUserDB.last_log = `[${timestamp}] ${msg}`;
35
+ };
36
+
37
+ // ==========================================
38
+ // ROUTES
39
+ // ==========================================
40
+
41
+ // 1. Render the Embedded HTML Interface
42
+ app.get('/', (req, res) => {
43
+ // We check if we have a saved token to adjust the UI state
44
+ const isAuthorized = !!mockUserDB.authorization_code;
45
+
46
+ const html = `
47
+ <!DOCTYPE html>
48
+ <html lang="en">
49
+ <head>
50
+ <meta charset="UTF-8">
51
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
52
+ <title>Paystack Card Tokenization Demo</title>
53
+ <style>
54
+ body { font-family: sans-serif; max-width: 600px; margin: 40px auto; padding: 20px; background: #f4f4f9; }
55
+ .card { background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 5px rgba(0,0,0,0.1); }
56
+ h1 { color: #333; }
57
+ input { padding: 10px; width: 100%; box-sizing: border-box; margin-bottom: 10px; }
58
+ button { padding: 10px 20px; cursor: pointer; border: none; border-radius: 4px; font-weight: bold; }
59
+ .btn-auth { background-color: #09a5db; color: white; }
60
+ .btn-charge { background-color: #27ae60; color: white; }
61
+ .btn-charge:disabled { background-color: #ccc; cursor: not-allowed; }
62
+ .logs { background: #333; color: #0f0; padding: 15px; border-radius: 4px; font-family: monospace; height: 150px; overflow-y: scroll; margin-top: 20px; white-space: pre-wrap; }
63
+ .status-box { margin-bottom: 20px; padding: 10px; background: #eef; border-left: 5px solid #09a5db; }
64
+ </style>
65
+ </head>
66
+ <body>
67
+ <div class="card">
68
+ <h1>💳 Paystack Recurring Charge Demo</h1>
69
+
70
+ <div class="status-box">
71
+ <strong>Status:</strong> <span id="status-text">${isAuthorized ? '✅ Card Authorized' : '❌ No Card stored'}</span><br>
72
+ <strong>Customer Email:</strong> ${mockUserDB.email || 'N/A'}<br>
73
+ <strong>Auth Code:</strong> ${mockUserDB.authorization_code || 'N/A'}
74
+ </div>
75
+
76
+ ${!isAuthorized ? `
77
+ <h3>Step 1: Authorize Card</h3>
78
+ <p>We will charge a tiny amount (e.g., NGN 50) to authorize the card.</p>
79
+ <form action="/initialize" method="POST">
80
+ <input type="email" name="email" placeholder="Enter email address" required value="test@user.com">
81
+ <button type="submit" class="btn-auth">Authorize Card Now</button>
82
+ </form>
83
+ ` : `
84
+ <h3>Step 2: Charge Saved Card</h3>
85
+ <p>The card is tokenized. You can now charge it anytime without user interaction.</p>
86
+ <button id="chargeBtn" class="btn-charge" onclick="chargeCard()">Charge NGN 100.00 Now</button>
87
+ <br><br>
88
+ <small><a href="/reset">Reset Demo</a></small>
89
+ `}
90
+
91
+ <h3>Server Logs</h3>
92
+ <div class="logs" id="log-window">${mockUserDB.last_log}</div>
93
+ </div>
94
+
95
+ <script>
96
+ // Simple polling to update logs
97
+ setInterval(() => {
98
+ fetch('/logs').then(res => res.json()).then(data => {
99
+ document.getElementById('log-window').innerText = data.log;
100
+ });
101
+ }, 2000);
102
+
103
+ async function chargeCard() {
104
+ const btn = document.getElementById('chargeBtn');
105
+ btn.disabled = true;
106
+ btn.innerText = "Charging...";
107
+
108
+ try {
109
+ const res = await fetch('/charge-token', { method: 'POST' });
110
+ const data = await res.json();
111
+ alert(data.message);
112
+ } catch (e) {
113
+ alert("Error charging card");
114
+ }
115
+ btn.disabled = false;
116
+ btn.innerText = "Charge NGN 100.00 Now";
117
+ window.location.reload();
118
+ }
119
+ </script>
120
+ </body>
121
+ </html>
122
+ `;
123
+ res.send(html);
124
+ });
125
 
126
+ // 2. Initialize Transaction (Get Redirect URL)
127
+ app.post('/initialize', async (req, res) => {
128
+ const { email } = req.body;
129
+
130
+ // Amount is in Kobo (or lowest currency unit). 5000 kobo = 50 Naira.
131
+ // Paystack requires a small amount to verify the card (which can be refunded later).
132
+ const amount = 5000;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
 
134
+ log(`Initializing transaction for ${email}...`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
 
 
136
  try {
137
+ const response = await axios.post('https://api.paystack.co/transaction/initialize', {
138
+ email: email,
139
+ amount: amount,
140
+ // Important: This is where Paystack returns the user after payment
141
+ callback_url: `http://localhost:${PORT}/callback`,
142
+ channels: ['card'] // Force card payment to ensure we get an auth token
143
+ }, {
144
+ headers: { Authorization: `Bearer ${PAYSTACK_SECRET_KEY}` }
145
+ });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
 
147
+ const authUrl = response.data.data.authorization_url;
148
+ log(`Redirecting user to Paystack: ${authUrl}`);
149
+
150
+ // Save email temporarily
151
+ mockUserDB.email = email;
152
+
153
+ // Redirect user to Paystack secure page
154
+ res.redirect(authUrl);
155
 
156
+ } catch (error) {
157
+ log(`Error initializing: ${error.response ? error.response.data.message : error.message}`);
158
+ res.send("Error initializing transaction. Check console.");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  }
160
+ });
161
 
162
+ // 3. Callback (Handle Return from Paystack)
163
+ app.get('/callback', async (req, res) => {
164
+ const reference = req.query.reference; // Paystack sends this in the URL
165
+ log(`Callback received. Verifying reference: ${reference}...`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
 
 
167
  try {
168
+ // Verify the transaction to get the Authorization Code
169
+ const response = await axios.get(`https://api.paystack.co/transaction/verify/${reference}`, {
170
+ headers: { Authorization: `Bearer ${PAYSTACK_SECRET_KEY}` }
 
 
 
 
 
 
 
 
171
  });
 
 
 
 
172
 
173
+ const data = response.data.data;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
 
175
+ if (data.status === 'success') {
176
+ // SUCCESS! We now have the Authorization Code (The Token)
177
+ const authCode = data.authorization.authorization_code;
178
+ const customerCode = data.customer.customer_code;
179
+ const cardType = data.authorization.card_type;
180
+ const last4 = data.authorization.last4;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
 
182
+ // Save to "Database"
183
+ mockUserDB.authorization_code = authCode;
184
+ mockUserDB.customer_code = customerCode;
185
+ mockUserDB.email = data.customer.email;
 
 
186
 
187
+ log(`SUCCESS! Card authorized. Type: ${cardType} *${last4}. Auth Code: ${authCode}`);
188
+ } else {
189
+ log(`Transaction failed or incomplete.`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
  }
191
+ } catch (error) {
192
+ log(`Verification failed: ${error.message}`);
 
193
  }
 
 
 
194
 
195
+ // Redirect back to our main page
196
+ res.redirect('/');
197
  });
198
 
199
+ // 4. Charge the Saved Card (Recurring Charge)
200
+ app.post('/charge-token', async (req, res) => {
201
+ if (!mockUserDB.authorization_code) {
202
+ return res.status(400).json({ message: "No card authorized yet." });
203
+ }
 
 
 
 
 
 
204
 
205
+ // Amount to charge (e.g., 100 Naira = 10000 kobo)
206
+ const chargeAmount = 10000;
 
 
 
207
 
208
+ log(`Attempting to charge saved card (Auth: ${mockUserDB.authorization_code}) amount: ${chargeAmount}...`);
209
 
 
210
  try {
211
+ const response = await axios.post('https://api.paystack.co/transaction/charge_authorization', {
212
+ email: mockUserDB.email,
213
+ amount: chargeAmount,
214
+ authorization_code: mockUserDB.authorization_code
215
+ }, {
216
+ headers: { Authorization: `Bearer ${PAYSTACK_SECRET_KEY}` }
217
+ });
218
+
219
+ const data = response.data.data;
220
+
221
+ if (response.data.status) {
222
+ log(`CHARGE SUCCESSFUL! Ref: ${data.reference} - Status: ${data.status}`);
223
+ res.json({ message: "Charge Successful! check logs." });
224
  } else {
225
+ log(`Charge Failed: ${response.data.message}`);
226
+ res.json({ message: "Charge Failed." });
227
  }
 
 
 
 
228
 
229
+ } catch (error) {
230
+ const errMsg = error.response ? error.response.data.message : error.message;
231
+ log(`API Error during charge: ${errMsg}`);
232
+ res.status(500).json({ message: `Error: ${errMsg}` });
233
+ }
 
234
  });
235
 
236
+ // Utility: Get Logs for Frontend
237
+ app.get('/logs', (req, res) => {
238
+ res.json({ log: mockUserDB.last_log });
239
+ });
 
 
240
 
241
+ // Utility: Reset Demo
242
+ app.get('/reset', (req, res) => {
243
+ mockUserDB = { email: null, customer_code: null, authorization_code: null, last_log: "System Reset." };
244
+ res.redirect('/');
245
+ });
246
 
247
+ // Start Server
248
  app.listen(PORT, () => {
249
+ console.log(`Server running at http://localhost:${PORT}`);
250
+ console.log(`Make sure to set your PAYSTACK_SECRET_KEY in code or .env`);
 
251
  });