mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-24 23:17:23 +00:00
fix: metering (#3591)
This commit is contained in:
@@ -1432,6 +1432,33 @@ describe('MeteringService', () => {
|
||||
expect(allowed.remaining).toBe(0);
|
||||
});
|
||||
|
||||
it('folds the legacy baseline in exactly once under concurrent increments', async () => {
|
||||
const sub = await target.getActorSubscription(actor);
|
||||
const month = `${new Date().getUTCFullYear()}-${String(new Date().getUTCMonth() + 1).padStart(2, '0')}`;
|
||||
|
||||
// Legacy spend with no split recorded — one page load then fires
|
||||
// many metered requests at once, all seeing the field absent.
|
||||
await server.stores.meteringBuffer.incr({
|
||||
key: `${METRICS_PREFIX}:actor:${actor.user.uuid}:${month}`,
|
||||
pathAndAmountMap: { total: 10_000 },
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: 8 }, () =>
|
||||
target.incrementUsage(actor, 'kv:read', 1, 1_000),
|
||||
),
|
||||
);
|
||||
|
||||
const { usage } =
|
||||
await target.getActorCurrentMonthUsageDetails(actor);
|
||||
expect(usage.total).toBe(18_000);
|
||||
// Without the claim, every concurrent settle re-adds the ~10_000
|
||||
// baseline and the month reads as nearly exhausted.
|
||||
expect(usage.allowanceUsed).toBe(18_000);
|
||||
const allowed = await target.getAllowedUsage(actor);
|
||||
expect(allowed.remaining).toBe(sub.monthUsageAllowance - 18_000);
|
||||
});
|
||||
|
||||
it('counts consumed credits from prior months against the credit pool only', async () => {
|
||||
// Simulate a prior-month overage: consumed credits exist but the
|
||||
// current month has no usage (monthly usage keys roll over).
|
||||
|
||||
@@ -1746,7 +1746,10 @@ export class MeteringService extends PuterService {
|
||||
* A month record without `allowanceUsed` predates the split; its first
|
||||
* settled increment folds the fallback baseline into the write, so the
|
||||
* record answers directly from then on and no balance moves on the deploy
|
||||
* that introduced the field.
|
||||
* that introduced the field. Writing the baseline is guarded by a claim
|
||||
* counter (the `monthlyChargesApplied` pattern): concurrent increments —
|
||||
* one page load meters many responses at once — all see the field absent,
|
||||
* and without the claim each would add the baseline again.
|
||||
*
|
||||
* Returns the month's allowance-charged spend as of after this settle —
|
||||
* `usageRecord` itself predates the write, so callers deciding on the
|
||||
@@ -1776,8 +1779,19 @@ export class MeteringService extends PuterService {
|
||||
const usedBefore =
|
||||
usageRecord.allowanceUsed ??
|
||||
Math.min(totalBefore, Math.max(0, monthUsageAllowance || 0));
|
||||
const baseline =
|
||||
usageRecord.allowanceUsed === undefined ? usedBefore : 0;
|
||||
|
||||
let baseline = 0;
|
||||
if (usageRecord.allowanceUsed === undefined && usedBefore > 0) {
|
||||
const { res } = await this.stores.meteringBuffer.incr({
|
||||
key: actorUsageKey,
|
||||
pathAndAmountMap: { allowanceUsedBaselined: 1 },
|
||||
});
|
||||
const claim = (res as unknown as UsageByType)
|
||||
.allowanceUsedBaselined;
|
||||
if (claim === 1) {
|
||||
baseline = usedBefore;
|
||||
}
|
||||
}
|
||||
|
||||
const headroom = Math.max(0, monthUsageAllowance - usedBefore);
|
||||
const allowanceCharge = Math.min(incrementCost, headroom);
|
||||
|
||||
@@ -51,6 +51,13 @@ export type UsageByType = {
|
||||
* against the allowance, capped at the allowance.
|
||||
*/
|
||||
allowanceUsed?: number;
|
||||
/**
|
||||
* Claim counter for folding the pre-split baseline into `allowanceUsed` on
|
||||
* a record that predates it. 1 for the settle that claimed the fold; higher
|
||||
* for concurrent settles that raced and lost and must not add the baseline
|
||||
* again.
|
||||
*/
|
||||
allowanceUsedBaselined?: number;
|
||||
/**
|
||||
* Claim counter for the month's recurring charges — see
|
||||
* `MONTHLY_CHARGE_CLAIM`. Absent until the first read or write of the
|
||||
|
||||
@@ -544,10 +544,7 @@ const TabHome = {
|
||||
// Load monthly usage data
|
||||
try {
|
||||
const res = await puter.auth.getMonthlyUsage();
|
||||
const budget = usageBudget(
|
||||
res.usage?.total ?? 0,
|
||||
res.allowanceInfo?.remaining ?? 0,
|
||||
);
|
||||
const budget = usageBudget(res.usage, res.allowanceInfo);
|
||||
// The server reports credits (already scaled) or raw amounts
|
||||
// (no multiplier configured), and says which via the unit flag.
|
||||
const inCredits = usageIsCredits(res.allowanceInfo);
|
||||
|
||||
@@ -279,7 +279,7 @@ function renderUsageTable () {
|
||||
|
||||
async function update_usage_details ($el_window) {
|
||||
const monthlyUsagePromise = puter.auth.getMonthlyUsage().then(res => {
|
||||
const budget = usageBudget(res.usage?.total ?? 0, res.allowanceInfo?.remaining ?? 0);
|
||||
const budget = usageBudget(res.usage, res.allowanceInfo);
|
||||
// The server reports credits (already scaled) or raw amounts (no
|
||||
// multiplier configured), and says which via the unit flag.
|
||||
const inCredits = usageIsCredits(res.allowanceInfo);
|
||||
|
||||
@@ -33,12 +33,14 @@ export const usageIsCredits = (allowanceInfo) =>
|
||||
* Credits as the raw number users see: whole credits once the amount has any
|
||||
* size, decimals only while fractions are all there is to show. Never renders
|
||||
* a nonzero amount as "0" — a cost that exists shows as at least "<0.01".
|
||||
* Negative amounts (net usage with unspent top-up) keep their sign.
|
||||
*
|
||||
* @param {number} credits
|
||||
* @returns {string}
|
||||
*/
|
||||
export const formatCredits = (credits) => {
|
||||
const value = Number.isFinite(credits) ? credits : 0;
|
||||
if (value < 0) return `-${formatCredits(-value)}`;
|
||||
if (value <= 0) return '0';
|
||||
if (value >= 100) {
|
||||
return Math.round(value).toLocaleString('en-US');
|
||||
|
||||
@@ -19,40 +19,53 @@
|
||||
|
||||
/**
|
||||
* @typedef {Object} UsageBudget
|
||||
* @property {number} used - Month-to-date spend, in the server's units.
|
||||
* @property {number} capacity - The whole budget: spend plus what is left.
|
||||
* @property {number} percent - `used` as a whole-number share of `capacity`, 0-100.
|
||||
* @property {number} barPercent - The same share unrounded, for a bar width.
|
||||
* @property {number} used - Allowance-charged spend net of unspent top-up, in
|
||||
* the server's units. Negative when top-up credit exceeds the spend.
|
||||
* @property {number} capacity - The monthly plan allowance.
|
||||
* @property {number} percent - `used` as a whole-number share of `capacity`.
|
||||
* Negative when `used` is.
|
||||
* @property {number} barPercent - The share clamped to 0-100, for a bar width.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The three numbers the usage cards show, from the two the server reports.
|
||||
* The numbers the usage cards show, anchored to the monthly plan.
|
||||
*
|
||||
* `remaining` is already netted: what is left of the monthly allowance plus
|
||||
* what is left of any purchased credit, with overage past the allowance
|
||||
* charged to the credit. So spend plus remaining IS the budget, and deriving
|
||||
* capacity that way can't contradict either input — "$3.00 of $12.00, 25%"
|
||||
* always adds up, whatever the mix.
|
||||
* Capacity is the plan's monthly allowance, always — the bar answers "how much
|
||||
* of my plan have I used", so its denominator must not move with purchases.
|
||||
* `used` is the month's allowance-charged spend (`usage.allowanceUsed`; records
|
||||
* from before the split was tracked fall back to the month total, capped at the
|
||||
* allowance), minus whatever top-up credit is still unspent. Unspent credit
|
||||
* therefore reads as headroom — spend a little with a large credit balance and
|
||||
* the share is negative — rather than inflating the plan's capacity.
|
||||
*
|
||||
* The share is taken against that whole budget rather than the monthly
|
||||
* allowance alone. Measured against the allowance, held credit subtracted from
|
||||
* spend, so an account that had bought credit and barely spent it read as a
|
||||
* negative percentage of its own plan.
|
||||
*
|
||||
* @param {number} totalUsage - Month-to-date spend.
|
||||
* @param {number} remaining - Server-netted budget left.
|
||||
* @param {Object | null | undefined} usage - `usage` from `getMonthlyUsage()`.
|
||||
* @param {Object | null | undefined} allowanceInfo - Its `allowanceInfo`
|
||||
* sibling.
|
||||
* @returns {UsageBudget}
|
||||
*/
|
||||
export const usageBudget = (totalUsage, remaining) => {
|
||||
const used = Number.isFinite(totalUsage) ? Math.max(0, totalUsage) : 0;
|
||||
const left = Number.isFinite(remaining) ? Math.max(0, remaining) : 0;
|
||||
const capacity = used + left;
|
||||
export const usageBudget = (usage, allowanceInfo) => {
|
||||
const capacity = Number.isFinite(allowanceInfo?.monthUsageAllowance)
|
||||
? Math.max(0, allowanceInfo.monthUsageAllowance)
|
||||
: 0;
|
||||
const total = Number.isFinite(usage?.total) ? Math.max(0, usage.total) : 0;
|
||||
const allowanceUsed = Number.isFinite(usage?.allowanceUsed)
|
||||
? Math.max(0, usage.allowanceUsed)
|
||||
: Math.min(total, capacity);
|
||||
const addons = allowanceInfo?.addons ?? {};
|
||||
const purchased = Number.isFinite(addons.purchasedCredits)
|
||||
? addons.purchasedCredits
|
||||
: 0;
|
||||
const consumed = Number.isFinite(addons.consumedPurchaseCredits)
|
||||
? addons.consumedPurchaseCredits
|
||||
: 0;
|
||||
const creditRemaining = Math.max(0, purchased - consumed);
|
||||
|
||||
const used = allowanceUsed - creditRemaining;
|
||||
const share = capacity ? (used / capacity) * 100 : 0;
|
||||
const barPercent = Math.max(0, Math.min(100, share));
|
||||
return {
|
||||
used,
|
||||
capacity,
|
||||
percent: Math.round(barPercent),
|
||||
barPercent,
|
||||
percent: Math.round(share),
|
||||
barPercent: Math.max(0, Math.min(100, share)),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,48 +1,96 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { usageBudget } from './usageBudget.js';
|
||||
|
||||
const info = (allowance, addons = {}) => ({
|
||||
monthUsageAllowance: allowance,
|
||||
addons,
|
||||
});
|
||||
|
||||
describe('usageBudget', () => {
|
||||
it('reports the plan alone when no credit has been bought', () => {
|
||||
// $0.50 spent of a $9.00 allowance.
|
||||
const budget = usageBudget(50_000_000, 850_000_000);
|
||||
it('anchors the bar to the monthly allowance', () => {
|
||||
// $0.50 of a $9.00 allowance spent, no top-up.
|
||||
const budget = usageBudget(
|
||||
{ total: 50_000_000, allowanceUsed: 50_000_000 },
|
||||
info(900_000_000),
|
||||
);
|
||||
expect(budget.capacity).toBe(900_000_000);
|
||||
expect(budget.used).toBe(50_000_000);
|
||||
expect(budget.percent).toBe(6);
|
||||
});
|
||||
|
||||
it('never reports a negative share for an account holding credit', () => {
|
||||
// $0.50 spent, $9.00 allowance, $10.00 of purchased credit untouched.
|
||||
// The cards used to subtract held credit from spend, which rendered
|
||||
// this account at roughly -105% of its own plan.
|
||||
const budget = usageBudget(50_000_000, 1_850_000_000);
|
||||
expect(budget.percent).toBeGreaterThanOrEqual(0);
|
||||
expect(budget.percent).toBe(3);
|
||||
expect(budget.capacity).toBe(1_900_000_000);
|
||||
it('keeps the capacity at the plan when top-up credit exists', () => {
|
||||
// $12.00 of a $95.00 allowance spent, $5.00 top-up untouched: the
|
||||
// credit reads as headroom against the plan, never as more plan.
|
||||
const budget = usageBudget(
|
||||
{ total: 1_200_000_000, allowanceUsed: 1_200_000_000 },
|
||||
info(9_500_000_000, {
|
||||
purchasedCredits: 500_000_000,
|
||||
consumedPurchaseCredits: 0,
|
||||
}),
|
||||
);
|
||||
expect(budget.capacity).toBe(9_500_000_000);
|
||||
expect(budget.used).toBe(700_000_000);
|
||||
expect(budget.percent).toBe(7);
|
||||
});
|
||||
|
||||
it('keeps used, capacity and share consistent once credit is being spent', () => {
|
||||
// $12.00 spent: the $9.00 allowance plus $3.00 of a $10.00 top-up,
|
||||
// leaving $7.00. Capacity has to stay the full $19.00 — reading it as
|
||||
// allowance-plus-what's-left implied $4.00 remaining while the server
|
||||
// said $7.00.
|
||||
const budget = usageBudget(1_200_000_000, 700_000_000);
|
||||
expect(budget.capacity).toBe(1_900_000_000);
|
||||
expect(budget.used + 700_000_000).toBe(budget.capacity);
|
||||
expect(budget.percent).toBe(63);
|
||||
it('goes negative when unspent top-up exceeds the spend', () => {
|
||||
// $0.50 spent, $10.00 credit untouched, $9.00 allowance.
|
||||
const budget = usageBudget(
|
||||
{ total: 50_000_000, allowanceUsed: 50_000_000 },
|
||||
info(900_000_000, {
|
||||
purchasedCredits: 1_000_000_000,
|
||||
consumedPurchaseCredits: 0,
|
||||
}),
|
||||
);
|
||||
expect(budget.used).toBe(-950_000_000);
|
||||
expect(budget.percent).toBe(-106);
|
||||
expect(budget.barPercent).toBe(0);
|
||||
});
|
||||
|
||||
it('reads a spent budget as full rather than overflowing the bar', () => {
|
||||
const budget = usageBudget(900_000_000, 0);
|
||||
it('ignores credit already consumed — only what is left offsets usage', () => {
|
||||
// Allowance exhausted, $200 of top-up bought and fully spent: the
|
||||
// month reads as full, not as 200 dollars into the negatives.
|
||||
const budget = usageBudget(
|
||||
{ total: 29_500_000_000, allowanceUsed: 9_500_000_000 },
|
||||
info(9_500_000_000, {
|
||||
purchasedCredits: 20_000_000_000,
|
||||
consumedPurchaseCredits: 20_000_000_000,
|
||||
}),
|
||||
);
|
||||
expect(budget.used).toBe(9_500_000_000);
|
||||
expect(budget.percent).toBe(100);
|
||||
expect(budget.barPercent).toBe(100);
|
||||
});
|
||||
|
||||
it('counts only allowance-charged spend against the plan', () => {
|
||||
// $9.11 of allowance used this month; the total also carries spend
|
||||
// that purchased credit already paid for.
|
||||
const budget = usageBudget(
|
||||
{ total: 1_500_000_000, allowanceUsed: 911_000_000 },
|
||||
info(9_500_000_000),
|
||||
);
|
||||
expect(budget.used).toBe(911_000_000);
|
||||
expect(budget.percent).toBe(10);
|
||||
});
|
||||
|
||||
it('falls back to the capped total for records without the split', () => {
|
||||
// Legacy record: no allowanceUsed. Everything up to the allowance
|
||||
// counts, and an overshot total still reads as a full plan.
|
||||
const budget = usageBudget({ total: 1_000_000_000 }, info(900_000_000));
|
||||
expect(budget.used).toBe(900_000_000);
|
||||
expect(budget.percent).toBe(100);
|
||||
expect(budget.barPercent).toBe(100);
|
||||
});
|
||||
|
||||
it('answers zero for an account with no budget at all', () => {
|
||||
const budget = usageBudget(0, 0);
|
||||
expect(budget).toMatchObject({ capacity: 0, percent: 0 });
|
||||
const budget = usageBudget({ total: 0 }, info(0));
|
||||
expect(budget).toMatchObject({ capacity: 0, used: 0, percent: 0 });
|
||||
});
|
||||
|
||||
it('treats missing numbers as zero rather than rendering NaN', () => {
|
||||
it('treats missing objects and numbers as zero rather than rendering NaN', () => {
|
||||
expect(usageBudget(undefined, undefined).percent).toBe(0);
|
||||
expect(usageBudget(NaN, 100).capacity).toBe(100);
|
||||
expect(usageBudget(null, info(NaN)).capacity).toBe(0);
|
||||
expect(usageBudget({ total: NaN }, info(100)).capacity).toBe(100);
|
||||
expect(usageBudget({ total: NaN }, info(100)).percent).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user