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.