D30-F02-A02 / Released engineering topic

Compound Interest Accrual: Separate Capitalization, Customer Credits and Exact Rounding Carry

Calculate daily and month-end compound interest across a complete cash-flow calendar without counting customer credits twice.

Compound Interest Accrual: Separate Capitalization, Customer Credits and Exact Rounding CarryD30 / D30-F02

“Compounded daily” does not tell you when a customer receives a posted credit. Interest can begin earning more interest internally before it is credited to the customer's deposit account. If the calculation treats both events as the same principal addition, it can count the same interest twice.

We will calculate exact interest across 35 complete UTC days, compare daily and month-end capitalization, and inspect an October customer-credit boundary. Python and TypeScript retain exact fractions, explain every day's interest base and return credit proposals with explicit limits. Their outputs are hypothetical product projections, not proof that a customer credit was funded or accepted by a ledger.

All examples are synthetic. Cedar Teaching Bank's product account is a separate deposits_product_usd branch, with author-created balances, rates and receipt IDs. The dates do not relabel the earlier Ada correction example. Amounts are USD minor units:100000 cents is $1000.00. No real product performance or provider observations are represented.

Five pools prevent one common double count

Daily Simple Interest Accrual established that interest sums across every eligible balance day; it does not automatically earn interest itself. Double-Entry Posting Engine explains balanced journals, while Event-Sourced Balance Fold explains complete accepted history. This lesson's product calculator starts from an explicit synthetic daily baseline and introduces capitalization.

Let X be external principal after that day's eligible external flows, excluding every interest credit modeled by this engine. Let Q be cumulative modeled whole-minor customer credits. Projected customer principal P=X+Q. Keep C for exact uncredited interest that is already internally capitalized, A for uncapitalized accrual and R for signed rounding carry.

Only P+C earns the day's interest. A waits for capitalization; R never earns. The distinction is operational: C can earn without being a customer-posted credit, while Q belongs in tomorrow's principal exactly once. Feeding a calendar that already contains Q into this excluded-interest baseline would count it again.

Separate interest-bearing and non-interest-bearing product pools

Follow the arrows in order: new accrual enters A; capitalization moves it to C; scheduled credit moves the rounded entitlement into Q and leaves the exact difference in R. Open the full-size pool diagram.

CFPB Regulation DD explicitly treats compounding and crediting as distinct policy choices. That is a US comparison, not a mandate for our chosen daily calendar or rounding model. The calculation here is not an APY disclosure or an effective-interest accounting engine. Regulation DD §1030.7

Freeze the daily order

Our selected calendar is UTC civil days and Actual/365 Fixed, including leap years. Annual rate is an integer in ppm:50000 ppm means5% nominal, not5% APY. A day's eligible external balance is applied before its accrual; intraday time-weighting is outside this profile.

For each day d:

Pd=Xd+Q,Id=(Pd+C)rd365⋅106,A←A+Id.P_d=X_d+Q,\qquad I_d=(P_d+C)\frac{r_d}{365\cdot10^6},\qquad A\leftarrow A+I_d.

SymbolMeaningUnit
XdExternal eligible principal excluding model creditsInteger minor units
QCumulative scheduled customer creditsInteger minor units
C/ACapitalized uncredited /uncapitalized interestExact rational minor units
RNon-interest-bearing rounding carrySigned rational, within ±1/2 minor
rdAnnual nominal rateInteger ppm
IdNew interest for the dayExact rational minor units

At a capitalization boundary, C←C+A and A←0. Daily capitalization applies every day; month-end applies on actual calendar month-end, including February 29. At a scheduled customer credit, T=C+R, q=halfEven(T), Q←Q+q, C←0 and R←T−q. Credit dates must be capitalization dates, so A is zero at credit. A credit at day end affects the following day's interest base; there is no extra same-day accrual.

Period-end is not automatically a capitalization or credit date. Our last day is 4 November, so a month-end variant leaves its November A uncapitalized. The final settlement estimate is a calculation only; it does not mutate state or claim a posted credit.

Keep all 35 days, including quiet dates

The external baseline is 100000 from 1–21 October 2026,97600 from 22–23 October,85600 from 24–25 October and 75600 from 26 October–4 November. The synthetic branch starts with 100000, then has withdrawals2400,12000 and 10000 on 22,24 and 26 October. There is no fee250 in this branch.

The 35-day sum is 3222400. You cannot replace those dates with four transaction dates and still calculate the same interest. At50000 ppm with no customer credits, independent calculations give:

MethodExact interest in minor unitsPending half-even estimate
Simple baseline32224/73≈441.424657534441
Month-end capitalization32240/73≈441.643835616442
Daily capitalization≈442.515457121443

These numbers were derived independently before implementation. They are pending settlement estimates with no customer credit schedule, not accepted journals or evidence of product returns.

Complete external-principal calendar and interest comparison

The same external cash flows feed every comparison. Capitalization changes which earned interest joins later days' base. Open the full-size comparison.

For month-end capitalization, October's complete31-day weighted balance sum is 2920000. Dividing by7300 gives exactly 400 minor units. At October 31 end, this enters C. November's four days earn on 75600+400, adding3040/73. At November 4 end, C=400 and A=3040/73; total32240/73. No premature period-end capitalization was needed to obtain that amount.

For daily capitalization, set a=7301/7300. Each external flow accumulates for its remaining eligible days. Independent earned interest is:

Plain text
100000(a^35−1) − 2400(a^14−1)
− 12000(a^12−1) − 10000(a^10−1)

This closed-form cash-flow calculation checks the iterative implementation without harvesting the implementation's own answer as its oracle.

Customer crediting changes a different pool

Now keep daily capitalization but schedule a customer credit on 31 October. Its rounded proposal is 401. The model moves that integer into Q, clears C and retains the exact difference in R. November's principal begins with external75600 plus scheduled401; new November interest then capitalizes daily.

The 35-day exact earned amount is now≈442.515532429, with scheduled credit 401 and a pending estimate42. It differs slightly from daily delayed credit's≈442.515457121. Whole-minor credit quantization changes the subsequent principal base, while R is non-interest-bearing. This selected policy does not promise numerical invariance when credit frequency changes.

For the month-end variant, October earnings happen to be exactly 400. Crediting that400 or leaving it internally capitalized produces the same November base76000 in this fixture. That coincidence is not a universal equivalence rule.

A negative carry is not negative principal

At50000 ppm, principal 3650 earns exactly 1/2 minor per day. A half-even credit at that boundary rounds to 0, carries+1/2 and produces no zero-valued journal legs. With principal 10950, interest is 3/2, the credit rounds to 2 and carry becomes−1/2. Next day's projected customer principal is 10952.

The negative carry offsets a previous rounding-up amount during later settlement; it never acts like a negative external balance and it does not enter the interest base. The calculator routes negative external principal to an explicit unsupported product policy. Do not describe that choice as satisfying the US regulation's separate negative-balance treatment.

Validate the calendar and rate chronology first

Every date in[start,end) must appear once, in order, including quiet and leap days. A day's balance receipt must be at or after day close and no later than K; K must reach period-end midnight. Missing a date yields no apparently complete accrual. Receipt names and consistent arithmetic do not authenticate the source.

Rates are immutable versions. Our selected strict profile requires announcement by the version's own effective-day start and chooses the latest applicable version known at each day's start. Future announcements cannot lend a later rate to earlier days; stale row references, conflicting identities and ambiguous equal effective dates reject. Retroactive rate policy needs explicit review rather than silently rewriting a contract.

The complete wire contract defines strict keys, resource bounds and error codes. The reference scope is USD, at most366 days and 512 rate versions, nominal rates0–1000000 ppm, and reduced rational opening components up to 4096 digits. These are portable implementation limits, not market or legal mandates.

Run the exact implementations

The Python reference uses Fraction, and the TypeScript reference uses reduced BigInt rationals. Monetary transport values remain strings. Python parses and formats bounded decimal chunks so long exact state does not depend on changing a process-wide integer conversion setting.

Python
from compound_interest import accrue_compound
result = accrue_compound(request)
if result['status'] == 'ok':
    print(result['accrual']['earned_exact'])
    print(result['closing_state'])

All 77 fixture cases compare entire JSON outputs across the languages: exact fractions, proposal legs/status, every trace day, errors and closing state. Fifteen cases use before-code closed-form or weighted arithmetic. Further tests compare a 31+4-day contiguous projection with one 35-day request, verify detached outputs and exercise exact output beyond 4300 digits. Output can outgrow the accepted input profile; a longer continuation then needs another explicit resource profile, not truncation. Validation and reproduction record

Try the guided35-day lab. Step through October 31 in daily/monthly and delayed/scheduled-credit scenarios. Inspect C, A, R and Q before looking at a rounded total. The complete calendar is retained for computation; the focus table helps read one region without deleting quiet days. The lab embeds the actual compiled TypeScript reference.

Keep product projections separate from accepted accounting

Internal C/A pools are not chart-of-account balances. A positive customer-credit proposal maps Dr interest payable/Cr the scoped deposit account, contingent on actual funding. Fresh earned interest normally follows the separate selected path Dr interest expense/Cr interest payable first. Crediting payable to deposits must not expense the same interest again.

The calculator cannot know which prior accruals or payable funding have already posted. It therefore does not invent another expense journal from its current pools. The host must reconcile funding, authorize the account, assign stable scoped business identity and submit a complete command through the posting boundary. A zero proposal has no legs; an amount-limit proposal remains explainably blocked.

Even when a proposal is blocked or unfunded, Q and closing_state remain a hypothetical scheduled projection. They must not be presented as an accepted customer balance. This boundary matters more than a mathematically attractive rounded result.

Historical-example decision: not useful without a complete licensed product contract and dated balance/rate/receipt evidence. Synthetic Cedar makes every rule inspectable without attaching invented terms to a named institution. The primary reference notes distinguish sourced US comparisons from implementation choices; CFPB AppendixA and its official interpretation also explain why disclosure yield and accrued/uncredited interest need their own definitions.

Continue with Daily Simple Interest Accrual for the baseline, then Average-Daily-Balance Interest to compare paths and rate segments. Actual balanced/duplicate-safe postings remain the responsibility of the ledger tutorials, not an assumption granted by a product calculation.

ReferencesPrimary sources and evidence notes

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

Accessed:25 October 2026. Supports: US comparison of daily balance methods, compounding/crediting and nominal/yield concepts as identified below. Limitations: these sources do not establish Cedar's selected schema, rate chronology, ACT/365 Fixed product, negative-balance route or a compliance/APY/EIR result. All examples are author-created.

SourceVersion/section and roleBoundary
CFPB Regulation DD §1030.7Current regulation page as accessed;7(a)(1) daily/average daily balances,7(b) distinct compounding and crediting frequenciesUS comparison only. No universal mandated frequency or selected Cedar policy is inferred.
CFPB AppendixA to Part1030Current page, PartI general yield discussionNominal rate is distinct from annual percentage yield. This tutorial does not calculate a statutory APY.
CFPB Official interpretation of AppendixACurrent interpretation, PartIIA accrued/uncredited interest discussionStatement-yield calculation has its own balance/frequency rules; do not equate it with this internal C/A model.

Implementation choices: UTC complete calendar, ACT_365_FIXED even in leap year, latest causally applicable ppm version, zero/negative handling, exact reduced fractions, half-even customer-credit quantization, signed non-interest-bearing R, input bounds and hypothetical posting-proposal statuses. Synthetic facts: Cedar/CB-A/product account, balances, rates, dates and receipt IDs. Author-derived: weighted simple 32224/73; monthly 32240/73; closed-form daily≈442.515457121. Independent derivations precede implementation in datasets/independent-algebra.json. No filed entity facts or provider observations occur.

Historical-example decision: not useful for this state-boundary lesson without complete licensed contract terms, daily chronology, receipts, rates and crediting/capitalization rules. The synthetic example makes those rules explicit; no named institution's product performance is implied.

compound-interest.ts
/** Exact hypothetical product schedule. Proposals are not accepted postings. */
type Obj=Record<string,any>;
type Rat={n:bigint,d:bigint};
const fields=(s:string)=>s.split(' ');
const REQUEST=fields('schema_version calculation_id ledger_id entity_id account_id currency period_start period_end knowledge_cutoff day_count balance_basis capitalization credit_dates opening_state rate_versions days');
const OPENING=fields('as_of credited_minor capitalized uncapitalized rounding_carry');
const DAY=fields('date balance_minor balance_available_at balance_receipt_ref rate_version_id');
const RATE=fields('rate_version_id rate_ppm effective_from available_at');
const exact=(x:any,f:string[])=>x!==null&&typeof x==='object'&&!Array.isArray(x)&&Object.keys(x).length===f.length&&f.every(k=>Object.hasOwn(x,k));
const match=(x:any,re:RegExp,digits=Infinity)=>typeof x==='string'&&re.exec(x)?.[0]===x&&x.replace(/^-/,'').length<=digits;
const ident=(x:any)=>match(x,/^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/);
const signed=(x:any,digits:number)=>match(x,/^(0|-?[1-9][0-9]*)$/,digits);
const nonneg=(x:any,digits:number)=>match(x,/^(0|[1-9][0-9]*)$/,digits);
class ContractError extends Error{}
function need(ok:any,code:string):void{if(!ok)throw new ContractError(code);}
const abs=(n:bigint)=>n<0n?-n:n;
function gcd(a:bigint,b:bigint):bigint{a=abs(a);b=abs(b);while(b){const r=a%b;a=b;b=r;}return a;}
function f(n:bigint,d=1n):Rat{if(d<0n){n=-n;d=-d;}const g=gcd(n,d);return {n:n/g,d:d/g};}
const add=(a:Rat,b:Rat)=>f(a.n*b.d+b.n*a.d,a.d*b.d);
const sub=(a:Rat,b:Rat)=>add(a,{n:-b.n,d:b.d});
const mul=(a:Rat,b:Rat)=>f(a.n*b.n,a.d*b.d);
const wire=(x:Rat)=>({numerator:String(x.n),denominator:String(x.d)});
function rational(x:any,nonnegative=false,carry=false):Rat{
 need(exact(x,fields('numerator denominator')),'INVALID_RATIONAL');
 need(signed(x.numerator,4096)&&match(x.denominator,/^[1-9][0-9]*$/,4096),'INVALID_RATIONAL');
 const n=BigInt(x.numerator),d=BigInt(x.denominator);need(gcd(n,d)===1n,'INVALID_RATIONAL');
 if(nonnegative)need(n>=0n,'INVALID_OPENING_STATE');if(carry)need(2n*abs(n)<=d,'INVALID_CARRY');return {n,d};
}
function halfEven(x:Rat):bigint{const sign=x.n<0n?-1n:1n,n=abs(x.n);let q=n/x.d;const r=n%x.d;if(r*2n>x.d||(r*2n===x.d&&q%2n===1n))q++;return sign*q;}
function monthDays(y:number,m:number):number{return [31,y%4===0&&(y%100!==0||y%400===0)?29:28,31,30,31,30,31,31,30,31,30,31][m-1]!;}
function validDate(x:any):boolean{if(!match(x,/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/))return false;const y=Number(x.slice(0,4)),m=Number(x.slice(5,7)),d=Number(x.slice(8,10));return y>=1&&m>=1&&m<=12&&d>=1&&d<=monthDays(y,m);}
function timestamp(x:any):boolean{return match(x,/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3}Z$/)&&validDate(x.slice(0,10))&&Number(x.slice(11,13))<24&&Number(x.slice(14,16))<60&&Number(x.slice(17,19))<60;}
const dayEpoch=(x:string)=>Date.parse(x+'T00:00:00.000Z');
const monthEnd=(x:string)=>Number(x.slice(8,10))===monthDays(Number(x.slice(0,4)),Number(x.slice(5,7)));
const sig=(v:Obj)=>JSON.stringify(RATE.map(k=>v[k]));

export function accrueCompound(request:any):Obj{
 try{return calculate(request);}catch(e){if(e instanceof ContractError)return {status:'rejected',code:e.message,accrual:null,closing_state:null,credit_proposals:[],trace:[]};throw e;}
}
function calculate(r:any):Obj{
 need(exact(r,REQUEST),'INVALID_SCHEMA');need(r.schema_version==='d30-compound-interest-v1','UNSUPPORTED_SCHEMA');
 need(['calculation_id','ledger_id','entity_id','account_id'].every(k=>ident(r[k])),'INVALID_IDENTIFIER');need(r.currency==='USD','UNSUPPORTED_CURRENCY');
 need(validDate(r.period_start)&&validDate(r.period_end),'INVALID_DATE');const length=(dayEpoch(r.period_end)-dayEpoch(r.period_start))/86400000;need(length>=1&&length<=366,'INVALID_PERIOD');
 need(timestamp(r.knowledge_cutoff),'INVALID_TIMESTAMP');need(r.knowledge_cutoff>=r.period_end+'T00:00:00.000Z','PERIOD_NOT_COVERED');
 need(r.day_count==='ACT_365_FIXED','UNSUPPORTED_DAY_COUNT');need(r.balance_basis==='external_principal_excluding_model_interest','UNSUPPORTED_BALANCE_BASIS');
 need(['daily','calendar_month_end'].includes(r.capitalization),'UNSUPPORTED_CAPITALIZATION');
 const opening=r.opening_state;need(exact(opening,OPENING),'INVALID_OPENING_STATE');need(opening.as_of===r.period_start&&nonneg(opening.credited_minor,30),'INVALID_OPENING_STATE');
 let Q=BigInt(opening.credited_minor),C=rational(opening.capitalized,true),A=rational(opening.uncapitalized,true),R=rational(opening.rounding_carry,false,true);const openingQ=Q;
 need(Array.isArray(r.credit_dates)&&r.credit_dates.length<=length,'INVALID_CREDIT_SCHEDULE');const creditDates=new Set<string>();let previous:string|null=null;
 for(const value of r.credit_dates){need(validDate(value)&&value>=r.period_start&&value<r.period_end,'INVALID_CREDIT_SCHEDULE');need(!creditDates.has(value),'DUPLICATE_CREDIT_DATE');need(previous===null||value>previous,'INVALID_CREDIT_SCHEDULE');need(r.capitalization==='daily'||monthEnd(value),'CREDIT_NOT_CAPITALIZATION_BOUNDARY');creditDates.add(value);previous=value;}
 need(Array.isArray(r.rate_versions)&&r.rate_versions.length>=1&&r.rate_versions.length<=512,'INVALID_RATE_VERSIONS');
 const rates=new Map<string,Obj>(),effectiveDates=new Set<string>();
 for(const v of r.rate_versions){need(exact(v,RATE),'INVALID_RATE_SCHEMA');need(ident(v.rate_version_id),'INVALID_IDENTIFIER');need(nonneg(v.rate_ppm,7)&&BigInt(v.rate_ppm)<=1000000n,'INVALID_RATE');need(validDate(v.effective_from),'INVALID_DATE');need(timestamp(v.available_at),'INVALID_TIMESTAMP');
  if(rates.has(v.rate_version_id))throw new ContractError(sig(v)===sig(rates.get(v.rate_version_id)!)?'DUPLICATE_RATE_VERSION':'RATE_VERSION_CONFLICT');
  need(!effectiveDates.has(v.effective_from),'AMBIGUOUS_RATE_SCHEDULE');need(v.available_at<=v.effective_from+'T00:00:00.000Z','RETROACTIVE_RATE_POLICY_UNSUPPORTED');effectiveDates.add(v.effective_from);rates.set(v.rate_version_id,v);
 }
 need(Array.isArray(r.days)&&r.days.length===length,'INVALID_DAY_COVERAGE');const rows:{row:Obj,ppm:bigint}[]=[],seen=new Set<string>();previous=null;
 for(let i=0;i<r.days.length;i++){const row=r.days[i];need(exact(row,DAY),'INVALID_DAY_SCHEMA');need(validDate(row.date),'INVALID_DATE');need(!seen.has(row.date),'DUPLICATE_DAY');need(previous===null||row.date>previous,'OUT_OF_ORDER_DAY');need(row.date===new Date(dayEpoch(r.period_start)+i*86400000).toISOString().slice(0,10),'INVALID_DAY_COVERAGE');seen.add(row.date);previous=row.date;
  need(signed(row.balance_minor,30),'INVALID_BALANCE');need(BigInt(row.balance_minor)>=0n,'NEGATIVE_BALANCE_POLICY_UNSUPPORTED');need(ident(row.balance_receipt_ref)&&ident(row.rate_version_id),'INVALID_IDENTIFIER');need(timestamp(row.balance_available_at),'INVALID_TIMESTAMP');need(row.balance_available_at>=row.date+'T23:59:59.999Z','BALANCE_NOT_COMPLETE');need(row.balance_available_at<=r.knowledge_cutoff,'BALANCE_NOT_AVAILABLE');
  need(rates.has(row.rate_version_id),'UNKNOWN_RATE_VERSION');const chosen=rates.get(row.rate_version_id)!,dayStart=row.date+'T00:00:00.000Z';need(chosen.effective_from<=row.date&&chosen.available_at<=dayStart,'RATE_NOT_AVAILABLE_AT_DAY_START');const applicable=[...rates.values()].filter(v=>v.effective_from<=row.date&&v.available_at<=dayStart).sort((a,b)=>a.effective_from.localeCompare(b.effective_from));need(chosen.rate_version_id===applicable[applicable.length-1]!.rate_version_id,'STALE_RATE_VERSION');rows.push({row,ppm:BigInt(chosen.rate_ppm)});
 }
 let earned=f(0n);const trace:Obj[]=[],proposals:Obj[]=[];
 for(const {row,ppm} of rows){const X=BigInt(row.balance_minor),beforeQ=Q,base=add(f(X+Q),C),interest=mul(base,f(ppm,365000000n));earned=add(earned,interest);A=add(A,interest);const capped=r.capitalization==='daily'||monthEnd(row.date);if(capped){C=add(C,A);A=f(0n);}const scheduled=creditDates.has(row.date);let q=0n;
  if(scheduled){const entitlement=add(C,R);q=halfEven(entitlement);Q+=q;C=f(0n);R=sub(entitlement,f(q));const status=q===0n?'zero':q<=10n**30n-1n?'ready':'amount-limit';const legs=status!=='ready'?[]:[{account_id:'interest_payable_usd',side:'debit',amount_minor:String(q)},{account_id:r.account_id,side:'credit',amount_minor:String(q)}];proposals.push({date:row.date,exact_entitlement:wire(entitlement),amount_minor:String(q),carry_after:wire(R),status,payable_funding_required:q>0n,legs});}
  trace.push({date:row.date,external_principal_minor:row.balance_minor,balance_receipt_ref:row.balance_receipt_ref,balance_available_at:row.balance_available_at,rate_version_id:row.rate_version_id,rate_ppm:String(ppm),credited_before_minor:String(beforeQ),interest_base_exact:wire(base),exact_daily_interest:wire(interest),cumulative_earned_exact:wire(earned),capitalization_applied:capped,credit_scheduled:scheduled,credit_minor:String(q),projected_principal_after_minor:String(X+Q),capitalized_after:wire(C),uncapitalized_after:wire(A),rounding_carry_after:wire(R)});
 }
 const pending=add(add(C,A),R),estimate=halfEven(pending),accrual:Obj={};for(const k of fields('ledger_id entity_id account_id currency period_start period_end day_count balance_basis capitalization knowledge_cutoff'))accrual[k]=r[k];
 Object.assign(accrual,{day_count_value:length,earned_exact:wire(earned),scheduled_credit_minor:String(Q-openingQ),projected_principal_minor:String(BigInt(rows[rows.length-1]!.row.balance_minor)+Q),pending_entitlement_exact:wire(pending),pending_round_half_even_minor:String(estimate),pending_estimate_residual:wire(sub(pending,f(estimate)))});
 return {status:'ok',code:'COMPOUND_INTEREST_ACCRUED',accrual,closing_state:{as_of:r.period_end,credited_minor:String(Q),capitalized:wire(C),uncapitalized:wire(A),rounding_carry:wire(R)},credit_proposals:proposals,trace};
}
Full-height labcompound 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.