backend-manager 5.0.89 → 5.0.92

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 (72) hide show
  1. package/CHANGELOG.md +2 -2
  2. package/CLAUDE.md +147 -8
  3. package/README.md +6 -6
  4. package/TODO-MARKETING.md +3 -0
  5. package/TODO-PAYMENT-v2.md +71 -0
  6. package/TODO.md +7 -0
  7. package/package.json +7 -5
  8. package/src/cli/commands/{emulators.js → emulator.js} +15 -15
  9. package/src/cli/commands/index.js +1 -1
  10. package/src/cli/commands/setup-tests/{emulators-config.js → emulator-config.js} +4 -4
  11. package/src/cli/commands/setup-tests/index.js +2 -2
  12. package/src/cli/commands/setup-tests/project-id-consistency.js +1 -1
  13. package/src/cli/commands/test.js +16 -16
  14. package/src/cli/index.js +15 -4
  15. package/src/manager/events/auth/on-create.js +5 -158
  16. package/src/manager/events/firestore/payments-webhooks/analytics.js +171 -0
  17. package/src/manager/events/firestore/payments-webhooks/on-write.js +95 -297
  18. package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-completed.js +19 -10
  19. package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-failed.js +4 -8
  20. package/src/manager/events/firestore/payments-webhooks/transitions/send-email.js +61 -0
  21. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/cancellation-requested.js +22 -9
  22. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/new-subscription.js +21 -8
  23. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-failed.js +18 -8
  24. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-recovered.js +18 -7
  25. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/plan-changed.js +26 -8
  26. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/subscription-cancelled.js +24 -9
  27. package/src/manager/functions/core/actions/api/admin/send-email.js +0 -131
  28. package/src/manager/functions/core/actions/api/general/add-marketing-contact.js +2 -137
  29. package/src/manager/functions/core/actions/api/user/sign-up.js +1 -1
  30. package/src/manager/helpers/user.js +1 -0
  31. package/src/manager/index.js +12 -0
  32. package/src/manager/libraries/email.js +483 -0
  33. package/src/manager/libraries/infer-contact.js +140 -0
  34. package/src/manager/libraries/payment-processors/resolve-price-id.js +19 -0
  35. package/src/manager/libraries/payment-processors/stripe.js +87 -48
  36. package/src/manager/libraries/payment-processors/test.js +4 -4
  37. package/src/manager/libraries/prompts/infer-contact.md +43 -0
  38. package/src/manager/routes/admin/backup/post.js +4 -3
  39. package/src/manager/routes/admin/email/post.js +11 -428
  40. package/src/manager/routes/admin/hook/post.js +3 -2
  41. package/src/manager/routes/admin/notification/post.js +14 -12
  42. package/src/manager/routes/admin/post/post.js +5 -6
  43. package/src/manager/routes/admin/post/put.js +3 -2
  44. package/src/manager/routes/admin/stats/get.js +19 -10
  45. package/src/manager/routes/general/email/post.js +8 -21
  46. package/src/manager/routes/marketing/contact/post.js +2 -100
  47. package/src/manager/routes/payments/intent/post.js +44 -2
  48. package/src/manager/routes/payments/intent/processors/stripe.js +10 -45
  49. package/src/manager/routes/payments/intent/processors/test.js +20 -25
  50. package/src/manager/routes/user/oauth2/_helpers.js +3 -2
  51. package/src/manager/routes/user/oauth2/delete.js +3 -3
  52. package/src/manager/routes/user/oauth2/get.js +2 -2
  53. package/src/manager/routes/user/oauth2/post.js +9 -9
  54. package/src/manager/routes/user/sessions/delete.js +4 -3
  55. package/src/manager/routes/user/signup/post.js +254 -54
  56. package/src/manager/schemas/admin/email/post.js +10 -5
  57. package/src/test/run-tests.js +1 -1
  58. package/src/test/runner.js +11 -0
  59. package/src/test/test-accounts.js +18 -0
  60. package/templates/backend-manager-config.json +31 -12
  61. package/test/events/payments/journey-payments-one-time-failure.js +105 -0
  62. package/test/events/payments/journey-payments-one-time.js +128 -0
  63. package/test/events/payments/journey-payments-plan-change.js +126 -0
  64. package/test/events/payments/journey-payments-upgrade.js +2 -2
  65. package/test/functions/admin/send-email.js +1 -88
  66. package/test/helpers/email.js +381 -0
  67. package/test/helpers/infer-contact.js +299 -0
  68. package/test/routes/admin/email.js +41 -90
  69. package/REFACTOR-BEM-API.md +0 -76
  70. package/REFACTOR-MIDDLEWARE.md +0 -62
  71. package/REFACTOR-PAYMENT.md +0 -66
  72. /package/bin/{bem → backend-manager} +0 -0
@@ -1,3 +1,7 @@
1
+ const fetch = require('wonderful-fetch');
2
+ const moment = require('moment');
3
+ const { inferContact } = require('../../../libraries/infer-contact.js');
4
+
1
5
  const MAX_POLL_TIME_MS = 30000;
2
6
  const POLL_INTERVAL_MS = 500;
3
7
  const MAX_ACCOUNT_AGE_MS = 5 * 60 * 1000; // 5 minutes
@@ -7,11 +11,13 @@ const MAX_ACCOUNT_AGE_MS = 5 * 60 * 1000; // 5 minutes
7
11
  *
8
12
  * Called by client after account creation to:
9
13
  * 1. Poll for user doc to exist (waits for onCreate to complete)
10
- * 2. Check if user has already signed up (prevents duplicate calls)
11
- * 3. Update user with client details (geolocation, browser info)
12
- * 4. Process affiliate code and update referrer
14
+ * 2. Validate (already processed, account age)
15
+ * 3. Gather all data (client details, inferred contact)
16
+ * 4. Write everything to user doc in one merge
17
+ * 5. Process affiliate referral (writes to referrer's doc)
18
+ * 6. Send welcome emails + add to marketing lists (non-blocking)
13
19
  */
14
- module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
20
+ module.exports = async ({ assistant, user, settings, libraries }) => {
15
21
  const { admin } = libraries;
16
22
 
17
23
  // Require authentication
@@ -30,7 +36,7 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
30
36
  assistant.log(`signup(): Starting for ${uid}`, settings);
31
37
 
32
38
  // 1. Poll for user doc to exist (wait for onCreate to complete)
33
- const userDoc = await pollForUserDoc(admin, assistant, uid);
39
+ const userDoc = await pollForUserDoc(assistant, uid);
34
40
 
35
41
  if (!userDoc) {
36
42
  return assistant.respond('User document not found after waiting. Please try again.', { code: 500 });
@@ -56,52 +62,24 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
56
62
  return assistant.respond('Account is too old to process signup', { code: 400 });
57
63
  }
58
64
 
59
- // 4. Normalize data - support both legacy and new formats
60
- const attribution = settings.attribution;
61
-
62
- // Legacy support: if affiliateCode exists, normalize to new format
63
- if (settings.affiliateCode && !attribution.affiliate?.code) {
64
- attribution.affiliate = { code: settings.affiliateCode };
65
- }
66
-
67
- const affiliateCode = attribution.affiliate?.code || null;
68
-
69
- // 5. Build update record with client details
70
- const userRecord = {
71
- flags: {
72
- signupProcessed: true,
73
- },
74
- activity: {
75
- ...settings.context,
76
- geolocation: {
77
- ...(settings.context?.geolocation || {}),
78
- ...assistant.request.geolocation,
79
- },
80
- client: {
81
- ...(settings.context?.client || {}),
82
- ...assistant.request.client,
83
- },
84
- },
85
- attribution: attribution || {},
86
- metadata: Manager.Metadata().set({ tag: 'user/signup' }),
87
- };
65
+ // 4. Gather all data, then write once
66
+ const email = user.auth.email;
67
+ const inferred = await inferUserContact(assistant, email);
68
+ const userRecord = buildUserRecord(assistant, settings, inferred);
88
69
 
89
- assistant.log(`signup(): Updating user record for ${uid}`, userRecord);
70
+ assistant.log(`signup(): Writing user record for ${uid}`, userRecord);
90
71
 
91
- // Update user doc with client details
92
72
  await admin.firestore().doc(`users/${uid}`)
93
73
  .set(userRecord, { merge: true })
94
74
  .catch((e) => {
95
75
  assistant.error(`signup(): Failed to update user record:`, e);
96
76
  });
97
77
 
98
- // 6. Update referrer if affiliate code provided
99
- if (affiliateCode) {
100
- await updateReferral(admin, assistant, uid, affiliateCode)
101
- .catch((e) => {
102
- assistant.error(`signup(): Failed to update referral:`, e);
103
- });
104
- }
78
+ // 5. Process affiliate referral (writes to referrer's doc, not this user's)
79
+ await processAffiliate(assistant, uid, settings);
80
+
81
+ // 6. Send emails + marketing (non-blocking, fire-and-forget)
82
+ sendEmailsAndMarketing(assistant, uid, email, inferred);
105
83
 
106
84
  return assistant.respond({ signedUp: true });
107
85
  };
@@ -109,7 +87,8 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
109
87
  /**
110
88
  * Poll for user doc to exist (wait for onCreate to complete)
111
89
  */
112
- async function pollForUserDoc(admin, assistant, uid) {
90
+ async function pollForUserDoc(assistant, uid) {
91
+ const { admin } = assistant.Manager.libraries;
113
92
  const startTime = Date.now();
114
93
 
115
94
  while (Date.now() - startTime < MAX_POLL_TIME_MS) {
@@ -133,25 +112,99 @@ async function pollForUserDoc(admin, assistant, uid) {
133
112
  }
134
113
 
135
114
  /**
136
- * Update referrer's referrals array when a new user signs up with an affiliate code
115
+ * Build the full user record: client details, attribution, and inferred contact
116
+ */
117
+ function buildUserRecord(assistant, settings, inferred) {
118
+ const Manager = assistant.Manager;
119
+ const attribution = settings.attribution;
120
+
121
+ // Legacy support: if affiliateCode exists, normalize to new format
122
+ if (settings.affiliateCode && !attribution.affiliate?.code) {
123
+ attribution.affiliate = { code: settings.affiliateCode };
124
+ }
125
+
126
+ const record = {
127
+ flags: {
128
+ signupProcessed: true,
129
+ },
130
+ activity: {
131
+ ...settings.context,
132
+ geolocation: {
133
+ ...(settings.context?.geolocation || {}),
134
+ ...assistant.request.geolocation,
135
+ },
136
+ client: {
137
+ ...(settings.context?.client || {}),
138
+ ...assistant.request.client,
139
+ },
140
+ },
141
+ attribution: attribution || {},
142
+ metadata: Manager.Metadata().set({ tag: 'user/signup' }),
143
+ };
144
+
145
+ // Add inferred name/company if available
146
+ if (inferred) {
147
+ record.personal = {
148
+ ...(inferred.firstName || inferred.lastName ? {
149
+ name: {
150
+ ...(inferred.firstName ? { first: inferred.firstName } : {}),
151
+ ...(inferred.lastName ? { last: inferred.lastName } : {}),
152
+ },
153
+ } : {}),
154
+ ...(inferred.company ? { company: { name: inferred.company } } : {}),
155
+ };
156
+ }
157
+
158
+ return record;
159
+ }
160
+
161
+ /**
162
+ * Infer name/company from email using AI (or regex fallback)
163
+ * Returns the inferred contact info, or null on failure
164
+ */
165
+ async function inferUserContact(assistant, email) {
166
+ try {
167
+ const inferred = await inferContact(email, assistant);
168
+
169
+ if (!inferred?.firstName && !inferred?.lastName && !inferred?.company) {
170
+ return null;
171
+ }
172
+
173
+ assistant.log(`signup(): Inferred contact: ${inferred.firstName || ''} ${inferred.lastName || ''}, company=${inferred.company || ''} (method=${inferred.method})`);
174
+
175
+ return inferred;
176
+ } catch (e) {
177
+ assistant.error('signup(): Name inference failed:', e);
178
+ return null;
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Process affiliate referral if affiliate code provided
184
+ * Writes to the referrer's doc (not the current user's)
137
185
  */
138
- async function updateReferral(admin, assistant, newUserUid, affiliateCode) {
186
+ async function processAffiliate(assistant, uid, settings) {
187
+ const { admin } = assistant.Manager.libraries;
188
+ const affiliateCode = settings.attribution?.affiliate?.code
189
+ || settings.affiliateCode
190
+ || null;
191
+
139
192
  if (!affiliateCode) {
140
193
  return;
141
194
  }
142
195
 
143
- assistant.log(`updateReferral(): Looking for referrer with code ${affiliateCode}`);
196
+ assistant.log(`processAffiliate(): Looking for referrer with code ${affiliateCode}`);
144
197
 
145
198
  const snapshot = await admin.firestore().collection('users')
146
199
  .where('affiliate.code', '==', affiliateCode)
147
200
  .get()
148
201
  .catch((e) => {
149
- assistant.error(`updateReferral(): Failed to find referrer:`, e);
202
+ assistant.error(`processAffiliate(): Failed to find referrer:`, e);
150
203
  throw e;
151
204
  });
152
205
 
153
206
  if (snapshot.empty) {
154
- assistant.log(`updateReferral(): No referrer found with code ${affiliateCode}`);
207
+ assistant.log(`processAffiliate(): No referrer found with code ${affiliateCode}`);
155
208
  return;
156
209
  }
157
210
 
@@ -162,13 +215,12 @@ async function updateReferral(admin, assistant, newUserUid, affiliateCode) {
162
215
  let referrals = referrerData?.affiliate?.referrals || [];
163
216
  referrals = Array.isArray(referrals) ? referrals : [];
164
217
 
165
- // Add new referral
166
218
  referrals.push({
167
- uid: newUserUid,
219
+ uid: uid,
168
220
  timestamp: assistant.meta.startTime.timestamp,
169
221
  });
170
222
 
171
- assistant.log(`updateReferral(): Appending referral to ${referrerDoc.id}`, referrals);
223
+ assistant.log(`processAffiliate(): Appending referral to ${referrerDoc.id}`, referrals);
172
224
 
173
225
  await admin.firestore().doc(`users/${referrerDoc.id}`)
174
226
  .set({
@@ -177,9 +229,157 @@ async function updateReferral(admin, assistant, newUserUid, affiliateCode) {
177
229
  },
178
230
  }, { merge: true })
179
231
  .then(() => {
180
- assistant.log(`updateReferral(): Success`);
232
+ assistant.log(`processAffiliate(): Success`);
181
233
  })
182
234
  .catch((e) => {
183
- assistant.error(`updateReferral(): Failed to update referrer:`, e);
235
+ assistant.error(`processAffiliate(): Failed to update referrer:`, e);
236
+ });
237
+ }
238
+
239
+ /**
240
+ * Send welcome emails and add to marketing lists (non-blocking, fire-and-forget)
241
+ */
242
+ function sendEmailsAndMarketing(assistant, uid, email, inferred) {
243
+ const Manager = assistant.Manager;
244
+ const shouldSend = !assistant.isTesting() || process.env.TEST_EXTENDED_MODE;
245
+
246
+ if (!shouldSend) {
247
+ assistant.log(`signup(): Skipping emails/marketing (BEM_TESTING=true, TEST_EXTENDED_MODE not set)`);
248
+ return;
249
+ }
250
+
251
+ assistant.log(`signup(): Sending emails/adding to marketing for ${uid}`);
252
+
253
+ // Add to marketing lists (SendGrid + Beehiiv) via centralized endpoint
254
+ fetch(`${Manager.project.apiUrl}/backend-manager/marketing/contact`, {
255
+ method: 'POST',
256
+ response: 'json',
257
+ body: {
258
+ backendManagerKey: process.env.BACKEND_MANAGER_KEY,
259
+ email: email,
260
+ firstName: inferred?.firstName || '',
261
+ lastName: inferred?.lastName || '',
262
+ source: 'user:signup',
263
+ },
264
+ }).catch(e => assistant.error('signup(): marketing-contact failed:', e));
265
+
266
+ // Send welcome emails (non-blocking, don't fail on error)
267
+ sendWelcomeEmail(assistant, email).catch(e => assistant.error('signup(): sendWelcomeEmail failed:', e));
268
+ sendCheckupEmail(assistant, email).catch(e => assistant.error('signup(): sendCheckupEmail failed:', e));
269
+ sendFeedbackEmail(assistant, email).catch(e => assistant.error('signup(): sendFeedbackEmail failed:', e));
270
+ }
271
+
272
+ /**
273
+ * Send welcome email (immediate)
274
+ */
275
+ function sendWelcomeEmail(assistant, email) {
276
+ const Manager = assistant.Manager;
277
+ const mailer = Manager.Email(assistant);
278
+
279
+ return mailer.send({
280
+ to: email,
281
+ categories: ['account/welcome'],
282
+ subject: `Welcome to ${Manager.config.brand.name}!`,
283
+ template: 'd-b7f8da3c98ad49a2ad1e187f3a67b546',
284
+ group: 25928,
285
+ copy: false,
286
+ data: {
287
+ email: {
288
+ preview: `Welcome aboard! I'm Ian, the CEO and founder of ${Manager.config.brand.name}. I'm here to ensure your journey with us gets off to a great start.`,
289
+ },
290
+ body: {
291
+ title: `Welcome to ${Manager.config.brand.name}!`,
292
+ message: `
293
+ Welcome aboard!
294
+ <br><br>
295
+ I'm Ian, the founder and CEO of <strong>${Manager.config.brand.name}</strong>, and I'm thrilled to have you with us.
296
+ Your journey begins today, and we are committed to supporting you every step of the way.
297
+ <br><br>
298
+ We are dedicated to ensuring your experience is exceptional.
299
+ Feel free to reply directly to this email with any questions you may have.
300
+ <br><br>
301
+ Thank you for choosing <strong>${Manager.config.brand.name}</strong>. Here's to new beginnings!
302
+ `,
303
+ },
304
+ signoff: {
305
+ type: 'personal',
306
+ name: 'Ian Wiedenman, CEO',
307
+ url: `https://ianwiedenman.com?utm_source=welcome-email&utm_medium=email&utm_campaign=${Manager.config.app.id}`,
308
+ urlText: '@ianwieds',
309
+ },
310
+ },
311
+ })
312
+ .then((result) => {
313
+ assistant.log('sendWelcomeEmail(): Success', result.status);
314
+ return result;
315
+ });
316
+ }
317
+
318
+ /**
319
+ * Send checkup email (7 days after signup)
320
+ */
321
+ function sendCheckupEmail(assistant, email) {
322
+ const Manager = assistant.Manager;
323
+ const mailer = Manager.Email(assistant);
324
+
325
+ return mailer.send({
326
+ to: email,
327
+ categories: ['account/checkup'],
328
+ subject: `How's your experience with ${Manager.config.brand.name}?`,
329
+ template: 'd-b7f8da3c98ad49a2ad1e187f3a67b546',
330
+ group: 25928,
331
+ copy: false,
332
+ sendAt: moment().add(7, 'days').unix(),
333
+ data: {
334
+ email: {
335
+ preview: `Checking in from ${Manager.config.brand.name} to see how things are going. Let us know if you have any questions or feedback!`,
336
+ },
337
+ body: {
338
+ title: `How's everything going?`,
339
+ message: `
340
+ Hi there,
341
+ <br><br>
342
+ It's Ian again from <strong>${Manager.config.brand.name}</strong>. Just checking in to see how things are going for you.
343
+ <br><br>
344
+ Have you had a chance to explore all our features? Any questions or feedback for us?
345
+ <br><br>
346
+ We're always here to help, so don't hesitate to reach out. Just reply to this email and we'll get back to you as soon as possible.
347
+ <br><br>
348
+ Thank you for choosing <strong>${Manager.config.brand.name}</strong>. Here's to new beginnings!
349
+ `,
350
+ },
351
+ signoff: {
352
+ type: 'personal',
353
+ name: 'Ian Wiedenman, CEO',
354
+ url: `https://ianwiedenman.com?utm_source=checkup-email&utm_medium=email&utm_campaign=${Manager.config.app.id}`,
355
+ urlText: '@ianwieds',
356
+ },
357
+ },
358
+ })
359
+ .then((result) => {
360
+ assistant.log('sendCheckupEmail(): Success', result.status);
361
+ return result;
362
+ });
363
+ }
364
+
365
+ /**
366
+ * Send feedback email (14 days after signup)
367
+ */
368
+ function sendFeedbackEmail(assistant, email) {
369
+ const Manager = assistant.Manager;
370
+ const mailer = Manager.Email(assistant);
371
+
372
+ return mailer.send({
373
+ to: email,
374
+ categories: ['engagement/feedback'],
375
+ subject: `Want to share your feedback about ${Manager.config.brand.name}?`,
376
+ template: 'd-c1522214c67b47058669acc5a81ed663',
377
+ group: 25928,
378
+ copy: false,
379
+ sendAt: moment().add(14, 'days').unix(),
380
+ })
381
+ .then((result) => {
382
+ assistant.log('sendFeedbackEmail(): Success', result.status);
383
+ return result;
184
384
  });
185
385
  }
@@ -1,20 +1,25 @@
1
1
  /**
2
2
  * Schema for POST /admin/email
3
+ *
4
+ * Recipients (to, cc, bcc) accept flexible formats:
5
+ * - String email: "user@example.com"
6
+ * - UID string: "uid:abc123" (auto-resolves from Firestore)
7
+ * - Object: { email: "user@example.com", name: "John" }
8
+ * - Array of any of the above
3
9
  */
4
10
  module.exports = () => ({
5
- to: { types: ['array'], default: [] },
6
- cc: { types: ['array'], default: [] },
7
- bcc: { types: ['array'], default: [] },
11
+ to: { types: ['array', 'string', 'object'], default: [] },
12
+ cc: { types: ['array', 'string', 'object'], default: [] },
13
+ bcc: { types: ['array', 'string', 'object'], default: [] },
8
14
  from: { types: ['object'], default: undefined },
9
15
  replyTo: { types: ['string'], default: undefined },
10
16
  subject: { types: ['string'], default: undefined },
11
17
  template: { types: ['string'], default: 'd-b7f8da3c98ad49a2ad1e187f3a67b546' },
12
18
  group: { types: ['number'], default: 24077 },
13
- sendAt: { types: ['number'], default: undefined },
19
+ sendAt: { types: ['number', 'string'], default: undefined },
14
20
  user: { types: ['object'], default: {} },
15
21
  data: { types: ['object'], default: {} },
16
22
  categories: { types: ['array'], default: [] },
17
23
  copy: { types: ['boolean'], default: true },
18
- ensureUnique: { types: ['boolean'], default: true },
19
24
  html: { types: ['string'], default: undefined },
20
25
  });
@@ -2,7 +2,7 @@
2
2
 
3
3
  /**
4
4
  * BEM Test Runner Entry Point
5
- * This script is executed by the CLI test command inside firebase emulators:exec
5
+ * This script is executed by the CLI test command inside the Firebase emulator
6
6
  * It reads configuration from BEM_TEST_CONFIG environment variable and runs the test suite
7
7
  */
8
8
 
@@ -1,3 +1,4 @@
1
+ const os = require('os');
1
2
  const path = require('path');
2
3
  const jetpack = require('fs-jetpack');
3
4
  const chalk = require('chalk');
@@ -52,6 +53,16 @@ class TestRunner {
52
53
  * Main run method
53
54
  */
54
55
  async run() {
56
+ // Abort if BEM is running from the user's home directory (e.g., accidental ~/node_modules install)
57
+ const homeDir = os.homedir();
58
+ if (__dirname.startsWith(path.join(homeDir, 'node_modules'))) {
59
+ console.error(chalk.red('\n ERROR: BEM is running from ~/node_modules (home directory install).'));
60
+ console.error(chalk.red(' This is likely an accidental global install that shadows local project copies.'));
61
+ console.error(chalk.red(` Fix: rm -rf ${path.join(homeDir, 'node_modules')} ${path.join(homeDir, 'package.json')} ${path.join(homeDir, 'package-lock.json')}`));
62
+ console.error(chalk.red(` Running from: ${__dirname}\n`));
63
+ process.exit(1);
64
+ }
65
+
55
66
  // Set testing flag to skip external API calls (emails, SendGrid)
56
67
  process.env.BEM_TESTING = 'true';
57
68
 
@@ -175,6 +175,24 @@ const JOURNEY_ACCOUNTS = {
175
175
  subscription: { product: { id: 'basic' }, status: 'active' }, // Test's first step overwrites with correct paid product from config
176
176
  },
177
177
  },
178
+ 'journey-payments-plan-change': {
179
+ id: 'journey-payments-plan-change',
180
+ uid: '_test-journey-payments-plan-change',
181
+ email: '_test.journey-payments-plan-change@{domain}',
182
+ properties: {
183
+ roles: {},
184
+ subscription: { product: { id: 'basic' }, status: 'active' }, // Test's first step overwrites with correct paid product from config
185
+ },
186
+ },
187
+ 'journey-payments-one-time': {
188
+ id: 'journey-payments-one-time',
189
+ uid: '_test-journey-payments-one-time',
190
+ email: '_test.journey-payments-one-time@{domain}',
191
+ properties: {
192
+ roles: {},
193
+ subscription: { product: { id: 'basic' }, status: 'active' },
194
+ },
195
+ },
178
196
  };
179
197
 
180
198
  /**
@@ -72,18 +72,37 @@
72
72
  },
73
73
  },
74
74
  },
75
- // Example one-time product:
76
- // {
77
- // id: 'credits-100',
78
- // name: '100 Credits',
79
- // type: 'one-time',
80
- // prices: {
81
- // once: {
82
- // amount: 9.99,
83
- // stripe: null,
84
- // },
85
- // },
86
- // },
75
+ {
76
+ id: 'ultimate',
77
+ name: 'Ultimate',
78
+ type: 'subscription',
79
+ limits: {
80
+ requests: 10000,
81
+ },
82
+ prices: {
83
+ monthly: {
84
+ amount: 19.99,
85
+ stripe: null,
86
+ paypal: null,
87
+ },
88
+ annually: {
89
+ amount: 199.99,
90
+ stripe: null,
91
+ paypal: null,
92
+ },
93
+ },
94
+ },
95
+ {
96
+ id: 'credits-100',
97
+ name: '100 Credits',
98
+ type: 'one-time',
99
+ prices: {
100
+ once: {
101
+ amount: 9.99,
102
+ stripe: null,
103
+ },
104
+ },
105
+ },
87
106
  // Add more products/tiers here
88
107
  ],
89
108
  },
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Test: Payment Journey - One-Time Payment Failure
3
+ * Simulates: invoice.payment_failed for a non-subscription invoice → purchase-failed transition
4
+ *
5
+ * This verifies the webhook routing for one-time invoice failures:
6
+ * invoice.payment_failed with no subscription billing_reason → category: 'one-time'
7
+ *
8
+ * Uses the journey-payments-one-time account (one-time events don't modify subscription state)
9
+ */
10
+ module.exports = {
11
+ description: 'Payment journey: one-time invoice.payment_failed → purchase-failed',
12
+ type: 'suite',
13
+ timeout: 30000,
14
+
15
+ tests: [
16
+ {
17
+ name: 'resolve-one-time-product',
18
+ async run({ accounts, assert, state, config }) {
19
+ const uid = accounts['journey-payments-one-time'].uid;
20
+
21
+ // Resolve first one-time product from config
22
+ 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
+
25
+ state.uid = uid;
26
+ state.productId = oneTimeProduct.id;
27
+ },
28
+ },
29
+
30
+ {
31
+ name: 'send-one-time-payment-failed',
32
+ async run({ http, assert, state, config }) {
33
+ state.eventId = `_test-evt-journey-onetime-fail-${Date.now()}`;
34
+ state.invoiceId = `_test-inv-onetime-fail-${Date.now()}`;
35
+ state.orderId = `0000-0000-0000`; // Fake orderId for test
36
+
37
+ // Send invoice.payment_failed with a non-subscription billing reason
38
+ // This routes to category: 'one-time' in the webhook parser
39
+ const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerKey}`, {
40
+ id: state.eventId,
41
+ type: 'invoice.payment_failed',
42
+ data: {
43
+ object: {
44
+ id: state.invoiceId,
45
+ object: 'invoice',
46
+ billing_reason: 'manual',
47
+ amount_due: 999,
48
+ amount_paid: 0,
49
+ status: 'open',
50
+ metadata: {
51
+ uid: state.uid,
52
+ orderId: state.orderId,
53
+ productId: state.productId,
54
+ },
55
+ },
56
+ },
57
+ });
58
+
59
+ assert.isSuccess(response, 'Webhook should be accepted');
60
+ },
61
+ },
62
+
63
+ {
64
+ name: 'webhook-categorized-as-one-time',
65
+ async run({ firestore, assert, state, waitFor }) {
66
+ await waitFor(async () => {
67
+ const doc = await firestore.get(`payments-webhooks/${state.eventId}`);
68
+ return doc?.status === 'completed' || doc?.status === 'failed';
69
+ }, 15000, 500);
70
+
71
+ const webhookDoc = await firestore.get(`payments-webhooks/${state.eventId}`);
72
+ assert.ok(webhookDoc, 'Webhook doc should exist');
73
+ assert.equal(webhookDoc.event?.category, 'one-time', 'Category should be one-time');
74
+ assert.equal(webhookDoc.event?.resourceType, 'invoice', 'Resource type should be invoice');
75
+ assert.equal(webhookDoc.transition, 'purchase-failed', 'Transition should be purchase-failed');
76
+ },
77
+ },
78
+
79
+ {
80
+ name: 'order-doc-created-with-failure',
81
+ async run({ firestore, assert, state }) {
82
+ const orderDoc = await firestore.get(`payments-orders/${state.orderId}`);
83
+
84
+ assert.ok(orderDoc, 'Order doc should exist');
85
+ assert.equal(orderDoc.type, 'one-time', 'Type should be one-time');
86
+ assert.equal(orderDoc.owner, state.uid, 'Owner should match');
87
+ assert.equal(orderDoc.processor, 'test', 'Processor should be test');
88
+ },
89
+ },
90
+
91
+ {
92
+ name: 'subscription-unchanged',
93
+ async run({ firestore, assert, state }) {
94
+ // One-time payment failures must NOT modify subscription state
95
+ const userDoc = await firestore.get(`users/${state.uid}`);
96
+
97
+ assert.equal(
98
+ userDoc.subscription?.product?.id,
99
+ 'basic',
100
+ 'Subscription should remain basic after one-time failure',
101
+ );
102
+ },
103
+ },
104
+ ],
105
+ };