8.9 KiB
Check Reserve Plan
Summary
Extend balances.check with an explicit reservation mode that:
- reserves credits instead of only doing today's
send_event: truededuction, - preserves the exact deduction provenance so later release/refund goes back to the same buckets,
- survives Redis cache eviction before Postgres sync,
- lets callers control reservation expiry with
expires_at.
This plan is requirements-first. It captures the current codebase behavior, the schema direction discussed so far, and the engineering constraints we need to solve before implementation.
Current Behavior In The Codebase
balances.check tracked path today
server/src/internal/api/check/handleCheck.tssend_event: trueroutes intorunCheckWithTrack
server/src/internal/api/check/runCheckWithTrack.ts- turns check into a track-style deduction with
overage_behavior: "reject"
- turns check into a track-style deduction with
server/src/internal/balances/track/runTrackV2.ts- loads
FullCustomerfrom cache or DB throughgetOrCreateCachedFullCustomer - then executes the Redis fast path via
runRedisTrack
- loads
server/src/internal/balances/track/utils/runRedisTrack.ts- calls
executeRedisDeduction - on success, only queues async sync/event work afterward
- calls
server/src/internal/balances/utils/deduction/executeRedisDeduction.ts- prepares deduction inputs
- calls Redis Lua
deductFromCustomerEntitlements
Deduction order today
Deduction order is determined before Lua in:
server/src/internal/balances/utils/deduction/prepareFeatureDeduction.tsshared/utils/cusUtils/fullCusUtils/fullCustomerToCustomerEntitlements.tsshared/utils/cusEntUtils/sortCusEntsForDeduction.ts
Current order:
- rollovers first, oldest
expires_atfirst - sorted
customer_entitlements - Lua pass 1 deducts to
0 - Lua pass 2 allows negative balances only where
usage_allowedis true
This means time-bounded balances like hourly/monthly can be consumed before lifetime balances, depending on the sorted entitlement order.
Refund behavior today
Refunds currently do not replay the original deduction provenance.
In server/src/_luaScriptsV2/deductFromCustomerEntitlements/deductFromCustomerEntitlements.lua:
- negative amounts are treated as refunds,
- pass 1 refills toward
0, - pass 2 can refill balances up to
max_balance.
The system does not persist a durable receipt of exactly which rollover / customer_entitlement / entity balance was consumed in the original deduction. That is the root cause of the current bucket-drift issue.
Durability path today
Durability is async and cache-based:
server/src/internal/balances/utils/sync/SyncBatchingManagerV2.ts- batches modified
customer_entitlementIDs / rollover IDs - queues
SyncBalanceBatchV3
- batches modified
- queue worker calls
server/src/internal/balances/utils/sync/syncItemV3.ts syncItemV3re-reads the cachedFullCustomer- it then calls
sync_balances_v2(...)to write current cache state to Postgres
Resiliency problem today
server/src/external/stripe/webhookMiddlewares/stripeWebhookRefreshMiddleware.ts can clear the cached customer after webhook handling.
That creates a failure window:
- Redis deduction succeeds
- async sync job is queued
- cache is deleted before
syncItemV3runs syncItemV3sees cache miss and skips- Postgres never sees the deduction
- a later refund/release can add credits on top of stale Postgres state
This is why reserve cannot rely on Redis mutation plus best-effort later balance sync alone.
Problem Statement
We need a new reservation-backed check flow that fixes three issues:
A. Exact release correctness
If a customer has multiple balances for the same feature, for example hourly + monthly + lifetime, reserve must later release/refund back into the exact buckets that were consumed.
We cannot continue using a generic refund order that loses provenance.
B. Resiliency against cache loss
A successful reservation must remain correct even if Redis cache is evicted before the async sync pipeline persists updated balances to Postgres.
We need durability for the reservation itself without turning balances.check into a slow synchronous balance-sync request.
C. Caller-controlled expiry
Reserve needs an expires_at field so the caller controls how long the reservation stays held before automatic release.
API Direction
balances.check request
Add a new reserve field:
reserve: {
enabled: X,
key: X,
expires_at: X,
}
Current intent of the fields:
enabled- marks the request as a reserve flow instead of plain
send_event
- marks the request as a reserve flow instead of plain
key- identifies the reservation so later operations can refer to it
expires_at- absolute release deadline; if the reservation is not finalized before this time it must be automatically released
Notes:
- this shape is the current draft, not the final contract
- whether
enabledis necessary is still open - whether
keyis caller-provided, server-generated, or both is still open
Follow-up endpoint
Add a new follow-up endpoint in the balances family.
Current placeholder name:
autumn.balances.confirm
Name is still TBD. Candidate directions to revisit later:
balances.confirmbalances.settlebalances.finalize
Follow-up request draft
Current draft input:
{
key: X,
refund: X,
final_value: X,
}
Current intent:
key- the reservation to operate on
refund- amount to release back from the originally reserved value
final_value- alternative to
refund; specifies the final usage value to keep, overwriting the original reserved amount
- alternative to
Still unresolved:
- whether
refundandfinal_valueshould both exist - if both exist, whether they must be mutually exclusive
- what the default behavior is when neither is sent
- whether the endpoint is semantically "confirm" or "settle"
Functional Requirements
1. Reservation provenance
- A successful reserve check must persist enough information to reconstruct the exact deduction path.
- Provenance must capture the exact:
- rollover rows used
customer_entitlementrows used- entity-scoped balance paths used
- deducted amount per bucket
- Later release/refund must use this stored provenance instead of rerunning generic negative-value deduction logic.
2. Reservation lifecycle
- reserve creates a held deduction
- follow-up endpoint finalizes the reservation into the final consumed amount
- any excess from the original reserve is released using stored provenance
- reservations expire automatically at
expires_atif not finalized in time - expired reservations become non-finalizable
3. Idempotency
- reserve must be safe to retry for the same logical reservation
- finalize/confirm must be idempotent
- release on expiry must be idempotent
- duplicate finalize or release must not double-deduct or double-refund
4. Durability and resiliency
- a reservation cannot exist only inside a transient Redis cache mutation
- reservation state must survive:
- full-customer cache eviction
- Stripe webhook refresh
- queue delay
- queue enqueue failure
- worker retry or replay
- the low-latency reserve path must not wait for full Redis-to-Postgres balance sync inside the request
5. Compatibility
- plain
balances.checkremains read-only - existing
send_event: truebehavior remains backward compatible while reserve is introduced - reserve is additive, not yet a silent redefinition of
send_event
Acceptance Scenarios
- reserve against hourly + lifetime balances, then finalize with a reduced final amount: released credits go back to the exact original buckets
- reserve consumes rollover balance, then release: the same rollover is restored
- reserve on entity-scoped balances, then release: the same entity-scoped balance is restored
- reserve succeeds in Redis, cache is evicted before async balance sync, then later finalize/release still behaves correctly
- duplicate finalize does not double-apply
- duplicate expiry release does not double-refund
- expired reservation cannot be finalized
- insufficient balance returns
allowed: falseand creates no reservation
Implementation Constraints
balances.checkwith reserve is expected to stay very low latency- we cannot require inline Redis track + Postgres sync in the same request
- resiliency must come from a smarter reservation design, not from making the hot path synchronous
- any eventual background reconcile flow must be replay-safe
Open Decisions
- final name of the follow-up endpoint
- final meaning of reservation
key - whether
reserve.enabledstays - whether
refundandfinal_valueboth exist - whether the follow-up endpoint is a pure confirm action or a broader settle/finalize action
- exact response schema for successful reserve and successful finalize
Next Planning Pass
The next pass should narrow Requirement A only:
- finalize the reserve request schema
- finalize the follow-up endpoint name and semantics
- lock the reservation state machine
- define response shapes for reserve and finalize