跳到正文
DEV Community· Damin Mutti·· 3 小時前AI 評分47

如何用 Decimal 拆分貸款還款本金與利息並生成會計分錄

Splitting loan payments into principal and interest with Decimal, and generating the journal entries

AI 導讀

示例以 Decimal 按未償還貸款餘額計算每月利息,將餘款列作本金,並為每筆還款生成借貸平衡的會計分錄;24,000.00、年利率 6%、分 24 個月償還的例子,月供為 1,063.69。ZeraBooks 由 AI 判斷銀行交易的類別,再以確定性程式碼計算利息,並用貸款結單核對拆分結果。

正文

To split a loan payment into principal and interest in code, compute each month's interest as the outstanding balance times the annual rate divided by 12, rounded to the cent, and treat the rest of the payment as principal. Then emit a balanced journal entry per payment and check the running balance against the lender's statement. ZeraBooks' ledger does the same thing in its own code, never in the AI.

I'm on the ZeraBooks team. Loan payments are a nice small example of a rule we hold to everywhere: the AI decides what a bank line is ("this is a payment on the equipment loan"), and deterministic code does the math. Here's that code, simplified, in Python.

Payment amount

For a fixed-rate amortizing loan, the level payment is the standard annuity formula:

from decimal import Decimal, ROUND_HALF_UP, getcontext

getcontext().prec = 28
CENT = Decimal("0.01")

def level_payment(principal: Decimal, annual_rate: Decimal, months: int) -> Decimal:
    r = annual_rate / 12
    if r == 0:
        return (principal / months).quantize(CENT, ROUND_HALF_UP)
    p = principal * r / (1 - (1 + r) ** -months)
    return p.quantize(CENT, ROUND_HALF_UP)

pay = level_payment(Decimal("24000"), Decimal("0.06"), 24)
assert pay == Decimal("1063.69")

Fictional loan: 24,000.00 at 6% fixed over 24 months. Decimal with an explicit rounding mode matters. Floats can drift a cent somewhere over 24 periods, and a one-cent drift means your loan balance never matches the lender's.

The schedule

from dataclasses import dataclass

@dataclass
class Period:
    n: int
    payment: Decimal
    interest: Decimal
    principal: Decimal
    balance: Decimal

def schedule(principal, annual_rate, months, payment=None):
    payment = payment or level_payment(principal, annual_rate, months)
    bal, out = principal, []
    for n in range(1, months + 1):
        interest = (bal * annual_rate / 12).quantize(CENT, ROUND_HALF_UP)
        prin = payment - interest
        if n == months:              # last payment absorbs rounding
            prin = bal
            payment = prin + interest
        bal -= prin
        out.append(Period(n, payment, interest, prin, bal))
    return out

for p in schedule(Decimal("24000"), Decimal("0.06"), 24)[:3]:
    print(p.n, p.interest, p.principal, p.balance)

Output:

1 120.00 943.69 23056.31
2 115.28 948.41 22107.90
3 110.54 953.15 21154.75

Interest on period 1 is 24,000.00 × 0.06 / 12 = 120.00. Three payments total 3,191.07: 345.82 interest and 2,845.25 principal, and 24,000.00 − 2,845.25 = 21,154.75.

The last-period adjustment is there because rounding each month's interest leaves a few cents unaccounted for by the end. Lenders do the same thing; the final payment is usually slightly different from the rest.

Journal entries

Each payment becomes one entry with a debit to the loan, a debit to interest expense, and a credit to the bank. Optional fee lines get their own account:

def payment_entry(period: Period, date: str, loan_acct: str,
                  bank_acct: str, fee: Decimal = Decimal("0")):
    lines = [
        {"account": loan_acct, "debit": period.principal, "credit": Decimal("0")},
        {"account": "Interest Expense", "debit": period.interest, "credit": Decimal("0")},
    ]
    if fee:
        lines.append({"account": "Bank and Loan Fees", "debit": fee, "credit": Decimal("0")})
    lines.append({"account": bank_acct, "debit": Decimal("0"),
                  "credit": period.payment + fee})
    dr = sum(l["debit"] for l in lines)
    cr = sum(l["credit"] for l in lines)
    assert dr == cr, f"unbalanced entry: {dr} vs {cr}"
    return {"date": date, "lines": lines}

The assertion is cheap and catches every bug where someone adds a fee to the bank line but not to the debits. The credit to the bank must equal the withdrawal on the bank statement, same amount and same date, so the bank reconciliation is unaffected.

When the lender's numbers win

The formula above assumes interest is charged monthly on the balance. Many lenders compute it daily, so a payment made late carries a bit more interest. If the lender's statement prints "applied to interest" and "applied to principal", use those numbers and let your schedule be the fallback:

def reconcile_to_lender(book_balance: Decimal, lender_balance: Decimal):
    diff = book_balance - lender_balance
    if diff:
        raise ValueError(f"loan off by {diff}: a payment or split is wrong")

Run that after every payment. It's the loan equivalent of a bank reconciliation, and it's the check people skip.

How ZeraBooks does it

In ZeraBooks, a loan payment on the bank statement is booked to the loan account. At month end, Autopilot asks for the rate if it doesn't know it (it offers 3.75% for an SBA EIDL), then moves that month's interest from the loan to Interest Expense: balance owed × rate ÷ 12. It never splits interest from a guessed rate, and each entry appears in Done by AI with Undo. Loan statements uploaded alongside bank statements are used as evidence for the split. At year end, it flags any loan that got six or more payments with no interest recorded.

Pricing: $79/month billed annually or $149/month billed monthly, 7-day trial. Nothing connects to a lender or bank; statements are uploaded.

The accounting side, with the entries for funding, an origination fee and a late fee, is in our guide on how to record loan payments.

Disclosure: I work on ZeraBooks.

來源:DEV Community · dev.to