feat: overage allowed control

This commit is contained in:
John Yeo
2026-03-30 15:25:40 +01:00
parent 1bd2344c9b
commit 596122b3ce
34 changed files with 1135 additions and 41 deletions

7
.cursor/settings.json Normal file
View File

@@ -0,0 +1,7 @@
{
"plugins": {
"linear": {
"enabled": true
}
}
}

View File

@@ -72,6 +72,7 @@
"dx": "bun scripts/dx.ts", "dx": "bun scripts/dx.ts",
"d:test": "lsof -ti:8080 -ti:3000 | xargs kill -9 2>/dev/null || true; ENV_FILE=.env infisical run --env=test -- bun scripts/dev.ts", "d:test": "lsof -ti:8080 -ti:3000 | xargs kill -9 2>/dev/null || true; ENV_FILE=.env infisical run --env=test -- bun scripts/dev.ts",
"p": "ENV_FILE=.env.prod infisical run --env=prod -- bun scripts/dev.ts", "p": "ENV_FILE=.env.prod infisical run --env=prod -- bun scripts/dev.ts",
"s": "ENV_FILE=.env.staging infisical run --env=staging -- bun scripts/dev.ts",
"l": "bash ./scripts/dev-local.sh", "l": "bash ./scripts/dev-local.sh",
"setup": "node scripts/setup/setup.js", "setup": "node scripts/setup/setup.js",
"setup:test": "infisical run --env=dev -- bun scripts/setup/setup-test.ts", "setup:test": "infisical run --env=dev -- bun scripts/setup/setup-test.ts",

View File

@@ -18,7 +18,8 @@
processors?: object | null, processors?: object | null,
auto_topups?: array | null, auto_topups?: array | null,
spend_limits?: array | null, spend_limits?: array | null,
usage_alerts?: array | null usage_alerts?: array | null,
overage_allowed?: array | null
} }
} }
@@ -121,4 +122,13 @@ if updates.usage_alerts ~= nil then
table.insert(updated_fields, 'usage_alerts') table.insert(updated_fields, 'usage_alerts')
end end
if updates.overage_allowed ~= nil then
if is_nil(updates.overage_allowed) then
redis.call('JSON.SET', cache_key, '$.overage_allowed', 'null')
else
redis.call('JSON.SET', cache_key, '$.overage_allowed', cjson.encode(updates.overage_allowed))
end
table.insert(updated_fields, 'overage_allowed')
end
return cjson.encode({ success = true, updated_fields = updated_fields }) return cjson.encode({ success = true, updated_fields = updated_fields })

View File

@@ -2,6 +2,7 @@ import {
cusEntToStartingBalance, cusEntToStartingBalance,
type FullCustomer, type FullCustomer,
fullCustomerToCustomerEntitlements, fullCustomerToCustomerEntitlements,
fullCustomerToOverageAllowedByFeatureId,
fullCustomerToSpendLimitByFeatureId, fullCustomerToSpendLimitByFeatureId,
fullCustomerToUsageBasedCusEntsByFeatureId, fullCustomerToUsageBasedCusEntsByFeatureId,
getMaxOverage, getMaxOverage,
@@ -85,6 +86,10 @@ export const prepareFeatureDeduction = ({
fullCustomer, fullCustomer,
featureIds: effectiveFeatureIds, featureIds: effectiveFeatureIds,
}); });
const overageAllowedByFeatureId = fullCustomerToOverageAllowedByFeatureId({
fullCustomer,
featureIds: effectiveFeatureIds,
});
// Build input for each customer entitlement // Build input for each customer entitlement
const customerEntitlementDeductions: CustomerEntitlementDeduction[] = const customerEntitlementDeductions: CustomerEntitlementDeduction[] =
@@ -104,12 +109,18 @@ export const prepareFeatureDeduction = ({
const isFreeAllocatedUsageAllowed = const isFreeAllocatedUsageAllowed =
isFreeAllocated && overageBehaviour !== "reject"; isFreeAllocated && overageBehaviour !== "reject";
const billingControlOverageAllowed =
overageAllowedByFeatureId[ce.entitlement.feature.id]?.enabled ?? false;
return { return {
customer_entitlement_id: ce.id, customer_entitlement_id: ce.id,
credit_cost: creditCost, credit_cost: creditCost,
feature_id: ce.entitlement.feature.id, feature_id: ce.entitlement.feature.id,
entity_feature_id: ce.entitlement.entity_feature_id ?? null, entity_feature_id: ce.entitlement.entity_feature_id ?? null,
usage_allowed: ce.usage_allowed || isFreeAllocatedUsageAllowed, usage_allowed:
ce.usage_allowed ||
isFreeAllocatedUsageAllowed ||
billingControlOverageAllowed,
min_balance: notNullish(maxOverage) ? -maxOverage : undefined, min_balance: notNullish(maxOverage) ? -maxOverage : undefined,
max_balance: resetBalance, max_balance: resetBalance,
}; };

View File

@@ -115,6 +115,7 @@ export const updateCustomer = async ({
auto_topups: billing_controls.auto_topups, auto_topups: billing_controls.auto_topups,
spend_limits: billing_controls.spend_limits, spend_limits: billing_controls.spend_limits,
usage_alerts: billing_controls.usage_alerts, usage_alerts: billing_controls.usage_alerts,
overage_allowed: billing_controls.overage_allowed,
}), }),
}; };

View File

@@ -69,6 +69,7 @@ export const getApiCustomerBase = async ({
auto_topups: fullCus.auto_topups ?? undefined, auto_topups: fullCus.auto_topups ?? undefined,
spend_limits: fullCus.spend_limits ?? undefined, spend_limits: fullCus.spend_limits ?? undefined,
usage_alerts: fullCus.usage_alerts ?? undefined, usage_alerts: fullCus.usage_alerts ?? undefined,
overage_allowed: fullCus.overage_allowed ?? undefined,
}, },
invoices: invoices:

View File

@@ -23,6 +23,7 @@ type CustomerDataUpdates = Pick<
| "auto_topups" | "auto_topups"
| "spend_limits" | "spend_limits"
| "usage_alerts" | "usage_alerts"
| "overage_allowed"
>; >;
/** /**

View File

@@ -36,7 +36,9 @@ export const updateEntityInCache = async ({
ctx: AutumnContext; ctx: AutumnContext;
customerId: string; customerId: string;
idOrInternalId: string; idOrInternalId: string;
updates: Partial<Pick<Entity, "spend_limits" | "usage_alerts">>; updates: Partial<
Pick<Entity, "spend_limits" | "usage_alerts" | "overage_allowed">
>;
}): Promise<UpdateEntityInCacheResult | null> => { }): Promise<UpdateEntityInCacheResult | null> => {
try { try {
if (Object.keys(updates).length === 0) { if (Object.keys(updates).length === 0) {

View File

@@ -37,6 +37,7 @@ const initCustomer = ({
auto_topups: customerData?.billing_controls?.auto_topups, auto_topups: customerData?.billing_controls?.auto_topups,
spend_limits: customerData?.billing_controls?.spend_limits, spend_limits: customerData?.billing_controls?.spend_limits,
usage_alerts: customerData?.billing_controls?.usage_alerts, usage_alerts: customerData?.billing_controls?.usage_alerts,
overage_allowed: customerData?.billing_controls?.overage_allowed,
}; };
}; };

View File

@@ -76,6 +76,7 @@ export const batchCreateEntities = async ({
...(inputEntities[0].billing_controls && { ...(inputEntities[0].billing_controls && {
spend_limits: inputEntities[0].billing_controls.spend_limits, spend_limits: inputEntities[0].billing_controls.spend_limits,
usage_alerts: inputEntities[0].billing_controls.usage_alerts, usage_alerts: inputEntities[0].billing_controls.usage_alerts,
overage_allowed: inputEntities[0].billing_controls.overage_allowed,
}), }),
}, },
}); });

View File

@@ -45,6 +45,7 @@ export const updateEntity = async ({
updates: { updates: {
spend_limits: billing_controls?.spend_limits, spend_limits: billing_controls?.spend_limits,
usage_alerts: billing_controls?.usage_alerts, usage_alerts: billing_controls?.usage_alerts,
overage_allowed: billing_controls?.overage_allowed,
}, },
}); });

View File

@@ -12,11 +12,15 @@ export const updateEntityDbAndCache = async ({
ctx: AutumnContext; ctx: AutumnContext;
customerId: string; customerId: string;
entity: Entity; entity: Entity;
updates: Partial<Pick<Entity, "spend_limits" | "usage_alerts">>; updates: Partial<
Pick<Entity, "spend_limits" | "usage_alerts" | "overage_allowed">
>;
}) => { }) => {
const filteredUpdates = Object.fromEntries( const filteredUpdates = Object.fromEntries(
Object.entries(updates).filter(([, value]) => value !== undefined), Object.entries(updates).filter(([, value]) => value !== undefined),
) as Partial<Pick<Entity, "spend_limits" | "usage_alerts">>; ) as Partial<
Pick<Entity, "spend_limits" | "usage_alerts" | "overage_allowed">
>;
if (Object.keys(filteredUpdates).length === 0) { if (Object.keys(filteredUpdates).length === 0) {
return entity; return entity;

View File

@@ -75,6 +75,7 @@ export const getApiEntityBase = async ({
billing_controls: { billing_controls: {
spend_limits: entity.spend_limits ?? undefined, spend_limits: entity.spend_limits ?? undefined,
usage_alerts: entity.usage_alerts ?? undefined, usage_alerts: entity.usage_alerts ?? undefined,
overage_allowed: entity.overage_allowed ?? undefined,
}, },
} satisfies ApiEntityV2); } satisfies ApiEntityV2);

View File

@@ -0,0 +1,191 @@
import { expect, test } from "bun:test";
import type { CheckResponseV3 } 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";
import { timeout } from "@tests/utils/genUtils.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
import { setCustomerOverageAllowed } from "../../utils/overage-allowed-utils/customerOverageAllowedUtils.js";
import { normalizeCheckResponse } from "../../utils/spend-limit-utils/checkSpendLimitUtils.js";
test.concurrent(`${chalk.yellowBright("check-overage-allowed-1: free feature, enabled:true, balance at 0 — check returns allowed:true")}`, async () => {
const freeProd = products.base({
id: "free-overage-check",
items: [items.lifetimeMessages({ includedUsage: 100 })],
});
const { autumnV2_1, customerId } = await initScenario({
customerId: "check-overage-allowed-1",
setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })],
actions: [s.attach({ productId: freeProd.id })],
});
await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 100,
});
const beforeControl = await autumnV2_1.check<CheckResponseV3>({
customer_id: customerId,
feature_id: TestFeature.Messages,
required_balance: 1,
});
expect(beforeControl.allowed).toBe(false);
await setCustomerOverageAllowed({
autumn: autumnV2_1,
customerId,
featureId: TestFeature.Messages,
enabled: true,
});
const afterControl = await autumnV2_1.check<CheckResponseV3>({
customer_id: customerId,
feature_id: TestFeature.Messages,
required_balance: 1,
});
expect(afterControl.allowed).toBe(true);
});
test.concurrent(`${chalk.yellowBright("check-overage-allowed-2: free feature, enabled:true, balance negative — check still returns allowed:true")}`, async () => {
const freeProd = products.base({
id: "free-overage-neg",
items: [items.lifetimeMessages({ includedUsage: 100 })],
});
const { autumnV2_1, customerId } = await initScenario({
customerId: "check-overage-allowed-2",
setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })],
actions: [s.attach({ productId: freeProd.id })],
});
await setCustomerOverageAllowed({
autumn: autumnV2_1,
customerId,
featureId: TestFeature.Messages,
enabled: true,
});
await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 150,
});
const check = await autumnV2_1.check<CheckResponseV3>({
customer_id: customerId,
feature_id: TestFeature.Messages,
required_balance: 50,
});
expect(check.allowed).toBe(true);
expect(check.balance!.remaining).toBeLessThan(0);
});
test.concurrent(`${chalk.yellowBright("check-overage-allowed-3: free feature, no billing control (baseline) — check returns allowed:false at 0")}`, async () => {
const freeProd = products.base({
id: "free-no-control",
items: [items.lifetimeMessages({ includedUsage: 100 })],
});
const { autumnV2_1, customerId } = await initScenario({
customerId: "check-overage-allowed-3",
setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })],
actions: [s.attach({ productId: freeProd.id })],
});
await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 100,
});
const check = await autumnV2_1.check<CheckResponseV3>({
customer_id: customerId,
feature_id: TestFeature.Messages,
required_balance: 1,
});
expect(check.allowed).toBe(false);
});
test.concurrent(`${chalk.yellowBright("check-overage-allowed-4: check with send_event:true, enabled:true — allowed:true and balance goes negative")}`, async () => {
const freeProd = products.base({
id: "free-overage-send-event",
items: [items.lifetimeMessages({ includedUsage: 100 })],
});
const { autumnV2_1, customerId } = await initScenario({
customerId: "check-overage-allowed-4",
setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })],
actions: [s.attach({ productId: freeProd.id })],
});
await setCustomerOverageAllowed({
autumn: autumnV2_1,
customerId,
featureId: TestFeature.Messages,
enabled: true,
});
await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 100,
});
const checkResult = await autumnV2_1.check<CheckResponseV3>({
customer_id: customerId,
feature_id: TestFeature.Messages,
required_balance: 25,
send_event: true,
});
expect(checkResult.allowed).toBe(true);
expect(checkResult.balance!.remaining).toBe(-25);
});
test.concurrent(`${chalk.yellowBright("check-overage-allowed-5: cache/DB parity — cached and uncached check responses match")}`, async () => {
const freeProd = products.base({
id: "free-overage-parity",
items: [items.lifetimeMessages({ includedUsage: 100 })],
});
const { autumnV2_1, customerId } = await initScenario({
customerId: "check-overage-allowed-5",
setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })],
actions: [s.attach({ productId: freeProd.id })],
});
await setCustomerOverageAllowed({
autumn: autumnV2_1,
customerId,
featureId: TestFeature.Messages,
enabled: true,
});
await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 120,
});
const cached = await autumnV2_1.check<CheckResponseV3>({
customer_id: customerId,
feature_id: TestFeature.Messages,
required_balance: 10,
});
expect(cached.allowed).toBe(true);
await timeout(4000);
const uncached = await autumnV2_1.check<CheckResponseV3>({
customer_id: customerId,
feature_id: TestFeature.Messages,
required_balance: 10,
skip_cache: true,
});
expect(normalizeCheckResponse(uncached)).toEqual(
normalizeCheckResponse(cached),
);
});

View File

@@ -0,0 +1,406 @@
import { expect, test } from "bun:test";
import type { ApiCustomerV5, CheckResponseV3 } from "@autumn/shared";
import { deleteLock } from "@tests/integration/balances/utils/lockUtils/deleteLock.js";
import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect.js";
import { TestFeature } from "@tests/setup/v2Features.js";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import { timeout } from "@tests/utils/genUtils.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
import { setCustomerOverageAllowed } from "../../utils/overage-allowed-utils/customerOverageAllowedUtils.js";
test.concurrent(`${chalk.yellowBright("track-overage-allowed-1: free feature, enabled:true — usage exceeds granted (cache + db parity)")}`, async () => {
const freeProd = products.base({
id: "free-track-overage",
items: [items.lifetimeMessages({ includedUsage: 100 })],
});
const { autumnV2_1, customerId } = await initScenario({
customerId: "track-overage-allowed-1",
setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })],
actions: [s.attach({ productId: freeProd.id })],
});
await setCustomerOverageAllowed({
autumn: autumnV2_1,
customerId,
featureId: TestFeature.Messages,
enabled: true,
});
await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 130,
});
const cached = await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer: cached,
featureId: TestFeature.Messages,
remaining: 0,
usage: 130,
});
await timeout(4000);
const uncached = await autumnV2_1.customers.get<ApiCustomerV5>(customerId, {
skip_cache: "true",
});
expectBalanceCorrect({
customer: uncached,
featureId: TestFeature.Messages,
remaining: 0,
usage: 130,
});
});
test.concurrent(`${chalk.yellowBright("track-overage-allowed-2: free feature, no billing control (baseline) — usage caps at granted")}`, async () => {
const freeProd = products.base({
id: "free-track-no-control",
items: [items.lifetimeMessages({ includedUsage: 100 })],
});
const { autumnV2_1, customerId } = await initScenario({
customerId: "track-overage-allowed-2",
setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })],
actions: [s.attach({ productId: freeProd.id })],
});
await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 130,
});
const customer = await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer,
featureId: TestFeature.Messages,
remaining: 0,
usage: 100,
});
});
test.concurrent(`${chalk.yellowBright("track-overage-allowed-3: free feature, enabled:true, multiple tracks — usage grows past granted")}`, async () => {
const freeProd = products.base({
id: "free-track-multi",
items: [items.lifetimeMessages({ includedUsage: 100 })],
});
const { autumnV2_1, customerId } = await initScenario({
customerId: "track-overage-allowed-3",
setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })],
actions: [s.attach({ productId: freeProd.id })],
});
await setCustomerOverageAllowed({
autumn: autumnV2_1,
customerId,
featureId: TestFeature.Messages,
enabled: true,
});
await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 80,
});
let customer = await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer,
featureId: TestFeature.Messages,
remaining: 20,
usage: 80,
});
await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 50,
});
customer = await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer,
featureId: TestFeature.Messages,
remaining: 0,
usage: 130,
});
await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 70,
});
customer = await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer,
featureId: TestFeature.Messages,
remaining: 0,
usage: 200,
});
});
test.concurrent(`${chalk.yellowBright("track-overage-allowed-4: overage_behavior:reject succeeds when overage_allowed is enabled")}`, async () => {
const freeProd = products.base({
id: "free-track-reject-allowed",
items: [items.lifetimeMessages({ includedUsage: 50 })],
});
const { autumnV2_1, customerId } = await initScenario({
customerId: "track-overage-allowed-4",
setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })],
actions: [s.attach({ productId: freeProd.id })],
});
await setCustomerOverageAllowed({
autumn: autumnV2_1,
customerId,
featureId: TestFeature.Messages,
enabled: true,
});
await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 50,
});
await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 30,
overage_behavior: "reject",
});
const customer = await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer,
featureId: TestFeature.Messages,
remaining: 0,
usage: 80,
});
});
test.concurrent(`${chalk.yellowBright("track-overage-allowed-5: send_event:true allowed when overage_allowed is enabled")}`, async () => {
const freeProd = products.base({
id: "free-track-send-event",
items: [items.lifetimeMessages({ includedUsage: 50 })],
});
const { autumnV2_1, customerId } = await initScenario({
customerId: "track-overage-allowed-5",
setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })],
actions: [s.attach({ productId: freeProd.id })],
});
await setCustomerOverageAllowed({
autumn: autumnV2_1,
customerId,
featureId: TestFeature.Messages,
enabled: true,
});
await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 50,
});
const checkResult = await autumnV2_1.check<CheckResponseV3>({
customer_id: customerId,
feature_id: TestFeature.Messages,
required_balance: 25,
send_event: true,
});
expect(checkResult.allowed).toBe(true);
const customer = await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer,
featureId: TestFeature.Messages,
remaining: 0,
usage: 75,
});
});
test.concurrent(`${chalk.yellowBright("track-overage-allowed-6: disabling overage_allowed reverts to capping at granted")}`, async () => {
const freeProd = products.base({
id: "free-track-disable",
items: [items.lifetimeMessages({ includedUsage: 100 })],
});
const { autumnV2_1, customerId } = await initScenario({
customerId: "track-overage-allowed-6",
setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })],
actions: [s.attach({ productId: freeProd.id })],
});
await setCustomerOverageAllowed({
autumn: autumnV2_1,
customerId,
featureId: TestFeature.Messages,
enabled: true,
});
await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 120,
});
let customer = await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer,
featureId: TestFeature.Messages,
remaining: 0,
usage: 120,
});
await setCustomerOverageAllowed({
autumn: autumnV2_1,
customerId,
featureId: TestFeature.Messages,
enabled: false,
});
await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 50,
});
customer = await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer,
featureId: TestFeature.Messages,
remaining: 0,
usage: 120,
});
});
test.concurrent(`${chalk.yellowBright("track-overage-allowed-7: lock + finalize with overage allowed — lock succeeds at 0, finalize confirm")}`, async () => {
const freeProd = products.base({
id: "free-lock-overage",
items: [items.lifetimeMessages({ includedUsage: 50 })],
});
const customerId = "track-overage-allowed-7";
const { autumnV2_1, ctx } = await initScenario({
customerId,
setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })],
actions: [s.attach({ productId: freeProd.id })],
});
await setCustomerOverageAllowed({
autumn: autumnV2_1,
customerId,
featureId: TestFeature.Messages,
enabled: true,
});
await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 50,
});
await deleteLock({ ctx, lockId: customerId });
const checkResult = await autumnV2_1.check({
customer_id: customerId,
feature_id: TestFeature.Messages,
required_balance: 30,
lock: { enabled: true, lock_id: customerId },
});
expect(checkResult.allowed).toBe(true);
await autumnV2_1.balances.finalize({
lock_id: customerId,
action: "confirm",
override_value: 20,
});
const cached = await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer: cached,
featureId: TestFeature.Messages,
remaining: 0,
usage: 70,
});
await timeout(4000);
const uncached = await autumnV2_1.customers.get<ApiCustomerV5>(customerId, {
skip_cache: "true",
});
expectBalanceCorrect({
customer: uncached,
featureId: TestFeature.Messages,
remaining: 0,
usage: 70,
});
});
test.concurrent(`${chalk.yellowBright("track-overage-allowed-8: lock + finalize with override > lockValue — additional deduction beyond lock")}`, async () => {
const freeProd = products.base({
id: "free-lock-override",
items: [items.lifetimeMessages({ includedUsage: 50 })],
});
const customerId = "track-overage-allowed-8";
const { autumnV2_1, ctx } = await initScenario({
customerId,
setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })],
actions: [s.attach({ productId: freeProd.id })],
});
await setCustomerOverageAllowed({
autumn: autumnV2_1,
customerId,
featureId: TestFeature.Messages,
enabled: true,
});
await deleteLock({ ctx, lockId: customerId });
const checkResult = await autumnV2_1.check({
customer_id: customerId,
feature_id: TestFeature.Messages,
required_balance: 30,
lock: { enabled: true, lock_id: customerId },
});
expect(checkResult.allowed).toBe(true);
await autumnV2_1.balances.finalize({
lock_id: customerId,
action: "confirm",
override_value: 80,
});
const cached = await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer: cached,
featureId: TestFeature.Messages,
remaining: 0,
usage: 80,
});
await timeout(4000);
const uncached = await autumnV2_1.customers.get<ApiCustomerV5>(customerId, {
skip_cache: "true",
});
expectBalanceCorrect({
customer: uncached,
featureId: TestFeature.Messages,
remaining: 0,
usage: 80,
});
});

View File

@@ -0,0 +1,29 @@
import type { CustomerBillingControls } from "@autumn/shared";
import type { initScenario } from "@tests/utils/testInitUtils/initScenario.js";
type AutumnV2_1Client = Awaited<ReturnType<typeof initScenario>>["autumnV2_1"];
export const setCustomerOverageAllowed = async ({
autumn,
customerId,
featureId,
enabled = true,
}: {
autumn: AutumnV2_1Client;
customerId: string;
featureId: string;
enabled?: boolean;
}) => {
const billingControls: CustomerBillingControls = {
overage_allowed: [
{
feature_id: featureId,
enabled,
},
],
};
await autumn.customers.update(customerId, {
billing_controls: billingControls,
});
};

View File

@@ -38,6 +38,15 @@ const usageAlertControls: CustomerBillingControls = {
], ],
}; };
const overageAllowedControls: CustomerBillingControls = {
overage_allowed: [
{
feature_id: TestFeature.Messages,
enabled: true,
},
],
};
test.concurrent(`${chalk.yellowBright("customer billing controls: create customer with spend limits")}`, async () => { test.concurrent(`${chalk.yellowBright("customer billing controls: create customer with spend limits")}`, async () => {
const customerId = "customer-billing-controls-1"; const customerId = "customer-billing-controls-1";
const { autumnV2_1, ctx } = await initScenario({ const { autumnV2_1, ctx } = await initScenario({
@@ -335,3 +344,131 @@ test.concurrent(`${chalk.yellowBright("customer billing controls: clearing usage
spendLimitControls.spend_limits, spendLimitControls.spend_limits,
); );
}); });
test.concurrent(`${chalk.yellowBright("customer billing controls: create customer with overage_allowed")}`, async () => {
const customerId = "customer-billing-controls-8";
const { autumnV2_1, ctx } = await initScenario({
setup: [s.deleteCustomer({ customerId })],
actions: [],
});
await autumnV2_1.customers.create({
id: customerId,
name: "Overage Allowed Customer",
email: `${customerId}@example.com`,
billing_controls: overageAllowedControls,
});
const cachedCustomer =
await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
expect(cachedCustomer.billing_controls?.overage_allowed).toEqual(
overageAllowedControls.overage_allowed,
);
const uncachedCustomer = await autumnV2_1.customers.get<ApiCustomerV5>(
customerId,
{
skip_cache: "true",
},
);
expect(uncachedCustomer.billing_controls?.overage_allowed).toEqual(
overageAllowedControls.overage_allowed,
);
const fromDb = await CusService.getFull({ ctx, idOrInternalId: customerId });
expect(fromDb.overage_allowed).toEqual(
overageAllowedControls.overage_allowed,
);
});
test.concurrent(`${chalk.yellowBright("customer billing controls: update overage_allowed without clearing other billing controls")}`, async () => {
const customerId = "customer-billing-controls-9";
const { autumnV2_1 } = await initScenario({
customerId,
setup: [s.customer({})],
actions: [],
});
await autumnV2_1.customers.update(customerId, {
billing_controls: spendLimitControls,
});
await autumnV2_1.customers.update(customerId, {
billing_controls: overageAllowedControls,
});
const cached = await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
expect(cached.billing_controls?.spend_limits).toEqual(
spendLimitControls.spend_limits,
);
expect(cached.billing_controls?.overage_allowed).toEqual(
overageAllowedControls.overage_allowed,
);
const uncached = await autumnV2_1.customers.get<ApiCustomerV5>(customerId, {
skip_cache: "true",
});
expect(uncached.billing_controls?.spend_limits).toEqual(
spendLimitControls.spend_limits,
);
expect(uncached.billing_controls?.overage_allowed).toEqual(
overageAllowedControls.overage_allowed,
);
});
test.concurrent(`${chalk.yellowBright("customer billing controls: reject duplicate overage_allowed feature ids")}`, async () => {
const customerId = "customer-billing-controls-10";
const { autumnV2_1 } = await initScenario({
setup: [s.deleteCustomer({ customerId })],
actions: [],
});
await expectAutumnError({
func: async () =>
await autumnV2_1.customers.create({
id: customerId,
name: "Duplicate Overage Allowed",
email: `${customerId}@example.com`,
billing_controls: {
overage_allowed: [
{ feature_id: TestFeature.Messages, enabled: true },
{ feature_id: TestFeature.Messages, enabled: false },
],
},
}),
});
});
test.concurrent(`${chalk.yellowBright("customer billing controls: clearing overage_allowed with empty array")}`, async () => {
const customerId = "customer-billing-controls-11";
const { autumnV2_1 } = await initScenario({
customerId,
setup: [s.customer({})],
actions: [],
});
await autumnV2_1.customers.update(customerId, {
billing_controls: {
...spendLimitControls,
...overageAllowedControls,
},
});
await autumnV2_1.customers.update(customerId, {
billing_controls: { overage_allowed: [] },
});
const cached = await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
expect(cached.billing_controls?.overage_allowed).toEqual([]);
expect(cached.billing_controls?.spend_limits).toEqual(
spendLimitControls.spend_limits,
);
const uncached = await autumnV2_1.customers.get<ApiCustomerV5>(customerId, {
skip_cache: "true",
});
expect(uncached.billing_controls?.overage_allowed).toEqual([]);
expect(uncached.billing_controls?.spend_limits).toEqual(
spendLimitControls.spend_limits,
);
});

View File

@@ -28,6 +28,15 @@ const usageAlertControls: EntityBillingControls = {
], ],
}; };
const overageAllowedControls: EntityBillingControls = {
overage_allowed: [
{
feature_id: TestFeature.Messages,
enabled: true,
},
],
};
test.concurrent(`${chalk.yellowBright("entity billing controls: create and update entity spend limits")}`, async () => { test.concurrent(`${chalk.yellowBright("entity billing controls: create and update entity spend limits")}`, async () => {
const { customerId, autumnV2_1 } = await initScenario({ const { customerId, autumnV2_1 } = await initScenario({
customerId: "entity-billing-controls-1", customerId: "entity-billing-controls-1",
@@ -351,3 +360,127 @@ test.concurrent(`${chalk.yellowBright("entity billing controls: clearing usage a
initialBillingControls.spend_limits, initialBillingControls.spend_limits,
); );
}); });
test.concurrent(`${chalk.yellowBright("entity billing controls: create entity with overage_allowed")}`, async () => {
const { customerId, autumnV2_1 } = await initScenario({
customerId: "entity-billing-controls-9",
setup: [s.customer({})],
actions: [],
});
const created = await autumnV2_1.entities.create(customerId, {
id: "entity-oa-1",
name: "Entity OA 1",
feature_id: TestFeature.Users,
billing_controls: overageAllowedControls,
});
expect((created as ApiEntityV2).billing_controls?.overage_allowed).toEqual(
overageAllowedControls.overage_allowed,
);
const fetched = await autumnV2_1.entities.get<ApiEntityV2>(
customerId,
"entity-oa-1",
);
expect(fetched.billing_controls?.overage_allowed).toEqual(
overageAllowedControls.overage_allowed,
);
});
test.concurrent(`${chalk.yellowBright("entity billing controls: update overage_allowed without clearing spend limits")}`, async () => {
const { customerId, autumnV2_1 } = await initScenario({
customerId: "entity-billing-controls-10",
setup: [s.customer({})],
actions: [],
});
await autumnV2_1.entities.create(customerId, {
id: "entity-oa-preserve-1",
name: "Entity OA Preserve 1",
feature_id: TestFeature.Users,
billing_controls: initialBillingControls,
});
await autumnV2_1.entities.update(customerId, "entity-oa-preserve-1", {
billing_controls: overageAllowedControls,
});
const fetched = await autumnV2_1.entities.get<ApiEntityV2>(
customerId,
"entity-oa-preserve-1",
);
expect(fetched.billing_controls?.spend_limits).toEqual(
initialBillingControls.spend_limits,
);
expect(fetched.billing_controls?.overage_allowed).toEqual(
overageAllowedControls.overage_allowed,
);
const fromDb = await CusService.getFull({
ctx,
idOrInternalId: customerId,
withEntities: true,
});
const entity = fromDb.entities.find(
(candidate) => candidate.id === "entity-oa-preserve-1",
);
expect(entity?.spend_limits).toEqual(initialBillingControls.spend_limits);
expect(entity?.overage_allowed).toEqual(
overageAllowedControls.overage_allowed,
);
});
test.concurrent(`${chalk.yellowBright("entity billing controls: reject duplicate overage_allowed feature ids")}`, async () => {
const { customerId, autumnV2_1 } = await initScenario({
customerId: "entity-billing-controls-11",
setup: [s.customer({})],
actions: [],
});
await expectAutumnError({
func: async () =>
await autumnV2_1.entities.create(customerId, {
id: "entity-oa-dup",
name: "Entity OA Dup",
feature_id: TestFeature.Users,
billing_controls: {
overage_allowed: [
{ feature_id: TestFeature.Messages, enabled: true },
{ feature_id: TestFeature.Messages, enabled: false },
],
},
}),
});
});
test.concurrent(`${chalk.yellowBright("entity billing controls: clearing overage_allowed with empty array")}`, async () => {
const { customerId, autumnV2_1 } = await initScenario({
customerId: "entity-billing-controls-12",
setup: [s.customer({})],
actions: [],
});
await autumnV2_1.entities.create(customerId, {
id: "entity-oa-clear-1",
name: "Entity OA Clear 1",
feature_id: TestFeature.Users,
billing_controls: {
...initialBillingControls,
...overageAllowedControls,
},
});
await autumnV2_1.entities.update(customerId, "entity-oa-clear-1", {
billing_controls: { overage_allowed: [] },
});
const fetched = await autumnV2_1.entities.get<ApiEntityV2>(
customerId,
"entity-oa-clear-1",
);
expect(fetched.billing_controls?.overage_allowed).toEqual([]);
expect(fetched.billing_controls?.spend_limits).toEqual(
initialBillingControls.spend_limits,
);
});

View File

@@ -1,4 +1,5 @@
import { z } from "zod/v4"; import { z } from "zod/v4";
import { ApiOverageAllowedSchema } from "./overageAllowed.js";
import { ApiSpendLimitSchema } from "./spendLimit.js"; import { ApiSpendLimitSchema } from "./spendLimit.js";
import { ApiUsageAlertSchema } from "./usageAlert.js"; import { ApiUsageAlertSchema } from "./usageAlert.js";
@@ -9,12 +10,16 @@ export const ApiEntityBillingControlsSchema = z.object({
usage_alerts: z.array(ApiUsageAlertSchema).optional().meta({ usage_alerts: z.array(ApiUsageAlertSchema).optional().meta({
description: "List of usage alert configurations per feature.", 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 = export const ApiEntityBillingControlsParamsSchema =
ApiEntityBillingControlsSchema.check((ctx) => { ApiEntityBillingControlsSchema.check((ctx) => {
const billingControls = ctx.value; const billingControls = ctx.value;
const featureIds = new Set<string>(); const spendLimitFeatureIds = new Set<string>();
for (const [index, spendLimit] of ( for (const [index, spendLimit] of (
billingControls.spend_limits ?? [] billingControls.spend_limits ?? []
@@ -23,7 +28,7 @@ export const ApiEntityBillingControlsParamsSchema =
continue; continue;
} }
if (featureIds.has(spendLimit.feature_id)) { if (spendLimitFeatureIds.has(spendLimit.feature_id)) {
ctx.issues.push({ ctx.issues.push({
code: "custom", code: "custom",
message: "Only one spend limit entry is allowed per feature_id", message: "Only one spend limit entry is allowed per feature_id",
@@ -33,7 +38,25 @@ export const ApiEntityBillingControlsParamsSchema =
return; return;
} }
featureIds.add(spendLimit.feature_id); spendLimitFeatureIds.add(spendLimit.feature_id);
}
const overageAllowedFeatureIds = new Set<string>();
for (const [index, overageAllowed] of (
billingControls.overage_allowed ?? []
).entries()) {
if (overageAllowedFeatureIds.has(overageAllowed.feature_id)) {
ctx.issues.push({
code: "custom",
message: "Only one overage_allowed entry is allowed per feature_id",
input: overageAllowed.feature_id,
path: ["overage_allowed", index, "feature_id"],
});
return;
}
overageAllowedFeatureIds.add(overageAllowed.feature_id);
} }
}); });

View File

@@ -1,3 +1,4 @@
export * from "./entityBillingControls.js"; export * from "./entityBillingControls.js";
export * from "./overageAllowed.js";
export * from "./spendLimit.js"; export * from "./spendLimit.js";
export * from "./usageAlert.js"; export * from "./usageAlert.js";

View File

@@ -0,0 +1,6 @@
import type { z } from "zod/v4";
import { DbOverageAllowedSchema } from "../../models/cusModels/billingControls/overageAllowed.js";
export const ApiOverageAllowedSchema = DbOverageAllowedSchema;
export type ApiOverageAllowed = z.infer<typeof ApiOverageAllowedSchema>;

View File

@@ -1,6 +1,7 @@
import type { ApiSubjectV0 } from "@api/customers/apiSubjectV0"; import type { ApiSubjectV0 } from "@api/customers/apiSubjectV0";
import type { ApiBalanceV1 } from "@api/customers/cusFeatures/apiBalanceV1"; import type { ApiBalanceV1 } from "@api/customers/cusFeatures/apiBalanceV1";
import { apiBalanceV1ToAvailableOverage } from "@api/customers/cusFeatures/utils/convert/apiBalanceV1ToAvailableOverage"; import { apiBalanceV1ToAvailableOverage } from "@api/customers/cusFeatures/utils/convert/apiBalanceV1ToAvailableOverage";
import { apiSubjectToOverageAllowedControl } from "@api/customers/utils/apiSubjectToOverageAllowed";
import type { Feature } from "@models/featureModels/featureModels"; import type { Feature } from "@models/featureModels/featureModels";
import { isBooleanFeature, notNullish } from "@utils/index"; import { isBooleanFeature, notNullish } from "@utils/index";
import { Decimal } from "decimal.js"; import { Decimal } from "decimal.js";
@@ -31,13 +32,23 @@ export const apiBalanceToAllowed = ({
if (requiredBalance < 0) return { allowed: true }; if (requiredBalance < 0) return { allowed: true };
if (apiBalance.overage_allowed) { const overageAllowedControl = apiSubjectToOverageAllowedControl({
subject: apiSubject,
feature,
});
// console.log("Overage allowed control", overageAllowedControl);
if (apiBalance.overage_allowed || overageAllowedControl?.enabled) {
const { availableOverage, reason } = apiBalanceV1ToAvailableOverage({ const { availableOverage, reason } = apiBalanceV1ToAvailableOverage({
apiBalance, apiBalance,
apiSubject, apiSubject,
feature, feature,
}); });
// console.log("Available overage", availableOverage);
// console.log("Reason", reason);
if (notNullish(availableOverage)) { if (notNullish(availableOverage)) {
const allowed = new Decimal(availableOverage) const allowed = new Decimal(availableOverage)
.add(apiBalance.remaining) .add(apiBalance.remaining)

View File

@@ -16,7 +16,7 @@ export const apiBalanceBreakdownV1ToMaxOverage = ({
return apiBalanceBreakdown.price?.max_purchase ?? undefined; return apiBalanceBreakdown.price?.max_purchase ?? undefined;
} }
return 0; return undefined;
}; };
export const apiBalanceV1ToMaxOverage = ({ export const apiBalanceV1ToMaxOverage = ({

View File

@@ -0,0 +1,25 @@
import type { ApiOverageAllowed } from "@api/billingControls";
import type { Feature } from "@models/featureModels/featureModels";
import type { ApiSubjectV0 } from "../apiSubjectV0";
export const apiSubjectToOverageAllowedControl = ({
subject,
feature,
}: {
subject: ApiSubjectV0;
feature: Feature;
}): ApiOverageAllowed | undefined => {
if (!("billing_controls" in subject) || !subject.billing_controls) {
return undefined;
}
if (!("overage_allowed" in subject.billing_controls)) {
return undefined;
}
const overageAllowed = subject.billing_controls.overage_allowed ?? [];
return overageAllowed.find(
(entry) => entry.enabled && entry.feature_id === feature.id,
);
};

View File

@@ -4,6 +4,10 @@ import {
type EntityBillingControlsParams, type EntityBillingControlsParams,
EntityBillingControlsSchema, EntityBillingControlsSchema,
} from "./entityBillingControls.js"; } from "./entityBillingControls.js";
import {
type DbOverageAllowed,
DbOverageAllowedSchema,
} from "./overageAllowed.js";
import { PurchaseLimitIntervalEnum } from "./purchaseLimitInterval.js"; import { PurchaseLimitIntervalEnum } from "./purchaseLimitInterval.js";
import { type DbSpendLimit, DbSpendLimitSchema } from "./spendLimit.js"; import { type DbSpendLimit, DbSpendLimitSchema } from "./spendLimit.js";
import { type DbUsageAlert, DbUsageAlertSchema } from "./usageAlert.js"; import { type DbUsageAlert, DbUsageAlertSchema } from "./usageAlert.js";
@@ -49,12 +53,16 @@ export const CustomerBillingControlsSchema = z.object({
usage_alerts: z.array(DbUsageAlertSchema).optional().meta({ usage_alerts: z.array(DbUsageAlertSchema).optional().meta({
description: "List of usage alert configurations per feature.", description: "List of usage alert configurations per feature.",
}), }),
overage_allowed: z.array(DbOverageAllowedSchema).optional().meta({
description:
"List of overage allowed controls per feature. When enabled, usage can exceed balance.",
}),
}); });
export const CustomerBillingControlsParamsSchema = export const CustomerBillingControlsParamsSchema =
CustomerBillingControlsSchema.check((ctx) => { CustomerBillingControlsSchema.check((ctx) => {
const billingControls = ctx.value; const billingControls = ctx.value;
const featureIds = new Set<string>(); const spendLimitFeatureIds = new Set<string>();
for (const [index, spendLimit] of ( for (const [index, spendLimit] of (
billingControls.spend_limits ?? [] billingControls.spend_limits ?? []
@@ -63,7 +71,7 @@ export const CustomerBillingControlsParamsSchema =
continue; continue;
} }
if (featureIds.has(spendLimit.feature_id)) { if (spendLimitFeatureIds.has(spendLimit.feature_id)) {
ctx.issues.push({ ctx.issues.push({
code: "custom", code: "custom",
message: "Only one spend limit entry is allowed per feature_id", message: "Only one spend limit entry is allowed per feature_id",
@@ -73,7 +81,25 @@ export const CustomerBillingControlsParamsSchema =
return; return;
} }
featureIds.add(spendLimit.feature_id); spendLimitFeatureIds.add(spendLimit.feature_id);
}
const overageAllowedFeatureIds = new Set<string>();
for (const [index, overageAllowed] of (
billingControls.overage_allowed ?? []
).entries()) {
if (overageAllowedFeatureIds.has(overageAllowed.feature_id)) {
ctx.issues.push({
code: "custom",
message: "Only one overage_allowed entry is allowed per feature_id",
input: overageAllowed.feature_id,
path: ["overage_allowed", index, "feature_id"],
});
return;
}
overageAllowedFeatureIds.add(overageAllowed.feature_id);
} }
}); });
@@ -89,10 +115,16 @@ export type CustomerBillingControlsParams = z.input<
typeof CustomerBillingControlsParamsSchema typeof CustomerBillingControlsParamsSchema
>; >;
export { EntityBillingControlsSchema, DbSpendLimitSchema, DbUsageAlertSchema };
export type { export type {
EntityBillingControls, DbOverageAllowed,
EntityBillingControlsParams,
DbSpendLimit, DbSpendLimit,
DbUsageAlert, DbUsageAlert,
EntityBillingControls,
EntityBillingControlsParams,
};
export {
DbOverageAllowedSchema,
DbSpendLimitSchema,
DbUsageAlertSchema,
EntityBillingControlsSchema,
}; };

View File

@@ -1,4 +1,5 @@
import { z } from "zod/v4"; import { z } from "zod/v4";
import { DbOverageAllowedSchema } from "./overageAllowed.js";
import { DbSpendLimitSchema } from "./spendLimit.js"; import { DbSpendLimitSchema } from "./spendLimit.js";
import { DbUsageAlertSchema } from "./usageAlert.js"; import { DbUsageAlertSchema } from "./usageAlert.js";
@@ -9,6 +10,10 @@ export const EntityBillingControlsSchema = z.object({
usage_alerts: z.array(DbUsageAlertSchema).optional().meta({ usage_alerts: z.array(DbUsageAlertSchema).optional().meta({
description: "List of usage alert configurations per feature.", description: "List of usage alert configurations per feature.",
}), }),
overage_allowed: z.array(DbOverageAllowedSchema).optional().meta({
description:
"List of overage allowed controls per feature. When enabled, usage can exceed balance.",
}),
}); });
export type EntityBillingControls = z.infer<typeof EntityBillingControlsSchema>; export type EntityBillingControls = z.infer<typeof EntityBillingControlsSchema>;

View File

@@ -0,0 +1,12 @@
import { z } from "zod/v4";
export const DbOverageAllowedSchema = z.object({
feature_id: z.string().meta({
description: "The feature ID this overage allowed control applies to.",
}),
enabled: z.boolean().default(false).meta({
description: "Whether overage is allowed for this feature.",
}),
});
export type DbOverageAllowed = z.infer<typeof DbOverageAllowedSchema>;

View File

@@ -1,31 +1,28 @@
import { z } from "zod/v4"; import { z } from "zod/v4";
export const UsageAlertThresholdType = z.enum([ export const UsageAlertThresholdType = z.enum(["usage", "usage_percentage"]);
"usage",
"usage_percentage",
]);
export const DbUsageAlertSchema = z export const DbUsageAlertSchema = z
.object({ .object({
feature_id: z.string().optional().meta({ feature_id: z.string().optional().meta({
description: description:
"The feature ID this alert applies to. If omitted, the alert applies globally.", "The feature ID this alert applies to. If omitted, the alert applies globally.",
}), }),
enabled: z.boolean().default(true).meta({ enabled: z.boolean().default(true).meta({
description: "Whether this usage alert is enabled.", description: "Whether this usage alert is enabled.",
}), }),
threshold: z.number().min(0).meta({ threshold: z.number().min(0).meta({
description: description:
"The threshold value that triggers the alert. For usage, this is an absolute count. For usage_percentage, this is a percentage (0-100).", "The threshold value that triggers the alert. For usage, this is an absolute count. For usage_percentage, this is a percentage (0-100).",
}), }),
threshold_type: UsageAlertThresholdType.meta({ threshold_type: UsageAlertThresholdType.meta({
description: description:
"Whether the threshold is an absolute usage count or a percentage of the usage allowance.", "Whether the threshold is an absolute usage count or a percentage of the usage allowance.",
}), }),
name: z.string().optional().meta({ name: z.string().optional().meta({
description: description:
"Optional user-defined label to distinguish multiple alerts on the same feature.", "Optional user-defined label to distinguish multiple alerts on the same feature.",
}), }),
}) })
.check((ctx) => { .check((ctx) => {
const { threshold_type, threshold } = ctx.value; const { threshold_type, threshold } = ctx.value;
@@ -35,8 +32,7 @@ export const DbUsageAlertSchema = z
code: "custom", code: "custom",
input: threshold, input: threshold,
path: ["threshold"], path: ["threshold"],
message: message: "Threshold must be between 0 and 100 for usage_percentage",
"Threshold must be between 0 and 100 for usage_percentage",
}); });
} }
}); });

View File

@@ -3,6 +3,7 @@ import { AppEnv } from "../genModels/genEnums.js";
import { ExternalProcessorsSchema } from "../genModels/processorSchemas.js"; import { ExternalProcessorsSchema } from "../genModels/processorSchemas.js";
import { import {
AutoTopupSchema, AutoTopupSchema,
DbOverageAllowedSchema,
DbSpendLimitSchema, DbSpendLimitSchema,
DbUsageAlertSchema, DbUsageAlertSchema,
} from "./billingControls/customerBillingControls.js"; } from "./billingControls/customerBillingControls.js";
@@ -25,6 +26,7 @@ export const CustomerSchema = z.object({
auto_topups: z.array(AutoTopupSchema).nullish(), auto_topups: z.array(AutoTopupSchema).nullish(),
spend_limits: z.array(DbSpendLimitSchema).nullish(), spend_limits: z.array(DbSpendLimitSchema).nullish(),
usage_alerts: z.array(DbUsageAlertSchema).nullish(), usage_alerts: z.array(DbUsageAlertSchema).nullish(),
overage_allowed: z.array(DbOverageAllowedSchema).nullish(),
}); });
export type Customer = z.infer<typeof CustomerSchema>; export type Customer = z.infer<typeof CustomerSchema>;

View File

@@ -15,6 +15,7 @@ import type { ExternalProcessors } from "../genModels/processorSchemas.js";
import { organizations } from "../orgModels/orgTable.js"; import { organizations } from "../orgModels/orgTable.js";
import type { import type {
AutoTopup, AutoTopup,
DbOverageAllowed,
DbSpendLimit, DbSpendLimit,
DbUsageAlert, DbUsageAlert,
} from "./billingControls/customerBillingControls.js"; } from "./billingControls/customerBillingControls.js";
@@ -44,6 +45,7 @@ export const customers = pgTable(
auto_topups: jsonb().$type<AutoTopup[]>(), auto_topups: jsonb().$type<AutoTopup[]>(),
spend_limits: jsonb().$type<DbSpendLimit[]>(), spend_limits: jsonb().$type<DbSpendLimit[]>(),
usage_alerts: jsonb().$type<DbUsageAlert[]>(), usage_alerts: jsonb().$type<DbUsageAlert[]>(),
overage_allowed: jsonb().$type<DbOverageAllowed[]>(),
}, },
(table) => [ (table) => [
unique("cus_id_constraint").on(table.org_id, table.id, table.env), unique("cus_id_constraint").on(table.org_id, table.id, table.env),

View File

@@ -1,6 +1,7 @@
import { z } from "zod/v4"; import { z } from "zod/v4";
import type { Feature } from "../../featureModels/featureModels.js"; import type { Feature } from "../../featureModels/featureModels.js";
import { import {
DbOverageAllowedSchema,
DbSpendLimitSchema, DbSpendLimitSchema,
DbUsageAlertSchema, DbUsageAlertSchema,
} from "../billingControls/customerBillingControls.js"; } from "../billingControls/customerBillingControls.js";
@@ -18,6 +19,7 @@ export const EntitySchema = z.object({
internal_feature_id: z.string(), internal_feature_id: z.string(),
spend_limits: z.array(DbSpendLimitSchema).nullish(), spend_limits: z.array(DbSpendLimitSchema).nullish(),
usage_alerts: z.array(DbUsageAlertSchema).nullish(), usage_alerts: z.array(DbUsageAlertSchema).nullish(),
overage_allowed: z.array(DbOverageAllowedSchema).nullish(),
}); });
// export const CreateEntitySchema = z.object({ // export const CreateEntitySchema = z.object({

View File

@@ -12,6 +12,7 @@ import {
import { features } from "../../featureModels/featureTable.js"; import { features } from "../../featureModels/featureTable.js";
import { organizations } from "../../orgModels/orgTable.js"; import { organizations } from "../../orgModels/orgTable.js";
import type { import type {
DbOverageAllowed,
DbSpendLimit, DbSpendLimit,
DbUsageAlert, DbUsageAlert,
} from "../billingControls/customerBillingControls.js"; } from "../billingControls/customerBillingControls.js";
@@ -31,6 +32,7 @@ export const entities = pgTable(
internal_feature_id: text("internal_feature_id"), internal_feature_id: text("internal_feature_id"),
spend_limits: jsonb().$type<DbSpendLimit[]>(), spend_limits: jsonb().$type<DbSpendLimit[]>(),
usage_alerts: jsonb().$type<DbUsageAlert[]>(), usage_alerts: jsonb().$type<DbUsageAlert[]>(),
overage_allowed: jsonb().$type<DbOverageAllowed[]>(),
// Optional... // Optional...
feature_id: text("feature_id"), feature_id: text("feature_id"),

View File

@@ -0,0 +1,36 @@
import type { DbOverageAllowed } from "@models/cusModels/billingControls/customerBillingControls.js";
import type { FullCustomer } from "@models/cusModels/fullCusModel.js";
/** Extract enabled overage_allowed entries for the requested features from a FullCustomer. */
export const fullCustomerToOverageAllowedByFeatureId = ({
fullCustomer,
featureIds,
internalEntityId,
}: {
fullCustomer: FullCustomer;
featureIds: string[];
internalEntityId?: string;
}): Record<string, DbOverageAllowed> => {
const entity = internalEntityId
? fullCustomer.entities?.find(
(candidate) => candidate.internal_id === internalEntityId,
)
: fullCustomer.entity;
const scopedOverageAllowed = internalEntityId
? entity?.overage_allowed
: (entity?.overage_allowed ?? fullCustomer.overage_allowed);
const overageAllowedByFeatureId: Record<string, DbOverageAllowed> = {};
const uniqueFeatureIds = [...new Set(featureIds)];
for (const featureId of uniqueFeatureIds) {
const overageAllowed = scopedOverageAllowed?.find(
(candidate) => candidate.feature_id === featureId && candidate.enabled,
);
if (overageAllowed) {
overageAllowedByFeatureId[featureId] = overageAllowed;
}
}
return overageAllowedByFeatureId;
};

View File

@@ -5,5 +5,6 @@ export * from "./cusPlanUtils/cusPlanUtils";
export * from "./fullCusUtils/enrichFullCustomer"; export * from "./fullCusUtils/enrichFullCustomer";
export * from "./fullCusUtils/fullCustomerToCustomerEntitlements"; export * from "./fullCusUtils/fullCustomerToCustomerEntitlements";
export * from "./fullCusUtils/fullCustomerToOverageAllowed";
export * from "./fullCusUtils/fullCustomerToSpendLimit"; export * from "./fullCusUtils/fullCustomerToSpendLimit";
export * from "./fullCusUtils/getCusStripeSubCount"; export * from "./fullCusUtils/getCusStripeSubCount";