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:
John Yeo
2026-03-12 12:03:28 +00:00
committed by GitHub
31 changed files with 2483 additions and 400 deletions

View 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.

View 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
View File

@@ -126,3 +126,6 @@ others/python-sdk/docs
packages/sdk/docs
TAKEHOME.md
.openlogs

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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;

View File

@@ -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;

View File

@@ -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[];

View File

@@ -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.

View File

@@ -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";
/**

View File

@@ -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";
/**

View File

@@ -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({

View File

@@ -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(

View File

@@ -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,
},
});
});

View File

@@ -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,
},
});
});

View File

@@ -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 },
),
});
});

View 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 },
),
});
});

View File

@@ -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),
);
};

View File

@@ -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,
});
};

View File

@@ -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";

View File

@@ -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",

View File

@@ -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,
},

View File

@@ -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,
});

View File

@@ -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;
};

View File

@@ -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 &&

View File

@@ -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
View File

@@ -0,0 +1,10 @@
{
"version": 1,
"skills": {
"openlogs-server-logs": {
"source": "charlietlamb/openlogs",
"sourceType": "github",
"computedHash": "8f05f0f8c7a0dbdd0b274ae4cb76cb887cc3ca794310907cc15f158c50bfd704"
}
}
}