completed redis lock release functions
This commit is contained in:
323
.claude/skills/mutation-logs/SKILL.md
Normal file
323
.claude/skills/mutation-logs/SKILL.md
Normal 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 feature’s 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?
|
||||
285
.plans/check-reserve/03_deduction_result_shapes.md
Normal file
285
.plans/check-reserve/03_deduction_result_shapes.md
Normal 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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
--[[
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
78
server/src/_luaScriptsV2/deduction/lock/unwindAndDeduct.lua
Normal file
78
server/src/_luaScriptsV2/deduction/lock/unwindAndDeduct.lua
Normal 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 {},
|
||||
})
|
||||
404
server/src/_luaScriptsV2/deduction/lock/unwindLockUtils.lua
Normal file
404
server/src/_luaScriptsV2/deduction/lock/unwindLockUtils.lua
Normal 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
|
||||
124
server/src/_luaScriptsV2/deduction/mutationItemUtils.lua
Normal file
124
server/src/_luaScriptsV2/deduction/mutationItemUtils.lua
Normal 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
|
||||
@@ -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
|
||||
// ============================================================================
|
||||
|
||||
5
server/src/external/autumn/autumnCli.ts
vendored
5
server/src/external/autumn/autumnCli.ts
vendored
@@ -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 = {
|
||||
|
||||
7
server/src/external/redis/initRedis.ts
vendored
7
server/src/external/redis/initRedis.ts
vendored
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
103
server/src/internal/balances/finalizeLock/finalizeLock.ts
Normal file
103
server/src/internal/balances/finalizeLock/finalizeLock.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
18
server/src/internal/balances/handlers/handleFinalizeLock.ts
Normal file
18
server/src/internal/balances/handlers/handleFinalizeLock.ts
Normal 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,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -27,7 +27,7 @@ export const handleTrack = createRoute({
|
||||
? getTrackFeatureDeductions({
|
||||
ctx,
|
||||
featureId: body.feature_id,
|
||||
reserve: body.reserve,
|
||||
lock: body.lock,
|
||||
value: body.value,
|
||||
})
|
||||
: getTrackEventNameDeductions({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = ({
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export const buildLockReceiptKey = ({
|
||||
orgId,
|
||||
env,
|
||||
lockKey,
|
||||
}: {
|
||||
orgId: string;
|
||||
env: string;
|
||||
lockKey: string;
|
||||
}) => {
|
||||
return `{${orgId}}:${env}:lock_receipt:${lockKey}`;
|
||||
};
|
||||
66
server/src/internal/balances/utils/lock/fetchLockReceipt.ts
Normal file
66
server/src/internal/balances/utils/lock/fetchLockReceipt.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
57
server/src/internal/balances/utils/lock/unwindLockUtils.ts
Normal file
57
server/src/internal/balances/utils/lock/unwindLockUtils.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
11
server/src/internal/balances/utils/types/mutationLogItem.ts
Normal file
11
server/src/internal/balances/utils/types/mutationLogItem.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -88,6 +88,7 @@ const createDevLogStream = () => {
|
||||
"stripe_event",
|
||||
"extras",
|
||||
"type",
|
||||
"durationMs",
|
||||
];
|
||||
const additionalFields = Object.keys(log)
|
||||
.filter((key) => !excludeFields.includes(key))
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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({
|
||||
|
||||
16
shared/api/balances/common/lockParams.ts
Normal file
16
shared/api/balances/common/lockParams.ts
Normal 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>;
|
||||
@@ -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>;
|
||||
9
shared/api/balances/finalizeLock/finalizeLockParamsV0.ts
Normal file
9
shared/api/balances/finalizeLock/finalizeLockParamsV0.ts
Normal 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>;
|
||||
1
shared/api/balances/finalizeLock/index.ts
Normal file
1
shared/api/balances/finalizeLock/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./finalizeLockParamsV0";
|
||||
@@ -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";
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user