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.
Files changed (50) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/CLAUDE.md +66 -3
  3. package/README.md +7 -5
  4. package/package.json +5 -4
  5. package/src/cli/commands/base-command.js +89 -0
  6. package/src/cli/commands/emulators.js +3 -0
  7. package/src/cli/commands/serve.js +5 -1
  8. package/src/cli/commands/stripe.js +14 -0
  9. package/src/cli/commands/test.js +11 -6
  10. package/src/cli/index.js +7 -0
  11. package/src/manager/cron/daily/reset-usage.js +56 -34
  12. package/src/manager/events/firestore/payments-webhooks/on-write.js +15 -13
  13. package/src/manager/functions/core/actions/api/user/get-subscription-info.js +1 -1
  14. package/src/manager/helpers/analytics.js +2 -2
  15. package/src/manager/helpers/api-manager.js +1 -1
  16. package/src/manager/helpers/usage.js +51 -3
  17. package/src/manager/index.js +5 -19
  18. package/src/manager/libraries/stripe.js +12 -8
  19. package/src/manager/libraries/test.js +27 -0
  20. package/src/manager/routes/app/get.js +11 -8
  21. package/src/manager/routes/payments/intent/post.js +31 -16
  22. package/src/manager/routes/payments/intent/processors/stripe.js +130 -0
  23. package/src/manager/routes/payments/intent/processors/test.js +106 -0
  24. package/src/manager/routes/payments/webhook/post.js +21 -8
  25. package/src/manager/routes/payments/webhook/{providers → processors}/stripe.js +16 -1
  26. package/src/manager/routes/payments/webhook/processors/test.js +15 -0
  27. package/src/manager/routes/user/subscription/get.js +1 -1
  28. package/src/manager/schemas/payments/webhook/post.js +1 -1
  29. package/src/test/test-accounts.js +18 -18
  30. package/templates/_.env +0 -2
  31. package/templates/backend-manager-config.json +50 -34
  32. package/test/events/payments/journey-payments-cancel.js +144 -0
  33. package/test/events/payments/journey-payments-suspend.js +143 -0
  34. package/test/events/payments/journey-payments-trial.js +120 -0
  35. package/test/events/payments/journey-payments-upgrade.js +99 -0
  36. package/test/fixtures/stripe/subscription-active.json +161 -0
  37. package/test/fixtures/stripe/subscription-canceled.json +161 -0
  38. package/test/fixtures/stripe/subscription-trialing.json +161 -0
  39. package/test/functions/user/get-subscription-info.js +2 -2
  40. package/test/helpers/stripe-to-unified.js +684 -0
  41. package/test/routes/payments/intent.js +189 -0
  42. package/test/{payments → routes/payments}/webhook.js +1 -1
  43. package/test/routes/test/usage.js +7 -6
  44. package/test/routes/user/subscription.js +2 -2
  45. package/src/manager/routes/payments/intent/providers/stripe.js +0 -66
  46. package/test/payments/intent.js +0 -104
  47. package/test/payments/journey-payment-cancel.js +0 -166
  48. package/test/payments/journey-payment-suspend.js +0 -162
  49. package/test/payments/journey-payment-trial.js +0 -167
  50. 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
- activated: user?.subscription?.trial?.activated ?? false,
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
- subscription_trial_activated: {
117
- value: authUser?.subscription?.trial?.activated || false,
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
- // If they are under the limit, resolve
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
 
@@ -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, options) {
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, Object.assign({
404
+ return self.Middleware(req, res).run(routePath, {
407
405
  routesDir: bemRoutesDir,
408
406
  schemasDir: bemSchemasDir,
409
407
  schema: routePath,
410
- }, options || {}));
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
- const overrides = routeMiddlewareOverrides[route.routePath];
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('every 24 hours')
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((raw.current_period_end || 0) * 1000), { output: 'string' });
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
- activated: activated,
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
- // For active/trialing subscriptions, use current_period_end
265
- const periodEnd = raw.current_period_end
266
- ? powertools.timestamp(new Date(raw.current_period_end * 1000), { output: 'string' })
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
- products: (config.products || []).map(p => ({
30
- id: p.id,
31
- name: p.name,
32
- type: p.type,
33
- limits: p.limits || {},
34
- trial: p.trial || {},
35
- prices: p.prices || {},
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
- const trial = settings.trial;
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
- // Check trial eligibility
29
- if (trial && user.subscription?.trial?.activated) {
30
- return assistant.respond('Trial already used', { code: 400 });
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
- // Load the provider
40
- let provider;
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
- provider = require(path.resolve(__dirname, `providers/${processor}.js`));
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
- // Initialize the processor SDK
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 provider.createIntent({
65
+ result = await processorModule.createIntent({
55
66
  uid,
56
67
  productId,
57
68
  frequency,
58
69
  trial,
59
70
  config: Manager.config,
60
- stripe,
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(`Intent ${result.id} created for user ${uid} (product=${productId}, frequency=${frequency}, trial=${trial})`);
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
+ };