Offline OFX bank transaction extraction and diagnostics
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.
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())
}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")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())| Function | Purpose |
|---|---|
| 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_amounts | Money arithmetic that reports overflow instead of wrapping silently. |
| Statement::to_csv / Document::to_csv | RFC 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. |
| Example | Meaning |
|---|---|
| error:unclosed-tag:@12 | a tag body is never closed by >; scanning stops, because every later offset would be a guess |
| error:empty-tag:@4 | a tag with no name, such as <> |
| error:unclosed-comment:@2 | a comment is never closed; scanning stops |
| error:unclosed-cdata:@9 | a CDATA section is never closed |
| warning:unknown-entity:&foo;@6 | a named reference the decoder does not know; the text is kept as written |
| warning:malformed-entity:&@6 | a bare &, or a numeric reference that is empty, non-numeric, zero, a surrogate or above U+10FFFF |
| Example | Meaning |
|---|---|
| warning:missing-header | the document has an <OFX> aggregate but declares no header at all |
| warning:unknown-header-line:MOONBEAM@2 | a prologue line or processing-instruction attribute this importer does not recognise |
| warning:empty-header-value:VERSION@3 | a known key declared with no value |
| warning:invalid-header-version:VERSION:1.0 | OFXHEADER is not 100/200/220, or VERSION is not three digits |
| error:unsupported-compression:GZIP | the body is compressed and cannot be read |
| Example | Meaning |
|---|---|
| error:unexpected-close:BANKTRANLIST@9 | a closing tag for an aggregate that was never opened |
| error:mismatched-aggregate:BANKTRANLIST@2 | a closing tag closes an aggregate that is not the innermost one |
| error:nested-aggregate:STMTTRN@14 | a <STMTTRN> opens inside another one |
| error:unclosed-aggregate:BANKTRANLIST@7 | an aggregate is still open at the end of the document |
| warning:conflicting-value:ACCTID:acct-1!=acct-2@14 | a statement-level field is declared twice with different values; the first wins |
| Example | Meaning |
|---|---|
| warning:invalid-status-code:zero@6 | STATUS/CODE is not a number |
| warning:invalid-server-date:DTSERVER@4 | DTSERVER is not a real calendar instant |
| warning:signon-status-error:ERROR:-1: Bad login | the server's own status says the response is an error |
| Example | Meaning |
|---|---|
| error:invalid-balance-amount:1,500.00@3 | LEDGERBAL/AVAILBAL has no BALAMT, or it is not a plain decimal amount; the balance is dropped |
| error:invalid-balance-date:20260931@4 | the balance has no DTASOF, or it is not a real date; the balance is dropped |
| Example | Meaning |
|---|---|
| error:missing-type:TRNTYPE@5 | no TRNTYPE in a record |
| error:missing-fitid:FITID@5 | no FITID in a record, or none in an investment record |
| error:invalid-posted:DTPOSTED@3 | DTPOSTED (or an investment DTTRADE) is not a real calendar date |
| error:invalid-amount:TRNAMT@4 | TRNAMT (or an investment TOTAL) is not a plain decimal amount |
| error:duplicate-fitid:abc-1 | a FITID that already appeared; the second record is dropped |
| warning:invalid-user-date:DTUSER@4 | DTUSER is not a real calendar date |
| warning:invalid-sic:SIC@7 | SIC is not four digits |
| Example | Meaning |
|---|---|
| warning:missing-acctid | transactions were found, but no ACCTID |
| warning:missing-curdef | transactions were found, but no CURDEF |
| warning:no-transactions | the statement parsed cleanly and holds nothing to import |
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/demopub struct Balance {
kind : String
amount : String
as_of : String
}pub enum Direction {
Debit
Credit
Unknown
}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
}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
}pub enum InvestmentKind {
BuyStock
SellStock
Income
Reinvest
BankTransfer
Transfer
MarginInterest
Expense
Unknown
}pub struct PostedDate {
year : Int
month : Int
day : Int
hour : Int
minute : Int
second : Int
offset_minutes : Int
}fn PostedDate::of(year : Int, month : Int, day : Int, hour : Int, minute : Int, second : Int, offset_minutes : Int) -> PostedDate?pub struct Reconciliation {
opening : Int64
declared_closing : Int64
computed_closing : Int64
difference : Int64
matched : Bool
}pub enum Severity {
Error
Warning
}pub struct Signon {
server_date : String
language : String
organisation : String
institution_id : String
session_cookie : String
status : Status?
}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]
}pub struct Status {
code : Int
severity : String
message : String
}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
}fn amount_from_currency(minor : Int64, currency : String) -> String?fn amount_in_currency(amount : String, currency : String) -> Int64?fn amount_to_minor_units(amount : String, scale : Int) -> Int64?fn checked_add(left : Int64, right : Int64) -> Int64?fn checked_sub(left : Int64, right : Int64) -> Int64?fn csv_escape(field : String) -> Stringfn currency_label(currency : String) -> Stringfn default_minor_unit_scale() -> Intfn is_known_currency(currency : String) -> Boolfn is_leap_year(year : Int) -> Boolfn is_valid_amount(amount : String) -> Boolfn is_valid_ofx_date(value : String) -> Boolfn minor_unit_name(currency : String) -> Stringfn minor_unit_scale(currency : String) -> Int?fn minor_units_to_amount(minor : Int64, scale : Int) -> String?fn month_length(year : Int, month : Int) -> Int?fn normalize_amount(amount : String) -> String?fn reconcile(opening : Int64, closing : Int64, transactions : Array[Transaction], scale : Int) -> Reconciliation?