backend-manager 5.0.88 → 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 (39) hide show
  1. package/CLAUDE.md +133 -2
  2. package/README.md +1 -1
  3. package/package.json +5 -3
  4. package/src/cli/index.js +11 -0
  5. package/src/manager/events/firestore/payments-webhooks/analytics.js +170 -0
  6. package/src/manager/events/firestore/payments-webhooks/on-write.js +75 -315
  7. package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-completed.js +20 -10
  8. package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-failed.js +4 -8
  9. package/src/manager/events/firestore/payments-webhooks/transitions/send-email.js +67 -0
  10. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/cancellation-requested.js +23 -9
  11. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/new-subscription.js +22 -8
  12. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-failed.js +19 -8
  13. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-recovered.js +19 -7
  14. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/plan-changed.js +27 -8
  15. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/subscription-cancelled.js +25 -9
  16. package/src/manager/helpers/user.js +2 -0
  17. package/src/manager/libraries/payment-processors/order-id.js +18 -0
  18. package/src/manager/libraries/payment-processors/resolve-price-id.js +19 -0
  19. package/src/manager/libraries/payment-processors/stripe.js +88 -47
  20. package/src/manager/libraries/payment-processors/test.js +14 -11
  21. package/src/manager/routes/payments/intent/post.js +61 -7
  22. package/src/manager/routes/payments/intent/processors/stripe.js +18 -50
  23. package/src/manager/routes/payments/intent/processors/test.js +18 -22
  24. package/src/manager/routes/payments/webhook/post.js +1 -1
  25. package/src/test/runner.js +11 -0
  26. package/src/test/test-accounts.js +20 -2
  27. package/templates/backend-manager-config.json +31 -12
  28. package/test/events/payments/journey-payments-cancel.js +2 -0
  29. package/test/events/payments/journey-payments-failure.js +2 -0
  30. package/test/events/payments/journey-payments-one-time-failure.js +105 -0
  31. package/test/events/payments/journey-payments-one-time.js +128 -0
  32. package/test/events/payments/journey-payments-plan-change.js +126 -0
  33. package/test/events/payments/journey-payments-suspend.js +2 -0
  34. package/test/events/payments/journey-payments-trial.js +4 -0
  35. package/test/events/payments/journey-payments-upgrade.js +20 -10
  36. package/test/helpers/stripe-to-unified.js +17 -0
  37. package/test/helpers/user.js +1 -0
  38. package/test/routes/payments/intent.js +10 -7
  39. /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
@@ -8,8 +9,8 @@ const transitions = require('./transitions/index.js');
8
9
  * 1. Loads the processor library
9
10
  * 2. Fetches the latest resource from the processor API (not the stale webhook payload)
10
11
  * 3. Branches on event.category to transform + write:
11
- * - subscription → toUnifiedSubscription → users/{uid}.subscription + payments-subscriptions/{resourceId}
12
- * - one-time → toUnifiedOneTime → payments-one-time/{resourceId}
12
+ * - subscription → toUnifiedSubscription → users/{uid}.subscription + payments-orders/{orderId}
13
+ * - one-time → toUnifiedOneTime → payments-orders/{orderId}
13
14
  * 4. Detects state transitions and dispatches handler files (non-blocking)
14
15
  * 5. Marks the webhook as completed
15
16
  */
@@ -31,7 +32,7 @@ module.exports = async ({ Manager, assistant, change, context, libraries }) => {
31
32
 
32
33
  try {
33
34
  const processor = dataAfter.processor;
34
- const uid = dataAfter.uid;
35
+ const uid = dataAfter.owner;
35
36
  const raw = dataAfter.raw;
36
37
  const eventType = dataAfter.event?.type;
37
38
  const category = dataAfter.event?.category;
@@ -70,21 +71,21 @@ module.exports = async ({ Manager, assistant, change, context, libraries }) => {
70
71
  const nowUNIX = powertools.timestamp(now, { output: 'unix' });
71
72
  const webhookReceivedUNIX = dataAfter.metadata?.received?.timestampUNIX || nowUNIX;
72
73
 
73
- // Branch on category
74
- let transitionName = null;
74
+ // Extract orderId from resource metadata (set at intent creation)
75
+ const orderId = resource.metadata?.orderId || null;
75
76
 
76
- if (category === 'subscription') {
77
- transitionName = await processSubscription({ library, resource, uid, processor, eventType, eventId, resourceId, now, nowUNIX, webhookReceivedUNIX, admin, assistant, Manager });
78
- } else if (category === 'one-time') {
79
- transitionName = await processOneTime({ library, resource, uid, processor, eventType, eventId, resourceId, now, nowUNIX, webhookReceivedUNIX, admin, assistant, Manager });
80
- } else {
77
+ // Process the payment event (subscription or one-time)
78
+ if (category !== 'subscription' && category !== 'one-time') {
81
79
  throw new Error(`Unknown event category: ${category}`);
82
80
  }
83
81
 
82
+ const transitionName = await processPaymentEvent({ category, library, resource, uid, processor, eventType, eventId, resourceId, orderId, now, nowUNIX, webhookReceivedUNIX, admin, assistant, Manager });
83
+
84
84
  // Mark webhook as completed (include transition name for auditing/testing)
85
85
  await webhookRef.set({
86
86
  status: 'completed',
87
- uid: uid,
87
+ owner: uid,
88
+ orderId: orderId,
88
89
  transition: transitionName,
89
90
  metadata: {
90
91
  processed: {
@@ -107,16 +108,21 @@ module.exports = async ({ Manager, assistant, change, context, libraries }) => {
107
108
  };
108
109
 
109
110
  /**
110
- * Process a subscription event
111
- * 1. Read current user subscription (before state)
112
- * 2. Transform raw resource unified subscription (after state)
113
- * 3. Detect and dispatch transition handlers (non-blocking)
114
- * 4. Write to user doc + payments-subscriptions
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)
115
119
  */
116
- async function processSubscription({ library, resource, uid, processor, eventType, eventId, resourceId, now, nowUNIX, webhookReceivedUNIX, admin, assistant, Manager }) {
117
- // Staleness check: skip if a newer webhook already wrote to this resource
118
- if (resourceId) {
119
- const existingDoc = await admin.firestore().doc(`payments-subscriptions/${resourceId}`).get();
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
+
123
+ // Staleness check: skip if a newer webhook already wrote to this order
124
+ if (orderId) {
125
+ const existingDoc = await admin.firestore().doc(`payments-orders/${orderId}`).get();
120
126
  if (existingDoc.exists) {
121
127
  const existingUpdatedUNIX = existingDoc.data()?.metadata?.updated?.timestampUNIX || 0;
122
128
  if (webhookReceivedUNIX < existingUpdatedUNIX) {
@@ -126,323 +132,77 @@ async function processSubscription({ library, resource, uid, processor, eventTyp
126
132
  }
127
133
  }
128
134
 
129
- // Read current user doc BEFORE writing (for transition detection)
135
+ // Read current user doc (needed for transition detection + handler context)
130
136
  const userDoc = await admin.firestore().doc(`users/${uid}`).get();
131
137
  const userData = userDoc.exists ? userDoc.data() : {};
132
- const before = userData.subscription || null;
133
-
134
- // Transform to unified subscription (the "after" state)
135
- const unified = library.toUnifiedSubscription(resource, {
136
- config: Manager.config,
137
- eventName: eventType,
138
- eventId: eventId,
139
- });
140
-
141
- 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);
142
-
143
- // Detect and dispatch transition (non-blocking)
144
- const shouldRunHandlers = !assistant.isTesting() || process.env.TEST_EXTENDED_MODE;
145
- const transitionName = transitions.detectTransition('subscription', before, unified, eventType);
146
-
147
- if (transitionName) {
148
- assistant.log(`Transition detected: subscription/${transitionName} (before.status=${before?.status || 'null'}, after.status=${unified.status})`);
149
-
150
- if (shouldRunHandlers) {
151
- transitions.dispatch(transitionName, 'subscription', {
152
- before, after: unified, uid, userDoc: userData, admin, assistant, Manager, eventType, eventId,
153
- });
154
- } else {
155
- assistant.log(`Transition handler skipped (testing mode): subscription/${transitionName}`);
156
- }
157
- }
158
-
159
- // Track payment analytics (non-blocking)
160
- if (transitionName && shouldRunHandlers) {
161
- trackPayment({ category: 'subscription', transitionName, unified, uid, processor, assistant, Manager });
162
- }
163
-
164
- // Write unified subscription to user doc
165
- await admin.firestore().doc(`users/${uid}`).set({
166
- subscription: unified,
167
- }, { merge: true });
168
-
169
- assistant.log(`Updated users/${uid}.subscription: status=${unified.status}, product=${unified.product.id}`);
170
-
171
- // Write to payments-subscriptions/{resourceId}
172
- if (resourceId) {
173
- await admin.firestore().doc(`payments-subscriptions/${resourceId}`).set({
174
- uid: uid,
175
- processor: processor,
176
- subscription: unified,
177
- metadata: {
178
- created: {
179
- timestamp: now,
180
- timestampUNIX: nowUNIX,
181
- },
182
- updated: {
183
- timestamp: now,
184
- timestampUNIX: nowUNIX,
185
- },
186
- updatedBy: {
187
- event: {
188
- name: eventType,
189
- id: eventId,
190
- },
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,
191
169
  },
192
170
  },
193
- }, { merge: true });
194
-
195
- assistant.log(`Updated payments-subscriptions/${resourceId}: uid=${uid}, eventType=${eventType}`);
196
- }
197
-
198
- return transitionName;
199
- }
200
-
201
- /**
202
- * Process a one-time payment event
203
- * 1. Transform raw resource → unified one-time
204
- * 2. Detect and dispatch transition handlers (non-blocking)
205
- * 3. Write to payments-one-time
206
- */
207
- async function processOneTime({ library, resource, uid, processor, eventType, eventId, resourceId, now, nowUNIX, webhookReceivedUNIX, admin, assistant, Manager }) {
208
- // Staleness check: skip if a newer webhook already wrote to this resource
209
- if (resourceId) {
210
- const existingDoc = await admin.firestore().doc(`payments-one-time/${resourceId}`).get();
211
- if (existingDoc.exists) {
212
- const existingUpdatedUNIX = existingDoc.data()?.metadata?.updated?.timestampUNIX || 0;
213
- if (webhookReceivedUNIX < existingUpdatedUNIX) {
214
- assistant.log(`Stale webhook ${eventId}: received=${webhookReceivedUNIX}, existing updated=${existingUpdatedUNIX}, skipping`);
215
- return null;
216
- }
217
- }
218
- }
219
-
220
- const unified = library.toUnifiedOneTime(resource, {
221
- config: Manager.config,
222
- eventName: eventType,
223
- eventId: eventId,
224
- });
225
-
226
- assistant.log(`Unified one-time: id=${unified.id}, status=${unified.status}`, unified);
171
+ },
172
+ };
227
173
 
228
174
  // Detect and dispatch transition (non-blocking)
229
175
  const shouldRunHandlers = !assistant.isTesting() || process.env.TEST_EXTENDED_MODE;
230
- const transitionName = transitions.detectTransition('one-time', null, unified, eventType);
176
+ const transitionName = transitions.detectTransition(category, before, unified, eventType);
231
177
 
232
178
  if (transitionName) {
233
- assistant.log(`Transition detected: one-time/${transitionName}`);
179
+ assistant.log(`Transition detected: ${category}/${transitionName} (before.status=${before?.status || 'null'}, after.status=${unified.status})`);
234
180
 
235
181
  if (shouldRunHandlers) {
236
- // Read user doc for handler context
237
- const userDoc = await admin.firestore().doc(`users/${uid}`).get();
238
- const userData = userDoc.exists ? userDoc.data() : {};
239
-
240
- transitions.dispatch(transitionName, 'one-time', {
241
- 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,
242
184
  });
243
185
  } else {
244
- assistant.log(`Transition handler skipped (testing mode): one-time/${transitionName}`);
186
+ assistant.log(`Transition handler skipped (testing mode): ${category}/${transitionName}`);
245
187
  }
246
188
  }
247
189
 
248
190
  // Track payment analytics (non-blocking)
249
191
  if (transitionName && shouldRunHandlers) {
250
- trackPayment({ category: 'one-time', transitionName, unified, uid, processor, assistant, Manager });
251
- }
252
-
253
- // Write to payments-one-time/{resourceId}
254
- if (resourceId) {
255
- await admin.firestore().doc(`payments-one-time/${resourceId}`).set({
256
- uid: uid,
257
- processor: processor,
258
- payment: unified,
259
- metadata: {
260
- created: {
261
- timestamp: now,
262
- timestampUNIX: nowUNIX,
263
- },
264
- updated: {
265
- timestamp: now,
266
- timestampUNIX: nowUNIX,
267
- },
268
- updatedBy: {
269
- event: {
270
- name: eventType,
271
- id: eventId,
272
- },
273
- },
274
- },
275
- }, { merge: true });
276
-
277
- assistant.log(`Updated payments-one-time/${resourceId}: uid=${uid}, eventType=${eventType}`);
278
- }
279
-
280
- return transitionName;
281
- }
282
-
283
- /**
284
- * Track payment events across analytics platforms (non-blocking)
285
- * Fires server-side events for GA4, Meta Conversions API, and TikTok Events API
286
- *
287
- * Maps transitions to standard platform events:
288
- * new-subscription (no trial) → purchase / Purchase / CompletePayment
289
- * new-subscription (trial) → start_trial / StartTrial / Subscribe
290
- * payment-recovered → purchase / Subscribe / Subscribe (recurring)
291
- * purchase-completed → purchase / Purchase / CompletePayment
292
- *
293
- * @param {object} options
294
- * @param {string} options.category - 'subscription' or 'one-time'
295
- * @param {string} options.transitionName - Detected transition (e.g., 'new-subscription', 'purchase-completed')
296
- * @param {object} options.unified - Unified subscription or one-time object
297
- * @param {string} options.uid - User ID
298
- * @param {string} options.processor - Payment processor (e.g., 'stripe', 'paypal')
299
- * @param {object} options.assistant - Assistant instance
300
- * @param {object} options.Manager - Manager instance
301
- */
302
- function trackPayment({ category, transitionName, unified, uid, processor, assistant, Manager }) {
303
- try {
304
- // Resolve the analytics event to fire based on transition
305
- const event = resolvePaymentEvent(category, transitionName, unified, Manager.config);
306
-
307
- if (!event) {
308
- return;
309
- }
310
-
311
- assistant.log(`trackPayment: event=${event.ga4}, value=${event.value}, currency=${event.currency}, product=${event.productId}, uid=${uid}`);
312
-
313
- // GA4 via Measurement Protocol
314
- Manager.Analytics({ assistant, uuid: uid }).event(event.ga4, {
315
- transaction_id: event.transactionId,
316
- value: event.value,
317
- currency: event.currency,
318
- items: [{
319
- item_id: event.productId,
320
- item_name: event.productName,
321
- price: event.value,
322
- quantity: 1,
323
- }],
324
- payment_processor: processor,
325
- payment_frequency: event.frequency,
326
- is_trial: event.isTrial,
327
- is_recurring: event.isRecurring,
328
- });
329
-
330
- // TODO: Meta Conversions API
331
- // Event name: event.meta (e.g., 'Purchase', 'StartTrial', 'Subscribe')
332
- // https://developers.facebook.com/docs/marketing-api/conversions-api
333
-
334
- // TODO: TikTok Events API
335
- // Event name: event.tiktok (e.g., 'CompletePayment', 'Subscribe')
336
- // https://business-api.tiktok.com/portal/docs?id=1771100865818625
337
- } catch (e) {
338
- assistant.error(`trackPayment failed: ${e.message}`, e);
192
+ trackPayment({ category, transitionName, unified, uid, processor, assistant, Manager });
339
193
  }
340
- }
341
194
 
342
- /**
343
- * Resolve which analytics event to fire based on transition + unified data
344
- * Returns null if the transition doesn't warrant an analytics event
345
- */
346
- function resolvePaymentEvent(category, transitionName, unified, config) {
347
- if (category === 'subscription') {
348
- return resolveSubscriptionEvent(transitionName, unified, config);
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}`);
349
199
  }
350
200
 
351
- if (category === 'one-time') {
352
- return resolveOneTimeEvent(transitionName, unified, config);
353
- }
354
-
355
- return null;
356
- }
357
-
358
- /**
359
- * Map subscription transitions to analytics events
360
- */
361
- function resolveSubscriptionEvent(transitionName, unified, config) {
362
- const productId = unified.product?.id;
363
- const productName = unified.product?.name;
364
- const frequency = unified.payment?.frequency;
365
- const isTrial = unified.trial?.claimed === true;
366
- const resourceId = unified.payment?.resourceId;
367
-
368
- // Resolve price from config
369
- const product = config.payment?.products?.find(p => p.id === productId);
370
- const price = product?.prices?.[frequency]?.amount || 0;
371
-
372
- if (transitionName === 'new-subscription' && isTrial) {
373
- return {
374
- ga4: 'start_trial',
375
- meta: 'StartTrial',
376
- tiktok: 'Subscribe',
377
- value: 0,
378
- currency: config.payment?.currency || 'USD',
379
- productId,
380
- productName,
381
- frequency,
382
- isTrial: true,
383
- isRecurring: false,
384
- transactionId: resourceId,
385
- };
201
+ // Write to payments-orders/{orderId}
202
+ if (orderId) {
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}`);
386
205
  }
387
206
 
388
- if (transitionName === 'new-subscription') {
389
- return {
390
- ga4: 'purchase',
391
- meta: 'Purchase',
392
- tiktok: 'CompletePayment',
393
- value: price,
394
- currency: config.payment?.currency || 'USD',
395
- productId,
396
- productName,
397
- frequency,
398
- isTrial: false,
399
- isRecurring: false,
400
- transactionId: resourceId,
401
- };
402
- }
403
-
404
- if (transitionName === 'payment-recovered') {
405
- return {
406
- ga4: 'purchase',
407
- meta: 'Subscribe',
408
- tiktok: 'Subscribe',
409
- value: price,
410
- currency: config.payment?.currency || 'USD',
411
- productId,
412
- productName,
413
- frequency,
414
- isTrial: false,
415
- isRecurring: true,
416
- transactionId: resourceId,
417
- };
418
- }
419
-
420
- return null;
421
- }
422
-
423
- /**
424
- * Map one-time transitions to analytics events
425
- */
426
- function resolveOneTimeEvent(transitionName, unified, config) {
427
- if (transitionName !== 'purchase-completed') {
428
- return null;
429
- }
430
-
431
- const productId = unified.metadata?.productId || unified.raw?.metadata?.productId;
432
- const product = config.payment?.products?.find(p => p.id === productId);
433
- const price = product?.prices?.once?.amount || 0;
434
-
435
- return {
436
- ga4: 'purchase',
437
- meta: 'Purchase',
438
- tiktok: 'CompletePayment',
439
- value: price,
440
- currency: config.payment?.currency || 'USD',
441
- productId: productId || 'unknown',
442
- productName: product?.name || 'Unknown',
443
- frequency: null,
444
- isTrial: false,
445
- isRecurring: false,
446
- transactionId: unified.id,
447
- };
207
+ return transitionName;
448
208
  }
@@ -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
  };