backend-manager 5.0.112 → 5.0.114

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-manager",
3
- "version": "5.0.112",
3
+ "version": "5.0.114",
4
4
  "description": "Quick tools for developing Firebase functions",
5
5
  "main": "src/manager/index.js",
6
6
  "bin": {
@@ -0,0 +1,104 @@
1
+ const powertools = require('node-powertools');
2
+
3
+ /**
4
+ * Expire PayPal pending cancellations
5
+ *
6
+ * PayPal has no cancel_at_period_end like Stripe — cancellation is immediate on PayPal's side.
7
+ * BEM keeps users active with cancellation.pending=true until the billing period ends.
8
+ * Since PayPal does NOT fire a webhook at period end, this cron job transitions those
9
+ * users to 'cancelled' status once their paid period has expired.
10
+ *
11
+ * Flow:
12
+ * 1. Query users with PayPal subscriptions where cancellation.pending=true
13
+ * 2. Check if subscription.expires.timestampUNIX < now
14
+ * 3. Update status to 'cancelled' and cancellation.pending to false
15
+ * 4. Dispatch 'subscription-cancelled' transition (sends email)
16
+ */
17
+ module.exports = async ({ Manager, assistant, context, libraries }) => {
18
+ const { admin } = libraries;
19
+ const transitions = require('../../events/firestore/payments-webhooks/transitions/index.js');
20
+
21
+ const now = new Date();
22
+ const nowStr = powertools.timestamp(now, { output: 'string' });
23
+ const nowUNIX = powertools.timestamp(nowStr, { output: 'unix' });
24
+
25
+ assistant.log('Checking for expired PayPal pending cancellations...');
26
+
27
+ let processed = 0;
28
+ let skipped = 0;
29
+
30
+ await Manager.Utilities().iterateCollection(async (batch, index) => {
31
+ for (const doc of batch.docs) {
32
+ const data = doc.data();
33
+ const sub = data.subscription;
34
+ const uid = doc.id;
35
+
36
+ // Double-check: skip if expires is in the future
37
+ if (!sub?.expires?.timestampUNIX || sub.expires.timestampUNIX > nowUNIX) {
38
+ assistant.log(`[skip] ${uid}: expires=${sub?.expires?.timestamp || 'null'} is still in the future (now=${nowStr})`);
39
+ skipped++;
40
+ continue;
41
+ }
42
+
43
+ assistant.log(`[expire] ${uid}: expires=${sub.expires.timestamp}, product=${sub.product?.id}, processor=${sub.payment?.processor}, orderId=${sub.payment?.orderId || 'null'}`);
44
+
45
+ // Snapshot the before state for transition detection
46
+ const before = { ...sub };
47
+
48
+ // Build the after state — transition to cancelled
49
+ const after = {
50
+ ...sub,
51
+ status: 'cancelled',
52
+ cancellation: {
53
+ ...sub.cancellation,
54
+ pending: false,
55
+ },
56
+ };
57
+
58
+ // Write to Firestore
59
+ await doc.ref.set({ subscription: after }, { merge: true });
60
+
61
+ assistant.log(`[expire] ${uid}: Updated status=cancelled, cancellation.pending=false`);
62
+
63
+ // Detect and dispatch transition (should fire 'subscription-cancelled')
64
+ const transitionName = transitions.detectTransition('subscription', before, after, null);
65
+
66
+ if (transitionName) {
67
+ assistant.log(`[expire] ${uid}: Transition detected: subscription/${transitionName}`);
68
+
69
+ // Build minimal order context for the handler
70
+ const order = {
71
+ id: sub.payment?.orderId || null,
72
+ type: 'subscription',
73
+ owner: uid,
74
+ processor: 'paypal',
75
+ resourceId: sub.payment?.resourceId || null,
76
+ unified: after,
77
+ };
78
+
79
+ transitions.dispatch(transitionName, 'subscription', {
80
+ before,
81
+ after,
82
+ order,
83
+ uid,
84
+ userDoc: data,
85
+ assistant,
86
+ });
87
+ } else {
88
+ assistant.log(`[expire] ${uid}: No transition detected (before.status=${before.status}, after.status=${after.status})`);
89
+ }
90
+
91
+ processed++;
92
+ }
93
+ }, {
94
+ collection: 'users',
95
+ where: [
96
+ { field: 'subscription.payment.processor', operator: '==', value: 'paypal' },
97
+ { field: 'subscription.cancellation.pending', operator: '==', value: true },
98
+ ],
99
+ batchSize: 5000,
100
+ log: true,
101
+ });
102
+
103
+ assistant.log(`Completed! Processed=${processed}, Skipped=${skipped}`);
104
+ };
@@ -36,7 +36,7 @@ module.exports = async ({ assistant, change, context }) => {
36
36
 
37
37
  try {
38
38
  const processor = dataAfter.processor;
39
- const uid = dataAfter.owner;
39
+ let uid = dataAfter.owner;
40
40
  const raw = dataAfter.raw;
41
41
  const eventType = dataAfter.event?.type;
42
42
  const category = dataAfter.event?.category;
@@ -45,11 +45,6 @@ module.exports = async ({ assistant, change, context }) => {
45
45
 
46
46
  assistant.log(`Processing webhook ${eventId}: processor=${processor}, eventType=${eventType}, category=${category}, resourceType=${resourceType}, resourceId=${resourceId}, uid=${uid || 'null'}`);
47
47
 
48
- // Validate UID
49
- if (!uid) {
50
- throw new Error('Webhook event has no UID — cannot process');
51
- }
52
-
53
48
  // Validate category
54
49
  if (!category) {
55
50
  throw new Error(`Webhook event has no category — cannot process`);
@@ -70,6 +65,24 @@ module.exports = async ({ assistant, change, context }) => {
70
65
 
71
66
  assistant.log(`Fetched resource: type=${resourceType}, id=${resourceId}, status=${resource.status || 'unknown'}`);
72
67
 
68
+ // Resolve UID from the fetched resource if not available from webhook parse
69
+ // This handles events like PAYMENT.SALE where the Sale object doesn't carry custom_id
70
+ // but the parent subscription (fetched via fetchResource) does
71
+ if (!uid && library.getUid) {
72
+ uid = library.getUid(resource);
73
+ assistant.log(`UID resolved from fetched resource: uid=${uid || 'null'}, processor=${processor}, resourceType=${resourceType}`);
74
+
75
+ // Update the webhook doc with the resolved UID so it's persisted for debugging
76
+ if (uid) {
77
+ await webhookRef.set({ owner: uid }, { merge: true });
78
+ }
79
+ }
80
+
81
+ // Validate UID — must have one by now (either from webhook parse or fetched resource)
82
+ if (!uid) {
83
+ throw new Error(`Webhook event has no UID — could not extract from webhook parse or fetched ${resourceType} resource`);
84
+ }
85
+
73
86
  // Build timestamps
74
87
  const now = powertools.timestamp(new Date(), { output: 'string' });
75
88
  const nowUNIX = powertools.timestamp(now, { output: 'unix' });
@@ -83,7 +96,7 @@ module.exports = async ({ assistant, change, context }) => {
83
96
  throw new Error(`Unknown event category: ${category}`);
84
97
  }
85
98
 
86
- const transitionName = await processPaymentEvent({ category, library, resource, resourceType, uid, processor, eventType, eventId, resourceId, orderId, now, nowUNIX, webhookReceivedUNIX, assistant });
99
+ const transitionName = await processPaymentEvent({ category, library, resource, resourceType, uid, processor, eventType, eventId, resourceId, orderId, now, nowUNIX, webhookReceivedUNIX, assistant, raw });
87
100
 
88
101
  // Mark webhook as completed (include transition name for auditing/testing)
89
102
  await webhookRef.set({
@@ -138,7 +151,7 @@ module.exports = async ({ assistant, change, context }) => {
138
151
  * 6. Track analytics (non-blocking)
139
152
  * 7. Write to Firestore (user doc for subscriptions + payments-orders)
140
153
  */
141
- async function processPaymentEvent({ category, library, resource, resourceType, uid, processor, eventType, eventId, resourceId, orderId, now, nowUNIX, webhookReceivedUNIX, assistant }) {
154
+ async function processPaymentEvent({ category, library, resource, resourceType, uid, processor, eventType, eventId, resourceId, orderId, now, nowUNIX, webhookReceivedUNIX, assistant, raw }) {
142
155
  const Manager = assistant.Manager;
143
156
  const admin = Manager.libraries.admin;
144
157
  const isSubscription = category === 'subscription';
@@ -215,8 +228,13 @@ async function processPaymentEvent({ category, library, resource, resourceType,
215
228
  assistant.log(`Transition detected: ${category}/${transitionName} (before.status=${before?.status || 'null'}, after.status=${unified.status})`);
216
229
 
217
230
  if (shouldRunHandlers) {
231
+ // Extract unified refund details from the processor library (keeps handlers processor-agnostic)
232
+ const refundDetails = (transitionName === 'payment-refunded' && library.getRefundDetails)
233
+ ? library.getRefundDetails(raw)
234
+ : null;
235
+
218
236
  transitions.dispatch(transitionName, category, {
219
- before, after: unified, order, uid, userDoc: userData, assistant,
237
+ before, after: unified, order, uid, userDoc: userData, assistant, refundDetails,
220
238
  });
221
239
  } else {
222
240
  assistant.log(`Transition handler skipped (testing mode): ${category}/${transitionName}`);
@@ -18,7 +18,7 @@ const path = require('path');
18
18
  */
19
19
  function detectTransition(category, before, after, eventType) {
20
20
  if (category === 'subscription') {
21
- return detectSubscriptionTransition(before, after);
21
+ return detectSubscriptionTransition(before, after, eventType);
22
22
  }
23
23
 
24
24
  if (category === 'one-time') {
@@ -37,11 +37,18 @@ function detectTransition(category, before, after, eventType) {
37
37
  * @param {object} after - New unified subscription
38
38
  * @returns {string|null} Transition name
39
39
  */
40
- function detectSubscriptionTransition(before, after) {
40
+ function detectSubscriptionTransition(before, after, eventType) {
41
41
  if (!after) {
42
42
  return null;
43
43
  }
44
44
 
45
+ // Refund events take priority — detected by webhook event type rather than state diff
46
+ // because the subscription state may not change meaningfully during a refund
47
+ const refundEvents = ['PAYMENT.SALE.REFUNDED', 'charge.refunded'];
48
+ if (refundEvents.includes(eventType)) {
49
+ return 'payment-refunded';
50
+ }
51
+
45
52
  const beforeStatus = before?.status;
46
53
  const afterStatus = after.status;
47
54
 
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Transition: payment-refunded
3
+ * Triggered when a payment refund webhook is received.
4
+ *
5
+ * Processor-agnostic — refund details are extracted by the processor library's
6
+ * getRefundDetails() method and passed as a unified { amount, currency, reason } object.
7
+ *
8
+ * This is webhook-driven so it fires regardless of how the refund originated
9
+ * (admin dashboard, user self-service, or direct processor action).
10
+ */
11
+ const { sendOrderEmail, formatDate } = require('../send-email.js');
12
+
13
+ module.exports = async function ({ before, after, order, uid, userDoc, assistant, refundDetails }) {
14
+ assistant.log(`Transition [subscription/payment-refunded]: uid=${uid}, product=${after?.product?.id}, amount=${refundDetails?.amount} ${refundDetails?.currency}, reason=${refundDetails?.reason || 'none'}`);
15
+
16
+ sendOrderEmail({
17
+ template: 'main/order/refunded',
18
+ subject: `Your payment has been refunded #${order?.id || ''}`,
19
+ categories: ['order/refunded'],
20
+ userDoc,
21
+ assistant,
22
+ data: {
23
+ order: {
24
+ ...order,
25
+ _computed: {
26
+ date: formatDate(new Date().toISOString()),
27
+ refundAmount: refundDetails?.amount || null,
28
+ refundCurrency: refundDetails?.currency || 'USD',
29
+ refundReason: refundDetails?.reason || null,
30
+ },
31
+ },
32
+ },
33
+ });
34
+ };
@@ -358,6 +358,35 @@ const PayPal = {
358
358
  return customData.orderId || null;
359
359
  },
360
360
 
361
+ /**
362
+ * Extract the UID from a PayPal resource's custom_id
363
+ * Used to resolve UID after fetchResource() for events like PAYMENT.SALE
364
+ * where the initial webhook payload doesn't carry custom_id
365
+ *
366
+ * @param {object} resource - Raw PayPal resource (subscription or order)
367
+ * @returns {string|null}
368
+ */
369
+ getUid(resource) {
370
+ const purchaseCustomId = resource.purchase_units?.[0]?.custom_id;
371
+ const customData = parseCustomId(purchaseCustomId || resource.custom_id);
372
+ return customData?.uid || null;
373
+ },
374
+
375
+ /**
376
+ * Extract refund details from a PayPal PAYMENT.SALE.REFUNDED webhook payload
377
+ * Returns a unified shape so transition handlers stay processor-agnostic
378
+ *
379
+ * @param {object} raw - Raw PayPal webhook payload
380
+ * @returns {{ amount: string|null, currency: string, reason: string|null }}
381
+ */
382
+ getRefundDetails(raw) {
383
+ return {
384
+ amount: raw?.resource?.amount?.total || raw?.resource?.total_refunded_amount?.value || null,
385
+ currency: raw?.resource?.amount?.currency || 'USD',
386
+ reason: raw?.resource?.reason_code || null,
387
+ };
388
+ },
389
+
361
390
  /**
362
391
  * Build the custom_id string for PayPal subscriptions and orders
363
392
  * Format: uid:{uid},orderId:{orderId} or uid:{uid},orderId:{orderId},productId:{productId}
@@ -408,6 +437,44 @@ function parseCustomId(customId) {
408
437
  return result;
409
438
  }
410
439
 
440
+ /**
441
+ * Calculate when the current billing period ends based on last payment + interval
442
+ * Used for cancelled subs to determine remaining access time
443
+ *
444
+ * @param {object} raw - Raw PayPal subscription (with _plan attached)
445
+ * @returns {Date|null} Period end date, or null if cannot be calculated
446
+ */
447
+ function calculatePeriodEnd(raw) {
448
+ const lastPayment = raw.billing_info?.last_payment?.time;
449
+
450
+ if (!lastPayment) {
451
+ return null;
452
+ }
453
+
454
+ const plan = raw._plan;
455
+ const regularCycle = plan?.billing_cycles?.find(c => c.tenure_type === 'REGULAR');
456
+
457
+ if (!regularCycle) {
458
+ return null;
459
+ }
460
+
461
+ const unit = regularCycle.frequency.interval_unit;
462
+ const count = regularCycle.frequency.interval_count || 1;
463
+ const lastDate = new Date(lastPayment);
464
+
465
+ if (unit === 'YEAR') {
466
+ lastDate.setFullYear(lastDate.getFullYear() + count);
467
+ } else if (unit === 'MONTH') {
468
+ lastDate.setMonth(lastDate.getMonth() + count);
469
+ } else if (unit === 'WEEK') {
470
+ lastDate.setDate(lastDate.getDate() + (count * 7));
471
+ } else if (unit === 'DAY') {
472
+ lastDate.setDate(lastDate.getDate() + count);
473
+ }
474
+
475
+ return lastDate;
476
+ }
477
+
411
478
  /**
412
479
  * Map PayPal subscription status to unified status
413
480
  *
@@ -415,7 +482,8 @@ function parseCustomId(customId) {
415
482
  * |------------------|----------------|
416
483
  * | ACTIVE | active |
417
484
  * | SUSPENDED | suspended |
418
- * | CANCELLED | cancelled |
485
+ * | CANCELLED (period remaining) | active (with cancellation.pending) |
486
+ * | CANCELLED (period ended) | cancelled |
419
487
  * | EXPIRED | cancelled |
420
488
  * | APPROVAL_PENDING | cancelled |
421
489
  * | APPROVED | active |
@@ -431,17 +499,47 @@ function resolveStatus(raw) {
431
499
  return 'suspended';
432
500
  }
433
501
 
434
- // CANCELLED, EXPIRED, APPROVAL_PENDING, or anything else
502
+ // CANCELLED check if user still has paid time remaining
503
+ if (status === 'CANCELLED') {
504
+ const periodEnd = calculatePeriodEnd(raw);
505
+
506
+ if (periodEnd && periodEnd > new Date()) {
507
+ // User still has access until period end — treat as active with pending cancellation
508
+ return 'active';
509
+ }
510
+
511
+ return 'cancelled';
512
+ }
513
+
514
+ // EXPIRED, APPROVAL_PENDING, or anything else
435
515
  return 'cancelled';
436
516
  }
437
517
 
438
518
  /**
439
519
  * Resolve cancellation state from PayPal subscription
520
+ *
521
+ * PayPal has no cancel_at_period_end like Stripe. When cancelled:
522
+ * - If billing period hasn't ended: pending=true, date=period end (user keeps access)
523
+ * - If billing period has ended: pending=false, date=cancellation time (fully cancelled)
440
524
  */
441
525
  function resolveCancellation(raw) {
442
526
  if (raw.status === 'CANCELLED') {
443
- // PayPal doesn't give a specific cancellation date on the sub itself
444
- // Use status_update_time if available
527
+ const periodEnd = calculatePeriodEnd(raw);
528
+
529
+ // Period still active — pending cancellation (user keeps access until period end)
530
+ if (periodEnd && periodEnd > new Date()) {
531
+ const periodEndStr = powertools.timestamp(periodEnd, { output: 'string' });
532
+
533
+ return {
534
+ pending: true,
535
+ date: {
536
+ timestamp: periodEndStr,
537
+ timestampUNIX: powertools.timestamp(periodEndStr, { output: 'unix' }),
538
+ },
539
+ };
540
+ }
541
+
542
+ // Period has ended — fully cancelled
445
543
  const cancelDate = raw.status_update_time
446
544
  ? powertools.timestamp(new Date(raw.status_update_time), { output: 'string' })
447
545
  : EPOCH_ZERO;
@@ -542,6 +640,7 @@ function resolveFrequency(raw) {
542
640
  /**
543
641
  * Resolve product by matching the PayPal product ID against config products
544
642
  * Uses: sub._plan.product_id → match config product.paypal.productId
643
+ * Also checks paypal.legacyProductIds[] for migrated products
545
644
  */
546
645
  function resolveProduct(raw, config) {
547
646
  // Get PayPal product ID from the plan (attached during fetchResource)
@@ -552,9 +651,15 @@ function resolveProduct(raw, config) {
552
651
  }
553
652
 
554
653
  for (const product of config.payment.products) {
654
+ // Check current product ID
555
655
  if (product.paypal?.productId === paypalProductId) {
556
656
  return { id: product.id, name: product.name || product.id };
557
657
  }
658
+
659
+ // Check legacy product IDs (for migrated products)
660
+ if (product.paypal?.legacyProductIds?.includes(paypalProductId)) {
661
+ return { id: product.id, name: product.name || product.id };
662
+ }
558
663
  }
559
664
 
560
665
  return { id: 'basic', name: 'Basic' };
@@ -579,9 +684,26 @@ function resolveProductOneTime(productId, config) {
579
684
 
580
685
  /**
581
686
  * Resolve subscription expiration from PayPal data
687
+ *
688
+ * For cancelled subs with remaining time, uses calculated period end.
689
+ * For active subs, uses next_billing_time.
582
690
  */
583
691
  function resolveExpires(raw) {
584
- // PayPal's billing_info.next_billing_time is the closest to "period end"
692
+ // Cancelled subs with remaining time use calculated period end
693
+ if (raw.status === 'CANCELLED') {
694
+ const periodEnd = calculatePeriodEnd(raw);
695
+
696
+ if (periodEnd && periodEnd > new Date()) {
697
+ const expiresStr = powertools.timestamp(periodEnd, { output: 'string' });
698
+
699
+ return {
700
+ timestamp: expiresStr,
701
+ timestampUNIX: powertools.timestamp(expiresStr, { output: 'unix' }),
702
+ };
703
+ }
704
+ }
705
+
706
+ // Active subs: PayPal's billing_info.next_billing_time is the closest to "period end"
585
707
  const nextBilling = raw.billing_info?.next_billing_time;
586
708
 
587
709
  if (!nextBilling) {
@@ -82,6 +82,36 @@ const Stripe = {
82
82
  return resource.metadata?.orderId || null;
83
83
  },
84
84
 
85
+ /**
86
+ * Extract the UID from a Stripe resource's metadata
87
+ * Used to resolve UID after fetchResource() for parity with PayPal
88
+ *
89
+ * @param {object} resource - Raw Stripe resource (subscription, session, invoice)
90
+ * @returns {string|null}
91
+ */
92
+ getUid(resource) {
93
+ return resource.metadata?.uid || null;
94
+ },
95
+
96
+ /**
97
+ * Extract refund details from a Stripe charge.refunded webhook payload
98
+ * Returns a unified shape so transition handlers stay processor-agnostic
99
+ *
100
+ * @param {object} raw - Raw Stripe webhook payload
101
+ * @returns {{ amount: string|null, currency: string, reason: string|null }}
102
+ */
103
+ getRefundDetails(raw) {
104
+ const charge = raw?.data?.object;
105
+ const amountCents = charge?.amount_refunded;
106
+ const latestRefund = charge?.refunds?.data?.[0];
107
+
108
+ return {
109
+ amount: amountCents ? (amountCents / 100).toFixed(2) : null,
110
+ currency: charge?.currency?.toUpperCase() || 'USD',
111
+ reason: latestRefund?.reason || null,
112
+ };
113
+ },
114
+
85
115
  /**
86
116
  * Transform a raw Stripe subscription object into the unified subscription shape
87
117
  * This produces the exact same object stored in users/{uid}.subscription
@@ -21,12 +21,17 @@ const Test = {
21
21
  * from Firestore instead of returning mismatched data.
22
22
  */
23
23
  async fetchResource(resourceType, resourceId, rawFallback, context) {
24
- // If the fallback matches the requested type, return it directly
25
- if (rawFallback?.object === resourceType) {
24
+ // If the fallback matches the requested type AND has a UID, return it directly
25
+ // When UID is missing (e.g., PAYMENT.SALE events), fall through to Firestore lookup
26
+ // so we can reconstruct a resource with the UID from the order's owner field
27
+ const fallbackHasUid = rawFallback?.metadata?.uid;
28
+ if (rawFallback?.object === resourceType && fallbackHasUid) {
26
29
  return rawFallback;
27
30
  }
28
31
 
29
- // Fallback doesn't match — try to look up the resource from payments-orders
32
+ // Look up the existing resource from payments-orders in Firestore
33
+ // This simulates what a real API call (Stripe/PayPal) would return — a resource
34
+ // with the full metadata including UID
30
35
  const admin = context?.admin;
31
36
  if (admin && resourceId) {
32
37
  const snapshot = await admin.firestore()
@@ -40,7 +45,15 @@ const Test = {
40
45
  // payments-orders stores the unified subscription inside .unified
41
46
  // Reconstruct a Stripe-shaped object from the unified data for toUnifiedSubscription()
42
47
  if (resourceType === 'subscription' && data.unified) {
43
- return buildStripeSubscriptionFromUnified(data.unified, resourceId, context?.eventType, context?.config);
48
+ const reconstructed = buildStripeSubscriptionFromUnified(data.unified, resourceId, context?.eventType, context?.config, data.owner);
49
+
50
+ // If the fallback matched the type but lacked UID, overlay the webhook's new state
51
+ // onto the reconstructed resource, but keep the reconstructed metadata (has uid)
52
+ if (rawFallback?.object === resourceType) {
53
+ return { ...rawFallback, metadata: { ...rawFallback.metadata, ...reconstructed.metadata } };
54
+ }
55
+
56
+ return reconstructed;
44
57
  }
45
58
  }
46
59
  }
@@ -56,6 +69,20 @@ const Test = {
56
69
  return Stripe.getOrderId(resource);
57
70
  },
58
71
 
72
+ /**
73
+ * Extract UID — delegates to Stripe (test processor uses Stripe-shaped data)
74
+ */
75
+ getUid(resource) {
76
+ return Stripe.getUid(resource);
77
+ },
78
+
79
+ /**
80
+ * Extract refund details — delegates to Stripe (test processor uses Stripe-shaped data)
81
+ */
82
+ getRefundDetails(raw) {
83
+ return Stripe.getRefundDetails(raw);
84
+ },
85
+
59
86
  /**
60
87
  * Transform raw subscription into unified shape
61
88
  * Delegates to Stripe's toUnifiedSubscription (same data shape), stamps processor as 'test'
@@ -87,7 +114,7 @@ module.exports = Test;
87
114
  * The unified → Stripe mapping must produce data that toUnifiedSubscription() can process correctly.
88
115
  * For payment failure events, we override the status to past_due so it maps to 'suspended'.
89
116
  */
90
- function buildStripeSubscriptionFromUnified(unified, resourceId, eventType, config) {
117
+ function buildStripeSubscriptionFromUnified(unified, resourceId, eventType, config, ownerUid) {
91
118
  // Map unified status back to a Stripe status
92
119
  const STATUS_MAP = {
93
120
  active: 'active',
@@ -120,7 +147,7 @@ function buildStripeSubscriptionFromUnified(unified, resourceId, eventType, conf
120
147
  id: resourceId,
121
148
  object: 'subscription',
122
149
  status: status,
123
- metadata: { orderId: unified.payment?.orderId || null },
150
+ metadata: { orderId: unified.payment?.orderId || null, uid: ownerUid || null },
124
151
  plan: {
125
152
  product: stripeProductId,
126
153
  interval: INTERVAL_MAP[frequency] || 'month',
@@ -20,6 +20,9 @@ const SUPPORTED_EVENTS = new Set([
20
20
 
21
21
  // Checkout completion (could be subscription or one-time)
22
22
  'checkout.session.completed',
23
+
24
+ // Refunds
25
+ 'charge.refunded',
23
26
  ]);
24
27
 
25
28
  module.exports = {
@@ -101,6 +104,30 @@ module.exports = {
101
104
  resourceId = dataObject.id;
102
105
  uid = dataObject.metadata?.uid || null;
103
106
  }
107
+
108
+ } else if (eventType === 'charge.refunded') {
109
+ // Refund event — the charge object contains an invoice ID which links to a subscription
110
+ const invoiceId = dataObject.invoice;
111
+ const subscriptionId = dataObject.subscription
112
+ || dataObject.metadata?.subscriptionId
113
+ || null;
114
+
115
+ if (subscriptionId) {
116
+ // Subscription-related refund
117
+ category = 'subscription';
118
+ resourceType = 'subscription';
119
+ resourceId = subscriptionId;
120
+ } else if (invoiceId) {
121
+ // Has invoice — likely subscription-related, will resolve via fetchResource
122
+ category = 'subscription';
123
+ resourceType = 'invoice';
124
+ resourceId = invoiceId;
125
+ } else {
126
+ // One-time payment refund — skip for now (no subscription to update)
127
+ category = null;
128
+ }
129
+
130
+ uid = dataObject.metadata?.uid || null;
104
131
  }
105
132
 
106
133
  return {
@@ -340,6 +340,36 @@ const JOURNEY_ACCOUNTS = {
340
340
  subscription: { product: { id: 'basic' }, status: 'active' },
341
341
  },
342
342
  },
343
+ // Journey: refund webhook transition (charge.refunded fires payment-refunded transition)
344
+ 'journey-payments-refund-webhook': {
345
+ id: 'journey-payments-refund-webhook',
346
+ uid: '_test-journey-payments-refund-webhook',
347
+ email: '_test.journey-payments-refund-webhook@{domain}',
348
+ properties: {
349
+ roles: {},
350
+ subscription: { product: { id: 'basic' }, status: 'active' },
351
+ },
352
+ },
353
+ // Journey: UID resolution fallback (webhook without uid in metadata, resolved from fetched resource)
354
+ 'journey-payments-uid-resolution': {
355
+ id: 'journey-payments-uid-resolution',
356
+ uid: '_test-journey-payments-uid-resolution',
357
+ email: '_test.journey-payments-uid-resolution@{domain}',
358
+ properties: {
359
+ roles: {},
360
+ subscription: { product: { id: 'basic' }, status: 'active' },
361
+ },
362
+ },
363
+ // Journey: legacy product ID resolution (webhook with legacy product ID maps to correct product)
364
+ 'journey-payments-legacy-product': {
365
+ id: 'journey-payments-legacy-product',
366
+ uid: '_test-journey-payments-legacy-product',
367
+ email: '_test.journey-payments-legacy-product@{domain}',
368
+ properties: {
369
+ roles: {},
370
+ subscription: { product: { id: 'basic' }, status: 'active' },
371
+ },
372
+ },
343
373
  };
344
374
 
345
375
  /**
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Test: Payment Journey - Legacy Product ID Resolution
3
+ * Simulates: webhook with a legacy Stripe product ID → resolves to the correct product
4
+ *
5
+ * Verifies Fix 4: when an existing subscriber's webhook carries a legacy product ID
6
+ * (from before product migration), resolveProduct() checks stripe.legacyProductIds[]
7
+ * and maps it to the correct current product.
8
+ *
9
+ * Flow:
10
+ * 1. Find a config product that has stripe.legacyProductIds (skip if none)
11
+ * 2. Send a customer.subscription.created webhook with the legacy product ID in plan.product
12
+ * 3. Verify: subscription.product.id resolves to the correct (current) product ID
13
+ *
14
+ * This test is config-dependent — it requires at least one product with legacyProductIds.
15
+ * If no such product exists, the test skips gracefully.
16
+ */
17
+ module.exports = {
18
+ description: 'Payment journey: webhook with legacy product ID → correct product resolution',
19
+ type: 'suite',
20
+ timeout: 30000,
21
+
22
+ tests: [
23
+ {
24
+ name: 'find-product-with-legacy-ids',
25
+ async run({ accounts, assert, state, config }) {
26
+ const uid = accounts['journey-payments-legacy-product'].uid;
27
+ state.uid = uid;
28
+
29
+ // Find a paid product that has legacy Stripe product IDs configured
30
+ const productWithLegacy = config.payment?.products?.find(
31
+ p => p.id !== 'basic'
32
+ && p.prices?.monthly
33
+ && p.stripe?.legacyProductIds?.length > 0,
34
+ );
35
+
36
+ if (!productWithLegacy) {
37
+ state.skip = true;
38
+ console.log('No product with stripe.legacyProductIds found in config — skipping legacy product ID test');
39
+ return;
40
+ }
41
+
42
+ state.skip = false;
43
+ state.productId = productWithLegacy.id;
44
+ state.productName = productWithLegacy.name;
45
+ state.currentStripeProductId = productWithLegacy.stripe.productId;
46
+ state.legacyStripeProductId = productWithLegacy.stripe.legacyProductIds[0];
47
+
48
+ assert.ok(state.legacyStripeProductId, 'Legacy product ID should exist');
49
+ assert.notEqual(state.legacyStripeProductId, state.currentStripeProductId, 'Legacy ID should differ from current ID');
50
+ },
51
+ },
52
+
53
+ {
54
+ name: 'send-webhook-with-legacy-product-id',
55
+ async run({ http, assert, state, config }) {
56
+ if (state.skip) {
57
+ return;
58
+ }
59
+
60
+ const futureDate = new Date();
61
+ futureDate.setMonth(futureDate.getMonth() + 1);
62
+
63
+ state.legacyEventId = `_test-evt-journey-legacy-prod-${Date.now()}`;
64
+ state.subscriptionId = `sub_test_legacy_${Date.now()}`;
65
+
66
+ // Send a subscription created webhook with the LEGACY product ID
67
+ // This simulates an existing subscriber whose Stripe subscription still
68
+ // references the old product ID from before migration
69
+ const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerKey}`, {
70
+ id: state.legacyEventId,
71
+ type: 'customer.subscription.created',
72
+ data: {
73
+ object: {
74
+ id: state.subscriptionId,
75
+ object: 'subscription',
76
+ status: 'active',
77
+ metadata: { uid: state.uid },
78
+ cancel_at_period_end: false,
79
+ canceled_at: null,
80
+ current_period_end: Math.floor(futureDate.getTime() / 1000),
81
+ current_period_start: Math.floor(Date.now() / 1000),
82
+ start_date: Math.floor(Date.now() / 1000),
83
+ trial_start: null,
84
+ trial_end: null,
85
+ // KEY: use the LEGACY product ID, not the current one
86
+ plan: { product: state.legacyStripeProductId, interval: 'month' },
87
+ },
88
+ },
89
+ });
90
+
91
+ assert.isSuccess(response, 'Webhook with legacy product ID should be accepted');
92
+ },
93
+ },
94
+
95
+ {
96
+ name: 'legacy-product-resolved-correctly',
97
+ async run({ firestore, assert, state, waitFor }) {
98
+ if (state.skip) {
99
+ return;
100
+ }
101
+
102
+ // Wait for webhook to complete
103
+ await waitFor(async () => {
104
+ const doc = await firestore.get(`payments-webhooks/${state.legacyEventId}`);
105
+ return doc?.status === 'completed' || doc?.status === 'failed';
106
+ }, 15000, 500);
107
+
108
+ const webhookDoc = await firestore.get(`payments-webhooks/${state.legacyEventId}`);
109
+ assert.equal(webhookDoc.status, 'completed', 'Webhook should complete successfully');
110
+ assert.equal(webhookDoc.transition, 'new-subscription', 'Transition should be new-subscription');
111
+
112
+ // Core assertion: the legacy product ID resolved to the correct current product
113
+ const userDoc = await firestore.get(`users/${state.uid}`);
114
+ assert.equal(
115
+ userDoc.subscription.product.id,
116
+ state.productId,
117
+ `Legacy product ID "${state.legacyStripeProductId}" should resolve to "${state.productId}" (not "basic")`,
118
+ );
119
+ assert.equal(userDoc.subscription.status, 'active', 'Status should be active');
120
+ assert.equal(userDoc.subscription.payment.processor, 'test', 'Processor should be test');
121
+ },
122
+ },
123
+
124
+ {
125
+ name: 'order-doc-has-correct-product',
126
+ async run({ firestore, assert, state }) {
127
+ if (state.skip) {
128
+ return;
129
+ }
130
+
131
+ // Find the order doc — legacy webhook may not have an orderId from metadata,
132
+ // so look it up by resourceId
133
+ const orderQuery = await firestore.query('payments-orders', {
134
+ where: [{ field: 'resourceId', op: '==', value: state.subscriptionId }],
135
+ limit: 1,
136
+ });
137
+
138
+ if (orderQuery && orderQuery.length > 0) {
139
+ const orderDoc = orderQuery[0];
140
+ assert.equal(
141
+ orderDoc.unified?.product?.id,
142
+ state.productId,
143
+ `Order unified product should be "${state.productId}"`,
144
+ );
145
+ }
146
+ },
147
+ },
148
+ ],
149
+ };
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Test: Payment Journey - Refund Webhook
3
+ * Simulates: basic → paid active → pending cancel → charge.refunded webhook → payment-refunded transition
4
+ *
5
+ * Verifies that refund webhook events:
6
+ * 1. Flow through the pipeline correctly
7
+ * 2. Trigger the payment-refunded transition (not subscription-cancelled)
8
+ * 3. Extract refundDetails via the processor library's getRefundDetails()
9
+ * 4. Record the transition name on the webhook doc for auditing
10
+ *
11
+ * Product-agnostic: resolves the first paid product from config.payment.products
12
+ */
13
+ module.exports = {
14
+ description: 'Payment journey: paid → refund webhook → payment-refunded transition',
15
+ type: 'suite',
16
+ timeout: 30000,
17
+
18
+ tests: [
19
+ {
20
+ name: 'setup-paid-subscription',
21
+ async run({ accounts, firestore, assert, state, config, http, waitFor }) {
22
+ const uid = accounts['journey-payments-refund-webhook'].uid;
23
+
24
+ const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices?.monthly);
25
+ assert.ok(paidProduct, 'Config should have at least one paid product with monthly price');
26
+
27
+ state.uid = uid;
28
+ state.paidProductId = paidProduct.id;
29
+ state.paidStripeProductId = paidProduct.stripe?.productId;
30
+
31
+ // Create subscription via test intent
32
+ const response = await http.as('journey-payments-refund-webhook').post('payments/intent', {
33
+ processor: 'test',
34
+ productId: paidProduct.id,
35
+ frequency: 'monthly',
36
+ });
37
+ assert.isSuccess(response, 'Intent should succeed');
38
+ state.orderId = response.data.orderId;
39
+
40
+ // Wait for subscription to activate
41
+ await waitFor(async () => {
42
+ const userDoc = await firestore.get(`users/${uid}`);
43
+ return userDoc?.subscription?.product?.id === paidProduct.id
44
+ && userDoc?.subscription?.status === 'active';
45
+ }, 15000, 500);
46
+
47
+ const userDoc = await firestore.get(`users/${uid}`);
48
+ assert.equal(userDoc.subscription?.product?.id, paidProduct.id, `Should be ${paidProduct.id}`);
49
+ assert.equal(userDoc.subscription?.status, 'active', 'Should be active');
50
+
51
+ state.subscriptionId = userDoc.subscription.payment.resourceId;
52
+ },
53
+ },
54
+
55
+ {
56
+ name: 'send-pending-cancel-webhook',
57
+ async run({ http, assert, state, config }) {
58
+ const futureDate = new Date();
59
+ futureDate.setMonth(futureDate.getMonth() + 1);
60
+
61
+ state.cancelEventId = `_test-evt-journey-refund-cancel-${Date.now()}`;
62
+
63
+ const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerKey}`, {
64
+ id: state.cancelEventId,
65
+ type: 'customer.subscription.updated',
66
+ data: {
67
+ object: {
68
+ id: state.subscriptionId,
69
+ object: 'subscription',
70
+ status: 'active',
71
+ metadata: { uid: state.uid },
72
+ cancel_at_period_end: true,
73
+ cancel_at: Math.floor(futureDate.getTime() / 1000),
74
+ canceled_at: null,
75
+ current_period_end: Math.floor(futureDate.getTime() / 1000),
76
+ current_period_start: Math.floor(Date.now() / 1000),
77
+ start_date: Math.floor(Date.now() / 1000) - 86400 * 30,
78
+ trial_start: null,
79
+ trial_end: null,
80
+ plan: { product: state.paidStripeProductId, interval: 'month' },
81
+ },
82
+ },
83
+ });
84
+
85
+ assert.isSuccess(response, 'Cancel webhook should be accepted');
86
+ },
87
+ },
88
+
89
+ {
90
+ name: 'cancellation-pending-confirmed',
91
+ async run({ firestore, assert, state, waitFor }) {
92
+ await waitFor(async () => {
93
+ const doc = await firestore.get(`payments-webhooks/${state.cancelEventId}`);
94
+ return doc?.status === 'completed';
95
+ }, 15000, 500);
96
+
97
+ const webhookDoc = await firestore.get(`payments-webhooks/${state.cancelEventId}`);
98
+ assert.equal(webhookDoc.transition, 'cancellation-requested', 'Should detect cancellation-requested');
99
+
100
+ const userDoc = await firestore.get(`users/${state.uid}`);
101
+ assert.equal(userDoc.subscription.status, 'active', 'Status should still be active');
102
+ assert.equal(userDoc.subscription.cancellation.pending, true, 'Cancellation should be pending');
103
+ },
104
+ },
105
+
106
+ {
107
+ name: 'send-charge-refunded-webhook',
108
+ async run({ http, assert, state, config }) {
109
+ // Simulate a charge.refunded event (Stripe-shaped, used by test processor)
110
+ // This carries refund amount data that getRefundDetails() extracts
111
+ state.refundEventId = `_test-evt-journey-refund-charge-${Date.now()}`;
112
+ state.refundAmountCents = 2800; // $28.00
113
+
114
+ const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerKey}`, {
115
+ id: state.refundEventId,
116
+ type: 'charge.refunded',
117
+ data: {
118
+ object: {
119
+ id: `ch_test_${Date.now()}`,
120
+ object: 'charge',
121
+ amount: state.refundAmountCents,
122
+ amount_refunded: state.refundAmountCents,
123
+ currency: 'usd',
124
+ subscription: state.subscriptionId,
125
+ metadata: { uid: state.uid },
126
+ refunds: {
127
+ data: [
128
+ {
129
+ id: `re_test_${Date.now()}`,
130
+ amount: state.refundAmountCents,
131
+ currency: 'usd',
132
+ reason: 'requested_by_customer',
133
+ },
134
+ ],
135
+ },
136
+ },
137
+ },
138
+ });
139
+
140
+ assert.isSuccess(response, 'Refund webhook should be accepted');
141
+ },
142
+ },
143
+
144
+ {
145
+ name: 'payment-refunded-transition-detected',
146
+ async run({ firestore, assert, state, waitFor }) {
147
+ // Wait for the refund webhook to be processed
148
+ await waitFor(async () => {
149
+ const doc = await firestore.get(`payments-webhooks/${state.refundEventId}`);
150
+ return doc?.status === 'completed';
151
+ }, 15000, 500);
152
+
153
+ const webhookDoc = await firestore.get(`payments-webhooks/${state.refundEventId}`);
154
+
155
+ // Core assertion: refund events trigger payment-refunded, not subscription-cancelled
156
+ assert.equal(webhookDoc.status, 'completed', 'Webhook should complete successfully');
157
+ assert.equal(webhookDoc.transition, 'payment-refunded', 'Transition should be payment-refunded (not subscription-cancelled)');
158
+ assert.equal(webhookDoc.owner, state.uid, 'Owner should match');
159
+ assert.equal(webhookDoc.orderId, state.orderId, 'Order ID should match');
160
+ },
161
+ },
162
+
163
+ {
164
+ name: 'order-doc-updated',
165
+ async run({ firestore, assert, state }) {
166
+ const orderDoc = await firestore.get(`payments-orders/${state.orderId}`);
167
+
168
+ assert.ok(orderDoc, 'Order doc should exist');
169
+ assert.equal(orderDoc.type, 'subscription', 'Type should be subscription');
170
+ assert.equal(orderDoc.owner, state.uid, 'Owner should match');
171
+
172
+ // The order was last updated by the refund webhook event
173
+ assert.equal(orderDoc.metadata?.updatedBy?.event?.name, 'charge.refunded', 'Last event should be charge.refunded');
174
+ },
175
+ },
176
+ ],
177
+ };
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Test: Payment Journey - UID Resolution Fallback
3
+ * Simulates: paid active subscription → webhook with uid OMITTED from metadata
4
+ *
5
+ * Verifies Fix 1: when a webhook event doesn't carry uid in metadata (like PayPal's
6
+ * PAYMENT.SALE events), the pipeline resolves uid from the fetched resource using
7
+ * library.getUid() and persists it on the webhook doc.
8
+ *
9
+ * Flow:
10
+ * 1. Set up paid subscription via test intent (establishes payments-orders doc with resourceId)
11
+ * 2. Send customer.subscription.updated webhook WITHOUT uid in metadata
12
+ * 3. Verify: webhook completes (not fails), uid resolved, subscription updated
13
+ *
14
+ * Product-agnostic: resolves the first paid product from config.payment.products
15
+ */
16
+ module.exports = {
17
+ description: 'Payment journey: webhook without uid → UID resolved from fetched resource',
18
+ type: 'suite',
19
+ timeout: 30000,
20
+
21
+ tests: [
22
+ {
23
+ name: 'setup-paid-subscription',
24
+ async run({ accounts, firestore, assert, state, config, http, waitFor }) {
25
+ const uid = accounts['journey-payments-uid-resolution'].uid;
26
+
27
+ const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices?.monthly);
28
+ assert.ok(paidProduct, 'Config should have at least one paid product with monthly price');
29
+
30
+ state.uid = uid;
31
+ state.paidProductId = paidProduct.id;
32
+ state.paidStripeProductId = paidProduct.stripe?.productId;
33
+
34
+ // Create subscription via test intent
35
+ const response = await http.as('journey-payments-uid-resolution').post('payments/intent', {
36
+ processor: 'test',
37
+ productId: paidProduct.id,
38
+ frequency: 'monthly',
39
+ });
40
+ assert.isSuccess(response, 'Intent should succeed');
41
+ state.orderId = response.data.orderId;
42
+
43
+ // Wait for subscription to activate
44
+ await waitFor(async () => {
45
+ const userDoc = await firestore.get(`users/${uid}`);
46
+ return userDoc?.subscription?.product?.id === paidProduct.id
47
+ && userDoc?.subscription?.status === 'active';
48
+ }, 15000, 500);
49
+
50
+ const userDoc = await firestore.get(`users/${uid}`);
51
+ state.subscriptionId = userDoc.subscription.payment.resourceId;
52
+
53
+ assert.ok(state.subscriptionId, 'Subscription resource ID should exist');
54
+ },
55
+ },
56
+
57
+ {
58
+ name: 'send-webhook-without-uid',
59
+ async run({ http, assert, state, config }) {
60
+ // Send a subscription update webhook WITHOUT uid in metadata
61
+ // The test processor's fetchResource() will look up payments-orders by resourceId
62
+ // and reconstruct a Stripe-shaped subscription that includes metadata.uid
63
+ // Then library.getUid() extracts uid from the fetched resource
64
+ const futureDate = new Date();
65
+ futureDate.setMonth(futureDate.getMonth() + 1);
66
+
67
+ state.noUidEventId = `_test-evt-journey-uid-resolve-${Date.now()}`;
68
+
69
+ const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerKey}`, {
70
+ id: state.noUidEventId,
71
+ type: 'customer.subscription.updated',
72
+ data: {
73
+ object: {
74
+ id: state.subscriptionId,
75
+ object: 'subscription',
76
+ status: 'active',
77
+ // NO metadata.uid — this is the key part of the test
78
+ metadata: {},
79
+ cancel_at_period_end: true,
80
+ cancel_at: Math.floor(futureDate.getTime() / 1000),
81
+ canceled_at: null,
82
+ current_period_end: Math.floor(futureDate.getTime() / 1000),
83
+ current_period_start: Math.floor(Date.now() / 1000),
84
+ start_date: Math.floor(Date.now() / 1000) - 86400 * 30,
85
+ trial_start: null,
86
+ trial_end: null,
87
+ plan: { product: state.paidStripeProductId, interval: 'month' },
88
+ },
89
+ },
90
+ });
91
+
92
+ assert.isSuccess(response, 'Webhook should be accepted (even without uid)');
93
+ },
94
+ },
95
+
96
+ {
97
+ name: 'uid-resolved-and-webhook-completed',
98
+ async run({ firestore, assert, state, waitFor }) {
99
+ // Wait for the webhook to be processed
100
+ await waitFor(async () => {
101
+ const doc = await firestore.get(`payments-webhooks/${state.noUidEventId}`);
102
+ return doc?.status === 'completed' || doc?.status === 'failed';
103
+ }, 15000, 500);
104
+
105
+ const webhookDoc = await firestore.get(`payments-webhooks/${state.noUidEventId}`);
106
+
107
+ // Core assertions: UID was resolved from the fetched resource
108
+ assert.equal(webhookDoc.status, 'completed', 'Webhook should complete (not fail due to missing UID)');
109
+ assert.equal(webhookDoc.owner, state.uid, 'Owner should be resolved to the correct UID');
110
+ assert.equal(webhookDoc.orderId, state.orderId, 'Order ID should match');
111
+
112
+ // Transition should be cancellation-requested (we sent cancel_at_period_end: true)
113
+ assert.equal(webhookDoc.transition, 'cancellation-requested', 'Transition should be cancellation-requested');
114
+ },
115
+ },
116
+
117
+ {
118
+ name: 'subscription-updated-correctly',
119
+ async run({ firestore, assert, state }) {
120
+ const userDoc = await firestore.get(`users/${state.uid}`);
121
+
122
+ // The webhook should have updated the user doc even though uid wasn't in the webhook metadata
123
+ assert.equal(userDoc.subscription.status, 'active', 'Status should still be active');
124
+ assert.equal(userDoc.subscription.cancellation.pending, true, 'Cancellation should be pending');
125
+ assert.equal(userDoc.subscription.product.id, state.paidProductId, `Product should be ${state.paidProductId}`);
126
+ },
127
+ },
128
+ ],
129
+ };