Attachments, statements, reconciliations, TIN vault, counterparty policy
MCP: books.link_attachment, unlink_attachment, attachments; link_to on attach_receipt and file_document; record_statement, statements; books.reconcile previews and, confirmed against a recorded statement, records the reconciliation; void_reconciliation, reconciliations; import_bank_csv links its source file; digest lists unreconciled accounts. TIN vault: books.set_w9 takes the TIN and the form into the encrypted vault (last four in the books); books.vendor_1099 include_tins and save_csv_to for the CPA's copy; books.get_w9; books.purge_tin. A leak test scans every tool output, the journal, the database, and the blobs. [books] parties = off | suggest | require (default suggest): every preview names the entry's party or suggests likely ones; require refuses to post until it names one or is marked as having none (entry.no_party). Viewer: documents on every subject page; statements and reconciliation history per account; reconciliation pages; reconcile and no-counterparty attention items; party money by period (paid out, received in, net, what for, by month); Needs attention gets its own full-width section. 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
a299077991
commit
08ce558ed3
41 files changed
+8463
-465
No files matched your search
@@ -266,6 +266,8 @@ pub struct Ledger {
|
||||
next_party_id: crate::PartyId,
|
||||
/// The party each entry is with (the counterparty).
|
||||
entry_parties: HashMap<EntryId, crate::PartyId>,
|
||||
/// Entries marked as having no counterparty, with why.
|
||||
entry_no_party: HashMap<EntryId, String>,
|
||||
}
|
||||
|
||||
impl Ledger {
|
||||
@@ -308,6 +310,7 @@ impl Ledger {
|
||||
parties: std::collections::BTreeMap::new(),
|
||||
next_party_id: 1,
|
||||
entry_parties: HashMap::new(),
|
||||
entry_no_party: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -605,6 +608,10 @@ impl Ledger {
|
||||
("parties", self.parties == other.parties),
|
||||
("next_party_id", self.next_party_id == other.next_party_id),
|
||||
("entry_parties", self.entry_parties == other.entry_parties),
|
||||
(
|
||||
"entry_no_party",
|
||||
self.entry_no_party == other.entry_no_party,
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|(_, same)| !same)
|
||||
@@ -2844,6 +2851,7 @@ impl Ledger {
|
||||
.ok_or(LedgerError::UnknownParty(p))?
|
||||
.vendor;
|
||||
self.entry_parties.insert(entry, p);
|
||||
self.entry_no_party.remove(&entry);
|
||||
match vendor {
|
||||
Some(v) => {
|
||||
self.entry_vendors.insert(entry, v);
|
||||
@@ -2861,6 +2869,51 @@ impl Ledger {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mark an entry as having no counterparty, with why (a transfer
|
||||
/// between the company's own accounts, an accrual, a correction), so a
|
||||
/// books policy that requires parties can tell "none" from "not yet".
|
||||
///
|
||||
/// ```text
|
||||
/// SOURCE: docs/parties-and-attachments.md "Counterparties"; DECISION
|
||||
/// (owner, 2026-10-15).
|
||||
/// SCENARIOS:
|
||||
/// 1. IF the entry is unknown, already names a party, or the reason
|
||||
/// is blank or fails the party text rules: SHOULD refuse.
|
||||
/// 2. Tagging the entry with a party later clears the mark.
|
||||
/// ```
|
||||
pub fn set_entry_no_party(&mut self, entry: EntryId, reason: &str) -> Result<(), LedgerError> {
|
||||
self.check_set_entry_no_party(entry, reason)?;
|
||||
self.entry_no_party.insert(entry, reason.trim().to_string());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The validation behind [`Self::set_entry_no_party`].
|
||||
pub fn check_set_entry_no_party(
|
||||
&self,
|
||||
entry: EntryId,
|
||||
reason: &str,
|
||||
) -> Result<(), LedgerError> {
|
||||
if !self.entries.contains_key(&entry) {
|
||||
return Err(LedgerError::UnknownEntry(entry));
|
||||
}
|
||||
if self.entry_parties.contains_key(&entry) {
|
||||
return Err(LedgerError::InvalidParty(format!(
|
||||
"entry {entry} names a party; clear it first"
|
||||
)));
|
||||
}
|
||||
if reason.trim().is_empty() {
|
||||
return Err(LedgerError::InvalidParty(
|
||||
"say why the entry has no counterparty".into(),
|
||||
));
|
||||
}
|
||||
crate::parties::check_text("reason", reason).map_err(LedgerError::InvalidParty)
|
||||
}
|
||||
|
||||
/// Why an entry has no counterparty, when it was marked so.
|
||||
pub fn entry_no_party(&self, entry: EntryId) -> Option<&str> {
|
||||
self.entry_no_party.get(&entry).map(String::as_str)
|
||||
}
|
||||
|
||||
/// The vendor an entry is tagged with.
|
||||
pub fn entry_vendor(&self, entry: EntryId) -> Option<VendorId> {
|
||||
self.entry_vendors.get(&entry).copied()
|
||||
|
||||
Reference in new issue
Block a user