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
package/src/cli/index.js CHANGED
@@ -1,6 +1,17 @@
1
+ const os = require('os');
2
+ const path = require('path');
1
3
  const argv = require('yargs').argv;
2
4
  const _ = require('lodash');
3
5
 
6
+ // Abort if running from ~/node_modules (accidental home directory install)
7
+ const _homeDir = os.homedir();
8
+ if (__dirname.startsWith(path.join(_homeDir, 'node_modules'))) {
9
+ console.error(`\nERROR: BEM is running from ~/node_modules (home directory install).`);
10
+ console.error(`This shadows the local project copy. Fix:`);
11
+ console.error(` rm -rf ~/node_modules ~/package.json ~/package-lock.json\n`);
12
+ process.exit(1);
13
+ }
14
+
4
15
  // Import commands
5
16
  const VersionCommand = require('./commands/version');
6
17
  const ClearCommand = require('./commands/clear');
@@ -10,7 +21,7 @@ const InstallCommand = require('./commands/install');
10
21
  const ServeCommand = require('./commands/serve');
11
22
  const DeployCommand = require('./commands/deploy');
12
23
  const TestCommand = require('./commands/test');
13
- const EmulatorsCommand = require('./commands/emulators');
24
+ const EmulatorCommand = require('./commands/emulator');
14
25
  const CleanCommand = require('./commands/clean');
15
26
  const IndexesCommand = require('./commands/indexes');
16
27
  const WatchCommand = require('./commands/watch');
@@ -95,9 +106,9 @@ Main.prototype.process = async function (args) {
95
106
  return await cmd.execute();
96
107
  }
97
108
 
98
- // Emulators (keep-alive mode)
99
- if (self.options['emulators'] || self.options['emulator']) {
100
- const cmd = new EmulatorsCommand(self);
109
+ // Emulator (keep-alive mode)
110
+ if (self.options['emulator'] || self.options['emulators']) {
111
+ const cmd = new EmulatorCommand(self);
101
112
  return await cmd.execute();
102
113
  }
103
114
 
@@ -1,5 +1,3 @@
1
- const fetch = require('wonderful-fetch');
2
- const moment = require('moment');
3
1
  const { FieldValue } = require('firebase-admin/firestore');
4
2
 
5
3
  const MAX_RETRIES = 3;
@@ -15,7 +13,9 @@ const RETRY_DELAY_MS = 1000;
15
13
  * - Checks if user doc already exists (auth.uid) → skips if exists (handles test accounts, provider linking)
16
14
  * - Batch writes user doc + increment count atomically
17
15
  * - Retries up to 3 times with exponential backoff on failure
18
- * - Sends analytics event (non-critical, no retry)
16
+ *
17
+ * Non-critical work (name inference, welcome emails, marketing contact) is handled
18
+ * by the user/signup endpoint, which the frontend calls after account creation.
19
19
  */
20
20
  module.exports = async ({ Manager, assistant, user, context, libraries }) => {
21
21
  const startTime = Date.now();
@@ -71,42 +71,13 @@ module.exports = async ({ Manager, assistant, user, context, libraries }) => {
71
71
  await batch.commit();
72
72
  }, MAX_RETRIES, RETRY_DELAY_MS);
73
73
 
74
- assistant.log(`onCreate: Successfully created user doc for ${user.uid}`);
74
+ assistant.log(`onCreate: Successfully created user doc for ${user.uid} (${Date.now() - startTime}ms)`);
75
75
  } catch (error) {
76
76
  assistant.error(`onCreate: Failed to create user doc after ${MAX_RETRIES} retries:`, error);
77
77
 
78
78
  // Don't reject - the user was already created in Auth
79
- // The user:sign-up endpoint will handle creating the doc if it's missing
80
- return;
81
- }
82
-
83
- // Send emails in dev/production, or in test mode if TEST_EXTENDED_MODE=true
84
- // Note: Must be passed to the emulator
85
- const shouldSendEmails = !assistant.isTesting() || process.env.TEST_EXTENDED_MODE;
86
-
87
- if (!shouldSendEmails) {
88
- assistant.log(`onCreate: Skipping emails/SendGrid (BEM_TESTING=true, TEST_EXTENDED_MODE not set)`);
89
- } else {
90
- assistant.log(`onCreate: Sending emails/adding to SendGrid for ${user.uid}`);
91
-
92
- // Add to marketing lists (SendGrid + Beehiiv) via centralized endpoint
93
- fetch(`${Manager.project.apiUrl}/backend-manager/marketing/contact`, {
94
- method: 'POST',
95
- response: 'json',
96
- body: {
97
- backendManagerKey: process.env.BACKEND_MANAGER_KEY,
98
- email: user.email,
99
- source: 'auth:on-create',
100
- },
101
- }).catch(e => assistant.error('onCreate: add-marketing-contact failed:', e));
102
-
103
- // Send welcome emails (non-blocking, don't fail on error)
104
- sendWelcomeEmail(Manager, assistant, user).catch(e => assistant.error('onCreate: sendWelcomeEmail failed:', e));
105
- sendCheckupEmail(Manager, assistant, user).catch(e => assistant.error('onCreate: sendCheckupEmail failed:', e));
106
- sendFeedbackEmail(Manager, assistant, user).catch(e => assistant.error('onCreate: sendFeedbackEmail failed:', e));
79
+ // The user/signup endpoint will handle creating the doc if it's missing
107
80
  }
108
-
109
- assistant.log(`onCreate: Completed for ${user.uid} (${Date.now() - startTime}ms)`);
110
81
  };
111
82
 
112
83
  /**
@@ -133,127 +104,3 @@ async function retryBatchWrite(assistant, fn, maxRetries, delayMs) {
133
104
 
134
105
  throw lastError; // All retries failed
135
106
  }
136
-
137
- /**
138
- * Send welcome email (immediate)
139
- */
140
- function sendWelcomeEmail(Manager, assistant, user) {
141
- return fetch(`${Manager.project.apiUrl}/backend-manager/admin/email`, {
142
- method: 'POST',
143
- response: 'json',
144
- body: {
145
- backendManagerKey: process.env.BACKEND_MANAGER_KEY,
146
- to: [{ email: user.email }],
147
- categories: ['account/welcome'],
148
- subject: `Welcome to ${Manager.config.brand.name}!`,
149
- template: 'd-b7f8da3c98ad49a2ad1e187f3a67b546',
150
- group: 25928,
151
- copy: false,
152
- ensureUnique: true,
153
- data: {
154
- email: {
155
- 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.`,
156
- },
157
- body: {
158
- title: `Welcome to ${Manager.config.brand.name}!`,
159
- message: `
160
- Welcome aboard!
161
- <br><br>
162
- I'm Ian, the founder and CEO of <strong>${Manager.config.brand.name}</strong>, and I'm thrilled to have you with us.
163
- Your journey begins today, and we are committed to supporting you every step of the way.
164
- <br><br>
165
- We are dedicated to ensuring your experience is exceptional.
166
- Feel free to reply directly to this email with any questions you may have.
167
- <br><br>
168
- Thank you for choosing <strong>${Manager.config.brand.name}</strong>. Here's to new beginnings!
169
- `,
170
- },
171
- signoff: {
172
- type: 'personal',
173
- name: 'Ian Wiedenman, CEO',
174
- url: `https://ianwiedenman.com?utm_source=welcome-email&utm_medium=email&utm_campaign=${Manager.config.app.id}`,
175
- urlText: '@ianwieds',
176
- },
177
- },
178
- },
179
- })
180
- .then((json) => {
181
- assistant.log('sendWelcomeEmail(): Success', json);
182
- return json;
183
- });
184
- }
185
-
186
- /**
187
- * Send checkup email (7 days after signup)
188
- */
189
- function sendCheckupEmail(Manager, assistant, user) {
190
- return fetch(`${Manager.project.apiUrl}/backend-manager/admin/email`, {
191
- method: 'POST',
192
- response: 'json',
193
- body: {
194
- backendManagerKey: process.env.BACKEND_MANAGER_KEY,
195
- to: [{ email: user.email }],
196
- categories: ['account/checkup'],
197
- subject: `How's your experience with ${Manager.config.brand.name}?`,
198
- template: 'd-b7f8da3c98ad49a2ad1e187f3a67b546',
199
- group: 25928,
200
- copy: false,
201
- ensureUnique: true,
202
- sendAt: moment().add(7, 'days').unix(),
203
- data: {
204
- email: {
205
- preview: `Checking in from ${Manager.config.brand.name} to see how things are going. Let us know if you have any questions or feedback!`,
206
- },
207
- body: {
208
- title: `How's everything going?`,
209
- message: `
210
- Hi there,
211
- <br><br>
212
- It's Ian again from <strong>${Manager.config.brand.name}</strong>. Just checking in to see how things are going for you.
213
- <br><br>
214
- Have you had a chance to explore all our features? Any questions or feedback for us?
215
- <br><br>
216
- 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.
217
- <br><br>
218
- Thank you for choosing <strong>${Manager.config.brand.name}</strong>. Here's to new beginnings!
219
- `,
220
- },
221
- signoff: {
222
- type: 'personal',
223
- name: 'Ian Wiedenman, CEO',
224
- url: `https://ianwiedenman.com?utm_source=checkup-email&utm_medium=email&utm_campaign=${Manager.config.app.id}`,
225
- urlText: '@ianwieds',
226
- },
227
- },
228
- },
229
- })
230
- .then((json) => {
231
- assistant.log('sendCheckupEmail(): Success', json);
232
- return json;
233
- });
234
- }
235
-
236
- /**
237
- * Send feedback email (14 days after signup)
238
- */
239
- function sendFeedbackEmail(Manager, assistant, user) {
240
- return fetch(`${Manager.project.apiUrl}/backend-manager/admin/email`, {
241
- method: 'POST',
242
- response: 'json',
243
- body: {
244
- backendManagerKey: process.env.BACKEND_MANAGER_KEY,
245
- to: [{ email: user.email }],
246
- categories: ['engagement/feedback'],
247
- subject: `Want to share your feedback about ${Manager.config.brand.name}?`,
248
- template: 'd-c1522214c67b47058669acc5a81ed663',
249
- group: 25928,
250
- copy: false,
251
- ensureUnique: true,
252
- sendAt: moment().add(14, 'days').unix(),
253
- },
254
- })
255
- .then((json) => {
256
- assistant.log('sendFeedbackEmail(): Success', json);
257
- return json;
258
- });
259
- }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Payment analytics tracking
3
+ * Fires server-side events for GA4, Meta Conversions API, and TikTok Events API
4
+ *
5
+ * Maps transitions to standard platform events:
6
+ * new-subscription (no trial) → purchase / Purchase / CompletePayment
7
+ * new-subscription (trial) → start_trial / StartTrial / Subscribe
8
+ * payment-recovered → purchase / Subscribe / Subscribe (recurring)
9
+ * purchase-completed → purchase / Purchase / CompletePayment
10
+ */
11
+
12
+ /**
13
+ * Track payment events across analytics platforms (non-blocking)
14
+ *
15
+ * @param {object} options
16
+ * @param {string} options.category - 'subscription' or 'one-time'
17
+ * @param {string} options.transitionName - Detected transition (e.g., 'new-subscription', 'purchase-completed')
18
+ * @param {object} options.unified - Unified subscription or one-time object
19
+ * @param {string} options.uid - User ID
20
+ * @param {string} options.processor - Payment processor (e.g., 'stripe', 'paypal')
21
+ * @param {object} options.assistant - Assistant instance (Manager derived via assistant.Manager)
22
+ */
23
+ function trackPayment({ category, transitionName, unified, uid, processor, assistant }) {
24
+ const Manager = assistant.Manager;
25
+
26
+ try {
27
+ // Resolve the analytics event to fire based on transition
28
+ const event = resolvePaymentEvent(category, transitionName, unified, Manager.config);
29
+
30
+ if (!event) {
31
+ return;
32
+ }
33
+
34
+ assistant.log(`trackPayment: event=${event.ga4}, value=${event.value}, currency=${event.currency}, product=${event.productId}, uid=${uid}`);
35
+
36
+ // GA4 via Measurement Protocol
37
+ Manager.Analytics({ assistant, uuid: uid }).event(event.ga4, {
38
+ transaction_id: event.transactionId,
39
+ value: event.value,
40
+ currency: event.currency,
41
+ items: [{
42
+ item_id: event.productId,
43
+ item_name: event.productName,
44
+ price: event.value,
45
+ quantity: 1,
46
+ }],
47
+ payment_processor: processor,
48
+ payment_frequency: event.frequency,
49
+ is_trial: event.isTrial,
50
+ is_recurring: event.isRecurring,
51
+ });
52
+
53
+ // TODO: Meta Conversions API
54
+ // Event name: event.meta (e.g., 'Purchase', 'StartTrial', 'Subscribe')
55
+ // https://developers.facebook.com/docs/marketing-api/conversions-api
56
+
57
+ // TODO: TikTok Events API
58
+ // Event name: event.tiktok (e.g., 'CompletePayment', 'Subscribe')
59
+ // https://business-api.tiktok.com/portal/docs?id=1771100865818625
60
+ } catch (e) {
61
+ assistant.error(`trackPayment failed: ${e.message}`, e);
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Resolve which analytics event to fire based on transition + unified data
67
+ * Returns null if the transition doesn't warrant an analytics event
68
+ */
69
+ function resolvePaymentEvent(category, transitionName, unified, config) {
70
+ if (category === 'subscription') {
71
+ return resolveSubscriptionEvent(transitionName, unified, config);
72
+ }
73
+
74
+ if (category === 'one-time') {
75
+ return resolveOneTimeEvent(transitionName, unified, config);
76
+ }
77
+
78
+ return null;
79
+ }
80
+
81
+ /**
82
+ * Map subscription transitions to analytics events
83
+ */
84
+ function resolveSubscriptionEvent(transitionName, unified, config) {
85
+ const productId = unified.product?.id;
86
+ const productName = unified.product?.name;
87
+ const frequency = unified.payment?.frequency;
88
+ const isTrial = unified.trial?.claimed === true;
89
+ const resourceId = unified.payment?.resourceId;
90
+ const price = unified.payment?.price || 0;
91
+
92
+ if (transitionName === 'new-subscription' && isTrial) {
93
+ return {
94
+ ga4: 'start_trial',
95
+ meta: 'StartTrial',
96
+ tiktok: 'Subscribe',
97
+ value: 0,
98
+ currency: config.payment?.currency || 'USD',
99
+ productId,
100
+ productName,
101
+ frequency,
102
+ isTrial: true,
103
+ isRecurring: false,
104
+ transactionId: resourceId,
105
+ };
106
+ }
107
+
108
+ if (transitionName === 'new-subscription') {
109
+ return {
110
+ ga4: 'purchase',
111
+ meta: 'Purchase',
112
+ tiktok: 'CompletePayment',
113
+ value: price,
114
+ currency: config.payment?.currency || 'USD',
115
+ productId,
116
+ productName,
117
+ frequency,
118
+ isTrial: false,
119
+ isRecurring: false,
120
+ transactionId: resourceId,
121
+ };
122
+ }
123
+
124
+ if (transitionName === 'payment-recovered') {
125
+ return {
126
+ ga4: 'purchase',
127
+ meta: 'Subscribe',
128
+ tiktok: 'Subscribe',
129
+ value: price,
130
+ currency: config.payment?.currency || 'USD',
131
+ productId,
132
+ productName,
133
+ frequency,
134
+ isTrial: false,
135
+ isRecurring: true,
136
+ transactionId: resourceId,
137
+ };
138
+ }
139
+
140
+ return null;
141
+ }
142
+
143
+ /**
144
+ * Map one-time transitions to analytics events
145
+ */
146
+ function resolveOneTimeEvent(transitionName, unified, config) {
147
+ if (transitionName !== 'purchase-completed') {
148
+ return null;
149
+ }
150
+
151
+ const productId = unified.product?.id;
152
+ const productName = unified.product?.name;
153
+ const price = unified.payment?.price || 0;
154
+ const resourceId = unified.payment?.resourceId;
155
+
156
+ return {
157
+ ga4: 'purchase',
158
+ meta: 'Purchase',
159
+ tiktok: 'CompletePayment',
160
+ value: price,
161
+ currency: config.payment?.currency || 'USD',
162
+ productId: productId || 'unknown',
163
+ productName: productName || 'Unknown',
164
+ frequency: null,
165
+ isTrial: false,
166
+ isRecurring: false,
167
+ transactionId: resourceId,
168
+ };
169
+ }
170
+
171
+ module.exports = { trackPayment };