completed redis lock release functions

This commit is contained in:
John Yeo
2026-03-08 21:04:34 +00:00
parent fb073b6e52
commit 6de5947e0a
53 changed files with 2427 additions and 438 deletions

View File

@@ -0,0 +1,323 @@
---
name: mutation-logs
description: Reference for Autumn balance mutation logs, lock receipts, ordered deduction provenance, and finalizeLock reconciliation semantics. Use when working on lock receipts, deduction provenance, mutation-log sync, or finalize/release flows.
---
# Mutation Logs Guide
## Summary
Autumn now treats ordered `mutation_logs` as the provenance source for balance deductions.
This replaces the earlier idea of encoding provenance inside:
- `DeductionUpdate.balance_delta`
- `DeductionUpdate.adjustment_delta`
- `DeductionUpdate.entity_deltas`
- `RolloverUpdate.balance_delta`
- `RolloverUpdate.usage_delta`
- `RolloverUpdate.entity_deltas`
Those additive delta fields were useful as an intermediate step, but they are not the right long-term model because they are:
- aggregated
- unordered
- not safe for reverse replay of partial lock releases
The correct source of truth is an ordered array of per-write mutation log items.
## Core Model
There are now two separate outputs from deduction:
### 1. Final-state updates
These remain:
- `DeductionUpdate`
- `RolloverUpdate`
They are for:
- applying updated balances to `FullCustomer`
- sync batching
- existing response helpers
They should describe final post-deduction state only.
### 2. Ordered mutation logs
These are the provenance layer.
They are for:
- lock receipt persistence
- reverse-order unwind in `finalizeLock`
- future mutation-log replay to Postgres
They must preserve the exact order in which Redis deductions were queued.
## Mutation Log Item Shape
TypeScript shape:
```ts
type MutationLogItem = {
target_type: "customer_entitlement" | "rollover";
customer_entitlement_id: string | null;
rollover_id: string | null;
entity_id: string | null;
balance_delta: number;
adjustment_delta: number;
usage_delta: number;
value_delta: number;
};
```
Field meanings:
- `target_type`
- whether the write targeted a `customer_entitlement` or a rollover
- `customer_entitlement_id`
- required for main balance writes and rollover parent linkage
- `rollover_id`
- present only for rollover items
- `entity_id`
- present for entity-scoped writes
- `balance_delta`
- exact Redis balance delta applied
- `adjustment_delta`
- exact granted/adjustment delta applied
- `usage_delta`
- exact rollover usage delta applied
- `value_delta`
- feature-unit amount represented by this step
## Why `value_delta` Exists
`balance_delta` is in credits.
`value_delta` is in the features own logical units.
Example:
- feature usage = `5`
- `credit_cost = 2`
- actual Redis balance change = `-10`
Then:
- `balance_delta = -10`
- `value_delta = 5`
This is needed because `finalizeLock` reconciles in feature/value units, not raw credits.
For one receipt, total locked value is:
```ts
sum(item.value_delta for item in receipt.items)
```
This total is signed:
- positive for deductions / tracked usage
- negative for refunds / credits
## Where Mutation Logs Are Created
Ordered mutation logs are appended during Lua deduction, not reconstructed later.
### Source of truth
`server/src/_luaScriptsV2/deductFromCustomerEntitlements/contextUtils.lua`
`init_context(...)` creates:
- `context.mutation_logs = {}`
### Append points
Mutation logs are appended from:
- `queue_balance_update(...)`
- `queue_rollover_update(...)`
This is the correct abstraction boundary because these functions already know:
- which logical bucket is being changed
- the exact Redis deltas
- the order in which writes are queued
Do not rebuild receipt items later from `updates` / `rollover_updates`.
That loses order.
## Lock Receipt Rules
Lock receipts are now stored from ordered mutation logs directly.
Relevant file:
- `server/src/_luaScriptsV2/deduction/lock/lockReceipt.lua`
Current rule:
- `receipt.items = mutation_logs`
not:
- rebuild from `updates`
- rebuild from `rollover_updates`
Lock receipts also store both:
- `lock_key`
- `hashed_key`
because:
- `lock_key` is the caller-facing logical key
- `hashed_key` is used to derive the Redis receipt key
## Redis Key Rules
The Redis receipt key is built from the hashed key, not the raw key.
Relevant helper:
- `server/src/internal/balances/utils/lock/buildLockReceiptKey.ts`
Current format:
```ts
`{${orgId}}:${env}:lock:${lockKey}`
```
The braces around `orgId` are intentional so the receipt key hashes to the same Redis cluster slot as the full-customer cache key.
## Lock Key Parsing Rules
Relevant helper:
- `server/src/internal/balances/utils/lock/parseCheckParamsForLock.ts`
Rules:
- if caller passes `lock.key`, keep it as the logical `key`
- also compute `hashed_key = Bun.hash(key).toString()`
- if no key is passed, generate a KSUID logical key and hash that
- if `key.length > 256`, throw
This means:
- user-facing API returns the logical `lock_key`
- internal Redis receipt storage uses the hashed key
## FinalizeLock Mental Model
Do not think in terms of “refund vs deduct”.
Think in terms of:
- current locked value
- desired final value
- reconcile from one to the other
### Correct abstraction
`finalizeLock` should reconcile from `locked_value` to `final_value`.
Cases:
1. Same sign, smaller magnitude
- unwind part of the existing receipt
- walk receipt items backward
2. Same sign, larger magnitude
- keep the existing receipt as-is
- deduct/refund the extra delta using the normal engine
3. Cross zero
- fully unwind existing receipt back to zero
- apply the remaining amount in the opposite direction using the normal engine
### Important rule
Do not implement finalize as:
- reverse the whole receipt
- re-run normal deduction for the final amount
That is not safe for provenance correctness when balances changed after the original lock.
## Why Ordered Logs Matter
Example original lock order:
- hourly `10`
- monthly `5`
- lifetime `2`
If finalize wants to reduce the locked value, the unwind order must be:
- lifetime first
- monthly second
- hourly last
This only works if receipt items are stored in actual deduction order.
Aggregated update maps cannot guarantee that.
## Redis vs Postgres Status
### Redis path
Redis deduction is now the authoritative ordered provenance path.
Relevant files:
- `server/src/_luaScriptsV2/deductFromCustomerEntitlements/contextUtils.lua`
- `server/src/_luaScriptsV2/deductFromCustomerEntitlements/deductFromMainBalance.lua`
- `server/src/_luaScriptsV2/deductFromCustomerEntitlements/deductFromRollovers.lua`
- `server/src/_luaScriptsV2/deductFromCustomerEntitlements/deductFromCustomerEntitlements.lua`
- `server/src/internal/balances/utils/deduction/executeRedisDeduction.ts`
`executeRedisDeduction` now exposes:
- `updates`
- `rolloverUpdates`
- `mutationLogs`
### Postgres path
Postgres deduction has not yet been upgraded to emit real ordered mutation logs.
Current behavior:
- `executePostgresDeduction` exposes `mutationLogs: []`
This is a compatibility placeholder so callers can converge on one return shape.
Future work:
- `performDeduction.sql` should emit ordered mutation log items directly
- do not reconstruct them later from final SQL updates
## Current Invariants
- lock receipts must persist ordered mutation logs directly
- mutation logs must be appended at the moment writes are queued
- final-state updates and mutation provenance are separate structures
- `value_delta` is required for partial reconcile logic
- Redis receipt keys use hashed keys and shared-slot formatting
- finalize must unwind receipt items backward for partial release
## When Editing This System
If you change deduction behavior, always check:
1. Are ordered mutation logs still appended in the true write order?
2. Does each mutation item still include correct `value_delta`?
3. Are lock receipts still persisted from `mutation_logs`, not rebuilt?
4. Did any change accidentally reintroduce provenance into aggregated update maps?
5. If touching Postgres deduction, did the SQL path preserve parity with the Redis executor return shape?

View File

@@ -0,0 +1,285 @@
# Deduction Result Shapes
## Summary
Additive delta/provenance fields need to be part of our deduction result objects so they can serve both:
- lock receipts now
- mutation log items later
The key rule is:
- keep the existing final-state fields unchanged in meaning
- add delta fields beside them
- compute those delta fields during deduction, not by diffing final snapshots later
This applies to both the Redis Lua path and the Postgres SQL path.
## Why This Change Is Needed
Today, our deduction results are mostly final-state objects.
For example, `DeductionUpdate` gives us:
- final `balance`
- final `adjustment`
- final `entities`
- aggregate `deducted`
That is enough for current snapshot-style consumers, but it is not enough for:
- exact lock receipts
- exact release/refund replay
- future mutation logs
What we need in addition is:
- top-level `balance_delta`
- top-level `adjustment_delta`
- sparse per-entity deltas
- rollover `balance_delta`
- rollover `usage_delta`
- parent `customer_entitlement` linkage for rollover updates
Without these fields, lock receipt creation would need to reconstruct provenance from final snapshots, which is more fragile than recording the change directly when the deduction happens.
## Result Shape Changes
### `DeductionUpdate`
Keep existing final-state fields:
- `balance`
- `additional_balance`
- `adjustment`
- `entities`
- `deducted`
Add:
```ts
balance_delta?: number;
adjustment_delta?: number;
entity_deltas?: Record<
string,
{
balance_delta: number;
adjustment_delta: number;
}
>;
```
Meaning:
- `balance`, `adjustment`, `entities`
- final post-deduction state
- `balance_delta`, `adjustment_delta`
- top-level change on the `customer_entitlement`
- `entity_deltas`
- sparse per-entity changes
### `RolloverUpdate`
Move `RolloverUpdate` into:
- `server/src/internal/balances/utils/types/rolloverUpdate.ts`
Keep existing final-state fields:
- `balance`
- `usage`
- `entities`
Add:
```ts
cus_ent_id?: string;
balance_delta?: number;
usage_delta?: number;
entity_deltas?: Record<
string,
{
balance_delta: number;
usage_delta: number;
}
>;
```
Meaning:
- `balance`, `usage`, `entities`
- final post-deduction rollover state
- `cus_ent_id`
- parent `customer_entitlement`
- `balance_delta`, `usage_delta`
- top-level rollover changes
- `entity_deltas`
- sparse per-entity rollover changes
## Shared Mutation Item Shape
We should treat the normalized `MutationItem` shape as the common bridge between:
- deduction results
- lock receipt `items`
- future mutation log `items`
Conceptual shape:
```ts
type MutationItem = {
target_type: "customer_entitlement" | "rollover";
customer_entitlement_id: string | null;
rollover_id: string | null;
entity_id: string | null;
balance_delta: number;
adjustment_delta: number;
usage_delta: number;
};
```
Lua conversion helpers should live in:
- `server/src/_luaScriptsV2/deduction/mutationItemUtils.lua`
Functions:
- `deduction_update_to_mutation_items(...)`
- `rollover_update_to_mutation_items(...)`
- `deduction_results_to_mutation_items(...)`
These functions should be used by:
- lock receipt creation now
- mutation log creation later
## Redis / Lua Path
The Redis Lua path should populate delta fields at write time.
### Core rule
Record deltas when we queue the write.
Do not reconstruct deltas later from final balances or final `entities` blobs.
### Main balance path
`queue_balance_update(...)` should:
1. queue the `JSON.NUMINCRBY` writes
2. update additive delta fields on the relevant `customer_entitlement`
Recommended in-memory context additions on `context.customer_entitlements[ent_id]`:
- `balance_delta`
- `adjustment_delta`
- `entity_deltas`
### Rollover path
`queue_rollover_update(...)` should:
1. queue the `balance` and `usage` writes
2. update additive delta fields on the relevant rollover
Recommended in-memory context additions on `context.rollovers[rollover_id]`:
- `cus_ent_id`
- `balance_delta`
- `usage_delta`
- `entity_deltas`
### Final Lua return value
When `deductFromCustomerEntitlements.lua` builds `updates` and `rollover_updates`, it should include:
- existing final-state fields
- the new delta fields
That keeps the return payload backward-compatible while making it rich enough for lock receipts and later mutation logs.
## Postgres / SQL Path
The Postgres deduction path should return the same conceptual shape as the Lua path.
Primary target:
- `server/src/internal/balances/utils/sql/performDeduction.sql`
### Required customer entitlement fields
For each `customer_entitlement`, SQL should return:
- `balance`
- `additional_balance`
- `adjustment`
- `entities`
- `deducted`
- `balance_delta`
- `adjustment_delta`
- `entity_deltas`
### Required rollover fields
For each rollover, SQL should return:
- `cus_ent_id`
- `balance`
- `usage`
- `entities`
- `balance_delta`
- `usage_delta`
- `entity_deltas`
### SQL computation rule
Just like the Lua path, SQL should compute these deltas during the deduction process.
It should not try to infer them afterward by diffing final snapshots.
That means `performDeduction.sql` and any helper SQL it uses should explicitly track:
- top-level `customer_entitlement` balance changes
- top-level adjustment changes
- per-entity balance / adjustment changes
- rollover balance / usage changes
- per-entity rollover balance / usage changes
## Compatibility
Existing snapshot-based consumers should keep working without reading the new delta fields.
This includes:
- `applyDeductionUpdateToFullCustomer`
- `applyRolloverUpdatesToFullCustomer`
- cache sync helpers
- logging helpers
- allocated invoice flows
Compatibility rule:
- current final-state fields remain authoritative for existing behavior
- new delta fields are additive only
## Test Cases
- Top-level deduction returns final `balance` plus `balance_delta`.
- Entity-scoped deduction returns final `entities` plus sparse `entity_deltas`.
- Granted-balance changes return matching `adjustment_delta`.
- Rollover deduction returns final `balance` / `usage` plus `balance_delta` / `usage_delta`.
- Entity-scoped rollover deduction returns sparse rollover `entity_deltas`.
- `deduction_results_to_mutation_items(...)` produces the expected flat `items` array from mixed updates and rollover updates.
- Reserve receipt creation can consume the same mutation item shape.
- Existing snapshot-based consumers continue to work while ignoring the new delta fields.
- Redis Lua and Postgres SQL paths converge on the same conceptual result shape.
## Assumptions
- This document covers deduction result shapes only, not full receipt persistence or finalize logic.
- Delta fields are additive and optional.
- Existing final-state fields must not change meaning.
- `MutationItem` is the shared intermediate format for:
- lock receipt items now
- mutation log items later

View File

@@ -1,6 +1,6 @@
{
"watch": ["../shared", "src"],
"ext": "js,ts",
"ext": "js,ts,lua",
"ignore": [
"../shared/dist/**/*.d.ts",
"../shared/node_modules",
@@ -13,4 +13,3 @@
"NODE_ENV": "development"
}
}

View File

@@ -12,12 +12,13 @@
params:
cache_key: string
sorted_entitlements: array of entitlement objects
customer_entitlement_ids: array of customer_entitlement IDs
full_customer: decoded FullCustomer object
Returns: context table with:
customer_entitlements: { [cus_ent_id]: { base_path, balance, adjustment, entities } }
rollovers: { [rollover_id]: { base_path, balance, usage, entities } }
rollovers: { [rollover_id]: { base_path, cus_ent_id, balance, usage, entities } }
mutation_logs: {} (ordered mutation items for receipts and replay)
pending_writes: {} (empty array to queue writes)
logs: {} (debug logs)
logger: { log(fmt, ...): function } (logger that appends to logs)
@@ -28,6 +29,7 @@ local function init_context(params)
local context = {
customer_entitlements = {},
rollovers = {},
mutation_logs = {},
pending_writes = {},
logs = logs,
logger = {
@@ -37,8 +39,7 @@ local function init_context(params)
},
}
for _, ent_obj in ipairs(params.sorted_entitlements) do
local ent_id = ent_obj.customer_entitlement_id
for _, ent_id in ipairs(params.customer_entitlement_ids or {}) do
local cus_ent, cus_product, ce_idx, cp_idx = find_entitlement(params.full_customer, ent_id)
if cus_ent then
@@ -56,7 +57,9 @@ local function init_context(params)
base_path = '$.customer_products[' .. cp_idx_0 .. '].customer_entitlements[' .. ce_idx_0 .. ']'
end
local has_entity_scope = ent_obj.entity_feature_id ~= nil and ent_obj.entity_feature_id ~= cjson.null
local entitlement = cus_ent.entitlement
local has_entity_scope = not is_nil(entitlement)
and not is_nil(entitlement.entity_feature_id)
local ent_data = {
base_path = base_path,
@@ -90,6 +93,7 @@ local function init_context(params)
if rollover_data then
context.rollovers[rollover.id] = {
base_path = rollover_path,
cus_ent_id = ent_id,
balance = rollover_data.balance,
usage = rollover_data.usage,
entities = rollover_data.entities,
@@ -105,111 +109,191 @@ local function init_context(params)
end
--[[
update_in_memory_customer_entitlement(params)
Updates balance (and optionally adjustment) in-memory on the context object.
params:
target: table (ent_data or entities table)
entity_id: string or nil (if entity-scoped)
delta: number (the change amount, negative = deduction)
alter_granted_balance: boolean (if true, also update adjustment)
append_mutation_log(params)
Appends one ordered mutation log entry for later receipt persistence and replay.
]]
local function update_in_memory_customer_entitlement(params)
local function append_mutation_log(params)
local context = params.context
table.insert(context.mutation_logs, {
target_type = params.target_type,
customer_entitlement_id = params.customer_entitlement_id or cjson.null,
rollover_id = params.rollover_id or cjson.null,
entity_id = params.entity_id or cjson.null,
credit_cost = params.credit_cost or 1,
balance_delta = params.balance_delta or 0,
adjustment_delta = params.adjustment_delta or 0,
usage_delta = params.usage_delta or 0,
value_delta = params.value_delta or 0,
})
end
--[[
update_in_memory_customer_entitlement_mutation(params)
Applies an arbitrary balance/adjustment mutation to an in-memory
customer_entitlement target.
]]
local function update_in_memory_customer_entitlement_mutation(params)
local target = params.target
local entity_id = params.entity_id
local delta = params.delta
local alter_granted_balance = params.alter_granted_balance
local balance_delta = params.balance_delta
local adjustment_delta = params.adjustment_delta
if balance_delta == nil then
balance_delta = params.delta or 0
end
if adjustment_delta == nil then
adjustment_delta = params.alter_granted_balance and balance_delta or 0
end
if entity_id then
if not target[entity_id] then
target[entity_id] = { balance = 0, adjustment = 0 }
end
target[entity_id].balance = (target[entity_id].balance or 0) + delta
if alter_granted_balance then
target[entity_id].adjustment = (target[entity_id].adjustment or 0) + delta
end
else
target.balance = (target.balance or 0) + delta
if alter_granted_balance then
target.adjustment = (target.adjustment or 0) + delta
end
target[entity_id].balance = (target[entity_id].balance or 0) + balance_delta
target[entity_id].adjustment = (target[entity_id].adjustment or 0) + adjustment_delta
return
end
target.balance = (target.balance or 0) + balance_delta
target.adjustment = (target.adjustment or 0) + adjustment_delta
end
--[[
queue_balance_update(params)
Queues a balance update to pending_writes and logs the action.
params:
context: table (context object)
path: string (JSON path to the balance field, WITHOUT .balance suffix)
delta: number (the change amount, negative = subtract from balance)
alter_granted_balance: boolean (if true, also queue adjustment update)
]]
local function queue_balance_update(params)
local context = params.context
local path = params.path
local delta = params.delta
-- Queue balance write
table.insert(context.pending_writes, { path = path .. '.balance', delta = delta })
-- Queue adjustment write if needed
if params.alter_granted_balance then
table.insert(context.pending_writes, { path = path .. '.adjustment', delta = delta })
end
end
update_in_memory_rollover_mutation(params)
--[[
queue_rollover_update(params)
Queues a rollover balance/usage update to pending_writes.
Rollovers track both balance (decrements) and usage (increments).
params:
context: table (context object)
path: string (JSON path to the rollover or entity, WITHOUT .balance/.usage suffix)
deduct_amount: number (positive amount to deduct from balance and add to usage)
Applies an arbitrary balance/usage mutation to an in-memory rollover target.
]]
local function queue_rollover_update(params)
local context = params.context
local path = params.path
local deduct_amount = params.deduct_amount
-- Queue balance decrement
table.insert(context.pending_writes, { path = path .. '.balance', delta = -deduct_amount })
-- Queue usage increment
table.insert(context.pending_writes, { path = path .. '.usage', delta = deduct_amount })
end
--[[
update_in_memory_rollover(params)
Updates balance and usage in-memory on a rollover object.
params:
target: table (rollover_data or entities table)
entity_id: string or nil (if entity-scoped)
deduct_amount: number (positive amount to deduct from balance and add to usage)
]]
local function update_in_memory_rollover(params)
local function update_in_memory_rollover_mutation(params)
local target = params.target
local entity_id = params.entity_id
local deduct_amount = params.deduct_amount
local balance_delta = params.balance_delta or 0
local usage_delta = params.usage_delta or 0
if entity_id then
if not target[entity_id] then
target[entity_id] = { balance = 0, usage = 0 }
end
target[entity_id].balance = (target[entity_id].balance or 0) - deduct_amount
target[entity_id].usage = (target[entity_id].usage or 0) + deduct_amount
else
target.balance = (target.balance or 0) - deduct_amount
target.usage = (target.usage or 0) + deduct_amount
target[entity_id].balance = (target[entity_id].balance or 0) + balance_delta
target[entity_id].usage = (target[entity_id].usage or 0) + usage_delta
return
end
target.balance = (target.balance or 0) + balance_delta
target.usage = (target.usage or 0) + usage_delta
end
--[[
queue_customer_entitlement_mutation(params)
Queues a generic customer_entitlement mutation into pending_writes and
mutation_logs.
]]
local function queue_customer_entitlement_mutation(params)
local context = params.context
local path = params.path
local balance_delta = params.balance_delta
local adjustment_delta = params.adjustment_delta
if balance_delta == nil then
balance_delta = params.delta or 0
end
if adjustment_delta == nil then
adjustment_delta = params.alter_granted_balance and balance_delta or 0
end
if balance_delta ~= 0 then
table.insert(context.pending_writes, { path = path .. '.balance', delta = balance_delta })
end
if adjustment_delta ~= 0 then
table.insert(context.pending_writes, { path = path .. '.adjustment', delta = adjustment_delta })
end
append_mutation_log({
context = context,
target_type = 'customer_entitlement',
customer_entitlement_id = params.customer_entitlement_id,
rollover_id = nil,
entity_id = params.entity_id,
credit_cost = params.credit_cost or 1,
balance_delta = balance_delta,
adjustment_delta = adjustment_delta,
usage_delta = 0,
value_delta = params.value_delta or 0,
})
end
--[[
queue_rollover_mutation(params)
Queues a generic rollover mutation into pending_writes and mutation_logs.
]]
local function queue_rollover_mutation(params)
local context = params.context
local path = params.path
local balance_delta = params.balance_delta or 0
local usage_delta = params.usage_delta or 0
local rollover_id = params.rollover_id
if balance_delta ~= 0 then
table.insert(context.pending_writes, { path = path .. '.balance', delta = balance_delta })
end
if usage_delta ~= 0 then
table.insert(context.pending_writes, { path = path .. '.usage', delta = usage_delta })
end
local rollover_data = context.rollovers[rollover_id]
append_mutation_log({
context = context,
target_type = 'rollover',
customer_entitlement_id = rollover_data and rollover_data.cus_ent_id or nil,
rollover_id = rollover_id,
entity_id = params.entity_id,
credit_cost = params.credit_cost or 1,
balance_delta = balance_delta,
adjustment_delta = 0,
usage_delta = usage_delta,
value_delta = params.value_delta or 0,
})
end
--[[
queue_rollover_update(params)
Queues a rollover balance/usage update to pending_writes.
Rollovers track both balance (decrements) and usage (increments).
]]
local function queue_rollover_update(params)
local deduct_amount = params.deduct_amount
queue_rollover_mutation({
context = params.context,
path = params.path,
rollover_id = params.rollover_id,
entity_id = params.entity_id,
balance_delta = -deduct_amount,
usage_delta = deduct_amount,
value_delta = params.value_delta or 0,
})
end
--[[
update_in_memory_rollover(params)
Backwards-compatible wrapper for main deduction paths.
]]
local function update_in_memory_rollover(params)
update_in_memory_rollover_mutation({
target = params.target,
entity_id = params.entity_id,
balance_delta = -(params.deduct_amount or 0),
usage_delta = params.deduct_amount or 0,
})
end
--[[

View File

@@ -59,6 +59,9 @@ local skip_additional_balance = params.skip_additional_balance or false
local alter_granted_balance = params.alter_granted_balance or false
local overage_behaviour = params.overage_behaviour or 'cap'
local feature_id = params.feature_id
local lock = params.lock
local unwind_value = params.unwind_value
local lock_receipt_key = params.lock_receipt_key
-- Compute overage_behavior_is_allow once
local overage_behavior_is_allow = alter_granted_balance or overage_behaviour == 'allow'
@@ -86,200 +89,66 @@ if not full_customer.customer_products then
end
-- Track updates for return value
local updates = {}
-- Initialize context with in-memory state from Redis
local customer_entitlement_ids = {}
for _, ent_obj in ipairs(sorted_entitlements) do
table.insert(customer_entitlement_ids, ent_obj.customer_entitlement_id)
end
local context = init_context({
cache_key = cache_key,
sorted_entitlements = sorted_entitlements,
customer_entitlement_ids = customer_entitlement_ids,
full_customer = full_customer,
})
-- Initialize remaining_amount (after context so we can use get_total_balance)
local remaining_amount
if not is_nil(target_balance) then
-- Calculate amount to deduct based on target_balance
local current_total = get_total_balance({
if not is_nil(unwind_value) and safe_number(unwind_value) > 0 then
local unwind_result = unwind_lock_on_context({
context = context,
sorted_entitlements = sorted_entitlements,
target_entity_id = target_entity_id,
lock_receipt_key = lock_receipt_key,
unwind_value = unwind_value,
})
remaining_amount = current_total - target_balance
else
remaining_amount = amount_to_deduct or 0
end
-- ============================================================================
-- HELPER: Round number to eliminate floating point errors
-- ============================================================================
local function round_to_precision(num, decimals)
local mult = 10 ^ (decimals or 10)
return math.floor(num * mult + 0.5) / mult
if not is_nil(unwind_result.error) then
return cjson.encode({
error = unwind_result.error,
updates = {},
rollover_updates = {},
mutation_logs = context.mutation_logs or cjson.decode('[]'),
remaining = 0,
logs = context.logs,
})
end
end
-- Determine if this is a refund (negative amount)
local is_refund = remaining_amount < 0
local logger = context.logger
logger.log("=== LUA DEDUCTION START ===")
logger.log("=== PARAMS ===")
logger.log(" amount_to_deduct: %s", tostring(amount_to_deduct or "nil"))
logger.log(" target_balance: %s", tostring(target_balance or "nil"))
logger.log(" remaining_amount: %s", tostring(remaining_amount or "nil"))
logger.log(" is_refund: %s", tostring(is_refund or false))
logger.log(" alter_granted_balance: %s", tostring(alter_granted_balance or false))
logger.log(" target_entity_id: %s", tostring(target_entity_id or "nil"))
logger.log(" overage_behaviour: %s", tostring(overage_behaviour or "nil"))
-- ============================================================================
-- HELPER: Process a single pass over customer_entitlements
-- ============================================================================
local function process_pass(pass_config)
local pass_number = pass_config.pass_number
local pass_name = "PASS" .. pass_number
local skip_if_not_usage_allowed = pass_config.skip_if_not_usage_allowed
local context = pass_config.context
local logger = context.logger
logger.log("=== %s START ===", pass_name)
for ent_idx, ent_obj in ipairs(sorted_entitlements) do
if remaining_amount == 0 then break end
local ent_id = ent_obj.customer_entitlement_id
local credit_cost = ent_obj.credit_cost
-- Handle cjson.null (truthy in Lua) and zero/nil values
if credit_cost == cjson.null or credit_cost == nil or credit_cost == 0 then
credit_cost = 1
end
local min_balance = ent_obj.min_balance
local max_balance = ent_obj.max_balance
-- Check usage_allowed
local usage_allowed = ent_obj.usage_allowed
if usage_allowed == cjson.null then usage_allowed = false end
usage_allowed = usage_allowed or overage_behavior_is_allow
-- Apply filter: only process if usage is allowed (or skip filter is disabled)
local should_process = not skip_if_not_usage_allowed or usage_allowed
-- Skip if not in context (entitlement wasn't found during init_context)
if not context.customer_entitlements[ent_id] then
should_process = false
end
if not should_process then
logger.log("%s skipping %s - usage_allowed=false or not in context", pass_name, ent_id)
else
local deducted = deduct_from_main_balance({
context = context,
ent_id = ent_id,
target_entity_id = target_entity_id,
amount = remaining_amount,
credit_cost = credit_cost,
pass_number = pass_number,
min_balance = min_balance,
max_balance = max_balance,
alter_granted_balance = alter_granted_balance,
overage_behavior_is_allow = overage_behavior_is_allow,
log_prefix = pass_name,
})
-- Update remaining_amount
remaining_amount = remaining_amount - (deducted / credit_cost)
-- Track in updates
if deducted ~= 0 then
if not updates[ent_id] then
updates[ent_id] = { deducted = 0, additional_deducted = 0 }
end
updates[ent_id].deducted = (updates[ent_id].deducted or 0) + deducted
end
logger.log("%s ent %s deducted=%s remaining=%s", pass_name, ent_id, deducted, remaining_amount)
end
end
logger.log("=== %s END === remaining=%s", pass_name, remaining_amount)
end
-- ============================================================================
-- HELPER: Process rollovers before main balance deduction
-- ============================================================================
local function process_rollovers(config)
local context = config.context
local rollovers = config.rollovers
local remaining = config.remaining_amount
local target_entity_id = config.target_entity_id
local sorted_entitlements = config.sorted_entitlements
local logger = context.logger
-- Early return if no rollovers or no positive amount
if is_nil(rollovers) or #rollovers == 0 or remaining <= 0 then
return 0
end
-- Determine has_entity_scope from first entitlement
local first_ent = sorted_entitlements[1]
local has_entity_scope = false
if first_ent then
has_entity_scope = first_ent.entity_feature_id ~= nil and first_ent.entity_feature_id ~= cjson.null
end
local rollover_deducted = deduct_from_rollovers({
context = context,
rollovers = rollovers,
amount = remaining,
target_entity_id = target_entity_id,
has_entity_scope = has_entity_scope,
})
logger.log("Rollover deduction: deducted=%s, remaining=%s", rollover_deducted, remaining - rollover_deducted)
return rollover_deducted
end
-- ============================================================================
-- MAIN DEDUCTION/REFUND LOGIC
-- Same two-pass structure for both deductions and refunds (matches SQL)
-- Step 1: Deduct from rollovers first (only for positive deductions)
-- Pass 1: Process all entitlements (floor at 0 for deductions, ceiling at 0 for refunds)
-- Pass 2: Process remaining (deductions: only usage_allowed can go negative; refunds: all can go above 0)
-- ============================================================================
-- Step 1: Deduct from rollovers BEFORE main balance deduction (only for track, not update balance)
if not alter_granted_balance then
local rollover_deducted = process_rollovers({
context = context,
rollovers = rollovers,
remaining_amount = remaining_amount,
target_entity_id = target_entity_id,
sorted_entitlements = sorted_entitlements,
})
remaining_amount = remaining_amount - rollover_deducted
end
process_pass({
pass_number = 1,
skip_if_not_usage_allowed = false,
local deduction_result = run_deduction_on_context({
context = context,
sorted_entitlements = sorted_entitlements,
rollovers = rollovers,
amount_to_deduct = amount_to_deduct,
target_balance = target_balance,
target_entity_id = target_entity_id,
alter_granted_balance = alter_granted_balance,
overage_behaviour = overage_behaviour,
})
local updates = deduction_result.updates
local rollover_updates = deduction_result.rollover_updates
local remaining_amount = deduction_result.remaining_amount
-- Pass 2: Exceed bounds
-- For deductions: only usage_allowed entitlements can go below 0 (into overage)
-- For refunds: ALL entitlements can go above 0 (up to max_balance)
if remaining_amount ~= 0 then
process_pass({
pass_number = 2,
skip_if_not_usage_allowed = not is_refund, -- Only skip for deductions, not refunds
context = context,
})
logger.log(" remaining_amount: %s", tostring(remaining_amount or "nil"))
logger.log(" is_refund: %s", tostring(remaining_amount < 0 or false))
local mutation_logs = context.mutation_logs
if #mutation_logs == 0 then
mutation_logs = cjson.decode('[]')
end
remaining_amount = round_to_precision(remaining_amount, 10)
-- Throw error and don't apply updates if we're in reject mode and there's still remaining amount
if remaining_amount > 0 and overage_behaviour == 'reject' then
return cjson.encode({
@@ -287,53 +156,41 @@ if remaining_amount > 0 and overage_behaviour == 'reject' then
feature_id = feature_id,
remaining = remaining_amount,
updates = {},
mutation_logs = mutation_logs,
logs = context.logs
})
end
if not is_nil(lock)
and not is_nil(lock.enabled)
and lock.enabled
and not is_nil(lock.redis_receipt_key)
then
save_lock_receipt_from_updates({
lock_receipt_key = lock.redis_receipt_key,
receipt = {
lock_key = lock.key or cjson.null,
hashed_key = lock.hashed_key or cjson.null,
status = 'pending',
customer_id = full_customer.id or cjson.null,
feature_id = feature_id or cjson.null,
entity_id = target_entity_id or cjson.null,
expires_at = lock.expires_at or cjson.null,
created_at = lock.created_at or cjson.null,
},
mutation_logs = mutation_logs,
})
end
-- Apply all pending writes to Redis (only after validation passes)
apply_pending_writes(cache_key, context)
-- ============================================================================
-- BUILD FINAL RETURN VALUE FROM CONTEXT
-- ============================================================================
for ent_id, update in pairs(updates) do
local ent_data = context.customer_entitlements[ent_id]
if ent_data then
if ent_data.has_entity_scope then
update.entities = ent_data.entities
update.balance = 0 -- Top-level unchanged for entity-scoped
else
update.balance = ent_data.balance
end
update.adjustment = ent_data.adjustment or 0
update.additional_balance = 0
end
end
-- Build rollover_updates from context.rollovers (only include modified ones)
local rollover_updates = {}
if not is_nil(rollovers) and #rollovers > 0 then
for rollover_id, rollover_data in pairs(context.rollovers) do
-- Include all rollovers that were in the rollovers list (they may have been modified)
for _, r in ipairs(rollovers) do
if r.id == rollover_id then
rollover_updates[rollover_id] = {
balance = rollover_data.balance,
usage = rollover_data.usage,
entities = rollover_data.entities,
}
break
end
end
end
end
logger.log("=== LUA DEDUCTION END ===")
return cjson.encode({
updates = updates,
rollover_updates = rollover_updates,
mutation_logs = mutation_logs,
remaining = remaining_amount,
error = cjson.null,
logs = context.logs

View File

@@ -145,18 +145,22 @@ local function deduct_from_main_balance(params)
if to_change ~= 0 then
local entity_path = build_entity_path(base_path, params.target_entity_id)
queue_balance_update({
queue_customer_entitlement_mutation({
context = context,
path = entity_path,
delta = -to_change,
alter_granted_balance = params.alter_granted_balance,
customer_entitlement_id = ent_id,
entity_id = params.target_entity_id,
credit_cost = params.credit_cost,
value_delta = to_change / params.credit_cost,
})
update_in_memory_customer_entitlement({
update_in_memory_customer_entitlement_mutation({
target = entities,
entity_id = params.target_entity_id,
delta = -to_change,
alter_granted_balance = params.alter_granted_balance,
balance_delta = -to_change,
adjustment_delta = params.alter_granted_balance and -to_change or 0,
})
deducted = to_change
@@ -191,18 +195,22 @@ local function deduct_from_main_balance(params)
if to_change ~= 0 then
local entity_path = build_entity_path(base_path, entity_key)
queue_balance_update({
queue_customer_entitlement_mutation({
context = context,
path = entity_path,
delta = -to_change,
alter_granted_balance = params.alter_granted_balance,
customer_entitlement_id = ent_id,
entity_id = entity_key,
credit_cost = params.credit_cost,
value_delta = to_change / params.credit_cost,
})
update_in_memory_customer_entitlement({
update_in_memory_customer_entitlement_mutation({
target = entities,
entity_id = entity_key,
delta = -to_change,
alter_granted_balance = params.alter_granted_balance,
balance_delta = -to_change,
adjustment_delta = params.alter_granted_balance and -to_change or 0,
})
deducted = deducted + to_change
@@ -236,18 +244,22 @@ local function deduct_from_main_balance(params)
local delta = -to_change
logger.log("%s queuing: delta=%s, alter_granted_balance=%s", prefix, delta, tostring(params.alter_granted_balance))
queue_balance_update({
queue_customer_entitlement_mutation({
context = context,
path = base_path,
delta = delta,
alter_granted_balance = params.alter_granted_balance,
customer_entitlement_id = ent_id,
entity_id = nil,
credit_cost = params.credit_cost,
value_delta = to_change / params.credit_cost,
})
update_in_memory_customer_entitlement({
update_in_memory_customer_entitlement_mutation({
target = ent_data,
entity_id = nil,
delta = delta,
alter_granted_balance = params.alter_granted_balance,
balance_delta = delta,
adjustment_delta = params.alter_granted_balance and delta or 0,
})
logger.log("%s after update: balance=%s, adjustment=%s", prefix, ent_data.balance, ent_data.adjustment)

View File

@@ -104,6 +104,10 @@ local function deduct_from_rollovers(params)
context = context,
path = entity_path,
deduct_amount = to_change,
rollover_id = rollover_id,
entity_id = target_entity_id,
credit_cost = credit_cost,
value_delta = to_change / credit_cost,
})
update_in_memory_rollover({
@@ -146,6 +150,10 @@ local function deduct_from_rollovers(params)
context = context,
path = entity_path,
deduct_amount = to_change,
rollover_id = rollover_id,
entity_id = entity_key,
credit_cost = credit_cost,
value_delta = to_change / credit_cost,
})
update_in_memory_rollover({
@@ -176,6 +184,10 @@ local function deduct_from_rollovers(params)
context = context,
path = base_path,
deduct_amount = to_change,
rollover_id = rollover_id,
entity_id = nil,
credit_cost = credit_cost,
value_delta = to_change / credit_cost,
})
update_in_memory_rollover({

View File

@@ -0,0 +1,265 @@
-- ============================================================================
-- RUN DEDUCTION ON CONTEXT
-- Shared deduction core for operating against an initialized in-memory context.
-- ============================================================================
--[[
round_to_precision(num, decimals)
Rounds a number to avoid floating point drift in remaining amounts.
]]
local function round_to_precision(num, decimals)
local mult = 10 ^ (decimals or 10)
return math.floor(num * mult + 0.5) / mult
end
--[[
process_deduction_pass(params)
Runs one main-balance deduction pass over all sorted customer_entitlements.
Returns:
{
updates = table,
remaining_amount = number,
}
]]
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 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
local skip_if_not_usage_allowed = params.skip_if_not_usage_allowed
local updates = params.updates or {}
local remaining_amount = params.remaining_amount or 0
local pass_name = "PASS" .. pass_number
local logger = context.logger
logger.log("=== %s START ===", pass_name)
for _, ent_obj in ipairs(sorted_entitlements) do
if remaining_amount == 0 then
break
end
local ent_id = ent_obj.customer_entitlement_id
local credit_cost = ent_obj.credit_cost
if credit_cost == cjson.null or credit_cost == nil or credit_cost == 0 then
credit_cost = 1
end
local usage_allowed = ent_obj.usage_allowed
if usage_allowed == cjson.null then
usage_allowed = false
end
usage_allowed = usage_allowed or overage_behavior_is_allow
local should_process = not skip_if_not_usage_allowed or usage_allowed
if not context.customer_entitlements[ent_id] then
should_process = false
end
if not should_process then
logger.log("%s skipping %s - usage_allowed=false or not in context", pass_name, ent_id)
else
local deducted = deduct_from_main_balance({
context = context,
ent_id = ent_id,
target_entity_id = target_entity_id,
amount = remaining_amount,
credit_cost = credit_cost,
pass_number = pass_number,
min_balance = ent_obj.min_balance,
max_balance = ent_obj.max_balance,
alter_granted_balance = alter_granted_balance,
overage_behavior_is_allow = overage_behavior_is_allow,
log_prefix = pass_name,
})
remaining_amount = remaining_amount - (deducted / credit_cost)
if deducted ~= 0 then
if not updates[ent_id] then
updates[ent_id] = { deducted = 0, additional_deducted = 0 }
end
updates[ent_id].deducted = (updates[ent_id].deducted or 0) + deducted
end
logger.log("%s ent %s deducted=%s remaining=%s", pass_name, ent_id, deducted, remaining_amount)
end
end
logger.log("=== %s END === remaining=%s", pass_name, remaining_amount)
return {
updates = updates,
remaining_amount = remaining_amount,
}
end
--[[
process_rollover_deduction(params)
Runs rollover deduction before the main balance passes.
]]
local function process_rollover_deduction(params)
local context = params.context
local sorted_entitlements = params.sorted_entitlements or {}
local rollovers = params.rollovers
local target_entity_id = params.target_entity_id
local remaining_amount = params.remaining_amount or 0
local logger = context.logger
if is_nil(rollovers) or #rollovers == 0 or remaining_amount <= 0 then
return 0
end
local first_ent = sorted_entitlements[1]
local has_entity_scope = false
if first_ent then
has_entity_scope = first_ent.entity_feature_id ~= nil and first_ent.entity_feature_id ~= cjson.null
end
local rollover_deducted = deduct_from_rollovers({
context = context,
rollovers = rollovers,
amount = remaining_amount,
target_entity_id = target_entity_id,
has_entity_scope = has_entity_scope,
})
logger.log("Rollover deduction: deducted=%s, remaining=%s", rollover_deducted, remaining_amount - rollover_deducted)
return rollover_deducted
end
--[[
run_deduction_on_context(params)
Executes rollover deduction and the two-pass main balance deduction against an
existing context, then builds final updates from that context.
params:
context: initialized context
sorted_entitlements: deduction inputs
rollovers: rollover inputs | nil
amount_to_deduct: number | nil
target_balance: number | nil
target_entity_id: string | nil
alter_granted_balance: boolean
overage_behaviour: string
Returns:
{
updates: table,
rollover_updates: table,
remaining_amount: number,
}
]]
local function run_deduction_on_context(params)
local context = params.context
local sorted_entitlements = params.sorted_entitlements or {}
local rollovers = params.rollovers
local target_entity_id = params.target_entity_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'
local updates = {}
local remaining_amount
if not is_nil(params.target_balance) then
local current_total = get_total_balance({
context = context,
sorted_entitlements = sorted_entitlements,
target_entity_id = target_entity_id,
})
remaining_amount = current_total - params.target_balance
else
remaining_amount = params.amount_to_deduct or 0
end
local is_refund = remaining_amount < 0
if not alter_granted_balance then
local rollover_deducted = process_rollover_deduction({
context = context,
sorted_entitlements = sorted_entitlements,
rollovers = rollovers,
target_entity_id = target_entity_id,
remaining_amount = remaining_amount,
})
remaining_amount = remaining_amount - rollover_deducted
end
local pass_one_result = process_deduction_pass({
context = context,
sorted_entitlements = sorted_entitlements,
target_entity_id = target_entity_id,
alter_granted_balance = alter_granted_balance,
overage_behavior_is_allow = overage_behavior_is_allow,
pass_number = 1,
skip_if_not_usage_allowed = false,
updates = updates,
remaining_amount = remaining_amount,
})
updates = pass_one_result.updates
remaining_amount = pass_one_result.remaining_amount
if remaining_amount ~= 0 then
local pass_two_result = process_deduction_pass({
context = context,
sorted_entitlements = sorted_entitlements,
target_entity_id = target_entity_id,
alter_granted_balance = alter_granted_balance,
overage_behavior_is_allow = overage_behavior_is_allow,
pass_number = 2,
skip_if_not_usage_allowed = not is_refund,
updates = updates,
remaining_amount = remaining_amount,
})
updates = pass_two_result.updates
remaining_amount = pass_two_result.remaining_amount
end
remaining_amount = round_to_precision(remaining_amount, 10)
for ent_id, update in pairs(updates) do
local ent_data = context.customer_entitlements[ent_id]
if ent_data then
if ent_data.has_entity_scope then
update.entities = ent_data.entities
update.balance = 0
else
update.balance = ent_data.balance
end
update.adjustment = ent_data.adjustment or 0
update.additional_balance = 0
end
end
local rollover_updates = {}
if not is_nil(rollovers) and #rollovers > 0 then
for rollover_id, rollover_data in pairs(context.rollovers) do
for _, rollover in ipairs(rollovers) do
if rollover.id == rollover_id then
rollover_updates[rollover_id] = {
cus_ent_id = rollover_data.cus_ent_id,
balance = rollover_data.balance,
usage = rollover_data.usage,
entities = rollover_data.entities,
}
break
end
end
end
end
return {
updates = updates,
rollover_updates = rollover_updates,
remaining_amount = remaining_amount,
}
end

View File

@@ -1,23 +1,23 @@
-- ============================================================================
-- RESERVATION EXPIRY HELPERS
-- Helpers for indexing reservation expiries in a Redis sorted set
-- LOCK EXPIRY HELPERS
-- Helpers for indexing lock expiries in a Redis sorted set
-- ============================================================================
-- ============================================================================
-- HELPER: Add reservation to expiry index
-- HELPER: Add lock to expiry index
-- No-op when expires_at_ms is nil/null.
-- ============================================================================
local function add_reservation_expiry(expiry_zset_key, reservation_key, expires_at_ms)
if expires_at_ms == nil or expires_at_ms == cjson.null then
local function add_lock_expiry(expiry_zset_key, lock_receipt_key, expires_at_ms)
if is_nil(expires_at_ms) then
return 0
end
return redis.call('ZADD', expiry_zset_key, tostring(expires_at_ms), reservation_key)
return redis.call('ZADD', expiry_zset_key, tostring(expires_at_ms), lock_receipt_key)
end
-- ============================================================================
-- HELPER: Remove reservation from expiry index
-- HELPER: Remove lock from expiry index
-- ============================================================================
local function remove_reservation_expiry(expiry_zset_key, reservation_key)
return redis.call('ZREM', expiry_zset_key, reservation_key)
local function remove_lock_expiry(expiry_zset_key, lock_receipt_key)
return redis.call('ZREM', expiry_zset_key, lock_receipt_key)
end

View File

@@ -1,6 +1,6 @@
-- ============================================================================
-- RESERVATION RECEIPT REDIS HELPERS
-- Helpers for loading and storing reservation receipts in RedisJSON
-- LOCK RECEIPT HELPERS
-- Helpers for loading, building, and storing lock receipts in RedisJSON
-- ============================================================================
-- ============================================================================
@@ -8,7 +8,7 @@
-- JSON.GET on '$' returns an array of matches, so unwrap the first element.
-- ============================================================================
local function decode_root_json(result)
if not result or result == cjson.null then
if is_nil(result) then
return nil
end
@@ -25,19 +25,35 @@ local function decode_root_json(result)
end
-- ============================================================================
-- HELPER: Load reservation receipt from Redis
-- HELPER: Load lock receipt from Redis
-- Returns the decoded receipt table or nil if the key does not exist.
-- ============================================================================
local function load_reservation_receipt(reservation_key)
local result = redis.call('JSON.GET', reservation_key, '$')
local function load_lock_receipt(lock_receipt_key)
local result = redis.call('JSON.GET', lock_receipt_key, '$')
return decode_root_json(result)
end
-- ============================================================================
-- HELPER: Store reservation receipt in Redis
-- Overwrites the full receipt document at the reservation key.
-- HELPER: Store lock receipt in Redis
-- Overwrites the full receipt document at the lock receipt key.
-- ============================================================================
local function store_reservation_receipt(reservation_key, receipt)
redis.call('JSON.SET', reservation_key, '$', cjson.encode(receipt))
local function store_lock_receipt(lock_receipt_key, receipt)
redis.call('JSON.SET', lock_receipt_key, '$', cjson.encode(receipt))
return receipt
end
-- ============================================================================
-- HELPER: Save a lock receipt from deduction update objects
--
-- params:
-- lock_receipt_key: string
-- receipt: table (base receipt metadata to persist)
-- mutation_logs: table | nil
-- ============================================================================
local function save_lock_receipt_from_updates(params)
local receipt = params.receipt or {}
local mutation_logs = params.mutation_logs or {}
receipt.items = #mutation_logs > 0 and mutation_logs or cjson.decode('[]')
return store_lock_receipt(params.lock_receipt_key, receipt)
end

View File

@@ -1,6 +1,6 @@
-- ============================================================================
-- RESERVATION STATE HELPERS
-- Helpers for guarding and transitioning reservation receipt state
-- Helpers for guarding and transitioning lock receipt state
-- ============================================================================
local RESERVATION_STATUS_PENDING = 'pending'
@@ -13,7 +13,7 @@ local RESERVATION_STATUS_EXPIRED = 'expired'
-- Defaults to pending if status is absent to ease early migrations.
-- ============================================================================
local function get_reservation_status(receipt)
if not receipt or receipt.status == nil or receipt.status == cjson.null then
if is_nil(receipt) or is_nil(receipt.status) then
return RESERVATION_STATUS_PENDING
end
@@ -57,7 +57,7 @@ end
-- Returns nil when valid, or an error code string when invalid.
-- ============================================================================
local function require_pending_receipt(receipt)
if not receipt then
if is_nil(receipt) then
return 'RESERVATION_NOT_FOUND'
end

View File

@@ -0,0 +1,78 @@
-- ============================================================================
-- UNWIND AND DEDUCT
-- Reconciles a lock by first unwinding the receipt tail, then optionally
-- applying an additional deduction/refund against the live cached customer.
-- Returns the same shape as deductFromCustomerEntitlements.
-- ============================================================================
local params = cjson.decode(ARGV[1])
local cache_key = params.full_customer_cache_key
local full_customer_json = redis.call('JSON.GET', cache_key, '.')
if is_nil(full_customer_json) then
return cjson.encode({
error = 'CUSTOMER_NOT_FOUND',
updates = {},
rollover_updates = {},
remaining = 0,
mutation_logs = cjson.decode('[]'),
logs = {},
})
end
local context = init_context({
cache_key = cache_key,
customer_entitlement_ids = params.customer_entitlement_ids or {},
full_customer = cjson.decode(full_customer_json),
})
local unwind_result = unwind_lock_on_context({
context = context,
lock_receipt_key = params.lock_receipt_key,
unwind_value = params.unwind_value or 0,
})
context.logger.log("UNWIND AND DEDUCT: unwind_result=%s", cjson.encode(unwind_result))
if not is_nil(unwind_result.error) then
return cjson.encode({
error = unwind_result.error,
updates = {},
rollover_updates = {},
remaining = 0,
mutation_logs = context.mutation_logs or cjson.decode('[]'),
logs = context.logs or {},
})
end
local additional_value = params.additional_value or 0
local deduction_result = {
updates = {},
rollover_updates = {},
remaining_amount = 0,
}
context.logger.log("UNWIND AND DEDUCT: additional_value=%s", additional_value)
if additional_value == 0 then
apply_pending_writes(cache_key, context)
else
deduction_result = run_deduction_on_context({
context = context,
sorted_entitlements = params.sorted_entitlements or {},
rollovers = params.rollovers,
amount_to_deduct = params.amount_to_deduct,
target_entity_id = params.target_entity_id,
})
apply_pending_writes(cache_key, context)
end
return cjson.encode({
error = cjson.null,
updates = deduction_result.updates or {},
rollover_updates = deduction_result.rollover_updates or {},
remaining = deduction_result.remaining_amount or 0,
mutation_logs = context.mutation_logs or cjson.decode('[]'),
logs = context.logs or {},
})

View File

@@ -0,0 +1,404 @@
-- ============================================================================
-- LOCK UNWIND HELPERS
-- Step-by-step helpers for reconciling a lock receipt to a final value
-- ============================================================================
-- ============================================================================
-- HELPER: Normalize a receipt identifier field to nil
-- ============================================================================
local function normalize_lock_item_id(value)
if is_nil(value) then
return nil
end
return value
end
-- ============================================================================
-- HELPER: Normalize delta signs for one mutation item
-- ============================================================================
local function get_lock_item_delta_signs(params)
local item = params.item or {}
return {
value_sign = safe_number(item.value_delta) >= 0 and 1 or -1,
balance_sign = safe_number(item.balance_delta) >= 0 and 1 or -1,
adjustment_sign = safe_number(item.adjustment_delta) >= 0 and 1 or -1,
usage_sign = safe_number(item.usage_delta) >= 0 and 1 or -1,
}
end
-- ============================================================================
-- HELPER: Calculate how much of one item should be unwound in this iteration
-- ============================================================================
local function calculate_unwind_iteration_value(params)
local item = params.item or {}
local remaining_unwind_value = safe_number(params.remaining_unwind_value)
local item_value_magnitude = math.abs(safe_number(item.value_delta))
return math.min(item_value_magnitude, remaining_unwind_value)
end
-- ============================================================================
-- STEP 1: Calculate the current signed lock value from receipt items
-- ============================================================================
local function calculate_lock_value(params)
local items = safe_table(params.items)
local lock_value = 0
for _, item in ipairs(items) do
lock_value = lock_value + safe_number(item.value_delta)
end
return lock_value
end
-- ============================================================================
-- STEP 2: Calculate how much value should be unwound from the current lock
-- ============================================================================
local function calculate_unwind_value(params)
local lock_value = safe_number(params.lock_value)
local final_value = safe_number(params.final_value)
local lock_magnitude = math.abs(lock_value)
local final_magnitude = math.abs(final_value)
if lock_value == 0 then
return {
unwind_value = 0,
}
end
if final_value == 0 then
return {
unwind_value = lock_magnitude,
}
end
local lock_sign = lock_value > 0 and 1 or -1
local final_sign = final_value > 0 and 1 or -1
if lock_sign ~= final_sign then
return {
unwind_value = lock_magnitude,
}
end
if final_magnitude >= lock_magnitude then
return {
unwind_value = 0,
}
end
return {
unwind_value = lock_magnitude - final_magnitude,
}
end
-- ============================================================================
-- STEP 3: Unwind one lock receipt item iteration
--
-- params:
-- context: table
-- item: mutation log item
-- remaining_unwind_value: positive magnitude
--
-- Returns:
-- {
-- applied = boolean,
-- unwind_iteration_value = number,
-- remaining_unwind_value = number,
-- error = string | nil,
-- }
-- ============================================================================
local function unwind_lock_item_iteration(params)
local context = params.context
local item = params.item or {}
local remaining_unwind_value = safe_number(params.remaining_unwind_value)
if remaining_unwind_value <= 0 then
return {
applied = false,
unwind_iteration_value = 0,
remaining_unwind_value = 0,
error = nil,
}
end
local unwind_iteration_value = calculate_unwind_iteration_value({
item = item,
remaining_unwind_value = remaining_unwind_value,
})
if unwind_iteration_value <= 0 then
return {
applied = false,
unwind_iteration_value = 0,
remaining_unwind_value = remaining_unwind_value,
error = nil,
}
end
local signs = get_lock_item_delta_signs({
item = item,
})
-- Calculate the amount of credits to unwind
local credits_to_unwind =
unwind_iteration_value * safe_number(item.credit_cost or 1)
local inverse_balance_delta = -signs.balance_sign * credits_to_unwind
local inverse_adjustment_delta = safe_number(item.adjustment_delta) ~= 0
and (-signs.adjustment_sign * credits_to_unwind)
or 0 --[[ default to 0 if adjustment_delta is not set ]]
local inverse_usage_delta = safe_number(item.usage_delta) ~= 0
and (-signs.usage_sign * credits_to_unwind)
or 0 --[[ default to 0 if usage_delta is not set ]]
local inverse_value_delta = -signs.value_sign * unwind_iteration_value
local entity_id = normalize_lock_item_id(item.entity_id)
if item.target_type == 'customer_entitlement' then
local customer_entitlement_id =
normalize_lock_item_id(item.customer_entitlement_id)
local ent_data = context.customer_entitlements[customer_entitlement_id]
if not ent_data then
return {
applied = false,
unwind_iteration_value = 0,
remaining_unwind_value = remaining_unwind_value,
error = 'LOCK_CUSTOMER_ENTITLEMENT_NOT_FOUND',
}
end
local path = ent_data.base_path
if entity_id then
path = build_entity_path(path, entity_id)
end
queue_customer_entitlement_mutation({
context = context,
path = path,
customer_entitlement_id = customer_entitlement_id,
entity_id = entity_id,
credit_cost = safe_number(item.credit_cost or 1),
balance_delta = inverse_balance_delta,
adjustment_delta = inverse_adjustment_delta,
value_delta = inverse_value_delta,
})
update_in_memory_customer_entitlement_mutation({
target = entity_id and ent_data.entities or ent_data,
entity_id = entity_id,
balance_delta = inverse_balance_delta,
adjustment_delta = inverse_adjustment_delta,
})
return {
applied = true,
unwind_iteration_value = unwind_iteration_value,
remaining_unwind_value = remaining_unwind_value - unwind_iteration_value,
error = nil,
}
end
if item.target_type == 'rollover' then
local rollover_id = normalize_lock_item_id(item.rollover_id)
local rollover_data = context.rollovers[rollover_id]
if not rollover_data then
return {
applied = false,
unwind_iteration_value = 0,
remaining_unwind_value = remaining_unwind_value,
error = 'LOCK_ROLLOVER_NOT_FOUND',
}
end
local path = rollover_data.base_path
if entity_id then
path = build_entity_path(path, entity_id)
end
queue_rollover_mutation({
context = context,
path = path,
rollover_id = rollover_id,
entity_id = entity_id,
credit_cost = safe_number(item.credit_cost or 1),
balance_delta = inverse_balance_delta,
usage_delta = inverse_usage_delta,
value_delta = inverse_value_delta,
})
update_in_memory_rollover_mutation({
target = entity_id and rollover_data.entities or rollover_data,
entity_id = entity_id,
balance_delta = inverse_balance_delta,
usage_delta = inverse_usage_delta,
})
return {
applied = true,
unwind_iteration_value = unwind_iteration_value,
remaining_unwind_value = remaining_unwind_value - unwind_iteration_value,
error = nil,
}
end
return {
applied = false,
unwind_iteration_value = 0,
remaining_unwind_value = remaining_unwind_value,
error = 'INVALID_LOCK_ITEM_TARGET_TYPE',
}
end
-- ============================================================================
-- STEP 4: Iterate through receipt items backwards and unwind them
--
-- params:
-- context: table
-- items: ordered receipt items
-- unwind_value: positive magnitude
--
-- Returns:
-- {
-- applied = boolean,
-- remaining_unwind_value = number,
-- iterations = array,
-- error = string | nil,
-- }
-- ============================================================================
local function unwind_lock_items(params)
local context = params.context
local items = safe_table(params.items)
local remaining_unwind_value = safe_number(params.unwind_value)
local iterations = {}
if remaining_unwind_value <= 0 then
return {
applied = false,
remaining_unwind_value = 0,
iterations = iterations,
error = nil,
}
end
for index = #items, 1, -1 do
if remaining_unwind_value <= 0 then
break
end
local item = items[index]
local result = unwind_lock_item_iteration({
context = context,
item = item,
remaining_unwind_value = remaining_unwind_value,
})
if not is_nil(result.error) then
return {
applied = #iterations > 0,
remaining_unwind_value = remaining_unwind_value,
iterations = iterations,
error = result.error,
}
end
if result.applied then
table.insert(iterations, {
item = item,
unwind_iteration_value = result.unwind_iteration_value,
})
end
remaining_unwind_value = result.remaining_unwind_value
end
return {
applied = #iterations > 0,
remaining_unwind_value = remaining_unwind_value,
iterations = iterations,
error = nil,
}
end
-- ============================================================================
-- STEP 5: Collect modified IDs from unwind iterations
-- ============================================================================
local function collect_unwind_modified_ids(params)
local iterations = safe_table(params.iterations)
local modified_customer_entitlement_ids = {}
local modified_rollover_ids = {}
local seen_customer_entitlements = {}
local seen_rollovers = {}
for _, iteration in ipairs(iterations) do
local item = iteration.item or {}
if not is_nil(item.customer_entitlement_id)
and not seen_customer_entitlements[item.customer_entitlement_id]
then
seen_customer_entitlements[item.customer_entitlement_id] = true
table.insert(modified_customer_entitlement_ids, item.customer_entitlement_id)
end
if not is_nil(item.rollover_id)
and not seen_rollovers[item.rollover_id]
then
seen_rollovers[item.rollover_id] = true
table.insert(modified_rollover_ids, item.rollover_id)
end
end
return {
modified_customer_entitlement_ids = modified_customer_entitlement_ids,
modified_rollover_ids = modified_rollover_ids,
}
end
-- ============================================================================
-- STEP 6: Unwind a lock receipt against an initialized context
-- ============================================================================
local function unwind_lock_on_context(params)
local context = params.context
local lock_receipt_key = params.lock_receipt_key
local unwind_value = params.unwind_value or 0
local empty_result = {
modified_customer_entitlement_ids = cjson.decode('[]'),
modified_rollover_ids = cjson.decode('[]'),
mutation_logs = cjson.decode('[]'),
}
local receipt = load_lock_receipt(lock_receipt_key)
local pending_error = require_pending_receipt(receipt)
if not is_nil(pending_error) then
empty_result.error = pending_error
return empty_result
end
local items = receipt.items or cjson.decode('[]')
local unwind_items_result = unwind_lock_items({
context = context,
items = items,
unwind_value = unwind_value,
})
if not is_nil(unwind_items_result.error) then
empty_result.error = unwind_items_result.error
return empty_result
end
local modified_ids = collect_unwind_modified_ids({
iterations = unwind_items_result.iterations,
})
return {
error = cjson.null,
unwind_value = unwind_value,
remaining_unwind_value = unwind_items_result.remaining_unwind_value,
modified_customer_entitlement_ids = modified_ids.modified_customer_entitlement_ids,
modified_rollover_ids = modified_ids.modified_rollover_ids,
mutation_logs = context.mutation_logs,
}
end

View File

@@ -0,0 +1,124 @@
-- ============================================================================
-- MUTATION ITEM HELPERS
-- Shared conversion helpers for turning deduction results into mutation items
-- ============================================================================
-- ============================================================================
-- HELPER: Append an item to a mutation item array
-- ============================================================================
local function append_mutation_item(items, item)
table.insert(items, item)
end
-- ============================================================================
-- HELPER: Convert one DeductionUpdate into mutation items
-- Returns an array of mutation items for top-level and entity-scoped deltas.
-- ============================================================================
local function deduction_update_to_mutation_items(customer_entitlement_id, update)
local items = {}
local balance_delta = safe_number(update.balance_delta)
local adjustment_delta = safe_number(update.adjustment_delta)
if balance_delta ~= 0 or adjustment_delta ~= 0 then
append_mutation_item(items, {
target_type = 'customer_entitlement',
customer_entitlement_id = customer_entitlement_id,
rollover_id = cjson.null,
entity_id = cjson.null,
balance_delta = balance_delta,
adjustment_delta = adjustment_delta,
usage_delta = 0,
})
end
local entity_deltas = safe_table(update.entity_deltas)
for entity_id, entity_delta in pairs(entity_deltas) do
local entity_balance_delta = safe_number(entity_delta.balance_delta)
local entity_adjustment_delta = safe_number(entity_delta.adjustment_delta)
if entity_balance_delta ~= 0 or entity_adjustment_delta ~= 0 then
append_mutation_item(items, {
target_type = 'customer_entitlement',
customer_entitlement_id = customer_entitlement_id,
rollover_id = cjson.null,
entity_id = entity_id,
balance_delta = entity_balance_delta,
adjustment_delta = entity_adjustment_delta,
usage_delta = 0,
})
end
end
return items
end
-- ============================================================================
-- HELPER: Convert one RolloverUpdate into mutation items
-- Returns an array of mutation items for top-level and entity-scoped deltas.
-- ============================================================================
local function rollover_update_to_mutation_items(rollover_id, rollover_update)
local items = {}
local balance_delta = safe_number(rollover_update.balance_delta)
local usage_delta = safe_number(rollover_update.usage_delta)
if balance_delta ~= 0 or usage_delta ~= 0 then
append_mutation_item(items, {
target_type = 'rollover',
customer_entitlement_id = rollover_update.cus_ent_id or cjson.null,
rollover_id = rollover_id,
entity_id = cjson.null,
balance_delta = balance_delta,
adjustment_delta = 0,
usage_delta = usage_delta,
})
end
local entity_deltas = safe_table(rollover_update.entity_deltas)
for entity_id, entity_delta in pairs(entity_deltas) do
local entity_balance_delta = safe_number(entity_delta.balance_delta)
local entity_usage_delta = safe_number(entity_delta.usage_delta)
if entity_balance_delta ~= 0 or entity_usage_delta ~= 0 then
append_mutation_item(items, {
target_type = 'rollover',
customer_entitlement_id = rollover_update.cus_ent_id or cjson.null,
rollover_id = rollover_id,
entity_id = entity_id,
balance_delta = entity_balance_delta,
adjustment_delta = 0,
usage_delta = entity_usage_delta,
})
end
end
return items
end
-- ============================================================================
-- HELPER: Convert deduction result objects into one flat mutation item array
--
-- params:
-- updates: table | nil
-- rollover_updates: table | nil
-- ============================================================================
local function deduction_results_to_mutation_items(params)
local items = {}
local updates = params.updates or {}
local rollover_updates = params.rollover_updates or {}
for customer_entitlement_id, update in pairs(updates) do
local update_items = deduction_update_to_mutation_items(customer_entitlement_id, update)
for _, item in ipairs(update_items) do
append_mutation_item(items, item)
end
end
for rollover_id, rollover_update in pairs(rollover_updates) do
local rollover_items = rollover_update_to_mutation_items(rollover_id, rollover_update)
for _, item in ipairs(rollover_items) do
append_mutation_item(items, item)
end
end
return items
end

View File

@@ -7,6 +7,8 @@ const __dirname = dirname(__filename);
// Path to script folders
const DEDUCT_DIR = join(__dirname, "deductFromCustomerEntitlements");
const DEDUCTION_DIR = join(__dirname, "deduction");
const LOCK_DIR = join(DEDUCTION_DIR, "lock");
const DELETE_CACHE_DIR = join(__dirname, "deleteFullCustomerCache");
const RESET_DIR = join(__dirname, "resetCustomerEntitlements");
const UPDATE_DIR = join(__dirname, "updateCustomerEntitlements");
@@ -42,6 +44,31 @@ const DEDUCT_FROM_MAIN_BALANCE = readFileSync(
"utf-8",
);
const RUN_DEDUCTION_ON_CONTEXT = readFileSync(
join(DEDUCT_DIR, "runDeductionOnContext.lua"),
"utf-8",
);
const MUTATION_ITEM_UTILS = readFileSync(
join(DEDUCTION_DIR, "mutationItemUtils.lua"),
"utf-8",
);
const LOCK_RECEIPT_UTILS = readFileSync(
join(LOCK_DIR, "lockReceipt.lua"),
"utf-8",
);
const LOCK_STATE_UTILS = readFileSync(
join(LOCK_DIR, "lockStateUtils.lua"),
"utf-8",
);
const LOCK_UNWIND_UTILS = readFileSync(
join(LOCK_DIR, "unwindLockUtils.lua"),
"utf-8",
);
// ============================================================================
// MAIN SCRIPT
// ============================================================================
@@ -63,8 +90,30 @@ ${CONTEXT_UTILS}
${GET_TOTAL_BALANCE}
${DEDUCT_FROM_ROLLOVERS}
${DEDUCT_FROM_MAIN_BALANCE}
${RUN_DEDUCTION_ON_CONTEXT}
${MUTATION_ITEM_UTILS}
${LOCK_RECEIPT_UTILS}
${LOCK_STATE_UTILS}
${LOCK_UNWIND_UTILS}
${mainScript}`;
const unwindAndDeductMainScript = readFileSync(
join(LOCK_DIR, "unwindAndDeduct.lua"),
"utf-8",
);
export const UNWIND_AND_DEDUCT_SCRIPT = `${LUA_UTILS}
${READ_BALANCES}
${CONTEXT_UTILS}
${GET_TOTAL_BALANCE}
${DEDUCT_FROM_ROLLOVERS}
${DEDUCT_FROM_MAIN_BALANCE}
${RUN_DEDUCTION_ON_CONTEXT}
${LOCK_RECEIPT_UTILS}
${LOCK_STATE_UTILS}
${LOCK_UNWIND_UTILS}
${unwindAndDeductMainScript}`;
// ============================================================================
// DELETE FULL CUSTOMER CACHE SCRIPTS
// ============================================================================

View File

@@ -27,6 +27,7 @@ import {
type DeleteBalanceParamsV0,
EntityExpand,
ErrCode,
type FinalizeLockParamsV0,
type LegacyVersion,
type OrgConfig,
type ProductItem,
@@ -849,6 +850,10 @@ export class AutumnInt {
const data = await this.post(`/balances.delete`, params);
return data;
},
finalize: async (params: FinalizeLockParamsV0) => {
const data = await this.post(`/balances.finalize`, params);
return data;
},
};
subscriptions = {

View File

@@ -21,6 +21,7 @@ import {
DELETE_FULL_CUSTOMER_CACHE_SCRIPT,
RESET_CUSTOMER_ENTITLEMENTS_SCRIPT,
SET_FULL_CUSTOMER_CACHE_SCRIPT,
UNWIND_AND_DEDUCT_SCRIPT,
UPDATE_CUSTOMER_DATA_SCRIPT,
UPDATE_CUSTOMER_ENTITLEMENTS_SCRIPT,
UPDATE_CUSTOMER_PRODUCT_SCRIPT,
@@ -171,6 +172,11 @@ const configureRedisInstance = (redisInstance: Redis): Redis => {
lua: DEDUCT_FROM_CUSTOMER_ENTITLEMENTS_SCRIPT,
});
redisInstance.defineCommand("unwindAndDeduct", {
numberOfKeys: 0,
lua: UNWIND_AND_DEDUCT_SCRIPT,
});
redisInstance.defineCommand("deleteFullCustomerCache", {
numberOfKeys: 3,
lua: DELETE_FULL_CUSTOMER_CACHE_SCRIPT,
@@ -374,6 +380,7 @@ declare module "ioredis" {
cacheKey: string,
paramsJson: string,
): Promise<string>;
unwindAndDeduct(paramsJson: string): Promise<string>;
deleteFullCustomerCache(
testGuardKey: string,
guardKey: string,

View File

@@ -8,7 +8,7 @@ import {
type CheckResponseV3,
} from "@autumn/shared";
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import { parseCheckParamsForReserve } from "@/internal/balances/utils/reserve/parseCheckParamsForReserve.js";
import { parseCheckParamsForLock } from "@/internal/balances/utils/lock/parseCheckParamsForLock.js";
import { getCheckData } from "./checkUtils/getCheckData.js";
import { getV2CheckResponse } from "./checkUtils/getV2CheckResponse.js";
import { getCheckPreview } from "./getCheckPreview.js";
@@ -27,7 +27,7 @@ export const handleCheck = createRoute({
let body = c.req.valid("json");
const ctx = c.get("ctx");
body = parseCheckParamsForReserve({
body = parseCheckParamsForLock({
params: body,
});
@@ -60,7 +60,7 @@ export const handleCheck = createRoute({
});
let response: CheckResponseV3;
if (send_event) {
if (send_event || body.lock?.enabled) {
response = await runCheckWithTrack({
ctx,
body,
@@ -99,7 +99,7 @@ export const handleCheck = createRoute({
return c.json({
...transformedResponse,
preview,
reserve_key: body.reserve?.key ?? undefined,
lock_key: body.lock?.key ?? undefined,
});
},
});

View File

@@ -42,7 +42,7 @@ export const runCheckWithTrack = async ({
const featureDeductions = getTrackFeatureDeductions({
ctx,
featureId: body.feature_id,
reserve: body.reserve,
lock: body.lock,
value: requiredBalance,
});
@@ -54,7 +54,7 @@ export const runCheckWithTrack = async ({
properties: body.properties,
skip_event: body.skip_event,
overage_behavior: "reject",
reserve: body.reserve,
lock: body.lock,
};
let allowed = true;
@@ -95,7 +95,7 @@ export const runCheckWithTrack = async ({
entity_id: checkData.entityId,
required_balance: requiredBalance,
balance: checkData.apiBalance ?? null,
reserve_key: body.reserve?.key,
lock_key: body.lock?.key,
});
return checkResponse;

View File

@@ -3,6 +3,7 @@ import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import { handleCheck } from "../api/check/handleCheck.js";
import { handleCreateBalance } from "./handlers/handleCreateBalance.js";
import { handleDeleteBalance } from "./handlers/handleDeleteBalance.js";
import { handleFinalizeLock } from "./handlers/handleFinalizeLock.js";
import { handleListBalances } from "./handlers/handleListBalances.js";
import { handleTrack } from "./handlers/handleTrack.js";
import { handleUpdateBalance } from "./handlers/handleUpdateBalance.js";
@@ -32,3 +33,4 @@ balancesRpcRouter.post("/balances.delete", ...handleDeleteBalance);
balancesRpcRouter.post("/balances.track", ...handleTrack);
balancesRpcRouter.post("/balances.check", ...handleCheck);
balancesRpcRouter.post("/balances.finalize", ...handleFinalizeLock);

View File

@@ -0,0 +1,103 @@
import { type FinalizeLockParamsV0, findFeatureById } from "@autumn/shared";
import { currentRegion } from "@/external/redis/initRedis.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { executeRedisDeduction } from "@/internal/balances/utils/deduction/executeRedisDeduction.js";
import { fetchLockReceipt } from "@/internal/balances/utils/lock/fetchLockReceipt.js";
import { calculateUnwindValue } from "@/internal/balances/utils/lock/unwindLockUtils.js";
import { deductionUpdatesToModifiedIds } from "@/internal/balances/utils/sync/deductionUpdatesToModifiedIds.js";
import { globalSyncBatchingManagerV2 } from "@/internal/balances/utils/sync/SyncBatchingManagerV2.js";
import type { DeductionUpdate } from "@/internal/balances/utils/types/deductionUpdate.js";
import type { FeatureDeduction } from "@/internal/balances/utils/types/featureDeduction.js";
import type { RolloverUpdate } from "@/internal/balances/utils/types/rolloverUpdate.js";
import { getOrSetCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/getOrSetCachedFullCustomer.js";
const queueSyncItem = ({
ctx,
customerId,
updates,
rolloverUpdates,
}: {
ctx: AutumnContext;
customerId: string;
updates: Record<string, DeductionUpdate>;
rolloverUpdates: Record<string, RolloverUpdate>;
}) => {
const modifiedCusEntIds = deductionUpdatesToModifiedIds({ updates });
const rolloverIds = Object.keys(rolloverUpdates);
if (modifiedCusEntIds.length === 0 && rolloverIds.length === 0) return;
ctx.logger.info(`[QUEUE SYNC] (${customerId})`);
globalSyncBatchingManagerV2.addSyncItem({
customerId,
orgId: ctx.org.id,
env: ctx.env,
cusEntIds: modifiedCusEntIds,
rolloverIds,
region: currentRegion,
});
};
export const finalizeLock = async ({
ctx,
params,
}: {
ctx: AutumnContext;
params: FinalizeLockParamsV0;
}) => {
const { receipt, lockReceiptKey } = await fetchLockReceipt({
ctx,
lockKey: params.lock_key,
});
const fullCustomer = await getOrSetCachedFullCustomer({
ctx,
customerId: receipt.customer_id!,
entityId: receipt.entity_id ?? undefined,
source: "finalizeLock",
});
const finalValue =
params.finalize_action === "release" ? 0 : params.overwrite_value;
const { unwindValue, additionalValue } = calculateUnwindValue({
receipt,
finalValue,
});
const feature = findFeatureById({
features: ctx.features,
featureId: receipt.feature_id,
errorOnNotFound: true,
});
const deduction: FeatureDeduction = {
feature,
deduction: additionalValue,
// For unwinding when finalizing a lock
unwindValue,
lockReceiptKey,
};
const { updates, rolloverUpdates } = await executeRedisDeduction({
ctx,
fullCustomer,
entityId: receipt.entity_id ?? undefined,
deductions: [deduction],
deductionOptions: {
triggerAutoTopUp: true,
},
});
queueSyncItem({
ctx,
customerId: receipt.customer_id,
updates,
rolloverUpdates,
});
return {
success: true,
};
};

View File

@@ -0,0 +1,18 @@
import { FinalizeLockParamsV0Schema } from "@autumn/shared";
import { createRoute } from "@/honoMiddlewares/routeHandler";
import { finalizeLock } from "../finalizeLock/finalizeLock";
export const handleFinalizeLock = createRoute({
body: FinalizeLockParamsV0Schema,
handler: async (c) => {
const ctx = c.get("ctx");
const params = c.req.valid("json");
return c.json(
await finalizeLock({
ctx,
params,
}),
);
},
});

View File

@@ -27,7 +27,7 @@ export const handleTrack = createRoute({
? getTrackFeatureDeductions({
ctx,
featureId: body.feature_id,
reserve: body.reserve,
lock: body.lock,
value: body.value,
})
: getTrackEventNameDeductions({

View File

@@ -1,7 +1,7 @@
import {
FeatureNotFoundError,
type LockParams,
RecaseError,
type ReserveParams,
} from "@autumn/shared";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js";
import type { FeatureDeduction } from "../../utils/types/featureDeduction.js";
@@ -11,12 +11,12 @@ const DEFAULT_VALUE = 1;
export const getTrackFeatureDeductions = ({
ctx,
featureId,
reserve,
lock,
value,
}: {
ctx: AutumnContext;
featureId: string;
reserve?: ReserveParams;
lock?: LockParams;
value?: number;
}) => {
const featureDeductions: FeatureDeduction[] = [];
@@ -35,7 +35,7 @@ export const getTrackFeatureDeductions = ({
featureDeductions.push({
feature: mainFeature,
deduction: mainFeatureDeduction,
reserve,
lock,
});
return featureDeductions;

View File

@@ -14,7 +14,7 @@ import { deductionUpdatesToModifiedIds } from "../../utils/sync/deductionUpdates
import { globalSyncBatchingManagerV2 } from "../../utils/sync/SyncBatchingManagerV2.js";
import type { DeductionUpdate } from "../../utils/types/deductionUpdate.js";
import type { FeatureDeduction } from "../../utils/types/featureDeduction.js";
import type { RolloverUpdate } from "../../utils/types/redisDeductionResult.js";
import type { RolloverUpdate } from "../../utils/types/rolloverUpdate.js";
import { handleRedisTrackError } from "./handleRedisTrackError.js";
const queueSyncItem = ({

View File

@@ -1,5 +1,5 @@
import type { FullCustomer } from "@autumn/shared";
import type { RolloverUpdate } from "../types/redisDeductionResult.js";
import type { RolloverUpdate } from "../types/rolloverUpdate.js";
/**
* Apply rollover updates to the in-memory FullCustomer object.

View File

@@ -13,6 +13,7 @@ import type { EventInfo } from "../../events/initEvent.js";
import { applyDeductionUpdateToFullCustomer } from "../../utils/deduction/applyDeductionUpdateToFullCustomer.js";
import type { DeductionUpdate } from "../../utils/types/deductionUpdate.js";
import type { FeatureDeduction } from "../../utils/types/featureDeduction.js";
import type { MutationLogItem } from "../../utils/types/mutationLogItem.js";
import { createAllocatedInvoice } from "../allocatedInvoice/createAllocatedInvoice.js";
import { handleThresholdReached } from "../handleThresholdReached.js";
import type { DeductionOptions } from "../types/deductionTypes.js";
@@ -43,6 +44,7 @@ export const executePostgresDeduction = async ({
oldFullCus: FullCustomer;
fullCus: FullCustomer | undefined;
updates: Record<string, DeductionUpdate>;
mutationLogs: MutationLogItem[];
}> => {
const { db, org, env } = ctx;
@@ -234,5 +236,6 @@ export const executePostgresDeduction = async ({
oldFullCus,
fullCus: fullCustomer,
updates: allUpdates,
mutationLogs: [],
};
};

View File

@@ -13,14 +13,13 @@ import { handleThresholdReached } from "../handleThresholdReached.js";
import type { DeductionOptions } from "../types/deductionTypes.js";
import type { DeductionUpdate } from "../types/deductionUpdate.js";
import type { FeatureDeduction } from "../types/featureDeduction.js";
import type { MutationLogItem } from "../types/mutationLogItem.js";
import {
RedisDeductionError,
RedisDeductionErrorCode,
} from "../types/redisDeductionError.js";
import type {
LuaDeductionResult,
RolloverUpdate,
} from "../types/redisDeductionResult.js";
import type { LuaDeductionResult } from "../types/redisDeductionResult.js";
import type { RolloverUpdate } from "../types/rolloverUpdate.js";
import { applyDeductionUpdateToFullCustomer } from "./applyDeductionUpdateToFullCustomer.js";
import { applyRolloverUpdatesToFullCustomer } from "./applyRolloverUpdatesToFullCustomer.js";
import { logDeductionUpdates } from "./logDeductionUpdates.js";
@@ -44,6 +43,7 @@ export const executeRedisDeduction = async ({
fullCus: FullCustomer | undefined;
updates: Record<string, DeductionUpdate>;
rolloverUpdates: Record<string, RolloverUpdate>;
mutationLogs: MutationLogItem[];
}> => {
const { org, env } = ctx;
const oldFullCus = structuredClone(fullCustomer);
@@ -70,6 +70,7 @@ export const executeRedisDeduction = async ({
let allUpdates: Record<string, DeductionUpdate> = {};
let allRolloverUpdates: Record<string, RolloverUpdate> = {};
let allMutationLogs: MutationLogItem[] = [];
// Build cache key
const customerId = fullCustomer.id || fullCustomer.internal_id;
@@ -80,13 +81,20 @@ export const executeRedisDeduction = async ({
});
for (const deduction of deductions) {
const { feature, deduction: toDeduct, targetBalance } = deduction;
const {
feature,
deduction: toDeduct,
targetBalance,
unwindValue,
lockReceiptKey,
} = deduction;
const {
customerEntitlementDeductions,
rollovers,
customerEntitlements,
unlimitedFeatureIds,
lock: preparedLock,
} = prepareFeatureDeduction({
ctx,
fullCustomer,
@@ -109,7 +117,11 @@ export const executeRedisDeduction = async ({
alter_granted_balance: options.alterGrantedBalance,
overage_behaviour: options.overageBehaviour,
feature_id: feature.id,
reserve: deduction.reserve ?? null,
lock: preparedLock ?? null,
// For unwinding when finalizing a lock
unwind_value: unwindValue ?? null,
lock_receipt_key: lockReceiptKey ?? null,
};
const result = await tryRedisWrite(() =>
@@ -138,7 +150,7 @@ export const executeRedisDeduction = async ({
});
}
const { updates, rollover_updates } = resultJson;
const { updates, rollover_updates, mutation_logs } = resultJson;
logDeductionUpdates({
ctx,
fullCustomer,
@@ -148,6 +160,7 @@ export const executeRedisDeduction = async ({
allUpdates = { ...allUpdates, ...updates };
allRolloverUpdates = { ...allRolloverUpdates, ...rollover_updates };
allMutationLogs = [...allMutationLogs, ...mutation_logs];
// Handle paid allocated entitlements and update fullCus in memory
try {
@@ -222,5 +235,6 @@ export const executeRedisDeduction = async ({
fullCus: fullCustomer,
updates: allUpdates,
rolloverUpdates: allRolloverUpdates,
mutationLogs: allMutationLogs,
};
};

View File

@@ -10,6 +10,7 @@ import {
orgToInStatuses,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { buildLockReceiptKey } from "@/internal/balances/utils/lock/buildLockReceiptKey.js";
import { getUnlimitedAndUsageAllowed } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js";
import { getCreditCost } from "@/internal/features/creditSystemUtils.js";
import type {
@@ -35,7 +36,8 @@ export const prepareFeatureDeduction = ({
options?: DeductionOptions;
}): PreparedFeatureDeduction => {
const { org } = ctx;
const { feature, targetBalance } = deduction;
const { env } = ctx;
const { feature, lock, targetBalance } = deduction;
const { overageBehaviour = "cap", customerEntitlementFilters } = options;
@@ -118,6 +120,19 @@ export const prepareFeatureDeduction = ({
return 0;
});
const preparedLock = lock
? {
...lock,
hashed_key: lock.hashed_key ?? Bun.hash(lock.key!).toString(),
redis_receipt_key: buildLockReceiptKey({
orgId: org.id,
env,
lockKey: lock.hashed_key ?? Bun.hash(lock.key!).toString(),
}),
created_at: Date.now(),
}
: undefined;
return {
customerEntitlements: cusEnts,
customerEntitlementDeductions,
@@ -126,5 +141,6 @@ export const prepareFeatureDeduction = ({
credit_cost: r.credit_cost,
})),
unlimitedFeatureIds,
lock: preparedLock,
};
};

View File

@@ -0,0 +1,11 @@
export const buildLockReceiptKey = ({
orgId,
env,
lockKey,
}: {
orgId: string;
env: string;
lockKey: string;
}) => {
return `{${orgId}}:${env}:lock_receipt:${lockKey}`;
};

View File

@@ -0,0 +1,66 @@
import { ErrCode, RecaseError } from "@autumn/shared";
import { redis } from "@/external/redis/initRedis.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { MutationLogItem } from "@/internal/balances/utils/types/mutationLogItem.js";
import { tryRedisRead } from "@/utils/cacheUtils/cacheUtils.js";
import { buildLockReceiptKey } from "./buildLockReceiptKey.js";
export type LockReceipt = {
customer_id: string;
feature_id: string;
entity_id?: string | null;
items: MutationLogItem[];
};
export const fetchLockReceipt = async ({
ctx,
lockKey,
}: {
ctx: AutumnContext;
lockKey: string;
}) => {
const hashedKey = Bun.hash(lockKey).toString();
const lockReceiptKey = buildLockReceiptKey({
orgId: ctx.org.id,
env: ctx.env,
lockKey: hashedKey,
});
const rawReceipt = await tryRedisRead(
() => redis.call("JSON.GET", lockReceiptKey, "$") as Promise<string | null>,
);
if (!rawReceipt) {
throw new RecaseError({
message: `Lock not found for key: ${lockKey}`,
code: ErrCode.InvalidRequest,
});
}
const receipt = (JSON.parse(rawReceipt) as LockReceipt[])[0];
if (!receipt?.customer_id) {
throw new RecaseError({
message: `Lock receipt is missing customer_id for key: ${lockKey}`,
code: ErrCode.InvalidRequest,
});
}
if (!receipt.feature_id) {
throw new RecaseError({
message: `Lock receipt is missing feature_id for key: ${lockKey}`,
code: ErrCode.InvalidRequest,
});
}
if (!receipt.items) {
throw new RecaseError({
message: `Lock receipt is missing items for key: ${lockKey}`,
code: ErrCode.InvalidRequest,
});
}
return {
receipt,
lockReceiptKey,
};
};

View File

@@ -0,0 +1,37 @@
import { generateKsuid } from "@autumn/ksuid";
import { type CheckParams, type LockParams, RecaseError } from "@autumn/shared";
export const parseCheckParamsForLock = ({
params,
}: {
params: CheckParams;
}) => {
const { lock } = params;
if (!lock?.enabled) {
return {
...params,
lock: undefined,
};
}
if (lock.key && lock.key.length > 256) {
throw new RecaseError({
message: "Lock key cannot exceed 256 characters",
});
}
const lockKey = lock.key ?? generateKsuid({ prefix: "lck" });
const hashedKey = Bun.hash(lockKey).toString();
const finalLock: LockParams = {
enabled: true,
key: lockKey,
hashed_key: hashedKey,
expires_at: lock.expires_at ?? undefined,
};
return {
...params,
lock: finalLock,
};
};

View File

@@ -0,0 +1,57 @@
import type { MutationLogItem } from "@/internal/balances/utils/types/mutationLogItem.js";
import type { LockReceipt } from "./fetchLockReceipt";
export const calculateLockValue = ({ items }: { items: MutationLogItem[] }) => {
return items.reduce((lockValue, item) => lockValue + item.value_delta, 0);
};
export const calculateUnwindValue = ({
receipt,
finalValue,
}: {
receipt: LockReceipt;
finalValue: number;
}) => {
const lockValue = calculateLockValue({
items: receipt.items,
});
const lockMagnitude = Math.abs(lockValue);
const finalMagnitude = Math.abs(finalValue);
if (lockValue === 0) {
return {
unwindValue: 0,
additionalValue: finalValue,
};
}
if (finalValue === 0) {
return {
unwindValue: lockMagnitude,
additionalValue: 0,
};
}
const lockSign = lockValue > 0 ? 1 : -1;
const finalSign = finalValue > 0 ? 1 : -1;
if (lockSign !== finalSign) {
return {
unwindValue: lockMagnitude,
additionalValue: finalValue,
};
}
if (finalMagnitude >= lockMagnitude) {
return {
unwindValue: 0,
additionalValue: finalValue - lockValue,
};
}
return {
unwindValue: lockMagnitude - finalMagnitude,
additionalValue: 0,
};
};

View File

@@ -1,29 +0,0 @@
import { generateKsuid } from "@autumn/ksuid";
import type { CheckParams, ReserveParams } from "@autumn/shared";
export const parseCheckParamsForReserve = ({
params,
}: {
params: CheckParams;
}) => {
const { reserve } = params;
if (!reserve?.enabled) {
return {
...params,
reserve: undefined,
};
}
const finalReserve: ReserveParams = {
enabled: true,
key: reserve.key
? Bun.hash(reserve.key).toString()
: generateKsuid({ prefix: "res" }),
expires_at: reserve.expires_at ?? undefined,
};
return {
...params,
reserve: finalReserve,
};
};

View File

@@ -1,11 +1,7 @@
import type {
CustomerEntitlementFilters,
FullCusEntWithFullCusProduct,
FullCustomer,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import type { DeductionUpdate } from "./deductionUpdate";
import type { FeatureDeduction } from "./featureDeduction.js";
/** Behavior options for deduction */
export type DeductionOptions = {
@@ -22,25 +18,6 @@ export type DeductionOptions = {
skipAdditionalBalance?: boolean;
};
/** Core params for deduction (shared by Redis & Postgres) */
type DeductionParams = {
ctx: AutumnContext;
fullCus: FullCustomer;
entityId?: string;
deductions: FeatureDeduction[];
options?: DeductionOptions;
};
/** Result from deduction (same for Redis & Postgres) */
type DeductionResult = {
oldFullCus: FullCustomer;
fullCus: FullCustomer | undefined;
isPaidAllocated: boolean;
actualDeductions: Record<string, number>;
remainingAmounts: Record<string, number>;
modifiedCusEntIds: string[];
};
/** Input for a single entitlement in the deduction script (Lua/SQL) */
export type CustomerEntitlementDeduction = {
customer_entitlement_id: string;
@@ -64,10 +41,12 @@ export type PreparedFeatureDeduction = {
// rolloverIds: string[];
rollovers: RolloverDeduction[];
unlimitedFeatureIds: string[];
};
/** Result from Postgres deduction */
type PostgresDeductionResult = {
updates: Record<string, DeductionUpdate>;
remaining: number;
lock?: {
enabled: true;
key?: string;
hashed_key?: string;
expires_at?: string;
redis_receipt_key: string;
created_at: number;
};
};

View File

@@ -1,7 +1,10 @@
import type { Feature, ReserveParams } from "@autumn/shared";
import type { Feature, LockParams } from "@autumn/shared";
export type FeatureDeduction = {
feature: Feature;
deduction: number;
targetBalance?: number;
reserve?: ReserveParams;
lock?: LockParams;
lockReceiptKey?: string;
unwindValue?: number;
};

View File

@@ -0,0 +1,11 @@
export interface MutationLogItem {
target_type: "customer_entitlement" | "rollover";
customer_entitlement_id: string | null;
rollover_id: string | null;
entity_id: string | null;
credit_cost: number;
balance_delta: number;
adjustment_delta: number;
usage_delta: number;
value_delta: number;
}

View File

@@ -1,15 +1,11 @@
import type { EntityRolloverBalance } from "@autumn/shared";
import type { DeductionUpdate } from "./deductionUpdate.js";
export interface RolloverUpdate {
balance: number;
usage: number;
entities: Record<string, EntityRolloverBalance>;
}
import type { MutationLogItem } from "./mutationLogItem.js";
import type { RolloverUpdate } from "./rolloverUpdate.js";
export interface LuaDeductionResult {
updates: Record<string, DeductionUpdate>;
rollover_updates: Record<string, RolloverUpdate>;
mutation_logs: MutationLogItem[];
remaining: number;
error?: string;
feature_id?: string;

View File

@@ -0,0 +1,8 @@
import type { EntityRolloverBalance } from "@autumn/shared";
export interface RolloverUpdate {
cus_ent_id?: string;
balance: number;
usage: number;
entities: Record<string, EntityRolloverBalance>;
}

View File

@@ -88,6 +88,7 @@ const createDevLogStream = () => {
"stripe_event",
"extras",
"type",
"durationMs",
];
const additionalFields = Object.keys(log)
.filter((key) => !excludeFields.includes(key))

View File

@@ -0,0 +1,54 @@
import { test } from "bun:test";
import type { ApiCustomerV5 } from "@autumn/shared";
import { deleteLock } from "@tests/integration/balances/utils/lockUtils/deleteLock.js";
import { TestFeature } from "@tests/setup/v2Features.js";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
// ═══════════════════════════════════════════════════════════════════
// CHECK: No feature attached
// ═══════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("check-with-lock-basic: /check with lock basic")}`, async () => {
const lockKey = "test-lock";
const hourlyMessages = items.hourlyMessages({ includedUsage: 5 });
const monthlyMessages = items.monthlyMessages({ includedUsage: 10 });
const freeProd = products.base({
id: "free",
items: [hourlyMessages, monthlyMessages],
});
const { customerId, autumnV2, ctx } = await initScenario({
customerId: "check-no-feature",
setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })],
actions: [s.attach({ productId: freeProd.id })],
});
await deleteLock({
ctx,
lockKey,
});
const firstResult = await autumnV2.check({
customer_id: customerId,
feature_id: TestFeature.Messages,
required_balance: 8,
lock: {
enabled: true,
key: lockKey,
},
});
// Release lock
await autumnV2.balances.finalize({
finalize_action: "confirm",
overwrite_value: 4,
lock_key: lockKey,
});
const customer = await autumnV2.customers.get<ApiCustomerV5>(customerId);
console.log("Message balance:", customer.balances[TestFeature.Messages]);
});

View File

@@ -0,0 +1,55 @@
import { test } from "bun:test";
import { deleteLock } from "@tests/integration/balances/utils/lockUtils/deleteLock.js";
import { TestFeature } from "@tests/setup/v2Features.js";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
// ═══════════════════════════════════════════════════════════════════
// CHECK: No feature attached
// ═══════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("check-with-lock-postgres: /check with lock postgres")}`, async () => {
const hourlyMessages = items.hourlyMessages({ includedUsage: 5 });
const monthlyMessages = items.monthlyMessages({ includedUsage: 10 });
const freeProd = products.base({
id: "free",
items: [hourlyMessages, monthlyMessages],
});
const { customerId, autumnV2, ctx } = await initScenario({
customerId: "check-with-lock-postgres",
setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })],
actions: [s.attach({ productId: freeProd.id })],
});
const lockKey = customerId;
await deleteLock({
ctx,
lockKey,
});
const firstResult = await autumnV2.check({
customer_id: customerId,
feature_id: TestFeature.Messages,
required_balance: 8,
lock: {
enabled: true,
key: lockKey,
},
skip_cache: true,
});
// // Release lock
// await autumnV2.balances.finalize({
// finalize_action: "confirm",
// overwrite_value: 4,
// lock_key: lockKey,
// });
// const customer = await autumnV2.customers.get<ApiCustomerV5>(customerId);
// console.log("Message balance:", customer.balances[TestFeature.Messages]);
});

View File

@@ -0,0 +1,20 @@
import type { TestContext } from "@tests/utils/testInitUtils/createTestContext.js";
import { redis } from "@/external/redis/initRedis.js";
import { buildLockReceiptKey } from "@/internal/balances/utils/lock/buildLockReceiptKey.js";
export const deleteLock = async ({
ctx,
lockKey,
}: {
ctx: TestContext;
lockKey: string;
}) => {
const hashedKey = Bun.hash(lockKey).toString();
const redisReceiptKey = buildLockReceiptKey({
orgId: ctx.org.id,
env: ctx.env,
lockKey: hashedKey,
});
await redis.del(redisReceiptKey);
};

View File

@@ -88,6 +88,35 @@ const monthlyMessages = ({
return item;
};
/**
* Hourly messages - resets each hour
* @param includedUsage - Free usage allowance (default: 100)
* @param entityFeatureId - Entity feature ID for per-entity balances
* @param resetUsageWhenEnabled - Whether to reset usage when enabled (default: undefined, uses server default)
*/
const hourlyMessages = ({
includedUsage = 100,
entityFeatureId,
resetUsageWhenEnabled,
}: {
includedUsage?: number;
entityFeatureId?: string;
resetUsageWhenEnabled?: boolean;
} = {}): LimitedItem => {
const item = constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage,
interval: ProductItemInterval.Hour,
entityFeatureId,
}) as LimitedItem;
if (resetUsageWhenEnabled !== undefined) {
item.reset_usage_when_enabled = resetUsageWhenEnabled;
}
return item;
};
/**
* Monthly words - resets each billing cycle
* @param includedUsage - Free usage allowance (default: 100)
@@ -721,6 +750,7 @@ export const items = {
// Free metered
free,
hourlyMessages,
monthlyMessages,
monthlyWords,
monthlyCredits,

View File

@@ -3,7 +3,7 @@ import { CustomerDataSchema } from "../../common/customerData";
import { EntityDataSchema } from "../../common/entityData";
import { queryStringArray } from "../../common/queryHelpers";
import { BalanceParamsBaseSchema } from "../common/balanceParamsBase";
import { ReserveParamsSchema } from "../common/reserveParams";
import { LockParamsSchema } from "../common/lockParams";
import { CheckExpand } from "./enums/CheckExpand";
export const CheckQuerySchema = z.object({
@@ -28,7 +28,7 @@ export const ExtCheckParamsSchema = BalanceParamsBaseSchema.extend({
"If true, atomically records a usage event while checking access. The required_balance value is used as the usage amount. Combines check + track in one call.",
}),
reserve: ReserveParamsSchema.optional(),
lock: LockParamsSchema.optional(),
with_preview: z.boolean().optional().meta({
description:

View File

@@ -27,9 +27,9 @@ export const CheckResponseV3Schema = z.object({
"The customer's balance for this feature. Null if the customer has no balance for this feature.",
}),
reserve_key: z.string().optional().meta({
lock_key: z.string().optional().meta({
description:
"The reservation key associated with this check when reserve mode is enabled.",
"The lock key associated with this check when lock mode is enabled.",
}),
preview: CheckFeaturePreviewSchema.optional().meta({

View File

@@ -0,0 +1,16 @@
import { z } from "zod/v4";
export const LockParamsSchema = z
.object({
enabled: z.literal(true),
key: z.string().max(256).optional(),
hashed_key: z.string().optional().meta({
internal: true,
}),
expires_at: z.string().optional(),
})
.meta({
internal: true,
});
export type LockParams = z.infer<typeof LockParamsSchema>;

View File

@@ -1,13 +0,0 @@
import { z } from "zod/v4";
export const ReserveParamsSchema = z
.object({
enabled: z.literal(true),
key: z.string().optional(),
expires_at: z.string().optional(),
})
.meta({
internal: true,
});
export type ReserveParams = z.infer<typeof ReserveParamsSchema>;

View File

@@ -0,0 +1,9 @@
import { z } from "zod/v4";
export const FinalizeLockParamsV0Schema = z.object({
lock_key: z.string(),
finalize_action: z.enum(["confirm", "release"]),
overwrite_value: z.number(),
});
export type FinalizeLockParamsV0 = z.infer<typeof FinalizeLockParamsV0Schema>;

View File

@@ -0,0 +1 @@
export * from "./finalizeLockParamsV0";

View File

@@ -1,4 +1,5 @@
export * from "./common/reserveParams";
export * from "./common/lockParams";
export * from "./create/index";
export * from "./delete/index";
export * from "./finalizeLock/index";
export * from "./update/index";

View File

@@ -4,7 +4,7 @@ import { EntityDataSchema } from "../../common/entityData";
import { queryStringArray } from "../../common/queryHelpers";
import { CheckExpand } from "../check/enums/CheckExpand";
import { BalanceParamsBaseSchema } from "../common/balanceParamsBase";
import { ReserveParamsSchema } from "../common/reserveParams";
import { LockParamsSchema } from "../common/lockParams";
export const TrackQuerySchema = z.object({
expand: queryStringArray(z.enum([CheckExpand.BalanceFeature])).optional(),
@@ -52,7 +52,7 @@ export const TrackParamsSchema = BalanceParamsBaseSchema.extend({
internal: true,
}),
reserve: ReserveParamsSchema.optional(),
lock: LockParamsSchema.optional(),
}).refine(
(data) => {
if (data.feature_id && data.event_name) {