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"],
|
"watch": ["../shared", "src"],
|
||||||
"ext": "js,ts",
|
"ext": "js,ts,lua",
|
||||||
"ignore": [
|
"ignore": [
|
||||||
"../shared/dist/**/*.d.ts",
|
"../shared/dist/**/*.d.ts",
|
||||||
"../shared/node_modules",
|
"../shared/node_modules",
|
||||||
@@ -13,4 +13,3 @@
|
|||||||
"NODE_ENV": "development"
|
"NODE_ENV": "development"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,12 +12,13 @@
|
|||||||
|
|
||||||
params:
|
params:
|
||||||
cache_key: string
|
cache_key: string
|
||||||
sorted_entitlements: array of entitlement objects
|
customer_entitlement_ids: array of customer_entitlement IDs
|
||||||
full_customer: decoded FullCustomer object
|
full_customer: decoded FullCustomer object
|
||||||
|
|
||||||
Returns: context table with:
|
Returns: context table with:
|
||||||
customer_entitlements: { [cus_ent_id]: { base_path, balance, adjustment, entities } }
|
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)
|
pending_writes: {} (empty array to queue writes)
|
||||||
logs: {} (debug logs)
|
logs: {} (debug logs)
|
||||||
logger: { log(fmt, ...): function } (logger that appends to logs)
|
logger: { log(fmt, ...): function } (logger that appends to logs)
|
||||||
@@ -28,6 +29,7 @@ local function init_context(params)
|
|||||||
local context = {
|
local context = {
|
||||||
customer_entitlements = {},
|
customer_entitlements = {},
|
||||||
rollovers = {},
|
rollovers = {},
|
||||||
|
mutation_logs = {},
|
||||||
pending_writes = {},
|
pending_writes = {},
|
||||||
logs = logs,
|
logs = logs,
|
||||||
logger = {
|
logger = {
|
||||||
@@ -37,8 +39,7 @@ local function init_context(params)
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, ent_obj in ipairs(params.sorted_entitlements) do
|
for _, ent_id in ipairs(params.customer_entitlement_ids or {}) do
|
||||||
local ent_id = ent_obj.customer_entitlement_id
|
|
||||||
local cus_ent, cus_product, ce_idx, cp_idx = find_entitlement(params.full_customer, ent_id)
|
local cus_ent, cus_product, ce_idx, cp_idx = find_entitlement(params.full_customer, ent_id)
|
||||||
|
|
||||||
if cus_ent then
|
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 .. ']'
|
base_path = '$.customer_products[' .. cp_idx_0 .. '].customer_entitlements[' .. ce_idx_0 .. ']'
|
||||||
end
|
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 = {
|
local ent_data = {
|
||||||
base_path = base_path,
|
base_path = base_path,
|
||||||
@@ -90,6 +93,7 @@ local function init_context(params)
|
|||||||
if rollover_data then
|
if rollover_data then
|
||||||
context.rollovers[rollover.id] = {
|
context.rollovers[rollover.id] = {
|
||||||
base_path = rollover_path,
|
base_path = rollover_path,
|
||||||
|
cus_ent_id = ent_id,
|
||||||
balance = rollover_data.balance,
|
balance = rollover_data.balance,
|
||||||
usage = rollover_data.usage,
|
usage = rollover_data.usage,
|
||||||
entities = rollover_data.entities,
|
entities = rollover_data.entities,
|
||||||
@@ -105,111 +109,191 @@ local function init_context(params)
|
|||||||
end
|
end
|
||||||
|
|
||||||
--[[
|
--[[
|
||||||
update_in_memory_customer_entitlement(params)
|
append_mutation_log(params)
|
||||||
|
|
||||||
Updates balance (and optionally adjustment) in-memory on the context object.
|
Appends one ordered mutation log entry for later receipt persistence and replay.
|
||||||
|
|
||||||
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)
|
|
||||||
]]
|
]]
|
||||||
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 target = params.target
|
||||||
local entity_id = params.entity_id
|
local entity_id = params.entity_id
|
||||||
local delta = params.delta
|
local balance_delta = params.balance_delta
|
||||||
local alter_granted_balance = params.alter_granted_balance
|
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 entity_id then
|
||||||
if not target[entity_id] then
|
if not target[entity_id] then
|
||||||
target[entity_id] = { balance = 0, adjustment = 0 }
|
target[entity_id] = { balance = 0, adjustment = 0 }
|
||||||
end
|
end
|
||||||
target[entity_id].balance = (target[entity_id].balance or 0) + delta
|
target[entity_id].balance = (target[entity_id].balance or 0) + balance_delta
|
||||||
if alter_granted_balance then
|
target[entity_id].adjustment = (target[entity_id].adjustment or 0) + adjustment_delta
|
||||||
target[entity_id].adjustment = (target[entity_id].adjustment or 0) + delta
|
return
|
||||||
end
|
|
||||||
else
|
|
||||||
target.balance = (target.balance or 0) + delta
|
|
||||||
if alter_granted_balance then
|
|
||||||
target.adjustment = (target.adjustment or 0) + delta
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
target.balance = (target.balance or 0) + balance_delta
|
||||||
|
target.adjustment = (target.adjustment or 0) + adjustment_delta
|
||||||
end
|
end
|
||||||
|
|
||||||
--[[
|
--[[
|
||||||
queue_balance_update(params)
|
update_in_memory_rollover_mutation(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
|
|
||||||
|
|
||||||
--[[
|
Applies an arbitrary balance/usage mutation to an in-memory rollover target.
|
||||||
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)
|
|
||||||
]]
|
]]
|
||||||
local function queue_rollover_update(params)
|
local function update_in_memory_rollover_mutation(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 target = params.target
|
local target = params.target
|
||||||
local entity_id = params.entity_id
|
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 entity_id then
|
||||||
if not target[entity_id] then
|
if not target[entity_id] then
|
||||||
target[entity_id] = { balance = 0, usage = 0 }
|
target[entity_id] = { balance = 0, usage = 0 }
|
||||||
end
|
end
|
||||||
target[entity_id].balance = (target[entity_id].balance 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) + deduct_amount
|
target[entity_id].usage = (target[entity_id].usage or 0) + usage_delta
|
||||||
else
|
return
|
||||||
target.balance = (target.balance or 0) - deduct_amount
|
|
||||||
target.usage = (target.usage or 0) + deduct_amount
|
|
||||||
end
|
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
|
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 alter_granted_balance = params.alter_granted_balance or false
|
||||||
local overage_behaviour = params.overage_behaviour or 'cap'
|
local overage_behaviour = params.overage_behaviour or 'cap'
|
||||||
local feature_id = params.feature_id
|
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
|
-- Compute overage_behavior_is_allow once
|
||||||
local overage_behavior_is_allow = alter_granted_balance or overage_behaviour == 'allow'
|
local overage_behavior_is_allow = alter_granted_balance or overage_behaviour == 'allow'
|
||||||
@@ -86,200 +89,66 @@ if not full_customer.customer_products then
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- Track updates for return value
|
-- Track updates for return value
|
||||||
local updates = {}
|
|
||||||
|
|
||||||
-- Initialize context with in-memory state from Redis
|
-- 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({
|
local context = init_context({
|
||||||
cache_key = cache_key,
|
cache_key = cache_key,
|
||||||
sorted_entitlements = sorted_entitlements,
|
customer_entitlement_ids = customer_entitlement_ids,
|
||||||
full_customer = full_customer,
|
full_customer = full_customer,
|
||||||
})
|
})
|
||||||
|
|
||||||
-- Initialize remaining_amount (after context so we can use get_total_balance)
|
if not is_nil(unwind_value) and safe_number(unwind_value) > 0 then
|
||||||
local remaining_amount
|
local unwind_result = unwind_lock_on_context({
|
||||||
if not is_nil(target_balance) then
|
|
||||||
-- Calculate amount to deduct based on target_balance
|
|
||||||
local current_total = get_total_balance({
|
|
||||||
context = context,
|
context = context,
|
||||||
sorted_entitlements = sorted_entitlements,
|
lock_receipt_key = lock_receipt_key,
|
||||||
target_entity_id = target_entity_id,
|
unwind_value = unwind_value,
|
||||||
})
|
})
|
||||||
remaining_amount = current_total - target_balance
|
|
||||||
else
|
|
||||||
remaining_amount = amount_to_deduct or 0
|
|
||||||
end
|
|
||||||
|
|
||||||
-- ============================================================================
|
if not is_nil(unwind_result.error) then
|
||||||
-- HELPER: Round number to eliminate floating point errors
|
return cjson.encode({
|
||||||
-- ============================================================================
|
error = unwind_result.error,
|
||||||
local function round_to_precision(num, decimals)
|
updates = {},
|
||||||
local mult = 10 ^ (decimals or 10)
|
rollover_updates = {},
|
||||||
return math.floor(num * mult + 0.5) / mult
|
mutation_logs = context.mutation_logs or cjson.decode('[]'),
|
||||||
|
remaining = 0,
|
||||||
|
logs = context.logs,
|
||||||
|
})
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Determine if this is a refund (negative amount)
|
|
||||||
local is_refund = remaining_amount < 0
|
|
||||||
|
|
||||||
local logger = context.logger
|
local logger = context.logger
|
||||||
logger.log("=== LUA DEDUCTION START ===")
|
logger.log("=== LUA DEDUCTION START ===")
|
||||||
logger.log("=== PARAMS ===")
|
logger.log("=== PARAMS ===")
|
||||||
logger.log(" amount_to_deduct: %s", tostring(amount_to_deduct or "nil"))
|
logger.log(" amount_to_deduct: %s", tostring(amount_to_deduct or "nil"))
|
||||||
logger.log(" target_balance: %s", tostring(target_balance 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(" alter_granted_balance: %s", tostring(alter_granted_balance or false))
|
||||||
logger.log(" target_entity_id: %s", tostring(target_entity_id or "nil"))
|
logger.log(" target_entity_id: %s", tostring(target_entity_id or "nil"))
|
||||||
logger.log(" overage_behaviour: %s", tostring(overage_behaviour or "nil"))
|
logger.log(" overage_behaviour: %s", tostring(overage_behaviour or "nil"))
|
||||||
|
local deduction_result = run_deduction_on_context({
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- 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,
|
|
||||||
context = 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
|
logger.log(" remaining_amount: %s", tostring(remaining_amount or "nil"))
|
||||||
-- For deductions: only usage_allowed entitlements can go below 0 (into overage)
|
logger.log(" is_refund: %s", tostring(remaining_amount < 0 or false))
|
||||||
-- For refunds: ALL entitlements can go above 0 (up to max_balance)
|
local mutation_logs = context.mutation_logs
|
||||||
if remaining_amount ~= 0 then
|
if #mutation_logs == 0 then
|
||||||
process_pass({
|
mutation_logs = cjson.decode('[]')
|
||||||
pass_number = 2,
|
|
||||||
skip_if_not_usage_allowed = not is_refund, -- Only skip for deductions, not refunds
|
|
||||||
context = context,
|
|
||||||
})
|
|
||||||
|
|
||||||
end
|
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
|
-- 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
|
if remaining_amount > 0 and overage_behaviour == 'reject' then
|
||||||
return cjson.encode({
|
return cjson.encode({
|
||||||
@@ -287,53 +156,41 @@ if remaining_amount > 0 and overage_behaviour == 'reject' then
|
|||||||
feature_id = feature_id,
|
feature_id = feature_id,
|
||||||
remaining = remaining_amount,
|
remaining = remaining_amount,
|
||||||
updates = {},
|
updates = {},
|
||||||
|
mutation_logs = mutation_logs,
|
||||||
logs = context.logs
|
logs = context.logs
|
||||||
})
|
})
|
||||||
end
|
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 all pending writes to Redis (only after validation passes)
|
||||||
apply_pending_writes(cache_key, context)
|
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 ===")
|
logger.log("=== LUA DEDUCTION END ===")
|
||||||
|
|
||||||
return cjson.encode({
|
return cjson.encode({
|
||||||
updates = updates,
|
updates = updates,
|
||||||
rollover_updates = rollover_updates,
|
rollover_updates = rollover_updates,
|
||||||
|
mutation_logs = mutation_logs,
|
||||||
remaining = remaining_amount,
|
remaining = remaining_amount,
|
||||||
error = cjson.null,
|
error = cjson.null,
|
||||||
logs = context.logs
|
logs = context.logs
|
||||||
|
|||||||
@@ -145,18 +145,22 @@ local function deduct_from_main_balance(params)
|
|||||||
if to_change ~= 0 then
|
if to_change ~= 0 then
|
||||||
local entity_path = build_entity_path(base_path, params.target_entity_id)
|
local entity_path = build_entity_path(base_path, params.target_entity_id)
|
||||||
|
|
||||||
queue_balance_update({
|
queue_customer_entitlement_mutation({
|
||||||
context = context,
|
context = context,
|
||||||
path = entity_path,
|
path = entity_path,
|
||||||
delta = -to_change,
|
delta = -to_change,
|
||||||
alter_granted_balance = params.alter_granted_balance,
|
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,
|
target = entities,
|
||||||
entity_id = params.target_entity_id,
|
entity_id = params.target_entity_id,
|
||||||
delta = -to_change,
|
balance_delta = -to_change,
|
||||||
alter_granted_balance = params.alter_granted_balance,
|
adjustment_delta = params.alter_granted_balance and -to_change or 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
deducted = to_change
|
deducted = to_change
|
||||||
@@ -191,18 +195,22 @@ local function deduct_from_main_balance(params)
|
|||||||
if to_change ~= 0 then
|
if to_change ~= 0 then
|
||||||
local entity_path = build_entity_path(base_path, entity_key)
|
local entity_path = build_entity_path(base_path, entity_key)
|
||||||
|
|
||||||
queue_balance_update({
|
queue_customer_entitlement_mutation({
|
||||||
context = context,
|
context = context,
|
||||||
path = entity_path,
|
path = entity_path,
|
||||||
delta = -to_change,
|
delta = -to_change,
|
||||||
alter_granted_balance = params.alter_granted_balance,
|
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,
|
target = entities,
|
||||||
entity_id = entity_key,
|
entity_id = entity_key,
|
||||||
delta = -to_change,
|
balance_delta = -to_change,
|
||||||
alter_granted_balance = params.alter_granted_balance,
|
adjustment_delta = params.alter_granted_balance and -to_change or 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
deducted = deducted + to_change
|
deducted = deducted + to_change
|
||||||
@@ -236,18 +244,22 @@ local function deduct_from_main_balance(params)
|
|||||||
local delta = -to_change
|
local delta = -to_change
|
||||||
logger.log("%s queuing: delta=%s, alter_granted_balance=%s", prefix, delta, tostring(params.alter_granted_balance))
|
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,
|
context = context,
|
||||||
path = base_path,
|
path = base_path,
|
||||||
delta = delta,
|
delta = delta,
|
||||||
alter_granted_balance = params.alter_granted_balance,
|
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,
|
target = ent_data,
|
||||||
entity_id = nil,
|
entity_id = nil,
|
||||||
delta = delta,
|
balance_delta = delta,
|
||||||
alter_granted_balance = params.alter_granted_balance,
|
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)
|
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,
|
context = context,
|
||||||
path = entity_path,
|
path = entity_path,
|
||||||
deduct_amount = to_change,
|
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({
|
update_in_memory_rollover({
|
||||||
@@ -146,6 +150,10 @@ local function deduct_from_rollovers(params)
|
|||||||
context = context,
|
context = context,
|
||||||
path = entity_path,
|
path = entity_path,
|
||||||
deduct_amount = to_change,
|
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({
|
update_in_memory_rollover({
|
||||||
@@ -176,6 +184,10 @@ local function deduct_from_rollovers(params)
|
|||||||
context = context,
|
context = context,
|
||||||
path = base_path,
|
path = base_path,
|
||||||
deduct_amount = to_change,
|
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({
|
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
|
-- LOCK EXPIRY HELPERS
|
||||||
-- Helpers for indexing reservation expiries in a Redis sorted set
|
-- 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.
|
-- No-op when expires_at_ms is nil/null.
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
local function add_reservation_expiry(expiry_zset_key, reservation_key, expires_at_ms)
|
local function add_lock_expiry(expiry_zset_key, lock_receipt_key, expires_at_ms)
|
||||||
if expires_at_ms == nil or expires_at_ms == cjson.null then
|
if is_nil(expires_at_ms) then
|
||||||
return 0
|
return 0
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- HELPER: Remove reservation from expiry index
|
-- HELPER: Remove lock from expiry index
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
local function remove_reservation_expiry(expiry_zset_key, reservation_key)
|
local function remove_lock_expiry(expiry_zset_key, lock_receipt_key)
|
||||||
return redis.call('ZREM', expiry_zset_key, reservation_key)
|
return redis.call('ZREM', expiry_zset_key, lock_receipt_key)
|
||||||
end
|
end
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- RESERVATION RECEIPT REDIS HELPERS
|
-- LOCK RECEIPT HELPERS
|
||||||
-- Helpers for loading and storing reservation receipts in RedisJSON
|
-- 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.
|
-- JSON.GET on '$' returns an array of matches, so unwrap the first element.
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
local function decode_root_json(result)
|
local function decode_root_json(result)
|
||||||
if not result or result == cjson.null then
|
if is_nil(result) then
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -25,19 +25,35 @@ local function decode_root_json(result)
|
|||||||
end
|
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.
|
-- Returns the decoded receipt table or nil if the key does not exist.
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
local function load_reservation_receipt(reservation_key)
|
local function load_lock_receipt(lock_receipt_key)
|
||||||
local result = redis.call('JSON.GET', reservation_key, '$')
|
local result = redis.call('JSON.GET', lock_receipt_key, '$')
|
||||||
return decode_root_json(result)
|
return decode_root_json(result)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- HELPER: Store reservation receipt in Redis
|
-- HELPER: Store lock receipt in Redis
|
||||||
-- Overwrites the full receipt document at the reservation key.
|
-- Overwrites the full receipt document at the lock receipt key.
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
local function store_reservation_receipt(reservation_key, receipt)
|
local function store_lock_receipt(lock_receipt_key, receipt)
|
||||||
redis.call('JSON.SET', reservation_key, '$', cjson.encode(receipt))
|
redis.call('JSON.SET', lock_receipt_key, '$', cjson.encode(receipt))
|
||||||
return receipt
|
return receipt
|
||||||
end
|
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
|
-- RESERVATION STATE HELPERS
|
||||||
-- Helpers for guarding and transitioning reservation receipt state
|
-- Helpers for guarding and transitioning lock receipt state
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
|
|
||||||
local RESERVATION_STATUS_PENDING = 'pending'
|
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.
|
-- Defaults to pending if status is absent to ease early migrations.
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
local function get_reservation_status(receipt)
|
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
|
return RESERVATION_STATUS_PENDING
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ end
|
|||||||
-- Returns nil when valid, or an error code string when invalid.
|
-- Returns nil when valid, or an error code string when invalid.
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
local function require_pending_receipt(receipt)
|
local function require_pending_receipt(receipt)
|
||||||
if not receipt then
|
if is_nil(receipt) then
|
||||||
return 'RESERVATION_NOT_FOUND'
|
return 'RESERVATION_NOT_FOUND'
|
||||||
end
|
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
|
// Path to script folders
|
||||||
const DEDUCT_DIR = join(__dirname, "deductFromCustomerEntitlements");
|
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 DELETE_CACHE_DIR = join(__dirname, "deleteFullCustomerCache");
|
||||||
const RESET_DIR = join(__dirname, "resetCustomerEntitlements");
|
const RESET_DIR = join(__dirname, "resetCustomerEntitlements");
|
||||||
const UPDATE_DIR = join(__dirname, "updateCustomerEntitlements");
|
const UPDATE_DIR = join(__dirname, "updateCustomerEntitlements");
|
||||||
@@ -42,6 +44,31 @@ const DEDUCT_FROM_MAIN_BALANCE = readFileSync(
|
|||||||
"utf-8",
|
"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
|
// MAIN SCRIPT
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -63,8 +90,30 @@ ${CONTEXT_UTILS}
|
|||||||
${GET_TOTAL_BALANCE}
|
${GET_TOTAL_BALANCE}
|
||||||
${DEDUCT_FROM_ROLLOVERS}
|
${DEDUCT_FROM_ROLLOVERS}
|
||||||
${DEDUCT_FROM_MAIN_BALANCE}
|
${DEDUCT_FROM_MAIN_BALANCE}
|
||||||
|
${RUN_DEDUCTION_ON_CONTEXT}
|
||||||
|
${MUTATION_ITEM_UTILS}
|
||||||
|
${LOCK_RECEIPT_UTILS}
|
||||||
|
${LOCK_STATE_UTILS}
|
||||||
|
${LOCK_UNWIND_UTILS}
|
||||||
${mainScript}`;
|
${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
|
// 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,
|
type DeleteBalanceParamsV0,
|
||||||
EntityExpand,
|
EntityExpand,
|
||||||
ErrCode,
|
ErrCode,
|
||||||
|
type FinalizeLockParamsV0,
|
||||||
type LegacyVersion,
|
type LegacyVersion,
|
||||||
type OrgConfig,
|
type OrgConfig,
|
||||||
type ProductItem,
|
type ProductItem,
|
||||||
@@ -849,6 +850,10 @@ export class AutumnInt {
|
|||||||
const data = await this.post(`/balances.delete`, params);
|
const data = await this.post(`/balances.delete`, params);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
finalize: async (params: FinalizeLockParamsV0) => {
|
||||||
|
const data = await this.post(`/balances.finalize`, params);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
subscriptions = {
|
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,
|
DELETE_FULL_CUSTOMER_CACHE_SCRIPT,
|
||||||
RESET_CUSTOMER_ENTITLEMENTS_SCRIPT,
|
RESET_CUSTOMER_ENTITLEMENTS_SCRIPT,
|
||||||
SET_FULL_CUSTOMER_CACHE_SCRIPT,
|
SET_FULL_CUSTOMER_CACHE_SCRIPT,
|
||||||
|
UNWIND_AND_DEDUCT_SCRIPT,
|
||||||
UPDATE_CUSTOMER_DATA_SCRIPT,
|
UPDATE_CUSTOMER_DATA_SCRIPT,
|
||||||
UPDATE_CUSTOMER_ENTITLEMENTS_SCRIPT,
|
UPDATE_CUSTOMER_ENTITLEMENTS_SCRIPT,
|
||||||
UPDATE_CUSTOMER_PRODUCT_SCRIPT,
|
UPDATE_CUSTOMER_PRODUCT_SCRIPT,
|
||||||
@@ -171,6 +172,11 @@ const configureRedisInstance = (redisInstance: Redis): Redis => {
|
|||||||
lua: DEDUCT_FROM_CUSTOMER_ENTITLEMENTS_SCRIPT,
|
lua: DEDUCT_FROM_CUSTOMER_ENTITLEMENTS_SCRIPT,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
redisInstance.defineCommand("unwindAndDeduct", {
|
||||||
|
numberOfKeys: 0,
|
||||||
|
lua: UNWIND_AND_DEDUCT_SCRIPT,
|
||||||
|
});
|
||||||
|
|
||||||
redisInstance.defineCommand("deleteFullCustomerCache", {
|
redisInstance.defineCommand("deleteFullCustomerCache", {
|
||||||
numberOfKeys: 3,
|
numberOfKeys: 3,
|
||||||
lua: DELETE_FULL_CUSTOMER_CACHE_SCRIPT,
|
lua: DELETE_FULL_CUSTOMER_CACHE_SCRIPT,
|
||||||
@@ -374,6 +380,7 @@ declare module "ioredis" {
|
|||||||
cacheKey: string,
|
cacheKey: string,
|
||||||
paramsJson: string,
|
paramsJson: string,
|
||||||
): Promise<string>;
|
): Promise<string>;
|
||||||
|
unwindAndDeduct(paramsJson: string): Promise<string>;
|
||||||
deleteFullCustomerCache(
|
deleteFullCustomerCache(
|
||||||
testGuardKey: string,
|
testGuardKey: string,
|
||||||
guardKey: string,
|
guardKey: string,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
type CheckResponseV3,
|
type CheckResponseV3,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
|
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 { getCheckData } from "./checkUtils/getCheckData.js";
|
||||||
import { getV2CheckResponse } from "./checkUtils/getV2CheckResponse.js";
|
import { getV2CheckResponse } from "./checkUtils/getV2CheckResponse.js";
|
||||||
import { getCheckPreview } from "./getCheckPreview.js";
|
import { getCheckPreview } from "./getCheckPreview.js";
|
||||||
@@ -27,7 +27,7 @@ export const handleCheck = createRoute({
|
|||||||
let body = c.req.valid("json");
|
let body = c.req.valid("json");
|
||||||
const ctx = c.get("ctx");
|
const ctx = c.get("ctx");
|
||||||
|
|
||||||
body = parseCheckParamsForReserve({
|
body = parseCheckParamsForLock({
|
||||||
params: body,
|
params: body,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ export const handleCheck = createRoute({
|
|||||||
});
|
});
|
||||||
|
|
||||||
let response: CheckResponseV3;
|
let response: CheckResponseV3;
|
||||||
if (send_event) {
|
if (send_event || body.lock?.enabled) {
|
||||||
response = await runCheckWithTrack({
|
response = await runCheckWithTrack({
|
||||||
ctx,
|
ctx,
|
||||||
body,
|
body,
|
||||||
@@ -99,7 +99,7 @@ export const handleCheck = createRoute({
|
|||||||
return c.json({
|
return c.json({
|
||||||
...transformedResponse,
|
...transformedResponse,
|
||||||
preview,
|
preview,
|
||||||
reserve_key: body.reserve?.key ?? undefined,
|
lock_key: body.lock?.key ?? undefined,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export const runCheckWithTrack = async ({
|
|||||||
const featureDeductions = getTrackFeatureDeductions({
|
const featureDeductions = getTrackFeatureDeductions({
|
||||||
ctx,
|
ctx,
|
||||||
featureId: body.feature_id,
|
featureId: body.feature_id,
|
||||||
reserve: body.reserve,
|
lock: body.lock,
|
||||||
value: requiredBalance,
|
value: requiredBalance,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ export const runCheckWithTrack = async ({
|
|||||||
properties: body.properties,
|
properties: body.properties,
|
||||||
skip_event: body.skip_event,
|
skip_event: body.skip_event,
|
||||||
overage_behavior: "reject",
|
overage_behavior: "reject",
|
||||||
reserve: body.reserve,
|
lock: body.lock,
|
||||||
};
|
};
|
||||||
|
|
||||||
let allowed = true;
|
let allowed = true;
|
||||||
@@ -95,7 +95,7 @@ export const runCheckWithTrack = async ({
|
|||||||
entity_id: checkData.entityId,
|
entity_id: checkData.entityId,
|
||||||
required_balance: requiredBalance,
|
required_balance: requiredBalance,
|
||||||
balance: checkData.apiBalance ?? null,
|
balance: checkData.apiBalance ?? null,
|
||||||
reserve_key: body.reserve?.key,
|
lock_key: body.lock?.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
return checkResponse;
|
return checkResponse;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
|
|||||||
import { handleCheck } from "../api/check/handleCheck.js";
|
import { handleCheck } from "../api/check/handleCheck.js";
|
||||||
import { handleCreateBalance } from "./handlers/handleCreateBalance.js";
|
import { handleCreateBalance } from "./handlers/handleCreateBalance.js";
|
||||||
import { handleDeleteBalance } from "./handlers/handleDeleteBalance.js";
|
import { handleDeleteBalance } from "./handlers/handleDeleteBalance.js";
|
||||||
|
import { handleFinalizeLock } from "./handlers/handleFinalizeLock.js";
|
||||||
import { handleListBalances } from "./handlers/handleListBalances.js";
|
import { handleListBalances } from "./handlers/handleListBalances.js";
|
||||||
import { handleTrack } from "./handlers/handleTrack.js";
|
import { handleTrack } from "./handlers/handleTrack.js";
|
||||||
import { handleUpdateBalance } from "./handlers/handleUpdateBalance.js";
|
import { handleUpdateBalance } from "./handlers/handleUpdateBalance.js";
|
||||||
@@ -32,3 +33,4 @@ balancesRpcRouter.post("/balances.delete", ...handleDeleteBalance);
|
|||||||
|
|
||||||
balancesRpcRouter.post("/balances.track", ...handleTrack);
|
balancesRpcRouter.post("/balances.track", ...handleTrack);
|
||||||
balancesRpcRouter.post("/balances.check", ...handleCheck);
|
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({
|
? getTrackFeatureDeductions({
|
||||||
ctx,
|
ctx,
|
||||||
featureId: body.feature_id,
|
featureId: body.feature_id,
|
||||||
reserve: body.reserve,
|
lock: body.lock,
|
||||||
value: body.value,
|
value: body.value,
|
||||||
})
|
})
|
||||||
: getTrackEventNameDeductions({
|
: getTrackEventNameDeductions({
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
FeatureNotFoundError,
|
FeatureNotFoundError,
|
||||||
|
type LockParams,
|
||||||
RecaseError,
|
RecaseError,
|
||||||
type ReserveParams,
|
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js";
|
import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js";
|
||||||
import type { FeatureDeduction } from "../../utils/types/featureDeduction.js";
|
import type { FeatureDeduction } from "../../utils/types/featureDeduction.js";
|
||||||
@@ -11,12 +11,12 @@ const DEFAULT_VALUE = 1;
|
|||||||
export const getTrackFeatureDeductions = ({
|
export const getTrackFeatureDeductions = ({
|
||||||
ctx,
|
ctx,
|
||||||
featureId,
|
featureId,
|
||||||
reserve,
|
lock,
|
||||||
value,
|
value,
|
||||||
}: {
|
}: {
|
||||||
ctx: AutumnContext;
|
ctx: AutumnContext;
|
||||||
featureId: string;
|
featureId: string;
|
||||||
reserve?: ReserveParams;
|
lock?: LockParams;
|
||||||
value?: number;
|
value?: number;
|
||||||
}) => {
|
}) => {
|
||||||
const featureDeductions: FeatureDeduction[] = [];
|
const featureDeductions: FeatureDeduction[] = [];
|
||||||
@@ -35,7 +35,7 @@ export const getTrackFeatureDeductions = ({
|
|||||||
featureDeductions.push({
|
featureDeductions.push({
|
||||||
feature: mainFeature,
|
feature: mainFeature,
|
||||||
deduction: mainFeatureDeduction,
|
deduction: mainFeatureDeduction,
|
||||||
reserve,
|
lock,
|
||||||
});
|
});
|
||||||
|
|
||||||
return featureDeductions;
|
return featureDeductions;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { deductionUpdatesToModifiedIds } from "../../utils/sync/deductionUpdates
|
|||||||
import { globalSyncBatchingManagerV2 } from "../../utils/sync/SyncBatchingManagerV2.js";
|
import { globalSyncBatchingManagerV2 } from "../../utils/sync/SyncBatchingManagerV2.js";
|
||||||
import type { DeductionUpdate } from "../../utils/types/deductionUpdate.js";
|
import type { DeductionUpdate } from "../../utils/types/deductionUpdate.js";
|
||||||
import type { FeatureDeduction } from "../../utils/types/featureDeduction.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";
|
import { handleRedisTrackError } from "./handleRedisTrackError.js";
|
||||||
|
|
||||||
const queueSyncItem = ({
|
const queueSyncItem = ({
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { FullCustomer } from "@autumn/shared";
|
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.
|
* 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 { applyDeductionUpdateToFullCustomer } from "../../utils/deduction/applyDeductionUpdateToFullCustomer.js";
|
||||||
import type { DeductionUpdate } from "../../utils/types/deductionUpdate.js";
|
import type { DeductionUpdate } from "../../utils/types/deductionUpdate.js";
|
||||||
import type { FeatureDeduction } from "../../utils/types/featureDeduction.js";
|
import type { FeatureDeduction } from "../../utils/types/featureDeduction.js";
|
||||||
|
import type { MutationLogItem } from "../../utils/types/mutationLogItem.js";
|
||||||
import { createAllocatedInvoice } from "../allocatedInvoice/createAllocatedInvoice.js";
|
import { createAllocatedInvoice } from "../allocatedInvoice/createAllocatedInvoice.js";
|
||||||
import { handleThresholdReached } from "../handleThresholdReached.js";
|
import { handleThresholdReached } from "../handleThresholdReached.js";
|
||||||
import type { DeductionOptions } from "../types/deductionTypes.js";
|
import type { DeductionOptions } from "../types/deductionTypes.js";
|
||||||
@@ -43,6 +44,7 @@ export const executePostgresDeduction = async ({
|
|||||||
oldFullCus: FullCustomer;
|
oldFullCus: FullCustomer;
|
||||||
fullCus: FullCustomer | undefined;
|
fullCus: FullCustomer | undefined;
|
||||||
updates: Record<string, DeductionUpdate>;
|
updates: Record<string, DeductionUpdate>;
|
||||||
|
mutationLogs: MutationLogItem[];
|
||||||
}> => {
|
}> => {
|
||||||
const { db, org, env } = ctx;
|
const { db, org, env } = ctx;
|
||||||
|
|
||||||
@@ -234,5 +236,6 @@ export const executePostgresDeduction = async ({
|
|||||||
oldFullCus,
|
oldFullCus,
|
||||||
fullCus: fullCustomer,
|
fullCus: fullCustomer,
|
||||||
updates: allUpdates,
|
updates: allUpdates,
|
||||||
|
mutationLogs: [],
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,14 +13,13 @@ import { handleThresholdReached } from "../handleThresholdReached.js";
|
|||||||
import type { DeductionOptions } from "../types/deductionTypes.js";
|
import type { DeductionOptions } from "../types/deductionTypes.js";
|
||||||
import type { DeductionUpdate } from "../types/deductionUpdate.js";
|
import type { DeductionUpdate } from "../types/deductionUpdate.js";
|
||||||
import type { FeatureDeduction } from "../types/featureDeduction.js";
|
import type { FeatureDeduction } from "../types/featureDeduction.js";
|
||||||
|
import type { MutationLogItem } from "../types/mutationLogItem.js";
|
||||||
import {
|
import {
|
||||||
RedisDeductionError,
|
RedisDeductionError,
|
||||||
RedisDeductionErrorCode,
|
RedisDeductionErrorCode,
|
||||||
} from "../types/redisDeductionError.js";
|
} from "../types/redisDeductionError.js";
|
||||||
import type {
|
import type { LuaDeductionResult } from "../types/redisDeductionResult.js";
|
||||||
LuaDeductionResult,
|
import type { RolloverUpdate } from "../types/rolloverUpdate.js";
|
||||||
RolloverUpdate,
|
|
||||||
} from "../types/redisDeductionResult.js";
|
|
||||||
import { applyDeductionUpdateToFullCustomer } from "./applyDeductionUpdateToFullCustomer.js";
|
import { applyDeductionUpdateToFullCustomer } from "./applyDeductionUpdateToFullCustomer.js";
|
||||||
import { applyRolloverUpdatesToFullCustomer } from "./applyRolloverUpdatesToFullCustomer.js";
|
import { applyRolloverUpdatesToFullCustomer } from "./applyRolloverUpdatesToFullCustomer.js";
|
||||||
import { logDeductionUpdates } from "./logDeductionUpdates.js";
|
import { logDeductionUpdates } from "./logDeductionUpdates.js";
|
||||||
@@ -44,6 +43,7 @@ export const executeRedisDeduction = async ({
|
|||||||
fullCus: FullCustomer | undefined;
|
fullCus: FullCustomer | undefined;
|
||||||
updates: Record<string, DeductionUpdate>;
|
updates: Record<string, DeductionUpdate>;
|
||||||
rolloverUpdates: Record<string, RolloverUpdate>;
|
rolloverUpdates: Record<string, RolloverUpdate>;
|
||||||
|
mutationLogs: MutationLogItem[];
|
||||||
}> => {
|
}> => {
|
||||||
const { org, env } = ctx;
|
const { org, env } = ctx;
|
||||||
const oldFullCus = structuredClone(fullCustomer);
|
const oldFullCus = structuredClone(fullCustomer);
|
||||||
@@ -70,6 +70,7 @@ export const executeRedisDeduction = async ({
|
|||||||
|
|
||||||
let allUpdates: Record<string, DeductionUpdate> = {};
|
let allUpdates: Record<string, DeductionUpdate> = {};
|
||||||
let allRolloverUpdates: Record<string, RolloverUpdate> = {};
|
let allRolloverUpdates: Record<string, RolloverUpdate> = {};
|
||||||
|
let allMutationLogs: MutationLogItem[] = [];
|
||||||
|
|
||||||
// Build cache key
|
// Build cache key
|
||||||
const customerId = fullCustomer.id || fullCustomer.internal_id;
|
const customerId = fullCustomer.id || fullCustomer.internal_id;
|
||||||
@@ -80,13 +81,20 @@ export const executeRedisDeduction = async ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const deduction of deductions) {
|
for (const deduction of deductions) {
|
||||||
const { feature, deduction: toDeduct, targetBalance } = deduction;
|
const {
|
||||||
|
feature,
|
||||||
|
deduction: toDeduct,
|
||||||
|
targetBalance,
|
||||||
|
unwindValue,
|
||||||
|
lockReceiptKey,
|
||||||
|
} = deduction;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
customerEntitlementDeductions,
|
customerEntitlementDeductions,
|
||||||
rollovers,
|
rollovers,
|
||||||
customerEntitlements,
|
customerEntitlements,
|
||||||
unlimitedFeatureIds,
|
unlimitedFeatureIds,
|
||||||
|
lock: preparedLock,
|
||||||
} = prepareFeatureDeduction({
|
} = prepareFeatureDeduction({
|
||||||
ctx,
|
ctx,
|
||||||
fullCustomer,
|
fullCustomer,
|
||||||
@@ -109,7 +117,11 @@ export const executeRedisDeduction = async ({
|
|||||||
alter_granted_balance: options.alterGrantedBalance,
|
alter_granted_balance: options.alterGrantedBalance,
|
||||||
overage_behaviour: options.overageBehaviour,
|
overage_behaviour: options.overageBehaviour,
|
||||||
feature_id: feature.id,
|
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(() =>
|
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({
|
logDeductionUpdates({
|
||||||
ctx,
|
ctx,
|
||||||
fullCustomer,
|
fullCustomer,
|
||||||
@@ -148,6 +160,7 @@ export const executeRedisDeduction = async ({
|
|||||||
|
|
||||||
allUpdates = { ...allUpdates, ...updates };
|
allUpdates = { ...allUpdates, ...updates };
|
||||||
allRolloverUpdates = { ...allRolloverUpdates, ...rollover_updates };
|
allRolloverUpdates = { ...allRolloverUpdates, ...rollover_updates };
|
||||||
|
allMutationLogs = [...allMutationLogs, ...mutation_logs];
|
||||||
|
|
||||||
// Handle paid allocated entitlements and update fullCus in memory
|
// Handle paid allocated entitlements and update fullCus in memory
|
||||||
try {
|
try {
|
||||||
@@ -222,5 +235,6 @@ export const executeRedisDeduction = async ({
|
|||||||
fullCus: fullCustomer,
|
fullCus: fullCustomer,
|
||||||
updates: allUpdates,
|
updates: allUpdates,
|
||||||
rolloverUpdates: allRolloverUpdates,
|
rolloverUpdates: allRolloverUpdates,
|
||||||
|
mutationLogs: allMutationLogs,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
orgToInStatuses,
|
orgToInStatuses,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
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 { getUnlimitedAndUsageAllowed } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js";
|
||||||
import { getCreditCost } from "@/internal/features/creditSystemUtils.js";
|
import { getCreditCost } from "@/internal/features/creditSystemUtils.js";
|
||||||
import type {
|
import type {
|
||||||
@@ -35,7 +36,8 @@ export const prepareFeatureDeduction = ({
|
|||||||
options?: DeductionOptions;
|
options?: DeductionOptions;
|
||||||
}): PreparedFeatureDeduction => {
|
}): PreparedFeatureDeduction => {
|
||||||
const { org } = ctx;
|
const { org } = ctx;
|
||||||
const { feature, targetBalance } = deduction;
|
const { env } = ctx;
|
||||||
|
const { feature, lock, targetBalance } = deduction;
|
||||||
|
|
||||||
const { overageBehaviour = "cap", customerEntitlementFilters } = options;
|
const { overageBehaviour = "cap", customerEntitlementFilters } = options;
|
||||||
|
|
||||||
@@ -118,6 +120,19 @@ export const prepareFeatureDeduction = ({
|
|||||||
return 0;
|
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 {
|
return {
|
||||||
customerEntitlements: cusEnts,
|
customerEntitlements: cusEnts,
|
||||||
customerEntitlementDeductions,
|
customerEntitlementDeductions,
|
||||||
@@ -126,5 +141,6 @@ export const prepareFeatureDeduction = ({
|
|||||||
credit_cost: r.credit_cost,
|
credit_cost: r.credit_cost,
|
||||||
})),
|
})),
|
||||||
unlimitedFeatureIds,
|
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 {
|
import type {
|
||||||
CustomerEntitlementFilters,
|
CustomerEntitlementFilters,
|
||||||
FullCusEntWithFullCusProduct,
|
FullCusEntWithFullCusProduct,
|
||||||
FullCustomer,
|
|
||||||
} from "@autumn/shared";
|
} 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 */
|
/** Behavior options for deduction */
|
||||||
export type DeductionOptions = {
|
export type DeductionOptions = {
|
||||||
@@ -22,25 +18,6 @@ export type DeductionOptions = {
|
|||||||
skipAdditionalBalance?: boolean;
|
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) */
|
/** Input for a single entitlement in the deduction script (Lua/SQL) */
|
||||||
export type CustomerEntitlementDeduction = {
|
export type CustomerEntitlementDeduction = {
|
||||||
customer_entitlement_id: string;
|
customer_entitlement_id: string;
|
||||||
@@ -64,10 +41,12 @@ export type PreparedFeatureDeduction = {
|
|||||||
// rolloverIds: string[];
|
// rolloverIds: string[];
|
||||||
rollovers: RolloverDeduction[];
|
rollovers: RolloverDeduction[];
|
||||||
unlimitedFeatureIds: string[];
|
unlimitedFeatureIds: string[];
|
||||||
};
|
lock?: {
|
||||||
|
enabled: true;
|
||||||
/** Result from Postgres deduction */
|
key?: string;
|
||||||
type PostgresDeductionResult = {
|
hashed_key?: string;
|
||||||
updates: Record<string, DeductionUpdate>;
|
expires_at?: string;
|
||||||
remaining: number;
|
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 = {
|
export type FeatureDeduction = {
|
||||||
feature: Feature;
|
feature: Feature;
|
||||||
deduction: number;
|
deduction: number;
|
||||||
targetBalance?: 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";
|
import type { DeductionUpdate } from "./deductionUpdate.js";
|
||||||
|
import type { MutationLogItem } from "./mutationLogItem.js";
|
||||||
export interface RolloverUpdate {
|
import type { RolloverUpdate } from "./rolloverUpdate.js";
|
||||||
balance: number;
|
|
||||||
usage: number;
|
|
||||||
entities: Record<string, EntityRolloverBalance>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LuaDeductionResult {
|
export interface LuaDeductionResult {
|
||||||
updates: Record<string, DeductionUpdate>;
|
updates: Record<string, DeductionUpdate>;
|
||||||
rollover_updates: Record<string, RolloverUpdate>;
|
rollover_updates: Record<string, RolloverUpdate>;
|
||||||
|
mutation_logs: MutationLogItem[];
|
||||||
remaining: number;
|
remaining: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
feature_id?: 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",
|
"stripe_event",
|
||||||
"extras",
|
"extras",
|
||||||
"type",
|
"type",
|
||||||
|
"durationMs",
|
||||||
];
|
];
|
||||||
const additionalFields = Object.keys(log)
|
const additionalFields = Object.keys(log)
|
||||||
.filter((key) => !excludeFields.includes(key))
|
.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;
|
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
|
* Monthly words - resets each billing cycle
|
||||||
* @param includedUsage - Free usage allowance (default: 100)
|
* @param includedUsage - Free usage allowance (default: 100)
|
||||||
@@ -721,6 +750,7 @@ export const items = {
|
|||||||
|
|
||||||
// Free metered
|
// Free metered
|
||||||
free,
|
free,
|
||||||
|
hourlyMessages,
|
||||||
monthlyMessages,
|
monthlyMessages,
|
||||||
monthlyWords,
|
monthlyWords,
|
||||||
monthlyCredits,
|
monthlyCredits,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { CustomerDataSchema } from "../../common/customerData";
|
|||||||
import { EntityDataSchema } from "../../common/entityData";
|
import { EntityDataSchema } from "../../common/entityData";
|
||||||
import { queryStringArray } from "../../common/queryHelpers";
|
import { queryStringArray } from "../../common/queryHelpers";
|
||||||
import { BalanceParamsBaseSchema } from "../common/balanceParamsBase";
|
import { BalanceParamsBaseSchema } from "../common/balanceParamsBase";
|
||||||
import { ReserveParamsSchema } from "../common/reserveParams";
|
import { LockParamsSchema } from "../common/lockParams";
|
||||||
import { CheckExpand } from "./enums/CheckExpand";
|
import { CheckExpand } from "./enums/CheckExpand";
|
||||||
|
|
||||||
export const CheckQuerySchema = z.object({
|
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.",
|
"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({
|
with_preview: z.boolean().optional().meta({
|
||||||
description:
|
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.",
|
"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:
|
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({
|
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 "./create/index";
|
||||||
export * from "./delete/index";
|
export * from "./delete/index";
|
||||||
|
export * from "./finalizeLock/index";
|
||||||
export * from "./update/index";
|
export * from "./update/index";
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { EntityDataSchema } from "../../common/entityData";
|
|||||||
import { queryStringArray } from "../../common/queryHelpers";
|
import { queryStringArray } from "../../common/queryHelpers";
|
||||||
import { CheckExpand } from "../check/enums/CheckExpand";
|
import { CheckExpand } from "../check/enums/CheckExpand";
|
||||||
import { BalanceParamsBaseSchema } from "../common/balanceParamsBase";
|
import { BalanceParamsBaseSchema } from "../common/balanceParamsBase";
|
||||||
import { ReserveParamsSchema } from "../common/reserveParams";
|
import { LockParamsSchema } from "../common/lockParams";
|
||||||
|
|
||||||
export const TrackQuerySchema = z.object({
|
export const TrackQuerySchema = z.object({
|
||||||
expand: queryStringArray(z.enum([CheckExpand.BalanceFeature])).optional(),
|
expand: queryStringArray(z.enum([CheckExpand.BalanceFeature])).optional(),
|
||||||
@@ -52,7 +52,7 @@ export const TrackParamsSchema = BalanceParamsBaseSchema.extend({
|
|||||||
internal: true,
|
internal: true,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
reserve: ReserveParamsSchema.optional(),
|
lock: LockParamsSchema.optional(),
|
||||||
}).refine(
|
}).refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
if (data.feature_id && data.event_name) {
|
if (data.feature_id && data.event_name) {
|
||||||
|
|||||||
Reference in New Issue
Block a user