merge: P4b S7, bad debts (branch S7, 4b4fecb9)
The fourth of the paused branches: Ledger::write_off and the bad_debts control role, invoice.written_off with its evidence filed in the same store call, books.write_off_invoice, the write-off on the timeline and in Needs attention, Lean writeOff_within_bounds, rules.md B-41 and the bookspec examples. Twenty-eight files conflicted with the S4.4, S5 and S6 merges; resolved as plan 8.0.2 says, keeping both sides' appends with their seams restored, and: the scope table at 125; S7's law numbered 37; B-41 placed before B-42; the handbook's step 4 and recovery procedures folded into the one invoices-and-bills section; S7's wire tests in their own module with the confirm S4.4's contract needs; S7's copy of the payment steps dropped (S6 replaced them with Ledger::settle) and its write-off journaled through S6's journal_auto_lines; in the old-books fixture S7's hand-written write-off moved to a second invoice, since S6's hand-written payment settles invoice 1 in full. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
commit
335a3eaa7e
47 files changed
+2883
-74
No files matched your search
@@ -73,16 +73,19 @@ pub const SECTIONS: &[(&str, &str)] = &[
|
||||
"revenue-and-payouts",
|
||||
include_str!("../handbook/revenue-and-payouts.md"),
|
||||
),
|
||||
// S4.5 gives invoices and bills their first three and fifth steps; S7
|
||||
// adds step 4 (void, write off or reverse) and the recovery
|
||||
// procedures (plan 8.2).
|
||||
(
|
||||
"invoices-and-bills",
|
||||
include_str!("../handbook/invoices-and-bills.md"),
|
||||
),
|
||||
("projects", include_str!("../handbook/projects.md")),
|
||||
(
|
||||
"parties-and-tins",
|
||||
include_str!("../handbook/parties-and-tins.md"),
|
||||
),
|
||||
("1099", include_str!("../handbook/1099.md")),
|
||||
(
|
||||
"invoices-and-bills",
|
||||
include_str!("../handbook/invoices-and-bills.md"),
|
||||
),
|
||||
("cpa-handoff", include_str!("../handbook/cpa-handoff.md")),
|
||||
(
|
||||
"settings-and-conventions",
|
||||
|
||||
@@ -56,7 +56,7 @@ const C: Needs = PostWhenConfirmed;
|
||||
const K: Needs = PostForApprovalRules;
|
||||
|
||||
/// Every tool and what it needs (INVARIANT 1).
|
||||
pub const TABLE: [(&str, Needs); 124] = [
|
||||
pub const TABLE: [(&str, Needs); 125] = [
|
||||
("books.handbook", R),
|
||||
("books.start", R),
|
||||
// Settings are committed at once: the settings commit of the plan.
|
||||
@@ -189,6 +189,8 @@ pub const TABLE: [(&str, Needs); 124] = [
|
||||
("books.access_log", A),
|
||||
("books.backup", R),
|
||||
("books.back_up_now", A),
|
||||
// P4b S7: a bad-debt write-off (preview, then post).
|
||||
("books.write_off_invoice", C),
|
||||
];
|
||||
|
||||
/// What `tool` needs, given its arguments; None for a tool this server
|
||||
|
||||
@@ -581,6 +581,7 @@ impl BooksServer {
|
||||
+ Self::payroll_router()
|
||||
+ Self::payouts_router()
|
||||
+ Self::tax_router()
|
||||
+ Self::write_offs_router()
|
||||
}
|
||||
|
||||
/// The settings in force for this request: the books' own, taken from
|
||||
@@ -1430,6 +1431,8 @@ mod settings_tools;
|
||||
mod settlements;
|
||||
#[path = "tools/tax.rs"]
|
||||
mod tax;
|
||||
#[path = "tools/write_offs.rs"]
|
||||
mod write_offs;
|
||||
|
||||
// The shared helpers are re-exported so every group file reaches them
|
||||
// through `use super::*`, and no group file reaches into another.
|
||||
|
||||
@@ -42,7 +42,7 @@ fn annotations_complete() {
|
||||
const D: Hints = (Some(false), Some(true), None);
|
||||
const WI: Hints = (Some(false), Some(false), Some(true));
|
||||
const DI: Hints = (Some(false), Some(true), Some(true));
|
||||
let table: [(&str, Hints); 124] = [
|
||||
let table: [(&str, Hints); 125] = [
|
||||
("books.handbook", R),
|
||||
("books.start", R),
|
||||
("books.set_setting", DI),
|
||||
@@ -167,6 +167,8 @@ fn annotations_complete() {
|
||||
("books.access_log", R),
|
||||
("books.backup", R),
|
||||
("books.back_up_now", WI),
|
||||
// P4b S7: a bad-debt write-off.
|
||||
("books.write_off_invoice", D),
|
||||
];
|
||||
let tools = BooksServer::tool_router().list_all();
|
||||
assert_eq!(tools.len(), table.len());
|
||||
@@ -206,8 +208,8 @@ fn annotations_complete() {
|
||||
"{name}: openWorldHint"
|
||||
);
|
||||
}
|
||||
// docs/mcp-interface.md names the 29 destructive tools.
|
||||
assert_eq!(table.iter().filter(|(_, h)| h.1 == Some(true)).count(), 29);
|
||||
// docs/mcp-interface.md names the 30 destructive tools.
|
||||
assert_eq!(table.iter().filter(|(_, h)| h.1 == Some(true)).count(), 30);
|
||||
}
|
||||
|
||||
/// Every refusal an agent can be given whose text is fixed here, or
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
//! `books.write_off_invoice`'s group: bad debts (plan P4b S7; rules.md
|
||||
//! B-41). A file of its own (plan 8.2 U0a) so S4.4's document tools and
|
||||
//! this one land apart: a tool is added to its group's file and nowhere
|
||||
//! else.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// An invoice's figures after a write-off, as the tool answers them: read
|
||||
/// from its tagged lines that stand (rules.md B-38), at the end of the
|
||||
/// books.
|
||||
fn write_off_out(
|
||||
ledger: &Ledger,
|
||||
invoice: u64,
|
||||
entry_id: u64,
|
||||
) -> Result<WriteOffInvoiceOut, String> {
|
||||
let st = ledger
|
||||
.doc_state(ledger_core::DocRef::Invoice(invoice), NaiveDate::MAX)
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| format!("invoice {invoice} not found"))?;
|
||||
Ok(WriteOffInvoiceOut {
|
||||
entry_id,
|
||||
document: WriteOffDocumentOut {
|
||||
r#ref: format!("invoice:{invoice}"),
|
||||
status: st.status.key().to_string(),
|
||||
paid: money(st.paid),
|
||||
written_off: money(st.written_off),
|
||||
outstanding: money(st.outstanding),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
#[tool_router(router = write_offs_router, vis = "pub(super)")]
|
||||
impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.write_off_invoice",
|
||||
title = "Write off an invoice",
|
||||
description = "Write off an invoice that will not be collected (a bad debt): Dr Bad Debt Expense 6990 / Cr Accounts Receivable, the receivable line tagged with the invoice, so it reads written_off and its figures stay derived. The evidence of worthlessness (collection correspondence, a bankruptcy notice) is filed with it in the same call: upload_id, or existing_attachment_id for one already filed. amount (default: all it owes) may be partial. Refused on cash-basis books, above what it owes, before its date or in a closed period. To recover it: reverse the write-off (books.reverse_entry; the invoice reopens), then books.receive_payment. Without confirmed=true, a preview.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = true,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn write_off_invoice(
|
||||
&self,
|
||||
params: Parameters<WriteOffInvoiceParams>,
|
||||
) -> Result<Json<WriteOffInvoiceOut>, String> {
|
||||
let p = params.0;
|
||||
let date = parse_date(&p.date)?;
|
||||
let mut store = self.lock()?;
|
||||
// The event type is new: books that haven't declared documents_v2
|
||||
// refuse it with the upgrade remedy, journaling nothing (B-27).
|
||||
store
|
||||
.ensure_feature(ledger_store::format::DOCUMENTS_V2)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let doc = ledger_core::DocRef::Invoice(p.invoice);
|
||||
let owed = store
|
||||
.ledger()
|
||||
.doc_state(doc, date)
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| format!("invoice {} is dated after {date}", p.invoice))?
|
||||
.outstanding;
|
||||
let amount = match p.amount.as_deref() {
|
||||
Some(a) => parse_amount(a)?,
|
||||
None => owed,
|
||||
};
|
||||
// The evidence is the point of a write-off (B-41 INVARIANT 1): one
|
||||
// of the two, and the preview says so too rather than posting
|
||||
// without it later.
|
||||
match (p.upload_id.is_some(), p.existing_attachment_id.is_some()) {
|
||||
(true, true) | (false, false) => {
|
||||
return Err(
|
||||
"give the evidence once: upload_id (a file to file with the write-off) or \
|
||||
existing_attachment_id (one already filed), exactly one"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if !p.confirmed.unwrap_or(false) {
|
||||
// The engine on a clone: every refusal the post would give, and
|
||||
// the figures the write-off would leave. Nothing is written.
|
||||
let mut probe = store.ledger().clone();
|
||||
probe
|
||||
.write_off(p.invoice, date, amount, &p.reason)
|
||||
.map_err(|e| e.to_string())?;
|
||||
return Ok(Json(write_off_out(&probe, p.invoice, 0)?));
|
||||
}
|
||||
let entry_id = match p.upload_id.as_deref() {
|
||||
Some(upload_id) => {
|
||||
let file = self.take_file(files::Given {
|
||||
upload_id,
|
||||
purposes: files::FILED,
|
||||
})?;
|
||||
let (file_name, mime_type) = named(None, None, &file)?;
|
||||
let (bytes, claim) = (file.bytes, file.claim);
|
||||
self.refuse_tin_file(&store, &bytes, &mime_type, &file_name)?;
|
||||
let (entry_id, _) = store
|
||||
.write_off_invoice(
|
||||
p.invoice,
|
||||
date,
|
||||
amount,
|
||||
&p.reason,
|
||||
Some(ledger_store::WriteOffEvidence::File {
|
||||
file_name: &file_name,
|
||||
mime_type: &mime_type,
|
||||
bytes: &bytes,
|
||||
description: Some("Evidence of worthlessness"),
|
||||
}),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
files::filed(claim);
|
||||
entry_id
|
||||
}
|
||||
None => {
|
||||
let id = p
|
||||
.existing_attachment_id
|
||||
.expect("checked: one of the two is given");
|
||||
let (entry_id, _) = store
|
||||
.write_off_invoice(
|
||||
p.invoice,
|
||||
date,
|
||||
amount,
|
||||
&p.reason,
|
||||
Some(ledger_store::WriteOffEvidence::Existing(id)),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
entry_id
|
||||
}
|
||||
};
|
||||
Ok(Json(write_off_out(store.ledger(), p.invoice, entry_id)?))
|
||||
}
|
||||
}
|
||||
@@ -892,7 +892,7 @@ pub struct SettingsOut {
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct AssignRoleParams {
|
||||
/// The role, from books.roles: receivables, revenue, payables,
|
||||
/// general_expense, retained_earnings, salary_expense,
|
||||
/// general_expense, bad_debts, retained_earnings, salary_expense,
|
||||
/// payroll_tax_expense, federal_withholding, state_withholding,
|
||||
/// social_security_payable, medicare_payable, other_deductions_payable.
|
||||
pub role: String,
|
||||
@@ -5293,3 +5293,63 @@ pub struct MarkSentOut {
|
||||
/// Every send on it, in order, the newest last.
|
||||
pub sends: Vec<SentOut>,
|
||||
}
|
||||
|
||||
// P4b S7: bad debts (books.write_off_invoice)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parameters for `books.write_off_invoice`.
|
||||
#[derive(Debug, Clone, Deserialize, JsonSchema)]
|
||||
pub struct WriteOffInvoiceParams {
|
||||
/// The invoice's id (books.documents).
|
||||
pub invoice: u64,
|
||||
/// The write-off's day, YYYY-MM-DD: not before the invoice, and in an
|
||||
/// open period.
|
||||
pub date: String,
|
||||
/// How much to write off, a decimal string (at most what the invoice
|
||||
/// owes on the day); left out, all of it.
|
||||
#[serde(default)]
|
||||
pub amount: Option<String>,
|
||||
/// Why it will not be collected: the collection attempts, a bankruptcy
|
||||
/// notice, the customer's liquidation. One line, journaled with the
|
||||
/// write-off.
|
||||
pub reason: String,
|
||||
/// The evidence's bytes: an upload_id from books.begin_upload (purpose
|
||||
/// document or receipt) and the command it returns. Exactly one of
|
||||
/// this and existing_attachment_id.
|
||||
#[serde(default)]
|
||||
pub upload_id: Option<String>,
|
||||
/// An attachment already filed and not linked to an entry
|
||||
/// (books.list_receipts): it is linked to the write-off's entry and
|
||||
/// to the invoice, the same call. Exactly one of this and upload_id.
|
||||
#[serde(default)]
|
||||
pub existing_attachment_id: Option<u64>,
|
||||
/// false or left out: a preview (the posting and the figures it would
|
||||
/// leave), writing nothing; true: post it.
|
||||
#[serde(default)]
|
||||
pub confirmed: Option<bool>,
|
||||
}
|
||||
|
||||
/// An invoice's figures after a write-off, derived from its tagged lines
|
||||
/// that stand.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct WriteOffDocumentOut {
|
||||
/// "invoice:N", as books.document names it.
|
||||
pub r#ref: String,
|
||||
/// Its status after the write-off: written_off, partial, paid, ...
|
||||
pub status: String,
|
||||
/// Cash paid against it that stands, decimal string.
|
||||
pub paid: String,
|
||||
/// What was written off that stands, decimal string.
|
||||
pub written_off: String,
|
||||
/// What is still owed, decimal string.
|
||||
pub outstanding: String,
|
||||
}
|
||||
|
||||
/// Output of `books.write_off_invoice`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct WriteOffInvoiceOut {
|
||||
/// The write-off's journal entry's id (0 in a preview).
|
||||
pub entry_id: u64,
|
||||
/// The invoice's derived figures after it.
|
||||
pub document: WriteOffDocumentOut,
|
||||
}
|
||||
Reference in new issue
Block a user