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
@@ -0,0 +1,483 @@
1
+ /**
2
+ * Shared email library for building and sending emails via SendGrid
3
+ *
4
+ * Usage:
5
+ * const email = Manager.Email(assistant);
6
+ * const result = await email.send(settings);
7
+ *
8
+ * Used by:
9
+ * - POST /admin/email route
10
+ * - POST /general/email route
11
+ * - Payment transition handlers (send-email.js)
12
+ * - Auth on-create handler (welcome/checkup/feedback emails)
13
+ */
14
+ const _ = require('lodash');
15
+ const moment = require('moment');
16
+
17
+ // SendGrid limit for scheduled emails (72 hours, but use 71 for buffer)
18
+ const SEND_AT_LIMIT = 71;
19
+
20
+ function Email(assistant) {
21
+ const self = this;
22
+
23
+ self.assistant = assistant;
24
+ self.Manager = assistant.Manager;
25
+ self.admin = self.Manager.libraries.admin;
26
+
27
+ return self;
28
+ }
29
+
30
+ /**
31
+ * Build a complete SendGrid email object from settings.
32
+ *
33
+ * @param {object} settings - Email settings (to, cc, bcc, subject, template, etc.)
34
+ * @returns {object} SendGrid-ready email object
35
+ * @throws {Error} On validation failure
36
+ */
37
+ Email.prototype.build = async function (settings) {
38
+ const self = this;
39
+ const Manager = self.Manager;
40
+ const admin = self.admin;
41
+ const assistant = self.assistant;
42
+ const powertools = require('node-powertools');
43
+
44
+ // Normalize recipients
45
+ let to = normalizeRecipients(settings.to);
46
+ let cc = normalizeRecipients(settings.cc);
47
+ let bcc = normalizeRecipients(settings.bcc);
48
+
49
+ // Resolve any uid: prefixed recipients from Firestore
50
+ [to, cc, bcc] = await Promise.all([
51
+ resolveRecipients(to, admin, assistant),
52
+ resolveRecipients(cc, admin, assistant),
53
+ resolveRecipients(bcc, admin, assistant),
54
+ ]);
55
+
56
+ // Build user template data from settings.user (for legacy callers that pass user object)
57
+ const userProperties = Manager.User(settings.user || {}).properties;
58
+
59
+ // Get brand config
60
+ const brand = Manager.config?.brand;
61
+
62
+ if (!brand) {
63
+ throw errorWithCode('Missing brand configuration in backend-manager-config.json', 400);
64
+ }
65
+
66
+ const app = {
67
+ id: brand.id,
68
+ name: brand.name,
69
+ url: brand.url,
70
+ email: brand.contact?.email,
71
+ images: brand.images || {},
72
+ };
73
+
74
+ if (!app.email) {
75
+ throw errorWithCode('Missing brand.contact.email in backend-manager-config.json', 400);
76
+ }
77
+
78
+ // Add carbon copy recipients
79
+ if (settings.copy) {
80
+ cc.push({
81
+ email: app.email,
82
+ name: app.name,
83
+ });
84
+ bcc.push(
85
+ {
86
+ email: 'support@itwcreativeworks.com',
87
+ name: 'ITW Creative Works',
88
+ },
89
+ {
90
+ email: 'parser+carboncopy@sendgrid-parser.itwcreativeworks.com',
91
+ name: 'ITW Creative Works (Carbon Copy)',
92
+ }
93
+ );
94
+ }
95
+
96
+ // Deduplicate all lists
97
+ ({ to, cc, bcc } = deduplicateRecipients(to, cc, bcc));
98
+
99
+ // Delete empty names
100
+ for (const list of [to, cc, bcc]) {
101
+ for (const entry of list) {
102
+ if (!entry.name) {
103
+ delete entry.name;
104
+ }
105
+ }
106
+ }
107
+
108
+ // Validate
109
+ if (!to.length || !to[0].email) {
110
+ throw errorWithCode('Parameter to is required with at least one email', 400);
111
+ }
112
+
113
+ const subject = settings.subject || settings?.data?.email?.subject || null;
114
+
115
+ if (!subject) {
116
+ throw errorWithCode('Parameter subject is required', 400);
117
+ }
118
+
119
+ const templateId = settings.template;
120
+
121
+ if (!templateId && !settings.html) {
122
+ throw errorWithCode('Parameter template is required', 400);
123
+ }
124
+
125
+ const groupId = settings.group;
126
+
127
+ if (!groupId) {
128
+ throw errorWithCode('Parameter group is required', 400);
129
+ }
130
+
131
+ // Build categories
132
+ const categories = _.uniq([
133
+ 'transactional',
134
+ app.id,
135
+ ...powertools.arrayify(settings.categories),
136
+ ]);
137
+
138
+ // Normalize sendAt
139
+ const sendAt = normalizeSendAt(settings.sendAt);
140
+
141
+ // Build unsubscribe URL
142
+ const unsubscribeUrl = `https://itwcreativeworks.com/portal/account/email-preferences?email=${encode(to[0].email)}&asmId=${encode(groupId)}&templateId=${encode(templateId)}&appName=${app.name}&appUrl=${app.url}`;
143
+
144
+ // Build signoff
145
+ const signoff = settings?.data?.signoff || {};
146
+ signoff.type = signoff.type || 'team';
147
+
148
+ if (signoff.type === 'personal') {
149
+ signoff.image = signoff.image
150
+ || 'https://cdn.itwcreativeworks.com/assets/ian-wiedenman/images/website/ian-wiedenman-headshot-2021-color-1024x1024.jpg';
151
+ signoff.name = signoff.name || 'Ian Wiedenman, CEO';
152
+ signoff.url = signoff.url || 'https://ianwiedenman.com';
153
+ signoff.urlText = signoff.urlText || '@ianwieds';
154
+ }
155
+
156
+ // Build dynamic template data
157
+ const dynamicTemplateData = {
158
+ email: {
159
+ id: Manager.require('uuid').v4(),
160
+ subject: settings?.data?.email?.subject || null,
161
+ preview: settings?.data?.email?.preview || null,
162
+ body: settings?.data?.email?.body || null,
163
+ unsubscribeUrl,
164
+ categories,
165
+ footer: {
166
+ text: settings?.data?.email?.footer?.text || null,
167
+ },
168
+ carbonCopy: settings.copy,
169
+ },
170
+ personalization: {
171
+ email: to[0].email,
172
+ name: to[0].name,
173
+ ...settings?.data?.personalization,
174
+ },
175
+ signoff,
176
+ app,
177
+ user: userProperties,
178
+ data: settings.data || {},
179
+ };
180
+
181
+ // Build the email object
182
+ const email = {
183
+ to,
184
+ cc,
185
+ bcc,
186
+ from: settings.from || { email: app.email, name: app.name },
187
+ replyTo: settings.replyTo || app.email,
188
+ subject,
189
+ templateId,
190
+ asm: { groupId },
191
+ categories,
192
+ dynamicTemplateData,
193
+ substitutionWrappers: ['{{', '}}'],
194
+ headers: {
195
+ 'List-Unsubscribe': `<${unsubscribeUrl}>`,
196
+ },
197
+ };
198
+
199
+ // Set sendAt
200
+ if (sendAt) {
201
+ email.sendAt = sendAt;
202
+ }
203
+
204
+ // Handle raw HTML override
205
+ if (settings.html) {
206
+ email.content = [{ type: 'text/html', value: settings.html }];
207
+ delete email.templateId;
208
+ }
209
+
210
+ // Build stringified version for template rendering
211
+ const clonedData = _.cloneDeep(dynamicTemplateData);
212
+ clonedData.app.sponsorships = {};
213
+ email.dynamicTemplateData._stringified = JSON.stringify(clonedData, null, 2);
214
+
215
+ return email;
216
+ };
217
+
218
+ /**
219
+ * Build and send an email via SendGrid, or queue it if scheduled beyond the limit.
220
+ * Calls .build() internally — callers only need to pass raw settings.
221
+ *
222
+ * @param {object} settings - Email settings (to, cc, bcc, subject, template, etc.)
223
+ * @returns {{ status: string, options?: object, response?: object }}
224
+ * @throws {Error} With code 400 for validation errors, code 500 for send failures
225
+ */
226
+ Email.prototype.send = async function (settings) {
227
+ const self = this;
228
+ const Manager = self.Manager;
229
+ const admin = self.admin;
230
+ const assistant = self.assistant;
231
+
232
+ // Build email from settings (throws with code: 400 on validation failure)
233
+ const email = await self.build(settings);
234
+
235
+ // Initialize SendGrid
236
+ const sendgrid = Manager.require('@sendgrid/mail');
237
+ sendgrid.setApiKey(process.env.SENDGRID_API_KEY);
238
+
239
+ // If scheduled beyond the limit, queue it
240
+ if (email.sendAt && email.sendAt >= moment().add(SEND_AT_LIMIT, 'hours').unix()) {
241
+ await saveToEmailQueue(email, admin, assistant);
242
+
243
+ return {
244
+ status: 'queued',
245
+ options: email,
246
+ response: null,
247
+ };
248
+ }
249
+
250
+ // Send via SendGrid
251
+ const send = await sendgrid.send(email).catch(e => e);
252
+
253
+ if (send instanceof Error) {
254
+ const details = send?.response?.body?.errors || send;
255
+ assistant.error('Email send failed:', details);
256
+ throw errorWithCode(`Failed to send email: ${JSON.stringify(details)}`, 500);
257
+ }
258
+
259
+ // Extract message ID
260
+ const messageId = send[0].headers['x-message-id'];
261
+
262
+ assistant.log('Email send succeeded:', messageId, send);
263
+
264
+ // Save audit trail (non-blocking)
265
+ saveAuditTrail(email, messageId, admin, assistant);
266
+
267
+ // Track analytics
268
+ if (assistant.analytics) {
269
+ assistant.analytics.event('admin/email', { status: 'sent' });
270
+ }
271
+
272
+ return {
273
+ status: 'sent',
274
+ options: email,
275
+ response: send,
276
+ };
277
+ };
278
+
279
+ // --- Private helpers ---
280
+
281
+ /**
282
+ * Normalize recipient input into a consistent array of { email, name? } objects.
283
+ * Entries with a `uid:` prefix are marked with `_uid` for later Firestore resolution.
284
+ */
285
+ function normalizeRecipients(input) {
286
+ if (!input) {
287
+ return [];
288
+ }
289
+
290
+ const items = Array.isArray(input) ? input : [input];
291
+ const result = [];
292
+
293
+ for (const item of items) {
294
+ if (!item) {
295
+ continue;
296
+ }
297
+
298
+ if (typeof item === 'string') {
299
+ if (item.startsWith('uid:')) {
300
+ result.push({ _uid: item.slice(4) });
301
+ } else {
302
+ result.push({ email: item });
303
+ }
304
+ } else if (typeof item === 'object' && item.email) {
305
+ result.push({ email: item.email, ...(item.name && { name: item.name }) });
306
+ }
307
+ }
308
+
309
+ return result;
310
+ }
311
+
312
+ /**
313
+ * Resolve any uid-prefixed recipients by fetching user docs from Firestore.
314
+ */
315
+ async function resolveRecipients(recipients, admin, assistant) {
316
+ const uidEntries = recipients.filter(r => r._uid);
317
+ const nonUidEntries = recipients.filter(r => !r._uid);
318
+
319
+ if (uidEntries.length === 0) {
320
+ return nonUidEntries;
321
+ }
322
+
323
+ // Fetch all UIDs in parallel
324
+ const snapshots = await Promise.all(
325
+ uidEntries.map(entry =>
326
+ admin.firestore().doc(`users/${entry._uid}`).get()
327
+ .catch(e => {
328
+ assistant.error(`resolveRecipients(): Failed to fetch user ${entry._uid}`, e);
329
+ return null;
330
+ })
331
+ )
332
+ );
333
+
334
+ const resolved = [];
335
+
336
+ for (let i = 0; i < uidEntries.length; i++) {
337
+ const snap = snapshots[i];
338
+
339
+ if (!snap || !snap.exists) {
340
+ assistant.warn(`resolveRecipients(): User ${uidEntries[i]._uid} not found, skipping`);
341
+ continue;
342
+ }
343
+
344
+ const data = snap.data();
345
+ const email = data?.auth?.email;
346
+
347
+ if (!email) {
348
+ assistant.warn(`resolveRecipients(): User ${uidEntries[i]._uid} has no email, skipping`);
349
+ continue;
350
+ }
351
+
352
+ resolved.push({
353
+ email,
354
+ ...(data?.personal?.name?.first && { name: data.personal.name.first }),
355
+ });
356
+ }
357
+
358
+ return [...nonUidEntries, ...resolved];
359
+ }
360
+
361
+ /**
362
+ * Deduplicate recipients within each list and cross-dedup cc/bcc against to.
363
+ */
364
+ function deduplicateRecipients(to, cc, bcc) {
365
+ const dedup = (arr) => {
366
+ const seen = new Set();
367
+
368
+ return arr.filter(r => {
369
+ if (!r.email || typeof r.email !== 'string') {
370
+ return false;
371
+ }
372
+
373
+ const key = r.email.toLowerCase();
374
+
375
+ if (seen.has(key)) {
376
+ return false;
377
+ }
378
+
379
+ seen.add(key);
380
+ return true;
381
+ });
382
+ };
383
+
384
+ to = dedup(to);
385
+
386
+ const toEmails = new Set(to.map(r => r.email.toLowerCase()));
387
+ cc = dedup(cc).filter(r => !toEmails.has(r.email.toLowerCase()));
388
+
389
+ const toCcEmails = new Set([...toEmails, ...cc.map(r => r.email.toLowerCase())]);
390
+ bcc = dedup(bcc).filter(r => !toCcEmails.has(r.email.toLowerCase()));
391
+
392
+ return { to, cc, bcc };
393
+ }
394
+
395
+ /**
396
+ * Normalize sendAt to a unix timestamp (seconds).
397
+ */
398
+ function normalizeSendAt(sendAt) {
399
+ if (!sendAt && sendAt !== 0) {
400
+ return null;
401
+ }
402
+
403
+ if (typeof sendAt === 'number') {
404
+ // If it looks like milliseconds (> year 2100 in seconds), convert
405
+ if (sendAt > 4102444800) {
406
+ return Math.floor(sendAt / 1000);
407
+ }
408
+ return sendAt;
409
+ }
410
+
411
+ if (typeof sendAt === 'string') {
412
+ const parsed = moment(sendAt);
413
+
414
+ if (parsed.isValid()) {
415
+ return parsed.unix();
416
+ }
417
+ }
418
+
419
+ return null;
420
+ }
421
+
422
+ /**
423
+ * Save email to queue for deferred sending (beyond 71h limit)
424
+ */
425
+ async function saveToEmailQueue(email, admin, assistant) {
426
+ const emailId = email.dynamicTemplateData.email.id;
427
+
428
+ // Clone and clean before storage
429
+ const emailCloned = _.cloneDeepWith(email, (value) => {
430
+ if (typeof value === 'undefined') {
431
+ return null;
432
+ }
433
+ });
434
+ delete emailCloned.dynamicTemplateData._stringified;
435
+
436
+ assistant.log(`saveToEmailQueue(): Saving email ${emailId}`);
437
+
438
+ await admin.firestore().doc(`email-queue/${emailId}`)
439
+ .set(emailCloned)
440
+ .then(() => assistant.log(`saveToEmailQueue(): Success ${emailId}`))
441
+ .catch(e => assistant.error(`saveToEmailQueue(): Failed ${emailId}`, e));
442
+ }
443
+
444
+ /**
445
+ * Save sent email to Firestore for audit trail (non-blocking)
446
+ */
447
+ function saveAuditTrail(email, messageId, admin, assistant) {
448
+ // Clone and clean before storage
449
+ const emailCloned = _.cloneDeepWith(email, (value) => {
450
+ if (typeof value === 'undefined') {
451
+ return null;
452
+ }
453
+ });
454
+ delete emailCloned.dynamicTemplateData._stringified;
455
+
456
+ admin.firestore().doc(`emails/${messageId}`)
457
+ .set({
458
+ id: messageId,
459
+ request: emailCloned,
460
+ body: { html: '', text: '' },
461
+ created: assistant.meta.startTime,
462
+ })
463
+ .then(() => assistant.log(`Audit trail saved: ${messageId}`))
464
+ .catch(e => assistant.error(`Audit trail failed: ${messageId}`, e));
465
+ }
466
+
467
+ /**
468
+ * Create an Error with a code property for distinguishing build (400) vs send (500) failures.
469
+ */
470
+ function errorWithCode(message, code) {
471
+ const err = new Error(message);
472
+ err.code = code;
473
+ return err;
474
+ }
475
+
476
+ /**
477
+ * URL-encode a value as base64
478
+ */
479
+ function encode(s) {
480
+ return encodeURIComponent(Buffer.from(String(s)).toString('base64'));
481
+ }
482
+
483
+ module.exports = Email;
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Shared contact inference library
3
+ *
4
+ * Infers first/last name and company from an email address.
5
+ * Tries AI first (if OPENAI_API_KEY is set), falls back to regex parsing.
6
+ *
7
+ * Usage:
8
+ * const { inferContact } = require('./libraries/infer-contact.js');
9
+ * const result = await inferContact(email, assistant);
10
+ * // { firstName, lastName, company, confidence, method }
11
+ */
12
+ const path = require('path');
13
+
14
+ const PROMPT_PATH = path.join(__dirname, 'prompts', 'infer-contact.md');
15
+
16
+ // Common email providers (don't infer company from these domains)
17
+ const GENERIC_DOMAINS = new Set([
18
+ 'gmail.com', 'yahoo.com', 'hotmail.com', 'outlook.com', 'aol.com',
19
+ 'icloud.com', 'mail.com', 'protonmail.com', 'proton.me', 'zoho.com',
20
+ 'yandex.com', 'gmx.com', 'live.com', 'msn.com', 'me.com',
21
+ ]);
22
+
23
+ /**
24
+ * Infer contact info from email address
25
+ * Tries AI first (if OPENAI_API_KEY is set), falls back to regex
26
+ *
27
+ * @param {string} email - Email address
28
+ * @param {object} assistant - Assistant instance (for AI access)
29
+ * @returns {{ firstName: string, lastName: string, company: string, confidence: number, method: string }}
30
+ */
31
+ async function inferContact(email, assistant) {
32
+ if (process.env.OPENAI_API_KEY) {
33
+ const aiResult = await inferContactWithAI(email, assistant);
34
+ if (aiResult && (aiResult.firstName || aiResult.lastName)) {
35
+ return aiResult;
36
+ }
37
+ }
38
+
39
+ return inferContactFromEmail(email);
40
+ }
41
+
42
+ /**
43
+ * Use AI to infer contact info from email
44
+ *
45
+ * @param {string} email - Email address
46
+ * @param {object} assistant - Assistant instance
47
+ * @returns {object|null} Inferred contact or null on failure
48
+ */
49
+ async function inferContactWithAI(email, assistant) {
50
+ try {
51
+ const ai = assistant.Manager.AI(assistant);
52
+ const result = await ai.request({
53
+ model: 'gpt-5-mini',
54
+ timeout: 30000,
55
+ maxTokens: 1024,
56
+ moderate: false,
57
+ response: 'json',
58
+ prompt: {
59
+ path: PROMPT_PATH,
60
+ },
61
+ message: {
62
+ content: `Email: ${email}`,
63
+ },
64
+ });
65
+
66
+ if (result?.firstName !== undefined) {
67
+ return {
68
+ firstName: capitalize(result.firstName || ''),
69
+ lastName: capitalize(result.lastName || ''),
70
+ company: capitalize(result.company || ''),
71
+ confidence: typeof result.confidence === 'number' ? result.confidence : 0.5,
72
+ method: 'ai',
73
+ };
74
+ }
75
+ } catch (e) {
76
+ if (assistant) {
77
+ assistant.error('inferContactWithAI: Failed:', e);
78
+ }
79
+ }
80
+
81
+ return null;
82
+ }
83
+
84
+ /**
85
+ * Regex-based contact inference from email
86
+ * Extracts name from local part and company from domain
87
+ *
88
+ * @param {string} email - Email address
89
+ * @returns {{ firstName: string, lastName: string, company: string, confidence: number, method: string }}
90
+ */
91
+ function inferContactFromEmail(email) {
92
+ const [local, domain] = email.split('@');
93
+
94
+ // Infer company from domain (skip generic providers)
95
+ let company = '';
96
+ if (domain && !GENERIC_DOMAINS.has(domain.toLowerCase())) {
97
+ const domainName = domain.split('.')[0];
98
+ company = capitalize(domainName.replace(/[-_]/g, ' '));
99
+ }
100
+
101
+ // Infer name from local part
102
+ const cleaned = local.replace(/[0-9]+$/, '');
103
+ const parts = cleaned.split(/[._-]/);
104
+
105
+ if (parts.length >= 2) {
106
+ return {
107
+ firstName: capitalize(parts[0]),
108
+ lastName: capitalize(parts.slice(1).join(' ')),
109
+ company,
110
+ confidence: 0.5,
111
+ method: 'regex',
112
+ };
113
+ }
114
+
115
+ return {
116
+ firstName: capitalize(cleaned),
117
+ lastName: '',
118
+ company,
119
+ confidence: 0.25,
120
+ method: 'regex',
121
+ };
122
+ }
123
+
124
+ /**
125
+ * Capitalize first letter of each word
126
+ *
127
+ * @param {string} str - String to capitalize
128
+ * @returns {string} Capitalized string
129
+ */
130
+ function capitalize(str) {
131
+ if (!str) {
132
+ return '';
133
+ }
134
+ return str
135
+ .split(' ')
136
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
137
+ .join(' ');
138
+ }
139
+
140
+ module.exports = { inferContact, inferContactFromEmail, capitalize };
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Resolve the Stripe price ID from a product config object
3
+ *
4
+ * @param {object} product - Product object from config (must have .prices)
5
+ * @param {string} productType - 'subscription' or 'one-time'
6
+ * @param {string} frequency - 'monthly', 'annually', etc. (subscriptions) — ignored for one-time
7
+ * @returns {string} Stripe price ID
8
+ * @throws {Error} If no price ID found
9
+ */
10
+ module.exports = function resolvePriceId(product, productType, frequency) {
11
+ const key = productType === 'subscription' ? frequency : 'once';
12
+ const priceId = product.prices?.[key]?.stripe;
13
+
14
+ if (!priceId) {
15
+ throw new Error(`No Stripe price found for ${product.id}/${key}`);
16
+ }
17
+
18
+ return priceId;
19
+ };