@@ -453,6 +453,30 @@ describe('MeteringService', () => {
453453 expect ( addons . consumedPurchaseCredits ) . toBe ( 1_000_000 ) ;
454454 } ) ;
455455 } ) ;
456+
457+ it ( 'charges the allowance first and records the split on the month record' , async ( ) => {
458+ const overActor : Actor = { user : makeUser ( ) } ;
459+ const sub = await target . getActorSubscription ( overActor ) ;
460+ await target . updateAddonCredit ( overActor . user . uuid ! , 5_000_000 ) ;
461+
462+ // One increment that straddles the boundary: the allowance part
463+ // lands in `allowanceUsed`, only the rest draws down credit.
464+ await target . incrementUsage (
465+ overActor ,
466+ 'kv:read' ,
467+ 1 ,
468+ sub . monthUsageAllowance + 1_000_000 ,
469+ ) ;
470+
471+ await waitFor ( async ( ) => {
472+ const { usage } =
473+ await target . getActorCurrentMonthUsageDetails ( overActor ) ;
474+ expect ( usage . allowanceUsed ) . toBe ( sub . monthUsageAllowance ) ;
475+ expect ( usage . total ) . toBe ( sub . monthUsageAllowance + 1_000_000 ) ;
476+ const addons = await target . getActorAddons ( overActor ) ;
477+ expect ( addons . consumedPurchaseCredits ) . toBe ( 1_000_000 ) ;
478+ } ) ;
479+ } ) ;
456480 } ) ;
457481
458482 // ── overuse alarm ────────────────────────────────────────────────
@@ -806,7 +830,9 @@ describe('MeteringService', () => {
806830 }
807831 const incrSpy = vi . spyOn ( server . stores . meteringBuffer , 'incr' ) ;
808832 await target . flushBufferedUsages ( ) ;
809- expect ( incrSpy ) . toHaveBeenCalledOnce ( ) ;
833+ // One usage write for all ten buffered events, plus the settle
834+ // write that records the allowance/credit split.
835+ expect ( incrSpy ) . toHaveBeenCalledTimes ( 2 ) ;
810836 incrSpy . mockRestore ( ) ;
811837
812838 const { usage } =
@@ -894,7 +920,8 @@ describe('MeteringService', () => {
894920 ] ) ;
895921 }
896922 await target . flushBufferedUsages ( ) ;
897- expect ( spy ) . toHaveBeenCalledTimes ( concurrency * 3 ) ;
923+ // Per bucket: the usage write plus the allowance settle.
924+ expect ( spy ) . toHaveBeenCalledTimes ( concurrency * 3 * 2 ) ;
898925 expect ( peak ) . toBeLessThanOrEqual ( concurrency ) ;
899926 } finally {
900927 spy . mockRestore ( ) ;
@@ -924,7 +951,8 @@ describe('MeteringService', () => {
924951 target . flushBufferedUsages ( ) ,
925952 target . flushBufferedUsages ( ) ,
926953 ] ) ;
927- expect ( started ) . toBe ( 1 ) ;
954+ // One cycle ran (usage write + allowance settle), not three.
955+ expect ( started ) . toBe ( 2 ) ;
928956 } finally {
929957 spy . mockRestore ( ) ;
930958 }
@@ -1191,6 +1219,32 @@ describe('MeteringService', () => {
11911219 expect ( result . total ) . toBe ( 100 ) ;
11921220 } ) ;
11931221
1222+ it ( 're-anchors allowanceUsed so the adjusted total is what the allowance is billed' , async ( ) => {
1223+ const sub = await target . getActorSubscription ( actor ) ;
1224+ await target . updateAddonCredit ( actor . user . uuid ! , 5_000_000 ) ;
1225+
1226+ // Overspend so the month holds allowance + credit-charged spend.
1227+ await target . incrementUsage (
1228+ actor ,
1229+ 'kv:read' ,
1230+ 1 ,
1231+ sub . monthUsageAllowance + 5_000_000 ,
1232+ ) ;
1233+ expect ( await target . getRemainingUsage ( actor ) ) . toBe ( 0 ) ;
1234+
1235+ // Support sets the month back down: the new total is billed to
1236+ // the allowance in full and the rest of it reopens.
1237+ const result = await target . setActorCurrentMonthUsageTotal (
1238+ actor ,
1239+ 1_000 ,
1240+ ) ;
1241+ expect ( result . total ) . toBe ( 1_000 ) ;
1242+ expect ( result . allowanceUsed ) . toBe ( 1_000 ) ;
1243+ expect ( await target . getRemainingUsage ( actor ) ) . toBe (
1244+ sub . monthUsageAllowance - 1_000 ,
1245+ ) ;
1246+ } ) ;
1247+
11941248 it ( 'rejects a negative total' , async ( ) => {
11951249 await expect (
11961250 target . setActorCurrentMonthUsageTotal ( actor , - 1 ) ,
@@ -1308,6 +1362,76 @@ describe('MeteringService', () => {
13081362 expect ( allowed . remaining ) . toBe ( 4_000_000 ) ;
13091363 } ) ;
13101364
1365+ it ( 'keeps the allowance and credit pools separate across a mid-month upgrade' , async ( ) => {
1366+ const freeSub = await target . getActorSubscription ( actor ) ;
1367+ const freeAllowance = freeSub . monthUsageAllowance ;
1368+ await target . updateAddonCredit ( actor . user . uuid ! , 5_000_000 ) ;
1369+
1370+ // Exhaust the free allowance, then draw 2_000_000 from credit.
1371+ await target . incrementUsage ( actor , 'kv:read' , 1 , freeAllowance ) ;
1372+ await target . incrementUsage ( actor , 'kv:read' , 1 , 2_000_000 ) ;
1373+ await waitFor ( async ( ) => {
1374+ const addons = await target . getActorAddons ( actor ) ;
1375+ expect ( addons . consumedPurchaseCredits ) . toBe ( 2_000_000 ) ;
1376+ } ) ;
1377+
1378+ // Upgrade mid-month to ten times the allowance. The allowance
1379+ // pool reopens (freeAllowance of 10x used); the credit pool is
1380+ // exactly where it was (2 of 5 consumed).
1381+ const paid = {
1382+ id : 'upgrade-paid' ,
1383+ monthUsageAllowance : freeAllowance * 10 ,
1384+ monthlyStorageAllowance : 1024 * 1024 * 1024 ,
1385+ } ;
1386+ target . registerPolicy ( paid ) ;
1387+ target . registerSubscriptionResolver ( async ( ) => 'upgrade-paid' ) ;
1388+ target . invalidateActorSubscription ( actor . user . uuid ! ) ;
1389+
1390+ const allowed = await target . getAllowedUsage ( actor ) ;
1391+ expect ( allowed . remaining ) . toBe ( freeAllowance * 9 + 3_000_000 ) ;
1392+ expect ( allowed . addons . consumedPurchaseCredits ) . toBe ( 2_000_000 ) ;
1393+
1394+ // Further spend consumes the reopened allowance, not credit.
1395+ await target . incrementUsage ( actor , 'kv:read' , 1 , freeAllowance * 9 ) ;
1396+ const addons = await target . getActorAddons ( actor ) ;
1397+ expect ( addons . consumedPurchaseCredits ) . toBe ( 2_000_000 ) ;
1398+ expect ( ( await target . getAllowedUsage ( actor ) ) . remaining ) . toBe (
1399+ 3_000_000 ,
1400+ ) ;
1401+
1402+ // Only once the new allowance is full does credit drain again.
1403+ await target . incrementUsage ( actor , 'kv:read' , 1 , 1_000_000 ) ;
1404+ await waitFor ( async ( ) => {
1405+ const after = await target . getActorAddons ( actor ) ;
1406+ expect ( after . consumedPurchaseCredits ) . toBe ( 3_000_000 ) ;
1407+ } ) ;
1408+ } ) ;
1409+
1410+ it ( 'falls back to the pre-split reading for month records without allowanceUsed' , async ( ) => {
1411+ const sub = await target . getActorSubscription ( actor ) ;
1412+ await target . updateAddonCredit ( actor . user . uuid ! , 5_000_000 ) ;
1413+ await server . stores . kv . incr ( {
1414+ key : `${ POLICY_PREFIX } :actor:${ actor . user . uuid } :addons` ,
1415+ pathAndAmountMap : { consumedPurchaseCredits : 5_000_000 } ,
1416+ } ) ;
1417+
1418+ // A legacy month record: total spans allowance + credit overage,
1419+ // no allowanceUsed split recorded.
1420+ const month = `${ new Date ( ) . getUTCFullYear ( ) } -${ String ( new Date ( ) . getUTCMonth ( ) + 1 ) . padStart ( 2 , '0' ) } ` ;
1421+ await server . stores . meteringBuffer . incr ( {
1422+ key : `${ METRICS_PREFIX } :actor:${ actor . user . uuid } :${ month } ` ,
1423+ pathAndAmountMap : {
1424+ total : sub . monthUsageAllowance + 5_000_000 ,
1425+ } ,
1426+ } ) ;
1427+
1428+ // Pre-split behavior: the whole total counts against the
1429+ // allowance (capped at it), so nothing changes at the deploy
1430+ // that introduced the field.
1431+ const allowed = await target . getAllowedUsage ( actor ) ;
1432+ expect ( allowed . remaining ) . toBe ( 0 ) ;
1433+ } ) ;
1434+
13111435 it ( 'counts consumed credits from prior months against the credit pool only' , async ( ) => {
13121436 // Simulate a prior-month overage: consumed credits exist but the
13131437 // current month has no usage (monthly usage keys roll over).
@@ -1894,8 +2018,9 @@ describe('MeteringService', () => {
18942018 const incr = vi . spyOn ( server . stores . meteringBuffer , 'incr' ) ;
18952019 const usage = await target . getActorCurrentMonthUsageDetails ( actor ) ;
18962020
1897- // Four charges across two listeners, settling as a single write.
1898- expect ( incr ) . toHaveBeenCalledTimes ( 1 ) ;
2021+ // Four charges across two listeners fold into a single usage
2022+ // write; the second call is the allowance settle.
2023+ expect ( incr ) . toHaveBeenCalledTimes ( 2 ) ;
18992024 expect ( incr . mock . calls [ 0 ] ! [ 0 ] . pathAndAmountMap ) . toEqual ( {
19002025 total : 600 ,
19012026 'workers:monthly.units' : 5 ,
0 commit comments