use core::fmt;
use time::Date;
use crate::core::{BalanceKey, ResolutionKind};
use crate::model::{
AccountHandle, AccountPath, AccountRef, Amount, BalanceLimit, Currency, EntryId, Label, Layer,
PeriodId, PostingRef, Side, ValueError, pad,
};
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum Rejection {
InvalidKey {
reason: ValueError,
},
TooFewPostings {
count: usize,
},
TooManyPostings {
count: usize,
max: usize,
},
InvalidPath {
posting: Option<u16>,
reason: ValueError,
},
NonPositiveAmount {
posting: u16,
},
InvalidDimensions {
posting: u16,
reason: ValueError,
},
Unbalanced {
currency: Currency,
layer: Layer,
debit: ScaledAmount,
credit: ScaledAmount,
},
InvalidLabel {
field: LabelField,
reason: ValueError,
},
InvalidMemo {
reason: ValueError,
},
TooManyDocuments {
count: usize,
max: usize,
},
TooManyRates {
count: usize,
max: usize,
},
RecordTooLarge {
bytes: usize,
max: usize,
},
TotalOutOfRange {
currency: Currency,
layer: Layer,
},
MemoSaltMissing,
NonPositiveClearingAmount,
InvalidPeriodRange {
start: Date,
end: Date,
},
InvalidOpenWindow {
open_from: Date,
open_to: Date,
},
ExponentTooLarge {
exponent: u8,
max: u8,
},
HoldLegs,
HoldExpiry {
booking: Date,
expires: Date,
},
ResolutionPostings {
count: usize,
},
PendingOutsideHold {
posting: u16,
},
ReversalWithHoldAction,
PostAmountNotPositive,
KeyConflict {
position: u64,
},
NoPeriod {
date: Date,
},
PeriodSealed {
period: PeriodId,
date: Date,
},
UnknownAccount {
account: AccountRef,
},
NotALeaf {
account: AccountPath,
},
AccountNotOpen {
account: AccountPath,
date: Date,
},
UndeclaredCurrency {
currency: Currency,
date: Date,
},
ExcessPrecision {
posting: u16,
currency: Currency,
exponent: u8,
},
MissingDimension {
posting: u16,
axis: Label,
},
ValueDateDrift {
days: u32,
max: u32,
},
AtOrBelowWatermark {
effective: Date,
watermark: Date,
},
EffectiveDateRegresses {
effective: Date,
latest: Date,
},
UnknownPeriod {
period: PeriodId,
},
ReversalTargetUnknown {
target: EntryId,
},
AlreadyReversed {
target: EntryId,
by: EntryId,
},
ReversalBeforeTarget {
target: EntryId,
booked: Date,
},
NotAMirror {
target: EntryId,
posting: Option<u16>,
},
ClearingTargetUnknown {
posting: PostingRef,
},
ClearingSideMismatch {
posting: PostingRef,
expected: Side,
},
ClearingKeyMismatch {
debit: BalanceKey,
credit: BalanceKey,
},
ClearingPrecision {
currency: Currency,
exponent: u8,
},
OverApplied {
posting: PostingRef,
open: ScaledAmount,
requested: ScaledAmount,
},
ClearingBeforePosting {
posting: PostingRef,
booked: Date,
},
ClearingUnknown {
clearing: u64,
},
ClearingAlreadyReset {
clearing: u64,
by: u64,
},
ResetBeforeClearing {
cleared_on: Date,
},
ClearingNotSettled {
posting: PostingRef,
},
AccountExists {
path: AccountPath,
handle: AccountHandle,
},
ParentHasPostings {
parent: AccountPath,
},
ClassFrozen {
account: AccountPath,
},
PostingsAfterClose {
account: AccountPath,
last_booked: Date,
},
ExponentLowered {
currency: Currency,
current: u8,
requested: u8,
},
PeriodExists {
period: PeriodId,
start: Date,
end: Date,
},
PeriodGap {
expected_start: Date,
start: Date,
},
PredecessorNotSealed {
predecessor: PeriodId,
earliest: PeriodId,
},
TooManyAccounts,
ClosingEquityInScope {
account: AccountPath,
},
ClosingAccountNotOpen {
account: AccountPath,
date: Date,
},
PolicyBeforeBookings {
effective: Date,
last_booking: Date,
},
HoldNotFound {
hold: EntryId,
},
NotAHold {
entry: EntryId,
},
HoldResolved {
hold: EntryId,
by: EntryId,
how: ResolutionKind,
},
ResolutionBeforeHold {
hold: EntryId,
held_on: Date,
},
HoldExpired {
hold: EntryId,
expires: Date,
},
HoldNeverExpires {
hold: EntryId,
},
HoldNotExpired {
hold: EntryId,
expires: Date,
},
PostAmountPrecision {
hold: EntryId,
currency: Currency,
exponent: u8,
},
PostExceedsHold {
hold: EntryId,
held: ScaledAmount,
requested: ScaledAmount,
},
PostToClosedAccount {
hold: EntryId,
account: AccountRef,
date: Date,
},
HoldNotReversible {
target: EntryId,
},
BalanceOverflow {
account: Option<AccountPath>,
currency: Currency,
layer: Layer,
},
LimitExceeded {
account: AccountPath,
currency: Currency,
limit: BalanceLimit,
shortfall: ScaledAmount,
},
}
impl fmt::Display for Rejection {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let text = match self {
Self::InvalidKey { reason } => format!("[100] invalid idempotency key: {reason}"),
Self::TooFewPostings { count } => {
format!("[101] an entry needs at least two postings, found {count}")
}
Self::TooManyPostings { count, max } => {
format!("[102] an entry has {count} postings, more than the maximum of {max}")
}
Self::InvalidPath { posting, reason } => format!(
"[103] invalid account path{}: {reason}",
posting_suffix(posting)
),
Self::NonPositiveAmount { posting } => {
format!("[104] posting {posting} has an amount that is not positive")
}
Self::InvalidDimensions { posting, reason } => {
format!("[105] posting {posting} has invalid dimensions: {reason}")
}
Self::Unbalanced {
currency,
layer,
debit,
credit,
} => format!(
"[106] {currency} {layer:?} does not balance: debits {debit}, credits {credit}"
),
Self::InvalidLabel { field, reason } => format!("[107] invalid {field}: {reason}"),
Self::InvalidMemo { reason } => format!("[108] invalid memo: {reason}"),
Self::TooManyDocuments { count, max } => {
format!("[109] an entry has {count} documents, more than the maximum of {max}")
}
Self::TooManyRates { count, max } => {
format!("[110] an entry has {count} rates, more than the maximum of {max}")
}
Self::RecordTooLarge { bytes, max } => {
format!("[111] the record would be {bytes} bytes, more than the maximum of {max}")
}
Self::TotalOutOfRange { currency, layer } => format!(
"[113] the {currency} {layer:?} total of the entry is beyond the amount range"
),
Self::MemoSaltMissing => String::from("[114] the memo has no salt"),
Self::NonPositiveClearingAmount => {
String::from("[120] the clearing amount is not positive")
}
Self::InvalidPeriodRange { start, end } => {
format!("[121] the period ends on {end}, before it starts on {start}")
}
Self::InvalidOpenWindow { open_from, open_to } => {
format!("[122] the account closes on {open_to}, before it opens on {open_from}")
}
Self::ExponentTooLarge { exponent, max } => {
format!("[123] currency exponent {exponent} exceeds the ledger scale {max}")
}
Self::HoldLegs => String::from(
"[140] a hold needs exactly two pending postings, one debit and one credit",
),
Self::HoldExpiry { booking, expires } => {
format!("[141] the hold expires on {expires}, not after its booking date {booking}")
}
Self::ResolutionPostings { count } => {
format!("[142] a hold resolution carries no postings of its own, found {count}")
}
Self::PendingOutsideHold { posting } => format!(
"[143] posting {posting} is pending, but the entry opens or resolves no hold"
),
Self::ReversalWithHoldAction => {
String::from("[144] an entry cannot both reverse an entry and act on a hold")
}
Self::PostAmountNotPositive => {
String::from("[145] a partial post of a hold must be positive")
}
Self::KeyConflict { position } => format!(
"[200] the key was already used, for different content, at position {position}"
),
Self::NoPeriod { date } => format!("[300] {date} lies in no defined period"),
Self::PeriodSealed { period, date } => {
format!("[301] {date} lies in the sealed period {period}")
}
Self::UnknownAccount { account } => {
format!("[302] unknown account {}", account_ref(account))
}
Self::NotALeaf { account } => {
format!("[303] {account} has sub-accounts and cannot be posted to")
}
Self::AccountNotOpen { account, date } => {
format!("[304] {account} is not open on {date}")
}
Self::UndeclaredCurrency { currency, date } => {
format!("[305] {currency} is not declared on {date}")
}
Self::ExcessPrecision {
posting,
currency,
exponent,
} => format!(
"[306] posting {posting} has more than {exponent} decimal places for {currency}"
),
Self::MissingDimension { posting, axis } => {
format!("[307] posting {posting} lacks the required dimension {axis}")
}
Self::ValueDateDrift { days, max } => format!(
"[308] the value date is {days} days from the booking date, more than {max}"
),
Self::AtOrBelowWatermark {
effective,
watermark,
} => format!("[309] {effective} is on or before the sealed watermark {watermark}"),
Self::EffectiveDateRegresses { effective, latest } => {
format!("[310] {effective} is before the latest change, effective {latest}")
}
Self::UnknownPeriod { period } => format!("[311] unknown period {period}"),
Self::ReversalTargetUnknown { target } => {
format!("[400] the reversal target {target} does not exist")
}
Self::AlreadyReversed { target, by } => {
format!("[401] {target} was already reversed by {by}")
}
Self::ReversalBeforeTarget { target, booked } => {
format!("[402] the reversal is dated before {target}, booked on {booked}")
}
Self::NotAMirror { target, posting } => format!(
"[403] the reversal does not mirror {target}{}",
posting_suffix(posting)
),
Self::ClearingTargetUnknown { posting } => {
format!("[410] the posting {} does not exist", posting_ref(posting))
}
Self::ClearingSideMismatch { posting, expected } => format!(
"[411] the posting {} is not a {expected:?} posting",
posting_ref(posting)
),
Self::ClearingKeyMismatch { debit, credit } => format!(
"[412] the cleared postings are on different balances: {debit} and {credit}"
),
Self::ClearingPrecision { currency, exponent } => format!(
"[413] the clearing amount has more than {exponent} decimal places for {currency}"
),
Self::OverApplied {
posting,
open,
requested,
} => format!(
"[414] {requested} exceeds the {open} still open on {}",
posting_ref(posting)
),
Self::ClearingBeforePosting { posting, booked } => format!(
"[415] the clearing is dated before {}, booked on {booked}",
posting_ref(posting)
),
Self::ClearingUnknown { clearing } => {
format!("[416] no clearing was applied at position {clearing}")
}
Self::ClearingAlreadyReset { clearing, by } => format!(
"[417] the clearing at position {clearing} was already reset at position {by}"
),
Self::ResetBeforeClearing { cleared_on } => {
format!("[418] the reset is dated before the clearing of {cleared_on}")
}
Self::ClearingNotSettled { posting } => format!(
"[419] the posting {} is Pending: only Settled postings are cleared",
posting_ref(posting)
),
Self::AccountExists { path, handle } => format!(
"[420] {path} is already registered, as {handle}, with different master data"
),
Self::ParentHasPostings { parent } => {
format!("[421] {parent} has postings and cannot get sub-accounts")
}
Self::ClassFrozen { account } => {
format!("[422] the class of {account} is fixed: it has postings")
}
Self::PostingsAfterClose {
account,
last_booked,
} => format!("[423] {account} has postings up to {last_booked}"),
Self::ExponentLowered {
currency,
current,
requested,
} => format!(
"[424] the exponent of {currency} is {current} and cannot be lowered to {requested}"
),
Self::PeriodExists { period, start, end } => {
format!("[425] the period {period} is already defined as {start} to {end}")
}
Self::PeriodGap {
expected_start,
start,
} => format!("[426] the next period must start on {expected_start}, not {start}"),
Self::PredecessorNotSealed {
predecessor,
earliest,
} => format!(
"[427] the preceding period {predecessor} is not sealed; seal from {earliest} on \
(seal_through seals every open period in order)"
),
Self::TooManyAccounts => {
String::from("[428] the ledger has the maximum number of accounts")
}
Self::ClosingEquityInScope { account } => format!(
"[429] {account} is an income or expense account and cannot take the result"
),
Self::ClosingAccountNotOpen { account, date } => {
format!("[430] {account} has a balance to close but is not open on {date}")
}
Self::PolicyBeforeBookings {
effective,
last_booking,
} => format!(
"[431] a policy effective {effective} would govern entries already booked up to {last_booking}"
),
Self::HoldNotFound { hold } => format!("[440] the hold {hold} does not exist"),
Self::NotAHold { entry } => format!("[441] the entry {entry} is not a hold"),
Self::HoldResolved { hold, by, how } => {
format!("[442] the hold {hold} was already {how} by {by}")
}
Self::ResolutionBeforeHold { hold, held_on } => {
format!("[443] the resolution is dated before the hold {hold}, booked on {held_on}")
}
Self::HoldExpired { hold, expires } => {
format!("[444] the hold {hold} expired on {expires} and can no longer be posted")
}
Self::HoldNeverExpires { hold } => {
format!("[445] the hold {hold} has no expiry date and cannot expire")
}
Self::HoldNotExpired { hold, expires } => {
format!("[446] the hold {hold} does not expire before {expires}")
}
Self::PostAmountPrecision {
hold,
currency,
exponent,
} => format!(
"[447] the post of the hold {hold} has more than {exponent} decimal places for {currency}"
),
Self::PostExceedsHold {
hold,
held,
requested,
} => format!("[448] the post of {requested} exceeds the {held} held by {hold}"),
Self::PostToClosedAccount {
hold,
account,
date,
} => format!(
"[449] the hold {hold} cannot be posted: {} is not open on {date}",
account_ref(account)
),
Self::HoldNotReversible { target } => {
format!("[450] {target} acts on a hold and cannot be reversed")
}
Self::BalanceOverflow {
account,
currency,
layer,
} => format!(
"[500] the {currency} {layer:?} totals{} would overflow",
account_suffix(account)
),
Self::LimitExceeded {
account,
currency,
shortfall,
..
} => format!("[501] limit exceeded on {account} in {currency}: short by {shortfall}"),
};
pad(f, &text)
}
}
impl std::error::Error for Rejection {}
impl Rejection {
#[must_use]
pub const fn code(&self) -> u16 {
match self {
Self::InvalidKey { .. } => 100,
Self::TooFewPostings { .. } => 101,
Self::TooManyPostings { .. } => 102,
Self::InvalidPath { .. } => 103,
Self::NonPositiveAmount { .. } => 104,
Self::InvalidDimensions { .. } => 105,
Self::Unbalanced { .. } => 106,
Self::InvalidLabel { .. } => 107,
Self::InvalidMemo { .. } => 108,
Self::TooManyDocuments { .. } => 109,
Self::TooManyRates { .. } => 110,
Self::RecordTooLarge { .. } => 111,
Self::TotalOutOfRange { .. } => 113,
Self::MemoSaltMissing => 114,
Self::NonPositiveClearingAmount => 120,
Self::InvalidPeriodRange { .. } => 121,
Self::InvalidOpenWindow { .. } => 122,
Self::ExponentTooLarge { .. } => 123,
Self::HoldLegs => 140,
Self::HoldExpiry { .. } => 141,
Self::ResolutionPostings { .. } => 142,
Self::PendingOutsideHold { .. } => 143,
Self::ReversalWithHoldAction => 144,
Self::PostAmountNotPositive => 145,
Self::KeyConflict { .. } => 200,
Self::NoPeriod { .. } => 300,
Self::PeriodSealed { .. } => 301,
Self::UnknownAccount { .. } => 302,
Self::NotALeaf { .. } => 303,
Self::AccountNotOpen { .. } => 304,
Self::UndeclaredCurrency { .. } => 305,
Self::ExcessPrecision { .. } => 306,
Self::MissingDimension { .. } => 307,
Self::ValueDateDrift { .. } => 308,
Self::AtOrBelowWatermark { .. } => 309,
Self::EffectiveDateRegresses { .. } => 310,
Self::UnknownPeriod { .. } => 311,
Self::ReversalTargetUnknown { .. } => 400,
Self::AlreadyReversed { .. } => 401,
Self::ReversalBeforeTarget { .. } => 402,
Self::NotAMirror { .. } => 403,
Self::ClearingTargetUnknown { .. } => 410,
Self::ClearingSideMismatch { .. } => 411,
Self::ClearingKeyMismatch { .. } => 412,
Self::ClearingPrecision { .. } => 413,
Self::OverApplied { .. } => 414,
Self::ClearingBeforePosting { .. } => 415,
Self::ClearingUnknown { .. } => 416,
Self::ClearingAlreadyReset { .. } => 417,
Self::ResetBeforeClearing { .. } => 418,
Self::ClearingNotSettled { .. } => 419,
Self::AccountExists { .. } => 420,
Self::ParentHasPostings { .. } => 421,
Self::ClassFrozen { .. } => 422,
Self::PostingsAfterClose { .. } => 423,
Self::ExponentLowered { .. } => 424,
Self::PeriodExists { .. } => 425,
Self::PeriodGap { .. } => 426,
Self::PredecessorNotSealed { .. } => 427,
Self::TooManyAccounts => 428,
Self::ClosingEquityInScope { .. } => 429,
Self::ClosingAccountNotOpen { .. } => 430,
Self::PolicyBeforeBookings { .. } => 431,
Self::HoldNotFound { .. } => 440,
Self::NotAHold { .. } => 441,
Self::HoldResolved { .. } => 442,
Self::ResolutionBeforeHold { .. } => 443,
Self::HoldExpired { .. } => 444,
Self::HoldNeverExpires { .. } => 445,
Self::HoldNotExpired { .. } => 446,
Self::PostAmountPrecision { .. } => 447,
Self::PostExceedsHold { .. } => 448,
Self::PostToClosedAccount { .. } => 449,
Self::HoldNotReversible { .. } => 450,
Self::BalanceOverflow { .. } => 500,
Self::LimitExceeded { .. } => 501,
}
}
#[must_use]
pub const fn stage(&self) -> Stage {
match self {
Self::InvalidKey { .. }
| Self::TooFewPostings { .. }
| Self::TooManyPostings { .. }
| Self::InvalidPath { .. }
| Self::NonPositiveAmount { .. }
| Self::InvalidDimensions { .. }
| Self::Unbalanced { .. }
| Self::InvalidLabel { .. }
| Self::InvalidMemo { .. }
| Self::TooManyDocuments { .. }
| Self::TooManyRates { .. }
| Self::RecordTooLarge { .. }
| Self::TotalOutOfRange { .. }
| Self::MemoSaltMissing
| Self::NonPositiveClearingAmount
| Self::InvalidPeriodRange { .. }
| Self::InvalidOpenWindow { .. }
| Self::ExponentTooLarge { .. }
| Self::HoldLegs
| Self::HoldExpiry { .. }
| Self::ResolutionPostings { .. }
| Self::PendingOutsideHold { .. }
| Self::ReversalWithHoldAction
| Self::PostAmountNotPositive => Stage::Structural,
Self::KeyConflict { .. } => Stage::Idempotency,
Self::NoPeriod { .. }
| Self::PeriodSealed { .. }
| Self::UnknownAccount { .. }
| Self::NotALeaf { .. }
| Self::AccountNotOpen { .. }
| Self::UndeclaredCurrency { .. }
| Self::ExcessPrecision { .. }
| Self::MissingDimension { .. }
| Self::ValueDateDrift { .. }
| Self::AtOrBelowWatermark { .. }
| Self::EffectiveDateRegresses { .. }
| Self::UnknownPeriod { .. } => Stage::Context,
Self::ReversalTargetUnknown { .. }
| Self::AlreadyReversed { .. }
| Self::ReversalBeforeTarget { .. }
| Self::NotAMirror { .. }
| Self::ClearingTargetUnknown { .. }
| Self::ClearingSideMismatch { .. }
| Self::ClearingKeyMismatch { .. }
| Self::ClearingPrecision { .. }
| Self::OverApplied { .. }
| Self::ClearingBeforePosting { .. }
| Self::ClearingUnknown { .. }
| Self::ClearingAlreadyReset { .. }
| Self::ResetBeforeClearing { .. }
| Self::ClearingNotSettled { .. }
| Self::AccountExists { .. }
| Self::ParentHasPostings { .. }
| Self::ClassFrozen { .. }
| Self::PostingsAfterClose { .. }
| Self::ExponentLowered { .. }
| Self::PeriodExists { .. }
| Self::PeriodGap { .. }
| Self::PredecessorNotSealed { .. }
| Self::TooManyAccounts
| Self::ClosingEquityInScope { .. }
| Self::ClosingAccountNotOpen { .. }
| Self::PolicyBeforeBookings { .. }
| Self::HoldNotFound { .. }
| Self::NotAHold { .. }
| Self::HoldResolved { .. }
| Self::ResolutionBeforeHold { .. }
| Self::HoldExpired { .. }
| Self::HoldNeverExpires { .. }
| Self::HoldNotExpired { .. }
| Self::PostAmountPrecision { .. }
| Self::PostExceedsHold { .. }
| Self::PostToClosedAccount { .. }
| Self::HoldNotReversible { .. } => Stage::References,
Self::BalanceOverflow { .. } | Self::LimitExceeded { .. } => Stage::Limits,
}
}
}
fn posting_suffix(posting: &Option<u16>) -> String {
posting.map_or_else(String::new, |posting| format!(" at posting {posting}"))
}
fn account_suffix(account: &Option<AccountPath>) -> String {
account
.as_ref()
.map_or_else(String::new, |account| format!(" of {account}"))
}
fn account_ref(account: &AccountRef) -> String {
match account {
AccountRef::Path(path) => path.to_string(),
AccountRef::Handle(handle) => handle.to_string(),
}
}
fn posting_ref(posting: &PostingRef) -> String {
format!("{}/{}", posting.entry, posting.index)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum Stage {
Structural,
Idempotency,
Context,
References,
Limits,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum LabelField {
Kind,
Class,
PeriodId,
Actor,
}
impl fmt::Display for LabelField {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Kind => "entry kind",
Self::Class => "account class",
Self::PeriodId => "period id",
Self::Actor => "actor",
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ScaledAmount {
pub minor: i128,
pub scale: u8,
}
impl<const P: u8> From<Amount<P>> for ScaledAmount {
fn from(amount: Amount<P>) -> Self {
Self {
minor: amount.to_minor(),
scale: P,
}
}
}
impl fmt::Display for ScaledAmount {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let sign = if self.minor < 0 { "-" } else { "" };
let scale = usize::from(self.scale);
if scale == 0 {
return write!(f, "{sign}{}", self.minor.unsigned_abs());
}
let digits = format!(
"{:0>width$}",
self.minor.unsigned_abs(),
width = scale.saturating_add(1)
);
let split = digits.len().saturating_sub(scale);
match digits.split_at_checked(split) {
Some((whole, fraction)) => write!(f, "{sign}{whole}.{fraction}"),
None => write!(f, "{sign}{digits}"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scaled_amounts_print_every_decimal() {
let cases = [
(0, 2, "0.00"),
(5, 2, "0.05"),
(-5, 2, "-0.05"),
(3000, 2, "30.00"),
(-3000, 0, "-3000"),
(1, 18, "0.000000000000000001"),
(i128::MIN, 0, "-170141183460469231731687303715884105728"),
];
for (minor, scale, text) in cases {
assert_eq!(ScaledAmount { minor, scale }.to_string(), text);
}
}
}