backend-manager 5.0.83 → 5.0.85

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/middleware.js +1 -1
  17. package/src/manager/helpers/usage.js +51 -3
  18. package/src/manager/index.js +5 -19
  19. package/src/manager/libraries/stripe.js +12 -8
  20. package/src/manager/libraries/test.js +27 -0
  21. package/src/manager/routes/app/get.js +11 -8
  22. package/src/manager/routes/payments/intent/post.js +31 -16
  23. package/src/manager/routes/payments/intent/{providers → processors}/stripe.js +48 -4
  24. package/src/manager/routes/payments/intent/processors/test.js +106 -0
  25. package/src/manager/routes/payments/webhook/post.js +21 -8
  26. package/src/manager/routes/payments/webhook/{providers → processors}/stripe.js +16 -1
  27. package/src/manager/routes/payments/webhook/processors/test.js +15 -0
  28. package/src/manager/routes/user/subscription/get.js +1 -1
  29. package/src/manager/schemas/payments/webhook/post.js +1 -1
  30. package/src/test/test-accounts.js +18 -18
  31. package/templates/_.env +0 -2
  32. package/templates/backend-manager-config.json +50 -34
  33. package/test/events/payments/journey-payments-cancel.js +144 -0
  34. package/test/events/payments/journey-payments-suspend.js +143 -0
  35. package/test/events/payments/journey-payments-trial.js +120 -0
  36. package/test/events/payments/journey-payments-upgrade.js +99 -0
  37. package/test/fixtures/stripe/subscription-active.json +161 -0
  38. package/test/fixtures/stripe/subscription-canceled.json +161 -0
  39. package/test/fixtures/stripe/subscription-trialing.json +161 -0
  40. package/test/functions/user/get-subscription-info.js +2 -2
  41. package/test/helpers/stripe-to-unified.js +684 -0
  42. package/test/routes/payments/intent.js +189 -0
  43. package/test/{payments → routes/payments}/webhook.js +1 -1
  44. package/test/routes/test/usage.js +7 -6
  45. package/test/routes/user/subscription.js +2 -2
  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) {
@@ -220,7 +220,7 @@ function clean(obj) {
220
220
  function stripUrl(url) {
221
221
  const newUrl = new URL(url);
222
222
 
223
- return `${newUrl.hostname}${newUrl.pathname}`.replace(/\/$/, '');
223
+ return `${newUrl.host}${newUrl.pathname}`.replace(/\/$/, '');
224
224
  }
225
225
 
226
226
  // Helper to safely stringify objects by truncating long strings (like base64)
@@ -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,
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Stripe intent provider
2
+ * Stripe intent processor
3
3
  * Creates Stripe Checkout Sessions for subscription purchases
4
4
  */
5
5
  module.exports = {
@@ -12,12 +12,16 @@ module.exports = {
12
12
  * @param {string} options.frequency - 'monthly' or 'annually'
13
13
  * @param {boolean} options.trial - Whether to include a trial period
14
14
  * @param {object} options.config - BEM config (must contain products array)
15
- * @param {object} options.stripe - Initialized Stripe SDK instance
15
+ * @param {object} options.Manager - Manager instance
16
16
  * @returns {object} { id, url, raw }
17
17
  */
18
- async createIntent({ uid, productId, frequency, trial, config, stripe }) {
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
+
19
23
  // Find the product in config
20
- const product = (config.products || []).find(p => p.id === productId);
24
+ const product = (config.payment?.products || []).find(p => p.id === productId);
21
25
  if (!product) {
22
26
  throw new Error(`Product '${productId}' not found in config`);
23
27
  }
@@ -28,9 +32,16 @@ module.exports = {
28
32
  throw new Error(`No Stripe price found for ${productId}/${frequency}`);
29
33
  }
30
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
+
31
41
  // Build session params
32
42
  const sessionParams = {
33
43
  mode: 'subscription',
44
+ customer: customer.id,
34
45
  line_items: [{
35
46
  price: priceId,
36
47
  quantity: 1,
@@ -52,11 +63,14 @@ module.exports = {
52
63
  // Add trial period if requested
53
64
  if (trial && product.trial?.days) {
54
65
  sessionParams.subscription_data.trial_period_days = product.trial.days;
66
+ assistant?.log(`Trial period added: ${product.trial.days} days`);
55
67
  }
56
68
 
57
69
  // Create the checkout session
58
70
  const session = await stripe.checkout.sessions.create(sessionParams);
59
71
 
72
+ assistant?.log(`Stripe session created: sessionId=${session.id}, url=${session.url}`);
73
+
60
74
  return {
61
75
  id: session.id,
62
76
  url: session.url,
@@ -64,3 +78,33 @@ module.exports = {
64
78
  };
65
79
  },
66
80
  };
81
+
82
+ /**
83
+ * Find an existing Stripe customer by uid metadata, or create one
84
+ */
85
+ async function resolveCustomer(stripe, uid, email, assistant) {
86
+ // Search for existing customer with this uid
87
+ const search = await stripe.customers.search({
88
+ query: `metadata['uid']:'${uid}'`,
89
+ limit: 1,
90
+ });
91
+
92
+ if (search.data.length > 0) {
93
+ const existing = search.data[0];
94
+ assistant?.log(`Found existing Stripe customer: ${existing.id}`);
95
+ return existing;
96
+ }
97
+
98
+ // Create new customer
99
+ const params = {
100
+ metadata: { uid },
101
+ };
102
+
103
+ if (email) {
104
+ params.email = email;
105
+ }
106
+
107
+ const customer = await stripe.customers.create(params);
108
+ assistant?.log(`Created new Stripe customer: ${customer.id}`);
109
+ return customer;
110
+ }
@@ -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
+ };
@@ -3,8 +3,12 @@ const powertools = require('node-powertools');
3
3
 
4
4
  /**
5
5
  * POST /payments/webhook?processor=stripe&key=XXX
6
- * Receives payment provider webhooks, validates them, and saves to Firestore
6
+ * Receives payment processor webhooks, validates them, and saves to Firestore
7
7
  * The Firestore onWrite trigger handles async processing
8
+ *
9
+ * This handler is processor-agnostic. Each processor module defines:
10
+ * - parseWebhook(req) — extracts { eventId, eventType, raw, uid }
11
+ * - isSupported(eventType) — returns true for events we should process
8
12
  */
9
13
  module.exports = async ({ assistant, Manager, libraries }) => {
10
14
  const { admin } = libraries;
@@ -25,32 +29,41 @@ module.exports = async ({ assistant, Manager, libraries }) => {
25
29
  return assistant.respond('Invalid key', { code: 401 });
26
30
  }
27
31
 
28
- // Load the provider
29
- let provider;
32
+ // Load the processor module
33
+ let processorModule;
30
34
  try {
31
- provider = require(path.resolve(__dirname, `providers/${processor}.js`));
35
+ processorModule = require(path.resolve(__dirname, `processors/${processor}.js`));
32
36
  } catch (e) {
33
37
  return assistant.respond(`Unknown processor: ${processor}`, { code: 400 });
34
38
  }
35
39
 
36
- // Parse the webhook using the provider
40
+ // Parse the webhook using the processor
37
41
  let parsed;
38
42
  try {
39
- parsed = provider.parseWebhook(assistant.ref.req);
43
+ parsed = processorModule.parseWebhook(assistant.ref.req);
40
44
  } catch (e) {
41
45
  return assistant.respond(`Failed to parse webhook: ${e.message}`, { code: 400 });
42
46
  }
43
47
 
44
48
  const { eventId, eventType, raw, uid } = parsed;
45
49
 
50
+ assistant.log(`Parsed webhook: eventId=${eventId}, eventType=${eventType}, uid=${uid || 'null'}`);
51
+
52
+ // Let the processor decide if this event type is relevant
53
+ if (processorModule.isSupported && !processorModule.isSupported(eventType)) {
54
+ assistant.log(`Ignoring event type: ${eventType}`);
55
+ return assistant.respond({ received: true, ignored: true });
56
+ }
57
+
46
58
  // Check for duplicate (skip if already processing/completed)
47
59
  const existingDoc = await admin.firestore().doc(`payments-webhooks/${eventId}`).get();
48
60
  if (existingDoc.exists) {
49
61
  const existingStatus = existingDoc.data()?.status;
50
62
  if (existingStatus !== 'failed') {
51
- assistant.log(`Webhook ${eventId} already exists with status=${existingStatus}, skipping`);
63
+ assistant.log(`Duplicate webhook ${eventId}, existing status=${existingStatus}, skipping`);
52
64
  return assistant.respond({ received: true, duplicate: true });
53
65
  }
66
+ assistant.log(`Retrying previously failed webhook ${eventId}`);
54
67
  }
55
68
 
56
69
  // Build timestamps
@@ -80,7 +93,7 @@ module.exports = async ({ assistant, Manager, libraries }) => {
80
93
  },
81
94
  });
82
95
 
83
- assistant.log(`Webhook ${eventId} saved (type=${eventType}, processor=${processor}, uid=${uid})`);
96
+ assistant.log(`Saved payments-webhooks/${eventId}: eventType=${eventType}, processor=${processor}, uid=${uid}`);
84
97
 
85
98
  // Return 200 immediately
86
99
  return assistant.respond({ received: true });