Baike.dev
All toolsAI codingTrendingOpen sourceNewsSubmit
Log in
Back to tool/Back to issues
#3638·sure

Loans: interest is always a flat twelfth, so actual/365 loans cannot be reconciled

Author: jaysbeekayCreated Sep 19, 2026Updated Sep 19, 2026

What happens

Every loan period is charged one twelfth of the annual interest, whatever the period actually holds. Loan::Simulator#monthly_rate divides the annual rate by 12 and that figure is used for every month:

ruby
PERCENT = BigDecimal("100")
MONTHS_PER_YEAR = BigDecimal("12")

def monthly_rate(annual_percentage)
  (BigDecimal(annual_percentage.to_s) / PERCENT) / MONTHS_PER_YEAR
end

January and February are charged the same interest on the same balance, despite February being three days shorter.

Why that is a problem

Many lenders do not accrue that way. actual/365 — interest for the days the period actually held, over a 365-day year — is the standard basis for UK and Australian mortgages, and it is normally printed on the statement. A borrower on that basis cannot reconcile a single row of our schedule against their lender's.

On 300,000 at 6%:

period Sure today actual/365 difference
January (31 days) 1,500.00 1,528.77 +28.77
February (28 days) 1,500.00 1,380.82 −119.18
April (30 days) 1,500.00 1,479.45 −20.55
leap February (29 days) 1,500.00 1,430.14 −69.86
a full non-leap year 18,000.00 18,000.00 0.00

This is not a case of Sure computing interest wrongly. Over a plain year the monthly differences cancel exactly, which is why it has never shown up as a discrepancy in an annual total. The problem is narrower and more stubborn: a convention the loan is genuinely written under cannot be expressed at all, so the monthly figures are unreconcilable even though the yearly one agrees.

Over a 30-year loan the difference does not fully cancel. Amortising the same loan with the payment held constant, actual/365 costs between £479 and £1,116 more in total interest than a flat twelfth, depending on where the loan's periods fall against calendar years — leap days are the residue that never cancels.

What we would like to add

A day_count_convention on the loan, offering:

  • thirty_360 — a flat twelfth. 30/360 reduces to 1/12 exactly, so this is what Sure does today, to the digit.
  • actual/365 — days in the period over 365.
  • actual/actual — days in the period over that calendar year's own length, split at 1 January when a period straddles it.

thirty_360 as the default, and that choice matters more than the feature. Loan::Simulator is the single accrual point — Loan::AmortizationSchedule runs through it, and Loan::PayoffProjection will once #3474 lands — so this reaches every loan figure in the product. A default that moved would silently restate every existing schedule.

It is worth saying why the default is not actual/actual, since that sounds like the conservative choice. It is not: actual/actual charges 31/365 in January and 28/365 in February, and only reaches a flat twelfth when a whole calendar year is summed. Measured across 30-year lifetimes from sixteen different start dates, it lands anywhere from −£212 to +£241 against today's arithmetic, because a loan's periods do not align to calendar years and its final period is partial. Only 30/360 is identical row for row.

One design point worth agreeing before code review

The day count applies to what a period charges, not to what sizes the payment.

AmortizationMath.level_payment is an annuity formula over one constant periodic rate. If the day count reached the sizing, the rate would differ every month, and under :reamortize the payment would be rebuilt every period and stop being level. So sizing stays on the nominal monthly rate — which is also what the borrower's contracted payment is actually set from.

Sequencing

This touches Loan::Simulator, which #3474 also moves, so it should follow that PR rather than race it. We have it built and reviewed on our fork and will open the PR here once #3474 merges, re-running the row-for-row check against the post-#3474 engine first — the claim that thirty_360 is bit-identical has to be proved against the engine it will actually ship on, not the one it was written against.

Happy to split it differently if you would rather see the convention column and the engine change as separate PRs.

Source: we-promise/sure

View original on GitHubView discussion on GitHub