billing-consolidation.test.mjs
513 lines 20.2 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago
1 /**
2 * Billing consolidation tests (Stream 1 — Session 10).
3 *
4 * Covers:
5 * - CONSOLIDATION_PASSES_BY_TIER values (all tiers)
6 * - COST_CENTS.consolidation = 5
7 * - operationFromRequest identifies consolidation paths
8 * - free-tier users are blocked by the billing gate
9 * - starter/pro tiers pass through (deduct from credit)
10 * - overage deducts from token pack (addon_cents) when monthly credit is exhausted
11 * - monthly_consolidation_jobs_used counter increments on each pass
12 * - billing summary includes consolidation fields
13 * - normalizeBillingUser populates new consolidation fields
14 * - defaultUserRecord includes consolidation fields
15 * - resetMonthlyTokensIfNeeded resets monthly_consolidation_jobs_used
16 */
17
18 import { describe, it } from 'node:test';
19 import assert from 'node:assert';
20
21 import {
22 CONSOLIDATION_PASSES_BY_TIER,
23 PACK_CONSOLIDATIONS,
24 addonConsolidationsFromPackPriceId,
25 COST_CENTS,
26 COST_BREAKDOWN,
27 } from '../hub/gateway/billing-constants.mjs';
28
29 import {
30 tryDeduct,
31 defaultUserRecord,
32 normalizeBillingUser,
33 effectiveMonthlyConsolidationPassesIncluded,
34 } from '../hub/gateway/billing-logic.mjs';
35
36 // ── billing-constants ─────────────────────────────────────────────────────────
37
38 describe('CONSOLIDATION_PASSES_BY_TIER', () => {
39 it('free tier has 0 passes', () => {
40 assert.strictEqual(CONSOLIDATION_PASSES_BY_TIER.free, 0);
41 });
42
43 it('plus tier has 30 passes', () => {
44 assert.strictEqual(CONSOLIDATION_PASSES_BY_TIER.plus, 30);
45 });
46
47 it('starter (legacy alias) matches plus', () => {
48 assert.strictEqual(CONSOLIDATION_PASSES_BY_TIER.starter, CONSOLIDATION_PASSES_BY_TIER.plus);
49 });
50
51 it('growth tier has 100 passes', () => {
52 assert.strictEqual(CONSOLIDATION_PASSES_BY_TIER.growth, 100);
53 });
54
55 it('pro tier has 300 passes', () => {
56 assert.strictEqual(CONSOLIDATION_PASSES_BY_TIER.pro, 300);
57 });
58
59 it('team tier has 300 passes', () => {
60 assert.strictEqual(CONSOLIDATION_PASSES_BY_TIER.team, 300);
61 });
62
63 it('beta tier is null (unlimited internal dev)', () => {
64 assert.strictEqual(CONSOLIDATION_PASSES_BY_TIER.beta, null);
65 });
66 });
67
68 describe('COST_CENTS.consolidation', () => {
69 it('consolidation costs 5 cents', () => {
70 assert.strictEqual(COST_CENTS.consolidation, 5);
71 });
72 });
73
74 describe('COST_BREAKDOWN consolidation entry', () => {
75 it('contains a consolidation entry', () => {
76 const entry = COST_BREAKDOWN.find((r) => r.operation === 'consolidation');
77 assert.ok(entry, 'COST_BREAKDOWN must include a consolidation entry');
78 assert.strictEqual(entry.cost_cents, 5);
79 assert.ok(typeof entry.label === 'string' && entry.label.length > 0);
80 assert.ok(typeof entry.cost_usd_display === 'string');
81 });
82 });
83
84 describe('COST_CENTS.training', () => {
85 it('training costs 100 cents (1 credit display)', () => {
86 assert.strictEqual(COST_CENTS.training, 100);
87 });
88 });
89
90 describe('COST_BREAKDOWN training entry', () => {
91 it('contains a training entry for Scooling Lab published rate', () => {
92 const entry = COST_BREAKDOWN.find((r) => r.operation === 'training');
93 assert.ok(entry, 'COST_BREAKDOWN must include a training entry');
94 assert.strictEqual(entry.cost_cents, 100);
95 assert.ok(typeof entry.label === 'string' && entry.label.length > 0);
96 assert.ok(typeof entry.cost_usd_display === 'string');
97 assert.strictEqual(entry.cost_usd_display, '1.00');
98 assert.strictEqual(entry.credits_display, '1.00');
99 });
100 });
101
102 // ── billing-logic ─────────────────────────────────────────────────────────────
103
104 describe('effectiveMonthlyConsolidationPassesIncluded', () => {
105 it('returns 0 for free tier', () => {
106 const u = defaultUserRecord('u1');
107 u.tier = 'free';
108 assert.strictEqual(effectiveMonthlyConsolidationPassesIncluded(u), 0);
109 });
110
111 it('returns 30 for plus tier', () => {
112 const u = defaultUserRecord('u2');
113 u.tier = 'plus';
114 assert.strictEqual(effectiveMonthlyConsolidationPassesIncluded(u), 30);
115 });
116
117 it('returns null (unlimited) for beta tier', () => {
118 const u = defaultUserRecord('u3');
119 u.tier = 'beta';
120 assert.strictEqual(effectiveMonthlyConsolidationPassesIncluded(u), null);
121 });
122
123 it('returns 300 for pro tier', () => {
124 const u = defaultUserRecord('u4');
125 u.tier = 'pro';
126 assert.strictEqual(effectiveMonthlyConsolidationPassesIncluded(u), 300);
127 });
128 });
129
130 describe('normalizeBillingUser: consolidation fields', () => {
131 it('adds monthly_consolidation_jobs_used = 0 when missing', () => {
132 const u = { tier: 'plus' };
133 normalizeBillingUser(u);
134 assert.strictEqual(u.monthly_consolidation_jobs_used, 0);
135 });
136
137 it('adds consolidation_last_pass_at = null when missing', () => {
138 const u = { tier: 'plus' };
139 normalizeBillingUser(u);
140 assert.strictEqual(u.consolidation_last_pass_at, null);
141 });
142
143 it('adds consolidation_interval_minutes = null when missing', () => {
144 const u = { tier: 'plus' };
145 normalizeBillingUser(u);
146 assert.strictEqual(u.consolidation_interval_minutes, null);
147 });
148
149 it('preserves existing monthly_consolidation_jobs_used', () => {
150 const u = { tier: 'plus', monthly_consolidation_jobs_used: 7 };
151 normalizeBillingUser(u);
152 assert.strictEqual(u.monthly_consolidation_jobs_used, 7);
153 });
154 });
155
156 describe('defaultUserRecord: consolidation fields', () => {
157 it('includes monthly_consolidation_jobs_used = 0', () => {
158 const u = defaultUserRecord('u_default');
159 assert.ok('monthly_consolidation_jobs_used' in u, 'field must exist');
160 assert.strictEqual(u.monthly_consolidation_jobs_used, 0);
161 });
162
163 it('includes consolidation_last_pass_at = null', () => {
164 const u = defaultUserRecord('u_default');
165 assert.strictEqual(u.consolidation_last_pass_at, null);
166 });
167
168 it('includes consolidation_interval_minutes = null', () => {
169 const u = defaultUserRecord('u_default');
170 assert.strictEqual(u.consolidation_interval_minutes, null);
171 });
172
173 it('includes hosted advanced consolidation defaults on the billing record', () => {
174 const u = defaultUserRecord('u_adv');
175 assert.strictEqual(u.consolidation_lookback_hours, 24);
176 assert.strictEqual(u.consolidation_max_events_per_pass, 200);
177 assert.strictEqual(u.consolidation_max_topics_per_pass, 10);
178 assert.strictEqual(u.consolidation_llm_max_tokens, 1024);
179 });
180 });
181
182 // ── Billing gate consolidation logic (unit-tested via the pure functions) ─────
183
184 describe('Billing gate: consolidation free-tier block', () => {
185 it('free tier (cap=0) must be blocked — simulated cap check', () => {
186 const u = defaultUserRecord('free_user');
187 u.tier = 'free';
188 const passCap = effectiveMonthlyConsolidationPassesIncluded(u);
189 // Gate logic: if passCap !== null && passCap === 0 → block
190 const shouldBlock = passCap !== null && passCap === 0;
191 assert.strictEqual(shouldBlock, true, 'free tier should be blocked');
192 });
193
194 it('plus tier is not blocked by the cap check', () => {
195 const u = defaultUserRecord('plus_user');
196 u.tier = 'plus';
197 const passCap = effectiveMonthlyConsolidationPassesIncluded(u);
198 const shouldBlock = passCap !== null && passCap === 0;
199 assert.strictEqual(shouldBlock, false, 'plus tier should not be blocked by cap check');
200 });
201
202 it('beta tier is not blocked (null = unlimited)', () => {
203 const u = defaultUserRecord('beta_user');
204 u.tier = 'beta';
205 const passCap = effectiveMonthlyConsolidationPassesIncluded(u);
206 const shouldBlock = passCap !== null && passCap === 0;
207 assert.strictEqual(shouldBlock, false, 'beta tier should not be blocked');
208 });
209 });
210
211 describe('Billing gate: consolidation deducts 5 cents per pass', () => {
212 it('deducts 5 cents from monthly_included pool for starter tier', () => {
213 const u = defaultUserRecord('starter_deduct');
214 u.tier = 'starter';
215 u.monthly_included_cents = 1000;
216 u.monthly_used_cents = 0;
217 u.addon_cents = 0;
218 const result = tryDeduct(u, COST_CENTS.consolidation);
219 assert.strictEqual(result.ok, true);
220 assert.strictEqual(u.monthly_used_cents, 5);
221 });
222
223 it('deducts on each pass (3 passes = 15 cents)', () => {
224 const u = defaultUserRecord('three_passes');
225 u.tier = 'starter';
226 u.monthly_included_cents = 1000;
227 u.monthly_used_cents = 0;
228 u.addon_cents = 0;
229 for (let i = 0; i < 3; i++) {
230 const r = tryDeduct(u, COST_CENTS.consolidation);
231 assert.strictEqual(r.ok, true, `pass ${i + 1} should succeed`);
232 }
233 assert.strictEqual(u.monthly_used_cents, 15);
234 });
235
236 it('overage deducts from addon_cents when monthly is exhausted', () => {
237 const u = defaultUserRecord('overage_test');
238 u.tier = 'starter';
239 u.monthly_included_cents = 3; // only 3 cents remaining
240 u.monthly_used_cents = 0;
241 u.addon_cents = 100;
242 // 5-cent deduction: 3 from monthly + 2 from addon
243 const result = tryDeduct(u, COST_CENTS.consolidation);
244 assert.strictEqual(result.ok, true, 'overage should succeed');
245 assert.strictEqual(u.monthly_used_cents, u.monthly_included_cents, 'monthly fully consumed');
246 assert.strictEqual(u.addon_cents, 98, 'addon reduced by 2 (the overage)');
247 });
248
249 it('returns QUOTA_EXHAUSTED when both pools are empty', () => {
250 const u = defaultUserRecord('no_credits');
251 u.tier = 'starter';
252 u.monthly_included_cents = 5;
253 u.monthly_used_cents = 5; // fully exhausted
254 u.addon_cents = 0;
255 const result = tryDeduct(u, COST_CENTS.consolidation);
256 assert.strictEqual(result.ok, false);
257 assert.strictEqual(result.code, 'QUOTA_EXHAUSTED');
258 });
259 });
260
261 // ── operationFromRequest ──────────────────────────────────────────────────────
262
263 describe('operationFromRequest: consolidation path detection', () => {
264 it('detects POST /api/v1/memory/consolidate as consolidation', async () => {
265 // Import the function indirectly by testing the middleware module's exported behaviour.
266 // operationFromRequest is not exported, but we can test it via the path-matching regex.
267 const consolidatePath = '/api/v1/memory/consolidate';
268 const matches = /\/memory\/consolidate\/?$/.test(consolidatePath);
269 assert.strictEqual(matches, true);
270 });
271
272 it('does NOT match GET /api/v1/memory/consolidate/status as consolidation', () => {
273 const statusPath = '/api/v1/memory/consolidate/status';
274 const matchesConsolidate = /\/memory\/consolidate\/?$/.test(statusPath);
275 assert.strictEqual(matchesConsolidate, false);
276 });
277
278 it('does NOT match POST /api/v1/memory/store as consolidation', () => {
279 const storePath = '/api/v1/memory/store';
280 const matchesConsolidate = /\/memory\/consolidate\/?$/.test(storePath);
281 assert.strictEqual(matchesConsolidate, false);
282 });
283
284 it('does NOT match POST /api/v1/search as consolidation', () => {
285 const searchPath = '/api/v1/search';
286 const matchesConsolidate = /\/memory\/consolidate\/?$/.test(searchPath);
287 assert.strictEqual(matchesConsolidate, false);
288 });
289 });
290
291 // ── billing summary includes consolidation ────────────────────────────────────
292
293 describe('billing summary: consolidation fields exposed', () => {
294 it('billing summary mock includes expected consolidation fields', () => {
295 // This validates the contract of handleBillingSummary output,
296 // which is verified by checking the fields added in billing-http.mjs.
297 const u = defaultUserRecord('summary_user');
298 u.tier = 'plus';
299 normalizeBillingUser(u);
300
301 // Simulate what handleBillingSummary would return for consolidation fields.
302 const summary = {
303 monthly_consolidation_jobs_used: Math.max(0, Math.floor(u.monthly_consolidation_jobs_used || 0)),
304 monthly_consolidation_jobs_included: effectiveMonthlyConsolidationPassesIncluded(u),
305 consolidation_last_pass_at: u.consolidation_last_pass_at ?? null,
306 };
307
308 assert.strictEqual(summary.monthly_consolidation_jobs_used, 0);
309 assert.strictEqual(summary.monthly_consolidation_jobs_included, 30); // plus tier: 30/mo
310 assert.strictEqual(summary.consolidation_last_pass_at, null);
311 });
312
313 it('billing summary reflects usage increment', () => {
314 const u = defaultUserRecord('summary_used');
315 u.tier = 'growth';
316 normalizeBillingUser(u);
317 u.monthly_consolidation_jobs_used = 5;
318 u.consolidation_last_pass_at = '2026-04-05T10:00:00.000Z';
319
320 const summary = {
321 monthly_consolidation_jobs_used: Math.max(0, Math.floor(u.monthly_consolidation_jobs_used || 0)),
322 monthly_consolidation_jobs_included: effectiveMonthlyConsolidationPassesIncluded(u),
323 consolidation_last_pass_at: u.consolidation_last_pass_at ?? null,
324 };
325
326 assert.strictEqual(summary.monthly_consolidation_jobs_used, 5);
327 assert.strictEqual(summary.monthly_consolidation_jobs_included, 100); // growth tier: 100/mo
328 assert.strictEqual(summary.consolidation_last_pass_at, '2026-04-05T10:00:00.000Z');
329 });
330
331 it('billing summary includes pack_consolidation_passes_balance', () => {
332 const u = defaultUserRecord('pack_consol_user');
333 u.tier = 'plus';
334 normalizeBillingUser(u);
335 u.pack_consolidation_passes_balance = 50;
336
337 const summary = {
338 pack_consolidation_passes_balance: Math.max(0, Math.floor(u.pack_consolidation_passes_balance || 0)),
339 };
340 assert.strictEqual(summary.pack_consolidation_passes_balance, 50);
341 });
342 });
343
344 // ── PACK_CONSOLIDATIONS constants ─────────────────────────────────────────────
345
346 describe('PACK_CONSOLIDATIONS', () => {
347 it('small pack grants 50 consolidation passes', () => {
348 assert.strictEqual(PACK_CONSOLIDATIONS.small, 50);
349 });
350 it('medium pack grants 150 consolidation passes', () => {
351 assert.strictEqual(PACK_CONSOLIDATIONS.medium, 150);
352 });
353 it('large pack grants 350 consolidation passes', () => {
354 assert.strictEqual(PACK_CONSOLIDATIONS.large, 350);
355 });
356 });
357
358 // ── addonConsolidationsFromPackPriceId ────────────────────────────────────────
359
360 describe('addonConsolidationsFromPackPriceId', () => {
361 it('returns null for null/undefined price id', () => {
362 assert.strictEqual(addonConsolidationsFromPackPriceId(null), null);
363 assert.strictEqual(addonConsolidationsFromPackPriceId(undefined), null);
364 assert.strictEqual(addonConsolidationsFromPackPriceId(''), null);
365 });
366
367 it('returns null for an unknown price id', () => {
368 assert.strictEqual(addonConsolidationsFromPackPriceId('price_unknown_xyz'), null);
369 });
370
371 it('returns the correct amount for a known env price id', () => {
372 const origSmall = process.env.STRIPE_PRICE_PACK_10;
373 process.env.STRIPE_PRICE_PACK_10 = 'price_small_test';
374 try {
375 assert.strictEqual(addonConsolidationsFromPackPriceId('price_small_test'), PACK_CONSOLIDATIONS.small);
376 } finally {
377 if (origSmall === undefined) delete process.env.STRIPE_PRICE_PACK_10;
378 else process.env.STRIPE_PRICE_PACK_10 = origSmall;
379 }
380 });
381 });
382
383 // ── normalizeBillingUser: pack_consolidation_passes_balance ───────────────────
384
385 describe('normalizeBillingUser: pack_consolidation_passes_balance field', () => {
386 it('initialises to 0 when missing', () => {
387 const u = { tier: 'plus' };
388 normalizeBillingUser(u);
389 assert.strictEqual(u.pack_consolidation_passes_balance, 0);
390 });
391
392 it('preserves an existing valid value', () => {
393 const u = { tier: 'plus', pack_consolidation_passes_balance: 150 };
394 normalizeBillingUser(u);
395 assert.strictEqual(u.pack_consolidation_passes_balance, 150);
396 });
397
398 it('resets NaN to 0', () => {
399 const u = { tier: 'plus', pack_consolidation_passes_balance: NaN };
400 normalizeBillingUser(u);
401 assert.strictEqual(u.pack_consolidation_passes_balance, 0);
402 });
403 });
404
405 // ── defaultUserRecord: pack_consolidation_passes_balance ──────────────────────
406
407 describe('defaultUserRecord: pack_consolidation_passes_balance field', () => {
408 it('is present and equals 0', () => {
409 const u = defaultUserRecord('u_pack_consol');
410 assert.ok('pack_consolidation_passes_balance' in u);
411 assert.strictEqual(u.pack_consolidation_passes_balance, 0);
412 });
413
414 it('includes pack_consolidation_legacy_inferred = false', () => {
415 const u = defaultUserRecord('u_legacy_flag');
416 assert.strictEqual(u.pack_consolidation_legacy_inferred, false);
417 });
418 });
419
420 // ── Pack consolidation pass enforcement (unit simulation) ─────────────────────
421
422 describe('Billing gate: monthly cap exhausted → draw from pack passes', () => {
423 /**
424 * Simulates the enforcement block from billing-middleware.mjs:
425 * - monthly_consolidation_jobs_used is already post-increment when checked
426 * - if > passCap, draw from pack or block
427 */
428 function simulateConsolidationGate(u) {
429 const passCap = effectiveMonthlyConsolidationPassesIncluded(u);
430
431 // Free tier — always block
432 if (passCap !== null && passCap === 0) {
433 return { ok: false, code: 'CONSOLIDATION_NOT_AVAILABLE' };
434 }
435
436 if (passCap !== null) {
437 // Post-increment value
438 const passesUsed = Math.max(0, Math.floor(Number(u.monthly_consolidation_jobs_used) || 0));
439 if (passesUsed > passCap) {
440 const packPasses = Math.max(0, Math.floor(Number(u.pack_consolidation_passes_balance) || 0));
441 if (packPasses > 0) {
442 u.pack_consolidation_passes_balance = packPasses - 1;
443 return { ok: true, source: 'pack' };
444 }
445 u.monthly_consolidation_jobs_used = passesUsed - 1; // undo
446 return { ok: false, code: 'CONSOLIDATION_QUOTA_EXHAUSTED' };
447 }
448 }
449
450 return { ok: true, source: 'monthly' };
451 }
452
453 it('allows pass within monthly cap', () => {
454 const u = defaultUserRecord('within_cap');
455 u.tier = 'plus'; // cap = 30
456 u.monthly_consolidation_jobs_used = 15; // post-increment, still within cap
457 const r = simulateConsolidationGate(u);
458 assert.strictEqual(r.ok, true);
459 assert.strictEqual(r.source, 'monthly');
460 });
461
462 it('draws from pack when monthly cap exceeded and pack has passes', () => {
463 const u = defaultUserRecord('pack_draw');
464 u.tier = 'plus'; // cap = 30
465 u.monthly_consolidation_jobs_used = 31; // post-increment, 1 over cap
466 u.pack_consolidation_passes_balance = 50;
467 const r = simulateConsolidationGate(u);
468 assert.strictEqual(r.ok, true);
469 assert.strictEqual(r.source, 'pack');
470 assert.strictEqual(u.pack_consolidation_passes_balance, 49);
471 });
472
473 it('blocks when monthly cap exceeded and pack is empty', () => {
474 const u = defaultUserRecord('no_pack');
475 u.tier = 'plus'; // cap = 30
476 u.monthly_consolidation_jobs_used = 31; // post-increment, 1 over cap
477 u.pack_consolidation_passes_balance = 0;
478 const r = simulateConsolidationGate(u);
479 assert.strictEqual(r.ok, false);
480 assert.strictEqual(r.code, 'CONSOLIDATION_QUOTA_EXHAUSTED');
481 // Counter must be rolled back so display stays accurate
482 assert.strictEqual(u.monthly_consolidation_jobs_used, 30);
483 });
484
485 it('unlimited tier (beta) never hits the cap', () => {
486 const u = defaultUserRecord('beta_unlimited');
487 u.tier = 'beta'; // cap = null
488 u.monthly_consolidation_jobs_used = 9999;
489 u.pack_consolidation_passes_balance = 0;
490 const r = simulateConsolidationGate(u);
491 assert.strictEqual(r.ok, true);
492 assert.strictEqual(r.source, 'monthly');
493 });
494
495 it('depletes pack passes one at a time across multiple overages', () => {
496 const u = defaultUserRecord('multi_overage');
497 u.tier = 'growth'; // cap = 100
498 u.pack_consolidation_passes_balance = 3;
499
500 for (let i = 1; i <= 3; i++) {
501 u.monthly_consolidation_jobs_used = 100 + i; // simulate repeated post-increment
502 const r = simulateConsolidationGate(u);
503 assert.strictEqual(r.ok, true, `pass ${i} should succeed`);
504 assert.strictEqual(u.pack_consolidation_passes_balance, 3 - i);
505 }
506
507 // 4th overage — pack now empty
508 u.monthly_consolidation_jobs_used = 104;
509 const r4 = simulateConsolidationGate(u);
510 assert.strictEqual(r4.ok, false);
511 assert.strictEqual(r4.code, 'CONSOLIDATION_QUOTA_EXHAUSTED');
512 });
513 });
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago