Spaces:
Sleeping
Sleeping
Update app.js
Browse files
app.js
CHANGED
|
@@ -1,759 +1,251 @@
|
|
| 1 |
-
|
| 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 |
-
|
| 17 |
-
|
| 18 |
-
|
| 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 |
-
|
| 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 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 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 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
|
|
|
|
|
|
|
|
|
| 472 |
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 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 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 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 |
-
|
| 520 |
-
const
|
| 521 |
-
|
| 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 |
-
|
| 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 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 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 |
-
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
console.error('Cleanup failed:', e);
|
| 649 |
-
}
|
| 650 |
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 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 |
-
|
| 688 |
-
|
| 689 |
-
}
|
| 690 |
}
|
| 691 |
-
} else {
|
| 692 |
-
console.log('Ignoring event:', event);
|
| 693 |
-
}
|
| 694 |
|
| 695 |
-
|
|
|
|
| 696 |
});
|
| 697 |
|
| 698 |
-
/
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 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 |
-
|
| 711 |
-
const
|
| 712 |
-
headers: { Authorization: `Bearer ${PAYSTACK_SECRET}`, 'Content-Type': 'application/json' },
|
| 713 |
-
timeout: 10_000,
|
| 714 |
-
});
|
| 715 |
|
| 716 |
-
|
| 717 |
|
| 718 |
-
// persist mapping doc keyed by slug only (no pageId doc creation)
|
| 719 |
try {
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
|
| 723 |
-
|
| 724 |
-
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
|
| 728 |
-
|
| 729 |
-
|
| 730 |
-
|
|
|
|
|
|
|
| 731 |
} else {
|
| 732 |
-
|
|
|
|
| 733 |
}
|
| 734 |
-
}
|
| 735 |
-
} catch (e) {
|
| 736 |
-
console.error('Failed to persist mapping doc (slug only):', e);
|
| 737 |
-
}
|
| 738 |
|
| 739 |
-
|
| 740 |
-
|
| 741 |
-
|
| 742 |
-
|
| 743 |
-
|
| 744 |
-
}
|
| 745 |
});
|
| 746 |
|
| 747 |
-
/
|
| 748 |
-
app.get('/
|
| 749 |
-
|
| 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 |
-
|
| 757 |
-
|
| 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 |
});
|