backend-manager 5.0.86 → 5.0.87

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 (40) hide show
  1. package/CLAUDE.md +53 -1
  2. package/package.json +1 -1
  3. package/src/cli/commands/base-command.js +5 -1
  4. package/src/cli/commands/serve.js +1 -2
  5. package/src/manager/cron/daily/ghostii-auto-publisher.js +10 -19
  6. package/src/manager/events/firestore/payments-webhooks/on-write.js +351 -56
  7. package/src/manager/events/firestore/payments-webhooks/transitions/index.js +148 -0
  8. package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-completed.js +16 -0
  9. package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-failed.js +15 -0
  10. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/cancellation-requested.js +15 -0
  11. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/new-subscription.js +18 -0
  12. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-failed.js +15 -0
  13. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-recovered.js +14 -0
  14. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/plan-changed.js +16 -0
  15. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/subscription-cancelled.js +16 -0
  16. package/src/manager/index.js +26 -36
  17. package/src/manager/libraries/{stripe.js → payment-processors/stripe.js} +57 -2
  18. package/src/manager/libraries/payment-processors/test.js +141 -0
  19. package/src/manager/routes/app/get.js +5 -22
  20. package/src/manager/routes/payments/intent/post.js +38 -23
  21. package/src/manager/routes/payments/intent/processors/stripe.js +96 -48
  22. package/src/manager/routes/payments/intent/processors/test.js +139 -76
  23. package/src/manager/routes/payments/webhook/post.js +14 -5
  24. package/src/manager/routes/payments/webhook/processors/stripe.js +75 -9
  25. package/src/manager/schemas/payments/intent/post.js +1 -1
  26. package/src/test/test-accounts.js +10 -1
  27. package/templates/backend-manager-config.json +16 -4
  28. package/test/events/payments/journey-payments-cancel.js +6 -0
  29. package/test/events/payments/journey-payments-failure.js +114 -0
  30. package/test/events/payments/journey-payments-suspend.js +6 -0
  31. package/test/events/payments/journey-payments-trial.js +12 -0
  32. package/test/events/payments/journey-payments-upgrade.js +17 -0
  33. package/test/fixtures/stripe/checkout-session-completed.json +130 -0
  34. package/test/fixtures/stripe/invoice-payment-failed.json +148 -0
  35. package/test/fixtures/stripe/invoice-subscription-payment-failed.json +28 -0
  36. package/test/helpers/stripe-parse-webhook.js +447 -0
  37. package/test/helpers/stripe-to-unified.js +59 -59
  38. package/test/routes/payments/intent.js +3 -3
  39. package/test/routes/payments/webhook.js +2 -2
  40. package/src/manager/libraries/test.js +0 -27
package/CLAUDE.md CHANGED
@@ -621,6 +621,57 @@ user.subscription.cancellation.pending === true
621
621
  user.subscription.status === 'suspended'
622
622
  ```
623
623
 
624
+ ## Payment Transition Handlers
625
+
626
+ ### Overview
627
+
628
+ When a webhook changes a subscription or processes a one-time payment, BEM detects the state transition and dispatches to a handler file. Handlers are fire-and-forget (non-blocking) — they run after the transition is detected but before or during the Firestore writes. Handler failures never block webhook processing.
629
+
630
+ Handlers are skipped during tests unless `TEST_EXTENDED_MODE` is set.
631
+
632
+ ### Transition Detection
633
+
634
+ The `transitions/index.js` module compares the **before** state (current `users/{uid}.subscription`) with the **after** state (new unified subscription) to detect what changed.
635
+
636
+ ### Subscription Transitions
637
+
638
+ | Transition | Before → After | File |
639
+ |---|---|---|
640
+ | `new-subscription` | basic/null → active paid | `transitions/subscription/new-subscription.js` |
641
+ | `trial-started` | basic/null → active paid with trial | `transitions/subscription/trial-started.js` |
642
+ | `payment-failed` | active → suspended | `transitions/subscription/payment-failed.js` |
643
+ | `payment-recovered` | suspended → active | `transitions/subscription/payment-recovered.js` |
644
+ | `cancellation-requested` | pending=false → pending=true | `transitions/subscription/cancellation-requested.js` |
645
+ | `subscription-cancelled` | non-cancelled → cancelled | `transitions/subscription/subscription-cancelled.js` |
646
+ | `plan-changed` | active product A → active product B | `transitions/subscription/plan-changed.js` |
647
+
648
+ ### One-Time Transitions
649
+
650
+ | Transition | Event Type | File |
651
+ |---|---|---|
652
+ | `purchase-completed` | `checkout.session.completed` | `transitions/one-time/purchase-completed.js` |
653
+ | `purchase-failed` | `invoice.payment_failed` | `transitions/one-time/purchase-failed.js` |
654
+
655
+ ### Handler Interface
656
+
657
+ All handlers are in `src/manager/events/firestore/payments-webhooks/transitions/` and export a single async function:
658
+
659
+ ```javascript
660
+ module.exports = async function ({ before, after, uid, userDoc, admin, assistant, Manager, eventType, eventId }) {
661
+ // before: previous subscription state (null for new/one-time)
662
+ // after: new unified state (subscription or one-time)
663
+ // userDoc: full user document data
664
+ // eventType: original webhook event type (e.g., 'customer.subscription.updated')
665
+ // eventId: webhook event ID
666
+ };
667
+ ```
668
+
669
+ ### Creating a New Transition Handler
670
+
671
+ 1. Add detection logic in `transitions/index.js` (in priority order)
672
+ 2. Create handler file in `transitions/{category}/{name}.js`
673
+ 3. Handler receives full context — use `assistant.log()` for logging, `Manager.project.apiUrl` for API calls
674
+
624
675
  ## Common Mistakes to Avoid
625
676
 
626
677
  1. **Don't modify Manager internals directly** - Use factory methods and public APIs
@@ -654,7 +705,8 @@ user.subscription.status === 'suspended'
654
705
  | Config template | `templates/backend-manager-config.json` |
655
706
  | CLI entry | `src/cli/index.js` |
656
707
  | Stripe webhook forwarding | `src/cli/commands/stripe.js` |
657
- | Stripe shared library | `src/manager/libraries/stripe.js` |
708
+ | Payment processor libraries | `src/manager/libraries/payment-processors/` |
709
+ | Payment transition handlers | `src/manager/events/firestore/payments-webhooks/transitions/` |
658
710
 
659
711
  ## Environment Detection
660
712
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-manager",
3
- "version": "5.0.86",
3
+ "version": "5.0.87",
4
4
  "description": "Quick tools for developing Firebase functions",
5
5
  "main": "src/manager/index.js",
6
6
  "bin": {
@@ -167,6 +167,10 @@ class BaseCommand {
167
167
  const projectDir = this.main.firebaseProjectPath;
168
168
  const functionsDir = path.join(projectDir, 'functions');
169
169
 
170
+ // Quit early here because its not supported yet
171
+ this.log(chalk.gray(' (Stripe webhook forwarding is currently disabled - coming soon!)\n'));
172
+ return null;
173
+
170
174
  // Load .env so STRIPE_SECRET_KEY and BACKEND_MANAGER_KEY are available
171
175
  const envPath = path.join(functionsDir, '.env');
172
176
  if (jetpack.exists(envPath)) {
@@ -247,4 +251,4 @@ class BaseCommand {
247
251
  }
248
252
  }
249
253
 
250
- module.exports = BaseCommand;
254
+ module.exports = BaseCommand;
@@ -12,8 +12,7 @@ class ServeCommand extends BaseCommand {
12
12
  watcher.startBackground();
13
13
 
14
14
  // Start Stripe webhook forwarding in background
15
- // Ignored because we cant really fully process them unless the emulator is running
16
- // this.startStripeWebhookForwarding();
15
+ this.startStripeWebhookForwarding();
17
16
 
18
17
  // Execute
19
18
  await powertools.execute(`firebase serve --port ${port}`, { log: true });
@@ -4,7 +4,7 @@ const moment = require('moment');
4
4
  const JSON5 = require('json5');
5
5
 
6
6
  const PROMPT = `
7
- Company: {app.name}: {app.brand.description}
7
+ Company: {app.brand.name}: {app.brand.description}
8
8
  Date: {date}
9
9
  Instructions: {prompt}
10
10
 
@@ -63,7 +63,7 @@ module.exports = async ({ Manager, assistant, context, libraries }) => {
63
63
  }
64
64
 
65
65
  // Log
66
- assistant.log(`Settings (app=${settings.app.id})`, settings);
66
+ assistant.log(`Settings (app=${settings.app.brand.id})`, settings);
67
67
 
68
68
  // Quit if articles are disabled
69
69
  if (!settings.articles || !settings.sources.length) {
@@ -90,21 +90,12 @@ module.exports = async ({ Manager, assistant, context, libraries }) => {
90
90
  };
91
91
 
92
92
  /**
93
- * Build app object from Manager.config (same shape as getApp response)
93
+ * Build app object from Manager.config (same shape as /app endpoint response)
94
94
  */
95
95
  function buildAppObject(config) {
96
- return {
97
- id: config.app?.id,
98
- name: config.brand?.name,
99
- brand: {
100
- description: config.brand?.description || '',
101
- },
102
- url: config.brand?.url,
103
- github: {
104
- user: config.github?.user,
105
- repo: (config.github?.repo_website || '').split('/').pop(),
106
- },
107
- };
96
+ const { buildPublicConfig } = require(require('path').join(__dirname, '..', '..', 'routes', 'app', 'get.js'));
97
+
98
+ return buildPublicConfig(config);
108
99
  }
109
100
 
110
101
  /**
@@ -122,7 +113,7 @@ async function harvest(assistant, settings) {
122
113
  const date = moment().format('MMMM YYYY');
123
114
 
124
115
  // Log
125
- assistant.log(`harvest(): Starting ${settings.app.id}...`);
116
+ assistant.log(`harvest(): Starting ${settings.app.brand.id}...`);
126
117
 
127
118
  // Process the number of sources in the settings
128
119
  for (let index = 0; index < settings.articles; index++) {
@@ -218,16 +209,16 @@ function requestGhostii(settings, content) {
218
209
  description: content,
219
210
  insertLinks: true,
220
211
  headerImageUrl: 'unsplash',
221
- url: settings.app.url,
212
+ url: settings.app.brand.url,
222
213
  sectionQuantity: powertools.random(3, 6, { mode: 'gaussian' }),
223
- feedUrl: `${settings.app.url}/feeds/posts.json`,
214
+ feedUrl: `${settings.app.brand.url}/feeds/posts.json`,
224
215
  links: settings.links,
225
216
  },
226
217
  });
227
218
  }
228
219
 
229
220
  function uploadPost(assistant, settings, article) {
230
- const apiUrl = `https://api.${(settings.app.url || '').replace(/^https?:\/\//, '')}`;
221
+ const apiUrl = `https://api.${(settings.app.brand.url || '').replace(/^https?:\/\//, '')}`;
231
222
  return fetch(`${apiUrl}/backend-manager/admin/post`, {
232
223
  method: 'POST',
233
224
  timeout: 90000,
@@ -1,13 +1,17 @@
1
1
  const powertools = require('node-powertools');
2
+ const transitions = require('./transitions/index.js');
2
3
 
3
4
  /**
4
5
  * Firestore trigger: payments-webhooks/{eventId} onWrite
5
6
  *
6
7
  * Processes pending webhook events:
7
- * 1. Transforms raw processor data into unified subscription object
8
- * 2. Updates the user's subscription in users/{uid}
9
- * 3. Stores the subscription doc in payments-subscriptions/{resourceId}
10
- * 4. Marks the webhook as completed
8
+ * 1. Loads the processor library
9
+ * 2. Fetches the latest resource from the processor API (not the stale webhook payload)
10
+ * 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}
13
+ * 4. Detects state transitions and dispatches handler files (non-blocking)
14
+ * 5. Marks the webhook as completed
11
15
  */
12
16
  module.exports = async ({ Manager, assistant, change, context, libraries }) => {
13
17
  const { admin } = libraries;
@@ -30,83 +34,57 @@ module.exports = async ({ Manager, assistant, change, context, libraries }) => {
30
34
  const uid = dataAfter.uid;
31
35
  const raw = dataAfter.raw;
32
36
  const eventType = dataAfter.event?.type;
37
+ const category = dataAfter.event?.category;
38
+ const resourceType = dataAfter.event?.resourceType;
39
+ const resourceId = dataAfter.event?.resourceId;
33
40
 
34
- assistant.log(`Processing webhook ${eventId}: processor=${processor}, eventType=${eventType}, uid=${uid || 'null'}`);
41
+ assistant.log(`Processing webhook ${eventId}: processor=${processor}, eventType=${eventType}, category=${category}, resourceType=${resourceType}, resourceId=${resourceId}, uid=${uid || 'null'}`);
35
42
 
36
43
  // Validate UID
37
44
  if (!uid) {
38
45
  throw new Error('Webhook event has no UID — cannot process');
39
46
  }
40
47
 
41
- // Load the shared library for this processor (only needs toUnified, not SDK init)
48
+ // Validate category
49
+ if (!category) {
50
+ throw new Error(`Webhook event has no category — cannot process`);
51
+ }
52
+
53
+ // Load the shared library for this processor
42
54
  let library;
43
55
  try {
44
- library = require(`../../../libraries/${processor}.js`);
56
+ library = require(`../../../libraries/payment-processors/${processor}.js`);
45
57
  } catch (e) {
46
58
  throw new Error(`Unknown processor library: ${processor}`);
47
59
  }
48
60
 
49
- // Extract the subscription object from the raw event
50
- // Stripe sends events with event.data.object as the subscription
51
- const rawSubscription = raw.data?.object || {};
61
+ // Fetch the latest resource from the processor API
62
+ // This ensures we always work with the most current state, not stale webhook data
63
+ const rawFallback = raw.data?.object || {};
64
+ const resource = await library.fetchResource(resourceType, resourceId, rawFallback, { admin, eventType, config: Manager.config });
52
65
 
53
- assistant.log(`Raw subscription: stripeStatus=${rawSubscription.status}, cancelAtPeriodEnd=${rawSubscription.cancel_at_period_end}, trialEnd=${rawSubscription.trial_end || 'none'}, resourceId=${rawSubscription.id}`);
54
-
55
- // Transform raw data into unified subscription object
56
- const unified = library.toUnified(rawSubscription, {
57
- config: Manager.config,
58
- eventName: eventType,
59
- eventId: eventId,
60
- });
61
-
62
- assistant.log(`Unified result: status=${unified.status}, product=${unified.product.id}, frequency=${unified.payment.frequency}, trial.claimed=${unified.trial.claimed}, cancellation.pending=${unified.cancellation.pending}`);
66
+ assistant.log(`Fetched resource: type=${resourceType}, id=${resourceId}, status=${resource.status || 'unknown'}`);
63
67
 
64
68
  // Build timestamps
65
69
  const now = powertools.timestamp(new Date(), { output: 'string' });
66
70
  const nowUNIX = powertools.timestamp(now, { output: 'unix' });
67
71
 
68
- // Write unified subscription to user doc
69
- await admin.firestore().doc(`users/${uid}`).set({
70
- subscription: unified,
71
- }, { merge: true });
72
-
73
- assistant.log(`Updated users/${uid}.subscription: status=${unified.status}, product=${unified.product.id}`);
74
-
75
- // Write to payments-subscriptions/{resourceId}
76
- const resourceId = unified.payment.resourceId;
77
- if (resourceId) {
78
- await admin.firestore().doc(`payments-subscriptions/${resourceId}`).set({
79
- uid: uid,
80
- processor: processor,
81
- subscription: unified,
82
- raw: rawSubscription,
83
- metadata: {
84
- created: {
85
- timestamp: now,
86
- timestampUNIX: nowUNIX,
87
- },
88
- updated: {
89
- timestamp: now,
90
- timestampUNIX: nowUNIX,
91
- },
92
- updatedBy: {
93
- event: {
94
- name: eventType,
95
- id: eventId,
96
- },
97
- },
98
- },
99
- }, { merge: true });
72
+ // Branch on category
73
+ let transitionName = null;
100
74
 
101
- assistant.log(`Updated payments-subscriptions/${resourceId}: uid=${uid}, eventType=${eventType}`);
75
+ if (category === 'subscription') {
76
+ transitionName = await processSubscription({ library, resource, uid, processor, eventType, eventId, resourceId, now, nowUNIX, admin, assistant, Manager });
77
+ } else if (category === 'one-time') {
78
+ transitionName = await processOneTime({ library, resource, uid, processor, eventType, eventId, resourceId, now, nowUNIX, admin, assistant, Manager });
102
79
  } else {
103
- assistant.log(`No resourceId in unified result, skipping payments-subscriptions write`);
80
+ throw new Error(`Unknown event category: ${category}`);
104
81
  }
105
82
 
106
- // Mark webhook as completed
83
+ // Mark webhook as completed (include transition name for auditing/testing)
107
84
  await webhookRef.set({
108
85
  status: 'completed',
109
86
  uid: uid,
87
+ transition: transitionName,
110
88
  metadata: {
111
89
  processed: {
112
90
  timestamp: now,
@@ -115,7 +93,7 @@ module.exports = async ({ Manager, assistant, change, context, libraries }) => {
115
93
  },
116
94
  }, { merge: true });
117
95
 
118
- assistant.log(`Webhook ${eventId} completed: wrote users/${uid}, payments-subscriptions/${resourceId || 'skipped'}, payments-webhooks/${eventId}=completed`);
96
+ assistant.log(`Webhook ${eventId} completed`);
119
97
  } catch (e) {
120
98
  assistant.error(`Webhook ${eventId} failed: ${e.message}`, e);
121
99
 
@@ -126,3 +104,320 @@ module.exports = async ({ Manager, assistant, change, context, libraries }) => {
126
104
  }, { merge: true });
127
105
  }
128
106
  };
107
+
108
+ /**
109
+ * Process a subscription event
110
+ * 1. Read current user subscription (before state)
111
+ * 2. Transform raw resource → unified subscription (after state)
112
+ * 3. Detect and dispatch transition handlers (non-blocking)
113
+ * 4. Write to user doc + payments-subscriptions
114
+ */
115
+ async function processSubscription({ library, resource, uid, processor, eventType, eventId, resourceId, now, nowUNIX, admin, assistant, Manager }) {
116
+ // Read current user doc BEFORE writing (for transition detection)
117
+ const userDoc = await admin.firestore().doc(`users/${uid}`).get();
118
+ const userData = userDoc.exists ? userDoc.data() : {};
119
+ const before = userData.subscription || null;
120
+
121
+ // Transform to unified subscription (the "after" state)
122
+ const unified = library.toUnifiedSubscription(resource, {
123
+ config: Manager.config,
124
+ eventName: eventType,
125
+ eventId: eventId,
126
+ });
127
+
128
+ 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);
129
+
130
+ // Detect and dispatch transition (non-blocking)
131
+ const shouldRunHandlers = !assistant.isTesting() || process.env.TEST_EXTENDED_MODE;
132
+ const transitionName = transitions.detectTransition('subscription', before, unified, eventType);
133
+
134
+ if (transitionName) {
135
+ assistant.log(`Transition detected: subscription/${transitionName} (before.status=${before?.status || 'null'}, after.status=${unified.status})`);
136
+
137
+ if (shouldRunHandlers) {
138
+ transitions.dispatch(transitionName, 'subscription', {
139
+ before, after: unified, uid, userDoc: userData, admin, assistant, Manager, eventType, eventId,
140
+ });
141
+ } else {
142
+ assistant.log(`Transition handler skipped (testing mode): subscription/${transitionName}`);
143
+ }
144
+ }
145
+
146
+ // Track payment analytics (non-blocking)
147
+ if (transitionName && shouldRunHandlers) {
148
+ trackPayment({ category: 'subscription', transitionName, unified, uid, processor, assistant, Manager });
149
+ }
150
+
151
+ // Write unified subscription to user doc
152
+ await admin.firestore().doc(`users/${uid}`).set({
153
+ subscription: unified,
154
+ }, { merge: true });
155
+
156
+ assistant.log(`Updated users/${uid}.subscription: status=${unified.status}, product=${unified.product.id}`);
157
+
158
+ // Write to payments-subscriptions/{resourceId}
159
+ if (resourceId) {
160
+ await admin.firestore().doc(`payments-subscriptions/${resourceId}`).set({
161
+ uid: uid,
162
+ processor: processor,
163
+ subscription: unified,
164
+ metadata: {
165
+ created: {
166
+ timestamp: now,
167
+ timestampUNIX: nowUNIX,
168
+ },
169
+ updated: {
170
+ timestamp: now,
171
+ timestampUNIX: nowUNIX,
172
+ },
173
+ updatedBy: {
174
+ event: {
175
+ name: eventType,
176
+ id: eventId,
177
+ },
178
+ },
179
+ },
180
+ }, { merge: true });
181
+
182
+ assistant.log(`Updated payments-subscriptions/${resourceId}: uid=${uid}, eventType=${eventType}`);
183
+ }
184
+
185
+ return transitionName;
186
+ }
187
+
188
+ /**
189
+ * Process a one-time payment event
190
+ * 1. Transform raw resource → unified one-time
191
+ * 2. Detect and dispatch transition handlers (non-blocking)
192
+ * 3. Write to payments-one-time
193
+ */
194
+ async function processOneTime({ library, resource, uid, processor, eventType, eventId, resourceId, now, nowUNIX, admin, assistant, Manager }) {
195
+ const unified = library.toUnifiedOneTime(resource, {
196
+ config: Manager.config,
197
+ eventName: eventType,
198
+ eventId: eventId,
199
+ });
200
+
201
+ assistant.log(`Unified one-time: id=${unified.id}, status=${unified.status}`, unified);
202
+
203
+ // Detect and dispatch transition (non-blocking)
204
+ const shouldRunHandlers = !assistant.isTesting() || process.env.TEST_EXTENDED_MODE;
205
+ const transitionName = transitions.detectTransition('one-time', null, unified, eventType);
206
+
207
+ if (transitionName) {
208
+ assistant.log(`Transition detected: one-time/${transitionName}`);
209
+
210
+ if (shouldRunHandlers) {
211
+ // Read user doc for handler context
212
+ const userDoc = await admin.firestore().doc(`users/${uid}`).get();
213
+ const userData = userDoc.exists ? userDoc.data() : {};
214
+
215
+ transitions.dispatch(transitionName, 'one-time', {
216
+ before: null, after: unified, uid, userDoc: userData, admin, assistant, Manager, eventType, eventId,
217
+ });
218
+ } else {
219
+ assistant.log(`Transition handler skipped (testing mode): one-time/${transitionName}`);
220
+ }
221
+ }
222
+
223
+ // Track payment analytics (non-blocking)
224
+ if (transitionName && shouldRunHandlers) {
225
+ trackPayment({ category: 'one-time', transitionName, unified, uid, processor, assistant, Manager });
226
+ }
227
+
228
+ // Write to payments-one-time/{resourceId}
229
+ if (resourceId) {
230
+ await admin.firestore().doc(`payments-one-time/${resourceId}`).set({
231
+ uid: uid,
232
+ processor: processor,
233
+ payment: unified,
234
+ metadata: {
235
+ created: {
236
+ timestamp: now,
237
+ timestampUNIX: nowUNIX,
238
+ },
239
+ updated: {
240
+ timestamp: now,
241
+ timestampUNIX: nowUNIX,
242
+ },
243
+ updatedBy: {
244
+ event: {
245
+ name: eventType,
246
+ id: eventId,
247
+ },
248
+ },
249
+ },
250
+ }, { merge: true });
251
+
252
+ assistant.log(`Updated payments-one-time/${resourceId}: uid=${uid}, eventType=${eventType}`);
253
+ }
254
+
255
+ return transitionName;
256
+ }
257
+
258
+ /**
259
+ * Track payment events across analytics platforms (non-blocking)
260
+ * Fires server-side events for GA4, Meta Conversions API, and TikTok Events API
261
+ *
262
+ * Maps transitions to standard platform events:
263
+ * new-subscription (no trial) → purchase / Purchase / CompletePayment
264
+ * new-subscription (trial) → start_trial / StartTrial / Subscribe
265
+ * payment-recovered → purchase / Subscribe / Subscribe (recurring)
266
+ * purchase-completed → purchase / Purchase / CompletePayment
267
+ *
268
+ * @param {object} options
269
+ * @param {string} options.category - 'subscription' or 'one-time'
270
+ * @param {string} options.transitionName - Detected transition (e.g., 'new-subscription', 'purchase-completed')
271
+ * @param {object} options.unified - Unified subscription or one-time object
272
+ * @param {string} options.uid - User ID
273
+ * @param {string} options.processor - Payment processor (e.g., 'stripe', 'paypal')
274
+ * @param {object} options.assistant - Assistant instance
275
+ * @param {object} options.Manager - Manager instance
276
+ */
277
+ function trackPayment({ category, transitionName, unified, uid, processor, assistant, Manager }) {
278
+ try {
279
+ // Resolve the analytics event to fire based on transition
280
+ const event = resolvePaymentEvent(category, transitionName, unified, Manager.config);
281
+
282
+ if (!event) {
283
+ return;
284
+ }
285
+
286
+ assistant.log(`trackPayment: event=${event.ga4}, value=${event.value}, currency=${event.currency}, product=${event.productId}, uid=${uid}`);
287
+
288
+ // GA4 via Measurement Protocol
289
+ Manager.Analytics({ assistant, uuid: uid }).event(event.ga4, {
290
+ transaction_id: event.transactionId,
291
+ value: event.value,
292
+ currency: event.currency,
293
+ items: [{
294
+ item_id: event.productId,
295
+ item_name: event.productName,
296
+ price: event.value,
297
+ quantity: 1,
298
+ }],
299
+ payment_processor: processor,
300
+ payment_frequency: event.frequency,
301
+ is_trial: event.isTrial,
302
+ is_recurring: event.isRecurring,
303
+ });
304
+
305
+ // TODO: Meta Conversions API
306
+ // Event name: event.meta (e.g., 'Purchase', 'StartTrial', 'Subscribe')
307
+ // https://developers.facebook.com/docs/marketing-api/conversions-api
308
+
309
+ // TODO: TikTok Events API
310
+ // Event name: event.tiktok (e.g., 'CompletePayment', 'Subscribe')
311
+ // https://business-api.tiktok.com/portal/docs?id=1771100865818625
312
+ } catch (e) {
313
+ assistant.error(`trackPayment failed: ${e.message}`, e);
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Resolve which analytics event to fire based on transition + unified data
319
+ * Returns null if the transition doesn't warrant an analytics event
320
+ */
321
+ function resolvePaymentEvent(category, transitionName, unified, config) {
322
+ if (category === 'subscription') {
323
+ return resolveSubscriptionEvent(transitionName, unified, config);
324
+ }
325
+
326
+ if (category === 'one-time') {
327
+ return resolveOneTimeEvent(transitionName, unified, config);
328
+ }
329
+
330
+ return null;
331
+ }
332
+
333
+ /**
334
+ * Map subscription transitions to analytics events
335
+ */
336
+ function resolveSubscriptionEvent(transitionName, unified, config) {
337
+ const productId = unified.product?.id;
338
+ const productName = unified.product?.name;
339
+ const frequency = unified.payment?.frequency;
340
+ const isTrial = unified.trial?.claimed === true;
341
+ const resourceId = unified.payment?.resourceId;
342
+
343
+ // Resolve price from config
344
+ const product = config.payment?.products?.find(p => p.id === productId);
345
+ const price = product?.prices?.[frequency]?.amount || 0;
346
+
347
+ if (transitionName === 'new-subscription' && isTrial) {
348
+ return {
349
+ ga4: 'start_trial',
350
+ meta: 'StartTrial',
351
+ tiktok: 'Subscribe',
352
+ value: 0,
353
+ currency: config.payment?.currency || 'USD',
354
+ productId,
355
+ productName,
356
+ frequency,
357
+ isTrial: true,
358
+ isRecurring: false,
359
+ transactionId: resourceId,
360
+ };
361
+ }
362
+
363
+ if (transitionName === 'new-subscription') {
364
+ return {
365
+ ga4: 'purchase',
366
+ meta: 'Purchase',
367
+ tiktok: 'CompletePayment',
368
+ value: price,
369
+ currency: config.payment?.currency || 'USD',
370
+ productId,
371
+ productName,
372
+ frequency,
373
+ isTrial: false,
374
+ isRecurring: false,
375
+ transactionId: resourceId,
376
+ };
377
+ }
378
+
379
+ if (transitionName === 'payment-recovered') {
380
+ return {
381
+ ga4: 'purchase',
382
+ meta: 'Subscribe',
383
+ tiktok: 'Subscribe',
384
+ value: price,
385
+ currency: config.payment?.currency || 'USD',
386
+ productId,
387
+ productName,
388
+ frequency,
389
+ isTrial: false,
390
+ isRecurring: true,
391
+ transactionId: resourceId,
392
+ };
393
+ }
394
+
395
+ return null;
396
+ }
397
+
398
+ /**
399
+ * Map one-time transitions to analytics events
400
+ */
401
+ function resolveOneTimeEvent(transitionName, unified, config) {
402
+ if (transitionName !== 'purchase-completed') {
403
+ return null;
404
+ }
405
+
406
+ const productId = unified.metadata?.productId || unified.raw?.metadata?.productId;
407
+ const product = config.payment?.products?.find(p => p.id === productId);
408
+ const price = product?.prices?.once?.amount || 0;
409
+
410
+ return {
411
+ ga4: 'purchase',
412
+ meta: 'Purchase',
413
+ tiktok: 'CompletePayment',
414
+ value: price,
415
+ currency: config.payment?.currency || 'USD',
416
+ productId: productId || 'unknown',
417
+ productName: product?.name || 'Unknown',
418
+ frequency: null,
419
+ isTrial: false,
420
+ isRecurring: false,
421
+ transactionId: unified.id,
422
+ };
423
+ }