backend-manager 5.1.4 → 5.2.0

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 (75) hide show
  1. package/.claude/settings.local.json +12 -0
  2. package/CHANGELOG.md +23 -0
  3. package/CLAUDE.md +2 -1
  4. package/README.md +15 -0
  5. package/docs/common-mistakes.md +1 -0
  6. package/docs/consent.md +333 -0
  7. package/docs/testing.md +36 -0
  8. package/package.json +1 -1
  9. package/src/cli/commands/emulator.js +44 -8
  10. package/src/cli/commands/serve.js +73 -7
  11. package/src/cli/commands/test.js +47 -1
  12. package/src/cli/commands/watch.js +15 -3
  13. package/src/manager/helpers/user.js +29 -0
  14. package/src/manager/index.js +29 -0
  15. package/src/manager/libraries/email/data/disposable-domains.json +8 -0
  16. package/src/manager/libraries/email/generators/newsletter.js +2 -2
  17. package/src/manager/libraries/email/providers/beehiiv.js +1 -0
  18. package/src/manager/libraries/payment/processors/stripe.js +12 -0
  19. package/src/manager/libraries/payment/processors/test.js +8 -1
  20. package/src/manager/routes/admin/infer-contact/post.js +3 -2
  21. package/src/manager/routes/marketing/email-preferences/post.js +165 -37
  22. package/src/manager/routes/marketing/webhook/forward/post.js +168 -0
  23. package/src/manager/routes/marketing/webhook/post.js +180 -0
  24. package/src/manager/routes/marketing/webhook/processors/beehiiv.js +196 -0
  25. package/src/manager/routes/marketing/webhook/processors/sendgrid.js +171 -0
  26. package/src/manager/routes/payments/cancel/post.js +2 -2
  27. package/src/manager/routes/payments/cancel/processors/test.js +5 -2
  28. package/src/manager/routes/payments/intent/processors/test.js +7 -3
  29. package/src/manager/routes/payments/refund/processors/test.js +4 -1
  30. package/src/manager/routes/user/signup/post.js +65 -1
  31. package/src/manager/schemas/marketing/email-preferences/post.js +8 -4
  32. package/src/manager/schemas/marketing/webhook/forward/post.js +8 -0
  33. package/src/manager/schemas/marketing/webhook/post.js +7 -0
  34. package/src/manager/schemas/payments/cancel/post.js +5 -0
  35. package/src/manager/schemas/user/signup/post.js +5 -0
  36. package/src/test/runner.js +61 -18
  37. package/src/test/test-accounts.js +94 -12
  38. package/src/test/utils/http-client.js +4 -3
  39. package/test/events/payments/journey-payments-cancel-endpoint.js +3 -12
  40. package/test/events/payments/journey-payments-cancel.js +4 -5
  41. package/test/events/payments/journey-payments-failure.js +0 -1
  42. package/test/events/payments/journey-payments-one-time-failure.js +6 -3
  43. package/test/events/payments/journey-payments-one-time.js +6 -3
  44. package/test/events/payments/journey-payments-plan-change.js +5 -5
  45. package/test/events/payments/journey-payments-refund-webhook.js +2 -3
  46. package/test/events/payments/journey-payments-suspend.js +4 -5
  47. package/test/events/payments/journey-payments-trial-cancel.js +3 -12
  48. package/test/events/payments/journey-payments-trial.js +2 -3
  49. package/test/events/payments/journey-payments-uid-resolution.js +2 -3
  50. package/test/functions/admin/database-read.js +0 -14
  51. package/test/functions/admin/database-write.js +0 -14
  52. package/test/functions/admin/firestore-query.js +0 -14
  53. package/test/functions/admin/firestore-read.js +0 -15
  54. package/test/functions/admin/firestore-write.js +0 -11
  55. package/test/functions/general/add-marketing-contact.js +16 -14
  56. package/test/helpers/email.js +1 -1
  57. package/test/helpers/infer-contact.js +3 -3
  58. package/test/helpers/user.js +241 -2
  59. package/test/helpers/webhook-forward.js +392 -0
  60. package/test/marketing/newsletter-generate.js +17 -7
  61. package/test/routes/admin/database.js +0 -13
  62. package/test/routes/admin/firestore-query.js +0 -13
  63. package/test/routes/admin/firestore.js +0 -14
  64. package/test/routes/admin/infer-contact.js +6 -3
  65. package/test/routes/admin/post.js +4 -2
  66. package/test/routes/marketing/contact.js +60 -26
  67. package/test/routes/marketing/email-preferences.js +145 -69
  68. package/test/routes/marketing/webhook-forward.js +54 -0
  69. package/test/routes/marketing/webhook.js +582 -0
  70. package/test/routes/payments/cancel.js +2 -7
  71. package/test/routes/payments/dispute-alert.js +0 -39
  72. package/test/routes/payments/refund.js +3 -1
  73. package/test/routes/payments/webhook.js +5 -26
  74. package/test/routes/test/usage.js +2 -2
  75. package/test/routes/user/signup.js +114 -0
@@ -87,7 +87,7 @@ class TestRunner {
87
87
  // Health check (use basic http client without accounts)
88
88
  // Use hosting URL for all requests (rewrites to bm_api function)
89
89
  const healthHttp = new HttpClient({
90
- hostingUrl: this.options.hostingUrl,
90
+ apiUrl: this.options.apiUrl,
91
91
  timeout: this.options.timeout,
92
92
  });
93
93
 
@@ -119,23 +119,30 @@ class TestRunner {
119
119
  await this.runTestsInDir(projectTestsDir, 'project');
120
120
  }
121
121
 
122
+ // Post-run cleanup: scrub test accounts from third-party marketing providers
123
+ // (SendGrid/Beehiiv) so each test run leaves the contact list in the same
124
+ // state it found it. Pairs with the pre-run cleanup as defense in depth —
125
+ // pre-run handles crashed previous runs, post-run handles the current run.
126
+ // Only fires in extended mode (normal mode never touches real providers).
127
+ if (process.env.TEST_EXTENDED_MODE) {
128
+ process.stdout.write(chalk.gray('\n Cleaning up test accounts from marketing providers... '));
129
+ try {
130
+ const cleanupResult = await testAccounts.cleanupMarketingProviders(this.options.domain, {
131
+ apiUrl: this.options.apiUrl,
132
+ backendManagerKey: this.options.backendManagerKey,
133
+ });
134
+ console.log(chalk.green(`✓ (${cleanupResult.cleaned} cleaned)`));
135
+ } catch (e) {
136
+ // Post-run cleanup is best-effort — failures shouldn't change the test result
137
+ console.log(chalk.yellow(`⚠ cleanup error: ${e.message}`));
138
+ }
139
+ }
140
+
122
141
  // Cleanup rules context
123
142
  if (this.rulesContext) {
124
143
  await this.rulesContext.cleanup();
125
144
  }
126
145
 
127
- // Clean up test accounts from marketing providers (SendGrid/Beehiiv)
128
- // Run at end of tests so auth:on-create has time to complete
129
- if (process.env.TEST_EXTENDED_MODE) {
130
- console.log('');
131
- process.stdout.write(chalk.gray(' Cleaning test accounts from marketing providers... '));
132
- const cleanupResult = await testAccounts.cleanupMarketingProviders(this.options.domain, {
133
- apiUrl: this.options.apiUrl,
134
- backendManagerKey: this.options.backendManagerKey,
135
- });
136
- console.log(chalk.green(`✓ (${cleanupResult.cleaned} cleaned)`));
137
- }
138
-
139
146
  // Report results
140
147
  this.reportResults();
141
148
 
@@ -147,9 +154,9 @@ class TestRunner {
147
154
  * Validate configuration
148
155
  */
149
156
  validateConfig() {
150
- if (!this.options.hostingUrl) {
151
- console.log(chalk.red(' ✗ Missing hostingUrl'));
152
- console.log(chalk.gray(' Set BEM_HOSTING_URL environment variable or pass --url flag'));
157
+ if (!this.options.apiUrl) {
158
+ console.log(chalk.red(' ✗ Missing apiUrl'));
159
+ console.log(chalk.gray(' Set BEM_API_URL environment variable or pass --url flag'));
153
160
  return false;
154
161
  }
155
162
 
@@ -199,7 +206,7 @@ class TestRunner {
199
206
 
200
207
  console.log(chalk.red('✗'));
201
208
  console.log(chalk.red(` Server not responding: ${response.error}`));
202
- console.log(chalk.gray(` Make sure your functions are deployed and running at ${this.options.hostingUrl}`));
209
+ console.log(chalk.gray(` Make sure your functions are deployed and running at ${this.options.apiUrl}`));
203
210
  return false;
204
211
  } catch (error) {
205
212
  console.log(chalk.red('✗'));
@@ -222,6 +229,18 @@ class TestRunner {
222
229
  const deleteResult = await testAccounts.deleteTestUsers(this.options.admin);
223
230
  console.log(chalk.green(`✓ (${deleteResult.deleted} deleted, ${deleteResult.skipped} skipped)`));
224
231
 
232
+ // Clean any leftover test accounts from third-party marketing providers
233
+ // (SendGrid/Beehiiv). Runs BEFORE we create fresh users so a previously
234
+ // killed run doesn't leave the contact list polluted.
235
+ if (process.env.TEST_EXTENDED_MODE) {
236
+ process.stdout.write(chalk.gray(' Cleaning test accounts from marketing providers... '));
237
+ const cleanupResult = await testAccounts.cleanupMarketingProviders(this.options.domain, {
238
+ apiUrl: this.options.apiUrl,
239
+ backendManagerKey: this.options.backendManagerKey,
240
+ });
241
+ console.log(chalk.green(`✓ (${cleanupResult.cleaned} cleaned)`));
242
+ }
243
+
225
244
  process.stdout.write(chalk.gray(' Creating test accounts... '));
226
245
 
227
246
  // Create fresh test accounts
@@ -532,6 +551,20 @@ class TestRunner {
532
551
  duration,
533
552
  suite: suiteDescription,
534
553
  });
554
+
555
+ // For suites (sequential, state-dependent tests), a skip on any step means
556
+ // subsequent steps can't run cleanly — propagate skip to the rest of the suite.
557
+ // Groups (independent tests) continue normally.
558
+ const shouldStopOnSkip = suite.type !== 'group' && suite.stopOnFailure !== false;
559
+ if (shouldStopOnSkip) {
560
+ const remaining = tests.length - i - 1;
561
+ if (remaining > 0) {
562
+ console.log(chalk.yellow(` Skipping ${remaining} remaining test(s) in suite (suite-level skip)`));
563
+ this.results.skipped += remaining;
564
+ }
565
+ break;
566
+ }
567
+
535
568
  continue;
536
569
  }
537
570
 
@@ -655,7 +688,7 @@ class TestRunner {
655
688
  // Create HTTP client with accounts for as() method
656
689
  // Use hosting URL for all requests (rewrites to bm_api function)
657
690
  const http = new HttpClient({
658
- hostingUrl: this.options.hostingUrl,
691
+ apiUrl: this.options.apiUrl,
659
692
  timeout: this.options.timeout,
660
693
  accounts: this.accounts,
661
694
  backendManagerKey: this.options.backendManagerKey,
@@ -694,6 +727,15 @@ class TestRunner {
694
727
  throw new SkipError(reason);
695
728
  };
696
729
 
730
+ // Precomputed map of BEM product id → Stripe-compatible product ID for tests.
731
+ // Falls back to the "_test_<id>" sentinel when no real Stripe product is configured,
732
+ // letting the Stripe resolver match it back to the BEM product. SSOT for tests that
733
+ // need to construct Stripe-shaped webhook payloads (cancel, refund, plan-change, etc.).
734
+ const products = this.config.payment?.products || [];
735
+ const stripeProductIds = Object.fromEntries(
736
+ products.map((p) => [p.id, p.stripe?.productId || `_test_${p.id}`])
737
+ );
738
+
697
739
  return {
698
740
  http,
699
741
  accounts: this.accounts,
@@ -711,6 +753,7 @@ class TestRunner {
711
753
  assistant: this.config.assistant,
712
754
  rules: this.rulesContext,
713
755
  config: this.config,
756
+ payments: { stripeProductIds },
714
757
  };
715
758
  }
716
759
 
@@ -162,6 +162,33 @@ const STATIC_ACCOUNTS = {
162
162
  subscription: { product: { id: 'basic' }, status: 'active' },
163
163
  },
164
164
  },
165
+ 'consent-granted': {
166
+ id: 'consent-granted',
167
+ uid: '_test-consent-granted',
168
+ email: '_test.consent-granted@{domain}',
169
+ properties: {
170
+ roles: {},
171
+ subscription: { product: { id: 'basic' }, status: 'active' },
172
+ },
173
+ },
174
+ 'consent-declined': {
175
+ id: 'consent-declined',
176
+ uid: '_test-consent-declined',
177
+ email: '_test.consent-declined@{domain}',
178
+ properties: {
179
+ roles: {},
180
+ subscription: { product: { id: 'basic' }, status: 'active' },
181
+ },
182
+ },
183
+ 'consent-missing': {
184
+ id: 'consent-missing',
185
+ uid: '_test-consent-missing',
186
+ email: '_test.consent-missing@{domain}',
187
+ properties: {
188
+ roles: {},
189
+ subscription: { product: { id: 'basic' }, status: 'active' },
190
+ },
191
+ },
165
192
  };
166
193
 
167
194
  /**
@@ -652,15 +679,53 @@ async function deleteTestUsers(admin) {
652
679
  })
653
680
  );
654
681
 
655
- // Clean up payment-related collections for test accounts
682
+ // Clean up payment-related collections for test accounts.
683
+ // Two passes per collection:
684
+ // 1. owner-keyed: query by owner ∈ test uids (Firestore `in` caps at 30; batch).
685
+ // 2. id-keyed: delete any doc whose id starts with the `_test-` prefix.
686
+ // Pass 2 catches docs that have no owner field (e.g. dispute alerts, raw test webhooks).
687
+ // All test-fixture IDs MUST start with `_test-` — that's the cleanup contract.
656
688
  const testUids = Object.values(TEST_ACCOUNTS).map(a => a.uid);
657
- const paymentCollections = ['payments-orders', 'payments-webhooks', 'payments-intents'];
689
+ // Collections that may carry test data tied to a test user (owner-keyed) or
690
+ // identified solely by an `_test-` doc id prefix (id-keyed). All must be wiped
691
+ // at the start of every run so a test that died mid-execution leaves no
692
+ // ghosts. New collections that participate in tests MUST be added here too.
693
+ const testDataCollections = ['payments-orders', 'payments-webhooks', 'payments-intents', 'payments-disputes', 'marketing-webhooks'];
694
+ // Collections that exist solely for tests — wipe in full. All docs in these
695
+ // collections come from tests, so a single recursive delete handles cleanup.
696
+ const testOnlyCollections = ['_test', '_test_query'];
697
+ const UID_BATCH_SIZE = 30;
698
+ const TEST_ID_PREFIX = '_test-';
699
+
700
+ const uidBatches = [];
701
+ for (let i = 0; i < testUids.length; i += UID_BATCH_SIZE) {
702
+ uidBatches.push(testUids.slice(i, i + UID_BATCH_SIZE));
703
+ }
658
704
 
659
- await Promise.all(
660
- paymentCollections.map(async (collection) => {
705
+ await Promise.all([
706
+ // Mixed collections: scoped delete of test data only.
707
+ ...testDataCollections.map(async (collection) => {
708
+ // Pass 1 — owner-keyed
709
+ for (const batch of uidBatches) {
710
+ try {
711
+ const snapshot = await admin.firestore().collection(collection)
712
+ .where('owner', 'in', batch)
713
+ .get();
714
+
715
+ await Promise.all(
716
+ snapshot.docs.map(doc => doc.ref.delete())
717
+ );
718
+ } catch (e) {
719
+ // Collection may not exist yet, or doesn't carry an owner field — ignore
720
+ }
721
+ }
722
+
723
+ // Pass 2 — id-keyed (catches ownerless test docs)
724
+ // documentId() range scan: `_test-` ≤ id < `_test.` (the next ASCII char after `-` is `.`)
661
725
  try {
662
726
  const snapshot = await admin.firestore().collection(collection)
663
- .where('owner', 'in', testUids)
727
+ .where(admin.firestore.FieldPath.documentId(), '>=', TEST_ID_PREFIX)
728
+ .where(admin.firestore.FieldPath.documentId(), '<', '_test.')
664
729
  .get();
665
730
 
666
731
  await Promise.all(
@@ -669,8 +734,25 @@ async function deleteTestUsers(admin) {
669
734
  } catch (e) {
670
735
  // Collection may not exist yet — ignore
671
736
  }
672
- })
673
- );
737
+ }),
738
+ // Test-only Firestore collections: wipe in full.
739
+ ...testOnlyCollections.map(async (collection) => {
740
+ try {
741
+ const snapshot = await admin.firestore().collection(collection).get();
742
+ await Promise.all(snapshot.docs.map(doc => doc.ref.delete()));
743
+ } catch (e) {
744
+ // Collection may not exist yet — ignore
745
+ }
746
+ }),
747
+ // Realtime Database: wipe the `_test` namespace in full.
748
+ (async () => {
749
+ try {
750
+ await admin.database().ref('_test').remove();
751
+ } catch (e) {
752
+ // RTDB may not be configured for this project — ignore
753
+ }
754
+ })(),
755
+ ]);
674
756
 
675
757
  return {
676
758
  success: results.failed.length === 0,
@@ -728,16 +810,16 @@ const TEST_DATA = {
728
810
  * Called after account setup when TEST_EXTENDED_MODE is set to remove
729
811
  * contacts added by auth:on-create
730
812
  * @param {string} domain - Domain for email addresses
731
- * @param {object} options - Options with hostingUrl and backendManagerKey
813
+ * @param {object} options - Options with apiUrl and backendManagerKey
732
814
  * @returns {Promise<object>} Result with cleaned count
733
815
  */
734
816
  async function cleanupMarketingProviders(domain, options = {}) {
735
817
  const fetch = require('wonderful-fetch');
736
818
  const results = { cleaned: 0, errors: [] };
737
819
 
738
- const { hostingUrl, backendManagerKey } = options;
739
- if (!hostingUrl || !backendManagerKey) {
740
- console.error('cleanupMarketingProviders: Missing hostingUrl or backendManagerKey');
820
+ const { apiUrl, backendManagerKey } = options;
821
+ if (!apiUrl || !backendManagerKey) {
822
+ console.error('cleanupMarketingProviders: Missing apiUrl or backendManagerKey');
741
823
  return results;
742
824
  }
743
825
 
@@ -749,7 +831,7 @@ async function cleanupMarketingProviders(domain, options = {}) {
749
831
  await Promise.all(
750
832
  emails.map(async (email) => {
751
833
  try {
752
- const response = await fetch(`${hostingUrl}/backend-manager/marketing/contact`, {
834
+ const response = await fetch(`${apiUrl}/backend-manager/marketing/contact`, {
753
835
  method: 'DELETE',
754
836
  response: 'json',
755
837
  timeout: 30000,
@@ -11,9 +11,10 @@ class HttpClient {
11
11
  constructor(options) {
12
12
  options = options || {};
13
13
 
14
- // Use hosting URL (port 5002) for all requests, not functions URL (port 5001)
15
- // All requests go through /backend-manager which rewrites to bm_api function
16
- this.baseUrl = options.hostingUrl || '';
14
+ // Use API URL (port 5002, the hosting emulator) for all requests, not the
15
+ // functions URL (port 5001). All requests go through /backend-manager which
16
+ // rewrites to the bm_api function.
17
+ this.baseUrl = options.apiUrl || '';
17
18
  this.defaultHeaders = {};
18
19
  this.defaultAuthParams = {};
19
20
  this.timeout = options.timeout || 30000;
@@ -44,26 +44,17 @@ module.exports = {
44
44
  },
45
45
  },
46
46
 
47
- {
48
- name: 'backdate-start-date',
49
- async run({ firestore, state }) {
50
- // Backdate startDate so the 24-hour guard doesn't block cancellation
51
- const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
52
- await firestore.set(`users/${state.uid}`, {
53
- subscription: { payment: { startDate: { timestamp: twoDaysAgo.toISOString(), timestampUNIX: twoDaysAgo.getTime() } } },
54
- }, { merge: true });
55
- },
56
- },
57
-
58
47
  {
59
48
  name: 'call-cancel-endpoint',
60
49
  async run({ http, assert }) {
61
50
  // Test processor writes a payments-webhooks doc directly,
62
- // triggering the on-write pipeline automatically — no manual webhook needed
51
+ // triggering the on-write pipeline automatically — no manual webhook needed.
52
+ // skipGuards bypasses the 24-hour subscription-age guard.
63
53
  const response = await http.as('journey-payments-cancel-route').post('payments/cancel', {
64
54
  confirmed: true,
65
55
  reason: 'Too expensive',
66
56
  feedback: 'Would return at a lower price',
57
+ skipGuards: true,
67
58
  });
68
59
 
69
60
  assert.isSuccess(response, 'Cancel endpoint should succeed');
@@ -23,7 +23,6 @@ module.exports = {
23
23
  state.uid = uid;
24
24
  state.paidProductId = paidProduct.id;
25
25
  state.paidProductName = paidProduct.name;
26
- state.paidStripeProductId = paidProduct.stripe?.productId;
27
26
 
28
27
  // Create subscription via test intent
29
28
  const response = await http.as('journey-payments-cancel').post('payments/intent', {
@@ -51,7 +50,7 @@ module.exports = {
51
50
 
52
51
  {
53
52
  name: 'send-pending-cancel-webhook',
54
- async run({ http, assert, state, config }) {
53
+ async run({ http, assert, state, config, payments }) {
55
54
  const futureDate = new Date();
56
55
  futureDate.setFullYear(futureDate.getFullYear() + 1);
57
56
 
@@ -74,7 +73,7 @@ module.exports = {
74
73
  start_date: Math.floor(Date.now() / 1000) - 86400 * 30,
75
74
  trial_start: null,
76
75
  trial_end: null,
77
- plan: { product: state.paidStripeProductId, interval: 'month' },
76
+ plan: { product: payments.stripeProductIds[state.paidProductId], interval: 'month' },
78
77
  },
79
78
  },
80
79
  });
@@ -103,7 +102,7 @@ module.exports = {
103
102
 
104
103
  {
105
104
  name: 'send-cancelled-webhook',
106
- async run({ http, assert, state, config }) {
105
+ async run({ http, assert, state, config, payments }) {
107
106
  state.eventId2 = `_test-evt-journey-cancel-final-${Date.now()}`;
108
107
 
109
108
  const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerKey}`, {
@@ -122,7 +121,7 @@ module.exports = {
122
121
  start_date: Math.floor(Date.now() / 1000) - 86400 * 60,
123
122
  trial_start: null,
124
123
  trial_end: null,
125
- plan: { product: state.paidStripeProductId, interval: 'month' },
124
+ plan: { product: payments.stripeProductIds[state.paidProductId], interval: 'month' },
126
125
  },
127
126
  },
128
127
  });
@@ -26,7 +26,6 @@ module.exports = {
26
26
  state.uid = uid;
27
27
  state.paidProductId = paidProduct.id;
28
28
  state.paidProductName = paidProduct.name;
29
- state.paidStripeProductId = paidProduct.stripe?.productId;
30
29
 
31
30
  // Create subscription via test intent
32
31
  const response = await http.as('journey-payments-failure').post('payments/intent', {
@@ -15,12 +15,15 @@ module.exports = {
15
15
  tests: [
16
16
  {
17
17
  name: 'resolve-one-time-product',
18
- async run({ accounts, assert, state, config }) {
18
+ async run({ accounts, assert, state, config, skip }) {
19
19
  const uid = accounts['journey-payments-one-time'].uid;
20
20
 
21
- // Resolve first one-time product from config
21
+ // Resolve first one-time product from config. If none configured, skip the
22
+ // entire journey — this is a config-gap, not a code failure.
22
23
  const oneTimeProduct = config.payment.products.find(p => p.type === 'one-time' && p.prices?.once);
23
- assert.ok(oneTimeProduct, 'Config should have at least one one-time product');
24
+ if (!oneTimeProduct) {
25
+ skip('No one-time product configured in this brand');
26
+ }
24
27
 
25
28
  state.uid = uid;
26
29
  state.productId = oneTimeProduct.id;
@@ -16,15 +16,18 @@ module.exports = {
16
16
  tests: [
17
17
  {
18
18
  name: 'resolve-one-time-product',
19
- async run({ accounts, firestore, assert, state, config }) {
19
+ async run({ accounts, firestore, assert, state, config, skip }) {
20
20
  const uid = accounts['journey-payments-one-time'].uid;
21
21
  const userDoc = await firestore.get(`users/${uid}`);
22
22
 
23
23
  assert.ok(userDoc, 'User doc should exist');
24
24
 
25
- // Resolve first one-time product from config
25
+ // Resolve first one-time product from config. If the brand has none configured,
26
+ // skip the entire journey — this is a config-gap, not a code failure.
26
27
  const oneTimeProduct = config.payment.products.find(p => p.type === 'one-time' && p.prices?.once);
27
- assert.ok(oneTimeProduct, 'Config should have at least one one-time product');
28
+ if (!oneTimeProduct) {
29
+ skip('No one-time product configured in this brand');
30
+ }
28
31
 
29
32
  state.uid = uid;
30
33
  state.productId = oneTimeProduct.id;
@@ -24,8 +24,8 @@ module.exports = {
24
24
  const productB = paidProducts[1];
25
25
 
26
26
  state.uid = uid;
27
- state.productA = { id: productA.id, name: productA.name, stripeProductId: productA.stripe?.productId };
28
- state.productB = { id: productB.id, name: productB.name, stripeProductId: productB.stripe?.productId };
27
+ state.productA = { id: productA.id, name: productA.name };
28
+ state.productB = { id: productB.id, name: productB.name };
29
29
 
30
30
  // Create subscription via test intent (product A)
31
31
  const response = await http.as('journey-payments-plan-change').post('payments/intent', {
@@ -52,13 +52,13 @@ module.exports = {
52
52
 
53
53
  {
54
54
  name: 'send-plan-change-webhook',
55
- async run({ http, assert, state, config }) {
55
+ async run({ http, assert, state, config, payments }) {
56
56
  const futureDate = new Date();
57
57
  futureDate.setMonth(futureDate.getMonth() + 1);
58
58
 
59
59
  state.eventId = `_test-evt-journey-plan-change-${Date.now()}`;
60
60
 
61
- // Send subscription.updated with a different product's Stripe product ID
61
+ // Send subscription.updated with product B's Stripe product ID (or test sentinel)
62
62
  const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerKey}`, {
63
63
  id: state.eventId,
64
64
  type: 'customer.subscription.updated',
@@ -75,7 +75,7 @@ module.exports = {
75
75
  start_date: Math.floor(Date.now() / 1000) - 86400 * 30,
76
76
  trial_start: null,
77
77
  trial_end: null,
78
- plan: { product: state.productB.stripeProductId, interval: 'month' },
78
+ plan: { product: payments.stripeProductIds[state.productB.id], interval: 'month' },
79
79
  },
80
80
  },
81
81
  });
@@ -26,7 +26,6 @@ module.exports = {
26
26
 
27
27
  state.uid = uid;
28
28
  state.paidProductId = paidProduct.id;
29
- state.paidStripeProductId = paidProduct.stripe?.productId;
30
29
 
31
30
  // Create subscription via test intent
32
31
  const response = await http.as('journey-payments-refund-webhook').post('payments/intent', {
@@ -54,7 +53,7 @@ module.exports = {
54
53
 
55
54
  {
56
55
  name: 'send-pending-cancel-webhook',
57
- async run({ http, assert, state, config }) {
56
+ async run({ http, assert, state, config, payments }) {
58
57
  const futureDate = new Date();
59
58
  futureDate.setMonth(futureDate.getMonth() + 1);
60
59
 
@@ -77,7 +76,7 @@ module.exports = {
77
76
  start_date: Math.floor(Date.now() / 1000) - 86400 * 30,
78
77
  trial_start: null,
79
78
  trial_end: null,
80
- plan: { product: state.paidStripeProductId, interval: 'month' },
79
+ plan: { product: payments.stripeProductIds[state.paidProductId], interval: 'month' },
81
80
  },
82
81
  },
83
82
  });
@@ -23,7 +23,6 @@ module.exports = {
23
23
  state.uid = uid;
24
24
  state.paidProductId = paidProduct.id;
25
25
  state.paidProductName = paidProduct.name;
26
- state.paidStripeProductId = paidProduct.stripe?.productId;
27
26
 
28
27
  // Create subscription via test intent
29
28
  const response = await http.as('journey-payments-suspend').post('payments/intent', {
@@ -51,7 +50,7 @@ module.exports = {
51
50
 
52
51
  {
53
52
  name: 'send-past-due-webhook',
54
- async run({ http, assert, state, config }) {
53
+ async run({ http, assert, state, config, payments }) {
55
54
  state.eventId1 = `_test-evt-journey-suspend-fail-${Date.now()}`;
56
55
 
57
56
  const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerKey}`, {
@@ -70,7 +69,7 @@ module.exports = {
70
69
  start_date: Math.floor(Date.now() / 1000) - 86400 * 60,
71
70
  trial_start: null,
72
71
  trial_end: null,
73
- plan: { product: state.paidStripeProductId, interval: 'month' },
72
+ plan: { product: payments.stripeProductIds[state.paidProductId], interval: 'month' },
74
73
  },
75
74
  },
76
75
  });
@@ -99,7 +98,7 @@ module.exports = {
99
98
 
100
99
  {
101
100
  name: 'send-recovery-webhook',
102
- async run({ http, assert, state, config }) {
101
+ async run({ http, assert, state, config, payments }) {
103
102
  const futureDate = new Date();
104
103
  futureDate.setMonth(futureDate.getMonth() + 1);
105
104
 
@@ -121,7 +120,7 @@ module.exports = {
121
120
  start_date: Math.floor(Date.now() / 1000) - 86400 * 60,
122
121
  trial_start: null,
123
122
  trial_end: null,
124
- plan: { product: state.paidStripeProductId, interval: 'month' },
123
+ plan: { product: payments.stripeProductIds[state.paidProductId], interval: 'month' },
125
124
  },
126
125
  },
127
126
  });
@@ -73,25 +73,16 @@ module.exports = {
73
73
  },
74
74
  },
75
75
 
76
- {
77
- name: 'backdate-start-date',
78
- async run({ firestore, state }) {
79
- // Backdate startDate so the 24-hour guard doesn't block cancellation
80
- const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
81
- await firestore.set(`users/${state.uid}`, {
82
- subscription: { payment: { startDate: { timestamp: twoDaysAgo.toISOString(), timestampUNIX: twoDaysAgo.getTime() } } },
83
- }, { merge: true });
84
- },
85
- },
86
-
87
76
  {
88
77
  name: 'cancel-during-trial',
89
78
  async run({ http, assert }) {
90
- // Cancel via endpoint — test processor should detect trial and simulate immediate cancel
79
+ // Cancel via endpoint — test processor should detect trial and simulate immediate cancel.
80
+ // skipGuards bypasses the 24-hour subscription-age guard.
91
81
  const response = await http.as('journey-payments-trial-cancel').post('payments/cancel', {
92
82
  confirmed: true,
93
83
  reason: 'Changed my mind during trial',
94
84
  feedback: 'Testing trial cancellation',
85
+ skipGuards: true,
95
86
  });
96
87
 
97
88
  assert.isSuccess(response, 'Cancel endpoint should succeed');
@@ -27,7 +27,6 @@ module.exports = {
27
27
 
28
28
  state.uid = uid;
29
29
  state.paidProductId = paidProduct.id;
30
- state.paidStripeProductId = paidProduct.stripe?.productId;
31
30
  },
32
31
  },
33
32
 
@@ -110,7 +109,7 @@ module.exports = {
110
109
 
111
110
  {
112
111
  name: 'send-trial-to-active-webhook',
113
- async run({ http, assert, state, config }) {
112
+ async run({ http, assert, state, config, payments }) {
114
113
  const futureDate = new Date();
115
114
  futureDate.setMonth(futureDate.getMonth() + 1);
116
115
 
@@ -132,7 +131,7 @@ module.exports = {
132
131
  start_date: Math.floor(Date.now() / 1000) - 86400 * 14,
133
132
  trial_start: Math.floor(Date.now() / 1000) - 86400 * 14,
134
133
  trial_end: Math.floor(Date.now() / 1000),
135
- plan: { product: state.paidStripeProductId, interval: 'month' },
134
+ plan: { product: payments.stripeProductIds[state.paidProductId], interval: 'month' },
136
135
  },
137
136
  },
138
137
  });
@@ -29,7 +29,6 @@ module.exports = {
29
29
 
30
30
  state.uid = uid;
31
31
  state.paidProductId = paidProduct.id;
32
- state.paidStripeProductId = paidProduct.stripe?.productId;
33
32
 
34
33
  // Create subscription via test intent
35
34
  const response = await http.as('journey-payments-uid-resolution').post('payments/intent', {
@@ -56,7 +55,7 @@ module.exports = {
56
55
 
57
56
  {
58
57
  name: 'send-webhook-without-uid',
59
- async run({ http, assert, state, config }) {
58
+ async run({ http, assert, state, config, payments }) {
60
59
  // Send a subscription update webhook WITHOUT uid in metadata
61
60
  // The test processor's fetchResource() will look up payments-orders by resourceId
62
61
  // and reconstruct a Stripe-shaped subscription that includes metadata.uid
@@ -84,7 +83,7 @@ module.exports = {
84
83
  start_date: Math.floor(Date.now() / 1000) - 86400 * 30,
85
84
  trial_start: null,
86
85
  trial_end: null,
87
- plan: { product: state.paidStripeProductId, interval: 'month' },
86
+ plan: { product: payments.stripeProductIds[state.paidProductId], interval: 'month' },
88
87
  },
89
88
  },
90
89
  });
@@ -130,19 +130,5 @@ module.exports = {
130
130
  },
131
131
  },
132
132
 
133
- // Test 7: Cleanup
134
- {
135
- name: 'cleanup',
136
- auth: 'admin',
137
- timeout: 15000,
138
-
139
- async run({ http }) {
140
- // Clean up test data by writing null
141
- await http.command('admin:database-write', {
142
- path: TEST_PATH,
143
- document: null,
144
- });
145
- },
146
- },
147
133
  ],
148
134
  };
@@ -155,19 +155,5 @@ module.exports = {
155
155
  },
156
156
  },
157
157
 
158
- // Test 7: Cleanup
159
- {
160
- name: 'cleanup',
161
- auth: 'admin',
162
- timeout: 15000,
163
-
164
- async run({ http }) {
165
- // Clean up test data by writing null
166
- await http.command('admin:database-write', {
167
- path: TEST_PATH,
168
- document: null,
169
- });
170
- },
171
- },
172
158
  ],
173
159
  };