expose usage_limit_used
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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>();
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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.",
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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).",
|
||||
}),
|
||||
|
||||
@@ -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>;
|
||||
|
||||
91
shared/utils/fullSubjectUtils/fullSubjectToApiSpendLimits.ts
Normal file
91
shared/utils/fullSubjectUtils/fullSubjectToApiSpendLimits.ts
Normal 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),
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user