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.
- package/CLAUDE.md +133 -2
- package/README.md +1 -1
- 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 +75 -315
- 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 +2 -0
- package/src/manager/libraries/payment-processors/order-id.js +18 -0
- package/src/manager/libraries/payment-processors/resolve-price-id.js +19 -0
- package/src/manager/libraries/payment-processors/stripe.js +88 -47
- package/src/manager/libraries/payment-processors/test.js +14 -11
- package/src/manager/routes/payments/intent/post.js +61 -7
- package/src/manager/routes/payments/intent/processors/stripe.js +18 -50
- package/src/manager/routes/payments/intent/processors/test.js +18 -22
- package/src/manager/routes/payments/webhook/post.js +1 -1
- package/src/test/runner.js +11 -0
- package/src/test/test-accounts.js +20 -2
- package/templates/backend-manager-config.json +31 -12
- package/test/events/payments/journey-payments-cancel.js +2 -0
- package/test/events/payments/journey-payments-failure.js +2 -0
- 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-suspend.js +2 -0
- package/test/events/payments/journey-payments-trial.js +4 -0
- package/test/events/payments/journey-payments-upgrade.js +20 -10
- package/test/helpers/stripe-to-unified.js +17 -0
- package/test/helpers/user.js +1 -0
- package/test/routes/payments/intent.js +10 -7
- /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-
|
|
12
|
-
* - one-time → toUnifiedOneTime → payments-
|
|
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.
|
|
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
|
-
//
|
|
74
|
-
|
|
74
|
+
// Extract orderId from resource metadata (set at intent creation)
|
|
75
|
+
const orderId = resource.metadata?.orderId || null;
|
|
75
76
|
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
|
111
|
-
* 1.
|
|
112
|
-
* 2.
|
|
113
|
-
* 3.
|
|
114
|
-
* 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)
|
|
115
119
|
*/
|
|
116
|
-
async function
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
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
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
// 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
|
-
},
|
|
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(
|
|
176
|
+
const transitionName = transitions.detectTransition(category, before, unified, eventType);
|
|
231
177
|
|
|
232
178
|
if (transitionName) {
|
|
233
|
-
assistant.log(`Transition detected:
|
|
179
|
+
assistant.log(`Transition detected: ${category}/${transitionName} (before.status=${before?.status || 'null'}, after.status=${unified.status})`);
|
|
234
180
|
|
|
235
181
|
if (shouldRunHandlers) {
|
|
236
|
-
|
|
237
|
-
|
|
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):
|
|
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
|
|
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
|
-
|
|
344
|
-
|
|
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
|
-
|
|
352
|
-
|
|
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
|
-
|
|
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
|
}
|
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
|
};
|