backend-manager 5.0.72 → 5.0.74

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/CLAUDE.md +70 -0
  2. package/README.md +83 -9
  3. package/package.json +1 -1
  4. package/src/manager/cron/daily/reset-usage.js +5 -32
  5. package/src/manager/events/firestore/payments-webhooks/on-write.js +126 -0
  6. package/src/manager/functions/core/actions/api/admin/get-stats.js +3 -3
  7. package/src/manager/functions/core/actions/api/general/add-marketing-contact.js +1 -1
  8. package/src/manager/functions/core/actions/api/user/delete.js +5 -3
  9. package/src/manager/functions/core/actions/api/user/get-subscription-info.js +25 -9
  10. package/src/manager/functions/core/actions/api/user/validate-settings.js +1 -1
  11. package/src/manager/helpers/analytics.js +4 -4
  12. package/src/manager/helpers/api-manager.js +25 -42
  13. package/src/manager/helpers/middleware.js +2 -2
  14. package/src/manager/helpers/usage.js +24 -93
  15. package/src/manager/helpers/user.js +29 -38
  16. package/src/manager/index.js +22 -10
  17. package/src/manager/libraries/stripe.js +293 -0
  18. package/src/manager/routes/admin/stats/get.js +3 -3
  19. package/src/manager/routes/marketing/contact/post.js +1 -1
  20. package/src/manager/routes/payments/intent/post.js +94 -0
  21. package/src/manager/routes/payments/intent/providers/stripe.js +66 -0
  22. package/src/manager/routes/payments/webhook/post.js +87 -0
  23. package/src/manager/routes/payments/webhook/providers/stripe.js +35 -0
  24. package/src/manager/routes/test/schema/post.js +5 -5
  25. package/src/manager/routes/user/delete.js +5 -3
  26. package/src/manager/routes/user/settings/validate/post.js +3 -3
  27. package/src/manager/routes/user/subscription/get.js +25 -9
  28. package/src/manager/schemas/payments/intent/post.js +22 -0
  29. package/src/manager/schemas/payments/webhook/post.js +6 -0
  30. package/src/manager/schemas/test/schema/post.js +1 -1
  31. package/src/test/test-accounts.js +63 -25
  32. package/src/test/utils/firestore-rules-client.js +5 -5
  33. package/templates/backend-manager-config.json +32 -0
  34. package/templates/firestore.rules +1 -1
  35. package/test/_init/accounts-validation.js +3 -3
  36. package/test/functions/user/delete.js +1 -1
  37. package/test/functions/user/get-subscription-info.js +18 -24
  38. package/test/payments/intent.js +104 -0
  39. package/test/payments/journey-payment-cancel.js +166 -0
  40. package/test/payments/journey-payment-suspend.js +162 -0
  41. package/test/payments/journey-payment-trial.js +167 -0
  42. package/test/payments/journey-payment-upgrade.js +136 -0
  43. package/test/payments/webhook.js +128 -0
  44. package/test/routes/test/schema.js +1 -1
  45. package/test/routes/user/delete.js +1 -1
  46. package/test/routes/user/subscription.js +18 -24
  47. package/test/routes/user/user.js +14 -14
  48. package/test/rules/user.js +8 -8
  49. package/src/manager/helpers/subscription-resolver-new.js +0 -827
  50. package/src/manager/helpers/subscription-resolver.js +0 -841
@@ -1,11 +1,10 @@
1
1
  /**
2
2
  * Usage
3
3
  * Meant to check and update usage for a user
4
- * Uses the ITWCW apps/{app}/products/{product}/limits/{metric} to check limits
4
+ * Reads product limits from Manager.config.products
5
5
  * Stores usage in the user's firestore document OR in local/temp storage if no user
6
6
  */
7
7
 
8
- const fetch = require('wonderful-fetch');
9
8
  const moment = require('moment');
10
9
  const _ = require('lodash');
11
10
  const hcaptcha = require('hcaptcha');
@@ -16,14 +15,12 @@ function Usage(m) {
16
15
  self.Manager = m;
17
16
 
18
17
  self.user = null;
19
- self.app = null;
20
18
  self.options = null;
21
19
  self.assistant = null;
22
20
  self.storage = null;
23
21
 
24
22
  self.paths = {
25
23
  user: '',
26
- app: '',
27
24
  }
28
25
 
29
26
  self.initialized = false;
@@ -37,8 +34,6 @@ Usage.prototype.init = function (assistant, options) {
37
34
 
38
35
  // Set options
39
36
  options = options || {};
40
- options.app = options.app || Manager.config.app.id;
41
- options.refetch = typeof options.refetch === 'undefined' ? false : options.refetch;
42
37
  options.clear = typeof options.clear === 'undefined' ? false : options.clear;
43
38
  options.today = typeof options.today === 'undefined' ? undefined : options.today;
44
39
  options.key = typeof options.key === 'undefined' ? undefined : options.key;
@@ -60,7 +55,7 @@ Usage.prototype.init = function (assistant, options) {
60
55
  // Set assistant
61
56
  self.assistant = assistant;
62
57
 
63
- // Setup storage
58
+ // Setup storage (used for unauthenticated local-mode usage tracking)
64
59
  self.storage = Manager.storage({name: 'usage', temporary: true, clear: options.clear, log: options.log});
65
60
 
66
61
  // Set local key
@@ -69,11 +64,6 @@ Usage.prototype.init = function (assistant, options) {
69
64
 
70
65
  // Set paths
71
66
  self.paths.user = `users.${self.key}`;
72
- self.paths.app = `apps.${options.app}`;
73
-
74
- // Get storage data
75
- const appLastFetched = moment(self.storage.get(`${self.paths.app}.lastFetched`, 0).value());
76
- const diff = moment().diff(appLastFetched, 'hours');
77
67
 
78
68
  // Authenticate user (user will be resolved as well)
79
69
  self.user = await assistant.authenticate();
@@ -100,31 +90,6 @@ Usage.prototype.init = function (assistant, options) {
100
90
  }
101
91
 
102
92
  // Log
103
- self.log(`Usage.init(): Checking if usage data needs to be fetched (${diff} hours)...`);
104
-
105
- // Get app data to get plan limits using cached data if possible
106
- if (diff > 1 || options.refetch) {
107
- await self.getApp(options.app)
108
- .then((json) => {
109
- // Write data and last fetched to storage
110
- self.storage.set(`${self.paths.app}.data`, json).write();
111
- self.storage.set(`${self.paths.app}.lastFetched`, new Date().toISOString()).write();
112
- })
113
- .catch(e => {
114
- assistant.errorify(`Usage.init(): Error fetching app data: ${e}`, {code: 500, sentry: true});
115
- });
116
- }
117
-
118
- // Get app data
119
- self.app = self.storage.get(`${self.paths.app}.data`, {}).value();
120
-
121
- // Check for app data
122
- if (!self.app) {
123
- return reject(new Error('Usage.init(): No app data found'));
124
- }
125
-
126
- // Log
127
- self.log(`Usage.init(): Got app data`, self.app);
128
93
  self.log(`Usage.init(): Got user`, self.user);
129
94
 
130
95
  // Set initialized to true
@@ -146,7 +111,7 @@ Usage.prototype.validate = function (name, options) {
146
111
  options = options || {};
147
112
  options.useCaptchaResponse = typeof options.useCaptchaResponse === 'undefined' ? true : options.useCaptchaResponse;
148
113
  options.log = typeof options.log === 'undefined' ? true : options.log;
149
- options.throw = typeof options.throw === 'undefined' ? false : options.throw;
114
+ options._forceReject = typeof options._forceReject === 'undefined' ? false : options._forceReject;
150
115
 
151
116
  // Check for required options
152
117
  const period = self.getUsage(name);
@@ -164,8 +129,8 @@ Usage.prototype.validate = function (name, options) {
164
129
  );
165
130
  }
166
131
 
167
- // Dev mode throw
168
- if (options.throw) {
132
+ // Force reject (for testing/debugging)
133
+ if (options._forceReject) {
169
134
  return _reject();
170
135
  }
171
136
 
@@ -283,20 +248,31 @@ Usage.prototype.getUsage = function (name) {
283
248
  }
284
249
  };
285
250
 
286
- Usage.prototype.getLimit = function (name) {
251
+ Usage.prototype.getProduct = function (id) {
287
252
  const self = this;
288
253
  const Manager = self.Manager;
289
- const assistant = self.assistant;
290
254
 
291
- // Get key
292
- const key = `products.${self.options.app}-${self.user.plan.id}.limits`;
255
+ const products = Manager.config.products || [];
256
+
257
+ // Look up by provided ID, or fall back to user's subscription product
258
+ id = id || self.user.subscription.product.id;
259
+
260
+ return products.find(p => p.id === id)
261
+ || products.find(p => p.id === 'basic')
262
+ || {};
263
+ };
264
+
265
+ Usage.prototype.getLimit = function (name) {
266
+ const self = this;
293
267
 
294
- // Get limit
268
+ const limits = self.getProduct().limits || {};
269
+
270
+ // Return specific limit or all limits
295
271
  if (name) {
296
- return _.get(self.app, `${key}.${name}`, 0);
297
- } else {
298
- return _.get(self.app, key, {});
272
+ return limits[name] || 0;
299
273
  }
274
+
275
+ return limits;
300
276
  };
301
277
 
302
278
  Usage.prototype.update = function () {
@@ -370,49 +346,4 @@ Usage.prototype.log = function () {
370
346
  }
371
347
  };
372
348
 
373
- Usage.prototype.getApp = function (id) {
374
- const self = this;
375
-
376
- // Shortcuts
377
- const Manager = self.Manager;
378
- const assistant = self.assistant;
379
-
380
- return new Promise(function(resolve, reject) {
381
- const { admin } = Manager.libraries;
382
-
383
- try {
384
- // If we're on ITW, we can read directly from Firestore
385
- // If we don't do this, calling getApp on ITW will call getApp on ITW again and again
386
- if (Manager.config.app.id === 'itw-creative-works') {
387
- admin.firestore().doc(`apps/${id}`)
388
- .get()
389
- .then((r) => {
390
- const data = r.data();
391
-
392
- // Check for data
393
- if (!data) {
394
- return reject(new Error('No data found'));
395
- }
396
-
397
- // Resolve
398
- return resolve(data);
399
- })
400
- .catch((e) => reject(e));
401
- } else {
402
- fetch('https://us-central1-itw-creative-works.cloudfunctions.net/getApp', {
403
- method: 'post',
404
- response: 'json',
405
- body: {
406
- id: id,
407
- },
408
- })
409
- .then((json) => resolve(json))
410
- .catch((e) => reject(e));
411
- }
412
- } catch (e) {
413
- return reject(e);
414
- }
415
- });
416
- };
417
-
418
349
  module.exports = Usage;
@@ -40,44 +40,52 @@ function User(Manager, settings, options) {
40
40
  email: settings?.auth?.email ?? null,
41
41
  temporary: getWithDefault(settings?.auth?.temporary, false, defaults),
42
42
  },
43
- plan: {
44
- id: getWithDefault(settings?.plan?.id, 'basic', defaults), // intro | basic | advanced | premium
45
- status: getWithDefault(settings?.plan?.status, 'cancelled', defaults), // active | suspended | cancelled
43
+ subscription: {
44
+ product: {
45
+ id: getWithDefault(settings?.subscription?.product?.id, 'basic', defaults), // product ID from config (e.g., 'basic', 'premium', 'pro')
46
+ name: getWithDefault(settings?.subscription?.product?.name, 'Basic', defaults), // display name from config
47
+ },
48
+ status: getWithDefault(settings?.subscription?.status, 'active', defaults), // 'active' | 'suspended' | 'cancelled'
46
49
  expires: {
47
- timestamp: getWithDefault(settings?.plan?.expires?.timestamp, oldDate, defaults),
48
- timestampUNIX: getWithDefault(settings?.plan?.expires?.timestampUNIX, oldDateUNIX, defaults),
50
+ timestamp: getWithDefault(settings?.subscription?.expires?.timestamp, oldDate, defaults),
51
+ timestampUNIX: getWithDefault(settings?.subscription?.expires?.timestampUNIX, oldDateUNIX, defaults),
49
52
  },
50
53
  trial: {
51
- activated: getWithDefault(settings?.plan?.trial?.activated, false, defaults),
54
+ activated: getWithDefault(settings?.subscription?.trial?.activated, false, defaults),
52
55
  expires: {
53
- timestamp: getWithDefault(settings?.plan?.trial?.expires?.timestamp, oldDate, defaults),
54
- timestampUNIX: getWithDefault(settings?.plan?.trial?.expires?.timestampUNIX, oldDateUNIX, defaults),
56
+ timestamp: getWithDefault(settings?.subscription?.trial?.expires?.timestamp, oldDate, defaults),
57
+ timestampUNIX: getWithDefault(settings?.subscription?.trial?.expires?.timestampUNIX, oldDateUNIX, defaults),
58
+ },
59
+ },
60
+ cancellation: {
61
+ pending: getWithDefault(settings?.subscription?.cancellation?.pending, false, defaults),
62
+ date: {
63
+ timestamp: getWithDefault(settings?.subscription?.cancellation?.date?.timestamp, oldDate, defaults),
64
+ timestampUNIX: getWithDefault(settings?.subscription?.cancellation?.date?.timestampUNIX, oldDateUNIX, defaults),
55
65
  },
56
66
  },
57
67
  limits: {
58
- // devices: settings?.plan?.limits?.devices ?? null,
68
+ // devices: settings?.subscription?.limits?.devices ?? null,
59
69
  },
60
70
  payment: {
61
- processor: settings?.plan?.payment?.processor ?? null, // paypal | stripe | chargebee, etc
62
- orderId: settings?.plan?.payment?.orderId ?? null, // xxx-xxx-xxx
63
- resourceId: settings?.plan?.payment?.resourceId ?? null, // x-xxxxxx
64
- frequency: settings?.plan?.payment?.frequency ?? null, // monthly || annually
65
- active: getWithDefault(settings?.plan?.payment?.active, false, defaults), // true | false
71
+ processor: settings?.subscription?.payment?.processor ?? null, // 'stripe' | 'paypal' | etc
72
+ resourceId: settings?.subscription?.payment?.resourceId ?? null, // subscription ID from provider (e.g., 'sub_xxx')
73
+ frequency: settings?.subscription?.payment?.frequency ?? null, // 'monthly' | 'annually'
66
74
  startDate: {
67
- timestamp: getWithDefault(settings?.plan?.payment?.startDate?.timestamp, now, defaults), // x-xxxxxx
68
- timestampUNIX: getWithDefault(settings?.plan?.payment?.startDate?.timestampUNIX, nowUNIX, defaults), // x-xxxxxx
75
+ timestamp: getWithDefault(settings?.subscription?.payment?.startDate?.timestamp, oldDate, defaults),
76
+ timestampUNIX: getWithDefault(settings?.subscription?.payment?.startDate?.timestampUNIX, oldDateUNIX, defaults),
69
77
  },
70
78
  updatedBy: {
71
79
  event: {
72
- name: settings?.plan?.payment?.updatedBy?.event?.name ?? null, // x-xxxxxx
73
- id: settings?.plan?.payment?.updatedBy?.event?.id ?? null, // x-xxxxxx
80
+ name: settings?.subscription?.payment?.updatedBy?.event?.name ?? null,
81
+ id: settings?.subscription?.payment?.updatedBy?.event?.id ?? null,
74
82
  },
75
83
  date: {
76
- timestamp: getWithDefault(settings?.plan?.payment?.updatedBy?.date?.timestamp, now, defaults), // x-xxxxxx
77
- timestampUNIX: getWithDefault(settings?.plan?.payment?.updatedBy?.date?.timestampUNIX, nowUNIX, defaults), // x-xxxxxx
84
+ timestamp: getWithDefault(settings?.subscription?.payment?.updatedBy?.date?.timestamp, oldDate, defaults),
85
+ timestampUNIX: getWithDefault(settings?.subscription?.payment?.updatedBy?.date?.timestampUNIX, oldDateUNIX, defaults),
78
86
  },
79
87
  },
80
- }
88
+ },
81
89
  },
82
90
  roles: {
83
91
  admin: getWithDefault(settings?.roles?.admin, false, defaults),
@@ -172,23 +180,6 @@ function User(Manager, settings, options) {
172
180
  self.properties = pruneObject(self.properties);
173
181
  }
174
182
 
175
- self.resolve = function (options) {
176
- options = options || {};
177
- options.defaultPlan = options.defaultPlan || 'basic';
178
- const planId = self.properties?.plan?.id ?? options.defaultPlan;
179
- const premiumExpire = self.properties?.plan?.expires?.timestamp ?? 0;
180
-
181
- let difference = ((new Date(premiumExpire).getTime() - new Date().getTime())/(24*3600*1000));
182
- // console.log('---difference', difference);
183
- if (difference <= -1) {
184
- _.set(self.properties, 'plan.id', options.defaultPlan);
185
- // console.log('---REVERTED TO BASIC BECAUSE EXPIRED');
186
- } else {
187
- // console.log('---ITS FINE');
188
- }
189
- return self;
190
- }
191
-
192
183
  self.merge = function (userObject) {
193
184
  self.properties = _.merge({}, self.properties, userObject)
194
185
  return self;
@@ -391,7 +391,7 @@ Manager.prototype._preProcess = function (mod) {
391
391
  });
392
392
  };
393
393
 
394
- Manager.prototype._processMiddleware = function (req, res, routePath) {
394
+ Manager.prototype._processMiddleware = function (req, res, routePath, options) {
395
395
  const self = this;
396
396
 
397
397
  // Set paths for BEM internal routes/schemas
@@ -399,11 +399,11 @@ Manager.prototype._processMiddleware = function (req, res, routePath) {
399
399
  const bemSchemasDir = path.resolve(__dirname, './schemas');
400
400
 
401
401
  // Route directly through middleware (no hooks for new system)
402
- return self.Middleware(req, res).run(routePath, {
402
+ return self.Middleware(req, res).run(routePath, Object.assign({
403
403
  routesDir: bemRoutesDir,
404
404
  schemasDir: bemSchemasDir,
405
405
  schema: routePath,
406
- });
406
+ }, options || {}));
407
407
  };
408
408
 
409
409
  // Manager.prototype.Assistant = function(ref, options) {
@@ -461,12 +461,6 @@ Manager.prototype.Roles = function () {
461
461
  return new self.libraries.Roles(self, ...arguments);
462
462
  };
463
463
 
464
- Manager.prototype.SubscriptionResolver = function () {
465
- const self = this;
466
- self.libraries.SubscriptionResolver = self.libraries.SubscriptionResolver || require('./helpers/subscription-resolver.js');
467
- return new self.libraries.SubscriptionResolver(self, ...arguments);
468
- };
469
-
470
464
  Manager.prototype.Usage = function () {
471
465
  const self = this;
472
466
  self.libraries.Usage = self.libraries.Usage || require('./helpers/usage.js');
@@ -719,6 +713,17 @@ Manager.prototype.setupFunctions = function (exporter, options) {
719
713
  self.assistant.log('Setting up Firebase functions...');
720
714
  }
721
715
 
716
+ // Route-specific middleware overrides
717
+ // Routes listed here get custom middleware options (e.g., skip auth for webhooks)
718
+ const routeMiddlewareOverrides = {
719
+ 'payments/webhook': {
720
+ authenticate: false,
721
+ setupUsage: false,
722
+ setupAnalytics: false,
723
+ includeNonSchemaSettings: true,
724
+ },
725
+ };
726
+
722
727
  // Setup functions
723
728
  exporter.bm_api =
724
729
  self.libraries.functions
@@ -731,7 +736,8 @@ Manager.prototype.setupFunctions = function (exporter, options) {
731
736
  return self._process((new (require(`${core}/actions/api.js`))()).init(self, { req, res }));
732
737
  } else {
733
738
  // New RESTful middleware system -> direct to middleware (no hooks)
734
- return self._processMiddleware(req, res, route.routePath);
739
+ const overrides = routeMiddlewareOverrides[route.routePath];
740
+ return self._processMiddleware(req, res, route.routePath, overrides);
735
741
  }
736
742
  });
737
743
 
@@ -924,6 +930,12 @@ Manager.prototype.setupFunctions = function (exporter, options) {
924
930
  .firestore.document('notifications/{token}')
925
931
  .onWrite((change, context) => self.EventMiddleware({ change, context }).run(`${events}/firestore/notifications/on-write.js`));
926
932
 
933
+ exporter.bm_paymentsWebhookOnWrite =
934
+ self.libraries.functions
935
+ .runWith({memory: '256MB', timeoutSeconds: 60})
936
+ .firestore.document('payments-webhooks/{eventId}')
937
+ .onWrite((change, context) => self.EventMiddleware({ change, context }).run(`${events}/firestore/payments-webhooks/on-write.js`));
938
+
927
939
  // Setup cron jobs
928
940
  exporter.bm_cronDaily =
929
941
  self.libraries.functions
@@ -0,0 +1,293 @@
1
+ const powertools = require('node-powertools');
2
+
3
+ // Lazy singleton Stripe SDK instance
4
+ let stripeInstance = null;
5
+
6
+ /**
7
+ * Stripe shared library
8
+ * Provides SDK initialization and unified subscription transformation
9
+ */
10
+ const Stripe = {
11
+ /**
12
+ * Initialize or return the Stripe SDK instance
13
+ * @param {string} secretKey - Stripe secret key
14
+ * @returns {object} Stripe SDK instance
15
+ */
16
+ init() {
17
+ if (!stripeInstance) {
18
+ const secretKey = process.env.STRIPE_SECRET_KEY;
19
+
20
+ if (!secretKey) {
21
+ throw new Error('STRIPE_SECRET_KEY environment variable is required');
22
+ }
23
+
24
+ stripeInstance = require('stripe')(secretKey);
25
+ }
26
+
27
+ return stripeInstance;
28
+ },
29
+
30
+ /**
31
+ * Transform a raw Stripe subscription object into the unified subscription shape
32
+ * This produces the exact same object stored in users/{uid}.subscription
33
+ *
34
+ * @param {object} rawSubscription - Raw Stripe subscription object
35
+ * @param {object} options
36
+ * @param {object} options.config - BEM config (must contain products array)
37
+ * @param {string} options.eventName - Name of the webhook event (e.g., 'customer.subscription.updated')
38
+ * @param {string} options.eventId - ID of the webhook event (e.g., 'evt_xxx')
39
+ * @returns {object} Unified subscription object
40
+ */
41
+ toUnified(rawSubscription, options) {
42
+ options = options || {};
43
+ const config = options.config || {};
44
+
45
+ const oldDate = powertools.timestamp(new Date(0), { output: 'string' });
46
+ const oldDateUNIX = powertools.timestamp(oldDate, { output: 'unix' });
47
+
48
+ // Resolve status
49
+ const status = resolveStatus(rawSubscription);
50
+
51
+ // Resolve cancellation
52
+ const cancellation = resolveCancellation(rawSubscription);
53
+
54
+ // Resolve trial
55
+ const trial = resolveTrial(rawSubscription);
56
+
57
+ // Resolve frequency
58
+ const frequency = resolveFrequency(rawSubscription);
59
+
60
+ // Resolve product from price
61
+ const product = resolveProduct(rawSubscription, config);
62
+
63
+ // Resolve expiration
64
+ const expires = resolveExpires(rawSubscription, oldDate, oldDateUNIX);
65
+
66
+ // Resolve start date
67
+ const startDate = resolveStartDate(rawSubscription, oldDate, oldDateUNIX);
68
+
69
+ // Build the unified subscription object
70
+ const now = powertools.timestamp(new Date(), { output: 'string' });
71
+ const nowUNIX = powertools.timestamp(now, { output: 'unix' });
72
+
73
+ return {
74
+ product: product,
75
+ status: status,
76
+ expires: expires,
77
+ trial: trial,
78
+ cancellation: cancellation,
79
+ limits: {},
80
+ payment: {
81
+ processor: 'stripe',
82
+ resourceId: rawSubscription.id || null,
83
+ frequency: frequency,
84
+ startDate: startDate,
85
+ updatedBy: {
86
+ event: {
87
+ name: options.eventName || null,
88
+ id: options.eventId || null,
89
+ },
90
+ date: {
91
+ timestamp: now,
92
+ timestampUNIX: nowUNIX,
93
+ },
94
+ },
95
+ },
96
+ };
97
+ },
98
+ };
99
+
100
+ /**
101
+ * Map Stripe subscription status to unified status
102
+ *
103
+ * | Stripe Status | Unified Status |
104
+ * |----------------------|----------------|
105
+ * | active | active |
106
+ * | trialing | active |
107
+ * | past_due | suspended |
108
+ * | unpaid | suspended |
109
+ * | canceled | cancelled |
110
+ * | incomplete | cancelled |
111
+ * | incomplete_expired | cancelled |
112
+ */
113
+ function resolveStatus(raw) {
114
+ const stripeStatus = raw.status;
115
+
116
+ if (stripeStatus === 'active' || stripeStatus === 'trialing') {
117
+ return 'active';
118
+ }
119
+
120
+ if (stripeStatus === 'past_due' || stripeStatus === 'unpaid') {
121
+ return 'suspended';
122
+ }
123
+
124
+ // canceled, incomplete, incomplete_expired, or anything else
125
+ return 'cancelled';
126
+ }
127
+
128
+ /**
129
+ * Resolve cancellation state from Stripe subscription
130
+ * Handles cancel_at_period_end for pending cancellations
131
+ */
132
+ function resolveCancellation(raw) {
133
+ const oldDate = powertools.timestamp(new Date(0), { output: 'string' });
134
+ const oldDateUNIX = powertools.timestamp(oldDate, { output: 'unix' });
135
+
136
+ // Pending cancellation: active but set to cancel at period end
137
+ if (raw.cancel_at_period_end) {
138
+ const cancelAt = raw.cancel_at
139
+ ? powertools.timestamp(new Date(raw.cancel_at * 1000), { output: 'string' })
140
+ : powertools.timestamp(new Date((raw.current_period_end || 0) * 1000), { output: 'string' });
141
+
142
+ return {
143
+ pending: true,
144
+ date: {
145
+ timestamp: cancelAt,
146
+ timestampUNIX: powertools.timestamp(cancelAt, { output: 'unix' }),
147
+ },
148
+ };
149
+ }
150
+
151
+ // Already cancelled
152
+ if (raw.canceled_at) {
153
+ const cancelledDate = powertools.timestamp(new Date(raw.canceled_at * 1000), { output: 'string' });
154
+
155
+ return {
156
+ pending: false,
157
+ date: {
158
+ timestamp: cancelledDate,
159
+ timestampUNIX: powertools.timestamp(cancelledDate, { output: 'unix' }),
160
+ },
161
+ };
162
+ }
163
+
164
+ // No cancellation
165
+ return {
166
+ pending: false,
167
+ date: {
168
+ timestamp: oldDate,
169
+ timestampUNIX: oldDateUNIX,
170
+ },
171
+ };
172
+ }
173
+
174
+ /**
175
+ * Resolve trial state from Stripe subscription
176
+ */
177
+ function resolveTrial(raw) {
178
+ const oldDate = powertools.timestamp(new Date(0), { output: 'string' });
179
+ const oldDateUNIX = powertools.timestamp(oldDate, { output: 'unix' });
180
+
181
+ const trialStart = raw.trial_start ? raw.trial_start * 1000 : 0;
182
+ const trialEnd = raw.trial_end ? raw.trial_end * 1000 : 0;
183
+ const activated = !!(trialStart && trialEnd);
184
+
185
+ // Build trial expiration
186
+ let trialExpires = { timestamp: oldDate, timestampUNIX: oldDateUNIX };
187
+ if (trialEnd) {
188
+ const trialEndDate = powertools.timestamp(new Date(trialEnd), { output: 'string' });
189
+ trialExpires = {
190
+ timestamp: trialEndDate,
191
+ timestampUNIX: powertools.timestamp(trialEndDate, { output: 'unix' }),
192
+ };
193
+ }
194
+
195
+ return {
196
+ activated: activated,
197
+ expires: trialExpires,
198
+ };
199
+ }
200
+
201
+ /**
202
+ * Resolve billing frequency from Stripe subscription
203
+ */
204
+ function resolveFrequency(raw) {
205
+ // Stripe stores interval on the plan/price object
206
+ const interval = raw.plan?.interval
207
+ || raw.items?.data?.[0]?.price?.recurring?.interval
208
+ || null;
209
+
210
+ if (interval === 'year') {
211
+ return 'annually';
212
+ }
213
+
214
+ if (interval === 'month') {
215
+ return 'monthly';
216
+ }
217
+
218
+ if (interval === 'week') {
219
+ return 'weekly';
220
+ }
221
+
222
+ if (interval === 'day') {
223
+ return 'daily';
224
+ }
225
+
226
+ return null;
227
+ }
228
+
229
+ /**
230
+ * Resolve product by matching the Stripe price ID against config products
231
+ * Returns { id, name } — falls back to basic if no match is found
232
+ */
233
+ function resolveProduct(raw, config) {
234
+ // Get the price ID from the subscription
235
+ const priceId = raw.plan?.id
236
+ || raw.items?.data?.[0]?.price?.id
237
+ || null;
238
+
239
+ if (!priceId || !config.products) {
240
+ return { id: 'basic', name: 'Basic' };
241
+ }
242
+
243
+ // Search through products for a matching price ID
244
+ for (const product of config.products) {
245
+ if (!product.prices) {
246
+ continue;
247
+ }
248
+
249
+ for (const frequency of Object.keys(product.prices)) {
250
+ if (product.prices[frequency]?.stripe === priceId) {
251
+ return { id: product.id, name: product.name || product.id };
252
+ }
253
+ }
254
+ }
255
+
256
+ // No match found
257
+ return { id: 'basic', name: 'Basic' };
258
+ }
259
+
260
+ /**
261
+ * Resolve subscription expiration from Stripe data
262
+ */
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' })
267
+ : oldDate;
268
+
269
+ return {
270
+ timestamp: periodEnd,
271
+ timestampUNIX: periodEnd !== oldDate
272
+ ? powertools.timestamp(periodEnd, { output: 'unix' })
273
+ : oldDateUNIX,
274
+ };
275
+ }
276
+
277
+ /**
278
+ * Resolve subscription start date from Stripe data
279
+ */
280
+ function resolveStartDate(raw, oldDate, oldDateUNIX) {
281
+ const startDate = raw.start_date
282
+ ? powertools.timestamp(new Date(raw.start_date * 1000), { output: 'string' })
283
+ : oldDate;
284
+
285
+ return {
286
+ timestamp: startDate,
287
+ timestampUNIX: startDate !== oldDate
288
+ ? powertools.timestamp(startDate, { output: 'unix' })
289
+ : oldDateUNIX,
290
+ };
291
+ }
292
+
293
+ module.exports = Stripe;
@@ -157,7 +157,7 @@ async function getAllSubscriptions(admin, assistant) {
157
157
  assistant.log('getAllSubscriptions(): Starting...');
158
158
 
159
159
  const snapshot = await admin.firestore().collection('users')
160
- .where('plan.expires.timestampUNIX', '>=', Date.now() / 1000)
160
+ .where('subscription.expires.timestampUNIX', '>=', Date.now() / 1000)
161
161
  .get();
162
162
 
163
163
  const stats = {
@@ -167,8 +167,8 @@ async function getAllSubscriptions(admin, assistant) {
167
167
 
168
168
  snapshot.forEach((doc) => {
169
169
  const data = doc.data();
170
- const planId = data?.plan?.id || 'basic';
171
- const frequency = data?.plan?.payment?.frequency || 'unknown';
170
+ const planId = data?.subscription?.product?.id || 'basic';
171
+ const frequency = data?.subscription?.payment?.frequency || 'unknown';
172
172
  const isAdmin = data?.roles?.admin || false;
173
173
  const isVip = data?.roles?.vip || false;
174
174
 
@@ -58,7 +58,7 @@ module.exports = async ({ assistant, Manager, settings, analytics }) => {
58
58
 
59
59
  // Check rate limit via Usage API
60
60
  try {
61
- await usage.validate('marketing-subscribe', { throw: true, useCaptchaResponse: false });
61
+ await usage.validate('marketing-subscribe', { useCaptchaResponse: false });
62
62
  usage.increment('marketing-subscribe');
63
63
  await usage.update();
64
64
  } catch (e) {