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
@@ -39,7 +39,7 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
39
39
 
40
40
  // Update stats if requested
41
41
  if (settings.update) {
42
- const error = await updateStats(admin, assistant, Manager, data, settings.update);
42
+ const error = await updateStats(assistant, data, settings.update);
43
43
 
44
44
  if (error) {
45
45
  return assistant.respond(error.message, { code: 500 });
@@ -58,7 +58,9 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
58
58
  return assistant.respond(data);
59
59
  };
60
60
 
61
- async function updateStats(admin, assistant, Manager, existingData, update) {
61
+ async function updateStats(assistant, existingData, update) {
62
+ const Manager = assistant.Manager;
63
+ const { admin } = Manager.libraries;
62
64
  const stats = admin.firestore().doc('meta/stats');
63
65
  const newData = {
64
66
  app: Manager.config?.app?.id || null,
@@ -70,7 +72,7 @@ async function updateStats(admin, assistant, Manager, existingData, update) {
70
72
 
71
73
  // Update notification stats
72
74
  if (update === true || update?.notifications) {
73
- const count = await getAllNotifications(admin, assistant).catch((e) => e);
75
+ const count = await getAllNotifications(assistant).catch((e) => e);
74
76
 
75
77
  if (count instanceof Error) {
76
78
  error = new Error(`Failed getting notifications: ${count.message}`);
@@ -81,7 +83,7 @@ async function updateStats(admin, assistant, Manager, existingData, update) {
81
83
 
82
84
  // Update subscription stats
83
85
  if (!error && (update === true || update?.subscriptions)) {
84
- const subscriptions = await getAllSubscriptions(admin, assistant).catch((e) => e);
86
+ const subscriptions = await getAllSubscriptions(assistant).catch((e) => e);
85
87
 
86
88
  if (subscriptions instanceof Error) {
87
89
  error = new Error(`Failed getting subscriptions: ${subscriptions.message}`);
@@ -92,7 +94,7 @@ async function updateStats(admin, assistant, Manager, existingData, update) {
92
94
 
93
95
  // Update user stats
94
96
  if (!error && (!existingData?.users?.total || update === true || update?.users)) {
95
- const users = await getAllUsers(admin, assistant).catch((e) => e);
97
+ const users = await getAllUsers(assistant).catch((e) => e);
96
98
 
97
99
  if (users instanceof Error) {
98
100
  error = new Error(`Failed getting users: ${users.message}`);
@@ -103,7 +105,7 @@ async function updateStats(admin, assistant, Manager, existingData, update) {
103
105
 
104
106
  // Update online users
105
107
  if (!error && (update === true || update?.online)) {
106
- const online = await countOnlineUsers(admin, assistant);
108
+ const online = await countOnlineUsers(assistant);
107
109
 
108
110
  _.set(newData, 'users.online', online);
109
111
  }
@@ -125,7 +127,9 @@ async function updateStats(admin, assistant, Manager, existingData, update) {
125
127
  return error;
126
128
  }
127
129
 
128
- async function getAllUsers(admin, assistant) {
130
+ async function getAllUsers(assistant) {
131
+ const { admin } = assistant.Manager.libraries;
132
+
129
133
  assistant.log('getAllUsers(): Starting...');
130
134
 
131
135
  const users = [];
@@ -142,7 +146,9 @@ async function getAllUsers(admin, assistant) {
142
146
  return users;
143
147
  }
144
148
 
145
- async function getAllNotifications(admin, assistant) {
149
+ async function getAllNotifications(assistant) {
150
+ const { admin } = assistant.Manager.libraries;
151
+
146
152
  assistant.log('getAllNotifications(): Starting...');
147
153
 
148
154
  const snap = await admin.firestore().collection('notifications').count().get();
@@ -153,7 +159,9 @@ async function getAllNotifications(admin, assistant) {
153
159
  return count;
154
160
  }
155
161
 
156
- async function getAllSubscriptions(admin, assistant) {
162
+ async function getAllSubscriptions(assistant) {
163
+ const { admin } = assistant.Manager.libraries;
164
+
157
165
  assistant.log('getAllSubscriptions(): Starting...');
158
166
 
159
167
  const snapshot = await admin.firestore().collection('users')
@@ -195,7 +203,8 @@ async function getAllSubscriptions(admin, assistant) {
195
203
  return stats;
196
204
  }
197
205
 
198
- async function countOnlineUsers(admin, assistant) {
206
+ async function countOnlineUsers(assistant) {
207
+ const { admin } = assistant.Manager.libraries;
199
208
  let online = 0;
200
209
 
201
210
  const paths = ['gatherings/online', 'sessions/app', 'sessions/online'];
@@ -4,10 +4,7 @@
4
4
  */
5
5
  const path = require('path');
6
6
  const { merge } = require('lodash');
7
-
8
- module.exports = async ({ assistant, Manager, settings, analytics }) => {
9
- const fetch = Manager.require('wonderful-fetch');
10
-
7
+ module.exports = async ({ assistant, Manager, settings }) => {
11
8
  // Validate required parameters
12
9
  if (!settings.id) {
13
10
  return assistant.respond('Parameter {id} is required.', { code: 400 });
@@ -22,10 +19,7 @@ module.exports = async ({ assistant, Manager, settings, analytics }) => {
22
19
  email: 3,
23
20
  },
24
21
  delay: 1,
25
- payload: {
26
- backendManagerKey: process.env.BACKEND_MANAGER_KEY,
27
- app: Manager.config.app.id,
28
- },
22
+ payload: {},
29
23
  };
30
24
 
31
25
  // Load email template
@@ -75,25 +69,18 @@ module.exports = async ({ assistant, Manager, settings, analytics }) => {
75
69
 
76
70
  assistant.log('Email payload:', emailPayload);
77
71
 
78
- // Send the email via NEW admin/email API
79
- const result = await fetch(`${Manager.project.apiUrl}/backend-manager/admin/email`, {
80
- method: 'post',
81
- response: 'json',
82
- log: true,
83
- headers: {
84
- 'Authorization': `Bearer ${process.env.BACKEND_MANAGER_KEY}`,
85
- },
86
- body: emailPayload.payload,
87
- }).catch(e => e);
72
+ // Send email directly via library
73
+ const email = Manager.Email(assistant);
74
+ const result = await email.send(emailPayload.payload).catch(e => e);
88
75
 
89
76
  if (result instanceof Error) {
90
- return assistant.respond(`Error sending email: ${result}`, { code: 500, sentry: true });
77
+ return assistant.respond(result.message, { code: result.code || 500, sentry: result.code !== 400 });
91
78
  }
92
79
 
93
- assistant.log('Response:', result);
80
+ assistant.log('Response:', result.status);
94
81
 
95
82
  // Track analytics
96
- analytics.event('general/email', { id: settings.id });
83
+ assistant.analytics.event('general/email', { id: settings.id });
97
84
 
98
85
  return assistant.respond({ success: true });
99
86
  };
@@ -9,9 +9,7 @@ const dns = require('dns').promises;
9
9
  // Load disposable domains list
10
10
  const DISPOSABLE_DOMAINS = require(path.join(__dirname, '..', '..', '..', 'libraries', 'disposable-domains.json'));
11
11
  const DISPOSABLE_SET = new Set(DISPOSABLE_DOMAINS.map(d => d.toLowerCase()));
12
-
13
- // Load OpenAI library
14
- const OpenAI = require(path.join(__dirname, '..', '..', '..', 'libraries', 'openai'));
12
+ const { inferContact } = require(path.join(__dirname, '..', '..', '..', 'libraries', 'infer-contact.js'));
15
13
 
16
14
  module.exports = async ({ assistant, Manager, settings, analytics }) => {
17
15
 
@@ -100,7 +98,7 @@ module.exports = async ({ assistant, Manager, settings, analytics }) => {
100
98
  // Infer name if not provided
101
99
  let nameInferred = null;
102
100
  if (!firstName && !lastName) {
103
- nameInferred = await inferName(email, assistant);
101
+ nameInferred = await inferContact(email, assistant);
104
102
  firstName = nameInferred.firstName;
105
103
  lastName = nameInferred.lastName;
106
104
  }
@@ -212,102 +210,6 @@ async function validateWithZeroBounce(email) {
212
210
  }
213
211
  }
214
212
 
215
- // Helper: Infer name from email
216
- async function inferName(email, assistant) {
217
- if (process.env.OPENAI_API_KEY) {
218
- const aiResult = await inferNameWithAI(email, assistant);
219
- if (aiResult && (aiResult.firstName || aiResult.lastName)) {
220
- return aiResult;
221
- }
222
- }
223
-
224
- return inferNameFromEmail(email);
225
- }
226
-
227
- // Helper: Use AI to infer name
228
- async function inferNameWithAI(email, assistant) {
229
- try {
230
- const ai = new OpenAI(assistant);
231
- const result = await ai.request({
232
- model: 'gpt-5-mini',
233
- timeout: 30000,
234
- maxTokens: 1024,
235
- moderate: false,
236
- response: 'json',
237
- prompt: {
238
- content: `
239
- <identity>
240
- You extract names and company from email addresses.
241
- </identity>
242
-
243
- <format>
244
- Return ONLY valid JSON like so:
245
- {
246
- "firstName": "...",
247
- "lastName": "...",
248
- "company": "...",
249
- "confidence": "..."
250
- }
251
-
252
- If you cannot determine a name, use empty strings.
253
- </format>
254
- `,
255
- },
256
- message: {
257
- content: `Email: ${email}`,
258
- },
259
- });
260
-
261
- if (result?.firstName !== undefined) {
262
- return {
263
- firstName: capitalize(result.firstName || ''),
264
- lastName: capitalize(result.lastName || ''),
265
- company: capitalize(result.company || ''),
266
- confidence: typeof result.confidence === 'number' ? result.confidence : 0.5,
267
- method: 'ai',
268
- };
269
- }
270
- } catch (e) {
271
- console.error('AI name inference error:', e);
272
- }
273
-
274
- return null;
275
- }
276
-
277
- // Helper: Regex-based name inference
278
- function inferNameFromEmail(email) {
279
- const local = email.split('@')[0];
280
- const cleaned = local.replace(/[0-9]+$/, '');
281
- const parts = cleaned.split(/[._-]/);
282
-
283
- if (parts.length >= 2) {
284
- return {
285
- firstName: capitalize(parts[0]),
286
- lastName: capitalize(parts.slice(1).join(' ')),
287
- confidence: 0.5,
288
- method: 'regex',
289
- };
290
- }
291
-
292
- return {
293
- firstName: capitalize(cleaned),
294
- lastName: '',
295
- confidence: 0.25,
296
- method: 'regex',
297
- };
298
- }
299
-
300
- // Helper: Capitalize string
301
- function capitalize(str) {
302
- if (!str) {
303
- return '';
304
- }
305
- return str
306
- .split(' ')
307
- .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
308
- .join(' ');
309
- }
310
-
311
213
  // Helper: Add contact to SendGrid
312
214
  async function addToSendGrid({ email, firstName, lastName, source, appId, brandName }) {
313
215
  try {
@@ -71,6 +71,10 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
71
71
 
72
72
  assistant.log(`Generated orderId=${orderId}`);
73
73
 
74
+ // Build redirect URLs
75
+ const confirmationUrl = buildConfirmationUrl(Manager.project.websiteUrl, { product, productId, productType, frequency, processor, trial, orderId });
76
+ const cancelUrl = buildCancelUrl(Manager.project.websiteUrl, { productId, frequency });
77
+
74
78
  // Load the processor module
75
79
  let processorModule;
76
80
  try {
@@ -89,8 +93,8 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
89
93
  productId,
90
94
  frequency,
91
95
  trial,
92
- config: Manager.config,
93
- Manager,
96
+ confirmationUrl,
97
+ cancelUrl,
94
98
  assistant,
95
99
  });
96
100
  } catch (e) {
@@ -132,3 +136,41 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
132
136
  url: result.url,
133
137
  });
134
138
  };
139
+
140
+ /**
141
+ * Build the confirmation/success redirect URL
142
+ */
143
+ function buildConfirmationUrl(baseUrl, { product, productId, productType, frequency, processor, trial, orderId }) {
144
+ const amount = productType === 'subscription'
145
+ ? (product.prices?.[frequency]?.amount || 0)
146
+ : (product.prices?.once?.amount || 0);
147
+
148
+ const url = new URL('/payment/confirmation', baseUrl);
149
+ url.searchParams.set('productId', productId);
150
+ url.searchParams.set('productName', product.name || productId);
151
+ url.searchParams.set('amount', trial && product.trial?.days ? '0' : String(amount));
152
+ url.searchParams.set('currency', 'USD');
153
+ url.searchParams.set('frequency', frequency || 'once');
154
+ url.searchParams.set('paymentMethod', processor);
155
+ url.searchParams.set('trial', String(!!trial && !!product.trial?.days));
156
+ url.searchParams.set('orderId', orderId);
157
+ url.searchParams.set('track', 'true');
158
+
159
+ return url.toString();
160
+ }
161
+
162
+ /**
163
+ * Build the cancel/back redirect URL
164
+ */
165
+ function buildCancelUrl(baseUrl, { productId, frequency }) {
166
+ const url = new URL('/payment/checkout', baseUrl);
167
+ url.searchParams.set('product', productId);
168
+
169
+ if (frequency) {
170
+ url.searchParams.set('frequency', frequency);
171
+ }
172
+
173
+ url.searchParams.set('payment', 'cancelled');
174
+
175
+ return url.toString();
176
+ }
@@ -1,3 +1,5 @@
1
+ const resolvePriceId = require('../../../../libraries/payment-processors/resolve-price-id.js');
2
+
1
3
  /**
2
4
  * Stripe intent processor
3
5
  * Creates Stripe Checkout Sessions for subscription and one-time purchases
@@ -12,11 +14,11 @@ module.exports = {
12
14
  * @param {string} options.productId - Product ID from config (e.g., 'premium')
13
15
  * @param {string} options.frequency - 'monthly' or 'annually' (subscriptions only)
14
16
  * @param {boolean} options.trial - Whether to include a trial period (subscriptions only)
15
- * @param {object} options.config - BEM config
16
- * @param {object} options.Manager - Manager instance
17
+ * @param {string} options.confirmationUrl - Success redirect URL
18
+ * @param {string} options.cancelUrl - Cancel redirect URL
17
19
  * @returns {object} { id, url, raw }
18
20
  */
19
- async createIntent({ uid, orderId, product, productId, frequency, trial, config, Manager, assistant }) {
21
+ async createIntent({ uid, orderId, product, productId, frequency, trial, confirmationUrl, cancelUrl, assistant }) {
20
22
  // Initialize Stripe SDK
21
23
  const StripeLib = require('../../../../libraries/payment-processors/stripe.js');
22
24
  const stripe = StripeLib.init();
@@ -24,50 +26,13 @@ module.exports = {
24
26
  const productType = product.type || 'subscription';
25
27
 
26
28
  // Resolve the Stripe price ID based on product type
27
- let priceId;
28
- if (productType === 'subscription') {
29
- priceId = product.prices?.[frequency]?.stripe;
30
- if (!priceId) {
31
- throw new Error(`No Stripe price found for ${productId}/${frequency}`);
32
- }
33
- } else {
34
- priceId = product.prices?.once?.stripe;
35
- if (!priceId) {
36
- throw new Error(`No Stripe price found for ${productId}/once`);
37
- }
38
- }
29
+ const priceId = resolvePriceId(product, productType, frequency);
39
30
 
40
31
  // Resolve or create Stripe customer (keyed by uid in metadata)
41
32
  const email = assistant?.getUser()?.auth?.email || null;
42
33
  const customer = await resolveCustomer(stripe, uid, email, assistant);
43
34
 
44
- assistant?.log(`Stripe checkout: type=${productType}, priceId=${priceId}, uid=${uid}, customerId=${customer.id}, trial=${trial}, trialDays=${product.trial?.days || 'none'}`);
45
-
46
- // Build confirmation redirect URL
47
- const baseUrl = Manager.project.websiteUrl;
48
- const amount = productType === 'subscription'
49
- ? (product.prices?.[frequency]?.amount || 0)
50
- : (product.prices?.once?.amount || 0);
51
-
52
- let confirmationUrl = new URL('/payment/confirmation', baseUrl);
53
- confirmationUrl.searchParams.set('productId', productId);
54
- confirmationUrl.searchParams.set('productName', product.name || productId);
55
- confirmationUrl.searchParams.set('amount', trial && product.trial?.days ? '0' : String(amount));
56
- confirmationUrl.searchParams.set('currency', 'USD');
57
- confirmationUrl.searchParams.set('frequency', frequency || 'once');
58
- confirmationUrl.searchParams.set('paymentMethod', 'stripe');
59
- confirmationUrl.searchParams.set('trial', String(!!trial && !!product.trial?.days));
60
- confirmationUrl.searchParams.set('orderId', orderId);
61
- confirmationUrl.searchParams.set('track', 'true');
62
- confirmationUrl = confirmationUrl.toString();
63
-
64
- let cancelUrl = new URL('/payment/checkout', baseUrl);
65
- cancelUrl.searchParams.set('product', productId);
66
- if (frequency) {
67
- cancelUrl.searchParams.set('frequency', frequency);
68
- }
69
- cancelUrl.searchParams.set('payment', 'cancelled');
70
- cancelUrl = cancelUrl.toString();
35
+ assistant.log(`Stripe checkout: type=${productType}, priceId=${priceId}, uid=${uid}, customerId=${customer.id}, trial=${trial}, trialDays=${product.trial?.days || 'none'}`);
71
36
 
72
37
  // Build session params based on product type
73
38
  let sessionParams;
@@ -81,7 +46,7 @@ module.exports = {
81
46
  // Create the checkout session
82
47
  const session = await stripe.checkout.sessions.create(sessionParams);
83
48
 
84
- assistant?.log(`Stripe session created: sessionId=${session.id}, mode=${sessionParams.mode}, url=${session.url}`);
49
+ assistant.log(`Stripe session created: sessionId=${session.id}, mode=${sessionParams.mode}, url=${session.url}`);
85
50
 
86
51
  return {
87
52
  id: session.id,
@@ -165,7 +130,7 @@ async function resolveCustomer(stripe, uid, email, assistant) {
165
130
 
166
131
  if (search.data.length > 0) {
167
132
  const existing = search.data[0];
168
- assistant?.log(`Found existing Stripe customer: ${existing.id}`);
133
+ assistant.log(`Found existing Stripe customer: ${existing.id}`);
169
134
  return existing;
170
135
  }
171
136
 
@@ -179,6 +144,6 @@ async function resolveCustomer(stripe, uid, email, assistant) {
179
144
  }
180
145
 
181
146
  const customer = await stripe.customers.create(params);
182
- assistant?.log(`Created new Stripe customer: ${customer.id}`);
147
+ assistant.log(`Created new Stripe customer: ${customer.id}`);
183
148
  return customer;
184
149
  }
@@ -1,4 +1,5 @@
1
1
  const fetch = require('wonderful-fetch');
2
+ const resolvePriceId = require('../../../../libraries/payment-processors/resolve-price-id.js');
2
3
 
3
4
  /**
4
5
  * Test intent processor
@@ -16,12 +17,12 @@ module.exports = {
16
17
  * @param {string} options.productId - Product ID from config
17
18
  * @param {string} options.frequency - 'monthly' or 'annually' (subscriptions only)
18
19
  * @param {boolean} options.trial - Whether to include a trial period (subscriptions only)
19
- * @param {object} options.config - BEM config
20
- * @param {object} options.Manager - Manager instance
20
+ * @param {string} options.confirmationUrl - Success redirect URL
21
+ * @param {string} options.cancelUrl - Cancel redirect URL
21
22
  * @param {object} options.assistant - Assistant instance
22
23
  * @returns {object} { id, url, raw }
23
24
  */
24
- async createIntent({ uid, orderId, product, productId, frequency, trial, config, Manager, assistant }) {
25
+ async createIntent({ uid, orderId, product, productId, frequency, trial, confirmationUrl, assistant }) {
25
26
  // Guard: test processor is not available in production
26
27
  if (assistant.isProduction()) {
27
28
  throw new Error('Test processor is not available in production');
@@ -30,10 +31,10 @@ module.exports = {
30
31
  const productType = product.type || 'subscription';
31
32
 
32
33
  if (productType === 'subscription') {
33
- return createSubscriptionIntent({ uid, orderId, product, productId, frequency, trial, config, Manager, assistant });
34
+ return createSubscriptionIntent({ uid, orderId, product, frequency, trial, confirmationUrl, assistant });
34
35
  }
35
36
 
36
- return createOneTimeIntent({ uid, orderId, product, productId, config, Manager, assistant });
37
+ return createOneTimeIntent({ uid, orderId, product, productId, confirmationUrl, assistant });
37
38
  },
38
39
  };
39
40
 
@@ -41,12 +42,9 @@ module.exports = {
41
42
  * Create a test subscription intent
42
43
  * Generates Stripe-shaped subscription + customer.subscription.created event
43
44
  */
44
- async function createSubscriptionIntent({ uid, orderId, product, productId, frequency, trial, config, Manager, assistant }) {
45
+ async function createSubscriptionIntent({ uid, orderId, product, frequency, trial, confirmationUrl, assistant }) {
45
46
  // Get the price ID for the requested frequency
46
- const priceId = product.prices?.[frequency]?.stripe;
47
- if (!priceId) {
48
- throw new Error(`No Stripe price found for ${productId}/${frequency}`);
49
- }
47
+ const priceId = resolvePriceId(product, 'subscription', frequency);
50
48
 
51
49
  // Generate IDs
52
50
  const timestamp = Date.now();
@@ -94,14 +92,14 @@ async function createSubscriptionIntent({ uid, orderId, product, productId, freq
94
92
  data: { object: subscription },
95
93
  };
96
94
 
97
- assistant?.log(`Test subscription intent: sessionId=${sessionId}, subscriptionId=${subscriptionId}, eventId=${eventId}, trial=${!!subscription.trial_start}`);
95
+ assistant.log(`Test subscription intent: sessionId=${sessionId}, subscriptionId=${subscriptionId}, eventId=${eventId}, trial=${!!subscription.trial_start}`);
98
96
 
99
97
  // Auto-fire webhook
100
- fireWebhook({ event, Manager, assistant });
98
+ fireWebhook({ event, assistant });
101
99
 
102
100
  return {
103
101
  id: sessionId,
104
- url: `${config.brand?.url || 'https://example.com'}/payment/confirmation?session=${sessionId}`,
102
+ url: confirmationUrl,
105
103
  raw: { id: sessionId, object: 'checkout.session', subscription: subscriptionId },
106
104
  };
107
105
  }
@@ -110,12 +108,9 @@ async function createSubscriptionIntent({ uid, orderId, product, productId, freq
110
108
  * Create a test one-time payment intent
111
109
  * Generates Stripe-shaped checkout session + checkout.session.completed event
112
110
  */
113
- async function createOneTimeIntent({ uid, orderId, product, productId, config, Manager, assistant }) {
114
- // Get the price ID for one-time purchase
115
- const priceId = product.prices?.once?.stripe;
116
- if (!priceId) {
117
- throw new Error(`No Stripe price found for ${productId}/once`);
118
- }
111
+ async function createOneTimeIntent({ uid, orderId, product, productId, confirmationUrl, assistant }) {
112
+ // Validate that a price exists (resolvePriceId throws if not found)
113
+ resolvePriceId(product, 'one-time', null);
119
114
 
120
115
  // Generate IDs
121
116
  const timestamp = Date.now();
@@ -141,14 +136,14 @@ async function createOneTimeIntent({ uid, orderId, product, productId, config, M
141
136
  data: { object: session },
142
137
  };
143
138
 
144
- assistant?.log(`Test one-time intent: sessionId=${sessionId}, eventId=${eventId}, productId=${productId}`);
139
+ assistant.log(`Test one-time intent: sessionId=${sessionId}, eventId=${eventId}, productId=${productId}`);
145
140
 
146
141
  // Auto-fire webhook
147
- fireWebhook({ event, Manager, assistant });
142
+ fireWebhook({ event, assistant });
148
143
 
149
144
  return {
150
145
  id: sessionId,
151
- url: `${config.brand?.url || 'https://example.com'}/payment/confirmation?session=${sessionId}`,
146
+ url: confirmationUrl,
152
147
  raw: { id: sessionId, object: 'checkout.session', mode: 'payment' },
153
148
  };
154
149
  }
@@ -156,14 +151,14 @@ async function createOneTimeIntent({ uid, orderId, product, productId, config, M
156
151
  /**
157
152
  * Fire-and-forget webhook to trigger the full pipeline
158
153
  */
159
- function fireWebhook({ event, Manager, assistant }) {
160
- const webhookUrl = `${Manager.project.apiUrl}/backend-manager/payments/webhook?processor=test&key=${process.env.BACKEND_MANAGER_KEY}`;
154
+ function fireWebhook({ event, assistant }) {
155
+ const webhookUrl = `${assistant.Manager.project.apiUrl}/backend-manager/payments/webhook?processor=test&key=${process.env.BACKEND_MANAGER_KEY}`;
161
156
  fetch(webhookUrl, {
162
157
  method: 'POST',
163
158
  response: 'json',
164
159
  body: event,
165
160
  timeout: 15000,
166
161
  }).catch((e) => {
167
- assistant?.log(`Test processor auto-webhook failed: ${e.message}`);
162
+ assistant.log(`Test processor auto-webhook failed: ${e.message}`);
168
163
  });
169
164
  }
@@ -14,8 +14,9 @@ const STATE_KEY = process.env.BACKEND_MANAGER_KEY
14
14
  * Build context object with common OAuth2 data
15
15
  * Used by GET, POST, DELETE handlers
16
16
  */
17
- async function buildContext({ assistant, Manager, user, settings, libraries, requireProvider = true }) {
18
- const { admin } = libraries;
17
+ async function buildContext({ assistant, user, settings, requireProvider = true }) {
18
+ const Manager = assistant.Manager;
19
+ const { admin } = Manager.libraries;
19
20
 
20
21
  // Require authentication
21
22
  if (!user.authenticated) {
@@ -8,14 +8,14 @@ const {
8
8
  *
9
9
  * Revokes tokens with the provider (best effort) and removes the connection.
10
10
  */
11
- module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
12
- const context = await buildContext({ assistant, Manager, user, settings, libraries });
11
+ module.exports = async ({ assistant, user, settings }) => {
12
+ const context = await buildContext({ assistant, user, settings });
13
13
 
14
14
  if (context.error) {
15
15
  return assistant.respond(context.error.message, { code: context.error.code });
16
16
  }
17
17
 
18
- const { admin, oauth2Provider, targetUid, targetUser, clientId, clientSecret } = context;
18
+ const { Manager, admin, oauth2Provider, targetUid, targetUser, clientId, clientSecret } = context;
19
19
 
20
20
  assistant.log('OAuth2 DELETE request', { provider: settings.provider });
21
21
 
@@ -12,8 +12,8 @@ const {
12
12
  * - authorize (default): Get authorization URL
13
13
  * - status: Check connection status
14
14
  */
15
- module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
16
- const context = await buildContext({ assistant, Manager, user, settings, libraries });
15
+ module.exports = async ({ assistant, user, settings }) => {
16
+ const context = await buildContext({ assistant, user, settings });
17
17
 
18
18
  if (context.error) {
19
19
  return assistant.respond(context.error.message, { code: context.error.code });
@@ -14,18 +14,16 @@ const {
14
14
  * - tokenize (default): Exchange authorization code for tokens
15
15
  * - refresh: Refresh access token
16
16
  */
17
- module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
18
- const { admin } = libraries;
19
-
17
+ module.exports = async ({ assistant, user, settings }) => {
20
18
  assistant.log('OAuth2 POST request', { action: settings.action });
21
19
 
22
20
  switch (settings.action) {
23
21
  case 'refresh':
24
- return processRefresh({ assistant, Manager, user, settings, libraries });
22
+ return processRefresh({ assistant, user, settings });
25
23
 
26
24
  case 'tokenize':
27
25
  default:
28
- return processTokenize({ assistant, Manager, admin, settings });
26
+ return processTokenize({ assistant, settings });
29
27
  }
30
28
  };
31
29
 
@@ -33,7 +31,9 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
33
31
  // Handlers
34
32
  // ============================================================================
35
33
 
36
- async function processTokenize({ assistant, Manager, admin, settings }) {
34
+ async function processTokenize({ assistant, settings }) {
35
+ const Manager = assistant.Manager;
36
+ const { admin } = Manager.libraries;
37
37
  assistant.log('processTokenize settings', {
38
38
  hasCode: !!settings.code,
39
39
  codeType: typeof settings.code,
@@ -167,14 +167,14 @@ async function processTokenize({ assistant, Manager, admin, settings }) {
167
167
  return assistant.respond({ success: true });
168
168
  }
169
169
 
170
- async function processRefresh({ assistant, Manager, user, settings, libraries }) {
171
- const context = await buildContext({ assistant, Manager, user, settings, libraries });
170
+ async function processRefresh({ assistant, user, settings }) {
171
+ const context = await buildContext({ assistant, user, settings });
172
172
 
173
173
  if (context.error) {
174
174
  return assistant.respond(context.error.message, { code: context.error.code });
175
175
  }
176
176
 
177
- const { admin, oauth2Provider, targetUid, targetUser, clientId, clientSecret } = context;
177
+ const { Manager, admin, oauth2Provider, targetUid, targetUser, clientId, clientSecret } = context;
178
178
 
179
179
  const refreshToken = targetUser?.oauth2?.[settings.provider]?.token?.refresh_token;
180
180
 
@@ -26,10 +26,10 @@ module.exports = async ({ assistant, user, settings, libraries }) => {
26
26
  let count = 0;
27
27
 
28
28
  // Sign out of main session
29
- count += await signOutOfSession(admin, assistant, uid, sessionPath);
29
+ count += await signOutOfSession(assistant, uid, sessionPath);
30
30
 
31
31
  // Legacy for somiibo and old electron-manager
32
- count += await signOutOfSession(admin, assistant, uid, 'gatherings/online');
32
+ count += await signOutOfSession(assistant, uid, 'gatherings/online');
33
33
 
34
34
  // Revoke Firebase refresh tokens
35
35
  try {
@@ -47,7 +47,8 @@ module.exports = async ({ assistant, user, settings, libraries }) => {
47
47
  /**
48
48
  * Sign out of a specific session path
49
49
  */
50
- async function signOutOfSession(admin, assistant, uid, sessionPath) {
50
+ async function signOutOfSession(assistant, uid, sessionPath) {
51
+ const { admin } = assistant.Manager.libraries;
51
52
  let count = 0;
52
53
 
53
54
  const snapshot = await admin.database().ref(sessionPath)