backend-manager 5.0.73 → 5.0.74

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/CLAUDE.md +70 -0
  2. package/README.md +81 -7
  3. package/package.json +1 -1
  4. package/src/manager/cron/daily/reset-usage.js +5 -32
  5. package/src/manager/events/firestore/payments-webhooks/on-write.js +126 -0
  6. package/src/manager/functions/core/actions/api/admin/get-stats.js +3 -3
  7. package/src/manager/functions/core/actions/api/general/add-marketing-contact.js +1 -1
  8. package/src/manager/functions/core/actions/api/user/delete.js +5 -3
  9. package/src/manager/functions/core/actions/api/user/get-subscription-info.js +25 -9
  10. package/src/manager/functions/core/actions/api/user/validate-settings.js +1 -1
  11. package/src/manager/helpers/analytics.js +4 -4
  12. package/src/manager/helpers/api-manager.js +25 -42
  13. package/src/manager/helpers/middleware.js +1 -1
  14. package/src/manager/helpers/usage.js +24 -93
  15. package/src/manager/helpers/user.js +29 -38
  16. package/src/manager/index.js +22 -10
  17. package/src/manager/libraries/stripe.js +293 -0
  18. package/src/manager/routes/admin/stats/get.js +3 -3
  19. package/src/manager/routes/marketing/contact/post.js +1 -1
  20. package/src/manager/routes/payments/intent/post.js +94 -0
  21. package/src/manager/routes/payments/intent/providers/stripe.js +66 -0
  22. package/src/manager/routes/payments/webhook/post.js +87 -0
  23. package/src/manager/routes/payments/webhook/providers/stripe.js +35 -0
  24. package/src/manager/routes/test/schema/post.js +5 -5
  25. package/src/manager/routes/user/delete.js +5 -3
  26. package/src/manager/routes/user/settings/validate/post.js +3 -3
  27. package/src/manager/routes/user/subscription/get.js +25 -9
  28. package/src/manager/schemas/payments/intent/post.js +22 -0
  29. package/src/manager/schemas/payments/webhook/post.js +6 -0
  30. package/src/manager/schemas/test/schema/post.js +1 -1
  31. package/src/test/test-accounts.js +63 -25
  32. package/src/test/utils/firestore-rules-client.js +5 -5
  33. package/templates/backend-manager-config.json +32 -0
  34. package/templates/firestore.rules +1 -1
  35. package/test/_init/accounts-validation.js +3 -3
  36. package/test/functions/user/delete.js +1 -1
  37. package/test/functions/user/get-subscription-info.js +18 -24
  38. package/test/payments/intent.js +104 -0
  39. package/test/payments/journey-payment-cancel.js +166 -0
  40. package/test/payments/journey-payment-suspend.js +162 -0
  41. package/test/payments/journey-payment-trial.js +167 -0
  42. package/test/payments/journey-payment-upgrade.js +136 -0
  43. package/test/payments/webhook.js +128 -0
  44. package/test/routes/test/schema.js +1 -1
  45. package/test/routes/user/delete.js +1 -1
  46. package/test/routes/user/subscription.js +18 -24
  47. package/test/routes/user/user.js +14 -14
  48. package/test/rules/user.js +8 -8
  49. package/src/manager/helpers/subscription-resolver-new.js +0 -827
  50. package/src/manager/helpers/subscription-resolver.js +0 -841
@@ -47,4 +47,36 @@
47
47
  author: 'alex-raeburn',
48
48
  }
49
49
  ],
50
+ products: [
51
+ {
52
+ id: 'basic',
53
+ name: 'Basic',
54
+ type: 'free',
55
+ limits: {
56
+ requests: 100,
57
+ },
58
+ },
59
+ {
60
+ id: 'premium',
61
+ name: 'Premium',
62
+ type: 'subscription',
63
+ limits: {
64
+ requests: 1000,
65
+ },
66
+ trial: {
67
+ days: 14,
68
+ },
69
+ prices: {
70
+ monthly: {
71
+ stripe: 'price_xxx',
72
+ paypal: 'P-xxx',
73
+ },
74
+ annually: {
75
+ stripe: 'price_yyy',
76
+ paypal: 'P-yyy',
77
+ },
78
+ },
79
+ },
80
+ // Add more products/tiers here
81
+ ],
50
82
  }
@@ -58,7 +58,7 @@ service cloud.firestore {
58
58
  return isWritingField('auth')
59
59
  || isWritingField('roles')
60
60
  || isWritingField('flags')
61
- || isWritingField('plan')
61
+ || isWritingField('subscription')
62
62
  || isWritingField('affiliate')
63
63
  || isWritingField('api')
64
64
  || isWritingField('usage');
@@ -48,9 +48,9 @@ module.exports = {
48
48
  assert.hasProperty(userDoc, 'activity.created.timestamp', `Account '${accountId}' should have activity.created.timestamp`);
49
49
  assert.hasProperty(userDoc, 'activity.created.timestampUNIX', `Account '${accountId}' should have activity.created.timestampUNIX`);
50
50
 
51
- // Validate plan fields (merged from test account properties)
52
- assert.hasProperty(userDoc, 'plan.id', `Account '${accountId}' should have plan.id`);
53
- assert.hasProperty(userDoc, 'plan.status', `Account '${accountId}' should have plan.status`);
51
+ // Validate subscription fields (merged from test account properties)
52
+ assert.hasProperty(userDoc, 'subscription.product.id', `Account '${accountId}' should have subscription.product.id`);
53
+ assert.hasProperty(userDoc, 'subscription.status', `Account '${accountId}' should have subscription.status`);
54
54
  }
55
55
 
56
56
  return { success: true };
@@ -53,7 +53,7 @@ module.exports = {
53
53
  name: 'remove-subscription-from-delete-user',
54
54
  async run({ firestore, state }) {
55
55
  // Remove the subscription (set to null to overwrite)
56
- await firestore.set(`users/${state.deleteUid}`, { plan: null }, { merge: true });
56
+ await firestore.set(`users/${state.deleteUid}`, { subscription: null }, { merge: true });
57
57
  },
58
58
  },
59
59
 
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Test: user:get-subscription-info
3
3
  * Tests the user get subscription info command
4
- * Returns plan details for authenticated users
4
+ * Returns subscription details for authenticated users
5
5
  */
6
6
  module.exports = {
7
7
  description: 'User get subscription info',
@@ -17,17 +17,17 @@ module.exports = {
17
17
  const response = await http.command('user:get-subscription-info', {});
18
18
 
19
19
  assert.isSuccess(response, 'Get subscription info should succeed for authenticated user');
20
- assert.hasProperty(response, 'data.plan', 'Response should contain plan object');
21
- assert.hasProperty(response, 'data.plan.id', 'Plan should have id');
22
- assert.hasProperty(response, 'data.plan.expires', 'Plan should have expires');
23
- assert.hasProperty(response, 'data.plan.trial', 'Plan should have trial info');
24
- assert.hasProperty(response, 'data.plan.payment', 'Plan should have payment info');
20
+ assert.hasProperty(response, 'data.subscription', 'Response should contain subscription object');
21
+ assert.hasProperty(response, 'data.subscription.product.id', 'Subscription should have id');
22
+ assert.hasProperty(response, 'data.subscription.expires', 'Subscription should have expires');
23
+ assert.hasProperty(response, 'data.subscription.trial', 'Subscription should have trial info');
24
+ assert.hasProperty(response, 'data.subscription.payment', 'Subscription should have payment info');
25
25
  },
26
26
  },
27
27
 
28
- // Test 2: Plan has correct structure
28
+ // Test 2: Subscription has correct structure
29
29
  {
30
- name: 'plan-structure-valid',
30
+ name: 'subscription-structure-valid',
31
31
  auth: 'basic',
32
32
  timeout: 15000,
33
33
 
@@ -36,31 +36,26 @@ module.exports = {
36
36
 
37
37
  assert.isSuccess(response, 'Get subscription info should succeed');
38
38
 
39
- const plan = response.data.plan;
39
+ const subscription = response.data.subscription;
40
40
 
41
41
  // Check expires structure
42
- assert.hasProperty(response, 'data.plan.expires.timestamp', 'expires should have timestamp');
43
- assert.hasProperty(response, 'data.plan.expires.timestampUNIX', 'expires should have timestampUNIX');
42
+ assert.hasProperty(response, 'data.subscription.expires.timestamp', 'expires should have timestamp');
43
+ assert.hasProperty(response, 'data.subscription.expires.timestampUNIX', 'expires should have timestampUNIX');
44
44
 
45
45
  // Check trial structure
46
46
  assert.ok(
47
- typeof plan.trial.activated === 'boolean',
47
+ typeof subscription.trial.activated === 'boolean',
48
48
  'trial.activated should be boolean'
49
49
  );
50
- assert.hasProperty(response, 'data.plan.trial.date', 'trial should have date');
51
- assert.hasProperty(response, 'data.plan.trial.date.timestamp', 'trial.date should have timestamp');
52
50
 
53
51
  // Check payment structure
54
- assert.ok(
55
- typeof plan.payment.active === 'boolean',
56
- 'payment.active should be boolean'
57
- );
52
+ assert.hasProperty(response, 'data.subscription.payment', 'subscription should have payment');
58
53
  },
59
54
  },
60
55
 
61
56
  // Test 3: Premium user has active subscription
62
57
  {
63
- name: 'premium-user-has-active-plan',
58
+ name: 'premium-user-has-active-subscription',
64
59
  auth: 'premium-active',
65
60
  timeout: 15000,
66
61
 
@@ -68,9 +63,8 @@ module.exports = {
68
63
  const response = await http.command('user:get-subscription-info', {});
69
64
 
70
65
  assert.isSuccess(response, 'Get subscription info should succeed for premium user');
71
- // The API returns plan.id from the user doc (test account has plan.id = 'premium')
72
- assert.hasProperty(response, 'data.plan.id', 'Premium user should have plan id');
73
- assert.hasProperty(response, 'data.plan.payment', 'Premium user should have payment info');
66
+ assert.hasProperty(response, 'data.subscription.product.id', 'Premium user should have subscription id');
67
+ assert.hasProperty(response, 'data.subscription.payment', 'Premium user should have payment info');
74
68
  },
75
69
  },
76
70
 
@@ -84,8 +78,8 @@ module.exports = {
84
78
  const response = await http.command('user:get-subscription-info', {});
85
79
 
86
80
  assert.isSuccess(response, 'Get subscription info should succeed for expired premium');
87
- assert.hasProperty(response, 'data.plan.id', 'Should still have plan id');
88
- assert.hasProperty(response, 'data.plan.expires.timestampUNIX', 'Should have expires timestamp');
81
+ assert.hasProperty(response, 'data.subscription.product.id', 'Should still have subscription id');
82
+ assert.hasProperty(response, 'data.subscription.expires.timestampUNIX', 'Should have expires timestamp');
89
83
  },
90
84
  },
91
85
 
@@ -0,0 +1,104 @@
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
+ };
@@ -0,0 +1,166 @@
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
+ };
@@ -0,0 +1,162 @@
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
+ };