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