backend-manager 5.0.113 → 5.0.115

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.113",
3
+ "version": "5.0.115",
4
4
  "description": "Quick tools for developing Firebase functions",
5
5
  "main": "src/manager/index.js",
6
6
  "bin": {
@@ -56,11 +56,11 @@ class LogsCommand extends BaseCommand {
56
56
 
57
57
  /**
58
58
  * Fetch historical logs.
59
- * Usage: npx bm logs:read [--fn bm_api] [--severity ERROR] [--since 1h] [--limit 50]
59
+ * Usage: npx bm logs:read [--fn bm_api] [--severity ERROR] [--since 1h] [--limit 300]
60
60
  */
61
61
  async read(projectId, argv) {
62
62
  const filter = this.buildFilter(argv);
63
- const limit = parseInt(argv.limit, 10) || 50;
63
+ const limit = parseInt(argv.limit, 10) || 300;
64
64
 
65
65
  const cmd = [
66
66
  'gcloud', 'logging', 'read',
@@ -88,8 +88,8 @@ class LogsCommand extends BaseCommand {
88
88
 
89
89
  const entries = JSON.parse(output || '[]');
90
90
 
91
- // Save raw JSON to log file for Claude/tooling to read
92
- jetpack.write(logPath, JSON.stringify(entries, null, 2));
91
+ // Save as newline-delimited JSON (matches tail format)
92
+ jetpack.write(logPath, entries.map(e => JSON.stringify(e)).join('\n'));
93
93
 
94
94
  if (entries.length === 0) {
95
95
  this.logWarning('No log entries found.');
@@ -2,8 +2,8 @@
2
2
  * Data requests cron job
3
3
  *
4
4
  * Processes data request status transitions:
5
- * - pending → complete: 14 days after creation
6
- * - complete → expired: 30 days after becoming complete (44 days after creation)
5
+ * - pending → completed: 14 days after creation
6
+ * - completed → expired: 30 days after becoming completed (44 days after creation)
7
7
  *
8
8
  * Scans the entire collection (no index required) since data-requests is small.
9
9
  */
@@ -33,7 +33,7 @@ module.exports = async ({ Manager, assistant, context, libraries }) => {
33
33
  const age = nowUNIX - createdUNIX;
34
34
 
35
35
  if (data.status === 'pending' && age >= FOURTEEN_DAYS) {
36
- await doc.ref.update({ status: 'complete' })
36
+ await doc.ref.update({ status: 'completed' })
37
37
  .then(() => {
38
38
  completed++;
39
39
  assistant.log(`Completed request ${doc.id} (age: ${Math.round(age / 86400)}d)`);
@@ -41,7 +41,7 @@ module.exports = async ({ Manager, assistant, context, libraries }) => {
41
41
  .catch((e) => {
42
42
  assistant.error(`Failed to complete request ${doc.id}: ${e.message}`);
43
43
  });
44
- } else if (data.status === 'complete' && age >= FORTY_FOUR_DAYS) {
44
+ } else if (data.status === 'completed' && age >= FORTY_FOUR_DAYS) {
45
45
  await doc.ref.update({ status: 'expired' })
46
46
  .then(() => {
47
47
  expired++;
@@ -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,10 +65,28 @@ 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' });
76
- const webhookReceivedUNIX = dataAfter.metadata?.received?.timestampUNIX || nowUNIX;
89
+ const webhookReceivedUNIX = dataAfter.metadata?.created?.timestampUNIX || nowUNIX;
77
90
 
78
91
  // Extract orderId from resource (processor-agnostic)
79
92
  orderId = library.getOrderId(resource);
@@ -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({
@@ -92,7 +105,7 @@ module.exports = async ({ assistant, change, context }) => {
92
105
  orderId: orderId,
93
106
  transition: transitionName,
94
107
  metadata: {
95
- processed: {
108
+ completed: {
96
109
  timestamp: now,
97
110
  timestampUNIX: nowUNIX,
98
111
  },
@@ -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';
@@ -186,6 +199,7 @@ async function processPaymentEvent({ category, library, resource, resourceType,
186
199
  id: orderId,
187
200
  type: category,
188
201
  owner: uid,
202
+ productId: unified.product.id,
189
203
  processor: processor,
190
204
  resourceId: resourceId,
191
205
  unified: unified,
@@ -215,8 +229,13 @@ async function processPaymentEvent({ category, library, resource, resourceType,
215
229
  assistant.log(`Transition detected: ${category}/${transitionName} (before.status=${before?.status || 'null'}, after.status=${unified.status})`);
216
230
 
217
231
  if (shouldRunHandlers) {
232
+ // Extract unified refund details from the processor library (keeps handlers processor-agnostic)
233
+ const refundDetails = (transitionName === 'payment-refunded' && library.getRefundDetails)
234
+ ? library.getRefundDetails(raw)
235
+ : null;
236
+
218
237
  transitions.dispatch(transitionName, category, {
219
- before, after: unified, order, uid, userDoc: userData, assistant,
238
+ before, after: unified, order, uid, userDoc: userData, assistant, refundDetails,
220
239
  });
221
240
  } else {
222
241
  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
+ };
@@ -268,7 +268,7 @@ const PayPal = {
268
268
 
269
269
  return {
270
270
  product: product,
271
- status: rawResource.status === 'COMPLETED' ? 'complete' : rawResource.status?.toLowerCase() || 'unknown',
271
+ status: rawResource.status === 'COMPLETED' ? 'completed' : rawResource.status?.toLowerCase() || 'unknown',
272
272
  payment: {
273
273
  processor: 'paypal',
274
274
  orderId: customData.orderId || null,
@@ -314,6 +314,7 @@ const PayPal = {
314
314
  }
315
315
 
316
316
  // Fetch plans with full details (Prefer header includes billing_cycles in list response)
317
+ // TODO: Paginate — page_size=20 only returns first page. Fine for now (each product has ~2-4 plans in 1:1 model) but will break if a product ever accumulates >20 plans.
317
318
  const response = await this.request(`/v1/billing/plans?product_id=${paypalProductId}&page_size=20&total_required=true`, {
318
319
  headers: { 'Prefer': 'return=representation' },
319
320
  });
@@ -358,6 +359,35 @@ const PayPal = {
358
359
  return customData.orderId || null;
359
360
  },
360
361
 
362
+ /**
363
+ * Extract the UID from a PayPal resource's custom_id
364
+ * Used to resolve UID after fetchResource() for events like PAYMENT.SALE
365
+ * where the initial webhook payload doesn't carry custom_id
366
+ *
367
+ * @param {object} resource - Raw PayPal resource (subscription or order)
368
+ * @returns {string|null}
369
+ */
370
+ getUid(resource) {
371
+ const purchaseCustomId = resource.purchase_units?.[0]?.custom_id;
372
+ const customData = parseCustomId(purchaseCustomId || resource.custom_id);
373
+ return customData?.uid || null;
374
+ },
375
+
376
+ /**
377
+ * Extract refund details from a PayPal PAYMENT.SALE.REFUNDED webhook payload
378
+ * Returns a unified shape so transition handlers stay processor-agnostic
379
+ *
380
+ * @param {object} raw - Raw PayPal webhook payload
381
+ * @returns {{ amount: string|null, currency: string, reason: string|null }}
382
+ */
383
+ getRefundDetails(raw) {
384
+ return {
385
+ amount: raw?.resource?.amount?.total || raw?.resource?.total_refunded_amount?.value || null,
386
+ currency: raw?.resource?.amount?.currency || 'USD',
387
+ reason: raw?.resource?.reason_code || null,
388
+ };
389
+ },
390
+
361
391
  /**
362
392
  * Build the custom_id string for PayPal subscriptions and orders
363
393
  * Format: uid:{uid},orderId:{orderId} or uid:{uid},orderId:{orderId},productId:{productId}
@@ -408,6 +438,44 @@ function parseCustomId(customId) {
408
438
  return result;
409
439
  }
410
440
 
441
+ /**
442
+ * Calculate when the current billing period ends based on last payment + interval
443
+ * Used for cancelled subs to determine remaining access time
444
+ *
445
+ * @param {object} raw - Raw PayPal subscription (with _plan attached)
446
+ * @returns {Date|null} Period end date, or null if cannot be calculated
447
+ */
448
+ function calculatePeriodEnd(raw) {
449
+ const lastPayment = raw.billing_info?.last_payment?.time;
450
+
451
+ if (!lastPayment) {
452
+ return null;
453
+ }
454
+
455
+ const plan = raw._plan;
456
+ const regularCycle = plan?.billing_cycles?.find(c => c.tenure_type === 'REGULAR');
457
+
458
+ if (!regularCycle) {
459
+ return null;
460
+ }
461
+
462
+ const unit = regularCycle.frequency.interval_unit;
463
+ const count = regularCycle.frequency.interval_count || 1;
464
+ const lastDate = new Date(lastPayment);
465
+
466
+ if (unit === 'YEAR') {
467
+ lastDate.setFullYear(lastDate.getFullYear() + count);
468
+ } else if (unit === 'MONTH') {
469
+ lastDate.setMonth(lastDate.getMonth() + count);
470
+ } else if (unit === 'WEEK') {
471
+ lastDate.setDate(lastDate.getDate() + (count * 7));
472
+ } else if (unit === 'DAY') {
473
+ lastDate.setDate(lastDate.getDate() + count);
474
+ }
475
+
476
+ return lastDate;
477
+ }
478
+
411
479
  /**
412
480
  * Map PayPal subscription status to unified status
413
481
  *
@@ -415,7 +483,8 @@ function parseCustomId(customId) {
415
483
  * |------------------|----------------|
416
484
  * | ACTIVE | active |
417
485
  * | SUSPENDED | suspended |
418
- * | CANCELLED | cancelled |
486
+ * | CANCELLED (period remaining) | active (with cancellation.pending) |
487
+ * | CANCELLED (period ended) | cancelled |
419
488
  * | EXPIRED | cancelled |
420
489
  * | APPROVAL_PENDING | cancelled |
421
490
  * | APPROVED | active |
@@ -431,17 +500,47 @@ function resolveStatus(raw) {
431
500
  return 'suspended';
432
501
  }
433
502
 
434
- // CANCELLED, EXPIRED, APPROVAL_PENDING, or anything else
503
+ // CANCELLED check if user still has paid time remaining
504
+ if (status === 'CANCELLED') {
505
+ const periodEnd = calculatePeriodEnd(raw);
506
+
507
+ if (periodEnd && periodEnd > new Date()) {
508
+ // User still has access until period end — treat as active with pending cancellation
509
+ return 'active';
510
+ }
511
+
512
+ return 'cancelled';
513
+ }
514
+
515
+ // EXPIRED, APPROVAL_PENDING, or anything else
435
516
  return 'cancelled';
436
517
  }
437
518
 
438
519
  /**
439
520
  * Resolve cancellation state from PayPal subscription
521
+ *
522
+ * PayPal has no cancel_at_period_end like Stripe. When cancelled:
523
+ * - If billing period hasn't ended: pending=true, date=period end (user keeps access)
524
+ * - If billing period has ended: pending=false, date=cancellation time (fully cancelled)
440
525
  */
441
526
  function resolveCancellation(raw) {
442
527
  if (raw.status === 'CANCELLED') {
443
- // PayPal doesn't give a specific cancellation date on the sub itself
444
- // Use status_update_time if available
528
+ const periodEnd = calculatePeriodEnd(raw);
529
+
530
+ // Period still active — pending cancellation (user keeps access until period end)
531
+ if (periodEnd && periodEnd > new Date()) {
532
+ const periodEndStr = powertools.timestamp(periodEnd, { output: 'string' });
533
+
534
+ return {
535
+ pending: true,
536
+ date: {
537
+ timestamp: periodEndStr,
538
+ timestampUNIX: powertools.timestamp(periodEndStr, { output: 'unix' }),
539
+ },
540
+ };
541
+ }
542
+
543
+ // Period has ended — fully cancelled
445
544
  const cancelDate = raw.status_update_time
446
545
  ? powertools.timestamp(new Date(raw.status_update_time), { output: 'string' })
447
546
  : EPOCH_ZERO;
@@ -542,6 +641,7 @@ function resolveFrequency(raw) {
542
641
  /**
543
642
  * Resolve product by matching the PayPal product ID against config products
544
643
  * Uses: sub._plan.product_id → match config product.paypal.productId
644
+ * Also checks paypal.legacyProductIds[] for migrated products
545
645
  */
546
646
  function resolveProduct(raw, config) {
547
647
  // Get PayPal product ID from the plan (attached during fetchResource)
@@ -552,9 +652,15 @@ function resolveProduct(raw, config) {
552
652
  }
553
653
 
554
654
  for (const product of config.payment.products) {
655
+ // Check current product ID
555
656
  if (product.paypal?.productId === paypalProductId) {
556
657
  return { id: product.id, name: product.name || product.id };
557
658
  }
659
+
660
+ // Check legacy product IDs (for migrated products)
661
+ if (product.paypal?.legacyProductIds?.includes(paypalProductId)) {
662
+ return { id: product.id, name: product.name || product.id };
663
+ }
558
664
  }
559
665
 
560
666
  return { id: 'basic', name: 'Basic' };
@@ -579,9 +685,26 @@ function resolveProductOneTime(productId, config) {
579
685
 
580
686
  /**
581
687
  * Resolve subscription expiration from PayPal data
688
+ *
689
+ * For cancelled subs with remaining time, uses calculated period end.
690
+ * For active subs, uses next_billing_time.
582
691
  */
583
692
  function resolveExpires(raw) {
584
- // PayPal's billing_info.next_billing_time is the closest to "period end"
693
+ // Cancelled subs with remaining time use calculated period end
694
+ if (raw.status === 'CANCELLED') {
695
+ const periodEnd = calculatePeriodEnd(raw);
696
+
697
+ if (periodEnd && periodEnd > new Date()) {
698
+ const expiresStr = powertools.timestamp(periodEnd, { output: 'string' });
699
+
700
+ return {
701
+ timestamp: expiresStr,
702
+ timestampUNIX: powertools.timestamp(expiresStr, { output: 'unix' }),
703
+ };
704
+ }
705
+ }
706
+
707
+ // Active subs: PayPal's billing_info.next_billing_time is the closest to "period end"
585
708
  const nextBilling = raw.billing_info?.next_billing_time;
586
709
 
587
710
  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
@@ -274,7 +304,7 @@ const Stripe = {
274
304
 
275
305
  return {
276
306
  product: product,
277
- status: rawResource.status || 'unknown',
307
+ status: rawResource.status === 'complete' ? 'completed' : rawResource.status || 'unknown',
278
308
  payment: {
279
309
  processor: 'stripe',
280
310
  orderId: rawResource.metadata?.orderId || null,
@@ -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',
@@ -116,7 +116,7 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
116
116
  owner: uid,
117
117
  status: 'pending',
118
118
  productId: productId,
119
- productType: productType,
119
+ type: productType,
120
120
  frequency: frequency,
121
121
  trial: trial,
122
122
  raw: result.raw,
@@ -91,11 +91,11 @@ module.exports = async ({ assistant, Manager, libraries }) => {
91
91
  },
92
92
  error: null,
93
93
  metadata: {
94
- received: {
94
+ created: {
95
95
  timestamp: now,
96
96
  timestampUNIX: nowUNIX,
97
97
  },
98
- processed: {
98
+ completed: {
99
99
  timestamp: null,
100
100
  timestampUNIX: null,
101
101
  },
@@ -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 {
@@ -2,11 +2,11 @@
2
2
  * GET /user/data-request - Check data request status or download data
3
3
  *
4
4
  * action=status (default): Returns the most recent request with its stored status.
5
- * action=download: Compiles user data live and returns it. Only works when status is 'complete'.
5
+ * action=download: Compiles user data live and returns it. Only works when status is 'completed'.
6
6
  *
7
7
  * Statuses:
8
- * pending — request submitted, waiting to be processed (bm_cronDaily sets to 'complete' after 14 days)
9
- * complete — data is available for download (downloads counter tracks how many times downloaded)
8
+ * pending — request submitted, waiting to be processed (bm_cronDaily sets to 'completed' after 14 days)
9
+ * completed — data is available for download (downloads counter tracks how many times downloaded)
10
10
  */
11
11
  module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
12
12
  const { admin } = libraries;
@@ -43,8 +43,8 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
43
43
  });
44
44
  }
45
45
 
46
- // Download action — only allowed when status is 'complete'
47
- if (status !== 'complete') {
46
+ // Download action — only allowed when status is 'completed'
47
+ if (status !== 'completed') {
48
48
  return assistant.respond('Your data request is still being processed. Please check back later.', { code: 400 });
49
49
  }
50
50
 
@@ -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
+ };
@@ -45,7 +45,7 @@ module.exports = {
45
45
  name: 'status-completed',
46
46
  async run({ assert }) {
47
47
  const result = toUnifiedOneTime({ status: 'COMPLETED' });
48
- assert.equal(result.status, 'complete', 'PayPal COMPLETED → unified complete');
48
+ assert.equal(result.status, 'completed', 'PayPal COMPLETED → unified completed');
49
49
  },
50
50
  },
51
51
 
@@ -250,7 +250,7 @@ module.exports = {
250
250
  assert.ok(result.payment, 'Should have payment');
251
251
 
252
252
  assert.equal(result.product.id, 'credits-100', 'Product should be credits-100');
253
- assert.equal(result.status, 'complete', 'Status should be complete');
253
+ assert.equal(result.status, 'completed', 'Status should be completed');
254
254
  assert.equal(result.payment.processor, 'paypal', 'Processor should be paypal');
255
255
  assert.equal(result.payment.resourceId, 'PAYID-FULL', 'Resource ID should match');
256
256
  assert.equal(result.payment.orderId, '1234-5678-9012', 'orderId should match');
@@ -336,7 +336,7 @@ module.exports = {
336
336
  async run({ assert }) {
337
337
  const result = toUnifiedOneTime(FIXTURE_ORDER_COMPLETED);
338
338
 
339
- assert.equal(result.status, 'complete', 'COMPLETED fixture → complete');
339
+ assert.equal(result.status, 'completed', 'COMPLETED fixture → completed');
340
340
  assert.equal(result.payment.processor, 'paypal', 'Processor is paypal');
341
341
  assert.equal(result.payment.resourceId, '5UX02069M9686893E', 'Resource ID from fixture');
342
342
  assert.equal(result.payment.orderId, 'ord-test-456', 'orderId from purchase_units custom_id');
@@ -44,7 +44,7 @@ module.exports = {
44
44
  name: 'status-complete',
45
45
  async run({ assert }) {
46
46
  const result = toUnifiedOneTime({ status: 'complete' });
47
- assert.equal(result.status, 'complete', 'Status passes through as-is');
47
+ assert.equal(result.status, 'completed', 'Stripe complete unified completed');
48
48
  },
49
49
  },
50
50
 
@@ -211,7 +211,7 @@ module.exports = {
211
211
  assert.ok(result.payment, 'Should have payment');
212
212
 
213
213
  assert.equal(result.product.id, 'credits-100', 'Product should be credits-100');
214
- assert.equal(result.status, 'complete', 'Status should be complete');
214
+ assert.equal(result.status, 'completed', 'Status should be completed');
215
215
  assert.equal(result.payment.processor, 'stripe', 'Processor should be stripe');
216
216
  assert.equal(result.payment.resourceId, 'cs_test_full', 'Resource ID should match');
217
217
  assert.equal(result.payment.orderId, '1234-5678-9012', 'orderId should match');
@@ -253,7 +253,7 @@ module.exports = {
253
253
  const result = toUnifiedOneTime(FIXTURE_SESSION);
254
254
 
255
255
  assert.ok(result.product, 'Should have product');
256
- assert.equal(result.status, 'complete', 'Real session fixture → complete');
256
+ assert.equal(result.status, 'completed', 'Real session fixture → completed');
257
257
  assert.equal(result.payment.processor, 'stripe', 'Processor is stripe');
258
258
  assert.equal(result.payment.resourceId, FIXTURE_SESSION.id, 'resourceId matches fixture ID');
259
259
  },