- Published on
Designing a Deposit Interest Calculation Engine — From Day-Count Math to Preferential Rates
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- Introduction
- The Traps of Interest Calculation — Why It Is Not One Multiplication
- Simple and Compound Interest — Formulas and Implementation
- Product-Specific Interest Formulas
- The Preferential Rate Condition Engine
- Tax Handling — The Basic Structure of Withholding
- Managing Rate Change History
- The Interest Payment Batch — A Settlement Landscape
- Verification — How to Trust an Interest Engine
- Rounding and Truncation — The Politics of One Won
- Failure Scenario Casebook — Where It Blows Up
- Checklist
- Closing Thoughts
- References
Introduction
If you thought "interest calculation is just one multiplication," you are making the most dangerous assumption in core banking. Interest calculation is the area of a deposit system where bugs erupt most often and most expensively. Even a one-won discrepancy can lead to customer complaints and regulatory reports, and an error in a settlement batch applied to millions of accounts instantly becomes a major incident.
In this article we design a deposit interest calculation engine from scratch: day-count traps, simple and compound interest implementations, product-specific formulas, a preferential rate rule engine, tax handling, rate history management, and rounding policy. Code samples are in Java and Python. This is a technical explanation, not tax or investment advice; always verify concrete tax rates and rules against the latest statutes and official sources.
The Traps of Interest Calculation — Why It Is Not One Multiplication
Daily vs. Monthly Proration, and Day-Count Conventions
If you park one million won for 73 days at an annual rate of 3.0 percent, how much interest accrues? The answer is "it depends on which day-count convention you use."
ACT/365 (actual days/365): 1,000,000 x 0.03 x 73/365 = 6,000.00 won
ACT/360 (actual days/360): 1,000,000 x 0.03 x 73/360 = 6,083.33 won
30/360 (30-day months) : days are normalized to 30-day months
Korean won deposits conventionally use "actual elapsed days / 365" (dividing by 365 even in leap years is the common practice, though some institutions and products use ACT/ACT). International money markets commonly use ACT/360, and bonds use the 30/360 family. No convention is inherently "correct"; the requirement is to implement exactly the convention written in the product terms.
| Convention | Numerator (days) | Denominator | Typical Use |
|---|---|---|---|
| ACT/365 Fixed | Actual elapsed days | Always 365 | Most KRW deposits and loans |
| ACT/360 | Actual elapsed days | Always 360 | Money markets, some FX |
| ACT/ACT | Actual elapsed days | Actual days in the year | Some bonds, international deals |
| 30/360 | Months normalized to 30 days | 360 | Bond coupon calculation |
The Leap Year Ambush
A leap year has 366 days. Under ACT/365 Fixed, a 366-day deposit earns "slightly more than the annual rate" (366/365). Under ACT/ACT, you must split the calculation into leap-year and normal-year segments. Maturity-date rules also need to match the terms: when does a one-year time deposit opened on February 29 mature (February 28? March 1?). These boundary cases must be pinned down as test cases.
Start Date and Payment Date — The First Day and the Last
When counting elapsed days, the common convention is "include the deposit date, exclude the payment date" (initial date inclusive, final date exclusive). Deposit on June 1, withdraw on June 2: one day of interest. If this rule deviates in even one place across the system (online closure calculation, daily accumulation batch, accrued interest posting), a one-day interest discrepancy appears. It looks trivial — it is not, across millions of accounts.
Simple and Compound Interest — Formulas and Implementation
Formulas
[Simple Interest]
interest = principal x annual rate x (elapsed days / 365)
I = P x r x (d / 365)
[Compound Interest - m compounding periods per year, n years]
amount = principal x (1 + r/m)^(m x n)
S = P x (1 + r/m)^(m*n)
[Monthly-compound time deposit example - 3.0% annual, 12 months]
S = P x (1 + 0.03/12)^12
= P x 1.030416... (effective yield about 3.0416%)
Java Implementation — BigDecimal Is Table Stakes
The moment you use double for monetary calculation, you are disqualified. Binary floating point cannot even represent 0.1 exactly.
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
public class InterestCalculator {
private static final BigDecimal DAYS_IN_YEAR = new BigDecimal("365");
private static final int CALC_SCALE = 10; // intermediate precision
private static final int KRW_SCALE = 0; // KRW has no decimal places
/** Simple interest: principal x rate x days/365, truncate below one won */
public BigDecimal simpleInterest(BigDecimal principal,
BigDecimal annualRate, // 0.03 = 3% p.a.
LocalDate from, // deposit date (inclusive)
LocalDate to) { // payment date (exclusive)
long days = ChronoUnit.DAYS.between(from, to);
if (days < 0) {
throw new IllegalArgumentException("payment date precedes deposit date");
}
return principal
.multiply(annualRate)
.multiply(new BigDecimal(days))
.divide(DAYS_IN_YEAR, CALC_SCALE, RoundingMode.HALF_UP)
.setScale(KRW_SCALE, RoundingMode.DOWN); // truncate sub-won
}
/** Monthly compounding: capitalize interest into principal each month */
public BigDecimal monthlyCompound(BigDecimal principal,
BigDecimal annualRate,
int months) {
BigDecimal monthlyRate = annualRate.divide(
new BigDecimal("12"), CALC_SCALE, RoundingMode.HALF_UP);
BigDecimal balance = principal;
for (int i = 0; i < months; i++) {
BigDecimal interest = balance.multiply(monthlyRate)
.setScale(KRW_SCALE, RoundingMode.DOWN); // truncate per period
balance = balance.add(interest);
}
return balance;
}
}
Two important details hide here.
- Carry intermediate calculations at ample precision (scale 10 or so) and truncate/round only when finalizing the amount. Truncating at every step accumulates error.
- The value computed in one shot with the mathematical power formula differs from the value accumulated period by period with per-period truncation. If the terms say "interest is capitalized into principal monthly," the latter (loop) is correct. The terms, not the formula, are the specification.
Python Implementation — the decimal Module
from decimal import Decimal, ROUND_DOWN, ROUND_HALF_UP, getcontext
from datetime import date
getcontext().prec = 28 # ample global precision
DAYS_IN_YEAR = Decimal("365")
def simple_interest(principal: Decimal, annual_rate: Decimal,
start: date, end: date) -> Decimal:
"""Simple interest. Start inclusive, end exclusive. Truncate sub-won."""
days = (end - start).days
if days < 0:
raise ValueError("payment date precedes deposit date")
raw = principal * annual_rate * Decimal(days) / DAYS_IN_YEAR
return raw.quantize(Decimal("1"), rounding=ROUND_DOWN)
def installment_maturity_interest(monthly_amount: Decimal,
annual_rate: Decimal,
months: int) -> Decimal:
"""Installment savings maturity interest (simple): proportional to months on deposit."""
total = Decimal("0")
for k in range(1, months + 1):
remaining_months = months - k + 1
portion = (monthly_amount * annual_rate
* Decimal(remaining_months) / Decimal("12"))
total += portion
return total.quantize(Decimal("1"), rounding=ROUND_DOWN)
Why float is unacceptable can be proven in one line.
>>> 0.1 + 0.2
0.30000000000000004
>>> Decimal("0.1") + Decimal("0.2")
Decimal('0.3')
Product-Specific Interest Formulas
Time Deposits — Maturity, Early Termination, Partial Withdrawal
[Payment at maturity]
interest = principal x contract rate x days on deposit/365
[Early termination]
interest = principal x early-termination rate (banded by holding period)
x actual days on deposit/365
- the early-termination rate is usually "base rate x ratio" or a fixed banded table
- preferential rates are typically excluded on early termination
[Partial withdrawal]
- withdrawn portion: settled up to the withdrawal date using the early-termination formula
- remaining portion: keeps the original contract terms
- validate product conditions such as withdrawal count limits and minimum balance
An example early-termination rate band table looks like this (it differs per product).
| Holding Period | Applied Rate |
|---|---|
| Under 1 month | 0.1 percent p.a. |
| 1 month to under 3 months | 0.5 percent p.a. |
| 3 months to under 6 months | 30 percent of base rate |
| 6 months to before maturity | 50 percent of base rate |
Installment Savings — The World of Installments
The essence of installment savings: each installment earns interest for the period remaining until maturity.
[Fixed installment maturity interest (simple, by contractual payment dates)]
interest on installment k = monthly amount x annual rate x (months remaining)/12
total interest = monthly amount x annual rate x (n + (n-1) + ... + 1)/12
= monthly amount x annual rate x n(n+1)/(2 x 12)
Example: 100,000 won monthly, 3% p.a., 12 months
total = 100,000 x 0.03 x 12x13/(2x12) = 100,000 x 0.03 x 6.5 = 19,500 won
The trap is the actual payment date. Pay later than the contractual date (missed then paid) and interest shrinks; pay early (prepayment) and it grows — the natural consequence of day-count math. Some product terms instead use "offset prepaid and overdue days to adjust the maturity date" (prepaid-day/overdue-day settlement). This settlement logic is where the most bugs occur in the installment domain, so per-installment comparison tests between the terms text and the computed result are mandatory.
The Preferential Rate Condition Engine
Requirements
Modern deposit products carry conditions like "0.2 percentage points for salary transfer, 0.3 for card spending of at least 300,000 won, 0.1 for marketing consent, up to a 0.5 maximum." Hardcode this with if statements and every new product needs a deployment; evaluation timing (at opening? at maturity? daily?) also differs per product, so it degenerates into spaghetti quickly. The standard structure declares preferential rates as data (rules) evaluated by an engine.
Rule Definition Table
CREATE TABLE preferential_rate_rule (
product_code VARCHAR(10) NOT NULL,
rule_id VARCHAR(8) NOT NULL,
rule_name VARCHAR(100) NOT NULL, -- e.g. salary transfer bonus
condition_type VARCHAR(20) NOT NULL, -- SALARY_TRANSFER, CARD_SPEND ...
condition_param VARCHAR(200), -- JSON parameters (thresholds etc.)
bonus_rate NUMERIC(7,4) NOT NULL, -- bonus width (annual %p)
eval_timing CHAR(1) NOT NULL, -- O:on open M:at maturity D:daily
valid_from DATE NOT NULL,
valid_to DATE NOT NULL,
CONSTRAINT pk_prr PRIMARY KEY (product_code, rule_id, valid_from)
);
Evaluation Engine Sketch
public interface PreferentialCondition {
/** Evaluate whether the condition is satisfied, given evaluation-time context. */
boolean evaluate(EvaluationContext ctx);
}
public class SalaryTransferCondition implements PreferentialCondition {
private final int requiredMonths; // N consecutive months of salary transfer
public SalaryTransferCondition(int requiredMonths) {
this.requiredMonths = requiredMonths;
}
@Override
public boolean evaluate(EvaluationContext ctx) {
return ctx.salaryTransferMonths() >= requiredMonths;
}
}
public class PreferentialRateEngine {
public BigDecimal totalBonusRate(String productCode,
EvaluationContext ctx,
LocalDate evalDate) {
List ruleRows = ruleRepository.findActive(productCode, evalDate);
BigDecimal sum = BigDecimal.ZERO;
for (Object row : ruleRows) {
RuleRow rule = (RuleRow) row;
PreferentialCondition cond = conditionFactory.create(rule);
if (cond.evaluate(ctx)) {
sum = sum.add(rule.bonusRate());
auditLogger.record(ctx.accountNo(), rule.ruleId(), evalDate);
}
}
return sum.min(maxBonusRate(productCode)); // apply the cap
}
}
Four design points:
- Separate evaluation timing: products judged "in bulk at maturity" and products judged "daily, feeding the daily applied rate" call the engine from different places. Evaluation timing must be rule metadata.
- Preserve evaluation evidence: preferential-rate disputes arrive as "I met the condition, why was the bonus dropped?" Always keep an audit log of which rule was judged satisfied or not, based on which data.
- Rule validity periods: when a product revision changes conditions, existing subscribers may follow the rules as of their opening date and new subscribers the new rules. Keep validity periods on the rule table and select by opening date.
- External data dependencies: card spending and salary-transfer flags are other systems' data. Explicitly design what happens if the data is not yet aggregated at evaluation time (park? re-evaluation queue?).
Tax Handling — The Basic Structure of Withholding
When paying interest, the bank withholds interest income tax. The conceptual structure is as follows (specific rates can change with legislation; always verify against official sources such as the National Tax Service).
pre-tax interest = output of the interest engine
income tax = pre-tax interest x income tax rate (unit rounding rules apply)
local income tax = income tax x local income tax rate (unit rounding rules apply)
after-tax interest = pre-tax interest - income tax - local income tax
System-level points:
- Tax classification codes: per-account tax types — standard taxation, tax-exempt (statutory products), tax-favored — must live in the master, and the type as of payment time governs the calculation.
- Truncation rules: tax computation may apply unit rules such as truncating below ten won, so even the order of fraction handling after multiplying the rate must be pinned to terms and tax law.
- Separate posting: pre-tax interest, income tax, local income tax, and after-tax interest must each be posted separately to enable withholding return aggregation.
- Income attribution timing: interest income is attributed at payment time in principle, so the timing gap between accrued interest (accrual-basis posting) and withholding (payment time) must be handled on both the accounting and tax sides.
Managing Rate Change History
Interest on floating-rate products applies "different rates per period" and sums the segments. The rate history table is the core.
CREATE TABLE product_rate_history (
product_code VARCHAR(10) NOT NULL,
rate_type CHAR(2) NOT NULL, -- 01:base 02:early-termination 03:post-maturity
effective_from DATE NOT NULL, -- start of application (inclusive)
effective_to DATE NOT NULL, -- end of application (exclusive), 9999-12-31 allowed
annual_rate NUMERIC(7,4) NOT NULL,
approved_by VARCHAR(20) NOT NULL, -- approval info
created_at TIMESTAMP NOT NULL,
CONSTRAINT pk_pr PRIMARY KEY (product_code, rate_type, effective_from)
);
Segment-sum calculation is expressed as:
total interest = SUM( principal (or daily balance) x segment rate x segment days/365 )
for each rate segment in [deposit date, payment date)
Example: 1,000,000 won, deposited Apr 1, paid Jul 1
Apr 1 - May 15 (44 days): 3.0% p.a. -> 1,000,000 x 0.030 x 44/365 = 3,616 won
May 16 - Jul 1 (47 days): 3.5% p.a. -> 1,000,000 x 0.035 x 47/365 = 4,506 won
total (per-segment or grand-total truncation per policy) = 8,122 won
Here too, whether truncation happens per segment or once on the total is a matter of the product terms. And there is an unwritten law of history table design: never overwrite rate history with UPDATE. Even a wrongly registered rate stays as a corrective history entry, and retroactive recalculation is handled by a recalculation batch with adjusting entries. You must be able to reproduce any past interest calculation at any time to face an audit.
The Interest Payment Batch — A Settlement Landscape
Demand-deposit products typically pay interest in bulk at quarterly or semiannual settlement. The skeleton of the settlement batch:
[Interest settlement batch flow]
1. Target extraction: products/accounts in scope (status, tax type included)
2. Daily accumulation: sum of daily balances over the period (reconstructible from journal)
3. Pre-tax interest: accumulation x daily rate (annual/365), reflecting rate segments
4. Tax calculation: withholding per tax classification
5. Payment processing: create after-tax interest credit transactions (interest type)
6. Posting: settle interest expense / accrued interest payable / tax withheld
7. Verification: total reconciliation (sum per account = posting totals), outlier checks vs. prior periods
8. Reports: settlement detail, withholding aggregation
The concept of accruing unpaid interest is also worth knowing. Accounting is accrual-based, so interest accrued daily is posted as accrued interest payable (a liability) even before payment. Each month-end the accumulated accrual is posted, and at actual payment the accrual is offset. The interest engine must support both "calculation for payment" and "calculation for accrual," and the two formulas must agree or settlement reconciliation breaks.
Verification — How to Trust an Interest Engine
Fixed Cases and Golden Files
Pin down cases from the terms documents, product brochures, and historical actual payments as golden files. Run full regression on every engine change.
Property-Based Testing — A Perfect Match for Interest Engines
Interest calculation has crisp mathematical properties, making property-based testing a natural fit.
from hypothesis import given, strategies as st
from decimal import Decimal
from datetime import date, timedelta
amounts = st.integers(min_value=1, max_value=10_000_000_000)
days = st.integers(min_value=0, max_value=3650)
rates = st.decimals(min_value="0.0001", max_value="0.20", places=4)
@given(p=amounts, d=days, r=rates)
def test_interest_is_monotonic_in_days(p, d, r):
"""More days on deposit never means less interest."""
start = date(2026, 1, 1)
i1 = simple_interest(Decimal(p), r, start, start + timedelta(days=d))
i2 = simple_interest(Decimal(p), r, start, start + timedelta(days=d + 1))
assert i2 >= i1
@given(p=amounts, d=days, r=rates)
def test_interest_never_negative(p, d, r):
start = date(2026, 1, 1)
i = simple_interest(Decimal(p), r, start, start + timedelta(days=d))
assert i >= 0
@given(p1=amounts, p2=amounts, d=days, r=rates)
def test_splitting_principal_loses_at_most_rounding(p1, p2, d, r):
"""Splitting the principal loses at most truncation error vs. computing whole."""
start = date(2026, 1, 1)
end = start + timedelta(days=d)
whole = simple_interest(Decimal(p1 + p2), r, start, end)
split = (simple_interest(Decimal(p1), r, start, end)
+ simple_interest(Decimal(p2), r, start, end))
assert 0 <= whole - split <= 1 # within one won of truncation
As the last test shows, split calculation and whole calculation are not exactly equal because of truncation policy. Nail down "exactly how much error is tolerated" as a property, and you can mechanically guarantee the consistency of partial-withdrawal and re-deposit logic. On the Java side, jqwik implements the same pattern.
The Total Verification Batch
In production, put a safety net in place: at every settlement, recalculate all (or sampled) accounts with an independent implementation — ideally a verification calculator built by a different team — and reconcile totals. The trick is to implement the verifier separately from the formula documents so engine and verifier do not share the same bug.
Rounding and Truncation — The Politics of One Won
Policy First, Code Later
Rounding is not a mathematics problem; it is a terms-and-regulations problem. What the system must decide:
| Decision Item | Example Options |
|---|---|
| Final interest fraction handling | Truncate sub-won / round / round up |
| When to apply | Per segment / per installment / once on the grand total |
| Tax fraction handling | Separate rules such as truncating below ten won |
| Intermediate precision | Keep 10 decimal places, etc. |
| Rate display precision | Two or four decimal places |
The same formula yields different results if the truncation point differs, and inconsistent handling that disadvantages the customer (one screen truncates, one batch rounds) is a staple of complaints and supervisory findings.
Reaffirming the Floating-Point Ban
// absolutely forbidden
double interest = 1000000 * 0.03 * 73 / 365.0; // binary error built in
// table stakes
BigDecimal interest = new BigDecimal("1000000")
.multiply(new BigDecimal("0.03"))
.multiply(new BigDecimal("73"))
.divide(new BigDecimal("365"), 10, RoundingMode.HALF_UP)
.setScale(0, RoundingMode.DOWN);
Even with BigDecimal there are traps. Use the double constructor like new BigDecimal(0.03) and a contaminated value has already entered. Always use the String constructor or valueOf, and always pass scale and RoundingMode to divide (an ArithmeticException on a non-terminating decimal is actually a kind failure).
Failure Scenario Casebook — Where It Blows Up
Anonymized, generalized patterns that recur in the field.
- Day-count off-by-one: the closure screen excludes the payment date while the settlement batch includes it; the same account shows interest differing by one day between screen and batch. Complaints over mismatch between quoted and paid amounts.
- Leap year ignored: maturity-date derivation for a February 29 account differs across modules (2/28 vs. 3/1); maturity notices and actual maturity processing misalign by a day, and post-maturity rate segments cascade out of line.
- Retroactive rate fix by UPDATE: a wrongly registered rate is overwritten, making it impossible to reproduce calculations for accounts already paid at that rate. A massive back-calculation project from raw transaction logs ensues for audit response.
- Stale preferential-rate data: card-spend aggregation lands at D+2 but the maturity-day judgment batch evaluates with D+1 data, dropping the bonus. Bulk corrective payments and apology notices to qualifying customers.
- A double sneaks in: a new microservice computes projected interest with double for display. Intermittent one-won differences vs. the finalized ledger amount surface as "the app and my passbook disagree" complaints.
- Per-installment vs. total truncation: an installment-savings refactor changes the truncation point, producing differences of a few won vs. the old version. Without parallel verification it would have been paid as-is.
The common lesson is singular: any change to interest logic ships only after a parallel old-vs-new calculation produces a full-account difference report.
Checklist
A review checklist for interest engine design.
- Does the day-count convention (ACT/365 etc.) match the product terms, and is it configurable per product
- Is the start-date-inclusive / payment-date-exclusive rule identical across all modules
- Are leap years, February 29 openings, and holiday maturity dates defined and tested
- Is every monetary calculation BigDecimal/Decimal based, with static checks against double contamination
- Is the truncation/rounding policy (target, timing, direction) documented and consistent with code
- Is rate history immutable (append-only) with past calculations reproducible
- Are preferential-rate evaluation timing, evidence data, and audit logs designed in
- Are tax calculations and postings separated by tax classification
- Do the accrual formula and the payment formula agree
- Are golden-file regression, property-based tests, and an independent verification calculator in place
- Is there a procedure to produce old-vs-new difference reports for any interest logic change
Closing Thoughts
An interest calculation engine is "a large system handling small numbers." The formula itself is middle-school math, but multiply in day-count conventions, truncation policies, rate histories, taxes, and preferential conditions, and you get combinatorial explosion — and in every combination the result must be right down to the last won. To restate the essentials: the terms are the spec, floating point is banned, history is immutable, verification is independent. Keep these four and most interest incidents are preventable.
References
- ISDA - day count convention definitions (2006 ISDA Definitions materials): https://www.isda.org
- ICMA - bond market conventions: https://www.icmagroup.org
- Java BigDecimal official documentation: https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/math/BigDecimal.html
- Python decimal module official documentation: https://docs.python.org/3/library/decimal.html
- IEEE 754 floating-point standard overview: https://standards.ieee.org/ieee/754/6210/
- Hypothesis (Python property-based testing): https://hypothesis.readthedocs.io
- jqwik (Java property-based testing): https://jqwik.net
- National Tax Service of Korea (interest income withholding): https://www.nts.go.kr
- Korea Federation of Banks (rate disclosure): https://www.kfb.or.kr
- Bank of Korea base rate: https://www.bok.or.kr/portal/main/contents.do?menuNo=200643