///|
/// Balance result for one camt.053 statement.
pub(all) enum StatementBalanceStatus {
Balanced
BalanceMismatch
BalanceIncomplete
}
///|
pub fn StatementBalanceStatus::name(self : StatementBalanceStatus) -> String {
match self {
Balanced => "BALANCED"
BalanceMismatch => "MISMATCH"
BalanceIncomplete => "INCOMPLETE"
}
}
///|
/// One opening, closing, interim or previously closed balance record.
pub struct StatementBalance {
code : String
amount : Decimal
currency : String
credit_debit : String
signed_amount : Decimal
}
///|
/// Exact duplicate candidates grouped by account, transaction ID, amount,
/// currency, direction and reversal flag.
pub struct DuplicateStatementEntry {
end_to_end_id : String
amount : Decimal
currency : String
account_iban : String?
credit_debit : String
reversal : Bool
occurrences : Int
}
///|
/// Independent audit result for one camt.053 bank statement.
pub struct StatementAuditReport {
account_iban : String?
account_count : Int
entry_count : Int
total_credits : Decimal
total_debits : Decimal
net_movement : Decimal
opening_balance : StatementBalance?
closing_balance : StatementBalance?
expected_closing : Decimal?
closing_difference : Decimal?
balance_status : StatementBalanceStatus
duplicate_entries : Array[DuplicateStatementEntry]
diagnostics : Array[Diagnostic]
}
///|
fn balance_code_supported(code : String) -> Bool {
code == "OPBD" || code == "PRCD" || code == "CLBD" || code == "ITBD"
}
///|
fn signed_balance_amount(amount : Decimal, direction : String) -> Decimal {
if direction == "DBIT" {
Decimal::zero().subtract(amount)
} else {
amount
}
}
///|
fn parse_statement_balances(
xml_text : String,
diagnostics : Array[Diagnostic],
) -> Array[StatementBalance] raise XmlParseError {
let reader = XmlReader::from_string(xml_text)
let path : Array[String] = []
let balances : Array[StatementBalance] = []
let mut in_balance = false
let mut code : String? = None
let mut amount_text : String? = None
let mut currency : String? = None
let mut direction : String? = None
for ;; {
match reader.read_event().kind {
Start(element) => {
path.push(element.name)
if path_equals(path, ["Document", "BkToCstmrStmt", "Stmt", "Bal"]) {
in_balance = true
code = None
amount_text = None
currency = None
direction = None
}
if path_equals(path, ["Document", "BkToCstmrStmt", "Stmt", "Bal", "Amt"]) {
currency = element.get("Ccy")
}
}
Empty(element) => {
path.push(element.name)
if path_equals(path, ["Document", "BkToCstmrStmt", "Stmt", "Bal", "Amt"]) {
currency = element.get("Ccy")
}
let _ = path.remove(path.length() - 1)
}
Text(content) =>
if path_equals(path, [
"Document", "BkToCstmrStmt", "Stmt", "Bal", "Tp", "CdOrPrtry", "Cd",
]) {
code = Some(content)
} else if path_equals(path, [
"Document", "BkToCstmrStmt", "Stmt", "Bal", "Amt",
]) {
amount_text = Some(content)
} else if path_equals(path, [
"Document", "BkToCstmrStmt", "Stmt", "Bal", "CdtDbtInd",
]) {
direction = Some(content)
}
End(name) => {
if name == "Bal" && in_balance {
match code {
Some(balance_code) =>
if balance_code_supported(balance_code) {
match amount_text {
Some(raw_amount) =>
match Decimal::parse(raw_amount) {
Some(amount) => {
match currency {
Some(ccy) =>
if !is_currency(ccy) {
push_diagnostic(
diagnostics, "BALANCE_CURRENCY_INVALID", "Stmt/Bal/Amt/@Ccy",
"Balance currency must be a three-letter uppercase code.",
)
}
None =>
push_diagnostic(
diagnostics, "BALANCE_CURRENCY_MISSING", "Stmt/Bal/Amt/@Ccy",
"Balance currency is required.",
)
}
match direction {
Some(direction_value) =>
if direction_value != "CRDT" &&
direction_value != "DBIT" {
push_diagnostic(
diagnostics, "BALANCE_DIRECTION_INVALID", "Stmt/Bal/CdtDbtInd",
"Balance credit/debit indicator must be CRDT or DBIT.",
)
}
None =>
push_diagnostic(
diagnostics, "BALANCE_DIRECTION_MISSING", "Stmt/Bal/CdtDbtInd",
"Balance credit/debit indicator is required.",
)
}
match (currency, direction) {
(Some(ccy), Some(direction_value)) =>
if is_currency(ccy) &&
(
direction_value == "CRDT" ||
direction_value == "DBIT"
) {
balances.push({
code: balance_code,
amount,
currency: ccy,
credit_debit: direction_value,
signed_amount: signed_balance_amount(
amount, direction_value,
),
})
}
_ => ()
}
}
None =>
push_diagnostic(
diagnostics, "BALANCE_AMOUNT_INVALID", "Stmt/Bal/Amt",
"Balance amount must be a decimal with up to two decimal places.",
)
}
None =>
push_diagnostic(
diagnostics, "BALANCE_AMOUNT_MISSING", "Stmt/Bal/Amt", "Balance amount value is required.",
)
}
}
None =>
push_diagnostic(
diagnostics, "BALANCE_TYPE_MISSING", "Stmt/Bal/Tp/CdOrPrtry/Cd",
"Balance type code is required.",
)
}
in_balance = false
}
if path.length() > 0 {
let _ = path.remove(path.length() - 1)
}
}
Eof => break
}
}
balances
}
///|
fn find_balance(
balances : Array[StatementBalance],
preferred : String,
fallback : String,
) -> StatementBalance? {
for balance in balances {
if balance.code == preferred {
return Some(balance)
}
}
for balance in balances {
if balance.code == fallback {
return Some(balance)
}
}
None
}
///|
fn account_text(record : StatementRecord) -> String {
match record.account_iban {
Some(value) => value
None => ""
}
}
///|
fn duplicate_key(record : StatementRecord) -> String {
record.end_to_end_id +
"|" +
account_text(record) +
"|" +
record.amount.to_string() +
"|" +
record.currency +
"|" +
record.credit_debit +
"|" +
(if record.reversal { "1" } else { "0" })
}
///|
fn collect_duplicate_entries(
records : Array[StatementRecord],
diagnostics : Array[Diagnostic],
) -> Array[DuplicateStatementEntry] {
let counts : Map[String, Int] = Map([])
for record in records {
let key = duplicate_key(record)
let count = match counts.get(key) {
Some(value) => value
None => 0
}
counts.set(key, count + 1)
}
let emitted : Map[String, Bool] = Map([])
let duplicates : Array[DuplicateStatementEntry] = []
for record in records {
let key = duplicate_key(record)
let count = counts.get(key).unwrap()
if count > 1 && !emitted.contains(key) {
emitted.set(key, true)
duplicates.push({
end_to_end_id: record.end_to_end_id,
amount: record.amount,
currency: record.currency,
account_iban: record.account_iban,
credit_debit: record.credit_debit,
reversal: record.reversal,
occurrences: count,
})
push_diagnostic(
diagnostics,
"STATEMENT_ENTRY_DUPLICATE",
"Ntry/NtryDtls/TxDtls/Refs/EndToEndId",
"Exact statement entry is repeated " +
count.to_string() +
" times for end-to-end ID " +
record.end_to_end_id +
".",
)
}
}
duplicates
}
///|
/// Audit statement balances, movement totals, account scope and exact duplicates.
pub fn statement_audit(
xml_text : String,
) -> StatementAuditReport raise XmlParseError {
let fields = flatten_xml(xml_text)
let records = statement_records(xml_text)
let diagnostics : Array[Diagnostic] = []
let balances = parse_statement_balances(xml_text, diagnostics)
let account_ids : Map[String, Bool] = Map([])
let account_order : Array[String] = []
for field in fields {
if field.path.has_suffix("/Stmt/Acct/Id/IBAN") {
match field.text {
Some(value) =>
if value != "" && !account_ids.contains(value) {
account_ids.set(value, true)
account_order.push(value)
}
None => ()
}
}
}
let account_count = account_order.length()
let account_iban = if account_count > 0 {
Some(account_order[0])
} else {
None
}
if account_count == 0 {
push_diagnostic(
diagnostics, "STATEMENT_ACCOUNT_MISSING", "Stmt/Acct/Id/IBAN", "Statement account IBAN is required for balance audit.",
)
} else if account_count > 1 {
push_diagnostic(
diagnostics, "MULTI_ACCOUNT_STATEMENT_UNSUPPORTED", "Stmt/Acct/Id/IBAN", "This version audits one statement account per file; multiple account IBANs were found.",
)
}
let xml_entry_count = elements_by_suffix(fields, "/Ntry").length()
if records.length() != xml_entry_count {
push_diagnostic(
diagnostics, "STATEMENT_ENTRY_INCOMPLETE", "Ntry", "Each statement entry must contain an end-to-end ID, amount, currency and valid direction for audit.",
)
}
let currency_ids : Map[String, Bool] = Map([])
let mut total_credits = Decimal::zero()
let mut total_debits = Decimal::zero()
for record in records {
currency_ids.set(record.currency, true)
if record.credit_debit == "CRDT" {
total_credits = total_credits.add(record.amount)
} else if record.credit_debit == "DBIT" {
total_debits = total_debits.add(record.amount)
} else {
push_diagnostic(
diagnostics, "ENTRY_CREDIT_DEBIT_INVALID", "Ntry/CdtDbtInd", "Credit/debit indicator must be CRDT or DBIT.",
)
}
}
let net_movement = total_credits.subtract(total_debits)
let mut entry_currency : String? = None
for record in records {
if entry_currency is None {
entry_currency = Some(record.currency)
}
}
if currency_ids.length() > 1 {
push_diagnostic(
diagnostics, "STATEMENT_CURRENCY_MIXED", "Ntry/Amt/@Ccy", "Statement entries use more than one currency; balance arithmetic requires one currency.",
)
}
let opening_balance = find_balance(balances, "OPBD", "PRCD")
let closing_balance = find_balance(balances, "CLBD", "ITBD")
match opening_balance {
Some(_) => ()
None =>
push_diagnostic(
diagnostics, "OPENING_BALANCE_MISSING", "Stmt/Bal/Tp/CdOrPrtry/Cd", "Opening balance OPBD or previously closed balance PRCD is required.",
)
}
match closing_balance {
Some(_) => ()
None =>
push_diagnostic(
diagnostics, "CLOSING_BALANCE_MISSING", "Stmt/Bal/Tp/CdOrPrtry/Cd", "Closing balance CLBD or interim booked balance ITBD is required.",
)
}
for balance in balances {
match entry_currency {
Some(currency) =>
if balance.currency != currency {
push_diagnostic(
diagnostics,
"BALANCE_CURRENCY_MISMATCH",
"Stmt/Bal/Amt/@Ccy",
"Balance currency " +
balance.currency +
" does not match statement entry currency " +
currency +
".",
)
}
None => ()
}
}
let expected_closing : Decimal? = match opening_balance {
Some(opening) => Some(opening.signed_amount.add(net_movement))
None => None
}
let closing_difference : Decimal? = match (opening_balance, closing_balance) {
(Some(opening), Some(closing)) =>
Some(
closing.signed_amount.subtract(opening.signed_amount.add(net_movement)),
)
_ => None
}
let balance_status = match
(opening_balance, closing_balance, closing_difference) {
(Some(_), Some(_), Some(difference)) =>
if difference.compare(Decimal::zero()) == 0 {
Balanced
} else {
push_diagnostic(
diagnostics,
"BALANCE_MISMATCH",
"Stmt/Bal",
"Closing balance does not equal opening balance plus credits minus debits; difference is " +
difference.to_string() +
".",
)
BalanceMismatch
}
_ => BalanceIncomplete
}
let duplicate_entries = collect_duplicate_entries(records, diagnostics)
{
account_iban,
account_count,
entry_count: records.length(),
total_credits,
total_debits,
net_movement,
opening_balance,
closing_balance,
expected_closing,
closing_difference,
balance_status,
duplicate_entries,
diagnostics,
}
}
///|
pub fn StatementAuditReport::is_valid(self : StatementAuditReport) -> Bool {
self.diagnostics.length() == 0
}
///|
fn balance_to_json(balance : StatementBalance?) -> Json {
match balance {
Some(value) => {
let item = Map([])
item.set("code", Json::string(value.code))
item.set("amount", Json::string(value.amount.to_string()))
item.set("currency", Json::string(value.currency))
item.set("creditDebit", Json::string(value.credit_debit))
item.set("signedAmount", Json::string(value.signed_amount.to_string()))
Json::object(item)
}
None => Json::null()
}
}
///|
fn optional_decimal_to_json(value : Decimal?) -> Json {
match value {
Some(decimal) => Json::string(decimal.to_string())
None => Json::null()
}
}
///|
pub fn StatementAuditReport::to_text(self : StatementAuditReport) -> String {
let output = StringBuilder()
output.write_string("Bank statement audit\nAccount: ")
output.write_string(
match self.account_iban {
Some(value) => value
None => "(missing)"
},
)
output.write_string("\nAccounts: ")
output.write_string(self.account_count.to_string())
output.write_string("\nEntries: ")
output.write_string(self.entry_count.to_string())
output.write_string("\nCredits: ")
output.write_string(self.total_credits.to_string())
output.write_string("\nDebits: ")
output.write_string(self.total_debits.to_string())
output.write_string("\nNet movement: ")
output.write_string(self.net_movement.to_string())
output.write_string("\nOpening balance: ")
output.write_string(
match self.opening_balance {
Some(value) =>
value.signed_amount.to_string() +
" " +
value.currency +
" (" +
value.code +
")"
None => "(missing)"
},
)
output.write_string("\nClosing balance: ")
output.write_string(
match self.closing_balance {
Some(value) =>
value.signed_amount.to_string() +
" " +
value.currency +
" (" +
value.code +
")"
None => "(missing)"
},
)
output.write_string("\nExpected closing: ")
output.write_string(
match self.expected_closing {
Some(value) => value.to_string()
None => "(not calculable)"
},
)
output.write_string("\nClosing difference: ")
output.write_string(
match self.closing_difference {
Some(value) => value.to_string()
None => "(not calculable)"
},
)
output.write_string("\nBalance status: ")
output.write_string(self.balance_status.name())
output.write_string("\nDuplicate groups: ")
output.write_string(self.duplicate_entries.length().to_string())
for duplicate in self.duplicate_entries {
output.write_string("\n- ")
output.write_string(duplicate.end_to_end_id)
output.write_string(" | ")
output.write_string(duplicate.amount.to_string())
output.write_string(" ")
output.write_string(duplicate.currency)
output.write_string(" | ")
output.write_string(duplicate.credit_debit)
output.write_string(" | occurrences=")
output.write_string(duplicate.occurrences.to_string())
}
output.write_string("\nResult: ")
if self.is_valid() {
output.write_string("VALID")
} else {
output.write_string("ISSUES FOUND (")
output.write_string(self.diagnostics.length().to_string())
output.write_string(")")
for diagnostic in self.diagnostics {
output.write_string("\n- [")
output.write_string(diagnostic.code)
output.write_string("] ")
output.write_string(diagnostic.path)
output.write_string(": ")
output.write_string(diagnostic.message)
}
}
output.to_string()
}
///|
pub fn StatementAuditReport::to_json(self : StatementAuditReport) -> String {
let duplicates : Array[Json] = []
for duplicate in self.duplicate_entries {
let account : Json = match duplicate.account_iban {
Some(value) => Json::string(value)
None => Json::null()
}
duplicates.push({
"endToEndId": duplicate.end_to_end_id,
"amount": duplicate.amount.to_string(),
"currency": duplicate.currency,
"accountIban": account,
"creditDebit": duplicate.credit_debit,
"reversal": duplicate.reversal,
"occurrences": duplicate.occurrences,
})
}
let diagnostics : Array[Json] = []
for diagnostic in self.diagnostics {
diagnostics.push({
"code": diagnostic.code,
"path": diagnostic.path,
"message": diagnostic.message,
})
}
let account_iban : Json = match self.account_iban {
Some(value) => Json::string(value)
None => Json::null()
}
let root = Map([])
root.set("valid", Json::boolean(self.is_valid()))
root.set("accountIban", account_iban)
root.set("accountCount", Json::number(self.account_count.to_double()))
root.set("entryCount", Json::number(self.entry_count.to_double()))
root.set("totalCredits", Json::string(self.total_credits.to_string()))
root.set("totalDebits", Json::string(self.total_debits.to_string()))
root.set("netMovement", Json::string(self.net_movement.to_string()))
root.set("openingBalance", balance_to_json(self.opening_balance))
root.set("closingBalance", balance_to_json(self.closing_balance))
root.set("expectedClosing", optional_decimal_to_json(self.expected_closing))
root.set(
"closingDifference",
optional_decimal_to_json(self.closing_difference),
)
root.set("balanceStatus", Json::string(self.balance_status.name()))
root.set("duplicateGroups", Json::array(duplicates))
root.set("diagnostics", Json::array(diagnostics))
Json::object(root).stringify(indent=2)
}
///|
pub fn StatementAuditReport::to_markdown(self : StatementAuditReport) -> String {
let output = StringBuilder()
output.write_string("# Bank statement audit\n\n")
output.write_string("| Field | Value |\n|---|---|\n")
output.write_string("| Account | `")
output.write_string(
match self.account_iban {
Some(value) => value
None => "(missing)"
},
)
output.write_string("` |\n")
output.write_string("| Entries | ")
output.write_string(self.entry_count.to_string())
output.write_string(" |\n| Credits | ")
output.write_string(self.total_credits.to_string())
output.write_string(" |\n| Debits | ")
output.write_string(self.total_debits.to_string())
output.write_string(" |\n| Net movement | ")
output.write_string(self.net_movement.to_string())
output.write_string(" |\n| Balance status | `")
output.write_string(self.balance_status.name())
output.write_string("` |\n| Duplicate groups | ")
output.write_string(self.duplicate_entries.length().to_string())
output.write_string(" |\n\n## Diagnostics\n\n")
if self.is_valid() {
output.write_string("No issues found.\n")
} else {
output.write_string("| Code | Path | Message |\n|---|---|---|\n")
for diagnostic in self.diagnostics {
output.write_string("| `")
output.write_string(diagnostic.code)
output.write_string("` | `")
output.write_string(diagnostic.path)
output.write_string("` | ")
output.write_string(diagnostic.message)
output.write_string(" |\n")
}
}
output.to_string()
}