feat: overage allowed control
This commit is contained in:
7
.cursor/settings.json
Normal file
7
.cursor/settings.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"plugins": {
|
||||
"linear": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,7 @@
|
||||
"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",
|
||||
"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",
|
||||
"setup": "node scripts/setup/setup.js",
|
||||
"setup:test": "infisical run --env=dev -- bun scripts/setup/setup-test.ts",
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
processors?: object | null,
|
||||
auto_topups?: 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')
|
||||
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 })
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
cusEntToStartingBalance,
|
||||
type FullCustomer,
|
||||
fullCustomerToCustomerEntitlements,
|
||||
fullCustomerToOverageAllowedByFeatureId,
|
||||
fullCustomerToSpendLimitByFeatureId,
|
||||
fullCustomerToUsageBasedCusEntsByFeatureId,
|
||||
getMaxOverage,
|
||||
@@ -85,6 +86,10 @@ export const prepareFeatureDeduction = ({
|
||||
fullCustomer,
|
||||
featureIds: effectiveFeatureIds,
|
||||
});
|
||||
const overageAllowedByFeatureId = fullCustomerToOverageAllowedByFeatureId({
|
||||
fullCustomer,
|
||||
featureIds: effectiveFeatureIds,
|
||||
});
|
||||
|
||||
// Build input for each customer entitlement
|
||||
const customerEntitlementDeductions: CustomerEntitlementDeduction[] =
|
||||
@@ -104,12 +109,18 @@ export const prepareFeatureDeduction = ({
|
||||
const isFreeAllocatedUsageAllowed =
|
||||
isFreeAllocated && overageBehaviour !== "reject";
|
||||
|
||||
const billingControlOverageAllowed =
|
||||
overageAllowedByFeatureId[ce.entitlement.feature.id]?.enabled ?? false;
|
||||
|
||||
return {
|
||||
customer_entitlement_id: ce.id,
|
||||
credit_cost: creditCost,
|
||||
feature_id: ce.entitlement.feature.id,
|
||||
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,
|
||||
max_balance: resetBalance,
|
||||
};
|
||||
|
||||
@@ -115,6 +115,7 @@ export const updateCustomer = async ({
|
||||
auto_topups: billing_controls.auto_topups,
|
||||
spend_limits: billing_controls.spend_limits,
|
||||
usage_alerts: billing_controls.usage_alerts,
|
||||
overage_allowed: billing_controls.overage_allowed,
|
||||
}),
|
||||
};
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ export const getApiCustomerBase = async ({
|
||||
auto_topups: fullCus.auto_topups ?? undefined,
|
||||
spend_limits: fullCus.spend_limits ?? undefined,
|
||||
usage_alerts: fullCus.usage_alerts ?? undefined,
|
||||
overage_allowed: fullCus.overage_allowed ?? undefined,
|
||||
},
|
||||
|
||||
invoices:
|
||||
|
||||
@@ -23,6 +23,7 @@ type CustomerDataUpdates = Pick<
|
||||
| "auto_topups"
|
||||
| "spend_limits"
|
||||
| "usage_alerts"
|
||||
| "overage_allowed"
|
||||
>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -36,7 +36,9 @@ export const updateEntityInCache = async ({
|
||||
ctx: AutumnContext;
|
||||
customerId: string;
|
||||
idOrInternalId: string;
|
||||
updates: Partial<Pick<Entity, "spend_limits" | "usage_alerts">>;
|
||||
updates: Partial<
|
||||
Pick<Entity, "spend_limits" | "usage_alerts" | "overage_allowed">
|
||||
>;
|
||||
}): Promise<UpdateEntityInCacheResult | null> => {
|
||||
try {
|
||||
if (Object.keys(updates).length === 0) {
|
||||
|
||||
@@ -37,6 +37,7 @@ const initCustomer = ({
|
||||
auto_topups: customerData?.billing_controls?.auto_topups,
|
||||
spend_limits: customerData?.billing_controls?.spend_limits,
|
||||
usage_alerts: customerData?.billing_controls?.usage_alerts,
|
||||
overage_allowed: customerData?.billing_controls?.overage_allowed,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@ export const batchCreateEntities = async ({
|
||||
...(inputEntities[0].billing_controls && {
|
||||
spend_limits: inputEntities[0].billing_controls.spend_limits,
|
||||
usage_alerts: inputEntities[0].billing_controls.usage_alerts,
|
||||
overage_allowed: inputEntities[0].billing_controls.overage_allowed,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -45,6 +45,7 @@ export const updateEntity = async ({
|
||||
updates: {
|
||||
spend_limits: billing_controls?.spend_limits,
|
||||
usage_alerts: billing_controls?.usage_alerts,
|
||||
overage_allowed: billing_controls?.overage_allowed,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -12,11 +12,15 @@ export const updateEntityDbAndCache = async ({
|
||||
ctx: AutumnContext;
|
||||
customerId: string;
|
||||
entity: Entity;
|
||||
updates: Partial<Pick<Entity, "spend_limits" | "usage_alerts">>;
|
||||
updates: Partial<
|
||||
Pick<Entity, "spend_limits" | "usage_alerts" | "overage_allowed">
|
||||
>;
|
||||
}) => {
|
||||
const filteredUpdates = Object.fromEntries(
|
||||
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) {
|
||||
return entity;
|
||||
|
||||
@@ -75,6 +75,7 @@ export const getApiEntityBase = async ({
|
||||
billing_controls: {
|
||||
spend_limits: entity.spend_limits ?? undefined,
|
||||
usage_alerts: entity.usage_alerts ?? undefined,
|
||||
overage_allowed: entity.overage_allowed ?? undefined,
|
||||
},
|
||||
} satisfies ApiEntityV2);
|
||||
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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 () => {
|
||||
const customerId = "customer-billing-controls-1";
|
||||
const { autumnV2_1, ctx } = await initScenario({
|
||||
@@ -335,3 +344,131 @@ test.concurrent(`${chalk.yellowBright("customer billing controls: clearing usage
|
||||
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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
const { customerId, autumnV2_1 } = await initScenario({
|
||||
customerId: "entity-billing-controls-1",
|
||||
@@ -351,3 +360,127 @@ test.concurrent(`${chalk.yellowBright("entity billing controls: clearing usage a
|
||||
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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod/v4";
|
||||
import { ApiOverageAllowedSchema } from "./overageAllowed.js";
|
||||
import { ApiSpendLimitSchema } from "./spendLimit.js";
|
||||
import { ApiUsageAlertSchema } from "./usageAlert.js";
|
||||
|
||||
@@ -9,12 +10,16 @@ export const ApiEntityBillingControlsSchema = z.object({
|
||||
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) => {
|
||||
const billingControls = ctx.value;
|
||||
const featureIds = new Set<string>();
|
||||
const spendLimitFeatureIds = new Set<string>();
|
||||
|
||||
for (const [index, spendLimit] of (
|
||||
billingControls.spend_limits ?? []
|
||||
@@ -23,7 +28,7 @@ export const ApiEntityBillingControlsParamsSchema =
|
||||
continue;
|
||||
}
|
||||
|
||||
if (featureIds.has(spendLimit.feature_id)) {
|
||||
if (spendLimitFeatureIds.has(spendLimit.feature_id)) {
|
||||
ctx.issues.push({
|
||||
code: "custom",
|
||||
message: "Only one spend limit entry is allowed per feature_id",
|
||||
@@ -33,7 +38,25 @@ export const ApiEntityBillingControlsParamsSchema =
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./entityBillingControls.js";
|
||||
export * from "./overageAllowed.js";
|
||||
export * from "./spendLimit.js";
|
||||
export * from "./usageAlert.js";
|
||||
|
||||
6
shared/api/billingControls/overageAllowed.ts
Normal file
6
shared/api/billingControls/overageAllowed.ts
Normal 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>;
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ApiSubjectV0 } from "@api/customers/apiSubjectV0";
|
||||
import type { ApiBalanceV1 } from "@api/customers/cusFeatures/apiBalanceV1";
|
||||
import { apiBalanceV1ToAvailableOverage } from "@api/customers/cusFeatures/utils/convert/apiBalanceV1ToAvailableOverage";
|
||||
import { apiSubjectToOverageAllowedControl } from "@api/customers/utils/apiSubjectToOverageAllowed";
|
||||
import type { Feature } from "@models/featureModels/featureModels";
|
||||
import { isBooleanFeature, notNullish } from "@utils/index";
|
||||
import { Decimal } from "decimal.js";
|
||||
@@ -31,13 +32,23 @@ export const apiBalanceToAllowed = ({
|
||||
|
||||
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({
|
||||
apiBalance,
|
||||
apiSubject,
|
||||
feature,
|
||||
});
|
||||
|
||||
// console.log("Available overage", availableOverage);
|
||||
// console.log("Reason", reason);
|
||||
|
||||
if (notNullish(availableOverage)) {
|
||||
const allowed = new Decimal(availableOverage)
|
||||
.add(apiBalance.remaining)
|
||||
|
||||
@@ -16,7 +16,7 @@ export const apiBalanceBreakdownV1ToMaxOverage = ({
|
||||
return apiBalanceBreakdown.price?.max_purchase ?? undefined;
|
||||
}
|
||||
|
||||
return 0;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const apiBalanceV1ToMaxOverage = ({
|
||||
|
||||
25
shared/api/customers/utils/apiSubjectToOverageAllowed.ts
Normal file
25
shared/api/customers/utils/apiSubjectToOverageAllowed.ts
Normal 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,
|
||||
);
|
||||
};
|
||||
@@ -4,6 +4,10 @@ import {
|
||||
type EntityBillingControlsParams,
|
||||
EntityBillingControlsSchema,
|
||||
} from "./entityBillingControls.js";
|
||||
import {
|
||||
type DbOverageAllowed,
|
||||
DbOverageAllowedSchema,
|
||||
} from "./overageAllowed.js";
|
||||
import { PurchaseLimitIntervalEnum } from "./purchaseLimitInterval.js";
|
||||
import { type DbSpendLimit, DbSpendLimitSchema } from "./spendLimit.js";
|
||||
import { type DbUsageAlert, DbUsageAlertSchema } from "./usageAlert.js";
|
||||
@@ -49,12 +53,16 @@ export const CustomerBillingControlsSchema = z.object({
|
||||
usage_alerts: z.array(DbUsageAlertSchema).optional().meta({
|
||||
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 =
|
||||
CustomerBillingControlsSchema.check((ctx) => {
|
||||
const billingControls = ctx.value;
|
||||
const featureIds = new Set<string>();
|
||||
const spendLimitFeatureIds = new Set<string>();
|
||||
|
||||
for (const [index, spendLimit] of (
|
||||
billingControls.spend_limits ?? []
|
||||
@@ -63,7 +71,7 @@ export const CustomerBillingControlsParamsSchema =
|
||||
continue;
|
||||
}
|
||||
|
||||
if (featureIds.has(spendLimit.feature_id)) {
|
||||
if (spendLimitFeatureIds.has(spendLimit.feature_id)) {
|
||||
ctx.issues.push({
|
||||
code: "custom",
|
||||
message: "Only one spend limit entry is allowed per feature_id",
|
||||
@@ -73,7 +81,25 @@ export const CustomerBillingControlsParamsSchema =
|
||||
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
|
||||
>;
|
||||
|
||||
export { EntityBillingControlsSchema, DbSpendLimitSchema, DbUsageAlertSchema };
|
||||
export type {
|
||||
EntityBillingControls,
|
||||
EntityBillingControlsParams,
|
||||
DbOverageAllowed,
|
||||
DbSpendLimit,
|
||||
DbUsageAlert,
|
||||
EntityBillingControls,
|
||||
EntityBillingControlsParams,
|
||||
};
|
||||
export {
|
||||
DbOverageAllowedSchema,
|
||||
DbSpendLimitSchema,
|
||||
DbUsageAlertSchema,
|
||||
EntityBillingControlsSchema,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod/v4";
|
||||
import { DbOverageAllowedSchema } from "./overageAllowed.js";
|
||||
import { DbSpendLimitSchema } from "./spendLimit.js";
|
||||
import { DbUsageAlertSchema } from "./usageAlert.js";
|
||||
|
||||
@@ -9,6 +10,10 @@ export const EntityBillingControlsSchema = z.object({
|
||||
usage_alerts: z.array(DbUsageAlertSchema).optional().meta({
|
||||
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>;
|
||||
|
||||
12
shared/models/cusModels/billingControls/overageAllowed.ts
Normal file
12
shared/models/cusModels/billingControls/overageAllowed.ts
Normal 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>;
|
||||
@@ -1,31 +1,28 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const UsageAlertThresholdType = z.enum([
|
||||
"usage",
|
||||
"usage_percentage",
|
||||
]);
|
||||
export const UsageAlertThresholdType = z.enum(["usage", "usage_percentage"]);
|
||||
|
||||
export const DbUsageAlertSchema = z
|
||||
.object({
|
||||
feature_id: z.string().optional().meta({
|
||||
description:
|
||||
"The feature ID this alert applies to. If omitted, the alert applies globally.",
|
||||
}),
|
||||
enabled: z.boolean().default(true).meta({
|
||||
description: "Whether this usage alert is enabled.",
|
||||
}),
|
||||
threshold: z.number().min(0).meta({
|
||||
description:
|
||||
"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({
|
||||
description:
|
||||
"Whether the threshold is an absolute usage count or a percentage of the usage allowance.",
|
||||
}),
|
||||
name: z.string().optional().meta({
|
||||
description:
|
||||
"Optional user-defined label to distinguish multiple alerts on the same feature.",
|
||||
}),
|
||||
feature_id: z.string().optional().meta({
|
||||
description:
|
||||
"The feature ID this alert applies to. If omitted, the alert applies globally.",
|
||||
}),
|
||||
enabled: z.boolean().default(true).meta({
|
||||
description: "Whether this usage alert is enabled.",
|
||||
}),
|
||||
threshold: z.number().min(0).meta({
|
||||
description:
|
||||
"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({
|
||||
description:
|
||||
"Whether the threshold is an absolute usage count or a percentage of the usage allowance.",
|
||||
}),
|
||||
name: z.string().optional().meta({
|
||||
description:
|
||||
"Optional user-defined label to distinguish multiple alerts on the same feature.",
|
||||
}),
|
||||
})
|
||||
.check((ctx) => {
|
||||
const { threshold_type, threshold } = ctx.value;
|
||||
@@ -35,8 +32,7 @@ export const DbUsageAlertSchema = z
|
||||
code: "custom",
|
||||
input: threshold,
|
||||
path: ["threshold"],
|
||||
message:
|
||||
"Threshold must be between 0 and 100 for usage_percentage",
|
||||
message: "Threshold must be between 0 and 100 for usage_percentage",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { AppEnv } from "../genModels/genEnums.js";
|
||||
import { ExternalProcessorsSchema } from "../genModels/processorSchemas.js";
|
||||
import {
|
||||
AutoTopupSchema,
|
||||
DbOverageAllowedSchema,
|
||||
DbSpendLimitSchema,
|
||||
DbUsageAlertSchema,
|
||||
} from "./billingControls/customerBillingControls.js";
|
||||
@@ -25,6 +26,7 @@ export const CustomerSchema = z.object({
|
||||
auto_topups: z.array(AutoTopupSchema).nullish(),
|
||||
spend_limits: z.array(DbSpendLimitSchema).nullish(),
|
||||
usage_alerts: z.array(DbUsageAlertSchema).nullish(),
|
||||
overage_allowed: z.array(DbOverageAllowedSchema).nullish(),
|
||||
});
|
||||
|
||||
export type Customer = z.infer<typeof CustomerSchema>;
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { ExternalProcessors } from "../genModels/processorSchemas.js";
|
||||
import { organizations } from "../orgModels/orgTable.js";
|
||||
import type {
|
||||
AutoTopup,
|
||||
DbOverageAllowed,
|
||||
DbSpendLimit,
|
||||
DbUsageAlert,
|
||||
} from "./billingControls/customerBillingControls.js";
|
||||
@@ -44,6 +45,7 @@ export const customers = pgTable(
|
||||
auto_topups: jsonb().$type<AutoTopup[]>(),
|
||||
spend_limits: jsonb().$type<DbSpendLimit[]>(),
|
||||
usage_alerts: jsonb().$type<DbUsageAlert[]>(),
|
||||
overage_allowed: jsonb().$type<DbOverageAllowed[]>(),
|
||||
},
|
||||
(table) => [
|
||||
unique("cus_id_constraint").on(table.org_id, table.id, table.env),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod/v4";
|
||||
import type { Feature } from "../../featureModels/featureModels.js";
|
||||
import {
|
||||
DbOverageAllowedSchema,
|
||||
DbSpendLimitSchema,
|
||||
DbUsageAlertSchema,
|
||||
} from "../billingControls/customerBillingControls.js";
|
||||
@@ -18,6 +19,7 @@ export const EntitySchema = z.object({
|
||||
internal_feature_id: z.string(),
|
||||
spend_limits: z.array(DbSpendLimitSchema).nullish(),
|
||||
usage_alerts: z.array(DbUsageAlertSchema).nullish(),
|
||||
overage_allowed: z.array(DbOverageAllowedSchema).nullish(),
|
||||
});
|
||||
|
||||
// export const CreateEntitySchema = z.object({
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { features } from "../../featureModels/featureTable.js";
|
||||
import { organizations } from "../../orgModels/orgTable.js";
|
||||
import type {
|
||||
DbOverageAllowed,
|
||||
DbSpendLimit,
|
||||
DbUsageAlert,
|
||||
} from "../billingControls/customerBillingControls.js";
|
||||
@@ -31,6 +32,7 @@ export const entities = pgTable(
|
||||
internal_feature_id: text("internal_feature_id"),
|
||||
spend_limits: jsonb().$type<DbSpendLimit[]>(),
|
||||
usage_alerts: jsonb().$type<DbUsageAlert[]>(),
|
||||
overage_allowed: jsonb().$type<DbOverageAllowed[]>(),
|
||||
|
||||
// Optional...
|
||||
feature_id: text("feature_id"),
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -5,5 +5,6 @@ export * from "./cusPlanUtils/cusPlanUtils";
|
||||
export * from "./fullCusUtils/enrichFullCustomer";
|
||||
|
||||
export * from "./fullCusUtils/fullCustomerToCustomerEntitlements";
|
||||
export * from "./fullCusUtils/fullCustomerToOverageAllowed";
|
||||
export * from "./fullCusUtils/fullCustomerToSpendLimit";
|
||||
export * from "./fullCusUtils/getCusStripeSubCount";
|
||||
|
||||
Reference in New Issue
Block a user