backend-manager 5.0.86 → 5.0.88

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