backend-manager 5.0.84 → 5.0.86
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 +34 -0
- package/CLAUDE.md +66 -3
- package/README.md +7 -5
- package/package.json +5 -4
- package/src/cli/commands/base-command.js +89 -0
- package/src/cli/commands/emulators.js +3 -0
- package/src/cli/commands/serve.js +5 -1
- package/src/cli/commands/stripe.js +14 -0
- package/src/cli/commands/test.js +11 -6
- package/src/cli/index.js +7 -0
- package/src/manager/cron/daily/reset-usage.js +56 -34
- package/src/manager/events/firestore/payments-webhooks/on-write.js +15 -13
- package/src/manager/functions/core/actions/api/user/get-subscription-info.js +1 -1
- package/src/manager/helpers/analytics.js +2 -2
- package/src/manager/helpers/api-manager.js +1 -1
- package/src/manager/helpers/usage.js +51 -3
- package/src/manager/index.js +5 -19
- package/src/manager/libraries/stripe.js +12 -8
- package/src/manager/libraries/test.js +27 -0
- package/src/manager/routes/app/get.js +11 -8
- package/src/manager/routes/payments/intent/post.js +31 -16
- package/src/manager/routes/payments/intent/processors/stripe.js +130 -0
- package/src/manager/routes/payments/intent/processors/test.js +106 -0
- package/src/manager/routes/payments/webhook/post.js +21 -8
- package/src/manager/routes/payments/webhook/{providers → processors}/stripe.js +16 -1
- package/src/manager/routes/payments/webhook/processors/test.js +15 -0
- package/src/manager/routes/user/subscription/get.js +1 -1
- package/src/manager/schemas/payments/webhook/post.js +1 -1
- package/src/test/test-accounts.js +18 -18
- package/templates/_.env +0 -2
- package/templates/backend-manager-config.json +50 -34
- package/test/events/payments/journey-payments-cancel.js +144 -0
- package/test/events/payments/journey-payments-suspend.js +143 -0
- package/test/events/payments/journey-payments-trial.js +120 -0
- package/test/events/payments/journey-payments-upgrade.js +99 -0
- package/test/fixtures/stripe/subscription-active.json +161 -0
- package/test/fixtures/stripe/subscription-canceled.json +161 -0
- package/test/fixtures/stripe/subscription-trialing.json +161 -0
- package/test/functions/user/get-subscription-info.js +2 -2
- package/test/helpers/stripe-to-unified.js +684 -0
- package/test/routes/payments/intent.js +189 -0
- package/test/{payments → routes/payments}/webhook.js +1 -1
- package/test/routes/test/usage.js +7 -6
- package/test/routes/user/subscription.js +2 -2
- package/src/manager/routes/payments/intent/providers/stripe.js +0 -66
- package/test/payments/intent.js +0 -104
- package/test/payments/journey-payment-cancel.js +0 -166
- package/test/payments/journey-payment-suspend.js +0 -162
- package/test/payments/journey-payment-trial.js +0 -167
- package/test/payments/journey-payment-upgrade.js +0 -136
|
@@ -29,7 +29,7 @@ Module.prototype.main = function () {
|
|
|
29
29
|
timestampUNIX: user?.subscription?.expires?.timestampUNIX || oldDateUNIX,
|
|
30
30
|
},
|
|
31
31
|
trial: {
|
|
32
|
-
|
|
32
|
+
claimed: user?.subscription?.trial?.claimed ?? false,
|
|
33
33
|
expires: {
|
|
34
34
|
timestamp: user?.subscription?.trial?.expires?.timestamp || oldDate,
|
|
35
35
|
timestampUNIX: user?.subscription?.trial?.expires?.timestampUNIX || oldDateUNIX,
|
|
@@ -113,8 +113,8 @@ function Analytics(Manager, options) {
|
|
|
113
113
|
subscription_id: {
|
|
114
114
|
value: authUser?.subscription?.product?.id || 'basic',
|
|
115
115
|
},
|
|
116
|
-
|
|
117
|
-
value: authUser?.subscription?.trial?.
|
|
116
|
+
subscription_trial_claimed: {
|
|
117
|
+
value: authUser?.subscription?.trial?.claimed || false,
|
|
118
118
|
},
|
|
119
119
|
activity_created: {
|
|
120
120
|
value: moment(authUser?.activity?.created?.timestampUNIX
|
|
@@ -54,7 +54,7 @@ ApiManager.prototype.init = function (options) {
|
|
|
54
54
|
options.whitelistedAPIKeys = options.whitelistedAPIKeys || [];
|
|
55
55
|
|
|
56
56
|
// Read limits from config products
|
|
57
|
-
const products = self.Manager.config.products || [];
|
|
57
|
+
const products = self.Manager.config.payment?.products || [];
|
|
58
58
|
options.plans = {};
|
|
59
59
|
|
|
60
60
|
for (const product of products) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Usage
|
|
3
3
|
* Meant to check and update usage for a user
|
|
4
|
-
* Reads product limits from Manager.config.products
|
|
4
|
+
* Reads product limits from Manager.config.payment.products
|
|
5
5
|
* Stores usage in the user's firestore document OR in local/temp storage if no user
|
|
6
6
|
*/
|
|
7
7
|
|
|
@@ -142,7 +142,21 @@ Usage.prototype.validate = function (name, options) {
|
|
|
142
142
|
return resolve(true);
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
-
//
|
|
145
|
+
// Check proportional daily allowance (for products with rateLimit: 'daily')
|
|
146
|
+
const dailyAllowance = self.getDailyAllowance(name);
|
|
147
|
+
if (dailyAllowance !== null) {
|
|
148
|
+
if (options.log) {
|
|
149
|
+
assistant.log(`Usage.validate(): Daily allowance check: ${period}/${dailyAllowance} (monthly: ${allowed}) for ${name} (${self.key})`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (period >= dailyAllowance) {
|
|
153
|
+
return reject(
|
|
154
|
+
assistant.errorify(`You have reached your daily usage limit for ${name} (${period}/${dailyAllowance}). Your monthly limit is ${allowed}.`, {code: 429})
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// If they are under the monthly limit, resolve
|
|
146
160
|
if (period < allowed) {
|
|
147
161
|
self.log(`Usage.validate(): Valid for ${name}`);
|
|
148
162
|
|
|
@@ -252,7 +266,7 @@ Usage.prototype.getProduct = function (id) {
|
|
|
252
266
|
const self = this;
|
|
253
267
|
const Manager = self.Manager;
|
|
254
268
|
|
|
255
|
-
const products = Manager.config.products || [];
|
|
269
|
+
const products = Manager.config.payment?.products || [];
|
|
256
270
|
|
|
257
271
|
// Look up by provided ID, or fall back to user's subscription product
|
|
258
272
|
id = id || self.user.subscription.product.id;
|
|
@@ -275,6 +289,40 @@ Usage.prototype.getLimit = function (name) {
|
|
|
275
289
|
return limits;
|
|
276
290
|
};
|
|
277
291
|
|
|
292
|
+
/**
|
|
293
|
+
* Get the proportional daily allowance for a metric
|
|
294
|
+
* Based on how far into the month we are: ceil(monthlyLimit * dayOfMonth / daysInMonth)
|
|
295
|
+
*
|
|
296
|
+
* Returns null if the product uses monthly rate limiting (no daily cap)
|
|
297
|
+
* Products can set rateLimit: 'daily' | 'monthly' (default: 'monthly')
|
|
298
|
+
*/
|
|
299
|
+
Usage.prototype.getDailyAllowance = function (name) {
|
|
300
|
+
const self = this;
|
|
301
|
+
|
|
302
|
+
// Get the product config
|
|
303
|
+
const product = self.getProduct();
|
|
304
|
+
const rateLimit = product.rateLimit || 'monthly';
|
|
305
|
+
|
|
306
|
+
// If monthly rate limiting, no daily cap
|
|
307
|
+
if (rateLimit !== 'daily') {
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Get the monthly limit
|
|
312
|
+
const monthlyLimit = self.getLimit(name);
|
|
313
|
+
if (!monthlyLimit) {
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Calculate proportional allowance based on day of month
|
|
318
|
+
const now = new Date();
|
|
319
|
+
const dayOfMonth = now.getDate();
|
|
320
|
+
const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
|
|
321
|
+
|
|
322
|
+
// ceil ensures at least 1 usage per day even with very low limits
|
|
323
|
+
return Math.ceil(monthlyLimit * (dayOfMonth / daysInMonth));
|
|
324
|
+
};
|
|
325
|
+
|
|
278
326
|
Usage.prototype.update = function () {
|
|
279
327
|
const self = this;
|
|
280
328
|
|
package/src/manager/index.js
CHANGED
|
@@ -133,8 +133,6 @@ Manager.prototype.init = function (exporter, options) {
|
|
|
133
133
|
{},
|
|
134
134
|
requireJSON5(BEM_CONFIG_TEMPLATE_PATH, true),
|
|
135
135
|
requireJSON5(self.project.backendManagerConfigPath, true),
|
|
136
|
-
// Load RUNTIME_CONFIG from .env (deprecated, will be removed in future versions)
|
|
137
|
-
process.env.RUNTIME_CONFIG ? JSON5.parse(process.env.RUNTIME_CONFIG) : {},
|
|
138
136
|
);
|
|
139
137
|
|
|
140
138
|
// Resolve legacy paths
|
|
@@ -395,7 +393,7 @@ Manager.prototype._preProcess = function (mod) {
|
|
|
395
393
|
});
|
|
396
394
|
};
|
|
397
395
|
|
|
398
|
-
Manager.prototype._processMiddleware = function (req, res, routePath
|
|
396
|
+
Manager.prototype._processMiddleware = function (req, res, routePath) {
|
|
399
397
|
const self = this;
|
|
400
398
|
|
|
401
399
|
// Set paths for BEM internal routes/schemas
|
|
@@ -403,11 +401,11 @@ Manager.prototype._processMiddleware = function (req, res, routePath, options) {
|
|
|
403
401
|
const bemSchemasDir = path.resolve(__dirname, './schemas');
|
|
404
402
|
|
|
405
403
|
// Route directly through middleware (no hooks for new system)
|
|
406
|
-
return self.Middleware(req, res).run(routePath,
|
|
404
|
+
return self.Middleware(req, res).run(routePath, {
|
|
407
405
|
routesDir: bemRoutesDir,
|
|
408
406
|
schemasDir: bemSchemasDir,
|
|
409
407
|
schema: routePath,
|
|
410
|
-
}
|
|
408
|
+
});
|
|
411
409
|
};
|
|
412
410
|
|
|
413
411
|
// Manager.prototype.Assistant = function(ref, options) {
|
|
@@ -717,17 +715,6 @@ Manager.prototype.setupFunctions = function (exporter, options) {
|
|
|
717
715
|
self.assistant.log('Setting up Firebase functions...');
|
|
718
716
|
}
|
|
719
717
|
|
|
720
|
-
// Route-specific middleware overrides
|
|
721
|
-
// Routes listed here get custom middleware options (e.g., skip auth for webhooks)
|
|
722
|
-
const routeMiddlewareOverrides = {
|
|
723
|
-
'payments/webhook': {
|
|
724
|
-
authenticate: false,
|
|
725
|
-
setupUsage: false,
|
|
726
|
-
setupAnalytics: false,
|
|
727
|
-
includeNonSchemaSettings: true,
|
|
728
|
-
},
|
|
729
|
-
};
|
|
730
|
-
|
|
731
718
|
// Setup functions
|
|
732
719
|
exporter.bm_api =
|
|
733
720
|
self.libraries.functions
|
|
@@ -740,8 +727,7 @@ Manager.prototype.setupFunctions = function (exporter, options) {
|
|
|
740
727
|
return self._process((new (require(`${core}/actions/api.js`))()).init(self, { req, res }));
|
|
741
728
|
} else {
|
|
742
729
|
// New RESTful middleware system -> direct to middleware (no hooks)
|
|
743
|
-
|
|
744
|
-
return self._processMiddleware(req, res, route.routePath, overrides);
|
|
730
|
+
return self._processMiddleware(req, res, route.routePath);
|
|
745
731
|
}
|
|
746
732
|
});
|
|
747
733
|
|
|
@@ -944,7 +930,7 @@ Manager.prototype.setupFunctions = function (exporter, options) {
|
|
|
944
930
|
exporter.bm_cronDaily =
|
|
945
931
|
self.libraries.functions
|
|
946
932
|
.runWith({ memory: '256MB', timeoutSeconds: 60 * 5})
|
|
947
|
-
.pubsub.schedule('
|
|
933
|
+
.pubsub.schedule('0 0 * * *')
|
|
948
934
|
.onRun((context) => self.EventMiddleware({ context }).run(`${cron}/daily.js`));
|
|
949
935
|
};
|
|
950
936
|
|
|
@@ -76,7 +76,6 @@ const Stripe = {
|
|
|
76
76
|
expires: expires,
|
|
77
77
|
trial: trial,
|
|
78
78
|
cancellation: cancellation,
|
|
79
|
-
limits: {},
|
|
80
79
|
payment: {
|
|
81
80
|
processor: 'stripe',
|
|
82
81
|
resourceId: rawSubscription.id || null,
|
|
@@ -135,9 +134,10 @@ function resolveCancellation(raw) {
|
|
|
135
134
|
|
|
136
135
|
// Pending cancellation: active but set to cancel at period end
|
|
137
136
|
if (raw.cancel_at_period_end) {
|
|
137
|
+
const periodEnd = raw.current_period_end || raw.items?.data?.[0]?.current_period_end || 0;
|
|
138
138
|
const cancelAt = raw.cancel_at
|
|
139
139
|
? powertools.timestamp(new Date(raw.cancel_at * 1000), { output: 'string' })
|
|
140
|
-
: powertools.timestamp(new Date(
|
|
140
|
+
: powertools.timestamp(new Date(periodEnd * 1000), { output: 'string' });
|
|
141
141
|
|
|
142
142
|
return {
|
|
143
143
|
pending: true,
|
|
@@ -193,7 +193,7 @@ function resolveTrial(raw) {
|
|
|
193
193
|
}
|
|
194
194
|
|
|
195
195
|
return {
|
|
196
|
-
|
|
196
|
+
claimed: activated,
|
|
197
197
|
expires: trialExpires,
|
|
198
198
|
};
|
|
199
199
|
}
|
|
@@ -236,12 +236,12 @@ function resolveProduct(raw, config) {
|
|
|
236
236
|
|| raw.items?.data?.[0]?.price?.id
|
|
237
237
|
|| null;
|
|
238
238
|
|
|
239
|
-
if (!priceId || !config.products) {
|
|
239
|
+
if (!priceId || !config.payment?.products) {
|
|
240
240
|
return { id: 'basic', name: 'Basic' };
|
|
241
241
|
}
|
|
242
242
|
|
|
243
243
|
// Search through products for a matching price ID
|
|
244
|
-
for (const product of config.products) {
|
|
244
|
+
for (const product of config.payment.products) {
|
|
245
245
|
if (!product.prices) {
|
|
246
246
|
continue;
|
|
247
247
|
}
|
|
@@ -261,9 +261,13 @@ function resolveProduct(raw, config) {
|
|
|
261
261
|
* Resolve subscription expiration from Stripe data
|
|
262
262
|
*/
|
|
263
263
|
function resolveExpires(raw, oldDate, oldDateUNIX) {
|
|
264
|
-
//
|
|
265
|
-
const
|
|
266
|
-
|
|
264
|
+
// Stripe API 2025+ moves period dates to items.data[0]
|
|
265
|
+
const periodEndRaw = raw.current_period_end
|
|
266
|
+
|| raw.items?.data?.[0]?.current_period_end
|
|
267
|
+
|| 0;
|
|
268
|
+
|
|
269
|
+
const periodEnd = periodEndRaw
|
|
270
|
+
? powertools.timestamp(new Date(periodEndRaw * 1000), { output: 'string' })
|
|
267
271
|
: oldDate;
|
|
268
272
|
|
|
269
273
|
return {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const Stripe = require('./stripe.js');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Test processor library
|
|
5
|
+
* Delegates to Stripe's toUnified() since test processor generates Stripe-shaped data
|
|
6
|
+
* Stamps processor as 'test' to distinguish from real Stripe subscriptions
|
|
7
|
+
*/
|
|
8
|
+
const Test = {
|
|
9
|
+
/**
|
|
10
|
+
* No-op init — test processor doesn't need an external SDK
|
|
11
|
+
*/
|
|
12
|
+
init() {
|
|
13
|
+
return null;
|
|
14
|
+
},
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Transform raw subscription into unified shape
|
|
18
|
+
* Delegates to Stripe's toUnified (same data shape), stamps processor as 'test'
|
|
19
|
+
*/
|
|
20
|
+
toUnified(rawSubscription, options) {
|
|
21
|
+
const unified = Stripe.toUnified(rawSubscription, options);
|
|
22
|
+
unified.payment.processor = 'test';
|
|
23
|
+
return unified;
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
module.exports = Test;
|
|
@@ -26,14 +26,17 @@ function buildPublicConfig(config) {
|
|
|
26
26
|
},
|
|
27
27
|
reviews: config.reviews || {},
|
|
28
28
|
firebaseConfig: config.firebaseConfig || {},
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
29
|
+
payment: {
|
|
30
|
+
processors: config.payment?.processors || {},
|
|
31
|
+
products: (config.payment?.products || []).map(p => ({
|
|
32
|
+
id: p.id,
|
|
33
|
+
name: p.name,
|
|
34
|
+
type: p.type,
|
|
35
|
+
limits: p.limits || {},
|
|
36
|
+
trial: p.trial || {},
|
|
37
|
+
prices: p.prices || {},
|
|
38
|
+
})),
|
|
39
|
+
},
|
|
37
40
|
};
|
|
38
41
|
}
|
|
39
42
|
|
|
@@ -18,51 +18,66 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
|
|
|
18
18
|
const processor = settings.processor;
|
|
19
19
|
const productId = settings.productId;
|
|
20
20
|
const frequency = settings.frequency;
|
|
21
|
-
|
|
21
|
+
let trial = settings.trial;
|
|
22
|
+
|
|
23
|
+
assistant.log(`Intent request: uid=${uid}, processor=${processor}, product=${productId}, frequency=${frequency}, trial=${trial}`);
|
|
22
24
|
|
|
23
25
|
// Check if user already has an active non-basic subscription
|
|
24
26
|
if (user.subscription?.status === 'active' && user.subscription?.product?.id !== 'basic') {
|
|
27
|
+
assistant.log(`User ${uid} already has active subscription: product=${user.subscription.product.id}, status=${user.subscription.status}, resourceId=${user.subscription.payment?.resourceId}`);
|
|
25
28
|
return assistant.respond('User already has an active subscription', { code: 400 });
|
|
26
29
|
}
|
|
27
30
|
|
|
28
|
-
//
|
|
29
|
-
if (trial
|
|
30
|
-
|
|
31
|
+
// Resolve trial eligibility: if requested but user has subscription history, silently downgrade
|
|
32
|
+
if (trial) {
|
|
33
|
+
const historySnapshot = await admin.firestore()
|
|
34
|
+
.collection('payments-subscriptions')
|
|
35
|
+
.where('uid', '==', uid)
|
|
36
|
+
.limit(1)
|
|
37
|
+
.get();
|
|
38
|
+
|
|
39
|
+
if (!historySnapshot.empty) {
|
|
40
|
+
assistant.log(`User ${uid} not eligible for trial (has subscription history), continuing without trial`);
|
|
41
|
+
trial = false;
|
|
42
|
+
}
|
|
31
43
|
}
|
|
32
44
|
|
|
33
45
|
// Validate product exists in config
|
|
34
|
-
const product = (Manager.config.products || []).find(p => p.id === productId);
|
|
46
|
+
const product = (Manager.config.payment?.products || []).find(p => p.id === productId);
|
|
35
47
|
if (!product) {
|
|
48
|
+
assistant.log(`Product "${productId}" not found (available: ${(Manager.config.payment?.products || []).map(p => p.id).join(', ')})`);
|
|
36
49
|
return assistant.respond(`Product '${productId}' not found`, { code: 400 });
|
|
37
50
|
}
|
|
38
51
|
|
|
39
|
-
|
|
40
|
-
|
|
52
|
+
assistant.log(`Product resolved: id=${product.id}, name=${product.name}, trialDays=${product.trial?.days || 'none'}`);
|
|
53
|
+
|
|
54
|
+
// Load the processor module
|
|
55
|
+
let processorModule;
|
|
41
56
|
try {
|
|
42
|
-
|
|
57
|
+
processorModule = require(path.resolve(__dirname, `processors/${processor}.js`));
|
|
43
58
|
} catch (e) {
|
|
44
59
|
return assistant.respond(`Unknown processor: ${processor}`, { code: 400 });
|
|
45
60
|
}
|
|
46
61
|
|
|
47
|
-
//
|
|
48
|
-
const StripeLib = require('../../../libraries/stripe.js');
|
|
49
|
-
const stripe = StripeLib.init();
|
|
50
|
-
|
|
51
|
-
// Create the intent via the provider
|
|
62
|
+
// Create the intent via the processor
|
|
52
63
|
let result;
|
|
53
64
|
try {
|
|
54
|
-
result = await
|
|
65
|
+
result = await processorModule.createIntent({
|
|
55
66
|
uid,
|
|
56
67
|
productId,
|
|
57
68
|
frequency,
|
|
58
69
|
trial,
|
|
59
70
|
config: Manager.config,
|
|
60
|
-
|
|
71
|
+
Manager,
|
|
72
|
+
assistant,
|
|
61
73
|
});
|
|
62
74
|
} catch (e) {
|
|
75
|
+
assistant.log(`Failed to create ${processor} intent: ${e.message}`);
|
|
63
76
|
return assistant.respond(`Failed to create intent: ${e.message}`, { code: 500, sentry: true });
|
|
64
77
|
}
|
|
65
78
|
|
|
79
|
+
assistant.log(`${processor} intent created: id=${result.id}, url=${result.url}`);
|
|
80
|
+
|
|
66
81
|
// Build timestamps
|
|
67
82
|
const now = powertools.timestamp(new Date(), { output: 'string' });
|
|
68
83
|
const nowUNIX = powertools.timestamp(now, { output: 'unix' });
|
|
@@ -85,7 +100,7 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
|
|
|
85
100
|
},
|
|
86
101
|
});
|
|
87
102
|
|
|
88
|
-
assistant.log(`
|
|
103
|
+
assistant.log(`Saved payments-intents/${result.id}: uid=${uid}, product=${productId}, frequency=${frequency}, trial=${trial}`);
|
|
89
104
|
|
|
90
105
|
return assistant.respond({
|
|
91
106
|
id: result.id,
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stripe intent processor
|
|
3
|
+
* Creates Stripe Checkout Sessions for subscription purchases
|
|
4
|
+
*/
|
|
5
|
+
module.exports = {
|
|
6
|
+
/**
|
|
7
|
+
* Create a Stripe Checkout Session for a subscription
|
|
8
|
+
*
|
|
9
|
+
* @param {object} options
|
|
10
|
+
* @param {string} options.uid - User's UID
|
|
11
|
+
* @param {string} options.productId - Product ID from config (e.g., 'premium')
|
|
12
|
+
* @param {string} options.frequency - 'monthly' or 'annually'
|
|
13
|
+
* @param {boolean} options.trial - Whether to include a trial period
|
|
14
|
+
* @param {object} options.config - BEM config (must contain products array)
|
|
15
|
+
* @param {object} options.Manager - Manager instance
|
|
16
|
+
* @returns {object} { id, url, raw }
|
|
17
|
+
*/
|
|
18
|
+
async createIntent({ uid, productId, frequency, trial, config, Manager, assistant }) {
|
|
19
|
+
// Initialize Stripe SDK
|
|
20
|
+
const StripeLib = require('../../../../libraries/stripe.js');
|
|
21
|
+
const stripe = StripeLib.init();
|
|
22
|
+
|
|
23
|
+
// Find the product in config
|
|
24
|
+
const product = (config.payment?.products || []).find(p => p.id === productId);
|
|
25
|
+
if (!product) {
|
|
26
|
+
throw new Error(`Product '${productId}' not found in config`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Get the Stripe price ID for the requested frequency
|
|
30
|
+
const priceId = product.prices?.[frequency]?.stripe;
|
|
31
|
+
if (!priceId) {
|
|
32
|
+
throw new Error(`No Stripe price found for ${productId}/${frequency}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Resolve or create Stripe customer (keyed by uid in metadata)
|
|
36
|
+
const email = assistant?.getUser()?.auth?.email || null;
|
|
37
|
+
const customer = await resolveCustomer(stripe, uid, email, assistant);
|
|
38
|
+
|
|
39
|
+
assistant?.log(`Stripe checkout: priceId=${priceId}, uid=${uid}, customerId=${customer.id}, trial=${trial}, trialDays=${product.trial?.days || 'none'}`);
|
|
40
|
+
|
|
41
|
+
// Build confirmation redirect URL with order details
|
|
42
|
+
const baseUrl = config.brand?.url;
|
|
43
|
+
const amount = product.prices?.[frequency]?.amount || 0;
|
|
44
|
+
|
|
45
|
+
const confirmationUrl = new URL('/payment/confirmation', baseUrl);
|
|
46
|
+
confirmationUrl.searchParams.set('orderId', '{CHECKOUT_SESSION_ID}');
|
|
47
|
+
confirmationUrl.searchParams.set('productId', productId);
|
|
48
|
+
confirmationUrl.searchParams.set('productName', product.name || productId);
|
|
49
|
+
confirmationUrl.searchParams.set('amount', trial && product.trial?.days ? '0' : String(amount));
|
|
50
|
+
confirmationUrl.searchParams.set('currency', 'USD');
|
|
51
|
+
confirmationUrl.searchParams.set('frequency', frequency);
|
|
52
|
+
confirmationUrl.searchParams.set('paymentMethod', 'stripe');
|
|
53
|
+
confirmationUrl.searchParams.set('trial', String(!!trial && !!product.trial?.days));
|
|
54
|
+
confirmationUrl.searchParams.set('track', 'true');
|
|
55
|
+
|
|
56
|
+
const cancelUrl = new URL('/payment/checkout', baseUrl);
|
|
57
|
+
cancelUrl.searchParams.set('product', productId);
|
|
58
|
+
cancelUrl.searchParams.set('frequency', frequency);
|
|
59
|
+
cancelUrl.searchParams.set('payment', 'cancelled');
|
|
60
|
+
|
|
61
|
+
// Build session params
|
|
62
|
+
const sessionParams = {
|
|
63
|
+
mode: 'subscription',
|
|
64
|
+
customer: customer.id,
|
|
65
|
+
line_items: [{
|
|
66
|
+
price: priceId,
|
|
67
|
+
quantity: 1,
|
|
68
|
+
}],
|
|
69
|
+
subscription_data: {
|
|
70
|
+
metadata: {
|
|
71
|
+
uid: uid,
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
success_url: confirmationUrl.toString(),
|
|
75
|
+
cancel_url: cancelUrl.toString(),
|
|
76
|
+
metadata: {
|
|
77
|
+
uid: uid,
|
|
78
|
+
productId: productId,
|
|
79
|
+
frequency: frequency,
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// Add trial period if requested
|
|
84
|
+
if (trial && product.trial?.days) {
|
|
85
|
+
sessionParams.subscription_data.trial_period_days = product.trial.days;
|
|
86
|
+
assistant?.log(`Trial period added: ${product.trial.days} days`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Create the checkout session
|
|
90
|
+
const session = await stripe.checkout.sessions.create(sessionParams);
|
|
91
|
+
|
|
92
|
+
assistant?.log(`Stripe session created: sessionId=${session.id}, url=${session.url}`);
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
id: session.id,
|
|
96
|
+
url: session.url,
|
|
97
|
+
raw: session,
|
|
98
|
+
};
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Find an existing Stripe customer by uid metadata, or create one
|
|
104
|
+
*/
|
|
105
|
+
async function resolveCustomer(stripe, uid, email, assistant) {
|
|
106
|
+
// Search for existing customer with this uid
|
|
107
|
+
const search = await stripe.customers.search({
|
|
108
|
+
query: `metadata['uid']:'${uid}'`,
|
|
109
|
+
limit: 1,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
if (search.data.length > 0) {
|
|
113
|
+
const existing = search.data[0];
|
|
114
|
+
assistant?.log(`Found existing Stripe customer: ${existing.id}`);
|
|
115
|
+
return existing;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Create new customer
|
|
119
|
+
const params = {
|
|
120
|
+
metadata: { uid },
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
if (email) {
|
|
124
|
+
params.email = email;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const customer = await stripe.customers.create(params);
|
|
128
|
+
assistant?.log(`Created new Stripe customer: ${customer.id}`);
|
|
129
|
+
return customer;
|
|
130
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
const fetch = require('wonderful-fetch');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Test intent processor
|
|
5
|
+
* Creates fake Stripe-shaped checkout sessions and auto-fires webhooks
|
|
6
|
+
* Only available in non-production environments
|
|
7
|
+
*/
|
|
8
|
+
module.exports = {
|
|
9
|
+
/**
|
|
10
|
+
* Create a test payment intent
|
|
11
|
+
* Generates Stripe-shaped data and auto-fires a webhook to trigger the full pipeline
|
|
12
|
+
*
|
|
13
|
+
* @param {object} options
|
|
14
|
+
* @param {string} options.uid - User's UID
|
|
15
|
+
* @param {string} options.productId - Product ID from config
|
|
16
|
+
* @param {string} options.frequency - 'monthly' or 'annually'
|
|
17
|
+
* @param {boolean} options.trial - Whether to include a trial period
|
|
18
|
+
* @param {object} options.config - BEM config
|
|
19
|
+
* @param {object} options.Manager - Manager instance
|
|
20
|
+
* @param {object} options.assistant - Assistant instance
|
|
21
|
+
* @returns {object} { id, url, raw }
|
|
22
|
+
*/
|
|
23
|
+
async createIntent({ uid, productId, frequency, trial, config, Manager, assistant }) {
|
|
24
|
+
// Guard: test processor is not available in production
|
|
25
|
+
if (assistant.isProduction()) {
|
|
26
|
+
throw new Error('Test processor is not available in production');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Find the product in config
|
|
30
|
+
const product = (config.payment?.products || []).find(p => p.id === productId);
|
|
31
|
+
if (!product) {
|
|
32
|
+
throw new Error(`Product '${productId}' not found in config`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Get the price ID for the requested frequency (needed for product resolution in toUnified)
|
|
36
|
+
const priceId = product.prices?.[frequency]?.stripe;
|
|
37
|
+
if (!priceId) {
|
|
38
|
+
throw new Error(`No Stripe price found for ${productId}/${frequency}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Generate IDs
|
|
42
|
+
const timestamp = Date.now();
|
|
43
|
+
const sessionId = `_test-cs-${timestamp}`;
|
|
44
|
+
const subscriptionId = `_test-sub-${timestamp}`;
|
|
45
|
+
const eventId = `_test-evt-${timestamp}`;
|
|
46
|
+
|
|
47
|
+
// Map frequency to Stripe interval
|
|
48
|
+
const interval = frequency === 'annually' ? 'year' : 'month';
|
|
49
|
+
|
|
50
|
+
// Build timestamps
|
|
51
|
+
const now = Math.floor(timestamp / 1000);
|
|
52
|
+
const periodEnd = frequency === 'annually'
|
|
53
|
+
? now + (365 * 86400)
|
|
54
|
+
: now + (30 * 86400);
|
|
55
|
+
|
|
56
|
+
// Build Stripe-shaped subscription object
|
|
57
|
+
const subscription = {
|
|
58
|
+
id: subscriptionId,
|
|
59
|
+
object: 'subscription',
|
|
60
|
+
status: trial && product.trial?.days ? 'trialing' : 'active',
|
|
61
|
+
metadata: { uid },
|
|
62
|
+
plan: { id: priceId, interval },
|
|
63
|
+
current_period_end: periodEnd,
|
|
64
|
+
current_period_start: now,
|
|
65
|
+
start_date: now,
|
|
66
|
+
cancel_at_period_end: false,
|
|
67
|
+
cancel_at: null,
|
|
68
|
+
canceled_at: null,
|
|
69
|
+
trial_start: null,
|
|
70
|
+
trial_end: null,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// Add trial dates if applicable
|
|
74
|
+
if (trial && product.trial?.days) {
|
|
75
|
+
subscription.trial_start = now;
|
|
76
|
+
subscription.trial_end = now + (product.trial.days * 86400);
|
|
77
|
+
subscription.current_period_end = subscription.trial_end;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Build Stripe-shaped event
|
|
81
|
+
const event = {
|
|
82
|
+
id: eventId,
|
|
83
|
+
type: 'customer.subscription.created',
|
|
84
|
+
data: { object: subscription },
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
assistant?.log(`Test intent: sessionId=${sessionId}, subscriptionId=${subscriptionId}, eventId=${eventId}, trial=${!!subscription.trial_start}`);
|
|
88
|
+
|
|
89
|
+
// Auto-fire webhook (fire-and-forget — don't block intent response)
|
|
90
|
+
const webhookUrl = `${Manager.project.apiUrl}/backend-manager/payments/webhook?processor=test&key=${process.env.BACKEND_MANAGER_KEY}`;
|
|
91
|
+
fetch(webhookUrl, {
|
|
92
|
+
method: 'POST',
|
|
93
|
+
response: 'json',
|
|
94
|
+
body: event,
|
|
95
|
+
timeout: 15000,
|
|
96
|
+
}).catch((e) => {
|
|
97
|
+
assistant?.log(`Test processor auto-webhook failed: ${e.message}`);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
id: sessionId,
|
|
102
|
+
url: `${config.brand?.url || 'https://example.com'}/payment/confirmation?session=${sessionId}`,
|
|
103
|
+
raw: { id: sessionId, object: 'checkout.session', subscription: subscriptionId },
|
|
104
|
+
};
|
|
105
|
+
},
|
|
106
|
+
};
|