Skip to content

Commit 478944d

Browse files
feat(billing): lastResetAt + archivedAt schema + meter cap (PR-N8) (#3557)
* feat(billing): lastResetAt + archivedAt schema + meter cap (PR-N8) P2.8 + P2.9 + P2.12 — robustness pass on the billing surface. - P2.8: Subscription gains lastResetAt field. New repository method findAllDueForResetByLastReset(now) replaces the 7-day window query (resilient to scheduler delays beyond a week). resetAllDue persists lastResetAt after each successful resetWeek. Legacy findAllDueForReset kept and marked @deprecated for backward compat. - P2.9: BillingUsage Mongoose + Zod schema declares archivedAt: Date|null. archiveOtherWeeks() now queries by archivedAt: null instead of $exists: false, matching the schema-default behavior. Without this the field was silently stripped on write per Mongoose strict mode. - P2.12: meter.service.attribute() applies maxUnitsPerOperation as a clamp+warn before incrementMeter. Feature breakdown is rescaled proportionally so persisted bucket totals stay consistent with meterUsed. Also updates scripts/tests/billing.cron.weeklyReset.unit.tests.js to mock the new findAllDueForResetByLastReset + updateLastResetAt methods. * fix(billing): capBreakdown — proportional rescaling + Object.create(null) + isCapped var - Replace greedy strategy with proportional rescaling (ratio × floor + remainder distributed to largest buckets) so breakdown buckets always sum to cappedUnits exactly, matching the 'rescaled proportionally' intent in the PR description. - Use Object.create(null) for cappedBreakdown to prevent prototype pollution (Codacy HIGH risk). - Extract isCapped boolean to eliminate the redundant cappedUnits < totalUnits double-check (Codacy LOW risk). - Pass originalTotal as a third arg to capBreakdown from attribute(). - Add 3 direct unit tests for capBreakdown (proportional multi-key, floor remainder distribution, empty breakdown) as flagged missing by Codacy.
1 parent 036e1bb commit 478944d

13 files changed

Lines changed: 383 additions & 20 deletions

modules/billing/models/billing.subscription.model.mongoose.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,14 @@ const SubscriptionMongoose = new Schema(
7171
type: Date,
7272
default: null,
7373
},
74+
/**
75+
* Timestamp of the last successful weekly meter reset sweep.
76+
* Used by the scheduler to recover after delayed or missed runs.
77+
*/
78+
lastResetAt: {
79+
type: Date,
80+
default: null,
81+
},
7482
},
7583
{
7684
timestamps: true,

modules/billing/models/billing.subscription.schema.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const baseShape = {
2424
planVersion: z.string().trim().optional(),
2525
currentPeriodStart: z.coerce.date().nullable().optional(),
2626
pastDueSince: z.coerce.date().nullable().optional(),
27+
lastResetAt: z.coerce.date().nullable().optional(),
2728
};
2829

2930
const Subscription = z.object({

modules/billing/models/billing.usage.model.mongoose.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,13 @@ const UsageMongoose = new Schema(
9797
type: Date,
9898
default: null,
9999
},
100+
/**
101+
* Timestamp when this usage period was archived by the reset sweep.
102+
*/
103+
archivedAt: {
104+
type: Date,
105+
default: null,
106+
},
100107
/**
101108
* ObjectIds of History documents consumed (attributed) this period.
102109
* Used for idempotent attribution checks.

modules/billing/models/billing.usage.schema.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ const BillingUsage = z.object({
3636
resetAt: z.coerce.date().optional().nullable(),
3737
alertedAt80: z.coerce.date().optional().nullable(),
3838
alertedAt100: z.coerce.date().optional().nullable(),
39+
archivedAt: z.coerce.date().optional().nullable(),
3940

4041
/**
4142
* Array of ObjectIds of History documents attributed to this period.

modules/billing/repositories/billing.subscription.repository.js

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ const findPlan = (organizationId) => {
128128
* @description Fetch active/trialing subscriptions whose currentPeriodStart falls
129129
* within the provided time window. Used by the weekly meter reset sweep.
130130
* Returns lean plain objects (no population) for performance.
131+
* @deprecated Prefer findAllDueForResetByLastReset() for scheduler-delay resilience.
131132
* @param {Date} from - The start of the window (inclusive).
132133
* @param {Date} to - The end of the window (inclusive).
133134
* @returns {Promise<Array<{organization: string, currentPeriodStart: Date}>>}
@@ -142,6 +143,52 @@ const findAllDueForReset = (from, to) =>
142143
{ organization: 1, currentPeriodStart: 1 },
143144
).lean();
144145

146+
/**
147+
* @function findAllDueForResetByLastReset
148+
* @description Fetch active/trialing subscriptions whose last successful reset is missing
149+
* or older than 7 days. Filters out subscriptions without currentPeriodStart
150+
* because resetWeek derives the next usage period from that timestamp.
151+
* Returns lean plain objects (no population) for performance.
152+
* @param {Date} now - The current timestamp used to compute the stale-reset threshold.
153+
* @returns {Promise<Array<{organization: string, currentPeriodStart: Date, lastResetAt: Date|null}>>}
154+
*/
155+
// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js repository, not Qwik
156+
const findAllDueForResetByLastReset = (now) => {
157+
if (!(now instanceof Date)) throw new TypeError('now must be a Date instance');
158+
const threshold = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
159+
160+
return Subscription.find(
161+
{
162+
status: { $in: ['active', 'trialing'] },
163+
currentPeriodStart: { $ne: null },
164+
$or: [
165+
{ lastResetAt: null },
166+
{ lastResetAt: { $lt: threshold } },
167+
],
168+
},
169+
{ organization: 1, currentPeriodStart: 1, lastResetAt: 1 },
170+
).lean();
171+
};
172+
173+
/**
174+
* @function updateLastResetAt
175+
* @description Update the last successful reset timestamp for a subscription by organization.
176+
* @param {string} organizationId - The organization ObjectId (string).
177+
* @param {Date} date - The timestamp to persist.
178+
* @returns {Promise<Object|null>} The updated subscription document, or null if the id is invalid.
179+
*/
180+
// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js repository, not Qwik
181+
const updateLastResetAt = (organizationId, date) => {
182+
if (!mongoose.Types.ObjectId.isValid(organizationId)) return null;
183+
if (!(date instanceof Date)) throw new TypeError('date must be a Date instance');
184+
185+
return Subscription.findOneAndUpdate(
186+
{ organization: organizationId },
187+
{ $set: { lastResetAt: date } },
188+
{ returnDocument: 'after', runValidators: true },
189+
).exec();
190+
};
191+
145192
/**
146193
* @function findStaleDunning
147194
* @description Fetch subscriptions with status 'past_due' whose pastDueSince is set
@@ -191,6 +238,8 @@ export default {
191238
findByStripeCustomerId,
192239
findByStripeSubscriptionId,
193240
findAllDueForReset,
241+
findAllDueForResetByLastReset,
242+
updateLastResetAt,
194243
findStaleDunning,
195244
markUnpaid,
196245
};

modules/billing/repositories/billing.usage.repository.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ const archiveOtherWeeks = (orgId, currentWeekKey, archivedAt) =>
176176
{
177177
organizationId: orgId,
178178
weekKey: { $ne: currentWeekKey },
179-
archivedAt: { $exists: false },
179+
archivedAt: null,
180180
},
181181
{ $set: { archivedAt } },
182182
);

modules/billing/services/billing.meter.service.js

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,49 @@ const unitsFromCosts = async (costs, planId, ratioVersion) => {
5656
return { totalUnits, breakdown };
5757
};
5858

59+
/**
60+
* @function capBreakdown
61+
* @description Rescale a feature breakdown map proportionally so its summed units do not exceed
62+
* the capped total. Each bucket is scaled by (cappedUnits / originalTotal) and
63+
* floored; any remainder is distributed to the largest remaining buckets so that
64+
* sum(result) === cappedUnits exactly.
65+
* @param {Object} breakdown - Feature-keyed units map.
66+
* @param {number} cappedUnits - Final capped total units to apply.
67+
* @param {number} originalTotal - Original uncapped total (used as the scaling denominator).
68+
* @returns {Object} Proportionally rescaled feature-keyed units map.
69+
*/
70+
const capBreakdown = (breakdown, cappedUnits, originalTotal) => {
71+
if (!breakdown || typeof breakdown !== 'object' || cappedUnits === Infinity) return breakdown;
72+
73+
const entries = Object.entries(breakdown).filter(
74+
([, value]) => Number.isFinite(value) && value > 0,
75+
);
76+
if (entries.length === 0 || originalTotal <= 0) return Object.create(null);
77+
78+
const ratio = cappedUnits / originalTotal;
79+
const cappedBreakdown = Object.create(null);
80+
let allocated = 0;
81+
82+
for (const [key, value] of entries) {
83+
const scaled = Math.floor(value * ratio);
84+
cappedBreakdown[key] = scaled;
85+
allocated += scaled;
86+
}
87+
88+
// Distribute remainder (due to floor) to the largest buckets first
89+
let remainder = cappedUnits - allocated;
90+
if (remainder > 0) {
91+
const sorted = entries.slice().sort(([, a], [, b]) => b - a);
92+
for (const [key] of sorted) {
93+
if (remainder <= 0) break;
94+
cappedBreakdown[key] += 1;
95+
remainder -= 1;
96+
}
97+
}
98+
99+
return cappedBreakdown;
100+
};
101+
59102
/**
60103
* @function attribute
61104
* @description Attribute meter units from a History-like input to a Usage document
@@ -90,12 +133,22 @@ const attribute = async (history, organizationId) => {
90133
({ totalUnits, breakdown } = await unitsFromCosts(history.costs, planId, ratioVersion));
91134
}
92135

136+
const maxUnits = config?.billing?.meter?.maxUnitsPerOperation ?? Infinity;
137+
const cappedUnits = Math.min(totalUnits, maxUnits);
138+
const isCapped = cappedUnits < totalUnits;
139+
const cappedBreakdown = isCapped ? capBreakdown(breakdown, cappedUnits, totalUnits) : breakdown;
140+
if (isCapped) {
141+
console.warn(
142+
`[billing.meter] units capped: requested ${totalUnits}, cap ${maxUnits}, applied ${cappedUnits}`,
143+
);
144+
}
145+
93146
const idempotencyKey = history._id?.toString?.() ?? String(history._id);
94147

95148
const result = await BillingUsageService.incrementMeter(
96149
organizationId,
97-
totalUnits,
98-
breakdown,
150+
cappedUnits,
151+
cappedBreakdown,
99152
idempotencyKey,
100153
);
101154

@@ -122,5 +175,6 @@ const attribute = async (history, organizationId) => {
122175
export default {
123176
unitsFromCosts,
124177
attribute,
178+
capBreakdown,
125179
METER_RUN_BASE,
126180
};

modules/billing/services/billing.reset.service.js

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -103,18 +103,15 @@ const resetAllDue = async () => {
103103
if (!config?.billing?.meterMode) return { processed: 0, errors: 0 };
104104

105105
const now = new Date();
106-
const oneWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
107-
108-
// Find subscriptions whose period started within the last week.
109-
// TODO PR-N5: replace window query with lastResetAt field for scheduler-delay resilience.
110-
const subs = await BillingSubscriptionRepository.findAllDueForReset(oneWeekAgo, now);
106+
const subs = await BillingSubscriptionRepository.findAllDueForResetByLastReset(now);
111107

112108
let processed = 0;
113109
let errors = 0;
114110

115111
for (const sub of subs) {
116112
try {
117113
await resetWeek(String(sub.organization), new Date(sub.currentPeriodStart));
114+
await BillingSubscriptionRepository.updateLastResetAt(String(sub.organization), now);
118115
processed += 1;
119116
} catch (err) {
120117
errors += 1;

modules/billing/tests/billing.meter.service.unit.tests.js

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ describe('BillingMeterService unit tests:', () => {
1010
let BillingMeterService;
1111
let mockBillingPlanService;
1212
let mockConfig;
13+
let mockBillingUsageService;
14+
let mockBillingExtraService;
1315

1416
const orgId = '507f1f77bcf86cd799439011';
1517

@@ -48,6 +50,14 @@ describe('BillingMeterService unit tests:', () => {
4850
getActivePlan: jest.fn(),
4951
};
5052

53+
mockBillingUsageService = {
54+
incrementMeter: jest.fn(),
55+
};
56+
57+
mockBillingExtraService = {
58+
debit: jest.fn(),
59+
};
60+
5161
jest.unstable_mockModule('../../../config/index.js', () => ({
5262
default: mockConfig,
5363
}));
@@ -56,6 +66,14 @@ describe('BillingMeterService unit tests:', () => {
5666
default: mockBillingPlanService,
5767
}));
5868

69+
jest.unstable_mockModule('../services/billing.usage.service.js', () => ({
70+
default: mockBillingUsageService,
71+
}));
72+
73+
jest.unstable_mockModule('../services/billing.extra.service.js', () => ({
74+
default: mockBillingExtraService,
75+
}));
76+
5977
const mod = await import('../services/billing.meter.service.js');
6078
BillingMeterService = mod.default;
6179
});
@@ -155,4 +173,132 @@ describe('BillingMeterService unit tests:', () => {
155173
expect(result.meterUsed).toBe(0);
156174
});
157175
});
176+
177+
describe('attribute — maxUnitsPerOperation cap', () => {
178+
test('caps metered units when computed units exceed config cap', async () => {
179+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
180+
mockBillingPlanService.getPlanByVersion.mockResolvedValue(makePlan({ ratios: { scrap: 1 } }));
181+
mockBillingUsageService.incrementMeter.mockResolvedValue({
182+
applied: true,
183+
meterUsed: 10000,
184+
extrasConsumed: 0,
185+
});
186+
187+
const history = {
188+
_id: '507f1f77bcf86cd799439033',
189+
costs: { scrap: 20 },
190+
planId: 'pro',
191+
planVersion: 'v1',
192+
};
193+
194+
const result = await BillingMeterService.attribute(history, orgId);
195+
196+
expect(mockBillingUsageService.incrementMeter).toHaveBeenCalledWith(
197+
orgId,
198+
10000,
199+
{ scrap: 10000 },
200+
'507f1f77bcf86cd799439033',
201+
);
202+
expect(warnSpy).toHaveBeenCalledWith(
203+
'[billing.meter] units capped: requested 20000, cap 10000, applied 10000',
204+
);
205+
expect(result).toEqual({ applied: true, meterUsed: 10000, extrasConsumed: 0 });
206+
});
207+
208+
test('does not clamp when units are within the configured cap', async () => {
209+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
210+
mockBillingPlanService.getPlanByVersion.mockResolvedValue(makePlan({ ratios: { scrap: 1 } }));
211+
mockBillingUsageService.incrementMeter.mockResolvedValue({
212+
applied: true,
213+
meterUsed: 5000,
214+
extrasConsumed: 0,
215+
});
216+
217+
const history = {
218+
_id: '507f1f77bcf86cd799439034',
219+
costs: { scrap: 5 },
220+
planId: 'pro',
221+
planVersion: 'v1',
222+
};
223+
224+
await BillingMeterService.attribute(history, orgId);
225+
226+
expect(mockBillingUsageService.incrementMeter).toHaveBeenCalledWith(
227+
orgId,
228+
5000,
229+
{ scrap: 5000 },
230+
'507f1f77bcf86cd799439034',
231+
);
232+
expect(warnSpy).not.toHaveBeenCalled();
233+
});
234+
235+
test('does not clamp when maxUnitsPerOperation is undefined', async () => {
236+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
237+
delete mockConfig.billing.meter.maxUnitsPerOperation;
238+
mockBillingPlanService.getPlanByVersion.mockResolvedValue(makePlan({ ratios: { scrap: 1 } }));
239+
mockBillingUsageService.incrementMeter.mockResolvedValue({
240+
applied: true,
241+
meterUsed: 20000,
242+
extrasConsumed: 0,
243+
});
244+
245+
const history = {
246+
_id: '507f1f77bcf86cd799439035',
247+
costs: { scrap: 20 },
248+
planId: 'pro',
249+
planVersion: 'v1',
250+
};
251+
252+
await BillingMeterService.attribute(history, orgId);
253+
254+
expect(mockBillingUsageService.incrementMeter).toHaveBeenCalledWith(
255+
orgId,
256+
20000,
257+
{ scrap: 20000 },
258+
'507f1f77bcf86cd799439035',
259+
);
260+
expect(warnSpy).not.toHaveBeenCalled();
261+
});
262+
});
263+
264+
describe('capBreakdown — proportional rescaling', () => {
265+
test('rescales a multi-key breakdown proportionally to the capped total', async () => {
266+
const mod = await import('../services/billing.meter.service.js');
267+
const { capBreakdown } = mod.default;
268+
269+
// breakdown: { scrap: 6000, autofix: 4000 } → total = 10000, cap to 5000
270+
// scrap: floor(6000 * 5000/10000) = 3000
271+
// autofix: floor(4000 * 5000/10000) = 2000
272+
// allocated = 5000, remainder = 0
273+
const result = capBreakdown({ scrap: 6000, autofix: 4000 }, 5000, 10000);
274+
275+
expect(result.scrap).toBe(3000);
276+
expect(result.autofix).toBe(2000);
277+
expect(result.scrap + result.autofix).toBe(5000);
278+
});
279+
280+
test('distributes floor remainder to the largest bucket first', async () => {
281+
const mod = await import('../services/billing.meter.service.js');
282+
const { capBreakdown } = mod.default;
283+
284+
// breakdown: { a: 3, b: 2, c: 1 } → total = 6, cap to 4
285+
// a: floor(3 * 4/6) = floor(2) = 2
286+
// b: floor(2 * 4/6) = floor(1.33) = 1
287+
// c: floor(1 * 4/6) = floor(0.66) = 0
288+
// allocated = 3, remainder = 1 → add 1 to largest bucket (a)
289+
const result = capBreakdown({ a: 3, b: 2, c: 1 }, 4, 6);
290+
291+
expect(result.a + result.b + (result.c ?? 0)).toBe(4);
292+
expect(result.a).toBeGreaterThanOrEqual(result.b);
293+
});
294+
295+
test('returns empty object when breakdown has no valid entries', async () => {
296+
const mod = await import('../services/billing.meter.service.js');
297+
const { capBreakdown } = mod.default;
298+
299+
const result = capBreakdown({}, 5000, 10000);
300+
301+
expect(Object.keys(result)).toHaveLength(0);
302+
});
303+
});
158304
});

0 commit comments

Comments
 (0)