backend-manager 5.0.88 → 5.0.91

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.
Files changed (39) hide show
  1. package/CLAUDE.md +133 -2
  2. package/README.md +1 -1
  3. package/package.json +5 -3
  4. package/src/cli/index.js +11 -0
  5. package/src/manager/events/firestore/payments-webhooks/analytics.js +170 -0
  6. package/src/manager/events/firestore/payments-webhooks/on-write.js +75 -315
  7. package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-completed.js +20 -10
  8. package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-failed.js +4 -8
  9. package/src/manager/events/firestore/payments-webhooks/transitions/send-email.js +67 -0
  10. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/cancellation-requested.js +23 -9
  11. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/new-subscription.js +22 -8
  12. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-failed.js +19 -8
  13. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-recovered.js +19 -7
  14. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/plan-changed.js +27 -8
  15. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/subscription-cancelled.js +25 -9
  16. package/src/manager/helpers/user.js +2 -0
  17. package/src/manager/libraries/payment-processors/order-id.js +18 -0
  18. package/src/manager/libraries/payment-processors/resolve-price-id.js +19 -0
  19. package/src/manager/libraries/payment-processors/stripe.js +88 -47
  20. package/src/manager/libraries/payment-processors/test.js +14 -11
  21. package/src/manager/routes/payments/intent/post.js +61 -7
  22. package/src/manager/routes/payments/intent/processors/stripe.js +18 -50
  23. package/src/manager/routes/payments/intent/processors/test.js +18 -22
  24. package/src/manager/routes/payments/webhook/post.js +1 -1
  25. package/src/test/runner.js +11 -0
  26. package/src/test/test-accounts.js +20 -2
  27. package/templates/backend-manager-config.json +31 -12
  28. package/test/events/payments/journey-payments-cancel.js +2 -0
  29. package/test/events/payments/journey-payments-failure.js +2 -0
  30. package/test/events/payments/journey-payments-one-time-failure.js +105 -0
  31. package/test/events/payments/journey-payments-one-time.js +128 -0
  32. package/test/events/payments/journey-payments-plan-change.js +126 -0
  33. package/test/events/payments/journey-payments-suspend.js +2 -0
  34. package/test/events/payments/journey-payments-trial.js +4 -0
  35. package/test/events/payments/journey-payments-upgrade.js +20 -10
  36. package/test/helpers/stripe-to-unified.js +17 -0
  37. package/test/helpers/user.js +1 -0
  38. package/test/routes/payments/intent.js +10 -7
  39. /package/bin/{bem → backend-manager} +0 -0
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Test: Payment Journey - Plan Change
3
+ * Simulates: basic → paid product A → plan-changed webhook → paid product B
4
+ *
5
+ * Uses test intent for initial subscription, then manual webhook to change plans.
6
+ * Requires at least two paid subscription products in config.
7
+ */
8
+ module.exports = {
9
+ description: 'Payment journey: paid product A → plan-changed → paid product B',
10
+ type: 'suite',
11
+ timeout: 30000,
12
+
13
+ tests: [
14
+ {
15
+ name: 'setup-paid-subscription',
16
+ async run({ accounts, firestore, assert, state, config, http, waitFor }) {
17
+ const uid = accounts['journey-payments-plan-change'].uid;
18
+
19
+ // Resolve two distinct paid subscription products from config
20
+ const paidProducts = config.payment.products.filter(p => p.id !== 'basic' && p.type === 'subscription' && p.prices);
21
+ assert.ok(paidProducts.length >= 2, 'Config should have at least two paid subscription products');
22
+
23
+ const productA = paidProducts[0];
24
+ const productB = paidProducts[1];
25
+
26
+ state.uid = uid;
27
+ state.productA = { id: productA.id, name: productA.name, priceId: productA.prices.monthly.stripe };
28
+ state.productB = { id: productB.id, name: productB.name, priceId: productB.prices.monthly.stripe };
29
+
30
+ // Create subscription via test intent (product A)
31
+ const response = await http.as('journey-payments-plan-change').post('payments/intent', {
32
+ processor: 'test',
33
+ productId: productA.id,
34
+ frequency: 'monthly',
35
+ });
36
+ assert.isSuccess(response, 'Intent should succeed');
37
+ state.orderId = response.data.orderId;
38
+
39
+ // Wait for subscription to activate
40
+ await waitFor(async () => {
41
+ const userDoc = await firestore.get(`users/${uid}`);
42
+ return userDoc?.subscription?.product?.id === productA.id;
43
+ }, 15000, 500);
44
+
45
+ const userDoc = await firestore.get(`users/${uid}`);
46
+ assert.equal(userDoc.subscription?.product?.id, productA.id, `Should start as ${productA.id}`);
47
+ assert.equal(userDoc.subscription?.status, 'active', 'Should be active');
48
+
49
+ state.subscriptionId = userDoc.subscription.payment.resourceId;
50
+ },
51
+ },
52
+
53
+ {
54
+ name: 'send-plan-change-webhook',
55
+ async run({ http, assert, state, config }) {
56
+ const futureDate = new Date();
57
+ futureDate.setMonth(futureDate.getMonth() + 1);
58
+
59
+ state.eventId = `_test-evt-journey-plan-change-${Date.now()}`;
60
+
61
+ // Send subscription.updated with a different product's price ID
62
+ const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerKey}`, {
63
+ id: state.eventId,
64
+ type: 'customer.subscription.updated',
65
+ data: {
66
+ object: {
67
+ id: state.subscriptionId,
68
+ object: 'subscription',
69
+ status: 'active',
70
+ metadata: { uid: state.uid, orderId: state.orderId },
71
+ cancel_at_period_end: false,
72
+ canceled_at: null,
73
+ current_period_end: Math.floor(futureDate.getTime() / 1000),
74
+ current_period_start: Math.floor(Date.now() / 1000),
75
+ start_date: Math.floor(Date.now() / 1000) - 86400 * 30,
76
+ trial_start: null,
77
+ trial_end: null,
78
+ plan: { id: state.productB.priceId, interval: 'month' },
79
+ },
80
+ },
81
+ });
82
+
83
+ assert.isSuccess(response, 'Webhook should be accepted');
84
+ },
85
+ },
86
+
87
+ {
88
+ name: 'plan-changed-transition-detected',
89
+ async run({ firestore, assert, state, waitFor }) {
90
+ await waitFor(async () => {
91
+ const doc = await firestore.get(`payments-webhooks/${state.eventId}`);
92
+ return doc?.status === 'completed';
93
+ }, 15000, 500);
94
+
95
+ const webhookDoc = await firestore.get(`payments-webhooks/${state.eventId}`);
96
+ assert.ok(webhookDoc, 'Webhook doc should exist');
97
+ assert.equal(webhookDoc.transition, 'plan-changed', 'Transition should be plan-changed');
98
+ },
99
+ },
100
+
101
+ {
102
+ name: 'subscription-updated-to-product-b',
103
+ async run({ firestore, assert, state }) {
104
+ const userDoc = await firestore.get(`users/${state.uid}`);
105
+
106
+ assert.equal(userDoc.subscription.product.id, state.productB.id, `Product should be ${state.productB.id}`);
107
+ assert.equal(userDoc.subscription.product.name, state.productB.name, `Product name should be ${state.productB.name}`);
108
+ assert.equal(userDoc.subscription.status, 'active', 'Status should still be active');
109
+ assert.equal(userDoc.subscription.payment.processor, 'test', 'Processor should be test');
110
+ assert.equal(userDoc.subscription.payment.frequency, 'monthly', 'Frequency should be monthly');
111
+ assert.equal(userDoc.subscription.payment.resourceId, state.subscriptionId, 'Resource ID should be the same subscription');
112
+ },
113
+ },
114
+
115
+ {
116
+ name: 'order-doc-updated',
117
+ async run({ firestore, assert, state }) {
118
+ const orderDoc = await firestore.get(`payments-orders/${state.orderId}`);
119
+
120
+ assert.ok(orderDoc, 'Order doc should exist');
121
+ assert.equal(orderDoc.unified.product.id, state.productB.id, `Order product should be ${state.productB.id}`);
122
+ assert.equal(orderDoc.unified.status, 'active', 'Order status should be active');
123
+ },
124
+ },
125
+ ],
126
+ };
@@ -32,6 +32,7 @@ module.exports = {
32
32
  frequency: 'monthly',
33
33
  });
34
34
  assert.isSuccess(response, 'Intent should succeed');
35
+ state.orderId = response.data.orderId;
35
36
 
36
37
  // Wait for subscription to activate
37
38
  await waitFor(async () => {
@@ -42,6 +43,7 @@ module.exports = {
42
43
  const userDoc = await firestore.get(`users/${uid}`);
43
44
  assert.equal(userDoc.subscription?.product?.id, paidProduct.id, `Should start as ${paidProduct.id}`);
44
45
  assert.equal(userDoc.subscription?.status, 'active', 'Should be active');
46
+ assert.equal(userDoc.subscription?.payment?.orderId, state.orderId, 'Order ID should match intent');
45
47
 
46
48
  state.subscriptionId = userDoc.subscription.payment.resourceId;
47
49
  },
@@ -43,8 +43,10 @@ module.exports = {
43
43
 
44
44
  assert.isSuccess(response, 'Intent should succeed');
45
45
  assert.ok(response.data.id, 'Should return intent ID');
46
+ assert.ok(response.data.orderId, 'Should return orderId');
46
47
 
47
48
  state.intentId = response.data.id;
49
+ state.orderId = response.data.orderId;
48
50
 
49
51
  // Derive webhook event ID from intent ID (same timestamp)
50
52
  state.eventId = response.data.id.replace('_test-cs-', '_test-evt-');
@@ -65,6 +67,7 @@ module.exports = {
65
67
  assert.equal(userDoc.subscription.product.id, state.paidProductId, `Product should be ${state.paidProductId}`);
66
68
  assert.equal(userDoc.subscription.status, 'active', 'Status should be active');
67
69
  assert.equal(userDoc.subscription.trial.claimed, true, 'Trial should be claimed');
70
+ assert.equal(userDoc.subscription.payment.orderId, state.orderId, 'Order ID should match intent');
68
71
 
69
72
  state.subscriptionId = userDoc.subscription.payment.resourceId;
70
73
 
@@ -72,6 +75,7 @@ module.exports = {
72
75
  const webhookDoc = await firestore.get(`payments-webhooks/${state.eventId}`);
73
76
  assert.ok(webhookDoc, 'Webhook doc should exist');
74
77
  assert.equal(webhookDoc.transition, 'new-subscription', 'Transition should be new-subscription (trial detected inside handler)');
78
+ assert.equal(webhookDoc.orderId, state.orderId, 'Webhook doc orderId should match intent');
75
79
  },
76
80
  },
77
81
 
@@ -42,9 +42,12 @@ module.exports = {
42
42
 
43
43
  assert.isSuccess(response, 'Intent should succeed');
44
44
  assert.ok(response.data.id, 'Should return intent ID');
45
+ assert.ok(response.data.orderId, 'Should return orderId');
46
+ assert.match(response.data.orderId, /^\d{4}-\d{4}-\d{4}$/, 'orderId should be XXXX-XXXX-XXXX format');
45
47
  assert.ok(response.data.url, 'Should return URL');
46
48
 
47
49
  state.intentId = response.data.id;
50
+ state.orderId = response.data.orderId;
48
51
 
49
52
  // Derive webhook event ID from intent ID (same timestamp)
50
53
  state.eventId = response.data.id.replace('_test-cs-', '_test-evt-');
@@ -65,6 +68,7 @@ module.exports = {
65
68
  assert.equal(userDoc.subscription.product.id, state.paidProductId, `Product should be ${state.paidProductId}`);
66
69
  assert.equal(userDoc.subscription.status, 'active', 'Status should be active');
67
70
  assert.equal(userDoc.subscription.payment.processor, 'test', 'Processor should be test');
71
+ assert.equal(userDoc.subscription.payment.orderId, state.orderId, 'Order ID should match intent');
68
72
  assert.ok(userDoc.subscription.payment.resourceId, 'Resource ID should be set');
69
73
  assert.equal(userDoc.subscription.payment.frequency, 'monthly', 'Frequency should be monthly');
70
74
  assert.equal(userDoc.subscription.cancellation.pending, false, 'Should not be pending cancellation');
@@ -74,15 +78,18 @@ module.exports = {
74
78
  },
75
79
 
76
80
  {
77
- name: 'subscription-doc-created',
81
+ name: 'order-doc-created',
78
82
  async run({ firestore, assert, state }) {
79
- const subDoc = await firestore.get(`payments-subscriptions/${state.subscriptionId}`);
80
-
81
- assert.ok(subDoc, 'Subscription doc should exist');
82
- assert.equal(subDoc.uid, state.uid, 'UID should match');
83
- assert.equal(subDoc.processor, 'test', 'Processor should be test');
84
- assert.equal(subDoc.subscription.product.id, state.paidProductId, `Product should be ${state.paidProductId}`);
85
- assert.equal(subDoc.subscription.status, 'active', 'Status should be active');
83
+ const orderDoc = await firestore.get(`payments-orders/${state.orderId}`);
84
+
85
+ assert.ok(orderDoc, 'Order doc should exist');
86
+ assert.equal(orderDoc.id, state.orderId, 'ID should match orderId');
87
+ assert.equal(orderDoc.type, 'subscription', 'Type should be subscription');
88
+ assert.equal(orderDoc.owner, state.uid, 'Owner should match');
89
+ assert.equal(orderDoc.processor, 'test', 'Processor should be test');
90
+ assert.equal(orderDoc.resourceId, state.subscriptionId, 'Resource ID should match');
91
+ assert.equal(orderDoc.unified.product.id, state.paidProductId, `Product should be ${state.paidProductId}`);
92
+ assert.equal(orderDoc.unified.status, 'active', 'Status should be active');
86
93
  },
87
94
  },
88
95
 
@@ -97,16 +104,19 @@ module.exports = {
97
104
  const webhookDoc = await firestore.get(`payments-webhooks/${state.eventId}`);
98
105
  assert.ok(webhookDoc, 'Webhook doc should exist');
99
106
  assert.equal(webhookDoc.transition, 'new-subscription', 'Transition should be new-subscription');
107
+ assert.equal(webhookDoc.orderId, state.orderId, 'Webhook doc orderId should match intent');
100
108
  },
101
109
  },
102
110
 
103
111
  {
104
112
  name: 'intent-doc-created',
105
113
  async run({ firestore, assert, state }) {
106
- const intentDoc = await firestore.get(`payments-intents/${state.intentId}`);
114
+ const intentDoc = await firestore.get(`payments-intents/${state.orderId}`);
107
115
 
108
116
  assert.ok(intentDoc, 'Intent doc should exist');
109
- assert.equal(intentDoc.uid, state.uid, 'UID should match');
117
+ assert.equal(intentDoc.id, state.orderId, 'ID should match orderId');
118
+ assert.equal(intentDoc.intentId, state.intentId, 'Intent ID should match processor session ID');
119
+ assert.equal(intentDoc.owner, state.uid, 'Owner should match');
110
120
  assert.equal(intentDoc.processor, 'test', 'Processor should be test');
111
121
  assert.equal(intentDoc.status, 'pending', 'Intent status should be pending');
112
122
  assert.equal(intentDoc.productId, state.paidProductId, `Product should be ${state.paidProductId}`);
@@ -365,6 +365,22 @@ module.exports = {
365
365
  },
366
366
  },
367
367
 
368
+ {
369
+ name: 'payment-order-id-from-metadata',
370
+ async run({ assert }) {
371
+ const result = toUnifiedSubscription({ metadata: { orderId: '1234-5678-9012' } });
372
+ assert.equal(result.payment.orderId, '1234-5678-9012', 'orderId should come from metadata');
373
+ },
374
+ },
375
+
376
+ {
377
+ name: 'payment-order-id-null-when-missing',
378
+ async run({ assert }) {
379
+ const result = toUnifiedSubscription({});
380
+ assert.equal(result.payment.orderId, null, 'Missing metadata → null orderId');
381
+ },
382
+ },
383
+
368
384
  {
369
385
  name: 'payment-event-metadata-passed-through',
370
386
  async run({ assert }) {
@@ -432,6 +448,7 @@ module.exports = {
432
448
  assert.equal(result.trial.claimed, false, 'Empty → trial not claimed');
433
449
  assert.equal(result.cancellation.pending, false, 'Empty → not pending');
434
450
  assert.equal(result.payment.processor, 'stripe', 'Empty → still stripe');
451
+ assert.equal(result.payment.orderId, null, 'Empty → null orderId');
435
452
  assert.equal(result.payment.resourceId, null, 'Empty → null resourceId');
436
453
  assert.equal(result.payment.frequency, null, 'Empty → null frequency');
437
454
  },
@@ -393,6 +393,7 @@ module.exports = {
393
393
  });
394
394
 
395
395
  assert.equal(user.subscription.payment.processor, 'stripe', 'processor preserved');
396
+ assert.equal(user.subscription.payment.orderId, null, 'missing orderId defaults to null');
396
397
  assert.equal(user.subscription.payment.resourceId, 'sub_123', 'resourceId preserved');
397
398
  assert.equal(user.subscription.payment.frequency, null, 'missing frequency defaults to null');
398
399
  assert.ok(user.subscription.payment.startDate.timestamp, 'missing startDate gets default');
@@ -126,11 +126,14 @@ module.exports = {
126
126
 
127
127
  assert.isSuccess(response, 'Should succeed with test processor');
128
128
  assert.ok(response.data.id, 'Should return intent ID');
129
+ assert.ok(response.data.orderId, 'Should return orderId');
130
+ assert.match(response.data.orderId, /^\d{4}-\d{4}-\d{4}$/, 'orderId should be XXXX-XXXX-XXXX format');
129
131
  assert.ok(response.data.url, 'Should return URL');
130
132
 
131
- // Verify intent doc was saved
132
- const intentDoc = await firestore.get(`payments-intents/${response.data.id}`);
133
+ // Verify intent doc was saved (keyed by orderId)
134
+ const intentDoc = await firestore.get(`payments-intents/${response.data.orderId}`);
133
135
  assert.ok(intentDoc, 'Intent doc should exist');
136
+ assert.equal(intentDoc.intentId, response.data.id, 'Intent ID should match response');
134
137
  assert.equal(intentDoc.processor, 'test', 'Processor should be test');
135
138
  assert.equal(intentDoc.productId, paidProduct.id, 'Product should match');
136
139
 
@@ -151,10 +154,10 @@ module.exports = {
151
154
  async run({ http, assert, config, accounts, firestore, waitFor }) {
152
155
  const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
153
156
  const uid = accounts.basic.uid;
154
- const subDocPath = `payments-subscriptions/_test-sub-history-${uid}`;
157
+ const orderDocPath = `payments-orders/_test-order-history-${uid}`;
155
158
 
156
159
  // Create fake subscription history so user is ineligible for trial
157
- await firestore.set(subDocPath, { uid, processor: 'test', status: 'cancelled' });
160
+ await firestore.set(orderDocPath, { owner: uid, type: 'subscription', processor: 'test', status: 'cancelled' });
158
161
 
159
162
  try {
160
163
  const response = await http.as('basic').post('payments/intent', {
@@ -167,8 +170,8 @@ module.exports = {
167
170
  // Should succeed (not reject with 400) — trial silently downgraded
168
171
  assert.isSuccess(response, 'Should not reject — trial silently downgraded');
169
172
 
170
- // Verify intent saved with trial=false
171
- const intentDoc = await firestore.get(`payments-intents/${response.data.id}`);
173
+ // Verify intent saved with trial=false (keyed by orderId)
174
+ const intentDoc = await firestore.get(`payments-intents/${response.data.orderId}`);
172
175
  assert.equal(intentDoc.trial, false, 'Trial should be false (downgraded)');
173
176
 
174
177
  // Clean up: wait for auto-webhook, restore basic user
@@ -181,7 +184,7 @@ module.exports = {
181
184
  subscription: { product: { id: 'basic' }, status: 'active' },
182
185
  }, { merge: true });
183
186
  } finally {
184
- await firestore.delete(subDocPath);
187
+ await firestore.delete(orderDocPath);
185
188
  }
186
189
  },
187
190
  },
File without changes