diff --git a/.agents/skills/openlogs-server-logs/SKILL.md b/.agents/skills/openlogs-server-logs/SKILL.md new file mode 100644 index 000000000..b551dc9c9 --- /dev/null +++ b/.agents/skills/openlogs-server-logs/SKILL.md @@ -0,0 +1,56 @@ +--- +name: openlogs-server-logs +description: Fetch and inspect recent local server logs in repos that use openlogs or the `ol` CLI. Use when a user asks what happened in the server, wants recent dev-server output, needs startup errors or stack traces, or asks you to check backend logs from `openlogs tail`, command-specific logs, or `.openlogs/latest.txt`. +--- + +# Openlogs Server Logs + +Use `openlogs tail` to retrieve recent server logs before asking the user to paste anything. Prefer the cleaned text log unless ANSI or raw terminal bytes matter. + +## Quick Start + +- Run `openlogs tail -n 200` to inspect the latest run in the project. +- If the user mentions a specific command or service, run `openlogs tail -n 200` to get the most recent matching run. +- Use `ol tail -n 200` if the short alias is preferred. +- Read `.openlogs/latest.txt` directly only when file access is simpler than spawning the command and you specifically want the latest overall run. +- Use `openlogs tail --raw -n 200` only when color codes, cursor control, or exact terminal output matters. +- Use `openlogs tail -f` for live follow mode. + +## Workflow + +1. Try `openlogs tail -n 200`. +2. If the user names a command or service, try `openlogs tail -n 200`. +3. If that fails, try `ol tail -n 200`. +4. If the CLI is unavailable but the workspace is accessible, read `.openlogs/latest.txt` or the matching command-specific file in `.openlogs/`. +5. If the log directory is missing, check whether the server was started with `openlogs ` or `ol `. +6. If it was not, tell the user to relaunch the server through openlogs, then inspect the resulting logs. + +## Common Commands + +```bash +openlogs tail -n 100 +openlogs tail dev -n 100 +openlogs tail server -f +openlogs tail -f +openlogs tail --raw -n 100 +openlogs tail --out-dir logs -n 200 +openlogs bun dev +ol npm run dev +``` + +## Interpretation Rules + +- Prefer the text log for analysis because it strips ANSI noise. +- `openlogs tail` without a query means the latest run overall in the current project. +- `openlogs tail ` means the latest run whose command or explicit name contains that query. +- Switch to `--raw` only when the cleaned log hides something important. +- Quote the exact failing lines or error block in your answer when useful. +- State whether you are looking at the latest captured run or a live-following stream. +- If the agent cannot access local gitignored files, ask the user to run `openlogs tail -n 200` and paste the output. + +## Response Shape + +- Start with the command or file you used. +- Summarize the likely issue in 1 to 3 sentences. +- Include the most relevant error lines. +- If logs are missing, say exactly what command the user should rerun under openlogs. diff --git a/.agents/skills/openlogs-server-logs/agents/openai.yaml b/.agents/skills/openlogs-server-logs/agents/openai.yaml new file mode 100644 index 000000000..4369c3e39 --- /dev/null +++ b/.agents/skills/openlogs-server-logs/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Openlogs Server Logs" + short_description: "Fetch and inspect recent server logs" + default_prompt: "Use $openlogs-server-logs to inspect the latest local server logs with openlogs tail, or query a specific command with openlogs tail ." diff --git a/.gitignore b/.gitignore index 15b631b83..a08658dba 100644 --- a/.gitignore +++ b/.gitignore @@ -126,3 +126,6 @@ others/python-sdk/docs packages/sdk/docs TAKEHOME.md + + +.openlogs diff --git a/server/src/_luaScriptsV2/deductFromCustomerEntitlements/deductFromCustomerEntitlements.lua b/server/src/_luaScriptsV2/deductFromCustomerEntitlements/deductFromCustomerEntitlements.lua index 2c49fbf44..a78b64b91 100644 --- a/server/src/_luaScriptsV2/deductFromCustomerEntitlements/deductFromCustomerEntitlements.lua +++ b/server/src/_luaScriptsV2/deductFromCustomerEntitlements/deductFromCustomerEntitlements.lua @@ -21,7 +21,8 @@ ARGV[1] = JSON params: { - sorted_entitlements: [{ customer_entitlement_id, credit_cost, entity_feature_id, usage_allowed, min_balance, max_balance }], + sorted_entitlements: [{ customer_entitlement_id, credit_cost, feature_id, entity_feature_id, usage_allowed, min_balance, max_balance }], + available_overage_by_feature_id: { [feature_id]: number } | null, amount_to_deduct: number | null, target_balance: number | null, target_entity_id: string | nil, @@ -51,6 +52,7 @@ local params = cjson.decode(ARGV[1]) -- Extract parameters local sorted_entitlements = params.sorted_entitlements or {} +local available_overage_by_feature_id = params.available_overage_by_feature_id local amount_to_deduct = params.amount_to_deduct local target_balance = params.target_balance local target_entity_id = params.target_entity_id @@ -147,6 +149,7 @@ logger.log(" overage_behaviour: %s", tostring(overage_behaviour or "nil")) local deduction_result = run_deduction_on_context({ context = context, sorted_entitlements = sorted_entitlements, + available_overage_by_feature_id = available_overage_by_feature_id, rollovers = rollovers, amount_to_deduct = amount_to_deduct, target_balance = target_balance, diff --git a/server/src/_luaScriptsV2/deductFromCustomerEntitlements/deductFromMainBalance.lua b/server/src/_luaScriptsV2/deductFromCustomerEntitlements/deductFromMainBalance.lua index 40dc7f039..7a2cc0b62 100644 --- a/server/src/_luaScriptsV2/deductFromCustomerEntitlements/deductFromMainBalance.lua +++ b/server/src/_luaScriptsV2/deductFromCustomerEntitlements/deductFromMainBalance.lua @@ -52,6 +52,8 @@ local function calculate_change(balance, amount, params) -- Pass 2: Floor at min_balance (can go below 0) if overage_behavior_is_allow then return amount -- No floor constraint + elseif not is_nil(params.available_overage) then + return math.max(0, math.min(amount, params.available_overage)) elseif params.min_balance then local to_deduct = math.min(amount, balance - params.min_balance) return math.max(0, to_deduct) @@ -114,6 +116,7 @@ local function deduct_from_main_balance(params) -- Base calc_params (adjustment is set per-case since entities have their own) local base_calc_params = { + available_overage = params.available_overage, max_balance = params.max_balance, min_balance = params.min_balance, pass_number = params.pass_number, @@ -131,6 +134,7 @@ local function deduct_from_main_balance(params) -- Use entity-specific adjustment local calc_params = { + available_overage = base_calc_params.available_overage, max_balance = base_calc_params.max_balance, min_balance = base_calc_params.min_balance, pass_number = base_calc_params.pass_number, @@ -172,6 +176,7 @@ local function deduct_from_main_balance(params) -- ======================================================================== local entities = ent_data.entities or {} local keys = sorted_keys(entities) + local remaining_available_overage = params.available_overage local remaining = amount for _, entity_key in ipairs(keys) do @@ -183,6 +188,7 @@ local function deduct_from_main_balance(params) -- Use entity-specific adjustment local calc_params = { + available_overage = remaining_available_overage, max_balance = base_calc_params.max_balance, min_balance = base_calc_params.min_balance, pass_number = base_calc_params.pass_number, @@ -215,6 +221,9 @@ local function deduct_from_main_balance(params) deducted = deducted + to_change remaining = remaining - to_change + if not is_nil(remaining_available_overage) and to_change > 0 then + remaining_available_overage = math.max(0, remaining_available_overage - to_change) + end end end @@ -229,6 +238,7 @@ local function deduct_from_main_balance(params) -- Use customer_entitlement-level adjustment for top-level balance local calc_params = { + available_overage = base_calc_params.available_overage, max_balance = base_calc_params.max_balance, min_balance = base_calc_params.min_balance, pass_number = base_calc_params.pass_number, diff --git a/server/src/_luaScriptsV2/deductFromCustomerEntitlements/runDeductionOnContext.lua b/server/src/_luaScriptsV2/deductFromCustomerEntitlements/runDeductionOnContext.lua index e81303882..0238a6fcc 100644 --- a/server/src/_luaScriptsV2/deductFromCustomerEntitlements/runDeductionOnContext.lua +++ b/server/src/_luaScriptsV2/deductFromCustomerEntitlements/runDeductionOnContext.lua @@ -28,6 +28,7 @@ local function process_deduction_pass(params) local context = params.context local sorted_entitlements = params.sorted_entitlements or {} local target_entity_id = params.target_entity_id + local available_overage_by_feature_id = params.available_overage_by_feature_id local alter_granted_balance = params.alter_granted_balance or false local overage_behavior_is_allow = params.overage_behavior_is_allow or false local pass_number = params.pass_number @@ -46,10 +47,21 @@ local function process_deduction_pass(params) local ent_id = ent_obj.customer_entitlement_id local credit_cost = ent_obj.credit_cost + local ent_feature_id = ent_obj.feature_id if credit_cost == cjson.null or credit_cost == nil or credit_cost == 0 then credit_cost = 1 end + local available_overage = nil + if pass_number == 2 + and remaining_amount > 0 + and not overage_behavior_is_allow + and not is_nil(available_overage_by_feature_id) + and not is_nil(ent_feature_id) + then + available_overage = available_overage_by_feature_id[ent_feature_id] + end + local usage_allowed = ent_obj.usage_allowed if usage_allowed == cjson.null then usage_allowed = false @@ -71,6 +83,7 @@ local function process_deduction_pass(params) amount = remaining_amount, credit_cost = credit_cost, pass_number = pass_number, + available_overage = available_overage, min_balance = ent_obj.min_balance, max_balance = ent_obj.max_balance, alter_granted_balance = alter_granted_balance, @@ -80,6 +93,17 @@ local function process_deduction_pass(params) remaining_amount = remaining_amount - (deducted / credit_cost) + if deducted > 0 + and not is_nil(available_overage) + and not is_nil(available_overage_by_feature_id) + and not is_nil(ent_feature_id) + then + available_overage_by_feature_id[ent_feature_id] = round_to_precision( + math.max(0, available_overage - deducted), + 10 + ) + end + if deducted ~= 0 then if not updates[ent_id] then updates[ent_id] = { deducted = 0, additional_deducted = 0 } @@ -163,6 +187,7 @@ local function run_deduction_on_context(params) local sorted_entitlements = params.sorted_entitlements or {} local rollovers = params.rollovers local target_entity_id = params.target_entity_id + local available_overage_by_feature_id = params.available_overage_by_feature_id local alter_granted_balance = params.alter_granted_balance or false local overage_behaviour = params.overage_behaviour or 'cap' local overage_behavior_is_allow = alter_granted_balance or overage_behaviour == 'allow' @@ -197,6 +222,7 @@ local function run_deduction_on_context(params) context = context, sorted_entitlements = sorted_entitlements, target_entity_id = target_entity_id, + available_overage_by_feature_id = available_overage_by_feature_id, alter_granted_balance = alter_granted_balance, overage_behavior_is_allow = overage_behavior_is_allow, pass_number = 1, @@ -212,6 +238,7 @@ local function run_deduction_on_context(params) context = context, sorted_entitlements = sorted_entitlements, target_entity_id = target_entity_id, + available_overage_by_feature_id = available_overage_by_feature_id, alter_granted_balance = alter_granted_balance, overage_behavior_is_allow = overage_behavior_is_allow, pass_number = 2, diff --git a/server/src/internal/balances/utils/deduction/executePostgresDeduction.ts b/server/src/internal/balances/utils/deduction/executePostgresDeduction.ts index cc52db437..84e2c5a02 100644 --- a/server/src/internal/balances/utils/deduction/executePostgresDeduction.ts +++ b/server/src/internal/balances/utils/deduction/executePostgresDeduction.ts @@ -104,6 +104,7 @@ export const executePostgresDeduction = async ({ const { customerEntitlementDeductions, + availableOverageByFeatureId, rollovers, customerEntitlements, unlimitedFeatureIds, @@ -123,6 +124,7 @@ export const executePostgresDeduction = async ({ sql`SELECT * FROM deduct_from_cus_ents( ${JSON.stringify({ sorted_entitlements: customerEntitlementDeductions, + available_overage_by_feature_id: availableOverageByFeatureId ?? null, amount_to_deduct: toDeduct ?? null, target_balance: targetBalance ?? null, lock_receipt: lockReceipt ?? null, diff --git a/server/src/internal/balances/utils/deduction/executeRedisDeduction.ts b/server/src/internal/balances/utils/deduction/executeRedisDeduction.ts index 986656e26..0c0bc1b60 100644 --- a/server/src/internal/balances/utils/deduction/executeRedisDeduction.ts +++ b/server/src/internal/balances/utils/deduction/executeRedisDeduction.ts @@ -101,6 +101,7 @@ export const executeRedisDeduction = async ({ const { customerEntitlementDeductions, + availableOverageByFeatureId, rollovers, customerEntitlements, unlimitedFeatureIds, @@ -119,6 +120,7 @@ export const executeRedisDeduction = async ({ // Call Lua script to deduct from FullCustomer in Redis const luaParams = { sorted_entitlements: customerEntitlementDeductions, + available_overage_by_feature_id: availableOverageByFeatureId ?? null, amount_to_deduct: toDeduct ?? null, target_balance: targetBalance ?? null, target_entity_id: entityId || null, diff --git a/server/src/internal/balances/utils/deduction/prepareFeatureDeduction.ts b/server/src/internal/balances/utils/deduction/prepareFeatureDeduction.ts index 7c34938f3..8daf67b05 100644 --- a/server/src/internal/balances/utils/deduction/prepareFeatureDeduction.ts +++ b/server/src/internal/balances/utils/deduction/prepareFeatureDeduction.ts @@ -1,6 +1,7 @@ import { cusEntToStartingBalance, type FullCustomer, + fullCustomerToAvailableOverage, fullCustomerToCustomerEntitlements, getMaxOverage, getRelevantFeatures, @@ -73,6 +74,13 @@ export const prepareFeatureDeduction = ({ } } + const effectiveFeatureIds = relevantFeatures.map((f) => f.id); + const availableOverageByFeatureId = fullCustomerToAvailableOverage({ + ctx, + fullCustomer, + featureIds: effectiveFeatureIds, + }); + // Build input for each customer entitlement const customerEntitlementDeductions: CustomerEntitlementDeduction[] = cusEnts.map((ce) => { @@ -94,6 +102,7 @@ export const prepareFeatureDeduction = ({ return { customer_entitlement_id: ce.id, credit_cost: creditCost, + feature_id: ce.entitlement.feature.id, entity_feature_id: ce.entitlement.entity_feature_id ?? null, usage_allowed: ce.usage_allowed || isFreeAllocatedUsageAllowed, min_balance: notNullish(maxOverage) ? -maxOverage : undefined, @@ -143,6 +152,10 @@ export const prepareFeatureDeduction = ({ return { customerEntitlements: cusEnts, customerEntitlementDeductions, + availableOverageByFeatureId: + Object.keys(availableOverageByFeatureId).length > 0 + ? availableOverageByFeatureId + : undefined, rollovers: sortedRollovers.map((r) => ({ id: r.id, credit_cost: r.credit_cost, diff --git a/server/src/internal/balances/utils/sql/deductFromMainBalance.sql b/server/src/internal/balances/utils/sql/deductFromMainBalance.sql index 4a571d08e..19699c562 100644 --- a/server/src/internal/balances/utils/sql/deductFromMainBalance.sql +++ b/server/src/internal/balances/utils/sql/deductFromMainBalance.sql @@ -23,6 +23,10 @@ DECLARE allow_negative boolean := COALESCE((params->>'allow_negative')::boolean, false); has_entity_scope boolean := COALESCE((params->>'has_entity_scope')::boolean, false); target_entity_id text := NULLIF(params->>'target_entity_id', ''); + available_overage numeric := CASE + WHEN params->>'available_overage' IS NULL THEN NULL + ELSE (params->>'available_overage')::numeric + END; min_balance numeric := CASE WHEN params->>'min_balance' IS NULL THEN NULL ELSE (params->>'min_balance')::numeric @@ -50,6 +54,7 @@ DECLARE entity_adjustment numeric; ceiling numeric; max_addable numeric; + remaining_available_overage numeric; mutation_logs_json jsonb := '[]'::jsonb; BEGIN @@ -61,6 +66,7 @@ BEGIN -- ============================================================================ IF has_entity_scope AND target_entity_id IS NULL THEN remaining := amount_to_deduct * credit_cost; + remaining_available_overage := available_overage; result_entities := current_entities; deducted_amount := 0; @@ -90,13 +96,15 @@ BEGIN deduct_amount := remaining; END IF; ELSIF allow_negative THEN - IF min_balance IS NULL THEN + IF available_overage IS NOT NULL THEN + deduct_amount := LEAST(remaining, remaining_available_overage); + ELSIF min_balance IS NULL THEN deduct_amount := remaining; ELSE deduct_amount := LEAST(remaining, entity_balance - min_balance); END IF; ELSE - deduct_amount := LEAST(entity_balance, remaining); + deduct_amount := LEAST(GREATEST(entity_balance, 0), remaining); END IF; IF deduct_amount != 0 THEN @@ -132,6 +140,13 @@ BEGIN remaining := remaining - deduct_amount; deducted_amount := deducted_amount + deduct_amount; + + IF remaining_available_overage IS NOT NULL AND deduct_amount > 0 THEN + remaining_available_overage := GREATEST( + 0, + remaining_available_overage - deduct_amount + ); + END IF; END IF; END LOOP; @@ -162,13 +177,18 @@ BEGIN deducted_amount := amount_to_deduct * credit_cost; END IF; ELSIF allow_negative THEN - IF min_balance IS NULL THEN + IF available_overage IS NOT NULL THEN + deducted_amount := LEAST(amount_to_deduct * credit_cost, available_overage); + ELSIF min_balance IS NULL THEN deducted_amount := amount_to_deduct * credit_cost; ELSE deducted_amount := LEAST(amount_to_deduct * credit_cost, entity_balance - min_balance); END IF; ELSE - deducted_amount := LEAST(entity_balance, amount_to_deduct * credit_cost); + deducted_amount := LEAST( + GREATEST(entity_balance, 0), + amount_to_deduct * credit_cost + ); END IF; IF deducted_amount != 0 THEN @@ -229,14 +249,19 @@ BEGIN END IF; ELSIF allow_negative THEN -- Pass 2: Can go negative (respecting min_balance) - IF min_balance IS NULL THEN + IF available_overage IS NOT NULL THEN + deducted_amount := LEAST(amount_to_deduct * credit_cost, available_overage); + ELSIF min_balance IS NULL THEN deducted_amount := amount_to_deduct * credit_cost; ELSE deducted_amount := LEAST(amount_to_deduct * credit_cost, current_balance - min_balance); END IF; ELSE -- Pass 1: Only deduct down to zero - deducted_amount := LEAST(current_balance, amount_to_deduct * credit_cost); + deducted_amount := LEAST( + GREATEST(current_balance, 0), + amount_to_deduct * credit_cost + ); END IF; result_balance := current_balance - deducted_amount; diff --git a/server/src/internal/balances/utils/sql/performDeduction.sql b/server/src/internal/balances/utils/sql/performDeduction.sql index 3888a1dd1..53049e436 100644 --- a/server/src/internal/balances/utils/sql/performDeduction.sql +++ b/server/src/internal/balances/utils/sql/performDeduction.sql @@ -12,6 +12,7 @@ AS $$ DECLARE -- Extract parameters from JSONB sorted_entitlements jsonb := params->'sorted_entitlements'; + available_overage_by_feature_id jsonb := params->'available_overage_by_feature_id'; amount_to_deduct numeric := NULLIF((params->>'amount_to_deduct')::numeric, NULL); target_balance numeric := NULLIF((params->>'target_balance')::numeric, NULL); target_entity_id text := NULLIF(params->>'target_entity_id', ''); @@ -43,6 +44,8 @@ DECLARE ent_id text; credit_cost numeric; usage_allowed boolean; + ent_feature_id text; + available_overage numeric; min_balance numeric; max_balance numeric; has_entity_scope boolean; @@ -175,6 +178,7 @@ BEGIN ent_id := ent_obj->>'customer_entitlement_id'; credit_cost := (ent_obj->>'credit_cost')::numeric; usage_allowed := COALESCE((ent_obj->>'usage_allowed')::boolean, false); + ent_feature_id := NULLIF(ent_obj->>'feature_id', ''); min_balance := (ent_obj->>'min_balance')::numeric; max_balance := (ent_obj->>'max_balance')::numeric; has_entity_scope := (ent_obj->>'entity_feature_id') IS NOT NULL; @@ -234,14 +238,15 @@ BEGIN 'current_balance', current_balance, 'current_entities', current_entities, 'current_adjustment', new_adjustment, - 'amount_to_deduct', remaining_amount, - 'credit_cost', credit_cost, - 'allow_negative', false, - 'has_entity_scope', has_entity_scope, - 'target_entity_id', target_entity_id, - 'min_balance', min_balance, - 'max_balance', max_balance, - 'alter_granted_balance', alter_granted_balance, + 'amount_to_deduct', remaining_amount, + 'credit_cost', credit_cost, + 'allow_negative', false, + 'has_entity_scope', has_entity_scope, + 'target_entity_id', target_entity_id, + 'available_overage', NULL, + 'min_balance', min_balance, + 'max_balance', max_balance, + 'alter_granted_balance', alter_granted_balance, 'overage_behavior_is_allow', overage_behavior_is_allow )); @@ -296,9 +301,18 @@ BEGIN ent_id := ent_obj->>'customer_entitlement_id'; credit_cost := (ent_obj->>'credit_cost')::numeric; usage_allowed := COALESCE((ent_obj->>'usage_allowed')::boolean, false) OR overage_behavior_is_allow; + ent_feature_id := NULLIF(ent_obj->>'feature_id', ''); min_balance := (ent_obj->>'min_balance')::numeric; max_balance := (ent_obj->>'max_balance')::numeric; has_entity_scope := (ent_obj->>'entity_feature_id') IS NOT NULL; + + available_overage := CASE + WHEN available_overage_by_feature_id IS NULL + OR ent_feature_id IS NULL + OR NOT (available_overage_by_feature_id ? ent_feature_id) + THEN NULL + ELSE (available_overage_by_feature_id->>ent_feature_id)::numeric + END; -- Skip entitlements without usage_allowed IF NOT usage_allowed THEN @@ -334,6 +348,7 @@ BEGIN 'allow_negative', true, 'has_entity_scope', has_entity_scope, 'target_entity_id', target_entity_id, + 'available_overage', available_overage, 'min_balance', min_balance, 'max_balance', max_balance, 'alter_granted_balance', alter_granted_balance, @@ -394,6 +409,14 @@ BEGIN END IF; remaining_amount := remaining_amount - (deducted / credit_cost); + + IF deducted > 0 AND available_overage IS NOT NULL THEN + available_overage_by_feature_id := jsonb_set( + COALESCE(available_overage_by_feature_id, '{}'::jsonb), + ARRAY[ent_feature_id], + to_jsonb(GREATEST(0, available_overage - deducted)) + ); + END IF; END IF; END LOOP; END IF; diff --git a/server/src/internal/balances/utils/types/deductionTypes.ts b/server/src/internal/balances/utils/types/deductionTypes.ts index d3313a2da..9b696d52b 100644 --- a/server/src/internal/balances/utils/types/deductionTypes.ts +++ b/server/src/internal/balances/utils/types/deductionTypes.ts @@ -22,6 +22,7 @@ export type DeductionOptions = { export type CustomerEntitlementDeduction = { customer_entitlement_id: string; credit_cost: number; + feature_id: string; entity_feature_id: string | null; usage_allowed: boolean; min_balance: number | undefined; @@ -38,6 +39,7 @@ export type RolloverDeduction = { export type PreparedFeatureDeduction = { customerEntitlements: FullCusEntWithFullCusProduct[]; customerEntitlementDeductions: CustomerEntitlementDeduction[]; + availableOverageByFeatureId?: Record; // rolloverIds: string[]; rollovers: RolloverDeduction[]; unlimitedFeatureIds: string[]; diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/cusProductToBalances.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/cusProductToBalances.ts index aa8263916..41bdad10b 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/cusProductToBalances.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/cusProductToBalances.ts @@ -1,3 +1,4 @@ +import { getApiBalance } from "@api/customers/cusFeatures"; import type { ApiBalanceV1, FullCusEntWithFullCusProduct, @@ -5,7 +6,6 @@ import type { FullCustomer, } from "@autumn/shared"; import type { RequestContext } from "@/honoUtils/HonoEnv.js"; -import { getApiBalance } from "./getApiBalance.js"; /** * Extract balances from a FullCusProduct's customer_entitlements. diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerBase.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerBase.ts index f03213024..557df070b 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerBase.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerBase.ts @@ -1,3 +1,4 @@ +import { getApiBalances } from "@api/customers/cusFeatures"; import { type ApiCustomerV5, ApiCustomerV5Schema, @@ -8,7 +9,6 @@ import { import { z } from "zod/v4"; import type { RequestContext } from "@/honoUtils/HonoEnv.js"; import { invoicesToResponse } from "../../../invoices/invoiceUtils.js"; -import { getApiBalances } from "./getApiBalance/getApiBalances.js"; import { getApiSubscriptions } from "./getApiSubscription/getApiSubscriptions.js"; /** diff --git a/server/src/internal/entities/entityUtils/apiEntityUtils/getApiEntityBase.ts b/server/src/internal/entities/entityUtils/apiEntityUtils/getApiEntityBase.ts index c3a1335b2..b24a42a5b 100644 --- a/server/src/internal/entities/entityUtils/apiEntityUtils/getApiEntityBase.ts +++ b/server/src/internal/entities/entityUtils/apiEntityUtils/getApiEntityBase.ts @@ -1,3 +1,4 @@ +import { getApiBalances } from "@api/customers/cusFeatures"; import { type ApiEntityV2, ApiEntityV2Schema, @@ -8,7 +9,6 @@ import { } from "@autumn/shared"; import { z } from "zod/v4"; import type { RequestContext } from "@/honoUtils/HonoEnv.js"; -import { getApiBalances } from "@/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalances.js"; import { getApiSubscriptions } from "../../../customers/cusUtils/apiCusUtils/getApiSubscription/getApiSubscriptions.js"; /** diff --git a/server/tests/integration/balances/check/spend-limit/check-entity-product-spend-limit.test.ts b/server/tests/integration/balances/check/spend-limit/check-entity-product-spend-limit.test.ts index d599fe3a3..cfae6e326 100644 --- a/server/tests/integration/balances/check/spend-limit/check-entity-product-spend-limit.test.ts +++ b/server/tests/integration/balances/check/spend-limit/check-entity-product-spend-limit.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import type { CheckResponseV3, EntityBillingControls } from "@autumn/shared"; +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"; @@ -7,148 +7,14 @@ import { timeout } from "@tests/utils/genUtils.js"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; import chalk from "chalk"; import { getCreditCost } from "@/internal/features/creditSystemUtils"; - -type AutumnV2_1Client = Awaited>["autumnV2_1"]; - -const normalizeCheckResponse = (response: CheckResponseV3) => ({ - allowed: response.allowed, - customer_id: response.customer_id, - entity_id: response.entity_id ?? null, - required_balance: response.required_balance ?? null, - balance: response.balance - ? { - feature_id: response.balance.feature_id, - granted: response.balance.granted, - remaining: response.balance.remaining, - usage: response.balance.usage, - unlimited: response.balance.unlimited, - overage_allowed: response.balance.overage_allowed, - max_purchase: response.balance.max_purchase, - breakdown: - response.balance.breakdown?.map((item) => ({ - plan_id: item.plan_id, - included_grant: item.included_grant, - prepaid_grant: item.prepaid_grant, - remaining: item.remaining, - usage: item.usage, - unlimited: item.unlimited, - billing_method: item.price?.billing_method ?? null, - max_purchase: item.price?.max_purchase ?? null, - reset_interval: item.reset?.interval ?? null, - })) ?? [], - } - : null, -}); - -const setEntitySpendLimit = async ({ - autumn, - customerId, - entityId, - featureId, - overageLimit, - enabled = true, -}: { - autumn: AutumnV2_1Client; - customerId: string; - entityId: string; - featureId: string; - overageLimit: number; - enabled?: boolean; -}) => { - const billingControls: EntityBillingControls = { - spend_limits: [ - { - feature_id: featureId, - enabled, - overage_limit: overageLimit, - }, - ], - }; - - await autumn.entities.update(customerId, entityId, { - billing_controls: billingControls, - }); -}; - -const getActionUnitsForCreditAmount = ({ - creditAmount, - creditCostPerActionUnit, -}: { - creditAmount: number; - creditCostPerActionUnit: number; -}) => creditAmount / creditCostPerActionUnit; - -const expectBoundaryAndParity = async ({ - autumn, - customerId, - entityId, - featureId, - allowedRequiredBalance, - blockedRequiredBalance, - expectedFeatureId = featureId, - expectedAllowedResponseRequiredBalance = allowedRequiredBalance, - expectedBlockedResponseRequiredBalance = blockedRequiredBalance, -}: { - autumn: AutumnV2_1Client; - customerId: string; - entityId: string; - featureId: string; - allowedRequiredBalance: number; - blockedRequiredBalance: number; - expectedFeatureId?: string; - expectedAllowedResponseRequiredBalance?: number; - expectedBlockedResponseRequiredBalance?: number; -}) => { - const allowedCached = await autumn.check({ - customer_id: customerId, - entity_id: entityId, - feature_id: featureId, - required_balance: allowedRequiredBalance, - }); - - const blockedCached = await autumn.check({ - customer_id: customerId, - entity_id: entityId, - feature_id: featureId, - required_balance: blockedRequiredBalance, - }); - - expect(allowedCached.allowed).toBe(true); - expect(blockedCached.allowed).toBe(false); - expect(allowedCached.balance?.feature_id).toBe(expectedFeatureId); - expect(blockedCached.balance?.feature_id).toBe(expectedFeatureId); - expect(allowedCached.required_balance).toBe( - expectedAllowedResponseRequiredBalance, - ); - expect(blockedCached.required_balance).toBe( - expectedBlockedResponseRequiredBalance, - ); - - await timeout(4000); - - const allowedUncached = await autumn.check({ - customer_id: customerId, - entity_id: entityId, - feature_id: featureId, - required_balance: allowedRequiredBalance, - skip_cache: true, - }); - - const blockedUncached = await autumn.check({ - customer_id: customerId, - entity_id: entityId, - feature_id: featureId, - required_balance: blockedRequiredBalance, - skip_cache: true, - }); - - expect(normalizeCheckResponse(allowedUncached)).toEqual( - normalizeCheckResponse(allowedCached), - ); - expect(normalizeCheckResponse(blockedUncached)).toEqual( - normalizeCheckResponse(blockedCached), - ); -}; +import { + expectBoundaryAndParity, + normalizeCheckResponse, +} from "../../utils/spend-limit-utils/checkSpendLimitUtils.js"; +import { + getActionUnitsForCreditAmount, + setEntitySpendLimit, +} from "../../utils/spend-limit-utils/entitySpendLimitUtils.js"; test.concurrent(`${chalk.yellowBright("check-entity-product-spend-limit1: lifetime + consumable entity product respects spend limit and cache parity")}`, async () => { const entityProduct = products.base({ @@ -172,7 +38,9 @@ test.concurrent(`${chalk.yellowBright("check-entity-product-spend-limit1: lifeti s.products({ list: [entityProduct] }), s.entities({ count: 1, featureId: TestFeature.Users }), ], - actions: [s.attach({ productId: entityProduct.id, entityIndex: 0 })], + actions: [ + s.billing.attach({ productId: entityProduct.id, entityIndex: 0 }), + ], }); await setEntitySpendLimit({ @@ -217,7 +85,7 @@ test.concurrent(`${chalk.yellowBright("check-entity-product-spend-limit2: prepai ], }); - const prepaidQuantity = 500; + const prepaidQuantity = 600; const { autumnV2_1, customerId, entities } = await initScenario({ customerId: "check-entity-product-spend-limit-2", setup: [ @@ -226,7 +94,7 @@ test.concurrent(`${chalk.yellowBright("check-entity-product-spend-limit2: prepai s.entities({ count: 1, featureId: TestFeature.Users }), ], actions: [ - s.attach({ + s.billing.attach({ productId: entityProduct.id, entityIndex: 0, options: [ @@ -281,7 +149,7 @@ test.concurrent(`${chalk.yellowBright("check-entity-product-spend-limit3: two en ], }); - const prepaidQuantity = 500; + const prepaidQuantity = 600; const { autumnV2_1, customerId, entities } = await initScenario({ customerId: "check-entity-product-spend-limit-3", setup: [ @@ -290,7 +158,7 @@ test.concurrent(`${chalk.yellowBright("check-entity-product-spend-limit3: two en s.entities({ count: 2, featureId: TestFeature.Users }), ], actions: [ - s.attach({ + s.billing.attach({ productId: entityProduct.id, entityIndex: 0, options: [ @@ -300,7 +168,7 @@ test.concurrent(`${chalk.yellowBright("check-entity-product-spend-limit3: two en }, ], }), - s.attach({ + s.billing.attach({ productId: entityProduct.id, entityIndex: 1, options: [ @@ -431,7 +299,9 @@ test.concurrent(`${chalk.yellowBright("check-entity-product-spend-limit4: alloca s.products({ list: [entityProduct] }), s.entities({ count: 1, featureId: TestFeature.Users }), ], - actions: [s.attach({ productId: entityProduct.id, entityIndex: 0 })], + actions: [ + s.billing.attach({ productId: entityProduct.id, entityIndex: 0 }), + ], }); await setEntitySpendLimit({ @@ -483,7 +353,9 @@ test.concurrent(`${chalk.yellowBright("check-entity-product-spend-limit5: credit s.products({ list: [entityProduct] }), s.entities({ count: 1, featureId: TestFeature.Users }), ], - actions: [s.attach({ productId: entityProduct.id, entityIndex: 0 })], + actions: [ + s.billing.attach({ productId: entityProduct.id, entityIndex: 0 }), + ], }); const creditsFeature = ctx.features.find( @@ -555,7 +427,9 @@ test.concurrent(`${chalk.yellowBright("check-entity-product-spend-limit6: disabl s.products({ list: [entityProduct] }), s.entities({ count: 1, featureId: TestFeature.Users }), ], - actions: [s.attach({ productId: entityProduct.id, entityIndex: 0 })], + actions: [ + s.billing.attach({ productId: entityProduct.id, entityIndex: 0 }), + ], }); await setEntitySpendLimit({ diff --git a/server/tests/integration/balances/check/spend-limit/check-per-entity-spend-limit.test.ts b/server/tests/integration/balances/check/spend-limit/check-per-entity-spend-limit.test.ts index 7ae2adaa4..83d0f53c8 100644 --- a/server/tests/integration/balances/check/spend-limit/check-per-entity-spend-limit.test.ts +++ b/server/tests/integration/balances/check/spend-limit/check-per-entity-spend-limit.test.ts @@ -1,155 +1,16 @@ -import { expect, test } from "bun:test"; -import type { CheckResponseV3, EntityBillingControls } from "@autumn/shared"; +import { test } from "bun:test"; 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 { getCreditCost } from "@/internal/features/creditSystemUtils"; import { constructArrearProratedItem } from "@/utils/scriptUtils/constructItem.js"; - -type AutumnV2_1Client = Awaited>["autumnV2_1"]; - -const normalizeCheckResponse = (response: CheckResponseV3) => ({ - allowed: response.allowed, - customer_id: response.customer_id, - entity_id: response.entity_id ?? null, - required_balance: response.required_balance ?? null, - balance: response.balance - ? { - feature_id: response.balance.feature_id, - granted: response.balance.granted, - remaining: response.balance.remaining, - usage: response.balance.usage, - unlimited: response.balance.unlimited, - overage_allowed: response.balance.overage_allowed, - max_purchase: response.balance.max_purchase, - breakdown: - response.balance.breakdown?.map((item) => ({ - plan_id: item.plan_id, - included_grant: item.included_grant, - prepaid_grant: item.prepaid_grant, - remaining: item.remaining, - usage: item.usage, - unlimited: item.unlimited, - billing_method: item.price?.billing_method ?? null, - max_purchase: item.price?.max_purchase ?? null, - reset_interval: item.reset?.interval ?? null, - })) ?? [], - } - : null, -}); - -const setEntitySpendLimit = async ({ - autumn, - customerId, - entityId, - featureId, - overageLimit, - enabled = true, -}: { - autumn: AutumnV2_1Client; - customerId: string; - entityId: string; - featureId: string; - overageLimit: number; - enabled?: boolean; -}) => { - const billingControls: EntityBillingControls = { - spend_limits: [ - { - feature_id: featureId, - enabled, - overage_limit: overageLimit, - }, - ], - }; - - await autumn.entities.update(customerId, entityId, { - billing_controls: billingControls, - }); -}; - -const getActionUnitsForCreditAmount = ({ - creditAmount, - creditCostPerActionUnit, -}: { - creditAmount: number; - creditCostPerActionUnit: number; -}) => creditAmount / creditCostPerActionUnit; - -const expectBoundaryAndParity = async ({ - autumn, - customerId, - entityId, - featureId, - allowedRequiredBalance, - blockedRequiredBalance, - expectedFeatureId = featureId, - expectedAllowedResponseRequiredBalance = allowedRequiredBalance, - expectedBlockedResponseRequiredBalance = blockedRequiredBalance, -}: { - autumn: AutumnV2_1Client; - customerId: string; - entityId: string; - featureId: string; - allowedRequiredBalance: number; - blockedRequiredBalance: number; - expectedFeatureId?: string; - expectedAllowedResponseRequiredBalance?: number; - expectedBlockedResponseRequiredBalance?: number; -}) => { - const allowedCached = await autumn.check({ - customer_id: customerId, - entity_id: entityId, - feature_id: featureId, - required_balance: allowedRequiredBalance, - }); - - const blockedCached = await autumn.check({ - customer_id: customerId, - entity_id: entityId, - feature_id: featureId, - required_balance: blockedRequiredBalance, - }); - - expect(allowedCached.allowed).toBe(true); - expect(blockedCached.allowed).toBe(false); - expect(allowedCached.balance?.feature_id).toBe(expectedFeatureId); - expect(blockedCached.balance?.feature_id).toBe(expectedFeatureId); - expect(allowedCached.required_balance).toBe( - expectedAllowedResponseRequiredBalance, - ); - expect(blockedCached.required_balance).toBe( - expectedBlockedResponseRequiredBalance, - ); - - await timeout(4000); - - const allowedUncached = await autumn.check({ - customer_id: customerId, - entity_id: entityId, - feature_id: featureId, - required_balance: allowedRequiredBalance, - skip_cache: true, - }); - - const blockedUncached = await autumn.check({ - customer_id: customerId, - entity_id: entityId, - feature_id: featureId, - required_balance: blockedRequiredBalance, - skip_cache: true, - }); - - expect(normalizeCheckResponse(allowedUncached)).toEqual( - normalizeCheckResponse(allowedCached), - ); - expect(normalizeCheckResponse(blockedUncached)).toEqual( - normalizeCheckResponse(blockedCached), - ); -}; +import { expectBoundaryAndParity } from "../../utils/spend-limit-utils/checkSpendLimitUtils.js"; +import { + getActionUnitsForCreditAmount, + setEntitySpendLimit, +} from "../../utils/spend-limit-utils/entitySpendLimitUtils.js"; test.concurrent(`${chalk.yellowBright("check-per-entity-spend-limit1: lifetime + consumable per-entity messages respect spend limit and cache parity")}`, async () => { const perEntityProduct = products.base({ @@ -175,7 +36,7 @@ test.concurrent(`${chalk.yellowBright("check-per-entity-spend-limit1: lifetime + s.products({ list: [perEntityProduct] }), s.entities({ count: 1, featureId: TestFeature.Users }), ], - actions: [s.attach({ productId: perEntityProduct.id })], + actions: [s.billing.attach({ productId: perEntityProduct.id })], }); await setEntitySpendLimit({ @@ -222,7 +83,7 @@ test.concurrent(`${chalk.yellowBright("check-per-entity-spend-limit2: prepaid + ], }); - const prepaidQuantity = 500; + const prepaidQuantity = 600; const { autumnV2_1, customerId, entities } = await initScenario({ customerId: "check-per-entity-spend-limit-2", setup: [ @@ -231,7 +92,7 @@ test.concurrent(`${chalk.yellowBright("check-per-entity-spend-limit2: prepaid + s.entities({ count: 1, featureId: TestFeature.Users }), ], actions: [ - s.attach({ + s.billing.attach({ productId: perEntityProduct.id, options: [ { @@ -290,7 +151,7 @@ test.concurrent(`${chalk.yellowBright("check-per-entity-spend-limit3: allocated s.products({ list: [perEntityProduct] }), s.entities({ count: 1, featureId: TestFeature.Users }), ], - actions: [s.attach({ productId: perEntityProduct.id })], + actions: [s.billing.attach({ productId: perEntityProduct.id })], }); await setEntitySpendLimit({ @@ -343,7 +204,7 @@ test.concurrent(`${chalk.yellowBright("check-per-entity-spend-limit4: credit-sys s.products({ list: [perEntityProduct] }), s.entities({ count: 1, featureId: TestFeature.Users }), ], - actions: [s.attach({ productId: perEntityProduct.id })], + actions: [s.billing.attach({ productId: perEntityProduct.id })], }); const creditsFeature = ctx.features.find( 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 new file mode 100644 index 000000000..9b846154c --- /dev/null +++ b/server/tests/integration/balances/track/spend-limit/track-entity-product-spend-limit.test.ts @@ -0,0 +1,549 @@ +import { 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 { + expectCustomerFeatureBalance, + expectEntityFeatureBalance, + expectSendEventBlocked, + getActionUnitsForCreditAmount, + setEntitySpendLimit, +} from "../../utils/spend-limit-utils/entitySpendLimitUtils.js"; + +test.concurrent(`${chalk.yellowBright("track-entity-product-spend-limit1: lifetime + consumable entity product caps overage and keeps entity/customer balances aligned")}`, async () => { + const entityProduct = products.base({ + id: "track-entity-product-lifetime-consumable", + items: [ + items.lifetimeMessages({ + includedUsage: 1000, + }), + items.consumableMessages({ + includedUsage: 100, + maxPurchase: 300, + price: 0.5, + }), + ], + }); + + const { autumnV2_1, customerId, entities } = await initScenario({ + customerId: "track-entity-product-spend-limit-1", + 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: 1120, + }); + await autumnV2_1.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: 10, + }); + + await expectEntityFeatureBalance({ + autumn: autumnV2_1, + customerId, + entityId: entities[0].id, + featureId: TestFeature.Messages, + granted: 1100, + remaining: 0, + usage: 1125, + maxPurchase: 300, + breakdownLength: 2, + }); + + await expectCustomerFeatureBalance({ + 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, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: 1, + overage_behavior: "reject", + }), + }); + await expectSendEventBlocked({ + autumn: autumnV2_1, + customerId, + entityId: entities[0].id, + requestFeatureId: TestFeature.Messages, + requiredBalance: 1, + entity: { + granted: 1100, + remaining: 0, + usage: 1125, + maxPurchase: 300, + breakdownLength: 2, + }, + }); +}); + +test.concurrent(`${chalk.yellowBright("track-entity-product-spend-limit2: prepaid + consumable entity product caps overage")}`, async () => { + const entityProduct = products.base({ + id: "track-entity-product-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, entities } = await initScenario({ + customerId: "track-entity-product-spend-limit-2", + 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, + options: [ + { + feature_id: TestFeature.Messages, + quantity: prepaidQuantity, + }, + ], + }), + ], + }); + + 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: 820, + }); + await autumnV2_1.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: 10, + }); + + await expectEntityFeatureBalance({ + autumn: autumnV2_1, + customerId, + entityId: entities[0].id, + featureId: TestFeature.Messages, + granted: 800, + remaining: 0, + usage: 825, + breakdownLength: 2, + }); + + await expectCustomerFeatureBalance({ + autumn: autumnV2_1, + customerId, + featureId: TestFeature.Messages, + granted: 800, + remaining: 0, + usage: 825, + breakdownLength: 2, + }); + await expectSendEventBlocked({ + autumn: autumnV2_1, + customerId, + entityId: entities[0].id, + requestFeatureId: TestFeature.Messages, + requiredBalance: 1, + entity: { + granted: 800, + remaining: 0, + usage: 825, + breakdownLength: 2, + }, + }); +}); + +test.concurrent(`${chalk.yellowBright("track-entity-product-spend-limit3: two entity products with different spend limits stay isolated and roll up to customer totals")}`, async () => { + const entityProduct = products.base({ + id: "track-entity-product-two-entities", + 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-entity-product-spend-limit-3", + 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 setEntitySpendLimit({ + autumn: autumnV2_1, + customerId, + entityId: entities[0].id, + featureId: TestFeature.Messages, + overageLimit: 25, + }); + await setEntitySpendLimit({ + autumn: autumnV2_1, + customerId, + entityId: entities[1].id, + featureId: TestFeature.Messages, + overageLimit: 40, + }); + + await autumnV2_1.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: 820, + }); + await autumnV2_1.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: 10, + }); + await autumnV2_1.track({ + customer_id: customerId, + entity_id: entities[1].id, + feature_id: TestFeature.Messages, + value: 820, + }); + await autumnV2_1.track({ + customer_id: customerId, + entity_id: entities[1].id, + feature_id: TestFeature.Messages, + value: 25, + }); + + await expectEntityFeatureBalance({ + autumn: autumnV2_1, + customerId, + entityId: entities[0].id, + featureId: TestFeature.Messages, + granted: 800, + remaining: 0, + usage: 825, + breakdownLength: 2, + }); + await expectEntityFeatureBalance({ + autumn: autumnV2_1, + customerId, + entityId: entities[1].id, + featureId: TestFeature.Messages, + granted: 800, + remaining: 0, + usage: 840, + breakdownLength: 2, + }); + + await expectCustomerFeatureBalance({ + autumn: autumnV2_1, + customerId, + featureId: TestFeature.Messages, + granted: 1600, + remaining: 0, + usage: 1665, + breakdownLength: 4, + }); + await expectSendEventBlocked({ + autumn: autumnV2_1, + customerId, + entityId: entities[0].id, + requestFeatureId: TestFeature.Messages, + requiredBalance: 1, + entity: { + granted: 800, + remaining: 0, + usage: 825, + breakdownLength: 2, + }, + customer: { + granted: 1600, + remaining: 0, + usage: 1665, + breakdownLength: 4, + }, + }); + await expectSendEventBlocked({ + autumn: autumnV2_1, + customerId, + entityId: entities[1].id, + requestFeatureId: TestFeature.Messages, + requiredBalance: 1, + entity: { + granted: 800, + remaining: 0, + usage: 840, + breakdownLength: 2, + }, + customer: { + granted: 1600, + remaining: 0, + usage: 1665, + breakdownLength: 4, + }, + }); +}); + +test.concurrent(`${chalk.yellowBright("track-entity-product-spend-limit4: allocated workflows entity product caps overage")}`, async () => { + const entityProduct = products.base({ + id: "track-entity-product-workflows", + items: [items.allocatedWorkflows({ includedUsage: 1 })], + }); + + const { autumnV2_1, customerId, entities } = await initScenario({ + customerId: "track-entity-product-spend-limit-4", + 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.Workflows, + overageLimit: 2, + }); + + await autumnV2_1.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Workflows, + value: 1, + }); + await autumnV2_1.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Workflows, + value: 2, + }); + + await expectEntityFeatureBalance({ + autumn: autumnV2_1, + customerId, + entityId: entities[0].id, + featureId: TestFeature.Workflows, + granted: 1, + remaining: 0, + usage: 3, + breakdownLength: 1, + }); + await expectSendEventBlocked({ + autumn: autumnV2_1, + customerId, + entityId: entities[0].id, + requestFeatureId: TestFeature.Workflows, + requiredBalance: 1, + entity: { + granted: 1, + remaining: 0, + usage: 3, + breakdownLength: 1, + }, + }); +}); + +test.concurrent(`${chalk.yellowBright("track-entity-product-spend-limit5: credit-system entity product uses converted credits and caps overage")}`, async () => { + const includedCredits = 100; + const spendLimitCredits = 25; + const existingOverageCredits = 20; + + const entityProduct = products.base({ + id: "track-entity-product-credits", + items: [ + items.consumable({ + featureId: TestFeature.Credits, + includedUsage: includedCredits, + maxPurchase: 300, + price: 0.5, + }), + ], + }); + + const { autumnV2_1, customerId, entities, ctx } = await initScenario({ + customerId: "track-entity-product-spend-limit-5", + 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 }), + ], + }); + + 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 setEntitySpendLimit({ + autumn: autumnV2_1, + customerId, + entityId: entities[0].id, + featureId: TestFeature.Credits, + overageLimit: spendLimitCredits, + }); + + await autumnV2_1.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Action1, + value: firstTrackValue, + }); + await autumnV2_1.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Action1, + value: secondTrackValue, + }); + + await expectEntityFeatureBalance({ + autumn: autumnV2_1, + customerId, + entityId: entities[0].id, + featureId: TestFeature.Credits, + granted: includedCredits, + remaining: 0, + usage: includedCredits + spendLimitCredits, + maxPurchase: 300, + breakdownLength: 1, + }); + + await expectCustomerFeatureBalance({ + 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, + entity_id: entities[0].id, + feature_id: TestFeature.Action1, + value: 1 / action1CreditCost, + overage_behavior: "reject", + }), + }); + await expectSendEventBlocked({ + autumn: autumnV2_1, + customerId, + entityId: entities[0].id, + requestFeatureId: TestFeature.Action1, + requiredBalance: 1 / action1CreditCost, + expectedFeatureId: TestFeature.Credits, + expectedResponseRequiredBalance: 1, + entity: { + granted: includedCredits, + remaining: 0, + usage: includedCredits + spendLimitCredits, + maxPurchase: 300, + breakdownLength: 1, + }, + }); +}); diff --git a/server/tests/integration/balances/track/spend-limit/track-per-entity-spend-limit.test.ts b/server/tests/integration/balances/track/spend-limit/track-per-entity-spend-limit.test.ts new file mode 100644 index 000000000..e61f687bd --- /dev/null +++ b/server/tests/integration/balances/track/spend-limit/track-per-entity-spend-limit.test.ts @@ -0,0 +1,577 @@ +import { 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 { constructArrearProratedItem } from "@/utils/scriptUtils/constructItem.js"; +import { + expectCustomerFeatureBalance, + expectEntityFeatureBalance, + expectSendEventBlocked, + getActionUnitsForCreditAmount, + setEntitySpendLimit, +} from "../../utils/spend-limit-utils/entitySpendLimitUtils.js"; + +test.concurrent(`${chalk.yellowBright("track-per-entity-spend-limit1: lifetime + consumable per-entity messages cap track overage")}`, async () => { + const perEntityProduct = products.base({ + id: "track-per-entity-lifetime-consumable", + items: [ + items.lifetimeMessages({ + includedUsage: 1000, + entityFeatureId: TestFeature.Users, + }), + items.consumableMessages({ + includedUsage: 100, + maxPurchase: 300, + price: 0.5, + entityFeatureId: TestFeature.Users, + }), + ], + }); + + const { autumnV2_1, customerId, entities } = await initScenario({ + customerId: "track-per-entity-spend-limit-1", + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [perEntityProduct] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [s.billing.attach({ productId: perEntityProduct.id })], + }); + + 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: 1120, + }); + + await autumnV2_1.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: 10, + }); + + await expectEntityFeatureBalance({ + autumn: autumnV2_1, + customerId, + entityId: entities[0].id, + featureId: TestFeature.Messages, + granted: 1100, + remaining: 0, + usage: 1125, + maxPurchase: 300, + breakdownLength: 2, + }); + + await expectCustomerFeatureBalance({ + 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, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: 1, + overage_behavior: "reject", + }), + }); + await expectSendEventBlocked({ + autumn: autumnV2_1, + customerId, + entityId: entities[0].id, + requestFeatureId: TestFeature.Messages, + requiredBalance: 1, + entity: { + granted: 1100, + remaining: 0, + usage: 1125, + maxPurchase: 300, + breakdownLength: 2, + }, + }); +}); + +test.concurrent(`${chalk.yellowBright("track-per-entity-spend-limit2: prepaid + consumable per-entity messages cap track overage")}`, async () => { + const perEntityProduct = products.base({ + id: "track-per-entity-prepaid-consumable", + items: [ + items.prepaidMessages({ + includedUsage: 100, + billingUnits: 100, + price: 8.5, + entityFeatureId: TestFeature.Users, + }), + items.consumableMessages({ + includedUsage: 200, + price: 0.5, + entityFeatureId: TestFeature.Users, + }), + ], + }); + + const totalQuantity = 600; + const { autumnV2_1, customerId, entities } = await initScenario({ + customerId: "track-per-entity-spend-limit-2", + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [perEntityProduct] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [ + s.billing.attach({ + productId: perEntityProduct.id, + options: [ + { + feature_id: TestFeature.Messages, + quantity: totalQuantity, + }, + ], + }), + ], + }); + + 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: totalQuantity + 220, + }); + + await autumnV2_1.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: 10, + }); + + await expectEntityFeatureBalance({ + autumn: autumnV2_1, + customerId, + entityId: entities[0].id, + featureId: TestFeature.Messages, + granted: totalQuantity + 200, + remaining: 0, + usage: totalQuantity + 225, + breakdownLength: 2, + }); + + await expectCustomerFeatureBalance({ + autumn: autumnV2_1, + customerId, + featureId: TestFeature.Messages, + granted: totalQuantity + 200, + remaining: 0, + usage: totalQuantity + 225, + + breakdownLength: 2, + }); + + await expectAutumnError({ + errCode: ErrCode.InsufficientBalance, + func: async () => + await autumnV2_1.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: 1, + overage_behavior: "reject", + }), + }); + await expectSendEventBlocked({ + autumn: autumnV2_1, + customerId, + entityId: entities[0].id, + requestFeatureId: TestFeature.Messages, + requiredBalance: 1, + entity: { + granted: totalQuantity + 200, + remaining: 0, + usage: totalQuantity + 225, + breakdownLength: 2, + }, + }); +}); + +test.concurrent(`${chalk.yellowBright("track-per-entity-spend-limit3: different per-entity spend limits stay isolated while customer balance aggregates")}`, async () => { + const perEntityProduct = products.base({ + id: "track-per-entity-two-entities", + 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-per-entity-spend-limit-3", + 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 setEntitySpendLimit({ + autumn: autumnV2_1, + customerId, + entityId: entities[0].id, + featureId: TestFeature.Messages, + overageLimit: 25, + }); + await setEntitySpendLimit({ + autumn: autumnV2_1, + customerId, + entityId: entities[1].id, + featureId: TestFeature.Messages, + overageLimit: 40, + }); + + await autumnV2_1.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: 820, + }); + await autumnV2_1.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: 10, + }); + + await autumnV2_1.track({ + customer_id: customerId, + entity_id: entities[1].id, + feature_id: TestFeature.Messages, + value: 820, + }); + await autumnV2_1.track({ + customer_id: customerId, + entity_id: entities[1].id, + feature_id: TestFeature.Messages, + value: 25, + }); + + await expectEntityFeatureBalance({ + autumn: autumnV2_1, + customerId, + entityId: entities[0].id, + featureId: TestFeature.Messages, + granted: 800, + remaining: 0, + usage: 825, + breakdownLength: 2, + }); + await expectEntityFeatureBalance({ + autumn: autumnV2_1, + customerId, + entityId: entities[1].id, + featureId: TestFeature.Messages, + granted: 800, + remaining: 0, + usage: 840, + breakdownLength: 2, + }); + + await expectCustomerFeatureBalance({ + autumn: autumnV2_1, + customerId, + featureId: TestFeature.Messages, + granted: 1600, + remaining: 0, + usage: 1665, + breakdownLength: 2, + }); + + await expectSendEventBlocked({ + autumn: autumnV2_1, + customerId, + entityId: entities[0].id, + requestFeatureId: TestFeature.Messages, + requiredBalance: 1, + entity: { + granted: 800, + remaining: 0, + usage: 825, + breakdownLength: 2, + }, + customer: { + granted: 1600, + remaining: 0, + usage: 1665, + breakdownLength: 2, + }, + }); + await expectSendEventBlocked({ + autumn: autumnV2_1, + customerId, + entityId: entities[1].id, + requestFeatureId: TestFeature.Messages, + requiredBalance: 1, + entity: { + granted: 800, + remaining: 0, + usage: 840, + breakdownLength: 2, + }, + customer: { + granted: 1600, + remaining: 0, + usage: 1665, + breakdownLength: 2, + }, + }); +}); + +test.concurrent(`${chalk.yellowBright("track-per-entity-spend-limit4: allocated workflows per entity cap track overage")}`, async () => { + const workflowItem = { + ...constructArrearProratedItem({ + featureId: TestFeature.Workflows, + pricePerUnit: 10, + includedUsage: 1, + }), + entity_feature_id: TestFeature.Users, + }; + + const perEntityProduct = products.base({ + id: "track-per-entity-workflows", + items: [workflowItem], + }); + + const { autumnV2_1, customerId, entities } = await initScenario({ + customerId: "track-per-entity-spend-limit-4", + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [perEntityProduct] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [s.billing.attach({ productId: perEntityProduct.id })], + }); + + await setEntitySpendLimit({ + autumn: autumnV2_1, + customerId, + entityId: entities[0].id, + featureId: TestFeature.Workflows, + overageLimit: 2, + }); + + await autumnV2_1.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Workflows, + value: 1, + }); + await autumnV2_1.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Workflows, + value: 2, + }); + + await expectEntityFeatureBalance({ + autumn: autumnV2_1, + customerId, + entityId: entities[0].id, + featureId: TestFeature.Workflows, + granted: 1, + remaining: 0, + usage: 3, + breakdownLength: 1, + }); + + await expectAutumnError({ + errCode: ErrCode.InsufficientBalance, + func: async () => + await autumnV2_1.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Workflows, + value: 1, + overage_behavior: "reject", + }), + }); + await expectSendEventBlocked({ + autumn: autumnV2_1, + customerId, + entityId: entities[0].id, + requestFeatureId: TestFeature.Workflows, + requiredBalance: 1, + entity: { + granted: 1, + remaining: 0, + usage: 3, + breakdownLength: 1, + }, + }); +}); + +test.concurrent(`${chalk.yellowBright("track-per-entity-spend-limit5: credit-system per-entity tracking uses converted credits and caps overage")}`, async () => { + const includedCredits = 100; + const spendLimitCredits = 25; + const existingOverageCredits = 20; + + const perEntityProduct = products.base({ + id: "track-per-entity-credits", + items: [ + items.consumable({ + featureId: TestFeature.Credits, + includedUsage: includedCredits, + maxPurchase: 300, + price: 0.5, + entityFeatureId: TestFeature.Users, + }), + ], + }); + + const { autumnV2_1, customerId, entities, ctx } = await initScenario({ + customerId: "track-per-entity-spend-limit-5", + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [perEntityProduct] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [s.billing.attach({ productId: perEntityProduct.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 setEntitySpendLimit({ + autumn: autumnV2_1, + customerId, + entityId: entities[0].id, + featureId: TestFeature.Credits, + overageLimit: spendLimitCredits, + }); + + await autumnV2_1.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Action1, + value: firstTrackValue, + }); + await autumnV2_1.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Action1, + value: secondTrackValue, + }); + + await expectEntityFeatureBalance({ + autumn: autumnV2_1, + customerId, + entityId: entities[0].id, + featureId: TestFeature.Credits, + granted: includedCredits, + remaining: 0, + usage: includedCredits + spendLimitCredits, + maxPurchase: 300, + breakdownLength: 1, + }); + + await expectCustomerFeatureBalance({ + 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, + entity_id: entities[0].id, + feature_id: TestFeature.Action1, + value: 1 / action1CreditCost, + overage_behavior: "reject", + }), + }); + await expectSendEventBlocked({ + autumn: autumnV2_1, + customerId, + entityId: entities[0].id, + requestFeatureId: TestFeature.Action1, + requiredBalance: 1 / action1CreditCost, + expectedFeatureId: TestFeature.Credits, + expectedResponseRequiredBalance: 1, + entity: { + granted: includedCredits, + remaining: 0, + usage: includedCredits + spendLimitCredits, + maxPurchase: 300, + breakdownLength: 1, + }, + }); +}); diff --git a/server/tests/integration/balances/track/spend-limit/track-postgres-entity-spend-limit.test.ts b/server/tests/integration/balances/track/spend-limit/track-postgres-entity-spend-limit.test.ts new file mode 100644 index 000000000..0d67b983b --- /dev/null +++ b/server/tests/integration/balances/track/spend-limit/track-postgres-entity-spend-limit.test.ts @@ -0,0 +1,416 @@ +import { 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 { timeout } from "@tests/utils/genUtils.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { getCreditCost } from "@/internal/features/creditSystemUtils.js"; +import { + expectCustomerFeatureCachedAndDb, + expectEntityFeatureCachedAndDb, + getActionUnitsForCreditAmount, + setEntitySpendLimit, +} from "../../utils/spend-limit-utils/entitySpendLimitUtils.js"; + +test.concurrent(`${chalk.yellowBright("track-postgres-entity-spend-limit1: per-entity messages cap overage across Redis then Postgres track paths")}`, async () => { + const perEntityProduct = products.base({ + id: "track-postgres-per-entity-messages", + items: [ + items.consumableMessages({ + includedUsage: 100, + maxPurchase: 300, + price: 0.5, + entityFeatureId: TestFeature.Users, + }), + ], + }); + + const { autumnV2, autumnV2_1, customerId, entities } = await initScenario({ + customerId: "track-postgres-entity-spend-limit-1", + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [perEntityProduct] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [s.billing.attach({ productId: perEntityProduct.id })], + }); + + await setEntitySpendLimit({ + autumn: autumnV2_1, + customerId, + entityId: entities[0].id, + featureId: TestFeature.Messages, + overageLimit: 25, + }); + + await autumnV2.track( + { + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: 120, + }, + { skipCache: true }, + ); + + await timeout(4000); + + await autumnV2.track( + { + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: 10, + }, + { skipCache: true }, + ); + + await expectEntityFeatureCachedAndDb({ + autumn: autumnV2_1, + customerId, + entityId: entities[0].id, + featureId: TestFeature.Messages, + granted: 100, + remaining: 0, + usage: 125, + maxPurchase: 300, + breakdownLength: 1, + }); + + await expectCustomerFeatureCachedAndDb({ + autumn: autumnV2_1, + customerId, + featureId: TestFeature.Messages, + granted: 100, + remaining: 0, + usage: 125, + maxPurchase: 300, + breakdownLength: 1, + }); + + await expectAutumnError({ + errCode: ErrCode.InsufficientBalance, + func: async () => + await autumnV2.track( + { + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: 1, + overage_behavior: "reject", + }, + { skipCache: true }, + ), + }); +}); + +test.concurrent(`${chalk.yellowBright("track-postgres-entity-spend-limit2: entity-product messages cap overage across Redis then Postgres track paths")}`, async () => { + const entityProduct = products.base({ + id: "track-postgres-entity-product-messages", + items: [ + items.consumableMessages({ + includedUsage: 100, + maxPurchase: 300, + price: 0.5, + }), + ], + }); + + const { autumnV2, autumnV2_1, customerId, entities } = await initScenario({ + customerId: "track-postgres-entity-spend-limit-2", + 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.track( + { + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: 120, + }, + { skipCache: true }, + ); + + await timeout(4000); + + await autumnV2.track( + { + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: 10, + }, + { skipCache: true }, + ); + + await expectEntityFeatureCachedAndDb({ + autumn: autumnV2_1, + customerId, + entityId: entities[0].id, + featureId: TestFeature.Messages, + granted: 100, + remaining: 0, + usage: 125, + maxPurchase: 300, + breakdownLength: 1, + }); + + await expectCustomerFeatureCachedAndDb({ + autumn: autumnV2_1, + customerId, + featureId: TestFeature.Messages, + granted: 100, + remaining: 0, + usage: 125, + maxPurchase: 300, + breakdownLength: 1, + }); +}); + +test.concurrent(`${chalk.yellowBright("track-postgres-entity-spend-limit3: credit-system overage stays capped when Postgres handles the second track")}`, async () => { + const includedCredits = 100; + const spendLimitCredits = 25; + const existingOverageCredits = 20; + const perEntityProduct = products.base({ + id: "track-postgres-per-entity-credits", + items: [ + items.consumable({ + featureId: TestFeature.Credits, + includedUsage: includedCredits, + maxPurchase: 300, + price: 0.5, + entityFeatureId: TestFeature.Users, + }), + ], + }); + + const { autumnV2, autumnV2_1, customerId, entities, ctx } = + await initScenario({ + customerId: "track-postgres-entity-spend-limit-3", + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [perEntityProduct] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [s.billing.attach({ productId: perEntityProduct.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 setEntitySpendLimit({ + autumn: autumnV2_1, + customerId, + entityId: entities[0].id, + featureId: TestFeature.Credits, + overageLimit: spendLimitCredits, + }); + + await autumnV2.track( + { + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Action1, + value: firstTrackValue, + }, + { skipCache: true }, + ); + + await timeout(4000); + + await autumnV2.track( + { + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Action1, + value: secondTrackValue, + }, + { skipCache: true }, + ); + + await expectEntityFeatureCachedAndDb({ + autumn: autumnV2_1, + customerId, + entityId: entities[0].id, + featureId: TestFeature.Credits, + granted: includedCredits, + remaining: 0, + usage: includedCredits + spendLimitCredits, + maxPurchase: 300, + breakdownLength: 1, + }); + + await expectCustomerFeatureCachedAndDb({ + autumn: autumnV2_1, + customerId, + featureId: TestFeature.Credits, + granted: includedCredits, + remaining: 0, + usage: includedCredits + spendLimitCredits, + maxPurchase: 300, + breakdownLength: 1, + }); +}); + +test.concurrent(`${chalk.yellowBright("track-postgres-entity-spend-limit4: prepaid + consumable credits stay capped when Postgres handles the second track")}`, async () => { + const prepaidQuantity = 600; + const consumableIncludedCredits = 200; + const spendLimitCredits = 25; + const existingOverageCredits = 20; + const totalGrantedCredits = prepaidQuantity + consumableIncludedCredits; + + const perEntityProduct = products.base({ + id: "track-postgres-per-entity-prepaid-consumable-credits", + items: [ + items.prepaid({ + featureId: TestFeature.Credits, + includedUsage: 100, + billingUnits: 100, + price: 8.5, + entityFeatureId: TestFeature.Users, + }), + items.consumable({ + featureId: TestFeature.Credits, + includedUsage: consumableIncludedCredits, + price: 0.5, + entityFeatureId: TestFeature.Users, + }), + ], + }); + + const { autumnV2, autumnV2_1, customerId, entities, ctx } = + await initScenario({ + customerId: "track-postgres-entity-spend-limit-4", + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [perEntityProduct] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [ + s.billing.attach({ + productId: perEntityProduct.id, + options: [ + { + feature_id: TestFeature.Credits, + quantity: prepaidQuantity, + }, + ], + }), + ], + }); + + const creditsFeature = ctx.features.find( + (feature) => feature.id === TestFeature.Credits, + )!; + const action1CreditCost = getCreditCost({ + featureId: TestFeature.Action1, + creditSystem: creditsFeature, + amount: 1, + }); + const firstTrackValue = getActionUnitsForCreditAmount({ + creditAmount: totalGrantedCredits + existingOverageCredits, + creditCostPerActionUnit: action1CreditCost, + }); + const secondTrackValue = getActionUnitsForCreditAmount({ + creditAmount: spendLimitCredits - existingOverageCredits + 5, + creditCostPerActionUnit: action1CreditCost, + }); + + await setEntitySpendLimit({ + autumn: autumnV2_1, + customerId, + entityId: entities[0].id, + featureId: TestFeature.Credits, + overageLimit: spendLimitCredits, + }); + + await autumnV2.track( + { + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Action1, + value: firstTrackValue, + }, + { skipCache: true }, + ); + + await timeout(4000); + + await autumnV2.track( + { + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Action1, + value: secondTrackValue, + }, + { skipCache: true }, + ); + + await expectEntityFeatureCachedAndDb({ + autumn: autumnV2_1, + customerId, + entityId: entities[0].id, + featureId: TestFeature.Credits, + granted: totalGrantedCredits, + remaining: 0, + usage: totalGrantedCredits + spendLimitCredits, + breakdownLength: 2, + }); + + await expectCustomerFeatureCachedAndDb({ + autumn: autumnV2_1, + customerId, + featureId: TestFeature.Credits, + granted: totalGrantedCredits, + remaining: 0, + usage: totalGrantedCredits + spendLimitCredits, + breakdownLength: 2, + }); + + await expectAutumnError({ + errCode: ErrCode.InsufficientBalance, + func: async () => + await autumnV2.track( + { + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Action1, + value: 1 / action1CreditCost, + overage_behavior: "reject", + }, + { skipCache: true }, + ), + }); +}); diff --git a/server/tests/integration/balances/track/track-postgres.test.ts b/server/tests/integration/balances/track/track-postgres.test.ts new file mode 100644 index 000000000..edb9c8948 --- /dev/null +++ b/server/tests/integration/balances/track/track-postgres.test.ts @@ -0,0 +1,105 @@ +import { test } from "bun:test"; +import type { ApiCustomerV5 } from "@autumn/shared"; +import { ErrCode } from "@autumn/shared"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect"; +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 { timeout } from "@tests/utils/genUtils.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +test.concurrent(`${chalk.yellowBright("track-postgres1: customer-level overage stays cumulative across entities when Postgres handles follow-up tracks")}`, async () => { + const customerProduct = products.base({ + id: "track-postgres-customer-across-entities", + items: [ + items.consumableMessages({ + includedUsage: 100, + maxPurchase: 25, + price: 0.5, + }), + ], + }); + + const { autumnV2, autumnV2_1, customerId, entities } = await initScenario({ + customerId: "track-postgres-1", + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [customerProduct] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [s.billing.attach({ productId: customerProduct.id })], + }); + + await autumnV2.track( + { + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: 120, + }, + { skipCache: true }, + ); + + await timeout(4000); + + await autumnV2.track( + { + customer_id: customerId, + entity_id: entities[1].id, + feature_id: TestFeature.Messages, + value: 10, + }, + { skipCache: true }, + ); + + const cachedCustomer = + await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer: cachedCustomer, + featureId: TestFeature.Messages, + remaining: 0, + breakdown: { + month: { + included_grant: 100, + remaining: 0, + usage: 125, + }, + }, + }); + + const uncachedCustomer = await autumnV2_1.customers.get( + customerId, + { + skip_cache: "true", + }, + ); + expectBalanceCorrect({ + customer: uncachedCustomer, + featureId: TestFeature.Messages, + remaining: 0, + breakdown: { + month: { + included_grant: 100, + remaining: 0, + usage: 125, + }, + }, + }); + + await expectAutumnError({ + errCode: ErrCode.InsufficientBalance, + func: async () => + await autumnV2.track( + { + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: 1, + overage_behavior: "reject", + }, + { 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 new file mode 100644 index 000000000..73c59482a --- /dev/null +++ b/server/tests/integration/balances/utils/spend-limit-utils/checkSpendLimitUtils.ts @@ -0,0 +1,106 @@ +import { expect } from "bun:test"; +import type { CheckResponseV3 } from "@autumn/shared"; +import { timeout } from "@tests/utils/genUtils.js"; +import type { AutumnV2_1Client } from "./entitySpendLimitUtils.js"; + +export const normalizeCheckResponse = (response: CheckResponseV3) => ({ + allowed: response.allowed, + customer_id: response.customer_id, + entity_id: response.entity_id ?? null, + required_balance: response.required_balance ?? null, + balance: response.balance + ? { + feature_id: response.balance.feature_id, + granted: response.balance.granted, + remaining: response.balance.remaining, + usage: response.balance.usage, + unlimited: response.balance.unlimited, + overage_allowed: response.balance.overage_allowed, + max_purchase: response.balance.max_purchase, + breakdown: + response.balance.breakdown?.map((item) => ({ + plan_id: item.plan_id, + included_grant: item.included_grant, + prepaid_grant: item.prepaid_grant, + remaining: item.remaining, + usage: item.usage, + unlimited: item.unlimited, + billing_method: item.price?.billing_method ?? null, + max_purchase: item.price?.max_purchase ?? null, + reset_interval: item.reset?.interval ?? null, + })) ?? [], + } + : null, +}); + +export const expectBoundaryAndParity = async ({ + autumn, + customerId, + entityId, + featureId, + allowedRequiredBalance, + blockedRequiredBalance, + expectedFeatureId = featureId, + expectedAllowedResponseRequiredBalance = allowedRequiredBalance, + expectedBlockedResponseRequiredBalance = blockedRequiredBalance, +}: { + autumn: AutumnV2_1Client; + customerId: string; + entityId: string; + featureId: string; + allowedRequiredBalance: number; + blockedRequiredBalance: number; + expectedFeatureId?: string; + expectedAllowedResponseRequiredBalance?: number; + expectedBlockedResponseRequiredBalance?: number; +}) => { + const allowedCached = await autumn.check({ + customer_id: customerId, + entity_id: entityId, + feature_id: featureId, + required_balance: allowedRequiredBalance, + }); + + const blockedCached = await autumn.check({ + customer_id: customerId, + entity_id: entityId, + feature_id: featureId, + required_balance: blockedRequiredBalance, + }); + + expect(allowedCached.allowed).toBe(true); + expect(blockedCached.allowed).toBe(false); + expect(allowedCached.balance?.feature_id).toBe(expectedFeatureId); + expect(blockedCached.balance?.feature_id).toBe(expectedFeatureId); + expect(allowedCached.required_balance).toBe( + expectedAllowedResponseRequiredBalance, + ); + expect(blockedCached.required_balance).toBe( + expectedBlockedResponseRequiredBalance, + ); + + await timeout(4000); + + const allowedUncached = await autumn.check({ + customer_id: customerId, + entity_id: entityId, + feature_id: featureId, + required_balance: allowedRequiredBalance, + skip_cache: true, + }); + + const blockedUncached = await autumn.check({ + customer_id: customerId, + entity_id: entityId, + feature_id: featureId, + required_balance: blockedRequiredBalance, + skip_cache: true, + }); + + expect(normalizeCheckResponse(allowedUncached)).toEqual( + normalizeCheckResponse(allowedCached), + ); + expect(normalizeCheckResponse(blockedUncached)).toEqual( + normalizeCheckResponse(blockedCached), + ); +}; diff --git a/server/tests/integration/balances/utils/spend-limit-utils/entitySpendLimitUtils.ts b/server/tests/integration/balances/utils/spend-limit-utils/entitySpendLimitUtils.ts new file mode 100644 index 000000000..d18c6cae2 --- /dev/null +++ b/server/tests/integration/balances/utils/spend-limit-utils/entitySpendLimitUtils.ts @@ -0,0 +1,321 @@ +import { expect } from "bun:test"; +import type { + ApiCustomerV5, + ApiEntityV2, + CheckResponseV3, + EntityBillingControls, +} from "@autumn/shared"; +import { timeout } from "@tests/utils/genUtils.js"; +import type { initScenario } from "@tests/utils/testInitUtils/initScenario.js"; + +export type AutumnV2_1Client = Awaited< + ReturnType +>["autumnV2_1"]; + +export const setEntitySpendLimit = async ({ + autumn, + customerId, + entityId, + featureId, + overageLimit, + enabled = true, +}: { + autumn: AutumnV2_1Client; + customerId: string; + entityId: string; + featureId: string; + overageLimit: number; + enabled?: boolean; +}) => { + const billingControls: EntityBillingControls = { + spend_limits: [ + { + feature_id: featureId, + enabled, + overage_limit: overageLimit, + }, + ], + }; + + await autumn.entities.update(customerId, entityId, { + billing_controls: billingControls, + }); +}; + +export const getActionUnitsForCreditAmount = ({ + creditAmount, + creditCostPerActionUnit, +}: { + creditAmount: number; + creditCostPerActionUnit: number; +}) => creditAmount / creditCostPerActionUnit; + +export const expectEntityFeatureBalance = async ({ + autumn, + customerId, + entityId, + featureId, + granted, + remaining, + usage, + maxPurchase, + breakdownLength, + skipCache = false, +}: { + autumn: AutumnV2_1Client; + customerId: string; + entityId: string; + featureId: string; + granted: number; + remaining: number; + usage: number; + maxPurchase?: number | null; + breakdownLength?: number; + skipCache?: boolean; +}) => { + await timeout(3000); + const entity = await autumn.entities.get(customerId, entityId, { + skip_cache: skipCache ? "true" : undefined, + }); + + expect(entity.balances[featureId]).toMatchObject({ + feature_id: featureId, + granted, + remaining, + usage, + ...(maxPurchase === undefined + ? {} + : { + max_purchase: maxPurchase, + }), + }); + + if (breakdownLength !== undefined) { + expect(entity.balances[featureId]?.breakdown).toHaveLength(breakdownLength); + } +}; + +export const expectCustomerFeatureBalance = async ({ + autumn, + customerId, + featureId, + granted, + remaining, + usage, + maxPurchase, + breakdownLength, + skipCache = false, +}: { + autumn: AutumnV2_1Client; + customerId: string; + featureId: string; + granted: number; + remaining: number; + usage: number; + maxPurchase?: number | null; + breakdownLength?: number; + skipCache?: boolean; +}) => { + const customer = await autumn.customers.get(customerId, { + skip_cache: skipCache ? "true" : undefined, + }); + + expect(customer.balances[featureId]).toMatchObject({ + feature_id: featureId, + granted, + remaining, + usage, + ...(maxPurchase === undefined + ? {} + : { + max_purchase: maxPurchase, + }), + }); + + if (breakdownLength !== undefined) { + expect(customer.balances[featureId]?.breakdown).toHaveLength( + breakdownLength, + ); + } +}; + +export const expectSendEventBlocked = async ({ + autumn, + customerId, + entityId, + requestFeatureId, + requiredBalance, + entity, + customer, + expectedFeatureId = requestFeatureId, + expectedResponseRequiredBalance = requiredBalance, +}: { + autumn: AutumnV2_1Client; + customerId: string; + entityId: string; + requestFeatureId: string; + requiredBalance: number; + entity: { + granted: number; + remaining: number; + usage: number; + maxPurchase?: number | null; + breakdownLength?: number; + }; + customer?: { + granted: number; + remaining: number; + usage: number; + maxPurchase?: number | null; + breakdownLength?: number; + }; + expectedFeatureId?: string; + expectedResponseRequiredBalance?: number; +}) => { + const customerExpectation = customer ?? entity; + + const response = await autumn.check({ + customer_id: customerId, + entity_id: entityId, + feature_id: requestFeatureId, + required_balance: requiredBalance, + send_event: true, + }); + + expect(response).toMatchObject({ + allowed: false, + customer_id: customerId, + entity_id: entityId, + required_balance: expectedResponseRequiredBalance, + balance: { + feature_id: expectedFeatureId, + granted: entity.granted, + remaining: entity.remaining, + usage: entity.usage, + ...(entity.maxPurchase === undefined + ? {} + : { + max_purchase: entity.maxPurchase, + }), + }, + }); + + if (entity.breakdownLength !== undefined) { + expect(response.balance?.breakdown).toHaveLength(entity.breakdownLength); + } + + await timeout(4000); + + await expectEntityFeatureCachedAndDb({ + autumn, + customerId, + entityId, + featureId: expectedFeatureId, + granted: entity.granted, + remaining: entity.remaining, + usage: entity.usage, + maxPurchase: entity.maxPurchase, + breakdownLength: entity.breakdownLength, + }); + + await expectCustomerFeatureCachedAndDb({ + autumn, + customerId, + featureId: expectedFeatureId, + granted: customerExpectation.granted, + remaining: customerExpectation.remaining, + usage: customerExpectation.usage, + maxPurchase: customerExpectation.maxPurchase, + breakdownLength: customerExpectation.breakdownLength, + }); +}; + +export const expectEntityFeatureCachedAndDb = async ({ + autumn, + customerId, + entityId, + featureId, + granted, + remaining, + usage, + maxPurchase, + breakdownLength, +}: { + autumn: AutumnV2_1Client; + customerId: string; + entityId: string; + featureId: string; + granted: number; + remaining: number; + usage: number; + maxPurchase?: number | null; + breakdownLength?: number; +}) => { + await expectEntityFeatureBalance({ + autumn, + customerId, + entityId, + featureId, + granted, + remaining, + usage, + maxPurchase, + breakdownLength, + }); + + await expectEntityFeatureBalance({ + autumn, + customerId, + entityId, + featureId, + granted, + remaining, + usage, + maxPurchase, + breakdownLength, + skipCache: true, + }); +}; + +export const expectCustomerFeatureCachedAndDb = async ({ + autumn, + customerId, + featureId, + granted, + remaining, + usage, + maxPurchase, + breakdownLength, +}: { + autumn: AutumnV2_1Client; + customerId: string; + featureId: string; + granted: number; + remaining: number; + usage: number; + maxPurchase?: number | null; + breakdownLength?: number; +}) => { + await expectCustomerFeatureBalance({ + autumn, + customerId, + featureId, + granted, + remaining, + usage, + maxPurchase, + breakdownLength, + }); + + await expectCustomerFeatureBalance({ + autumn, + customerId, + featureId, + granted, + remaining, + usage, + maxPurchase, + breakdownLength, + skipCache: true, + }); +}; diff --git a/shared/api/customers/cusFeatures/index.ts b/shared/api/customers/cusFeatures/index.ts index 07a2ef623..70f52c627 100644 --- a/shared/api/customers/cusFeatures/index.ts +++ b/shared/api/customers/cusFeatures/index.ts @@ -4,6 +4,9 @@ export * from "./previousVersions/apiCusFeatureV0"; export * from "./previousVersions/apiCusFeatureV1"; export * from "./previousVersions/apiCusFeatureV2"; export * from "./previousVersions/apiCusFeatureV3"; +export * from "./utils/apiBalanceUtils"; export * from "./utils/check/index"; export * from "./utils/convert/apiBalanceToAllowed"; export * from "./utils/convert/apiBalanceV1ToAvailableOverage"; +export * from "./utils/getApiBalance"; +export * from "./utils/getApiBalances"; diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/apiBalanceUtils.ts b/shared/api/customers/cusFeatures/utils/apiBalanceUtils.ts similarity index 82% rename from server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/apiBalanceUtils.ts rename to shared/api/customers/cusFeatures/utils/apiBalanceUtils.ts index 68c93e4d1..573a5531d 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/apiBalanceUtils.ts +++ b/shared/api/customers/cusFeatures/utils/apiBalanceUtils.ts @@ -1,11 +1,7 @@ -import { - type ApiBalanceBreakdownV1, - type ApiBalanceV1, - type ApiFeatureV1, - cusEntsToPlanId, - cusEntsToRollovers, - type FullCusEntWithFullCusProduct, -} from "@autumn/shared"; +import type { ApiFeatureV1 } from "@api/features/apiFeatureV1"; +import type { FullCusEntWithFullCusProduct } from "@models/cusProductModels/cusEntModels/cusEntWithProduct"; +import { cusEntsToPlanId, cusEntsToRollovers } from "@utils/index.js"; +import type { ApiBalanceBreakdownV1, ApiBalanceV1 } from "../apiBalanceV1"; export const getBooleanApiBalance = ({ cusEnts, @@ -51,7 +47,7 @@ export const getBooleanApiBalance = ({ } satisfies ApiBalanceBreakdownV1, ], rollovers: undefined, - } satisfies ApiBalanceV1; + }; }; export const getUnlimitedApiBalance = ({ @@ -64,7 +60,7 @@ export const getUnlimitedApiBalance = ({ const feature = cusEnts[0].entitlement.feature; const planId = cusEntsToPlanId({ cusEnts }); const id = cusEnts[0].id; - const entityId = undefined; // Unlimited features don't have entity context + const entityId = undefined; return { object: "balance", diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts b/shared/api/customers/cusFeatures/utils/getApiBalance.ts similarity index 81% rename from server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts rename to shared/api/customers/cusFeatures/utils/getApiBalance.ts index fd931d84b..1dd48e7c0 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts +++ b/shared/api/customers/cusFeatures/utils/getApiBalance.ts @@ -1,10 +1,6 @@ -import type { - ApiBalanceBreakdownV1, - ApiBalanceV1, - FullCusEntWithFullCusProduct, - FullCustomer, -} from "@autumn/shared"; import { + type ApiBalanceBreakdownV1, + type ApiBalanceV1, CheckExpand, CustomerExpand, cusEntsToAdjustment, @@ -26,19 +22,47 @@ import { expandIncludes, type Feature, FeatureType, + type FullCusEntWithFullCusProduct, + type FullCustomer, getCusEntBalance, isUnlimitedCusEnt, nullish, + type SharedContext, sumValues, } from "@autumn/shared"; +import { AllowanceType } from "@models/productModels/entModels/entModels.js"; import { Decimal } from "decimal.js"; -import type { RequestContext } from "@/honoUtils/HonoEnv.js"; -import { getUnlimitedAndUsageAllowed } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js"; import { getBooleanApiBalance, getUnlimitedApiBalance, } from "./apiBalanceUtils.js"; +const getUnlimitedAndUsageAllowed = ({ + cusEnts, + internalFeatureId, + includeUsageLimit = true, +}: { + cusEnts: FullCusEntWithFullCusProduct[]; + internalFeatureId: string; + includeUsageLimit?: boolean; +}) => { + const unlimited = cusEnts.some( + (cusEnt) => + cusEnt.internal_feature_id === internalFeatureId && + (cusEnt.entitlement.allowance_type === AllowanceType.Unlimited || + cusEnt.unlimited), + ); + + const usageAllowed = cusEnts.some( + (cusEnt) => + cusEnt.internal_feature_id === internalFeatureId && + cusEnt.usage_allowed && + (includeUsageLimit ? nullish(cusEnt.entitlement.usage_limit) : true), + ); + + return { unlimited, usageAllowed }; +}; + const getApiBalanceBreakdownItem = ({ fullCus, customerEntitlement, @@ -47,10 +71,7 @@ const getApiBalanceBreakdownItem = ({ customerEntitlement: FullCusEntWithFullCusProduct; }): ApiBalanceBreakdownV1 => { const entityId = fullCus.entity?.id ?? fullCus.entity?.internal_id; - const planId = cusEntsToPlanId({ cusEnts: [customerEntitlement] }); - - // Included grant const allowance = cusEntsToAllowance({ cusEnts: [customerEntitlement], entityId, @@ -60,55 +81,36 @@ const getApiBalanceBreakdownItem = ({ entityId, }); const includedGrant = new Decimal(allowance).add(adjustment).toNumber(); - - // Prepaid grant const prepaidGrant = cusEntsToPrepaidQuantity({ cusEnts: [customerEntitlement], sumAcrossEntities: nullish(entityId), }); - - // Remaining const remaining = cusEntsToCurrentBalance({ cusEnts: [customerEntitlement], entityId, }); - - // Usage const usage = cusEntsToUsage({ cusEnts: [customerEntitlement], entityId }); - - // Unlimited const unlimited = isUnlimitedCusEnt(customerEntitlement); - - // Reset const reset = cusEntsToReset({ cusEnts: [customerEntitlement] }); - - // Price const price = customerEntitlementToBalancePrice({ customerEntitlement }); - const overage = cusEntToInvoiceOverage({ cusEnt: customerEntitlement, entityId, }); - const expiresAt = customerEntitlement.expires_at; - return { object: "balance_breakdown", - id: customerEntitlement.external_id ?? customerEntitlement.id, plan_id: planId, - included_grant: includedGrant, prepaid_grant: prepaidGrant, - remaining: remaining, - usage: usage, - unlimited: unlimited, - - reset: reset, - price: price, - expires_at: expiresAt, - - overage: overage, + remaining, + usage, + unlimited, + reset, + price, + expires_at: customerEntitlement.expires_at, + overage, }; }; @@ -118,7 +120,7 @@ export const getApiBalance = ({ cusEnts, feature, }: { - ctx: RequestContext; + ctx: SharedContext; fullCus: FullCustomer; cusEnts: FullCusEntWithFullCusProduct[]; feature: Feature; @@ -132,7 +134,6 @@ export const getApiBalance = ({ ? dbToApiFeatureV1({ ctx, dbFeature: feature }) : undefined; - // 1. If feature is boolean if (feature.type === FeatureType.Boolean) { return { data: getBooleanApiBalance({ @@ -143,12 +144,11 @@ export const getApiBalance = ({ } const { unlimited, usageAllowed } = getUnlimitedAndUsageAllowed({ - cusEnts: cusEnts, + cusEnts, internalFeatureId: feature.internal_id, includeUsageLimit: false, }); - // 2. If feature is unlimited if (unlimited) { return { data: getUnlimitedApiBalance({ apiFeature, cusEnts }), @@ -162,29 +162,20 @@ export const getApiBalance = ({ }), ); - // Build breakdown items - one per customer entitlement const breakdownItems = cusEnts.map((cusEnt) => getApiBalanceBreakdownItem({ fullCus, customerEntitlement: cusEnt }), ); - - // Calculate totals from breakdown const totalGranted = sumValues( breakdownItems.map((item) => new Decimal(item.included_grant).add(item.prepaid_grant).toNumber(), ), ); - const totalUsage = sumValues(breakdownItems.map((item) => item.usage)); - const totalRemaining = sumValues( breakdownItems.map((item) => item.remaining), ); - const totalMaxPurchase = cusEntsToMaxPurchase({ cusEnts, entityId }); - const nextResetAt = cusEntsToNextResetAt({ cusEnts }); - - // Rollover calculations const totalRollovers = cusEntsToRollovers({ cusEnts, entityId }); const totalRolloverGranted = cusEntsToRolloverGranted({ cusEnts, entityId }); const totalRolloverBalance = cusEntsToRolloverBalance({ cusEnts, entityId }); @@ -193,28 +184,21 @@ export const getApiBalance = ({ return { data: { object: "balance", - feature_id: feature.id, feature: apiFeature, - granted: new Decimal(totalGranted).add(totalRolloverGranted).toNumber(), - remaining: new Decimal(totalRemaining) .add(totalRolloverBalance) .add(totalUnused) .toNumber(), - usage: new Decimal(totalUsage) .add(totalRolloverUsage) .sub(totalUnused) .toNumber(), - - unlimited: unlimited, + unlimited, overage_allowed: usageAllowed ?? false, - max_purchase: totalMaxPurchase, next_reset_at: nextResetAt, - breakdown: breakdownItems, rollovers: totalRollovers, }, diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalances.ts b/shared/api/customers/cusFeatures/utils/getApiBalances.ts similarity index 88% rename from server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalances.ts rename to shared/api/customers/cusFeatures/utils/getApiBalances.ts index 012b5db63..f71428082 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalances.ts +++ b/shared/api/customers/cusFeatures/utils/getApiBalances.ts @@ -4,23 +4,20 @@ import { type FullCustomer, fullCustomerToCustomerEntitlements, orgToInStatuses, + type SharedContext, } from "@autumn/shared"; -import type { RequestContext } from "@/honoUtils/HonoEnv.js"; - import { getApiBalance } from "./getApiBalance.js"; export const getApiBalances = async ({ ctx, fullCus, }: { - ctx: RequestContext; + ctx: SharedContext; fullCus: FullCustomer; }): Promise<{ data: Record }> => { - const { org } = ctx; - const allCusEnts = fullCustomerToCustomerEntitlements({ fullCustomer: fullCus, - inStatuses: orgToInStatuses({ org }), + inStatuses: orgToInStatuses({ org: ctx.org }), entity: fullCus.entity, }); diff --git a/shared/utils/cusUtils/fullCusUtils/fullCustomerToAvailableOverage.ts b/shared/utils/cusUtils/fullCusUtils/fullCustomerToAvailableOverage.ts new file mode 100644 index 000000000..4398917b5 --- /dev/null +++ b/shared/utils/cusUtils/fullCusUtils/fullCustomerToAvailableOverage.ts @@ -0,0 +1,102 @@ +import { findFeatureById } from "@utils/featureUtils/index.js"; +import type { ApiSubjectV0 } from "../../../api/customers/apiSubjectV0.js"; +import { apiBalanceV1ToAvailableOverage } from "../../../api/customers/cusFeatures/utils/convert/apiBalanceV1ToAvailableOverage.js"; +import { getApiBalance } from "../../../api/customers/cusFeatures/utils/getApiBalance.js"; +import type { Entity } from "../../../models/cusModels/entityModels/entityModels.js"; +import type { FullCustomer } from "../../../models/cusModels/fullCusModel.js"; +import type { SharedContext } from "../../../types/sharedContext.js"; +import { orgToInStatuses } from "../../orgUtils/convertOrgUtils.js"; +import { fullCustomerToCustomerEntitlements } from "./fullCustomerToCustomerEntitlements.js"; + +const getApiSubject = ({ + fullCustomer, + entity, +}: { + fullCustomer: FullCustomer; + entity?: Entity; +}): ApiSubjectV0 => + entity + ? ({ + billing_controls: { + spend_limits: entity.spend_limits ?? undefined, + }, + } as ApiSubjectV0) + : ({ + billing_controls: { + spend_limits: fullCustomer.spend_limits ?? undefined, + }, + } as ApiSubjectV0); + +export const fullCustomerToAvailableOverage = ({ + ctx, + fullCustomer, + featureIds, + internalEntityId, +}: { + ctx: SharedContext; + fullCustomer: FullCustomer; + featureIds: string[]; + internalEntityId?: string; +}) => { + const entity = internalEntityId + ? fullCustomer.entities?.find( + (candidate) => candidate.internal_id === internalEntityId, + ) + : fullCustomer.entity; + const uniqueFeatureIds = [...new Set(featureIds)]; + + if (uniqueFeatureIds.length === 0) { + return {}; + } + + const scopedFullCustomer = entity + ? { + ...fullCustomer, + entity, + } + : fullCustomer; + const apiSubject = getApiSubject({ + fullCustomer, + entity, + }); + const availableOverageByFeatureId: Record = {}; + + for (const featureId of uniqueFeatureIds) { + const feature = findFeatureById({ + features: ctx.features, + featureId, + }); + + if (!feature) continue; + + const customerEntitlements = fullCustomerToCustomerEntitlements({ + fullCustomer, + featureId, + entity, + inStatuses: orgToInStatuses({ org: ctx.org }), + }); + + if (customerEntitlements.length === 0) { + continue; + } + + const { data: apiBalance } = getApiBalance({ + ctx, + fullCus: scopedFullCustomer, + cusEnts: customerEntitlements, + feature, + }); + + const availableOverage = apiBalanceV1ToAvailableOverage({ + apiBalance, + apiSubject, + feature, + }); + + if (availableOverage === undefined) continue; + + availableOverageByFeatureId[featureId] = availableOverage; + } + + return availableOverageByFeatureId; +}; diff --git a/server/src/internal/balances/autoTopUp/helpers/fullCustomerToSpendLimit.ts b/shared/utils/cusUtils/fullCusUtils/fullCustomerToSpendLimit.ts similarity index 52% rename from server/src/internal/balances/autoTopUp/helpers/fullCustomerToSpendLimit.ts rename to shared/utils/cusUtils/fullCusUtils/fullCustomerToSpendLimit.ts index 4fc5c42cc..e4acb4f6a 100644 --- a/server/src/internal/balances/autoTopUp/helpers/fullCustomerToSpendLimit.ts +++ b/shared/utils/cusUtils/fullCusUtils/fullCustomerToSpendLimit.ts @@ -1,6 +1,7 @@ -import type { DbSpendLimit, FullCustomer } from "@autumn/shared"; +import type { DbSpendLimit } from "@models/cusModels/billingControls/customerBillingControls.js"; +import type { FullCustomer } from "@models/cusModels/fullCusModel.js"; -/** Extract the enabled spend limit for a given feature from a FullCustomer. Returns undefined if none found. */ +/** Extract the enabled spend limit for a given feature from a FullCustomer. */ export const fullCustomerToSpendLimit = ({ fullCustomer, featureId, @@ -10,14 +11,23 @@ export const fullCustomerToSpendLimit = ({ featureId: string; internalEntityId?: string; }): DbSpendLimit | undefined => { + const entity = internalEntityId + ? fullCustomer.entities?.find( + (candidate) => candidate.internal_id === internalEntityId, + ) + : fullCustomer.entity; + if (internalEntityId) { - fullCustomer.entity = fullCustomer.entities.find( - (entity) => entity.id === internalEntityId, + return entity?.spend_limits?.find( + (spendLimit) => + spendLimit.feature_id === featureId && + spendLimit.enabled && + spendLimit.overage_limit !== undefined, ); } - if (fullCustomer.entity) { - return fullCustomer.entity.spend_limits?.find( + if (entity) { + return entity.spend_limits?.find( (spendLimit) => spendLimit.feature_id === featureId && spendLimit.enabled && diff --git a/shared/utils/cusUtils/index.ts b/shared/utils/cusUtils/index.ts index 8a45f80c6..981e6b6ae 100644 --- a/shared/utils/cusUtils/index.ts +++ b/shared/utils/cusUtils/index.ts @@ -3,5 +3,7 @@ export * from "./cusPlanUtils/cusPlanUtils"; // Full cus utils export * from "./fullCusUtils/enrichFullCustomer"; +export * from "./fullCusUtils/fullCustomerToAvailableOverage"; export * from "./fullCusUtils/fullCustomerToCustomerEntitlements"; +export * from "./fullCusUtils/fullCustomerToSpendLimit"; export * from "./fullCusUtils/getCusStripeSubCount"; diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 000000000..cfed9651c --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "skills": { + "openlogs-server-logs": { + "source": "charlietlamb/openlogs", + "sourceType": "github", + "computedHash": "8f05f0f8c7a0dbdd0b274ae4cb76cb887cc3ca794310907cc15f158c50bfd704" + } + } +}