Audit fixes: party money, reconciliations, require policy, K-1 property
An independent recompute of the real books found no numeric disagreements; review found 15 defects, all fixed with spec-first regression tests: - k1: a prepaid credited straight to capital is property; a member paying a vendor through 2100 is cash. Docs and B-13 match. - parties: a reversal of a reversal counts on its root's side; liability-to-liability payments (member pays the card) count as paid. - viewer: party money uses the server's cash accounts and full calendar year; per-party "What for" scales multi-party entries. - reconciliations: refuse a withdrawn statement before journaling; new books.void_statement; reconciled lines can't be un-cleared; one shared reconcilable-accounts rule for digest and viewer. - CPA package includes receipts linked to entries; a file can't be both filed on and linked to one entry. - parties = "require" covers payroll, tax remittance (agency) and disposal (buyer); no-party marks follow reversal chains and clear when a vendor is tagged. - derived member shares are frozen to exact amounts when posted, so a settings change never re-splits history. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
1 parent
e1b2509c22
commit
8498e6cfab
22 files changed
+1802
-343
No files matched your search
@@ -21,6 +21,7 @@
|
||||
pub mod blob;
|
||||
pub mod error;
|
||||
pub mod journal;
|
||||
pub mod reconcilable;
|
||||
pub mod schema;
|
||||
pub mod snapshot;
|
||||
pub mod store;
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
//! Which accounts the monthly close reconciles, and when one is due: one
|
||||
//! rule, read by the server's `books.digest` (`unreconciled_accounts`) and
|
||||
//! the viewer's Needs attention and account pages alike.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: the monthly close's reconciliation step can't be forgotten, and
|
||||
//! the digest and the viewer never disagree on what is due.
|
||||
//! SOURCE: docs/parties-and-attachments.md "Reconciliations" (Needs
|
||||
//! attention: an account with activity not reconciled through last
|
||||
//! month's end); CONVENTION (bank and card statements close
|
||||
//! monthly); AGENTS.md's monthly close.
|
||||
//! DEFINITIONS:
|
||||
//! cash accounts: `[books] cash_accounts`, resolved; when none are set,
|
||||
//! the asset accounts (not placeholders) whose names say they hold
|
||||
//! cash ("cash", "bank", "checking", "savings", "money market").
|
||||
//! card accounts: liability accounts whose names contain "card".
|
||||
//! INVARIANTS:
|
||||
//! 1. reconcilable_accounts: the cash accounts, then the card accounts,
|
||||
//! then every other liability with a recorded statement, each once.
|
||||
//! A card is expected before its first statement, so its first
|
||||
//! reconciliation is asked for; a liability named otherwise (an Amex)
|
||||
//! joins once its first statement is recorded.
|
||||
//! 2. reconciliation_due(account) = Some(through), through the last day
|
||||
//! of the month before today's, iff the account has a posted line
|
||||
//! dated on or before it and no reconciliation not voided reaches it;
|
||||
//! None otherwise (including activity only this month).
|
||||
//! ```
|
||||
|
||||
use crate::{Reconciliation, Statement};
|
||||
use chrono::{Datelike, NaiveDate};
|
||||
use ledger_core::{AccountId, AccountType, Ledger};
|
||||
|
||||
/// The words an asset account's name uses to say it holds cash.
|
||||
pub const CASH_WORDS: [&str; 5] = ["cash", "bank", "checking", "savings", "money market"];
|
||||
|
||||
/// The cash accounts: `configured` (account codes) resolved, else the
|
||||
/// asset accounts whose names say they hold cash.
|
||||
pub fn cash_accounts(ledger: &Ledger, configured: &[String]) -> Vec<AccountId> {
|
||||
if !configured.is_empty() {
|
||||
return configured
|
||||
.iter()
|
||||
.filter_map(|c| ledger.chart().find_by_code(c).map(|a| a.id))
|
||||
.collect();
|
||||
}
|
||||
ledger
|
||||
.chart()
|
||||
.all()
|
||||
.filter(|a| {
|
||||
let n = a.name.to_lowercase();
|
||||
a.account_type == AccountType::Asset
|
||||
&& !a.placeholder
|
||||
&& CASH_WORDS.iter().any(|w| n.contains(w))
|
||||
})
|
||||
.map(|a| a.id)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The card accounts: liabilities whose names contain "card".
|
||||
pub fn card_accounts(ledger: &Ledger) -> Vec<AccountId> {
|
||||
ledger
|
||||
.chart()
|
||||
.all()
|
||||
.filter(|a| {
|
||||
a.account_type == AccountType::Liability && a.name.to_lowercase().contains("card")
|
||||
})
|
||||
.map(|a| a.id)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The accounts the books reconcile monthly (INVARIANT 1).
|
||||
pub fn reconcilable_accounts(
|
||||
ledger: &Ledger,
|
||||
cash: &[AccountId],
|
||||
statements: &[Statement],
|
||||
) -> Vec<AccountId> {
|
||||
let mut out: Vec<AccountId> = Vec::new();
|
||||
let with_statement = statements.iter().map(|s| s.account_id).filter(|&a| {
|
||||
ledger
|
||||
.chart()
|
||||
.get(a)
|
||||
.is_some_and(|x| x.account_type == AccountType::Liability)
|
||||
});
|
||||
for a in cash
|
||||
.iter()
|
||||
.copied()
|
||||
.chain(card_accounts(ledger))
|
||||
.chain(with_statement)
|
||||
{
|
||||
if !out.contains(&a) {
|
||||
out.push(a);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The last day of the month before `today`'s.
|
||||
pub fn last_month_end(today: NaiveDate) -> NaiveDate {
|
||||
today
|
||||
.with_day(1)
|
||||
.and_then(|d| d.pred_opt())
|
||||
.expect("a month before today")
|
||||
}
|
||||
|
||||
/// The latest statement date `account` is reconciled through: its newest
|
||||
/// reconciliation not voided.
|
||||
pub fn reconciled_through(
|
||||
reconciliations: &[Reconciliation],
|
||||
account: AccountId,
|
||||
) -> Option<NaiveDate> {
|
||||
reconciliations
|
||||
.iter()
|
||||
.filter(|r| r.account_id == account && r.voided.is_none())
|
||||
.map(|r| r.statement_date)
|
||||
.max()
|
||||
}
|
||||
|
||||
/// When `account` is due a reconciliation (INVARIANT 2).
|
||||
pub fn reconciliation_due(
|
||||
ledger: &Ledger,
|
||||
reconciliations: &[Reconciliation],
|
||||
account: AccountId,
|
||||
today: NaiveDate,
|
||||
) -> Option<NaiveDate> {
|
||||
let through = last_month_end(today);
|
||||
let active = ledger
|
||||
.posted_entries()
|
||||
.any(|e| e.date() <= through && e.lines().iter().any(|l| l.account == account));
|
||||
(active && reconciled_through(reconciliations, account).is_none_or(|d| d < through))
|
||||
.then_some(through)
|
||||
}
|
||||
@@ -1473,12 +1473,39 @@ impl Store {
|
||||
|
||||
/// Mark a posted entry's line cleared (or uncleared) for bank
|
||||
/// reconciliation. Journaled as `line.cleared`.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: a recorded reconciliation stays proof: the lines it cleared
|
||||
/// stay cleared while it stands.
|
||||
/// SOURCE: docs/parties-and-attachments.md "Reconciliations" (never
|
||||
/// edited; void to redo).
|
||||
/// SCENARIOS:
|
||||
/// 1. IF the line is one a reconciliation not voided recorded as
|
||||
/// cleared and `cleared` is false: SHOULD refuse
|
||||
/// (ReconciliationRefused, naming it: void it first), journaling
|
||||
/// nothing.
|
||||
/// 2. Clearing a line dated on or before a reconciliation (an
|
||||
/// outstanding check that clears next month) is allowed: the
|
||||
/// later statement shows it.
|
||||
/// 3. Otherwise as Ledger::mark_cleared.
|
||||
/// ```
|
||||
pub fn mark_cleared(
|
||||
&mut self,
|
||||
entry_id: EntryId,
|
||||
line_index: usize,
|
||||
cleared: bool,
|
||||
) -> Result<(), StoreError> {
|
||||
if !cleared
|
||||
&& let Some(r) = self
|
||||
.reconciliations(None)?
|
||||
.into_iter()
|
||||
.find(|r| r.voided.is_none() && r.cleared.contains(&(entry_id, line_index)))
|
||||
{
|
||||
return Err(StoreError::ReconciliationRefused(format!(
|
||||
"entry {entry_id} line {line_index} is cleared by reconciliation {} (account {} through {}): void that reconciliation (with a reason) to un-clear it",
|
||||
r.id, r.account_id, r.statement_date
|
||||
)));
|
||||
}
|
||||
let mut next = self.ledger.clone();
|
||||
next.mark_cleared(entry_id, line_index, cleared)?;
|
||||
let line = self.append_journal(
|
||||
@@ -2477,7 +2504,12 @@ impl Store {
|
||||
/// 2. IF it is withdrawn: SHOULD refuse (AttachmentWithdrawn).
|
||||
/// 3. IF it is linked (to any entry): SHOULD refuse (AttachmentLinked).
|
||||
/// 4. IF the entry is unknown: SHOULD refuse.
|
||||
/// 5. Otherwise: SHOULD journal `receipt.linked`.
|
||||
/// 5. IF it is already linked to that entry (link_attachment): SHOULD
|
||||
/// refuse (AlreadyLinked) BECAUSE filed on and linked to the same
|
||||
/// entry is one receipt counted once, which neither unlink_receipt
|
||||
/// nor unlink_attachment could then take off (link_attachment
|
||||
/// SCENARIO 2, the other way round).
|
||||
/// 6. Otherwise: SHOULD journal `receipt.linked`.
|
||||
/// ```
|
||||
pub fn link_receipt(
|
||||
&mut self,
|
||||
@@ -2488,6 +2520,10 @@ impl Store {
|
||||
.get_entry(entry_id)
|
||||
.ok_or(LedgerError::UnknownEntry(entry_id))?;
|
||||
self.check_linkable(attachment_id)?;
|
||||
let subject = Subject::Entry(entry_id);
|
||||
if self.attachment_links(attachment_id)?.contains(&subject) {
|
||||
return Err(StoreError::AlreadyLinked(attachment_id, subject.describe()));
|
||||
}
|
||||
let line = self.append_journal(
|
||||
"receipt.linked",
|
||||
json!({ "attachment_id": attachment_id, "entry_id": entry_id }),
|
||||
@@ -2763,10 +2799,12 @@ impl Store {
|
||||
/// then `attachment.linked` to the account.
|
||||
/// SCENARIOS:
|
||||
/// 1. IF the attachment is unknown or withdrawn, already records a
|
||||
/// statement, the account is unknown or not an asset or liability
|
||||
/// (a bank, cash, or card account), the period runs backwards, or
|
||||
/// a balance has more than two decimal places: SHOULD refuse,
|
||||
/// journaling nothing.
|
||||
/// statement (not voided), the account is unknown or not an asset
|
||||
/// or liability (a bank, cash, or card account), the period runs
|
||||
/// backwards, or a balance has more than two decimal places:
|
||||
/// SHOULD refuse, journaling nothing.
|
||||
/// 2. IF its statement was voided (void_statement): records it
|
||||
/// afresh, the correction of a mis-recorded statement.
|
||||
/// ```
|
||||
pub fn record_statement(&mut self, st: Statement) -> Result<(), StoreError> {
|
||||
let (_, withdrawn) = self.attachment_state(st.attachment_id)?;
|
||||
@@ -2818,7 +2856,57 @@ impl Store {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The statement an attachment records, if any.
|
||||
/// Void a recorded statement, with the reason: what it says was
|
||||
/// recorded wrong (a mistyped balance or period), or it shouldn't have
|
||||
/// been recorded. Journaled as `statement.voided {attachment_id,
|
||||
/// reason}`.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: a mis-recorded statement can be corrected on the record,
|
||||
/// nothing erased (the journal keeps what it said and why it was
|
||||
/// voided).
|
||||
/// SOURCE: docs/parties-and-attachments.md "Reconciliations"; R-13a.
|
||||
/// INVARIANTS:
|
||||
/// 1. After it, the statement is no longer listed (statements,
|
||||
/// statement), backs no reconciliation, and the same attachment
|
||||
/// may record a statement again (record_statement).
|
||||
/// SCENARIOS:
|
||||
/// 1. IF the reason is blank: SHOULD refuse (ReasonRequired).
|
||||
/// 2. IF the attachment records no statement: SHOULD refuse.
|
||||
/// 3. IF a reconciliation not voided names it: SHOULD refuse
|
||||
/// (ReconciliationRefused: void that reconciliation first).
|
||||
/// 4. Every refusal journals nothing.
|
||||
/// ```
|
||||
pub fn void_statement(&mut self, attachment_id: u64, reason: &str) -> Result<(), StoreError> {
|
||||
let reason = reason.trim();
|
||||
if reason.is_empty() {
|
||||
return Err(StoreError::ReasonRequired);
|
||||
}
|
||||
if self.statement(attachment_id)?.is_none() {
|
||||
return Err(StoreError::InvalidStatement(format!(
|
||||
"attachment {attachment_id} records no statement"
|
||||
)));
|
||||
}
|
||||
if let Some(r) = self
|
||||
.reconciliations(None)?
|
||||
.into_iter()
|
||||
.find(|r| r.voided.is_none() && r.attachment_id == attachment_id)
|
||||
{
|
||||
return Err(StoreError::ReconciliationRefused(format!(
|
||||
"statement {attachment_id} backs reconciliation {}: void that reconciliation first",
|
||||
r.id
|
||||
)));
|
||||
}
|
||||
let line = self.append_journal(
|
||||
"statement.voided",
|
||||
json!({"attachment_id": attachment_id, "reason": reason}),
|
||||
)?;
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The statement an attachment records, if any (not voided, its file
|
||||
/// not withdrawn).
|
||||
pub fn statement(&self, attachment_id: u64) -> Result<Option<Statement>, StoreError> {
|
||||
Ok(self
|
||||
.statements(None)?
|
||||
@@ -2826,7 +2914,9 @@ impl Store {
|
||||
.find(|s| s.attachment_id == attachment_id))
|
||||
}
|
||||
|
||||
/// Recorded statements, optionally one account's, by period end.
|
||||
/// Recorded statements, optionally one account's, by period end: those
|
||||
/// not voided whose file isn't withdrawn (a withdrawn file retires its
|
||||
/// statement).
|
||||
pub fn statements(&self, account: Option<AccountId>) -> Result<Vec<Statement>, StoreError> {
|
||||
query_statements(&self.conn, account)
|
||||
}
|
||||
@@ -2851,16 +2941,23 @@ impl Store {
|
||||
/// 4. Journaled as `reconciliation.completed`; never edited (void and
|
||||
/// redo).
|
||||
/// SCENARIOS:
|
||||
/// 1. IF the attachment records no statement: SHOULD refuse.
|
||||
/// 1. IF the attachment is withdrawn: SHOULD refuse
|
||||
/// (AttachmentWithdrawn). IF it records no statement (or only a
|
||||
/// voided one): SHOULD refuse.
|
||||
/// 2. IF the books differ from the statement: SHOULD refuse with
|
||||
/// NotReconciled naming the difference.
|
||||
/// 3. IF a reconciliation not voided is dated on or after it: SHOULD
|
||||
/// refuse.
|
||||
/// 4. Every refusal comes before anything is journaled.
|
||||
/// ```
|
||||
pub fn complete_reconciliation(
|
||||
&mut self,
|
||||
statement_attachment: u64,
|
||||
) -> Result<Reconciliation, StoreError> {
|
||||
let (_, withdrawn) = self.attachment_state(statement_attachment)?;
|
||||
if withdrawn {
|
||||
return Err(StoreError::AttachmentWithdrawn(statement_attachment));
|
||||
}
|
||||
let st = self.statement(statement_attachment)?.ok_or_else(|| {
|
||||
StoreError::InvalidStatement(format!(
|
||||
"attachment {statement_attachment} records no statement (record_statement first)"
|
||||
@@ -4029,6 +4126,7 @@ pub(crate) fn replay_line_into_ledger(
|
||||
"attachment.linked"
|
||||
| "attachment.unlinked"
|
||||
| "statement.recorded"
|
||||
| "statement.voided"
|
||||
| "reconciliation.completed"
|
||||
| "reconciliation.voided" => {
|
||||
// Evidence, not accounting state: the projection records them.
|
||||
@@ -4668,6 +4766,14 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
|
||||
],
|
||||
)?;
|
||||
}
|
||||
"statement.voided" => {
|
||||
// The journal keeps what it said; the projection lists only
|
||||
// statements that stand.
|
||||
conn.execute(
|
||||
"DELETE FROM statements WHERE attachment_id = ?",
|
||||
params![get_u64(p, "attachment_id")? as i64],
|
||||
)?;
|
||||
}
|
||||
"reconciliation.completed" => {
|
||||
let id = get_u64(p, "id")? as i64;
|
||||
conn.execute(
|
||||
@@ -5326,13 +5432,14 @@ pub struct Reconciliation {
|
||||
|
||||
/// Recorded statements in the projection, optionally one account's, by
|
||||
/// period end (shared by [`Store::statements`] and the read-only
|
||||
/// snapshot).
|
||||
/// snapshot): those not voided, leaving out any whose attachment is
|
||||
/// withdrawn.
|
||||
pub(crate) fn query_statements(
|
||||
conn: &Connection,
|
||||
account: Option<AccountId>,
|
||||
) -> Result<Vec<Statement>, StoreError> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT attachment_id, account_id, period_start, period_end, opening_balance, closing_balance FROM statements ORDER BY period_end, attachment_id",
|
||||
"SELECT attachment_id, account_id, period_start, period_end, opening_balance, closing_balance FROM statements WHERE attachment_id NOT IN (SELECT id FROM attachments WHERE withdrawn_at IS NOT NULL) ORDER BY period_end, attachment_id",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok((
|
||||
|
||||
@@ -1273,3 +1273,151 @@ fn everything_survives_replay_and_verifies() {
|
||||
assert_eq!(view(&s, &subjects), before, "full replay");
|
||||
s.verify().unwrap();
|
||||
}
|
||||
|
||||
// ------------------------------------------- withdrawn and voided statements
|
||||
|
||||
/// Oracle: complete_reconciliation SCENARIOS 1 and 4 (a withdrawn
|
||||
/// statement file: AttachmentWithdrawn, before anything is journaled);
|
||||
/// statements ("a withdrawn file retires its statement"). The statement's
|
||||
/// account link is taken off and the file withdrawn; confirming then must
|
||||
/// record no reconciliation, and the statement is no longer listed.
|
||||
#[test]
|
||||
fn a_withdrawn_statement_records_no_reconciliation() {
|
||||
let dir = test_dir("withdrawn-statement");
|
||||
let (mut s, fx) = fixture(&dir);
|
||||
let a = jan_cash(&mut s, &fx, "jan.pdf");
|
||||
clear_jan(&mut s, &fx);
|
||||
s.unlink_attachment(a, Subject::Account(fx.cash), "filed wrong")
|
||||
.unwrap();
|
||||
s.withdraw_attachment(a, "filed wrong").unwrap();
|
||||
let e = refused_nothing_journaled(&mut s, &dir, "withdrawn", |s| s.complete_reconciliation(a));
|
||||
assert!(
|
||||
matches!(e, StoreError::AttachmentWithdrawn(x) if x == a),
|
||||
"{e:?}"
|
||||
);
|
||||
assert!(s.reconciliations(None).unwrap().is_empty());
|
||||
assert_eq!(s.statement(a).unwrap(), None);
|
||||
assert!(s.statements(Some(fx.cash)).unwrap().is_empty());
|
||||
s.verify().unwrap();
|
||||
}
|
||||
|
||||
/// Oracle: void_statement SCENARIOS 1–4 and INVARIANT 1; record_statement
|
||||
/// SCENARIO 2 (a voided statement is recorded afresh on the same file);
|
||||
/// the docs' `statement.voided {attachment_id, reason}`. A January
|
||||
/// statement mistyped at 830.00 is voided and recorded again at 380.00,
|
||||
/// then reconciled; voiding it while that reconciliation stands is
|
||||
/// refused. Reopening and a full replay agree.
|
||||
#[test]
|
||||
fn a_mis_recorded_statement_is_voided_and_recorded_again() {
|
||||
let dir = test_dir("void-statement");
|
||||
let (mut s, fx) = fixture(&dir);
|
||||
let a = doc(&mut s, "jan.pdf");
|
||||
let typo = stmt(
|
||||
a,
|
||||
fx.cash,
|
||||
date(2026, 1, 1),
|
||||
date(2026, 1, 31),
|
||||
"0.00",
|
||||
"830.00",
|
||||
);
|
||||
s.record_statement(typo).unwrap();
|
||||
for blank in ["", " "] {
|
||||
let e = refused_nothing_journaled(&mut s, &dir, "blank", |s| s.void_statement(a, blank));
|
||||
assert!(matches!(e, StoreError::ReasonRequired), "{e:?}");
|
||||
}
|
||||
let plain = doc(&mut s, "plain.pdf");
|
||||
let e = refused_nothing_journaled(&mut s, &dir, "no statement", |s| {
|
||||
s.void_statement(plain, "why")
|
||||
});
|
||||
assert!(matches!(e, StoreError::InvalidStatement(_)), "{e:?}");
|
||||
s.void_statement(a, "closing balance mistyped").unwrap();
|
||||
let (kind, payload) = journal(&dir).last().cloned().unwrap();
|
||||
assert_eq!(kind, "statement.voided");
|
||||
assert_eq!(
|
||||
payload,
|
||||
json!({"attachment_id": a, "reason": "closing balance mistyped"})
|
||||
);
|
||||
assert_eq!(s.statement(a).unwrap(), None);
|
||||
assert!(s.statements(None).unwrap().is_empty());
|
||||
let e = refused_nothing_journaled(&mut s, &dir, "voided twice", |s| {
|
||||
s.void_statement(a, "again")
|
||||
});
|
||||
assert!(matches!(e, StoreError::InvalidStatement(_)), "{e:?}");
|
||||
let right = stmt(
|
||||
a,
|
||||
fx.cash,
|
||||
date(2026, 1, 1),
|
||||
date(2026, 1, 31),
|
||||
"0.00",
|
||||
"380.00",
|
||||
);
|
||||
s.record_statement(right.clone()).unwrap();
|
||||
assert_eq!(s.statements(None).unwrap(), vec![right.clone()]);
|
||||
clear_jan(&mut s, &fx);
|
||||
let rec = s.complete_reconciliation(a).unwrap();
|
||||
let e = refused_nothing_journaled(&mut s, &dir, "backs a reconciliation", |s| {
|
||||
s.void_statement(a, "why")
|
||||
});
|
||||
assert!(matches!(e, StoreError::ReconciliationRefused(_)), "{e:?}");
|
||||
s.verify().unwrap();
|
||||
drop(s);
|
||||
delete_db(&dir);
|
||||
let s = Store::open(&dir).unwrap();
|
||||
assert_eq!(s.statements(None).unwrap(), vec![right]);
|
||||
assert_eq!(s.reconciliations(None).unwrap(), vec![rec]);
|
||||
s.verify().unwrap();
|
||||
}
|
||||
|
||||
/// Oracle: mark_cleared SCENARIOS 1–3. A line January's reconciliation
|
||||
/// recorded as cleared can't be un-cleared while it stands; once it is
|
||||
/// voided it can. A line dated in January but cleared later (an
|
||||
/// outstanding item) may still be cleared.
|
||||
#[test]
|
||||
fn a_reconciled_line_stays_cleared_until_the_reconciliation_is_voided() {
|
||||
let dir = test_dir("reconciled-line");
|
||||
let (mut s, fx) = fixture(&dir);
|
||||
let a = jan_cash(&mut s, &fx, "jan.pdf");
|
||||
clear_jan(&mut s, &fx);
|
||||
let rec = s.complete_reconciliation(a).unwrap();
|
||||
let e = refused_nothing_journaled(&mut s, &dir, "un-clear", |s| {
|
||||
s.mark_cleared(fx.e2, 1, false)
|
||||
});
|
||||
assert!(matches!(e, StoreError::ReconciliationRefused(_)), "{e:?}");
|
||||
assert!(s.ledger().is_cleared(fx.e2, 1));
|
||||
// An outstanding January item cleared afterwards: allowed.
|
||||
let late = post(
|
||||
&mut s,
|
||||
date(2026, 1, 30),
|
||||
"Check 101",
|
||||
fx.expense,
|
||||
fx.cash,
|
||||
"10.00",
|
||||
);
|
||||
s.mark_cleared(late, 1, true).unwrap();
|
||||
s.mark_cleared(late, 1, false).unwrap();
|
||||
s.void_reconciliation(rec.id, "redo").unwrap();
|
||||
s.mark_cleared(fx.e2, 1, false).unwrap();
|
||||
assert!(!s.ledger().is_cleared(fx.e2, 1));
|
||||
s.verify().unwrap();
|
||||
}
|
||||
|
||||
/// Oracle: link_receipt SCENARIO 5 (already linked to that entry: refuse,
|
||||
/// AlreadyLinked, journaling nothing), the mirror of link_attachment
|
||||
/// SCENARIO 2. A file linked to an entry can't then be filed on it too.
|
||||
#[test]
|
||||
fn a_file_linked_to_an_entry_cannot_also_be_filed_on_it() {
|
||||
let dir = test_dir("linked-then-filed");
|
||||
let (mut s, fx) = fixture(&dir);
|
||||
let (a, _, _) = s
|
||||
.attach_receipt(None, "r.pdf", "application/pdf", b"%PDF r", None)
|
||||
.unwrap();
|
||||
s.link_attachment(a, Subject::Entry(fx.e2)).unwrap();
|
||||
let e = refused_nothing_journaled(&mut s, &dir, "filed on too", |s| s.link_receipt(a, fx.e2));
|
||||
assert!(
|
||||
matches!(e, StoreError::AlreadyLinked(x, _) if x == a),
|
||||
"{e:?}"
|
||||
);
|
||||
// Another entry is fine: filed on one, linked to another.
|
||||
s.link_receipt(a, fx.e3).unwrap();
|
||||
s.verify().unwrap();
|
||||
}
|
||||
Reference in new issue
Block a user