backend-manager 5.0.89 → 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 (29) hide show
  1. package/CLAUDE.md +133 -2
  2. package/package.json +5 -3
  3. package/src/cli/index.js +11 -0
  4. package/src/manager/events/firestore/payments-webhooks/analytics.js +170 -0
  5. package/src/manager/events/firestore/payments-webhooks/on-write.js +64 -314
  6. package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-completed.js +20 -10
  7. package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-failed.js +4 -8
  8. package/src/manager/events/firestore/payments-webhooks/transitions/send-email.js +67 -0
  9. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/cancellation-requested.js +23 -9
  10. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/new-subscription.js +22 -8
  11. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-failed.js +19 -8
  12. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-recovered.js +19 -7
  13. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/plan-changed.js +27 -8
  14. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/subscription-cancelled.js +25 -9
  15. package/src/manager/helpers/user.js +1 -0
  16. package/src/manager/libraries/payment-processors/resolve-price-id.js +19 -0
  17. package/src/manager/libraries/payment-processors/stripe.js +87 -48
  18. package/src/manager/libraries/payment-processors/test.js +4 -4
  19. package/src/manager/routes/payments/intent/post.js +44 -0
  20. package/src/manager/routes/payments/intent/processors/stripe.js +10 -45
  21. package/src/manager/routes/payments/intent/processors/test.js +16 -20
  22. package/src/test/runner.js +11 -0
  23. package/src/test/test-accounts.js +18 -0
  24. package/templates/backend-manager-config.json +31 -12
  25. package/test/events/payments/journey-payments-one-time-failure.js +105 -0
  26. package/test/events/payments/journey-payments-one-time.js +128 -0
  27. package/test/events/payments/journey-payments-plan-change.js +126 -0
  28. package/test/events/payments/journey-payments-upgrade.js +2 -2
  29. /package/bin/{bem → backend-manager} +0 -0
@@ -1,4 +1,5 @@
1
1
  const fetch = require('wonderful-fetch');
2
+ const resolvePriceId = require('../../../../libraries/payment-processors/resolve-price-id.js');
2
3
 
3
4
  /**
4
5
  * Test intent processor
@@ -16,12 +17,13 @@ module.exports = {
16
17
  * @param {string} options.productId - Product ID from config
17
18
  * @param {string} options.frequency - 'monthly' or 'annually' (subscriptions only)
18
19
  * @param {boolean} options.trial - Whether to include a trial period (subscriptions only)
19
- * @param {object} options.config - BEM config
20
+ * @param {string} options.confirmationUrl - Success redirect URL
21
+ * @param {string} options.cancelUrl - Cancel redirect URL
20
22
  * @param {object} options.Manager - Manager instance
21
23
  * @param {object} options.assistant - Assistant instance
22
24
  * @returns {object} { id, url, raw }
23
25
  */
24
- async createIntent({ uid, orderId, product, productId, frequency, trial, config, Manager, assistant }) {
26
+ async createIntent({ uid, orderId, product, productId, frequency, trial, confirmationUrl, Manager, assistant }) {
25
27
  // Guard: test processor is not available in production
26
28
  if (assistant.isProduction()) {
27
29
  throw new Error('Test processor is not available in production');
@@ -30,10 +32,10 @@ module.exports = {
30
32
  const productType = product.type || 'subscription';
31
33
 
32
34
  if (productType === 'subscription') {
33
- return createSubscriptionIntent({ uid, orderId, product, productId, frequency, trial, config, Manager, assistant });
35
+ return createSubscriptionIntent({ uid, orderId, product, frequency, trial, confirmationUrl, Manager, assistant });
34
36
  }
35
37
 
36
- return createOneTimeIntent({ uid, orderId, product, productId, config, Manager, assistant });
38
+ return createOneTimeIntent({ uid, orderId, product, productId, confirmationUrl, Manager, assistant });
37
39
  },
38
40
  };
39
41
 
@@ -41,12 +43,9 @@ module.exports = {
41
43
  * Create a test subscription intent
42
44
  * Generates Stripe-shaped subscription + customer.subscription.created event
43
45
  */
44
- async function createSubscriptionIntent({ uid, orderId, product, productId, frequency, trial, config, Manager, assistant }) {
46
+ async function createSubscriptionIntent({ uid, orderId, product, frequency, trial, confirmationUrl, Manager, assistant }) {
45
47
  // Get the price ID for the requested frequency
46
- const priceId = product.prices?.[frequency]?.stripe;
47
- if (!priceId) {
48
- throw new Error(`No Stripe price found for ${productId}/${frequency}`);
49
- }
48
+ const priceId = resolvePriceId(product, 'subscription', frequency);
50
49
 
51
50
  // Generate IDs
52
51
  const timestamp = Date.now();
@@ -94,14 +93,14 @@ async function createSubscriptionIntent({ uid, orderId, product, productId, freq
94
93
  data: { object: subscription },
95
94
  };
96
95
 
97
- assistant?.log(`Test subscription intent: sessionId=${sessionId}, subscriptionId=${subscriptionId}, eventId=${eventId}, trial=${!!subscription.trial_start}`);
96
+ assistant.log(`Test subscription intent: sessionId=${sessionId}, subscriptionId=${subscriptionId}, eventId=${eventId}, trial=${!!subscription.trial_start}`);
98
97
 
99
98
  // Auto-fire webhook
100
99
  fireWebhook({ event, Manager, assistant });
101
100
 
102
101
  return {
103
102
  id: sessionId,
104
- url: `${config.brand?.url || 'https://example.com'}/payment/confirmation?session=${sessionId}`,
103
+ url: confirmationUrl,
105
104
  raw: { id: sessionId, object: 'checkout.session', subscription: subscriptionId },
106
105
  };
107
106
  }
@@ -110,12 +109,9 @@ async function createSubscriptionIntent({ uid, orderId, product, productId, freq
110
109
  * Create a test one-time payment intent
111
110
  * Generates Stripe-shaped checkout session + checkout.session.completed event
112
111
  */
113
- async function createOneTimeIntent({ uid, orderId, product, productId, config, Manager, assistant }) {
114
- // Get the price ID for one-time purchase
115
- const priceId = product.prices?.once?.stripe;
116
- if (!priceId) {
117
- throw new Error(`No Stripe price found for ${productId}/once`);
118
- }
112
+ async function createOneTimeIntent({ uid, orderId, product, productId, confirmationUrl, Manager, assistant }) {
113
+ // Validate that a price exists (resolvePriceId throws if not found)
114
+ resolvePriceId(product, 'one-time', null);
119
115
 
120
116
  // Generate IDs
121
117
  const timestamp = Date.now();
@@ -141,14 +137,14 @@ async function createOneTimeIntent({ uid, orderId, product, productId, config, M
141
137
  data: { object: session },
142
138
  };
143
139
 
144
- assistant?.log(`Test one-time intent: sessionId=${sessionId}, eventId=${eventId}, productId=${productId}`);
140
+ assistant.log(`Test one-time intent: sessionId=${sessionId}, eventId=${eventId}, productId=${productId}`);
145
141
 
146
142
  // Auto-fire webhook
147
143
  fireWebhook({ event, Manager, assistant });
148
144
 
149
145
  return {
150
146
  id: sessionId,
151
- url: `${config.brand?.url || 'https://example.com'}/payment/confirmation?session=${sessionId}`,
147
+ url: confirmationUrl,
152
148
  raw: { id: sessionId, object: 'checkout.session', mode: 'payment' },
153
149
  };
154
150
  }
@@ -164,6 +160,6 @@ function fireWebhook({ event, Manager, assistant }) {
164
160
  body: event,
165
161
  timeout: 15000,
166
162
  }).catch((e) => {
167
- assistant?.log(`Test processor auto-webhook failed: ${e.message}`);
163
+ assistant.log(`Test processor auto-webhook failed: ${e.message}`);
168
164
  });
169
165
  }
@@ -1,3 +1,4 @@
1
+ const os = require('os');
1
2
  const path = require('path');
2
3
  const jetpack = require('fs-jetpack');
3
4
  const chalk = require('chalk');
@@ -52,6 +53,16 @@ class TestRunner {
52
53
  * Main run method
53
54
  */
54
55
  async run() {
56
+ // Abort if BEM is running from the user's home directory (e.g., accidental ~/node_modules install)
57
+ const homeDir = os.homedir();
58
+ if (__dirname.startsWith(path.join(homeDir, 'node_modules'))) {
59
+ console.error(chalk.red('\n ERROR: BEM is running from ~/node_modules (home directory install).'));
60
+ console.error(chalk.red(' This is likely an accidental global install that shadows local project copies.'));
61
+ console.error(chalk.red(` Fix: rm -rf ${path.join(homeDir, 'node_modules')} ${path.join(homeDir, 'package.json')} ${path.join(homeDir, 'package-lock.json')}`));
62
+ console.error(chalk.red(` Running from: ${__dirname}\n`));
63
+ process.exit(1);
64
+ }
65
+
55
66
  // Set testing flag to skip external API calls (emails, SendGrid)
56
67
  process.env.BEM_TESTING = 'true';
57
68
 
@@ -175,6 +175,24 @@ const JOURNEY_ACCOUNTS = {
175
175
  subscription: { product: { id: 'basic' }, status: 'active' }, // Test's first step overwrites with correct paid product from config
176
176
  },
177
177
  },
178
+ 'journey-payments-plan-change': {
179
+ id: 'journey-payments-plan-change',
180
+ uid: '_test-journey-payments-plan-change',
181
+ email: '_test.journey-payments-plan-change@{domain}',
182
+ properties: {
183
+ roles: {},
184
+ subscription: { product: { id: 'basic' }, status: 'active' }, // Test's first step overwrites with correct paid product from config
185
+ },
186
+ },
187
+ 'journey-payments-one-time': {
188
+ id: 'journey-payments-one-time',
189
+ uid: '_test-journey-payments-one-time',
190
+ email: '_test.journey-payments-one-time@{domain}',
191
+ properties: {
192
+ roles: {},
193
+ subscription: { product: { id: 'basic' }, status: 'active' },
194
+ },
195
+ },
178
196
  };
179
197
 
180
198
  /**
@@ -72,18 +72,37 @@
72
72
  },
73
73
  },
74
74
  },
75
- // Example one-time product:
76
- // {
77
- // id: 'credits-100',
78
- // name: '100 Credits',
79
- // type: 'one-time',
80
- // prices: {
81
- // once: {
82
- // amount: 9.99,
83
- // stripe: null,
84
- // },
85
- // },
86
- // },
75
+ {
76
+ id: 'ultimate',
77
+ name: 'Ultimate',
78
+ type: 'subscription',
79
+ limits: {
80
+ requests: 10000,
81
+ },
82
+ prices: {
83
+ monthly: {
84
+ amount: 19.99,
85
+ stripe: null,
86
+ paypal: null,
87
+ },
88
+ annually: {
89
+ amount: 199.99,
90
+ stripe: null,
91
+ paypal: null,
92
+ },
93
+ },
94
+ },
95
+ {
96
+ id: 'credits-100',
97
+ name: '100 Credits',
98
+ type: 'one-time',
99
+ prices: {
100
+ once: {
101
+ amount: 9.99,
102
+ stripe: null,
103
+ },
104
+ },
105
+ },
87
106
  // Add more products/tiers here
88
107
  ],
89
108
  },
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Test: Payment Journey - One-Time Payment Failure
3
+ * Simulates: invoice.payment_failed for a non-subscription invoice → purchase-failed transition
4
+ *
5
+ * This verifies the webhook routing for one-time invoice failures:
6
+ * invoice.payment_failed with no subscription billing_reason → category: 'one-time'
7
+ *
8
+ * Uses the journey-payments-one-time account (one-time events don't modify subscription state)
9
+ */
10
+ module.exports = {
11
+ description: 'Payment journey: one-time invoice.payment_failed → purchase-failed',
12
+ type: 'suite',
13
+ timeout: 30000,
14
+
15
+ tests: [
16
+ {
17
+ name: 'resolve-one-time-product',
18
+ async run({ accounts, assert, state, config }) {
19
+ const uid = accounts['journey-payments-one-time'].uid;
20
+
21
+ // Resolve first one-time product from config
22
+ const oneTimeProduct = config.payment.products.find(p => p.type === 'one-time' && p.prices?.once);
23
+ assert.ok(oneTimeProduct, 'Config should have at least one one-time product');
24
+
25
+ state.uid = uid;
26
+ state.productId = oneTimeProduct.id;
27
+ },
28
+ },
29
+
30
+ {
31
+ name: 'send-one-time-payment-failed',
32
+ async run({ http, assert, state, config }) {
33
+ state.eventId = `_test-evt-journey-onetime-fail-${Date.now()}`;
34
+ state.invoiceId = `_test-inv-onetime-fail-${Date.now()}`;
35
+ state.orderId = `0000-0000-0000`; // Fake orderId for test
36
+
37
+ // Send invoice.payment_failed with a non-subscription billing reason
38
+ // This routes to category: 'one-time' in the webhook parser
39
+ const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerKey}`, {
40
+ id: state.eventId,
41
+ type: 'invoice.payment_failed',
42
+ data: {
43
+ object: {
44
+ id: state.invoiceId,
45
+ object: 'invoice',
46
+ billing_reason: 'manual',
47
+ amount_due: 999,
48
+ amount_paid: 0,
49
+ status: 'open',
50
+ metadata: {
51
+ uid: state.uid,
52
+ orderId: state.orderId,
53
+ productId: state.productId,
54
+ },
55
+ },
56
+ },
57
+ });
58
+
59
+ assert.isSuccess(response, 'Webhook should be accepted');
60
+ },
61
+ },
62
+
63
+ {
64
+ name: 'webhook-categorized-as-one-time',
65
+ async run({ firestore, assert, state, waitFor }) {
66
+ await waitFor(async () => {
67
+ const doc = await firestore.get(`payments-webhooks/${state.eventId}`);
68
+ return doc?.status === 'completed' || doc?.status === 'failed';
69
+ }, 15000, 500);
70
+
71
+ const webhookDoc = await firestore.get(`payments-webhooks/${state.eventId}`);
72
+ assert.ok(webhookDoc, 'Webhook doc should exist');
73
+ assert.equal(webhookDoc.event?.category, 'one-time', 'Category should be one-time');
74
+ assert.equal(webhookDoc.event?.resourceType, 'invoice', 'Resource type should be invoice');
75
+ assert.equal(webhookDoc.transition, 'purchase-failed', 'Transition should be purchase-failed');
76
+ },
77
+ },
78
+
79
+ {
80
+ name: 'order-doc-created-with-failure',
81
+ async run({ firestore, assert, state }) {
82
+ const orderDoc = await firestore.get(`payments-orders/${state.orderId}`);
83
+
84
+ assert.ok(orderDoc, 'Order doc should exist');
85
+ assert.equal(orderDoc.type, 'one-time', 'Type should be one-time');
86
+ assert.equal(orderDoc.owner, state.uid, 'Owner should match');
87
+ assert.equal(orderDoc.processor, 'test', 'Processor should be test');
88
+ },
89
+ },
90
+
91
+ {
92
+ name: 'subscription-unchanged',
93
+ async run({ firestore, assert, state }) {
94
+ // One-time payment failures must NOT modify subscription state
95
+ const userDoc = await firestore.get(`users/${state.uid}`);
96
+
97
+ assert.equal(
98
+ userDoc.subscription?.product?.id,
99
+ 'basic',
100
+ 'Subscription should remain basic after one-time failure',
101
+ );
102
+ },
103
+ },
104
+ ],
105
+ };
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Test: Payment Journey - One-Time Purchase
3
+ * Simulates: user → test intent (one-time product) → auto-webhook → purchase-completed
4
+ *
5
+ * Uses the test processor to exercise the full intent→webhook→trigger pipeline
6
+ * for one-time payments. Unlike subscriptions, one-time payments only write to
7
+ * payments-orders/{orderId} — they do NOT modify users/{uid}.subscription.
8
+ *
9
+ * Requires at least one product with type: 'one-time' in config.payment.products
10
+ */
11
+ module.exports = {
12
+ description: 'Payment journey: one-time purchase via test intent → purchase-completed',
13
+ type: 'suite',
14
+ timeout: 30000,
15
+
16
+ tests: [
17
+ {
18
+ name: 'resolve-one-time-product',
19
+ async run({ accounts, firestore, assert, state, config }) {
20
+ const uid = accounts['journey-payments-one-time'].uid;
21
+ const userDoc = await firestore.get(`users/${uid}`);
22
+
23
+ assert.ok(userDoc, 'User doc should exist');
24
+
25
+ // Resolve first one-time product from config
26
+ const oneTimeProduct = config.payment.products.find(p => p.type === 'one-time' && p.prices?.once);
27
+ assert.ok(oneTimeProduct, 'Config should have at least one one-time product');
28
+
29
+ state.uid = uid;
30
+ state.productId = oneTimeProduct.id;
31
+ state.productName = oneTimeProduct.name;
32
+ state.price = oneTimeProduct.prices.once.amount;
33
+
34
+ // Snapshot subscription before purchase — should remain unchanged after
35
+ state.subscriptionBefore = userDoc.subscription || null;
36
+ },
37
+ },
38
+
39
+ {
40
+ name: 'create-one-time-intent',
41
+ async run({ http, assert, state }) {
42
+ const response = await http.as('journey-payments-one-time').post('payments/intent', {
43
+ processor: 'test',
44
+ productId: state.productId,
45
+ });
46
+
47
+ assert.isSuccess(response, 'Intent should succeed');
48
+ assert.ok(response.data.id, 'Should return intent ID');
49
+ assert.ok(response.data.orderId, 'Should return orderId');
50
+ assert.match(response.data.orderId, /^\d{4}-\d{4}-\d{4}$/, 'orderId should be XXXX-XXXX-XXXX format');
51
+ assert.ok(response.data.url, 'Should return URL');
52
+
53
+ state.intentId = response.data.id;
54
+ state.orderId = response.data.orderId;
55
+
56
+ // Derive webhook event ID from intent ID (same timestamp)
57
+ state.eventId = response.data.id.replace('_test-cs-', '_test-evt-');
58
+ },
59
+ },
60
+
61
+ {
62
+ name: 'webhook-transition-purchase-completed',
63
+ async run({ firestore, assert, state, waitFor }) {
64
+ // Poll until the webhook is processed
65
+ await waitFor(async () => {
66
+ const doc = await firestore.get(`payments-webhooks/${state.eventId}`);
67
+ return doc?.status === 'completed';
68
+ }, 15000, 500);
69
+
70
+ const webhookDoc = await firestore.get(`payments-webhooks/${state.eventId}`);
71
+ assert.ok(webhookDoc, 'Webhook doc should exist');
72
+ assert.equal(webhookDoc.transition, 'purchase-completed', 'Transition should be purchase-completed');
73
+ assert.equal(webhookDoc.orderId, state.orderId, 'Webhook doc orderId should match intent');
74
+ assert.equal(webhookDoc.event?.category, 'one-time', 'Category should be one-time');
75
+ },
76
+ },
77
+
78
+ {
79
+ name: 'order-doc-created',
80
+ async run({ firestore, assert, state }) {
81
+ const orderDoc = await firestore.get(`payments-orders/${state.orderId}`);
82
+
83
+ assert.ok(orderDoc, 'Order doc should exist');
84
+ assert.equal(orderDoc.id, state.orderId, 'ID should match orderId');
85
+ assert.equal(orderDoc.type, 'one-time', 'Type should be one-time');
86
+ assert.equal(orderDoc.owner, state.uid, 'Owner should match');
87
+ assert.equal(orderDoc.processor, 'test', 'Processor should be test');
88
+ assert.equal(orderDoc.unified.product.id, state.productId, `Product should be ${state.productId}`);
89
+ assert.equal(orderDoc.unified.payment.processor, 'test', 'Unified processor should be test');
90
+ assert.equal(orderDoc.unified.payment.orderId, state.orderId, 'Unified orderId should match');
91
+ },
92
+ },
93
+
94
+ {
95
+ name: 'subscription-unchanged',
96
+ async run({ firestore, assert, state }) {
97
+ // One-time payments must NOT modify users/{uid}.subscription
98
+ const userDoc = await firestore.get(`users/${state.uid}`);
99
+ const subAfter = userDoc.subscription || null;
100
+
101
+ assert.deepEqual(
102
+ subAfter?.product?.id,
103
+ state.subscriptionBefore?.product?.id,
104
+ 'Subscription product should be unchanged after one-time purchase',
105
+ );
106
+ assert.deepEqual(
107
+ subAfter?.status,
108
+ state.subscriptionBefore?.status,
109
+ 'Subscription status should be unchanged after one-time purchase',
110
+ );
111
+ },
112
+ },
113
+
114
+ {
115
+ name: 'intent-doc-created',
116
+ async run({ firestore, assert, state }) {
117
+ const intentDoc = await firestore.get(`payments-intents/${state.orderId}`);
118
+
119
+ assert.ok(intentDoc, 'Intent doc should exist');
120
+ assert.equal(intentDoc.id, state.orderId, 'ID should match orderId');
121
+ assert.equal(intentDoc.intentId, state.intentId, 'Intent ID should match processor session ID');
122
+ assert.equal(intentDoc.owner, state.uid, 'Owner should match');
123
+ assert.equal(intentDoc.processor, 'test', 'Processor should be test');
124
+ assert.equal(intentDoc.productId, state.productId, `Product should be ${state.productId}`);
125
+ },
126
+ },
127
+ ],
128
+ };
@@ -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
+ };
@@ -88,8 +88,8 @@ module.exports = {
88
88
  assert.equal(orderDoc.owner, state.uid, 'Owner should match');
89
89
  assert.equal(orderDoc.processor, 'test', 'Processor should be test');
90
90
  assert.equal(orderDoc.resourceId, state.subscriptionId, 'Resource ID should match');
91
- assert.equal(orderDoc.subscription.product.id, state.paidProductId, `Product should be ${state.paidProductId}`);
92
- assert.equal(orderDoc.subscription.status, 'active', 'Status should be active');
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');
93
93
  },
94
94
  },
95
95
 
File without changes