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