added entity spend limit to track (#929)
## Summary <!-- Provide a short summary of your changes and the motivation behind them. --> ## Related Issues <!-- List any related issues, e.g. Fixes #123 or Closes #456 --> ## Type of Change - [ ] Bug fix - [ ] New feature - [ ] Breaking change - [ ] Refactor - [ ] Other (please describe): ## Checklist - [ ] I have read the [CONTRIBUTING.md](https://github.com/useautumn/autumn/blob/staging/.github/CONTRIBUTING.md) - [ ] My code follows the code style of this project - [ ] I have added tests where applicable - [ ] I have tested my changes locally - [ ] I have linked relevant issues - [ ] I have added screenshots for UI changes (if applicable) ## Screenshots (if applicable) <!-- Add before/after screenshots or GIFs here --> ## Additional Context <!-- Add any other context or information about the PR here --> <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Adds entity spend limit enforcement to the track path, capping per-feature overage at entity and entity-product levels when recording usage. Aligns Redis and Postgres deduction logic and adds coverage with new integration tests. - **New Features** - Enforce entity spend limits during track by capping overage per feature and per-entity product. - Compute available overage per feature and pass as `available_overage_by_feature_id` through `prepareFeatureDeduction`, Redis/Postgres execution, Lua, and SQL deduction paths. - Added integration tests for Redis and Postgres paths, including cross-entity scenarios. - **Refactors** - Moved `getApiBalance` and `getApiBalances` into `shared/api/customers/cusFeatures` and updated imports. - Added `fullCustomerToAvailableOverage` and streamlined spend-limit utils; minor typing and import cleanups. - Added `openlogs-server-logs` skill and ignored `.openlogs` in `.gitignore`. <sup>Written for commit d8afe1eefa182d1af4f5a7df52cafb123ef5a680. Summary will update on new commits.</sup> <!-- End of auto-generated description by cubic. --> <!-- greptile_comment --> <details><summary><h3>Greptile Summary</h3></summary> This PR enforces entity-level spend limits during the track path by computing available overage per feature before deduction and propagating it through both the Redis (Lua) and Postgres (SQL) execution paths. It also moves `getApiBalance`/`getApiBalances` to `shared/` (widening the context type from `RequestContext` to `SharedContext`), refactors `fullCustomerToSpendLimit` to fix a mutation side-effect and an incorrect entity lookup (`entity.id` → `entity.internal_id`), and adds comprehensive integration tests for both execution paths. **Key changes:** - **Improvements** — New `fullCustomerToAvailableOverage` utility computes remaining overage budget per feature for an entity or customer; result is threaded as `available_overage_by_feature_id` through `prepareFeatureDeduction` → `executeRedisDeduction`/`executePostgresDeduction` → Lua/SQL. - **Bug fixes** — Pass 1 deduction in SQL and Lua now uses `GREATEST(balance, 0)` to prevent a negative balance from producing a negative (i.e. upward) deduction. - **Bug fixes** — `fullCustomerToSpendLimit` no longer mutates `fullCustomer.entity` as a side-effect; entity lookup corrected from `entity.id` to `entity.internal_id`. - **Improvements** — `getApiBalance`, `getApiBalances`, and `apiBalanceUtils` moved to `shared/api/customers/cusFeatures/` so they can be consumed by shared utilities without a server dependency. - **Improvements** — Duplicated test helpers (`setEntitySpendLimit`, `normalizeCheckResponse`, `expectBoundaryAndParity`) extracted into `entitySpendLimitUtils.ts` and `checkSpendLimitUtils.ts` and removed from individual test files. One logic concern worth addressing before merging: in both `deductFromMainBalance.sql` and `deductFromMainBalance.lua`, the Pass-2 branch that handles `available_overage` completely bypasses the `min_balance` constraint (derived from the product's `max_purchase`). If an entity spend limit is set higher than the product's max_purchase, the balance can be driven below `min_balance`, effectively letting an entity exceed the product-level hard cap. </details> <h3>Confidence Score: 2/5</h3> - Not yet safe to merge — the `min_balance` bypass in both the SQL and Lua deduction paths needs to be addressed to prevent product-cap violations. - The spend limit plumbing is well-structured and the new tests give good coverage of the happy paths. However, both `deductFromMainBalance.sql` and `deductFromMainBalance.lua` contain the same logic bug: when `available_overage` is set in Pass 2, the `min_balance` (product max_purchase) constraint is silently ignored. If an entity spend limit is configured higher than the product's max_purchase, a customer could be billed beyond the product's hard overage cap. This is a correctness issue in the core billing engine, lowering confidence to 2. - `server/src/internal/balances/utils/sql/deductFromMainBalance.sql` and `server/src/_luaScriptsV2/deductFromCustomerEntitlements/deductFromMainBalance.lua` — both share the same `min_balance` bypass bug in their Pass-2 `allow_negative` branches. <h3>Important Files Changed</h3> | Filename | Overview | |----------|----------| | server/src/internal/balances/utils/sql/deductFromMainBalance.sql | Adds `available_overage` param to cap per-entity deductions based on entity spend limits in Pass 2; also fixes a Pass 1 bug with `GREATEST(balance, 0)`. Has a logic issue where `available_overage` completely bypasses the `min_balance` (product max_purchase) constraint, potentially allowing balance to drop below the product-level floor. | | server/src/internal/balances/utils/sql/performDeduction.sql | Threads `available_overage_by_feature_id` and `feature_id` through the Pass-2 deduction loop; correctly updates the map after each entitlement deduction; minor indentation inconsistency on the `overage_behavior_is_allow` line. | | shared/utils/cusUtils/fullCusUtils/fullCustomerToAvailableOverage.ts | New utility that computes available overage per feature for an entity or customer by building a scoped FullCustomer, fetching API balances, and delegating to `apiBalanceV1ToAvailableOverage`. Logic appears sound; correctly handles both entity-scoped and customer-scoped paths. | | shared/utils/cusUtils/fullCusUtils/fullCustomerToSpendLimit.ts | Refactored to remove the mutation of `fullCustomer.entity` side-effect; also fixes an incorrect entity lookup (`entity.id` → `entity.internal_id`). Clean improvement. | | server/src/internal/balances/utils/deduction/prepareFeatureDeduction.ts | Computes `availableOverageByFeatureId` via the new utility and adds `feature_id` to each `CustomerEntitlementDeduction`; returns the map only when non-empty. Relies on `fullCustomer.entity` being pre-scoped by the caller, which appears to be the convention throughout the track path. | | server/src/_luaScriptsV2/deductFromCustomerEntitlements/runDeductionOnContext.lua | Propagates `available_overage_by_feature_id` into both passes; resolves per-entitlement `available_overage` in Pass 2 only (correct); updates the map after each deduction. Has a minor redundant nil guard. | | server/src/_luaScriptsV2/deductFromCustomerEntitlements/deductFromMainBalance.lua | Mirrors the SQL bug: in Pass 2, `available_overage` takes priority over `min_balance` with no combined constraint check, which can allow deductions past the product-level floor. Also carries `available_overage` through entity-scope loop and decrements `remaining_available_overage` correctly. | | shared/api/customers/cusFeatures/utils/getApiBalance.ts | Moved from `server/src/internal/...` to `shared/`; key change is `RequestContext` → `SharedContext` to allow use outside the server. Inlines a copy of `getUnlimitedAndUsageAllowed` to remove the server-specific import. Code logic unchanged. | </details> <details><summary><h3>Sequence Diagram</h3></summary> ```mermaid sequenceDiagram participant Track as Track Handler participant PFD as prepareFeatureDeduction participant FCAO as fullCustomerToAvailableOverage participant ERD as executeRedisDeduction participant EPD as executePostgresDeduction participant Lua as Lua Script (Redis) participant SQL as SQL Function (Postgres) Track->>PFD: fullCustomer, features, entityId PFD->>FCAO: fullCustomer, featureIds FCAO-->>PFD: availableOverageByFeatureId {featureId: credits} PFD-->>Track: customerEntitlementDeductions + availableOverageByFeatureId alt Redis path (cached) Track->>ERD: deductions + availableOverageByFeatureId ERD->>Lua: sorted_entitlements + available_overage_by_feature_id Note over Lua: Pass 1: deduct to 0<br/>Pass 2: deduct into overage<br/>capped by available_overage per feature_id Lua-->>ERD: deduction results else Postgres path (skipCache) Track->>EPD: deductions + availableOverageByFeatureId EPD->>SQL: sorted_entitlements + available_overage_by_feature_id Note over SQL: Same two-pass logic<br/>available_overage resolved per ent_feature_id<br/>map updated after each entitlement loop SQL-->>EPD: deduction results end ``` </details> <!-- greptile_failed_comments --> <details><summary><h3>Comments Outside Diff (2)</h3></summary> 1. `server/src/internal/balances/utils/sql/performDeduction.sql`, line 502-503 ([link](d8afe1eefa/server/src/internal/balances/utils/sql/performDeduction.sql (L502-L503))) **Inconsistent indentation within the same `jsonb_build_object` call** In the Pass-1 call to `deduct_from_main_balance`, all keys except the final one were re-indented from 6 to 8 spaces, but `'overage_behavior_is_allow'` was left at the old indentation level: ```sql 'alter_granted_balance', alter_granted_balance, 'overage_behavior_is_allow', overage_behavior_is_allow ← 6-space indent ``` This creates a visual break inside a single expression and makes it harder to scan which keys belong together. <details><summary>Prompt To Fix With AI</summary> `````markdown This is a comment left during a code review. Path: server/src/internal/balances/utils/sql/performDeduction.sql Line: 502-503 Comment: **Inconsistent indentation within the same `jsonb_build_object` call** In the Pass-1 call to `deduct_from_main_balance`, all keys except the final one were re-indented from 6 to 8 spaces, but `'overage_behavior_is_allow'` was left at the old indentation level: ```sql 'alter_granted_balance', alter_granted_balance, 'overage_behavior_is_allow', overage_behavior_is_allow ← 6-space indent ``` This creates a visual break inside a single expression and makes it harder to scan which keys belong together. How can I resolve this? If you propose a fix, please make it concise. ````` </details> <sub>Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!</sub> 2. `server/src/_luaScriptsV2/deductFromCustomerEntitlements/runDeductionOnContext.lua`, line 223-233 ([link](d8afe1eefa/server/src/_luaScriptsV2/deductFromCustomerEntitlements/runDeductionOnContext.lua (L223-L233))) **`available_overage` guard condition could allow stale map entry** After deducting, the map is only updated when `deducted > 0 AND available_overage IS NOT NULL`. If `available_overage` resolves to `0` for a feature (spend limit fully consumed by an earlier entitlement in the same pass), the map retains the previous `0` value, which is fine. But when `deducted == 0` with a non-nil `available_overage`, no update occurs, which is also fine. There is however a subtle edge case: the condition checks `not is_nil(available_overage)` but `available_overage` was already confirmed non-nil at assignment time (the outer `if` already required it). The redundant check is harmless but adds noise; consider simplifying to just `if deducted > 0 and not is_nil(available_overage_by_feature_id) and not is_nil(ent_feature_id) then`. <details><summary>Prompt To Fix With AI</summary> `````markdown This is a comment left during a code review. Path: server/src/_luaScriptsV2/deductFromCustomerEntitlements/runDeductionOnContext.lua Line: 223-233 Comment: **`available_overage` guard condition could allow stale map entry** After deducting, the map is only updated when `deducted > 0 AND available_overage IS NOT NULL`. If `available_overage` resolves to `0` for a feature (spend limit fully consumed by an earlier entitlement in the same pass), the map retains the previous `0` value, which is fine. But when `deducted == 0` with a non-nil `available_overage`, no update occurs, which is also fine. There is however a subtle edge case: the condition checks `not is_nil(available_overage)` but `available_overage` was already confirmed non-nil at assignment time (the outer `if` already required it). The redundant check is harmless but adds noise; consider simplifying to just `if deducted > 0 and not is_nil(available_overage_by_feature_id) and not is_nil(ent_feature_id) then`. How can I resolve this? If you propose a fix, please make it concise. ````` </details> </details> <!-- /greptile_failed_comments --> <sub>Last reviewed commit: d8afe1e</sub> > Greptile also left **1 inline comment** on this PR. <!-- /greptile_comment -->
This commit is contained in:
56
.agents/skills/openlogs-server-logs/SKILL.md
Normal file
56
.agents/skills/openlogs-server-logs/SKILL.md
Normal file
@@ -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 <query> -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 <query> -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 <command>` or `ol <command>`.
|
||||
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 <query>` 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.
|
||||
4
.agents/skills/openlogs-server-logs/agents/openai.yaml
Normal file
4
.agents/skills/openlogs-server-logs/agents/openai.yaml
Normal file
@@ -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 <query>."
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -126,3 +126,6 @@ others/python-sdk/docs
|
||||
packages/sdk/docs
|
||||
|
||||
TAKEHOME.md
|
||||
|
||||
|
||||
.openlogs
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, number>;
|
||||
// rolloverIds: string[];
|
||||
rollovers: RolloverDeduction[];
|
||||
unlimitedFeatureIds: string[];
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
/**
|
||||
|
||||
@@ -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";
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<ReturnType<typeof initScenario>>["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<CheckResponseV3>({
|
||||
customer_id: customerId,
|
||||
entity_id: entityId,
|
||||
feature_id: featureId,
|
||||
required_balance: allowedRequiredBalance,
|
||||
});
|
||||
|
||||
const blockedCached = await autumn.check<CheckResponseV3>({
|
||||
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<CheckResponseV3>({
|
||||
customer_id: customerId,
|
||||
entity_id: entityId,
|
||||
feature_id: featureId,
|
||||
required_balance: allowedRequiredBalance,
|
||||
skip_cache: true,
|
||||
});
|
||||
|
||||
const blockedUncached = await autumn.check<CheckResponseV3>({
|
||||
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({
|
||||
|
||||
@@ -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<ReturnType<typeof initScenario>>["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<CheckResponseV3>({
|
||||
customer_id: customerId,
|
||||
entity_id: entityId,
|
||||
feature_id: featureId,
|
||||
required_balance: allowedRequiredBalance,
|
||||
});
|
||||
|
||||
const blockedCached = await autumn.check<CheckResponseV3>({
|
||||
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<CheckResponseV3>({
|
||||
customer_id: customerId,
|
||||
entity_id: entityId,
|
||||
feature_id: featureId,
|
||||
required_balance: allowedRequiredBalance,
|
||||
skip_cache: true,
|
||||
});
|
||||
|
||||
const blockedUncached = await autumn.check<CheckResponseV3>({
|
||||
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(
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
),
|
||||
});
|
||||
});
|
||||
105
server/tests/integration/balances/track/track-postgres.test.ts
Normal file
105
server/tests/integration/balances/track/track-postgres.test.ts
Normal file
@@ -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<ApiCustomerV5>(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<ApiCustomerV5>(
|
||||
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 },
|
||||
),
|
||||
});
|
||||
});
|
||||
@@ -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<CheckResponseV3>({
|
||||
customer_id: customerId,
|
||||
entity_id: entityId,
|
||||
feature_id: featureId,
|
||||
required_balance: allowedRequiredBalance,
|
||||
});
|
||||
|
||||
const blockedCached = await autumn.check<CheckResponseV3>({
|
||||
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<CheckResponseV3>({
|
||||
customer_id: customerId,
|
||||
entity_id: entityId,
|
||||
feature_id: featureId,
|
||||
required_balance: allowedRequiredBalance,
|
||||
skip_cache: true,
|
||||
});
|
||||
|
||||
const blockedUncached = await autumn.check<CheckResponseV3>({
|
||||
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),
|
||||
);
|
||||
};
|
||||
@@ -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<typeof initScenario>
|
||||
>["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<ApiEntityV2>(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<ApiCustomerV5>(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<CheckResponseV3>({
|
||||
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,
|
||||
});
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
@@ -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,
|
||||
},
|
||||
@@ -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<string, ApiBalanceV1> }> => {
|
||||
const { org } = ctx;
|
||||
|
||||
const allCusEnts = fullCustomerToCustomerEntitlements({
|
||||
fullCustomer: fullCus,
|
||||
inStatuses: orgToInStatuses({ org }),
|
||||
inStatuses: orgToInStatuses({ org: ctx.org }),
|
||||
entity: fullCus.entity,
|
||||
});
|
||||
|
||||
@@ -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<string, number> = {};
|
||||
|
||||
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;
|
||||
};
|
||||
@@ -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 &&
|
||||
@@ -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";
|
||||
|
||||
10
skills-lock.json
Normal file
10
skills-lock.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"openlogs-server-logs": {
|
||||
"source": "charlietlamb/openlogs",
|
||||
"sourceType": "github",
|
||||
"computedHash": "8f05f0f8c7a0dbdd0b274ae4cb76cb887cc3ca794310907cc15f158c50bfd704"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user