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,5 +1,6 @@
1
1
  const powertools = require('node-powertools');
2
2
  const transitions = require('./transitions/index.js');
3
+ const { trackPayment } = require('./analytics.js');
3
4
 
4
5
  /**
5
6
  * Firestore trigger: payments-webhooks/{eventId} onWrite
@@ -13,8 +14,9 @@ const transitions = require('./transitions/index.js');
13
14
  * 4. Detects state transitions and dispatches handler files (non-blocking)
14
15
  * 5. Marks the webhook as completed
15
16
  */
16
- module.exports = async ({ Manager, assistant, change, context, libraries }) => {
17
- const { admin } = libraries;
17
+ module.exports = async ({ assistant, change, context }) => {
18
+ const Manager = assistant.Manager;
19
+ const admin = Manager.libraries.admin;
18
20
 
19
21
  const dataAfter = change.after.data();
20
22
 
@@ -73,17 +75,13 @@ module.exports = async ({ Manager, assistant, change, context, libraries }) => {
73
75
  // Extract orderId from resource metadata (set at intent creation)
74
76
  const orderId = resource.metadata?.orderId || null;
75
77
 
76
- // Branch on category
77
- let transitionName = null;
78
-
79
- if (category === 'subscription') {
80
- transitionName = await processSubscription({ library, resource, uid, processor, eventType, eventId, resourceId, orderId, now, nowUNIX, webhookReceivedUNIX, admin, assistant, Manager });
81
- } else if (category === 'one-time') {
82
- transitionName = await processOneTime({ library, resource, uid, processor, eventType, eventId, resourceId, orderId, now, nowUNIX, webhookReceivedUNIX, admin, assistant, Manager });
83
- } else {
78
+ // Process the payment event (subscription or one-time)
79
+ if (category !== 'subscription' && category !== 'one-time') {
84
80
  throw new Error(`Unknown event category: ${category}`);
85
81
  }
86
82
 
83
+ const transitionName = await processPaymentEvent({ category, library, resource, resourceType, uid, processor, eventType, eventId, resourceId, orderId, now, nowUNIX, webhookReceivedUNIX, assistant });
84
+
87
85
  // Mark webhook as completed (include transition name for auditing/testing)
88
86
  await webhookRef.set({
89
87
  status: 'completed',
@@ -111,13 +109,20 @@ module.exports = async ({ Manager, assistant, change, context, libraries }) => {
111
109
  };
112
110
 
113
111
  /**
114
- * Process a subscription event
115
- * 1. Read current user subscription (before state)
116
- * 2. Transform raw resource unified subscription (after state)
117
- * 3. Detect and dispatch transition handlers (non-blocking)
118
- * 4. Write to user doc + payments-orders
112
+ * Process a payment event (subscription or one-time)
113
+ * 1. Staleness check
114
+ * 2. Read user doc (for transition detection)
115
+ * 3. Transform raw resource unified object
116
+ * 4. Build order object
117
+ * 5. Detect and dispatch transition handlers (non-blocking)
118
+ * 6. Track analytics (non-blocking)
119
+ * 7. Write to Firestore (user doc for subscriptions + payments-orders)
119
120
  */
120
- async function processSubscription({ library, resource, uid, processor, eventType, eventId, resourceId, orderId, now, nowUNIX, webhookReceivedUNIX, admin, assistant, Manager }) {
121
+ async function processPaymentEvent({ category, library, resource, resourceType, uid, processor, eventType, eventId, resourceId, orderId, now, nowUNIX, webhookReceivedUNIX, assistant }) {
122
+ const Manager = assistant.Manager;
123
+ const admin = Manager.libraries.admin;
124
+ const isSubscription = category === 'subscription';
125
+
121
126
  // Staleness check: skip if a newer webhook already wrote to this order
122
127
  if (orderId) {
123
128
  const existingDoc = await admin.firestore().doc(`payments-orders/${orderId}`).get();
@@ -130,329 +135,122 @@ async function processSubscription({ library, resource, uid, processor, eventTyp
130
135
  }
131
136
  }
132
137
 
133
- // Read current user doc BEFORE writing (for transition detection)
138
+ // Read current user doc (needed for transition detection + handler context)
134
139
  const userDoc = await admin.firestore().doc(`users/${uid}`).get();
135
140
  const userData = userDoc.exists ? userDoc.data() : {};
136
- const before = userData.subscription || null;
137
-
138
- // Transform to unified subscription (the "after" state)
139
- const unified = library.toUnifiedSubscription(resource, {
140
- config: Manager.config,
141
- eventName: eventType,
142
- eventId: eventId,
143
- });
144
-
145
- assistant.log(`Unified subscription: status=${unified.status}, product=${unified.product.id}, frequency=${unified.payment.frequency}, trial.claimed=${unified.trial.claimed}, cancellation.pending=${unified.cancellation.pending}`, unified);
146
-
147
- // Detect and dispatch transition (non-blocking)
148
- const shouldRunHandlers = !assistant.isTesting() || process.env.TEST_EXTENDED_MODE;
149
- const transitionName = transitions.detectTransition('subscription', before, unified, eventType);
150
-
151
- if (transitionName) {
152
- assistant.log(`Transition detected: subscription/${transitionName} (before.status=${before?.status || 'null'}, after.status=${unified.status})`);
153
-
154
- if (shouldRunHandlers) {
155
- transitions.dispatch(transitionName, 'subscription', {
156
- before, after: unified, uid, userDoc: userData, admin, assistant, Manager, eventType, eventId,
157
- });
158
- } else {
159
- assistant.log(`Transition handler skipped (testing mode): subscription/${transitionName}`);
141
+ const before = isSubscription ? (userData.subscription || null) : null;
142
+
143
+ // Auto-fill user name from payment processor if not already set
144
+ if (!userData?.personal?.name?.first) {
145
+ const customerName = extractCustomerName(resource, resourceType);
146
+ if (customerName?.first) {
147
+ await admin.firestore().doc(`users/${uid}`).set({
148
+ personal: { name: customerName },
149
+ }, { merge: true });
150
+ assistant.log(`Auto-filled user name from ${resourceType}: ${customerName.first} ${customerName.last || ''}`);
160
151
  }
161
152
  }
162
153
 
163
- // Track payment analytics (non-blocking)
164
- if (transitionName && shouldRunHandlers) {
165
- trackPayment({ category: 'subscription', transitionName, unified, uid, processor, assistant, Manager });
166
- }
167
-
168
- // Write unified subscription to user doc
169
- await admin.firestore().doc(`users/${uid}`).set({
170
- subscription: unified,
171
- }, { merge: true });
172
-
173
- assistant.log(`Updated users/${uid}.subscription: status=${unified.status}, product=${unified.product.id}`);
174
-
175
- // Write to payments-orders/{orderId}
176
- if (orderId) {
177
- await admin.firestore().doc(`payments-orders/${orderId}`).set({
178
- id: orderId,
179
- type: 'subscription',
180
- owner: uid,
181
- processor: processor,
182
- resourceId: resourceId,
183
- subscription: unified,
184
- metadata: {
185
- created: {
186
- timestamp: now,
187
- timestampUNIX: nowUNIX,
188
- },
189
- updated: {
190
- timestamp: now,
191
- timestampUNIX: nowUNIX,
192
- },
193
- updatedBy: {
194
- event: {
195
- name: eventType,
196
- id: eventId,
197
- },
154
+ // Transform raw resource → unified object
155
+ const transformOptions = { config: Manager.config, eventName: eventType, eventId: eventId };
156
+ const unified = isSubscription
157
+ ? library.toUnifiedSubscription(resource, transformOptions)
158
+ : library.toUnifiedOneTime(resource, transformOptions);
159
+
160
+ assistant.log(`Unified ${category}: product=${unified.product.id}, status=${unified.status}`, unified);
161
+
162
+ // Build the order object (single source of truth for handlers + Firestore)
163
+ const order = {
164
+ id: orderId,
165
+ type: category,
166
+ owner: uid,
167
+ processor: processor,
168
+ resourceId: resourceId,
169
+ unified: unified,
170
+ metadata: {
171
+ created: {
172
+ timestamp: now,
173
+ timestampUNIX: nowUNIX,
174
+ },
175
+ updated: {
176
+ timestamp: now,
177
+ timestampUNIX: nowUNIX,
178
+ },
179
+ updatedBy: {
180
+ event: {
181
+ name: eventType,
182
+ id: eventId,
198
183
  },
199
184
  },
200
- }, { merge: true });
201
-
202
- assistant.log(`Updated payments-orders/${orderId}: type=subscription, uid=${uid}, eventType=${eventType}`);
203
- }
204
-
205
- return transitionName;
206
- }
207
-
208
- /**
209
- * Process a one-time payment event
210
- * 1. Transform raw resource → unified one-time
211
- * 2. Detect and dispatch transition handlers (non-blocking)
212
- * 3. Write to payments-orders
213
- */
214
- async function processOneTime({ library, resource, uid, processor, eventType, eventId, resourceId, orderId, now, nowUNIX, webhookReceivedUNIX, admin, assistant, Manager }) {
215
- // Staleness check: skip if a newer webhook already wrote to this order
216
- if (orderId) {
217
- const existingDoc = await admin.firestore().doc(`payments-orders/${orderId}`).get();
218
- if (existingDoc.exists) {
219
- const existingUpdatedUNIX = existingDoc.data()?.metadata?.updated?.timestampUNIX || 0;
220
- if (webhookReceivedUNIX < existingUpdatedUNIX) {
221
- assistant.log(`Stale webhook ${eventId}: received=${webhookReceivedUNIX}, existing updated=${existingUpdatedUNIX}, skipping`);
222
- return null;
223
- }
224
- }
225
- }
226
-
227
- const unified = library.toUnifiedOneTime(resource, {
228
- config: Manager.config,
229
- eventName: eventType,
230
- eventId: eventId,
231
- });
232
-
233
- assistant.log(`Unified one-time: id=${unified.id}, status=${unified.status}`, unified);
185
+ },
186
+ };
234
187
 
235
188
  // Detect and dispatch transition (non-blocking)
236
189
  const shouldRunHandlers = !assistant.isTesting() || process.env.TEST_EXTENDED_MODE;
237
- const transitionName = transitions.detectTransition('one-time', null, unified, eventType);
190
+ const transitionName = transitions.detectTransition(category, before, unified, eventType);
238
191
 
239
192
  if (transitionName) {
240
- assistant.log(`Transition detected: one-time/${transitionName}`);
193
+ assistant.log(`Transition detected: ${category}/${transitionName} (before.status=${before?.status || 'null'}, after.status=${unified.status})`);
241
194
 
242
195
  if (shouldRunHandlers) {
243
- // Read user doc for handler context
244
- const userDoc = await admin.firestore().doc(`users/${uid}`).get();
245
- const userData = userDoc.exists ? userDoc.data() : {};
246
-
247
- transitions.dispatch(transitionName, 'one-time', {
248
- before: null, after: unified, uid, userDoc: userData, admin, assistant, Manager, eventType, eventId,
196
+ transitions.dispatch(transitionName, category, {
197
+ before, after: unified, order, uid, assistant,
249
198
  });
250
199
  } else {
251
- assistant.log(`Transition handler skipped (testing mode): one-time/${transitionName}`);
200
+ assistant.log(`Transition handler skipped (testing mode): ${category}/${transitionName}`);
252
201
  }
253
202
  }
254
203
 
255
204
  // Track payment analytics (non-blocking)
256
205
  if (transitionName && shouldRunHandlers) {
257
- trackPayment({ category: 'one-time', transitionName, unified, uid, processor, assistant, Manager });
206
+ trackPayment({ category, transitionName, unified, uid, processor, assistant });
207
+ }
208
+
209
+ // Write unified subscription to user doc (subscriptions only)
210
+ if (isSubscription) {
211
+ await admin.firestore().doc(`users/${uid}`).set({ subscription: unified }, { merge: true });
212
+ assistant.log(`Updated users/${uid}.subscription: status=${unified.status}, product=${unified.product.id}`);
258
213
  }
259
214
 
260
215
  // Write to payments-orders/{orderId}
261
216
  if (orderId) {
262
- await admin.firestore().doc(`payments-orders/${orderId}`).set({
263
- id: orderId,
264
- type: 'one-time',
265
- owner: uid,
266
- processor: processor,
267
- resourceId: resourceId,
268
- payment: unified,
269
- metadata: {
270
- created: {
271
- timestamp: now,
272
- timestampUNIX: nowUNIX,
273
- },
274
- updated: {
275
- timestamp: now,
276
- timestampUNIX: nowUNIX,
277
- },
278
- updatedBy: {
279
- event: {
280
- name: eventType,
281
- id: eventId,
282
- },
283
- },
284
- },
285
- }, { merge: true });
286
-
287
- assistant.log(`Updated payments-orders/${orderId}: type=one-time, uid=${uid}, eventType=${eventType}`);
217
+ await admin.firestore().doc(`payments-orders/${orderId}`).set(order, { merge: true });
218
+ assistant.log(`Updated payments-orders/${orderId}: type=${category}, uid=${uid}, eventType=${eventType}`);
288
219
  }
289
220
 
290
221
  return transitionName;
291
222
  }
292
223
 
293
224
  /**
294
- * Track payment events across analytics platforms (non-blocking)
295
- * Fires server-side events for GA4, Meta Conversions API, and TikTok Events API
296
- *
297
- * Maps transitions to standard platform events:
298
- * new-subscription (no trial) → purchase / Purchase / CompletePayment
299
- * new-subscription (trial) → start_trial / StartTrial / Subscribe
300
- * payment-recovered → purchase / Subscribe / Subscribe (recurring)
301
- * purchase-completed → purchase / Purchase / CompletePayment
225
+ * Extract customer name from a raw payment processor resource
302
226
  *
303
- * @param {object} options
304
- * @param {string} options.category - 'subscription' or 'one-time'
305
- * @param {string} options.transitionName - Detected transition (e.g., 'new-subscription', 'purchase-completed')
306
- * @param {object} options.unified - Unified subscription or one-time object
307
- * @param {string} options.uid - User ID
308
- * @param {string} options.processor - Payment processor (e.g., 'stripe', 'paypal')
309
- * @param {object} options.assistant - Assistant instance
310
- * @param {object} options.Manager - Manager instance
227
+ * @param {object} resource - Raw processor resource (Stripe subscription, session, invoice)
228
+ * @param {string} resourceType - 'subscription' | 'session' | 'invoice'
229
+ * @returns {{ first: string, last: string }|null}
311
230
  */
312
- function trackPayment({ category, transitionName, unified, uid, processor, assistant, Manager }) {
313
- try {
314
- // Resolve the analytics event to fire based on transition
315
- const event = resolvePaymentEvent(category, transitionName, unified, Manager.config);
316
-
317
- if (!event) {
318
- return;
319
- }
320
-
321
- assistant.log(`trackPayment: event=${event.ga4}, value=${event.value}, currency=${event.currency}, product=${event.productId}, uid=${uid}`);
322
-
323
- // GA4 via Measurement Protocol
324
- Manager.Analytics({ assistant, uuid: uid }).event(event.ga4, {
325
- transaction_id: event.transactionId,
326
- value: event.value,
327
- currency: event.currency,
328
- items: [{
329
- item_id: event.productId,
330
- item_name: event.productName,
331
- price: event.value,
332
- quantity: 1,
333
- }],
334
- payment_processor: processor,
335
- payment_frequency: event.frequency,
336
- is_trial: event.isTrial,
337
- is_recurring: event.isRecurring,
338
- });
339
-
340
- // TODO: Meta Conversions API
341
- // Event name: event.meta (e.g., 'Purchase', 'StartTrial', 'Subscribe')
342
- // https://developers.facebook.com/docs/marketing-api/conversions-api
343
-
344
- // TODO: TikTok Events API
345
- // Event name: event.tiktok (e.g., 'CompletePayment', 'Subscribe')
346
- // https://business-api.tiktok.com/portal/docs?id=1771100865818625
347
- } catch (e) {
348
- assistant.error(`trackPayment failed: ${e.message}`, e);
349
- }
350
- }
231
+ function extractCustomerName(resource, resourceType) {
232
+ let fullName = null;
351
233
 
352
- /**
353
- * Resolve which analytics event to fire based on transition + unified data
354
- * Returns null if the transition doesn't warrant an analytics event
355
- */
356
- function resolvePaymentEvent(category, transitionName, unified, config) {
357
- if (category === 'subscription') {
358
- return resolveSubscriptionEvent(transitionName, unified, config);
234
+ // Checkout sessions have customer_details.name
235
+ if (resourceType === 'session') {
236
+ fullName = resource.customer_details?.name;
359
237
  }
360
238
 
361
- if (category === 'one-time') {
362
- return resolveOneTimeEvent(transitionName, unified, config);
239
+ // Invoices have customer_name
240
+ if (resourceType === 'invoice') {
241
+ fullName = resource.customer_name;
363
242
  }
364
243
 
365
- return null;
366
- }
244
+ // Subscriptions only have customer ID, no name
367
245
 
368
- /**
369
- * Map subscription transitions to analytics events
370
- */
371
- function resolveSubscriptionEvent(transitionName, unified, config) {
372
- const productId = unified.product?.id;
373
- const productName = unified.product?.name;
374
- const frequency = unified.payment?.frequency;
375
- const isTrial = unified.trial?.claimed === true;
376
- const resourceId = unified.payment?.resourceId;
377
-
378
- // Resolve price from config
379
- const product = config.payment?.products?.find(p => p.id === productId);
380
- const price = product?.prices?.[frequency]?.amount || 0;
381
-
382
- if (transitionName === 'new-subscription' && isTrial) {
383
- return {
384
- ga4: 'start_trial',
385
- meta: 'StartTrial',
386
- tiktok: 'Subscribe',
387
- value: 0,
388
- currency: config.payment?.currency || 'USD',
389
- productId,
390
- productName,
391
- frequency,
392
- isTrial: true,
393
- isRecurring: false,
394
- transactionId: resourceId,
395
- };
396
- }
397
-
398
- if (transitionName === 'new-subscription') {
399
- return {
400
- ga4: 'purchase',
401
- meta: 'Purchase',
402
- tiktok: 'CompletePayment',
403
- value: price,
404
- currency: config.payment?.currency || 'USD',
405
- productId,
406
- productName,
407
- frequency,
408
- isTrial: false,
409
- isRecurring: false,
410
- transactionId: resourceId,
411
- };
412
- }
413
-
414
- if (transitionName === 'payment-recovered') {
415
- return {
416
- ga4: 'purchase',
417
- meta: 'Subscribe',
418
- tiktok: 'Subscribe',
419
- value: price,
420
- currency: config.payment?.currency || 'USD',
421
- productId,
422
- productName,
423
- frequency,
424
- isTrial: false,
425
- isRecurring: true,
426
- transactionId: resourceId,
427
- };
428
- }
429
-
430
- return null;
431
- }
432
-
433
- /**
434
- * Map one-time transitions to analytics events
435
- */
436
- function resolveOneTimeEvent(transitionName, unified, config) {
437
- if (transitionName !== 'purchase-completed') {
246
+ if (!fullName) {
438
247
  return null;
439
248
  }
440
249
 
441
- const productId = unified.metadata?.productId || unified.raw?.metadata?.productId;
442
- const product = config.payment?.products?.find(p => p.id === productId);
443
- const price = product?.prices?.once?.amount || 0;
444
-
250
+ const { capitalize } = require('../../../libraries/infer-contact.js');
251
+ const parts = fullName.trim().split(/\s+/);
445
252
  return {
446
- ga4: 'purchase',
447
- meta: 'Purchase',
448
- tiktok: 'CompletePayment',
449
- value: price,
450
- currency: config.payment?.currency || 'USD',
451
- productId: productId || 'unknown',
452
- productName: product?.name || 'Unknown',
453
- frequency: null,
454
- isTrial: false,
455
- isRecurring: false,
456
- transactionId: unified.id,
253
+ first: capitalize(parts[0]) || null,
254
+ last: capitalize(parts.slice(1).join(' ')) || null,
457
255
  };
458
256
  }
@@ -1,16 +1,25 @@
1
1
  /**
2
2
  * Transition: purchase-completed
3
3
  * Triggered when a one-time payment checkout completes (checkout.session.completed with mode=payment)
4
- *
5
- * Use cases:
6
- * - Send purchase receipt/confirmation email
7
- * - Deliver digital goods or credits
8
- * - Fire analytics event for purchase
9
4
  */
10
- module.exports = async function ({ before, after, uid, userDoc, admin, assistant, Manager, eventType, eventId }) {
11
- assistant.log(`Transition [one-time/purchase-completed]: uid=${uid}, resourceId=${after.id}`);
5
+ const { sendOrderEmail, formatDate } = require('../send-email.js');
12
6
 
13
- // TODO: Send purchase confirmation email
14
- // TODO: Deliver digital goods
15
- // TODO: Fire analytics event
7
+ module.exports = async function ({ before, after, order, uid, assistant }) {
8
+ assistant.log(`Transition [one-time/purchase-completed]: uid=${uid}, resourceId=${after.payment.resourceId}`);
9
+
10
+ sendOrderEmail({
11
+ template: 'd-5371ac2b4e3b490bbce51bfc2922ece8',
12
+ subject: 'Your order is confirmed!',
13
+ categories: ['order/confirmation'],
14
+ uid,
15
+ assistant,
16
+ data: {
17
+ order: {
18
+ ...order,
19
+ _computed: {
20
+ date: formatDate(new Date().toISOString()),
21
+ },
22
+ },
23
+ },
24
+ });
16
25
  };
@@ -2,14 +2,10 @@
2
2
  * Transition: purchase-failed
3
3
  * Triggered when a one-time payment fails (invoice.payment_failed with billing_reason=manual)
4
4
  *
5
- * Use cases:
6
- * - Send payment failure notification
7
- * - Include retry link or alternative payment method
8
- * - Fire analytics event
5
+ * NOTE: No email template exists for this transition yet. Keeping as stub.
9
6
  */
10
- module.exports = async function ({ before, after, uid, userDoc, admin, assistant, Manager, eventType, eventId }) {
11
- assistant.log(`Transition [one-time/purchase-failed]: uid=${uid}, resourceId=${after.id}`);
7
+ module.exports = async function ({ before, after, order, uid, assistant }) {
8
+ assistant.log(`Transition [one-time/purchase-failed]: uid=${uid}, orderId=${order?.id}`);
12
9
 
13
- // TODO: Send payment failure email with retry link
14
- // TODO: Fire analytics event
10
+ // TODO: Send payment failure email once template is created
15
11
  };
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Shared email helper for payment transition handlers
3
+ * Sends transactional order emails directly via the shared email library (no HTTP round-trip)
4
+ */
5
+ const moment = require('moment');
6
+
7
+ /**
8
+ * Send an order email directly using the shared email library (fire-and-forget)
9
+ *
10
+ * @param {object} options
11
+ * @param {string} options.template - SendGrid dynamic template ID
12
+ * @param {string} options.subject - Email subject line
13
+ * @param {string[]} options.categories - SendGrid categories for filtering
14
+ * @param {object} options.data - Template data (passed as-is to the email)
15
+ * @param {string} options.uid - User UID (resolved from Firestore for email + name)
16
+ * @param {object} options.assistant - Assistant instance
17
+ */
18
+ function sendOrderEmail({ template, subject, categories, data, uid, assistant }) {
19
+ const email = assistant.Manager.Email(assistant);
20
+
21
+ const settings = {
22
+ to: `uid:${uid}`,
23
+ subject,
24
+ template,
25
+ categories,
26
+ copy: false,
27
+ data,
28
+ };
29
+
30
+ email.send(settings)
31
+ .then((result) => {
32
+ assistant.log(`sendOrderEmail(): Success template=${template}, uid=${uid}, status=${result.status}`);
33
+ })
34
+ .catch((e) => {
35
+ assistant.error(`sendOrderEmail(): Failed template=${template}, uid=${uid}: ${e.message}`);
36
+ });
37
+ }
38
+
39
+ /**
40
+ * Format an ISO timestamp or Unix timestamp to display format
41
+ *
42
+ * @param {string|number} timestamp - ISO string or Unix timestamp (seconds)
43
+ * @returns {string} Formatted date (e.g., 'Feb 25, 2026')
44
+ */
45
+ function formatDate(timestamp) {
46
+ if (!timestamp) {
47
+ return '';
48
+ }
49
+
50
+ // Unix timestamp (number or numeric string)
51
+ if (typeof timestamp === 'number' || /^\d+$/.test(timestamp)) {
52
+ return moment.unix(Number(timestamp)).utc().format('MMM D, YYYY');
53
+ }
54
+
55
+ return moment(timestamp).utc().format('MMM D, YYYY');
56
+ }
57
+
58
+ module.exports = {
59
+ sendOrderEmail,
60
+ formatDate,
61
+ };
@@ -1,15 +1,28 @@
1
1
  /**
2
2
  * Transition: cancellation-requested
3
3
  * Triggered when a user requests cancellation at period end (cancellation.pending flips to true)
4
- *
5
- * Use cases:
6
- * - Send cancellation confirmation email with period end date
7
- * - Include win-back offer or feedback survey link
8
- * - Fire analytics event for churn intent
9
4
  */
10
- module.exports = async function ({ before, after, uid, userDoc, admin, assistant, Manager, eventType, eventId }) {
11
- assistant.log(`Transition [subscription/cancellation-requested]: uid=${uid}, product=${after.product.id}, cancelDate=${after.cancellation.date.timestamp}`);
5
+ const { sendOrderEmail, formatDate } = require('../send-email.js');
12
6
 
13
- // TODO: Send cancellation confirmation email with end date
14
- // TODO: Fire analytics event
7
+ module.exports = async function ({ before, after, order, uid, assistant }) {
8
+ assistant.log(`Transition [subscription/cancellation-requested]: uid=${uid}, product=${after.product.id}, cancelDate=${after.cancellation.date?.timestamp}`);
9
+
10
+ sendOrderEmail({
11
+ template: 'd-78074f3e8c844146bf263b86fc8d5ecf',
12
+ subject: 'Your subscription cancellation is confirmed',
13
+ categories: ['order/cancellation-requested'],
14
+ uid,
15
+ assistant,
16
+ data: {
17
+ order: {
18
+ ...order,
19
+ _computed: {
20
+ date: formatDate(new Date().toISOString()),
21
+ ...(after.cancellation?.date?.timestamp && {
22
+ cancellationDate: formatDate(after.cancellation.date.timestamp),
23
+ }),
24
+ },
25
+ },
26
+ },
27
+ });
15
28
  };
@@ -2,17 +2,30 @@
2
2
  * Transition: new-subscription
3
3
  * Triggered when a user subscribes for the first time (basic/null → active paid)
4
4
  * Check after.trial.claimed to determine if this is a trial subscription
5
- *
6
- * Use cases:
7
- * - Send order confirmation email with plan details (include trial info if applicable)
8
- * - Fire analytics event for new subscriber
9
- * - Update marketing lists
10
5
  */
11
- module.exports = async function ({ before, after, uid, userDoc, admin, assistant, Manager, eventType, eventId }) {
6
+ const { sendOrderEmail, formatDate } = require('../send-email.js');
7
+
8
+ module.exports = async function ({ before, after, order, uid, assistant }) {
12
9
  const isTrial = after.trial?.claimed === true;
13
10
 
14
11
  assistant.log(`Transition [subscription/new-subscription]: uid=${uid}, product=${after.product.id}, frequency=${after.payment.frequency}, trial=${isTrial}`);
15
12
 
16
- // TODO: Send order confirmation email (modify content if isTrial)
17
- // TODO: Fire analytics event
13
+ sendOrderEmail({
14
+ template: 'd-5371ac2b4e3b490bbce51bfc2922ece8',
15
+ subject: isTrial ? 'Your free trial has started!' : 'Your subscription is confirmed!',
16
+ categories: ['order/confirmation'],
17
+ uid,
18
+ assistant,
19
+ data: {
20
+ order: {
21
+ ...order,
22
+ _computed: {
23
+ date: formatDate(new Date().toISOString()),
24
+ ...(isTrial && after.trial?.expires?.timestamp && {
25
+ trialExpires: formatDate(after.trial.expires.timestamp),
26
+ }),
27
+ },
28
+ },
29
+ },
30
+ });
18
31
  };