Division 7A loan review
+----------------------------------------------------------------------+
| Division 7A loan review |
+----------------------------------------------------------------------+
| ITAA 1936 Div 7A: s 109N terms, s 109E repayments |
+----------------------------------+-----------------------------------+
| DR what it gives you | CR what it needs |
+----------------------------------+-----------------------------------+
| s 109N(2) benchmark rate | loan register CSV |
| s 109N(1) verdict, limb by limb | the year of income to review |
| s 109E minimum yearly repayment | operator-asserted statutory facts |
| shortfall and exposure flag | a reviewed rate for that year |
| statutory trace to re-perform | a human to decide the rest |
+----------------------------------+-----------------------------------+
Distribution div7a-loan-review, import package div7aloan, command div7a-loan-review.
Experimental review aid. Not a Division 7A determination.
v1 reviews the s 109N terms of a private-company loan and the s 109E minimum yearly repayment for an operator-supplied amalgamated loan. It does not form amalgamated loans, does not classify payments, does not do unpaid present entitlements, and does not apply s 109RB.
The australian-accounting repository contains the maintained source. The
div7a-loan-review distribution, command and div7aloan import package remain
compatibility identifiers.
A rising benchmark rate raises the minimum yearly repayment on existing complying loans, not just on new ones. s 109E(6) uses the current year's benchmark rate, not the rate written into the agreement. A 7-year loan signed in 2021-22 at 4.52% is measured for 2026-27 against 8.77 per cent, and the repayment that was right when it was written is no longer enough. Nothing in the process tells you. A loan that has met its repayments for 4 years can fall short in the fifth on unchanged terms.
No install, just the rates and the reasoning: https://duguid.com.au/rates/div7a-benchmark-rate/
Citation: CITATION.cff; release:
v0.1.1.
What it does
Three questions, one job.
rate- the s 109N(2) benchmark interest rate for a year of income, with provenance: RBA table, series, month, and the date this repository last reviewed the figure.gate- whether a loan meets every limb of s 109N(1), on facts the operator asserts. Written agreement, terms in place before the lodgment day, interest rate at or above the benchmark, term within the s 109N(3) maximum.myr- the s 109E(5) minimum yearly repayment for a later year of income, the payments applied against it, and any shortfall.
review runs the gate and the repayment together over a register.
What it refuses
These are features. The engine returns UNKNOWN or REFUSED with a reason
rather than a number it cannot stand behind.
| Refused | Why |
|---|---|
| Forming the amalgamated loan from constituent loans | s 109E(3). v1 takes the unpaid balance at the end of the previous year from the operator. A list of component loans gets UNKNOWN. |
| A minimum yearly repayment for the year the loan was made | s 109E(1)(a) reaches a loan made 'in an earlier year of income'; s 109P puts an amalgamated loan outside s 109D in the year it is made. |
| A repayment figure for a loan that is not on s 109N terms | A constituent loan is one that would be a s 109D dividend apart from s 109N (s 109E(3)(b)), so a loan that fails s 109N is not one. Absent another Subdivision D exclusion, s 109D(1) treats it as a dividend in the year it was made (s 109D(1AA)). A schedule does not save it. |
| Whether a payment is a genuine repayment | s 109R turns on what a reasonable person would conclude about intention. The operator asserts the amount applied. Bank credits are not a substitute. |
| Computing the lodgment day | s 109D(6) is the earlier of the return's due date and the date of lodgement. That needs a lodgment-program date this engine does not hold. |
| s 109C payments | This engine is loans, not payments. |
| s 109F forgiven debts | Out of scope. |
| s 109T, s 109W, s 109C(3A) interposed entities | Out of scope. |
| s 109XA unpaid present entitlements, sub-trusts, PCG 2017/13, TD 2022/11 | Out of scope. |
| s 109RB Commissioner's discretion, s 109Q hardship, s 109RD extensions | Out of scope, and each can change the answer. |
| s 109Y distributable surplus | Not modelled. The shortfall is not the dividend; see below. |
| Loans made before 4 December 1997 | s 109D(5). SKIPPED, including the 1997-98 year that straddles the date. |
| Public companies | Outside scope. The operator must establish private-company status before using the gate, or mark the row with out_of_scope_reason; the engine does not classify lenders. |
| 'This looks like a dividend, call it s 109C or s 109D for me' | Characterisation is not arithmetic. |
| Interest accrued but not paid, offset accounts, set-off, journal-only repayments | Not payments this engine will count for you. |
| Any rate year not in the frozen table and not in a reviewed override | Fail closed. No extrapolation, no runtime scraping. |
An UNKNOWN is never softened into a best-effort guess, and never coerced to
false. A limb that has not been established is a different finding from a
limb that has failed, and only one of those is a breach.
The shortfall is not the dividend
Where the repayment falls short the engine reports an experimental deemed-dividend exposure equal to the shortfall. Read it as a review aid.
s 109E(2) makes the dividend the shortfall subject to s 109Y, which caps Division 7A dividends at the company's distributable surplus, and s 109E(1)(d) removes the dividend entirely where s 109Q applies. Neither is modelled here, and neither is s 109RB. This repository does not write "the ATO will assess $X", and neither should anything built on it.
Excel workbook
No Python? workbooks/div7a-loan-review.xlsx
runs the same review in ordinary worksheet formulas: paste the register, pick
the year of income, read each s 109N(1) limb, the gate verdict, the s 109E(6)
repayment and the shortfall per loan. REFUSED and UNKNOWN stay REFUSED and
UNKNOWN. It is macro-free, needs desktop Excel for Microsoft 365 or Excel
2024, and is held to this engine's answer by tests/test_workbook.py. See
workbooks/README.md.
Install
pip install div7a-loan-review
Python 3.10 or later. No runtime dependencies. To run the examples from a source checkout:
git clone https://github.com/ryanduguid/australian-accounting.git
cd australian-accounting/packages/div7a-loan-review
pip install .
Use
div7a-loan-review rate --year 2026-27
div7a-loan-review gate --input examples/sample_loans_myr_met.csv
div7a-loan-review myr --input examples/sample_loans_myr_met.csv --year 2026-27
div7a-loan-review review --input examples/sample_loans_mixed.csv --year 2026-27
Add --format json for machine-readable output, --trace on review to
print the statutory trace for every row, and --rates-override FILE to supply
a reviewed benchmark rate for a year outside the frozen table.
Which year the gate reads its benchmark from
gate without --year anchors each row to its own year_loan_made.
gate --year 2026-27 uses the nominated year's benchmark instead.
The interest-floor interpretation is unresolved. ATO guidance
requires the benchmark for each year of the loan. The model compares one
supplied rate with one selected benchmark. Its COMPLYING label does not
establish that the agreement satisfies the annual requirement. Review the
agreement and relevant years before relying on it. The source review
preserves the earlier interpretation and records this conflict.
review always anchors the gate to year_loan_made and works the repayment
for --year.
Exit codes
| Code | Meaning |
|---|---|
| 0 | Nothing exposed, nothing undecided |
| 2 | A shortfall, an undecided row, or a loan not on s 109N terms |
| 1 | The input, the rate table or the override could not be read |
The register CSV
One amalgamated loan per row. The run is refused if a required column is
absent: reading a missing column as blank would turn every absent fact into
unknown and produce a file of UNKNOWN verdicts that looks like a
considered review rather than a mis-shaped file.
Booleans are true, false or unknown. A blank cell is unknown. Amounts
and rates are decimal strings. Rates are fractions, so 8.77% is
0.0877, not 8.77. Years of income are written 2026-27.
Required by gate, myr and review:
| Column | What it asserts |
|---|---|
loan_id |
Your reference for the loan |
year_loan_made |
The year of income the loan was made in |
written_agreement |
s 109N(1)(a): the agreement is in writing |
terms_in_place_before_lodgment_day |
s 109N(1) chapeau, with s 109D(6). You assert this; the engine does not compute the lodgment day |
maximum_term_years |
The term of the loan under the written agreement, in years. It is tested against the maximum term worked out under s 109N(3). The column name follows the published API surface, not the Act's use of 'maximum term' for the statutory cap |
secured_by_registered_mortgage_over_real_property |
s 109N(3)(a)(i) |
security_coverage_at_first_made |
s 109N(3)(a)(ii): the property's market value less liabilities secured over it in priority to the loan, as a ratio to the loan, at the time the loan is first made. 1.10 is the 110% the paragraph requires |
interest_rate_for_years_after_year_loan_made |
s 109N(1)(b), as a decimal fraction |
Also required by myr and review:
| Column | What it asserts |
|---|---|
amalgamated_loan_unpaid_at_end_of_previous_year |
The balance the s 109E(6) formula works on |
remaining_term_years |
s 109E(6): the longest constituent-loan term, less the years from the end of the loan year to the end of the year before the one being reviewed. Rounded up by the engine if it is not whole |
payments_applied_during_the_year |
The amount you are treating as applied. The engine does not apply s 109R. If you cannot assert it, leave it unknown |
Optional:
| Column | Effect |
|---|---|
borrower_reference |
Printed with the row |
out_of_scope_reason |
Any non-empty value marks the row SKIPPED, unreviewed, with your reason |
year_of_income_being_tested |
Nominates the benchmark floor year for that row's s 109N(1)(b) limb. --year on gate overrides it |
Two samples ship with the repository:
examples/sample_loans_myr_met.csv- 3 complying loans that run to a verdict with no confirmation flags.examples/sample_loans_mixed.csv-UNKNOWNrows, a loan whose rate is below the benchmark, a 25-year loan with 109% cover, a nil remaining term, and 2 skipped rows.
Both are fabricated. Every identifier is synthetic.
Benchmark rates
s 109N(2) sets the benchmark interest rate as the RBA Indicator Lending Rates - Bank variable housing loans figure last published before the start of the year of income. A year of income starts on 1 July. The RBA publishes statistical table F5 in arrears, in the first week of the following month, so the last figure published before 1 July is the May figure.
The June figure is published in early July, after the year has already begun, and is the usual wrong rate. For 2025-26, May 2025 was 8.37% and June 2025 was 8.27%. 8.37 is the benchmark rate.
| Year of income | Rate | RBA F5 FILRHLBVS figure |
|---|---|---|
| 2026-27 | 8.77% | May 2026 |
| 2025-26 | 8.37% | May 2025 |
| 2024-25 | 8.77% | May 2024 |
| 2023-24 | 8.27% | May 2023 |
| 2022-23 | 4.77% | May 2022 |
| 2021-22 | 4.52% | May 2021 |
| 2020-21 | 4.52% | May 2020 |
| 2019-20 | 5.37% | May 2019 |
The table lives in
div7aloan/data/benchmark_rates.csv
with its own reviewed_until and reviewed_on header, last reviewed
28 August 2026. Nothing in this repository reads the network, in tests or at
runtime.
Every row also names the primary source it was read from, in primary_url,
retrieved_on and snapshot_sha256: the RBA F5 historical workbook, the date
it was downloaded and the digest of that download. verify_at stays beside
them as a convenience link for a human, not as the source of the figure. The
read that produced those columns is in
docs/primary-source-review-2026-09-20.md,
which checked all 8 reviewed years against the RBA workbook and changed no
rate.
A year outside that coverage is UNKNOWN. To review one, supply a reviewed
override:
{
"verified_until": "2027-28",
"citation": "RBA table F5 series FILRHLBVS, May 2027 figure, read 2027-07-02",
"rates": [
{ "year_of_income": "2027-28", "rate": "0.0850", "rba_month": "2027-05" }
]
}
The file is refused without both verified_until and a non-empty citation,
and refused if it lists a year past its own verified_until. The point of an
override is that a human went and read the figure; a file that does not say
who checked what, and how far, is not a review.
Which table produced the number
Every JSON result carries a manifest, naming the rate tables the answer was
actually computed from and the SHA-256 of what was read:
"manifest": {
"rate_table_uris": [
{ "uri": "div7aloan/data/benchmark_rates.csv", "sha256": "84264b28..." }
]
}
The digest is emitted by the lookup that read the file, not attached by hand,
and it is taken over the decoded text so a CRLF checkout and an LF checkout of
the same reviewed table agree. Two outputs with the same digest were computed
from the same table; an output whose digest has moved was not, whatever its
provenance fields still say. A reviewed override is listed after the frozen
table as override:<file name>: the name and digest identify it without
putting the operator's directory layout into the result.
An UNKNOWN rate still names the table it was looked for in, because the
absence of a row in that table is the answer. An empty list means no rate
table was consulted at all.
Reason codes
REFUSED and UNKNOWN results carry reason_codes beside reasons: one
stable token per reason, in the same order.
"reasons": ["remaining_term_years was not established."],
"reason_codes": ["REMAINING_TERM_UNKNOWN"]
The prose is written for a person and changes with the wording of the Act or the engine. The codes are the half a caller branches on. There are three prefixes, one per question:
| Prefix | Means |
|---|---|
REFUSED_* |
the s 109E question is outside this engine; no further fact helps |
GATE_* |
a s 109N(1) limb failed (_FAIL) or could not be decided (_UNKNOWN) |
other *_UNKNOWN |
a fact the operator can still establish was not established |
A gate result carries one GATE_* code per limb that did not pass, so a
caller can tell a loan that fails the interest limb from one whose term was
never supplied without reading the prose.
The vocabulary is ReasonCode in div7aloan/verdicts.py; a code is not
removed without a version bump, and a reason built without one fails at
construction rather than reaching a caller with nothing to branch on.
tests/test_reason_codes.py holds the other half of that contract: every
published code is reachable from some scenario, so the vocabulary cannot
promise a token no engine emits.
Arithmetic
decimal.Decimal throughout, built from strings. No float, no numpy, no
pandas. Intermediate arithmetic in the s 109E(6) formula runs at 60
significant digits in a local decimal context, so a result never depends on
the ambient context a caller happens to have set, and is quantised once at the
end.
Money is quantised to cents with ROUND_HALF_UP. The Act prescribes no
rounding for the s 109E(6) amount, so this is the engine's documented choice
rather than a statutory rule, and it is not the ATO calculator's rounding
adopted silently. Every result says so, in its statutory trace and in the JSON
rounding field.
JSON amounts are quoted decimal strings, never JSON numbers. Dates are ISO 8601. Verdicts are enums. The emitter refuses to write a float, and the test suite fails if one ever appears.
Library API
The surface the MCP adapter will import. Stable; the modules behind it are not.
from decimal import Decimal
from div7aloan import (
GateFacts, MyrFacts, benchmark_rate, complying_loan_gate,
minimum_yearly_repayment, parse_year, review_register,
)
year = parse_year("2026-27")
rate = benchmark_rate(year)
# rate.verdict, rate.rate (Decimal), rate.rba_month, rate.reason
gate = complying_loan_gate(GateFacts(
written_agreement=True,
terms_in_place_before_lodgment_day=True,
maximum_term_years=Decimal("7"),
secured_by_registered_mortgage_over_real_property=False,
interest_rate_for_years_after_year_loan_made=Decimal("0.0827"),
year_loan_made=parse_year("2023-24"),
))
# gate.verdict, gate.limbs, gate.reasons, gate.caveats
result = minimum_yearly_repayment(MyrFacts(
year_of_income=year,
amalgamated_loan_unpaid_at_end_of_previous_year=Decimal("100000.00"),
remaining_term_years=Decimal("5"),
payments_applied_during_the_year=Decimal("25556.00"),
gate_result=gate,
year_loan_made=parse_year("2023-24"),
))
# result.verdict, result.myr_required, result.shortfall,
# result.experimental_deemed_dividend_exposure, result.statutory_trace
report = review_register(rows, year)
# report.summary, report.lines, report.total_exposure
Every result carries a verdict, amounts as Decimal, provenance, and a
refusal or unknown reason where one applies.
Evaluation pack
evaluation/div7a_myr/ holds fabricated fixtures,
pinned expectations, and 3 minimum yearly repayments worked by hand from
the statutory formula with the arithmetic shown. A reviewer who knows s 109N
and s 109E can re-perform every one on paper without running Python.
It also records where this repository's reading of s 109N(1)(b) differs from common practice, and why the Act was followed.
Tests
python -m pytest -q
Every frozen rate year, every verdict, every refusal, the 2025-26 May-vs-June trap, the formula against exact rational arithmetic, and a check that no amount is ever emitted as a JSON number.
GATES.md is the completion ledger: one gate per acceptance criterion, each naming the command that decides it and carrying the recorded evidence of its last run.
Provenance
Written against the
Income Tax Assessment Act 1936
as compiled on the Federal Register of Legislation, C1936A00027, in force
1 July 2026, Part III Division 7A. Sections 109D, 109E, 109N, 109P and 109R
were read in full from the compilation, and the s 109E(6) formula from the
formula image in that text. It is not a port of any existing Division 7A
calculator.
Where the compiled Act and a secondary source disagree, this repository follows the Act, cites the section, and records the disagreement.
The full source trail is in docs/primary-source-review-2026-08-31.md: which sections were read, how the s 109E(6) formula was recovered from the compilation's own image, the reviewed position on s 109N(1)(b), and what this engine deliberately does not establish.
Related
- DISCLAIMER.md - read it.
- SECURITY.md
- Benchmark rate explainer: https://duguid.com.au/rates/div7a-benchmark-rate/
- Australian tax AI agents: https://duguid.com.au/tools/australian-tax-ai-agents/
From an AI coding agent, the same engine runs through aus-accounting-mcp.
The adapter and engine now share this monorepo but remain separately versioned
Python distributions.
Author and licence
Ryan Duguid. Written independently, in his own time, on his own equipment. Provisional member of Chartered Accountants Australia and New Zealand; that membership is not an endorsement of this software by CA ANZ.
MIT. See LICENSE.
Metadata
Release files for div7a-loan-review 0.1.4
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| div7a_loan_review-0.1.4.tar.gz | 149.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| div7a_loan_review-0.1.4-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 194.3 kB
Release files / div7a_loan_review-0.1.4.tar.gz
| Download URL | div7a_loan_review-0.1.4.tar.gz |
|---|---|
| Size | 149.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
91d8a58aa6de87ee95c820651928733ff5e8bcd028c10bfd1251f8a9a83ec19c
|
|
BLAKE2b-256 checksum How to use checksums |
914bded24ac6e3de5f3468b7f48c8c4286a7e51d82ffa996681b5f693016309e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 20, 2026.
Transparency logRelease files / div7a_loan_review-0.1.4-py3-none-any.whl
| Download URL | div7a_loan_review-0.1.4-py3-none-any.whl |
|---|---|
| Size | 44.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
ceaed6b6d9433b1d52598699c545a92d29a3648d04fa442bfde199c8c0a30949
|
|
BLAKE2b-256 checksum How to use checksums |
b3435decc5c9c750a1cf0d0580ea9f29a262f597bd9b23834b5d8c3364cf3f87
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 20, 2026.
Transparency log