///|
/// One `` 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.
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
}
///|
/// 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.
pub enum Direction {
Debit
Credit
Unknown
}
///|
/// `debit`, `credit` or `unknown`.
pub fn Direction::to_string(self : Direction) -> String {
match self {
Debit => "debit"
Credit => "credit"
Unknown => "unknown"
}
}
///|
/// Which side of the ledger the amount falls on.
pub fn Transaction::direction(self : Transaction) -> Direction {
if !is_valid_amount(self.amount) {
return Unknown
}
let mut negative = false
let mut nonzero = false
for c in self.amount.iter() {
if c == '-' {
negative = true
} else if c >= '1' && c <= '9' {
nonzero = true
}
}
if !nonzero {
return Unknown
}
if negative {
Debit
} else {
Credit
}
}
///|
/// 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`].
pub fn Transaction::amount_minor_units(
self : Transaction,
scale : Int,
) -> Int64? {
amount_to_minor_units(self.amount, scale)
}
///|
/// The parsed posting date, or `None` when `DTPOSTED` is not a valid OFX date.
pub fn Transaction::posted_date(self : Transaction) -> PostedDate? {
parse_posted_date(self.posted)
}
///|
/// 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.
pub fn Transaction::utc_posted(self : Transaction) -> String? {
match parse_posted_date(self.posted) {
Some(date) => Some(date.to_utc_string())
None => None
}
}
///|
/// 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.
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]
}
///|
/// Whether any diagnostic has severity `Error`.
pub fn Statement::has_errors(self : Statement) -> Bool {
self.diagnostics.any(d => d.severity is Error)
}
///|
/// 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.
pub fn Statement::ok(self : Statement) -> Bool {
!self.has_errors()
}
///|
/// Number of `Error` diagnostics.
pub fn Statement::error_count(self : Statement) -> Int {
self.diagnostics.filter(d => d.severity is Error).length()
}
///|
/// Number of `Warning` diagnostics.
pub fn Statement::warning_count(self : Statement) -> Int {
self.diagnostics.filter(d => d.severity is Warning).length()
}
///|
/// A copy of the diagnostics, in detection order.
pub fn Statement::diagnostics(self : Statement) -> Array[Diagnostic] {
self.diagnostics.copy()
}
///|
/// The diagnostics as stable `severity:code[:location]` strings, in detection
/// order.
pub fn Statement::render_diagnostics(self : Statement) -> Array[String] {
self.diagnostics.map(d => d.to_string())
}
///|
/// The aggregates whose nesting the importer tracks.
///
/// Only these names can open a scope. Every other tag is a leaf field, so an
/// unrecognised vendor tag cannot push the parser into a state it never leaves.
fn is_aggregate(tag : String) -> Bool {
match tag {
"OFX"
| "SIGNONMSGSRSV1"
| "SONRS"
| "FI"
| "STATUS"
| "BANKMSGSRSV1"
| "STMTTRNRS"
| "STMTRS"
| "CCSTMTTRNRS"
| "CCSTMTRS"
| "BANKACCTFROM"
| "CCACCTFROM"
| "BANKTRANLIST"
| "STMTTRN"
| "LEDGERBAL"
| "AVAILBAL"
| "BAL" => true
_ => false
}
}
///|
/// Decide the value of a statement-level field that the document sets more than
/// once.
///
/// The first declaration wins, because a file that holds several statements is
/// reported one statement at a time and the caller should get the account the
/// file opens with. A later, different value is reported as `conflicting-value`
/// rather than silently overwriting it.
fn merge_field(
current : String,
tag : String,
value : String,
line : Int,
diagnostics : Array[Diagnostic],
) -> String {
if current == "" {
value
} else if current == value {
current
} else {
diagnostics.push(
warning_at(
"conflicting-value",
tag + ":" + current + "!=" + value + "@" + line.to_string(),
),
)
current
}
}
///|
/// Drop a leading UTF-8 byte-order mark.
///
/// A BOM is not part of the document. Without this, a file that starts with one
/// would have its first header key read as `\u{FEFF}OFXHEADER`, so every declared
/// field would be lost and a complete OFX 1.x file would look like one that only
/// warns about a missing header.
fn strip_leading_bom(text : String) -> String {
match text.get_char(0) {
Some('\u{FEFF}') => text[1:].to_owned()
_ => text
}
}
///|
/// Turn one statement's worth of tokens into a `Statement`.
///
/// The scanner is deliberately tolerant: it accepts OFX 1.x SGML (`value`
/// without a closing tag) and OFX 2.x XML (`value`) in the same
/// pass, keeps the original field order, and never fails as a whole. Tags are
/// matched without regard to case, so a document that writes `` is read
/// the same way as one that writes ``. Aggregate nesting is tracked, so
/// a field is attributed to the account, period or transaction that owns it. A
/// transaction is dropped only when one of its own fields is missing or
/// malformed, and the reason is always reported.
///
/// `document_level` says whether these tokens are a whole document, where the
/// header and completeness rules apply to the file, or one statement cut out of
/// a larger file, where they apply to the statement. Everything else is
/// identical, so a single-statement export and a ten-statement one are read by
/// the same rules.
fn statement_from_tokens(
tokens : Array[TagToken],
header : Header,
signon : Signon,
diagnostics : Array[Diagnostic],
document_level : Bool,
) -> Statement {
let complete = if document_level {
tokens.any(token => token.name == "OFX")
} else {
true
}
if document_level && header.is_empty() && complete {
diagnostics.push(warning_at("missing-header", ""))
}
let (ledger_balance, avail_balance) = parse_balances(tokens, diagnostics)
let investments = parse_investments(tokens, diagnostics)
let transactions : Array[Transaction] = []
let seen : Map[String, Bool] = Map([])
let open_aggregates : Array[(String, Int)] = []
let mut bank_id = ""
let mut branch_id = ""
let mut account_id = ""
let mut account_type = ""
let mut currency = ""
let mut start_date = ""
let mut end_date = ""
let mut kind = ""
let mut posted = ""
let mut user_date = ""
let mut amount = ""
let mut fitid = ""
let mut check_number = ""
let mut ref_number = ""
let mut payee_id = ""
let mut sic = ""
let mut name = ""
let mut memo = ""
let mut posted_line = 0
let mut amount_line = 0
let mut user_date_line = 0
let mut sic_line = 0
for token in tokens {
let tag = token.name
let line = token.line
let value = token.value
let in_transaction = open_aggregates.length() > 0 &&
open_aggregates[open_aggregates.length() - 1].0 == "STMTTRN"
match close_target(tag) {
Some(target) => {
if !is_aggregate(target) {
// The closing half of a leaf field, e.g. ``: the value was
// already read from the opening tag.
continue
}
if !open_aggregates.any(entry => entry.0 == target) {
diagnostics.push(error_at("unexpected-close", at_line(target, line)))
continue
}
let mut closing_transaction = false
while open_aggregates.length() > 0 {
let (open_name, open_line) = open_aggregates[open_aggregates.length() -
1]
if open_name == target {
let _ = open_aggregates.pop()
closing_transaction = target == "STMTTRN"
break
}
diagnostics.push(
error_at("mismatched-aggregate", at_line(open_name, open_line)),
)
let _ = open_aggregates.pop()
}
if closing_transaction {
let mut valid = true
if kind == "" {
diagnostics.push(error_at("missing-type", at_line("TRNTYPE", line)))
valid = false
}
if !valid_date(posted) {
diagnostics.push(
error_at("invalid-posted", at_line("DTPOSTED", posted_line)),
)
valid = false
}
if !is_valid_amount(amount) {
diagnostics.push(
error_at("invalid-amount", at_line("TRNAMT", amount_line)),
)
valid = false
}
if fitid == "" {
diagnostics.push(error_at("missing-fitid", at_line("FITID", line)))
valid = false
} else if seen.get(fitid) is Some(_) {
diagnostics.push(error_at("duplicate-fitid", fitid))
valid = false
}
if user_date != "" && !valid_date(user_date) {
diagnostics.push(
warning_at("invalid-user-date", at_line("DTUSER", user_date_line)),
)
}
if sic != "" && !is_digits(sic, 4) {
diagnostics.push(
warning_at("invalid-sic", at_line("SIC", sic_line)),
)
}
if valid {
seen[fitid] = true
transactions.push({
kind,
posted,
user_date,
amount,
fitid,
check_number,
ref_number,
payee_id,
sic,
name,
memo,
})
}
}
}
None =>
if is_aggregate(tag) {
if token.self_closing {
// An empty element such as `` has no children and no
// closing tag: there is no scope to open, and treating it as one
// would report a mismatch when the enclosing aggregate closes.
continue
}
if in_transaction && tag == "STMTTRN" {
diagnostics.push(error_at("nested-aggregate", at_line(tag, line)))
}
if tag == "STMTTRN" {
kind = ""
posted = ""
user_date = ""
amount = ""
fitid = ""
check_number = ""
ref_number = ""
payee_id = ""
sic = ""
name = ""
memo = ""
posted_line = line
amount_line = line
user_date_line = line
sic_line = line
}
open_aggregates.push((tag, line))
} else if in_transaction {
match tag {
"TRNTYPE" => kind = value
"DTPOSTED" => {
posted = value
posted_line = line
}
"DTUSER" => {
user_date = value
user_date_line = line
}
"TRNAMT" => {
amount = value
amount_line = line
}
"FITID" => fitid = value
"CHECKNUM" => check_number = value
"REFNUM" => ref_number = value
"PAYEEID" => payee_id = value
"SIC" => {
sic = value
sic_line = line
}
"NAME" => name = value
"MEMO" => memo = value
_ => ()
}
} else {
match tag {
"BANKID" =>
bank_id = merge_field(bank_id, tag, value, line, diagnostics)
"BRANCHID" =>
branch_id = merge_field(branch_id, tag, value, line, diagnostics)
"ACCTID" =>
account_id = merge_field(
account_id, tag, value, line, diagnostics,
)
"ACCTTYPE" =>
account_type = merge_field(
account_type, tag, value, line, diagnostics,
)
"CURDEF" =>
currency = merge_field(currency, tag, value, line, diagnostics)
"DTSTART" =>
start_date = merge_field(
start_date, tag, value, line, diagnostics,
)
"DTEND" =>
end_date = merge_field(end_date, tag, value, line, diagnostics)
_ => ()
}
}
}
}
if open_aggregates.length() > 0 {
let (open_name, open_line) = open_aggregates[open_aggregates.length() - 1]
diagnostics.push(
error_at("unclosed-aggregate", at_line(open_name, open_line)),
)
}
if complete && !diagnostics.any(d => d.severity is Error) {
if transactions.length() == 0 {
diagnostics.push(warning_at("no-transactions", ""))
} else {
if account_id == "" {
diagnostics.push(warning_at("missing-acctid", ""))
}
if currency == "" {
diagnostics.push(warning_at("missing-curdef", ""))
}
}
}
{
header,
signon,
bank_id,
branch_id,
account_id,
account_type,
currency,
start_date,
end_date,
ledger_balance,
avail_balance,
transactions,
investments,
diagnostics,
}
}