expose usage_limit_used

This commit is contained in:
Owen Greenhalgh
2026-06-08 11:25:54 +01:00
parent f35f0d00b2
commit bbcb68ba08
12 changed files with 376 additions and 111 deletions

View File

@@ -219,6 +219,38 @@ if not is_nil(unwind_value) and safe_number(unwind_value) > 0 then
end
local logger = context.logger
-- Usage windows are enforced only for positive consumption, never for refunds,
-- target_balance, granted-balance edits, locks, or unwinds.
local enforce_usage_windows = is_consumption
and is_nil(unwind_value)
and (is_nil(lock) or not lock.enabled)
and not is_nil(usage_window_limits)
and #usage_window_limits > 0
if enforce_usage_windows then
local clamp_result = clamp_amount_to_usage_windows({
context = context,
usage_window_limits = usage_window_limits,
amount_to_deduct = amount_to_deduct,
})
if not is_nil(clamp_result.exceeded_feature_id) then
return cjson.encode({
error = 'USAGE_LIMIT_EXCEEDED',
feature_id = clamp_result.exceeded_feature_id,
remaining = safe_number(amount_to_deduct),
updates = {},
rollover_updates = {},
modified_customer_entitlement_ids = new_empty_array(),
mutation_logs = new_empty_array(),
logs = context.logs,
})
end
amount_to_deduct = clamp_result.amount_to_deduct
end
logger.log("=== LUA DEDUCTION START ===")
logger.log("=== PARAMS ===")
logger.log(" amount_to_deduct: %s", tostring(amount_to_deduct or "nil"))
@@ -279,17 +311,6 @@ if remaining_amount > 0 and overage_behaviour == 'reject' then
})
end
-- Hard windowed usage-limit enforcement, on ACTUAL consumed amounts, before any
-- writes. Only for positive consumption (refunds / target_balance / granted
-- balance edits never trip or move counters). v1 also excludes lock-based and
-- unwind flows: counter reversal on partial unwind is not implemented yet, so
-- enforcing there could drift the counter.
local enforce_usage_windows = is_consumption
and is_nil(unwind_value)
and (is_nil(lock) or not lock.enabled)
and not is_nil(usage_window_limits)
and #usage_window_limits > 0
if enforce_usage_windows then
local exceeded_feature_id = check_usage_window_limits({
context = context,

View File

@@ -74,6 +74,50 @@ local function usage_window_consumed(params)
return safe_number(params.amount_to_deduct) - safe_number(params.remaining_amount)
end
-- Clamps metered-feature usage caps before deduction so over-cap tracks apply
-- only the remaining headroom. Balance caps are credit-denominated, so unit
-- clamping would need credit conversion; they stay on the post-deduction check.
local function clamp_amount_to_usage_windows(params)
local context = params.context
local limits = params.usage_window_limits or {}
local clamped_amount = safe_number(params.amount_to_deduct)
for _, limit in ipairs(limits) do
local windows = get_anchor_usage_windows(
context,
limit.anchor_customer_entitlement_id
)
if is_nil(windows) then
return {
amount_to_deduct = clamped_amount,
exceeded_feature_id = limit.feature_id,
}
end
if limit.dimension_type ~= 'balance' then
local existing = find_usage_window(
windows,
limit.feature_id,
limit.window_start_at
)
local current_usage = existing and safe_number(existing.usage) or 0
local headroom = safe_number(limit.limit) - current_usage
if headroom < 0 then
headroom = 0
end
if clamped_amount > headroom then
clamped_amount = headroom
end
end
end
return {
amount_to_deduct = clamped_amount,
exceeded_feature_id = nil,
}
end
-- Returns the feature_id of the first limit that would be exceeded (so the
-- caller can hard-reject), or nil if every limit has room. Null/missing anchor
-- fails closed: a cap that cannot resolve an owner must not silently allow.

View File

@@ -5,6 +5,9 @@ import {
CustomerExpand,
type CustomerLegacyData,
type FullCustomer,
fullCustomerToFullSubject,
fullSubjectToApiSpendLimits,
orgToInStatuses,
scopeExpandForCtx,
} from "@autumn/shared";
import { z } from "zod/v4";
@@ -45,6 +48,11 @@ export const getApiCustomerBase = async ({
ctx: subscriptionsScopedCtx,
fullCus,
});
const spendLimits = fullSubjectToApiSpendLimits({
fullSubject: fullCustomerToFullSubject({ fullCustomer: fullCus }),
features: ctx.features,
inStatuses: orgToInStatuses({ org: ctx.org }),
});
const apiCustomer = ApiCustomerV5Schema.extend({
autumn_id: z.string().optional(),
@@ -68,7 +76,7 @@ export const getApiCustomerBase = async ({
send_email_receipts: fullCus.send_email_receipts ?? false,
billing_controls: {
auto_topups: fullCus.auto_topups ?? undefined,
spend_limits: fullCus.spend_limits ?? undefined,
spend_limits: spendLimits,
usage_alerts: fullCus.usage_alerts ?? undefined,
overage_allowed: fullCus.overage_allowed ?? undefined,
},

View File

@@ -4,6 +4,8 @@ import {
CustomerExpand,
type CustomerLegacyData,
type FullSubject,
fullSubjectToApiSpendLimits,
orgToInStatuses,
scopeExpandForCtx,
} from "@autumn/shared";
import { z } from "zod/v4";
@@ -46,6 +48,11 @@ export const getApiCustomerBaseV2 = async ({
});
const customer = fullSubject.customer;
const spendLimits = fullSubjectToApiSpendLimits({
fullSubject,
features: ctx.features,
inStatuses: orgToInStatuses({ org: ctx.org }),
});
const apiCustomer = ApiCustomerV5Schema.extend({
autumn_id: z.string().optional(),
@@ -66,7 +73,7 @@ export const getApiCustomerBaseV2 = async ({
send_email_receipts: customer.send_email_receipts ?? false,
billing_controls: {
auto_topups: customer.auto_topups ?? undefined,
spend_limits: customer.spend_limits ?? undefined,
spend_limits: spendLimits,
usage_alerts: customer.usage_alerts ?? undefined,
overage_allowed: customer.overage_allowed ?? undefined,
},

View File

@@ -1,5 +1,9 @@
import { expect, test } from "bun:test";
import { type CustomerBillingControls, EntInterval } from "@autumn/shared";
import {
type ApiCustomerV5,
type CustomerBillingControls,
EntInterval,
} from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
@@ -51,11 +55,11 @@ const setCustomerUsageLimit = async ({
};
// Credit system: 100 credits, 1 action1 = 0.2 credits (see v2Features.ts).
// A cap of 5 action1 units consumes only 1 credit, so the cap must block the
// A cap of 5 action1 units consumes only 1 credit, so the cap must clamp the
// 6th unit while ~99 credits remain, proving it's a second, independent
// dimension, not a balance check.
test.concurrent(
`${chalk.yellowBright("track-customer-usage-limit1: per-feature cap blocks deduction while credits remain")}`,
`${chalk.yellowBright("track-customer-usage-limit1: per-feature cap clamps the over-cap unit while credits remain")}`,
async () => {
const customerProduct = products.base({
id: "track-customer-usage-limit",
@@ -93,24 +97,18 @@ test.concurrent(
usage: 1,
});
// The 6th unit exceeds the cap. It must be hard-blocked BEFORE any
// deduction, even though ~99 credits remain.
let blocked = false;
let blockedCode: string | undefined;
try {
await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Action1,
value: 1,
});
} catch (error) {
blocked = true;
blockedCode = (error as { code?: string }).code;
}
expect(blocked).toBe(true);
// 400 not 429: clients flatten a 429 to a generic rate_limit_exceeded.
expect(blockedCode).toBe("usage_limit_exceeded");
// The 6th unit is over the cap, so it clamps to 0: the track succeeds but
// applies nothing, leaving credits unchanged.
const overCap = await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Action1,
value: 1,
});
expect(overCap.balances?.[TestFeature.Credits]).toMatchObject({
granted: 100,
remaining: 99,
usage: 1,
});
},
);
@@ -214,9 +212,9 @@ test.concurrent(
);
// A single spend_limit entry carrying BOTH an overage_limit and a windowed usage
// cap must still enforce the window (the two caps are independent).
// cap must still clamp on the window (the two caps are independent).
test.concurrent(
`${chalk.yellowBright("track-customer-usage-limit4: a spend_limit with both overage_limit and a usage window still enforces the window")}`,
`${chalk.yellowBright("track-customer-usage-limit4: a spend_limit with both overage_limit and a usage window clamps the window")}`,
async () => {
const customerProduct = products.base({
id: "track-customer-compound-cap",
@@ -256,26 +254,25 @@ test.concurrent(
value: 5,
});
let blockedCode: string | undefined;
try {
await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Action1,
value: 1,
});
} catch (error) {
blockedCode = (error as { code?: string }).code;
}
expect(blockedCode).toBe("usage_limit_exceeded");
// The window cap clamps the over-cap unit to 0 (the overage path is separate),
// so the track succeeds and credits are unchanged.
const overCap = await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Action1,
value: 1,
});
expect(overCap.balances?.[TestFeature.Credits]).toMatchObject({
remaining: 99,
usage: 1,
});
},
);
// Two concurrent tracks on the SAME customer's SAME window must serialize (Redis
// runs each deduction Lua atomically): combined value exceeds the cap, so exactly
// one succeeds and one is rejected, and the counter reflects only the winner.
// runs each deduction Lua atomically): combined value exceeds the cap, so the
// second track clamps and the counter reflects exactly the capped usage.
test.concurrent(
`${chalk.yellowBright("track-customer-usage-limit6: concurrent tracks on one window serialize, one rejected")}`,
`${chalk.yellowBright("track-customer-usage-limit6: concurrent tracks on one window serialize, total clamped to the cap")}`,
async () => {
const customerProduct = products.base({
id: "track-customer-concurrent-cap",
@@ -313,16 +310,17 @@ test.concurrent(
}),
]);
const fulfilled = results.filter((result) => result.status === "fulfilled");
const rejected = results.filter(
(result): result is PromiseRejectedResult => result.status === "rejected",
);
// Both succeed now (clamp, not reject), but the window clamps the combined
// applied usage to the cap: one applies 5, the other clamps to 0.
expect(results.every((result) => result.status === "fulfilled")).toBe(true);
expect(fulfilled).toHaveLength(1);
expect(rejected).toHaveLength(1);
expect((rejected[0].reason as { code?: string }).code).toBe(
"usage_limit_exceeded",
);
await timeout(2000);
const final = await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
expect(final.balances?.[TestFeature.Credits]).toMatchObject({
feature_id: TestFeature.Credits,
remaining: 99,
usage: 1,
});
},
);
@@ -489,7 +487,7 @@ test.concurrent(
// No manual sync flush: the counter must survive the mutation's cache invalidation on
// its own, else the cap silently resets and hands out fresh headroom.
test(
`${chalk.yellowBright("track-customer-usage-limit-lowercap: lowering the cap below current usage keeps blocking (no counter reset)")}`,
`${chalk.yellowBright("track-customer-usage-limit-lowercap: lowering the cap below current usage keeps the counter (clamps, no reset)")}`,
async () => {
const customerProduct = products.base({
id: "track-customer-uw-lowercap",
@@ -532,28 +530,22 @@ test(
},
});
let blocked = false;
let blockedCode: string | undefined;
try {
await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 1,
});
} catch (error) {
blocked = true;
blockedCode = (error as { code?: string }).code;
}
expect(blocked).toBe(true);
expect(blockedCode).toBe("usage_limit_exceeded");
const clamped = await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 1,
});
expect(clamped.balance).toMatchObject({
remaining: 992,
usage: 8,
});
},
);
// Bug 1: a second balance grant (balances.create) is a cache-invalidating mutation;
// the cap counter must survive it. It used to reset to 0, opening fresh headroom.
test(
`${chalk.yellowBright("track-customer-usage-limit-regrant: re-granting a balance does not reset the cap")}`,
`${chalk.yellowBright("track-customer-usage-limit-regrant: the cap counter survives a re-grant (clamps)")}`,
async () => {
const customerProduct = products.base({
id: "track-customer-uw-regrant",
@@ -583,18 +575,12 @@ test(
value: 5,
});
let blockedBefore = false;
try {
await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 1,
});
} catch (error) {
blockedBefore =
(error as { code?: string }).code === "usage_limit_exceeded";
}
expect(blockedBefore).toBe(true);
const clampedBefore = await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 1,
});
expect(clampedBefore.balance).toMatchObject({ usage: 5 });
// Re-grant a second balance for the same feature while at the cap.
await autumnV2_1.post("/balances.create", {
@@ -604,20 +590,99 @@ test(
reset: { interval: EntInterval.Month },
});
let blockedAfter = false;
let blockedCode: string | undefined;
try {
await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 1,
});
} catch (error) {
blockedAfter = true;
blockedCode = (error as { code?: string }).code;
}
expect(blockedAfter).toBe(true);
expect(blockedCode).toBe("usage_limit_exceeded");
const clampedAfter = await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 1,
});
expect(clampedAfter.balance).toMatchObject({ usage: 5 });
},
);
// Q1 clamp: an over-cap track applies what fits (the remaining headroom) instead of
// rejecting the whole track. cap 5, track 10 from 0 -> applies 5 (not 10, not a 400).
test(
`${chalk.yellowBright("track-customer-usage-limit-clamp: over-cap track applies what fits (clamp, not reject)")}`,
async () => {
const customerProduct = products.base({
id: "track-customer-uw-clamp",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const customerId = `track-customer-uw-clamp-1-${Date.now()}`;
const { autumnV2_1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success", testClock: false }),
s.products({ list: [customerProduct] }),
],
actions: [s.billing.attach({ productId: customerProduct.id })],
});
await setCustomerUsageLimit({
autumn: autumnV2_1,
customerId,
featureId: TestFeature.Messages,
limit: 5,
});
// Track 10 against a cap of 5 (from 0): clamps to 5, returns 200, not a reject.
const clamped = await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 10,
});
expect(clamped.value).toBe(10);
expect(clamped.balance).toMatchObject({ remaining: 95, usage: 5 });
// At the cap: a further track applies 0 (fully clamped), still 200.
const atCap = await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 3,
});
expect(atCap.balance).toMatchObject({ remaining: 95, usage: 5 });
},
);
// Q2: the spend_limit in the customer response exposes the current window usage.
test(
`${chalk.yellowBright("track-customer-usage-limit-counter: spend_limit exposes the current window usage")}`,
async () => {
const customerProduct = products.base({
id: "track-customer-uw-counter",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const customerId = `track-customer-uw-counter-1-${Date.now()}`;
const { autumnV2_1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success", testClock: false }),
s.products({ list: [customerProduct] }),
],
actions: [s.billing.attach({ productId: customerProduct.id })],
});
await setCustomerUsageLimit({
autumn: autumnV2_1,
customerId,
featureId: TestFeature.Messages,
limit: 5,
});
await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 3,
});
const customer = (await autumnV2_1.get(
`/customers/${customerId}`,
)) as ApiCustomerV5;
const limit = customer.billing_controls?.spend_limits?.find(
(entry) => entry.feature_id === TestFeature.Messages,
);
expect(limit?.usage_limit_used).toBe(3);
},
);

View File

@@ -1,4 +1,5 @@
import { z } from "zod/v4";
import { DbSpendLimitSchema } from "../../models/cusModels/billingControls/spendLimit.js";
import { ApiOverageAllowedSchema } from "./overageAllowed.js";
import { ApiSpendLimitSchema } from "./spendLimit.js";
import { ApiUsageAlertSchema } from "./usageAlert.js";
@@ -17,8 +18,22 @@ export const ApiEntityBillingControlsSchema = z.object({
}),
});
const ApiEntityBillingControlsParamsBaseSchema = z.object({
spend_limits: z.array(DbSpendLimitSchema).optional().meta({
description:
"List of spend limits per feature. Each entry caps overage (overage_limit) and/or windowed usage (usage_limit).",
}),
usage_alerts: z.array(ApiUsageAlertSchema).optional().meta({
description: "List of usage alert configurations per feature.",
}),
overage_allowed: z.array(ApiOverageAllowedSchema).optional().meta({
description:
"List of overage allowed controls per feature. When enabled, usage can exceed balance.",
}),
});
export const ApiEntityBillingControlsParamsSchema =
ApiEntityBillingControlsSchema.check((ctx) => {
ApiEntityBillingControlsParamsBaseSchema.check((ctx) => {
const billingControls = ctx.value;
const spendLimitFeatureIds = new Set<string>();

View File

@@ -1,6 +1,6 @@
import type { z } from "zod/v4";
import { DbSpendLimitSchema } from "../../models/cusModels/billingControls/spendLimit.js";
import { SpendLimitResponseSchema } from "../../models/cusModels/billingControls/spendLimit.js";
export const ApiSpendLimitSchema = DbSpendLimitSchema;
export const ApiSpendLimitSchema = SpendLimitResponseSchema;
export type ApiSpendLimit = z.infer<typeof ApiSpendLimitSchema>;

View File

@@ -1,5 +1,5 @@
import { z } from "zod/v4";
import { ApiEntityBillingControlsSchema } from "../billingControls/entityBillingControls.js";
import { ApiEntityBillingControlsParamsSchema } from "../billingControls/entityBillingControls.js";
export const EntityDataSchema = z
.object({
@@ -9,7 +9,7 @@ export const EntityDataSchema = z
name: z.string().optional().meta({
description: "Name of the entity",
}),
billing_controls: ApiEntityBillingControlsSchema.optional().meta({
billing_controls: ApiEntityBillingControlsParamsSchema.optional().meta({
description: "Billing controls for the entity.",
}),
})

View File

@@ -9,7 +9,11 @@ import {
DbOverageAllowedSchema,
} from "./overageAllowed.js";
import { PurchaseLimitIntervalEnum } from "./purchaseLimitInterval.js";
import { type DbSpendLimit, DbSpendLimitSchema } from "./spendLimit.js";
import {
type DbSpendLimit,
DbSpendLimitSchema,
SpendLimitResponseSchema,
} from "./spendLimit.js";
import { type DbUsageAlert, DbUsageAlertSchema } from "./usageAlert.js";
export const AutoTopupPurchaseLimitSchema = z.object({
@@ -125,7 +129,7 @@ export const CustomerBillingControlsResponseSchema = z.object({
auto_topups: z.array(AutoTopupResponseSchema).optional().meta({
description: "List of auto top-up configurations per feature.",
}),
spend_limits: z.array(DbSpendLimitSchema).optional().meta({
spend_limits: z.array(SpendLimitResponseSchema).optional().meta({
description:
"List of spend limits per feature. Each entry caps overage (overage_limit) and/or windowed usage (usage_limit).",
}),

View File

@@ -33,3 +33,12 @@ export const DbSpendLimitSchema = z
);
export type DbSpendLimit = z.infer<typeof DbSpendLimitSchema>;
export const SpendLimitResponseSchema = DbSpendLimitSchema.extend({
usage_limit_used: z.number().min(0).optional().meta({
description:
"Current usage already consumed in the active usage_limit window. Response-only; not stored on billing controls.",
}),
});
export type SpendLimitResponse = z.infer<typeof SpendLimitResponseSchema>;

View File

@@ -0,0 +1,91 @@
import type { SpendLimitResponse } from "../../models/cusModels/billingControls/spendLimit.js";
import type { FullSubject } from "../../models/cusModels/fullSubject/fullSubjectModel.js";
import type { FullCustomerEntitlement } from "../../models/cusProductModels/cusEntModels/cusEntModels.js";
import type { CusProductStatus } from "../../models/cusProductModels/cusProductEnums.js";
import type { Feature } from "../../models/featureModels/featureModels.js";
import { fullSubjectToUsageWindowLimits } from "./fullSubjectToUsageWindowLimits.js";
const fullSubjectToAllCustomerEntitlements = ({
fullSubject,
}: {
fullSubject: FullSubject;
}): FullCustomerEntitlement[] => [
...fullSubject.customer_products.flatMap(
(customerProduct) => customerProduct.customer_entitlements,
),
...(fullSubject.extra_customer_entitlements ?? []),
];
/**
* Response decorator for customer spend limits. `usage_limit_used` is runtime
* state read from the current usage-window counter, not stored billing config.
*/
export const fullSubjectToApiSpendLimits = ({
fullSubject,
features,
now = Date.now(),
inStatuses,
}: {
fullSubject: FullSubject;
features: Feature[];
now?: number;
inStatuses?: CusProductStatus[];
}): SpendLimitResponse[] | undefined => {
const spendLimits = fullSubject.customer.spend_limits;
if (spendLimits == null) return undefined;
const usageLimitFeatureIds = spendLimits
.filter(
(spendLimit) =>
spendLimit.feature_id != null && spendLimit.usage_limit != null,
)
.map((spendLimit) => spendLimit.feature_id!);
const usageWindowLimits =
usageLimitFeatureIds.length > 0
? fullSubjectToUsageWindowLimits({
fullSubject,
featureIds: usageLimitFeatureIds,
features,
now,
inStatuses,
})
: [];
const allCustomerEntitlements = fullSubjectToAllCustomerEntitlements({
fullSubject,
});
const usageLimitUsedByFeatureId = new Map<string, number>();
for (const limit of usageWindowLimits) {
if (limit.anchor_customer_entitlement_id == null) continue;
const anchorCustomerEntitlement = allCustomerEntitlements.find(
(customerEntitlement) =>
customerEntitlement.id === limit.anchor_customer_entitlement_id,
);
const usageWindow = anchorCustomerEntitlement?.usage_windows?.find(
(window) =>
window.feature_id === limit.feature_id &&
Number(window.window_start_at) === limit.window_start_at,
);
const usage = Number(usageWindow?.usage ?? 0);
usageLimitUsedByFeatureId.set(
limit.feature_id,
Number.isFinite(usage) ? Math.max(0, usage) : 0,
);
}
return spendLimits.map((spendLimit) => {
if (spendLimit.usage_limit == null) return spendLimit;
return {
...spendLimit,
usage_limit_used:
spendLimit.feature_id == null
? 0
: (usageLimitUsedByFeatureId.get(spendLimit.feature_id) ?? 0),
};
});
};

View File

@@ -2,6 +2,7 @@ export * from "./aggregatedUtils/index.js";
export { fullSubjectHasUsageBasedAllocated } from "./classifyFullSubject.js";
export { fullCustomerToFullSubject } from "./fullCustomerToFullSubject.js";
export { fullSubjectToApiCustomerProducts } from "./fullSubjectToApiCustomerProducts.js";
export { fullSubjectToApiSpendLimits } from "./fullSubjectToApiSpendLimits.js";
export { fullSubjectToCustomerEntitlements } from "./fullSubjectToCustomerEntitlements.js";
export { fullSubjectToFullCustomer } from "./fullSubjectToFullCustomer.js";
export { fullSubjectToOverageAllowedByFeatureId } from "./fullSubjectToOverageAllowed.js";