backend-manager 5.0.85 → 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.
- package/CLAUDE.md +53 -1
- package/package.json +1 -1
- package/src/cli/commands/base-command.js +5 -1
- package/src/cli/commands/serve.js +1 -2
- package/src/manager/cron/daily/ghostii-auto-publisher.js +10 -19
- package/src/manager/events/firestore/payments-webhooks/on-write.js +351 -56
- package/src/manager/events/firestore/payments-webhooks/transitions/index.js +148 -0
- package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-completed.js +16 -0
- package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-failed.js +15 -0
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/cancellation-requested.js +15 -0
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/new-subscription.js +18 -0
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-failed.js +15 -0
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-recovered.js +14 -0
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/plan-changed.js +16 -0
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/subscription-cancelled.js +16 -0
- package/src/manager/index.js +26 -36
- package/src/manager/libraries/{stripe.js → payment-processors/stripe.js} +57 -2
- package/src/manager/libraries/payment-processors/test.js +141 -0
- package/src/manager/routes/app/get.js +5 -22
- package/src/manager/routes/payments/intent/post.js +38 -23
- package/src/manager/routes/payments/intent/processors/stripe.js +112 -44
- package/src/manager/routes/payments/intent/processors/test.js +139 -76
- package/src/manager/routes/payments/webhook/post.js +14 -5
- package/src/manager/routes/payments/webhook/processors/stripe.js +75 -9
- package/src/manager/schemas/payments/intent/post.js +1 -1
- package/src/test/test-accounts.js +10 -1
- package/templates/backend-manager-config.json +16 -4
- package/test/events/payments/journey-payments-cancel.js +6 -0
- package/test/events/payments/journey-payments-failure.js +114 -0
- package/test/events/payments/journey-payments-suspend.js +6 -0
- package/test/events/payments/journey-payments-trial.js +12 -0
- package/test/events/payments/journey-payments-upgrade.js +17 -0
- package/test/fixtures/stripe/checkout-session-completed.json +130 -0
- package/test/fixtures/stripe/invoice-payment-failed.json +148 -0
- package/test/fixtures/stripe/invoice-subscription-payment-failed.json +28 -0
- package/test/helpers/stripe-parse-webhook.js +447 -0
- package/test/helpers/stripe-to-unified.js +59 -59
- package/test/routes/payments/intent.js +3 -3
- package/test/routes/payments/webhook.js +2 -2
- 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
|
-
|
|
|
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
|
@@ -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
|
-
|
|
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
|
|
93
|
+
* Build app object from Manager.config (same shape as /app endpoint response)
|
|
94
94
|
*/
|
|
95
95
|
function buildAppObject(config) {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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.
|
|
8
|
-
* 2.
|
|
9
|
-
* 3.
|
|
10
|
-
*
|
|
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
|
-
//
|
|
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
|
-
//
|
|
50
|
-
//
|
|
51
|
-
const
|
|
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(`
|
|
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
|
-
//
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|