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
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Test: POST /payments/intent
3
+ * Tests intent creation endpoint validation + end-to-end flow via test processor
4
+ *
5
+ * Validation tests use processor=stripe (fail at SDK step, proving validation logic)
6
+ * Success tests use processor=test (full intent→webhook→trigger pipeline)
7
+ *
8
+ * Product-agnostic: resolves the first paid product from config.payment.products
9
+ */
10
+ module.exports = {
11
+ description: 'Payment intent creation',
12
+ type: 'group',
13
+ timeout: 30000,
14
+
15
+ tests: [
16
+ {
17
+ name: 'rejects-unauthenticated',
18
+ auth: 'none',
19
+ async run({ http, assert, config }) {
20
+ const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
21
+
22
+ const response = await http.as('none').post('payments/intent', {
23
+ processor: 'stripe',
24
+ productId: paidProduct.id,
25
+ frequency: 'monthly',
26
+ });
27
+
28
+ assert.isError(response, 401, 'Should reject unauthenticated request');
29
+ },
30
+ },
31
+
32
+ {
33
+ name: 'rejects-missing-processor',
34
+ async run({ http, assert, config }) {
35
+ const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
36
+
37
+ const response = await http.as('basic').post('payments/intent', {
38
+ productId: paidProduct.id,
39
+ frequency: 'monthly',
40
+ });
41
+
42
+ assert.isError(response, 400, 'Should reject missing processor');
43
+ },
44
+ },
45
+
46
+ {
47
+ name: 'rejects-missing-product-id',
48
+ async run({ http, assert }) {
49
+ const response = await http.as('basic').post('payments/intent', {
50
+ processor: 'stripe',
51
+ frequency: 'monthly',
52
+ });
53
+
54
+ assert.isError(response, 400, 'Should reject missing productId');
55
+ },
56
+ },
57
+
58
+ {
59
+ name: 'rejects-missing-frequency',
60
+ async run({ http, assert, config }) {
61
+ const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
62
+
63
+ const response = await http.as('basic').post('payments/intent', {
64
+ processor: 'stripe',
65
+ productId: paidProduct.id,
66
+ });
67
+
68
+ assert.isError(response, 400, 'Should reject missing frequency');
69
+ },
70
+ },
71
+
72
+ {
73
+ name: 'rejects-active-paid-user',
74
+ auth: 'premium-active',
75
+ async run({ http, assert, config }) {
76
+ const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
77
+
78
+ const response = await http.as('premium-active').post('payments/intent', {
79
+ processor: 'stripe',
80
+ productId: paidProduct.id,
81
+ frequency: 'monthly',
82
+ });
83
+
84
+ assert.isError(response, 400, 'Should reject user with active subscription');
85
+ },
86
+ },
87
+
88
+ {
89
+ name: 'rejects-invalid-product',
90
+ async run({ http, assert }) {
91
+ const response = await http.as('basic').post('payments/intent', {
92
+ processor: 'stripe',
93
+ productId: 'nonexistent-product',
94
+ frequency: 'monthly',
95
+ });
96
+
97
+ assert.isError(response, 400, 'Should reject invalid product');
98
+ },
99
+ },
100
+
101
+ {
102
+ name: 'rejects-unknown-processor',
103
+ async run({ http, assert, config }) {
104
+ const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
105
+
106
+ const response = await http.as('basic').post('payments/intent', {
107
+ processor: 'unknown-processor',
108
+ productId: paidProduct.id,
109
+ frequency: 'monthly',
110
+ });
111
+
112
+ assert.isError(response, 400, 'Should reject unknown processor');
113
+ },
114
+ },
115
+
116
+ {
117
+ name: 'succeeds-with-test-processor',
118
+ async run({ http, assert, config, firestore, accounts, waitFor }) {
119
+ const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
120
+
121
+ const response = await http.as('basic').post('payments/intent', {
122
+ processor: 'test',
123
+ productId: paidProduct.id,
124
+ frequency: 'monthly',
125
+ });
126
+
127
+ assert.isSuccess(response, 'Should succeed with test processor');
128
+ assert.ok(response.data.id, 'Should return intent ID');
129
+ assert.ok(response.data.url, 'Should return URL');
130
+
131
+ // Verify intent doc was saved
132
+ const intentDoc = await firestore.get(`payments-intents/${response.data.id}`);
133
+ assert.ok(intentDoc, 'Intent doc should exist');
134
+ assert.equal(intentDoc.processor, 'test', 'Processor should be test');
135
+ assert.equal(intentDoc.productId, paidProduct.id, 'Product should match');
136
+
137
+ // Clean up: wait for auto-webhook to process, then restore basic user
138
+ await waitFor(async () => {
139
+ const userDoc = await firestore.get(`users/${accounts.basic.uid}`);
140
+ return userDoc?.subscription?.product?.id === paidProduct.id;
141
+ }, 15000, 500).catch(() => {});
142
+
143
+ await firestore.set(`users/${accounts.basic.uid}`, {
144
+ subscription: { product: { id: 'basic' }, status: 'active' },
145
+ }, { merge: true });
146
+ },
147
+ },
148
+
149
+ {
150
+ name: 'downgrades-trial-for-user-with-history',
151
+ async run({ http, assert, config, accounts, firestore, waitFor }) {
152
+ const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
153
+ const uid = accounts.basic.uid;
154
+ const subDocPath = `payments-subscriptions/_test-sub-history-${uid}`;
155
+
156
+ // Create fake subscription history so user is ineligible for trial
157
+ await firestore.set(subDocPath, { uid, processor: 'test', status: 'cancelled' });
158
+
159
+ try {
160
+ const response = await http.as('basic').post('payments/intent', {
161
+ processor: 'test',
162
+ productId: paidProduct.id,
163
+ frequency: 'monthly',
164
+ trial: true,
165
+ });
166
+
167
+ // Should succeed (not reject with 400) — trial silently downgraded
168
+ assert.isSuccess(response, 'Should not reject — trial silently downgraded');
169
+
170
+ // Verify intent saved with trial=false
171
+ const intentDoc = await firestore.get(`payments-intents/${response.data.id}`);
172
+ assert.equal(intentDoc.trial, false, 'Trial should be false (downgraded)');
173
+
174
+ // Clean up: wait for auto-webhook, restore basic user
175
+ await waitFor(async () => {
176
+ const userDoc = await firestore.get(`users/${uid}`);
177
+ return userDoc?.subscription?.product?.id === paidProduct.id;
178
+ }, 15000, 500).catch(() => {});
179
+
180
+ await firestore.set(`users/${uid}`, {
181
+ subscription: { product: { id: 'basic' }, status: 'active' },
182
+ }, { merge: true });
183
+ } finally {
184
+ await firestore.delete(subDocPath);
185
+ }
186
+ },
187
+ },
188
+ ],
189
+ };
@@ -2,7 +2,7 @@
2
2
  * Test: POST /payments/webhook
3
3
  * Tests the webhook endpoint validates requests and saves to Firestore
4
4
  */
5
- const { TEST_ACCOUNTS } = require('../../src/test/test-accounts.js');
5
+ const { TEST_ACCOUNTS } = require('../../../src/test/test-accounts.js');
6
6
 
7
7
  module.exports = {
8
8
  description: 'Payment webhook endpoint',
@@ -171,17 +171,18 @@ module.exports = {
171
171
  {
172
172
  name: 'unauthenticated-usage-by-ip',
173
173
  async run({ http, assert, state }) {
174
- // Unauthenticated requests use IP as key (127.0.0.1 in emulator)
175
- state.unauthKey = '127.0.0.1';
174
+ // Unauthenticated requests use IP as key (no proxy headers in emulator, so falls back to 'unknown')
175
+ state.unauthKey = 'unknown';
176
176
 
177
177
  const response = await http.as('none').post('test/usage', {});
178
178
 
179
179
  assert.isSuccess(response, 'Unauthenticated usage increment should succeed');
180
180
  assert.equal(response.data.authenticated, false, 'Should report as unauthenticated');
181
- assert.equal(response.data.key, state.unauthKey, 'Key should be 127.0.0.1');
181
+ assert.equal(response.data.key, state.unauthKey, 'Key should be unknown');
182
182
 
183
- // Verify increment happened
184
- assert.equal(response.data.after.period, 1, 'Period should be 1 after first increment');
183
+ // Verify increment happened (relative check — prior tests may have incremented too)
184
+ state.unauthPeriod = response.data.after.period;
185
+ assert.equal(response.data.after.period, response.data.before.period + 1, 'Period should increment by 1');
185
186
  },
186
187
  },
187
188
 
@@ -193,7 +194,7 @@ module.exports = {
193
194
 
194
195
  assert.ok(usageDoc, 'Usage doc should exist in usage collection');
195
196
  assert.ok(usageDoc?.requests, 'Usage doc should have the requests metric');
196
- assert.equal(usageDoc.requests.period, 1, 'Persisted period should be 1');
197
+ assert.equal(usageDoc.requests.period, state.unauthPeriod, 'Persisted period should match');
197
198
  },
198
199
  },
199
200
 
@@ -44,8 +44,8 @@ module.exports = {
44
44
 
45
45
  // Check trial structure
46
46
  assert.ok(
47
- typeof subscription.trial.activated === 'boolean',
48
- 'trial.activated should be boolean'
47
+ typeof subscription.trial.claimed === 'boolean',
48
+ 'trial.claimed should be boolean'
49
49
  );
50
50
 
51
51
  // Check payment structure
@@ -1,104 +0,0 @@
1
- /**
2
- * Test: POST /payments/intent
3
- * Tests intent creation endpoint validation
4
- *
5
- * Note: These tests validate the route logic (auth, validation, subscription checks)
6
- * They do NOT create real Stripe sessions since there's no Stripe key in test env
7
- */
8
- module.exports = {
9
- description: 'Payment intent creation',
10
- type: 'group',
11
- timeout: 30000,
12
-
13
- tests: [
14
- {
15
- name: 'rejects-unauthenticated',
16
- auth: 'none',
17
- async run({ http, assert }) {
18
- const response = await http.as('none').post('payments/intent', {
19
- processor: 'stripe',
20
- productId: 'premium',
21
- frequency: 'monthly',
22
- });
23
-
24
- assert.isError(response, 401, 'Should reject unauthenticated request');
25
- },
26
- },
27
-
28
- {
29
- name: 'rejects-missing-processor',
30
- async run({ http, assert }) {
31
- const response = await http.as('basic').post('payments/intent', {
32
- productId: 'premium',
33
- frequency: 'monthly',
34
- });
35
-
36
- assert.isError(response, 400, 'Should reject missing processor');
37
- },
38
- },
39
-
40
- {
41
- name: 'rejects-missing-product-id',
42
- async run({ http, assert }) {
43
- const response = await http.as('basic').post('payments/intent', {
44
- processor: 'stripe',
45
- frequency: 'monthly',
46
- });
47
-
48
- assert.isError(response, 400, 'Should reject missing productId');
49
- },
50
- },
51
-
52
- {
53
- name: 'rejects-missing-frequency',
54
- async run({ http, assert }) {
55
- const response = await http.as('basic').post('payments/intent', {
56
- processor: 'stripe',
57
- productId: 'premium',
58
- });
59
-
60
- assert.isError(response, 400, 'Should reject missing frequency');
61
- },
62
- },
63
-
64
- {
65
- name: 'rejects-active-premium-user',
66
- auth: 'premium-active',
67
- async run({ http, assert }) {
68
- const response = await http.as('premium-active').post('payments/intent', {
69
- processor: 'stripe',
70
- productId: 'premium',
71
- frequency: 'monthly',
72
- });
73
-
74
- assert.isError(response, 400, 'Should reject user with active subscription');
75
- },
76
- },
77
-
78
- {
79
- name: 'rejects-invalid-product',
80
- async run({ http, assert }) {
81
- const response = await http.as('basic').post('payments/intent', {
82
- processor: 'stripe',
83
- productId: 'nonexistent-product',
84
- frequency: 'monthly',
85
- });
86
-
87
- assert.isError(response, 400, 'Should reject invalid product');
88
- },
89
- },
90
-
91
- {
92
- name: 'rejects-unknown-processor',
93
- async run({ http, assert }) {
94
- const response = await http.as('basic').post('payments/intent', {
95
- processor: 'unknown-processor',
96
- productId: 'premium',
97
- frequency: 'monthly',
98
- });
99
-
100
- assert.isError(response, 400, 'Should reject unknown processor');
101
- },
102
- },
103
- ],
104
- };
@@ -1,166 +0,0 @@
1
- /**
2
- * Test: Payment Journey - Cancel
3
- * Simulates: premium active → pending cancel → cancelled
4
- *
5
- * Step 1: Webhook with cancel_at_period_end=true → cancellation.pending=true
6
- * Step 2: Webhook with status=canceled → status=cancelled, id=basic
7
- */
8
- const powertools = require('node-powertools');
9
-
10
- module.exports = {
11
- description: 'Payment journey: premium → pending cancel → cancelled',
12
- type: 'suite',
13
- timeout: 30000,
14
-
15
- tests: [
16
- {
17
- name: 'verify-starts-as-premium',
18
- async run({ accounts, firestore, assert, state }) {
19
- const uid = accounts['journey-payment-cancel'].uid;
20
- const userDoc = await firestore.get(`users/${uid}`);
21
-
22
- assert.ok(userDoc, 'User doc should exist');
23
- assert.equal(userDoc.subscription?.product?.id, 'premium', 'Should start as premium');
24
- assert.equal(userDoc.subscription?.status, 'active', 'Should be active');
25
-
26
- state.uid = uid;
27
- state.subscriptionId = '_test-sub-journey-cancel';
28
- },
29
- },
30
-
31
- {
32
- name: 'write-pending-cancel-webhook',
33
- async run({ firestore, state }) {
34
- const now = powertools.timestamp(new Date(), { output: 'string' });
35
- const nowUNIX = powertools.timestamp(now, { output: 'unix' });
36
- const futureDate = new Date();
37
- futureDate.setFullYear(futureDate.getFullYear() + 1);
38
-
39
- state.eventId1 = '_test-evt-journey-cancel-pending';
40
-
41
- await firestore.delete(`payments-webhooks/${state.eventId1}`);
42
- await firestore.delete(`payments-subscriptions/${state.subscriptionId}`);
43
-
44
- // Webhook: active subscription with cancel_at_period_end = true
45
- await firestore.set(`payments-webhooks/${state.eventId1}`, {
46
- id: state.eventId1,
47
- processor: 'stripe',
48
- status: 'pending',
49
- uid: state.uid,
50
- event: { type: 'customer.subscription.updated' },
51
- raw: {
52
- id: state.eventId1,
53
- type: 'customer.subscription.updated',
54
- data: {
55
- object: {
56
- id: state.subscriptionId,
57
- object: 'subscription',
58
- status: 'active',
59
- metadata: { uid: state.uid },
60
- cancel_at_period_end: true,
61
- cancel_at: Math.floor(futureDate.getTime() / 1000),
62
- canceled_at: null,
63
- current_period_end: Math.floor(futureDate.getTime() / 1000),
64
- current_period_start: Math.floor(Date.now() / 1000),
65
- start_date: Math.floor(Date.now() / 1000) - 86400 * 30,
66
- trial_start: null,
67
- trial_end: null,
68
- plan: { id: 'price_xxx', interval: 'month' },
69
- },
70
- },
71
- },
72
- error: null,
73
- metadata: {
74
- received: { timestamp: now, timestampUNIX: nowUNIX },
75
- processed: { timestamp: null, timestampUNIX: null },
76
- },
77
- });
78
- },
79
- },
80
-
81
- {
82
- name: 'pending-cancel-processed',
83
- async run({ firestore, assert, state, waitFor }) {
84
- await waitFor(async () => {
85
- const doc = await firestore.get(`payments-webhooks/${state.eventId1}`);
86
- return doc?.status === 'completed';
87
- }, 15000, 500);
88
-
89
- const userDoc = await firestore.get(`users/${state.uid}`);
90
-
91
- assert.equal(userDoc.subscription.status, 'active', 'Status should still be active');
92
- assert.equal(userDoc.subscription.cancellation.pending, true, 'Cancellation should be pending');
93
- },
94
- },
95
-
96
- {
97
- name: 'write-cancelled-webhook',
98
- async run({ firestore, state }) {
99
- const now = powertools.timestamp(new Date(), { output: 'string' });
100
- const nowUNIX = powertools.timestamp(now, { output: 'unix' });
101
-
102
- state.eventId2 = '_test-evt-journey-cancel-final';
103
-
104
- await firestore.delete(`payments-webhooks/${state.eventId2}`);
105
-
106
- // Webhook: subscription now cancelled
107
- await firestore.set(`payments-webhooks/${state.eventId2}`, {
108
- id: state.eventId2,
109
- processor: 'stripe',
110
- status: 'pending',
111
- uid: state.uid,
112
- event: { type: 'customer.subscription.deleted' },
113
- raw: {
114
- id: state.eventId2,
115
- type: 'customer.subscription.deleted',
116
- data: {
117
- object: {
118
- id: state.subscriptionId,
119
- object: 'subscription',
120
- status: 'canceled',
121
- metadata: { uid: state.uid },
122
- cancel_at_period_end: false,
123
- canceled_at: Math.floor(Date.now() / 1000),
124
- current_period_end: Math.floor(Date.now() / 1000),
125
- current_period_start: Math.floor(Date.now() / 1000) - 86400 * 30,
126
- start_date: Math.floor(Date.now() / 1000) - 86400 * 60,
127
- trial_start: null,
128
- trial_end: null,
129
- plan: { id: 'price_xxx', interval: 'month' },
130
- },
131
- },
132
- },
133
- error: null,
134
- metadata: {
135
- received: { timestamp: now, timestampUNIX: nowUNIX },
136
- processed: { timestamp: null, timestampUNIX: null },
137
- },
138
- });
139
- },
140
- },
141
-
142
- {
143
- name: 'subscription-cancelled',
144
- async run({ firestore, assert, state, waitFor }) {
145
- await waitFor(async () => {
146
- const doc = await firestore.get(`payments-webhooks/${state.eventId2}`);
147
- return doc?.status === 'completed';
148
- }, 15000, 500);
149
-
150
- const userDoc = await firestore.get(`users/${state.uid}`);
151
-
152
- assert.equal(userDoc.subscription.status, 'cancelled', 'Status should be cancelled');
153
- assert.equal(userDoc.subscription.cancellation.pending, false, 'Cancellation should not be pending');
154
- },
155
- },
156
-
157
- {
158
- name: 'cleanup',
159
- async run({ firestore, state }) {
160
- await firestore.delete(`payments-webhooks/${state.eventId1}`);
161
- await firestore.delete(`payments-webhooks/${state.eventId2}`);
162
- await firestore.delete(`payments-subscriptions/${state.subscriptionId}`);
163
- },
164
- },
165
- ],
166
- };
@@ -1,162 +0,0 @@
1
- /**
2
- * Test: Payment Journey - Suspend & Recover
3
- * Simulates: premium active → payment fails → suspended → payment succeeds → active again
4
- */
5
- const powertools = require('node-powertools');
6
-
7
- module.exports = {
8
- description: 'Payment journey: premium → suspended → recovered',
9
- type: 'suite',
10
- timeout: 30000,
11
-
12
- tests: [
13
- {
14
- name: 'verify-starts-as-premium',
15
- async run({ accounts, firestore, assert, state }) {
16
- const uid = accounts['journey-payment-suspend'].uid;
17
- const userDoc = await firestore.get(`users/${uid}`);
18
-
19
- assert.ok(userDoc, 'User doc should exist');
20
- assert.equal(userDoc.subscription?.product?.id, 'premium', 'Should start as premium');
21
- assert.equal(userDoc.subscription?.status, 'active', 'Should be active');
22
-
23
- state.uid = uid;
24
- state.subscriptionId = '_test-sub-journey-suspend';
25
- },
26
- },
27
-
28
- {
29
- name: 'write-past-due-webhook',
30
- async run({ firestore, state }) {
31
- const now = powertools.timestamp(new Date(), { output: 'string' });
32
- const nowUNIX = powertools.timestamp(now, { output: 'unix' });
33
-
34
- state.eventId1 = '_test-evt-journey-suspend-fail';
35
-
36
- await firestore.delete(`payments-webhooks/${state.eventId1}`);
37
- await firestore.delete(`payments-subscriptions/${state.subscriptionId}`);
38
-
39
- // Webhook: subscription status changed to past_due
40
- await firestore.set(`payments-webhooks/${state.eventId1}`, {
41
- id: state.eventId1,
42
- processor: 'stripe',
43
- status: 'pending',
44
- uid: state.uid,
45
- event: { type: 'customer.subscription.updated' },
46
- raw: {
47
- id: state.eventId1,
48
- type: 'customer.subscription.updated',
49
- data: {
50
- object: {
51
- id: state.subscriptionId,
52
- object: 'subscription',
53
- status: 'past_due',
54
- metadata: { uid: state.uid },
55
- cancel_at_period_end: false,
56
- canceled_at: null,
57
- current_period_end: Math.floor(Date.now() / 1000) + 86400,
58
- current_period_start: Math.floor(Date.now() / 1000) - 86400 * 29,
59
- start_date: Math.floor(Date.now() / 1000) - 86400 * 60,
60
- trial_start: null,
61
- trial_end: null,
62
- plan: { id: 'price_xxx', interval: 'month' },
63
- },
64
- },
65
- },
66
- error: null,
67
- metadata: {
68
- received: { timestamp: now, timestampUNIX: nowUNIX },
69
- processed: { timestamp: null, timestampUNIX: null },
70
- },
71
- });
72
- },
73
- },
74
-
75
- {
76
- name: 'subscription-suspended',
77
- async run({ firestore, assert, state, waitFor }) {
78
- await waitFor(async () => {
79
- const doc = await firestore.get(`payments-webhooks/${state.eventId1}`);
80
- return doc?.status === 'completed';
81
- }, 15000, 500);
82
-
83
- const userDoc = await firestore.get(`users/${state.uid}`);
84
-
85
- assert.equal(userDoc.subscription.status, 'suspended', 'Status should be suspended');
86
- assert.equal(userDoc.subscription.product.id, 'premium', 'Product should still be premium');
87
- },
88
- },
89
-
90
- {
91
- name: 'write-recovery-webhook',
92
- async run({ firestore, state }) {
93
- const now = powertools.timestamp(new Date(), { output: 'string' });
94
- const nowUNIX = powertools.timestamp(now, { output: 'unix' });
95
- const futureDate = new Date();
96
- futureDate.setMonth(futureDate.getMonth() + 1);
97
-
98
- state.eventId2 = '_test-evt-journey-suspend-recover';
99
-
100
- await firestore.delete(`payments-webhooks/${state.eventId2}`);
101
-
102
- // Webhook: subscription back to active after payment succeeds
103
- await firestore.set(`payments-webhooks/${state.eventId2}`, {
104
- id: state.eventId2,
105
- processor: 'stripe',
106
- status: 'pending',
107
- uid: state.uid,
108
- event: { type: 'customer.subscription.updated' },
109
- raw: {
110
- id: state.eventId2,
111
- type: 'customer.subscription.updated',
112
- data: {
113
- object: {
114
- id: state.subscriptionId,
115
- object: 'subscription',
116
- status: 'active',
117
- metadata: { uid: state.uid },
118
- cancel_at_period_end: false,
119
- canceled_at: null,
120
- current_period_end: Math.floor(futureDate.getTime() / 1000),
121
- current_period_start: Math.floor(Date.now() / 1000),
122
- start_date: Math.floor(Date.now() / 1000) - 86400 * 60,
123
- trial_start: null,
124
- trial_end: null,
125
- plan: { id: 'price_xxx', interval: 'month' },
126
- },
127
- },
128
- },
129
- error: null,
130
- metadata: {
131
- received: { timestamp: now, timestampUNIX: nowUNIX },
132
- processed: { timestamp: null, timestampUNIX: null },
133
- },
134
- });
135
- },
136
- },
137
-
138
- {
139
- name: 'subscription-recovered',
140
- async run({ firestore, assert, state, waitFor }) {
141
- await waitFor(async () => {
142
- const doc = await firestore.get(`payments-webhooks/${state.eventId2}`);
143
- return doc?.status === 'completed';
144
- }, 15000, 500);
145
-
146
- const userDoc = await firestore.get(`users/${state.uid}`);
147
-
148
- assert.equal(userDoc.subscription.status, 'active', 'Status should be active again');
149
- assert.equal(userDoc.subscription.product.id, 'premium', 'Product should still be premium');
150
- },
151
- },
152
-
153
- {
154
- name: 'cleanup',
155
- async run({ firestore, state }) {
156
- await firestore.delete(`payments-webhooks/${state.eventId1}`);
157
- await firestore.delete(`payments-webhooks/${state.eventId2}`);
158
- await firestore.delete(`payments-subscriptions/${state.subscriptionId}`);
159
- },
160
- },
161
- ],
162
- };