feat: overage allowed control
This commit is contained in:
7
.cursor/settings.json
Normal file
7
.cursor/settings.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"plugins": {
|
||||||
|
"linear": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,6 +72,7 @@
|
|||||||
"dx": "bun scripts/dx.ts",
|
"dx": "bun scripts/dx.ts",
|
||||||
"d:test": "lsof -ti:8080 -ti:3000 | xargs kill -9 2>/dev/null || true; ENV_FILE=.env infisical run --env=test -- bun scripts/dev.ts",
|
"d:test": "lsof -ti:8080 -ti:3000 | xargs kill -9 2>/dev/null || true; ENV_FILE=.env infisical run --env=test -- bun scripts/dev.ts",
|
||||||
"p": "ENV_FILE=.env.prod infisical run --env=prod -- bun scripts/dev.ts",
|
"p": "ENV_FILE=.env.prod infisical run --env=prod -- bun scripts/dev.ts",
|
||||||
|
"s": "ENV_FILE=.env.staging infisical run --env=staging -- bun scripts/dev.ts",
|
||||||
"l": "bash ./scripts/dev-local.sh",
|
"l": "bash ./scripts/dev-local.sh",
|
||||||
"setup": "node scripts/setup/setup.js",
|
"setup": "node scripts/setup/setup.js",
|
||||||
"setup:test": "infisical run --env=dev -- bun scripts/setup/setup-test.ts",
|
"setup:test": "infisical run --env=dev -- bun scripts/setup/setup-test.ts",
|
||||||
|
|||||||
@@ -18,7 +18,8 @@
|
|||||||
processors?: object | null,
|
processors?: object | null,
|
||||||
auto_topups?: array | null,
|
auto_topups?: array | null,
|
||||||
spend_limits?: array | null,
|
spend_limits?: array | null,
|
||||||
usage_alerts?: array | null
|
usage_alerts?: array | null,
|
||||||
|
overage_allowed?: array | null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,4 +122,13 @@ if updates.usage_alerts ~= nil then
|
|||||||
table.insert(updated_fields, 'usage_alerts')
|
table.insert(updated_fields, 'usage_alerts')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if updates.overage_allowed ~= nil then
|
||||||
|
if is_nil(updates.overage_allowed) then
|
||||||
|
redis.call('JSON.SET', cache_key, '$.overage_allowed', 'null')
|
||||||
|
else
|
||||||
|
redis.call('JSON.SET', cache_key, '$.overage_allowed', cjson.encode(updates.overage_allowed))
|
||||||
|
end
|
||||||
|
table.insert(updated_fields, 'overage_allowed')
|
||||||
|
end
|
||||||
|
|
||||||
return cjson.encode({ success = true, updated_fields = updated_fields })
|
return cjson.encode({ success = true, updated_fields = updated_fields })
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
cusEntToStartingBalance,
|
cusEntToStartingBalance,
|
||||||
type FullCustomer,
|
type FullCustomer,
|
||||||
fullCustomerToCustomerEntitlements,
|
fullCustomerToCustomerEntitlements,
|
||||||
|
fullCustomerToOverageAllowedByFeatureId,
|
||||||
fullCustomerToSpendLimitByFeatureId,
|
fullCustomerToSpendLimitByFeatureId,
|
||||||
fullCustomerToUsageBasedCusEntsByFeatureId,
|
fullCustomerToUsageBasedCusEntsByFeatureId,
|
||||||
getMaxOverage,
|
getMaxOverage,
|
||||||
@@ -85,6 +86,10 @@ export const prepareFeatureDeduction = ({
|
|||||||
fullCustomer,
|
fullCustomer,
|
||||||
featureIds: effectiveFeatureIds,
|
featureIds: effectiveFeatureIds,
|
||||||
});
|
});
|
||||||
|
const overageAllowedByFeatureId = fullCustomerToOverageAllowedByFeatureId({
|
||||||
|
fullCustomer,
|
||||||
|
featureIds: effectiveFeatureIds,
|
||||||
|
});
|
||||||
|
|
||||||
// Build input for each customer entitlement
|
// Build input for each customer entitlement
|
||||||
const customerEntitlementDeductions: CustomerEntitlementDeduction[] =
|
const customerEntitlementDeductions: CustomerEntitlementDeduction[] =
|
||||||
@@ -104,12 +109,18 @@ export const prepareFeatureDeduction = ({
|
|||||||
const isFreeAllocatedUsageAllowed =
|
const isFreeAllocatedUsageAllowed =
|
||||||
isFreeAllocated && overageBehaviour !== "reject";
|
isFreeAllocated && overageBehaviour !== "reject";
|
||||||
|
|
||||||
|
const billingControlOverageAllowed =
|
||||||
|
overageAllowedByFeatureId[ce.entitlement.feature.id]?.enabled ?? false;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
customer_entitlement_id: ce.id,
|
customer_entitlement_id: ce.id,
|
||||||
credit_cost: creditCost,
|
credit_cost: creditCost,
|
||||||
feature_id: ce.entitlement.feature.id,
|
feature_id: ce.entitlement.feature.id,
|
||||||
entity_feature_id: ce.entitlement.entity_feature_id ?? null,
|
entity_feature_id: ce.entitlement.entity_feature_id ?? null,
|
||||||
usage_allowed: ce.usage_allowed || isFreeAllocatedUsageAllowed,
|
usage_allowed:
|
||||||
|
ce.usage_allowed ||
|
||||||
|
isFreeAllocatedUsageAllowed ||
|
||||||
|
billingControlOverageAllowed,
|
||||||
min_balance: notNullish(maxOverage) ? -maxOverage : undefined,
|
min_balance: notNullish(maxOverage) ? -maxOverage : undefined,
|
||||||
max_balance: resetBalance,
|
max_balance: resetBalance,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ export const updateCustomer = async ({
|
|||||||
auto_topups: billing_controls.auto_topups,
|
auto_topups: billing_controls.auto_topups,
|
||||||
spend_limits: billing_controls.spend_limits,
|
spend_limits: billing_controls.spend_limits,
|
||||||
usage_alerts: billing_controls.usage_alerts,
|
usage_alerts: billing_controls.usage_alerts,
|
||||||
|
overage_allowed: billing_controls.overage_allowed,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export const getApiCustomerBase = async ({
|
|||||||
auto_topups: fullCus.auto_topups ?? undefined,
|
auto_topups: fullCus.auto_topups ?? undefined,
|
||||||
spend_limits: fullCus.spend_limits ?? undefined,
|
spend_limits: fullCus.spend_limits ?? undefined,
|
||||||
usage_alerts: fullCus.usage_alerts ?? undefined,
|
usage_alerts: fullCus.usage_alerts ?? undefined,
|
||||||
|
overage_allowed: fullCus.overage_allowed ?? undefined,
|
||||||
},
|
},
|
||||||
|
|
||||||
invoices:
|
invoices:
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ type CustomerDataUpdates = Pick<
|
|||||||
| "auto_topups"
|
| "auto_topups"
|
||||||
| "spend_limits"
|
| "spend_limits"
|
||||||
| "usage_alerts"
|
| "usage_alerts"
|
||||||
|
| "overage_allowed"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ export const updateEntityInCache = async ({
|
|||||||
ctx: AutumnContext;
|
ctx: AutumnContext;
|
||||||
customerId: string;
|
customerId: string;
|
||||||
idOrInternalId: string;
|
idOrInternalId: string;
|
||||||
updates: Partial<Pick<Entity, "spend_limits" | "usage_alerts">>;
|
updates: Partial<
|
||||||
|
Pick<Entity, "spend_limits" | "usage_alerts" | "overage_allowed">
|
||||||
|
>;
|
||||||
}): Promise<UpdateEntityInCacheResult | null> => {
|
}): Promise<UpdateEntityInCacheResult | null> => {
|
||||||
try {
|
try {
|
||||||
if (Object.keys(updates).length === 0) {
|
if (Object.keys(updates).length === 0) {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const initCustomer = ({
|
|||||||
auto_topups: customerData?.billing_controls?.auto_topups,
|
auto_topups: customerData?.billing_controls?.auto_topups,
|
||||||
spend_limits: customerData?.billing_controls?.spend_limits,
|
spend_limits: customerData?.billing_controls?.spend_limits,
|
||||||
usage_alerts: customerData?.billing_controls?.usage_alerts,
|
usage_alerts: customerData?.billing_controls?.usage_alerts,
|
||||||
|
overage_allowed: customerData?.billing_controls?.overage_allowed,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export const batchCreateEntities = async ({
|
|||||||
...(inputEntities[0].billing_controls && {
|
...(inputEntities[0].billing_controls && {
|
||||||
spend_limits: inputEntities[0].billing_controls.spend_limits,
|
spend_limits: inputEntities[0].billing_controls.spend_limits,
|
||||||
usage_alerts: inputEntities[0].billing_controls.usage_alerts,
|
usage_alerts: inputEntities[0].billing_controls.usage_alerts,
|
||||||
|
overage_allowed: inputEntities[0].billing_controls.overage_allowed,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export const updateEntity = async ({
|
|||||||
updates: {
|
updates: {
|
||||||
spend_limits: billing_controls?.spend_limits,
|
spend_limits: billing_controls?.spend_limits,
|
||||||
usage_alerts: billing_controls?.usage_alerts,
|
usage_alerts: billing_controls?.usage_alerts,
|
||||||
|
overage_allowed: billing_controls?.overage_allowed,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,11 +12,15 @@ export const updateEntityDbAndCache = async ({
|
|||||||
ctx: AutumnContext;
|
ctx: AutumnContext;
|
||||||
customerId: string;
|
customerId: string;
|
||||||
entity: Entity;
|
entity: Entity;
|
||||||
updates: Partial<Pick<Entity, "spend_limits" | "usage_alerts">>;
|
updates: Partial<
|
||||||
|
Pick<Entity, "spend_limits" | "usage_alerts" | "overage_allowed">
|
||||||
|
>;
|
||||||
}) => {
|
}) => {
|
||||||
const filteredUpdates = Object.fromEntries(
|
const filteredUpdates = Object.fromEntries(
|
||||||
Object.entries(updates).filter(([, value]) => value !== undefined),
|
Object.entries(updates).filter(([, value]) => value !== undefined),
|
||||||
) as Partial<Pick<Entity, "spend_limits" | "usage_alerts">>;
|
) as Partial<
|
||||||
|
Pick<Entity, "spend_limits" | "usage_alerts" | "overage_allowed">
|
||||||
|
>;
|
||||||
|
|
||||||
if (Object.keys(filteredUpdates).length === 0) {
|
if (Object.keys(filteredUpdates).length === 0) {
|
||||||
return entity;
|
return entity;
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export const getApiEntityBase = async ({
|
|||||||
billing_controls: {
|
billing_controls: {
|
||||||
spend_limits: entity.spend_limits ?? undefined,
|
spend_limits: entity.spend_limits ?? undefined,
|
||||||
usage_alerts: entity.usage_alerts ?? undefined,
|
usage_alerts: entity.usage_alerts ?? undefined,
|
||||||
|
overage_allowed: entity.overage_allowed ?? undefined,
|
||||||
},
|
},
|
||||||
} satisfies ApiEntityV2);
|
} satisfies ApiEntityV2);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import type { CheckResponseV3 } from "@autumn/shared";
|
||||||
|
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||||
|
import { items } from "@tests/utils/fixtures/items.js";
|
||||||
|
import { products } from "@tests/utils/fixtures/products.js";
|
||||||
|
import { timeout } from "@tests/utils/genUtils.js";
|
||||||
|
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
|
||||||
|
import chalk from "chalk";
|
||||||
|
import { setCustomerOverageAllowed } from "../../utils/overage-allowed-utils/customerOverageAllowedUtils.js";
|
||||||
|
import { normalizeCheckResponse } from "../../utils/spend-limit-utils/checkSpendLimitUtils.js";
|
||||||
|
|
||||||
|
test.concurrent(`${chalk.yellowBright("check-overage-allowed-1: free feature, enabled:true, balance at 0 — check returns allowed:true")}`, async () => {
|
||||||
|
const freeProd = products.base({
|
||||||
|
id: "free-overage-check",
|
||||||
|
items: [items.lifetimeMessages({ includedUsage: 100 })],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { autumnV2_1, customerId } = await initScenario({
|
||||||
|
customerId: "check-overage-allowed-1",
|
||||||
|
setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })],
|
||||||
|
actions: [s.attach({ productId: freeProd.id })],
|
||||||
|
});
|
||||||
|
|
||||||
|
await autumnV2_1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
value: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const beforeControl = await autumnV2_1.check<CheckResponseV3>({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
required_balance: 1,
|
||||||
|
});
|
||||||
|
expect(beforeControl.allowed).toBe(false);
|
||||||
|
|
||||||
|
await setCustomerOverageAllowed({
|
||||||
|
autumn: autumnV2_1,
|
||||||
|
customerId,
|
||||||
|
featureId: TestFeature.Messages,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const afterControl = await autumnV2_1.check<CheckResponseV3>({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
required_balance: 1,
|
||||||
|
});
|
||||||
|
expect(afterControl.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.concurrent(`${chalk.yellowBright("check-overage-allowed-2: free feature, enabled:true, balance negative — check still returns allowed:true")}`, async () => {
|
||||||
|
const freeProd = products.base({
|
||||||
|
id: "free-overage-neg",
|
||||||
|
items: [items.lifetimeMessages({ includedUsage: 100 })],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { autumnV2_1, customerId } = await initScenario({
|
||||||
|
customerId: "check-overage-allowed-2",
|
||||||
|
setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })],
|
||||||
|
actions: [s.attach({ productId: freeProd.id })],
|
||||||
|
});
|
||||||
|
|
||||||
|
await setCustomerOverageAllowed({
|
||||||
|
autumn: autumnV2_1,
|
||||||
|
customerId,
|
||||||
|
featureId: TestFeature.Messages,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await autumnV2_1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
value: 150,
|
||||||
|
});
|
||||||
|
|
||||||
|
const check = await autumnV2_1.check<CheckResponseV3>({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
required_balance: 50,
|
||||||
|
});
|
||||||
|
expect(check.allowed).toBe(true);
|
||||||
|
expect(check.balance!.remaining).toBeLessThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.concurrent(`${chalk.yellowBright("check-overage-allowed-3: free feature, no billing control (baseline) — check returns allowed:false at 0")}`, async () => {
|
||||||
|
const freeProd = products.base({
|
||||||
|
id: "free-no-control",
|
||||||
|
items: [items.lifetimeMessages({ includedUsage: 100 })],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { autumnV2_1, customerId } = await initScenario({
|
||||||
|
customerId: "check-overage-allowed-3",
|
||||||
|
setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })],
|
||||||
|
actions: [s.attach({ productId: freeProd.id })],
|
||||||
|
});
|
||||||
|
|
||||||
|
await autumnV2_1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
value: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const check = await autumnV2_1.check<CheckResponseV3>({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
required_balance: 1,
|
||||||
|
});
|
||||||
|
expect(check.allowed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.concurrent(`${chalk.yellowBright("check-overage-allowed-4: check with send_event:true, enabled:true — allowed:true and balance goes negative")}`, async () => {
|
||||||
|
const freeProd = products.base({
|
||||||
|
id: "free-overage-send-event",
|
||||||
|
items: [items.lifetimeMessages({ includedUsage: 100 })],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { autumnV2_1, customerId } = await initScenario({
|
||||||
|
customerId: "check-overage-allowed-4",
|
||||||
|
setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })],
|
||||||
|
actions: [s.attach({ productId: freeProd.id })],
|
||||||
|
});
|
||||||
|
|
||||||
|
await setCustomerOverageAllowed({
|
||||||
|
autumn: autumnV2_1,
|
||||||
|
customerId,
|
||||||
|
featureId: TestFeature.Messages,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await autumnV2_1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
value: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const checkResult = await autumnV2_1.check<CheckResponseV3>({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
required_balance: 25,
|
||||||
|
send_event: true,
|
||||||
|
});
|
||||||
|
expect(checkResult.allowed).toBe(true);
|
||||||
|
expect(checkResult.balance!.remaining).toBe(-25);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.concurrent(`${chalk.yellowBright("check-overage-allowed-5: cache/DB parity — cached and uncached check responses match")}`, async () => {
|
||||||
|
const freeProd = products.base({
|
||||||
|
id: "free-overage-parity",
|
||||||
|
items: [items.lifetimeMessages({ includedUsage: 100 })],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { autumnV2_1, customerId } = await initScenario({
|
||||||
|
customerId: "check-overage-allowed-5",
|
||||||
|
setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })],
|
||||||
|
actions: [s.attach({ productId: freeProd.id })],
|
||||||
|
});
|
||||||
|
|
||||||
|
await setCustomerOverageAllowed({
|
||||||
|
autumn: autumnV2_1,
|
||||||
|
customerId,
|
||||||
|
featureId: TestFeature.Messages,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await autumnV2_1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
value: 120,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cached = await autumnV2_1.check<CheckResponseV3>({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
required_balance: 10,
|
||||||
|
});
|
||||||
|
expect(cached.allowed).toBe(true);
|
||||||
|
|
||||||
|
await timeout(4000);
|
||||||
|
|
||||||
|
const uncached = await autumnV2_1.check<CheckResponseV3>({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
required_balance: 10,
|
||||||
|
skip_cache: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(normalizeCheckResponse(uncached)).toEqual(
|
||||||
|
normalizeCheckResponse(cached),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import type { ApiCustomerV5, CheckResponseV3 } from "@autumn/shared";
|
||||||
|
import { deleteLock } from "@tests/integration/balances/utils/lockUtils/deleteLock.js";
|
||||||
|
import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect.js";
|
||||||
|
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||||
|
import { items } from "@tests/utils/fixtures/items.js";
|
||||||
|
import { products } from "@tests/utils/fixtures/products.js";
|
||||||
|
import { timeout } from "@tests/utils/genUtils.js";
|
||||||
|
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
|
||||||
|
import chalk from "chalk";
|
||||||
|
import { setCustomerOverageAllowed } from "../../utils/overage-allowed-utils/customerOverageAllowedUtils.js";
|
||||||
|
|
||||||
|
test.concurrent(`${chalk.yellowBright("track-overage-allowed-1: free feature, enabled:true — usage exceeds granted (cache + db parity)")}`, async () => {
|
||||||
|
const freeProd = products.base({
|
||||||
|
id: "free-track-overage",
|
||||||
|
items: [items.lifetimeMessages({ includedUsage: 100 })],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { autumnV2_1, customerId } = await initScenario({
|
||||||
|
customerId: "track-overage-allowed-1",
|
||||||
|
setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })],
|
||||||
|
actions: [s.attach({ productId: freeProd.id })],
|
||||||
|
});
|
||||||
|
|
||||||
|
await setCustomerOverageAllowed({
|
||||||
|
autumn: autumnV2_1,
|
||||||
|
customerId,
|
||||||
|
featureId: TestFeature.Messages,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await autumnV2_1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
value: 130,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cached = await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
|
||||||
|
expectBalanceCorrect({
|
||||||
|
customer: cached,
|
||||||
|
featureId: TestFeature.Messages,
|
||||||
|
remaining: 0,
|
||||||
|
usage: 130,
|
||||||
|
});
|
||||||
|
|
||||||
|
await timeout(4000);
|
||||||
|
|
||||||
|
const uncached = await autumnV2_1.customers.get<ApiCustomerV5>(customerId, {
|
||||||
|
skip_cache: "true",
|
||||||
|
});
|
||||||
|
expectBalanceCorrect({
|
||||||
|
customer: uncached,
|
||||||
|
featureId: TestFeature.Messages,
|
||||||
|
remaining: 0,
|
||||||
|
usage: 130,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.concurrent(`${chalk.yellowBright("track-overage-allowed-2: free feature, no billing control (baseline) — usage caps at granted")}`, async () => {
|
||||||
|
const freeProd = products.base({
|
||||||
|
id: "free-track-no-control",
|
||||||
|
items: [items.lifetimeMessages({ includedUsage: 100 })],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { autumnV2_1, customerId } = await initScenario({
|
||||||
|
customerId: "track-overage-allowed-2",
|
||||||
|
setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })],
|
||||||
|
actions: [s.attach({ productId: freeProd.id })],
|
||||||
|
});
|
||||||
|
|
||||||
|
await autumnV2_1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
value: 130,
|
||||||
|
});
|
||||||
|
|
||||||
|
const customer = await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
|
||||||
|
expectBalanceCorrect({
|
||||||
|
customer,
|
||||||
|
featureId: TestFeature.Messages,
|
||||||
|
remaining: 0,
|
||||||
|
usage: 100,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.concurrent(`${chalk.yellowBright("track-overage-allowed-3: free feature, enabled:true, multiple tracks — usage grows past granted")}`, async () => {
|
||||||
|
const freeProd = products.base({
|
||||||
|
id: "free-track-multi",
|
||||||
|
items: [items.lifetimeMessages({ includedUsage: 100 })],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { autumnV2_1, customerId } = await initScenario({
|
||||||
|
customerId: "track-overage-allowed-3",
|
||||||
|
setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })],
|
||||||
|
actions: [s.attach({ productId: freeProd.id })],
|
||||||
|
});
|
||||||
|
|
||||||
|
await setCustomerOverageAllowed({
|
||||||
|
autumn: autumnV2_1,
|
||||||
|
customerId,
|
||||||
|
featureId: TestFeature.Messages,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await autumnV2_1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
value: 80,
|
||||||
|
});
|
||||||
|
|
||||||
|
let customer = await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
|
||||||
|
expectBalanceCorrect({
|
||||||
|
customer,
|
||||||
|
featureId: TestFeature.Messages,
|
||||||
|
remaining: 20,
|
||||||
|
usage: 80,
|
||||||
|
});
|
||||||
|
|
||||||
|
await autumnV2_1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
value: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
customer = await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
|
||||||
|
expectBalanceCorrect({
|
||||||
|
customer,
|
||||||
|
featureId: TestFeature.Messages,
|
||||||
|
remaining: 0,
|
||||||
|
usage: 130,
|
||||||
|
});
|
||||||
|
|
||||||
|
await autumnV2_1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
value: 70,
|
||||||
|
});
|
||||||
|
|
||||||
|
customer = await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
|
||||||
|
expectBalanceCorrect({
|
||||||
|
customer,
|
||||||
|
featureId: TestFeature.Messages,
|
||||||
|
remaining: 0,
|
||||||
|
usage: 200,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.concurrent(`${chalk.yellowBright("track-overage-allowed-4: overage_behavior:reject succeeds when overage_allowed is enabled")}`, async () => {
|
||||||
|
const freeProd = products.base({
|
||||||
|
id: "free-track-reject-allowed",
|
||||||
|
items: [items.lifetimeMessages({ includedUsage: 50 })],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { autumnV2_1, customerId } = await initScenario({
|
||||||
|
customerId: "track-overage-allowed-4",
|
||||||
|
setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })],
|
||||||
|
actions: [s.attach({ productId: freeProd.id })],
|
||||||
|
});
|
||||||
|
|
||||||
|
await setCustomerOverageAllowed({
|
||||||
|
autumn: autumnV2_1,
|
||||||
|
customerId,
|
||||||
|
featureId: TestFeature.Messages,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await autumnV2_1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
value: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
await autumnV2_1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
value: 30,
|
||||||
|
overage_behavior: "reject",
|
||||||
|
});
|
||||||
|
|
||||||
|
const customer = await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
|
||||||
|
expectBalanceCorrect({
|
||||||
|
customer,
|
||||||
|
featureId: TestFeature.Messages,
|
||||||
|
remaining: 0,
|
||||||
|
usage: 80,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.concurrent(`${chalk.yellowBright("track-overage-allowed-5: send_event:true allowed when overage_allowed is enabled")}`, async () => {
|
||||||
|
const freeProd = products.base({
|
||||||
|
id: "free-track-send-event",
|
||||||
|
items: [items.lifetimeMessages({ includedUsage: 50 })],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { autumnV2_1, customerId } = await initScenario({
|
||||||
|
customerId: "track-overage-allowed-5",
|
||||||
|
setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })],
|
||||||
|
actions: [s.attach({ productId: freeProd.id })],
|
||||||
|
});
|
||||||
|
|
||||||
|
await setCustomerOverageAllowed({
|
||||||
|
autumn: autumnV2_1,
|
||||||
|
customerId,
|
||||||
|
featureId: TestFeature.Messages,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await autumnV2_1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
value: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
const checkResult = await autumnV2_1.check<CheckResponseV3>({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
required_balance: 25,
|
||||||
|
send_event: true,
|
||||||
|
});
|
||||||
|
expect(checkResult.allowed).toBe(true);
|
||||||
|
|
||||||
|
const customer = await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
|
||||||
|
expectBalanceCorrect({
|
||||||
|
customer,
|
||||||
|
featureId: TestFeature.Messages,
|
||||||
|
remaining: 0,
|
||||||
|
usage: 75,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.concurrent(`${chalk.yellowBright("track-overage-allowed-6: disabling overage_allowed reverts to capping at granted")}`, async () => {
|
||||||
|
const freeProd = products.base({
|
||||||
|
id: "free-track-disable",
|
||||||
|
items: [items.lifetimeMessages({ includedUsage: 100 })],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { autumnV2_1, customerId } = await initScenario({
|
||||||
|
customerId: "track-overage-allowed-6",
|
||||||
|
setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })],
|
||||||
|
actions: [s.attach({ productId: freeProd.id })],
|
||||||
|
});
|
||||||
|
|
||||||
|
await setCustomerOverageAllowed({
|
||||||
|
autumn: autumnV2_1,
|
||||||
|
customerId,
|
||||||
|
featureId: TestFeature.Messages,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await autumnV2_1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
value: 120,
|
||||||
|
});
|
||||||
|
|
||||||
|
let customer = await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
|
||||||
|
expectBalanceCorrect({
|
||||||
|
customer,
|
||||||
|
featureId: TestFeature.Messages,
|
||||||
|
remaining: 0,
|
||||||
|
usage: 120,
|
||||||
|
});
|
||||||
|
|
||||||
|
await setCustomerOverageAllowed({
|
||||||
|
autumn: autumnV2_1,
|
||||||
|
customerId,
|
||||||
|
featureId: TestFeature.Messages,
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await autumnV2_1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
value: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
customer = await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
|
||||||
|
expectBalanceCorrect({
|
||||||
|
customer,
|
||||||
|
featureId: TestFeature.Messages,
|
||||||
|
remaining: 0,
|
||||||
|
usage: 120,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.concurrent(`${chalk.yellowBright("track-overage-allowed-7: lock + finalize with overage allowed — lock succeeds at 0, finalize confirm")}`, async () => {
|
||||||
|
const freeProd = products.base({
|
||||||
|
id: "free-lock-overage",
|
||||||
|
items: [items.lifetimeMessages({ includedUsage: 50 })],
|
||||||
|
});
|
||||||
|
|
||||||
|
const customerId = "track-overage-allowed-7";
|
||||||
|
|
||||||
|
const { autumnV2_1, ctx } = await initScenario({
|
||||||
|
customerId,
|
||||||
|
setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })],
|
||||||
|
actions: [s.attach({ productId: freeProd.id })],
|
||||||
|
});
|
||||||
|
|
||||||
|
await setCustomerOverageAllowed({
|
||||||
|
autumn: autumnV2_1,
|
||||||
|
customerId,
|
||||||
|
featureId: TestFeature.Messages,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await autumnV2_1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
value: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
await deleteLock({ ctx, lockId: customerId });
|
||||||
|
|
||||||
|
const checkResult = await autumnV2_1.check({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
required_balance: 30,
|
||||||
|
lock: { enabled: true, lock_id: customerId },
|
||||||
|
});
|
||||||
|
expect(checkResult.allowed).toBe(true);
|
||||||
|
|
||||||
|
await autumnV2_1.balances.finalize({
|
||||||
|
lock_id: customerId,
|
||||||
|
action: "confirm",
|
||||||
|
override_value: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cached = await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
|
||||||
|
expectBalanceCorrect({
|
||||||
|
customer: cached,
|
||||||
|
featureId: TestFeature.Messages,
|
||||||
|
remaining: 0,
|
||||||
|
usage: 70,
|
||||||
|
});
|
||||||
|
|
||||||
|
await timeout(4000);
|
||||||
|
|
||||||
|
const uncached = await autumnV2_1.customers.get<ApiCustomerV5>(customerId, {
|
||||||
|
skip_cache: "true",
|
||||||
|
});
|
||||||
|
expectBalanceCorrect({
|
||||||
|
customer: uncached,
|
||||||
|
featureId: TestFeature.Messages,
|
||||||
|
remaining: 0,
|
||||||
|
usage: 70,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.concurrent(`${chalk.yellowBright("track-overage-allowed-8: lock + finalize with override > lockValue — additional deduction beyond lock")}`, async () => {
|
||||||
|
const freeProd = products.base({
|
||||||
|
id: "free-lock-override",
|
||||||
|
items: [items.lifetimeMessages({ includedUsage: 50 })],
|
||||||
|
});
|
||||||
|
|
||||||
|
const customerId = "track-overage-allowed-8";
|
||||||
|
|
||||||
|
const { autumnV2_1, ctx } = await initScenario({
|
||||||
|
customerId,
|
||||||
|
setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })],
|
||||||
|
actions: [s.attach({ productId: freeProd.id })],
|
||||||
|
});
|
||||||
|
|
||||||
|
await setCustomerOverageAllowed({
|
||||||
|
autumn: autumnV2_1,
|
||||||
|
customerId,
|
||||||
|
featureId: TestFeature.Messages,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await deleteLock({ ctx, lockId: customerId });
|
||||||
|
|
||||||
|
const checkResult = await autumnV2_1.check({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
required_balance: 30,
|
||||||
|
lock: { enabled: true, lock_id: customerId },
|
||||||
|
});
|
||||||
|
expect(checkResult.allowed).toBe(true);
|
||||||
|
|
||||||
|
await autumnV2_1.balances.finalize({
|
||||||
|
lock_id: customerId,
|
||||||
|
action: "confirm",
|
||||||
|
override_value: 80,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cached = await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
|
||||||
|
expectBalanceCorrect({
|
||||||
|
customer: cached,
|
||||||
|
featureId: TestFeature.Messages,
|
||||||
|
remaining: 0,
|
||||||
|
usage: 80,
|
||||||
|
});
|
||||||
|
|
||||||
|
await timeout(4000);
|
||||||
|
|
||||||
|
const uncached = await autumnV2_1.customers.get<ApiCustomerV5>(customerId, {
|
||||||
|
skip_cache: "true",
|
||||||
|
});
|
||||||
|
expectBalanceCorrect({
|
||||||
|
customer: uncached,
|
||||||
|
featureId: TestFeature.Messages,
|
||||||
|
remaining: 0,
|
||||||
|
usage: 80,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { CustomerBillingControls } from "@autumn/shared";
|
||||||
|
import type { initScenario } from "@tests/utils/testInitUtils/initScenario.js";
|
||||||
|
|
||||||
|
type AutumnV2_1Client = Awaited<ReturnType<typeof initScenario>>["autumnV2_1"];
|
||||||
|
|
||||||
|
export const setCustomerOverageAllowed = async ({
|
||||||
|
autumn,
|
||||||
|
customerId,
|
||||||
|
featureId,
|
||||||
|
enabled = true,
|
||||||
|
}: {
|
||||||
|
autumn: AutumnV2_1Client;
|
||||||
|
customerId: string;
|
||||||
|
featureId: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
}) => {
|
||||||
|
const billingControls: CustomerBillingControls = {
|
||||||
|
overage_allowed: [
|
||||||
|
{
|
||||||
|
feature_id: featureId,
|
||||||
|
enabled,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
await autumn.customers.update(customerId, {
|
||||||
|
billing_controls: billingControls,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -38,6 +38,15 @@ const usageAlertControls: CustomerBillingControls = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const overageAllowedControls: CustomerBillingControls = {
|
||||||
|
overage_allowed: [
|
||||||
|
{
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
test.concurrent(`${chalk.yellowBright("customer billing controls: create customer with spend limits")}`, async () => {
|
test.concurrent(`${chalk.yellowBright("customer billing controls: create customer with spend limits")}`, async () => {
|
||||||
const customerId = "customer-billing-controls-1";
|
const customerId = "customer-billing-controls-1";
|
||||||
const { autumnV2_1, ctx } = await initScenario({
|
const { autumnV2_1, ctx } = await initScenario({
|
||||||
@@ -335,3 +344,131 @@ test.concurrent(`${chalk.yellowBright("customer billing controls: clearing usage
|
|||||||
spendLimitControls.spend_limits,
|
spendLimitControls.spend_limits,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.concurrent(`${chalk.yellowBright("customer billing controls: create customer with overage_allowed")}`, async () => {
|
||||||
|
const customerId = "customer-billing-controls-8";
|
||||||
|
const { autumnV2_1, ctx } = await initScenario({
|
||||||
|
setup: [s.deleteCustomer({ customerId })],
|
||||||
|
actions: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
await autumnV2_1.customers.create({
|
||||||
|
id: customerId,
|
||||||
|
name: "Overage Allowed Customer",
|
||||||
|
email: `${customerId}@example.com`,
|
||||||
|
billing_controls: overageAllowedControls,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cachedCustomer =
|
||||||
|
await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
|
||||||
|
expect(cachedCustomer.billing_controls?.overage_allowed).toEqual(
|
||||||
|
overageAllowedControls.overage_allowed,
|
||||||
|
);
|
||||||
|
|
||||||
|
const uncachedCustomer = await autumnV2_1.customers.get<ApiCustomerV5>(
|
||||||
|
customerId,
|
||||||
|
{
|
||||||
|
skip_cache: "true",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(uncachedCustomer.billing_controls?.overage_allowed).toEqual(
|
||||||
|
overageAllowedControls.overage_allowed,
|
||||||
|
);
|
||||||
|
|
||||||
|
const fromDb = await CusService.getFull({ ctx, idOrInternalId: customerId });
|
||||||
|
expect(fromDb.overage_allowed).toEqual(
|
||||||
|
overageAllowedControls.overage_allowed,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.concurrent(`${chalk.yellowBright("customer billing controls: update overage_allowed without clearing other billing controls")}`, async () => {
|
||||||
|
const customerId = "customer-billing-controls-9";
|
||||||
|
const { autumnV2_1 } = await initScenario({
|
||||||
|
customerId,
|
||||||
|
setup: [s.customer({})],
|
||||||
|
actions: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
await autumnV2_1.customers.update(customerId, {
|
||||||
|
billing_controls: spendLimitControls,
|
||||||
|
});
|
||||||
|
|
||||||
|
await autumnV2_1.customers.update(customerId, {
|
||||||
|
billing_controls: overageAllowedControls,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cached = await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
|
||||||
|
expect(cached.billing_controls?.spend_limits).toEqual(
|
||||||
|
spendLimitControls.spend_limits,
|
||||||
|
);
|
||||||
|
expect(cached.billing_controls?.overage_allowed).toEqual(
|
||||||
|
overageAllowedControls.overage_allowed,
|
||||||
|
);
|
||||||
|
|
||||||
|
const uncached = await autumnV2_1.customers.get<ApiCustomerV5>(customerId, {
|
||||||
|
skip_cache: "true",
|
||||||
|
});
|
||||||
|
expect(uncached.billing_controls?.spend_limits).toEqual(
|
||||||
|
spendLimitControls.spend_limits,
|
||||||
|
);
|
||||||
|
expect(uncached.billing_controls?.overage_allowed).toEqual(
|
||||||
|
overageAllowedControls.overage_allowed,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.concurrent(`${chalk.yellowBright("customer billing controls: reject duplicate overage_allowed feature ids")}`, async () => {
|
||||||
|
const customerId = "customer-billing-controls-10";
|
||||||
|
const { autumnV2_1 } = await initScenario({
|
||||||
|
setup: [s.deleteCustomer({ customerId })],
|
||||||
|
actions: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expectAutumnError({
|
||||||
|
func: async () =>
|
||||||
|
await autumnV2_1.customers.create({
|
||||||
|
id: customerId,
|
||||||
|
name: "Duplicate Overage Allowed",
|
||||||
|
email: `${customerId}@example.com`,
|
||||||
|
billing_controls: {
|
||||||
|
overage_allowed: [
|
||||||
|
{ feature_id: TestFeature.Messages, enabled: true },
|
||||||
|
{ feature_id: TestFeature.Messages, enabled: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.concurrent(`${chalk.yellowBright("customer billing controls: clearing overage_allowed with empty array")}`, async () => {
|
||||||
|
const customerId = "customer-billing-controls-11";
|
||||||
|
const { autumnV2_1 } = await initScenario({
|
||||||
|
customerId,
|
||||||
|
setup: [s.customer({})],
|
||||||
|
actions: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
await autumnV2_1.customers.update(customerId, {
|
||||||
|
billing_controls: {
|
||||||
|
...spendLimitControls,
|
||||||
|
...overageAllowedControls,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await autumnV2_1.customers.update(customerId, {
|
||||||
|
billing_controls: { overage_allowed: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
const cached = await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
|
||||||
|
expect(cached.billing_controls?.overage_allowed).toEqual([]);
|
||||||
|
expect(cached.billing_controls?.spend_limits).toEqual(
|
||||||
|
spendLimitControls.spend_limits,
|
||||||
|
);
|
||||||
|
|
||||||
|
const uncached = await autumnV2_1.customers.get<ApiCustomerV5>(customerId, {
|
||||||
|
skip_cache: "true",
|
||||||
|
});
|
||||||
|
expect(uncached.billing_controls?.overage_allowed).toEqual([]);
|
||||||
|
expect(uncached.billing_controls?.spend_limits).toEqual(
|
||||||
|
spendLimitControls.spend_limits,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -28,6 +28,15 @@ const usageAlertControls: EntityBillingControls = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const overageAllowedControls: EntityBillingControls = {
|
||||||
|
overage_allowed: [
|
||||||
|
{
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
test.concurrent(`${chalk.yellowBright("entity billing controls: create and update entity spend limits")}`, async () => {
|
test.concurrent(`${chalk.yellowBright("entity billing controls: create and update entity spend limits")}`, async () => {
|
||||||
const { customerId, autumnV2_1 } = await initScenario({
|
const { customerId, autumnV2_1 } = await initScenario({
|
||||||
customerId: "entity-billing-controls-1",
|
customerId: "entity-billing-controls-1",
|
||||||
@@ -351,3 +360,127 @@ test.concurrent(`${chalk.yellowBright("entity billing controls: clearing usage a
|
|||||||
initialBillingControls.spend_limits,
|
initialBillingControls.spend_limits,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.concurrent(`${chalk.yellowBright("entity billing controls: create entity with overage_allowed")}`, async () => {
|
||||||
|
const { customerId, autumnV2_1 } = await initScenario({
|
||||||
|
customerId: "entity-billing-controls-9",
|
||||||
|
setup: [s.customer({})],
|
||||||
|
actions: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const created = await autumnV2_1.entities.create(customerId, {
|
||||||
|
id: "entity-oa-1",
|
||||||
|
name: "Entity OA 1",
|
||||||
|
feature_id: TestFeature.Users,
|
||||||
|
billing_controls: overageAllowedControls,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((created as ApiEntityV2).billing_controls?.overage_allowed).toEqual(
|
||||||
|
overageAllowedControls.overage_allowed,
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetched = await autumnV2_1.entities.get<ApiEntityV2>(
|
||||||
|
customerId,
|
||||||
|
"entity-oa-1",
|
||||||
|
);
|
||||||
|
expect(fetched.billing_controls?.overage_allowed).toEqual(
|
||||||
|
overageAllowedControls.overage_allowed,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.concurrent(`${chalk.yellowBright("entity billing controls: update overage_allowed without clearing spend limits")}`, async () => {
|
||||||
|
const { customerId, autumnV2_1 } = await initScenario({
|
||||||
|
customerId: "entity-billing-controls-10",
|
||||||
|
setup: [s.customer({})],
|
||||||
|
actions: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
await autumnV2_1.entities.create(customerId, {
|
||||||
|
id: "entity-oa-preserve-1",
|
||||||
|
name: "Entity OA Preserve 1",
|
||||||
|
feature_id: TestFeature.Users,
|
||||||
|
billing_controls: initialBillingControls,
|
||||||
|
});
|
||||||
|
|
||||||
|
await autumnV2_1.entities.update(customerId, "entity-oa-preserve-1", {
|
||||||
|
billing_controls: overageAllowedControls,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetched = await autumnV2_1.entities.get<ApiEntityV2>(
|
||||||
|
customerId,
|
||||||
|
"entity-oa-preserve-1",
|
||||||
|
);
|
||||||
|
expect(fetched.billing_controls?.spend_limits).toEqual(
|
||||||
|
initialBillingControls.spend_limits,
|
||||||
|
);
|
||||||
|
expect(fetched.billing_controls?.overage_allowed).toEqual(
|
||||||
|
overageAllowedControls.overage_allowed,
|
||||||
|
);
|
||||||
|
|
||||||
|
const fromDb = await CusService.getFull({
|
||||||
|
ctx,
|
||||||
|
idOrInternalId: customerId,
|
||||||
|
withEntities: true,
|
||||||
|
});
|
||||||
|
const entity = fromDb.entities.find(
|
||||||
|
(candidate) => candidate.id === "entity-oa-preserve-1",
|
||||||
|
);
|
||||||
|
expect(entity?.spend_limits).toEqual(initialBillingControls.spend_limits);
|
||||||
|
expect(entity?.overage_allowed).toEqual(
|
||||||
|
overageAllowedControls.overage_allowed,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.concurrent(`${chalk.yellowBright("entity billing controls: reject duplicate overage_allowed feature ids")}`, async () => {
|
||||||
|
const { customerId, autumnV2_1 } = await initScenario({
|
||||||
|
customerId: "entity-billing-controls-11",
|
||||||
|
setup: [s.customer({})],
|
||||||
|
actions: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expectAutumnError({
|
||||||
|
func: async () =>
|
||||||
|
await autumnV2_1.entities.create(customerId, {
|
||||||
|
id: "entity-oa-dup",
|
||||||
|
name: "Entity OA Dup",
|
||||||
|
feature_id: TestFeature.Users,
|
||||||
|
billing_controls: {
|
||||||
|
overage_allowed: [
|
||||||
|
{ feature_id: TestFeature.Messages, enabled: true },
|
||||||
|
{ feature_id: TestFeature.Messages, enabled: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.concurrent(`${chalk.yellowBright("entity billing controls: clearing overage_allowed with empty array")}`, async () => {
|
||||||
|
const { customerId, autumnV2_1 } = await initScenario({
|
||||||
|
customerId: "entity-billing-controls-12",
|
||||||
|
setup: [s.customer({})],
|
||||||
|
actions: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
await autumnV2_1.entities.create(customerId, {
|
||||||
|
id: "entity-oa-clear-1",
|
||||||
|
name: "Entity OA Clear 1",
|
||||||
|
feature_id: TestFeature.Users,
|
||||||
|
billing_controls: {
|
||||||
|
...initialBillingControls,
|
||||||
|
...overageAllowedControls,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await autumnV2_1.entities.update(customerId, "entity-oa-clear-1", {
|
||||||
|
billing_controls: { overage_allowed: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetched = await autumnV2_1.entities.get<ApiEntityV2>(
|
||||||
|
customerId,
|
||||||
|
"entity-oa-clear-1",
|
||||||
|
);
|
||||||
|
expect(fetched.billing_controls?.overage_allowed).toEqual([]);
|
||||||
|
expect(fetched.billing_controls?.spend_limits).toEqual(
|
||||||
|
initialBillingControls.spend_limits,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
import { ApiOverageAllowedSchema } from "./overageAllowed.js";
|
||||||
import { ApiSpendLimitSchema } from "./spendLimit.js";
|
import { ApiSpendLimitSchema } from "./spendLimit.js";
|
||||||
import { ApiUsageAlertSchema } from "./usageAlert.js";
|
import { ApiUsageAlertSchema } from "./usageAlert.js";
|
||||||
|
|
||||||
@@ -9,12 +10,16 @@ export const ApiEntityBillingControlsSchema = z.object({
|
|||||||
usage_alerts: z.array(ApiUsageAlertSchema).optional().meta({
|
usage_alerts: z.array(ApiUsageAlertSchema).optional().meta({
|
||||||
description: "List of usage alert configurations per feature.",
|
description: "List of usage alert configurations per feature.",
|
||||||
}),
|
}),
|
||||||
|
overage_allowed: z.array(ApiOverageAllowedSchema).optional().meta({
|
||||||
|
description:
|
||||||
|
"List of overage allowed controls per feature. When enabled, usage can exceed balance.",
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ApiEntityBillingControlsParamsSchema =
|
export const ApiEntityBillingControlsParamsSchema =
|
||||||
ApiEntityBillingControlsSchema.check((ctx) => {
|
ApiEntityBillingControlsSchema.check((ctx) => {
|
||||||
const billingControls = ctx.value;
|
const billingControls = ctx.value;
|
||||||
const featureIds = new Set<string>();
|
const spendLimitFeatureIds = new Set<string>();
|
||||||
|
|
||||||
for (const [index, spendLimit] of (
|
for (const [index, spendLimit] of (
|
||||||
billingControls.spend_limits ?? []
|
billingControls.spend_limits ?? []
|
||||||
@@ -23,7 +28,7 @@ export const ApiEntityBillingControlsParamsSchema =
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (featureIds.has(spendLimit.feature_id)) {
|
if (spendLimitFeatureIds.has(spendLimit.feature_id)) {
|
||||||
ctx.issues.push({
|
ctx.issues.push({
|
||||||
code: "custom",
|
code: "custom",
|
||||||
message: "Only one spend limit entry is allowed per feature_id",
|
message: "Only one spend limit entry is allowed per feature_id",
|
||||||
@@ -33,7 +38,25 @@ export const ApiEntityBillingControlsParamsSchema =
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
featureIds.add(spendLimit.feature_id);
|
spendLimitFeatureIds.add(spendLimit.feature_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const overageAllowedFeatureIds = new Set<string>();
|
||||||
|
|
||||||
|
for (const [index, overageAllowed] of (
|
||||||
|
billingControls.overage_allowed ?? []
|
||||||
|
).entries()) {
|
||||||
|
if (overageAllowedFeatureIds.has(overageAllowed.feature_id)) {
|
||||||
|
ctx.issues.push({
|
||||||
|
code: "custom",
|
||||||
|
message: "Only one overage_allowed entry is allowed per feature_id",
|
||||||
|
input: overageAllowed.feature_id,
|
||||||
|
path: ["overage_allowed", index, "feature_id"],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
overageAllowedFeatureIds.add(overageAllowed.feature_id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from "./entityBillingControls.js";
|
export * from "./entityBillingControls.js";
|
||||||
|
export * from "./overageAllowed.js";
|
||||||
export * from "./spendLimit.js";
|
export * from "./spendLimit.js";
|
||||||
export * from "./usageAlert.js";
|
export * from "./usageAlert.js";
|
||||||
|
|||||||
6
shared/api/billingControls/overageAllowed.ts
Normal file
6
shared/api/billingControls/overageAllowed.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import type { z } from "zod/v4";
|
||||||
|
import { DbOverageAllowedSchema } from "../../models/cusModels/billingControls/overageAllowed.js";
|
||||||
|
|
||||||
|
export const ApiOverageAllowedSchema = DbOverageAllowedSchema;
|
||||||
|
|
||||||
|
export type ApiOverageAllowed = z.infer<typeof ApiOverageAllowedSchema>;
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ApiSubjectV0 } from "@api/customers/apiSubjectV0";
|
import type { ApiSubjectV0 } from "@api/customers/apiSubjectV0";
|
||||||
import type { ApiBalanceV1 } from "@api/customers/cusFeatures/apiBalanceV1";
|
import type { ApiBalanceV1 } from "@api/customers/cusFeatures/apiBalanceV1";
|
||||||
import { apiBalanceV1ToAvailableOverage } from "@api/customers/cusFeatures/utils/convert/apiBalanceV1ToAvailableOverage";
|
import { apiBalanceV1ToAvailableOverage } from "@api/customers/cusFeatures/utils/convert/apiBalanceV1ToAvailableOverage";
|
||||||
|
import { apiSubjectToOverageAllowedControl } from "@api/customers/utils/apiSubjectToOverageAllowed";
|
||||||
import type { Feature } from "@models/featureModels/featureModels";
|
import type { Feature } from "@models/featureModels/featureModels";
|
||||||
import { isBooleanFeature, notNullish } from "@utils/index";
|
import { isBooleanFeature, notNullish } from "@utils/index";
|
||||||
import { Decimal } from "decimal.js";
|
import { Decimal } from "decimal.js";
|
||||||
@@ -31,13 +32,23 @@ export const apiBalanceToAllowed = ({
|
|||||||
|
|
||||||
if (requiredBalance < 0) return { allowed: true };
|
if (requiredBalance < 0) return { allowed: true };
|
||||||
|
|
||||||
if (apiBalance.overage_allowed) {
|
const overageAllowedControl = apiSubjectToOverageAllowedControl({
|
||||||
|
subject: apiSubject,
|
||||||
|
feature,
|
||||||
|
});
|
||||||
|
|
||||||
|
// console.log("Overage allowed control", overageAllowedControl);
|
||||||
|
|
||||||
|
if (apiBalance.overage_allowed || overageAllowedControl?.enabled) {
|
||||||
const { availableOverage, reason } = apiBalanceV1ToAvailableOverage({
|
const { availableOverage, reason } = apiBalanceV1ToAvailableOverage({
|
||||||
apiBalance,
|
apiBalance,
|
||||||
apiSubject,
|
apiSubject,
|
||||||
feature,
|
feature,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// console.log("Available overage", availableOverage);
|
||||||
|
// console.log("Reason", reason);
|
||||||
|
|
||||||
if (notNullish(availableOverage)) {
|
if (notNullish(availableOverage)) {
|
||||||
const allowed = new Decimal(availableOverage)
|
const allowed = new Decimal(availableOverage)
|
||||||
.add(apiBalance.remaining)
|
.add(apiBalance.remaining)
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export const apiBalanceBreakdownV1ToMaxOverage = ({
|
|||||||
return apiBalanceBreakdown.price?.max_purchase ?? undefined;
|
return apiBalanceBreakdown.price?.max_purchase ?? undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const apiBalanceV1ToMaxOverage = ({
|
export const apiBalanceV1ToMaxOverage = ({
|
||||||
|
|||||||
25
shared/api/customers/utils/apiSubjectToOverageAllowed.ts
Normal file
25
shared/api/customers/utils/apiSubjectToOverageAllowed.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type { ApiOverageAllowed } from "@api/billingControls";
|
||||||
|
import type { Feature } from "@models/featureModels/featureModels";
|
||||||
|
import type { ApiSubjectV0 } from "../apiSubjectV0";
|
||||||
|
|
||||||
|
export const apiSubjectToOverageAllowedControl = ({
|
||||||
|
subject,
|
||||||
|
feature,
|
||||||
|
}: {
|
||||||
|
subject: ApiSubjectV0;
|
||||||
|
feature: Feature;
|
||||||
|
}): ApiOverageAllowed | undefined => {
|
||||||
|
if (!("billing_controls" in subject) || !subject.billing_controls) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!("overage_allowed" in subject.billing_controls)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const overageAllowed = subject.billing_controls.overage_allowed ?? [];
|
||||||
|
|
||||||
|
return overageAllowed.find(
|
||||||
|
(entry) => entry.enabled && entry.feature_id === feature.id,
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -4,6 +4,10 @@ import {
|
|||||||
type EntityBillingControlsParams,
|
type EntityBillingControlsParams,
|
||||||
EntityBillingControlsSchema,
|
EntityBillingControlsSchema,
|
||||||
} from "./entityBillingControls.js";
|
} from "./entityBillingControls.js";
|
||||||
|
import {
|
||||||
|
type DbOverageAllowed,
|
||||||
|
DbOverageAllowedSchema,
|
||||||
|
} from "./overageAllowed.js";
|
||||||
import { PurchaseLimitIntervalEnum } from "./purchaseLimitInterval.js";
|
import { PurchaseLimitIntervalEnum } from "./purchaseLimitInterval.js";
|
||||||
import { type DbSpendLimit, DbSpendLimitSchema } from "./spendLimit.js";
|
import { type DbSpendLimit, DbSpendLimitSchema } from "./spendLimit.js";
|
||||||
import { type DbUsageAlert, DbUsageAlertSchema } from "./usageAlert.js";
|
import { type DbUsageAlert, DbUsageAlertSchema } from "./usageAlert.js";
|
||||||
@@ -49,12 +53,16 @@ export const CustomerBillingControlsSchema = z.object({
|
|||||||
usage_alerts: z.array(DbUsageAlertSchema).optional().meta({
|
usage_alerts: z.array(DbUsageAlertSchema).optional().meta({
|
||||||
description: "List of usage alert configurations per feature.",
|
description: "List of usage alert configurations per feature.",
|
||||||
}),
|
}),
|
||||||
|
overage_allowed: z.array(DbOverageAllowedSchema).optional().meta({
|
||||||
|
description:
|
||||||
|
"List of overage allowed controls per feature. When enabled, usage can exceed balance.",
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const CustomerBillingControlsParamsSchema =
|
export const CustomerBillingControlsParamsSchema =
|
||||||
CustomerBillingControlsSchema.check((ctx) => {
|
CustomerBillingControlsSchema.check((ctx) => {
|
||||||
const billingControls = ctx.value;
|
const billingControls = ctx.value;
|
||||||
const featureIds = new Set<string>();
|
const spendLimitFeatureIds = new Set<string>();
|
||||||
|
|
||||||
for (const [index, spendLimit] of (
|
for (const [index, spendLimit] of (
|
||||||
billingControls.spend_limits ?? []
|
billingControls.spend_limits ?? []
|
||||||
@@ -63,7 +71,7 @@ export const CustomerBillingControlsParamsSchema =
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (featureIds.has(spendLimit.feature_id)) {
|
if (spendLimitFeatureIds.has(spendLimit.feature_id)) {
|
||||||
ctx.issues.push({
|
ctx.issues.push({
|
||||||
code: "custom",
|
code: "custom",
|
||||||
message: "Only one spend limit entry is allowed per feature_id",
|
message: "Only one spend limit entry is allowed per feature_id",
|
||||||
@@ -73,7 +81,25 @@ export const CustomerBillingControlsParamsSchema =
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
featureIds.add(spendLimit.feature_id);
|
spendLimitFeatureIds.add(spendLimit.feature_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const overageAllowedFeatureIds = new Set<string>();
|
||||||
|
|
||||||
|
for (const [index, overageAllowed] of (
|
||||||
|
billingControls.overage_allowed ?? []
|
||||||
|
).entries()) {
|
||||||
|
if (overageAllowedFeatureIds.has(overageAllowed.feature_id)) {
|
||||||
|
ctx.issues.push({
|
||||||
|
code: "custom",
|
||||||
|
message: "Only one overage_allowed entry is allowed per feature_id",
|
||||||
|
input: overageAllowed.feature_id,
|
||||||
|
path: ["overage_allowed", index, "feature_id"],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
overageAllowedFeatureIds.add(overageAllowed.feature_id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -89,10 +115,16 @@ export type CustomerBillingControlsParams = z.input<
|
|||||||
typeof CustomerBillingControlsParamsSchema
|
typeof CustomerBillingControlsParamsSchema
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export { EntityBillingControlsSchema, DbSpendLimitSchema, DbUsageAlertSchema };
|
|
||||||
export type {
|
export type {
|
||||||
EntityBillingControls,
|
DbOverageAllowed,
|
||||||
EntityBillingControlsParams,
|
|
||||||
DbSpendLimit,
|
DbSpendLimit,
|
||||||
DbUsageAlert,
|
DbUsageAlert,
|
||||||
|
EntityBillingControls,
|
||||||
|
EntityBillingControlsParams,
|
||||||
|
};
|
||||||
|
export {
|
||||||
|
DbOverageAllowedSchema,
|
||||||
|
DbSpendLimitSchema,
|
||||||
|
DbUsageAlertSchema,
|
||||||
|
EntityBillingControlsSchema,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
import { DbOverageAllowedSchema } from "./overageAllowed.js";
|
||||||
import { DbSpendLimitSchema } from "./spendLimit.js";
|
import { DbSpendLimitSchema } from "./spendLimit.js";
|
||||||
import { DbUsageAlertSchema } from "./usageAlert.js";
|
import { DbUsageAlertSchema } from "./usageAlert.js";
|
||||||
|
|
||||||
@@ -9,6 +10,10 @@ export const EntityBillingControlsSchema = z.object({
|
|||||||
usage_alerts: z.array(DbUsageAlertSchema).optional().meta({
|
usage_alerts: z.array(DbUsageAlertSchema).optional().meta({
|
||||||
description: "List of usage alert configurations per feature.",
|
description: "List of usage alert configurations per feature.",
|
||||||
}),
|
}),
|
||||||
|
overage_allowed: z.array(DbOverageAllowedSchema).optional().meta({
|
||||||
|
description:
|
||||||
|
"List of overage allowed controls per feature. When enabled, usage can exceed balance.",
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type EntityBillingControls = z.infer<typeof EntityBillingControlsSchema>;
|
export type EntityBillingControls = z.infer<typeof EntityBillingControlsSchema>;
|
||||||
|
|||||||
12
shared/models/cusModels/billingControls/overageAllowed.ts
Normal file
12
shared/models/cusModels/billingControls/overageAllowed.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
export const DbOverageAllowedSchema = z.object({
|
||||||
|
feature_id: z.string().meta({
|
||||||
|
description: "The feature ID this overage allowed control applies to.",
|
||||||
|
}),
|
||||||
|
enabled: z.boolean().default(false).meta({
|
||||||
|
description: "Whether overage is allowed for this feature.",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type DbOverageAllowed = z.infer<typeof DbOverageAllowedSchema>;
|
||||||
@@ -1,31 +1,28 @@
|
|||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
export const UsageAlertThresholdType = z.enum([
|
export const UsageAlertThresholdType = z.enum(["usage", "usage_percentage"]);
|
||||||
"usage",
|
|
||||||
"usage_percentage",
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const DbUsageAlertSchema = z
|
export const DbUsageAlertSchema = z
|
||||||
.object({
|
.object({
|
||||||
feature_id: z.string().optional().meta({
|
feature_id: z.string().optional().meta({
|
||||||
description:
|
description:
|
||||||
"The feature ID this alert applies to. If omitted, the alert applies globally.",
|
"The feature ID this alert applies to. If omitted, the alert applies globally.",
|
||||||
}),
|
}),
|
||||||
enabled: z.boolean().default(true).meta({
|
enabled: z.boolean().default(true).meta({
|
||||||
description: "Whether this usage alert is enabled.",
|
description: "Whether this usage alert is enabled.",
|
||||||
}),
|
}),
|
||||||
threshold: z.number().min(0).meta({
|
threshold: z.number().min(0).meta({
|
||||||
description:
|
description:
|
||||||
"The threshold value that triggers the alert. For usage, this is an absolute count. For usage_percentage, this is a percentage (0-100).",
|
"The threshold value that triggers the alert. For usage, this is an absolute count. For usage_percentage, this is a percentage (0-100).",
|
||||||
}),
|
}),
|
||||||
threshold_type: UsageAlertThresholdType.meta({
|
threshold_type: UsageAlertThresholdType.meta({
|
||||||
description:
|
description:
|
||||||
"Whether the threshold is an absolute usage count or a percentage of the usage allowance.",
|
"Whether the threshold is an absolute usage count or a percentage of the usage allowance.",
|
||||||
}),
|
}),
|
||||||
name: z.string().optional().meta({
|
name: z.string().optional().meta({
|
||||||
description:
|
description:
|
||||||
"Optional user-defined label to distinguish multiple alerts on the same feature.",
|
"Optional user-defined label to distinguish multiple alerts on the same feature.",
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
.check((ctx) => {
|
.check((ctx) => {
|
||||||
const { threshold_type, threshold } = ctx.value;
|
const { threshold_type, threshold } = ctx.value;
|
||||||
@@ -35,8 +32,7 @@ export const DbUsageAlertSchema = z
|
|||||||
code: "custom",
|
code: "custom",
|
||||||
input: threshold,
|
input: threshold,
|
||||||
path: ["threshold"],
|
path: ["threshold"],
|
||||||
message:
|
message: "Threshold must be between 0 and 100 for usage_percentage",
|
||||||
"Threshold must be between 0 and 100 for usage_percentage",
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { AppEnv } from "../genModels/genEnums.js";
|
|||||||
import { ExternalProcessorsSchema } from "../genModels/processorSchemas.js";
|
import { ExternalProcessorsSchema } from "../genModels/processorSchemas.js";
|
||||||
import {
|
import {
|
||||||
AutoTopupSchema,
|
AutoTopupSchema,
|
||||||
|
DbOverageAllowedSchema,
|
||||||
DbSpendLimitSchema,
|
DbSpendLimitSchema,
|
||||||
DbUsageAlertSchema,
|
DbUsageAlertSchema,
|
||||||
} from "./billingControls/customerBillingControls.js";
|
} from "./billingControls/customerBillingControls.js";
|
||||||
@@ -25,6 +26,7 @@ export const CustomerSchema = z.object({
|
|||||||
auto_topups: z.array(AutoTopupSchema).nullish(),
|
auto_topups: z.array(AutoTopupSchema).nullish(),
|
||||||
spend_limits: z.array(DbSpendLimitSchema).nullish(),
|
spend_limits: z.array(DbSpendLimitSchema).nullish(),
|
||||||
usage_alerts: z.array(DbUsageAlertSchema).nullish(),
|
usage_alerts: z.array(DbUsageAlertSchema).nullish(),
|
||||||
|
overage_allowed: z.array(DbOverageAllowedSchema).nullish(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Customer = z.infer<typeof CustomerSchema>;
|
export type Customer = z.infer<typeof CustomerSchema>;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type { ExternalProcessors } from "../genModels/processorSchemas.js";
|
|||||||
import { organizations } from "../orgModels/orgTable.js";
|
import { organizations } from "../orgModels/orgTable.js";
|
||||||
import type {
|
import type {
|
||||||
AutoTopup,
|
AutoTopup,
|
||||||
|
DbOverageAllowed,
|
||||||
DbSpendLimit,
|
DbSpendLimit,
|
||||||
DbUsageAlert,
|
DbUsageAlert,
|
||||||
} from "./billingControls/customerBillingControls.js";
|
} from "./billingControls/customerBillingControls.js";
|
||||||
@@ -44,6 +45,7 @@ export const customers = pgTable(
|
|||||||
auto_topups: jsonb().$type<AutoTopup[]>(),
|
auto_topups: jsonb().$type<AutoTopup[]>(),
|
||||||
spend_limits: jsonb().$type<DbSpendLimit[]>(),
|
spend_limits: jsonb().$type<DbSpendLimit[]>(),
|
||||||
usage_alerts: jsonb().$type<DbUsageAlert[]>(),
|
usage_alerts: jsonb().$type<DbUsageAlert[]>(),
|
||||||
|
overage_allowed: jsonb().$type<DbOverageAllowed[]>(),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
unique("cus_id_constraint").on(table.org_id, table.id, table.env),
|
unique("cus_id_constraint").on(table.org_id, table.id, table.env),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
import type { Feature } from "../../featureModels/featureModels.js";
|
import type { Feature } from "../../featureModels/featureModels.js";
|
||||||
import {
|
import {
|
||||||
|
DbOverageAllowedSchema,
|
||||||
DbSpendLimitSchema,
|
DbSpendLimitSchema,
|
||||||
DbUsageAlertSchema,
|
DbUsageAlertSchema,
|
||||||
} from "../billingControls/customerBillingControls.js";
|
} from "../billingControls/customerBillingControls.js";
|
||||||
@@ -18,6 +19,7 @@ export const EntitySchema = z.object({
|
|||||||
internal_feature_id: z.string(),
|
internal_feature_id: z.string(),
|
||||||
spend_limits: z.array(DbSpendLimitSchema).nullish(),
|
spend_limits: z.array(DbSpendLimitSchema).nullish(),
|
||||||
usage_alerts: z.array(DbUsageAlertSchema).nullish(),
|
usage_alerts: z.array(DbUsageAlertSchema).nullish(),
|
||||||
|
overage_allowed: z.array(DbOverageAllowedSchema).nullish(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// export const CreateEntitySchema = z.object({
|
// export const CreateEntitySchema = z.object({
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { features } from "../../featureModels/featureTable.js";
|
import { features } from "../../featureModels/featureTable.js";
|
||||||
import { organizations } from "../../orgModels/orgTable.js";
|
import { organizations } from "../../orgModels/orgTable.js";
|
||||||
import type {
|
import type {
|
||||||
|
DbOverageAllowed,
|
||||||
DbSpendLimit,
|
DbSpendLimit,
|
||||||
DbUsageAlert,
|
DbUsageAlert,
|
||||||
} from "../billingControls/customerBillingControls.js";
|
} from "../billingControls/customerBillingControls.js";
|
||||||
@@ -31,6 +32,7 @@ export const entities = pgTable(
|
|||||||
internal_feature_id: text("internal_feature_id"),
|
internal_feature_id: text("internal_feature_id"),
|
||||||
spend_limits: jsonb().$type<DbSpendLimit[]>(),
|
spend_limits: jsonb().$type<DbSpendLimit[]>(),
|
||||||
usage_alerts: jsonb().$type<DbUsageAlert[]>(),
|
usage_alerts: jsonb().$type<DbUsageAlert[]>(),
|
||||||
|
overage_allowed: jsonb().$type<DbOverageAllowed[]>(),
|
||||||
|
|
||||||
// Optional...
|
// Optional...
|
||||||
feature_id: text("feature_id"),
|
feature_id: text("feature_id"),
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { DbOverageAllowed } from "@models/cusModels/billingControls/customerBillingControls.js";
|
||||||
|
import type { FullCustomer } from "@models/cusModels/fullCusModel.js";
|
||||||
|
|
||||||
|
/** Extract enabled overage_allowed entries for the requested features from a FullCustomer. */
|
||||||
|
export const fullCustomerToOverageAllowedByFeatureId = ({
|
||||||
|
fullCustomer,
|
||||||
|
featureIds,
|
||||||
|
internalEntityId,
|
||||||
|
}: {
|
||||||
|
fullCustomer: FullCustomer;
|
||||||
|
featureIds: string[];
|
||||||
|
internalEntityId?: string;
|
||||||
|
}): Record<string, DbOverageAllowed> => {
|
||||||
|
const entity = internalEntityId
|
||||||
|
? fullCustomer.entities?.find(
|
||||||
|
(candidate) => candidate.internal_id === internalEntityId,
|
||||||
|
)
|
||||||
|
: fullCustomer.entity;
|
||||||
|
const scopedOverageAllowed = internalEntityId
|
||||||
|
? entity?.overage_allowed
|
||||||
|
: (entity?.overage_allowed ?? fullCustomer.overage_allowed);
|
||||||
|
const overageAllowedByFeatureId: Record<string, DbOverageAllowed> = {};
|
||||||
|
const uniqueFeatureIds = [...new Set(featureIds)];
|
||||||
|
|
||||||
|
for (const featureId of uniqueFeatureIds) {
|
||||||
|
const overageAllowed = scopedOverageAllowed?.find(
|
||||||
|
(candidate) => candidate.feature_id === featureId && candidate.enabled,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (overageAllowed) {
|
||||||
|
overageAllowedByFeatureId[featureId] = overageAllowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return overageAllowedByFeatureId;
|
||||||
|
};
|
||||||
@@ -5,5 +5,6 @@ export * from "./cusPlanUtils/cusPlanUtils";
|
|||||||
export * from "./fullCusUtils/enrichFullCustomer";
|
export * from "./fullCusUtils/enrichFullCustomer";
|
||||||
|
|
||||||
export * from "./fullCusUtils/fullCustomerToCustomerEntitlements";
|
export * from "./fullCusUtils/fullCustomerToCustomerEntitlements";
|
||||||
|
export * from "./fullCusUtils/fullCustomerToOverageAllowed";
|
||||||
export * from "./fullCusUtils/fullCustomerToSpendLimit";
|
export * from "./fullCusUtils/fullCustomerToSpendLimit";
|
||||||
export * from "./fullCusUtils/getCusStripeSubCount";
|
export * from "./fullCusUtils/getCusStripeSubCount";
|
||||||
|
|||||||
Reference in New Issue
Block a user