ローン返済シミュレーターに仕訳CSV出力・営業日判定・据え置き期間を実装した全記録
Excelの返済計算データを参考に作ったローンシミュレーターを、実務で使えるレベルまで拡張した。MFクラウド会計へのインポート用CSV出力、祝日を考慮した営業日判定、据え置き期間対応、丸め誤差の最終回調整など、1日で一気に積み上げた作業の記録。
やったこと一覧
今回対応した項目は以下の通り。
- 元利均等・元金均等の比較表示(初期実装)
- 入力欄のカンマ自動表示
- 計算ロジックのユーティリティ分離とVitestテスト
- 仕訳CSV出力(MFクラウド会計対応 + シンプル形式)
- 営業日判定(@holiday-jp/holiday_jp による祝日スキップ)
- MFプレビューの2カラムレイアウト
- 勘定科目設定のヘッダー2段化
- 借入先と取引銀行(入金先)の分離
- バインディング問題の修正(プログラム的更新 vs ユーザー編集の区別)
- 元金返済据え置き期間
- 丸め誤差の最終回調整ロジック
- OGP/SEOメタ設定
- MF仕訳タイプの空欄化修正
以下、それぞれの実装内容を書いていく。
1. 元利均等・元金均等の比較表示
Excelで作っていた返済シミュレーションをブラウザに移植するところから始めた。元利均等返済と元金均等返済の両方を計算し、タブで切り替えて表示する構成にした。
利息差額を画面下部にカードで表示して、どちらの返済方式が有利かひと目でわかるようにしている。
<div v-if="interestDiff !== 0" class="card diff-card">
<p class="diff-text">
<strong>利息差額:</strong>
元金均等の方が {{ formatCurrency(Math.abs(interestDiff)) }}
{{ interestDiff > 0 ? '少ない' : '多い' }}
</p>
</div>
interestDiff は「元利均等の利息合計 - 元金均等の利息合計」で計算している。利率が正なら通常は正の値になり「元金均等の方が少ない」と表示される。利率0%では差額がゼロになるので v-if で非表示にした。
2. 入力欄のカンマ自動表示
借入金額やボーナス返済額の入力欄で、カンマ区切り表示を実装した。
type="number" だとカンマを表示できないため、type="text" + inputmode="numeric" に変更し、focus/blurイベントで表示を切り替える方式にした。
const formatNumber = (v) => v ? new Intl.NumberFormat('ja-JP').format(v) : '0'
const principalDisplay = computed(() => formatNumber(principal.value))
const onCurrencyFocus = (e, currentValue) => {
e.target.value = currentValue || ''
e.target.select()
}
const onCurrencyBlur = (e, setter) => {
const num = Number(e.target.value.replace(/[^0-9]/g, '')) || 0
setter(num)
}
- フォーカス時: 生の数値を表示して全選択(値が0のときは空欄)
- フォーカスが外れた時:
Intl.NumberFormatでカンマ区切り表示
入力中にリアルタイムでカンマを挿入する方式も検討したが、カーソル位置の制御が面倒になるのでfocus/blur方式を採用した。使い勝手も十分。
3. 計算ロジックの分離とVitestテスト
Vueコンポーネントの <script setup> に直書きしていた計算ロジックを app/utils/loan-calculator.ts に抽出した。
抽出した関数
| 関数 | 役割 |
|---|---|
getPaymentDate | 返済月の日付文字列を生成 |
isBonusMonth | ボーナス月かどうかを判定 |
calcEqualPaymentSchedule | 元利均等返済スケジュール計算 |
calcEqualPrincipalSchedule | 元金均等返済スケジュール計算 |
makeSummary | スケジュールから合計値を算出 |
コンポーネント側はパラメータを渡して結果を受け取るだけになった。
const loanParamsV2 = computed(() => ({
principal: principal.value,
annualRate: annualRate.value,
totalPayments: totalPayments.value,
startDate: startDate.value,
bonusAmount: bonusAmount.value,
bonusMonths: bonusMonths.value,
borrowDate: borrowDate.value,
lender: lender.value,
businessDayAdjustment: businessDayAdjustment.value,
gracePeriod: gracePeriod.value,
}))
const equalPaymentSchedule = computed(() => calcEqualPaymentScheduleV2(loanParamsV2.value))
const equalPrincipalSchedule = computed(() => calcEqualPrincipalScheduleV2(loanParamsV2.value))
テスト構成
Vitestで合計40件以上のテストを書いた。V1(基本版)とV2(営業日調整・据え置き対応版)の両方をカバーしている。
| カテゴリ | 件数 | 検証内容 |
|---|---|---|
| 日付ヘルパー | 3 | 年月生成、年またぎ |
| ボーナス判定 | 2 | 該当/非該当 |
| 元利均等返済V1 | 8 | 最終残高ゼロ、回数一致、元金合計、残高単調減少、利率0%など |
| 元金均等返済V1 | 6 | 最終残高ゼロ、利息単調減少など |
| 比較 | 1 | 元金均等の方が総利息が少ない |
| ボーナス返済 | 4 | 残高ゼロ、回数減少、利息減少 |
| エッジケース | 5 | 金額0、回数0、回数1、少額、高金利 |
| 元利均等V2据え置き | 9 | 据え置き中の利息のみ返済、残高不変、据え置き後の元金返済開始 |
| 元金均等V2据え置き | 7 | 同上パターン |
丸め誤差の許容
Math.round で各フィールドを個別に丸めるため、返済額 != 元金分 + 利息分 となるケースがある。テストでは許容誤差を設定した。
it('各行の返済額 ≈ 元金分 + 利息分(丸め誤差±1円)', () => {
const schedule = calcEqualPaymentSchedule(baseParams)
schedule.forEach(row => {
const diff = Math.abs(row.payment - (row.principalPart + row.interestPart))
expect(diff).toBeLessThanOrEqual(1)
})
})
4. 仕訳CSV出力(MFクラウド会計対応)
返済スケジュールから仕訳データを生成し、CSV出力する機能を追加した。
仕訳エントリの構造
仕訳生成は journal-entry.ts に分離した。入金時と返済時の2パターンを生成する。
export const makeReceiptEntry = (
borrowDate: string,
principal: number,
lender: string,
accounts: AccountConfig,
): JournalEntry => ({
date: borrowDate,
description: `${lender} 借入金入金`,
lines: [
{ side: 'debit', account: accounts.receiptDebitAccount,
subAccount: accounts.receiptDebitSub, amount: principal },
{ side: 'credit', account: accounts.receiptCreditAccount,
subAccount: accounts.receiptCreditSub, amount: principal },
],
})
据え置き期間中は元金返済がないため、利息のみの仕訳を生成する。
const isGrace = row.principalPart === 0 && row.bonus === 0
const description = isGrace
? `${lender} 利息支払 第${row.period}回(据え置き)`
: `${lender} 借入金返済 第${row.period}回`
CSV形式
2つの形式に対応した。
MF形式: MFクラウド会計のインポートフォーマットに準拠。20列のヘッダーを持つ。
const MF_HEADERS = [
'取引No', '取引日', '借方勘定科目', '借方補助科目', '借方税区分',
'借方金額', '借方税額', '貸方勘定科目', '貸方補助科目', '貸方税区分',
'貸方金額', '貸方税額', '摘要', 'タグ', 'MF仕訳タイプ',
'決算整理仕訳', '作成日時', '最終更新日時', 'メモ', '借方部門',
]
シンプル形式: 日付、借方/貸方の科目・補助・金額、摘要の8列構成。汎用的に使える。
CSVダウンロードはBOM付きUTF-8で出力。Excelで開いたときの文字化けを防ぐため。
export const downloadJournalCsv = (
entries: JournalEntry[],
format: JournalCsvFormat,
filename: string,
): void => {
const csv = format === 'mf' ? toMfCsv(entries) : toSimpleCsv(entries)
const bom = '\uFEFF'
const blob = new Blob([bom + csv], { type: 'text/csv;charset=utf-8' })
// ... Blob URLでダウンロード
}
MF仕訳タイプの空欄化
MFクラウド会計のCSVインポートで「MF仕訳タイプ」列に値が入っていると、意図しない仕訳タイプで登録されることがあった。この列を空文字にすることで、MF側のデフォルト動作に任せるようにした。
5. 営業日判定(祝日スキップ)
返済日が土日祝日にあたる場合、前営業日または翌営業日に調整する機能を実装した。
@holiday-jp/holiday_jp の利用
日本の祝日判定には @holiday-jp/holiday_jp パッケージを使った。内部に祝日データを持っているため、APIコールなしで判定できる。
import holidayJp from '@holiday-jp/holiday_jp'
export type BusinessDayAdjustment = 'previous' | 'next' | 'none'
const isWeekend = (date: Date): boolean => {
const day = date.getDay()
return day === 0 || day === 6
}
export const isBankHoliday = (date: Date): boolean => {
const m = date.getMonth() + 1
const d = date.getDate()
return (m === 12 && d === 31) || (m === 1 && (d === 2 || d === 3))
}
export const isNonBusinessDay = (date: Date): boolean =>
isWeekend(date) || holidayJp.isHoliday(date) || isBankHoliday(date)
非営業日の判定は3つの条件を組み合わせている。
- 土日(
getDay()が 0 または 6) - 祝日(
holiday_jp.isHoliday()) - 銀行休業日(12/31、1/2、1/3)
営業日調整
export const adjustToBusinessDay = (date: Date, adjustment: BusinessDayAdjustment): Date => {
if (adjustment === 'none') return date
const result = new Date(date)
const direction = adjustment === 'previous' ? -1 : 1
while (isNonBusinessDay(result)) {
result.setDate(result.getDate() + direction)
}
return result
}
UIでは「前営業日」「翌営業日」「調整なし」の3択をラジオボタンで選べるようにした。
月末日の繰り越し
返済日が31日の場合、2月は28日(or 29日)しかないため、その月の最終日に丸める処理も入れている。
export const getPaymentDateFull = (
startDateStr: string,
periodIndex: number,
adjustment: BusinessDayAdjustment,
): string => {
const start = new Date(startDateStr)
const targetDay = start.getDate()
const result = new Date(start.getFullYear(), start.getMonth() + periodIndex, 1)
const lastDay = new Date(result.getFullYear(), result.getMonth() + 1, 0).getDate()
result.setDate(Math.min(targetDay, lastDay))
const adjusted = adjustToBusinessDay(result, adjustment)
// ...
}
6. MFプレビューの2カラムレイアウト
勘定科目の設定画面を2カラムにした。左側が「仕訳CSVインポート」用の勘定科目設定(編集可)、右側が「クラウド会計(MF)」側のプレビュー表示。
.account-columns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
MFプレビュー側は、CSVの勘定科目とMFクラウド会計の勘定科目が異なるケースを想定している。たとえばCSVでは「仮受金」で取り込み、MF側では「普通預金 / 仮受金」のように記帳されるといった対応関係を視覚的に確認できる。
export const getMfPreview = (bankName: string): MfPreview => ({
receiptDebit: { account: '普通預金', sub: bankName },
receiptCredit: { account: '仮受金', sub: bankName },
repayDebit: { account: '仮払金', sub: bankName },
repayCredit: { account: '普通預金', sub: bankName },
})
7. 勘定科目設定のヘッダー2段化
勘定科目テーブルのヘッダーを2段にした。1段目に「借方」「貸方」、2段目に「勘定科目」「補助科目」を表示する。
<thead>
<tr>
<th class="account-th-group" colspan="2">借方</th>
<th class="account-th-group" colspan="2">貸方</th>
</tr>
<tr>
<th>勘定科目</th>
<th>補助科目</th>
<th>勘定科目</th>
<th>補助科目</th>
</tr>
</thead>
1段目のグループヘッダーにはグレー背景をつけて視覚的に区別できるようにした。
8. 借入先と取引銀行(入金先)の分離
当初は「借入先」だけを入力する設計だったが、実務では借入先(例: 政策金融公庫)と入金先の銀行(例: みずほ銀行)が異なるケースが多い。補助科目にそれぞれの名前を反映する必要があるため、入力欄を分離した。
const lender = ref('政策金融公庫')
const bankName = ref('みずほ銀行')
勘定科目のデフォルト値は、借入先と取引銀行名から自動生成される。
export const defaultAccountConfig = (lender: string, bankName: string): AccountConfig => ({
receiptDebitAccount: '仮受金',
receiptDebitSub: bankName, // 取引銀行名
receiptCreditAccount: '長期借入金',
receiptCreditSub: lender, // 借入先名
repayPrincipalDebitAccount: '長期借入金',
repayPrincipalDebitSub: lender,
repayInterestDebitAccount: '支払利息',
repayInterestDebitSub: lender,
repayCreditAccount: '仮払金',
repayCreditSub: bankName,
})
9. バインディング問題の修正
借入先や取引銀行の変更に連動して勘定科目のデフォルト値を更新する仕組みを入れたところ、ユーザーが手動で編集した後も上書きされてしまう問題が発生した。
問題
- ユーザーが勘定科目を手動で変更
- 借入先の文字を1文字変更
- 勘定科目がデフォルト値に戻ってしまう
解決策: プログラム的更新とユーザー編集の区別
accountsEdited フラグと accountsSyncing フラグの2つを使って、更新の発生源を判別する。
const accounts = reactive(defaultAccountConfig('政策金融公庫', 'みずほ銀行'))
const accountsEdited = ref(false)
let accountsSyncing = false
// 借入先・取引銀行変更時にデフォルト更新(ユーザー未編集の場合のみ)
watch([lender, bankName], ([newLender, newBank]) => {
if (!accountsEdited.value) {
accountsSyncing = true
Object.assign(accounts, defaultAccountConfig(newLender, newBank))
nextTick(() => { accountsSyncing = false })
}
})
// 勘定科目が編集されたかを追跡(プログラム的更新は無視)
watch(accounts, () => {
if (!accountsSyncing) {
accountsEdited.value = true
}
}, { deep: true })
ポイントは nextTick のタイミング。Object.assign で値を設定すると、Vue のリアクティブシステムが watch(accounts, ...) を発火させる。accountsSyncing を true にしておくことで、プログラム的な更新時には accountsEdited が true にならない。nextTick で同期が完了した後にフラグを戻す。
MFプレビューにも同じパターンを適用している。
10. 元金返済据え置き期間
融資契約で、最初の数ヶ月は利息のみ支払い、元金返済を据え置くケースがある。gracePeriod パラメータで据え置き月数を指定できるようにした。
export interface LoanParamsV2 extends LoanParams {
borrowDate: string
lender: string
businessDayAdjustment: BusinessDayAdjustment
gracePeriod: number // 据え置き月数(0 = なし)
}
計算ロジック内では、据え置き期間中は利息のみを計算し、元金返済回数を据え置き分だけ差し引いて毎月返済額を算出する。
const repaymentCount = n - gracePeriod
if (repaymentCount <= 0) return []
const monthlyPayment = r > 0
? P * r * Math.pow(1 + r, repaymentCount) / (Math.pow(1 + r, repaymentCount) - 1)
: P / repaymentCount
// ...
for (let i = 0; i < n; i++) {
const isGrace = i < gracePeriod
if (isGrace) {
schedule.push({
period: i + 1,
date: getPaymentDateFull(startDate, i, businessDayAdjustment),
payment: Math.round(interestPart),
principalPart: 0,
interestPart: Math.round(interestPart),
bonus: 0,
balance: Math.round(balance),
})
continue
}
// 通常の元金返済...
}
テストでは据え置き期間中の残高不変、利息のみ返済、据え置き後の元金返済開始を検証している。
it('据え置き期間中はbalanceが変化しない', () => {
const params = { ...baseParamsV2, gracePeriod: 12 }
const schedule = calcEqualPaymentScheduleV2(params)
const graceRows = schedule.slice(0, 12)
graceRows.forEach(row => {
expect(row.balance).toBe(baseParamsV2.principal)
})
})
11. 丸め誤差の最終回調整ロジック
V1では最終回に principalPart = balance として残高を強制的にゼロにしていたが、Math.round による累積誤差で元金合計が借入金額と一致しないことがあった。
V2では roundedPrincipalSum を累積し、最終回で「借入金額 - これまでの元金合計」を元金分として計上する方式に変更した。
let roundedPrincipalSum = 0
for (let i = 0; i < n; i++) {
// ...
const isLast = i === n - 1 || balance - principalPart - bonusPay <= 0
if (isLast) {
const roundedPrincipal = P - roundedPrincipalSum
const roundedInterest = Math.round(interestPart)
schedule.push({
period: i + 1,
payment: roundedPrincipal + roundedInterest,
principalPart: roundedPrincipal,
interestPart: roundedInterest,
balance: 0,
// ...
})
break
}
const roundedPrincipal = Math.round(principalPart + bonusPay)
roundedPrincipalSum += roundedPrincipal
// ...
}
この方式で「元金合計 = 借入金額」が必ず成り立つ。テストでも完全一致を検証している。
it('元金合計が借入金額と完全一致する(丸め調整)', () => {
const schedule = calcEqualPaymentScheduleV2(baseParamsV2)
const totalPrincipal = schedule.reduce((sum, r) => sum + r.principalPart, 0)
expect(totalPrincipal).toBe(baseParamsV2.principal)
})
12. OGP/SEOメタ設定
useSeoMeta と useHead でOGP・Twitterカード・SEOメタデータを設定した。
const pageTitle = 'ローン返済シミュレーター - log.eurekapu.com'
const pageDescription = '元金均等・元利均等返済の比較シミュレーション。ボーナス返済対応、据え置き期間対応、仕訳生成、CSV出力可能。'
useSeoMeta({
title: pageTitle,
description: pageDescription,
ogType: 'website',
ogUrl: 'https://log.eurekapu.com/loan-simulator',
ogTitle: pageTitle,
ogDescription: pageDescription,
twitterCard: 'summary_large_image',
})
OGP画像はサーバサイドでのみ生成する。useAsyncData の中で import.meta.server を確認し、クライアントサイドでは null を返す。
ファイル構成
最終的なファイル構成はこうなった。
apps/web/
├── app/
│ ├── pages/
│ │ └── loan-simulator.vue # UIコンポーネント
│ └── utils/
│ ├── loan-calculator.ts # V1/V2の返済計算ロジック
│ ├── business-day.ts # 営業日判定・調整
│ ├── journal-entry.ts # 仕訳エントリ生成
│ └── journal-csv.ts # CSV出力(MF形式/シンプル形式)
└── tests/
└── loan-calculator.test.ts # テスト40件以上
振り返り
1日でこれだけの機能を積み上げられたのは、Claude Codeとの協業のおかげではある。ただし、バインディング問題のように「動くけど挙動がおかしい」系のバグはAIに任せきりにせず自分で気づく必要があった。
丸め誤差の最終回調整は地味だが、会計データとして使う以上「元金合計 = 借入金額」は譲れない制約。V1では「ほぼ一致」だった検証を、V2では「完全一致」に引き上げられたのは良かった。
MFクラウド会計のCSVインポートは列数が多く、空欄にすべき列(MF仕訳タイプなど)を間違えると意図しない登録になる。実際にインポートして確認しながら修正したので、この辺りの知見は記録しておく価値がある。