use core::fmt;
use std::collections::BTreeMap;
use time::Date;
use super::context::{self, EntryFacts};
use super::state::StateView;
use super::writeset::Outstanding;
use crate::error::{Rejection, ScaledAmount};
use crate::model::{
AccountHandle, AccountRef, Amount, Currency, EntryId, EntryParts, EntryRecord, HoldAction,
Layer, LedgerId, MAX_LEAF_BYTES, PostAmount, PostingRecord, Side, pad,
};
use crate::store::HoldRow;
use super::structural::index;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HoldView<const P: u8> {
pub id: EntryId,
pub position: u64,
pub booking: Date,
pub expires: Option<Date>,
pub legs: [PostingRecord<P>; 2],
pub resolution: Option<(EntryId, ResolutionKind)>,
}
impl<const P: u8> HoldView<P> {
#[must_use]
pub fn open(id: EntryId, position: u64, record: &EntryRecord<P>) -> Option<Self> {
let parts = record.parts();
let Some(HoldAction::Open { expires }) = parts.hold else {
return None;
};
let [first, second] = <[PostingRecord<P>; 2]>::try_from(parts.postings.clone()).ok()?;
(first.side != second.side).then_some(Self {
id,
position,
booking: parts.booking_date,
expires,
legs: [first, second],
resolution: None,
})
}
#[must_use]
pub fn resolved(self, by: EntryId, how: ResolutionKind) -> Self {
Self {
resolution: Some((by, how)),
..self
}
}
#[must_use]
pub fn debit(&self) -> &PostingRecord<P> {
self.leg(Side::Debit)
}
#[must_use]
pub fn credit(&self) -> &PostingRecord<P> {
self.leg(Side::Credit)
}
fn leg(&self, side: Side) -> &PostingRecord<P> {
let [first, second] = &self.legs;
if first.side == side { first } else { second }
}
#[must_use]
pub const fn amount(&self) -> Amount<P> {
let [first, _] = &self.legs;
first.amount
}
#[must_use]
pub const fn currency(&self) -> Currency {
let [first, _] = &self.legs;
first.currency
}
#[must_use]
pub fn row(&self) -> HoldRow<P> {
HoldRow {
id: self.id,
position: self.position,
booking: self.booking,
expires: self.expires,
debit: self.debit().handle,
credit: self.credit().handle,
currency: self.currency(),
amount: self.amount(),
resolution: self.resolution,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum HoldEffect<const P: u8> {
Opened(HoldView<P>),
Resolved {
hold: EntryId,
by: EntryId,
how: ResolutionKind,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum ResolutionKind {
Posted,
Voided,
Expired,
}
impl fmt::Display for ResolutionKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
pad(
f,
match self {
Self::Posted => "posted",
Self::Voided => "voided",
Self::Expired => "expired",
},
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum HoldStatus<const P: u8 = 2> {
Open,
Posted {
by: EntryId,
amount: Amount<P>,
},
Voided {
by: EntryId,
},
Expired {
by: EntryId,
},
}
impl<const P: u8> HoldStatus<P> {
#[must_use]
pub const fn resolved_by(&self) -> Option<EntryId> {
match self {
Self::Open => None,
Self::Posted { by, .. } | Self::Voided { by } | Self::Expired { by } => Some(*by),
}
}
}
impl<const P: u8> fmt::Display for HoldStatus<P> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let text = match self {
Self::Open => String::from("open"),
Self::Posted { by, amount } => format!("posted {amount} by {by}"),
Self::Voided { by } => format!("voided by {by}"),
Self::Expired { by } => format!("expired by {by}"),
};
pad(f, &text)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub struct HoldState<const P: u8 = 2> {
pub id: EntryId,
pub position: u64,
pub booking: Date,
pub expires: Option<Date>,
pub debit: AccountHandle,
pub credit: AccountHandle,
pub currency: Currency,
pub amount: Amount<P>,
pub status: HoldStatus<P>,
}
impl<const P: u8> HoldState<P> {
#[must_use]
pub fn derive(
ledger: &LedgerId,
position: u64,
hold: &EntryRecord<P>,
resolution: Option<&EntryRecord<P>>,
) -> Option<Self> {
let id = hold.id(ledger);
let view = HoldView::open(id, position, hold)?;
let held = view.amount();
let row = view.row();
let Some(resolution) = resolution else {
return Some(Self::from_row(&row, held));
};
let by = resolution.id(ledger);
let (target, how, posted) = match resolution.parts().hold? {
HoldAction::Post { hold, amount } => {
(hold, ResolutionKind::Posted, posted(held, amount))
}
HoldAction::Void { hold } => (hold, ResolutionKind::Voided, held),
HoldAction::Expire { hold } => (hold, ResolutionKind::Expired, held),
HoldAction::Open { .. } => return None,
};
(target == id).then(|| Self::from_row(&view.resolved(by, how).row(), posted))
}
pub(crate) fn from_row(row: &HoldRow<P>, posted: Amount<P>) -> Self {
let status = match row.resolution {
None => HoldStatus::Open,
Some((by, ResolutionKind::Posted)) => HoldStatus::Posted { by, amount: posted },
Some((by, ResolutionKind::Voided)) => HoldStatus::Voided { by },
Some((by, ResolutionKind::Expired)) => HoldStatus::Expired { by },
};
Self {
id: row.id,
position: row.position,
booking: row.booking,
expires: row.expires,
debit: row.debit,
credit: row.credit,
currency: row.currency,
amount: row.amount,
status,
}
}
#[must_use]
pub const fn is_open(&self) -> bool {
matches!(self.status, HoldStatus::Open)
}
#[must_use]
pub const fn remaining(&self) -> Amount<P> {
if self.is_open() {
self.amount
} else {
Amount::ZERO
}
}
}
impl<const P: u8> fmt::Display for HoldState<P> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let expires = self
.expires
.map_or_else(String::new, |expires| format!(" until {expires}"));
pad(
f,
&format!(
"hold {} {} {} {} → {}{expires}, {}",
self.id, self.amount, self.currency, self.debit, self.credit, self.status
),
)
}
}
pub(crate) const fn posted<const P: u8>(held: Amount<P>, amount: PostAmount<P>) -> Amount<P> {
match amount {
PostAmount::Full => held,
PostAmount::Partial(amount) => amount,
}
}
pub(crate) fn structural<const P: u8>(
hold: Option<&HoldAction<P>>,
booking: Date,
reverses: Option<EntryId>,
postings: &[(Layer, Side)],
derived: bool,
) -> Vec<Rejection> {
let mut found = Vec::new();
match hold {
Some(HoldAction::Open { expires }) => {
let legs = matches!(
postings,
[(Layer::Pending, a), (Layer::Pending, b)] if a != b
);
if !legs {
found.push(Rejection::HoldLegs);
}
if let Some(expires) = *expires
&& expires <= booking
{
found.push(Rejection::HoldExpiry { booking, expires });
}
}
Some(HoldAction::Post { amount, .. }) => {
if let PostAmount::Partial(amount) = amount
&& !amount.is_positive()
{
found.push(Rejection::PostAmountNotPositive);
}
}
Some(HoldAction::Void { .. } | HoldAction::Expire { .. }) | None => {}
}
if is_resolution(hold) && !derived && !postings.is_empty() {
found.push(Rejection::ResolutionPostings {
count: postings.len(),
});
}
if hold.is_none()
&& let Some(posting) = postings
.iter()
.position(|(layer, _)| *layer == Layer::Pending)
{
found.push(Rejection::PendingOutsideHold {
posting: index(posting),
});
}
if hold.is_some() && reverses.is_some() {
found.push(Rejection::ReversalWithHoldAction);
}
found
}
pub(crate) const fn is_resolution<const P: u8>(hold: Option<&HoldAction<P>>) -> bool {
matches!(
hold,
Some(HoldAction::Post { .. } | HoldAction::Void { .. } | HoldAction::Expire { .. })
)
}
pub(crate) const fn is_release<const P: u8>(hold: Option<&HoldAction<P>>) -> bool {
matches!(
hold,
Some(HoldAction::Void { .. } | HoldAction::Expire { .. })
)
}
pub(crate) struct Checked<const P: u8> {
pub(crate) record: EntryRecord<P>,
pub(crate) effect: Option<HoldEffect<P>>,
pub(crate) derived: Option<EntryFacts<P>>,
pub(crate) outstanding: Vec<Outstanding<P>>,
}
pub(crate) fn check<const P: u8>(
view: &impl StateView<P>,
record: EntryRecord<P>,
) -> Result<Checked<P>, Rejection> {
let header = view.header();
let id = record.id(&header.ledger);
let parts = record.parts();
let booking = parts.booking_date;
let Some(action) = parts.hold else {
return Ok(Checked {
record,
effect: None,
derived: None,
outstanding: Vec::new(),
});
};
let (target, how) = match action {
HoldAction::Open { .. } => {
let opened = HoldView::open(id, header.size, &record).ok_or(Rejection::HoldLegs)?;
let outstanding = outstanding(view, &opened, true)?;
return Ok(Checked {
record,
effect: Some(HoldEffect::Opened(opened)),
derived: None,
outstanding,
});
}
HoldAction::Post { hold, .. } => (hold, ResolutionKind::Posted),
HoldAction::Void { hold } => (hold, ResolutionKind::Voided),
HoldAction::Expire { hold } => (hold, ResolutionKind::Expired),
};
let Some(hold) = view.hold(target) else {
return Err(match view.entry(target) {
None => Rejection::HoldNotFound { hold: target },
Some(_) => Rejection::NotAHold { entry: target },
});
};
let postings = derive_legs(&hold, &action);
let derived = context::gather(view, booking, &context::legs(&postings), None);
let released = outstanding(view, &hold, false);
if let Some((by, how)) = hold.resolution {
return Err(Rejection::HoldResolved {
hold: target,
by,
how,
});
}
if booking < hold.booking {
return Err(Rejection::ResolutionBeforeHold {
hold: target,
held_on: hold.booking,
});
}
match action {
HoldAction::Post { amount, .. } => post(view, &hold, amount, booking)?,
HoldAction::Expire { .. } => {
let expires = hold
.expires
.ok_or(Rejection::HoldNeverExpires { hold: target })?;
if booking < expires {
return Err(Rejection::HoldNotExpired {
hold: target,
expires,
});
}
}
HoldAction::Void { .. } | HoldAction::Open { .. } => {}
}
let outstanding = released?;
let record = EntryRecord::new(EntryParts {
postings,
..record.into_parts()
})
.map_err(|error| super::structural::record_error(error, MAX_LEAF_BYTES))?;
Ok(Checked {
record,
effect: Some(HoldEffect::Resolved {
hold: target,
by: id,
how,
}),
derived: Some(derived),
outstanding,
})
}
fn post<const P: u8>(
view: &impl StateView<P>,
hold: &HoldView<P>,
amount: PostAmount<P>,
booking: Date,
) -> Result<(), Rejection> {
let target = hold.id;
if let Some(expires) = hold.expires
&& booking >= expires
{
return Err(Rejection::HoldExpired {
hold: target,
expires,
});
}
if let PostAmount::Partial(requested) = amount {
let currency = hold.currency();
if let Some(exponent) = view.currency(currency, booking)
&& !requested.fits_exponent(exponent)
{
return Err(Rejection::PostAmountPrecision {
hold: target,
currency,
exponent,
});
}
if requested > hold.amount() {
return Err(Rejection::PostExceedsHold {
hold: target,
held: ScaledAmount::from(hold.amount()),
requested: ScaledAmount::from(requested),
});
}
}
for leg in &hold.legs {
let open = view
.account_terms(leg.handle, booking)
.is_some_and(|terms| terms.window.contains(booking));
if !open {
return Err(Rejection::PostToClosedAccount {
hold: target,
account: view
.account(leg.handle)
.map_or(AccountRef::Handle(leg.handle), |account| {
AccountRef::Path(account.path().clone())
}),
date: booking,
});
}
}
Ok(())
}
pub(crate) fn derive_legs<const P: u8>(
hold: &HoldView<P>,
action: &HoldAction<P>,
) -> Vec<PostingRecord<P>> {
let release = hold.legs.iter().map(|leg| PostingRecord {
side: leg.side.opposite(),
layer: Layer::Pending,
..leg.clone()
});
let settle = match action {
HoldAction::Post { amount, .. } => {
let amount = posted(hold.amount(), *amount);
hold.legs
.iter()
.map(|leg| PostingRecord {
amount,
layer: Layer::Settled,
..leg.clone()
})
.collect()
}
HoldAction::Open { .. } | HoldAction::Void { .. } | HoldAction::Expire { .. } => Vec::new(),
};
release.chain(settle).collect()
}
fn outstanding<const P: u8>(
view: &impl StateView<P>,
hold: &HoldView<P>,
opened: bool,
) -> Result<Vec<Outstanding<P>>, Rejection> {
let (amount, currency) = (hold.amount(), hold.currency());
let mut rows: BTreeMap<AccountHandle, (Amount<P>, Amount<P>)> = BTreeMap::new();
for leg in &hold.legs {
let (debits, credits) = rows.entry(leg.handle).or_insert_with(|| {
(
view.outstanding_hold_debits(leg.handle, currency),
view.outstanding_hold_credits(leg.handle, currency),
)
});
let total = match leg.side {
Side::Debit => debits,
Side::Credit => credits,
};
let changed = if opened {
total.checked_add(amount)
} else {
total.checked_sub(amount)
};
*total = changed.map_err(|_| Rejection::BalanceOverflow {
account: view
.account(leg.handle)
.map(|account| account.path().clone()),
currency,
layer: Layer::Pending,
})?;
}
Ok(rows
.into_iter()
.map(|(handle, (debits, credits))| Outstanding {
handle,
currency,
debits,
credits,
})
.collect())
}
pub(crate) fn outstanding_after<const P: u8>(
view: &impl StateView<P>,
after: &[Outstanding<P>],
handle: AccountHandle,
currency: Currency,
) -> (Amount<P>, Amount<P>) {
after
.iter()
.find(|row| row.handle == handle && row.currency == currency)
.map_or_else(
|| {
(
view.outstanding_hold_debits(handle, currency),
view.outstanding_hold_credits(handle, currency),
)
},
|row| (row.debits, row.credits),
)
}