Skip to content
Published on

預金利息計算エンジンの設計 — 日割り計算から優遇金利まで

Authors

はじめに

「利息計算なんて掛け算1回だろう」と思っていたなら、コアバンキングで最も危険な思い込みをしています。利息計算は預金システムの中で、バグが最も頻繁に、最も高くつく形で爆発する領域です。1ウォンの誤差でも顧客からの苦情や監督当局への報告につながりかねず、数百万口座に一括適用される決算バッチでの誤りは、即座に大規模インシデントになります。

本記事では、預金利息計算エンジンをゼロから設計するという視点で、日割り計算の罠、単利・複利の実装、商品別の算式、優遇金利ルールエンジン、税金処理、金利履歴の管理、そして丸め方針までを見ていきます。コードはJavaとPythonで提示します。本記事は技術解説であり税務・投資の助言ではありません。具体的な税率・規定は必ず最新の法令と公的機関の公式資料でご確認ください。

利息計算の罠 — 掛け算1回で済まない理由

日割りと月割り、そして日数慣行

年3.0パーセントの金利で100万ウォンを73日間預けたら利息はいくらでしょうか。答えは「どの日数慣行(day count convention)を使うかによって異なる」です。

ACT/365 (実日数/365):  1,000,000 x 0.03 x 73/365 = 6,000.00ウォン
ACT/360 (実日数/360):  1,000,000 x 0.03 x 73/360 = 6,083.33ウォン
30/360  (月30日換算)  :  日数を30日単位に換算して計算

韓国ウォンの預金は通常「実経過日数 / 365」方式を使います(閏年でも365で割る慣行が一般的ですが、機関・商品によってはACT/ACTを使う場合もあります)。一方、国際短期金融市場ではACT/360慣行が一般的で、債券には30/360系があります。どの慣行が「正しい」かではなく、商品約款に明記された慣行を正確に実装することが要件です。

慣行分子(日数)分母主な用途
ACT/365 Fixed実経過日数常に365ウォン建て預金・貸出の多数
ACT/360実経過日数常に360マネーマーケット、一部外貨
ACT/ACT実経過日数当該年の実日数一部債券・国際取引
30/360月を30日に換算360債券クーポン計算

閏年という伏兵

閏年は1年が366日です。ACT/365 Fixed慣行なら、366日間の預入で「年利よりわずかに多い」利息が付きます(366/365)。ACT/ACTなら閏年区間と平年区間を分けて計算しなければなりません。2月29日に新規契約した定期預金の1年満期日はいつか(2月28日? 3月1日?)といった満期日の算定規則も、約款とシステムが一致していなければなりません。こうした境界ケースは必ずテストケースとして固定しておくべきです。

起算日と支払日 — 初日と最終日

経過日数を数えるときは「預入日は含み、支払日は除く」(initial date inclusive, final date exclusive)が一般的な慣行です。6月1日に入れて6月2日に出せば1日分の利息です。この規則がシステムのどこか1か所(オンライン解約計算、日積数バッチ、未払利息の計上)でもずれると、1日分の利息誤差が発生します。些細に見えますが、数百万口座では些細ではありません。

単利と複利 — 数式と実装

数式

[単利 Simple Interest]
  利息 = 元本 x 年利率 x (経過日数 / 365)
  I = P x r x (d / 365)

[複利 Compound Interest - 年m回複利、n年]
  元利金 = 元本 x (1 + r/m)^(m x n)
  S = P x (1 + r/m)^(m*n)

[月複利の定期預金の例 - 年3.0%、12か月、月複利]
  S = P x (1 + 0.03/12)^12
    = P x 1.030416...  (実効利回り約3.0416%)

Java実装 — BigDecimalが基本動作

金額計算にdoubleを使った瞬間に失格です。二進浮動小数点は0.1すら正確に表現できません。

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;   // 中間計算の精度
    private static final int KRW_SCALE = 0;     // ウォンは小数点なし

    /** 単利: 元本 x 年利率 x 経過日数/365、ウォン未満切捨て */
    public BigDecimal simpleInterest(BigDecimal principal,
                                     BigDecimal annualRate,   // 0.03 = 年3%
                                     LocalDate from,          // 預入日(含む)
                                     LocalDate to) {          // 支払日(除く)
        long days = ChronoUnit.DAYS.between(from, to);
        if (days < 0) {
            throw new IllegalArgumentException("支払日が預入日より前です");
        }
        return principal
                .multiply(annualRate)
                .multiply(new BigDecimal(days))
                .divide(DAYS_IN_YEAR, CALC_SCALE, RoundingMode.HALF_UP)
                .setScale(KRW_SCALE, RoundingMode.DOWN);  // ウォン未満切捨て
    }

    /** 月複利: 毎月利息を元本へ組み入れて積み上げる */
    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); // 回次ごとに切捨て
            balance = balance.add(interest);
        }
        return balance;
    }
}

重要なディテールが2つ隠れています。

  1. 中間計算は十分な精度(scale 10など)で行い、最終金額の確定時点でのみ切捨て・丸めを行います。各段階で切り捨てると誤差が累積します。
  2. 複利を数学の累乗公式で一発計算した値と、回次ごとに切り捨てながら累積した値は異なります。約款に「毎月利息を元本へ組み入れる」とあれば、後者(ループ方式)が正解です。公式ではなく約款が仕様です。

Python実装 — decimalモジュール

from decimal import Decimal, ROUND_DOWN, ROUND_HALF_UP, getcontext
from datetime import date

getcontext().prec = 28  # 十分なグローバル精度

DAYS_IN_YEAR = Decimal("365")

def simple_interest(principal: Decimal, annual_rate: Decimal,
                    start: date, end: date) -> Decimal:
    """単利利息。預入日含む、支払日除く。ウォン未満切捨て。"""
    days = (end - start).days
    if days < 0:
        raise ValueError("支払日が預入日より前です")
    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:
    """定額積立の満期利息(単利): 回次ごとの預入月数に比例。"""
    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)

floatがなぜダメなのかは1行で証明できます。

>>> 0.1 + 0.2
0.30000000000000004
>>> Decimal("0.1") + Decimal("0.2")
Decimal('0.3')

商品別の利息算式

定期預金 — 満期、中途解約、一部引出

[満期支払]
  利息 = 元本 x 約定金利 x 預入日数/365

[中途解約]
  利息 = 元本 x 中途解約金利(預入期間の区分別) x 実預入日数/365
  - 中途解約金利は通常「基準金利 x 比率」または区分別の固定金利テーブル
  - 優遇金利は通常、中途解約時には適用除外

[一部引出(一部解約)]
  - 引出分: 引出時点まで中途解約算式で精算
  - 残存分: 元の約定条件を維持
  - 引出回数制限、最低維持残高など商品条件の検証が必要

中途解約金利の区分テーブルの例は次のとおりです(商品ごとに異なります)。

預入期間適用金利
1か月未満年0.1パーセント
1か月以上3か月未満年0.5パーセント
3か月以上6か月未満基準金利の30パーセント
6か月以上満期前基準金利の50パーセント

定期積金 — 回次の世界

積立は「各回次の払込金が満期まで残った期間分の利息を受け取る」というのが本質です。

[定額積立の満期利息(単利、約定払込日基準)]
  k回次払込金の利息 = 月掛金 x 年利率 x (満期までの残月数)/12
  総利息 = 月掛金 x 年利率 x (n + (n-1) + ... + 1)/12
        = 月掛金 x 年利率 x n(n+1)/(2 x 12)

  例: 月10万ウォン、年3%、12か月
  総利息 = 100,000 x 0.03 x 12x13/(2x12) = 100,000 x 0.03 x 6.5 = 19,500ウォン

罠は実際の払込日です。約定日より遅く払えば(未納後の払込)利息が減り、早く払えば(先納)増えるのが日割り計算の自然な帰結ですが、商品約款によっては「先納・未納日数を相殺して満期日を調整する」方式(先納日数/未納日数の精算)を使うこともあります。この精算ロジックは積立ドメインで最もバグが出やすい場所なので、約款の文言と計算結果を回次単位で突き合わせるテストが必須です。

優遇金利の条件エンジン

要件

近年の預金商品には「給与振込で0.2パーセントポイント、カード利用30万ウォン以上で0.3パーセントポイント、マーケティング同意で0.1パーセントポイント、最大0.5パーセントポイント優遇」といった条件が付きます。これをif文でハードコードすると、商品が増えるたびにデプロイが必要になり、条件判定のタイミング(新規時? 満期時? 毎日?)も商品ごとに違うため、すぐにスパゲッティ化します。そこで優遇金利はデータ(ルール)として宣言し、エンジンが評価する構造が定石です。

ルール定義テーブル

CREATE TABLE preferential_rate_rule (
    product_code     VARCHAR(10)  NOT NULL,
    rule_id          VARCHAR(8)   NOT NULL,
    rule_name        VARCHAR(100) NOT NULL,   -- 例: 給与振込優遇
    condition_type   VARCHAR(20)  NOT NULL,   -- SALARY_TRANSFER, CARD_SPEND ...
    condition_param  VARCHAR(200),            -- JSONパラメータ(閾値など)
    bonus_rate       NUMERIC(7,4) NOT NULL,   -- 優遇幅(年利 %p)
    eval_timing      CHAR(1)      NOT NULL,   -- O:新規時 M:満期時 D:毎日
    valid_from       DATE         NOT NULL,
    valid_to         DATE         NOT NULL,
    CONSTRAINT pk_prr PRIMARY KEY (product_code, rule_id, valid_from)
);

評価エンジンのスケッチ

public interface PreferentialCondition {
    /** 条件充足の可否を評価する。評価時点のコンテキストを受け取る。 */
    boolean evaluate(EvaluationContext ctx);
}

public class SalaryTransferCondition implements PreferentialCondition {
    private final int requiredMonths;  // 直近N か月連続の給与振込

    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));  // 上限の適用
    }
}

設計ポイントは4つです。

  1. 判定タイミングの分離: 「満期時に一括判定」する商品と「毎日判定して日別適用金利に反映」する商品では、エンジンの呼び出し位置が異なります。ルールに評価タイミングをメタデータとして持たせる必要があります。
  2. 判定根拠の保存: 優遇金利の紛争は「条件を満たしたのになぜ優遇が外れたのか」という形でやってきます。どのルールがどのデータで充足/未充足と判定されたか、監査ログを必ず残します。
  3. ルールの有効期間: 商品改定で条件が変わると、既存加入者は加入時点のルール、新規加入者は新ルールを適用すべき場合があります。ルールテーブルに有効期間を持たせ、加入日基準で選択します。
  4. 外部データへの依存: カード利用実績や給与振込の有無は他システムのデータです。評価時点でデータが未集計だったらどうするか(保留? 再評価キュー?)を明示的に設計しなければなりません。

税金処理 — 源泉徴収の基本構造

利息を支払う際、銀行は利子所得税を源泉徴収します。概念構造は次のとおりです(税率などの具体的な数値は法改正で変わりうるため、必ず国税庁など公式資料での確認が必要です)。

税引前利息 = 利息計算エンジンの算出額
所得税     = 税引前利息 x 所得税率   (端数処理規則を適用)
地方所得税 = 所得税 x 地方所得税率   (端数処理規則を適用)
税引後利息 = 税引前利息 - 所得税 - 地方所得税

システム観点のポイントは次のとおりです。

  • 課税区分コード: 一般課税、非課税(法定非課税商品)、税優遇など、口座別の課税タイプがマスタに必要で、支払時点のタイプで計算します。
  • 端数規則: 税金計算では10ウォン未満切捨てのような端数処理規則が適用される場合があり、税引前利息に税率を掛けた後の端数処理の順序まで、約款・税法基準で固定する必要があります。
  • 分離記帳: 税引前利息、所得税、地方所得税、税引後利息をそれぞれ別仕訳で記録しなければ、源泉徴収申告の集計ができません。
  • 所得の帰属時期: 利子所得の帰属は「支払時点」基準が原則のため、未払利息(発生主義での計上)と源泉徴収(支払時点)の間のタイミング差を、会計と税務の両面で扱う必要があります。

金利変更履歴の管理

変動金利商品の利息は「期間ごとに異なる金利」を適用して区間合算します。金利履歴テーブルが核心です。

CREATE TABLE product_rate_history (
    product_code   VARCHAR(10)  NOT NULL,
    rate_type      CHAR(2)      NOT NULL,   -- 01:基準 02:中途解約 03:満期後
    effective_from DATE         NOT NULL,   -- 適用開始日(含む)
    effective_to   DATE         NOT NULL,   -- 適用終了日(除く)、9999-12-31許容
    annual_rate    NUMERIC(7,4) NOT NULL,
    approved_by    VARCHAR(20)  NOT NULL,   -- 決裁情報
    created_at     TIMESTAMP    NOT NULL,
    CONSTRAINT pk_pr PRIMARY KEY (product_code, rate_type, effective_from)
);

区間合算の計算は次のように表現されます。

総利息 = SUM( 元本(または日別残高) x 区間金利 x 区間日数/365 )
        for 各金利区間 in [預入日, 支払日)

例: 100万ウォン、4/1預入、7/1支払
    4/1 - 5/15 (44日): 年3.0%  ->  1,000,000 x 0.030 x 44/365 = 3,616ウォン
    5/16 - 7/1 (47日): 年3.5%  ->  1,000,000 x 0.035 x 47/365 = 4,506ウォン
    合計(ウォン未満切捨て方針により区間別/総額別に処理)        = 8,122ウォン

ここでも、切捨てを区間ごとに行うか総額で1回だけ行うかは約款事項です。そして履歴テーブル設計の不文律があります。金利履歴は絶対にUPDATEで上書きしません。誤って登録された金利も訂正履歴として残し、遡及再計算が必要なら再計算バッチと補正仕訳で処理します。過去の利息計算をいつでも再現できなければ、監査に対応できないからです。

利息支払バッチ — 決算の風景

普通預金類は四半期または半期決算で利息を一括支払するのが一般的です。決算バッチの骨格は次のとおりです。

 [利息決算バッチの流れ]
   1. 対象抽出: 決算対象の商品/口座 (状態、課税タイプを含む)
   2. 日積数集計: 決算期間の日別残高の合計 (ジャーナルから再構成可能であること)
   3. 税引前利息計算: 日積数 x 日歩(年利率/365)、区間金利を反映
   4. 税金計算: 課税タイプ別の源泉徴収額を算出
   5. 支払処理: 税引後利息の入金取引を生成 (利息取引タイプ)
   6. 仕訳記録: 支払利息 / 未払利息 / 預り金(税金) の整理
   7. 検証: 総額照合 (口座別合計 = 仕訳総額)、前年/前期比の異常値チェック
   8. レポート: 決算明細、源泉徴収の集計を出力

未払利息の計上という概念も知っておく必要があります。会計は発生主義なので、まだ支払っていなくても日次で発生した利息を未払利息(負債)として計上します。月末ごとに「発生利息の累計」を計上し、実際の支払時点で未払利息を相殺する構造です。利息エンジンは「支払用の計算」と「計上用の計算」の両方をサポートしなければならず、両者の算式が一致してこそ決算照合が合います。

検証 — 利息エンジンをどう信じるか

固定ケースとゴールデンファイル

約款の例示、商品説明書の計算例、過去の実支払履歴から抽出したケースをゴールデンファイルとして固定します。エンジンを修正するたびに全ケースの回帰検証を行います。

Property-basedテスト — 利息エンジンとの相性は抜群

利息計算は数学的性質が明確なので、property-based testingがよく合います。

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):
    """預入日数が増えれば利息は減らない。"""
    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):
    """元本を分割して計算した合計は、一括計算と切捨て誤差以内で一致する。"""
    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  # 切捨て1ウォン以内

最後のテストが示すように、切捨て方針のせいで分割計算と一括計算は厳密には一致しません。「誤差の許容範囲が正確にいくらか」を性質として釘付けにしておけば、一部引出・再預入ロジックの整合性を機械的に保証できます。Java界隈ではjqwikで同じパターンを実装できます。

総額検証バッチ

本番環境では、決算のたびに独立実装(できれば別チームが作った検証用計算機)で全口座または標本口座を再計算し、総額を照合するセーフティネットを設けます。エンジンと検証機が同じバグを共有しないよう、算式ドキュメントから別々に実装するのがコツです。

丸めと切捨て — 1ウォンの政治学

方針が先、コードは後

丸めは数学の問題ではなく、約款・規定の問題です。システムが決めるべきことは次のとおりです。

決定項目選択肢の例
最終利息の端数処理ウォン未満切捨て / 四捨五入 / 切上げ
処理タイミング区間別 / 回次別 / 最終総額で1回
税金の端数処理10ウォン未満切捨てなど別規則
中間計算の精度小数点以下10桁を維持など
金利の表示桁数小数点第2位/第4位

同じ算式でも切捨てのタイミングが違えば結果が変わり、顧客に不利な方向での一貫性のない処理(ある画面は切捨て、あるバッチは四捨五入)は、苦情と監督指摘の定番ネタです。

浮動小数点禁止の再確認

// 絶対禁止
double interest = 1000000 * 0.03 * 73 / 365.0;  // 二進誤差が内在

// 基本動作
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);

BigDecimalを使う場合にも罠があります。new BigDecimal(0.03)のようにdoubleコンストラクタを使うと、すでに汚染された値が入ってきます。必ず文字列コンストラクタまたはvalueOfを使い、divideにはscaleとRoundingModeを明示します(無限小数でArithmeticExceptionが出るのは、むしろ親切な失敗です)。

失敗事例シナリオ — どこで爆発するか

実際の現場で繰り返される事故パターンを匿名化・一般化したシナリオです。

  1. 日数計算のoff-by-one: 解約画面は支払日除外、決算バッチは支払日込みで実装され、同じ口座の利息が画面とバッチで1日分異なって算出。案内金額と実支払額の不一致で苦情が発生。
  2. 閏年の考慮漏れ: 2月29日新規口座の満期日算定がモジュールごとに異なり(2/28 vs 3/1)、満期案内と実際の満期処理が1日ずれる。満期後金利の適用区間も連鎖的に狂う。
  3. 金利履歴の遡及修正: 誤登録した金利をUPDATEで上書きし、すでにその金利で支払済みの口座の再現計算が不可能に。監査対応のため全取引ログからの逆算という大工事が発生。
  4. 優遇金利判定データの遅延: カード実績の集計がD+2なのに、満期日当日の判定バッチがD+1データで評価して優遇が未適用。充足顧客への一括補正支払いと謝罪案内。
  5. doubleの混入: 新しいマイクロサービスが照会用の予想利息をdoubleで計算。元帳確定額と1ウォンの差が間欠的に発生し、「アプリと通帳が違う」という苦情で発覚。
  6. 回次別切捨て vs 総額切捨て: 積立の満期利息をリファクタリングする際に切捨てタイミングが変わり、旧バージョン比で数ウォン単位の差。並行検証がなければそのまま支払われるところだった事例。

共通の教訓は1つです。利息ロジックの変更は、必ず新旧並行計算で全口座の差分レポートを出してからデプロイします。

チェックリスト

利息エンジンの設計・レビュー時の点検リストです。

  • 日数慣行(ACT/365など)が商品約款と一致しているか、商品ごとに設定可能か
  • 起算日含む/支払日除くの規則が全モジュールで同一か
  • 閏年、2月29日新規、満期日が休日の場合の規則が定義・テストされているか
  • すべての金額計算がBigDecimal/Decimalベースか、double混入の静的検査があるか
  • 切捨て/丸め方針(対象、タイミング、方向)が文書化されコードと一致しているか
  • 金利履歴が不変(append-only)で、過去の計算を再現できるか
  • 優遇金利の判定タイミング、根拠データ、監査ログが設計されているか
  • 課税タイプ別の税金計算と仕訳分離ができているか
  • 未払利息の計上算式と支払算式が一致しているか
  • ゴールデンファイル回帰 + property-basedテスト + 独立検証計算機が揃っているか
  • 利息ロジック変更時に新旧並行計算の差分レポート手順があるか

おわりに

利息計算エンジンは「小さな数字を扱う大きなシステム」です。算式自体は中学数学ですが、日数慣行・切捨て方針・金利履歴・税金・優遇条件が掛け合わさって組合せ爆発が起き、そのすべての組合せで1ウォンまで合わなければならないのがこのドメインの難しさです。要点を再度まとめます。約款が仕様である、浮動小数点は禁止である、履歴は不変である、検証は独立して行う。この4つを守るだけでも、利息事故の大半は予防できます。

参考資料