D30-F02-A04 / Released engineering topic

Calculate Marginal Usage Fees Without Repricing Earlier Transactions

Trace exact tier allocations, compare a whole-amount bracket, and apply aggregate rounding, minimum and cap under a known tariff.

Calculate Marginal Usage Fees Without Repricing Earlier TransactionsD30 / D30-F02

A tariff says the first 100 transactions are free, the next 900 cost 2 cents each, and further transactions cost 1 cent each. For 1250 transactions, multiplying everything by the last rate produces 1250 cents. Allocating each band produces 2050 cents. Both calculations can describe a pricing method; only one matches a marginal tariff.

This tutorial makes the method visible before writing a posting proposal. You will inspect occupied bands, preserve fractional cents, apply one rounding decision, and explain why the final fee changed. Operations can answer a charge query with a trace. Engineers can reject a malformed schedule or an inapplicable tariff before returning a plausible number.

All Cedar tariffs, usage counts and events below are synthetic teaching inputs. Arithmetic is author-derived. The official Stripe tiered-pricing documentation supports the distinction between graduated per-band and whole-quantity volume methods. Our exact tariff, zero-usage exemption, bounds and rounding are selected choices; this is not a Stripe integration or an actual bank's fee schedule.

Decide what a tier means

In the canonical marginal method, entering another band changes the rate for the additional units. Earlier transactions keep their earlier allocation. Transaction 1001 costs 1 cent here; it does not change the 900 transactions already charged 2 cents.

The comparison method selects the bracket reached by total quantity and applies its rate to all units. At 1000 transactions its 2-cent bracket charges 2000 cents. At 1001 its 1-cent bracket charges 1001 cents. A decreasing total at that boundary is possible under this whole-amount design. Do not repair it into a marginal formula while claiming to implement the same contract.

Synthetic method at 1250 transactionsCalculation in centsRaw fee
Marginal100×0 +900×2 +250×12050
Whole amount1250×11250

Our marginal tariff also caps the rounded fee at 2000 cents. The final canonical proposal is therefore 2000, or USD 20.00. The cap explains the 50-cent adjustment from the raw 2050. It does not turn the underlying method into a whole-amount bracket.

Synthetic marginal allocation across three bands totals 2050 cents before the 2000 cap, while a whole-amount bracket charges 1250 cents.

Open the full-size tier comparison. Notice which units keep the 2-cent rate when total usage passes 1000.

Put the boundaries in the data

Represent each tier with a cumulative upper bound and a per-unit rate. The first upper is 100, the next 1000, and the final upper is null for unlimited. Lower bounds are implied by the preceding upper, beginning 0. Strictly increasing uppers prevent overlaps and empty bands; an unlimited band anywhere except last is invalid.

Exactly 100 transactions occupy the free band. At 101 only one unit enters the next band, so fee is 2 cents. At 1000 the allocation is 100 free and 900 charged 2 cents, or 1800. At 1001 the extra unit adds 1 cent, giving 1801. These equality cases tell you more about the contract than a single large example.

For marginal tier i, allocation is max(0,min(quantity,upper_i)−lower_i), treating an unlimited upper as the total quantity. Sum allocations and verify they equal total usage. The whole-amount branch instead assigns all quantity to its selected bracket and zero to the others. Both return the same ordered diagnostic shape so a reader can compare them directly.

Preserve fractions until the aggregate is ready

Rates use micro-minor units:1000000 means one cent per transaction. A rate of 250000 is one quarter of a cent. This unit is a selected precision convention, not an annual rate or a percentage of a deposit balance. Quantity is an integer transaction count; neither quantity nor money is accepted as a numeric JSON float.

Two quarter-cent units produce exactly 1/2 cent. The selected half-even rule rounds that to 0. Three produce 3/4 cent and round 1. More subtly, two different tiers each contributing 1/2 cent sum to 1 cent. Rounding each tier individually to 0 would lose the complete fee.

The reference sums exact integer micro-minor charges first, reduces the raw fraction, and rounds once. It returns the residual between exact and rounded amounts. Then it applies the positive-usage minimum and cap. That separates a rounding adjustment from a contractual bound adjustment. It also keeps huge integer quantities exact beyond JavaScript's safe Number range.

State zero, minimum and cap rules explicitly

For positive usage, a minimum 500 can raise a 2-cent rounded fee to 500. With zero usage, this tariff's exemption keeps fee at 0 even when the minimum is 500. Other products may define a base fee; this example has none. The source documentation's flat-fee rules are not silently imported.

Minimum must not exceed cap. Both are whole minor-unit values. Applying bounds after one aggregate round is part of the versioned contract. If a product chooses another order, it needs a different implementation profile and boundary tests. The next tutorial's Minimum-Balance Fee is also a different question: it measures daily account balances rather than imposing a lower bound on a usage charge.

The validation branch covers the whole request. A bad final band must not leave a caller with a partial total from earlier bands.

Use the tariff that actually covered the period

The example aggregation period is 1 October through the exclusive 1 November boundary. A complete usage receipt arrives just after that boundary and no later than the requested knowledge cutoff. The tariff covers the whole period and was available before it began.

A newly announced November tariff cannot be applied to October just because the calculation runs later. An in-period schedule change is unsupported by this one-version profile and must be split or handled by another explicitly defined method. Currency and measure unit must match. A dollar amount is not a transaction count; an EUR tariff is not a USD tariff.

These checks still rely on trusted source completeness. A scalar count and receipt ID cannot prove that every transaction was counted exactly once or that no superseding tariff was omitted. The host must establish that evidence. The calculator explains a supplied complete aggregate; it does not manufacture its provenance.

Separate fee calculation from accepted posting

A supported positive proposal debits the customer's deposit liability on the bank's books and credits fee income. That reduces the liability and increases income. Read Double-Entry Posting Engine for that perspective and Idempotent Journal Posting for the durable business identity boundary.

This function performs no charge. A host still checks actual chart, account scope, entitlement, capacity, economic/source/booking clocks and stable journal identity. Zero yields no journal legs. If an exact result exceeds the posting engine's per-leg amount limit, the fee remains inspectable while proposal status is amount-limit and legs are empty. A mathematical result must not masquerade as a posting-ready instruction outside its interface limits.

Verify the trace, then inspect the lab

The fixture download supplies 101 independently authored full expected outputs. Python and TypeScript match every result and rejection with strict compilation. Behavioral tests also check allocation conservation, marginal monotonicity, the whole-amount boundary decrease and detached outputs. Validation evidence records the actual local checks and separates visitor acceptance.

Run the supplied Python example from the topic directory with python examples/run_example.py. It loads the complete canonical request, rather than hiding tariff dates or usage provenance in defaults. The calculation and output inspection are:

Python
request = next(case["input"] for case in cases["cases"]
               if case["id"] == "canonical-1250-capped")
result = calculate_tiered_fee(request)
assert result["code"] == "FEE_CALCULATED"
assert result["fee"]["fee_minor"] == "2000"
for band in result["trace"]:
    print(band["allocated_quantity_units"], band["exact_segment_fee"])

The allocation quantities are 100, 900 and 250. Inspect the exact segment fractions before the final proposal; a final amount alone cannot explain the pricing method. Python and TypeScript use the same request and complete expected result in their shared fixtures.

The guided lab lets you inspect the canonical allocation, boundary quantities, fractional rounding and an invalid schedule. Method changes recalculate from the same reference. The static comparison and exact table preserve the lesson without motion.

Historical-example decision: not useful for the selected arithmetic. A real issuer example would need the complete applicable contract, units and source chronology; no such event is invented. You can now explain why a usage fee has its amount and what must be verified before posting it. Continue with Minimum-Balance Fee for a calendar-based threshold decision and its one-period identity.

References2 primary sources and evidence notes

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

R01 — Set up tiered pricing

  • Organization: Stripe; official product documentation, current undated version accessed25October2026.
  • Accessed: 25 October 2026.
  • URL: Tiered pricing.
  • Source type: primary software/product-method documentation; no banking jurisdiction claim.
  • Supports: sections Volume-based pricing and Graduated pricing distinguish a whole-quantity tier from per-band allocation; decreasing total at a volume boundary is possible.
  • Limitations: Cedar's tariff/counts/free tier/micro-minor units/half-even/minimum/cap/zero exemption are independently selected. No current Stripe price, API integration, tax, legal entitlement or conformance claim. Flat-fee examples are not imported. No named customer example or table copied.

R02 — Frozen Cedar tariff specification and independent fixtures

  • Type: authored implementation contract and synthetic teaching data,25October2026.
  • Public artifact: data contract, full cases.
  • Supports: exact fields, chronology, integer units, formulas, endpoints, rejection policy and worked expectations.
  • Limitations: receipt strings do not authenticate counts or tariff ownership; complete applicable source inputs are a host obligation. Full-result tests prove the portable example's behavior, not live billing or durable posting.
  • Rights: all tariff values and financial examples are synthetic/public authored material; historical example not useful without a complete verified real contract.
tiered-fee.ts
/** Exact micro-minor tariff arithmetic. The returned posting is a proposal. */
type Json=any;
const REQUEST='schema_version calculation_id ledger_id entity_id account_id account_type normal_side currency period_start period_end knowledge_cutoff mode tariff usage'.split(' ');
const TARIFF='tariff_id version_id currency measure_unit effective_from effective_until available_at tiers minimum_minor cap_minor'.split(' ');
const USAGE='quantity_units measure_unit period_start period_end available_at receipt_ref'.split(' ');
class Invalid extends Error {}
function need(ok:boolean,code:string):asserts ok{if(!ok)throw new Invalid(code);}
function exact(x:Json,keys:string[]):void{need(x!==null&&typeof x==='object'&&!Array.isArray(x)&&Object.keys(x).length===keys.length&&keys.every(k=>Object.hasOwn(x,k)),'INVALID_SCHEMA');}
function full(re:RegExp,x:Json):boolean{if(typeof x!=='string')return false;const m=re.exec(x);return m!==null&&m[0].length===x.length;}
function identifier(x:Json):void{need(full(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/,x),'INVALID_IDENTIFIER');}
function amount(x:Json,positive=false):bigint{need(full(positive?/^[1-9][0-9]{0,29}$/:/^(?:0|[1-9][0-9]{0,29})$/,x),'INVALID_AMOUNT');return BigInt(x);}
function day(x:Json):number{
 need(full(/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/,x),'INVALID_DATE');const y=Number(x.slice(0,4)),m=Number(x.slice(5,7)),d=Number(x.slice(8,10)),leap=y%4===0&&(y%100!==0||y%400===0),days=[31,leap?29:28,31,30,31,30,31,31,30,31,30,31];need(y>=1&&m>=1&&m<=12&&d>=1&&d<=days[m-1],'INVALID_DATE');return Date.parse(x+'T00:00:00.000Z')/86400000;
}
function timestamp(x:Json):void{need(full(/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3}Z$/,x),'INVALID_TIMESTAMP');try{day(x.slice(0,10));}catch{throw new Invalid('INVALID_TIMESTAMP');}need(Number(x.slice(11,13))<=23&&Number(x.slice(14,16))<=59&&Number(x.slice(17,19))<=59,'INVALID_TIMESTAMP');}
function gcd(a:bigint,b:bigint):bigint{a=a<0n?-a:a;while(b!==0n){const r=a%b;a=b;b=r;}return a;}
function rational(n:bigint,d=1000000n):Json{const g=gcd(n,d);return {numerator:String(n/g),denominator:String(d/g)};}
function halfEven(n:bigint,d:bigint):bigint{const q=n/d,r=n%d;return q+(2n*r>d||(2n*r===d&&q%2n!==0n)?1n:0n);}
export function calculateTieredFee(request:Json):Json{
 try{
  exact(request,REQUEST);need(request.schema_version==='d30-tiered-fee-v1','UNSUPPORTED_SCHEMA');for(const k of ['calculation_id','ledger_id','entity_id','account_id'])identifier(request[k]);
  need(request.account_type==='liability'&&request.normal_side==='credit','UNSUPPORTED_ACCOUNT_PERSPECTIVE');need(request.currency==='USD','UNSUPPORTED_CURRENCY');need(['marginal','whole_amount'].includes(request.mode),'UNSUPPORTED_MODE');
  const start=day(request.period_start),end=day(request.period_end);need(end-start>=1&&end-start<=366,'INVALID_PERIOD');timestamp(request.knowledge_cutoff);const boundary=request.period_end+'T00:00:00.000Z';need(request.knowledge_cutoff>=boundary,'INCOMPLETE_PERIOD');
  const t=request.tariff,u=request.usage;exact(t,TARIFF);exact(u,USAGE);identifier(t.tariff_id);identifier(t.version_id);need(t.currency===request.currency,'CURRENCY_MISMATCH');need(t.measure_unit==='transaction_count','UNIT_MISMATCH');const first=day(t.effective_from),last=t.effective_until===null?null:day(t.effective_until);need(last===null||last>first,'INVALID_PERIOD');need(first<=start&&(last===null||end<=last),'TARIFF_NOT_APPLICABLE');timestamp(t.available_at);need(t.available_at<=request.period_start+'T00:00:00.000Z'&&t.available_at<=request.knowledge_cutoff,'TARIFF_NOT_AVAILABLE');
  const minimum=amount(t.minimum_minor),cap=t.cap_minor===null?null:amount(t.cap_minor);need(cap===null||minimum<=cap,'INVALID_BOUNDS');need(Array.isArray(t.tiers)&&t.tiers.length>=1&&t.tiers.length<=32,'INVALID_TIERS');let lower=0n;const parsed:{lower:bigint,upper:bigint|null,rate:bigint}[]=[];
  for(let i=0;i<t.tiers.length;i++){const tier=t.tiers[i];exact(tier,['upper_bound_units','rate_microminor_per_unit']);const upper=tier.upper_bound_units===null?null:amount(tier.upper_bound_units,true);need((upper===null)===(i===t.tiers.length-1),'INVALID_TIERS');need(upper===null||upper>lower,'INVALID_TIERS');const rate=amount(tier.rate_microminor_per_unit);parsed.push({lower,upper,rate});if(upper!==null)lower=upper;}
  const quantity=amount(u.quantity_units);need(u.measure_unit===t.measure_unit,'UNIT_MISMATCH');day(u.period_start);day(u.period_end);need(u.period_start===request.period_start&&u.period_end===request.period_end,'USAGE_PERIOD_MISMATCH');timestamp(u.available_at);identifier(u.receipt_ref);need(u.available_at>=boundary,'INCOMPLETE_USAGE');need(u.available_at<=request.knowledge_cutoff,'FUTURE_EVIDENCE');
  const trace:Json[]=[];let totalMicro=0n,wholeSelected=false;
  for(const [index,{lower,upper,rate}]of parsed.entries()){
   let allocated:bigint;if(request.mode==='marginal'){const top=upper===null||quantity<upper?quantity:upper;allocated=top>lower?top-lower:0n;}else{const selected:boolean=!wholeSelected&&(upper===null||quantity<=upper);allocated=selected?quantity:0n;wholeSelected=wholeSelected||selected;}
   const segmentMicro=allocated*rate;totalMicro+=segmentMicro;trace.push({index,lower_bound_units:String(lower),upper_bound_units:upper===null?null:String(upper),rate_microminor_per_unit:String(rate),allocated_quantity_units:String(allocated),exact_segment_fee:rational(segmentMicro)});
  }
  const rounded=halfEven(totalMicro,1000000n);let fee=rounded,minimumApplied=false,capApplied=false;if(quantity===0n)fee=0n;else{if(fee<minimum){fee=minimum;minimumApplied=true;}if(cap!==null&&fee>cap){fee=cap;capApplied=true;}}
  const details:Json={};for(const k of ['ledger_id','entity_id','account_id','currency','period_start','period_end','knowledge_cutoff','mode'])details[k]=request[k];Object.assign(details,{tariff_id:t.tariff_id,version_id:t.version_id,measure_unit:t.measure_unit,quantity_units:String(quantity),raw_fee_exact:rational(totalMicro),rounded_before_bounds_minor:String(rounded),rounding_residual:rational(totalMicro-rounded*1000000n),minimum_applied:minimumApplied,cap_applied:capApplied,zero_usage_exemption:quantity===0n,fee_minor:String(fee)});
  const status=fee===0n?'zero':fee>10n**30n-1n?'amount-limit':'ready',proposal:Json={};for(const k of ['calculation_id','ledger_id','entity_id','account_id','currency','period_start','period_end','knowledge_cutoff'])proposal[k]=request[k];Object.assign(proposal,{schema_version:'d30-fee-proposal-v1',status,amount_minor:String(fee),legs:status!=='ready'?[]:[{account_id:request.account_id,side:'debit',amount_minor:String(fee)},{account_id:'fee_income_usd',side:'credit',amount_minor:String(fee)}]});
  return {status:'ok',code:'FEE_CALCULATED',fee:details,trace,posting_proposal:proposal};
 }catch(error){if(error instanceof Invalid)return {status:'rejected',code:error.message,fee:null,trace:[],posting_proposal:null};throw error;}
}
Full-height labtiered fee 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.