backend-manager 5.0.83 → 5.0.85

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 (50) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/CLAUDE.md +66 -3
  3. package/README.md +7 -5
  4. package/package.json +5 -4
  5. package/src/cli/commands/base-command.js +89 -0
  6. package/src/cli/commands/emulators.js +3 -0
  7. package/src/cli/commands/serve.js +5 -1
  8. package/src/cli/commands/stripe.js +14 -0
  9. package/src/cli/commands/test.js +11 -6
  10. package/src/cli/index.js +7 -0
  11. package/src/manager/cron/daily/reset-usage.js +56 -34
  12. package/src/manager/events/firestore/payments-webhooks/on-write.js +15 -13
  13. package/src/manager/functions/core/actions/api/user/get-subscription-info.js +1 -1
  14. package/src/manager/helpers/analytics.js +2 -2
  15. package/src/manager/helpers/api-manager.js +1 -1
  16. package/src/manager/helpers/middleware.js +1 -1
  17. package/src/manager/helpers/usage.js +51 -3
  18. package/src/manager/index.js +5 -19
  19. package/src/manager/libraries/stripe.js +12 -8
  20. package/src/manager/libraries/test.js +27 -0
  21. package/src/manager/routes/app/get.js +11 -8
  22. package/src/manager/routes/payments/intent/post.js +31 -16
  23. package/src/manager/routes/payments/intent/{providers → processors}/stripe.js +48 -4
  24. package/src/manager/routes/payments/intent/processors/test.js +106 -0
  25. package/src/manager/routes/payments/webhook/post.js +21 -8
  26. package/src/manager/routes/payments/webhook/{providers → processors}/stripe.js +16 -1
  27. package/src/manager/routes/payments/webhook/processors/test.js +15 -0
  28. package/src/manager/routes/user/subscription/get.js +1 -1
  29. package/src/manager/schemas/payments/webhook/post.js +1 -1
  30. package/src/test/test-accounts.js +18 -18
  31. package/templates/_.env +0 -2
  32. package/templates/backend-manager-config.json +50 -34
  33. package/test/events/payments/journey-payments-cancel.js +144 -0
  34. package/test/events/payments/journey-payments-suspend.js +143 -0
  35. package/test/events/payments/journey-payments-trial.js +120 -0
  36. package/test/events/payments/journey-payments-upgrade.js +99 -0
  37. package/test/fixtures/stripe/subscription-active.json +161 -0
  38. package/test/fixtures/stripe/subscription-canceled.json +161 -0
  39. package/test/fixtures/stripe/subscription-trialing.json +161 -0
  40. package/test/functions/user/get-subscription-info.js +2 -2
  41. package/test/helpers/stripe-to-unified.js +684 -0
  42. package/test/routes/payments/intent.js +189 -0
  43. package/test/{payments → routes/payments}/webhook.js +1 -1
  44. package/test/routes/test/usage.js +7 -6
  45. package/test/routes/user/subscription.js +2 -2
  46. package/test/payments/intent.js +0 -104
  47. package/test/payments/journey-payment-cancel.js +0 -166
  48. package/test/payments/journey-payment-suspend.js +0 -162
  49. package/test/payments/journey-payment-trial.js +0 -167
  50. package/test/payments/journey-payment-upgrade.js +0 -136
@@ -1,8 +1,23 @@
1
1
  /**
2
- * Stripe webhook provider
2
+ * Stripe webhook processor
3
3
  * Extracts and validates webhook event data from Stripe
4
4
  */
5
+
6
+ // Stripe event types we process — add new ones here as needed
7
+ const SUPPORTED_EVENTS = new Set([
8
+ 'customer.subscription.created',
9
+ 'customer.subscription.updated',
10
+ 'customer.subscription.deleted',
11
+ ]);
12
+
5
13
  module.exports = {
14
+ /**
15
+ * Returns true if this event type should be saved and processed
16
+ */
17
+ isSupported(eventType) {
18
+ return SUPPORTED_EVENTS.has(eventType);
19
+ },
20
+
6
21
  /**
7
22
  * Parse a Stripe webhook request
8
23
  * Extracts the event data, event type, and resolves the UID from metadata
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Test webhook processor
3
+ * Delegates to Stripe's parser since test processor generates Stripe-shaped event payloads
4
+ */
5
+ const stripeProcessor = require('./stripe.js');
6
+
7
+ module.exports = {
8
+ isSupported(eventType) {
9
+ return stripeProcessor.isSupported(eventType);
10
+ },
11
+
12
+ parseWebhook(req) {
13
+ return stripeProcessor.parseWebhook(req);
14
+ },
15
+ };
@@ -49,7 +49,7 @@ module.exports = async ({ assistant, user, settings, libraries }) => {
49
49
  timestampUNIX: userData?.subscription?.expires?.timestampUNIX || oldDateUNIX,
50
50
  },
51
51
  trial: {
52
- activated: userData?.subscription?.trial?.activated ?? false,
52
+ claimed: userData?.subscription?.trial?.claimed ?? false,
53
53
  expires: {
54
54
  timestamp: userData?.subscription?.trial?.expires?.timestamp || oldDate,
55
55
  timestampUNIX: userData?.subscription?.trial?.expires?.timestampUNIX || oldDateUNIX,
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Schema: POST /payments/webhook
3
- * Minimal schema - webhook payloads are validated by the provider, not the schema
3
+ * Minimal schema - webhook payloads are validated by the processor, not the schema
4
4
  * The processor and key come from query params, not the body
5
5
  */
6
6
  module.exports = () => ({});
@@ -130,37 +130,37 @@ const STATIC_ACCOUNTS = {
130
130
  * These accounts transition through states via webhook tests
131
131
  */
132
132
  const JOURNEY_ACCOUNTS = {
133
- 'journey-payment-upgrade': {
134
- id: 'journey-payment-upgrade',
135
- uid: '_test-journey-payment-upgrade',
136
- email: '_test.journey-payment-upgrade@{domain}',
133
+ 'journey-payments-upgrade': {
134
+ id: 'journey-payments-upgrade',
135
+ uid: '_test-journey-payments-upgrade',
136
+ email: '_test.journey-payments-upgrade@{domain}',
137
137
  properties: {
138
138
  roles: {},
139
139
  subscription: { product: { id: 'basic' }, status: 'active' }, // Starts as basic, upgraded via Stripe webhook
140
140
  },
141
141
  },
142
- 'journey-payment-cancel': {
143
- id: 'journey-payment-cancel',
144
- uid: '_test-journey-payment-cancel',
145
- email: '_test.journey-payment-cancel@{domain}',
142
+ 'journey-payments-cancel': {
143
+ id: 'journey-payments-cancel',
144
+ uid: '_test-journey-payments-cancel',
145
+ email: '_test.journey-payments-cancel@{domain}',
146
146
  properties: {
147
147
  roles: {},
148
- subscription: { product: { id: 'premium' }, status: 'active', expires: getFutureExpires() }, // Starts as premium, cancelled via Stripe webhook
148
+ subscription: { product: { id: 'basic' }, status: 'active' }, // Test's first step overwrites with correct paid product from config
149
149
  },
150
150
  },
151
- 'journey-payment-suspend': {
152
- id: 'journey-payment-suspend',
153
- uid: '_test-journey-payment-suspend',
154
- email: '_test.journey-payment-suspend@{domain}',
151
+ 'journey-payments-suspend': {
152
+ id: 'journey-payments-suspend',
153
+ uid: '_test-journey-payments-suspend',
154
+ email: '_test.journey-payments-suspend@{domain}',
155
155
  properties: {
156
156
  roles: {},
157
- subscription: { product: { id: 'premium' }, status: 'active', expires: getFutureExpires() }, // Starts as premium, suspended via failed payment webhook
157
+ subscription: { product: { id: 'basic' }, status: 'active' }, // Test's first step overwrites with correct paid product from config
158
158
  },
159
159
  },
160
- 'journey-payment-trial': {
161
- id: 'journey-payment-trial',
162
- uid: '_test-journey-payment-trial',
163
- email: '_test.journey-payment-trial@{domain}',
160
+ 'journey-payments-trial': {
161
+ id: 'journey-payments-trial',
162
+ uid: '_test-journey-payments-trial',
163
+ email: '_test.journey-payments-trial@{domain}',
164
164
  properties: {
165
165
  roles: {},
166
166
  subscription: { product: { id: 'basic' }, status: 'active' }, // Starts as basic, upgraded via trial webhook
package/templates/_.env CHANGED
@@ -10,10 +10,8 @@ GITHUB_TOKEN=""
10
10
  OPENAI_API_KEY=""
11
11
 
12
12
  # Payment Processors
13
- PAYPAL_CLIENT_ID=""
14
13
  PAYPAL_CLIENT_SECRET=""
15
14
  STRIPE_SECRET_KEY=""
16
- CHARGEBEE_SITE=""
17
15
  CHARGEBEE_API_KEY=""
18
16
  COINBASE_API_KEY=""
19
17
 
@@ -25,6 +25,56 @@
25
25
  secret: 'ABCx1234567890ABCDEFGH',
26
26
  },
27
27
  oauth2: {},
28
+ payment: {
29
+ processors: {
30
+ stripe: {
31
+ publishableKey: null,
32
+ },
33
+ paypal: {
34
+ clientId: null,
35
+ },
36
+ chargebee: {
37
+ site: null,
38
+ },
39
+ coinbase: {
40
+ enabled: false,
41
+ },
42
+ },
43
+ products: [
44
+ {
45
+ id: 'basic',
46
+ name: 'Basic',
47
+ type: 'subscription',
48
+ limits: {
49
+ requests: 100,
50
+ },
51
+ },
52
+ {
53
+ id: 'premium',
54
+ name: 'Premium',
55
+ type: 'subscription',
56
+ limits: {
57
+ requests: 1000,
58
+ },
59
+ trial: {
60
+ days: 14,
61
+ },
62
+ prices: {
63
+ monthly: {
64
+ amount: 4.99,
65
+ stripe: 'price_xxx',
66
+ paypal: 'P-xxx',
67
+ },
68
+ annually: {
69
+ amount: 49.99,
70
+ stripe: 'price_yyy',
71
+ paypal: 'P-yyy',
72
+ },
73
+ },
74
+ },
75
+ // Add more products/tiers here
76
+ ],
77
+ },
28
78
  firebaseConfig: {
29
79
  apiKey: '123-456',
30
80
  authDomain: 'PROJECT-ID.firebaseapp.com',
@@ -51,40 +101,6 @@
51
101
  // appUrl: 'https://api.otherapp.com', // Required if app is set (fetches /backend-manager/app)
52
102
  }
53
103
  ],
54
- products: [
55
- {
56
- id: 'basic',
57
- name: 'Basic',
58
- type: 'subscription',
59
- limits: {
60
- requests: 100,
61
- },
62
- },
63
- {
64
- id: 'premium',
65
- name: 'Premium',
66
- type: 'subscription',
67
- limits: {
68
- requests: 1000,
69
- },
70
- trial: {
71
- days: 14,
72
- },
73
- prices: {
74
- monthly: {
75
- amount: 4.99,
76
- stripe: 'price_xxx',
77
- paypal: 'P-xxx',
78
- },
79
- annually: {
80
- amount: 49.99,
81
- stripe: 'price_yyy',
82
- paypal: 'P-yyy',
83
- },
84
- },
85
- },
86
- // Add more products/tiers here
87
- ],
88
104
  reviews: {
89
105
  enabled: true,
90
106
  sites: [
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Test: Payment Journey - Cancel
3
+ * Simulates: paid active → pending cancel → cancelled
4
+ *
5
+ * Uses test intent for initial subscription, then manual webhooks for cancel flow
6
+ * Product-agnostic: resolves the first paid product from config.payment.products
7
+ */
8
+ module.exports = {
9
+ description: 'Payment journey: paid → pending cancel → cancelled',
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-cancel'].uid;
18
+
19
+ // Resolve first paid product from config
20
+ const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
21
+ assert.ok(paidProduct, 'Config should have at least one paid product');
22
+
23
+ state.uid = uid;
24
+ state.paidProductId = paidProduct.id;
25
+ state.paidProductName = paidProduct.name;
26
+ state.paidPriceId = paidProduct.prices.monthly.stripe;
27
+
28
+ // Create subscription via test intent
29
+ const response = await http.as('journey-payments-cancel').post('payments/intent', {
30
+ processor: 'test',
31
+ productId: paidProduct.id,
32
+ frequency: 'monthly',
33
+ });
34
+ assert.isSuccess(response, 'Intent should succeed');
35
+
36
+ // Wait for subscription to activate
37
+ await waitFor(async () => {
38
+ const userDoc = await firestore.get(`users/${uid}`);
39
+ return userDoc?.subscription?.product?.id === paidProduct.id;
40
+ }, 15000, 500);
41
+
42
+ const userDoc = await firestore.get(`users/${uid}`);
43
+ assert.equal(userDoc.subscription?.product?.id, paidProduct.id, `Should start as ${paidProduct.id}`);
44
+ assert.equal(userDoc.subscription?.status, 'active', 'Should be active');
45
+
46
+ state.subscriptionId = userDoc.subscription.payment.resourceId;
47
+ },
48
+ },
49
+
50
+ {
51
+ name: 'send-pending-cancel-webhook',
52
+ async run({ http, assert, state, config }) {
53
+ const futureDate = new Date();
54
+ futureDate.setFullYear(futureDate.getFullYear() + 1);
55
+
56
+ state.eventId1 = `_test-evt-journey-cancel-pending-${Date.now()}`;
57
+
58
+ const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerKey}`, {
59
+ id: state.eventId1,
60
+ type: 'customer.subscription.updated',
61
+ data: {
62
+ object: {
63
+ id: state.subscriptionId,
64
+ object: 'subscription',
65
+ status: 'active',
66
+ metadata: { uid: state.uid },
67
+ cancel_at_period_end: true,
68
+ cancel_at: Math.floor(futureDate.getTime() / 1000),
69
+ canceled_at: null,
70
+ current_period_end: Math.floor(futureDate.getTime() / 1000),
71
+ current_period_start: Math.floor(Date.now() / 1000),
72
+ start_date: Math.floor(Date.now() / 1000) - 86400 * 30,
73
+ trial_start: null,
74
+ trial_end: null,
75
+ plan: { id: state.paidPriceId, interval: 'month' },
76
+ },
77
+ },
78
+ });
79
+
80
+ assert.isSuccess(response, 'Webhook should be accepted');
81
+ },
82
+ },
83
+
84
+ {
85
+ name: 'pending-cancel-processed',
86
+ async run({ firestore, assert, state, waitFor }) {
87
+ await waitFor(async () => {
88
+ const doc = await firestore.get(`payments-webhooks/${state.eventId1}`);
89
+ return doc?.status === 'completed';
90
+ }, 15000, 500);
91
+
92
+ const userDoc = await firestore.get(`users/${state.uid}`);
93
+
94
+ assert.equal(userDoc.subscription.status, 'active', 'Status should still be active');
95
+ assert.equal(userDoc.subscription.cancellation.pending, true, 'Cancellation should be pending');
96
+ },
97
+ },
98
+
99
+ {
100
+ name: 'send-cancelled-webhook',
101
+ async run({ http, assert, state, config }) {
102
+ state.eventId2 = `_test-evt-journey-cancel-final-${Date.now()}`;
103
+
104
+ const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerKey}`, {
105
+ id: state.eventId2,
106
+ type: 'customer.subscription.deleted',
107
+ data: {
108
+ object: {
109
+ id: state.subscriptionId,
110
+ object: 'subscription',
111
+ status: 'canceled',
112
+ metadata: { uid: state.uid },
113
+ cancel_at_period_end: false,
114
+ canceled_at: Math.floor(Date.now() / 1000),
115
+ current_period_end: Math.floor(Date.now() / 1000),
116
+ current_period_start: Math.floor(Date.now() / 1000) - 86400 * 30,
117
+ start_date: Math.floor(Date.now() / 1000) - 86400 * 60,
118
+ trial_start: null,
119
+ trial_end: null,
120
+ plan: { id: state.paidPriceId, interval: 'month' },
121
+ },
122
+ },
123
+ });
124
+
125
+ assert.isSuccess(response, 'Webhook should be accepted');
126
+ },
127
+ },
128
+
129
+ {
130
+ name: 'subscription-cancelled',
131
+ async run({ firestore, assert, state, waitFor }) {
132
+ await waitFor(async () => {
133
+ const doc = await firestore.get(`payments-webhooks/${state.eventId2}`);
134
+ return doc?.status === 'completed';
135
+ }, 15000, 500);
136
+
137
+ const userDoc = await firestore.get(`users/${state.uid}`);
138
+
139
+ assert.equal(userDoc.subscription.status, 'cancelled', 'Status should be cancelled');
140
+ assert.equal(userDoc.subscription.cancellation.pending, false, 'Cancellation should not be pending');
141
+ },
142
+ },
143
+ ],
144
+ };
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Test: Payment Journey - Suspend & Recover
3
+ * Simulates: paid active → payment fails → suspended → payment succeeds → active again
4
+ *
5
+ * Uses test intent for initial subscription, then manual webhooks for suspend/recover
6
+ * Product-agnostic: resolves the first paid product from config.payment.products
7
+ */
8
+ module.exports = {
9
+ description: 'Payment journey: paid → suspended → recovered via test processor',
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-suspend'].uid;
18
+
19
+ // Resolve first paid product from config
20
+ const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
21
+ assert.ok(paidProduct, 'Config should have at least one paid product');
22
+
23
+ state.uid = uid;
24
+ state.paidProductId = paidProduct.id;
25
+ state.paidProductName = paidProduct.name;
26
+ state.paidPriceId = paidProduct.prices.monthly.stripe;
27
+
28
+ // Create subscription via test intent
29
+ const response = await http.as('journey-payments-suspend').post('payments/intent', {
30
+ processor: 'test',
31
+ productId: paidProduct.id,
32
+ frequency: 'monthly',
33
+ });
34
+ assert.isSuccess(response, 'Intent should succeed');
35
+
36
+ // Wait for subscription to activate
37
+ await waitFor(async () => {
38
+ const userDoc = await firestore.get(`users/${uid}`);
39
+ return userDoc?.subscription?.product?.id === paidProduct.id;
40
+ }, 15000, 500);
41
+
42
+ const userDoc = await firestore.get(`users/${uid}`);
43
+ assert.equal(userDoc.subscription?.product?.id, paidProduct.id, `Should start as ${paidProduct.id}`);
44
+ assert.equal(userDoc.subscription?.status, 'active', 'Should be active');
45
+
46
+ state.subscriptionId = userDoc.subscription.payment.resourceId;
47
+ },
48
+ },
49
+
50
+ {
51
+ name: 'send-past-due-webhook',
52
+ async run({ http, assert, state, config }) {
53
+ state.eventId1 = `_test-evt-journey-suspend-fail-${Date.now()}`;
54
+
55
+ const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerKey}`, {
56
+ id: state.eventId1,
57
+ type: 'customer.subscription.updated',
58
+ data: {
59
+ object: {
60
+ id: state.subscriptionId,
61
+ object: 'subscription',
62
+ status: 'past_due',
63
+ metadata: { uid: state.uid },
64
+ cancel_at_period_end: false,
65
+ canceled_at: null,
66
+ current_period_end: Math.floor(Date.now() / 1000) + 86400,
67
+ current_period_start: Math.floor(Date.now() / 1000) - 86400 * 29,
68
+ start_date: Math.floor(Date.now() / 1000) - 86400 * 60,
69
+ trial_start: null,
70
+ trial_end: null,
71
+ plan: { id: state.paidPriceId, interval: 'month' },
72
+ },
73
+ },
74
+ });
75
+
76
+ assert.isSuccess(response, 'Webhook should be accepted');
77
+ },
78
+ },
79
+
80
+ {
81
+ name: 'subscription-suspended',
82
+ async run({ firestore, assert, state, waitFor }) {
83
+ await waitFor(async () => {
84
+ const doc = await firestore.get(`payments-webhooks/${state.eventId1}`);
85
+ return doc?.status === 'completed';
86
+ }, 15000, 500);
87
+
88
+ const userDoc = await firestore.get(`users/${state.uid}`);
89
+
90
+ assert.equal(userDoc.subscription.status, 'suspended', 'Status should be suspended');
91
+ assert.equal(userDoc.subscription.product.id, state.paidProductId, `Product should still be ${state.paidProductId}`);
92
+ },
93
+ },
94
+
95
+ {
96
+ name: 'send-recovery-webhook',
97
+ async run({ http, assert, state, config }) {
98
+ const futureDate = new Date();
99
+ futureDate.setMonth(futureDate.getMonth() + 1);
100
+
101
+ state.eventId2 = `_test-evt-journey-suspend-recover-${Date.now()}`;
102
+
103
+ const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerKey}`, {
104
+ id: state.eventId2,
105
+ type: 'customer.subscription.updated',
106
+ data: {
107
+ object: {
108
+ id: state.subscriptionId,
109
+ object: 'subscription',
110
+ status: 'active',
111
+ metadata: { uid: state.uid },
112
+ cancel_at_period_end: false,
113
+ canceled_at: null,
114
+ current_period_end: Math.floor(futureDate.getTime() / 1000),
115
+ current_period_start: Math.floor(Date.now() / 1000),
116
+ start_date: Math.floor(Date.now() / 1000) - 86400 * 60,
117
+ trial_start: null,
118
+ trial_end: null,
119
+ plan: { id: state.paidPriceId, interval: 'month' },
120
+ },
121
+ },
122
+ });
123
+
124
+ assert.isSuccess(response, 'Webhook should be accepted');
125
+ },
126
+ },
127
+
128
+ {
129
+ name: 'subscription-recovered',
130
+ async run({ firestore, assert, state, waitFor }) {
131
+ await waitFor(async () => {
132
+ const doc = await firestore.get(`payments-webhooks/${state.eventId2}`);
133
+ return doc?.status === 'completed';
134
+ }, 15000, 500);
135
+
136
+ const userDoc = await firestore.get(`users/${state.uid}`);
137
+
138
+ assert.equal(userDoc.subscription.status, 'active', 'Status should be active again');
139
+ assert.equal(userDoc.subscription.product.id, state.paidProductId, `Product should still be ${state.paidProductId}`);
140
+ },
141
+ },
142
+ ],
143
+ };
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Test: Payment Journey - Trial
3
+ * Simulates: basic user → trial activation via test intent → trial ends → active paid
4
+ *
5
+ * Uses the test processor for initial trial, then manual webhook for trial-to-active
6
+ * Product-agnostic: resolves the first paid product from config.payment.products
7
+ */
8
+ module.exports = {
9
+ description: 'Payment journey: basic → trial → active paid via test processor',
10
+ type: 'suite',
11
+ timeout: 30000,
12
+
13
+ tests: [
14
+ {
15
+ name: 'verify-starts-as-basic',
16
+ async run({ accounts, firestore, assert, state, config }) {
17
+ const uid = accounts['journey-payments-trial'].uid;
18
+ const userDoc = await firestore.get(`users/${uid}`);
19
+
20
+ assert.ok(userDoc, 'User doc should exist');
21
+ assert.equal(userDoc.subscription?.product?.id, 'basic', 'Should start as basic');
22
+ assert.equal(userDoc.subscription?.trial?.claimed, false, 'Trial should not be claimed');
23
+
24
+ // Resolve first paid product from config
25
+ const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
26
+ assert.ok(paidProduct, 'Config should have at least one paid product');
27
+
28
+ state.uid = uid;
29
+ state.paidProductId = paidProduct.id;
30
+ state.paidPriceId = paidProduct.prices.monthly.stripe;
31
+ },
32
+ },
33
+
34
+ {
35
+ name: 'create-trial-intent',
36
+ async run({ http, assert, state }) {
37
+ const response = await http.as('journey-payments-trial').post('payments/intent', {
38
+ processor: 'test',
39
+ productId: state.paidProductId,
40
+ frequency: 'monthly',
41
+ trial: true,
42
+ });
43
+
44
+ assert.isSuccess(response, 'Intent should succeed');
45
+ assert.ok(response.data.id, 'Should return intent ID');
46
+
47
+ state.intentId = response.data.id;
48
+ },
49
+ },
50
+
51
+ {
52
+ name: 'trial-activated',
53
+ async run({ firestore, assert, state, waitFor }) {
54
+ // Poll until trial subscription appears
55
+ await waitFor(async () => {
56
+ const userDoc = await firestore.get(`users/${state.uid}`);
57
+ return userDoc?.subscription?.trial?.claimed === true;
58
+ }, 15000, 500);
59
+
60
+ const userDoc = await firestore.get(`users/${state.uid}`);
61
+
62
+ assert.equal(userDoc.subscription.product.id, state.paidProductId, `Product should be ${state.paidProductId}`);
63
+ assert.equal(userDoc.subscription.status, 'active', 'Status should be active');
64
+ assert.equal(userDoc.subscription.trial.claimed, true, 'Trial should be claimed');
65
+
66
+ state.subscriptionId = userDoc.subscription.payment.resourceId;
67
+ },
68
+ },
69
+
70
+ {
71
+ name: 'send-trial-to-active-webhook',
72
+ async run({ http, assert, state, config }) {
73
+ const futureDate = new Date();
74
+ futureDate.setMonth(futureDate.getMonth() + 1);
75
+
76
+ state.eventId2 = `_test-evt-journey-trial-active-${Date.now()}`;
77
+
78
+ const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerKey}`, {
79
+ id: state.eventId2,
80
+ type: 'customer.subscription.updated',
81
+ data: {
82
+ object: {
83
+ id: state.subscriptionId,
84
+ object: 'subscription',
85
+ status: 'active',
86
+ metadata: { uid: state.uid },
87
+ cancel_at_period_end: false,
88
+ canceled_at: null,
89
+ current_period_end: Math.floor(futureDate.getTime() / 1000),
90
+ current_period_start: Math.floor(Date.now() / 1000),
91
+ start_date: Math.floor(Date.now() / 1000) - 86400 * 14,
92
+ trial_start: Math.floor(Date.now() / 1000) - 86400 * 14,
93
+ trial_end: Math.floor(Date.now() / 1000),
94
+ plan: { id: state.paidPriceId, interval: 'month' },
95
+ },
96
+ },
97
+ });
98
+
99
+ assert.isSuccess(response, 'Webhook should be accepted');
100
+ },
101
+ },
102
+
103
+ {
104
+ name: 'trial-transitioned-to-active',
105
+ async run({ firestore, assert, state, waitFor }) {
106
+ await waitFor(async () => {
107
+ const doc = await firestore.get(`payments-webhooks/${state.eventId2}`);
108
+ return doc?.status === 'completed';
109
+ }, 15000, 500);
110
+
111
+ const userDoc = await firestore.get(`users/${state.uid}`);
112
+
113
+ assert.equal(userDoc.subscription.product.id, state.paidProductId, `Product should be ${state.paidProductId}`);
114
+ assert.equal(userDoc.subscription.status, 'active', 'Status should be active');
115
+ assert.equal(userDoc.subscription.trial.claimed, true, 'Trial should remain claimed (historical)');
116
+ assert.equal(userDoc.subscription.payment.frequency, 'monthly', 'Frequency should be monthly');
117
+ },
118
+ },
119
+ ],
120
+ };