From c36e1048b3c1f9704699cafa543dbccb2df26f4f Mon Sep 17 00:00:00 2001 From: John Yeo Date: Wed, 11 Mar 2026 20:48:13 +0000 Subject: [PATCH] feat: added spend limits to customer billing controls --- .claude/rules/write-tests.mdc | 4 + .../v2.1/contracts/entitiesContract.ts | 40 + packages/openapi/v2.1/contracts/index.ts | 2 + packages/openapi/v2.1/jsDocs/entityJsDocs.ts | 30 + server/run.sh | 1 - .../customers/updateCustomerData.lua | 28 +- server/src/_luaScriptsV2/luaScriptsV2.ts | 7 +- server/src/external/autumn/autumnCli.ts | 8 +- server/src/external/autumn/autumnCliV2.ts | 4 +- .../setup/setupCreateCustomer.ts | 2 + .../actions/update/updateCustomer.ts | 1 + .../updateCachedCustomerData.ts | 1 + .../customers/cusUtils/initCustomer.ts | 1 + server/tests/_groups/temp.ts | 30 +- .../auto-topup/utils/makeAutoTopupConfig.ts | 4 +- .../check-customer-spend-limit.test.ts | 545 ++++++++++++ .../track-customer-spend-limit.test.ts | 782 ++++++++++++++++++ .../track-entity-product-spend-limit.test.ts | 98 +++ .../spend-limit-utils/checkSpendLimitUtils.ts | 10 +- .../customerSpendLimitUtils.ts | 30 + .../entitySpendLimitUtils.ts | 66 ++ .../customer-billing-controls.test.ts | 172 ++++ .../billingControls/entityBillingControls.ts | 12 +- shared/api/common/customerData.ts | 4 +- .../api/entities/crud/createEntityParams.ts | 4 +- .../api/entities/crud/updateEntityParams.ts | 4 +- .../customerBillingControls.ts | 38 +- .../billingControls/entityBillingControls.ts | 2 +- shared/models/cusModels/cusTable.ts | 6 +- shared/models/cusModels/index.ts | 2 + 30 files changed, 1868 insertions(+), 70 deletions(-) create mode 100644 server/tests/integration/balances/check/spend-limit/check-customer-spend-limit.test.ts create mode 100644 server/tests/integration/balances/track/spend-limit/track-customer-spend-limit.test.ts create mode 100644 server/tests/integration/balances/utils/spend-limit-utils/customerSpendLimitUtils.ts create mode 100644 server/tests/integration/crud/customers/customer-billing-controls.test.ts diff --git a/.claude/rules/write-tests.mdc b/.claude/rules/write-tests.mdc index 0979cf2c8..fb0826e27 100644 --- a/.claude/rules/write-tests.mdc +++ b/.claude/rules/write-tests.mdc @@ -32,6 +32,10 @@ const { autumnV1, otherCustomers } = await initScenario({ }); ``` +### 1.1. If You Are Testing Customer Creation, Do NOT Pass `customerId` to `initScenario` + +`initScenario({ customerId, ... })` creates or registers that customer as part of setup. If the test itself is meant to call `autumn.customers.create(...)`, leave `customerId` out of `initScenario` or you will be testing re-create behavior instead of create behavior. + ### 2. `s.billing.attach` and `s.attach` Already Have Timeouts Both `s.billing.attach` (5-8s) and `s.attach` (4-5s) sleep after the API call. Do NOT add extra `await timeout()` after `initScenario` that already uses these in `actions`. Only add manual timeouts when calling `autumnV1.billing.attach()` directly in the test body. diff --git a/packages/openapi/v2.1/contracts/entitiesContract.ts b/packages/openapi/v2.1/contracts/entitiesContract.ts index 3b20825d9..3a12e02f9 100644 --- a/packages/openapi/v2.1/contracts/entitiesContract.ts +++ b/packages/openapi/v2.1/contracts/entitiesContract.ts @@ -5,12 +5,14 @@ import { CreateEntityParamsV1Schema, DeleteEntityParamsV0Schema, GetEntityParamsV0Schema, + UpdateEntityParamsSchema, } from "@autumn/shared"; import { oc } from "@orpc/contract"; import { createEntityJsDoc, deleteEntityJsDoc, getEntityJsDoc, + updateEntityJsDoc, } from "../jsDocs/entityJsDocs"; const API_ENTITY_V2_EXAMPLE = { @@ -106,6 +108,44 @@ export const getEntityContract = oc }), ); +export const updateEntityContract = oc + .route({ + method: "POST", + path: "/v1/entities.update", + operationId: "updateEntity", + tags: ["entities"], + description: updateEntityJsDoc, + spec: (spec) => ({ + ...spec, + "x-speakeasy-name-override": "update", + }), + }) + .input( + UpdateEntityParamsSchema.meta({ + title: "UpdateEntityParams", + examples: [ + { + customer_id: "cus_123", + entity_id: "seat_42", + billing_controls: { + spend_limits: [ + { + feature_id: "messages", + enabled: true, + overage_limit: 25, + }, + ], + }, + }, + ], + }), + ) + .output( + ApiEntityV2Schema.meta({ + examples: [API_ENTITY_V2_EXAMPLE], + }), + ); + export const deleteEntityContract = oc .route({ method: "POST", diff --git a/packages/openapi/v2.1/contracts/index.ts b/packages/openapi/v2.1/contracts/index.ts index 13c9b41fa..48745bece 100644 --- a/packages/openapi/v2.1/contracts/index.ts +++ b/packages/openapi/v2.1/contracts/index.ts @@ -27,6 +27,7 @@ import { createEntityContract, deleteEntityContract, getEntityContract, + updateEntityContract, } from "./entitiesContract.js"; import { eventsAggregateContract, @@ -97,6 +98,7 @@ export const v2_1ContractRouter = oc.router({ // Entities entitiesCreate: createEntityContract, entitiesGet: getEntityContract, + entitiesUpdate: updateEntityContract, entitiesDelete: deleteEntityContract, // Referrals diff --git a/packages/openapi/v2.1/jsDocs/entityJsDocs.ts b/packages/openapi/v2.1/jsDocs/entityJsDocs.ts index 66f4dd2dc..fc9b6fc57 100644 --- a/packages/openapi/v2.1/jsDocs/entityJsDocs.ts +++ b/packages/openapi/v2.1/jsDocs/entityJsDocs.ts @@ -2,6 +2,7 @@ import { CreateEntityParamsV1Schema, DeleteEntityParamsV0Schema, GetEntityParamsV0Schema, + UpdateEntityParamsSchema, } from "@autumn/shared"; import { createJSDocDescription, example } from "../../utils/jsDocs/index.js"; @@ -68,3 +69,32 @@ export const deleteEntityJsDoc = createJSDocDescription({ methodName: "entities.delete", returns: "A success flag indicating the entity was deleted.", }); + +export const updateEntityJsDoc = createJSDocDescription({ + description: + "Updates an existing entity and returns the refreshed entity object.", + whenToUse: + "Use this to change entity billing controls or other mutable entity fields after the entity has already been created.", + body: UpdateEntityParamsSchema, + examples: [ + example({ + description: "Update a seat entity's billing controls", + values: { + customerId: "cus_123", + entityId: "seat_42", + billingControls: { + spendLimits: [ + { + featureId: "messages", + enabled: true, + overageLimit: 25, + }, + ], + }, + }, + }), + ], + methodName: "entities.update", + returns: + "The updated entity object including its current subscriptions, purchases, and balances.", +}); diff --git a/server/run.sh b/server/run.sh index 09907bea3..1b260e755 100755 --- a/server/run.sh +++ b/server/run.sh @@ -22,4 +22,3 @@ fi # path_after_tests="${path_after_tests%.ts}" # # Use scripts/test.ts which auto-detects framework # NODE_ENV=development infisical run --env=dev -- bun ../scripts/test.ts "$path_after_tests" - diff --git a/server/src/_luaScriptsV2/customers/updateCustomerData.lua b/server/src/_luaScriptsV2/customers/updateCustomerData.lua index 0b25c68da..570acb58d 100644 --- a/server/src/_luaScriptsV2/customers/updateCustomerData.lua +++ b/server/src/_luaScriptsV2/customers/updateCustomerData.lua @@ -16,7 +16,8 @@ send_email_receipts?: boolean | null, processor?: object | null, processors?: object | null, - auto_topups?: array | null + auto_topups?: array | null, + spend_limits?: array | null } } @@ -53,22 +54,22 @@ local updated_fields = {} -- For scalar fields (string, boolean, number), use JSON.SET with the value -- For object fields (metadata, processor, processors), encode as JSON -if updates.name ~= nil and updates.name ~= cjson.null then +if not is_nil(updates.name) then redis.call('JSON.SET', cache_key, '$.name', cjson.encode(updates.name)) table.insert(updated_fields, 'name') end -if updates.email ~= nil and updates.email ~= cjson.null then +if not is_nil(updates.email) then redis.call('JSON.SET', cache_key, '$.email', cjson.encode(updates.email)) table.insert(updated_fields, 'email') end -if updates.fingerprint ~= nil and updates.fingerprint ~= cjson.null then +if not is_nil(updates.fingerprint) then redis.call('JSON.SET', cache_key, '$.fingerprint', cjson.encode(updates.fingerprint)) table.insert(updated_fields, 'fingerprint') end -if updates.send_email_receipts ~= nil and updates.send_email_receipts ~= cjson.null then +if not is_nil(updates.send_email_receipts) then local bool_val = 'false' if updates.send_email_receipts == true then bool_val = 'true' @@ -77,23 +78,23 @@ if updates.send_email_receipts ~= nil and updates.send_email_receipts ~= cjson.n table.insert(updated_fields, 'send_email_receipts') end -if updates.metadata ~= nil and updates.metadata ~= cjson.null then +if not is_nil(updates.metadata) then redis.call('JSON.SET', cache_key, '$.metadata', cjson.encode(updates.metadata)) table.insert(updated_fields, 'metadata') end -if updates.processor ~= nil and updates.processor ~= cjson.null then +if not is_nil(updates.processor) then redis.call('JSON.SET', cache_key, '$.processor', cjson.encode(updates.processor)) table.insert(updated_fields, 'processor') end -if updates.processors ~= nil and updates.processors ~= cjson.null then +if not is_nil(updates.processors) then redis.call('JSON.SET', cache_key, '$.processors', cjson.encode(updates.processors)) table.insert(updated_fields, 'processors') end if updates.auto_topups ~= nil then - if updates.auto_topups == cjson.null then + if is_nil(updates.auto_topups) then redis.call('JSON.SET', cache_key, '$.auto_topups', 'null') else redis.call('JSON.SET', cache_key, '$.auto_topups', cjson.encode(updates.auto_topups)) @@ -101,4 +102,13 @@ if updates.auto_topups ~= nil then table.insert(updated_fields, 'auto_topups') end +if updates.spend_limits ~= nil then + if is_nil(updates.spend_limits) then + redis.call('JSON.SET', cache_key, '$.spend_limits', 'null') + else + redis.call('JSON.SET', cache_key, '$.spend_limits', cjson.encode(updates.spend_limits)) + end + table.insert(updated_fields, 'spend_limits') +end + return cjson.encode({ success = true, updated_fields = updated_fields }) diff --git a/server/src/_luaScriptsV2/luaScriptsV2.ts b/server/src/_luaScriptsV2/luaScriptsV2.ts index 589176410..648bf2220 100644 --- a/server/src/_luaScriptsV2/luaScriptsV2.ts +++ b/server/src/_luaScriptsV2/luaScriptsV2.ts @@ -199,12 +199,15 @@ ${adjustBalanceMainScript}`; const CUSTOMER_DIR = join(__dirname, "customers"); -/** Atomically update top-level customer fields (name, email, metadata, etc.). */ -export const UPDATE_CUSTOMER_DATA_SCRIPT = readFileSync( +const updateCustomerDataMainScript = readFileSync( join(CUSTOMER_DIR, "updateCustomerData.lua"), "utf-8", ); +/** Atomically update top-level customer fields (name, email, metadata, etc.). */ +export const UPDATE_CUSTOMER_DATA_SCRIPT = `${LUA_UTILS} +${updateCustomerDataMainScript}`; + /** * Atomically append an entity to the customer's entities array. * CRDT-safe: JSON.ARRAPPEND uses merge conflict resolution in Active-Active. diff --git a/server/src/external/autumn/autumnCli.ts b/server/src/external/autumn/autumnCli.ts index 66e692ccb..049970ed7 100644 --- a/server/src/external/autumn/autumnCli.ts +++ b/server/src/external/autumn/autumnCli.ts @@ -8,7 +8,7 @@ import { type ApiCusFeatureV3, type ApiCusProductV3, type ApiCustomerV3, - type ApiEntityBillingControlsInput, + type ApiEntityBillingControlsParams, type ApiEntityV0, type AttachBodyV0, type AttachParamsV0Input, @@ -23,7 +23,7 @@ import { type CreateCustomerParamsV0Input, type CreateEntityParams, type CreateRewardProgram, - type CustomerBillingControlsInput, + type CustomerBillingControlsParams, CustomerExpand, type DeleteBalanceParamsV0, EntityExpand, @@ -513,7 +513,7 @@ export class AutumnInt { email?: string; send_email_receipts?: boolean; metadata?: Record; - billing_controls?: CustomerBillingControlsInput; + billing_controls?: CustomerBillingControlsParams; }, ) => { const data = await this.patch(`/customers/${customerId}`, updates); @@ -599,7 +599,7 @@ export class AutumnInt { customerId: string, entityId: string, updates: { - billing_controls?: ApiEntityBillingControlsInput; + billing_controls?: ApiEntityBillingControlsParams; }, ) => { const data = await this.post(`/entities.update`, { diff --git a/server/src/external/autumn/autumnCliV2.ts b/server/src/external/autumn/autumnCliV2.ts index 1e712e4cb..28f64bafa 100644 --- a/server/src/external/autumn/autumnCliV2.ts +++ b/server/src/external/autumn/autumnCliV2.ts @@ -5,7 +5,7 @@ dotenv.config(); import { type ApiCustomerV3, - type ApiEntityBillingControlsInput, + type ApiEntityBillingControlsParams, type AttachBodyV0, type CancelBody, type CheckoutParams, @@ -368,7 +368,7 @@ export class AutumnCliV2 { customerId: string, entityId: string, updates: { - billing_controls?: ApiEntityBillingControlsInput; + billing_controls?: ApiEntityBillingControlsParams; }, ) => { return await this.post(`/entities.update`, { diff --git a/server/src/internal/customers/actions/createWithDefaults/setup/setupCreateCustomer.ts b/server/src/internal/customers/actions/createWithDefaults/setup/setupCreateCustomer.ts index 6988d4092..561722bdb 100644 --- a/server/src/internal/customers/actions/createWithDefaults/setup/setupCreateCustomer.ts +++ b/server/src/internal/customers/actions/createWithDefaults/setup/setupCreateCustomer.ts @@ -28,6 +28,8 @@ export const setupCreateCustomer = async ({ }); } + console.log("Customer data:", customerData); + // 2. Init full customer const fullCustomer = initFullCustomer({ ctx, customerId, customerData }); diff --git a/server/src/internal/customers/actions/update/updateCustomer.ts b/server/src/internal/customers/actions/update/updateCustomer.ts index a11fb3f95..21a65ea5a 100644 --- a/server/src/internal/customers/actions/update/updateCustomer.ts +++ b/server/src/internal/customers/actions/update/updateCustomer.ts @@ -113,6 +113,7 @@ export const updateCustomer = async ({ }, ...(billing_controls && { auto_topups: billing_controls.auto_topups, + spend_limits: billing_controls.spend_limits, }), }; diff --git a/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/updateCachedCustomerData.ts b/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/updateCachedCustomerData.ts index f3a6b65d0..3047724e1 100644 --- a/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/updateCachedCustomerData.ts +++ b/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/updateCachedCustomerData.ts @@ -21,6 +21,7 @@ type CustomerDataUpdates = Pick< | "processor" | "processors" | "auto_topups" + | "spend_limits" >; /** diff --git a/server/src/internal/customers/cusUtils/initCustomer.ts b/server/src/internal/customers/cusUtils/initCustomer.ts index e7a46e3be..ae809f37e 100644 --- a/server/src/internal/customers/cusUtils/initCustomer.ts +++ b/server/src/internal/customers/cusUtils/initCustomer.ts @@ -35,6 +35,7 @@ const initCustomer = ({ : null, send_email_receipts: customerData?.send_email_receipts ?? false, auto_topups: customerData?.billing_controls?.auto_topups, + spend_limits: customerData?.billing_controls?.spend_limits, }; }; diff --git a/server/tests/_groups/temp.ts b/server/tests/_groups/temp.ts index d2c4b18a1..70c88bd4c 100644 --- a/server/tests/_groups/temp.ts +++ b/server/tests/_groups/temp.ts @@ -6,33 +6,7 @@ export const temp: TestGroup = { tier: "domain", paths: [ // Cancel immediately with default tests - "integration/billing/update-subscription/cancel/immediately/cancel-immediately.test.ts", - - // Cancel end of cycle tests - "integration/billing/update-subscription/cancel/end-of-cycle/cancel-end-of-cycle.test.ts", - - // Uncancel tests - "integration/billing/update-subscription/cancel/uncancel/uncancel-basic.test.ts", - - // Create customer with defaults - "integration/crud/customers/create-customer-defaults.test.ts", - - // Default applies to entities (the new behavior) - "integration/org-config/default-applies-to-entity.test.ts", - - // Stripe webhook: subscription deleted (activates defaults) - "integration/billing/stripe-webhooks/subscription-deleted/subscription-deleted.test.ts", - - // Stripe webhook: subscription updated uncancel - "integration/billing/stripe-webhooks/subscription-updated/subscription-updated-uncancel.test.ts", - - // Scheduled switch basic (pro to free downgrade flows) - "integration/billing/attach/scheduled-switch/scheduled-switch-basic.test.ts", - - // Scheduled switch with entities - "integration/billing/attach/scheduled-switch/scheduled-switch-entities-basic.test.ts", - - // Invoice created consumable (usage-in-arrear) - "integration/billing/stripe-webhooks/invoice-created/invoice-created-consumable.test.ts", + "integration/balances/check/spend-limit", + "integration/balances/track/spend-limit", ], }; diff --git a/server/tests/integration/balances/auto-topup/utils/makeAutoTopupConfig.ts b/server/tests/integration/balances/auto-topup/utils/makeAutoTopupConfig.ts index d279156a6..9eef7c77f 100644 --- a/server/tests/integration/balances/auto-topup/utils/makeAutoTopupConfig.ts +++ b/server/tests/integration/balances/auto-topup/utils/makeAutoTopupConfig.ts @@ -1,5 +1,5 @@ import type { - CustomerBillingControlsInput, + CustomerBillingControlsParams, PurchaseLimitInterval, } from "@autumn/shared"; import { TestFeature } from "@tests/setup/v2Features"; @@ -14,7 +14,7 @@ export const makeAutoTopupConfig = ({ quantity?: number; enabled?: boolean; purchaseLimit?: { interval: PurchaseLimitInterval; limit: number }; -} = {}): CustomerBillingControlsInput => ({ +} = {}): CustomerBillingControlsParams => ({ auto_topups: [ { feature_id: TestFeature.Messages, diff --git a/server/tests/integration/balances/check/spend-limit/check-customer-spend-limit.test.ts b/server/tests/integration/balances/check/spend-limit/check-customer-spend-limit.test.ts new file mode 100644 index 000000000..943556fb2 --- /dev/null +++ b/server/tests/integration/balances/check/spend-limit/check-customer-spend-limit.test.ts @@ -0,0 +1,545 @@ +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 { + expectBoundaryAndParity, + normalizeCheckResponse, +} from "../../utils/spend-limit-utils/checkSpendLimitUtils.js"; +import { setCustomerSpendLimit } from "../../utils/spend-limit-utils/customerSpendLimitUtils.js"; + +const expectAllowedCheckParity = async ({ + autumn, + customerId, + featureId, + requiredBalance, +}: { + autumn: Awaited>["autumnV2_1"]; + customerId: string; + featureId: string; + requiredBalance: number; +}) => { + const cached = await autumn.check({ + customer_id: customerId, + feature_id: featureId, + required_balance: requiredBalance, + }); + + expect(cached.allowed).toBe(true); + + await timeout(4000); + + const uncached = await autumn.check({ + customer_id: customerId, + feature_id: featureId, + required_balance: requiredBalance, + skip_cache: true, + }); + + expect(normalizeCheckResponse(uncached)).toEqual( + normalizeCheckResponse(cached), + ); +}; + +test.concurrent(`${chalk.yellowBright("check-customer-spend-limit1: lifetime + consumable customer messages respect spend limit and cache parity")}`, async () => { + const customerProduct = products.base({ + id: "customer-lifetime-consumable", + items: [ + items.lifetimeMessages({ + includedUsage: 1000, + }), + items.consumableMessages({ + includedUsage: 100, + maxPurchase: 300, + price: 0.5, + }), + ], + }); + + const { autumnV2_1, customerId } = await initScenario({ + customerId: "check-customer-spend-limit-1", + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [customerProduct] }), + ], + actions: [s.billing.attach({ productId: customerProduct.id })], + }); + + await setCustomerSpendLimit({ + autumn: autumnV2_1, + customerId, + featureId: TestFeature.Messages, + overageLimit: 25, + }); + + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 1120, + }); + + await expectBoundaryAndParity({ + autumn: autumnV2_1, + customerId, + featureId: TestFeature.Messages, + allowedRequiredBalance: 5, + blockedRequiredBalance: 6, + }); +}); + +test.concurrent(`${chalk.yellowBright("check-customer-spend-limit2: prepaid + consumable customer messages respect spend limit and cache parity")}`, async () => { + const customerProduct = products.base({ + id: "customer-prepaid-consumable", + items: [ + items.prepaidMessages({ + includedUsage: 100, + billingUnits: 100, + price: 8.5, + }), + items.consumableMessages({ + includedUsage: 200, + maxPurchase: 300, + price: 0.5, + }), + ], + }); + + const { autumnV2_1, customerId } = await initScenario({ + customerId: "check-customer-spend-limit-2", + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [customerProduct] }), + ], + actions: [ + s.billing.attach({ + productId: customerProduct.id, + options: [ + { + feature_id: TestFeature.Messages, + quantity: 600, + }, + ], + }), + ], + }); + + await setCustomerSpendLimit({ + autumn: autumnV2_1, + customerId, + featureId: TestFeature.Messages, + overageLimit: 25, + }); + + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 820, + }); + + await expectBoundaryAndParity({ + autumn: autumnV2_1, + customerId, + featureId: TestFeature.Messages, + allowedRequiredBalance: 5, + blockedRequiredBalance: 6, + }); +}); + +test.concurrent(`${chalk.yellowBright("check-customer-spend-limit3: prepaid addon + consumable customer messages respect spend limit and cache parity")}`, async () => { + const consumableProduct = products.base({ + id: "customer-consumable", + items: [ + items.consumableMessages({ + includedUsage: 200, + maxPurchase: 300, + price: 0.5, + }), + ], + }); + + const prepaidAddon = products.base({ + id: "customer-prepaid-addon", + isAddOn: true, + items: [ + items.prepaidMessages({ + includedUsage: 100, + billingUnits: 100, + price: 8.5, + }), + ], + }); + + const { autumnV2_1, customerId } = await initScenario({ + customerId: "check-customer-spend-limit-3", + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [consumableProduct, prepaidAddon] }), + ], + actions: [ + s.billing.attach({ productId: consumableProduct.id }), + s.billing.attach({ + productId: prepaidAddon.id, + options: [ + { + feature_id: TestFeature.Messages, + quantity: 600, + }, + ], + }), + ], + }); + + await setCustomerSpendLimit({ + autumn: autumnV2_1, + customerId, + featureId: TestFeature.Messages, + overageLimit: 25, + }); + + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 820, + }); + + await expectBoundaryAndParity({ + autumn: autumnV2_1, + customerId, + featureId: TestFeature.Messages, + allowedRequiredBalance: 5, + blockedRequiredBalance: 6, + }); +}); + +test.concurrent(`${chalk.yellowBright("check-customer-spend-limit4: customer spend limit applies when customer inherits entity product balances")}`, async () => { + const entityProduct = products.base({ + id: "customer-entity-product", + items: [ + items.prepaidMessages({ + includedUsage: 100, + billingUnits: 100, + price: 8.5, + }), + items.consumableMessages({ + includedUsage: 200, + price: 0.5, + }), + ], + }); + + const { autumnV2_1, customerId, entities } = await initScenario({ + customerId: "check-customer-spend-limit-4", + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [entityProduct] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.billing.attach({ + productId: entityProduct.id, + entityIndex: 0, + options: [ + { + feature_id: TestFeature.Messages, + quantity: 600, + }, + ], + }), + s.billing.attach({ + productId: entityProduct.id, + entityIndex: 1, + options: [ + { + feature_id: TestFeature.Messages, + quantity: 600, + }, + ], + }), + ], + }); + + await setCustomerSpendLimit({ + autumn: autumnV2_1, + customerId, + featureId: TestFeature.Messages, + overageLimit: 25, + }); + + await autumnV2_1.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: 810, + }); + + await autumnV2_1.track({ + customer_id: customerId, + entity_id: entities[1].id, + feature_id: TestFeature.Messages, + value: 810, + }); + + await expectBoundaryAndParity({ + autumn: autumnV2_1, + customerId, + featureId: TestFeature.Messages, + allowedRequiredBalance: 5, + blockedRequiredBalance: 6, + }); +}); + +test.concurrent(`${chalk.yellowBright("check-customer-spend-limit5: customer spend limit applies when customer inherits per-entity balances")}`, async () => { + const perEntityProduct = products.base({ + id: "customer-per-entity-product", + items: [ + items.prepaidMessages({ + includedUsage: 100, + billingUnits: 100, + price: 8.5, + entityFeatureId: TestFeature.Users, + }), + items.consumableMessages({ + includedUsage: 200, + price: 0.5, + entityFeatureId: TestFeature.Users, + }), + ], + }); + + const { autumnV2_1, customerId, entities } = await initScenario({ + customerId: "check-customer-spend-limit-5", + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [perEntityProduct] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.billing.attach({ + productId: perEntityProduct.id, + options: [ + { + feature_id: TestFeature.Messages, + quantity: 600, + }, + ], + }), + ], + }); + + await setCustomerSpendLimit({ + autumn: autumnV2_1, + customerId, + featureId: TestFeature.Messages, + overageLimit: 25, + }); + + await autumnV2_1.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: 810, + }); + + await autumnV2_1.track({ + customer_id: customerId, + entity_id: entities[1].id, + feature_id: TestFeature.Messages, + value: 810, + }); + + await expectBoundaryAndParity({ + autumn: autumnV2_1, + customerId, + featureId: TestFeature.Messages, + allowedRequiredBalance: 5, + blockedRequiredBalance: 6, + }); +}); + +test.concurrent(`${chalk.yellowBright("check-customer-spend-limit6: disabled customer spend limit no longer caps direct customer checks")}`, async () => { + const customerProduct = products.base({ + id: "customer-disabled-limit", + items: [ + items.consumableMessages({ + includedUsage: 100, + price: 0.5, + }), + ], + }); + + const { autumnV2_1, customerId } = await initScenario({ + customerId: "check-customer-spend-limit-6", + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [customerProduct] }), + ], + actions: [s.billing.attach({ productId: customerProduct.id })], + }); + + await setCustomerSpendLimit({ + autumn: autumnV2_1, + customerId, + featureId: TestFeature.Messages, + overageLimit: 25, + }); + + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 120, + }); + + await setCustomerSpendLimit({ + autumn: autumnV2_1, + customerId, + featureId: TestFeature.Messages, + overageLimit: 25, + enabled: false, + }); + + await expectAllowedCheckParity({ + autumn: autumnV2_1, + customerId, + featureId: TestFeature.Messages, + requiredBalance: 50, + }); +}); + +test.concurrent(`${chalk.yellowBright("check-customer-spend-limit7: disabled customer spend limit no longer caps inherited entity-product checks across entities")}`, async () => { + const entityProduct = products.base({ + id: "customer-disabled-entity-product", + items: [ + items.consumableMessages({ + includedUsage: 100, + price: 0.5, + }), + ], + }); + + const { autumnV2_1, customerId, entities } = await initScenario({ + customerId: "check-customer-spend-limit-7", + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [entityProduct] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.billing.attach({ productId: entityProduct.id, entityIndex: 0 }), + s.billing.attach({ productId: entityProduct.id, entityIndex: 1 }), + ], + }); + + await setCustomerSpendLimit({ + autumn: autumnV2_1, + customerId, + featureId: TestFeature.Messages, + overageLimit: 25, + }); + + await autumnV2_1.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: 110, + }); + await autumnV2_1.track({ + customer_id: customerId, + entity_id: entities[1].id, + feature_id: TestFeature.Messages, + value: 110, + }); + + await setCustomerSpendLimit({ + autumn: autumnV2_1, + customerId, + featureId: TestFeature.Messages, + overageLimit: 25, + enabled: false, + }); + + await expectAllowedCheckParity({ + autumn: autumnV2_1, + customerId, + featureId: TestFeature.Messages, + requiredBalance: 50, + }); +}); + +test.concurrent(`${chalk.yellowBright("check-customer-spend-limit8: disabled customer spend limit no longer caps inherited per-entity checks across entities")}`, async () => { + const perEntityProduct = products.base({ + id: "customer-disabled-per-entity-product", + items: [ + items.prepaidMessages({ + includedUsage: 100, + billingUnits: 100, + price: 8.5, + entityFeatureId: TestFeature.Users, + }), + items.consumableMessages({ + includedUsage: 200, + price: 0.5, + entityFeatureId: TestFeature.Users, + }), + ], + }); + + const prepaidQuantity = 600; + const { autumnV2_1, customerId, entities } = await initScenario({ + customerId: "check-customer-spend-limit-8", + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [perEntityProduct] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.billing.attach({ + productId: perEntityProduct.id, + options: [ + { + feature_id: TestFeature.Messages, + quantity: prepaidQuantity, + }, + ], + }), + ], + }); + + await setCustomerSpendLimit({ + autumn: autumnV2_1, + customerId, + featureId: TestFeature.Messages, + overageLimit: 25, + }); + + await autumnV2_1.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: 810, + }); + await autumnV2_1.track({ + customer_id: customerId, + entity_id: entities[1].id, + feature_id: TestFeature.Messages, + value: 810, + }); + + await setCustomerSpendLimit({ + autumn: autumnV2_1, + customerId, + featureId: TestFeature.Messages, + overageLimit: 25, + enabled: false, + }); + + await expectAllowedCheckParity({ + autumn: autumnV2_1, + customerId, + featureId: TestFeature.Messages, + requiredBalance: 50, + }); +}); diff --git a/server/tests/integration/balances/track/spend-limit/track-customer-spend-limit.test.ts b/server/tests/integration/balances/track/spend-limit/track-customer-spend-limit.test.ts new file mode 100644 index 000000000..7e6669aad --- /dev/null +++ b/server/tests/integration/balances/track/spend-limit/track-customer-spend-limit.test.ts @@ -0,0 +1,782 @@ +import { expect, test } from "bun:test"; +import { ErrCode } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { getCreditCost } from "@/internal/features/creditSystemUtils.js"; +import { setCustomerSpendLimit } from "../../utils/spend-limit-utils/customerSpendLimitUtils.js"; +import { + expectCustomerFeatureCachedAndDb, + expectCustomerSendEventBlocked, + expectEntityFeatureBalance, + getActionUnitsForCreditAmount, +} from "../../utils/spend-limit-utils/entitySpendLimitUtils.js"; + +test.concurrent(`${chalk.yellowBright("track-customer-spend-limit1: lifetime + consumable customer messages cap track overage")}`, async () => { + const customerProduct = products.base({ + id: "track-customer-lifetime-consumable", + items: [ + items.lifetimeMessages({ + includedUsage: 1000, + }), + items.consumableMessages({ + includedUsage: 100, + maxPurchase: 300, + price: 0.5, + }), + ], + }); + + const { autumnV2_1, customerId } = await initScenario({ + customerId: "track-customer-spend-limit-1", + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [customerProduct] }), + ], + actions: [s.billing.attach({ productId: customerProduct.id })], + }); + + await setCustomerSpendLimit({ + autumn: autumnV2_1, + customerId, + featureId: TestFeature.Messages, + overageLimit: 25, + }); + + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 1120, + }); + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 10, + }); + + await expectCustomerFeatureCachedAndDb({ + autumn: autumnV2_1, + customerId, + featureId: TestFeature.Messages, + granted: 1100, + remaining: 0, + usage: 1125, + maxPurchase: 300, + breakdownLength: 2, + }); + + await expectAutumnError({ + errCode: ErrCode.InsufficientBalance, + func: async () => + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 1, + overage_behavior: "reject", + }), + }); + await expectCustomerSendEventBlocked({ + autumn: autumnV2_1, + customerId, + requestFeatureId: TestFeature.Messages, + requiredBalance: 1, + customer: { + granted: 1100, + remaining: 0, + usage: 1125, + maxPurchase: 300, + breakdownLength: 2, + }, + }); +}); + +test.concurrent(`${chalk.yellowBright("track-customer-spend-limit2: prepaid + consumable customer messages cap track overage")}`, async () => { + const customerProduct = products.base({ + id: "track-customer-prepaid-consumable", + items: [ + items.prepaidMessages({ + includedUsage: 100, + billingUnits: 100, + price: 8.5, + }), + items.consumableMessages({ + includedUsage: 200, + price: 0.5, + }), + ], + }); + + const prepaidQuantity = 600; + const { autumnV2_1, customerId } = await initScenario({ + customerId: "track-customer-spend-limit-2", + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [customerProduct] }), + ], + actions: [ + s.billing.attach({ + productId: customerProduct.id, + options: [ + { + feature_id: TestFeature.Messages, + quantity: prepaidQuantity, + }, + ], + }), + ], + }); + + await setCustomerSpendLimit({ + autumn: autumnV2_1, + customerId, + featureId: TestFeature.Messages, + overageLimit: 25, + }); + + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 820, + }); + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 10, + }); + + await expectCustomerFeatureCachedAndDb({ + autumn: autumnV2_1, + customerId, + featureId: TestFeature.Messages, + granted: 800, + remaining: 0, + usage: 825, + breakdownLength: 2, + }); + + await expectAutumnError({ + errCode: ErrCode.InsufficientBalance, + func: async () => + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 1, + overage_behavior: "reject", + }), + }); + await expectCustomerSendEventBlocked({ + autumn: autumnV2_1, + customerId, + requestFeatureId: TestFeature.Messages, + requiredBalance: 1, + customer: { + granted: 800, + remaining: 0, + usage: 825, + breakdownLength: 2, + }, + }); +}); + +test.concurrent(`${chalk.yellowBright("track-customer-spend-limit3: prepaid addon + consumable customer messages cap track overage")}`, async () => { + const consumableProduct = products.base({ + id: "track-customer-consumable", + items: [ + items.consumableMessages({ + includedUsage: 200, + price: 0.5, + }), + ], + }); + + const prepaidAddon = products.base({ + id: "track-customer-prepaid-addon", + isAddOn: true, + items: [ + items.prepaidMessages({ + includedUsage: 100, + billingUnits: 100, + price: 8.5, + }), + ], + }); + + const { autumnV2_1, customerId } = await initScenario({ + customerId: "track-customer-spend-limit-3", + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [consumableProduct, prepaidAddon] }), + ], + actions: [ + s.billing.attach({ productId: consumableProduct.id }), + s.billing.attach({ + productId: prepaidAddon.id, + options: [ + { + feature_id: TestFeature.Messages, + quantity: 600, + }, + ], + }), + ], + }); + + await setCustomerSpendLimit({ + autumn: autumnV2_1, + customerId, + featureId: TestFeature.Messages, + overageLimit: 25, + }); + + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 820, + }); + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 10, + }); + + await expectCustomerFeatureCachedAndDb({ + autumn: autumnV2_1, + customerId, + featureId: TestFeature.Messages, + granted: 800, + remaining: 0, + usage: 825, + breakdownLength: 2, + }); + + await expectAutumnError({ + errCode: ErrCode.InsufficientBalance, + func: async () => + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 1, + overage_behavior: "reject", + }), + }); + await expectCustomerSendEventBlocked({ + autumn: autumnV2_1, + customerId, + requestFeatureId: TestFeature.Messages, + requiredBalance: 1, + customer: { + granted: 800, + remaining: 0, + usage: 825, + breakdownLength: 2, + }, + }); +}); + +test.concurrent(`${chalk.yellowBright("track-customer-spend-limit4: customer spend limit is enforced on aggregate entity product balances")}`, async () => { + const entityProduct = products.base({ + id: "track-customer-entity-product", + items: [ + items.prepaidMessages({ + includedUsage: 100, + billingUnits: 100, + price: 8.5, + }), + items.consumableMessages({ + includedUsage: 200, + price: 0.5, + }), + ], + }); + + const prepaidQuantity = 600; + const { autumnV2_1, customerId, entities } = await initScenario({ + customerId: "track-customer-spend-limit-4", + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [entityProduct] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.billing.attach({ + productId: entityProduct.id, + entityIndex: 0, + options: [ + { + feature_id: TestFeature.Messages, + quantity: prepaidQuantity, + }, + ], + }), + s.billing.attach({ + productId: entityProduct.id, + entityIndex: 1, + options: [ + { + feature_id: TestFeature.Messages, + quantity: prepaidQuantity, + }, + ], + }), + ], + }); + + await setCustomerSpendLimit({ + autumn: autumnV2_1, + customerId, + featureId: TestFeature.Messages, + overageLimit: 25, + }); + + await autumnV2_1.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: 810, + }); + await autumnV2_1.track({ + customer_id: customerId, + entity_id: entities[1].id, + feature_id: TestFeature.Messages, + value: 810, + }); + await expectEntityFeatureBalance({ + autumn: autumnV2_1, + customerId, + entityId: entities[0].id, + featureId: TestFeature.Messages, + granted: 800, + remaining: 0, + usage: 810, + breakdownLength: 2, + }); + await expectEntityFeatureBalance({ + autumn: autumnV2_1, + customerId, + entityId: entities[1].id, + featureId: TestFeature.Messages, + granted: 800, + remaining: 0, + usage: 810, + breakdownLength: 2, + }); + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 10, + }); + + await expectCustomerFeatureCachedAndDb({ + autumn: autumnV2_1, + customerId, + featureId: TestFeature.Messages, + granted: 1600, + remaining: 0, + usage: 1625, + breakdownLength: 2, + }); + + await expectAutumnError({ + errCode: ErrCode.InsufficientBalance, + func: async () => + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 1, + overage_behavior: "reject", + }), + }); + await expectCustomerSendEventBlocked({ + autumn: autumnV2_1, + customerId, + requestFeatureId: TestFeature.Messages, + requiredBalance: 1, + customer: { + granted: 1600, + remaining: 0, + usage: 1625, + breakdownLength: 2, + }, + }); +}); + +test.concurrent(`${chalk.yellowBright("track-customer-spend-limit5: customer spend limit is enforced on aggregate per-entity balances")}`, async () => { + const perEntityProduct = products.base({ + id: "track-customer-per-entity-product", + items: [ + items.prepaidMessages({ + includedUsage: 100, + billingUnits: 100, + price: 8.5, + entityFeatureId: TestFeature.Users, + }), + items.consumableMessages({ + includedUsage: 200, + price: 0.5, + entityFeatureId: TestFeature.Users, + }), + ], + }); + + const prepaidQuantity = 600; + const { autumnV2_1, customerId, entities } = await initScenario({ + customerId: "track-customer-spend-limit-5", + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [perEntityProduct] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.billing.attach({ + productId: perEntityProduct.id, + options: [ + { + feature_id: TestFeature.Messages, + quantity: prepaidQuantity, + }, + ], + }), + ], + }); + + await setCustomerSpendLimit({ + autumn: autumnV2_1, + customerId, + featureId: TestFeature.Messages, + overageLimit: 25, + }); + + await autumnV2_1.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: 810, + }); + await autumnV2_1.track({ + customer_id: customerId, + entity_id: entities[1].id, + feature_id: TestFeature.Messages, + value: 810, + }); + await expectEntityFeatureBalance({ + autumn: autumnV2_1, + customerId, + entityId: entities[0].id, + featureId: TestFeature.Messages, + granted: 800, + remaining: 0, + usage: 810, + breakdownLength: 2, + }); + await expectEntityFeatureBalance({ + autumn: autumnV2_1, + customerId, + entityId: entities[1].id, + featureId: TestFeature.Messages, + granted: 800, + remaining: 0, + usage: 810, + breakdownLength: 2, + }); + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 10, + }); + + await expectCustomerFeatureCachedAndDb({ + autumn: autumnV2_1, + customerId, + featureId: TestFeature.Messages, + granted: 1600, + remaining: 0, + usage: 1625, + breakdownLength: 4, + }); + + await expectAutumnError({ + errCode: ErrCode.InsufficientBalance, + func: async () => + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 1, + overage_behavior: "reject", + }), + }); + await expectCustomerSendEventBlocked({ + autumn: autumnV2_1, + customerId, + requestFeatureId: TestFeature.Messages, + requiredBalance: 1, + customer: { + granted: 1600, + remaining: 0, + usage: 1625, + breakdownLength: 4, + }, + }); +}); + +test.concurrent(`${chalk.yellowBright("track-customer-spend-limit6: credit-system customer tracking uses converted credits and caps overage")}`, async () => { + const includedCredits = 100; + const spendLimitCredits = 25; + const existingOverageCredits = 20; + + const customerProduct = products.base({ + id: "track-customer-credits", + items: [ + items.consumable({ + featureId: TestFeature.Credits, + includedUsage: includedCredits, + maxPurchase: 300, + price: 0.5, + }), + ], + }); + + const { autumnV2_1, customerId, ctx } = await initScenario({ + customerId: "track-customer-spend-limit-6", + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [customerProduct] }), + ], + actions: [s.billing.attach({ productId: customerProduct.id })], + }); + + const creditsFeature = ctx.features.find( + (feature) => feature.id === TestFeature.Credits, + )!; + const action1CreditCost = getCreditCost({ + featureId: TestFeature.Action1, + creditSystem: creditsFeature, + amount: 1, + }); + const firstTrackValue = getActionUnitsForCreditAmount({ + creditAmount: includedCredits + existingOverageCredits, + creditCostPerActionUnit: action1CreditCost, + }); + const secondTrackValue = getActionUnitsForCreditAmount({ + creditAmount: spendLimitCredits - existingOverageCredits + 5, + creditCostPerActionUnit: action1CreditCost, + }); + + await setCustomerSpendLimit({ + autumn: autumnV2_1, + customerId, + featureId: TestFeature.Credits, + overageLimit: spendLimitCredits, + }); + + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Action1, + value: firstTrackValue, + }); + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Action1, + value: secondTrackValue, + }); + + await expectCustomerFeatureCachedAndDb({ + autumn: autumnV2_1, + customerId, + featureId: TestFeature.Credits, + granted: includedCredits, + remaining: 0, + usage: includedCredits + spendLimitCredits, + maxPurchase: 300, + breakdownLength: 1, + }); + + await expectAutumnError({ + errCode: ErrCode.InsufficientBalance, + func: async () => + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Action1, + value: 1 / action1CreditCost, + overage_behavior: "reject", + }), + }); + await expectCustomerSendEventBlocked({ + autumn: autumnV2_1, + customerId, + requestFeatureId: TestFeature.Action1, + requiredBalance: 1 / action1CreditCost, + expectedFeatureId: TestFeature.Credits, + expectedResponseRequiredBalance: 1, + customer: { + granted: includedCredits, + remaining: 0, + usage: includedCredits + spendLimitCredits, + maxPurchase: 300, + breakdownLength: 1, + }, + }); +}); + +test.concurrent(`${chalk.yellowBright("track-customer-spend-limit7: disabled customer spend limit no longer caps rejecting track")}`, async () => { + const customerProduct = products.base({ + id: "track-customer-disabled-limit", + items: [ + items.consumableMessages({ + includedUsage: 100, + price: 0.5, + }), + ], + }); + + const { autumnV2_1, customerId } = await initScenario({ + customerId: "track-customer-spend-limit-7", + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [customerProduct] }), + ], + actions: [s.billing.attach({ productId: customerProduct.id })], + }); + + await setCustomerSpendLimit({ + autumn: autumnV2_1, + customerId, + featureId: TestFeature.Messages, + overageLimit: 25, + }); + + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 120, + }); + + await setCustomerSpendLimit({ + autumn: autumnV2_1, + customerId, + featureId: TestFeature.Messages, + overageLimit: 25, + enabled: false, + }); + + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 10, + overage_behavior: "reject", + }); + + await expectCustomerFeatureCachedAndDb({ + autumn: autumnV2_1, + customerId, + featureId: TestFeature.Messages, + granted: 100, + remaining: 0, + usage: 130, + breakdownLength: 1, + }); +}); + +test.concurrent(`${chalk.yellowBright("track-customer-spend-limit8: disabled customer spend limit no longer caps aggregate entity-product track across entities")}`, async () => { + const entityProduct = products.base({ + id: "track-customer-disabled-entity-product", + items: [ + items.consumableMessages({ + includedUsage: 100, + price: 0.5, + }), + ], + }); + + const { autumnV2_1, customerId, entities } = await initScenario({ + customerId: "track-customer-spend-limit-8", + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [entityProduct] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.billing.attach({ productId: entityProduct.id, entityIndex: 0 }), + s.billing.attach({ productId: entityProduct.id, entityIndex: 1 }), + ], + }); + + await setCustomerSpendLimit({ + autumn: autumnV2_1, + customerId, + featureId: TestFeature.Messages, + overageLimit: 25, + }); + + await autumnV2_1.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: 110, + }); + await autumnV2_1.track({ + customer_id: customerId, + entity_id: entities[1].id, + feature_id: TestFeature.Messages, + value: 110, + }); + + await setCustomerSpendLimit({ + autumn: autumnV2_1, + customerId, + featureId: TestFeature.Messages, + overageLimit: 25, + enabled: false, + }); + + await autumnV2_1.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: 10, + overage_behavior: "reject", + }); + + await expectEntityFeatureBalance({ + autumn: autumnV2_1, + customerId, + entityId: entities[0].id, + featureId: TestFeature.Messages, + granted: 100, + remaining: 0, + usage: 120, + breakdownLength: 1, + }); + await expectEntityFeatureBalance({ + autumn: autumnV2_1, + customerId, + entityId: entities[1].id, + featureId: TestFeature.Messages, + granted: 100, + remaining: 0, + usage: 110, + breakdownLength: 1, + }); + + await expectCustomerFeatureCachedAndDb({ + autumn: autumnV2_1, + customerId, + featureId: TestFeature.Messages, + granted: 200, + remaining: 0, + usage: 230, + breakdownLength: 2, + }); + + expect( + ( + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 10, + }) + ).allowed, + ).toBe(true); +}); diff --git a/server/tests/integration/balances/track/spend-limit/track-entity-product-spend-limit.test.ts b/server/tests/integration/balances/track/spend-limit/track-entity-product-spend-limit.test.ts index 9b846154c..f37b227c2 100644 --- a/server/tests/integration/balances/track/spend-limit/track-entity-product-spend-limit.test.ts +++ b/server/tests/integration/balances/track/spend-limit/track-entity-product-spend-limit.test.ts @@ -547,3 +547,101 @@ test.concurrent(`${chalk.yellowBright("track-entity-product-spend-limit5: credit }, }); }); + +test.concurrent(`${chalk.yellowBright("track-entity-product-spend-limit6: disabled spend limit no longer caps entity product track")}`, async () => { + const entityProduct = products.base({ + id: "track-entity-product-disabled-limit", + items: [ + items.consumableMessages({ + includedUsage: 100, + price: 0.5, + }), + ], + }); + + const { autumnV2_1, customerId, entities } = await initScenario({ + customerId: "track-entity-product-spend-limit-6", + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [entityProduct] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [ + s.billing.attach({ productId: entityProduct.id, entityIndex: 0 }), + ], + }); + + await setEntitySpendLimit({ + autumn: autumnV2_1, + customerId, + entityId: entities[0].id, + featureId: TestFeature.Messages, + overageLimit: 25, + }); + + await autumnV2_1.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: 120, + }); + + await setEntitySpendLimit({ + autumn: autumnV2_1, + customerId, + entityId: entities[0].id, + featureId: TestFeature.Messages, + overageLimit: 25, + enabled: false, + }); + + await autumnV2_1.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: 10, + overage_behavior: "reject", + }); + + await expectEntityFeatureBalance({ + autumn: autumnV2_1, + customerId, + entityId: entities[0].id, + featureId: TestFeature.Messages, + granted: 100, + remaining: 0, + usage: 130, + breakdownLength: 1, + }); + await expectEntityFeatureBalance({ + autumn: autumnV2_1, + customerId, + entityId: entities[0].id, + featureId: TestFeature.Messages, + granted: 100, + remaining: 0, + usage: 130, + breakdownLength: 1, + skipCache: true, + }); + + await expectCustomerFeatureBalance({ + autumn: autumnV2_1, + customerId, + featureId: TestFeature.Messages, + granted: 100, + remaining: 0, + usage: 130, + breakdownLength: 1, + }); + await expectCustomerFeatureBalance({ + autumn: autumnV2_1, + customerId, + featureId: TestFeature.Messages, + granted: 100, + remaining: 0, + usage: 130, + breakdownLength: 1, + skipCache: true, + }); +}); diff --git a/server/tests/integration/balances/utils/spend-limit-utils/checkSpendLimitUtils.ts b/server/tests/integration/balances/utils/spend-limit-utils/checkSpendLimitUtils.ts index 73c59482a..d2b9ca625 100644 --- a/server/tests/integration/balances/utils/spend-limit-utils/checkSpendLimitUtils.ts +++ b/server/tests/integration/balances/utils/spend-limit-utils/checkSpendLimitUtils.ts @@ -46,7 +46,7 @@ export const expectBoundaryAndParity = async ({ }: { autumn: AutumnV2_1Client; customerId: string; - entityId: string; + entityId?: string; featureId: string; allowedRequiredBalance: number; blockedRequiredBalance: number; @@ -56,14 +56,14 @@ export const expectBoundaryAndParity = async ({ }) => { const allowedCached = await autumn.check({ customer_id: customerId, - entity_id: entityId, + ...(entityId ? { entity_id: entityId } : {}), feature_id: featureId, required_balance: allowedRequiredBalance, }); const blockedCached = await autumn.check({ customer_id: customerId, - entity_id: entityId, + ...(entityId ? { entity_id: entityId } : {}), feature_id: featureId, required_balance: blockedRequiredBalance, }); @@ -83,7 +83,7 @@ export const expectBoundaryAndParity = async ({ const allowedUncached = await autumn.check({ customer_id: customerId, - entity_id: entityId, + ...(entityId ? { entity_id: entityId } : {}), feature_id: featureId, required_balance: allowedRequiredBalance, skip_cache: true, @@ -91,7 +91,7 @@ export const expectBoundaryAndParity = async ({ const blockedUncached = await autumn.check({ customer_id: customerId, - entity_id: entityId, + ...(entityId ? { entity_id: entityId } : {}), feature_id: featureId, required_balance: blockedRequiredBalance, skip_cache: true, diff --git a/server/tests/integration/balances/utils/spend-limit-utils/customerSpendLimitUtils.ts b/server/tests/integration/balances/utils/spend-limit-utils/customerSpendLimitUtils.ts new file mode 100644 index 000000000..47ce5f3af --- /dev/null +++ b/server/tests/integration/balances/utils/spend-limit-utils/customerSpendLimitUtils.ts @@ -0,0 +1,30 @@ +import type { CustomerBillingControls } from "@autumn/shared"; +import type { AutumnV2_1Client } from "./entitySpendLimitUtils.js"; + +export const setCustomerSpendLimit = async ({ + autumn, + customerId, + featureId, + overageLimit, + enabled = true, +}: { + autumn: AutumnV2_1Client; + customerId: string; + featureId: string; + overageLimit: number; + enabled?: boolean; +}) => { + const billingControls: CustomerBillingControls = { + spend_limits: [ + { + feature_id: featureId, + enabled, + overage_limit: overageLimit, + }, + ], + }; + + await autumn.customers.update(customerId, { + billing_controls: billingControls, + }); +}; diff --git a/server/tests/integration/balances/utils/spend-limit-utils/entitySpendLimitUtils.ts b/server/tests/integration/balances/utils/spend-limit-utils/entitySpendLimitUtils.ts index d18c6cae2..2ebcbfb3f 100644 --- a/server/tests/integration/balances/utils/spend-limit-utils/entitySpendLimitUtils.ts +++ b/server/tests/integration/balances/utils/spend-limit-utils/entitySpendLimitUtils.ts @@ -116,6 +116,7 @@ export const expectCustomerFeatureBalance = async ({ breakdownLength?: number; skipCache?: boolean; }) => { + await timeout(3000); const customer = await autumn.customers.get(customerId, { skip_cache: skipCache ? "true" : undefined, }); @@ -230,6 +231,71 @@ export const expectSendEventBlocked = async ({ }); }; +export const expectCustomerSendEventBlocked = async ({ + autumn, + customerId, + requestFeatureId, + requiredBalance, + customer, + expectedFeatureId = requestFeatureId, + expectedResponseRequiredBalance = requiredBalance, +}: { + autumn: AutumnV2_1Client; + customerId: string; + requestFeatureId: string; + requiredBalance: number; + customer: { + granted: number; + remaining: number; + usage: number; + maxPurchase?: number | null; + breakdownLength?: number; + }; + expectedFeatureId?: string; + expectedResponseRequiredBalance?: number; +}) => { + const response = await autumn.check({ + customer_id: customerId, + feature_id: requestFeatureId, + required_balance: requiredBalance, + send_event: true, + }); + + expect(response).toMatchObject({ + allowed: false, + customer_id: customerId, + required_balance: expectedResponseRequiredBalance, + balance: { + feature_id: expectedFeatureId, + granted: customer.granted, + remaining: customer.remaining, + usage: customer.usage, + ...(customer.maxPurchase === undefined + ? {} + : { + max_purchase: customer.maxPurchase, + }), + }, + }); + + if (customer.breakdownLength !== undefined) { + expect(response.balance?.breakdown).toHaveLength(customer.breakdownLength); + } + + await timeout(4000); + + await expectCustomerFeatureCachedAndDb({ + autumn, + customerId, + featureId: expectedFeatureId, + granted: customer.granted, + remaining: customer.remaining, + usage: customer.usage, + maxPurchase: customer.maxPurchase, + breakdownLength: customer.breakdownLength, + }); +}; + export const expectEntityFeatureCachedAndDb = async ({ autumn, customerId, diff --git a/server/tests/integration/crud/customers/customer-billing-controls.test.ts b/server/tests/integration/crud/customers/customer-billing-controls.test.ts new file mode 100644 index 000000000..5e97aa80d --- /dev/null +++ b/server/tests/integration/crud/customers/customer-billing-controls.test.ts @@ -0,0 +1,172 @@ +import { expect, test } from "bun:test"; +import type { ApiCustomerV5, CustomerBillingControls } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { CusService } from "@/internal/customers/CusService.js"; + +const spendLimitControls: CustomerBillingControls = { + spend_limits: [ + { + feature_id: TestFeature.Messages, + enabled: true, + overage_limit: 25, + }, + ], +}; + +const autoTopupControls: CustomerBillingControls = { + auto_topups: [ + { + feature_id: TestFeature.Credits, + enabled: true, + threshold: 20, + quantity: 100, + }, + ], +}; + +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({ + setup: [s.deleteCustomer({ customerId })], + actions: [], + }); + + await autumnV2_1.customers.create({ + id: customerId, + name: "Spend Limit Customer", + email: `${customerId}@example.com`, + billing_controls: spendLimitControls, + }); + + const cachedCustomer = + await autumnV2_1.customers.get(customerId); + expect(cachedCustomer.billing_controls?.spend_limits).toEqual( + spendLimitControls.spend_limits, + ); + + const uncachedCustomer = await autumnV2_1.customers.get( + customerId, + { + skip_cache: "true", + }, + ); + expect(uncachedCustomer.billing_controls?.spend_limits).toEqual( + spendLimitControls.spend_limits, + ); + + const fromDb = await CusService.getFull({ + ctx, + idOrInternalId: customerId, + }); + expect(fromDb.spend_limits).toEqual(spendLimitControls.spend_limits); +}); + +test.concurrent(`${chalk.yellowBright("customer billing controls: update spend limits without clearing other billing controls")}`, async () => { + const customerId = "customer-billing-controls-2"; + const { autumnV2_1 } = await initScenario({ + customerId, + setup: [s.customer({})], + actions: [], + }); + + await autumnV2_1.customers.get(customerId); + + await autumnV2_1.customers.update(customerId, { + billing_controls: autoTopupControls, + }); + + await autumnV2_1.customers.update(customerId, { + billing_controls: spendLimitControls, + }); + + const afterSpendLimitUpdate = + await autumnV2_1.customers.get(customerId); + expect(afterSpendLimitUpdate.billing_controls?.auto_topups).toEqual( + autoTopupControls.auto_topups, + ); + expect(afterSpendLimitUpdate.billing_controls?.spend_limits).toEqual( + spendLimitControls.spend_limits, + ); + + await autumnV2_1.customers.update(customerId, { + billing_controls: { spend_limits: [] }, + }); + + const cachedCustomer = + await autumnV2_1.customers.get(customerId); + expect(cachedCustomer.billing_controls?.auto_topups).toEqual( + autoTopupControls.auto_topups, + ); + expect(cachedCustomer.billing_controls?.spend_limits).toEqual([]); + + const uncachedCustomer = await autumnV2_1.customers.get( + customerId, + { + skip_cache: "true", + }, + ); + expect(uncachedCustomer.billing_controls?.auto_topups).toEqual( + autoTopupControls.auto_topups, + ); + expect(uncachedCustomer.billing_controls?.spend_limits).toEqual([]); +}); + +test.concurrent(`${chalk.yellowBright("customer billing controls: reject duplicate spend limit feature ids on create and update")}`, async () => { + const customerId = "customer-billing-controls-3"; + const { autumnV2_1 } = await initScenario({ + setup: [s.deleteCustomer({ customerId })], + actions: [], + }); + + await expectAutumnError({ + func: async () => + await autumnV2_1.customers.create({ + id: customerId, + name: "Duplicate Spend Limit Create", + email: `${customerId}@example.com`, + billing_controls: { + spend_limits: [ + { + feature_id: TestFeature.Messages, + enabled: true, + overage_limit: 10, + }, + { + feature_id: TestFeature.Messages, + enabled: false, + overage_limit: 20, + }, + ], + }, + }), + }); + + await autumnV2_1.customers.create({ + id: customerId, + name: "Duplicate Spend Limit Update", + email: `${customerId}@example.com`, + }); + + await expectAutumnError({ + func: async () => + await autumnV2_1.customers.update(customerId, { + billing_controls: { + spend_limits: [ + { + feature_id: TestFeature.Credits, + enabled: true, + overage_limit: 15, + }, + { + feature_id: TestFeature.Credits, + enabled: true, + overage_limit: 30, + }, + ], + }, + }), + }); +}); diff --git a/shared/api/billingControls/entityBillingControls.ts b/shared/api/billingControls/entityBillingControls.ts index f20db714e..693f5b15a 100644 --- a/shared/api/billingControls/entityBillingControls.ts +++ b/shared/api/billingControls/entityBillingControls.ts @@ -7,8 +7,9 @@ export const ApiEntityBillingControlsSchema = z.object({ }), }); -export const ApiEntityBillingControlsInputSchema = - ApiEntityBillingControlsSchema.superRefine((billingControls, ctx) => { +export const ApiEntityBillingControlsParamsSchema = + ApiEntityBillingControlsSchema.check((ctx) => { + const billingControls = ctx.value; const featureIds = new Set(); for (const [index, spendLimit] of ( @@ -19,9 +20,10 @@ export const ApiEntityBillingControlsInputSchema = } if (featureIds.has(spendLimit.feature_id)) { - ctx.addIssue({ + ctx.issues.push({ code: "custom", message: "Only one spend limit entry is allowed per feature_id", + input: spendLimit.feature_id, path: ["spend_limits", index, "feature_id"], }); return; @@ -34,6 +36,6 @@ export const ApiEntityBillingControlsInputSchema = export type ApiEntityBillingControls = z.infer< typeof ApiEntityBillingControlsSchema >; -export type ApiEntityBillingControlsInput = z.input< - typeof ApiEntityBillingControlsInputSchema +export type ApiEntityBillingControlsParams = z.input< + typeof ApiEntityBillingControlsParamsSchema >; diff --git a/shared/api/common/customerData.ts b/shared/api/common/customerData.ts index b2ff6d586..c463251f5 100644 --- a/shared/api/common/customerData.ts +++ b/shared/api/common/customerData.ts @@ -1,5 +1,5 @@ import { z } from "zod/v4"; -import { CustomerBillingControlsSchema } from "../../models/cusModels/billingControls/customerBillingControls"; +import { CustomerBillingControlsParamsSchema } from "../../models/cusModels/billingControls/customerBillingControls"; import { ExternalProcessorsSchema } from "../../models/genModels/processorSchemas"; // for internal use only @@ -50,7 +50,7 @@ export const CustomerDataSchema = z description: "Whether to send email receipts to this customer", }), - billing_controls: CustomerBillingControlsSchema.optional().meta({ + billing_controls: CustomerBillingControlsParamsSchema.optional().meta({ description: "Billing controls for the customer (auto top-ups, etc.)", }), diff --git a/shared/api/entities/crud/createEntityParams.ts b/shared/api/entities/crud/createEntityParams.ts index c665322d0..c9cad43ea 100644 --- a/shared/api/entities/crud/createEntityParams.ts +++ b/shared/api/entities/crud/createEntityParams.ts @@ -1,5 +1,5 @@ import { z } from "zod/v4"; -import { ApiEntityBillingControlsInputSchema } from "../../billingControls/entityBillingControls.js"; +import { ApiEntityBillingControlsParamsSchema } from "../../billingControls/entityBillingControls.js"; import { CustomerDataSchema } from "../../common/customerData.js"; export const CreateEntityParamsV0Schema = z.object({ @@ -18,7 +18,7 @@ export const CreateEntityParamsV0Schema = z.object({ feature_id: z.string().meta({ description: "The ID of the feature this entity is associated with", }), - billing_controls: ApiEntityBillingControlsInputSchema.optional().meta({ + billing_controls: ApiEntityBillingControlsParamsSchema.optional().meta({ description: "Billing controls for the entity.", }), customer_data: CustomerDataSchema.optional().meta({ diff --git a/shared/api/entities/crud/updateEntityParams.ts b/shared/api/entities/crud/updateEntityParams.ts index 6589751ee..07fae4f47 100644 --- a/shared/api/entities/crud/updateEntityParams.ts +++ b/shared/api/entities/crud/updateEntityParams.ts @@ -1,5 +1,5 @@ import { z } from "zod/v4"; -import { ApiEntityBillingControlsInputSchema } from "../../billingControls/entityBillingControls.js"; +import { ApiEntityBillingControlsParamsSchema } from "../../billingControls/entityBillingControls.js"; export const UpdateEntityParamsSchema = z.object({ customer_id: z.string().optional().meta({ @@ -8,7 +8,7 @@ export const UpdateEntityParamsSchema = z.object({ entity_id: z.string().meta({ description: "The ID of the entity.", }), - billing_controls: ApiEntityBillingControlsInputSchema.optional().meta({ + billing_controls: ApiEntityBillingControlsParamsSchema.optional().meta({ description: "Billing controls to replace on the entity.", }), }); diff --git a/shared/models/cusModels/billingControls/customerBillingControls.ts b/shared/models/cusModels/billingControls/customerBillingControls.ts index a9cbe489a..ce52b2214 100644 --- a/shared/models/cusModels/billingControls/customerBillingControls.ts +++ b/shared/models/cusModels/billingControls/customerBillingControls.ts @@ -1,7 +1,7 @@ import { z } from "zod/v4"; import { type EntityBillingControls, - type EntityBillingControlsInput, + type EntityBillingControlsParams, EntityBillingControlsSchema, } from "./entityBillingControls.js"; import { PurchaseLimitIntervalEnum } from "./purchaseLimitInterval.js"; @@ -47,6 +47,32 @@ export const CustomerBillingControlsSchema = z.object({ }), }); +export const CustomerBillingControlsParamsSchema = + CustomerBillingControlsSchema.check((ctx) => { + const billingControls = ctx.value; + const featureIds = new Set(); + + for (const [index, spendLimit] of ( + billingControls.spend_limits ?? [] + ).entries()) { + if (!spendLimit.feature_id) { + continue; + } + + if (featureIds.has(spendLimit.feature_id)) { + ctx.issues.push({ + code: "custom", + message: "Only one spend limit entry is allowed per feature_id", + input: spendLimit.feature_id, + path: ["spend_limits", index, "feature_id"], + }); + return; + } + + featureIds.add(spendLimit.feature_id); + } + }); + export type AutoTopupPurchaseLimit = z.infer< typeof AutoTopupPurchaseLimitSchema >; @@ -55,9 +81,13 @@ export type CustomerBillingControls = z.infer< typeof CustomerBillingControlsSchema >; -export type CustomerBillingControlsInput = z.input< - typeof CustomerBillingControlsSchema +export type CustomerBillingControlsParams = z.input< + typeof CustomerBillingControlsParamsSchema >; export { EntityBillingControlsSchema, DbSpendLimitSchema }; -export type { EntityBillingControls, EntityBillingControlsInput, DbSpendLimit }; +export type { + EntityBillingControls, + EntityBillingControlsParams, + DbSpendLimit, +}; diff --git a/shared/models/cusModels/billingControls/entityBillingControls.ts b/shared/models/cusModels/billingControls/entityBillingControls.ts index 66b33419a..afde88943 100644 --- a/shared/models/cusModels/billingControls/entityBillingControls.ts +++ b/shared/models/cusModels/billingControls/entityBillingControls.ts @@ -8,6 +8,6 @@ export const EntityBillingControlsSchema = z.object({ }); export type EntityBillingControls = z.infer; -export type EntityBillingControlsInput = z.input< +export type EntityBillingControlsParams = z.input< typeof EntityBillingControlsSchema >; diff --git a/shared/models/cusModels/cusTable.ts b/shared/models/cusModels/cusTable.ts index d19f572f9..1bf23b6ca 100644 --- a/shared/models/cusModels/cusTable.ts +++ b/shared/models/cusModels/cusTable.ts @@ -12,7 +12,10 @@ import { import { collatePgColumn } from "../../db/utils.js"; import type { ExternalProcessors } from "../genModels/processorSchemas.js"; import { organizations } from "../orgModels/orgTable.js"; -import type { AutoTopup } from "./billingControls/customerBillingControls.js"; +import type { + AutoTopup, + DbSpendLimit, +} from "./billingControls/customerBillingControls.js"; export type CustomerProcessor = { type: "stripe"; @@ -37,6 +40,7 @@ export const customers = pgTable( .default({} as ExternalProcessors), send_email_receipts: boolean("send_email_receipts").default(false), auto_topups: jsonb().$type(), + spend_limits: jsonb().$type(), }, (table) => [ unique("cus_id_constraint").on(table.org_id, table.id, table.env), diff --git a/shared/models/cusModels/index.ts b/shared/models/cusModels/index.ts index 6b06a2daf..4962d3510 100644 --- a/shared/models/cusModels/index.ts +++ b/shared/models/cusModels/index.ts @@ -2,3 +2,5 @@ export * from "./billingControls/customerBillingControls.js"; export * from "./billingControls/entityBillingControls.js"; export * from "./billingControls/purchaseLimitInterval.js"; export * from "./billingControls/spendLimit.js"; +export * from "./cusModels.js"; +export * from "./cusTable.js";