moon-ofx-importer

    Offline OFX bank transaction extraction and diagnostics

    ofx
    banking
    ledger
    finance
    Download zip
    Version
    0.3.0
    License
    MIT
    Last updated
    last month
    Downloads
    30

    No dependencies

    #moon-ofx-importer

    A pure MoonBit library that reads OFX bank statements offline and reports what it could not trust.

    parse_statement takes the text of an OFX 1.x SGML or OFX 2.x XML file — as exported by most retail banks — and returns the account it belongs to, the period it covers, the balances it declares, the transactions it holds and the investment records it carries, each with the fields a ledger needs. parse_document does the same for a file that holds several statements, one account at a time. Everything rejected or found suspicious comes back as a diagnostic that names the tag and the line, so an import can be reviewed instead of guessed at.

    Scope boundary. The parser never connects to a bank, never decrypts and never decompresses. reconcile checks whether a statement's own numbers add up; it cannot tell you whether the bank's numbers are right, and a statement with no diagnostics means "this text is well formed and complete enough to import", not "these amounts are correct". COMPRESSION other than NONE is an error precisely because the body cannot be read. Diagnostics are the input to a review, not a substitute for one.

    Release history is in CHANGELOG.md.

    #Use

    let report = @moon-ofx-importer.parse_statement(ofx_text)

    // Refuse to import rather than import something half-read.
    if !report.ok() {
    for diagnostic in report.render_diagnostics() {
    println(diagnostic)
    }
    }

    println(report.account_id + " in " + report.currency)

    for tx in report.transactions {
    // Exact decimal -> integer conversion: no floating point anywhere.
    let cents = match tx.amount_minor_units(2) {
    Some(units) => units
    None => continue
    }
    // The same instant regardless of the zone the bank wrote it in.
    let posted = match tx.utc_posted() {
    Some(stamp) => stamp
    None => continue
    }
    println(posted + " " + tx.fitid + " " + tx.direction().to_string() + " " + cents.to_string())
    }

    Reading a document that was written in a different zone, or comparing two spellings of the same amount:

    let date = @moon-ofx-importer.parse_posted_date("20260924120000[-5:EST]").unwrap()
    date.to_utc_string() // "20260924170000"
    date.to_iso_string() // "2026-09-24T17:00:00Z"

    @moon-ofx-importer.normalize_amount("+042.50") // Some("42.50")
    @moon-ofx-importer.minor_units_to_amount(-4250L, 2) // Some("-42.50")

    A file that holds several statements, balances to check, or an export to write:

    let document = @moon-ofx-importer.parse_document(ofx_text)

    for label in document.account_labels() {
    println("account " + label)
    }

    // Check the closing balance the document declares against the transactions.
    let opening = 100000L
    match document.statement(0).unwrap().reconcile(opening, 2) {
    Some(result) => println(result.to_string())
    None => println("cannot be checked at this scale")
    }

    // One CSV table for the whole file, every row labelled with its account.
    println(document.to_csv())

    #API

    FunctionPurpose
    parse_statement(text)Scan a document into its first Statement. Never fails as a whole; a transaction is dropped only when one of its own fields is missing or malformed.
    parse_document(text)Every statement in the file, each with its own diagnostics, plus file-level aggregation (account_labels, currencies, period, total_minor_units).
    amount_to_minor_units(amount, scale)Decimal string to an Int64 count of minor units, checking Int64 overflow at every step.
    minor_units_to_amount(minor, scale)The exact inverse, including Int64 min.
    normalize_amount(amount)One canonical spelling per value, for de-duplication and reconciliation.
    is_valid_amount(amount)The strict decimal check both directions use.
    minor_unit_scale(currency)How many fractional digits a currency's minor unit uses (ISO 4217), or None for a code it does not know.
    amount_in_currency(amount, currency)Conversion at the scale the currency implies, for a ledger import that should not have to guess.
    parse_posted_date(value)DTPOSTED/DTUSER to a PostedDate with date_string(), to_utc_string(), to_iso_string(), add_days(), compare() and weekday().
    reconcile(opening, closing, transactions, scale)Adds the transactions to an opening balance and compares with the closing balance, reporting the signed difference.
    checked_add / checked_sub / sum_amountsMoney arithmetic that reports overflow instead of wrapping silently.
    Statement::to_csv / Document::to_csvRFC 4180 tables of the transactions, with the account and currency leading each document row.
    Statement::render_report()A short human report: account, period, balances, record counts and every diagnostic.
    diagnostic_codes()Every code the library can emit, in the order the parser can produce them.

    Statement carries header, signon, bank_id, branch_id, account_id, account_type, currency, start_date, end_date, ledger_balance, avail_balance, transactions, investments and diagnostics, plus ok(), has_errors(), error_count(), warning_count(), diagnostics(), render_diagnostics(), reconcile() and render_table(). Transaction carries kind, posted, user_date, amount, fitid, check_number, ref_number, payee_id, sic, name and memo, plus direction(), amount_minor_units(scale), posted_date() and utc_posted(). Investment carries the security, units, price, costs and total of an investment record, plus total_minor_units(scale), units_at_scale(scale), proceeds_minor_units(scale), trade_time() and is_debit().

    #Diagnostics

    A diagnostic renders as severity:code[:location], where location is a tag and its 1-based line (DTPOSTED@12), the offending value, or empty for the document as a whole. Codes are stable and are never reused for a different meaning; diagnostic_codes() returns the complete list so a consumer can reject a code it does not know.

    Markup and entities

    ExampleMeaning
    error:unclosed-tag:@12a tag body is never closed by >; scanning stops, because every later offset would be a guess
    error:empty-tag:@4a tag with no name, such as <>
    error:unclosed-comment:@2a comment is never closed; scanning stops
    error:unclosed-cdata:@9a CDATA section is never closed
    warning:unknown-entity:&foo;@6a named reference the decoder does not know; the text is kept as written
    warning:malformed-entity:&@6a bare &, or a numeric reference that is empty, non-numeric, zero, a surrogate or above U+10FFFF

    Header

    ExampleMeaning
    warning:missing-headerthe document has an <OFX> aggregate but declares no header at all
    warning:unknown-header-line:MOONBEAM@2a prologue line or processing-instruction attribute this importer does not recognise
    warning:empty-header-value:VERSION@3a known key declared with no value
    warning:invalid-header-version:VERSION:1.0OFXHEADER is not 100/200/220, or VERSION is not three digits
    error:unsupported-compression:GZIPthe body is compressed and cannot be read

    Aggregates

    ExampleMeaning
    error:unexpected-close:BANKTRANLIST@9a closing tag for an aggregate that was never opened
    error:mismatched-aggregate:BANKTRANLIST@2a closing tag closes an aggregate that is not the innermost one
    error:nested-aggregate:STMTTRN@14a <STMTTRN> opens inside another one
    error:unclosed-aggregate:BANKTRANLIST@7an aggregate is still open at the end of the document
    warning:conflicting-value:ACCTID:acct-1!=acct-2@14a statement-level field is declared twice with different values; the first wins

    Sign-on

    ExampleMeaning
    warning:invalid-status-code:zero@6STATUS/CODE is not a number
    warning:invalid-server-date:DTSERVER@4DTSERVER is not a real calendar instant
    warning:signon-status-error:ERROR:-1: Bad loginthe server's own status says the response is an error

    Balances

    ExampleMeaning
    error:invalid-balance-amount:1,500.00@3LEDGERBAL/AVAILBAL has no BALAMT, or it is not a plain decimal amount; the balance is dropped
    error:invalid-balance-date:20260931@4the balance has no DTASOF, or it is not a real date; the balance is dropped

    Transactions

    ExampleMeaning
    error:missing-type:TRNTYPE@5no TRNTYPE in a record
    error:missing-fitid:FITID@5no FITID in a record, or none in an investment record
    error:invalid-posted:DTPOSTED@3DTPOSTED (or an investment DTTRADE) is not a real calendar date
    error:invalid-amount:TRNAMT@4TRNAMT (or an investment TOTAL) is not a plain decimal amount
    error:duplicate-fitid:abc-1a FITID that already appeared; the second record is dropped
    warning:invalid-user-date:DTUSER@4DTUSER is not a real calendar date
    warning:invalid-sic:SIC@7SIC is not four digits

    Whole documents and statements — reported only for a document that contains an <OFX> aggregate, or for one statement of a multi-statement file, and raised no error, so a fragment stays quiet.

    ExampleMeaning
    warning:missing-acctidtransactions were found, but no ACCTID
    warning:missing-curdeftransactions were found, but no CURDEF
    warning:no-transactionsthe statement parsed cleanly and holds nothing to import

    #Accepted input

    • OFX 1.x SGML without closing tags and OFX 2.x XML with them, in the same document if it comes to that.
    • Header fields from the KEY:VALUE prologue and from <?xml?>/<?OFX ...?> processing instructions, with single-quoted, double-quoted or unquoted values.
    • Tag names in any case, tags carrying attributes, self-closing tags, comments, DOCTYPE, processing instructions and CDATA.
    • DTPOSTED of the forms YYYYMMDD, YYYYMMDDHHMMSS, YYYYMMDDHHMMSS.sss and YYYYMMDDHHMMSS[gmt offset:tz name], including half-hour offsets. The three-letter zone name is ignored because the abbreviations are not unique; only the numeric offset is used.
    • The named entities lt, gt, amp, quot, apos, nbsp and numeric references in decimal or hexadecimal.
    • Several statements in one file, each with its own account, period, balances and transactions.
    • SONRS/FI/STATUS, LEDGERBAL/AVAILBAL/BAL, and a common investment subset: BUYSTOCK/BUYMF, SELLSTOCK/SELLMF, INCOME, REINVEST, INVBANKTRAN, TRANSFER, MARGININTEREST and INVEXPENSE.

    #Install and reproduce

    Install the MoonBit toolchain and confirm that moon version --all works. Run these commands from this project's root directory:

    moon check --deny-warn --target all moon build --target wasm moon test --deny-warn --target wasm moon test --deny-warn --target wasm-gc moon test --deny-warn --target js moon run --target wasm examples/demo

    moon check --target all type-checks all four backends without a C toolchain. Building, testing or running --target native needs a system C compiler (cc, gcc or clang) on PATH; without one, moon build --target all stops at the native step for the example executable, which says nothing about the library code.

    The example embeds a fictional OFX 1.x statement — three importable records and one with a malformed amount — and prints the header, the account, the period, each transaction in UTC with its minor units, the diagnostics, and the CSV export of the file.

    CI runs check, build, test and the example on wasm, wasm-gc, js and native, and fails if moon fmt or moon info would change the tree. CI configuration existing is not the same as CI having passed.

    #Limits

    Not implemented, and not claimed: XML namespaces, SIGNONMSGS authentication (the sign-on response is read, but nothing is signed or verified), OFX request messages, position and lot tracking, and full OFX schema validation. The investment support is a common subset: BUYSTOCK/BUYMF, SELLSTOCK/SELLMF, INCOME, REINVEST, INVBANKTRAN, TRANSFER, MARGININTEREST and INVEXPENSE are read, but SPLIT, options, debt instruments and position aggregates are not interpreted — an unclassified aggregate in an investment list still produces a record, with unknown as its kind and the tag preserved. Balance reconciliation checks arithmetic, not truth: it can say that a statement's numbers do not add up, not that the bank's are right. parse_statement still reports one statement at a time and keeps the first ACCTID/CURDEF, raising conflicting-value for a later different one; use parse_document for a file that holds several. Tag names are matched against a fixed list of aggregates, so an aggregate specific to a vendor is read as a leaf field. Character data is assumed to have been decoded to UTF-8 before it reaches this library; the declared CHARSET is reported, never applied.

    #Source and licence

    Format reference: OFX, the Open Financial Exchange specification published by the OFX Consortium. GitHub search moonbit ofx and Mooncakes search ofx found no relevant MoonBit package on 2026-09-24; that is a bounded search, not a proof of uniqueness.

    No bank data, third-party parser code or downloaded fixtures are included: every test document is a synthetic string written for this repository, and the amounts, FITIDs and account numbers in them are fictional. MIT licence, see LICENSE.

    Balance

    pub struct Balance {
    kind : String
    amount : String
    as_of : String
    }

    A LEDGERBAL or AVAILBAL aggregate: an amount and the instant it was true.

    kind is the tag the balance came from, so a report can say which balance it is printing. The amount stays text for the same reason a transaction amount does: the caller knows the scale the ledger stores.

    Balance::amount_minor_units

    fn Balance::amount_minor_units(self : Balance, scale : Int) -> Int64?

    The balance amount at a scale, or None when it cannot be represented.

    Balance::as_of_date

    fn Balance::as_of_date(self : Balance) -> PostedDate?

    The instant the balance was true, or None when the document's DTASOF is not a date this importer accepts.

    Balance::to_string

    fn Balance::to_string(self : Balance) -> String

    The balance as KIND amount as of DATE, for a report line.

    Diagnostic

    pub struct Diagnostic {
    severity : Severity
    code : String
    location : String
    }

    One diagnostic produced while scanning an OFX document.

    code is a stable, machine-readable identifier such as invalid-posted; location names where the problem is, either a tag and its 1-based line (DTPOSTED@12) or the offending value (a FITID). It is empty when the diagnostic applies to the document as a whole.

    Diagnostic::code

    fn Diagnostic::code(self : Diagnostic) -> String

    The stable machine-readable code, such as invalid-posted.

    Diagnostic::location

    fn Diagnostic::location(self : Diagnostic) -> String

    The tag and line, or value, the diagnostic belongs to; empty for document-wide diagnostics.

    Diagnostic::severity

    fn Diagnostic::severity(self : Diagnostic) -> Severity

    Whether the diagnostic blocks the import.

    Diagnostic::to_string

    fn Diagnostic::to_string(self : Diagnostic) -> String

    The stable severity:code[:location] rendering of a diagnostic.

    Direction

    pub enum Direction {
    Debit
    Credit
    Unknown
    }

    The side of the ledger a transaction belongs to.

    It is derived from the sign of the amount, never from TRNTYPE: one bank labels a card payment DEBIT and another labels it POS, while the sign is what a ledger must trust. A zero amount or an amount that cannot be parsed has no direction.

    Direction::to_string

    fn Direction::to_string(self : Direction) -> String

    debit, credit or unknown.

    Document

    pub struct Document {
    header : Header
    signon : Signon
    statements : Array[Statement]
    diagnostics : Array[Diagnostic]
    }

    Every statement in an OFX file, in the order the file states them.

    header and signon belong to the file and are shared by every statement, while diagnostics holds what could be said about the file itself: the lexer's complaints, the header, and the sign-on. The diagnostics that belong to one account's statement live on that statement, so a caller can report "statement 3 of 5 failed" instead of one undifferentiated list.

    Document::account_count

    fn Document::account_count(self : Document) -> Int

    How many distinct accounts the file holds.

    A file that states the same account twice is still one account to a ledger, so the count is of names, not of statements.

    Document::account_labels

    fn Document::account_labels(self : Document) -> Array[String]

    The account labels of the file, in statement order.

    A label is BANKID/ACCTID when both are known, the account id alone when there is no bank id, and UNKNOWN when the statement named no account, so a report can always print something.

    Document::currencies

    fn Document::currencies(self : Document) -> Array[String]

    The distinct currencies the file uses, in the order they first appear.

    A caller that sums the whole file needs one scale, and this is how it finds out whether the file has one.

    Document::diagnostics

    fn Document::diagnostics(self : Document) -> Array[Diagnostic]

    Every diagnostic in the file: the file-level ones first, then each statement's in order.

    Document::error_count

    fn Document::error_count(self : Document) -> Int

    Number of Error diagnostics across the file and its statements.

    Document::has_errors

    fn Document::has_errors(self : Document) -> Bool

    Whether any diagnostic, at either level, has severity Error.

    Document::investments

    fn Document::investments(self : Document) -> Array[Investment]

    Every investment record in the file, statement by statement.

    Document::investments_to_csv

    fn Document::investments_to_csv(self : Document) -> String

    Every statement's investment records as one CSV table, with the account and the currency leading each row.

    Document::is_empty

    fn Document::is_empty(self : Document) -> Bool

    Whether the file held no statement at all.

    Document::ok

    fn Document::ok(self : Document) -> Bool

    Whether every statement can be imported: no Error at either level.

    Document::period

    fn Document::period(self : Document) -> (String, String)?

    The period the whole file covers, as (start, end).

    None means no statement stated a period at all. A statement that states only one end is included from that end, so the answer is never narrower than the file.

    Document::render_diagnostics

    fn Document::render_diagnostics(self : Document) -> Array[String]

    Every diagnostic as a stable severity:code[:location] string.

    Document::render_report

    fn Document::render_report(self : Document) -> String

    A document as a short human report: one block per statement, then the file totals.

    Document::statement

    fn Document::statement(self : Document, index : Int) -> Statement?

    The statement at an index, or None when the index is out of range.

    Document::statement_count

    fn Document::statement_count(self : Document) -> Int

    How many statements the file holds.

    Document::statement_for_account

    fn Document::statement_for_account(self : Document, account_id : String) -> Statement?

    The statement that belongs to an account, or None when the file has none.

    The account is matched against ACCTID exactly; matching a bank id as well is left to the caller, because two institutions can use the same account number.

    Document::to_csv

    fn Document::to_csv(self : Document) -> String

    Every statement's transactions as one CSV table, with the account and the currency leading each row.

    The two extra columns are what makes a multi-statement export usable: a row that cannot be attributed to an account is a row a spreadsheet cannot filter, and a row without its currency cannot be summed with another statement's rows.

    Document::total_minor_units

    fn Document::total_minor_units(self : Document, scale : Int) -> Int64?

    Sum every transaction in the file at one scale, or None when an amount will not convert or the total overflows.

    Document::transactions

    fn Document::transactions(self : Document) -> Array[Transaction]

    Every transaction in the file, statement by statement.

    Document::warning_count

    fn Document::warning_count(self : Document) -> Int

    Number of Warning diagnostics across the file and its statements.
    pub struct Header {
    ofx_header : String
    data : String
    version : String
    security : String
    encoding : String
    charset : String
    compression : String
    old_file_uid : String
    new_file_uid : String
    }

    The declared header of an OFX document.

    OFX 1.x files open with KEY:VALUE lines (OFXHEADER:100, DATA:OFXSGML, VERSION:102, SECURITY:NONE, ENCODING:USASCII, CHARSET:1252, COMPRESSION:NONE, OLDFILEUID, NEWFILEUID); OFX 2.x files carry the same facts in the XML declaration and an <?OFX ...?> processing instruction. Both shapes fill this struct. A field the document does not declare stays empty: the importer never invents a default, so a caller can tell "the file says 1252" from "the file says nothing".

    Header::is_empty

    fn Header::is_empty(self : Header) -> Bool

    Whether the document declared no header field at all.

    Header::is_xml

    fn Header::is_xml(self : Header) -> Bool

    Whether the document declares itself as OFX 2.x XML rather than OFX 1.x SGML. Only the header decides this; the body is scanned the same way either way.

    Header::render

    fn Header::render(self : Header) -> String

    The declared fields as KEY:VALUE pairs separated by a single space, in header order, omitting everything the document left out.

    Investment

    pub struct Investment {
    kind : InvestmentKind
    tag : String
    fitid : String
    trade_date : String
    settle_date : String
    security_id : String
    security_id_type : String
    units : String
    unit_price : String
    commission : String
    fees : String
    taxes : String
    total : String
    sub_account_sec : String
    sub_account_fund : String
    income_type : String
    buy_type : String
    sell_type : String
    currency : String
    name : String
    memo : String
    }

    One investment transaction, flattened to the fields a ledger needs.

    The nesting OFX uses (INVTRAN inside INVBUY inside BUYSTOCK, a STMTTRN inside INVBANKTRAN) is collapsed here: fitid, trade_date and total carry the same meaning whichever aggregate supplied them, and a diagnostic still points at the line the value came from.

    Investment::is_debit

    fn Investment::is_debit(self : Investment) -> Bool

    Whether the record moved money the way a ledger books as a debit.

    The document's own total is the authority where it is present: a buy with a positive total is a refund of a purchase, and this library will not turn it into a debit. Only when there is no total does the kind decide.

    Investment::proceeds_minor_units

    fn Investment::proceeds_minor_units(self : Investment, scale : Int) -> Int64?

    The money the record actually moved, after the costs it states.

    A brokerage total is usually the gross amount, so a buy of 1501.00 with a commission of 1.00 is 1502.00 out of the account. None means the document stated no total, or one of the amounts will not convert at scale, or the subtraction overflows - a caller reconciling a cash balance must not be handed a number this library had to guess.

    Investment::security_label

    fn Investment::security_label(self : Investment) -> String

    The security the record moved, as TYPE:ID when both are known, or just the identifier when the type is missing.

    Investment::settle_time

    fn Investment::settle_time(self : Investment) -> PostedDate?

    The settlement instant, or None when it is absent or not a real date.

    Investment::to_string

    fn Investment::to_string(self : Investment) -> String

    The record as one line, for a report or a log.

    Investment::total_minor_units

    fn Investment::total_minor_units(self : Investment, scale : Int) -> Int64?

    The total of the record at a scale, or None when the document stated none or it cannot be represented.

    Investment::trade_time

    fn Investment::trade_time(self : Investment) -> PostedDate?

    The trade instant, or None when it is absent or not a real date.

    Investment::unit_price_minor_units

    fn Investment::unit_price_minor_units(self : Investment, scale : Int) -> Int64?

    The unit price at a scale, or None when the document stated none.

    Investment::units_at_scale

    fn Investment::units_at_scale(self : Investment, scale : Int) -> Int64?

    The traded quantity at a scale, for a caller that stores share counts as integers (scale 4 keeps four decimal places of a share).

    InvestmentKind

    pub enum InvestmentKind {
    BuyStock
    SellStock
    Income
    Reinvest
    BankTransfer
    Transfer
    MarginInterest
    Expense
    Unknown
    }

    The kind of investment activity a record describes.

    The variants cover the aggregates a retail brokerage statement actually uses; a tag this library does not classify still produces a record, with Unknown as its kind and the original tag kept in [Investment::tag], so nothing is silently dropped.

    InvestmentKind::to_string

    fn InvestmentKind::to_string(self : InvestmentKind) -> String

    buy, sell, income, reinvest, transfer, bank-transfer, margin-interest, expense or unknown.

    PostedDate

    pub struct PostedDate {
    year : Int
    month : Int
    day : Int
    hour : Int
    minute : Int
    second : Int
    offset_minutes : Int
    }

    A parsed OFX posting date.

    The fields are the ones the document states, in the zone it states them in: hour/minute/second are local to offset_minutes, not UTC. Call [PostedDate::to_utc_string] to compare or sort records that a bank wrote in different zones.

    PostedDate::add_days

    fn PostedDate::add_days(self : PostedDate, days : Int) -> PostedDate

    The date shifted by a whole number of days, carrying month and year.

    add_days(-1) on the first of a month lands on the last day of the previous one, including across a leap year.

    PostedDate::at_utc

    fn PostedDate::at_utc(year : Int, month : Int, day : Int) -> PostedDate?

    Midnight UTC on a date, or None when the date does not exist.

    PostedDate::compare

    fn PostedDate::compare(self : PostedDate, other : PostedDate) -> Int

    Compare two instants in UTC: negative when self is earlier.

    PostedDate::date_string

    fn PostedDate::date_string(self : PostedDate) -> String

    The date as YYYY-MM-DD, in the zone the document declared.

    PostedDate::is_utc

    fn PostedDate::is_utc(self : PostedDate) -> Bool

    Whether the stamp is already in UTC, i.e. declares no offset.

    PostedDate::of

    fn PostedDate::of(year : Int, month : Int, day : Int, hour : Int, minute : Int, second : Int, offset_minutes : Int) -> PostedDate?

    Build a date from its parts, or None when they are not a real calendar moment.

    This is the checked constructor the parser uses internally; a caller that computes a date rather than reading one can use it to avoid inventing a PostedDate that never existed.

    PostedDate::same_instant

    fn PostedDate::same_instant(self : PostedDate, other : PostedDate) -> Bool

    Whether the two instants are the same moment, wherever they were written.

    20260924120000[-5:EST] and 20260924170000 are the same instant and this returns true, which is what a reconciliation between two exports of the same account needs.

    PostedDate::to_epoch_seconds

    fn PostedDate::to_epoch_seconds(self : PostedDate) -> Int64

    The instant as seconds since the Unix epoch in UTC, ignoring the fractional seconds an OFX stamp may carry.

    PostedDate::to_iso_string

    fn PostedDate::to_iso_string(self : PostedDate) -> String

    The instant as YYYY-MM-DDTHH:MM:SSZ in UTC, for logs and JSON.

    PostedDate::to_ofx_date

    fn PostedDate::to_ofx_date(self : PostedDate) -> String

    The date as YYYYMMDD, the form OFX uses for a period boundary.

    PostedDate::to_ofx_stamp

    fn PostedDate::to_ofx_stamp(self : PostedDate) -> String

    The instant as YYYYMMDDHHMMSS in the zone the document declared, which is the inverse of [parse_posted_date] for a stamp without a zone bracket.

    PostedDate::to_utc_string

    fn PostedDate::to_utc_string(self : PostedDate) -> String

    The instant as YYYYMMDDHHMMSS in UTC, with the day, month and year carried where the offset crosses a boundary.

    PostedDate::weekday

    fn PostedDate::weekday(self : PostedDate) -> Int

    The weekday of the date in the zone it declares, where 0 is Sunday.

    PostedDate::weekday_name

    fn PostedDate::weekday_name(self : PostedDate) -> String

    The English name of [PostedDate::weekday], for logs and reports.

    Reconciliation

    pub struct Reconciliation {
    opening : Int64
    declared_closing : Int64
    computed_closing : Int64
    difference : Int64
    matched : Bool
    }

    The result of checking a closing balance against an opening balance plus the transactions between them.

    difference is declared - computed, so a positive value means the document claims more money than the transaction list accounts for. matched is the answer to the only question that matters before writing to a ledger.

    Reconciliation::difference

    fn Reconciliation::difference(self : Reconciliation) -> Int64

    The signed difference declared - computed, in minor units.

    Reconciliation::matched

    fn Reconciliation::matched(self : Reconciliation) -> Bool

    Whether the declared closing balance equals the computed one.

    Reconciliation::to_string

    fn Reconciliation::to_string(self : Reconciliation) -> String

    The reconciliation as one line, with the difference spelled out.

    Severity

    pub enum Severity {
    Error
    Warning
    }

    Whether a diagnostic blocks importing the affected transaction.

    Error marks a value the importer refuses to hand downstream, so the affected transaction is dropped from [Statement::transactions]. Warning marks a value that was kept but is suspicious, such as an OFX header line the parser could not interpret.

    Severity::to_string

    fn Severity::to_string(self : Severity) -> String

    error or warning.

    Signon

    pub struct Signon {
    server_date : String
    language : String
    organisation : String
    institution_id : String
    session_cookie : String
    status : Status?
    }

    The SONRS sign-on response: when the file was produced, which financial institution produced it, and whether that server considered the response a success.

    A statement whose sign-on reports an error is still parsed — the transactions are usually there — but the caller should know that the server itself said something went wrong, so the error is also raised as a diagnostic.

    Signon::is_present

    fn Signon::is_present(self : Signon) -> Bool

    Whether the document carried a SONRS aggregate at all.

    Signon::server_time

    fn Signon::server_time(self : Signon) -> PostedDate?

    The DTSERVER instant, or None when it is absent or not a real date.

    Signon::to_string

    fn Signon::to_string(self : Signon) -> String

    The sign-on as one line for a report, or the empty string when the document declared none.

    Statement

    pub struct Statement {
    header : Header
    signon : Signon
    bank_id : String
    branch_id : String
    account_id : String
    account_type : String
    currency : String
    start_date : String
    end_date : String
    ledger_balance : Balance?
    avail_balance : Balance?
    transactions : Array[Transaction]
    investments : Array[Investment]
    diagnostics : Array[Diagnostic]
    }

    The result of scanning one OFX document.

    transactions holds only the records that passed every check; everything the parser rejected or found suspicious is described by diagnostics. Use [Statement::ok] for a pipeline decision and [Statement::render_diagnostics] for operator-facing text.

    Statement::diagnostics

    fn Statement::diagnostics(self : Statement) -> Array[Diagnostic]

    A copy of the diagnostics, in detection order.

    Statement::error_count

    fn Statement::error_count(self : Statement) -> Int

    Number of Error diagnostics.

    Statement::has_errors

    fn Statement::has_errors(self : Statement) -> Bool

    Whether any diagnostic has severity Error.

    Statement::investments_to_csv

    fn Statement::investments_to_csv(self : Statement) -> String

    A statement's investment records as a CSV table with a header row.

    Statement::ok

    fn Statement::ok(self : Statement) -> Bool

    Whether the statement can be imported: no Error diagnostic was raised.

    Warnings never block an import, but they still deserve a look before the transactions reach a ledger.

    Statement::reconcile

    fn Statement::reconcile(self : Statement, opening : Int64, scale : Int) -> Reconciliation?

    Reconcile a statement's own ledger balance against an opening balance the caller supplies plus the transactions the statement holds.

    None when the statement declares no ledger balance, when an amount cannot be converted at scale, or when the arithmetic overflows.

    Statement::render_diagnostics

    fn Statement::render_diagnostics(self : Statement) -> Array[String]

    The diagnostics as stable severity:code[:location] strings, in detection order.

    Statement::render_report

    fn Statement::render_report(self : Statement) -> String

    A statement as a short human report.

    This is the text an operator reads before deciding to import: who the statement belongs to, what period it covers, how many records it holds, how the balances look against those records, and every diagnostic. It is deliberately verbose about the diagnostics and silent about the amounts — the CSV is for the amounts.

    Statement::render_table

    fn Statement::render_table(self : Statement) -> String

    A statement's transactions as an aligned text table.

    The instants are printed in UTC, because a table is exactly where two rows written in different zones would otherwise look out of order, and the raw stamp is kept in the CSV for a caller that wants it.

    Statement::to_csv

    fn Statement::to_csv(self : Statement) -> String

    A statement's transactions as a CSV table with a header row.

    Amounts are written exactly as the document spelled them; the conversion to minor units is the caller's decision, because the scale belongs to the ledger being written, not to the export.

    Statement::warning_count

    fn Statement::warning_count(self : Statement) -> Int

    Number of Warning diagnostics.

    Status

    pub struct Status {
    code : Int
    severity : String
    message : String
    }

    The STATUS aggregate OFX attaches to a response.

    code is the numeric result: zero means success, and a negative or positive value is the server's own error code. severity is the document's own INFO/WARN/ERROR label, kept as written so a caller can compare it with the code instead of trusting either one alone.

    Status::is_error

    fn Status::is_error(self : Status) -> Bool

    Whether the document labelled the status as an error, without regard to case.

    Status::is_success

    fn Status::is_success(self : Status) -> Bool

    Whether the status code means success.

    Status::to_string

    fn Status::to_string(self : Status) -> String

    The status as severity:code: message, the form a log line wants.

    Transaction

    pub struct Transaction {
    kind : String
    posted : String
    user_date : String
    amount : String
    fitid : String
    check_number : String
    ref_number : String
    payee_id : String
    sic : String
    name : String
    memo : String
    }

    One <STMTTRN> record.

    Values are kept exactly as the document spells them, after XML entity decoding: amount stays a decimal string and posted stays an OFX date stamp, so the caller decides how to round or in which zone to interpret them. Fields the document omits are empty strings rather than defaults.

    Transaction::amount_minor_units

    fn Transaction::amount_minor_units(self : Transaction, scale : Int) -> Int64?

    The amount as an integer count of minor units at the given scale, or None when the amount is malformed, has more fractional digits than scale, or does not fit in Int64. See [amount_to_minor_units].

    Transaction::direction

    fn Transaction::direction(self : Transaction) -> Direction

    Which side of the ledger the amount falls on.

    Transaction::posted_date

    fn Transaction::posted_date(self : Transaction) -> PostedDate?

    The parsed posting date, or None when DTPOSTED is not a valid OFX date.

    Transaction::utc_posted

    fn Transaction::utc_posted(self : Transaction) -> String?

    The posting instant as YYYYMMDDHHMMSS in UTC, or None when the date is not valid. Sorting a batch of statements by this value gives the right order even when the files were written in different zones.

    amount_from_currency

    fn amount_from_currency(minor : Int64, currency : String) -> String?

    Render an integer count of minor units using the currency's scale.

    amount_in_currency

    fn amount_in_currency(amount : String, currency : String) -> Int64?

    Convert an amount using the scale the currency implies.

    This is the conversion a ledger import usually wants: the document states both the amount and the currency, so the scale does not have to be passed in by hand. None means the amount is malformed, needs more fractional digits than the currency has, overflows Int64, or names an unknown currency.

    amount_to_minor_units

    fn amount_to_minor_units(amount : String, scale : Int) -> Int64?

    Convert a decimal amount string to an integer count of minor units.

    scale is the number of fractional digits the target ledger stores (2 for cents); scales from 0 to 6 are accepted. The conversion never uses floating point: it shifts digits and checks Int64 overflow at every step, so "9223372036854775808" at scale 0 returns None instead of a wrapped value. Input with more fractional digits than scale is rejected rather than rounded, because silently losing a digit is exactly the kind of error an importer must not hide.

    checked_add

    fn checked_add(left : Int64, right : Int64) -> Int64?

    Add two counts of minor units, or return None when the sum would overflow.

    Summing a statement's amounts is the other place an importer can silently produce a wrong number rather than no number, so the addition is checked the same way the conversion is.

    checked_sub

    fn checked_sub(left : Int64, right : Int64) -> Int64?

    Subtract two counts of minor units, or return None when the difference would overflow.

    csv_escape

    fn csv_escape(field : String) -> String

    Escape one field for CSV, following RFC 4180.

    A field is quoted when it contains a comma, a quote or a line break, and its quotes are doubled inside the quotes. This matters here more than in most exporters: a bank memo is free text and routinely contains commas, and a ledger that splits memo into two columns because of one is worse than no export at all.

    currency_label

    fn currency_label(currency : String) -> String

    The currency code as it should be written in a report: upper-cased, or the fallback when the document declared nothing.

    date_range

    fn date_range(start : PostedDate, end : PostedDate) -> Array[PostedDate]

    Every date from start to end inclusive, or an empty array when end is before start.

    A statement period is a date range, and a caller that wants one row per day (a cash-flow chart, a zero-activity check) should not have to write the loop and the month-length rules again. The range is capped at 3660 days so a reversed or absurd period cannot allocate without bound.

    days_between

    fn days_between(start : PostedDate, end : PostedDate) -> Int

    Whole days from start to end, comparing the instants in UTC.

    A negative result means end is before start. The division floors, so a difference of one hour still reports 0 days and a difference of twenty-three hours reports 1 only when it crosses a UTC midnight.

    default_minor_unit_scale

    fn default_minor_unit_scale() -> Int

    The scale an amount falls back to when the document names no currency.

    Two digits: it is what most currencies use, and the importer says so instead of pretending the choice was read from the document.

    diagnostic_codes

    fn diagnostic_codes() -> Array[String]

    Every diagnostic code this library can emit, in the order the parser is able to produce them.

    Consumers can diff this list between library versions to reject codes they do not understand yet. A code is never renamed or reused for a different meaning.

    investment_columns

    fn investment_columns() -> Array[String]

    The column names of the investment table.

    investment_kind_of

    fn investment_kind_of(tag : String) -> InvestmentKind

    Classify an investment aggregate name.

    is_known_currency

    fn is_known_currency(currency : String) -> Bool

    Whether the currency code is one this library knows.

    is_leap_year

    fn is_leap_year(year : Int) -> Bool

    Whether a year is a leap year in the Gregorian calendar.

    is_valid_amount

    fn is_valid_amount(amount : String) -> Bool

    Whether a string is a decimal amount this importer accepts.

    The accepted shape is an optional + or -, one or more integer digits, an optional . followed by one or more digits. Nothing else: no thousands separators, no trailing point, no exponent, no surrounding whitespace. The check is strict on purpose, because a bank that writes 1.234,56 means something the importer must not guess at.

    is_valid_ofx_date

    fn is_valid_ofx_date(value : String) -> Bool

    Whether a string is a date this importer accepts, without building one.

    This is the same calendar check [parse_posted_date] applies, exposed for a caller that only needs the yes-or-no answer, for example when validating a DTSTART/DTEND pair before storing it.

    minor_unit_name

    fn minor_unit_name(currency : String) -> String

    The minor-unit name a report can print next to an amount, or the empty string for an unknown currency.

    minor_unit_scale

    fn minor_unit_scale(currency : String) -> Int?

    How many fractional digits the currency's minor unit uses.

    Some(2) for most currencies, Some(0) for the yen, won and the other no-fraction currencies, Some(3) for the dinar family and Some(4) for the Chilean and Uruguayan unit-of-account codes. None means the code is not one this library knows, and the caller must choose a scale itself rather than have one guessed.

    The argument is matched without regard to case, because a bank may write CURDEF>usd.

    minor_units_to_amount

    fn minor_units_to_amount(minor : Int64, scale : Int) -> String?

    Convert an integer count of minor units back into a decimal amount string.

    This is the inverse of [amount_to_minor_units] at the same scale: -4250 at scale 2 becomes "-42.50" and 5 becomes "0.05", so a ledger can be rendered back into the decimal form a bank used. None is returned only for a scale outside 0 to 6; every Int64 is representable, including the smallest one.

    month_length

    fn month_length(year : Int, month : Int) -> Int?

    The number of days in a month, or None when month is outside 1 to 12.

    normalize_amount

    fn normalize_amount(amount : String) -> String?

    Rewrite an amount in one canonical spelling, or return None when it is not a valid amount.

    +042.50 becomes 42.50, 0000 becomes 0 and -0.00 becomes 0.00; a genuinely negative fraction such as -0.5 keeps its sign. Two spellings of the same amount therefore compare equal, which is what a de-duplication or reconciliation pass needs before it can match a bank line against a ledger entry.

    parse_document

    fn parse_document(source : String) -> Document

    Scan an OFX document and return every statement it holds.

    A file that holds one statement and a file that holds thirty are read by the same rules, and each statement is diagnosed on its own: a bad record in the third account does not make the first two unimportable, and the caller can see which statement failed. A document with no statement wrapper at all is still read, as a single statement, so fragments keep working exactly as they do with [parse_statement].

    parse_period_date

    fn parse_period_date(value : String) -> PostedDate?

    Parse a period boundary such as DTSTART/DTEND.

    OFX writes these as a date or as a full stamp; both are accepted, and the result is the instant the document declares. None means the value is not a date this importer accepts.

    parse_posted_date

    fn parse_posted_date(value : String) -> PostedDate?

    Parse an OFX date stamp, or return None when it is not one.

    A date without a time is midnight; a date without a zone bracket is treated as already being in UTC, which is what OFX means by omitting the offset. The zone name after the colon is documentation for a human and is ignored: only the numeric offset is used, because the abbreviations are not unique.

    parse_statement

    fn parse_statement(source : String) -> Statement

    Scan an OFX document and return its first statement plus every diagnostic.

    This is the entry point for the common case: one export, one account. A file that holds several statements is reported one statement at a time — the first declaration of a statement-level field wins and later different values raise conflicting-value — so use [parse_document] when the caller needs every statement in the file, and this function when it needs the one a single-account import expects.

    The scanner never fails as a whole: a transaction is dropped only when one of its own fields is missing or malformed, and the reason is always reported.

    reconcile

    fn reconcile(opening : Int64, closing : Int64, transactions : Array[Transaction], scale : Int) -> Reconciliation?

    Check that an opening balance plus every transaction equals a closing balance, all at the same scale.

    None means the arithmetic cannot be done honestly: an amount that will not convert at this scale, or a sum that overflows Int64. Returning None is deliberate — reporting "reconciled" because the addition wrapped around is exactly the failure this function exists to prevent.

    sum_amounts

    fn sum_amounts(amounts : Array[String], scale : Int, opening : Int64) -> Int64?

    Sum a list of amounts at one scale, or return None when an amount cannot be converted or the total overflows.

    opening is added first, so a caller can fold a running balance over a batch of statements without reimplementing the overflow checks.

    transaction_columns

    fn transaction_columns() -> Array[String]

    The column names of the transaction table.