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 +18 -19
- package/README.md +6 -6
- package/package.json +1 -1
- package/src/manager/cron/daily/reset-usage.js +79 -73
- package/src/manager/helpers/usage.js +44 -20
- package/src/manager/helpers/user.js +2 -1
- package/src/manager/routes/test/usage/post.js +10 -6
- package/test/_legacy/usage.js +5 -5
- package/test/routes/test/usage.js +134 -30
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`, `
|
|
650
|
-
- `
|
|
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
|
-
###
|
|
655
|
+
### Limits & Daily Caps
|
|
654
656
|
|
|
655
|
-
|
|
657
|
+
Limits are always specified as **monthly** values in product config (e.g., `limits.requests = 100` means 100/month).
|
|
656
658
|
|
|
657
|
-
|
|
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
|
-
|
|
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": "
|
|
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.*.
|
|
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
|
|
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: {
|
|
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
|
|
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
|
@@ -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
|
|
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
|
-
|
|
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
|
|
21
|
-
await
|
|
21
|
+
// Reset authenticated user counters (daily + monthly on 1st)
|
|
22
|
+
await resetAuthenticated(Manager, assistant);
|
|
22
23
|
};
|
|
23
24
|
|
|
24
|
-
|
|
25
|
-
assistant.log('[local]:
|
|
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]:
|
|
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
|
|
51
|
-
const
|
|
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
|
|
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
|
-
|
|
75
|
-
metrics.requests = true;
|
|
58
|
+
assistant.log(`[authenticated]: Resetting ${isFirstOfMonth ? 'daily + monthly' : 'daily'} for metrics`, metricNames);
|
|
76
59
|
|
|
77
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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}.
|
|
78
|
+
{ field: `usage.${metric}.daily`, operator: '>', value: 0 },
|
|
122
79
|
],
|
|
123
80
|
batchSize: 5000,
|
|
124
|
-
log:
|
|
81
|
+
log: false,
|
|
125
82
|
})
|
|
126
|
-
.
|
|
127
|
-
assistant.
|
|
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 ${
|
|
136
|
+
assistant.errorify(`Error resetting ${uid}: ${e}`, { code: 500, log: true });
|
|
131
137
|
});
|
|
132
138
|
}
|
|
133
139
|
|
|
134
|
-
assistant.log(
|
|
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
|
|
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 ${
|
|
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 ${
|
|
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
|
|
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
|
|
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
|
-
|
|
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} (${
|
|
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 (
|
|
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
|
|
204
|
-
['total', '
|
|
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
|
|
241
|
-
const resolved = `usage.${name}.
|
|
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}.
|
|
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
|
|
294
|
-
*
|
|
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
|
|
297
|
-
* Products can set rateLimit: 'daily'
|
|
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 || '
|
|
327
|
+
const rateLimit = product.rateLimit || 'daily';
|
|
305
328
|
|
|
306
|
-
// If
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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: {
|
|
29
|
-
after: {
|
|
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
|
-
|
|
40
|
+
monthly: beforeMonthly,
|
|
41
|
+
daily: beforeDaily,
|
|
39
42
|
total: beforeTotal,
|
|
40
43
|
},
|
|
41
44
|
after: {
|
|
42
|
-
|
|
45
|
+
monthly: afterMonthly,
|
|
46
|
+
daily: afterDaily,
|
|
43
47
|
total: afterTotal,
|
|
44
48
|
},
|
|
45
49
|
user: {
|
package/test/_legacy/usage.js
CHANGED
|
@@ -54,7 +54,7 @@ describe(`${package.name}`, () => {
|
|
|
54
54
|
return assert.deepStrictEqual({
|
|
55
55
|
requests: {
|
|
56
56
|
total: 0,
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
46
|
+
// Verify monthly incremented
|
|
46
47
|
assert.equal(
|
|
47
|
-
response.data.after.
|
|
48
|
-
response.data.before.
|
|
49
|
-
'
|
|
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.
|
|
73
|
-
state.afterFirstIncrement.
|
|
74
|
-
'Persisted
|
|
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
|
|
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.
|
|
98
|
-
response.data.before.
|
|
99
|
-
'
|
|
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.
|
|
121
|
-
state.afterCustomAmount.
|
|
122
|
-
'Requests
|
|
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
|
|
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.
|
|
159
|
-
|
|
160
|
-
`
|
|
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
|
|
184
|
-
|
|
185
|
-
assert.equal(response.data.after.
|
|
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.
|
|
235
|
+
assert.equal(usageDoc.requests.monthly, state.unauthMonthly, 'Persisted monthly should match');
|
|
198
236
|
},
|
|
199
237
|
},
|
|
200
238
|
|
|
201
|
-
// Test 9:
|
|
239
|
+
// Test 9: Cron resets daily counters for authenticated users
|
|
202
240
|
{
|
|
203
|
-
name: '
|
|
204
|
-
async run({ assert, firestore, state, waitFor, pubsub }) {
|
|
205
|
-
// Verify
|
|
206
|
-
const
|
|
207
|
-
assert.ok(
|
|
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
|
|
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
|
};
|