Skip to content

Recipe: add an export report

Every download on the Imports page is a function in app/exports.py returning (headers, rows). The CSV and workbook writers, the filename, and the UI entry all come from that.

Start with the test

Go to Test it and write it now, with the figures worked out by hand from a small fixture. A report tested after the fact asserts what the code computed; this is money going to an accountant. See Testing.

1. Write the function

def dividends_by_year(session: Session, fy: int | None = None, **_) -> Report:
    """One line on what question this answers for whom.

    Say if it is shaped for a particular purpose — "the figures an accountant
    needs for the dividend schedule" tells the next person what they may and
    may not change.
    """
    headers = ["Financial year", "Ticker", "Cash (AUD)", "Franking credits (AUD)"]
    fxbook = queries.FxBook(session)          # never convert at 1:1 by hand
    rows = []
    for dividend in session.scalars(select(Dividend)):
        rate = fxbook.of(dividend, dividend.instrument.currency)
        rows.append([
            fyreport.fy_label(...),
            dividend.instrument.ticker,
            _d(_aud(dividend.cash_amount, rate)),   # blank if unconvertible
            _d(dividend.franking_credits),
        ])
    rows.sort(key=lambda r: (r[0], r[1]))
    return headers, rows

**_ in the signature is not noise: every report is called with the same keyword arguments (fy, ticker) and ignores the ones it does not use.

2. Register it

Two places, both in exports.py:

  • REPORTS — the key maps to the function.
  • The label dictionary — what the checkbox says on the page.

3. Write its ABOUT text

The workbook ships an About sheet explaining every report. Add yours. This is where you say what the numbers mean and what they do not — the existing entries are the tone to match, and they are the app's most honest documentation of its own limits.

The rules that matter

Never fabricate a number to fill a cell. If a figure cannot be produced honestly, the cell is empty. A blank is a question; a wrong number gets totalled by a spreadsheet and lands in a tax return. Concretely:

  • Convert through queries.FxBook, which returns None for a currency with no stored rate rather than falling back to 1:1.
  • _d(None) renders as "". Use it.
  • If a total depends on a missing component, the total is blank too — do not quietly sum the parts you happen to have.

Put the unit in the header. Cost base (AUD), not Cost base. Someone will paste this into a sheet with columns from three sources.

Sort deterministically. Two runs over unchanged data must produce byte-identical files, or nobody can diff an export to see what changed.

Test it

@freeze_time(ref.TODAY)
def test_dividends_by_year_groups_and_totals(portfolio):
    headers, rows = exports.dividends_by_year(portfolio)

    assert column(headers, rows, "Ticker") == ["ALPHA", "BETAX"]
    assert cell(headers, rows[0], "Cash (AUD)") == Decimal("60.00")

tests/test_exports.py has column and cell helpers so assertions name the header rather than an index — a column inserted in the middle then breaks nothing.

Cover: the ordinary case against the reference fixture, an empty portfolio (a report that raises on no data is a report that breaks a fresh install), and a value that cannot be converted, asserting the cell is blank rather than wrong.