// Semantic validation. This layer turns a parsed Ledger into a validated
// ledger: entries are ordered by date (non-transactions before transactions),
// accounts are tracked from open to close, transactions are booked against
// inventories, tolerances are inferred, and balance assertions and pads are
// checked. Diagnostics keep the source location of the directive they refer
// to so that command line output can mirror bean-check.
///|
pub(all) struct AccountInfo {
account : String
open_date : @ast.Date
mut close_date : @ast.Date?
currencies : Array[String]
booking : String?
loc : @ast.Loc
}
///|
pub(all) struct ValidatedLedger {
entries : Array[@ast.Entry]
errors : Array[@ast.Diagnostic]
warnings : Array[@ast.Diagnostic]
options : @ast.Options
plugins : Array[@ast.Plugin]
accounts : Map[String, AccountInfo]
state : @booking.BookingState
}
///|
pub fn ValidatedLedger::inventory(
self : ValidatedLedger,
account : String,
) -> @booking.Inventory {
match self.state.inventory(account) {
Some(inv) => inv.copy()
None => @booking.Inventory::new()
}
}
///|
pub fn ValidatedLedger::account_info(
self : ValidatedLedger,
account : String,
) -> AccountInfo? {
self.accounts.get(account)
}
///|
pub fn ValidatedLedger::has_errors(self : ValidatedLedger) -> Bool {
self.errors.length() > 0
}
///|
pub fn ValidatedLedger::error_count(self : ValidatedLedger) -> Int32 {
self.errors.length()
}
///|
pub fn ValidatedLedger::warning_count(self : ValidatedLedger) -> Int32 {
self.warnings.length()
}
///|
pub fn ValidatedLedger::account_names(self : ValidatedLedger) -> Array[String] {
let names = self.accounts.keys().to_array()
let mut i = 1
while i < names.length() {
let current = names[i]
let mut j = i
while j > 0 {
let prev = names[j - 1]
if prev.lexical_compare(current) > 0 {
names[j - 1] = current
names[j] = prev
j = j - 1
} else {
break
}
}
i = i + 1
}
names
}
///|
priv struct PadState {
account : String
source : String
date : @ast.Date
loc : @ast.Loc
mut consumed : Bool
}
///|
priv struct Context {
errors : Array[@ast.Diagnostic]
warnings : Array[@ast.Diagnostic]
accounts : Map[String, AccountInfo]
state : @booking.BookingState
pads : Array[PadState]
balance_seen : Map[String, @ast.Amount]
entries : Array[@ast.Entry]
options : @ast.Options
plugins : Array[@ast.Plugin]
known_opens : Map[String, @ast.Date]
}
///|
fn Context::new(ledger : @ast.Ledger) -> Context {
let known_opens : Map[String, @ast.Date] = Map([])
for entry in ledger.entries {
match entry.directive {
DOpen(open) =>
if known_opens.get(open.account) is None {
known_opens.set(open.account, entry.date)
}
_ => ()
}
}
{
errors: [],
warnings: [],
accounts: {},
state: @booking.BookingState::new(),
pads: [],
balance_seen: {},
entries: [],
options: ledger.options,
plugins: ledger.plugins,
known_opens,
}
}
///|
fn error(
ctx : Context,
code : String,
message : String,
loc : @ast.Loc,
) -> Unit {
ctx.errors.push(@ast.Diagnostic::new(code, message, loc))
}
///|
fn warning(
ctx : Context,
code : String,
message : String,
loc : @ast.Loc,
) -> Unit {
ctx.warnings.push(@ast.Diagnostic::new(code, message, loc))
}
///|
fn is_booking_method(name : String) -> Bool {
name == "STRICT" ||
name == "STRICT_WITH_SIZE" ||
name == "FIFO" ||
name == "LIFO" ||
name == "HIFO" ||
name == "AVERAGE" ||
name == "NONE"
}
///|
fn directive_rank(directive : @ast.Directive) -> Int32 {
match directive {
DOpen(_) => 0
DCommodity(_) => 1
DClose(_) => 2
DPad(_) => 3
DBalance(_) => 4
DPrice(_) => 5
DNote(_) => 6
DDocument(_) => 7
DEvent(_) => 8
DQuery(_) => 9
DCustom(_) => 10
DTransaction(_) => 11
}
}
///|
fn sorted_entries(entries : Array[@ast.Entry]) -> Array[@ast.Entry] {
let result = []
for entry in entries {
result.push(entry)
}
let mut i = 1
while i < result.length() {
let current = result[i]
let mut j = i
while j > 0 {
let prev = result[j - 1]
let date_cmp = prev.date.compare(current.date)
let rank_cmp = directive_rank(prev.directive).compare(
directive_rank(current.directive),
)
let should_swap = date_cmp > 0 || (date_cmp == 0 && rank_cmp > 0)
if should_swap {
result[j - 1] = current
result[j] = prev
j = j - 1
} else {
break
}
}
i = i + 1
}
result
}
///|
fn add_weight(target : Array[@ast.Amount], amount : @ast.Amount) -> Unit {
for i in 0.. @decimal.Decimal {
let mut total = @decimal.Decimal::zero()
for amount in weights {
if amount.currency == currency {
total = total.add(amount.number)
}
}
total
}
///|
fn infer_currency(weights : Array[@ast.Amount]) -> String? {
let nonzero = []
for amount in weights {
if !amount.number.is_zero() && !nonzero.contains(amount.currency) {
nonzero.push(amount.currency)
}
}
if nonzero.length() == 1 {
return Some(nonzero[0])
}
if nonzero.length() == 0 && weights.length() == 1 {
return Some(weights[0].currency)
}
None
}
///|
fn larger(a : @decimal.Decimal, b : @decimal.Decimal) -> @decimal.Decimal {
if a.less_than(b) {
b
} else {
a
}
}
///|
fn is_active(info : AccountInfo, date : @ast.Date) -> Bool {
if date.less_than(info.open_date) {
return false
}
match info.close_date {
Some(close_date) => !close_date.less_than(date)
None => true
}
}
///|
fn check_account(
ctx : Context,
account : String,
date : @ast.Date,
loc : @ast.Loc,
require_active : Bool,
) -> AccountInfo? {
match ctx.accounts.get(account) {
Some(info) => {
if require_active && !is_active(info, date) {
error(
ctx,
"validation.inactive_account",
"Invalid reference to inactive account '" + account + "'",
loc,
)
}
Some(info)
}
None => {
match ctx.known_opens.get(account) {
Some(open_date) =>
if date.less_than(open_date) {
error(
ctx,
"validation.inactive_account",
"Invalid reference to inactive account '" + account + "'",
loc,
)
} else {
error(
ctx,
"validation.unknown_account",
"Invalid reference to unknown account '" + account + "'",
loc,
)
}
None =>
error(
ctx,
"validation.unknown_account",
"Invalid reference to unknown account '" + account + "'",
loc,
)
}
None
}
}
}
///|
fn check_currency(
ctx : Context,
info : AccountInfo?,
currency : String,
loc : @ast.Loc,
) -> Unit {
match info {
Some(info) =>
if info.currencies.length() > 0 && !info.currencies.contains(currency) {
error(
ctx,
"validation.currency",
"Invalid currency " + currency + " for account '" + info.account + "'",
loc,
)
}
None => ()
}
}
///|
fn process_open(ctx : Context, entry : @ast.Entry, open : @ast.Open) -> Unit {
match open.booking {
Some(booking) =>
if !is_booking_method(booking) {
error(
ctx,
"validation.booking_method",
"Invalid booking method: " + booking,
entry.loc,
)
}
None => ()
}
if ctx.accounts.get(open.account) is Some(_) {
error(
ctx,
"validation.duplicate_open",
"Duplicate open directive for " + open.account,
entry.loc,
)
return
}
let info = {
account: open.account,
open_date: entry.date,
close_date: None,
currencies: open.currencies.copy(),
booking: open.booking,
loc: entry.loc,
}
ctx.accounts.set(open.account, info)
match open.booking {
Some(booking) =>
if is_booking_method(booking) {
ctx.state.set_booking(open.account, booking)
}
None => ()
}
}
///|
fn process_close(ctx : Context, entry : @ast.Entry, close : @ast.Close) -> Unit {
match ctx.accounts.get(close.account) {
Some(info) =>
match info.close_date {
Some(_) =>
error(
ctx,
"validation.duplicate_close",
"Duplicate close directive for " + close.account,
entry.loc,
)
None => {
info.close_date = Some(entry.date)
ctx.accounts.set(close.account, info)
}
}
None =>
error(
ctx,
"validation.close_unopened",
"Unopened account " + close.account + " is being closed",
entry.loc,
)
}
}
///|
fn process_pad(ctx : Context, entry : @ast.Entry, pad : @ast.Pad) -> Unit {
check_account(ctx, pad.account, entry.date, entry.loc, true) |> ignore
check_account(ctx, pad.source_account, entry.date, entry.loc, true) |> ignore
ctx.pads.push({
account: pad.account,
source: pad.source_account,
date: entry.date,
loc: entry.loc,
consumed: false,
})
}
///|
fn is_subaccount(name : String, parent : String) -> Bool {
if name == parent {
return true
}
let name_parts = @ast.account_parts(name)
let parent_parts = @ast.account_parts(parent)
if name_parts.length() <= parent_parts.length() {
return false
}
for i in 0.. @decimal.Decimal {
let mut total = @decimal.Decimal::zero()
for name in ctx.state.accounts.keys().to_array() {
if is_subaccount(name, account) {
match ctx.state.accounts.get(name) {
Some(state) => total = total.add(state.inventory.quantity(currency))
None => ()
}
}
}
total
}
///|
fn balance_tolerance(ctx : Context, balance : @ast.Balance) -> @decimal.Decimal {
match balance.tolerance {
Some(explicit) => explicit.abs()
None =>
balance.amount.number
.last_digit_place()
.abs()
.mul(ctx.options.tolerance_multiplier)
}
}
///|
fn padding_narration(
expected : @ast.Amount,
difference : @decimal.Decimal,
) -> String {
"(Padding inserted for Balance of " +
expected.to_string() +
" for difference " +
difference.to_string() +
" " +
expected.currency +
")"
}
///|
fn insert_padding(
ctx : Context,
pad : PadState,
expected : @ast.Amount,
difference : @decimal.Decimal,
) -> Unit {
let target = @ast.Posting::new(pad.account)
target.units = Some(@ast.Amount::new(difference, expected.currency))
let source = @ast.Posting::new(pad.source)
source.units = Some(@ast.Amount::new(difference.neg(), expected.currency))
let txn = @ast.Transaction::new(
"P",
None,
padding_narration(expected, difference),
[],
[],
)
txn.postings.push(target)
txn.postings.push(source)
let entry = @ast.Entry::new(
pad.date,
pad.loc,
@ast.Directive::DTransaction(txn),
)
ctx.entries.push(entry)
ctx.state
.inventory_for(pad.account)
.add_amount(@ast.Amount::new(difference, expected.currency))
ctx.state
.inventory_for(pad.source)
.add_amount(@ast.Amount::new(difference.neg(), expected.currency))
}
///|
fn applicable_pad(ctx : Context, account : String, date : @ast.Date) -> Int32? {
let mut result : Int32? = None
for i in 0.. Unit {
let info = check_account(ctx, balance.account, entry.date, entry.loc, false)
match info {
Some(info) =>
if info.currencies.length() > 0 &&
!info.currencies.contains(balance.amount.currency) {
error(
ctx,
"validation.balance_currency",
"Invalid currency '" +
balance.amount.currency +
"' for Balance directive",
entry.loc,
)
}
None => ()
}
let key = balance.account + "@" + entry.date.to_string()
match ctx.balance_seen.get(key) {
Some(previous) =>
if previous.currency != balance.amount.currency ||
!previous.number.equal(balance.amount.number) {
error(
ctx,
"validation.duplicate_balance",
"Duplicate balance assertion with different amounts",
entry.loc,
)
}
None => ()
}
ctx.balance_seen.set(key, balance.amount)
let actual = accumulated_quantity(
ctx,
balance.account,
balance.amount.currency,
)
let tolerance = balance_tolerance(ctx, balance)
let difference = balance.amount.number.sub(actual)
let pad_index = applicable_pad(ctx, balance.account, entry.date)
match pad_index {
Some(index) => {
let pad = ctx.pads[index]
if difference.abs().less_equal(tolerance) {
// A pad is only consumed when it actually inserts a padding entry.
return
}
pad.consumed = true
ctx.pads[index] = pad
insert_padding(ctx, pad, balance.amount, difference)
return
}
None => ()
}
if difference.abs().less_equal(tolerance) {
return
}
let direction = if difference.is_positive() {
"too little"
} else {
"too much"
}
error(
ctx,
"validation.balance",
"Balance failed for '" +
balance.account +
"': expected " +
balance.amount.to_string() +
" != accumulated " +
actual.to_string() +
" " +
balance.amount.currency +
" (" +
difference.abs().to_string() +
" " +
direction +
")",
entry.loc,
)
}
///|
fn needs_cost_inference(spec : @ast.CostSpec) -> Bool {
(spec.number_per is None && spec.number_total is None) ||
spec.currency is None
}
///|
fn book_explicit(
ctx : Context,
entry : @ast.Entry,
index : Int32,
posting : @ast.Posting,
weights : Array[@ast.Amount],
booked : Map[Int32, Array[@ast.Posting]],
) -> Bool {
let outcome = @booking.book_posting(
ctx.state,
posting.account,
posting,
entry.date,
ctx.options.booking_method,
)
match outcome.issue {
Some(issue) => {
error(ctx, "validation.booking", issue.message, entry.loc)
false
}
None =>
if outcome.needs_inference {
error(
ctx,
"validation.cost",
"Failed to categorize posting " + (index + 1).to_string(),
entry.loc,
)
false
} else {
if outcome.positions.length() > 0 {
let price = match posting.price {
Some(@ast.Total(amount)) =>
if outcome.positions.length() > 1 {
let rate = amount.number
.div(posting.units.unwrap().number.abs())
.unwrap()
Some(@ast.PerUnit(@ast.Amount::new(rate, amount.currency)))
} else {
posting.price
}
_ => posting.price
}
booked.set(
index,
outcome.positions.map(fn(position) {
{
..posting,
units: Some(position.units),
price,
cost: position.cost.map(fn(cost) {
let spec = @ast.CostSpec::new_per(
cost.number,
cost.currency,
cost.date,
cost.label,
)
{
..spec,
merge: posting.cost.map(fn(c) { c.merge }).unwrap_or(false),
}
}),
}
}),
)
}
for weight in outcome.weights {
add_weight(weights, weight)
}
true
}
}
}
///|
fn infer_cost(
ctx : Context,
entry : @ast.Entry,
index : Int32,
posting : @ast.Posting,
weights : Array[@ast.Amount],
) -> Unit {
let units = match posting.units {
Some(units) => units
None => return
}
let spec = match posting.cost {
Some(spec) => spec
None => return
}
let currency = match spec.currency {
Some(currency) => Some(currency)
None => infer_currency(weights)
}
match currency {
None =>
error(
ctx,
"validation.cost",
"Failed to categorize posting " + (index + 1).to_string(),
entry.loc,
)
Some(currency) => {
let number = match spec.number_per {
Some(number) => Some(number)
None =>
match spec.number_total {
Some(total) =>
if units.number.is_zero() {
None
} else {
total.div(units.number.abs())
}
None =>
if units.number.is_zero() {
None
} else {
weight_sum(weights, currency).neg().div(units.number)
}
}
}
match number {
None =>
error(
ctx,
"validation.cost",
"Failed to categorize posting " + (index + 1).to_string(),
entry.loc,
)
Some(number) => {
let date = match spec.date {
Some(date) => date
None => entry.date
}
let cost = @ast.Cost::new(number, currency, Some(date), spec.label)
posting.cost = Some(
@ast.CostSpec::new_per(number, currency, Some(date), spec.label),
)
let booked = @booking.book_augmentation_with_cost(
ctx.state,
posting.account,
units,
cost,
posting.price,
)
for weight in booked {
add_weight(weights, weight)
}
}
}
}
}
}
///|
fn interpolate_missing(
ctx : Context,
entry : @ast.Entry,
posting : @ast.Posting,
weights : Array[@ast.Amount],
) -> Unit {
if posting.cost is Some(_) {
error(
ctx,
"validation.cost",
"Failed to categorize posting with a missing amount and a cost",
entry.loc,
)
return
}
let currency = match posting.units_currency {
Some(currency) => Some(currency)
None => infer_currency(weights)
}
match currency {
None =>
error(
ctx,
"validation.missing",
"Cannot infer the missing posting amount",
entry.loc,
)
Some(currency) => {
let amount = match posting.price {
Some(price) =>
match price {
PerUnit(unit_price) => {
let per = unit_price.number
if per.is_zero() {
@decimal.Decimal::zero()
} else {
match weight_sum(weights, unit_price.currency).neg().div(per) {
Some(value) => value
None => @decimal.Decimal::zero()
}
}
}
Total(_) => weight_sum(weights, currency).neg()
}
None => weight_sum(weights, currency).neg()
}
posting.units = Some(@ast.Amount::new(amount, currency))
match posting.price {
Some(price) =>
match price {
PerUnit(unit_price) =>
add_weight(
weights,
@ast.Amount::new(
amount.mul(unit_price.number),
unit_price.currency,
),
)
Total(total) =>
add_weight(
weights,
@ast.Amount::new(total.number, total.currency),
)
}
None => add_weight(weights, @ast.Amount::new(amount, currency))
}
}
}
}
///|
fn posting_tolerance(
ctx : Context,
postings : Array[@ast.Posting],
currency : String,
) -> @decimal.Decimal {
let mut tolerance = @decimal.Decimal::zero()
for posting in postings {
let units = match posting.units {
Some(units) => units
None => continue
}
if units.currency == currency && units.number.has_fractional_digits() {
tolerance = larger(
tolerance,
units.number
.last_digit_place()
.abs()
.mul(ctx.options.tolerance_multiplier),
)
}
if ctx.options.infer_tolerance_from_cost {
match posting.cost {
Some(spec) =>
match spec.number_per {
Some(number) =>
match spec.currency {
Some(cost_currency) =>
if cost_currency == currency {
tolerance = larger(
tolerance,
number
.last_digit_place()
.abs()
.mul(units.number.abs())
.mul(ctx.options.tolerance_multiplier),
)
}
None => ()
}
None =>
match spec.number_total {
Some(total) =>
match spec.currency {
Some(cost_currency) =>
if cost_currency == currency {
tolerance = larger(
tolerance,
total
.last_digit_place()
.abs()
.mul(ctx.options.tolerance_multiplier),
)
}
None => ()
}
None => ()
}
}
None => ()
}
}
}
match ctx.options.inferred_tolerance_default.get(currency) {
Some(default) => larger(tolerance, default)
None => tolerance
}
}
///|
fn check_balanced(
ctx : Context,
entry : @ast.Entry,
postings : Array[@ast.Posting],
weights : Array[@ast.Amount],
) -> Unit {
let totals = []
for weight in weights {
add_weight(totals, weight)
}
for total in totals {
if total.number.is_zero() {
continue
}
let tolerance = posting_tolerance(ctx, postings, total.currency)
if total.number.abs().less_equal(tolerance) {
continue
}
error(
ctx,
"validation.unbalanced",
"Transaction does not balance: (" +
total.number.to_string() +
" " +
total.currency +
")",
entry.loc,
)
}
}
///|
fn add_currency(currencies : Array[String], currency : String) -> Unit {
if currency == "" {
return
}
if !currencies.contains(currency) {
currencies.push(currency)
}
}
///|
fn transaction_currencies(postings : Array[@ast.Posting]) -> Array[String] {
let currencies : Array[String] = []
for posting in postings {
match posting.units {
Some(units) => add_currency(currencies, units.currency)
None => ()
}
match posting.units_currency {
Some(currency) => add_currency(currencies, currency)
None => ()
}
match posting.cost {
Some(spec) =>
match spec.currency {
Some(currency) => add_currency(currencies, currency)
None => ()
}
None => ()
}
match posting.price {
Some(price) => add_currency(currencies, price.amount().currency)
None => ()
}
}
currencies
}
///|
/// Resolves postings written with a bare number and no currency. When the
/// transaction mentions exactly one currency it is applied to every such
/// posting; otherwise the transaction cannot be categorized.
fn resolve_missing_currencies(
ctx : Context,
entry : @ast.Entry,
postings : Array[@ast.Posting],
) -> Bool {
let missing : Array[Int32] = []
for i in 0.. if units.currency == "" { missing.push(i) }
None => ()
}
}
if missing.length() == 0 {
return true
}
let currencies = transaction_currencies(postings)
if currencies.length() != 1 {
let target = postings[missing[0]].loc
let loc = if target.line > 0 { target } else { entry.loc }
error(
ctx,
"validation.cost",
"Failed to categorize posting " + (missing[0] + 1).to_string(),
loc,
)
return false
}
for index in missing {
let units = postings[index].units.unwrap()
postings[index].units = Some(@ast.Amount::new(units.number, currencies[0]))
}
true
}
///|
fn process_transaction(
ctx : Context,
entry : @ast.Entry,
txn : @ast.Transaction,
) -> Unit {
let postings = txn.postings
let booked : Map[Int32, Array[@ast.Posting]] = Map([])
let mut booking_failed = !resolve_missing_currencies(ctx, entry, postings)
for posting in postings {
let info = check_account(ctx, posting.account, entry.date, entry.loc, true)
match posting.units {
Some(units) =>
if units.currency != "" {
check_currency(ctx, info, units.currency, entry.loc)
}
None => ()
}
}
let weights : Array[@ast.Amount] = []
let deferred : Array[Int32] = []
let missing : Array[Int32] = []
// Reductions are booked first so that augmentations in the same transaction
// cannot change which lots are matched.
for i in 0..
match posting.cost {
Some(_) =>
if units.number.is_negative() {
if !book_explicit(ctx, entry, i, posting, weights, booked) {
booking_failed = true
}
}
None => ()
}
None => ()
}
}
for i in 0..
match posting.cost {
Some(spec) =>
if !units.number.is_negative() {
if needs_cost_inference(spec) {
deferred.push(i)
} else if !book_explicit(ctx, entry, i, posting, weights, booked) {
booking_failed = true
}
}
None =>
if !book_explicit(ctx, entry, i, posting, weights, booked) {
booking_failed = true
}
}
None => missing.push(i)
}
}
if deferred.length() > 1 {
for index in deferred {
let target = postings[index].loc
let loc = if target.line > 0 { target } else { entry.loc }
error(
ctx,
"validation.cost",
"Failed to categorize posting " + (index + 1).to_string(),
loc,
)
}
booking_failed = true
} else if deferred.length() == 1 {
infer_cost(ctx, entry, deferred[0], postings[deferred[0]], weights)
}
if missing.length() > 1 {
let last = missing[missing.length() - 1]
let target = postings[last].loc
let loc = if target.line > 0 { target } else { entry.loc }
error(
ctx, "validation.missing", "You may not have more than one auto-posting per currency",
loc,
)
booking_failed = true
} else if missing.length() == 1 {
interpolate_missing(ctx, entry, postings[missing[0]], weights)
}
// A transaction whose postings cannot be booked is aborted before balancing,
// matching Beancount's error precedence.
if !booking_failed {
check_balanced(ctx, entry, postings, weights)
let detailed : Array[@ast.Posting] = []
for i in 0.. detailed.append(parts)
None => detailed.push(postings[i])
}
}
postings.clear()
postings.append(detailed)
}
}
///|
/// Validates a parsed ledger and returns the bookable, ordered representation
/// used by reports and the query engine.
pub fn validate_ledger(ledger : @ast.Ledger) -> ValidatedLedger {
let ctx = Context::new(ledger)
for diagnostic in ledger.errors {
ctx.errors.push(diagnostic)
}
for plugin in ledger.plugins {
warning(
ctx,
"validation.plugin",
"Plugin '" + plugin.name + "' is not supported and will be ignored",
plugin.loc,
)
}
for pair in ledger.options.raw {
if pair.0 == "inferred_tolerance_multiplier" {
warning(
ctx,
"validation.deprecated_option",
"Renamed to 'tolerance_multiplier'.",
@ast.Loc::new(ledger_file_name(ledger), 0),
)
}
}
let ordered = sorted_entries(ledger.entries)
for entry in ordered {
match entry.directive {
DOpen(open) => process_open(ctx, entry, open)
DClose(close) => process_close(ctx, entry, close)
DPad(pad) => process_pad(ctx, entry, pad)
DBalance(balance) => process_balance(ctx, entry, balance)
DTransaction(txn) => process_transaction(ctx, entry, txn)
DNote(note) =>
check_account(ctx, note.account, entry.date, entry.loc, false) |> ignore
DDocument(document) => {
check_account(ctx, document.account, entry.date, entry.loc, false)
|> ignore
warning(
ctx,
"validation.document_unchecked",
"Document is metadata only; file existence and contents are not checked",
entry.loc,
)
}
DCommodity(_) | DPrice(_) | DEvent(_) | DQuery(_) | DCustom(_) => ()
}
ctx.entries.push(entry)
}
for pad in ctx.pads {
if !pad.consumed {
error(ctx, "validation.pad", "Unused Pad entry", pad.loc)
}
}
let entries = sorted_entries(ctx.entries)
let accounts : Map[String, AccountInfo] = Map([])
for name in ctx.accounts.keys().to_array() {
match ctx.accounts.get(name) {
Some(info) => accounts.set(name, info)
None => ()
}
}
{
entries,
errors: ctx.errors,
warnings: ctx.warnings,
options: ctx.options,
plugins: ctx.plugins,
accounts,
state: ctx.state,
}
}
///|
fn ledger_file_name(ledger : @ast.Ledger) -> String {
if ledger.entries.length() > 0 {
ledger.entries[0].loc.file
} else {
""
}
}
///|
fn txn_signature(entry : @ast.Entry, txn : @ast.Transaction) -> String {
let sb = StringBuilder()
sb.write_string(entry.date.to_string())
sb.write_string("|")
sb.write_string(txn.flag)
sb.write_string("|")
match txn.payee {
Some(payee) => sb.write_string(payee)
None => ()
}
sb.write_string("|")
sb.write_string(txn.narration)
for tag in txn.tags {
sb.write_string("|#")
sb.write_string(tag)
}
for link in txn.links {
sb.write_string("|^")
sb.write_string(link)
}
for posting in txn.postings {
sb.write_string("|")
match posting.flag {
Some(flag) => sb.write_string(flag)
None => ()
}
sb.write_string(posting.account)
match posting.units {
Some(units) => {
sb.write_string(" ")
sb.write_string(units.number.to_string())
sb.write_string(" ")
sb.write_string(units.currency)
}
None => ()
}
match posting.cost {
Some(cost) => {
sb.write_string(" ")
sb.write_string(cost.to_string())
}
None => ()
}
match posting.price {
Some(price) => {
sb.write_string(" ")
sb.write_string(price.to_string())
}
None => ()
}
}
sb.to_string()
}
///|
/// Finds exact duplicate transactions (same date, flag, narration, postings,
/// and so on). This check is exposed separately because bean-check does not
/// report duplicates by default.
pub fn find_duplicate_transactions(
entries : Array[@ast.Entry],
) -> Array[(@ast.Loc, @ast.Loc)] {
let seen : Map[String, @ast.Loc] = Map([])
let duplicates = []
for entry in entries {
match entry.directive {
DTransaction(txn) => {
let signature = txn_signature(entry, txn)
match seen.get(signature) {
Some(first) => duplicates.push((first, entry.loc))
None => seen.set(signature, entry.loc)
}
}
_ => ()
}
}
duplicates
}