Spaces:
Sleeping
Sleeping
Update app.js
Browse files
app.js
CHANGED
|
@@ -123,7 +123,6 @@ function getExpiryFromPlan(planInput) {
|
|
| 123 |
|
| 124 |
/* -------------------- Mapping helpers & updates -------------------- */
|
| 125 |
|
| 126 |
-
// update mapping doc(s) by slug/pageId with authoritative identifiers. merge=true
|
| 127 |
async function updateMappingWithIdentifiers(dbInstance, { slug, pageId, userId, customerId, payerEmail, subscriptionCode, authorizationCode } = {}) {
|
| 128 |
if (!dbInstance) return;
|
| 129 |
try {
|
|
@@ -154,11 +153,9 @@ async function updateMappingWithIdentifiers(dbInstance, { slug, pageId, userId,
|
|
| 154 |
}
|
| 155 |
}
|
| 156 |
|
| 157 |
-
// get userId string from mapping doc (doc id == slug or where pageId==slug)
|
| 158 |
async function getUserIdFromSlug(dbInstance, slugOrPageId) {
|
| 159 |
if (!dbInstance || !slugOrPageId) return null;
|
| 160 |
try {
|
| 161 |
-
// doc id lookup
|
| 162 |
const docRef = dbInstance.collection('paystack-page-mappings').doc(String(slugOrPageId));
|
| 163 |
const snap = await docRef.get();
|
| 164 |
if (snap.exists) {
|
|
@@ -167,9 +164,7 @@ async function getUserIdFromSlug(dbInstance, slugOrPageId) {
|
|
| 167 |
console.log('Found mapping doc (by docId) for', slugOrPageId, ':', { userId: d.userId, customerId: d.customerId, payerEmail: d.payerEmail, subscriptionCode: d.subscriptionCode });
|
| 168 |
return d.userId;
|
| 169 |
}
|
| 170 |
-
// If doc exists but doesn't have userId, still return null (we'll use other mapping queries later)
|
| 171 |
}
|
| 172 |
-
// fallback query by pageId
|
| 173 |
const q = await dbInstance.collection('paystack-page-mappings').where('pageId', '==', String(slugOrPageId)).limit(1).get();
|
| 174 |
if (!q.empty) {
|
| 175 |
const d = q.docs[0].data();
|
|
@@ -183,7 +178,6 @@ async function getUserIdFromSlug(dbInstance, slugOrPageId) {
|
|
| 183 |
}
|
| 184 |
}
|
| 185 |
|
| 186 |
-
// If direct slug/pageId mapping didn't resolve, search mapping collection by customerId/payerEmail/subscriptionCode
|
| 187 |
async function findMappingByIdentifiers(dbInstance, { customerId, payerEmail, subscriptionCode } = {}) {
|
| 188 |
if (!dbInstance) return null;
|
| 189 |
try {
|
|
@@ -207,23 +201,19 @@ async function findMappingByIdentifiers(dbInstance, { customerId, payerEmail, su
|
|
| 207 |
}
|
| 208 |
}
|
| 209 |
|
| 210 |
-
// Attempt resolution: given mapping key (slug), return a users/<docRef> or null
|
| 211 |
async function resolveUserDocFromMapping(dbInstance, { key = null, customerId = null, payerEmail = null, subscriptionCode = null } = {}) {
|
| 212 |
if (!dbInstance) return null;
|
| 213 |
const usersRef = dbInstance.collection('users');
|
| 214 |
|
| 215 |
try {
|
| 216 |
if (key) {
|
| 217 |
-
// first try direct mapping doc by key (slug or pageId)
|
| 218 |
const mappedUserId = await getUserIdFromSlug(dbInstance, key);
|
| 219 |
if (mappedUserId) {
|
| 220 |
-
// Try doc lookup
|
| 221 |
try {
|
| 222 |
const directRef = usersRef.doc(String(mappedUserId));
|
| 223 |
const ds = await directRef.get();
|
| 224 |
if (ds.exists) return directRef;
|
| 225 |
} catch (e) {}
|
| 226 |
-
// fallback queries by common id/email fields
|
| 227 |
if (String(mappedUserId).includes('@')) {
|
| 228 |
try {
|
| 229 |
const q = await usersRef.where('email', '==', String(mappedUserId)).limit(1).get();
|
|
@@ -240,7 +230,6 @@ async function resolveUserDocFromMapping(dbInstance, { key = null, customerId =
|
|
| 240 |
}
|
| 241 |
}
|
| 242 |
|
| 243 |
-
// if not resolved by key, try mapping collection queries (customerId/payerEmail/subscriptionCode)
|
| 244 |
const mappingFound = await findMappingByIdentifiers(dbInstance, { customerId, payerEmail, subscriptionCode });
|
| 245 |
if (mappingFound && mappingFound.data) {
|
| 246 |
const mappedUserId = mappingFound.data.userId;
|
|
@@ -250,7 +239,6 @@ async function resolveUserDocFromMapping(dbInstance, { key = null, customerId =
|
|
| 250 |
const ds = await directRef.get();
|
| 251 |
if (ds.exists) return directRef;
|
| 252 |
} catch (e) {}
|
| 253 |
-
// fallback queries
|
| 254 |
if (String(mappedUserId).includes('@')) {
|
| 255 |
try {
|
| 256 |
const q = await usersRef.where('email', '==', String(mappedUserId)).limit(1).get();
|
|
@@ -315,33 +303,109 @@ async function findUserDocRef(dbInstance, { metadataUserId, email, paystackCusto
|
|
| 315 |
return null;
|
| 316 |
}
|
| 317 |
|
| 318 |
-
/* --------------------
|
| 319 |
|
| 320 |
-
async function
|
| 321 |
if (!dbInstance || !userDocRef) return;
|
| 322 |
try {
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 336 |
}
|
| 337 |
-
} catch (e) {
|
| 338 |
-
console.error('Error deleting mapping doc', docId, e);
|
| 339 |
}
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 343 |
} catch (e) {
|
| 344 |
-
console.error('
|
| 345 |
}
|
| 346 |
}
|
| 347 |
|
|
@@ -367,7 +431,7 @@ app.post('/webhook/paystack', express.raw({ type: 'application/json', limit: '1m
|
|
| 367 |
|
| 368 |
if (!db) console.error('Firestore admin not initialized — cannot persist webhook data.');
|
| 369 |
|
| 370 |
-
// persist webhook audit
|
| 371 |
try {
|
| 372 |
if (db) {
|
| 373 |
await db.collection('paystack-webhooks').add({ event, payload, receivedAt: admin.firestore.FieldValue.serverTimestamp() });
|
|
@@ -396,15 +460,14 @@ app.post('/webhook/paystack', express.raw({ type: 'application/json', limit: '1m
|
|
| 396 |
}
|
| 397 |
}
|
| 398 |
|
| 399 |
-
//
|
| 400 |
const paystackCustomerId = data.customer?.id ?? data.customer?.customer_code ?? null;
|
| 401 |
-
const payerEmail = data.customer?.email ?? null; //
|
| 402 |
const subscriptionCode = data.subscription_code || data.subscription?.subscription_code || null;
|
| 403 |
const authorizationCode = data.authorization?.authorization_code || data.authorization_code || null;
|
| 404 |
|
| 405 |
-
// quick metadata extraction
|
| 406 |
let metadataUserId = null;
|
| 407 |
-
let customerEmail = null;
|
| 408 |
let extractorSource = null;
|
| 409 |
try {
|
| 410 |
const metadata = data.metadata || {};
|
|
@@ -431,12 +494,9 @@ app.post('/webhook/paystack', express.raw({ type: 'application/json', limit: '1m
|
|
| 431 |
console.error('Error during quick metadata extraction:', e);
|
| 432 |
}
|
| 433 |
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
// Resolve user: mapping-first (key), then mapping-by-identifiers, then fallback findUserDocRef
|
| 437 |
let userDocRef = null;
|
| 438 |
try {
|
| 439 |
-
// try mapping with slug/pageId first (fast)
|
| 440 |
const mappingKey = maybeSlug || maybePageId || null;
|
| 441 |
if (mappingKey && db) {
|
| 442 |
userDocRef = await resolveUserDocFromMapping(db, { key: mappingKey, customerId: paystackCustomerId, payerEmail, subscriptionCode });
|
|
@@ -444,15 +504,13 @@ app.post('/webhook/paystack', express.raw({ type: 'application/json', limit: '1m
|
|
| 444 |
else console.log('No mapping resolved from key:', mappingKey);
|
| 445 |
}
|
| 446 |
|
| 447 |
-
// if not found via key, try mapping by identifiers (customerId / payerEmail / subscriptionCode)
|
| 448 |
if (!userDocRef && db && (paystackCustomerId || payerEmail || subscriptionCode)) {
|
| 449 |
userDocRef = await resolveUserDocFromMapping(db, { key: null, customerId: paystackCustomerId, payerEmail, subscriptionCode });
|
| 450 |
if (userDocRef) console.log('Resolved user from mapping by identifiers ->', userDocRef.path);
|
| 451 |
}
|
| 452 |
|
| 453 |
-
// fallback: resolve user document using direct user fields
|
| 454 |
if (!userDocRef) {
|
| 455 |
-
userDocRef = await findUserDocRef(db, { metadataUserId, email:
|
| 456 |
if (userDocRef) console.log('Resolved user via fallback queries:', userDocRef.path);
|
| 457 |
}
|
| 458 |
} catch (e) {
|
|
@@ -460,7 +518,7 @@ app.post('/webhook/paystack', express.raw({ type: 'application/json', limit: '1m
|
|
| 460 |
userDocRef = null;
|
| 461 |
}
|
| 462 |
|
| 463 |
-
//
|
| 464 |
try {
|
| 465 |
await updateMappingWithIdentifiers(db, {
|
| 466 |
slug: maybeSlug,
|
|
@@ -475,21 +533,19 @@ app.post('/webhook/paystack', express.raw({ type: 'application/json', limit: '1m
|
|
| 475 |
console.error('Failed updateMappingWithIdentifiers:', e);
|
| 476 |
}
|
| 477 |
|
| 478 |
-
// ---------------------------
|
| 479 |
// subscription.create handler
|
| 480 |
-
// ---------------------------
|
| 481 |
if (/subscription\.create/i.test(event) || event === 'subscription.create') {
|
| 482 |
-
const
|
| 483 |
const status = data.status || 'active';
|
| 484 |
const planObj = data.plan || data.subscription?.plan || null;
|
| 485 |
const expiry = getExpiryFromPlan(planObj);
|
| 486 |
|
| 487 |
-
if (userDocRef &&
|
| 488 |
try {
|
| 489 |
const updateObj = {
|
| 490 |
-
subscriptionId:
|
| 491 |
subscription: {
|
| 492 |
-
id:
|
| 493 |
status,
|
| 494 |
plan: planObj ? { id: planObj.id, code: planObj.plan_code, amount: planObj.amount, interval: planObj.interval } : null,
|
| 495 |
createdAt: data.createdAt ? admin.firestore.Timestamp.fromDate(new Date(data.createdAt)) : admin.firestore.FieldValue.serverTimestamp(),
|
|
@@ -500,7 +556,9 @@ app.post('/webhook/paystack', express.raw({ type: 'application/json', limit: '1m
|
|
| 500 |
};
|
| 501 |
if (paystackCustomerId) updateObj.paystack_customer_id = String(paystackCustomerId);
|
| 502 |
await userDocRef.update(updateObj);
|
| 503 |
-
console.log('subscription.create: updated user with subscription:',
|
|
|
|
|
|
|
| 504 |
} catch (e) {
|
| 505 |
console.error('subscription.create: failed to update user:', e);
|
| 506 |
}
|
|
@@ -509,11 +567,9 @@ app.post('/webhook/paystack', express.raw({ type: 'application/json', limit: '1m
|
|
| 509 |
}
|
| 510 |
}
|
| 511 |
|
| 512 |
-
// ---------------------------
|
| 513 |
// charge.success handler - entitlement add + mapping updates
|
| 514 |
-
// ---------------------------
|
| 515 |
if (isPayment) {
|
| 516 |
-
console.log('charge.success identifiers:', { metadataUserId,
|
| 517 |
|
| 518 |
const recurringMarker = Boolean((data.metadata && (data.metadata.custom_filters?.recurring || data.metadata.recurring)) || false);
|
| 519 |
const hasSubscription = !!(data.subscription || data.subscriptions || data.plan || subscriptionCode);
|
|
@@ -534,7 +590,7 @@ app.post('/webhook/paystack', express.raw({ type: 'application/json', limit: '1m
|
|
| 534 |
createdAt: admin.firestore.Timestamp.now(),
|
| 535 |
};
|
| 536 |
|
| 537 |
-
// Ensure mapping gets authoritative identifiers on first charge
|
| 538 |
try {
|
| 539 |
await updateMappingWithIdentifiers(db, {
|
| 540 |
slug: maybeSlug,
|
|
@@ -550,7 +606,6 @@ app.post('/webhook/paystack', express.raw({ type: 'application/json', limit: '1m
|
|
| 550 |
}
|
| 551 |
|
| 552 |
if (userDocRef && isLikelySubscriptionPayment) {
|
| 553 |
-
// add entitlement & save customer id on user
|
| 554 |
try {
|
| 555 |
await userDocRef.update({
|
| 556 |
entitlements: admin.firestore.FieldValue.arrayUnion(entitlement),
|
|
@@ -589,6 +644,13 @@ app.post('/webhook/paystack', express.raw({ type: 'application/json', limit: '1m
|
|
| 589 |
} catch (e) {
|
| 590 |
console.error('Cleanup failed:', e);
|
| 591 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 592 |
} else if (userDocRef && !isLikelySubscriptionPayment) {
|
| 593 |
console.log('charge.success received but not flagged subscription/recurring - skipping entitlement add.');
|
| 594 |
} else {
|
|
@@ -612,6 +674,8 @@ app.post('/webhook/paystack', express.raw({ type: 'application/json', limit: '1m
|
|
| 612 |
});
|
| 613 |
await userDocRef.update({ entitlements: filtered, updatedAt: admin.firestore.FieldValue.serverTimestamp() });
|
| 614 |
console.log('Removed entitlements matching refund:', refundedReference);
|
|
|
|
|
|
|
| 615 |
} else {
|
| 616 |
console.warn('Refund handling: user doc vanished.');
|
| 617 |
}
|
|
|
|
| 123 |
|
| 124 |
/* -------------------- Mapping helpers & updates -------------------- */
|
| 125 |
|
|
|
|
| 126 |
async function updateMappingWithIdentifiers(dbInstance, { slug, pageId, userId, customerId, payerEmail, subscriptionCode, authorizationCode } = {}) {
|
| 127 |
if (!dbInstance) return;
|
| 128 |
try {
|
|
|
|
| 153 |
}
|
| 154 |
}
|
| 155 |
|
|
|
|
| 156 |
async function getUserIdFromSlug(dbInstance, slugOrPageId) {
|
| 157 |
if (!dbInstance || !slugOrPageId) return null;
|
| 158 |
try {
|
|
|
|
| 159 |
const docRef = dbInstance.collection('paystack-page-mappings').doc(String(slugOrPageId));
|
| 160 |
const snap = await docRef.get();
|
| 161 |
if (snap.exists) {
|
|
|
|
| 164 |
console.log('Found mapping doc (by docId) for', slugOrPageId, ':', { userId: d.userId, customerId: d.customerId, payerEmail: d.payerEmail, subscriptionCode: d.subscriptionCode });
|
| 165 |
return d.userId;
|
| 166 |
}
|
|
|
|
| 167 |
}
|
|
|
|
| 168 |
const q = await dbInstance.collection('paystack-page-mappings').where('pageId', '==', String(slugOrPageId)).limit(1).get();
|
| 169 |
if (!q.empty) {
|
| 170 |
const d = q.docs[0].data();
|
|
|
|
| 178 |
}
|
| 179 |
}
|
| 180 |
|
|
|
|
| 181 |
async function findMappingByIdentifiers(dbInstance, { customerId, payerEmail, subscriptionCode } = {}) {
|
| 182 |
if (!dbInstance) return null;
|
| 183 |
try {
|
|
|
|
| 201 |
}
|
| 202 |
}
|
| 203 |
|
|
|
|
| 204 |
async function resolveUserDocFromMapping(dbInstance, { key = null, customerId = null, payerEmail = null, subscriptionCode = null } = {}) {
|
| 205 |
if (!dbInstance) return null;
|
| 206 |
const usersRef = dbInstance.collection('users');
|
| 207 |
|
| 208 |
try {
|
| 209 |
if (key) {
|
|
|
|
| 210 |
const mappedUserId = await getUserIdFromSlug(dbInstance, key);
|
| 211 |
if (mappedUserId) {
|
|
|
|
| 212 |
try {
|
| 213 |
const directRef = usersRef.doc(String(mappedUserId));
|
| 214 |
const ds = await directRef.get();
|
| 215 |
if (ds.exists) return directRef;
|
| 216 |
} catch (e) {}
|
|
|
|
| 217 |
if (String(mappedUserId).includes('@')) {
|
| 218 |
try {
|
| 219 |
const q = await usersRef.where('email', '==', String(mappedUserId)).limit(1).get();
|
|
|
|
| 230 |
}
|
| 231 |
}
|
| 232 |
|
|
|
|
| 233 |
const mappingFound = await findMappingByIdentifiers(dbInstance, { customerId, payerEmail, subscriptionCode });
|
| 234 |
if (mappingFound && mappingFound.data) {
|
| 235 |
const mappedUserId = mappingFound.data.userId;
|
|
|
|
| 239 |
const ds = await directRef.get();
|
| 240 |
if (ds.exists) return directRef;
|
| 241 |
} catch (e) {}
|
|
|
|
| 242 |
if (String(mappedUserId).includes('@')) {
|
| 243 |
try {
|
| 244 |
const q = await usersRef.where('email', '==', String(mappedUserId)).limit(1).get();
|
|
|
|
| 303 |
return null;
|
| 304 |
}
|
| 305 |
|
| 306 |
+
/* -------------------- Prune helper (entitlements + old webhook audits) -------------------- */
|
| 307 |
|
| 308 |
+
async function pruneUserEntitlementsAndWebhooks(dbInstance, userDocRef, { paystackCustomerId = null, payerEmail = null } = {}) {
|
| 309 |
if (!dbInstance || !userDocRef) return;
|
| 310 |
try {
|
| 311 |
+
// cutoff ~60 days (2 months approximated as 60 days)
|
| 312 |
+
const cutoffMs = Date.now() - (60 * 24 * 60 * 60 * 1000);
|
| 313 |
+
|
| 314 |
+
// 1) prune entitlements on the user doc
|
| 315 |
+
const snap = await userDocRef.get();
|
| 316 |
+
if (!snap.exists) return;
|
| 317 |
+
const userData = snap.data() || {};
|
| 318 |
+
const entitlements = Array.isArray(userData.entitlements) ? userData.entitlements : [];
|
| 319 |
+
|
| 320 |
+
const cleaned = entitlements.filter(e => {
|
| 321 |
+
// if expiresAtMs present -> keep only if still in future
|
| 322 |
+
if (e && (e.expiresAtMs || e.expiresAtMs === 0)) {
|
| 323 |
+
const exMs = Number(e.expiresAtMs) || 0;
|
| 324 |
+
return exMs > Date.now(); // keep if expiry in future
|
| 325 |
+
}
|
| 326 |
+
// else if createdAt present -> keep only if createdAt within cutoff window
|
| 327 |
+
if (e && e.createdAt) {
|
| 328 |
+
// createdAt could be a Firestore Timestamp or a number/string
|
| 329 |
+
let createdMs = 0;
|
| 330 |
+
try {
|
| 331 |
+
if (typeof e.createdAt === 'number') createdMs = e.createdAt;
|
| 332 |
+
else if (e.createdAt && typeof e.createdAt.toMillis === 'function') createdMs = e.createdAt.toMillis();
|
| 333 |
+
else if (typeof e.createdAt === 'string') createdMs = Number(e.createdAt) || 0;
|
| 334 |
+
else if (e.createdAt._seconds) createdMs = (Number(e.createdAt._seconds) * 1000) + (Number(e.createdAt._nanoseconds || 0) / 1e6);
|
| 335 |
+
} catch (ee) {
|
| 336 |
+
createdMs = 0;
|
| 337 |
+
}
|
| 338 |
+
if (createdMs) return createdMs >= cutoffMs;
|
| 339 |
+
// if no timestamps at all, conservatively keep
|
| 340 |
+
return true;
|
| 341 |
+
}
|
| 342 |
+
// if no expiry and no createdAt -> keep (can't judge)
|
| 343 |
+
return true;
|
| 344 |
+
});
|
| 345 |
+
|
| 346 |
+
if (cleaned.length !== entitlements.length) {
|
| 347 |
+
await userDocRef.update({
|
| 348 |
+
entitlements: cleaned,
|
| 349 |
+
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
|
| 350 |
+
});
|
| 351 |
+
console.log('Pruned entitlements: removed', entitlements.length - cleaned.length, 'entries for', userDocRef.path);
|
| 352 |
+
} else {
|
| 353 |
+
console.log('No entitlements to prune for', userDocRef.path);
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
// 2) prune paystack-webhooks audit docs for this user older than cutoff
|
| 357 |
+
try {
|
| 358 |
+
const coll = dbInstance.collection('paystack-webhooks');
|
| 359 |
+
const toDeleteRefs = [];
|
| 360 |
+
|
| 361 |
+
// Query by customer id
|
| 362 |
+
if (paystackCustomerId) {
|
| 363 |
+
try {
|
| 364 |
+
const q = await coll.where('payload.data.customer.id', '==', paystackCustomerId).get();
|
| 365 |
+
q.docs.forEach(d => {
|
| 366 |
+
const r = d.data();
|
| 367 |
+
const ts = r.receivedAt;
|
| 368 |
+
let tsMs = 0;
|
| 369 |
+
if (ts && typeof ts.toMillis === 'function') tsMs = ts.toMillis();
|
| 370 |
+
if (tsMs && tsMs < cutoffMs) toDeleteRefs.push(d.ref);
|
| 371 |
+
});
|
| 372 |
+
} catch (e) {
|
| 373 |
+
// May require index; ignore failure to avoid breaking webhook
|
| 374 |
+
console.warn('Could not query paystack-webhooks by customer.id (skipping):', e);
|
| 375 |
}
|
|
|
|
|
|
|
| 376 |
}
|
| 377 |
+
|
| 378 |
+
// Query by payer email
|
| 379 |
+
if (payerEmail) {
|
| 380 |
+
try {
|
| 381 |
+
const q2 = await coll.where('payload.data.customer.email', '==', payerEmail).get();
|
| 382 |
+
q2.docs.forEach(d => {
|
| 383 |
+
const r = d.data();
|
| 384 |
+
const ts = r.receivedAt;
|
| 385 |
+
let tsMs = 0;
|
| 386 |
+
if (ts && typeof ts.toMillis === 'function') tsMs = ts.toMillis();
|
| 387 |
+
if (tsMs && tsMs < cutoffMs) {
|
| 388 |
+
if (!toDeleteRefs.find(x => x.path === d.ref.path)) toDeleteRefs.push(d.ref);
|
| 389 |
+
}
|
| 390 |
+
});
|
| 391 |
+
} catch (e) {
|
| 392 |
+
console.warn('Could not query paystack-webhooks by customer.email (skipping):', e);
|
| 393 |
+
}
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
if (toDeleteRefs.length) {
|
| 397 |
+
const batch = dbInstance.batch();
|
| 398 |
+
toDeleteRefs.forEach(r => batch.delete(r));
|
| 399 |
+
await batch.commit();
|
| 400 |
+
console.log('Deleted', toDeleteRefs.length, 'old paystack-webhooks audit docs for user', userDocRef.id);
|
| 401 |
+
} else {
|
| 402 |
+
console.log('No old paystack-webhooks audit docs to prune for user', userDocRef.id);
|
| 403 |
+
}
|
| 404 |
+
} catch (e) {
|
| 405 |
+
console.error('Failed pruning paystack-webhooks (non-fatal):', e);
|
| 406 |
+
}
|
| 407 |
} catch (e) {
|
| 408 |
+
console.error('pruneUserEntitlementsAndWebhooks failed:', e);
|
| 409 |
}
|
| 410 |
}
|
| 411 |
|
|
|
|
| 431 |
|
| 432 |
if (!db) console.error('Firestore admin not initialized — cannot persist webhook data.');
|
| 433 |
|
| 434 |
+
// persist webhook audit
|
| 435 |
try {
|
| 436 |
if (db) {
|
| 437 |
await db.collection('paystack-webhooks').add({ event, payload, receivedAt: admin.firestore.FieldValue.serverTimestamp() });
|
|
|
|
| 460 |
}
|
| 461 |
}
|
| 462 |
|
| 463 |
+
// authoritative identifiers
|
| 464 |
const paystackCustomerId = data.customer?.id ?? data.customer?.customer_code ?? null;
|
| 465 |
+
const payerEmail = data.customer?.email ?? null; // saved on mapping as payerEmail
|
| 466 |
const subscriptionCode = data.subscription_code || data.subscription?.subscription_code || null;
|
| 467 |
const authorizationCode = data.authorization?.authorization_code || data.authorization_code || null;
|
| 468 |
|
| 469 |
+
// quick metadata extraction
|
| 470 |
let metadataUserId = null;
|
|
|
|
| 471 |
let extractorSource = null;
|
| 472 |
try {
|
| 473 |
const metadata = data.metadata || {};
|
|
|
|
| 494 |
console.error('Error during quick metadata extraction:', e);
|
| 495 |
}
|
| 496 |
|
| 497 |
+
// Resolve user: mapping-first (slug/pageId), then mapping by identifiers, then fallback queries
|
|
|
|
|
|
|
| 498 |
let userDocRef = null;
|
| 499 |
try {
|
|
|
|
| 500 |
const mappingKey = maybeSlug || maybePageId || null;
|
| 501 |
if (mappingKey && db) {
|
| 502 |
userDocRef = await resolveUserDocFromMapping(db, { key: mappingKey, customerId: paystackCustomerId, payerEmail, subscriptionCode });
|
|
|
|
| 504 |
else console.log('No mapping resolved from key:', mappingKey);
|
| 505 |
}
|
| 506 |
|
|
|
|
| 507 |
if (!userDocRef && db && (paystackCustomerId || payerEmail || subscriptionCode)) {
|
| 508 |
userDocRef = await resolveUserDocFromMapping(db, { key: null, customerId: paystackCustomerId, payerEmail, subscriptionCode });
|
| 509 |
if (userDocRef) console.log('Resolved user from mapping by identifiers ->', userDocRef.path);
|
| 510 |
}
|
| 511 |
|
|
|
|
| 512 |
if (!userDocRef) {
|
| 513 |
+
userDocRef = await findUserDocRef(db, { metadataUserId, email: payerEmail, paystackCustomerId });
|
| 514 |
if (userDocRef) console.log('Resolved user via fallback queries:', userDocRef.path);
|
| 515 |
}
|
| 516 |
} catch (e) {
|
|
|
|
| 518 |
userDocRef = null;
|
| 519 |
}
|
| 520 |
|
| 521 |
+
// update mapping with authoritative identifiers if available
|
| 522 |
try {
|
| 523 |
await updateMappingWithIdentifiers(db, {
|
| 524 |
slug: maybeSlug,
|
|
|
|
| 533 |
console.error('Failed updateMappingWithIdentifiers:', e);
|
| 534 |
}
|
| 535 |
|
|
|
|
| 536 |
// subscription.create handler
|
|
|
|
| 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(),
|
|
|
|
| 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 |
+
// prune after successful subscription update
|
| 561 |
+
await pruneUserEntitlementsAndWebhooks(db, userDocRef, { paystackCustomerId, payerEmail });
|
| 562 |
} catch (e) {
|
| 563 |
console.error('subscription.create: failed to update user:', e);
|
| 564 |
}
|
|
|
|
| 567 |
}
|
| 568 |
}
|
| 569 |
|
|
|
|
| 570 |
// charge.success handler - entitlement add + mapping updates
|
|
|
|
| 571 |
if (isPayment) {
|
| 572 |
+
console.log('charge.success identifiers:', { metadataUserId, payerEmail, maybeSlug, maybePageId, paystackCustomerId, subscriptionCode, extractorSource });
|
| 573 |
|
| 574 |
const recurringMarker = Boolean((data.metadata && (data.metadata.custom_filters?.recurring || data.metadata.recurring)) || false);
|
| 575 |
const hasSubscription = !!(data.subscription || data.subscriptions || data.plan || subscriptionCode);
|
|
|
|
| 590 |
createdAt: admin.firestore.Timestamp.now(),
|
| 591 |
};
|
| 592 |
|
| 593 |
+
// Ensure mapping gets authoritative identifiers on first charge
|
| 594 |
try {
|
| 595 |
await updateMappingWithIdentifiers(db, {
|
| 596 |
slug: maybeSlug,
|
|
|
|
| 606 |
}
|
| 607 |
|
| 608 |
if (userDocRef && isLikelySubscriptionPayment) {
|
|
|
|
| 609 |
try {
|
| 610 |
await userDocRef.update({
|
| 611 |
entitlements: admin.firestore.FieldValue.arrayUnion(entitlement),
|
|
|
|
| 644 |
} catch (e) {
|
| 645 |
console.error('Cleanup failed:', e);
|
| 646 |
}
|
| 647 |
+
|
| 648 |
+
// prune entitlements and old webhook audits for this user (runs after successful processing)
|
| 649 |
+
try {
|
| 650 |
+
await pruneUserEntitlementsAndWebhooks(db, userDocRef, { paystackCustomerId, payerEmail });
|
| 651 |
+
} catch (e) {
|
| 652 |
+
console.error('Prune helper failed:', e);
|
| 653 |
+
}
|
| 654 |
} else if (userDocRef && !isLikelySubscriptionPayment) {
|
| 655 |
console.log('charge.success received but not flagged subscription/recurring - skipping entitlement add.');
|
| 656 |
} else {
|
|
|
|
| 674 |
});
|
| 675 |
await userDocRef.update({ entitlements: filtered, updatedAt: admin.firestore.FieldValue.serverTimestamp() });
|
| 676 |
console.log('Removed entitlements matching refund:', refundedReference);
|
| 677 |
+
// also prune (to remove other expired ones if present)
|
| 678 |
+
await pruneUserEntitlementsAndWebhooks(db, userDocRef, { paystackCustomerId, payerEmail });
|
| 679 |
} else {
|
| 680 |
console.warn('Refund handling: user doc vanished.');
|
| 681 |
}
|