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.
- package/CLAUDE.md +133 -2
- package/package.json +5 -3
- package/src/cli/index.js +11 -0
- package/src/manager/events/firestore/payments-webhooks/analytics.js +170 -0
- package/src/manager/events/firestore/payments-webhooks/on-write.js +64 -314
- package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-completed.js +20 -10
- package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-failed.js +4 -8
- package/src/manager/events/firestore/payments-webhooks/transitions/send-email.js +67 -0
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/cancellation-requested.js +23 -9
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/new-subscription.js +22 -8
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-failed.js +19 -8
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-recovered.js +19 -7
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/plan-changed.js +27 -8
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/subscription-cancelled.js +25 -9
- package/src/manager/helpers/user.js +1 -0
- package/src/manager/libraries/payment-processors/resolve-price-id.js +19 -0
- package/src/manager/libraries/payment-processors/stripe.js +87 -48
- package/src/manager/libraries/payment-processors/test.js +4 -4
- package/src/manager/routes/payments/intent/post.js +44 -0
- package/src/manager/routes/payments/intent/processors/stripe.js +10 -45
- package/src/manager/routes/payments/intent/processors/test.js +16 -20
- package/src/test/runner.js +11 -0
- package/src/test/test-accounts.js +18 -0
- package/templates/backend-manager-config.json +31 -12
- package/test/events/payments/journey-payments-one-time-failure.js +105 -0
- package/test/events/payments/journey-payments-one-time.js +128 -0
- package/test/events/payments/journey-payments-plan-change.js +126 -0
- package/test/events/payments/journey-payments-upgrade.js +2 -2
- /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
|
-
//
|
|
77
|
-
|
|
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
|
|
115
|
-
* 1.
|
|
116
|
-
* 2.
|
|
117
|
-
* 3.
|
|
118
|
-
* 4.
|
|
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
|
|
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
|
|
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
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
},
|
|
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(
|
|
176
|
+
const transitionName = transitions.detectTransition(category, before, unified, eventType);
|
|
238
177
|
|
|
239
178
|
if (transitionName) {
|
|
240
|
-
assistant.log(`Transition detected:
|
|
179
|
+
assistant.log(`Transition detected: ${category}/${transitionName} (before.status=${before?.status || 'null'}, after.status=${unified.status})`);
|
|
241
180
|
|
|
242
181
|
if (shouldRunHandlers) {
|
|
243
|
-
|
|
244
|
-
|
|
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):
|
|
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
|
|
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
|
-
|
|
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
|
-
}
|
package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-completed.js
CHANGED
|
@@ -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
|
-
|
|
11
|
-
assistant.log(`Transition [one-time/purchase-completed]: uid=${uid}, resourceId=${after.id}`);
|
|
5
|
+
const { sendOrderEmail, formatDate } = require('../send-email.js');
|
|
12
6
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
};
|
package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-failed.js
CHANGED
|
@@ -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
|
-
*
|
|
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,
|
|
11
|
-
assistant.log(`Transition [one-time/purchase-failed]: uid=${uid},
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
14
|
-
|
|
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
|
};
|
package/src/manager/events/firestore/payments-webhooks/transitions/subscription/new-subscription.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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
|
};
|
package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-failed.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
14
|
-
|
|
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
|
};
|