"""OFX import. The highest-leverage importer in the app: a broker that exports OFX needs no template and no column mapping, which makes it the most likely thing to work for somebody in a country nobody has written a broker format for. Two fixtures, deliberately: **OFX 1.x is SGML with unclosed tags** and 2.x is XML. Real brokers emit both, and a reader that only handles the tidy one fails on the older, more common exports. """ from __future__ import annotations import datetime as dt from decimal import Decimal import pytest from app import ofx # OFX 1.x: no closing tags on leaf elements. This is not malformed — it is what # the specification says, and what most brokers still produce. SGML = """OFXHEADER:100 DATA:OFXSGML VERSION:102 AUD 1001 20260302120000.000[+11:AEDT] Bought 100 ALPHA AU000000ALP1 ISIN 100 10.50 9.50 -1059.50 BUY 1002 20260610000000 AU000000ALP1 -40 12.25 9.50 SELL 1003 20260401000000 AU000000ALP1 DIV 42.00 AU000000ALP1 ISIN Alpha Australian Shares ALPHA.AX """ # OFX 2.x: well-formed XML. Same content, different syntax. XML = """ AUD 200120260305 AU000000BET1 508.005.00 AU000000BET1 BETAX """ # --------------------------------------------------------------------------- # # Both dialects # --------------------------------------------------------------------------- # def test_the_sgml_dialect_with_unclosed_tags_parses(): """The one that breaks XML parsers, and the one most brokers emit.""" result = ofx.parse_ofx(SGML) assert result.errors == [] assert [(c.ticker, c.type) for c in result.candidates] == [ ("ALPHA", "buy"), ("ALPHA", "sell") ] def test_the_xml_dialect_parses_too(): result = ofx.parse_ofx(XML) assert result.errors == [] assert [(c.ticker, c.type) for c in result.candidates] == [("BETAX", "buy")] # --------------------------------------------------------------------------- # # Reading a transaction # --------------------------------------------------------------------------- # def test_a_buy_carries_every_figure_the_app_needs(): buy = ofx.parse_ofx(SGML).candidates[0] assert buy.date == dt.date(2026, 3, 2) assert buy.quantity == Decimal("100") assert buy.unit_price == Decimal("10.50") assert buy.brokerage == Decimal("9.50") assert buy.currency == "AUD" def test_a_sell_s_negative_units_become_a_positive_quantity(): """OFX signs UNITS by direction. This app stores a magnitude with the direction in `type` — keeping the sign would make a negative holding.""" sell = ofx.parse_ofx(SGML).candidates[1] assert sell.type == "sell" assert sell.quantity == Decimal("40") def test_the_timezone_suffix_is_discarded_not_applied(): """"20260302120000.000[+11:AEDT]". A trade date is the date the broker says it is; re-interpreting it would move trades across financial years.""" assert ofx.parse_ofx(SGML).candidates[0].date == dt.date(2026, 3, 2) @pytest.mark.parametrize("raw,expected", [ ("20260302", dt.date(2026, 3, 2)), ("20260302120000", dt.date(2026, 3, 2)), ("20260302120000.000[+11:AEDT]", dt.date(2026, 3, 2)), ("2026", None), ("notadate", None), (None, None), ]) def test_the_date_shapes_ofx_actually_uses(raw, expected): assert ofx._date(raw) == expected def test_a_reinvestment_becomes_a_drp_not_a_buy(): """REINVEST is units in without money out. Recording it as a buy would inflate the amount invested and understate every return.""" text = SGML.replace("", "").replace("", "") kinds = {c.type for c in ofx.parse_ofx(text).candidates} assert "drp" in kinds # --------------------------------------------------------------------------- # # Securities # --------------------------------------------------------------------------- # def test_securities_are_resolved_from_the_security_list(): """Transactions reference a security by CUSIP or ISIN, never by ticker. Without the list every row is unidentifiable.""" assert ofx.security_tickers(SGML) == {"AU000000ALP1": "ALPHA"} def test_an_exchange_suffix_is_stripped_from_the_ticker(): """Some brokers write "ALPHA.AX"; the app stores the bare code with the exchange held separately.""" assert ofx.security_tickers(SGML)["AU000000ALP1"] == "ALPHA" def test_a_transaction_for_an_unlisted_security_is_reported_not_dropped(): """Silently losing a trade is the worst outcome — the import looks like it worked and the balance is wrong.""" text = SGML.replace("ALPHA.AX", "") result = ofx.parse_ofx(text) assert result.candidates == [] assert any("security list" in s for s in result.skipped) def test_a_transaction_missing_a_figure_says_which_one(): text = SGML.replace("10.50", "") result = ofx.parse_ofx(text) assert any("missing its price" in s for s in result.skipped) # --------------------------------------------------------------------------- # # What is not a trade # --------------------------------------------------------------------------- # def test_a_split_is_skipped_and_pointed_at_the_holding_page(): """There is a form for it now (#146), so the skip says where.""" result = ofx.parse_ofx( "" "20260302" "") assert result.skipped == ["2026-03-02: a share split — record it on the holding's page"] def test_income_is_skipped_by_name_rather_than_ignored(): """A dividend is real money that this importer cannot record as a trade. Naming it is the difference between "handled" and "lost".""" result = ofx.parse_ofx(SGML) assert any("income" in s for s in result.skipped) assert any("2026-04-01" in s for s in result.skipped) def test_a_cash_only_export_says_so_usefully(): """A bank OFX has no investment transactions. The message has to say that, or somebody re-exports the same file three times.""" result = ofx.parse_ofx("\n\n\n") assert result.candidates == [] assert any("cash-only" in e for e in result.errors) def test_a_file_that_is_not_ofx_is_refused_with_a_hint(): result = ofx.parse_ofx("Date,Ticker,Units\n2026-03-02,ALPHA,100\n") assert any("does not look like an OFX file" in e for e in result.errors) assert any("Money/Quicken" in e for e in result.errors) # --------------------------------------------------------------------------- # # Safety # --------------------------------------------------------------------------- # def test_an_external_entity_is_inert(): """This reads a file somebody uploaded. Handing that to an XML parser opens external-entity expansion — reading /etc/passwd into the document — which is live in Python's stdlib parsers unless deliberately disabled. A reader that understands only tags has no entity machinery to abuse. Do not "improve" the module by swapping in ElementTree. """ hostile = XML.replace( '', '\n' ']>', ).replace("BETAX", "&xxe;") result = ofx.parse_ofx(hostile) # The entity is never expanded: it stays the literal text it was. assert "root:" not in str(result.candidates) assert all("/etc/passwd" not in (c.ticker or "") for c in result.candidates) def test_a_deeply_nested_document_does_not_blow_up(): """The other XML-parser hazard: entity expansion as a denial of service. Nothing here recurses, so depth is just characters.""" result = ofx.parse_ofx("" + "" * 20000 + "") assert result.errors # no transactions, reported normally def test_an_empty_file_is_an_error_not_a_crash(): assert ofx.parse_ofx("").errors # --------------------------------------------------------------------------- # # Through the import flow # --------------------------------------------------------------------------- # def test_an_ofx_upload_previews_like_a_csv(client, session_factory): """OFX shares the whole preview-and-commit path — the only thing special about it is that there is no format to choose.""" import factories as fac from test_routes import bind_to_only_portfolio, make_login, session_csrf make_login(client, session_factory) with session_factory() as s: bind_to_only_portfolio(s) fac.make_instrument(s, "ALPHA") s.commit() resp = client.post( "/imports-exports/csv", files={"file": ("export.ofx", SGML.encode(), "application/x-ofx")}, data={"broker": "ofx", "_csrf": session_csrf(session_factory)}, headers={"accept": "text/html"}, ) assert resp.status_code == 200 assert "ALPHA" in resp.text assert "10.50" in resp.text def test_the_imports_page_offers_ofx_for_any_broker(client, session_factory): from test_routes import make_login make_login(client, session_factory) page = client.get("/imports-exports", headers={"accept": "text/html"}).text assert 'value="ofx"' in page assert "any broker" in page # --------------------------------------------------------------------------- # # Figures that are not figures # --------------------------------------------------------------------------- # @pytest.mark.parametrize("tag, was, value, message", [ ("UNITS", "100", "NaN", "Units: 'NaN' is not a number"), ("UNITPRICE", "10.50", "Infinity", "Price: 'Infinity' is not a number"), ("COMMISSION", "9.50", "NaN", "Brokerage: 'NaN' is not a number"), ("UNITS", "100", "1E+999999", "Units: '1E+999999' is too large"), ("UNITS", "100", "0", "Units: 0 has to be more than zero"), ]) def test_an_unreadable_figure_is_an_error_naming_the_field(tag, was, value, message): """It used to read as "missing" and be skipped, or (1E+999999) pass.""" broken = SGML.replace(f"<{tag}>{was}", f"<{tag}>{value}", 1) result = ofx.parse_ofx(broken) assert any(message in e for e in result.errors), result.errors assert all(c.quantity > 0 for c in result.candidates) def test_a_negative_units_figure_is_still_the_sign_of_a_sell_not_an_error(): """The shared check runs after the sign is taken off, so this stays valid.""" assert ofx.parse_ofx(SGML).errors == [] # --------------------------------------------------------------------------- # # What a mutation run found unchecked # --------------------------------------------------------------------------- # def test_a_trade_with_no_commission_tag_is_free_not_a_crash(): """COMMISSION is optional in the specification, and every transaction in the fixtures had one.""" text = SGML.replace("9.50\n-1059.50", "-1050.00", 1) buy = next(c for c in ofx.parse_ofx(text).candidates if c.type == "buy") assert buy.brokerage == Decimal(0) @pytest.mark.parametrize(("tag", "was", "missing"), [ ("DTTRADE", "20260302120000.000[+11:AEDT]", "date"), ("UNITS", "100", "units"), ("UNITPRICE", "10.50", "price"), ]) def test_each_figure_missing_alone_is_named(tag, was, missing): text = SGML.replace(f"<{tag}>{was}", f"<{tag}>", 1) result = ofx.parse_ofx(text) assert any(f"missing its {missing}" in s for s in result.skipped), result.skipped assert [c.type for c in result.candidates] == ["sell"] def test_the_files_own_currency_is_the_default_and_a_trades_own_wins(): usd = SGML.replace("AUD", "USD", 1) assert {c.currency for c in ofx.parse_ofx(usd).candidates} == {"USD"} own = SGML.replace("-40", "\nnzd\n1.1\n\n" "-40", 1) by_type = {c.type: c.currency for c in ofx.parse_ofx(own).candidates} assert by_type == {"buy": "AUD", "sell": "NZD"} def test_trades_come_back_in_date_order_whatever_the_file_order(): """Buys are read before sells, so a sale dated before a purchase came back after it.""" text = SGML.replace("20260610000000", "20260110000000", 1) assert [(c.date, c.type) for c in ofx.parse_ofx(text).candidates] == [ (dt.date(2026, 1, 10), "sell"), (dt.date(2026, 3, 2), "buy")] def test_a_trade_dated_only_by_settlement_takes_that_date(): text = SGML.replace("20260610000000", "20260612", 1) sell = next(c for c in ofx.parse_ofx(text).candidates if c.type == "sell") assert sell.date == dt.date(2026, 6, 12) def test_a_date_with_a_zone_and_no_time_is_read(): assert ofx._date("20260302[-5:EST]") == dt.date(2026, 3, 2) def test_a_lower_case_ticker_is_stored_upper_case(): assert ofx.security_tickers(SGML.replace("ALPHA.AX", "alpha.ax")) == { "AU000000ALP1": "ALPHA"} def test_an_unreadable_figure_names_the_holding_it_belongs_to(): result = ofx.parse_ofx(SGML.replace("100", "NaN", 1)) assert result.errors and result.errors[0].startswith("ALPHA: ")