D30-F01-A04 / Released engineering topic

Calculate Spendable Deposit Funds Without Double-Counting Holds

Build an exact availability breakdown from posted liabilities and remaining reservations, with explicit capture, expiry and evidence boundaries.

Calculate Spendable Deposit Funds Without Double-Counting HoldsD30 / D30-F01

A customer deposit can show 97600 cents posted while only 67600 cents are available for another instruction. The missing 30000 cents has not necessarily left the ledger. It may be reserved by a pending outgoing transaction. When part of that transaction posts, the reservation must change with it; otherwise a display can subtract the same purchase twice.

This tutorial builds a transparent balance calculation for a fictional bank product. You will distinguish posted funds from remaining holds, implement exact arithmetic, inspect expiry equality, and recognize the consistency boundary that a read function cannot provide. For operations, the result explains a balance difference. For engineers, it defines what the snapshot exporter and reservation writer must coordinate.

Start with Double-Entry Posting Engine and Event-Sourced Balance Fold. A customer's deposit is a liability on the bank's books. Credits increase its normal balance; debits reduce it. The number used here is that credit-normal balance, rather than debit-positive raw movement.

Define the product before the formula

“Available” is a policy result. The OCC's official bulletin discusses posted and available balances in US supervisory context. A bank's product FAQ shows that some incoming funds may become available before posting. Those sources establish why policy matters; they do not specify this fictional product.

Our implementation choice is cedar-no-overdraft-v1: outgoing reservations reduce capacity, pending incoming transfers contribute zero, and the credit facility is zero. Deposit collection holds, overdraft lines, card-network settlement rules and fee assessment require other contracts. All Cedar accounts, times and amounts in this tutorial are synthetic teaching inputs. Calculations are author-derived.

We retain pending incoming as an informational amount. Showing it separately helps explain that known evidence is not automatically spending capacity. A policy version also prevents a later credit-facility implementation from silently changing what the same response field means.

Subtract what is still reserved

Let L be posted credit-normal liability in minor units and H be the sum of effective remaining outgoing reservations. Available balance is A = L − H. Spendable capacity is max(0,A); shortfall is max(0,−A). Both incoming contribution and credit-limit contribution are explicitly zero.

Do not clamp A itself. If an external posted debit creates L = −400 and there are no holds, available is −400, spendable is zero, and shortfall is 400. Replacing all three with a single zero hides the deficit. A no-overdraft reservation service also cannot promise that every unrelated posting source invokes its guard.

Here is the capture bridge to keep in view:

Synthetic balance bridge showing posted 97600 less hold 30000 equals available 67600, and posted 85600 less remaining hold 18000 also equals 67600 after partial capture.

Open the full-size balance bridge. The 12000-cent capture reduces the posted liability and remaining hold by the same amount. It moves value between posted and pending categories without reducing capacity a second time.

Follow one complete instruction

The initial 97600 cents comes from Cedar's earlier corrected funding/transfer example. Reserve 30000: posted remains 97600, hold becomes 30000, available becomes 67600. Capture 12000 while retaining the remainder: posted falls to 85600, hold falls to 18000, and available remains 67600.

Now record pending incoming evidence for 5000 cents. The informational amount becomes 5000; capacity stays 67600. Finally capture another 10000 and explicitly release the unused 8000. Posted becomes 75600, hold becomes zero, and available rises to 75600 because the unused reservation was released.

Synthetic USD-cent snapshotPostedEffective remaining holdAvailable
Before reservation97600097600
Reserve 30000976003000067600
Capture 12000, retain rest856001800067600
Incoming 5000 remains informational856001800067600
Final capture 10000, release 800075600075600

The original hold reconciles exactly: 30000 = 22000 captured + 8000 released + zero remaining. The implementation validates this decomposition before calculating any balance. A plausible total is insufficient when the lifecycle components disagree.

Make expiry equality visible

An active reservation counts when as_of is strictly earlier than expires_at. At exact equality it counts zero. This half-open rule is an explicit teaching policy; other systems may release pending capacity at a different processing boundary.

A background worker may not yet have recorded terminal expiry. Keep its stored status active, derive effective status effective_expired, and show zero counted amount. A read should explain that distinction without rewriting history. A later expiry event records the terminal disposition; it does not rescue a capture submitted after expiry. The reservation writer checks trusted processing time.

For the exact-exhaustion scenario, posted is 75600 and the remaining hold is 75600. One millisecond before expiry, spendable is zero. At expiry equality, the same stored row contributes zero and spendable is 75600. The informational incoming 5000 still contributes nothing.

Validate one consistent snapshot

The exact request declares scope, account perspective, currency, as-of time, snapshot time, posted amount, holds and incoming evidence. Snapshot time must equal as-of. Every hold's ledger, entity, account and currency must match. Its creation/update times must be causal and no later than as-of. Incoming evidence cannot be dated after as-of. Duplicate IDs and unknown fields reject.

This strictness prevents accidental mixing, but a JSON label is not provenance. If an exporter reads the posted projection after a capture and the hold projection before it, both rows may look individually valid. The result is stale because the exporter broke the consistency contract. Obtain both from one complete committed boundary. For a past query, obtain the actual past snapshot; filtering today's captured row cannot recover yesterday's remaining reservation.

The branch validates the complete request before exposing any sum. A malformed later hold cannot leave the caller with a misleading partial availability number.

Keep monetary arithmetic exact

Money enters as canonical decimal strings. The reference accepts signed posted balances and nonnegative hold components, rejecting numeric JSON values, leading zeros, negative zero and decimals. Python integers and TypeScript BigInt preserve exact values. The independently specified boundary case is 9007199254740993 − 993 = 9007199254740000, beyond JavaScript's safe integer limit.

Hold amounts use the journal's 30-digit per-command bound; the read envelope permits 120 posted magnitude digits as a resource limit. There is no rounding step. USD and EUR snapshots are separate, both exponent 2 in this teaching contract. An EUR request cannot contain a USD hold. Time is genuine Gregorian UTC with milliseconds; invalid February dates and leap seconds reject rather than normalize.

The implementation loops once over holds and incoming evidence, producing linear-sized diagnostics. Each hold exposes stored status, effective status, remainder and counted amount. These fields let an operator explain why a row contributed zero instead of relying on a single opaque total.

Verify the boundaries and use the result

The shared fixture download contains independently authored expected full outputs. Python and TypeScript agree on 78 cases, including capture, release, expiry equality, negatives, mixed scopes, future evidence and malformed data. Additional behavioral tests check capture conservation and defensive copies. Validation evidence distinguishes these portable checks from database guarantees.

Use the guided lab to step through the eight snapshots, then compare a large-integer case and a failed decomposition. Reset restores the same initial state. Reduced-motion Play advances one meaningful step. The static bridge and worked table preserve the explanation without scripting.

Historical-example decision: not useful for this calculation. A named event without its complete product contract and consistent hold history would add a story without validating the algorithm. The primary sources support the terminology and policy variation only.

You can now explain posted versus spendable funds and implement a transparent read model. Next, Pending-Transaction Reservation adds the commands, durable identities and atomic capture boundary that keep these snapshots consistent. Bitemporal Ledger Versioning supplies the separate discipline needed to reproduce what evidence an observer actually had at an earlier time.

References3 primary sources and evidence notes

Expand the source trail, evidence role, and limitations behind the engineering choices.

R01 — OCC Bulletin 2023-12: Overdraft Protection Programs

  • Organization: Office of the Comptroller of the Currency.
  • Type: official supervisory bulletin; published 26 April 2023, page notes a 20 March 2025 revision.
  • URL: Risk Management Practices.
  • Accessed: 25 October 2026; jurisdiction: United States, institutions addressed by bulletin.
  • Supports: ledger/available terminology and the operational importance of authorization-versus-settlement timing, section “Authorize Positive, Settle Negative Fee Practices”.
  • Limitations: does not define Cedar's product, hold expiry, fixture amounts, legal recognition, or code. No legal-advice or bank-specific outcome claim. No data copied.

R02 — Wells Fargo Account Activity Questions

  • Organization: Wells Fargo; official product help, current undated web version accessed 25 October 2026.
  • URL: Activity FAQ.
  • Jurisdiction: US retail deposit product context.
  • Supports: “What is my available balance?”, “How is my available balance calculated?” and “What other factors affect my available balance?” explain product-specific known pending transactions and incoming availability.
  • Limitations: comparison only. Cedar excludes incoming availability and does not reproduce bank expiry, processing, fee or deposit-availability rules. No customer example copied.

R03 — Stage 0 and Stage 2 authored contracts

  • Type: internal canonical definition and independent synthetic arithmetic, frozen 25 October 2026.
  • Public extract: data contract; shared fixtures.
  • Supports: exact schema, bank-book normal perspective, integer units, equality, errors, independent expected outputs and resource limits.
  • Rights: authored synthetic teaching material; no provider observations or confidential records. Full-output parity is local implementation evidence, not source authority or production certification.
availability.ts
/** Pure exact arithmetic over one trusted consistent snapshot; no spending lock. */
type Json = any;
const REQUEST=['schema_version','policy_version','ledger_id','entity_id','account_id','account_type','normal_side','currency','as_of','snapshot_at','posted_balance_minor','holds','pending_incoming'];
const HOLD=['ledger_id','entity_id','account_id','currency','hold_id','original_minor','captured_minor','released_minor','remaining_minor','created_at','updated_at','expires_at','version','status'];
const INCOMING=['pending_id','amount_minor','source_available_at'];
class Invalid extends Error {}
function requireThat(condition: boolean, code: string): asserts condition {if(!condition)throw new Invalid(code);}
function exact(value: Json, keys: string[]): void {requireThat(value!==null && typeof value==='object' && !Array.isArray(value) && Object.keys(value).length===keys.length && keys.every(k=>Object.hasOwn(value,k)),'INVALID_SCHEMA');}
function full(pattern:RegExp,value:Json):boolean {if(typeof value!=='string')return false; const match=pattern.exec(value);return match!==null&&match[0].length===value.length;}
function identifier(value:Json):void {requireThat(full(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/,value),'INVALID_IDENTIFIER');}
function timestamp(value:Json):void {
 requireThat(full(/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3}Z$/,value),'INVALID_TIMESTAMP');
 const y=Number(value.slice(0,4)),m=Number(value.slice(5,7)),d=Number(value.slice(8,10)),h=Number(value.slice(11,13)),n=Number(value.slice(14,16)),s=Number(value.slice(17,19));
 const leap=y%4===0&&(y%100!==0||y%400===0);const days=[31,leap?29:28,31,30,31,30,31,31,30,31,30,31];
 requireThat(y>=1&&y<=9999&&m>=1&&m<=12&&d>=1&&d<=days[m-1]&&h<=23&&n<=59&&s<=59,'INVALID_TIMESTAMP');
}
function amount(value:Json,positive=false,signed=false):bigint {
 const pattern=signed?/^(?:-?[1-9][0-9]{0,119}|0)$/:positive?/^[1-9][0-9]{0,29}$/:/^(?:[1-9][0-9]{0,29}|0)$/;
 requireThat(full(pattern,value),'INVALID_AMOUNT');return BigInt(value);
}
export function calculateAvailability(request:Json):Json {
 try{
  exact(request,REQUEST);requireThat(request.schema_version==='d30-availability-v1','UNSUPPORTED_SCHEMA');requireThat(request.policy_version==='cedar-no-overdraft-v1','UNSUPPORTED_POLICY');
  for(const key of ['ledger_id','entity_id','account_id'])identifier(request[key]);
  requireThat(request.account_type==='liability'&&request.normal_side==='credit','UNSUPPORTED_ACCOUNT_PERSPECTIVE');requireThat(['USD','EUR'].includes(request.currency),'UNSUPPORTED_CURRENCY');
  timestamp(request.as_of);timestamp(request.snapshot_at);requireThat(request.as_of===request.snapshot_at,'INCONSISTENT_SNAPSHOT_TIME');const posted=amount(request.posted_balance_minor,false,true);
  for(const key of ['holds','pending_incoming'])requireThat(Array.isArray(request[key])&&request[key].length<=1000,'INVALID_SCHEMA');
  let reserved=0n;const holdRows:Json[]=[];const seen=new Set<string>();
  for(const hold of request.holds){
   exact(hold,HOLD);identifier(hold.hold_id);requireThat(!seen.has(hold.hold_id),'DUPLICATE_HOLD');seen.add(hold.hold_id);
   for(const key of ['ledger_id','entity_id','account_id']){identifier(hold[key]);requireThat(hold[key]===request[key],'HOLD_SCOPE_MISMATCH');}
   requireThat(hold.currency===request.currency,'CURRENCY_MISMATCH');
   const original=amount(hold.original_minor,true),captured=amount(hold.captured_minor),released=amount(hold.released_minor),remaining=amount(hold.remaining_minor);amount(hold.version,true);
   requireThat(original===captured+released+remaining,'INVALID_HOLD_DECOMPOSITION');for(const key of ['created_at','updated_at','expires_at'])timestamp(hold[key]);
   requireThat(hold.created_at<=hold.updated_at&&hold.created_at<hold.expires_at,'INVALID_HOLD_TIME');requireThat(hold.updated_at<=request.as_of,'FUTURE_EVIDENCE');const status=hold.status;
   requireThat(typeof status==='string'&&['active','captured','released','expired'].includes(status),'INVALID_HOLD_STATUS');
   const valid=(status==='active'&&remaining>0n&&released===0n)||(status==='captured'&&remaining===0n&&captured>0n)||(['released','expired'].includes(status)&&remaining===0n&&released>0n);requireThat(valid,'INVALID_HOLD_STATUS');
   if(status==='expired')requireThat(hold.updated_at>=hold.expires_at,'INVALID_HOLD_TIME');else if(['captured','released'].includes(status))requireThat(hold.updated_at<hold.expires_at,'INVALID_HOLD_TIME');
   const effective=status==='active'&&request.as_of>=hold.expires_at?'effective_expired':status,counted=effective==='active'?remaining:0n;reserved+=counted;
   holdRows.push({hold_id:hold.hold_id,stored_status:status,effective_status:effective,remaining_minor:String(remaining),counted_minor:String(counted)});
  }
  let incoming=0n;seen.clear();for(const row of request.pending_incoming){exact(row,INCOMING);identifier(row.pending_id);requireThat(!seen.has(row.pending_id),'DUPLICATE_PENDING');seen.add(row.pending_id);const n=amount(row.amount_minor,true);timestamp(row.source_available_at);requireThat(row.source_available_at<=request.as_of,'FUTURE_EVIDENCE');incoming+=n;}
  const available=posted-reserved,result:Json={};for(const key of ['ledger_id','entity_id','account_id','currency','as_of','policy_version'])result[key]=request[key];
  Object.assign(result,{ledger_balance_minor:String(posted),active_holds_minor:String(reserved),informational_pending_incoming_minor:String(incoming),credited_pending_incoming_minor:'0',credit_limit_minor:'0',available_balance_minor:String(available),spendable_minor:String(available>0n?available:0n),shortfall_minor:String(available<0n?-available:0n),hold_rows:holdRows});return {status:'accepted',code:'AVAILABILITY_CALCULATED',result};
 }catch(error){if(error instanceof Invalid)return {status:'rejected',code:error.message,result:null};throw error;}
}
Full-height labavailability labOpen full screen
Written by

Fintech engineer building market-data and financial systems, and the author of every article, glossary record, and reference implementation on The Fintech Builder.