diff --git a/.cursor/settings.json b/.cursor/settings.json new file mode 100644 index 000000000..d1f99b31d --- /dev/null +++ b/.cursor/settings.json @@ -0,0 +1,7 @@ +{ + "plugins": { + "linear": { + "enabled": true + } + } +} diff --git a/package.json b/package.json index 1fecf8082..552801bb2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server/src/_luaScriptsV2/customers/updateCustomerData.lua b/server/src/_luaScriptsV2/customers/updateCustomerData.lua index 9b4762908..f6fada181 100644 --- a/server/src/_luaScriptsV2/customers/updateCustomerData.lua +++ b/server/src/_luaScriptsV2/customers/updateCustomerData.lua @@ -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 }) diff --git a/server/src/internal/balances/utils/deduction/prepareFeatureDeduction.ts b/server/src/internal/balances/utils/deduction/prepareFeatureDeduction.ts index b97822779..66679aeeb 100644 --- a/server/src/internal/balances/utils/deduction/prepareFeatureDeduction.ts +++ b/server/src/internal/balances/utils/deduction/prepareFeatureDeduction.ts @@ -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, }; diff --git a/server/src/internal/customers/actions/update/updateCustomer.ts b/server/src/internal/customers/actions/update/updateCustomer.ts index 06e5db716..ecb264052 100644 --- a/server/src/internal/customers/actions/update/updateCustomer.ts +++ b/server/src/internal/customers/actions/update/updateCustomer.ts @@ -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, }), }; diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerBase.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerBase.ts index fbb187d48..29edfb98e 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerBase.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerBase.ts @@ -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: diff --git a/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/updateCachedCustomerData.ts b/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/updateCachedCustomerData.ts index 20f620d09..d77286d65 100644 --- a/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/updateCachedCustomerData.ts +++ b/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/updateCachedCustomerData.ts @@ -23,6 +23,7 @@ type CustomerDataUpdates = Pick< | "auto_topups" | "spend_limits" | "usage_alerts" + | "overage_allowed" >; /** diff --git a/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/updateEntityInCache.ts b/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/updateEntityInCache.ts index 88be31a22..0b566bfd4 100644 --- a/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/updateEntityInCache.ts +++ b/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/updateEntityInCache.ts @@ -36,7 +36,9 @@ export const updateEntityInCache = async ({ ctx: AutumnContext; customerId: string; idOrInternalId: string; - updates: Partial>; + updates: Partial< + Pick + >; }): Promise => { try { if (Object.keys(updates).length === 0) { diff --git a/server/src/internal/customers/cusUtils/initCustomer.ts b/server/src/internal/customers/cusUtils/initCustomer.ts index 8ab40b74b..b24345c91 100644 --- a/server/src/internal/customers/cusUtils/initCustomer.ts +++ b/server/src/internal/customers/cusUtils/initCustomer.ts @@ -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, }; }; diff --git a/server/src/internal/entities/actions/batchCreateEntities.ts b/server/src/internal/entities/actions/batchCreateEntities.ts index f0ee6fae6..5346440ee 100644 --- a/server/src/internal/entities/actions/batchCreateEntities.ts +++ b/server/src/internal/entities/actions/batchCreateEntities.ts @@ -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, }), }, }); diff --git a/server/src/internal/entities/actions/updateEntity.ts b/server/src/internal/entities/actions/updateEntity.ts index 34606cd3c..2d2fcc3bc 100644 --- a/server/src/internal/entities/actions/updateEntity.ts +++ b/server/src/internal/entities/actions/updateEntity.ts @@ -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, }, }); diff --git a/server/src/internal/entities/actions/updateEntityDbAndCache.ts b/server/src/internal/entities/actions/updateEntityDbAndCache.ts index e5483f7a9..d0a508f01 100644 --- a/server/src/internal/entities/actions/updateEntityDbAndCache.ts +++ b/server/src/internal/entities/actions/updateEntityDbAndCache.ts @@ -12,11 +12,15 @@ export const updateEntityDbAndCache = async ({ ctx: AutumnContext; customerId: string; entity: Entity; - updates: Partial>; + updates: Partial< + Pick + >; }) => { const filteredUpdates = Object.fromEntries( Object.entries(updates).filter(([, value]) => value !== undefined), - ) as Partial>; + ) as Partial< + Pick + >; if (Object.keys(filteredUpdates).length === 0) { return entity; diff --git a/server/src/internal/entities/entityUtils/apiEntityUtils/getApiEntityBase.ts b/server/src/internal/entities/entityUtils/apiEntityUtils/getApiEntityBase.ts index 555e6a0de..5a3b03cfc 100644 --- a/server/src/internal/entities/entityUtils/apiEntityUtils/getApiEntityBase.ts +++ b/server/src/internal/entities/entityUtils/apiEntityUtils/getApiEntityBase.ts @@ -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); diff --git a/server/tests/integration/balances/check/overage-allowed/check-customer-overage-allowed.test.ts b/server/tests/integration/balances/check/overage-allowed/check-customer-overage-allowed.test.ts new file mode 100644 index 000000000..8eaf13c7e --- /dev/null +++ b/server/tests/integration/balances/check/overage-allowed/check-customer-overage-allowed.test.ts @@ -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({ + 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({ + 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({ + 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({ + 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({ + 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({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 10, + }); + expect(cached.allowed).toBe(true); + + await timeout(4000); + + const uncached = await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 10, + skip_cache: true, + }); + + expect(normalizeCheckResponse(uncached)).toEqual( + normalizeCheckResponse(cached), + ); +}); diff --git a/server/tests/integration/balances/track/overage-allowed/track-customer-overage-allowed.test.ts b/server/tests/integration/balances/track/overage-allowed/track-customer-overage-allowed.test.ts new file mode 100644 index 000000000..9236a7db1 --- /dev/null +++ b/server/tests/integration/balances/track/overage-allowed/track-customer-overage-allowed.test.ts @@ -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(customerId); + expectBalanceCorrect({ + customer: cached, + featureId: TestFeature.Messages, + remaining: 0, + usage: 130, + }); + + await timeout(4000); + + const uncached = await autumnV2_1.customers.get(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(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(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(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(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(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({ + 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(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(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(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(customerId); + expectBalanceCorrect({ + customer: cached, + featureId: TestFeature.Messages, + remaining: 0, + usage: 70, + }); + + await timeout(4000); + + const uncached = await autumnV2_1.customers.get(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(customerId); + expectBalanceCorrect({ + customer: cached, + featureId: TestFeature.Messages, + remaining: 0, + usage: 80, + }); + + await timeout(4000); + + const uncached = await autumnV2_1.customers.get(customerId, { + skip_cache: "true", + }); + expectBalanceCorrect({ + customer: uncached, + featureId: TestFeature.Messages, + remaining: 0, + usage: 80, + }); +}); diff --git a/server/tests/integration/balances/utils/overage-allowed-utils/customerOverageAllowedUtils.ts b/server/tests/integration/balances/utils/overage-allowed-utils/customerOverageAllowedUtils.ts new file mode 100644 index 000000000..ece511980 --- /dev/null +++ b/server/tests/integration/balances/utils/overage-allowed-utils/customerOverageAllowedUtils.ts @@ -0,0 +1,29 @@ +import type { CustomerBillingControls } from "@autumn/shared"; +import type { initScenario } from "@tests/utils/testInitUtils/initScenario.js"; + +type AutumnV2_1Client = Awaited>["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, + }); +}; diff --git a/server/tests/integration/crud/customers/customer-billing-controls.test.ts b/server/tests/integration/crud/customers/customer-billing-controls.test.ts index 2c4c22044..ebbd5613a 100644 --- a/server/tests/integration/crud/customers/customer-billing-controls.test.ts +++ b/server/tests/integration/crud/customers/customer-billing-controls.test.ts @@ -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(customerId); + expect(cachedCustomer.billing_controls?.overage_allowed).toEqual( + overageAllowedControls.overage_allowed, + ); + + const uncachedCustomer = await autumnV2_1.customers.get( + 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(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(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(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(customerId, { + skip_cache: "true", + }); + expect(uncached.billing_controls?.overage_allowed).toEqual([]); + expect(uncached.billing_controls?.spend_limits).toEqual( + spendLimitControls.spend_limits, + ); +}); diff --git a/server/tests/integration/crud/entities/update-entity-billing-controls.test.ts b/server/tests/integration/crud/entities/update-entity-billing-controls.test.ts index f3ecd781e..826b7f01c 100644 --- a/server/tests/integration/crud/entities/update-entity-billing-controls.test.ts +++ b/server/tests/integration/crud/entities/update-entity-billing-controls.test.ts @@ -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( + 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( + 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( + customerId, + "entity-oa-clear-1", + ); + expect(fetched.billing_controls?.overage_allowed).toEqual([]); + expect(fetched.billing_controls?.spend_limits).toEqual( + initialBillingControls.spend_limits, + ); +}); diff --git a/shared/api/billingControls/entityBillingControls.ts b/shared/api/billingControls/entityBillingControls.ts index 179d63638..fe63c3cfe 100644 --- a/shared/api/billingControls/entityBillingControls.ts +++ b/shared/api/billingControls/entityBillingControls.ts @@ -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(); + const spendLimitFeatureIds = new Set(); 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(); + + 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); } }); diff --git a/shared/api/billingControls/index.ts b/shared/api/billingControls/index.ts index 9beba0431..c179c7c84 100644 --- a/shared/api/billingControls/index.ts +++ b/shared/api/billingControls/index.ts @@ -1,3 +1,4 @@ export * from "./entityBillingControls.js"; +export * from "./overageAllowed.js"; export * from "./spendLimit.js"; export * from "./usageAlert.js"; diff --git a/shared/api/billingControls/overageAllowed.ts b/shared/api/billingControls/overageAllowed.ts new file mode 100644 index 000000000..ad939de52 --- /dev/null +++ b/shared/api/billingControls/overageAllowed.ts @@ -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; diff --git a/shared/api/customers/cusFeatures/utils/convert/apiBalanceToAllowed.ts b/shared/api/customers/cusFeatures/utils/convert/apiBalanceToAllowed.ts index f5880d406..13f69fce6 100644 --- a/shared/api/customers/cusFeatures/utils/convert/apiBalanceToAllowed.ts +++ b/shared/api/customers/cusFeatures/utils/convert/apiBalanceToAllowed.ts @@ -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) diff --git a/shared/api/customers/cusFeatures/utils/convert/apiBalanceV1ToAvailableOverage.ts b/shared/api/customers/cusFeatures/utils/convert/apiBalanceV1ToAvailableOverage.ts index 586ee9d0b..d54c5e8d0 100644 --- a/shared/api/customers/cusFeatures/utils/convert/apiBalanceV1ToAvailableOverage.ts +++ b/shared/api/customers/cusFeatures/utils/convert/apiBalanceV1ToAvailableOverage.ts @@ -16,7 +16,7 @@ export const apiBalanceBreakdownV1ToMaxOverage = ({ return apiBalanceBreakdown.price?.max_purchase ?? undefined; } - return 0; + return undefined; }; export const apiBalanceV1ToMaxOverage = ({ diff --git a/shared/api/customers/utils/apiSubjectToOverageAllowed.ts b/shared/api/customers/utils/apiSubjectToOverageAllowed.ts new file mode 100644 index 000000000..0dff872c2 --- /dev/null +++ b/shared/api/customers/utils/apiSubjectToOverageAllowed.ts @@ -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, + ); +}; diff --git a/shared/models/cusModels/billingControls/customerBillingControls.ts b/shared/models/cusModels/billingControls/customerBillingControls.ts index 6ef0eb105..3cdd3366f 100644 --- a/shared/models/cusModels/billingControls/customerBillingControls.ts +++ b/shared/models/cusModels/billingControls/customerBillingControls.ts @@ -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(); + const spendLimitFeatureIds = new Set(); 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(); + + 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, }; diff --git a/shared/models/cusModels/billingControls/entityBillingControls.ts b/shared/models/cusModels/billingControls/entityBillingControls.ts index d815fcd2a..b1efb832e 100644 --- a/shared/models/cusModels/billingControls/entityBillingControls.ts +++ b/shared/models/cusModels/billingControls/entityBillingControls.ts @@ -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; diff --git a/shared/models/cusModels/billingControls/overageAllowed.ts b/shared/models/cusModels/billingControls/overageAllowed.ts new file mode 100644 index 000000000..dd1e51194 --- /dev/null +++ b/shared/models/cusModels/billingControls/overageAllowed.ts @@ -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; diff --git a/shared/models/cusModels/billingControls/usageAlert.ts b/shared/models/cusModels/billingControls/usageAlert.ts index e838e5dd3..c75771d4d 100644 --- a/shared/models/cusModels/billingControls/usageAlert.ts +++ b/shared/models/cusModels/billingControls/usageAlert.ts @@ -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", }); } }); diff --git a/shared/models/cusModels/cusModels.ts b/shared/models/cusModels/cusModels.ts index e96d2d356..47eaf5b2f 100644 --- a/shared/models/cusModels/cusModels.ts +++ b/shared/models/cusModels/cusModels.ts @@ -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; diff --git a/shared/models/cusModels/cusTable.ts b/shared/models/cusModels/cusTable.ts index 14e59dbf3..b5f8bb6cf 100644 --- a/shared/models/cusModels/cusTable.ts +++ b/shared/models/cusModels/cusTable.ts @@ -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(), spend_limits: jsonb().$type(), usage_alerts: jsonb().$type(), + overage_allowed: jsonb().$type(), }, (table) => [ unique("cus_id_constraint").on(table.org_id, table.id, table.env), diff --git a/shared/models/cusModels/entityModels/entityModels.ts b/shared/models/cusModels/entityModels/entityModels.ts index 25c8413f0..a6bca9b4f 100644 --- a/shared/models/cusModels/entityModels/entityModels.ts +++ b/shared/models/cusModels/entityModels/entityModels.ts @@ -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({ diff --git a/shared/models/cusModels/entityModels/entityTable.ts b/shared/models/cusModels/entityModels/entityTable.ts index 5cfe847b6..1803e5764 100644 --- a/shared/models/cusModels/entityModels/entityTable.ts +++ b/shared/models/cusModels/entityModels/entityTable.ts @@ -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(), usage_alerts: jsonb().$type(), + overage_allowed: jsonb().$type(), // Optional... feature_id: text("feature_id"), diff --git a/shared/utils/cusUtils/fullCusUtils/fullCustomerToOverageAllowed.ts b/shared/utils/cusUtils/fullCusUtils/fullCustomerToOverageAllowed.ts new file mode 100644 index 000000000..d2a9c717b --- /dev/null +++ b/shared/utils/cusUtils/fullCusUtils/fullCustomerToOverageAllowed.ts @@ -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 => { + 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 = {}; + 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; +}; diff --git a/shared/utils/cusUtils/index.ts b/shared/utils/cusUtils/index.ts index bb1f7840d..074bf897c 100644 --- a/shared/utils/cusUtils/index.ts +++ b/shared/utils/cusUtils/index.ts @@ -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";