refactor(mcp): split server.rs into a file per tool group (U0a)
server.rs had grown to 12,566 lines: every tool handler, every helper and the request machinery in one file, so every unit that adds a tool touched it and parallel worktrees conflicted on every merge (plan/served.md 8.2 U0a, 8.0.3). The 98 handlers still in it now live one file per group under tools/, following the pattern tools/kept.rs and tools/projects.rs already use (`#[path]` declaration, `use super::*`, one `#[tool_router(vis = "pub(super)")] impl` per file): documents (invoices, bills, payments, voids, books.documents, books.document, the control check, the agings), entries (draft, line, post, reverse, get, search, the bank CSV import), reports (the statements, books.show, verify, digest, export, the CPA package, the P&L by class, with the export builders), assets, schedules, periods, reconciliation, parties (parties, vendors, customers, W-9s, the TIN vault, 1099s, entry parties), attachments (receipts, filed documents, links), settings (the handbook, books.settings, FX rates), chart, payroll, payouts, tax; add_class and list_classes join the existing projects file, since a project is a class with its facts. The helpers only one group uses moved into that group's file; the ones several groups share moved to tools/helpers.rs, re-exported by server.rs so every group reaches them through `use super::*` and no group file reaches into another's (resolve_instrument and the attachment/statement shapers moved there too, since the payment, entries and attachment tools all use them). server.rs keeps BooksServer, the store and its lock, the request machinery, call_tool/get_info, tool_router (which now composes the group routers; core_router is gone), and the shared helpers the plan names (as_of, hand_over, resolve_vendor, parse_doc_ref, preview_lines_for). This is a move, not a change: no accounting behaviour, tool name, description, annotation or schema changed. A dump of tool_router().list_all() — every tool's name, title, description, input and output schema and annotations, in order — is identical to main's (122 tools; the plan's "118" is stale). server.rs is 1,909 lines and has no #[tool] handler. The server's cross-group tests moved to server_tests.rs beside it (a #[path] module; fixtures in server::test_support) and each group's own tests into its group file, so server.rs stays under the 3,000-line bar. cargo test -p ledger-mcp: 99 passed; cargo test -p ledger-cli: 25 suites, 0 failed.
This commit is contained in:
1 parent
d5385a7ee9
commit
3cab75c494
19 files changed
+10986
-10786
No files matched your search
+105
-10765
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,541 @@
|
||||
//! Evidence through the server: receipts and filed documents, and their
|
||||
//! links to whatever they belong to. Split whole out of `server.rs`
|
||||
//! (plan/served.md 8.2 U0a).
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Resolve a tool's subject: its kind, and its id resolved the way the
|
||||
/// other tools resolve references (an account by code or id, an asset by
|
||||
/// tag or id, a party by id or name), checked to exist.
|
||||
fn resolve_subject(store: &Store, s: &SubjectParam) -> Result<ledger_store::Subject, String> {
|
||||
use ledger_store::Subject;
|
||||
let kind = s.kind.trim();
|
||||
let text = match &s.id {
|
||||
None => None,
|
||||
Some(RefId::Number(n)) => Some(n.to_string()),
|
||||
Some(RefId::Text(t)) => Some(t.trim().to_string()),
|
||||
};
|
||||
if Subject::from_parts(kind, 0).is_none() {
|
||||
return Err(format!(
|
||||
"unknown subject kind {kind:?}: expected one of {SUBJECT_KINDS}"
|
||||
));
|
||||
}
|
||||
if kind == "company" {
|
||||
return match text {
|
||||
None => Ok(Subject::Company),
|
||||
Some(_) => Err("the company subject takes no id".into()),
|
||||
};
|
||||
}
|
||||
let t = text.ok_or_else(|| format!("a {kind} subject needs its id"))?;
|
||||
let sub = match kind {
|
||||
"account" => Subject::Account(resolve_account(store, &t)?),
|
||||
"asset" => Subject::Asset(resolve_asset_ref(store, &t)?),
|
||||
"party" => Subject::Party(resolve_party(store, &t)?),
|
||||
// A JSON number is an id, never a name.
|
||||
"project" => match &s.id {
|
||||
Some(RefId::Number(n)) => Subject::Project(*n),
|
||||
_ => Subject::Project(u64::from(resolve_class(store, &t)?)),
|
||||
},
|
||||
k => {
|
||||
let id = t
|
||||
.parse::<u64>()
|
||||
.map_err(|_| format!("a {k} subject's id is a number, got {t:?}"))?;
|
||||
Subject::from_parts(k, id).expect("kind checked")
|
||||
}
|
||||
};
|
||||
if !store.subject_exists(sub).map_err(|e| e.to_string())? {
|
||||
return Err(format!("{} doesn't exist", sub.describe()));
|
||||
}
|
||||
Ok(sub)
|
||||
}
|
||||
|
||||
/// Check every `link_to` subject before anything is filed: each resolves,
|
||||
/// its journal feature is declared, none twice, none the entry the file is
|
||||
/// filed on (all or nothing).
|
||||
fn check_link_to(
|
||||
store: &Store,
|
||||
filed_on: Option<u64>,
|
||||
link_to: &[SubjectParam],
|
||||
) -> Result<Vec<ledger_store::Subject>, String> {
|
||||
let mut out: Vec<ledger_store::Subject> = Vec::new();
|
||||
for s in link_to {
|
||||
let sub = resolve_subject(store, s)?;
|
||||
// A link to a subject kind an older version doesn't know needs its
|
||||
// journal feature, asked here, before the file is filed: a refusal
|
||||
// leaves nothing half-filed (rules.md B-27).
|
||||
if let Some(f) = Store::subject_feature(sub) {
|
||||
store.ensure_feature(f).map_err(|e| e.to_string())?;
|
||||
}
|
||||
if out.contains(&sub) {
|
||||
return Err(format!("link_to names {} twice", sub.describe()));
|
||||
}
|
||||
if filed_on.map(ledger_store::Subject::Entry) == Some(sub) {
|
||||
return Err(format!(
|
||||
"link_to names {}, which it is filed on already",
|
||||
sub.describe()
|
||||
));
|
||||
}
|
||||
out.push(sub);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[tool_router(router = attachments_router, vis = "pub(super)")]
|
||||
impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.attach_receipt",
|
||||
title = "Attach a receipt",
|
||||
description = "Attach a receipt to an entry, or file it unlinked and link later with books.link_receipt. The bytes: upload_id, from books.begin_upload and `wanderbooks upload` (a file never passes through you). Files are content-addressed and deduplicated. link_to links it to more subjects in the same call (another entry it also supports, an asset, a party, a project, ...: see books.link_attachment); every subject is checked first, so one bad subject files nothing. A file holding a full TIN is refused: one the server read or wrote with TINs, or text with a TIN the vault keeps or an SSN or EIN printed 3-2-4 or 2-7 with hyphens.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn attach_receipt(
|
||||
&self,
|
||||
params: Parameters<AttachReceiptParams>,
|
||||
) -> Result<Json<AttachReceiptOut>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
if let Some(e) = p.entry_id
|
||||
&& store.ledger().get_entry(e).is_none()
|
||||
{
|
||||
return Err(format!("unknown entry {e}"));
|
||||
}
|
||||
let link_to = check_link_to(&store, p.entry_id, &p.link_to)?;
|
||||
// Taken once everything else is known good.
|
||||
let file = self.take_file(files::Given {
|
||||
upload_id: &p.upload_id,
|
||||
purposes: files::FILED,
|
||||
})?;
|
||||
let (file_name, mime_type) = named(p.file_name, p.mime_type, &file)?;
|
||||
let (bytes, claim) = (file.bytes, file.claim);
|
||||
self.refuse_tin_file(&store, &bytes, &mime_type, &file_name)?;
|
||||
let (attachment_id, sha256, deduplicated) = store
|
||||
.attach_receipt(
|
||||
p.entry_id,
|
||||
&file_name,
|
||||
&mime_type,
|
||||
&bytes,
|
||||
p.description.as_deref(),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
files::filed(claim);
|
||||
for sub in link_to {
|
||||
store
|
||||
.link_attachment(attachment_id, sub)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(Json(AttachReceiptOut {
|
||||
links: links_out(&store, attachment_id)?,
|
||||
attachment_id,
|
||||
sha256,
|
||||
deduplicated,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.file_document",
|
||||
title = "File a document",
|
||||
description = "File a document: category formation, legal, tax, accounting, statement (a bank or card statement: then books.record_statement), contract, photo, warranty, import_source (a bank export), or other. link_to links it to subjects in the same call (an account, an asset, a party, a project, the company: see books.link_attachment), all checked first. The bytes: upload_id, from books.begin_upload and `wanderbooks upload` (a file never passes through you). Never a W-9 (books.set_w9 w9_upload_id puts it in the vault). A file holding a full TIN is refused: one the server read or wrote with TINs, or text with a TIN the vault keeps or an SSN or EIN printed 3-2-4 or 2-7 with hyphens.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn file_document(
|
||||
&self,
|
||||
params: Parameters<FileDocumentParams>,
|
||||
) -> Result<Json<AttachReceiptOut>, String> {
|
||||
let p = params.0;
|
||||
match p.category.as_str() {
|
||||
"formation" | "legal" | "tax" | "accounting" | "statement" | "contract" | "photo"
|
||||
| "warranty" | "import_source" | "other" => {}
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"bad category {:?}: expected formation, legal, tax, accounting, statement, contract, photo, warranty, import_source, or other",
|
||||
p.category
|
||||
));
|
||||
}
|
||||
}
|
||||
let mut store = self.lock()?;
|
||||
let link_to = check_link_to(&store, None, &p.link_to)?;
|
||||
let file = self.take_file(files::Given {
|
||||
upload_id: &p.upload_id,
|
||||
purposes: files::FILED,
|
||||
})?;
|
||||
let (file_name, mime_type) = named(p.file_name, p.mime_type, &file)?;
|
||||
let (bytes, claim) = (file.bytes, file.claim);
|
||||
self.refuse_tin_file(&store, &bytes, &mime_type, &file_name)?;
|
||||
let (attachment_id, sha256, deduplicated) = store
|
||||
.file_document(
|
||||
&p.category,
|
||||
&file_name,
|
||||
&mime_type,
|
||||
&bytes,
|
||||
p.description.as_deref(),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
files::filed(claim);
|
||||
for sub in link_to {
|
||||
store
|
||||
.link_attachment(attachment_id, sub)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(Json(AttachReceiptOut {
|
||||
links: links_out(&store, attachment_id)?,
|
||||
attachment_id,
|
||||
sha256,
|
||||
deduplicated,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.link_receipt",
|
||||
title = "Link a receipt to an entry",
|
||||
description = "Link a previously filed (unlinked) receipt to an entry.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn link_receipt(
|
||||
&self,
|
||||
params: Parameters<LinkReceiptParams>,
|
||||
) -> Result<Json<LinkReceiptOut>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
store
|
||||
.link_receipt(p.attachment_id, p.entry_id)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(Json(LinkReceiptOut { ok: true }))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.unlink_receipt",
|
||||
title = "Unlink a receipt",
|
||||
description = "Detach a receipt from its entry, with a reason recorded in the journal. The receipt stays filed (unlinked); link it elsewhere or withdraw it with books.withdraw_attachment. The receipt policy still holds: an entry that requires a receipt cannot be left with none, so to replace a receipt attach the new one first, then unlink the old.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = true,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn unlink_receipt(
|
||||
&self,
|
||||
params: Parameters<UnlinkReceiptParams>,
|
||||
) -> Result<Json<UnlinkReceiptOut>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
let attachment = store
|
||||
.list_attachments(None)
|
||||
.map_err(|e| e.to_string())?
|
||||
.into_iter()
|
||||
.find(|a| a.id == p.attachment_id)
|
||||
.ok_or_else(|| format!("unknown attachment {}", p.attachment_id))?;
|
||||
if let Some(entry_id) = attachment.entry_id {
|
||||
let remaining = store
|
||||
.list_attachments(Some(entry_id))
|
||||
.map_err(|e| e.to_string())?
|
||||
.len()
|
||||
- 1;
|
||||
if remaining == 0 {
|
||||
self.ensure_receipt_not_required(&store, entry_id)?;
|
||||
}
|
||||
}
|
||||
let entry_id = store
|
||||
.unlink_receipt(p.attachment_id, &p.reason)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let remaining_receipts = store
|
||||
.list_attachments(Some(entry_id))
|
||||
.map_err(|e| e.to_string())?
|
||||
.len() as u64;
|
||||
Ok(Json(UnlinkReceiptOut {
|
||||
ok: true,
|
||||
entry_id,
|
||||
remaining_receipts,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.withdraw_attachment",
|
||||
title = "Withdraw a receipt or document",
|
||||
description = "Withdraw an unlinked receipt or a filed document, with a reason recorded in the journal. It drops out of books.list_receipts and can never be linked again. Nothing is deleted: the bytes stay in the blob store and the journal keeps the filing and the withdrawal. Unlink it first (books.unlink_receipt from its entry, books.unlink_attachment from anything else).",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = true,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn withdraw_attachment(
|
||||
&self,
|
||||
params: Parameters<WithdrawAttachmentParams>,
|
||||
) -> Result<Json<WithdrawAttachmentOut>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
store
|
||||
.withdraw_attachment(p.attachment_id, &p.reason)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(Json(WithdrawAttachmentOut { ok: true }))
|
||||
}
|
||||
|
||||
/// ```text
|
||||
/// GOAL: one file, linked wherever it is evidence, without refiling.
|
||||
/// SOURCE: docs/parties-and-attachments.md "Attachments";
|
||||
/// Store::link_attachment.
|
||||
/// INVARIANTS:
|
||||
/// 1. Journaled as `attachment.linked`; a refusal journals nothing.
|
||||
/// 2. A file linked to an entry counts as that entry's receipt.
|
||||
/// SCENARIOS:
|
||||
/// 1. IF the attachment is unknown or withdrawn, the subject kind is
|
||||
/// unknown, or the subject doesn't exist: SHOULD refuse.
|
||||
/// 2. IF it is already linked there (or filed on that entry): SHOULD
|
||||
/// refuse.
|
||||
/// ```
|
||||
#[tool(
|
||||
name = "books.link_attachment",
|
||||
title = "Link a file to a subject",
|
||||
description = "Link a stored receipt or document to a subject, so one file is evidence wherever it applies without filing it again. subject: {kind, id}: entry (id), account (code or id: its statements, the agreement that opened it), asset (tag or id: photos, warranty, manual), party (id or name: contracts, correspondence), project (id or name: its contract, spec, pitch), invoice, bill, period (its close package), payroll_run, schedule (the prepaid contract), reconciliation, or company (no id: formation documents). A file linked to an entry counts as one of its receipts. Journaled as attachment.linked. Returns the attachment's links.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn link_attachment(
|
||||
&self,
|
||||
params: Parameters<LinkAttachmentParams>,
|
||||
) -> Result<Json<AttachmentLinksOut>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
find_attachment(&store, p.attachment_id)?;
|
||||
let sub = resolve_subject(&store, &p.subject)?;
|
||||
store
|
||||
.link_attachment(p.attachment_id, sub)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(Json(AttachmentLinksOut {
|
||||
attachment_id: p.attachment_id,
|
||||
links: links_out(&store, p.attachment_id)?,
|
||||
}))
|
||||
}
|
||||
|
||||
/// ```text
|
||||
/// GOAL: take a link off, on the record, without leaving evidence
|
||||
/// missing where a rule needs it.
|
||||
/// SOURCE: R-13a (receipt corrections are append-only events);
|
||||
/// Store::unlink_attachment.
|
||||
/// INVARIANTS:
|
||||
/// 1. Journaled as `attachment.unlinked` with the reason; the file
|
||||
/// stays filed. A refusal journals nothing.
|
||||
/// SCENARIOS:
|
||||
/// 1. IF the reason is blank, or it isn't linked to the subject:
|
||||
/// SHOULD refuse.
|
||||
/// 2. IF it is filed on that entry (rather than linked): SHOULD refuse,
|
||||
/// naming books.unlink_receipt.
|
||||
/// 3. IF it is a posted entry's last receipt and the receipt policy
|
||||
/// covers the entry: SHOULD refuse (receipt_required).
|
||||
/// 4. IF it is the statement of a reconciliation not voided: SHOULD
|
||||
/// refuse (void the reconciliation first).
|
||||
/// ```
|
||||
#[tool(
|
||||
name = "books.unlink_attachment",
|
||||
title = "Unlink a file from a subject",
|
||||
description = "Take a link (books.link_attachment) off a subject, with the reason recorded in the journal. The file stays filed and keeps its other links. A receipt filed on an entry comes off with books.unlink_receipt instead. Refused when it would leave a posted entry the receipt policy covers without a receipt (attach the replacement first), or take the statement off a reconciliation that isn't voided. Journaled as attachment.unlinked. Returns the attachment's links.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = true,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn unlink_attachment(
|
||||
&self,
|
||||
params: Parameters<UnlinkAttachmentParams>,
|
||||
) -> Result<Json<AttachmentLinksOut>, String> {
|
||||
use ledger_store::Subject;
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
let a = find_attachment(&store, p.attachment_id)?;
|
||||
let sub = resolve_subject(&store, &p.subject)?;
|
||||
let linked = store
|
||||
.attachment_links(a.id)
|
||||
.map_err(|e| e.to_string())?
|
||||
.contains(&sub);
|
||||
match sub {
|
||||
Subject::Entry(e) if a.entry_id == Some(e) => {
|
||||
return Err(format!(
|
||||
"attachment {} is filed on entry {e}, not linked to it: take it off with books.unlink_receipt",
|
||||
a.id
|
||||
));
|
||||
}
|
||||
Subject::Entry(e) if linked => {
|
||||
let others = store
|
||||
.list_attachments(Some(e))
|
||||
.map_err(|e| e.to_string())?
|
||||
.iter()
|
||||
.filter(|x| x.id != a.id)
|
||||
.count();
|
||||
if others == 0 {
|
||||
self.ensure_receipt_not_required(&store, e)?;
|
||||
}
|
||||
}
|
||||
Subject::Reconciliation(r)
|
||||
if store
|
||||
.reconciliations(None)
|
||||
.map_err(|e| e.to_string())?
|
||||
.iter()
|
||||
.any(|x| x.id == r && x.attachment_id == a.id && x.voided.is_none()) =>
|
||||
{
|
||||
return Err(format!(
|
||||
"attachment {} is the statement reconciliation {r} rests on: void the reconciliation first (books.void_reconciliation)",
|
||||
a.id
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
store
|
||||
.unlink_attachment(a.id, sub, &p.reason)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(Json(AttachmentLinksOut {
|
||||
attachment_id: a.id,
|
||||
links: links_out(&store, a.id)?,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.attachments",
|
||||
title = "Files linked to a subject",
|
||||
description = "The files on a subject ({kind, id} as in books.link_attachment): for an entry, the receipts filed on it and the files linked to it; for anything else, the files linked to it. Withdrawn files are left out. Each comes with all its links.",
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn attachments(
|
||||
&self,
|
||||
params: Parameters<AttachmentsParams>,
|
||||
) -> Result<Json<AttachmentsOut>, String> {
|
||||
let store = self.lock()?;
|
||||
let sub = resolve_subject(&store, ¶ms.0.subject)?;
|
||||
let attachments = store
|
||||
.attachments_for(sub)
|
||||
.map_err(|e| e.to_string())?
|
||||
.into_iter()
|
||||
.map(|a| receipt_out(&store, a))
|
||||
.collect::<Result<_, _>>()?;
|
||||
Ok(Json(AttachmentsOut {
|
||||
subject: subject_out(&store, sub),
|
||||
attachments,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.list_receipts",
|
||||
title = "List receipts and documents",
|
||||
description = "List receipts and filed documents, optionally filtered to one entry (filed on it or linked to it), each with everything it is linked to (links). Withdrawn attachments are hidden unless include_withdrawn=true. For any other subject's files, books.attachments.",
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn list_receipts(
|
||||
&self,
|
||||
params: Parameters<ListReceiptsParams>,
|
||||
) -> Result<Json<ListReceiptsOut>, String> {
|
||||
let p = params.0;
|
||||
let store = self.lock()?;
|
||||
let include_withdrawn = p.include_withdrawn.unwrap_or(false);
|
||||
let receipts = store
|
||||
.list_attachments(p.entry_id)
|
||||
.map_err(|e| e.to_string())?
|
||||
.into_iter()
|
||||
.filter(|a| include_withdrawn || a.withdrawn_at.is_none())
|
||||
.map(|a| receipt_out(&store, a))
|
||||
.collect::<Result<_, _>>()?;
|
||||
Ok(Json(ListReceiptsOut { receipts }))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.get_receipt",
|
||||
title = "Get a receipt",
|
||||
description = "A receipt or document: what it is and everything it is linked to. view bytes adds the file, handed over: run its download (wanderbooks download) to save a copy in a folder you name; its bytes never come back here.",
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
)]
|
||||
pub(super) async fn get_receipt(
|
||||
&self,
|
||||
params: Parameters<GetReceiptParams>,
|
||||
) -> Result<Json<GetReceiptOut>, String> {
|
||||
let p = params.0;
|
||||
let bytes_wanted = match p.view.as_deref().unwrap_or("meta") {
|
||||
"meta" => false,
|
||||
"bytes" => true,
|
||||
other => {
|
||||
return Err(format!(
|
||||
"view {:?}: meta (the default) or bytes",
|
||||
other.chars().take(20).collect::<String>()
|
||||
));
|
||||
}
|
||||
};
|
||||
let store = self.lock()?;
|
||||
let meta = find_attachment(&store, p.attachment_id)?;
|
||||
// Handed over, read hash-verified (D-46): never its bytes here.
|
||||
let file = if bytes_wanted {
|
||||
let bytes = store
|
||||
.attachment_bytes(p.attachment_id)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Some(self.hand_over(&book_name(&store), &meta.file_name, &meta.mime_type, bytes)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(Json(GetReceiptOut {
|
||||
file,
|
||||
links: links_out(&store, meta.id)?,
|
||||
description: meta.description.clone(),
|
||||
attachment_id: meta.id,
|
||||
sha256: meta.blob_sha,
|
||||
entry_id: meta.entry_id,
|
||||
category: meta.category,
|
||||
file_name: meta.file_name,
|
||||
mime_type: meta.mime_type,
|
||||
size_bytes: meta.size_bytes,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::server::test_support::*;
|
||||
|
||||
/// Unlinking a receipt is refused only from a posted, non-reversal
|
||||
/// entry the policy covers (crate::policy).
|
||||
#[test]
|
||||
fn receipts_are_kept_only_where_the_policy_needs_them() {
|
||||
let books = unit_dir("unlink");
|
||||
let mut s = store(&books);
|
||||
let cash = s
|
||||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let rent = s
|
||||
.add_account("6000", "Rent", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
let draft = s.create_entry(d(2026, 3, 1), "draft").unwrap();
|
||||
s.add_line(draft, rent, Side::Debit, usd("10.00")).unwrap();
|
||||
s.add_line(draft, cash, Side::Credit, usd("10.00")).unwrap();
|
||||
let posted = post(&mut s, d(2026, 3, 2), rent, cash, "10.00");
|
||||
let reversal = s.reverse_entry(posted, d(2026, 3, 3), "undo").unwrap();
|
||||
let server = served(settled(
|
||||
s,
|
||||
&[("policy.evidence.threshold", serde_json::json!("0.00"))],
|
||||
));
|
||||
let store = server.lock().unwrap();
|
||||
assert!(
|
||||
server.ensure_receipt_not_required(&store, draft).is_ok(),
|
||||
"a draft"
|
||||
);
|
||||
assert!(
|
||||
server.ensure_receipt_not_required(&store, reversal).is_ok(),
|
||||
"a reversal"
|
||||
);
|
||||
assert!(
|
||||
server.ensure_receipt_not_required(&store, posted).is_err(),
|
||||
"a posted entry"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
//! The chart of accounts through the server: `books.chart`,
|
||||
//! `books.add_account`, the chart template and CSV import, renaming and
|
||||
//! renumbering, and the control roles. Split whole out of `server.rs`
|
||||
//! (plan/served.md 8.2 U0a).
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The settings that disagree with the books: each names the setting and
|
||||
/// what to change. A code must name an account in the chart, of the kind
|
||||
/// the setting needs (cash and investing: assets; financing: liabilities;
|
||||
/// capital, distributions, close_into, close_sweep: equity).
|
||||
/// The live conventions that name account `code` (a posting template's
|
||||
/// line, an approval rule's accounts): after a renumber, each still names
|
||||
/// the old code until revised.
|
||||
fn convention_mentions(ledger: &Ledger, code: &str) -> Vec<String> {
|
||||
convention_codes(ledger)
|
||||
.into_iter()
|
||||
.filter(|(_, c)| c == code)
|
||||
.map(|(id, _)| format!("convention {id} (revise it with books.set_convention)"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Every control role and the account filling it, for books.roles.
|
||||
fn roles_out(ledger: &Ledger) -> Vec<RoleOut> {
|
||||
ledger_core::control_accounts()
|
||||
.map(|c| {
|
||||
let account = ledger.role_account(c).and_then(|id| ledger.chart().get(id));
|
||||
RoleOut {
|
||||
role: c.key.to_string(),
|
||||
purpose: c.role.to_string(),
|
||||
account_type: format!("{:?}", c.account_type),
|
||||
default_code: c.code.to_string(),
|
||||
assigned: ledger.role_assignment(c).is_some(),
|
||||
account_code: account.map(|a| a.code.clone()),
|
||||
account_name: account.map(|a| a.name.clone()),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_account_type(s: &str) -> Result<AccountType, String> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"asset" => Ok(AccountType::Asset),
|
||||
"liability" => Ok(AccountType::Liability),
|
||||
"equity" => Ok(AccountType::Equity),
|
||||
"revenue" => Ok(AccountType::Revenue),
|
||||
"expense" => Ok(AccountType::Expense),
|
||||
_ => Err(format!(
|
||||
"bad account_type {s:?}: expected asset, liability, equity, revenue, or expense"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[tool_router(router = chart_router, vis = "pub(super)")]
|
||||
impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.chart",
|
||||
title = "Chart of accounts",
|
||||
description = "List the chart of accounts with current balances. Start here: every line you post must use a real account from this chart.",
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
)]
|
||||
pub(super) async fn chart(&self) -> Result<Json<ChartOut>, String> {
|
||||
let store = self.lock()?;
|
||||
let ledger = store.ledger();
|
||||
let mut accounts = Vec::new();
|
||||
let mut all: Vec<_> = ledger.chart().all().collect();
|
||||
all.sort_by(|a, b| a.code.cmp(&b.code));
|
||||
for a in all {
|
||||
let balance = ledger.account_balance(a.id).map_err(|e| e.to_string())?;
|
||||
accounts.push(ChartAccount {
|
||||
id: a.id,
|
||||
code: a.code.clone(),
|
||||
name: a.name.clone(),
|
||||
account_type: format!("{:?}", a.account_type).to_lowercase(),
|
||||
parent: a.parent,
|
||||
placeholder: a.placeholder,
|
||||
balance: money(if balance.is_zero() {
|
||||
Decimal::ZERO
|
||||
} else {
|
||||
balance
|
||||
}),
|
||||
tax_category: a.tax_category.clone(),
|
||||
deductible_percent: a.deductible_percent.map(|p| p.to_string()),
|
||||
});
|
||||
}
|
||||
Ok(Json(ChartOut {
|
||||
accounts,
|
||||
base_currency: store.base_currency().code().to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.add_account",
|
||||
title = "Add an account",
|
||||
description = "Add an account to the chart. Codes must be unique; check books.chart first. Give a revenue or expense account its tax_category (books.tax_categories): without one the result warns, or, under policy.require_tax_category = refuse, the account is refused. A name another account under the same parent has (ignoring capitals), one holding nine digits in a row, or one that isn't a single line of at most 200 characters, is refused.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn add_account(
|
||||
&self,
|
||||
params: Parameters<AddAccountParams>,
|
||||
) -> Result<Json<AddAccountOut>, String> {
|
||||
let p = params.0;
|
||||
let account_type = parse_account_type(&p.account_type)?;
|
||||
let placeholder = p.placeholder.unwrap_or(false);
|
||||
let pct = match &p.deductible_percent {
|
||||
Some(s) => Some(parse_amount(s)?),
|
||||
None => None,
|
||||
};
|
||||
let category = p.tax_category.as_deref();
|
||||
if pct.is_some() && category.is_none() {
|
||||
return Err("deductible_percent needs a tax_category".into());
|
||||
}
|
||||
if let Some(cat) = category {
|
||||
ledger_core::tax::check_category(cat, account_type, self.settings().company.tax_form)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
let mut store = self.lock()?;
|
||||
let id = store
|
||||
.add_account_with_tax(
|
||||
&p.code,
|
||||
&p.name,
|
||||
account_type,
|
||||
p.parent,
|
||||
placeholder,
|
||||
category.map(|c| (c, pct)),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let warnings =
|
||||
if category.is_none() && ledger_core::tax::needs_category(account_type, placeholder) {
|
||||
vec![uncategorized_warning(&p.code, &p.name)]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
Ok(Json(AddAccountOut {
|
||||
account_id: id,
|
||||
warnings,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.chart_template",
|
||||
title = "Starter chart of accounts",
|
||||
description = "A starter chart of accounts for a kind of business (sole proprietorship, partnership or multi-member LLC, S corporation, C corporation): the right equity and owner-pay accounts (draws; per-partner capital and distributions plus guaranteed payments; officer compensation and distributions; dividends and income tax), common income and expense accounts with tax categories assigned, and the settings that go with it (changes for books.set_setting). A proposal: review it with the human, adjust, then books.import_chart_csv and books.set_setting.",
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn chart_template(
|
||||
&self,
|
||||
params: Parameters<ChartTemplateParams>,
|
||||
) -> Result<Json<ChartTemplateOut>, String> {
|
||||
let p = params.0;
|
||||
let entity = ledger_import::EntityType::parse(&p.entity_type).ok_or_else(|| {
|
||||
format!(
|
||||
"entity_type {:?}: expected sole_proprietorship, partnership, s_corporation, or c_corporation (an LLC is one of these for tax purposes)",
|
||||
p.entity_type
|
||||
)
|
||||
})?;
|
||||
let t = ledger_import::chart_template(entity, &p.owners)?;
|
||||
// The template's settings, as the changes that set them in new
|
||||
// books (the one way settings change: books.set_setting).
|
||||
let settings = ledger_config::codec::import(
|
||||
&t.settings_toml,
|
||||
None,
|
||||
&ledger_core::settings::Settings::default(),
|
||||
self.currency(),
|
||||
)?
|
||||
.changes
|
||||
.into_iter()
|
||||
.filter_map(|c| {
|
||||
Some(TemplateSettingOut {
|
||||
value: c.value?,
|
||||
key: c.key,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(ChartTemplateOut {
|
||||
csv: t.csv(),
|
||||
settings,
|
||||
notes: t.notes,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.renumber_account",
|
||||
title = "Renumber an account",
|
||||
description = "Give an account a new code. Only the code changes: its id, name, and every entry posted to it stay, so balances and reports don't move. Refuses a code in use, or a control account's code (invoices, bills, payroll, closes) for an account of the wrong type. Lists any settings that still name the old code. Never renumber the human's accounts on your own: only when they ask.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = true,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn renumber_account(
|
||||
&self,
|
||||
params: Parameters<RenumberAccountParams>,
|
||||
) -> Result<Json<RenumberAccountOut>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
let id = resolve_account(&store, &p.account)?;
|
||||
let previous_code = store
|
||||
.ledger()
|
||||
.chart()
|
||||
.get(id)
|
||||
.expect("resolved")
|
||||
.code
|
||||
.clone();
|
||||
store
|
||||
.renumber_account(id, &p.code)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(Json(RenumberAccountOut {
|
||||
id,
|
||||
config_mentions: self
|
||||
.config_mentions(&previous_code)
|
||||
.into_iter()
|
||||
.chain(convention_mentions(store.ledger(), &previous_code))
|
||||
.collect(),
|
||||
previous_code,
|
||||
code: p.code,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.rename_account",
|
||||
title = "Rename an account",
|
||||
description = "Give an account a new name. Only the name changes: its id, code, type, tax category and every entry posted to it stay, so no entry, balance or statement amount changes; every report, past periods included, prints the new name, and the journal keeps the old one. Refuses a blank name, the name it has, a name another account under the same parent has (ignoring capitals), one holding nine digits in a row, or one that isn't a single line of at most 200 characters. QuickBooks matches accounts by name: if the CPA imported an earlier IIF, tell them. Never rename the human's accounts on your own: only when they ask.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = true,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn rename_account(
|
||||
&self,
|
||||
params: Parameters<RenameAccountParams>,
|
||||
) -> Result<Json<RenameAccountOut>, String> {
|
||||
use ledger_store::reconcilable;
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
let id = resolve_account(&store, &p.account)?;
|
||||
let reason = p.reason.as_deref().map(str::trim).filter(|r| !r.is_empty());
|
||||
// The sets the books pick by name, before and after (rules.md
|
||||
// B-31: a rename moves no amount, but it can move an account into
|
||||
// or out of one of these).
|
||||
let configured_cash = self.settings().roles.cash.clone();
|
||||
let picked = |ledger: &Ledger| {
|
||||
(
|
||||
configured_cash.is_empty()
|
||||
&& reconcilable::cash_accounts(ledger, &[]).contains(&id),
|
||||
reconcilable::card_accounts(ledger).contains(&id),
|
||||
)
|
||||
};
|
||||
let before = picked(store.ledger());
|
||||
let previous_name = store
|
||||
.ledger()
|
||||
.chart()
|
||||
.get(id)
|
||||
.expect("resolved")
|
||||
.name
|
||||
.clone();
|
||||
store
|
||||
.rename_account(id, &p.name, reason)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let ledger = store.ledger();
|
||||
let a = ledger.chart().get(id).expect("resolved");
|
||||
let after = picked(ledger);
|
||||
let mut notes = vec![format!(
|
||||
"Every report, past periods included, prints \u{201c}{}\u{201d} now; the journal keeps \u{201c}{previous_name}\u{201d}. QuickBooks matches accounts by name: if the CPA imported an earlier IIF, tell them.",
|
||||
a.name
|
||||
)];
|
||||
let children = ledger
|
||||
.chart()
|
||||
.all()
|
||||
.filter(|c| c.parent == Some(id))
|
||||
.count();
|
||||
if children > 0 {
|
||||
notes.push(format!(
|
||||
"Its {children} sub-account{} carry the new name in their QuickBooks full names (IIF) too.",
|
||||
if children == 1 { "" } else { "s" }
|
||||
));
|
||||
}
|
||||
for (was, is, what) in [
|
||||
(
|
||||
before.0,
|
||||
after.0,
|
||||
"a cash account (books.cash_accounts is unset, so cash accounts are the asset accounts whose names say cash, bank, checking, savings or money market): the reconciliation reminders and the viewer's cash figures, Cash page and cash flow statement follow it. Set books.cash_accounts to choose them by code",
|
||||
),
|
||||
(
|
||||
before.1,
|
||||
after.1,
|
||||
"a card account (a liability whose name says card): the reconciliation reminders follow it",
|
||||
),
|
||||
] {
|
||||
if was != is {
|
||||
notes.push(format!(
|
||||
"{} {} is {} {what}.",
|
||||
a.code,
|
||||
a.name,
|
||||
if is { "now" } else { "no longer" }
|
||||
));
|
||||
}
|
||||
}
|
||||
// A blank old name (an earlier version wrote one) is in every
|
||||
// text: it names no convention.
|
||||
let old = previous_name.trim().to_lowercase();
|
||||
for c in ledger
|
||||
.conventions()
|
||||
.conventions()
|
||||
.filter(|c| c.retired.is_none() && !old.is_empty())
|
||||
.filter(|c| {
|
||||
c.title.to_lowercase().contains(&old) || c.body_md.to_lowercase().contains(&old)
|
||||
})
|
||||
{
|
||||
notes.push(format!(
|
||||
"Convention {} names \u{201c}{previous_name}\u{201d} in its text: if it means this account, revise it with books.set_convention.",
|
||||
c.id
|
||||
));
|
||||
}
|
||||
Ok(Json(RenameAccountOut {
|
||||
id,
|
||||
code: a.code.clone(),
|
||||
previous_name,
|
||||
name: a.name.clone(),
|
||||
notes,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.roles",
|
||||
title = "Control account roles",
|
||||
description = "The control roles invoices, bills, payroll, and closes post to (receivables, revenue, payables, general expense, retained earnings, payroll expense and withholdings), and the account filling each: the one assigned with books.assign_role, else the account at the role's default code.",
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn roles(&self) -> Result<Json<RolesOut>, String> {
|
||||
let store = self.lock()?;
|
||||
Ok(Json(RolesOut {
|
||||
roles: roles_out(store.ledger()),
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.assign_role",
|
||||
title = "Assign a control role",
|
||||
description = "Assign a control role (see books.roles) one of the chart's own accounts, so invoices, bills, payroll, or closes post there instead of the default code; account null returns the role to its default code. Journaled. Refuses an account of the wrong type, a placeholder, one already filling another role, and moving a receivables, payables, withholding, or retained earnings role while its account holds a balance (move the balance with an entry first). Posted entries never change. Only when the human agrees.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = true,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn assign_role(
|
||||
&self,
|
||||
params: Parameters<AssignRoleParams>,
|
||||
) -> Result<Json<RolesOut>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
let account = match &p.account {
|
||||
Some(a) => Some(resolve_account(&store, a)?),
|
||||
None => None,
|
||||
};
|
||||
store
|
||||
.assign_role(&p.role, account)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(Json(RolesOut {
|
||||
roles: roles_out(store.ledger()),
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.import_chart_csv",
|
||||
title = "Import a chart of accounts",
|
||||
description = "Import a chart of accounts from CSV (header: code,name,type,parent_code,tax_category; the last is optional), as upload_id: upload it with books.begin_upload purpose import and `wanderbooks upload` (a file never passes through you). Types: asset, liability, equity, revenue, expense. Parents must come before children. Fails on the first bad row; nothing is half-imported. A revenue or expense row without a tax_category is listed in warnings, or, under policy.require_tax_category = refuse, refuses the file.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn import_chart_csv(
|
||||
&self,
|
||||
params: Parameters<ImportChartCsvParams>,
|
||||
) -> Result<Json<ImportChartCsvOut>, String> {
|
||||
let p = params.0;
|
||||
let (csv, claim) = self.csv_given(&p.upload_id)?;
|
||||
let rows = ledger_import::parse_chart_csv(&csv).map_err(|e| e.to_string())?;
|
||||
let mut store = self.lock()?;
|
||||
let parse_type = |t: &str| match t {
|
||||
"asset" => Ok(AccountType::Asset),
|
||||
"liability" => Ok(AccountType::Liability),
|
||||
"equity" => Ok(AccountType::Equity),
|
||||
"revenue" => Ok(AccountType::Revenue),
|
||||
"expense" => Ok(AccountType::Expense),
|
||||
_ => Err(format!("bad account type: {t}")),
|
||||
};
|
||||
// Rehearse the whole import on a copy of the ledger first, so a bad
|
||||
// row anywhere (a duplicate within the file, a parent listed after
|
||||
// its child or of another type, a blank code, a name a sibling
|
||||
// has, a missing category the policy requires) refuses the file
|
||||
// and nothing is half-imported. The old up-front checks covered
|
||||
// only clashes with the existing chart and tax categories.
|
||||
let mut scratch = store.ledger().clone();
|
||||
let mut warnings = Vec::new();
|
||||
for row in &rows {
|
||||
let account_type = parse_type(&row.account_type)?;
|
||||
let parent = match &row.parent_code {
|
||||
Some(code) => Some(
|
||||
scratch
|
||||
.chart()
|
||||
.find_by_code(code)
|
||||
.map(|a| a.id)
|
||||
.ok_or_else(|| format!("{}: unknown parent code {code}", row.code))?,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
let id = scratch
|
||||
.add_account(&row.code, &row.name, account_type, parent, false)
|
||||
.map_err(|e| format!("{}: {e}", row.code))?;
|
||||
// A row is never a header account (the file can't say so), so
|
||||
// a revenue or expense row needs a category under refuse.
|
||||
scratch
|
||||
.check_tax_category_required(
|
||||
&row.code,
|
||||
&row.name,
|
||||
account_type,
|
||||
false,
|
||||
row.tax_category.as_deref(),
|
||||
)
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"{}: {e}, as the file's fifth column (tax_category); a row of the file is never a header account",
|
||||
row.code
|
||||
)
|
||||
})?;
|
||||
if let Some(cat) = &row.tax_category {
|
||||
ledger_core::tax::check_category(
|
||||
cat,
|
||||
account_type,
|
||||
self.settings().company.tax_form,
|
||||
)
|
||||
.map_err(|e| format!("{}: {e}", row.code))?;
|
||||
// A category an earlier version doesn't know waits for the
|
||||
// books' format upgrade: refused here, with the file whole,
|
||||
// never after the rows before it were written.
|
||||
if let Some(f) = ledger_store::format::tax_category_feature(cat) {
|
||||
store
|
||||
.ensure_feature(f)
|
||||
.map_err(|e| format!("{}: {e}", row.code))?;
|
||||
}
|
||||
scratch
|
||||
.set_account_tax(id, Some(cat), None)
|
||||
.map_err(|e| format!("{}: {e}", row.code))?;
|
||||
} else if ledger_core::tax::needs_category(account_type, false) {
|
||||
warnings.push(uncategorized_warning(&row.code, &row.name));
|
||||
}
|
||||
}
|
||||
// Rehearsed whole: the upload is used up as the writing begins.
|
||||
files::filed(claim);
|
||||
let mut created = Vec::new();
|
||||
for row in rows {
|
||||
let account_type = match row.account_type.as_str() {
|
||||
"asset" => AccountType::Asset,
|
||||
"liability" => AccountType::Liability,
|
||||
"equity" => AccountType::Equity,
|
||||
"revenue" => AccountType::Revenue,
|
||||
"expense" => AccountType::Expense,
|
||||
_ => return Err(format!("bad account type: {}", row.account_type)),
|
||||
};
|
||||
let parent = match &row.parent_code {
|
||||
Some(code) => Some(
|
||||
store
|
||||
.ledger()
|
||||
.chart()
|
||||
.find_by_code(code)
|
||||
.map(|a| a.id)
|
||||
.ok_or_else(|| format!("unknown parent code: {code}"))?,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
store
|
||||
.add_account_with_tax(
|
||||
&row.code,
|
||||
&row.name,
|
||||
account_type,
|
||||
parent,
|
||||
false,
|
||||
row.tax_category.as_deref().map(|c| (c, None)),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
created.push(row.code.clone());
|
||||
}
|
||||
let count = created.len();
|
||||
Ok(Json(ImportChartCsvOut {
|
||||
created,
|
||||
count,
|
||||
warnings,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::server::test_support::*;
|
||||
|
||||
/// Settings naming a code are found: cash/financing/investing roles,
|
||||
/// close_into, and each member's capital and distributions accounts.
|
||||
#[test]
|
||||
fn config_mentions_finds_every_setting_naming_a_code() {
|
||||
let books = unit_dir("mentions");
|
||||
let server = served(settled(
|
||||
store(&books),
|
||||
&[
|
||||
members(),
|
||||
("books.cash_accounts", serde_json::json!(["1000"])),
|
||||
("books.financing_accounts", serde_json::json!(["2100"])),
|
||||
("books.investing_accounts", serde_json::json!(["1400"])),
|
||||
],
|
||||
));
|
||||
assert_eq!(server.config_mentions("1000"), ["books.cash_accounts"]);
|
||||
assert_eq!(server.config_mentions("2100"), ["books.financing_accounts"]);
|
||||
assert_eq!(server.config_mentions("1400"), ["books.investing_accounts"]);
|
||||
assert_eq!(
|
||||
server.config_mentions("3000"),
|
||||
["company.members Ada capital_account"]
|
||||
);
|
||||
assert_eq!(
|
||||
server.config_mentions("3001"),
|
||||
["company.members Bo capital_account"]
|
||||
);
|
||||
assert_eq!(
|
||||
server.config_mentions("3100"),
|
||||
["company.members Ada distribution_account"]
|
||||
);
|
||||
assert!(server.config_mentions("6000").is_empty());
|
||||
let books = unit_dir("mentions-solo");
|
||||
let solo = served(settled(
|
||||
store(&books),
|
||||
&[("books.close_into", serde_json::json!("3000"))],
|
||||
));
|
||||
assert_eq!(solo.config_mentions("3000"), ["books.close_into"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,633 @@
|
||||
//! `books.documents`' group: the A/R and A/P documents (invoices and
|
||||
//! bills), the payments and voids against them, the read tools
|
||||
//! (`books.documents`, `books.document`), the control check and the
|
||||
//! agings. Split whole out of `server.rs` (plan/served.md 8.2 U0a): the
|
||||
//! tools, their names, descriptions and rules are unchanged
|
||||
//! (`docs/mcp-interface.md`; rules.md B-38).
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A payment's method and reference from its parameters (the method by
|
||||
/// id or label, books.instruments).
|
||||
fn payment_details(
|
||||
store: &Store,
|
||||
p: &PaymentParams,
|
||||
) -> Result<ledger_core::PaymentDetails, String> {
|
||||
Ok(ledger_core::PaymentDetails {
|
||||
instrument: p
|
||||
.instrument
|
||||
.as_deref()
|
||||
.map(|r| resolve_instrument(store, r))
|
||||
.transpose()?,
|
||||
reference: p.reference.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// What a payment tool answers: the document's figures after it, derived
|
||||
/// from its lines that stand (rules.md B-38), as of the end of the books.
|
||||
fn payment_out(
|
||||
ledger: &Ledger,
|
||||
doc: ledger_core::DocRef,
|
||||
entry_id: u64,
|
||||
) -> Result<Json<PaymentOut>, String> {
|
||||
let st = ledger
|
||||
.doc_state(doc, chrono::NaiveDate::MAX)
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| format!("{} not found", doc_label(doc)))?;
|
||||
Ok(Json(PaymentOut {
|
||||
entry_id,
|
||||
remaining: money(st.outstanding),
|
||||
status: st.status.key().to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// A document's id.
|
||||
fn doc_id(doc: ledger_core::DocRef) -> u64 {
|
||||
match doc {
|
||||
ledger_core::DocRef::Invoice(id) | ledger_core::DocRef::Bill(id) => id,
|
||||
}
|
||||
}
|
||||
|
||||
/// A document as books.documents and books.document name it
|
||||
/// ("invoice:7", "bill:12").
|
||||
fn doc_ref_text(doc: ledger_core::DocRef) -> String {
|
||||
match doc {
|
||||
ledger_core::DocRef::Invoice(id) => format!("invoice:{id}"),
|
||||
ledger_core::DocRef::Bill(id) => format!("bill:{id}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A tagged line's movement on the wire.
|
||||
fn movement_key(m: ledger_core::Movement) -> &'static str {
|
||||
match m {
|
||||
ledger_core::Movement::Origin => "origin",
|
||||
ledger_core::Movement::Payment => "payment",
|
||||
ledger_core::Movement::Discount => "discount",
|
||||
ledger_core::Movement::WriteOff => "write_off",
|
||||
ledger_core::Movement::Adopted => "adopted",
|
||||
ledger_core::Movement::RoleMove => "role_move",
|
||||
}
|
||||
}
|
||||
|
||||
/// A document's kind and id for people ("invoice 7").
|
||||
fn doc_label(doc: ledger_core::DocRef) -> String {
|
||||
match doc {
|
||||
ledger_core::DocRef::Invoice(id) => format!("invoice {id}"),
|
||||
ledger_core::DocRef::Bill(id) => format!("bill {id}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// An invoice's or bill's lines as the tool takes them: each amount
|
||||
/// parsed and each project resolved ([`resolve_class`]) before anything is
|
||||
/// written.
|
||||
fn document_lines(
|
||||
store: &Store,
|
||||
lines: Vec<DocumentLineParam>,
|
||||
) -> Result<Vec<DocumentLine>, String> {
|
||||
lines
|
||||
.into_iter()
|
||||
.map(|l| {
|
||||
Ok(DocumentLine {
|
||||
description: l.description,
|
||||
amount: parse_amount(&l.amount)?,
|
||||
class_id: l
|
||||
.class
|
||||
.as_deref()
|
||||
.map(|c| resolve_class(store, c))
|
||||
.transpose()?,
|
||||
account: None,
|
||||
quantity: None,
|
||||
unit_price: None,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A document's notes, each project once, in the order first named: its
|
||||
/// late-tag note ([`projects::late_tag_note`]) for a finished or abandoned
|
||||
/// project, and on an invoice ([`projects::on_spec_invoice_note`]) one for
|
||||
/// an on-spec project (rules.md B-37 items 4, 5 and 8).
|
||||
fn document_notes(
|
||||
ledger: &ledger_core::Ledger,
|
||||
lines: &[DocumentLine],
|
||||
invoice: bool,
|
||||
) -> Vec<String> {
|
||||
let mut named: Vec<u32> = Vec::new();
|
||||
for c in lines.iter().filter_map(|l| l.class_id) {
|
||||
if !named.contains(&c) {
|
||||
named.push(c);
|
||||
}
|
||||
}
|
||||
let mut notes = Vec::new();
|
||||
for c in named {
|
||||
notes.extend(projects::late_tag_note(ledger, c));
|
||||
if invoice {
|
||||
notes.extend(projects::on_spec_invoice_note(ledger, c));
|
||||
}
|
||||
}
|
||||
notes
|
||||
}
|
||||
|
||||
#[tool_router(router = documents_router, vis = "pub(super)")]
|
||||
impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.create_invoice",
|
||||
title = "Create and post an invoice",
|
||||
description = "Create a sales invoice. Auto-posts Dr Accounts Receivable / Cr Revenue. Lines are {description, amount, class?} with decimal-string amounts; class (a project: name or id, books.projects) tags that line's revenue: one revenue line per project, the untagged lines' as one; the receivable line is never tagged. Its result gains notes for a finished, abandoned or on-spec project (on spec: is there now an agreement?), tagged anyway.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = true,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn create_invoice(
|
||||
&self,
|
||||
params: Parameters<CreateDocumentParams>,
|
||||
) -> Result<Json<CreateDocumentOut>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
let lines = document_lines(&store, p.lines)?;
|
||||
let first_new = store.ledger().next_account_id();
|
||||
let id = store
|
||||
.create_invoice(
|
||||
p.party_id,
|
||||
parse_date(&p.date)?,
|
||||
parse_date(&p.due_date)?,
|
||||
lines,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let inv = store
|
||||
.ledger()
|
||||
.get_invoice(id)
|
||||
.ok_or_else(|| format!("invoice {id} not found after creation"))?;
|
||||
Ok(Json(CreateDocumentOut {
|
||||
id,
|
||||
total: inv.total().map_err(|e| e.to_string())?.to_string(),
|
||||
entry_id: inv.entry_id,
|
||||
warnings: created_uncategorized(store.ledger(), first_new),
|
||||
notes: document_notes(store.ledger(), &inv.lines, true),
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.create_bill",
|
||||
title = "Create and post a bill",
|
||||
description = "Create a vendor bill. Auto-posts Dr General Expenses / Cr Accounts Payable. Lines are {description, amount, class?} with decimal-string amounts; class (a project: name or id, books.projects) tags that line's cost: one expense line per project, the untagged lines' as one; the payable line is never tagged. Its result gains notes for a finished or abandoned project, tagged anyway.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = true,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn create_bill(
|
||||
&self,
|
||||
params: Parameters<CreateDocumentParams>,
|
||||
) -> Result<Json<CreateDocumentOut>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
let lines = document_lines(&store, p.lines)?;
|
||||
let first_new = store.ledger().next_account_id();
|
||||
let id = store
|
||||
.create_bill(
|
||||
p.party_id,
|
||||
parse_date(&p.date)?,
|
||||
parse_date(&p.due_date)?,
|
||||
lines,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let bill = store
|
||||
.ledger()
|
||||
.get_bill(id)
|
||||
.ok_or_else(|| format!("bill {id} not found after creation"))?;
|
||||
Ok(Json(CreateDocumentOut {
|
||||
id,
|
||||
total: bill.total().map_err(|e| e.to_string())?.to_string(),
|
||||
entry_id: bill.entry_id,
|
||||
warnings: created_uncategorized(store.ledger(), first_new),
|
||||
notes: document_notes(store.ledger(), &bill.lines, false),
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.receive_payment",
|
||||
title = "Receive an invoice payment",
|
||||
description = "Record a customer payment against an invoice. Posts Dr Cash / Cr A/R, the receivable line tagged with the invoice (its figures are derived from its lines that stand: a reversed payment no longer counts). Name instrument (how the money moved, books.instruments) and the processor's or bank's reference when known. The books record payments; they never collect them (D-45). Refused above what it owes, before its date, or into an account that isn't cash.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = true,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn receive_payment(
|
||||
&self,
|
||||
params: Parameters<PaymentParams>,
|
||||
) -> Result<Json<PaymentOut>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
let cash = resolve_account(&store, &p.cash_account)?;
|
||||
let details = payment_details(&store, &p)?;
|
||||
let entry_id = store
|
||||
.receive_payment_with(
|
||||
p.document_id,
|
||||
parse_date(&p.date)?,
|
||||
parse_amount(&p.amount)?,
|
||||
cash,
|
||||
&details,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
payment_out(
|
||||
store.ledger(),
|
||||
ledger_core::DocRef::Invoice(p.document_id),
|
||||
entry_id,
|
||||
)
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.pay_bill",
|
||||
title = "Pay a bill",
|
||||
description = "Record a payment of a vendor bill. Posts Dr A/P / Cr Cash, the payable line tagged with the bill (its figures are derived from its lines that stand: a reversed payment no longer counts). Name instrument (how the money moved, books.instruments: a card payment is left out of 1099-NEC) and the payment's reference when known. The books record payments; they never make them (D-45). Refused above what it owes, before its date, or from an account that isn't cash.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = true,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn pay_bill(
|
||||
&self,
|
||||
params: Parameters<PaymentParams>,
|
||||
) -> Result<Json<PaymentOut>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
let cash = resolve_account(&store, &p.cash_account)?;
|
||||
let details = payment_details(&store, &p)?;
|
||||
let entry_id = store
|
||||
.pay_bill_with(
|
||||
p.document_id,
|
||||
parse_date(&p.date)?,
|
||||
parse_amount(&p.amount)?,
|
||||
cash,
|
||||
&details,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
payment_out(
|
||||
store.ledger(),
|
||||
ledger_core::DocRef::Bill(p.document_id),
|
||||
entry_id,
|
||||
)
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.void_document",
|
||||
title = "Void an invoice or bill",
|
||||
description = "Void an invoice or bill that should never have existed (a duplicate, the wrong customer or vendor): reverses its own entry, dated as given, with the reason. It keeps its number and shows as void; nothing is deleted. Refused while a payment stands on it (reverse that payment first: the refusal names it), when it is void already, before its date, or in a closed period. To lower a valid document, not void it: a credit memo (later). Posts at once.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = true,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn void_document(
|
||||
&self,
|
||||
params: Parameters<VoidDocumentParams>,
|
||||
) -> Result<Json<VoidDocumentOut>, String> {
|
||||
let p = params.0;
|
||||
let doc = doc_ref(&p.kind, p.id)?;
|
||||
let date = parse_date(&p.date)?;
|
||||
let mut store = self.lock()?;
|
||||
let entry_id = store
|
||||
.void_document(doc, date, &p.reason)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let status = store
|
||||
.ledger()
|
||||
.doc_state(doc, date)
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_or("void", |st| st.status.key());
|
||||
Ok(Json(VoidDocumentOut {
|
||||
entry_id,
|
||||
status: status.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.documents",
|
||||
title = "List invoices or bills",
|
||||
description = described(
|
||||
"List invoices (kind invoices: money owed to the company) or bills (kind bills: money it owes), due date first: each with its ref (for books.document), party, dates, total, what it still owes and its status, all derived from its ledger lines that stand. status: open (default: anything still owed), overdue, paid, void, written_off, overpaid, or all; party and due_by narrow it (due_by: what is due by a day, e.g. what to pay this week).",
|
||||
ledger_core::calendar::PeriodKind::AsOf
|
||||
),
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn documents(
|
||||
&self,
|
||||
params: Parameters<DocumentsParams>,
|
||||
) -> Result<Json<DocumentsOut>, String> {
|
||||
let p = params.0;
|
||||
let as_of = self.as_of(p.as_of.as_deref())?;
|
||||
let due_by = p.due_by.as_deref().map(parse_date).transpose()?;
|
||||
let invoices = match p.kind.trim().to_ascii_lowercase().as_str() {
|
||||
"invoices" | "invoice" => true,
|
||||
"bills" | "bill" => false,
|
||||
other => return Err(format!("bad kind {other:?}: expected invoices or bills")),
|
||||
};
|
||||
let want = p
|
||||
.status
|
||||
.as_deref()
|
||||
.unwrap_or("open")
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
if !matches!(
|
||||
want.as_str(),
|
||||
"open" | "overdue" | "paid" | "void" | "written_off" | "overpaid" | "all"
|
||||
) {
|
||||
return Err(format!(
|
||||
"bad status {want:?}: expected open, overdue, paid, void, written_off, overpaid or all"
|
||||
));
|
||||
}
|
||||
let store = self.lock()?;
|
||||
let ledger = store.ledger();
|
||||
let docs: Vec<(ledger_core::DocRef, String, NaiveDate, NaiveDate)> = if invoices {
|
||||
ledger
|
||||
.invoices()
|
||||
.into_iter()
|
||||
.map(|i| {
|
||||
let who = ledger
|
||||
.get_customer(i.customer_id)
|
||||
.map(|c| c.name.clone())
|
||||
.unwrap_or_default();
|
||||
(ledger_core::DocRef::Invoice(i.id), who, i.date, i.due_date)
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
ledger
|
||||
.bills()
|
||||
.into_iter()
|
||||
.map(|b| {
|
||||
let who = ledger
|
||||
.get_vendor(b.vendor_id)
|
||||
.map(|v| v.name.clone())
|
||||
.unwrap_or_default();
|
||||
(ledger_core::DocRef::Bill(b.id), who, b.date, b.due_date)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let party = p.party.as_deref().map(|s| s.trim().to_lowercase());
|
||||
let mut rows = Vec::new();
|
||||
let mut owed = Decimal::ZERO;
|
||||
for (doc, who, date, due) in docs {
|
||||
if let Some(want_party) = &party
|
||||
&& who.to_lowercase() != *want_party
|
||||
&& doc_id(doc).to_string() != *want_party
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if due_by.is_some_and(|d| due > d) {
|
||||
continue;
|
||||
}
|
||||
let Some(st) = ledger.doc_state(doc, as_of).map_err(|e| e.to_string())? else {
|
||||
continue;
|
||||
};
|
||||
let key = st.status.key();
|
||||
let keep = match want.as_str() {
|
||||
"all" => true,
|
||||
"open" => !st.outstanding.is_zero() && key != "void",
|
||||
"overdue" => st.overdue_days > 0,
|
||||
other => key == other,
|
||||
};
|
||||
if !keep {
|
||||
continue;
|
||||
}
|
||||
owed = owed
|
||||
.checked_add(st.outstanding)
|
||||
.ok_or("the total is too large to compute")?;
|
||||
rows.push((
|
||||
due,
|
||||
doc_id(doc),
|
||||
DocumentRowOut {
|
||||
r#ref: doc_ref_text(doc),
|
||||
party: who,
|
||||
date: date.to_string(),
|
||||
due_date: due.to_string(),
|
||||
total: money(st.total),
|
||||
outstanding: money(st.outstanding),
|
||||
status: key.to_string(),
|
||||
overdue_days: st.overdue_days,
|
||||
},
|
||||
));
|
||||
}
|
||||
rows.sort_by_key(|r| (r.0, r.1));
|
||||
Ok(Json(DocumentsOut {
|
||||
as_of: as_of.to_string(),
|
||||
kind: if invoices { "invoices" } else { "bills" }.to_string(),
|
||||
documents: rows.into_iter().map(|r| r.2).collect(),
|
||||
outstanding_total: money(owed),
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.document",
|
||||
title = "An invoice or bill",
|
||||
description = described(
|
||||
"One invoice or bill (ref \"invoice:7\" or \"bill:12\", from books.documents) as of a day: party, dates, lines with projects, total, paid, discounted, written off, still owed, status; and its timeline, every ledger line tagged with it in posting order, each saying whether its entry still stands (a reversed payment shows as the payment, not standing, and its reversal, standing: they net to nothing). pdf: true also hands over an invoice's PDF to download, re-created from the books.",
|
||||
ledger_core::calendar::PeriodKind::AsOf
|
||||
),
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn document(
|
||||
&self,
|
||||
params: Parameters<DocumentParams>,
|
||||
) -> Result<Json<DocumentOut>, String> {
|
||||
let p = params.0;
|
||||
let as_of = self.as_of(p.as_of.as_deref())?;
|
||||
let doc = parse_doc_ref(&p.r#ref)?;
|
||||
let store = self.lock()?;
|
||||
let ledger = store.ledger();
|
||||
let (who, date, due, entry_id, lines) = match doc {
|
||||
ledger_core::DocRef::Invoice(id) => {
|
||||
let i = ledger
|
||||
.get_invoice(id)
|
||||
.ok_or_else(|| format!("no invoice {id}: see books.documents"))?;
|
||||
let who = ledger
|
||||
.get_customer(i.customer_id)
|
||||
.map(|c| c.name.clone())
|
||||
.unwrap_or_default();
|
||||
(who, i.date, i.due_date, i.entry_id, i.lines.clone())
|
||||
}
|
||||
ledger_core::DocRef::Bill(id) => {
|
||||
let b = ledger
|
||||
.get_bill(id)
|
||||
.ok_or_else(|| format!("no bill {id}: see books.documents"))?;
|
||||
let who = ledger
|
||||
.get_vendor(b.vendor_id)
|
||||
.map(|v| v.name.clone())
|
||||
.unwrap_or_default();
|
||||
(who, b.date, b.due_date, b.entry_id, b.lines.clone())
|
||||
}
|
||||
};
|
||||
let st = ledger
|
||||
.doc_state(doc, as_of)
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| format!("{} is dated after {as_of}", doc_ref_text(doc)))?;
|
||||
let pdf = match (p.pdf, doc) {
|
||||
(false, _) => None,
|
||||
(true, ledger_core::DocRef::Bill(_)) => {
|
||||
return Err(
|
||||
"a bill is the vendor's document: its PDF is the file filed with it (books.list_receipts)"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
(true, ledger_core::DocRef::Invoice(id)) => {
|
||||
let settings = self.settings();
|
||||
let printed = ledger_package::invoice_document(
|
||||
ledger,
|
||||
&settings.company,
|
||||
id,
|
||||
as_of,
|
||||
today(),
|
||||
)?;
|
||||
let bytes =
|
||||
ledger_import::invoice_pdf::invoice_pdf(&printed).map_err(|e| e.to_string())?;
|
||||
Some(self.hand_over(
|
||||
&book_name(&store),
|
||||
&format!("invoice-{id}.pdf"),
|
||||
"application/pdf",
|
||||
bytes,
|
||||
)?)
|
||||
}
|
||||
};
|
||||
let timeline = ledger
|
||||
.doc_lines(doc)
|
||||
.into_iter()
|
||||
.filter(|l| l.date <= as_of)
|
||||
.map(|l| DocumentEventOut {
|
||||
date: l.date.to_string(),
|
||||
entry_id: l.entry,
|
||||
kind: movement_key(l.movement).to_string(),
|
||||
amount: money(l.amount),
|
||||
reversal: l.reversal,
|
||||
stands: ledger.stands_on(l.entry, as_of),
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(DocumentOut {
|
||||
r#ref: doc_ref_text(doc),
|
||||
party: who,
|
||||
date: date.to_string(),
|
||||
due_date: due.to_string(),
|
||||
entry_id,
|
||||
lines: lines
|
||||
.iter()
|
||||
.map(|l| DocumentLineOut {
|
||||
description: l.description.clone(),
|
||||
amount: money(l.amount),
|
||||
project: l
|
||||
.class_id
|
||||
.and_then(|c| ledger.get_class(c))
|
||||
.map(|c| c.name.clone()),
|
||||
})
|
||||
.collect(),
|
||||
total: money(st.total),
|
||||
paid: money(st.paid),
|
||||
discounted: money(st.discounted),
|
||||
written_off: money(st.written_off),
|
||||
outstanding: money(st.outstanding),
|
||||
status: st.status.key().to_string(),
|
||||
overdue_days: st.overdue_days,
|
||||
timeline,
|
||||
pdf,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.control_check",
|
||||
title = "Receivables and payables agree",
|
||||
description = described(
|
||||
"Check that Accounts Receivable and Accounts Payable equal their documents as of a day: for each control account, its ledger balance, what its open invoices or bills still owe on it, every line on it that is no document's (a hand entry or an older version's), the difference (always 0.00), and the documents needing attention (overpaid, or not in the ledger). agrees is true when there is nothing to look at. The first thing a CPA ties out.",
|
||||
ledger_core::calendar::PeriodKind::AsOf
|
||||
),
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn control_check(
|
||||
&self,
|
||||
params: Parameters<AgingParams>,
|
||||
) -> Result<Json<ControlCheckOut>, String> {
|
||||
let p = params.0;
|
||||
let as_of = self.as_of(p.as_of.as_deref())?;
|
||||
let store = self.lock()?;
|
||||
let ledger = store.ledger();
|
||||
let checks = ledger.control_check(as_of).map_err(|e| e.to_string())?;
|
||||
Ok(Json(control_check_out(ledger, as_of, &checks)))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.ar_aging",
|
||||
title = "Receivables aging",
|
||||
description = described(
|
||||
"A/R aging report as of a date: outstanding balances per customer, bucketed by days past due (current, 30, 60, 90, 90+).",
|
||||
ledger_core::calendar::PeriodKind::AsOf
|
||||
),
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn ar_aging(&self, params: Parameters<AgingParams>) -> Result<Json<AgingOut>, String> {
|
||||
let p = params.0;
|
||||
let as_of = self.as_of(p.as_of.as_deref())?;
|
||||
let store = self.lock()?;
|
||||
let rows = store
|
||||
.ledger()
|
||||
.ar_aging(as_of)
|
||||
.map_err(|e| e.to_string())?
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let total = r.total().map_err(|e| e.to_string())?.to_string();
|
||||
Ok(AgingRowOut {
|
||||
name: r.name,
|
||||
current: money(r.current),
|
||||
days_30: money(r.days_30),
|
||||
days_60: money(r.days_60),
|
||||
days_90: money(r.days_90),
|
||||
days_90_plus: money(r.days_90_plus),
|
||||
total,
|
||||
})
|
||||
})
|
||||
.collect::<Result<_, String>>()?;
|
||||
Ok(Json(AgingOut {
|
||||
as_of: as_of.to_string(),
|
||||
rows,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.ap_aging",
|
||||
title = "Payables aging",
|
||||
description = described(
|
||||
"A/P aging report as of a date: outstanding balances per vendor, bucketed by days past due (current, 30, 60, 90, 90+).",
|
||||
ledger_core::calendar::PeriodKind::AsOf
|
||||
),
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn ap_aging(&self, params: Parameters<AgingParams>) -> Result<Json<AgingOut>, String> {
|
||||
let p = params.0;
|
||||
let as_of = self.as_of(p.as_of.as_deref())?;
|
||||
let store = self.lock()?;
|
||||
let rows = store
|
||||
.ledger()
|
||||
.ap_aging(as_of)
|
||||
.map_err(|e| e.to_string())?
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let total = r.total().map_err(|e| e.to_string())?.to_string();
|
||||
Ok(AgingRowOut {
|
||||
name: r.name,
|
||||
current: money(r.current),
|
||||
days_30: money(r.days_30),
|
||||
days_60: money(r.days_60),
|
||||
days_90: money(r.days_90),
|
||||
days_90_plus: money(r.days_90_plus),
|
||||
total,
|
||||
})
|
||||
})
|
||||
.collect::<Result<_, String>>()?;
|
||||
Ok(Json(AgingOut {
|
||||
as_of: as_of.to_string(),
|
||||
rows,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,902 @@
|
||||
//! Journal entries through the server: `books.draft_entry`,
|
||||
//! `books.add_line`, `books.post_entry`, `books.reverse_entry`,
|
||||
//! `books.get_entry`, `books.search_entries`, and the bank CSV import.
|
||||
//! Split whole out of `server.rs` (plan/served.md 8.2 U0a): nothing about
|
||||
//! the tools changed.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Likely counterparties for an entry, best first (at most three): parties
|
||||
/// named in its description, then in its receipts' names and
|
||||
/// descriptions, then the party of an earlier entry with the same
|
||||
/// description. Names match as whole words, ignoring case; a party also
|
||||
/// matches by the first word of its name when that word has 4+ letters
|
||||
/// ("Apple" for "Apple Inc.").
|
||||
fn suggest_parties(store: &Store, entry: &JournalEntry) -> Result<Vec<PartySuggestionOut>, String> {
|
||||
let ledger = store.ledger();
|
||||
let words = |t: &str| -> Vec<String> {
|
||||
t.to_lowercase()
|
||||
.split(|c: char| !c.is_alphanumeric())
|
||||
.filter(|w| !w.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
};
|
||||
let contains = |hay: &[String], needle: &[String]| {
|
||||
!needle.is_empty() && hay.windows(needle.len()).any(|w| w == needle)
|
||||
};
|
||||
let names_of = |p: &ledger_core::Party| -> Vec<Vec<String>> {
|
||||
let mut out = vec![words(&p.name)];
|
||||
if let Some(d) = &p.display_name {
|
||||
out.push(words(d));
|
||||
}
|
||||
if let Some(first) = words(&p.name).first()
|
||||
&& first.len() >= 4
|
||||
{
|
||||
out.push(vec![first.clone()]);
|
||||
}
|
||||
out
|
||||
};
|
||||
let receipts: String = store
|
||||
.attachments_for(ledger_store::Subject::Entry(entry.id()))
|
||||
.map_err(|e| e.to_string())?
|
||||
.iter()
|
||||
.map(|a| {
|
||||
format!(
|
||||
"{} {} ",
|
||||
a.file_name,
|
||||
a.description.clone().unwrap_or_default()
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let desc = words(entry.description());
|
||||
let rec = words(&receipts);
|
||||
let mut out: Vec<PartySuggestionOut> = Vec::new();
|
||||
let push = |out: &mut Vec<PartySuggestionOut>, p: &ledger_core::Party, why: String| {
|
||||
if !out.iter().any(|s| s.party_id == p.id) {
|
||||
out.push(PartySuggestionOut {
|
||||
party_id: p.id,
|
||||
name: p.name.clone(),
|
||||
why,
|
||||
});
|
||||
}
|
||||
};
|
||||
let live: Vec<&ledger_core::Party> = ledger.parties().filter(|p| !p.archived).collect();
|
||||
for p in &live {
|
||||
if names_of(p).iter().any(|n| contains(&desc, n)) {
|
||||
push(&mut out, p, "named in the description".into());
|
||||
}
|
||||
}
|
||||
for p in &live {
|
||||
if names_of(p).iter().any(|n| contains(&rec, n)) {
|
||||
push(&mut out, p, "named in a receipt".into());
|
||||
}
|
||||
}
|
||||
let key = entry.description().trim().to_lowercase();
|
||||
for e in ledger.posted_entries() {
|
||||
if e.id() != entry.id()
|
||||
&& e.description().trim().to_lowercase() == key
|
||||
&& let Some(p) = ledger
|
||||
.entry_parties(e.id())
|
||||
.first()
|
||||
.and_then(|s| ledger.get_party(s.party))
|
||||
&& !p.archived
|
||||
{
|
||||
push(
|
||||
&mut out,
|
||||
p,
|
||||
format!("tagged on entry {} with the same description", e.id()),
|
||||
);
|
||||
}
|
||||
}
|
||||
out.truncate(3);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// An entry's parties for output, each share resolved, and why they don't
|
||||
/// resolve when they don't.
|
||||
fn entry_parties_out(
|
||||
store: &Store,
|
||||
settings: &Settings,
|
||||
entry: &JournalEntry,
|
||||
) -> (Vec<EntryPartyOut>, Option<String>) {
|
||||
let ledger = store.ledger();
|
||||
let shares = ledger.entry_parties(entry.id());
|
||||
let (accounts, members) = share_context(ledger, settings);
|
||||
let (resolved, error) =
|
||||
match ledger_core::shares::resolve(ledger, entry.id(), shares, &accounts, &members) {
|
||||
Ok(r) => (Some(r), None),
|
||||
Err(e) => (None, Some(e)),
|
||||
};
|
||||
let out = shares
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, s)| {
|
||||
let (kind, amount, percent) = match s.share {
|
||||
ledger_core::Share::Amount(a) => ("amount", Some(money(a)), None),
|
||||
ledger_core::Share::Percent(p) => {
|
||||
("percent", None, Some(p.normalize().to_string()))
|
||||
}
|
||||
ledger_core::Share::Derived => ("derived", None, None),
|
||||
};
|
||||
EntryPartyOut {
|
||||
party_id: s.party,
|
||||
party: ledger
|
||||
.get_party(s.party)
|
||||
.map(|p| p.label().to_string())
|
||||
.unwrap_or_else(|| format!("party {}", s.party)),
|
||||
share: kind.to_string(),
|
||||
amount,
|
||||
percent,
|
||||
resolved: resolved.as_ref().map(|r| money(r.parts[i].amount)),
|
||||
direction: resolved
|
||||
.as_ref()
|
||||
.map(|r| r.target.direction.key().to_string()),
|
||||
cash: resolved.as_ref().map(|r| money(r.parts[i].cash)),
|
||||
in_kind: resolved.as_ref().map(|r| money(r.parts[i].in_kind)),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
(out, error)
|
||||
}
|
||||
|
||||
/// Preview lines with resulting balances for an entry.
|
||||
fn preview_lines(store: &Store, entry: &JournalEntry) -> Result<Vec<PreviewLine>, String> {
|
||||
let lines: Vec<(AccountId, Side, Money)> = entry
|
||||
.lines()
|
||||
.iter()
|
||||
.map(|l| (l.account, l.side, l.amount))
|
||||
.collect();
|
||||
preview_lines_for(store, &lines)
|
||||
}
|
||||
|
||||
fn preview_receipts(store: &Store, entry_id: u64) -> Result<Vec<PreviewReceipt>, String> {
|
||||
Ok(store
|
||||
.list_attachments(Some(entry_id))
|
||||
.map_err(|e| e.to_string())?
|
||||
.into_iter()
|
||||
.map(|a| PreviewReceipt {
|
||||
attachment_id: a.id,
|
||||
file_name: a.file_name,
|
||||
sha256: a.blob_sha,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[tool_router(router = entries_router, vis = "pub(super)")]
|
||||
impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.draft_entry",
|
||||
title = "Draft an entry",
|
||||
description = "Start a draft journal entry. Drafts are journaled and survive restarts, but don't affect reports until posted; add lines with books.add_line. Pass party (id or name) for the counterparty: every entry with a vendor, platform, agency, bank, member, or contractor names its party (a party with a vendor record tags the vendor too, for 1099s). An entry with several counterparties passes parties instead, each with an amount, a percent, or neither (derived): name both members on a joint member transaction and let their shares derive.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn draft_entry(
|
||||
&self,
|
||||
params: Parameters<DraftEntryParams>,
|
||||
) -> Result<Json<DraftEntryOut>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
// Resolve every reference before writing anything (INVARIANT 2).
|
||||
let date = parse_date(&p.date)?;
|
||||
let vendor = p
|
||||
.vendor
|
||||
.as_deref()
|
||||
.map(|v| resolve_vendor(&store, v))
|
||||
.transpose()?;
|
||||
let instrument = p
|
||||
.instrument
|
||||
.as_deref()
|
||||
.map(|r| resolve_instrument(&store, r))
|
||||
.transpose()?;
|
||||
let party = p
|
||||
.party
|
||||
.as_deref()
|
||||
.map(|r| resolve_party(&store, r))
|
||||
.transpose()?;
|
||||
let no_party = p
|
||||
.no_party_reason
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|r| !r.is_empty());
|
||||
let shares = p
|
||||
.parties
|
||||
.as_deref()
|
||||
.map(|ps| parse_shares(&store, ps))
|
||||
.transpose()?;
|
||||
if no_party.is_some() && (party.is_some() || vendor.is_some() || shares.is_some()) {
|
||||
return Err(
|
||||
"no_party_reason is for an entry with no counterparty: pass it without party, parties, or vendor".into(),
|
||||
);
|
||||
}
|
||||
if shares.is_some() && (party.is_some() || vendor.is_some()) {
|
||||
return Err(
|
||||
"pass party (one) or parties (several, with shares), not both; a vendor party in parties tags its vendor record".into(),
|
||||
);
|
||||
}
|
||||
if let Some(sh) = &shares {
|
||||
store
|
||||
.ledger()
|
||||
.check_party_shares(sh)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
if let (Some(v), Some(pid)) = (vendor, party) {
|
||||
let party_vendor = store.ledger().get_party(pid).and_then(|p| p.vendor);
|
||||
if party_vendor != Some(v) {
|
||||
return Err(format!(
|
||||
"vendor {v} isn't party {pid}'s vendor record: pass only the party (its vendor record is tagged with it), or the vendor alone"
|
||||
));
|
||||
}
|
||||
}
|
||||
// A payment method exists only in books that declare the
|
||||
// payment_instruments feature (books.add_instrument needs it), so
|
||||
// tagging the draft with one below can't be refused for the format
|
||||
// after the draft's first line (B-27).
|
||||
let id = store
|
||||
.create_entry(date, &p.description)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if let Some(sh) = shares.filter(|sh| !sh.is_empty()) {
|
||||
store
|
||||
.set_entry_parties(id, &sh)
|
||||
.map_err(|e| e.to_string())?;
|
||||
} else if party.is_some() {
|
||||
store
|
||||
.set_entry_party(id, party)
|
||||
.map_err(|e| e.to_string())?;
|
||||
} else if vendor.is_some() {
|
||||
store
|
||||
.set_entry_vendor(id, vendor)
|
||||
.map_err(|e| e.to_string())?;
|
||||
} else if let Some(r) = no_party {
|
||||
store.set_entry_no_party(id, r).map_err(|e| e.to_string())?;
|
||||
}
|
||||
if instrument.is_some() {
|
||||
store
|
||||
.set_entry_instrument(id, instrument)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(Json(DraftEntryOut {
|
||||
entry_id: id,
|
||||
status: "draft".to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.add_line",
|
||||
title = "Add a line to a draft",
|
||||
description = "Add one line to a draft entry. Amount is a decimal string, never a float. The entry must balance before posting. For foreign-currency lines, pass foreign_currency, foreign_amount, and fx_rate (foreign -> base); the base amount is computed as foreign_amount * fx_rate. Pass class (a project: name or id) to tag the line; see books.projects.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn add_line(
|
||||
&self,
|
||||
params: Parameters<AddLineParams>,
|
||||
) -> Result<Json<AddLineOut>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
let account = resolve_account(&store, &p.account)?;
|
||||
let side = parse_side(&p.side)?;
|
||||
// Resolve the class before writing anything (INVARIANT 2).
|
||||
let class_id = match &p.class {
|
||||
Some(c) => Some(resolve_class(&store, c)?),
|
||||
None => None,
|
||||
};
|
||||
// Foreign-currency line?
|
||||
if p.foreign_currency.is_some() || p.foreign_amount.is_some() || p.fx_rate.is_some() {
|
||||
let fc = p
|
||||
.foreign_currency
|
||||
.ok_or("foreign_currency is required for fx lines")?;
|
||||
let fa = p
|
||||
.foreign_amount
|
||||
.ok_or("foreign_amount is required for fx lines")?;
|
||||
let rate = p.fx_rate.ok_or("fx_rate is required for fx lines")?;
|
||||
let currency = Currency::new(&fc).map_err(|e| e.to_string())?;
|
||||
let foreign_amount = parse_amount(&fa)?;
|
||||
let fx_rate = parse_amount(&rate)?;
|
||||
store
|
||||
.add_line_fx(p.entry_id, account, side, currency, foreign_amount, fx_rate)
|
||||
.map_err(|e| e.to_string())?;
|
||||
} else {
|
||||
let amount = parse_amount(&p.amount)?;
|
||||
if amount <= Decimal::ZERO {
|
||||
return Err("amount must be positive".to_string());
|
||||
}
|
||||
let currency = store.base_currency();
|
||||
store
|
||||
.add_line(p.entry_id, account, side, Money::new(amount, currency))
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
// Class tag, if requested. The line just added is the last one.
|
||||
if let Some(class_id) = class_id {
|
||||
let line_index = store
|
||||
.ledger()
|
||||
.get_entry(p.entry_id)
|
||||
.ok_or_else(|| format!("unknown entry {}", p.entry_id))?
|
||||
.lines()
|
||||
.len()
|
||||
.checked_sub(1)
|
||||
.ok_or_else(|| "entry has no lines".to_string())?;
|
||||
store
|
||||
.set_line_class(p.entry_id, line_index, Some(class_id))
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
let note = class_id.and_then(|c| projects::late_tag_note(store.ledger(), c));
|
||||
Ok(Json(AddLineOut { ok: true, note }))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.post_entry",
|
||||
title = "Post an entry",
|
||||
description = "Post a draft entry. Without confirmed=true this returns a preview (lines, resulting balances, receipt checklist) and posts nothing. With confirmed=true it posts after enforcing the receipt policy.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = true,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn post_entry(
|
||||
&self,
|
||||
params: Parameters<PostEntryParams>,
|
||||
) -> Result<Json<PostEntryOut>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
let entry = store
|
||||
.ledger()
|
||||
.get_entry(p.entry_id)
|
||||
.ok_or_else(|| format!("unknown entry {}", p.entry_id))?
|
||||
.clone();
|
||||
if entry.status() != ledger_core::EntryStatus::Draft {
|
||||
return Err(format!("entry {} is not a draft", p.entry_id));
|
||||
}
|
||||
// Pure validation before anything is journaled.
|
||||
entry
|
||||
.validate(store.ledger().chart())
|
||||
.map_err(|e| e.to_string())?;
|
||||
// Validated: its lines are in a line's range, so their sum is exact.
|
||||
let total = ReceiptPolicy::entry_debits(&entry).map_err(|e| e.to_string())?;
|
||||
if let Some(expected) = p.expected_total {
|
||||
let want = parse_amount(&expected)?;
|
||||
if total != want {
|
||||
return Err(format!(
|
||||
"expected_total mismatch: entry totals {total}, expected {want}"
|
||||
));
|
||||
}
|
||||
}
|
||||
let mut receipt_required = self
|
||||
.settings()
|
||||
.policy
|
||||
.receipt_required(store.ledger(), &entry);
|
||||
let receipts = preview_receipts(&store, p.entry_id)?;
|
||||
// Anti-splitting: if this entry looks like part of a split group,
|
||||
// the receipt policy applies to the group total.
|
||||
let split_group = self
|
||||
.settings()
|
||||
.policy
|
||||
.split_group(store.ledger(), p.entry_id, &entry);
|
||||
if !split_group.is_empty() {
|
||||
receipt_required = true;
|
||||
}
|
||||
let receipt_missing = receipt_required && receipts.is_empty();
|
||||
let party_policy = self.settings().parties;
|
||||
let party_id = store.ledger().entry_party(p.entry_id);
|
||||
let (parties, parties_error) = entry_parties_out(&store, &self.settings(), &entry);
|
||||
let no_party_reason = store
|
||||
.ledger()
|
||||
.entry_no_party(p.entry_id)
|
||||
.map(str::to_string);
|
||||
// Any party, or a no-party mark, satisfies the policy.
|
||||
let party_missing = party_policy != ledger_config::PartyPolicy::Off
|
||||
&& parties.is_empty()
|
||||
&& no_party_reason.is_none();
|
||||
let party_suggestions = if party_missing {
|
||||
suggest_parties(&store, &entry)?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let out = PostEntryOut {
|
||||
status: "preview".to_string(),
|
||||
entry_id: p.entry_id,
|
||||
date: entry.date().to_string(),
|
||||
description: entry.description().to_string(),
|
||||
total: money(total),
|
||||
lines: preview_lines(&store, &entry)?,
|
||||
receipts,
|
||||
receipt_required,
|
||||
receipt_missing,
|
||||
split_detected: split_group.clone(),
|
||||
party_policy: party_policy.key().to_string(),
|
||||
party_id,
|
||||
party: party_id
|
||||
.and_then(|i| store.ledger().get_party(i))
|
||||
.map(|x| x.name.clone()),
|
||||
parties,
|
||||
parties_error,
|
||||
no_party_reason,
|
||||
party_missing,
|
||||
party_suggestions,
|
||||
approvals_missing: store
|
||||
.ledger()
|
||||
.approvals_missing(p.entry_id)
|
||||
.map_err(|e| e.to_string())?
|
||||
.into_iter()
|
||||
.map(|(rule, approver)| ApprovalNeededOut { rule, approver })
|
||||
.collect(),
|
||||
};
|
||||
if !p.confirmed.unwrap_or(false) {
|
||||
return Ok(Json(out));
|
||||
}
|
||||
if !out.approvals_missing.is_empty() {
|
||||
let missing: Vec<String> = out
|
||||
.approvals_missing
|
||||
.iter()
|
||||
.map(|a| format!("{} (rule {})", a.approver, a.rule))
|
||||
.collect();
|
||||
return Err(serde_json::json!({
|
||||
"code": "approval_required",
|
||||
"message": format!(
|
||||
"entry {} needs approval before it posts (policy.approval_rules is enforce): {}. Ask each approver; when they approve, record it with books.approve_entry, then post.",
|
||||
p.entry_id,
|
||||
missing.join("; ")
|
||||
),
|
||||
"entry_id": p.entry_id,
|
||||
"remedy_tool": "books.approve_entry",
|
||||
})
|
||||
.to_string());
|
||||
}
|
||||
if let Some(why) = &out.parties_error {
|
||||
return Err(serde_json::json!({
|
||||
"code": "party_shares",
|
||||
"message": format!(
|
||||
"entry {}'s party shares don't add up: {why}. Fix them with books.set_entry_party parties (amounts that sum to what it moved, or percents summing to 100).",
|
||||
p.entry_id
|
||||
),
|
||||
"entry_id": p.entry_id,
|
||||
"remedy_tool": "books.set_entry_party",
|
||||
})
|
||||
.to_string());
|
||||
}
|
||||
if party_missing && party_policy == ledger_config::PartyPolicy::Require {
|
||||
let names: Vec<String> = out
|
||||
.party_suggestions
|
||||
.iter()
|
||||
.map(|s| format!("{} (party {}, {})", s.name, s.party_id, s.why))
|
||||
.collect();
|
||||
return Err(serde_json::json!({
|
||||
"code": "party_required",
|
||||
"message": format!(
|
||||
"entry {} names no counterparty, and these books require one (policy.parties = \"require\"). Tag it with books.set_entry_party (add the party with books.add_party first if it's new), or, if it truly has none, call books.set_entry_party with party null and no_party_reason.{}",
|
||||
p.entry_id,
|
||||
if names.is_empty() { String::new() } else { format!(" Likely: {}.", names.join("; ")) }
|
||||
),
|
||||
"entry_id": p.entry_id,
|
||||
"remedy_tool": "books.set_entry_party",
|
||||
"party_suggestions": out.party_suggestions,
|
||||
})
|
||||
.to_string());
|
||||
}
|
||||
if receipt_missing {
|
||||
let split_note = if split_group.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(
|
||||
" Anti-splitting: this looks like part of a split group with entries {:?}; the policy applies to the group total.",
|
||||
split_group
|
||||
)
|
||||
};
|
||||
return Err(ReceiptRequiredError {
|
||||
code: "receipt_required".to_string(),
|
||||
message: format!(
|
||||
"entry {} moves {} in debits ({}); attach a receipt with books.attach_receipt before posting.{}",
|
||||
p.entry_id,
|
||||
money(total),
|
||||
self.settings().policy.describe(),
|
||||
split_note
|
||||
),
|
||||
entry_id: Some(p.entry_id),
|
||||
asset_id: None,
|
||||
debits: total.to_string(),
|
||||
policy: self.settings().policy.describe(),
|
||||
remedy_tool: "books.attach_receipt".to_string(),
|
||||
remedy_hint: format!(
|
||||
"attach a receipt to entry {} with books.attach_receipt, then retry books.post_entry with confirmed=true",
|
||||
p.entry_id
|
||||
),
|
||||
split_group,
|
||||
}
|
||||
.to_tool_error());
|
||||
}
|
||||
store.post_entry(p.entry_id).map_err(|e| e.to_string())?;
|
||||
fix_derived_shares(&mut store, &self.settings(), p.entry_id)?;
|
||||
let mut posted = out;
|
||||
posted.status = "posted".to_string();
|
||||
Ok(Json(posted))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.reverse_entry",
|
||||
title = "Reverse an entry",
|
||||
description = "Reverse a posted entry with an offsetting entry. Without confirmed=true returns a preview; reversals are exempt from the receipt policy.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = true,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn reverse_entry(
|
||||
&self,
|
||||
params: Parameters<ReverseEntryParams>,
|
||||
) -> Result<Json<ReverseEntryOut>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
let orig = store
|
||||
.ledger()
|
||||
.get_entry(p.entry_id)
|
||||
.ok_or_else(|| format!("unknown entry {}", p.entry_id))?
|
||||
.clone();
|
||||
if orig.status() != ledger_core::EntryStatus::Posted {
|
||||
return Err(format!("entry {} is not posted", p.entry_id));
|
||||
}
|
||||
let date = parse_date(&p.date)?;
|
||||
// Refused before the preview, as the post would be (an asset's
|
||||
// disposal, a run its disposal took as final, a month posted twice).
|
||||
store
|
||||
.check_reverse_entry(p.entry_id, date)
|
||||
.map_err(|e| e.to_string())?;
|
||||
// Build the preview from the flipped lines without mutating.
|
||||
let flipped: Vec<(AccountId, Side, Money)> = orig
|
||||
.lines()
|
||||
.iter()
|
||||
.map(|l| {
|
||||
let side = match l.side {
|
||||
Side::Debit => Side::Credit,
|
||||
Side::Credit => Side::Debit,
|
||||
};
|
||||
(l.account, side, l.amount)
|
||||
})
|
||||
.collect();
|
||||
let out = ReverseEntryOut {
|
||||
status: "preview".to_string(),
|
||||
reversal_id: 0, // filled in on post
|
||||
reverses: p.entry_id,
|
||||
lines: preview_lines_for(&store, &flipped)?,
|
||||
};
|
||||
if !p.confirmed.unwrap_or(false) {
|
||||
return Ok(Json(out));
|
||||
}
|
||||
let reversal_id = store
|
||||
.reverse_entry(p.entry_id, date, &p.description)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut posted = out;
|
||||
posted.status = "posted".to_string();
|
||||
posted.reversal_id = reversal_id;
|
||||
Ok(Json(posted))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.get_entry",
|
||||
title = "Get an entry",
|
||||
description = "Full detail of one entry: lines, totals, status, and attached receipts.",
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
)]
|
||||
pub(super) async fn get_entry(
|
||||
&self,
|
||||
params: Parameters<GetEntryParams>,
|
||||
) -> Result<Json<GetEntryOut>, String> {
|
||||
let p = params.0;
|
||||
let store = self.lock()?;
|
||||
let entry = store
|
||||
.ledger()
|
||||
.get_entry(p.entry_id)
|
||||
.ok_or_else(|| format!("unknown entry {}", p.entry_id))?
|
||||
.clone();
|
||||
let lines = entry
|
||||
.lines()
|
||||
.iter()
|
||||
.map(|l| {
|
||||
// A draft line an earlier version journaled may name an
|
||||
// account the chart doesn't have (rules.md B-28).
|
||||
let (code, name) = match store.ledger().chart().get(l.account) {
|
||||
Some(a) => (a.code.clone(), a.name.clone()),
|
||||
None => (String::new(), format!("(no account {})", l.account)),
|
||||
};
|
||||
EntryLineOut {
|
||||
account_id: l.account,
|
||||
code,
|
||||
name,
|
||||
side: match l.side {
|
||||
Side::Debit => "debit".to_string(),
|
||||
Side::Credit => "credit".to_string(),
|
||||
},
|
||||
amount: money(l.amount.amount),
|
||||
class_id: l.class_id,
|
||||
project: l
|
||||
.class_id
|
||||
.and_then(|c| store.ledger().get_class(c))
|
||||
.map(|c| c.name.clone()),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let (parties, parties_error) = entry_parties_out(&store, &self.settings(), &entry);
|
||||
let total = ReceiptPolicy::entry_debits(&entry);
|
||||
Ok(Json(GetEntryOut {
|
||||
entry_id: p.entry_id,
|
||||
date: entry.date().to_string(),
|
||||
description: entry.description().to_string(),
|
||||
status: match entry.status() {
|
||||
ledger_core::EntryStatus::Draft => "draft".to_string(),
|
||||
ledger_core::EntryStatus::Posted => "posted".to_string(),
|
||||
},
|
||||
reversal_of: entry.reversal_of(),
|
||||
lines,
|
||||
total: total.as_ref().ok().map(|t| money(*t)),
|
||||
total_error: total.err().map(|e| e.to_string()),
|
||||
receipts: preview_receipts(&store, p.entry_id)?,
|
||||
vendor: store
|
||||
.ledger()
|
||||
.entry_vendor(p.entry_id)
|
||||
.and_then(|v| store.ledger().get_vendor(v))
|
||||
.map(|v| v.name.clone()),
|
||||
instrument: store
|
||||
.ledger()
|
||||
.entry_instrument(p.entry_id)
|
||||
.and_then(|i| store.ledger().get_instrument(i))
|
||||
.map(|i| i.label.clone()),
|
||||
instrument_id: store.ledger().entry_instrument(p.entry_id),
|
||||
party_id: store.ledger().entry_party(p.entry_id),
|
||||
party: store
|
||||
.ledger()
|
||||
.entry_party(p.entry_id)
|
||||
.and_then(|id| store.ledger().get_party(id))
|
||||
.map(|p| p.label().to_string()),
|
||||
parties,
|
||||
parties_error,
|
||||
no_party_reason: store
|
||||
.ledger()
|
||||
.entry_no_party(p.entry_id)
|
||||
.map(str::to_string),
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.search_entries",
|
||||
title = "Search entries",
|
||||
description = "Search posted entries by account, date range, and description text. Newest first.",
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn search_entries(
|
||||
&self,
|
||||
params: Parameters<SearchEntriesParams>,
|
||||
) -> Result<Json<SearchEntriesOut>, String> {
|
||||
let p = params.0;
|
||||
let store = self.lock()?;
|
||||
let rows = store
|
||||
.search_entries(
|
||||
p.account_id,
|
||||
p.date_from.as_deref(),
|
||||
p.date_to.as_deref(),
|
||||
p.text.as_deref(),
|
||||
p.limit.unwrap_or(50).min(500),
|
||||
p.offset.unwrap_or(0),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut entries = Vec::with_capacity(rows.len());
|
||||
for r in rows {
|
||||
let e = store
|
||||
.ledger()
|
||||
.get_entry(r.id)
|
||||
.ok_or_else(|| format!("entry {} missing from ledger", r.id))?;
|
||||
let total = ReceiptPolicy::entry_debits(e).map_err(|e| e.to_string())?;
|
||||
let receipt_count = store
|
||||
.list_attachments(Some(r.id))
|
||||
.map_err(|e| e.to_string())?
|
||||
.len() as u64;
|
||||
entries.push(EntrySummary {
|
||||
entry_id: r.id,
|
||||
date: r.date,
|
||||
description: r.description,
|
||||
total: money(total),
|
||||
reversal_of: r.reversal_of,
|
||||
receipt_count,
|
||||
});
|
||||
}
|
||||
Ok(Json(SearchEntriesOut { entries }))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.import_bank_csv",
|
||||
title = "Import a bank CSV as drafts",
|
||||
description = "Import a QuickBooks-style bank/credit-card CSV: 3 columns Date,Description,Amount (payments negative), or 4 columns Date,Description, money out, money in (Intuit heads them Credit,Debit). Slash dates must settle month-first or day-first from the file itself or from date_order (\"month_first\" for mm/dd, \"day_first\" for dd/mm); ask the human when the tool says the dates read either way. Creates one DRAFT entry per transaction, all or none: bank account on one side, categorized offset on the other (the longest matching rule pattern, else default_offset_code). Give the CSV as upload_id: upload it with books.begin_upload purpose import and `wanderbooks upload` (a file never passes through you). Review with books.digest, then post. File the export too (books.file_document category import_source) and pass source_attachment_id: it is linked to the bank account, so the import traces to its source (never to the drafts: a bank export is not a receipt).",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn import_bank_csv(
|
||||
&self,
|
||||
params: Parameters<ImportBankCsvParams>,
|
||||
) -> Result<Json<ImportBankCsvOut>, String> {
|
||||
let p = params.0;
|
||||
let order = match p.date_order.as_deref() {
|
||||
None => None,
|
||||
Some("month_first") => Some(ledger_import::DateOrder::MonthFirst),
|
||||
Some("day_first") => Some(ledger_import::DateOrder::DayFirst),
|
||||
Some(other) => {
|
||||
return Err(format!(
|
||||
"date_order must be \"month_first\" or \"day_first\", got {other:?}"
|
||||
));
|
||||
}
|
||||
};
|
||||
let (csv, claim) = self.csv_given(&p.upload_id)?;
|
||||
let txns = ledger_import::parse_bank_csv_with(&csv, order).map_err(|e| e.to_string())?;
|
||||
let mut store = self.lock()?;
|
||||
let bank = store
|
||||
.ledger()
|
||||
.chart()
|
||||
.find_by_code(&p.bank_account_code)
|
||||
.map(|a| (a.id, a.code.clone()))
|
||||
.ok_or_else(|| format!("unknown bank account code: {}", p.bank_account_code))?;
|
||||
// The source file links to the account, checked before any draft.
|
||||
let link_source = match p.source_attachment_id {
|
||||
None => false,
|
||||
Some(att) => {
|
||||
let a = find_attachment(&store, att)?;
|
||||
if a.withdrawn_at.is_some() {
|
||||
return Err(format!("attachment {att} is withdrawn"));
|
||||
}
|
||||
!store
|
||||
.attachment_links(att)
|
||||
.map_err(|e| e.to_string())?
|
||||
.contains(&ledger_store::Subject::Account(bank.0))
|
||||
}
|
||||
};
|
||||
let default_offset = store
|
||||
.ledger()
|
||||
.chart()
|
||||
.find_by_code(&p.default_offset_code)
|
||||
.map(|a| (a.id, a.code.clone()))
|
||||
.ok_or_else(|| format!("unknown offset account code: {}", p.default_offset_code))?;
|
||||
// Resolve rule account codes upfront.
|
||||
let mut rules: Vec<(String, (ledger_core::AccountId, String))> = Vec::new();
|
||||
for (pattern, code) in &p.rules {
|
||||
let (id, c) = store
|
||||
.ledger()
|
||||
.chart()
|
||||
.find_by_code(code)
|
||||
.map(|a| (a.id, a.code.clone()))
|
||||
.ok_or_else(|| format!("unknown rule account code: {code}"))?;
|
||||
rules.push((pattern.to_lowercase(), (id, c)));
|
||||
}
|
||||
// The most specific pattern wins (longest, then alphabetical): the
|
||||
// rules arrive as a map with no order, so "first match" would pick
|
||||
// a different account from run to run when two patterns match.
|
||||
rules.sort_by(|a, b| b.0.len().cmp(&a.0.len()).then_with(|| a.0.cmp(&b.0)));
|
||||
let offset_for = |description: &str| {
|
||||
let desc_lower = description.to_lowercase();
|
||||
rules
|
||||
.iter()
|
||||
.find(|(pat, _)| desc_lower.contains(pat.as_str()))
|
||||
.map(|(_, v)| v.clone())
|
||||
.unwrap_or_else(|| default_offset.clone())
|
||||
};
|
||||
let base = store.ledger().base_currency();
|
||||
// Rehearse the whole file on a copy first, so a transaction that
|
||||
// can't become a draft (a date in a closed period, say) refuses the
|
||||
// file instead of leaving the ones before it behind.
|
||||
let mut scratch = store.ledger().clone();
|
||||
for txn in &txns {
|
||||
let (offset_id, _) = offset_for(&txn.description);
|
||||
let amount = Money::new(txn.amount.abs(), base);
|
||||
let (bank_side, offset_side) = if txn.amount > Decimal::ZERO {
|
||||
(Side::Debit, Side::Credit)
|
||||
} else {
|
||||
(Side::Credit, Side::Debit)
|
||||
};
|
||||
let e = scratch
|
||||
.try_create_entry(txn.date, &txn.description)
|
||||
.and_then(|e| scratch.add_line(e, bank.0, bank_side, amount).map(|()| e))
|
||||
.and_then(|e| scratch.add_line(e, offset_id, offset_side, amount))
|
||||
.map_err(|e| format!("{} {}: {e}", txn.date, txn.description));
|
||||
e?;
|
||||
}
|
||||
// Rehearsed whole: the upload is used up as the writing begins.
|
||||
files::filed(claim);
|
||||
let mut imported = Vec::new();
|
||||
for txn in txns {
|
||||
let (offset_id, offset_code) = offset_for(&txn.description);
|
||||
let amount = Money::new(txn.amount.abs(), store.ledger().base_currency());
|
||||
let entry_id = store
|
||||
.create_entry(txn.date, &format!("Bank import — {}", txn.description))
|
||||
.map_err(|e| e.to_string())?;
|
||||
// Money in: debit bank, credit offset. Money out: reverse.
|
||||
let (bank_side, offset_side) = if txn.amount > Decimal::ZERO {
|
||||
(Side::Debit, Side::Credit)
|
||||
} else {
|
||||
(Side::Credit, Side::Debit)
|
||||
};
|
||||
store
|
||||
.add_line(entry_id, bank.0, bank_side, amount)
|
||||
.map_err(|e| e.to_string())?;
|
||||
store
|
||||
.add_line(entry_id, offset_id, offset_side, amount)
|
||||
.map_err(|e| e.to_string())?;
|
||||
imported.push(ImportedTxnOut {
|
||||
entry_id,
|
||||
date: txn.date.to_string(),
|
||||
description: txn.description.clone(),
|
||||
amount: money(txn.amount),
|
||||
offset_account: offset_code,
|
||||
});
|
||||
}
|
||||
if link_source && let Some(att) = p.source_attachment_id {
|
||||
store
|
||||
.link_attachment(att, ledger_store::Subject::Account(bank.0))
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
let count = imported.len();
|
||||
Ok(Json(ImportBankCsvOut {
|
||||
imported,
|
||||
count,
|
||||
source_attachment_id: p.source_attachment_id,
|
||||
source_linked: link_source,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::server::test_support::*;
|
||||
|
||||
/// lines_settled: posted, or a draft with lines whose debits equal its
|
||||
/// credits; a draft whose sides are past exact arithmetic (one an
|
||||
/// earlier version journaled, rules.md B-28) is not settled.
|
||||
#[test]
|
||||
fn settled_lines_are_posted_or_balanced_drafts() {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let cash = l
|
||||
.add_account("1000", "Cash", ledger_core::AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let rent = l
|
||||
.add_account(
|
||||
"6000",
|
||||
"Rent",
|
||||
ledger_core::AccountType::Expense,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let mut draft = |lines: &[(u64, Side, Decimal)]| {
|
||||
let e = l.create_entry(d(2026, 3, 1), "x");
|
||||
for (a, side, amount) in lines {
|
||||
l.replay_add_line(e, *a, *side, Money::new(*amount, Currency::USD))
|
||||
.unwrap();
|
||||
}
|
||||
e
|
||||
};
|
||||
let ten = Decimal::TEN;
|
||||
let empty = draft(&[]);
|
||||
let balanced = draft(&[(rent, Side::Debit, ten), (cash, Side::Credit, ten)]);
|
||||
let unbalanced = draft(&[(rent, Side::Debit, ten), (cash, Side::Credit, Decimal::ONE)]);
|
||||
let huge = draft(&[
|
||||
(rent, Side::Debit, Decimal::MAX),
|
||||
(rent, Side::Debit, Decimal::MAX),
|
||||
(cash, Side::Credit, Decimal::MAX),
|
||||
(cash, Side::Credit, Decimal::MAX),
|
||||
]);
|
||||
let posted = draft(&[(rent, Side::Debit, ten), (cash, Side::Credit, ten)]);
|
||||
l.post_entry(posted).unwrap();
|
||||
let settled = |e: u64| lines_settled(l.get_entry(e).unwrap());
|
||||
assert!(!settled(empty));
|
||||
assert!(settled(balanced));
|
||||
assert!(!settled(unbalanced));
|
||||
assert!(!settled(huge));
|
||||
assert!(settled(posted));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,710 @@
|
||||
//! The helpers the tool groups share: resolving a name or a code to an
|
||||
//! id, reading a tool's amounts and dates, shaping a party, a document or
|
||||
//! a statement for output, and the journal's own readings. They live here
|
||||
//! so that no group file reaches into another group's (plan/served.md 8.2
|
||||
//! U0a, Build step 2); `server.rs` re-exports them and keeps the request
|
||||
//! machinery.
|
||||
|
||||
use super::*;
|
||||
use ledger_core::InstrumentId;
|
||||
|
||||
/// The book's id, as the commands given to agents name it: its folder's
|
||||
/// name in the data root.
|
||||
pub(crate) fn book_name(store: &Store) -> String {
|
||||
store
|
||||
.data_dir()
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A filed file's name and type: those given, else the upload's own.
|
||||
pub(crate) fn named(
|
||||
file_name: Option<String>,
|
||||
mime_type: Option<String>,
|
||||
file: &files::TakenFile,
|
||||
) -> Result<(String, String), String> {
|
||||
let name = file_name.unwrap_or_else(|| file.file_name.clone());
|
||||
let mime = mime_type.unwrap_or_else(|| file.mime.clone());
|
||||
if let Some(why) = ledger_store::uploads::file_name_problem(&name)
|
||||
.or_else(|| ledger_store::uploads::mime_problem(&mime))
|
||||
{
|
||||
return Err(why);
|
||||
}
|
||||
Ok((name, mime))
|
||||
}
|
||||
|
||||
/// The settings as ledger_package reads them: the company, and the
|
||||
/// `[books]` cash, financing, and investing roles.
|
||||
pub(crate) fn package_settings(settings: &Settings) -> ledger_package::Settings<'_> {
|
||||
ledger_package::Settings {
|
||||
company: &settings.company,
|
||||
cash_accounts: &settings.roles.cash,
|
||||
financing_accounts: &settings.roles.financing,
|
||||
investing_accounts: &settings.roles.investing,
|
||||
}
|
||||
}
|
||||
|
||||
/// A decimal string, exactly `-?digits[.digits]` (INVARIANT 3).
|
||||
pub(crate) fn parse_amount(s: &str) -> Result<Decimal, String> {
|
||||
ledger_config::decimal(s)
|
||||
.ok_or_else(|| format!("bad amount {s:?}: expected a decimal string like \"123.45\""))
|
||||
}
|
||||
|
||||
pub(crate) fn parse_side(s: &str) -> Result<Side, String> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"debit" => Ok(Side::Debit),
|
||||
"credit" => Ok(Side::Credit),
|
||||
_ => Err(format!("bad side {s:?}: expected \"debit\" or \"credit\"")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every account code a live convention names: (convention id, code).
|
||||
pub(crate) fn convention_codes(ledger: &Ledger) -> Vec<(String, String)> {
|
||||
let mut out = Vec::new();
|
||||
for c in ledger
|
||||
.conventions()
|
||||
.conventions()
|
||||
.filter(|c| c.retired.is_none())
|
||||
{
|
||||
if let Some(t) = c.template() {
|
||||
for l in t.lines {
|
||||
if ledger_core::conventions::slot(&l.account).is_none() {
|
||||
out.push((c.id.clone(), l.account));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(r) = c.approval_rule() {
|
||||
for a in r.accounts {
|
||||
out.push((c.id.clone(), a));
|
||||
}
|
||||
}
|
||||
}
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
|
||||
pub(crate) fn settings_problems(ledger: &Ledger, cfg: &Settings) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
// Named as the books keep them.
|
||||
let how = " with books.set_setting";
|
||||
// The cash flow roles: codes the chart lacks here; the rest is the
|
||||
// engine's own check, the one the statement refuses on (rules.md B-14
|
||||
// rule 2), so the two never disagree.
|
||||
let lists = [
|
||||
(
|
||||
ledger_core::RoleList::Cash,
|
||||
"books.cash_accounts",
|
||||
&cfg.roles.cash,
|
||||
),
|
||||
(
|
||||
ledger_core::RoleList::Financing,
|
||||
"books.financing_accounts",
|
||||
&cfg.roles.financing,
|
||||
),
|
||||
(
|
||||
ledger_core::RoleList::Investing,
|
||||
"books.investing_accounts",
|
||||
&cfg.roles.investing,
|
||||
),
|
||||
];
|
||||
let mut ids: [Vec<ledger_core::AccountId>; 3] = Default::default();
|
||||
for ((_, setting, codes), ids) in lists.iter().zip(&mut ids) {
|
||||
for code in codes.iter() {
|
||||
match ledger.chart().find_by_code(code) {
|
||||
Some(a) => ids.push(a.id),
|
||||
None => out.push(format!(
|
||||
"{setting} names {code}, which isn't in the chart: add that account, or change {setting}{how}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
let named = |l: ledger_core::RoleList| {
|
||||
lists
|
||||
.iter()
|
||||
.find(|(list, _, _)| *list == l)
|
||||
.map_or(String::new(), |(_, setting, _)| setting.to_string())
|
||||
};
|
||||
for p in ledger_core::role_problems(ledger, &ids[0], &ids[1], &ids[2]) {
|
||||
out.push(format!("{}{how}", p.message(named)));
|
||||
}
|
||||
let mut check = |setting: &str, code: &str, want: &[AccountType]| {
|
||||
match ledger.chart().find_by_code(code) {
|
||||
None => out.push(format!(
|
||||
"{setting} names {code}, which isn't in the chart: add that account, or change {setting}{how}"
|
||||
)),
|
||||
Some(a) if !want.contains(&a.account_type) => out.push(format!(
|
||||
"{setting} names {code} \"{}\", a {:?} account, but needs {}: change {setting}{how}",
|
||||
a.name,
|
||||
a.account_type,
|
||||
want.iter().map(|t| format!("{t:?}")).collect::<Vec<_>>().join(" or "),
|
||||
)),
|
||||
Some(_) => {}
|
||||
}
|
||||
};
|
||||
use AccountType::*;
|
||||
|
||||
let c = &cfg.company;
|
||||
for m in &c.members {
|
||||
check(
|
||||
&format!("{} {} capital_account", "company.members", m.name),
|
||||
&m.capital_account,
|
||||
&[Equity],
|
||||
);
|
||||
if let Some(d) = &m.distribution_account {
|
||||
check(
|
||||
&format!("{} {} distribution_account", "company.members", m.name),
|
||||
d,
|
||||
&[Equity],
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(code) = &c.close_into {
|
||||
check("books.close_into", code, &[Equity]);
|
||||
}
|
||||
for code in &c.close_sweep {
|
||||
check("books.close_sweep", code, &[Equity]);
|
||||
}
|
||||
// Conventions naming an account no longer in the chart (renumbered or
|
||||
// never there): a template can't draft, a rule covers nothing.
|
||||
for (id, code) in convention_codes(ledger) {
|
||||
if ledger.chart().find_by_code(&code).is_none() {
|
||||
out.push(format!(
|
||||
"convention {id} names account {code}, which isn't in the chart: revise it with books.set_convention"
|
||||
));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// An asset named by a string that is its current tag or its id. A tag
|
||||
/// wins: tags like "1001" are allowed, and the label is what the human
|
||||
/// reads.
|
||||
pub(crate) fn resolve_asset_ref(store: &Store, r: &str) -> Result<u64, String> {
|
||||
if let Some(a) = store.ledger().asset_by_tag(r) {
|
||||
return Ok(a.id);
|
||||
}
|
||||
match r.parse::<u64>() {
|
||||
Ok(id) if store.ledger().get_asset(id).is_some() => Ok(id),
|
||||
_ => Err(format!("no asset has the tag or id {r:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a calendar-month period "YYYY-MM".
|
||||
pub(crate) fn parse_period_mcp(period: &str) -> Result<(i32, u32), String> {
|
||||
let err = || format!("bad period {period:?}: expected YYYY-MM");
|
||||
let mut parts = period.split('-');
|
||||
let year: i32 = parts.next().ok_or_else(err)?.parse().map_err(|_| err())?;
|
||||
let month: u32 = parts.next().ok_or_else(err)?.parse().map_err(|_| err())?;
|
||||
// A month of the calendar the books keep (ledger_core::calendar).
|
||||
if parts.next().is_some() || !(1..=9999).contains(&year) || !(1..=12).contains(&month) {
|
||||
return Err(err());
|
||||
}
|
||||
Ok((year, month))
|
||||
}
|
||||
|
||||
/// Last calendar day of a month: refused, never a panic, for one chrono
|
||||
/// can't reach (P0 review round 3, S1).
|
||||
pub(crate) fn last_day_of_month(year: i32, month: u32) -> Result<NaiveDate, String> {
|
||||
NaiveDate::from_ymd_opt(year, month, 1)
|
||||
.and_then(|first| first.checked_add_months(chrono::Months::new(1)))
|
||||
.and_then(|next| next.pred_opt())
|
||||
.ok_or_else(|| format!("bad period {year:04}-{month:02}: no such month"))
|
||||
}
|
||||
|
||||
/// Resolve an account reference: an account code, or a numeric id. A
|
||||
/// number that is one account's code and another's id is refused
|
||||
/// (INVARIANT 6).
|
||||
pub(crate) fn resolve_account(store: &Store, account: &str) -> Result<AccountId, String> {
|
||||
let chart = store.ledger().chart();
|
||||
let by_code = chart.find_by_code(account).map(|a| a.id);
|
||||
let by_id = account
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.filter(|id| chart.get(*id).is_some());
|
||||
match (by_code, by_id) {
|
||||
(Some(c), Some(i)) if c != i => Err(format!(
|
||||
"account {account:?} is ambiguous: it is the code of account {c} and the id of account {i}; pass the code of the one you mean"
|
||||
)),
|
||||
(Some(c), _) => Ok(c),
|
||||
(None, Some(i)) => Ok(i),
|
||||
(None, None) => Err(format!(
|
||||
"unknown account {account:?}: use an id or code from books.chart"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// "invoice" or "bill" and an id, as a document.
|
||||
pub(crate) fn doc_ref(kind: &str, id: u64) -> Result<ledger_core::DocRef, String> {
|
||||
match kind.trim().to_ascii_lowercase().as_str() {
|
||||
"invoice" => Ok(ledger_core::DocRef::Invoice(id)),
|
||||
"bill" => Ok(ledger_core::DocRef::Bill(id)),
|
||||
other => Err(format!("bad kind {other:?}: expected invoice or bill")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The control check as `books.control_check` answers it.
|
||||
pub(crate) fn control_check_out(
|
||||
ledger: &Ledger,
|
||||
as_of: chrono::NaiveDate,
|
||||
checks: &[ledger_core::ControlCheck],
|
||||
) -> ControlCheckOut {
|
||||
let accounts: Vec<ControlCheckRowOut> = checks
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let acct = ledger.chart().get(c.account);
|
||||
ControlCheckRowOut {
|
||||
role: c.role.clone(),
|
||||
code: acct.map(|a| a.code.clone()).unwrap_or_default(),
|
||||
name: acct.map(|a| a.name.clone()).unwrap_or_default(),
|
||||
balance: money(c.balance),
|
||||
documents_total: money(c.documents_total),
|
||||
unattached: c
|
||||
.unattached
|
||||
.iter()
|
||||
.map(|u| UnattachedLineOut {
|
||||
entry_id: u.entry,
|
||||
line: u.line,
|
||||
date: u.date.to_string(),
|
||||
amount: money(u.amount),
|
||||
})
|
||||
.collect(),
|
||||
difference: money(c.difference),
|
||||
attention: c
|
||||
.attention
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let (kind, id) = match a.doc {
|
||||
ledger_core::DocRef::Invoice(id) => ("invoice", id),
|
||||
ledger_core::DocRef::Bill(id) => ("bill", id),
|
||||
};
|
||||
DocAttentionOut {
|
||||
kind: kind.to_string(),
|
||||
id,
|
||||
status: a.status.key().to_string(),
|
||||
total: money(a.total),
|
||||
outstanding: money(a.outstanding),
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let agrees = checks
|
||||
.iter()
|
||||
.all(|c| c.difference.is_zero() && c.unattached.is_empty() && c.attention.is_empty());
|
||||
ControlCheckOut {
|
||||
as_of: as_of.to_string(),
|
||||
agrees,
|
||||
accounts,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a party by id or by name: the name ignoring case and
|
||||
/// surrounding space, among parties not archived (an archived party goes
|
||||
/// by id). A number that is one party's id and another's name is refused
|
||||
/// (INVARIANT 6).
|
||||
pub(crate) fn resolve_party(store: &Store, party: &str) -> Result<ledger_core::PartyId, String> {
|
||||
let ledger = store.ledger();
|
||||
let by_id = party
|
||||
.trim()
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.filter(|id| ledger.get_party(*id).is_some());
|
||||
let by_name = ledger.party_by_name(party).map(|p| p.id);
|
||||
match (by_id, by_name) {
|
||||
(Some(i), Some(n)) if i != n => Err(format!(
|
||||
"party {party:?} is ambiguous: it is party {i}'s id and party {n}'s name; pass the id"
|
||||
)),
|
||||
(Some(i), _) | (None, Some(i)) => Ok(i),
|
||||
(None, None) => Err(format!(
|
||||
"unknown party {party:?}: use an id or name from books.parties (an archived party goes by id), or add it with books.add_party"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A stored attachment's metadata, by id.
|
||||
pub(crate) fn find_attachment(store: &Store, id: u64) -> Result<ledger_store::Attachment, String> {
|
||||
store
|
||||
.list_attachments(None)
|
||||
.map_err(|e| e.to_string())?
|
||||
.into_iter()
|
||||
.find(|a| a.id == id)
|
||||
.ok_or_else(|| format!("unknown attachment {id}"))
|
||||
}
|
||||
|
||||
/// An attachment for a listing, with its links.
|
||||
pub(crate) fn receipt_out(
|
||||
store: &Store,
|
||||
a: ledger_store::Attachment,
|
||||
) -> Result<ReceiptOut, String> {
|
||||
Ok(ReceiptOut {
|
||||
links: links_out(store, a.id)?,
|
||||
attachment_id: a.id,
|
||||
sha256: a.blob_sha,
|
||||
entry_id: a.entry_id,
|
||||
category: a.category,
|
||||
file_name: a.file_name,
|
||||
mime_type: a.mime_type,
|
||||
size_bytes: a.size_bytes,
|
||||
description: a.description,
|
||||
withdrawn: a.withdrawn_at.is_some(),
|
||||
withdrawn_reason: a.withdrawn_reason,
|
||||
})
|
||||
}
|
||||
|
||||
/// An account's code and name.
|
||||
pub(crate) fn code_name(store: &Store, id: AccountId) -> (String, String) {
|
||||
store
|
||||
.ledger()
|
||||
.chart()
|
||||
.get(id)
|
||||
.map(|a| (a.code.clone(), a.name.clone()))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The latest statement date an account is reconciled through
|
||||
/// (reconciliations not voided), if any.
|
||||
pub(crate) fn reconciled_through(
|
||||
store: &Store,
|
||||
account: AccountId,
|
||||
) -> Result<Option<NaiveDate>, String> {
|
||||
Ok(store
|
||||
.reconciliations(Some(account))
|
||||
.map_err(|e| e.to_string())?
|
||||
.into_iter()
|
||||
.filter(|r| r.voided.is_none())
|
||||
.map(|r| r.statement_date)
|
||||
.max())
|
||||
}
|
||||
|
||||
/// The last four digits of every TIN the books know of, from the journal
|
||||
/// (each party's TIN and W-9 facts), never the vault: the filing scan's
|
||||
/// probe bound (tin_files.rs INVARIANT 4).
|
||||
pub(crate) fn known_tin_last4(store: &Store) -> Vec<u32> {
|
||||
store
|
||||
.ledger()
|
||||
.parties()
|
||||
.flat_map(|p| {
|
||||
[
|
||||
p.tin.as_ref().map(|t| t.last4.as_str()),
|
||||
p.w9.as_ref().map(|w| w.tin_last4.as_str()),
|
||||
]
|
||||
})
|
||||
.flatten()
|
||||
.filter_map(|l| l.parse().ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// An optional party text from a tool: trimmed, and "" clears it.
|
||||
pub(crate) fn optional_text(s: &str) -> Option<String> {
|
||||
let t = s.trim();
|
||||
(!t.is_empty()).then(|| t.to_string())
|
||||
}
|
||||
|
||||
/// The sum of `values`, each computed or refused: refused (as text) when
|
||||
/// one is, or when the sum is past exact arithmetic. Figures an earlier
|
||||
/// version journaled are as journaled (rules.md B-28).
|
||||
pub(crate) fn checked_sum(
|
||||
values: impl IntoIterator<Item = Result<Decimal, ledger_core::LedgerError>>,
|
||||
what: &str,
|
||||
) -> Result<Decimal, String> {
|
||||
values.into_iter().try_fold(Decimal::ZERO, |sum, v| {
|
||||
let v = v.map_err(|e| e.to_string())?;
|
||||
sum.checked_add(v).ok_or_else(|| {
|
||||
ledger_core::LedgerError::TooLargeToCompute(what.to_string()).to_string()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// The `party_required` refusal for an engine-posted entry (payroll, a
|
||||
/// remittance, a disposal) that names no counterparty under `[books]
|
||||
/// parties = "require"`: `what` says what to pass.
|
||||
pub(crate) fn party_required(what: &str) -> String {
|
||||
serde_json::json!({
|
||||
"code": "party_required",
|
||||
"message": format!(
|
||||
"these books require a counterparty on every entry (their parties setting is \"require\"): {what} (add the party with books.add_party first if it's new)"
|
||||
),
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// The chart's ids for these account codes (codes it lacks are skipped).
|
||||
pub(crate) fn cash_ids(ledger: &Ledger, codes: &[String]) -> Vec<AccountId> {
|
||||
codes
|
||||
.iter()
|
||||
.filter_map(|c| ledger.chart().find_by_code(c).map(|a| a.id))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// What party shares resolve with ([`ledger_core::shares::resolve`]): the
|
||||
/// money accounts over the `[books] cash_accounts` and
|
||||
/// `financing_accounts` (with the cards), and the members.
|
||||
pub(crate) fn share_context(
|
||||
ledger: &Ledger,
|
||||
settings: &Settings,
|
||||
) -> (
|
||||
ledger_core::parties::MoneyAccounts,
|
||||
Vec<ledger_core::PartyMember>,
|
||||
) {
|
||||
(
|
||||
ledger_core::parties::money_accounts(
|
||||
ledger,
|
||||
&cash_ids(ledger, &settings.roles.cash),
|
||||
&cash_ids(ledger, &settings.roles.financing),
|
||||
),
|
||||
settings.company.party_members(ledger),
|
||||
)
|
||||
}
|
||||
|
||||
/// `parties` params as shares: each party resolved, `amount` or `percent`
|
||||
/// parsed (not both), neither meaning derived.
|
||||
pub(crate) fn parse_shares(
|
||||
store: &Store,
|
||||
params: &[PartyShareParam],
|
||||
) -> Result<Vec<ledger_core::PartyShare>, String> {
|
||||
params
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let party = resolve_party(store, &p.party)?;
|
||||
let share = match (&p.amount, &p.percent) {
|
||||
(Some(_), Some(_)) => {
|
||||
return Err(format!(
|
||||
"party {}: give an amount or a percent, not both",
|
||||
p.party
|
||||
));
|
||||
}
|
||||
(Some(a), None) => ledger_core::Share::Amount(parse_amount(a)?),
|
||||
(None, Some(pc)) => ledger_core::Share::Percent(parse_amount(pc)?),
|
||||
(None, None) => ledger_core::Share::Derived,
|
||||
};
|
||||
Ok(ledger_core::PartyShare { party, share })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether an entry's lines are final enough to check shares against:
|
||||
/// posted, or a draft with lines whose debits equal its credits.
|
||||
pub(crate) fn lines_settled(entry: &JournalEntry) -> bool {
|
||||
if entry.status() == ledger_core::EntryStatus::Posted {
|
||||
return true;
|
||||
}
|
||||
// A draft's lines are as journaled, of any size (rules.md B-28): sides
|
||||
// past exact arithmetic are not settled lines.
|
||||
let side = |s: Side| -> Option<Decimal> {
|
||||
entry
|
||||
.lines()
|
||||
.iter()
|
||||
.filter(|l| l.side == s)
|
||||
.try_fold(Decimal::ZERO, |sum, l| sum.checked_add(l.amount.amount))
|
||||
};
|
||||
match (side(Side::Debit), side(Side::Credit)) {
|
||||
(Some(debits), Some(credits)) => !entry.lines().is_empty() && debits == credits,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fix a posted entry's derived split among several parties: journal the
|
||||
/// exact amounts they resolve to now, so a later change to
|
||||
/// `[[company.members]]` (a new income split, a member archived) never
|
||||
/// re-splits posted history.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: posted is forever, the split between its parties included.
|
||||
/// SOURCE: AGENTS.md ("Posted is forever"); docs/parties-and-attachments.md
|
||||
/// "Shares"; ledger_core::shares INVARIANT 10 (derived several:
|
||||
/// the members' capital lines, else their income shares, both read
|
||||
/// from the settings).
|
||||
/// INVARIANTS:
|
||||
/// 1. Only a posted entry naming two or more parties with derived shares
|
||||
/// changes: its shares become Amount(|part|) for each party, in
|
||||
/// order, journaled as entry.parties_set (the reversal chain too), so
|
||||
/// each party's resolved part is exactly what it was.
|
||||
/// 2. Nothing changes when the shares don't resolve, or a part is zero
|
||||
/// (an amount must be positive): the entry keeps its derived shares.
|
||||
/// ```
|
||||
pub(crate) fn fix_derived_shares(
|
||||
store: &mut Store,
|
||||
settings: &Settings,
|
||||
entry_id: u64,
|
||||
) -> Result<(), String> {
|
||||
let ledger = store.ledger();
|
||||
let Some(entry) = ledger.get_entry(entry_id) else {
|
||||
return Ok(());
|
||||
};
|
||||
let shares = ledger.entry_parties(entry_id);
|
||||
if entry.status() != ledger_core::EntryStatus::Posted
|
||||
|| shares.len() < 2
|
||||
|| !shares
|
||||
.iter()
|
||||
.any(|s| matches!(s.share, ledger_core::Share::Derived))
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let (money, members) = share_context(ledger, settings);
|
||||
let Ok(resolved) = ledger_core::shares::resolve(ledger, entry_id, shares, &money, &members)
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
if resolved.parts.iter().any(|p| p.amount.is_zero()) {
|
||||
return Ok(());
|
||||
}
|
||||
let fixed: Vec<ledger_core::PartyShare> = resolved
|
||||
.parts
|
||||
.iter()
|
||||
.map(|p| ledger_core::PartyShare {
|
||||
party: p.party,
|
||||
share: ledger_core::Share::Amount(p.amount.abs()),
|
||||
})
|
||||
.collect();
|
||||
store
|
||||
.set_entry_parties(entry_id, &fixed)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Resolve a class (a project) by id or name, trimmed, the name ignoring
|
||||
/// capitals as the duplicate check does; a number that is one class's id
|
||||
/// and another's name is refused (INVARIANT 6). New names can't be digits
|
||||
/// only, so only old books can hold such a pair.
|
||||
pub(crate) fn resolve_class(store: &Store, class: &str) -> Result<u32, String> {
|
||||
let class = class.trim();
|
||||
let by_id = class
|
||||
.parse::<u32>()
|
||||
.ok()
|
||||
.filter(|id| store.ledger().get_class(*id).is_some());
|
||||
let by_name = store.ledger().find_class_by_name(class).map(|c| c.id);
|
||||
match (by_id, by_name) {
|
||||
(Some(i), Some(n)) if i != n => {
|
||||
let name = store
|
||||
.ledger()
|
||||
.get_class(i)
|
||||
.map(|c| c.name.clone())
|
||||
.unwrap_or_default();
|
||||
Err(format!(
|
||||
"project {class:?} is ambiguous: it is project {i}'s id and project {n}'s name; pass {n} for project {n}, or {name:?} (project {i}'s name) for project {i}"
|
||||
))
|
||||
}
|
||||
(Some(i), _) | (None, Some(i)) => Ok(i),
|
||||
(None, None) => Err(format!(
|
||||
"unknown project {class:?}: use an id or name from books.projects"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A tool description that ends with the one sentence for its kind of
|
||||
/// dates (rules.md B-30), so no tool says other than the resolver does.
|
||||
pub(crate) fn described(what: &str, kind: ledger_core::calendar::PeriodKind) -> String {
|
||||
format!("{what} {}", kind.describe())
|
||||
}
|
||||
|
||||
/// The warning for a revenue or expense account created without a tax
|
||||
/// category (rules.md B-32).
|
||||
pub(crate) fn uncategorized_warning(code: &str, name: &str) -> String {
|
||||
format!(
|
||||
"{} {} has no tax category: the tax summary will list it as uncategorized. Propose one from books.tax_categories and set it with books.set_tax_category once the human agrees.",
|
||||
code.trim(),
|
||||
name.trim()
|
||||
)
|
||||
}
|
||||
|
||||
/// The warnings for the revenue and expense accounts an operation created
|
||||
/// (control accounts: Revenue, General Expenses, payroll's expenses)
|
||||
/// without a tax category: every account from `first_new` on.
|
||||
pub(crate) fn created_uncategorized(
|
||||
ledger: &Ledger,
|
||||
first_new: ledger_core::AccountId,
|
||||
) -> Vec<String> {
|
||||
let mut new: Vec<&ledger_core::Account> = ledger
|
||||
.chart()
|
||||
.all()
|
||||
.filter(|a| a.id >= first_new && a.tax_category.is_none())
|
||||
.filter(|a| ledger_core::tax::needs_category(a.account_type, a.placeholder))
|
||||
.collect();
|
||||
new.sort_by_key(|a| a.id);
|
||||
new.into_iter()
|
||||
.map(|a| uncategorized_warning(&a.code, &a.name))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Everything an attachment is linked to, for a tool's output.
|
||||
pub(crate) fn links_out(store: &Store, attachment_id: u64) -> Result<Vec<SubjectOut>, String> {
|
||||
Ok(store
|
||||
.attachment_links(attachment_id)
|
||||
.map_err(|e| e.to_string())?
|
||||
.into_iter()
|
||||
.map(|s| subject_out(store, s))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// A subject for a tool's output, with human words for it.
|
||||
pub(crate) fn subject_out(store: &Store, sub: ledger_store::Subject) -> SubjectOut {
|
||||
use ledger_store::Subject as S;
|
||||
let l = store.ledger();
|
||||
let label = match sub {
|
||||
S::Entry(i) => l
|
||||
.get_entry(i)
|
||||
.map(|e| format!("entry #{i} {}", e.description())),
|
||||
S::Account(i) => l
|
||||
.chart()
|
||||
.get(i)
|
||||
.map(|a| format!("account {} {}", a.code, a.name)),
|
||||
S::Asset(i) => l.get_asset(i).map(|a| match &a.tag {
|
||||
Some(t) => format!("asset {t} {}", a.description),
|
||||
None => format!("asset {i} {}", a.description),
|
||||
}),
|
||||
S::Party(i) => l.get_party(i).map(|p| format!("party {i} {}", p.label())),
|
||||
S::Project(i) => u32::try_from(i)
|
||||
.ok()
|
||||
.and_then(|c| l.get_class(c))
|
||||
.map(|c| format!("project {i} {}", c.name)),
|
||||
S::Period(i) => l
|
||||
.list_periods()
|
||||
.iter()
|
||||
.find(|p| p.id == i)
|
||||
.map(|p| format!("period {}", p.name)),
|
||||
S::PayrollRun(i) => l
|
||||
.get_payroll_run(i)
|
||||
.map(|r| format!("payroll run {i} of {}", r.date)),
|
||||
S::Schedule(i) => l
|
||||
.get_schedule(i)
|
||||
.map(|s| format!("schedule {i} {}", s.description)),
|
||||
S::Reconciliation(i) => store.reconciliations(None).ok().and_then(|rs| {
|
||||
rs.into_iter().find(|r| r.id == i).map(|r| {
|
||||
let code = l
|
||||
.chart()
|
||||
.get(r.account_id)
|
||||
.map(|a| a.code.clone())
|
||||
.unwrap_or_default();
|
||||
format!("reconciliation {i} of {code} through {}", r.statement_date)
|
||||
})
|
||||
}),
|
||||
S::Invoice(_) | S::Bill(_) | S::Company => None,
|
||||
}
|
||||
.unwrap_or_else(|| sub.describe());
|
||||
SubjectOut {
|
||||
kind: sub.kind().to_string(),
|
||||
id: (sub != S::Company).then(|| sub.id()),
|
||||
label,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a payment method by id or label (INVARIANT 5).
|
||||
pub(crate) fn resolve_instrument(store: &Store, r: &str) -> Result<InstrumentId, String> {
|
||||
let ledger = store.ledger();
|
||||
let by_id = r
|
||||
.trim()
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.filter(|id| ledger.get_instrument(*id).is_some());
|
||||
let by_label = ledger.instrument_by_label(r).map(|i| i.id);
|
||||
match (by_id, by_label) {
|
||||
(Some(i), Some(l)) if i != l => Err(format!(
|
||||
"payment method {r:?} is ambiguous: it is method {i}'s id and method {l}'s label; pass the id"
|
||||
)),
|
||||
(Some(i), _) | (None, Some(i)) => Ok(i),
|
||||
(None, None) => Err(format!(
|
||||
"unknown payment method {r:?}: use an id or label from books.instruments (books.add_instrument adds one)"
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -25,8 +25,8 @@
|
||||
//! ```
|
||||
|
||||
use super::*;
|
||||
use ledger_core::InstrumentKind;
|
||||
use ledger_core::form1099::{Applied, Dated1099, Decision1099, ExemptReason, Form1099};
|
||||
use ledger_core::{InstrumentId, InstrumentKind};
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -189,26 +189,6 @@ fn instrument_out(store: &Store, i: &ledger_core::Instrument) -> InstrumentOut {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a payment method by id or label (INVARIANT 5).
|
||||
pub(super) fn resolve_instrument(store: &Store, r: &str) -> Result<InstrumentId, String> {
|
||||
let ledger = store.ledger();
|
||||
let by_id = r
|
||||
.trim()
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.filter(|id| ledger.get_instrument(*id).is_some());
|
||||
let by_label = ledger.instrument_by_label(r).map(|i| i.id);
|
||||
match (by_id, by_label) {
|
||||
(Some(i), Some(l)) if i != l => Err(format!(
|
||||
"payment method {r:?} is ambiguous: it is method {i}'s id and method {l}'s label; pass the id"
|
||||
)),
|
||||
(Some(i), _) | (None, Some(i)) => Ok(i),
|
||||
(None, None) => Err(format!(
|
||||
"unknown payment method {r:?}: use an id or label from books.instruments (books.add_instrument adds one)"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The decision `books.set_vendor_1099` was given, or none when it
|
||||
/// changes only the W-9 flag (INVARIANTS 1-3).
|
||||
pub(super) fn decision_in(p: &SetVendor1099Params) -> Result<Option<Dated1099>, String> {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,99 @@
|
||||
//! Payouts through the server (`books.record_payout`). Split whole out
|
||||
//! of `server.rs` (plan/served.md 8.2 U0a).
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tool_router(router = payouts_router, vis = "pub(super)")]
|
||||
impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.record_payout",
|
||||
title = "Record a platform payout",
|
||||
description = "Record a platform payout (app store, payment processor, marketplace) grossed up: Dr the deposit account the net deposit, Dr fee accounts the fees withheld, Dr/Cr adjustments (refunds, chargebacks, reserves, taxes withheld), Cr revenue the gross sales (several lines and classes allowed). Refuses unless gross - fees +/- adjustments equals the deposit to the cent, and says by how much it's off. Creates a draft: attach the payout statement as its receipt, preview, and post.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn record_payout(
|
||||
&self,
|
||||
params: Parameters<RecordPayoutParams>,
|
||||
) -> Result<Json<RecordPayoutOut>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
let class = |store: &Store, c: &Option<String>| -> Result<Option<u32>, String> {
|
||||
c.as_deref().map(|c| resolve_class(store, c)).transpose()
|
||||
};
|
||||
let lines = |store: &Store, ls: &[PayoutLineParam]| {
|
||||
ls.iter()
|
||||
.map(|l| {
|
||||
Ok(ledger_core::PayoutLine {
|
||||
account: resolve_account(store, &l.account)?,
|
||||
amount: parse_amount(&l.amount)?,
|
||||
class: class(store, &l.class)?,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, String>>()
|
||||
};
|
||||
let payout = ledger_core::Payout {
|
||||
deposit_account: resolve_account(&store, &p.deposit_account)?,
|
||||
deposit: parse_amount(&p.deposit)?,
|
||||
revenue: lines(&store, &p.revenue)?,
|
||||
fees: lines(&store, &p.fees)?,
|
||||
adjustments: p
|
||||
.adjustments
|
||||
.iter()
|
||||
.map(|a| {
|
||||
Ok(ledger_core::PayoutAdjustment {
|
||||
account: resolve_account(&store, &a.account)?,
|
||||
side: parse_side(&a.side)?,
|
||||
amount: parse_amount(&a.amount)?,
|
||||
class: class(&store, &a.class)?,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, String>>()?,
|
||||
};
|
||||
let (plan, totals) =
|
||||
ledger_core::payout_plan(&payout, store.ledger().chart()).map_err(|e| e.to_string())?;
|
||||
let currency = store.base_currency();
|
||||
let entry_id = store
|
||||
.create_entry(parse_date(&p.date)?, &p.description)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut described = Vec::new();
|
||||
let mut tagged: Vec<u32> = Vec::new();
|
||||
for (index, (account, side, amount, class)) in plan.into_iter().enumerate() {
|
||||
store
|
||||
.add_line(entry_id, account, side, Money::new(amount, currency))
|
||||
.map_err(|e| e.to_string())?;
|
||||
if let Some(class) = class {
|
||||
store
|
||||
.set_line_class(entry_id, index, Some(class))
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !tagged.contains(&class) {
|
||||
tagged.push(class);
|
||||
}
|
||||
}
|
||||
let code = store
|
||||
.ledger()
|
||||
.chart()
|
||||
.get(account)
|
||||
.map(|a| a.code.clone())
|
||||
.unwrap_or_default();
|
||||
described.push(format!("{side:?} {code} {}", money(amount)));
|
||||
}
|
||||
Ok(Json(RecordPayoutOut {
|
||||
entry_id,
|
||||
status: "draft".to_string(),
|
||||
gross: money(totals.gross),
|
||||
fees: money(totals.fees),
|
||||
adjustments: money(totals.adjustments),
|
||||
deposit: money(totals.deposit),
|
||||
lines: described,
|
||||
notes: tagged
|
||||
.into_iter()
|
||||
.filter_map(|c| projects::late_tag_note(store.ledger(), c))
|
||||
.collect(),
|
||||
next: "Attach the payout statement (books.attach_receipt), then preview and post with books.post_entry.".to_string(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
//! Payroll through the server: accounting only, the caller supplies every
|
||||
//! number (no tax tables). Split whole out of `server.rs`
|
||||
//! (plan/served.md 8.2 U0a).
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tool_router(router = payroll_router, vis = "pub(super)")]
|
||||
impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.run_payroll",
|
||||
title = "Run payroll",
|
||||
description = "Run payroll for a pay date. Each line carries one employee's gross pay and every withholding/deduction as decimal strings — the engine cuts one balanced entry (Dr Salary & Wage Expense, Dr Payroll Tax Expense for the employer share, Cr the withholding payables, Cr cash for net pay). Auto-creates the 61xx/21xx accounts. All amounts are decimal strings, never floats. Name each line's employee party (employee: id or name, a party with the employee role; add one with books.add_party and record its W-4 with books.set_w4 first): new runs should, so the run shows on the employee's books.party.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = true,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn run_payroll(
|
||||
&self,
|
||||
params: Parameters<RunPayrollParams>,
|
||||
) -> Result<Json<RunPayrollOut>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
let cash = resolve_account(&store, &p.cash_account)?;
|
||||
let lines: Vec<ledger_store::PayrollLineInput> = p
|
||||
.lines
|
||||
.into_iter()
|
||||
.map(|l| {
|
||||
let employee = l
|
||||
.employee
|
||||
.as_deref()
|
||||
.map(|r| resolve_party(&store, r))
|
||||
.transpose()?;
|
||||
let employee_name = match (l.employee_name, employee) {
|
||||
(Some(n), _) => n,
|
||||
(None, Some(id)) => store
|
||||
.ledger()
|
||||
.get_party(id)
|
||||
.map(|p| p.name.clone())
|
||||
.unwrap_or_default(),
|
||||
(None, None) => {
|
||||
return Err(
|
||||
"each payroll line names its employee: employee (a party) or employee_name"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
};
|
||||
Ok(ledger_core::PayrollLine {
|
||||
employee_name,
|
||||
employee,
|
||||
gross_pay: parse_amount(&l.gross_pay)?,
|
||||
federal_withholding: parse_amount(&l.federal_withholding)?,
|
||||
state_withholding: parse_amount(&l.state_withholding)?,
|
||||
ss_employee: parse_amount(&l.ss_employee)?,
|
||||
medicare_employee: parse_amount(&l.medicare_employee)?,
|
||||
ss_employer: parse_amount(&l.ss_employer)?,
|
||||
medicare_employer: parse_amount(&l.medicare_employer)?,
|
||||
other_deductions: parse_amount(&l.other_deductions)?,
|
||||
})
|
||||
})
|
||||
.collect::<Result<_, String>>()?;
|
||||
if self.settings().parties == ledger_config::PartyPolicy::Require
|
||||
&& lines
|
||||
.iter()
|
||||
.any(|l| l.employee.is_none() && l.net_pay().is_ok_and(|n| !n.is_zero()))
|
||||
{
|
||||
return Err(party_required(
|
||||
"name each payroll line's employee party (employee), so the entry names who was paid",
|
||||
));
|
||||
}
|
||||
let first_new = store.ledger().next_account_id();
|
||||
let run_id = store
|
||||
.run_payroll(parse_date(&p.date)?, lines, cash)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let run = store
|
||||
.ledger()
|
||||
.get_payroll_run(run_id)
|
||||
.ok_or_else(|| format!("payroll run {run_id} not found after creation"))?;
|
||||
let total_gross = checked_sum(run.lines.iter().map(|l| Ok(l.gross_pay)), "gross pay")?;
|
||||
let total_net = checked_sum(run.lines.iter().map(|l| l.net_pay()), "net pay")?;
|
||||
Ok(Json(RunPayrollOut {
|
||||
run_id,
|
||||
entry_id: run.entry_id,
|
||||
total_gross: money(total_gross),
|
||||
total_net: money(total_net),
|
||||
warnings: created_uncategorized(store.ledger(), first_new),
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.remit_payroll_taxes",
|
||||
title = "Remit payroll taxes",
|
||||
description = "Remit all outstanding payroll tax payables (federal/state withholding, SS, Medicare, other deductions) to cash in one balanced entry. Name the tax agency paid (agency: a party id or name, e.g. the IRS): it is the entry's party, and [books] parties = \"require\" refuses without it.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = true,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn remit_payroll_taxes(
|
||||
&self,
|
||||
params: Parameters<RemitPayrollTaxesParams>,
|
||||
) -> Result<Json<RemitPayrollTaxesOut>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
let cash = resolve_account(&store, &p.cash_account)?;
|
||||
let agency = p
|
||||
.agency
|
||||
.as_deref()
|
||||
.map(|r| resolve_party(&store, r))
|
||||
.transpose()?;
|
||||
if agency.is_none() && self.settings().parties == ledger_config::PartyPolicy::Require {
|
||||
return Err(party_required("pass agency: the tax agency paid"));
|
||||
}
|
||||
// Total before remitting, for the response.
|
||||
let total: Decimal = store
|
||||
.ledger()
|
||||
.payroll_tax_payables()
|
||||
.iter()
|
||||
.map(|(_, b)| *b)
|
||||
.sum();
|
||||
let entry_id = store
|
||||
.remit_payroll_taxes(parse_date(&p.date)?, cash)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if let Some(a) = agency {
|
||||
store
|
||||
.set_entry_party(entry_id, Some(a))
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(Json(RemitPayrollTaxesOut {
|
||||
entry_id,
|
||||
total: money(total),
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.list_payroll_runs",
|
||||
title = "List payroll runs",
|
||||
description = "List payroll runs in id order: date, employee count, totals, and the posted entry id.",
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn list_payroll_runs(&self) -> Result<Json<ListPayrollRunsOut>, String> {
|
||||
let store = self.lock()?;
|
||||
let runs = store
|
||||
.list_payroll_runs()
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
// A run is as journaled (rules.md B-28): a total past exact
|
||||
// arithmetic refuses the list, never a panic.
|
||||
let total_gross =
|
||||
checked_sum(r.lines.iter().map(|l| Ok(l.gross_pay)), "gross pay")?;
|
||||
Ok(PayrollRunOut {
|
||||
id: r.id,
|
||||
date: r.date.to_string(),
|
||||
employee_count: r.lines.len(),
|
||||
total_gross: money(total_gross),
|
||||
entry_id: r.entry_id,
|
||||
})
|
||||
})
|
||||
.collect::<Result<_, String>>()?;
|
||||
Ok(Json(ListPayrollRunsOut { runs }))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
//! Calendar periods through the server: `books.create_period`,
|
||||
//! `books.close_period` and `books.list_periods`. Split whole out of
|
||||
//! `server.rs` (plan/served.md 8.2 U0a).
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tool_router(router = periods_router, vis = "pub(super)")]
|
||||
impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.create_period",
|
||||
title = "Create a period",
|
||||
description = "Create an accounting period (month, quarter, year). Dates are YYYY-MM-DD, inclusive.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn create_period(
|
||||
&self,
|
||||
params: Parameters<CreatePeriodParams>,
|
||||
) -> Result<Json<CreatePeriodOut>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
let id = store
|
||||
.create_period(&p.name, parse_date(&p.start)?, parse_date(&p.end)?)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(Json(CreatePeriodOut { period_id: id }))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.close_period",
|
||||
title = "Close a period",
|
||||
description = "Close an accounting period. The closing entry, dated at period end, zeroes revenue and expense balances into Retained Earnings or, for a multi-member LLC (members in the settings), into each member's capital account by share, cumulatively: each close gives a member their share of everything closed to the members so far, less what earlier closes gave them (odd cents by largest remainder), so no member is ever a cent off their exact share; a close may carry a one-time correction of earlier rounding. Periods close in order. Without confirmed=true, returns the exact closing entry and members' split, closing nothing; with confirmed=true, posts it and closes the period to new entries.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = true,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn close_period(
|
||||
&self,
|
||||
params: Parameters<ClosePeriodParams>,
|
||||
) -> Result<Json<ClosePeriodOut>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
// Verify the period exists.
|
||||
if store.ledger().get_period(p.period_id).is_none() {
|
||||
return Err(format!("unknown period {}", p.period_id));
|
||||
}
|
||||
let to = self.close_target(store.ledger())?;
|
||||
let describe = |ledger: &Ledger, eid: Option<ledger_core::EntryId>| -> Vec<String> {
|
||||
let Some(entry) = eid.and_then(|e| ledger.get_entry(e)) else {
|
||||
return Vec::new();
|
||||
};
|
||||
entry
|
||||
.lines()
|
||||
.iter()
|
||||
.map(|line| {
|
||||
let code = ledger
|
||||
.chart()
|
||||
.get(line.account)
|
||||
.map(|a| a.code.clone())
|
||||
.unwrap_or_default();
|
||||
format!("{:?} {} {}", line.side, code, money(line.amount.amount))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
// The members' split, from the closed ledger (rules.md B-13).
|
||||
let split = |ledger: &Ledger| -> Option<MembersCloseOut> {
|
||||
let (since_close, parts) = ledger.member_close_split(p.period_id)?;
|
||||
let names = self.member_capitals(ledger).unwrap_or_default();
|
||||
let members = parts
|
||||
.iter()
|
||||
.map(|m| MemberCloseOut {
|
||||
name: names
|
||||
.iter()
|
||||
.find(|c| c.capital_account == m.capital)
|
||||
.map(|c| c.name.clone())
|
||||
.unwrap_or_default(),
|
||||
capital_account: ledger
|
||||
.chart()
|
||||
.get(m.capital)
|
||||
.map(|a| a.code.clone())
|
||||
.unwrap_or_default(),
|
||||
share: ledger_package::share_text(m.share),
|
||||
this_close: money(m.part),
|
||||
running: money(m.running),
|
||||
exact: money(m.exact.normalize()),
|
||||
})
|
||||
.collect();
|
||||
Some(MembersCloseOut {
|
||||
since_close,
|
||||
members,
|
||||
})
|
||||
};
|
||||
if !p.confirmed {
|
||||
// Preview: run the real close on a copy of the ledger, so the
|
||||
// preview is exactly what confirming would post.
|
||||
let mut scratch = store.ledger().clone();
|
||||
let closing = scratch
|
||||
.close_period_to(p.period_id, &to)
|
||||
.map_err(|e| e.to_string())?;
|
||||
return Ok(Json(ClosePeriodOut {
|
||||
status: "preview".to_string(),
|
||||
period_id: p.period_id,
|
||||
closing_entry: None,
|
||||
lines: describe(&scratch, closing),
|
||||
members: split(&scratch),
|
||||
}));
|
||||
}
|
||||
let closing = store
|
||||
.close_period_to(p.period_id, &to)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let lines = describe(store.ledger(), closing);
|
||||
Ok(Json(ClosePeriodOut {
|
||||
status: "closed".to_string(),
|
||||
period_id: p.period_id,
|
||||
closing_entry: closing,
|
||||
lines,
|
||||
members: split(store.ledger()),
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.list_periods",
|
||||
title = "List periods",
|
||||
description = "List all accounting periods with their status, and for a period closed to the members, the rounding rule it was closed under.",
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn list_periods(&self) -> Result<Json<ListPeriodsOut>, String> {
|
||||
let store = self.lock()?;
|
||||
let periods = store
|
||||
.ledger()
|
||||
.list_periods()
|
||||
.into_iter()
|
||||
.map(|p| PeriodOut {
|
||||
id: p.id,
|
||||
name: p.name.clone(),
|
||||
start: p.start.to_string(),
|
||||
end: p.end.to_string(),
|
||||
status: match p.status {
|
||||
PeriodStatus::Open => "open".to_string(),
|
||||
PeriodStatus::Closed => "closed".to_string(),
|
||||
},
|
||||
closing_entry: p.closing_entry,
|
||||
close_rule: p.members_closed.as_ref().map(|m| match m.rule {
|
||||
ledger_core::MemberRule::Cumulative { since_seq } => {
|
||||
format!("members_cumulative_since:{since_seq}")
|
||||
}
|
||||
ledger_core::MemberRule::Run => "members_run".to_string(),
|
||||
ledger_core::MemberRule::PerClose => "members_per_close".to_string(),
|
||||
ledger_core::MemberRule::LastAbsorbs => "members_last_absorbs".to_string(),
|
||||
}),
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(ListPeriodsOut { periods }))
|
||||
}
|
||||
}
|
||||
@@ -419,4 +419,43 @@ impl BooksServer {
|
||||
decisions,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.add_class",
|
||||
title = "Add a class",
|
||||
description = "Add a class: a project with only its name (books.add_project records its facts too; books.update_project adds them later). Names are unique, ignoring capitals. Tag journal lines with it via books.add_line's class param.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn add_class(
|
||||
&self,
|
||||
params: Parameters<AddClassParams>,
|
||||
) -> Result<Json<AddClassOut>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
let id = store.add_class(&p.name).map_err(|e| e.to_string())?;
|
||||
Ok(Json(AddClassOut { id }))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.list_classes",
|
||||
title = "List classes",
|
||||
description = "List classes (projects) in id order; books.projects gives their facts and figures.",
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn list_classes(&self) -> Result<Json<ListClassesOut>, String> {
|
||||
let store = self.lock()?;
|
||||
let classes = store
|
||||
.list_classes()
|
||||
.into_iter()
|
||||
.map(|c| ClassOut {
|
||||
id: c.id,
|
||||
name: c.name.clone(),
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(ListClassesOut { classes }))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
//! Bank statements and reconciliations through the server: recording a
|
||||
//! statement, clearing lines, reconciling, and voiding either. Split
|
||||
//! whole out of `server.rs` (plan/served.md 8.2 U0a).
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A recorded statement for a tool's output.
|
||||
fn statement_out(store: &Store, st: &ledger_store::Statement) -> Result<StatementOut, String> {
|
||||
let (account_code, account_name) = code_name(store, st.account_id);
|
||||
let file_name = find_attachment(store, st.attachment_id)?.file_name;
|
||||
let reconciliation_id = store
|
||||
.reconciliations(Some(st.account_id))
|
||||
.map_err(|e| e.to_string())?
|
||||
.into_iter()
|
||||
.find(|r| r.attachment_id == st.attachment_id && r.voided.is_none())
|
||||
.map(|r| r.id);
|
||||
Ok(StatementOut {
|
||||
attachment_id: st.attachment_id,
|
||||
file_name,
|
||||
account_code,
|
||||
account_name,
|
||||
period_start: st.period_start.to_string(),
|
||||
period_end: st.period_end.to_string(),
|
||||
opening_balance: money(st.opening_balance),
|
||||
closing_balance: money(st.closing_balance),
|
||||
reconciliation_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// A recorded reconciliation for a tool's output.
|
||||
fn reconciliation_out(store: &Store, r: &ledger_store::Reconciliation) -> ReconciliationOut {
|
||||
let (account_code, account_name) = code_name(store, r.account_id);
|
||||
ReconciliationOut {
|
||||
reconciliation_id: r.id,
|
||||
account_code,
|
||||
account_name,
|
||||
statement_date: r.statement_date.to_string(),
|
||||
statement_balance: money(r.statement_balance),
|
||||
statement_attachment: r.attachment_id,
|
||||
cleared: r
|
||||
.cleared
|
||||
.iter()
|
||||
.map(|&(entry_id, line_index)| ClearedRefOut {
|
||||
entry_id,
|
||||
line_index,
|
||||
})
|
||||
.collect(),
|
||||
voided: r.voided.is_some(),
|
||||
voided_reason: r.voided.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A line of a reconciliation report.
|
||||
fn reconcile_line(
|
||||
entry_id: u64,
|
||||
line_index: usize,
|
||||
date: NaiveDate,
|
||||
description: &str,
|
||||
side: Side,
|
||||
amount: Decimal,
|
||||
) -> ReconcileLineOut {
|
||||
ReconcileLineOut {
|
||||
entry_id,
|
||||
line_index,
|
||||
date: date.to_string(),
|
||||
description: description.to_string(),
|
||||
side: match side {
|
||||
Side::Debit => "debit",
|
||||
Side::Credit => "credit",
|
||||
}
|
||||
.to_string(),
|
||||
amount: money(amount),
|
||||
}
|
||||
}
|
||||
|
||||
#[tool_router(router = reconciliation_router, vis = "pub(super)")]
|
||||
impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.mark_cleared",
|
||||
title = "Mark a line cleared",
|
||||
description = "Mark a posted entry's line as cleared (or uncleared) for bank reconciliation. Only posted lines can be marked.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn mark_cleared(
|
||||
&self,
|
||||
params: Parameters<MarkClearedParams>,
|
||||
) -> Result<Json<MarkClearedOut>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
store
|
||||
.mark_cleared(p.entry_id, p.line_index, p.cleared)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(Json(MarkClearedOut { ok: true }))
|
||||
}
|
||||
|
||||
/// ```text
|
||||
/// GOAL: a monthly reconciliation that leaves proof: which statement,
|
||||
/// which balance, which lines it cleared.
|
||||
/// SOURCE: CONVENTION (bank reconciliation); docs/parties-and-
|
||||
/// attachments.md "Reconciliations"; Store::complete_reconciliation.
|
||||
/// INVARIANTS:
|
||||
/// 1. Without confirmed=true nothing is recorded: the preview reports
|
||||
/// the difference, the cleared and uncleared lines, and the
|
||||
/// problems a confirmation would hit.
|
||||
/// 2. A reconciliation is recorded only against a recorded statement
|
||||
/// (statement_attachment), only at a difference of exactly zero,
|
||||
/// and only after the account's last reconciliation not voided.
|
||||
/// 3. A refusal journals nothing.
|
||||
/// SCENARIOS:
|
||||
/// 1. IF statement_attachment is given: the account, date, and
|
||||
/// balance are the statement's; any given that disagree: SHOULD
|
||||
/// refuse. IF its attachment is withdrawn (the statement retired)
|
||||
/// or its statement voided: SHOULD refuse, preview or not.
|
||||
/// 2. IF it isn't: account, statement_date, and statement_balance are
|
||||
/// required, and confirmed=true SHOULD refuse.
|
||||
/// 3. IF confirmed=true and problems is not empty: SHOULD refuse,
|
||||
/// naming them.
|
||||
/// ```
|
||||
#[tool(
|
||||
name = "books.reconcile",
|
||||
title = "Reconcile an account",
|
||||
description = "Reconcile a bank, cash, or card account against a statement. Give statement_attachment (a statement recorded with books.record_statement: its account, period end, and closing balance), or account, statement_date, and statement_balance for a quick check against any balance. Returns the book balance, cleared balance, statement balance, the difference (cleared minus statement; zero means reconciled), the cleared and uncleared lines through the statement date, and the problems that would stop it being recorded. Without confirmed=true it is a preview and records nothing. With confirmed=true and a recorded statement it records the reconciliation (journaled as reconciliation.completed, linked to the statement), refused unless the difference is exactly zero and it comes after the account's last reconciliation. Mark lines cleared with books.mark_cleared first.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = true,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn reconcile(
|
||||
&self,
|
||||
params: Parameters<ReconcileParams>,
|
||||
) -> Result<Json<ReconcileOut>, String> {
|
||||
let p = params.0;
|
||||
let confirmed = p.confirmed.unwrap_or(false);
|
||||
let mut store = self.lock()?;
|
||||
let given_account = p
|
||||
.account
|
||||
.as_deref()
|
||||
.map(|a| resolve_account(&store, a))
|
||||
.transpose()?;
|
||||
let given_date = p.statement_date.as_deref().map(parse_date).transpose()?;
|
||||
let given_balance = p
|
||||
.statement_balance
|
||||
.as_deref()
|
||||
.map(parse_amount)
|
||||
.transpose()?;
|
||||
let (account, date, balance) = match p.statement_attachment {
|
||||
Some(att) => {
|
||||
if find_attachment(&store, att)?.withdrawn_at.is_some() {
|
||||
return Err(format!(
|
||||
"attachment {att} is withdrawn, so its statement is retired: file the statement again and record it (books.record_statement)"
|
||||
));
|
||||
}
|
||||
let st = store
|
||||
.statement(att)
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"attachment {att} records no statement: record it with books.record_statement first"
|
||||
)
|
||||
})?;
|
||||
if let Some(a) = given_account.filter(|a| *a != st.account_id) {
|
||||
return Err(format!(
|
||||
"statement {att} is for account {}, not {}",
|
||||
code_name(&store, st.account_id).0,
|
||||
code_name(&store, a).0
|
||||
));
|
||||
}
|
||||
if let Some(d) = given_date.filter(|d| *d != st.period_end) {
|
||||
return Err(format!("statement {att} ends {}, not {d}", st.period_end));
|
||||
}
|
||||
if let Some(b) = given_balance.filter(|b| *b != st.closing_balance) {
|
||||
return Err(format!(
|
||||
"statement {att} closes at {}, not {}",
|
||||
money(st.closing_balance),
|
||||
money(b)
|
||||
));
|
||||
}
|
||||
(st.account_id, st.period_end, st.closing_balance)
|
||||
}
|
||||
None => {
|
||||
if confirmed {
|
||||
return Err("a reconciliation is recorded only against a recorded statement: file it (books.file_document category statement), record what it says (books.record_statement), then pass statement_attachment".into());
|
||||
}
|
||||
match (given_account, given_date, given_balance) {
|
||||
(Some(a), Some(d), Some(b)) => (a, d, b),
|
||||
_ => {
|
||||
return Err("give statement_attachment, or account, statement_date, and statement_balance".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
let report = store
|
||||
.ledger()
|
||||
.reconciliation_report(account, date, balance)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let ledger = store.ledger();
|
||||
let mut cleared_lines = Vec::new();
|
||||
for e in ledger.posted_entries() {
|
||||
if e.date() > date {
|
||||
continue;
|
||||
}
|
||||
for (i, l) in e.lines().iter().enumerate() {
|
||||
if l.account == account && ledger.is_cleared(e.id(), i) {
|
||||
cleared_lines.push(reconcile_line(
|
||||
e.id(),
|
||||
i,
|
||||
e.date(),
|
||||
e.description(),
|
||||
l.side,
|
||||
l.amount.amount,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
cleared_lines.sort_by(|a, b| {
|
||||
a.date
|
||||
.cmp(&b.date)
|
||||
.then(a.entry_id.cmp(&b.entry_id))
|
||||
.then(a.line_index.cmp(&b.line_index))
|
||||
});
|
||||
let mut problems = Vec::new();
|
||||
if p.statement_attachment.is_none() {
|
||||
problems.push("no recorded statement: file it, books.record_statement, then pass statement_attachment".to_string());
|
||||
}
|
||||
if !report.difference.is_zero() {
|
||||
problems.push(format!(
|
||||
"the cleared balance differs from the statement by {}: mark the lines the statement shows with books.mark_cleared (or find what's missing)",
|
||||
money(report.difference)
|
||||
));
|
||||
}
|
||||
if let Some(last) = reconciled_through(&store, account)?
|
||||
&& last >= date
|
||||
{
|
||||
problems.push(format!(
|
||||
"already reconciled through {last}: void that reconciliation (books.void_reconciliation) to redo it"
|
||||
));
|
||||
}
|
||||
let mut out = ReconcileOut {
|
||||
status: "preview".to_string(),
|
||||
account_code: code_name(&store, account).0,
|
||||
statement_date: date.to_string(),
|
||||
statement_attachment: p.statement_attachment,
|
||||
book_balance: money(report.book_balance),
|
||||
cleared_balance: money(report.cleared_balance),
|
||||
statement_balance: money(report.statement_balance),
|
||||
difference: money(report.difference),
|
||||
uncleared_lines: report
|
||||
.uncleared_lines
|
||||
.into_iter()
|
||||
.map(|l| {
|
||||
reconcile_line(
|
||||
l.entry_id,
|
||||
l.line_index,
|
||||
l.date,
|
||||
&l.description,
|
||||
l.side,
|
||||
l.amount,
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
cleared_lines,
|
||||
problems,
|
||||
reconciliation: None,
|
||||
};
|
||||
if !confirmed {
|
||||
return Ok(Json(out));
|
||||
}
|
||||
if !out.problems.is_empty() {
|
||||
return Err(format!("not recorded: {}", out.problems.join("; ")));
|
||||
}
|
||||
let att = p.statement_attachment.expect("required when confirmed");
|
||||
let r = store
|
||||
.complete_reconciliation(att)
|
||||
.map_err(|e| e.to_string())?;
|
||||
out.status = "reconciled".to_string();
|
||||
out.reconciliation = Some(reconciliation_out(&store, &r));
|
||||
Ok(Json(out))
|
||||
}
|
||||
|
||||
/// ```text
|
||||
/// GOAL: the bank and card statements are in the books, so a
|
||||
/// reconciliation names the one it agrees with.
|
||||
/// SOURCE: Store::record_statement.
|
||||
/// INVARIANTS:
|
||||
/// 1. Journaled as `statement.recorded`, then `attachment.linked` to
|
||||
/// the account (unless linked already). A refusal journals nothing.
|
||||
/// SCENARIOS:
|
||||
/// 1. IF the attachment is unknown, withdrawn, or already records a
|
||||
/// statement; the account is unknown or not an asset or liability;
|
||||
/// the period runs backwards; or a balance isn't a decimal string
|
||||
/// in cents: SHOULD refuse.
|
||||
/// ```
|
||||
#[tool(
|
||||
name = "books.record_statement",
|
||||
title = "Record a statement",
|
||||
description = "Record what a filed bank or card statement says (file it first: books.file_document category statement): the account it is for (code or id; an asset or liability), the period it covers (period_start, period_end: the statement date), and its opening and closing balances as printed, in the account's normal direction (what the bank holds; what the card is owed), decimal strings. Links the statement to the account. Then reconcile to it: books.reconcile statement_attachment. Journaled as statement.recorded. Returns the statement.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn record_statement(
|
||||
&self,
|
||||
params: Parameters<RecordStatementParams>,
|
||||
) -> Result<Json<StatementOut>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
find_attachment(&store, p.attachment_id)?;
|
||||
let st = ledger_store::Statement {
|
||||
attachment_id: p.attachment_id,
|
||||
account_id: resolve_account(&store, &p.account)?,
|
||||
period_start: parse_date(&p.period_start)?,
|
||||
period_end: parse_date(&p.period_end)?,
|
||||
opening_balance: parse_amount(&p.opening_balance)?,
|
||||
closing_balance: parse_amount(&p.closing_balance)?,
|
||||
};
|
||||
store
|
||||
.record_statement(st.clone())
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(Json(statement_out(&store, &st)?))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.statements",
|
||||
title = "List statements",
|
||||
description = "Recorded bank and card statements (books.record_statement), optionally one account's (code or id), by statement date: the attachment, account, period, opening and closing balances, and the reconciliation made against it, if any.",
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn statements(
|
||||
&self,
|
||||
params: Parameters<AccountFilterParams>,
|
||||
) -> Result<Json<StatementsOut>, String> {
|
||||
let store = self.lock()?;
|
||||
let account = params
|
||||
.0
|
||||
.account
|
||||
.as_deref()
|
||||
.map(|a| resolve_account(&store, a))
|
||||
.transpose()?;
|
||||
let statements = store
|
||||
.statements(account)
|
||||
.map_err(|e| e.to_string())?
|
||||
.iter()
|
||||
.map(|st| statement_out(&store, st))
|
||||
.collect::<Result<_, _>>()?;
|
||||
Ok(Json(StatementsOut { statements }))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.reconciliations",
|
||||
title = "List reconciliations",
|
||||
description = "Recorded reconciliations (books.reconcile confirmed), optionally one account's (code or id), by statement date: the account, the statement date and balance, the statement's attachment, the lines it found cleared, and whether it was voided (and why).",
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn reconciliations(
|
||||
&self,
|
||||
params: Parameters<AccountFilterParams>,
|
||||
) -> Result<Json<ReconciliationsOut>, String> {
|
||||
let store = self.lock()?;
|
||||
let account = params
|
||||
.0
|
||||
.account
|
||||
.as_deref()
|
||||
.map(|a| resolve_account(&store, a))
|
||||
.transpose()?;
|
||||
let reconciliations = store
|
||||
.reconciliations(account)
|
||||
.map_err(|e| e.to_string())?
|
||||
.iter()
|
||||
.map(|r| reconciliation_out(&store, r))
|
||||
.collect();
|
||||
Ok(Json(ReconciliationsOut { reconciliations }))
|
||||
}
|
||||
|
||||
/// ```text
|
||||
/// GOAL: correct a mis-recorded statement on the record.
|
||||
/// SOURCE: Store::void_statement.
|
||||
/// SCENARIOS:
|
||||
/// 1. IF the attachment records no statement, a reconciliation not
|
||||
/// voided rests on it, or the reason is blank: SHOULD refuse,
|
||||
/// journaling nothing.
|
||||
/// 2. Otherwise: journaled as `statement.voided`; books.statements no
|
||||
/// longer lists it, and the same attachment can record the
|
||||
/// statement again (books.record_statement).
|
||||
/// ```
|
||||
#[tool(
|
||||
name = "books.void_statement",
|
||||
title = "Void a statement",
|
||||
description = "Void a recorded statement that was recorded wrong (a mistyped balance or period), with the reason recorded in the journal, so the same filed statement can be recorded again with books.record_statement. Refused while a reconciliation not voided rests on it (books.void_reconciliation first). Nothing is erased: the journal keeps what it said. Journaled as statement.voided. Returns the statement as it was recorded.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = true,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn void_statement(
|
||||
&self,
|
||||
params: Parameters<VoidStatementParams>,
|
||||
) -> Result<Json<StatementOut>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
let st = store
|
||||
.statement(p.attachment_id)
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| format!("attachment {} records no statement", p.attachment_id))?;
|
||||
let out = statement_out(&store, &st)?;
|
||||
store
|
||||
.void_statement(p.attachment_id, &p.reason)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(Json(out))
|
||||
}
|
||||
|
||||
/// ```text
|
||||
/// GOAL: undo a mistaken reconciliation on the record, to redo it.
|
||||
/// SOURCE: Store::void_reconciliation.
|
||||
/// SCENARIOS:
|
||||
/// 1. IF it is unknown or already voided, or the reason is blank:
|
||||
/// SHOULD refuse, journaling nothing.
|
||||
/// 2. Otherwise: journaled as `reconciliation.voided`; it stays
|
||||
/// listed, marked voided, and the account can be reconciled
|
||||
/// through that date again.
|
||||
/// ```
|
||||
#[tool(
|
||||
name = "books.void_reconciliation",
|
||||
title = "Void a reconciliation",
|
||||
description = "Void a recorded reconciliation that was a mistake, with the reason recorded in the journal, so the account can be reconciled through that date again. Nothing is erased: it stays listed in books.reconciliations, marked voided. Journaled as reconciliation.voided. Returns it.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = true,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn void_reconciliation(
|
||||
&self,
|
||||
params: Parameters<VoidReconciliationParams>,
|
||||
) -> Result<Json<ReconciliationOut>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
store
|
||||
.void_reconciliation(p.reconciliation_id, &p.reason)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let r = store
|
||||
.reconciliations(None)
|
||||
.map_err(|e| e.to_string())?
|
||||
.into_iter()
|
||||
.find(|r| r.id == p.reconciliation_id)
|
||||
.ok_or_else(|| format!("unknown reconciliation {}", p.reconciliation_id))?;
|
||||
Ok(Json(reconciliation_out(&store, &r)))
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,346 @@
|
||||
//! Prepaid schedules and recurring entries through the server: create,
|
||||
//! list and run each. Split whole out of `server.rs` (plan/served.md 8.2
|
||||
//! U0a).
|
||||
|
||||
use super::*;
|
||||
|
||||
fn schedule_out(ledger: &Ledger, s: &ledger_core::AmortizationSchedule) -> ScheduleOut {
|
||||
// A schedule an earlier version journaled may run past the last date
|
||||
// chrono holds (rules.md B-28): shown, with why its plan can't be.
|
||||
let plan = s.plan();
|
||||
let code = |id| {
|
||||
ledger
|
||||
.chart()
|
||||
.get(id)
|
||||
.map(|a| a.code.clone())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
ScheduleOut {
|
||||
schedule_id: s.id,
|
||||
description: s.description.clone(),
|
||||
kind: match s.kind {
|
||||
ledger_core::ScheduleKind::PrepaidExpense => "prepaid_expense",
|
||||
ledger_core::ScheduleKind::DeferredRevenue => "deferred_revenue",
|
||||
}
|
||||
.to_string(),
|
||||
balance_account: code(s.balance_account),
|
||||
pl_account: code(s.pl_account),
|
||||
total: money(s.total.amount),
|
||||
convention: s.convention.key().to_string(),
|
||||
start: s.first_day().to_string(),
|
||||
end: s.last_day().map(|d| d.to_string()),
|
||||
next: s.next().ok().flatten().map(|d| d.to_string()),
|
||||
remaining: plan.as_ref().ok().and(s.remaining().ok()).map(money),
|
||||
months: plan
|
||||
.as_ref()
|
||||
.map(|plan| {
|
||||
plan.iter()
|
||||
.map(|(d, a)| ScheduleMonthOut {
|
||||
date: d.to_string(),
|
||||
amount: money(*a),
|
||||
posted: s.posted.contains(d),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
// A null remaining is always explained (ScheduleOut::plan_error).
|
||||
plan_error: plan
|
||||
.err()
|
||||
.or_else(|| s.remaining().err())
|
||||
.map(|e| e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn recurring_out(r: &ledger_core::RecurringEntry) -> RecurringOut {
|
||||
RecurringOut {
|
||||
recurring_id: r.id,
|
||||
description: r.description.clone(),
|
||||
frequency: format!("{:?}", r.frequency).to_lowercase(),
|
||||
start: r.start.to_string(),
|
||||
end: r.end.map(|e| e.to_string()),
|
||||
generated: r.generated.iter().map(|d| d.to_string()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tool_router(router = schedules_router, vis = "pub(super)")]
|
||||
impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.create_schedule",
|
||||
title = "Create an amortization schedule",
|
||||
description = "Spread a prepaid expense (an asset used up as time passes, e.g. a year of insurance paid up front) or deferred revenue (a liability earned as time passes, e.g. an annual subscription collected up front) over the time it covers, to the cent. convention: daily (by the day from start: a mid-month start posts a small first and last month, one month end more than its months) or monthly_even (equal months; starts on the 1st); omitted, the schedule_convention setting, else daily. Record the original payment first (Dr prepaid / Cr cash, with its receipt; or Dr cash / Cr deferred revenue). Returns the plan; post it with books.run_schedules.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn create_schedule(
|
||||
&self,
|
||||
params: Parameters<CreateScheduleParams>,
|
||||
) -> Result<Json<ScheduleOut>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
let kind = match p.kind.as_str() {
|
||||
"prepaid_expense" => ledger_core::ScheduleKind::PrepaidExpense,
|
||||
"deferred_revenue" => ledger_core::ScheduleKind::DeferredRevenue,
|
||||
other => {
|
||||
return Err(format!(
|
||||
"kind {other:?}: expected prepaid_expense or deferred_revenue"
|
||||
));
|
||||
}
|
||||
};
|
||||
// The books' setting, else daily (D-11, D-37).
|
||||
let convention = match p.convention.as_deref() {
|
||||
None => store.ledger().settings().schedule_convention(),
|
||||
Some(c) => ledger_core::ScheduleConvention::parse(c)
|
||||
.ok_or_else(|| format!("convention {c:?}: expected daily or monthly_even"))?,
|
||||
};
|
||||
let currency = store.ledger().base_currency();
|
||||
let balance_account = resolve_account(&store, &p.balance_account)?;
|
||||
let pl_account = resolve_account(&store, &p.pl_account)?;
|
||||
let id = store
|
||||
.register_schedule(ledger_core::NewSchedule {
|
||||
description: p.description,
|
||||
kind,
|
||||
total: Money::new(parse_amount(&p.total)?, currency),
|
||||
start: parse_date(&p.start)?,
|
||||
months: p.months,
|
||||
balance_account,
|
||||
pl_account,
|
||||
convention,
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(Json(schedule_out(
|
||||
store.ledger(),
|
||||
store.ledger().get_schedule(id).expect("registered"),
|
||||
)))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.list_schedules",
|
||||
title = "List amortization schedules",
|
||||
description = "Amortization schedules (prepaid expenses, deferred revenue): each one's convention (daily or monthly_even), the first and last day it covers, each month's amount, what has posted, the next month to post, and what remains.",
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn list_schedules(&self) -> Result<Json<ListSchedulesOut>, String> {
|
||||
let store = self.lock()?;
|
||||
let ledger = store.ledger();
|
||||
Ok(Json(ListSchedulesOut {
|
||||
schedules: ledger
|
||||
.schedules()
|
||||
.map(|s| schedule_out(ledger, s))
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.run_schedules",
|
||||
title = "Post scheduled amortization",
|
||||
description = "Post every amortization schedule's months ending on or before `through` (default today) that haven't posted. Idempotent: a month posts once; a month reversed in its own month is due again. A month in a closed period, or reversed after it ended, is listed under skipped, not posted. Without confirmed=true, returns the postings it would make. Exempt from the receipt policy: the schedule is the evidence (the original payment carries the receipt).",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = true,
|
||||
idempotent_hint = true,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn run_schedules(
|
||||
&self,
|
||||
params: Parameters<RunSchedulesParams>,
|
||||
) -> Result<Json<RunSchedulesOut>, String> {
|
||||
let p = params.0;
|
||||
let through = match &p.through {
|
||||
Some(s) => parse_date(s)?,
|
||||
None => today(),
|
||||
};
|
||||
let mut store = self.lock()?;
|
||||
// A schedule whose plan can't be computed refuses the run, naming
|
||||
// it, before any month posts.
|
||||
let mut due: Vec<(NaiveDate, u64, Decimal)> = Vec::new();
|
||||
for s in store.ledger().schedules() {
|
||||
let months = s
|
||||
.due(through)
|
||||
.map_err(|e| format!("schedule {}: {e}", s.id))?;
|
||||
due.extend(months.into_iter().map(|(d, a)| (d, s.id, a)));
|
||||
}
|
||||
due.sort();
|
||||
// A month dated in a closed period can't post, nor one whose
|
||||
// posting was reversed after the month ended (it would count twice
|
||||
// until then): left for a human, and said, never a failed run.
|
||||
let (held, due): (Vec<_>, Vec<_>) = due
|
||||
.into_iter()
|
||||
.partition(|(d, id, _)| store.ledger().schedule_month_hold(*id, *d).is_some());
|
||||
let skipped: Vec<ScheduleSkippedOut> = held
|
||||
.into_iter()
|
||||
.map(|(d, id, _)| ScheduleSkippedOut {
|
||||
schedule_id: id,
|
||||
date: d.to_string(),
|
||||
reason: store
|
||||
.ledger()
|
||||
.schedule_month_hold(id, d)
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
.collect();
|
||||
let describe = |store: &Store, id: u64, date: NaiveDate| -> Result<String, String> {
|
||||
Ok(store
|
||||
.ledger()
|
||||
.preview_schedule_month(id, date)
|
||||
.map_err(|e| e.to_string())?
|
||||
.1)
|
||||
};
|
||||
if !p.confirmed {
|
||||
let postings = due
|
||||
.into_iter()
|
||||
.map(|(date, id, amount)| {
|
||||
Ok(SchedulePostingOut {
|
||||
schedule_id: id,
|
||||
date: date.to_string(),
|
||||
description: describe(&store, id, date)?,
|
||||
amount: money(amount),
|
||||
entry_id: None,
|
||||
})
|
||||
})
|
||||
.collect::<Result<_, String>>()?;
|
||||
return Ok(Json(RunSchedulesOut {
|
||||
status: "preview".to_string(),
|
||||
postings,
|
||||
skipped,
|
||||
}));
|
||||
}
|
||||
let mut postings = Vec::new();
|
||||
for (date, id, amount) in due {
|
||||
let description = describe(&store, id, date)?;
|
||||
let entry = store
|
||||
.post_schedule_month(id, date)
|
||||
.map_err(|e| e.to_string())?;
|
||||
postings.push(SchedulePostingOut {
|
||||
schedule_id: id,
|
||||
date: date.to_string(),
|
||||
description,
|
||||
amount: money(amount),
|
||||
entry_id: Some(entry),
|
||||
});
|
||||
}
|
||||
Ok(Json(RunSchedulesOut {
|
||||
status: "posted".to_string(),
|
||||
postings,
|
||||
skipped,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.create_recurring",
|
||||
title = "Create a recurring entry",
|
||||
description = "A recurring entry (rent, a subscription, a loan payment): balanced lines and a frequency (weekly, monthly, quarterly, yearly) from a start date, optionally ending. books.run_recurring turns due occurrences into drafts, never posted entries: attach each actual receipt and post through the normal preview.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn create_recurring(
|
||||
&self,
|
||||
params: Parameters<CreateRecurringParams>,
|
||||
) -> Result<Json<RecurringOut>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
let currency = store.ledger().base_currency();
|
||||
let frequency = ledger_core::Frequency::parse(&p.frequency).ok_or_else(|| {
|
||||
format!(
|
||||
"frequency {:?}: expected weekly, monthly, quarterly, or yearly",
|
||||
p.frequency
|
||||
)
|
||||
})?;
|
||||
let lines = p
|
||||
.lines
|
||||
.iter()
|
||||
.map(|l| {
|
||||
Ok((
|
||||
resolve_account(&store, &l.account)?,
|
||||
parse_side(&l.side)?,
|
||||
Money::new(parse_amount(&l.amount)?, currency),
|
||||
))
|
||||
})
|
||||
.collect::<Result<Vec<_>, String>>()?;
|
||||
let id = store
|
||||
.register_recurring(ledger_core::NewRecurring {
|
||||
description: p.description,
|
||||
lines,
|
||||
frequency,
|
||||
start: parse_date(&p.start)?,
|
||||
end: match &p.end {
|
||||
Some(e) => Some(parse_date(e)?),
|
||||
None => None,
|
||||
},
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
let r = store
|
||||
.ledger()
|
||||
.recurring_entries()
|
||||
.find(|r| r.id == id)
|
||||
.expect("registered");
|
||||
Ok(Json(recurring_out(r)))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.list_recurring",
|
||||
title = "List recurring entries",
|
||||
description = "Recurring entries and the occurrences generated so far.",
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn list_recurring(&self) -> Result<Json<ListRecurringOut>, String> {
|
||||
let store = self.lock()?;
|
||||
Ok(Json(ListRecurringOut {
|
||||
recurring: store
|
||||
.ledger()
|
||||
.recurring_entries()
|
||||
.map(recurring_out)
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.run_recurring",
|
||||
title = "Draft recurring entries",
|
||||
description = "Turn every recurring entry's occurrences on or before `through` (default today) into drafts. Idempotent: an occurrence generates once. Occurrences dated in a closed period are not generated; they come back in `skipped_closed_period` for the human to decide. Each draft then goes through the normal flow: attach the receipt, preview, post.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = true,
|
||||
idempotent_hint = true,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn run_recurring(
|
||||
&self,
|
||||
params: Parameters<RunRecurringParams>,
|
||||
) -> Result<Json<RunRecurringOut>, String> {
|
||||
let through = match ¶ms.0.through {
|
||||
Some(s) => parse_date(s)?,
|
||||
None => today(),
|
||||
};
|
||||
let mut store = self.lock()?;
|
||||
let drafts = store
|
||||
.run_recurring(through)
|
||||
.map_err(|e| e.to_string())?
|
||||
.into_iter()
|
||||
.map(|(id, date, entry)| RecurringDraftOut {
|
||||
recurring_id: id,
|
||||
date: date.to_string(),
|
||||
entry_id: entry,
|
||||
})
|
||||
.collect();
|
||||
let skipped_closed_period = store
|
||||
.ledger()
|
||||
.recurring_in_closed_periods(through)
|
||||
.into_iter()
|
||||
.map(|(id, date)| RecurringSkippedOut {
|
||||
recurring_id: id,
|
||||
date: date.to_string(),
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(RunRecurringOut {
|
||||
drafts,
|
||||
skipped_closed_period,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
//! The books' own settings and guidance through the server:
|
||||
//! `books.handbook`, `books.settings` and the recorded FX rates. Split
|
||||
//! whole out of `server.rs` (plan/served.md 8.2 U0a).
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tool_router(router = settings_router, vis = "pub(super)")]
|
||||
impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.handbook",
|
||||
title = "Bookkeeping handbook",
|
||||
description = "The guide to keeping these books: every procedure and rule, by section. Read the contract section first, then each section you need, before bookkeeping. Without section: the index; with section: that section's text (also at books://handbook/{id}); section \"all\": the whole handbook.",
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
)]
|
||||
pub(super) async fn handbook(
|
||||
&self,
|
||||
params: Parameters<HandbookParams>,
|
||||
) -> Result<Json<HandbookOut>, String> {
|
||||
let settings = self.settings();
|
||||
let declared = self.document_line_projects();
|
||||
let sections = handbook::SECTIONS
|
||||
.iter()
|
||||
.map(|(id, _)| {
|
||||
let text =
|
||||
handbook::section(id, &settings, self.currency(), declared).unwrap_or_default();
|
||||
HandbookSectionOut {
|
||||
id: id.to_string(),
|
||||
title: handbook::title(&text).to_string(),
|
||||
uri: format!("{}/{id}", handbook::URI),
|
||||
chars: text.chars().count(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let section = match params.0.section.as_deref().map(str::trim) {
|
||||
None => None,
|
||||
Some(id) => {
|
||||
let text = handbook::section(id, &settings, self.currency(), declared).ok_or_else(|| {
|
||||
let ids: Vec<&str> = handbook::SECTIONS.iter().map(|(id, _)| *id).collect();
|
||||
format!(
|
||||
"no handbook section {id:?}: the sections are {}, or \"{}\" for all of them",
|
||||
ids.join(", "),
|
||||
handbook::ALL
|
||||
)
|
||||
})?;
|
||||
let title = if id == handbook::ALL {
|
||||
"The whole handbook".to_string()
|
||||
} else {
|
||||
handbook::title(&text).to_string()
|
||||
};
|
||||
Some(HandbookTextOut {
|
||||
id: id.to_string(),
|
||||
title,
|
||||
text,
|
||||
})
|
||||
}
|
||||
};
|
||||
Ok(Json(HandbookOut {
|
||||
read_first: "Read the contract section first: the rules every entry follows. Then each section you need, or section \"all\" for the whole handbook.".to_string(),
|
||||
sections,
|
||||
section,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.settings",
|
||||
title = "Settings in force",
|
||||
description = "The settings in force (company, fiscal year, tax form, members, basis, receipt rule with every account it requires or exempts, cash flow roles, close target, time zone, approval rules), every key the books know with its value and who set it (change them with books.set_setting), and problems: settings or conventions that disagree with the chart, each saying what to change.",
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
)]
|
||||
pub(super) async fn settings_tool(&self) -> Result<Json<SettingsOut>, String> {
|
||||
// The books first: their settings sync as they lock, so the
|
||||
// settings read after are theirs.
|
||||
let store = self.lock()?;
|
||||
let cfg = self.settings();
|
||||
let c = &cfg.company;
|
||||
Ok(Json(SettingsOut {
|
||||
timezone: cfg.timezone.clone(),
|
||||
today: today().to_string(),
|
||||
approval_rules: match store.ledger().settings().approval_rules() {
|
||||
ledger_core::settings::ApprovalMode::Off => "off",
|
||||
ledger_core::settings::ApprovalMode::Enforce => "enforce",
|
||||
}
|
||||
.to_string(),
|
||||
keys: kept::keys_out(&store.ledger().settings().clone()),
|
||||
company: c.name.clone(),
|
||||
fiscal_year_start: format!("{:02}-{:02}", c.fiscal_year_start.0, c.fiscal_year_start.1),
|
||||
tax_form: c.tax_form.map(|f| f.label().to_string()),
|
||||
basis: c.basis.clone(),
|
||||
receipt_rule: cfg.policy.rule(self.currency()),
|
||||
receipt_required_accounts: cfg.policy.receipt_required_accounts.clone(),
|
||||
receipt_exempt_accounts: cfg.policy.receipt_exempt_accounts.clone(),
|
||||
parties: cfg.parties.key().to_string(),
|
||||
members: c
|
||||
.members
|
||||
.iter()
|
||||
.map(|m| MemberSettingOut {
|
||||
name: m.name.clone(),
|
||||
share: m.share.normalize().to_string(),
|
||||
capital_account: m.capital_account.clone(),
|
||||
distribution_account: m.distribution_account.clone(),
|
||||
files_jointly_with: m.files_jointly_with.clone(),
|
||||
})
|
||||
.collect(),
|
||||
cash_accounts: cfg.roles.cash.clone(),
|
||||
financing_accounts: cfg.roles.financing.clone(),
|
||||
investing_accounts: cfg.roles.investing.clone(),
|
||||
close_into: c.close_into.clone(),
|
||||
close_sweep: c.close_sweep.clone(),
|
||||
problems: settings_problems(store.ledger(), &cfg),
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.set_fx_rate",
|
||||
title = "Set an exchange rate",
|
||||
description = "Record a historical FX rate: how much base currency one unit of the foreign currency buys on the given date. For reference; lines store their own rate at entry time.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn set_fx_rate(
|
||||
&self,
|
||||
params: Parameters<SetFxRateParams>,
|
||||
) -> Result<Json<SetFxRateOut>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
let currency = Currency::new(&p.currency).map_err(|e| e.to_string())?;
|
||||
store
|
||||
.set_fx_rate(currency, parse_date(&p.date)?, parse_amount(&p.rate)?)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(Json(SetFxRateOut { ok: true }))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::server::test_support::*;
|
||||
|
||||
/// A client that connects after the settings changed is told the rule
|
||||
/// now in force (plan P0): the connect text is composed from the
|
||||
/// books' settings as they are at initialize, not as they were when
|
||||
/// the server started.
|
||||
#[test]
|
||||
fn a_new_session_is_told_the_settings_in_force() {
|
||||
let books = unit_dir("info");
|
||||
let server = served(store(&books));
|
||||
let told = |s: &BooksServer| rmcp::ServerHandler::get_info(s).instructions.unwrap();
|
||||
let before = told(&server);
|
||||
assert!(before.contains("more than 25.00 USD in debits"), "{before}");
|
||||
{
|
||||
let mut store = server.lock().unwrap();
|
||||
store
|
||||
.change_settings(
|
||||
&[ledger_core::settings::Change {
|
||||
key: "policy.evidence.threshold".into(),
|
||||
value: Some(serde_json::json!("0.00")),
|
||||
}],
|
||||
&ledger_core::settings::Source {
|
||||
decided_by: "Test Owner".into(),
|
||||
decided_on: d(2026, 10, 23),
|
||||
citation: None,
|
||||
note: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let after = told(&server);
|
||||
assert!(
|
||||
after.contains("every entry needs at least one receipt"),
|
||||
"{after}"
|
||||
);
|
||||
assert!(!after.contains("25.00"), "{after}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
//! Tax categories through the server: `books.tax_categories` and
|
||||
//! `books.set_tax_category`. Split whole out of `server.rs`
|
||||
//! (plan/served.md 8.2 U0a).
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tool_router(router = tax_router, vis = "pub(super)")]
|
||||
impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.tax_categories",
|
||||
title = "Tax categories",
|
||||
description = "The tax categories an account can map to (where it lands on the income tax return), filtered to the business's return when the settings say which (tax_form or tax_classification). Each has a default deductible percent (meals 50, nondeductible 0) and guidance. Categories are stable keys, not form line numbers.",
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn tax_categories(&self) -> Result<Json<TaxCategoriesOut>, String> {
|
||||
let form = self.settings().company.tax_form;
|
||||
let categories = ledger_core::tax::CATEGORIES
|
||||
.iter()
|
||||
.filter(|c| form.is_none_or(|f| c.forms.contains(&f)))
|
||||
.map(|c| TaxCategoryOut {
|
||||
key: c.key.to_string(),
|
||||
label: c.label.to_string(),
|
||||
kind: match c.kind {
|
||||
ledger_core::TaxKind::Income => "income",
|
||||
ledger_core::TaxKind::CostOfGoods => "cost_of_goods",
|
||||
ledger_core::TaxKind::Deduction => "deduction",
|
||||
}
|
||||
.to_string(),
|
||||
default_deductible_percent: c.default_deductible_percent,
|
||||
note: c.note.to_string(),
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(TaxCategoriesOut {
|
||||
form: form.map(|f| f.label().to_string()),
|
||||
categories,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.set_tax_category",
|
||||
title = "Set an account's tax category",
|
||||
description = "Map a revenue or expense account to a tax category (see books.tax_categories), optionally overriding its deductible percent; category null clears it (refused under policy.require_tax_category = refuse, for a revenue or expense account that isn't a header). Drives the tax_summary report.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn set_tax_category(
|
||||
&self,
|
||||
params: Parameters<SetTaxCategoryParams>,
|
||||
) -> Result<Json<SetTaxCategoryOut>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
let id = resolve_account(&store, &p.account)?;
|
||||
let pct = match &p.deductible_percent {
|
||||
Some(s) => Some(parse_amount(s)?),
|
||||
None => None,
|
||||
};
|
||||
if let Some(cat) = &p.category {
|
||||
let t = store
|
||||
.ledger()
|
||||
.chart()
|
||||
.get(id)
|
||||
.expect("resolved")
|
||||
.account_type;
|
||||
ledger_core::tax::check_category(cat, t, self.settings().company.tax_form)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
store
|
||||
.set_account_tax(id, p.category.as_deref(), pct)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(Json(SetTaxCategoryOut { ok: true }))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user