backend-manager 5.0.89 → 5.0.91

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 (29) hide show
  1. package/CLAUDE.md +133 -2
  2. package/package.json +5 -3
  3. package/src/cli/index.js +11 -0
  4. package/src/manager/events/firestore/payments-webhooks/analytics.js +170 -0
  5. package/src/manager/events/firestore/payments-webhooks/on-write.js +64 -314
  6. package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-completed.js +20 -10
  7. package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-failed.js +4 -8
  8. package/src/manager/events/firestore/payments-webhooks/transitions/send-email.js +67 -0
  9. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/cancellation-requested.js +23 -9
  10. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/new-subscription.js +22 -8
  11. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-failed.js +19 -8
  12. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-recovered.js +19 -7
  13. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/plan-changed.js +27 -8
  14. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/subscription-cancelled.js +25 -9
  15. package/src/manager/helpers/user.js +1 -0
  16. package/src/manager/libraries/payment-processors/resolve-price-id.js +19 -0
  17. package/src/manager/libraries/payment-processors/stripe.js +87 -48
  18. package/src/manager/libraries/payment-processors/test.js +4 -4
  19. package/src/manager/routes/payments/intent/post.js +44 -0
  20. package/src/manager/routes/payments/intent/processors/stripe.js +10 -45
  21. package/src/manager/routes/payments/intent/processors/test.js +16 -20
  22. package/src/test/runner.js +11 -0
  23. package/src/test/test-accounts.js +18 -0
  24. package/templates/backend-manager-config.json +31 -12
  25. package/test/events/payments/journey-payments-one-time-failure.js +105 -0
  26. package/test/events/payments/journey-payments-one-time.js +128 -0
  27. package/test/events/payments/journey-payments-plan-change.js +126 -0
  28. package/test/events/payments/journey-payments-upgrade.js +2 -2
  29. /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
@@ -73,17 +74,13 @@ module.exports = async ({ Manager, assistant, change, context, libraries }) => {
73
74
  // Extract orderId from resource metadata (set at intent creation)
74
75
  const orderId = resource.metadata?.orderId || null;
75
76
 
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 {
77
+ // Process the payment event (subscription or one-time)
78
+ if (category !== 'subscription' && category !== 'one-time') {
84
79
  throw new Error(`Unknown event category: ${category}`);
85
80
  }
86
81
 
82
+ const transitionName = await processPaymentEvent({ category, library, resource, uid, processor, eventType, eventId, resourceId, orderId, now, nowUNIX, webhookReceivedUNIX, admin, assistant, Manager });
83
+
87
84
  // Mark webhook as completed (include transition name for auditing/testing)
88
85
  await webhookRef.set({
89
86
  status: 'completed',
@@ -111,13 +108,18 @@ module.exports = async ({ Manager, assistant, change, context, libraries }) => {
111
108
  };
112
109
 
113
110
  /**
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
111
+ * Process a payment event (subscription or one-time)
112
+ * 1. Staleness check
113
+ * 2. Read user doc (for transition detection)
114
+ * 3. Transform raw resource unified object
115
+ * 4. Build order object
116
+ * 5. Detect and dispatch transition handlers (non-blocking)
117
+ * 6. Track analytics (non-blocking)
118
+ * 7. Write to Firestore (user doc for subscriptions + payments-orders)
119
119
  */
120
- async function processSubscription({ library, resource, uid, processor, eventType, eventId, resourceId, orderId, now, nowUNIX, webhookReceivedUNIX, admin, assistant, Manager }) {
120
+ async function processPaymentEvent({ category, library, resource, uid, processor, eventType, eventId, resourceId, orderId, now, nowUNIX, webhookReceivedUNIX, admin, assistant, Manager }) {
121
+ const isSubscription = category === 'subscription';
122
+
121
123
  // Staleness check: skip if a newer webhook already wrote to this order
122
124
  if (orderId) {
123
125
  const existingDoc = await admin.firestore().doc(`payments-orders/${orderId}`).get();
@@ -130,329 +132,77 @@ async function processSubscription({ library, resource, uid, processor, eventTyp
130
132
  }
131
133
  }
132
134
 
133
- // Read current user doc BEFORE writing (for transition detection)
135
+ // Read current user doc (needed for transition detection + handler context)
134
136
  const userDoc = await admin.firestore().doc(`users/${uid}`).get();
135
137
  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}`);
160
- }
161
- }
162
-
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
- },
138
+ const before = isSubscription ? (userData.subscription || null) : null;
139
+
140
+ // Transform raw resource unified object
141
+ const transformOptions = { config: Manager.config, eventName: eventType, eventId: eventId };
142
+ const unified = isSubscription
143
+ ? library.toUnifiedSubscription(resource, transformOptions)
144
+ : library.toUnifiedOneTime(resource, transformOptions);
145
+
146
+ assistant.log(`Unified ${category}: product=${unified.product.id}, status=${unified.status}`, unified);
147
+
148
+ // Build the order object (single source of truth for handlers + Firestore)
149
+ const order = {
150
+ id: orderId,
151
+ type: category,
152
+ owner: uid,
153
+ processor: processor,
154
+ resourceId: resourceId,
155
+ unified: unified,
156
+ metadata: {
157
+ created: {
158
+ timestamp: now,
159
+ timestampUNIX: nowUNIX,
160
+ },
161
+ updated: {
162
+ timestamp: now,
163
+ timestampUNIX: nowUNIX,
164
+ },
165
+ updatedBy: {
166
+ event: {
167
+ name: eventType,
168
+ id: eventId,
198
169
  },
199
170
  },
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);
171
+ },
172
+ };
234
173
 
235
174
  // Detect and dispatch transition (non-blocking)
236
175
  const shouldRunHandlers = !assistant.isTesting() || process.env.TEST_EXTENDED_MODE;
237
- const transitionName = transitions.detectTransition('one-time', null, unified, eventType);
176
+ const transitionName = transitions.detectTransition(category, before, unified, eventType);
238
177
 
239
178
  if (transitionName) {
240
- assistant.log(`Transition detected: one-time/${transitionName}`);
179
+ assistant.log(`Transition detected: ${category}/${transitionName} (before.status=${before?.status || 'null'}, after.status=${unified.status})`);
241
180
 
242
181
  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,
182
+ transitions.dispatch(transitionName, category, {
183
+ before, after: unified, order, uid, userDoc: userData, admin, assistant, Manager, eventType, eventId,
249
184
  });
250
185
  } else {
251
- assistant.log(`Transition handler skipped (testing mode): one-time/${transitionName}`);
186
+ assistant.log(`Transition handler skipped (testing mode): ${category}/${transitionName}`);
252
187
  }
253
188
  }
254
189
 
255
190
  // Track payment analytics (non-blocking)
256
191
  if (transitionName && shouldRunHandlers) {
257
- trackPayment({ category: 'one-time', transitionName, unified, uid, processor, assistant, Manager });
192
+ trackPayment({ category, transitionName, unified, uid, processor, assistant, Manager });
193
+ }
194
+
195
+ // Write unified subscription to user doc (subscriptions only)
196
+ if (isSubscription) {
197
+ await admin.firestore().doc(`users/${uid}`).set({ subscription: unified }, { merge: true });
198
+ assistant.log(`Updated users/${uid}.subscription: status=${unified.status}, product=${unified.product.id}`);
258
199
  }
259
200
 
260
201
  // Write to payments-orders/{orderId}
261
202
  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}`);
203
+ await admin.firestore().doc(`payments-orders/${orderId}`).set(order, { merge: true });
204
+ assistant.log(`Updated payments-orders/${orderId}: type=${category}, uid=${uid}, eventType=${eventType}`);
288
205
  }
289
206
 
290
207
  return transitionName;
291
208
  }
292
-
293
- /**
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
302
- *
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
311
- */
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
- }
351
-
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);
359
- }
360
-
361
- if (category === 'one-time') {
362
- return resolveOneTimeEvent(transitionName, unified, config);
363
- }
364
-
365
- return null;
366
- }
367
-
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') {
438
- return null;
439
- }
440
-
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
-
445
- 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,
457
- };
458
- }
@@ -1,16 +1,26 @@
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, Manager }) {
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
+ Manager,
17
+ data: {
18
+ order: {
19
+ ...order,
20
+ _computed: {
21
+ date: formatDate(new Date().toISOString()),
22
+ },
23
+ },
24
+ },
25
+ });
16
26
  };
@@ -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, Manager }) {
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,67 @@
1
+ /**
2
+ * Shared email helper for payment transition handlers
3
+ * Sends transactional order emails via POST /admin/email
4
+ */
5
+ const moment = require('moment');
6
+
7
+ /**
8
+ * Send an order email via the admin/email endpoint (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 API)
15
+ * @param {string} options.uid - User UID (endpoint resolves email from Firestore)
16
+ * @param {object} options.assistant - Assistant instance
17
+ * @param {object} options.Manager - Manager instance
18
+ */
19
+ function sendOrderEmail({ template, subject, categories, data, uid, assistant, Manager }) {
20
+ const fetch = require('wonderful-fetch');
21
+
22
+ fetch(`${Manager.project.apiUrl}/backend-manager/admin/email`, {
23
+ method: 'POST',
24
+ response: 'json',
25
+ timeout: 30000,
26
+ body: {
27
+ backendManagerKey: process.env.BACKEND_MANAGER_KEY,
28
+ user: { auth: { uid } },
29
+ subject: subject,
30
+ template: template,
31
+ categories: categories,
32
+ copy: false,
33
+ ensureUnique: true,
34
+ data: data,
35
+ },
36
+ })
37
+ .then((json) => {
38
+ assistant.log(`sendOrderEmail(): Success template=${template}, uid=${uid}, status=${json?.data?.status || 'unknown'}`);
39
+ })
40
+ .catch((e) => {
41
+ assistant.error(`sendOrderEmail(): Failed template=${template}, uid=${uid}: ${e.message}`);
42
+ });
43
+ }
44
+
45
+ /**
46
+ * Format an ISO timestamp or Unix timestamp to display format
47
+ *
48
+ * @param {string|number} timestamp - ISO string or Unix timestamp (seconds)
49
+ * @returns {string} Formatted date (e.g., 'Feb 25, 2026')
50
+ */
51
+ function formatDate(timestamp) {
52
+ if (!timestamp) {
53
+ return '';
54
+ }
55
+
56
+ // Unix timestamp (number or numeric string)
57
+ if (typeof timestamp === 'number' || /^\d+$/.test(timestamp)) {
58
+ return moment.unix(Number(timestamp)).utc().format('MMM D, YYYY');
59
+ }
60
+
61
+ return moment(timestamp).utc().format('MMM D, YYYY');
62
+ }
63
+
64
+ module.exports = {
65
+ sendOrderEmail,
66
+ formatDate,
67
+ };
@@ -1,15 +1,29 @@
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, Manager }) {
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
+ Manager,
17
+ data: {
18
+ order: {
19
+ ...order,
20
+ _computed: {
21
+ date: formatDate(new Date().toISOString()),
22
+ ...(after.cancellation?.date?.timestamp && {
23
+ cancellationDate: formatDate(after.cancellation.date.timestamp),
24
+ }),
25
+ },
26
+ },
27
+ },
28
+ });
15
29
  };
@@ -2,17 +2,31 @@
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, Manager }) {
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
+ Manager,
20
+ data: {
21
+ order: {
22
+ ...order,
23
+ _computed: {
24
+ date: formatDate(new Date().toISOString()),
25
+ ...(isTrial && after.trial?.expires?.timestamp && {
26
+ trialExpires: formatDate(after.trial.expires.timestamp),
27
+ }),
28
+ },
29
+ },
30
+ },
31
+ });
18
32
  };
@@ -1,15 +1,26 @@
1
1
  /**
2
2
  * Transition: payment-failed
3
3
  * Triggered when a subscription payment fails (active → suspended)
4
- *
5
- * Use cases:
6
- * - Send payment failure notification email
7
- * - Include link to update payment method
8
- * - Fire analytics event for churn risk
9
4
  */
10
- module.exports = async function ({ before, after, uid, userDoc, admin, assistant, Manager, eventType, eventId }) {
5
+ const { sendOrderEmail, formatDate } = require('../send-email.js');
6
+
7
+ module.exports = async function ({ before, after, order, uid, assistant, Manager }) {
11
8
  assistant.log(`Transition [subscription/payment-failed]: uid=${uid}, product=${after.product.id}, previousStatus=${before?.status}`);
12
9
 
13
- // TODO: Send payment failure email with update-payment link
14
- // TODO: Fire analytics event
10
+ sendOrderEmail({
11
+ template: 'd-e56af0ac62364bfb9e50af02854e2cd3',
12
+ subject: 'Your payment failed',
13
+ categories: ['order/payment-failed'],
14
+ uid,
15
+ assistant,
16
+ Manager,
17
+ data: {
18
+ order: {
19
+ ...order,
20
+ _computed: {
21
+ date: formatDate(new Date().toISOString()),
22
+ },
23
+ },
24
+ },
25
+ });
15
26
  };