feat: payout gross-up (books.record_payout)

A platform deposit becomes a draft entry with the deposit, fees,
adjustments, and gross revenue (by class), refused unless gross - fees
+/- adjustments equals the deposit to the cent. The payout statement is
attached as its receipt before posting. 60 tools.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-11 21:12:38 -07:00
1 parent ddb67a2ca2
commit d65f9aa840
11 files changed
+640 -2

No files matched your search

+79 -1
View File
@@ -23,7 +23,7 @@ use rust_decimal::Decimal;
use std::sync::{Arc, Mutex};
/// The agent operating contract, delivered via initialize.
const INSTRUCTIONS: &str = "You are the bookkeeping agent for these books. The engine enforces the accounting; you enforce the workflow.\n\nRULES\n- Money is exact decimal. Always pass amounts as decimal strings (\"123.45\"), never floats.\n- Never post directly. The flow is draft (books.draft_entry) -> add lines (books.add_line) -> preview (books.post_entry without confirmed) -> review the preview -> post (books.post_entry with confirmed=true).\n- Every entry must balance: debits equal credits, exactly. Every line needs a real account from books.chart. The engine rejects anything else.\n- Receipts: any entry moving more than $25.00 in debits needs at least one receipt attached (books.attach_receipt) before posting. The server rejects the post otherwise. This is enforced, not a suggestion.\n- Never invent accounts. If the account you need does not exist, add it with books.add_account first.\n- Corrections go through books.reverse_entry. Posted entries are immutable; there is no edit.\n- Receipt corrections: receipts can change, but nothing is erased. To replace one, attach the new receipt, then books.unlink_receipt the old (with a reason); retire unwanted receipts or documents with books.withdraw_attachment (with a reason). The journal keeps every step.\n- After posting, check books.digest to review what changed, so a human can review too.\n- Amounts in every response are decimal strings. Treat them as exact.\n- Fixed assets: register with books.register_asset, inspect with books.asset_schedule and books.list_assets, post monthly depreciation with books.run_depreciation (exempt from the receipt policy; the schedule is the evidence), and dispose with books.dispose_asset (preview first; the bill of sale is the receipt). Depreciation is straight-line, book basis only - tax depreciation stays with the CPA.\n- Prepaid expenses and deferred revenue: record the original payment (with its receipt), then books.create_schedule; books.run_schedules posts the monthly amortization (preview first; exempt from the receipt policy like depreciation). Recurring entries (books.create_recurring, books.run_recurring) only ever create drafts: attach each actual receipt and post through the normal preview.";
const INSTRUCTIONS: &str = "You are the bookkeeping agent for these books. The engine enforces the accounting; you enforce the workflow.\n\nRULES\n- Money is exact decimal. Always pass amounts as decimal strings (\"123.45\"), never floats.\n- Never post directly. The flow is draft (books.draft_entry) -> add lines (books.add_line) -> preview (books.post_entry without confirmed) -> review the preview -> post (books.post_entry with confirmed=true).\n- Every entry must balance: debits equal credits, exactly. Every line needs a real account from books.chart. The engine rejects anything else.\n- Receipts: any entry moving more than $25.00 in debits needs at least one receipt attached (books.attach_receipt) before posting. The server rejects the post otherwise. This is enforced, not a suggestion.\n- Never invent accounts. If the account you need does not exist, add it with books.add_account first.\n- Corrections go through books.reverse_entry. Posted entries are immutable; there is no edit.\n- Receipt corrections: receipts can change, but nothing is erased. To replace one, attach the new receipt, then books.unlink_receipt the old (with a reason); retire unwanted receipts or documents with books.withdraw_attachment (with a reason). The journal keeps every step.\n- After posting, check books.digest to review what changed, so a human can review too.\n- Amounts in every response are decimal strings. Treat them as exact.\n- Fixed assets: register with books.register_asset, inspect with books.asset_schedule and books.list_assets, post monthly depreciation with books.run_depreciation (exempt from the receipt policy; the schedule is the evidence), and dispose with books.dispose_asset (preview first; the bill of sale is the receipt). Depreciation is straight-line, book basis only - tax depreciation stays with the CPA.\n- Prepaid expenses and deferred revenue: record the original payment (with its receipt), then books.create_schedule; books.run_schedules posts the monthly amortization (preview first; exempt from the receipt policy like depreciation). Recurring entries (books.create_recurring, books.run_recurring) only ever create drafts: attach each actual receipt and post through the normal preview.\n- Platform payouts (app stores, payment processors, marketplaces): never book the net deposit as revenue. books.record_payout grosses it up (gross revenue, fees, adjustments) into a draft and refuses unless it reconciles to the cent; attach the payout statement, then preview and post.";
/// Max receipt/document upload: 25 MB.
const MAX_UPLOAD_BYTES: usize = 25 * 1024 * 1024;
@@ -1668,6 +1668,84 @@ impl BooksServer {
Ok(Json(RunRecurringOut { drafts }))
}
#[tool(
name = "books.record_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."
)]
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();
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())?;
}
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,
next: "Attach the payout statement (books.attach_receipt), then preview and post with books.post_entry.".to_string(),
}))
}
#[tool(
name = "books.chart_template",
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 wanderbooks.toml settings that go with it. A proposal: review it with the human, adjust, then books.import_chart_csv.",