backend-manager 5.0.123 → 5.0.124

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -646,44 +646,43 @@ npx bm firestore:delete users/test123 --emulator
646
646
 
647
647
  ### Overview
648
648
 
649
- Usage is tracked per-metric (e.g., `requests`, `marketing-subscribe`) with two counters:
650
- - `period`: Current month's count, reset on the 1st of each month
649
+ Usage is tracked per-metric (e.g., `requests`, `sponsorships`) with four fields:
650
+ - `monthly`: Current month's count, reset on the 1st of each month by cron
651
+ - `daily`: Current day's count, reset every day by cron
651
652
  - `total`: All-time count, never resets
653
+ - `last`: Object with `id`, `timestamp`, `timestampUNIX` of the last usage event
652
654
 
653
- ### Product Rate Limit Modes
655
+ ### Limits & Daily Caps
654
656
 
655
- Products can set a `rateLimit` field to control how limits are enforced:
657
+ Limits are always specified as **monthly** values in product config (e.g., `limits.requests = 100` means 100/month).
656
658
 
657
- | Value | Behavior | Default |
658
- |-------|----------|---------|
659
- | `'monthly'` | Full monthly limit available at any time | Yes |
660
- | `'daily'` | Proportional daily cap: `ceil(limit * dayOfMonth / daysInMonth)` | No |
659
+ By default, limits are enforced with **daily caps** to prevent users from burning their entire monthly quota in a single day. Two checks are applied:
661
660
 
662
- Example config (not in the template add per-product as needed):
661
+ 1. **Flat daily cap**: `ceil(monthlyLimit / daysInMonth)`max uses per day
662
+ - e.g., 100/month in a 31-day month = `ceil(100/31)` = 4/day
663
+ 2. **Proportional monthly cap**: `ceil(monthlyLimit * dayOfMonth / daysInMonth)` — running total
664
+ - Prevents accumulating too much too fast even within daily limits
665
+ - e.g., Day 15 of a 30-day month with 100/month limit = max 50 used so far
666
+
667
+ Products can opt out of daily caps by setting `rateLimit: 'monthly'` (default is `'daily'`):
663
668
  ```json
664
669
  {
665
670
  "id": "basic",
666
671
  "limits": { "requests": 100 },
667
- "rateLimit": "daily"
672
+ "rateLimit": "monthly"
668
673
  }
669
674
  ```
670
675
 
671
- With `rateLimit: 'daily'` and 100 requests/month in a 30-day month:
672
- - Day 1: max 4 requests used so far
673
- - Day 15: max 50 requests used so far
674
- - Day 30: max 100 requests (full allocation)
675
-
676
- Unused days roll forward — a user who doesn't use the product for 2 weeks can use a burst later.
677
-
678
676
  ### Reset Schedule
679
677
 
680
678
  | Target | Frequency | What happens |
681
679
  |--------|-----------|-------------|
682
680
  | Local storage | Daily | Cleared entirely |
683
681
  | `usage` collection (unauthenticated) | Daily | Deleted entirely |
684
- | User doc `usage.*.period` (authenticated) | Monthly (1st) | Reset to 0 |
682
+ | User doc `usage.*.daily` (authenticated) | Daily | Reset to 0 |
683
+ | User doc `usage.*.monthly` (authenticated) | Monthly (1st) | Reset to 0 |
685
684
 
686
- The daily cron runs at midnight UTC (`0 0 * * *`). Authenticated user period resets only execute on the 1st of the month.
685
+ The daily cron (`reset-usage.js`) runs at midnight UTC. It collects all users with non-zero counters across all metrics, then performs a single write per user to reset daily (and monthly on the 1st).
687
686
 
688
687
  ## Subscription System
689
688
 
package/README.md CHANGED
@@ -473,7 +473,7 @@ const userProps = Manager.User(existingData, { defaults: true }).properties;
473
473
  affiliate: { code, referrals, referrer },
474
474
  activity: { lastActivity, created, geolocation, client },
475
475
  api: { clientId, privateKey },
476
- usage: { requests: { period, total, last } },
476
+ usage: { requests: { monthly, daily, total, last } },
477
477
  personal: { birthday, gender, location, name, company, telephone },
478
478
  oauth2: {}
479
479
  }
@@ -519,13 +519,13 @@ const usage = await Manager.Usage().init(assistant, {
519
519
  });
520
520
 
521
521
  // Check and validate limits
522
- const currentUsage = usage.getUsage('requests'); // Get current period usage
523
- const limit = usage.getLimit('requests'); // Get plan limit
524
- await usage.validate('requests'); // Throws if over limit
522
+ const currentUsage = usage.getUsage('requests'); // Get current monthly usage
523
+ const limit = usage.getLimit('requests'); // Get plan limit (monthly)
524
+ await usage.validate('requests'); // Throws if over daily or monthly limit
525
525
 
526
- // Increment usage
526
+ // Increment usage (increments monthly, daily, and total counters)
527
527
  usage.increment('requests', 1);
528
- usage.set('requests', 0); // Reset to specific value
528
+ usage.set('requests', 0); // Reset monthly to specific value
529
529
 
530
530
  // Save to Firestore
531
531
  await usage.update();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-manager",
3
- "version": "5.0.123",
3
+ "version": "5.0.124",
4
4
  "description": "Quick tools for developing Firebase functions",
5
5
  "main": "src/manager/index.js",
6
6
  "bin": {
@@ -4,7 +4,8 @@
4
4
  * Runs daily at midnight UTC and handles different reset schedules:
5
5
  * - Local storage: cleared every day
6
6
  * - Unauthenticated usage collection: deleted every day
7
- * - Authenticated user period counters: reset on the 1st (or 2nd as grace window) of each month
7
+ * - Authenticated user daily counters: reset every day
8
+ * - Authenticated user monthly counters: reset on the 1st of each month
8
9
  */
9
10
  module.exports = async ({ Manager, assistant, context, libraries }) => {
10
11
  const storage = Manager.storage({ name: 'usage', temporary: true, clear: false, log: false });
@@ -12,24 +13,18 @@ module.exports = async ({ Manager, assistant, context, libraries }) => {
12
13
  assistant.log('Starting...');
13
14
 
14
15
  // Clear local storage (daily)
15
- await clearLocal(assistant, storage);
16
+ clearLocal(assistant, storage);
16
17
 
17
18
  // Clear unauthenticated usage collection (daily)
18
19
  await clearUnauthenticatedUsage(assistant, libraries);
19
20
 
20
- // Reset authenticated user periods (monthly - 1st or 2nd of month)
21
- await resetAuthenticatedUsage(Manager, assistant, libraries);
21
+ // Reset authenticated user counters (daily + monthly on 1st)
22
+ await resetAuthenticated(Manager, assistant);
22
23
  };
23
24
 
24
- async function clearLocal(assistant, storage) {
25
- assistant.log('[local]: Starting...');
26
-
27
- assistant.log('[local]: storage(apps)', storage.get('apps', {}).value());
28
- assistant.log('[local]: storage(users)', storage.get('users', {}).value());
29
-
30
- // Clear storage
25
+ function clearLocal(assistant, storage) {
26
+ assistant.log('[local]: Clearing...');
31
27
  storage.setState({}).write();
32
-
33
28
  assistant.log('[local]: Completed!');
34
29
  }
35
30
 
@@ -40,96 +35,107 @@ async function clearUnauthenticatedUsage(assistant, libraries) {
40
35
 
41
36
  await admin.firestore().recursiveDelete(admin.firestore().collection('usage'))
42
37
  .then(() => {
43
- assistant.log('[unauthenticated]: Deleted usage collection');
38
+ assistant.log('[unauthenticated]: Completed!');
44
39
  })
45
40
  .catch((e) => {
46
41
  assistant.errorify(`Error deleting usage collection: ${e}`, { code: 500, log: true });
47
42
  });
48
43
  }
49
44
 
50
- async function resetAuthenticatedUsage(Manager, assistant, libraries) {
51
- const { admin } = libraries;
52
- const dayOfMonth = new Date().getDate();
53
-
54
- // Only reset on the 1st of the month
55
- if (dayOfMonth !== 1) {
56
- assistant.log('[authenticated]: Skipping period reset (not the 1st of the month)');
57
- return;
58
- }
59
-
60
- assistant.log('[authenticated]: Monthly reset starting...');
61
-
62
- // Gather all unique metric names from ALL products
45
+ async function resetAuthenticated(Manager, assistant) {
46
+ const isFirstOfMonth = new Date().getDate() === 1;
63
47
  const products = Manager.config.payment?.products || [];
64
- const metrics = {};
65
48
 
49
+ // Gather all metric names from all products
50
+ const metricSet = { requests: true };
66
51
  for (const product of products) {
67
- const limits = product.limits || {};
68
-
69
- for (const key of Object.keys(limits)) {
70
- metrics[key] = true;
52
+ for (const key of Object.keys(product.limits || {})) {
53
+ metricSet[key] = true;
71
54
  }
72
55
  }
56
+ const metricNames = Object.keys(metricSet);
73
57
 
74
- // Ensure requests is always included
75
- metrics.requests = true;
58
+ assistant.log(`[authenticated]: Resetting ${isFirstOfMonth ? 'daily + monthly' : 'daily'} for metrics`, metricNames);
76
59
 
77
- const metricNames = Object.keys(metrics);
60
+ // Collect all user IDs that need resetting (deduplicated across metrics)
61
+ // Each entry maps uid -> { ref, usage } so we only write once per user
62
+ const usersToReset = {};
78
63
 
79
- assistant.log('[authenticated]: Resetting metrics', metricNames);
80
-
81
- // Reset each metric for users who have usage > 0
82
64
  for (const metric of metricNames) {
83
- assistant.log(`[authenticated]: Resetting ${metric} for all users`);
84
-
85
- await Manager.Utilities().iterateCollection((batch, index) => {
86
- return new Promise(async (resolve, reject) => {
65
+ // Query users with daily > 0 for this metric
66
+ await Manager.Utilities().iterateCollection((batch) => {
67
+ return new Promise(async (resolve) => {
87
68
  for (const doc of batch.docs) {
88
- const data = doc.data();
89
-
90
- // Normalize the metric
91
- data.usage = data.usage || {};
92
- data.usage[metric] = data.usage[metric] || {};
93
- data.usage[metric].period = data.usage[metric].period || 0;
94
- data.usage[metric].total = data.usage[metric].total || 0;
95
- data.usage[metric].last = data.usage[metric].last || {};
96
-
97
- // Skip if already 0
98
- if (data.usage[metric].period <= 0) {
99
- continue;
69
+ if (!usersToReset[doc.id]) {
70
+ usersToReset[doc.id] = { ref: doc.ref, usage: doc.data().usage || {} };
100
71
  }
101
-
102
- // Reset the metric
103
- const original = data.usage[metric].period;
104
- data.usage[metric].period = 0;
105
-
106
- // Update the doc
107
- await doc.ref.update({ usage: data.usage })
108
- .then(r => {
109
- assistant.log(`[authenticated]: Reset ${metric} for ${doc.id} (${original} -> 0)`);
110
- })
111
- .catch(e => {
112
- assistant.errorify(`Error resetting ${metric} for ${doc.id}: ${e}`, { code: 500, log: true });
113
- });
114
72
  }
115
-
116
73
  return resolve();
117
74
  });
118
75
  }, {
119
76
  collection: 'users',
120
77
  where: [
121
- { field: `usage.${metric}.period`, operator: '>', value: 0 },
78
+ { field: `usage.${metric}.daily`, operator: '>', value: 0 },
122
79
  ],
123
80
  batchSize: 5000,
124
- log: true,
81
+ log: false,
125
82
  })
126
- .then((r) => {
127
- assistant.log(`[authenticated]: Reset ${metric} for all users complete!`);
83
+ .catch(e => {
84
+ assistant.errorify(`Error querying ${metric}.daily: ${e}`, { code: 500, log: true });
85
+ });
86
+
87
+ // On the 1st, also query users with monthly > 0
88
+ if (isFirstOfMonth) {
89
+ await Manager.Utilities().iterateCollection((batch) => {
90
+ return new Promise(async (resolve) => {
91
+ for (const doc of batch.docs) {
92
+ if (!usersToReset[doc.id]) {
93
+ usersToReset[doc.id] = { ref: doc.ref, usage: doc.data().usage || {} };
94
+ }
95
+ }
96
+ return resolve();
97
+ });
98
+ }, {
99
+ collection: 'users',
100
+ where: [
101
+ { field: `usage.${metric}.monthly`, operator: '>', value: 0 },
102
+ ],
103
+ batchSize: 5000,
104
+ log: false,
105
+ })
106
+ .catch(e => {
107
+ assistant.errorify(`Error querying ${metric}.monthly: ${e}`, { code: 500, log: true });
108
+ });
109
+ }
110
+ }
111
+
112
+ const userIds = Object.keys(usersToReset);
113
+ assistant.log(`[authenticated]: Found ${userIds.length} users to reset`);
114
+
115
+ // Single write per user: reset daily (always) + monthly (on 1st) for all metrics
116
+ for (const uid of userIds) {
117
+ const { ref, usage } = usersToReset[uid];
118
+
119
+ for (const metric of metricNames) {
120
+ if (!usage[metric]) {
121
+ continue;
122
+ }
123
+
124
+ usage[metric].daily = 0;
125
+
126
+ if (isFirstOfMonth) {
127
+ usage[metric].monthly = 0;
128
+ }
129
+ }
130
+
131
+ await ref.update({ usage })
132
+ .then(() => {
133
+ assistant.log(`[authenticated]: Reset ${uid}`);
128
134
  })
129
135
  .catch(e => {
130
- assistant.errorify(`Error resetting ${metric} for all users: ${e}`, { code: 500, log: true });
136
+ assistant.errorify(`Error resetting ${uid}: ${e}`, { code: 500, log: true });
131
137
  });
132
138
  }
133
139
 
134
- assistant.log('[authenticated]: Monthly reset completed!');
140
+ assistant.log(`[authenticated]: Completed!`);
135
141
  }
@@ -114,18 +114,18 @@ Usage.prototype.validate = function (name, options) {
114
114
  options._forceReject = typeof options._forceReject === 'undefined' ? false : options._forceReject;
115
115
 
116
116
  // Check for required options
117
- const period = self.getUsage(name);
117
+ const monthly = self.getUsage(name);
118
118
  const allowed = self.getLimit(name);
119
119
 
120
120
  // Log (independent of options.log because this is important)
121
121
  if (options.log) {
122
- assistant.log(`Usage.validate(): Checking ${period}/${allowed} for ${name} (${self.key})...`);
122
+ assistant.log(`Usage.validate(): Checking ${monthly}/${allowed} for ${name} (${self.key})...`);
123
123
  }
124
124
 
125
125
  // Reject function
126
126
  function _reject() {
127
127
  reject(
128
- assistant.errorify(`You have exceeded your ${name} usage limit of ${period}/${allowed}.`, {code: 429})
128
+ assistant.errorify(`You have exceeded your ${name} usage limit of ${monthly}/${allowed}.`, {code: 429})
129
129
  );
130
130
  }
131
131
 
@@ -142,22 +142,38 @@ Usage.prototype.validate = function (name, options) {
142
142
  return resolve(true);
143
143
  }
144
144
 
145
- // Check proportional daily allowance (for products with rateLimit: 'daily')
145
+ // Check daily caps (for products with rateLimit: 'daily', which is the default)
146
146
  const dailyAllowance = self.getDailyAllowance(name);
147
147
  if (dailyAllowance !== null) {
148
+ // Flat daily cap: ceil(monthlyLimit / daysInMonth)
149
+ const now = new Date();
150
+ const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
151
+ const flatDailyCap = Math.ceil(allowed / daysInMonth);
152
+
153
+ // Get today's usage from the daily counter
154
+ const daily = _.get(self.user, `usage.${name}.daily`, 0);
155
+
148
156
  if (options.log) {
149
- assistant.log(`Usage.validate(): Daily allowance check: ${period}/${dailyAllowance} (monthly: ${allowed}) for ${name} (${self.key})`);
157
+ assistant.log(`Usage.validate(): Daily cap check: ${daily}/${flatDailyCap} today, ${monthly}/${dailyAllowance} proportional (monthly: ${allowed}) for ${name} (${self.key})`);
150
158
  }
151
159
 
152
- if (period >= dailyAllowance) {
160
+ // Check flat daily cap (can't exceed ceil(limit/daysInMonth) in a single day)
161
+ if (daily >= flatDailyCap) {
153
162
  return reject(
154
- assistant.errorify(`You have reached your daily usage limit for ${name} (${period}/${dailyAllowance}). Your monthly limit is ${allowed}.`, {code: 429})
163
+ assistant.errorify(`You have reached your daily usage limit for ${name} (${daily}/${flatDailyCap}). Your monthly limit is ${allowed}.`, {code: 429})
164
+ );
165
+ }
166
+
167
+ // Check proportional monthly cap (can't accumulate too fast)
168
+ if (monthly >= dailyAllowance) {
169
+ return reject(
170
+ assistant.errorify(`You have reached your usage limit for ${name} (${monthly}/${dailyAllowance}). Your monthly limit is ${allowed}.`, {code: 429})
155
171
  );
156
172
  }
157
173
  }
158
174
 
159
175
  // If they are under the monthly limit, resolve
160
- if (period < allowed) {
176
+ if (monthly < allowed) {
161
177
  self.log(`Usage.validate(): Valid for ${name}`);
162
178
 
163
179
  return resolve(true);
@@ -200,8 +216,8 @@ Usage.prototype.increment = function (name, value, options) {
200
216
  options = options || {};
201
217
  options.id = options.id || null;
202
218
 
203
- // Update total and period
204
- ['total', 'period', 'last'].forEach((key) => {
219
+ // Update total, monthly, daily, and last
220
+ ['total', 'monthly', 'daily', 'last'].forEach((key) => {
205
221
  const resolved = `usage.${name}.${key}`;
206
222
  const existing = _.get(self.user, resolved, 0);
207
223
 
@@ -237,8 +253,8 @@ Usage.prototype.set = function (name, value) {
237
253
  // Set value
238
254
  value = typeof value === 'undefined' ? 0 : value;
239
255
 
240
- // Update total and period
241
- const resolved = `usage.${name}.period`;
256
+ // Update monthly
257
+ const resolved = `usage.${name}.monthly`;
242
258
 
243
259
  // Set the value
244
260
  _.set(self.user, resolved, value);
@@ -256,7 +272,7 @@ Usage.prototype.getUsage = function (name) {
256
272
 
257
273
  // Get usage
258
274
  if (name) {
259
- return _.get(self.user, `usage.${name}.period`, 0);
275
+ return _.get(self.user, `usage.${name}.monthly`, 0);
260
276
  } else {
261
277
  return self.user.usage;
262
278
  }
@@ -290,20 +306,27 @@ Usage.prototype.getLimit = function (name) {
290
306
  };
291
307
 
292
308
  /**
293
- * Get the proportional daily allowance for a metric
294
- * Based on how far into the month we are: ceil(monthlyLimit * dayOfMonth / daysInMonth)
309
+ * Get the daily allowance cap for a metric
310
+ * Prevents users from burning their entire monthly quota in a single day
311
+ *
312
+ * Uses two checks:
313
+ * 1. Flat daily cap: ceil(monthlyLimit / daysInMonth) — max uses per day
314
+ * e.g. 100/month in March (31 days) = ceil(100/31) = 4/day
315
+ * e.g. 10/month in March (31 days) = ceil(10/31) = 1/day
316
+ * 2. Proportional monthly cap: ceil(monthlyLimit * dayOfMonth / daysInMonth) — running total
317
+ * Ensures users can't accumulate too much too fast even within daily limits
295
318
  *
296
- * Returns null if the product uses monthly rate limiting (no daily cap)
297
- * Products can set rateLimit: 'daily' | 'monthly' (default: 'monthly')
319
+ * Returns null if the product opts out with rateLimit: 'monthly'
320
+ * Products can set rateLimit: 'daily' (default) | 'monthly'
298
321
  */
299
322
  Usage.prototype.getDailyAllowance = function (name) {
300
323
  const self = this;
301
324
 
302
325
  // Get the product config
303
326
  const product = self.getProduct();
304
- const rateLimit = product.rateLimit || 'monthly';
327
+ const rateLimit = product.rateLimit || 'daily';
305
328
 
306
- // If monthly rate limiting, no daily cap
329
+ // If explicitly set to monthly, no daily cap
307
330
  if (rateLimit !== 'daily') {
308
331
  return null;
309
332
  }
@@ -314,11 +337,12 @@ Usage.prototype.getDailyAllowance = function (name) {
314
337
  return null;
315
338
  }
316
339
 
317
- // Calculate proportional allowance based on day of month
340
+ // Calculate caps
318
341
  const now = new Date();
319
342
  const dayOfMonth = now.getDate();
320
343
  const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
321
344
 
345
+ // Proportional monthly cap — how much should be used by this day at most
322
346
  // ceil ensures at least 1 usage per day even with very low limits
323
347
  return Math.ceil(monthlyLimit * (dayOfMonth / daysInMonth));
324
348
  };
@@ -97,7 +97,8 @@ const SCHEMA = {
97
97
  usage: {
98
98
  $passthrough: true,
99
99
  $template: {
100
- period: { type: 'number', default: 0 },
100
+ monthly: { type: 'number', default: 0 },
101
+ daily: { type: 'number', default: 0 },
101
102
  total: { type: 'number', default: 0 },
102
103
  last: {
103
104
  id: { type: 'string', default: null, nullable: true },
@@ -8,8 +8,9 @@ module.exports = async ({ assistant, user, settings }) => {
8
8
  const amount = settings.amount;
9
9
 
10
10
  // Get usage before increment
11
- const beforePeriod = usage.getUsage('requests');
11
+ const beforeMonthly = usage.getUsage('requests');
12
12
  const beforeTotal = user.usage?.requests?.total || 0;
13
+ const beforeDaily = user.usage?.requests?.daily || 0;
13
14
 
14
15
  // Increment usage
15
16
  usage.increment('requests', amount);
@@ -18,15 +19,16 @@ module.exports = async ({ assistant, user, settings }) => {
18
19
  await usage.update();
19
20
 
20
21
  // Get usage after increment
21
- const afterPeriod = usage.getUsage('requests');
22
+ const afterMonthly = usage.getUsage('requests');
22
23
  const afterTotal = user.usage?.requests?.total || 0;
24
+ const afterDaily = user.usage?.requests?.daily || 0;
23
25
 
24
26
  // Log
25
27
  assistant.log(`test/usage: Incremented requests by ${amount}`, {
26
28
  authenticated: user.authenticated,
27
29
  key: usage.key,
28
- before: { period: beforePeriod, total: beforeTotal },
29
- after: { period: afterPeriod, total: afterTotal },
30
+ before: { monthly: beforeMonthly, daily: beforeDaily, total: beforeTotal },
31
+ after: { monthly: afterMonthly, daily: afterDaily, total: afterTotal },
30
32
  });
31
33
 
32
34
  return assistant.respond({
@@ -35,11 +37,13 @@ module.exports = async ({ assistant, user, settings }) => {
35
37
  authenticated: user.authenticated,
36
38
  key: usage.key,
37
39
  before: {
38
- period: beforePeriod,
40
+ monthly: beforeMonthly,
41
+ daily: beforeDaily,
39
42
  total: beforeTotal,
40
43
  },
41
44
  after: {
42
- period: afterPeriod,
45
+ monthly: afterMonthly,
46
+ daily: afterDaily,
43
47
  total: afterTotal,
44
48
  },
45
49
  user: {
@@ -54,7 +54,7 @@ describe(`${package.name}`, () => {
54
54
  return assert.deepStrictEqual({
55
55
  requests: {
56
56
  total: 0,
57
- period: 0,
57
+ monthly: 0,
58
58
  last: {
59
59
  id: '',
60
60
  timestamp: '1970-01-01T00:00:00.000Z',
@@ -72,7 +72,7 @@ describe(`${package.name}`, () => {
72
72
  return assert.deepStrictEqual({
73
73
  requests: {
74
74
  total: 1,
75
- period: 1,
75
+ monthly: 1,
76
76
  last: {
77
77
  id: 'increment',
78
78
  timestamp: '2024-01-01T01:00:00.000Z',
@@ -90,7 +90,7 @@ describe(`${package.name}`, () => {
90
90
  return assert.deepStrictEqual({
91
91
  requests: {
92
92
  total: -1,
93
- period: -1,
93
+ monthly: -1,
94
94
  last: {
95
95
  id: 'decrement',
96
96
  timestamp: '2024-01-01T01:00:00.000Z',
@@ -132,7 +132,7 @@ describe(`${package.name}`, () => {
132
132
  return assert.deepStrictEqual({
133
133
  requests: {
134
134
  total: 1,
135
- period: 1,
135
+ monthly: 1,
136
136
  last: {
137
137
  id: 'update',
138
138
  timestamp: '2024-01-01T01:00:00.000Z',
@@ -154,7 +154,7 @@ describe(`${package.name}`, () => {
154
154
  return assert.deepStrictEqual({
155
155
  signups: {
156
156
  total: 1,
157
- period: 1,
157
+ monthly: 1,
158
158
  last: {
159
159
  id: 'singups',
160
160
  timestamp: '2024-01-01T01:00:00.000Z',
@@ -19,7 +19,8 @@ module.exports = {
19
19
  state.initialUsage = userDoc?.usage || {};
20
20
 
21
21
  // Store initial values for requests metric (may not exist yet)
22
- state.initialPeriod = state.initialUsage?.requests?.period || 0;
22
+ state.initialMonthly = state.initialUsage?.requests?.monthly || 0;
23
+ state.initialDaily = state.initialUsage?.requests?.daily || 0;
23
24
  state.initialTotal = state.initialUsage?.requests?.total || 0;
24
25
 
25
26
  assert.ok(true, 'Initial usage state captured');
@@ -42,12 +43,21 @@ module.exports = {
42
43
  assert.equal(response.data.metric, 'requests', 'Metric should be requests');
43
44
  assert.equal(response.data.amount, 1, 'Default amount should be 1');
44
45
 
45
- // Verify increment happened
46
+ // Verify monthly incremented
46
47
  assert.equal(
47
- response.data.after.period,
48
- response.data.before.period + 1,
49
- 'Period should be incremented by 1'
48
+ response.data.after.monthly,
49
+ response.data.before.monthly + 1,
50
+ 'Monthly should be incremented by 1'
50
51
  );
52
+
53
+ // Verify daily incremented
54
+ assert.equal(
55
+ response.data.after.daily,
56
+ response.data.before.daily + 1,
57
+ 'Daily should be incremented by 1'
58
+ );
59
+
60
+ // Verify total incremented
51
61
  assert.equal(
52
62
  response.data.after.total,
53
63
  response.data.before.total + 1,
@@ -69,15 +79,25 @@ module.exports = {
69
79
  assert.ok(userDoc?.usage?.requests, 'User should have requests usage');
70
80
 
71
81
  assert.equal(
72
- userDoc.usage.requests.period,
73
- state.afterFirstIncrement.period,
74
- 'Persisted period should match API response'
82
+ userDoc.usage.requests.monthly,
83
+ state.afterFirstIncrement.monthly,
84
+ 'Persisted monthly should match API response'
85
+ );
86
+ assert.equal(
87
+ userDoc.usage.requests.daily,
88
+ state.afterFirstIncrement.daily,
89
+ 'Persisted daily should match API response'
75
90
  );
76
91
  assert.equal(
77
92
  userDoc.usage.requests.total,
78
93
  state.afterFirstIncrement.total,
79
94
  'Persisted total should match API response'
80
95
  );
96
+
97
+ // Verify last timestamp exists
98
+ assert.ok(userDoc.usage.requests.last, 'Should have last object');
99
+ assert.ok(userDoc.usage.requests.last.timestamp, 'Should have last.timestamp');
100
+ assert.ok(userDoc.usage.requests.last.timestampUNIX, 'Should have last.timestampUNIX');
81
101
  },
82
102
  },
83
103
 
@@ -92,11 +112,16 @@ module.exports = {
92
112
  assert.isSuccess(response, 'Custom amount increment should succeed');
93
113
  assert.equal(response.data.amount, 5, 'Amount should be 5');
94
114
 
95
- // Verify increment happened with custom amount
115
+ // Verify all counters incremented by 5
116
+ assert.equal(
117
+ response.data.after.monthly,
118
+ response.data.before.monthly + 5,
119
+ 'Monthly should be incremented by 5'
120
+ );
96
121
  assert.equal(
97
- response.data.after.period,
98
- response.data.before.period + 5,
99
- 'Period should be incremented by 5'
122
+ response.data.after.daily,
123
+ response.data.before.daily + 5,
124
+ 'Daily should be incremented by 5'
100
125
  );
101
126
  assert.equal(
102
127
  response.data.after.total,
@@ -117,9 +142,14 @@ module.exports = {
117
142
  assert.ok(userDoc?.usage?.requests, 'User should have requests usage');
118
143
 
119
144
  assert.equal(
120
- userDoc.usage.requests.period,
121
- state.afterCustomAmount.period,
122
- 'Requests period should be persisted'
145
+ userDoc.usage.requests.monthly,
146
+ state.afterCustomAmount.monthly,
147
+ 'Requests monthly should be persisted'
148
+ );
149
+ assert.equal(
150
+ userDoc.usage.requests.daily,
151
+ state.afterCustomAmount.daily,
152
+ 'Requests daily should be persisted'
123
153
  );
124
154
  assert.equal(
125
155
  userDoc.usage.requests.total,
@@ -151,13 +181,19 @@ module.exports = {
151
181
  assert.isSuccess(response3, 'Third increment should succeed');
152
182
 
153
183
  // Verify accumulation: should be initial + 1 (test 2) + 5 (test 4) + 1 + 1 + 3 = initial + 11
154
- const expectedPeriod = state.initialPeriod + 11;
184
+ const expectedMonthly = state.initialMonthly + 11;
185
+ const expectedDaily = state.initialDaily + 11;
155
186
  const expectedTotal = state.initialTotal + 11;
156
187
 
157
188
  assert.equal(
158
- response3.data.after.period,
159
- expectedPeriod,
160
- `Period should accumulate to ${expectedPeriod}`
189
+ response3.data.after.monthly,
190
+ expectedMonthly,
191
+ `Monthly should accumulate to ${expectedMonthly}`
192
+ );
193
+ assert.equal(
194
+ response3.data.after.daily,
195
+ expectedDaily,
196
+ `Daily should accumulate to ${expectedDaily}`
161
197
  );
162
198
  assert.equal(
163
199
  response3.data.after.total,
@@ -180,9 +216,11 @@ module.exports = {
180
216
  assert.equal(response.data.authenticated, false, 'Should report as unauthenticated');
181
217
  assert.equal(response.data.key, state.unauthKey, 'Key should be unknown');
182
218
 
183
- // Verify increment happened (relative check — prior tests may have incremented too)
184
- state.unauthPeriod = response.data.after.period;
185
- assert.equal(response.data.after.period, response.data.before.period + 1, 'Period should increment by 1');
219
+ // Verify all counters incremented
220
+ assert.equal(response.data.after.monthly, response.data.before.monthly + 1, 'Monthly should increment by 1');
221
+ assert.equal(response.data.after.daily, response.data.before.daily + 1, 'Daily should increment by 1');
222
+
223
+ state.unauthMonthly = response.data.after.monthly;
186
224
  },
187
225
  },
188
226
 
@@ -194,22 +232,66 @@ module.exports = {
194
232
 
195
233
  assert.ok(usageDoc, 'Usage doc should exist in usage collection');
196
234
  assert.ok(usageDoc?.requests, 'Usage doc should have the requests metric');
197
- assert.equal(usageDoc.requests.period, state.unauthPeriod, 'Persisted period should match');
235
+ assert.equal(usageDoc.requests.monthly, state.unauthMonthly, 'Persisted monthly should match');
198
236
  },
199
237
  },
200
238
 
201
- // Test 9: Cleanup - trigger daily cron via PubSub to delete usage collection
239
+ // Test 9: Cron resets daily counters for authenticated users
202
240
  {
203
- name: 'cleanup-reset-usage',
204
- async run({ assert, firestore, state, waitFor, pubsub }) {
205
- // Verify unauthenticated usage doc exists before cron
206
- const beforeUsageDoc = await firestore.get(`usage/${state.unauthKey}`);
207
- assert.ok(beforeUsageDoc, 'Usage doc should exist before cron');
241
+ name: 'cron-resets-daily-counters',
242
+ async run({ assert, firestore, state, accounts, waitFor, pubsub }) {
243
+ // Verify daily counter is > 0 before cron
244
+ const beforeDoc = await firestore.get(`users/${accounts.basic.uid}`);
245
+ assert.ok(beforeDoc?.usage?.requests?.daily > 0, 'Daily counter should be > 0 before cron');
246
+
247
+ // Store monthly and total before cron (should NOT be reset by daily cron)
248
+ state.monthlyBeforeCron = beforeDoc.usage.requests.monthly;
249
+ state.totalBeforeCron = beforeDoc.usage.requests.total;
208
250
 
209
251
  // Trigger cron via PubSub
210
252
  await pubsub.trigger('bm_cronDaily');
211
253
 
212
- // Wait for cron to delete the usage collection doc (max 10 seconds)
254
+ // Wait for cron to reset daily counter
255
+ try {
256
+ await waitFor(
257
+ async () => {
258
+ const doc = await firestore.get(`users/${accounts.basic.uid}`);
259
+ return doc?.usage?.requests?.daily === 0;
260
+ },
261
+ 10000,
262
+ 500
263
+ );
264
+ assert.ok(true, 'Daily counter was reset to 0 by cron');
265
+ } catch (error) {
266
+ assert.fail('Daily counter should be reset to 0 within 10s');
267
+ }
268
+ },
269
+ },
270
+
271
+ // Test 10: Cron preserves monthly and total counters (non-1st of month)
272
+ {
273
+ name: 'cron-preserves-monthly-and-total',
274
+ async run({ assert, firestore, state, accounts }) {
275
+ const afterDoc = await firestore.get(`users/${accounts.basic.uid}`);
276
+
277
+ assert.equal(
278
+ afterDoc.usage.requests.monthly,
279
+ state.monthlyBeforeCron,
280
+ 'Monthly counter should be preserved after daily cron'
281
+ );
282
+ assert.equal(
283
+ afterDoc.usage.requests.total,
284
+ state.totalBeforeCron,
285
+ 'Total counter should be preserved after daily cron'
286
+ );
287
+ },
288
+ },
289
+
290
+ // Test 11: Cron deletes unauthenticated usage collection
291
+ {
292
+ name: 'cron-deletes-unauthenticated-usage',
293
+ async run({ assert, firestore, state, waitFor }) {
294
+ // The cron was already triggered in test 9, so the usage collection should be deleted
213
295
  try {
214
296
  await waitFor(
215
297
  async () => {
@@ -225,5 +307,27 @@ module.exports = {
225
307
  }
226
308
  },
227
309
  },
310
+
311
+ // Test 12: Daily counter accumulates after cron reset
312
+ {
313
+ name: 'daily-counter-accumulates-after-reset',
314
+ async run({ http, assert }) {
315
+ // After cron reset daily to 0, new increments should start from 0
316
+ const response = await http.as('basic').post('test/usage', {
317
+ amount: 3,
318
+ });
319
+
320
+ assert.isSuccess(response, 'Increment after cron reset should succeed');
321
+ assert.equal(response.data.before.daily, 0, 'Daily should be 0 after cron reset');
322
+ assert.equal(response.data.after.daily, 3, 'Daily should be 3 after increment');
323
+
324
+ // Monthly should have continued accumulating (not reset)
325
+ assert.equal(
326
+ response.data.after.monthly,
327
+ response.data.before.monthly + 3,
328
+ 'Monthly should continue accumulating'
329
+ );
330
+ },
331
+ },
228
332
  ],
229
333
  };