backend-manager 5.0.84 → 5.0.86

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/usage.js +51 -3
  17. package/src/manager/index.js +5 -19
  18. package/src/manager/libraries/stripe.js +12 -8
  19. package/src/manager/libraries/test.js +27 -0
  20. package/src/manager/routes/app/get.js +11 -8
  21. package/src/manager/routes/payments/intent/post.js +31 -16
  22. package/src/manager/routes/payments/intent/processors/stripe.js +130 -0
  23. package/src/manager/routes/payments/intent/processors/test.js +106 -0
  24. package/src/manager/routes/payments/webhook/post.js +21 -8
  25. package/src/manager/routes/payments/webhook/{providers → processors}/stripe.js +16 -1
  26. package/src/manager/routes/payments/webhook/processors/test.js +15 -0
  27. package/src/manager/routes/user/subscription/get.js +1 -1
  28. package/src/manager/schemas/payments/webhook/post.js +1 -1
  29. package/src/test/test-accounts.js +18 -18
  30. package/templates/_.env +0 -2
  31. package/templates/backend-manager-config.json +50 -34
  32. package/test/events/payments/journey-payments-cancel.js +144 -0
  33. package/test/events/payments/journey-payments-suspend.js +143 -0
  34. package/test/events/payments/journey-payments-trial.js +120 -0
  35. package/test/events/payments/journey-payments-upgrade.js +99 -0
  36. package/test/fixtures/stripe/subscription-active.json +161 -0
  37. package/test/fixtures/stripe/subscription-canceled.json +161 -0
  38. package/test/fixtures/stripe/subscription-trialing.json +161 -0
  39. package/test/functions/user/get-subscription-info.js +2 -2
  40. package/test/helpers/stripe-to-unified.js +684 -0
  41. package/test/routes/payments/intent.js +189 -0
  42. package/test/{payments → routes/payments}/webhook.js +1 -1
  43. package/test/routes/test/usage.js +7 -6
  44. package/test/routes/user/subscription.js +2 -2
  45. package/src/manager/routes/payments/intent/providers/stripe.js +0 -66
  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
@@ -3,8 +3,12 @@ const powertools = require('node-powertools');
3
3
 
4
4
  /**
5
5
  * POST /payments/webhook?processor=stripe&key=XXX
6
- * Receives payment provider webhooks, validates them, and saves to Firestore
6
+ * Receives payment processor webhooks, validates them, and saves to Firestore
7
7
  * The Firestore onWrite trigger handles async processing
8
+ *
9
+ * This handler is processor-agnostic. Each processor module defines:
10
+ * - parseWebhook(req) — extracts { eventId, eventType, raw, uid }
11
+ * - isSupported(eventType) — returns true for events we should process
8
12
  */
9
13
  module.exports = async ({ assistant, Manager, libraries }) => {
10
14
  const { admin } = libraries;
@@ -25,32 +29,41 @@ module.exports = async ({ assistant, Manager, libraries }) => {
25
29
  return assistant.respond('Invalid key', { code: 401 });
26
30
  }
27
31
 
28
- // Load the provider
29
- let provider;
32
+ // Load the processor module
33
+ let processorModule;
30
34
  try {
31
- provider = require(path.resolve(__dirname, `providers/${processor}.js`));
35
+ processorModule = require(path.resolve(__dirname, `processors/${processor}.js`));
32
36
  } catch (e) {
33
37
  return assistant.respond(`Unknown processor: ${processor}`, { code: 400 });
34
38
  }
35
39
 
36
- // Parse the webhook using the provider
40
+ // Parse the webhook using the processor
37
41
  let parsed;
38
42
  try {
39
- parsed = provider.parseWebhook(assistant.ref.req);
43
+ parsed = processorModule.parseWebhook(assistant.ref.req);
40
44
  } catch (e) {
41
45
  return assistant.respond(`Failed to parse webhook: ${e.message}`, { code: 400 });
42
46
  }
43
47
 
44
48
  const { eventId, eventType, raw, uid } = parsed;
45
49
 
50
+ assistant.log(`Parsed webhook: eventId=${eventId}, eventType=${eventType}, uid=${uid || 'null'}`);
51
+
52
+ // Let the processor decide if this event type is relevant
53
+ if (processorModule.isSupported && !processorModule.isSupported(eventType)) {
54
+ assistant.log(`Ignoring event type: ${eventType}`);
55
+ return assistant.respond({ received: true, ignored: true });
56
+ }
57
+
46
58
  // Check for duplicate (skip if already processing/completed)
47
59
  const existingDoc = await admin.firestore().doc(`payments-webhooks/${eventId}`).get();
48
60
  if (existingDoc.exists) {
49
61
  const existingStatus = existingDoc.data()?.status;
50
62
  if (existingStatus !== 'failed') {
51
- assistant.log(`Webhook ${eventId} already exists with status=${existingStatus}, skipping`);
63
+ assistant.log(`Duplicate webhook ${eventId}, existing status=${existingStatus}, skipping`);
52
64
  return assistant.respond({ received: true, duplicate: true });
53
65
  }
66
+ assistant.log(`Retrying previously failed webhook ${eventId}`);
54
67
  }
55
68
 
56
69
  // Build timestamps
@@ -80,7 +93,7 @@ module.exports = async ({ assistant, Manager, libraries }) => {
80
93
  },
81
94
  });
82
95
 
83
- assistant.log(`Webhook ${eventId} saved (type=${eventType}, processor=${processor}, uid=${uid})`);
96
+ assistant.log(`Saved payments-webhooks/${eventId}: eventType=${eventType}, processor=${processor}, uid=${uid}`);
84
97
 
85
98
  // Return 200 immediately
86
99
  return assistant.respond({ received: true });
@@ -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
+ };