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:
11 files changed
+640
-2
No files matched your search
@@ -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.",
|
||||
|
||||
@@ -223,6 +223,74 @@ pub struct RunRecurringOut {
|
||||
pub drafts: Vec<RecurringDraftOut>,
|
||||
}
|
||||
|
||||
/// A revenue or fee line of a payout.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct PayoutLineParam {
|
||||
/// Account code or id.
|
||||
pub account: String,
|
||||
/// Positive decimal string.
|
||||
pub amount: String,
|
||||
/// Optional class (name or id), e.g. the app or product line.
|
||||
pub class: Option<String>,
|
||||
}
|
||||
|
||||
/// An adjustment on a payout statement.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct PayoutAdjustmentParam {
|
||||
/// Account code or id.
|
||||
pub account: String,
|
||||
/// "debit" reduces the deposit (refund, chargeback, reserve held, tax
|
||||
/// withheld); "credit" increases it (reserve released, adjustment in
|
||||
/// your favor).
|
||||
pub side: String,
|
||||
/// Positive decimal string.
|
||||
pub amount: String,
|
||||
/// Optional class (name or id).
|
||||
pub class: Option<String>,
|
||||
}
|
||||
|
||||
/// Parameters for `books.record_payout`.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct RecordPayoutParams {
|
||||
/// Deposit date, YYYY-MM-DD.
|
||||
pub date: String,
|
||||
/// E.g. "App Store payout, October 2026".
|
||||
pub description: String,
|
||||
/// Account the money landed in.
|
||||
pub deposit_account: String,
|
||||
/// Net deposit, decimal string; negative when the platform debited you.
|
||||
pub deposit: String,
|
||||
/// Gross revenue lines (credited).
|
||||
pub revenue: Vec<PayoutLineParam>,
|
||||
/// Fees withheld (debited).
|
||||
#[serde(default)]
|
||||
pub fees: Vec<PayoutLineParam>,
|
||||
/// Everything else on the statement.
|
||||
#[serde(default)]
|
||||
pub adjustments: Vec<PayoutAdjustmentParam>,
|
||||
}
|
||||
|
||||
/// Result of `books.record_payout`.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct RecordPayoutOut {
|
||||
/// The draft entry.
|
||||
pub entry_id: u64,
|
||||
/// "draft".
|
||||
pub status: String,
|
||||
/// Gross revenue.
|
||||
pub gross: String,
|
||||
/// Fees withheld.
|
||||
pub fees: String,
|
||||
/// Net effect of adjustments on the deposit.
|
||||
pub adjustments: String,
|
||||
/// The deposit.
|
||||
pub deposit: String,
|
||||
/// The entry's lines, e.g. "Debit 1000 1265.01".
|
||||
pub lines: Vec<String>,
|
||||
/// What to do next.
|
||||
pub next: String,
|
||||
}
|
||||
|
||||
/// Parameters for `books.chart_template`.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct ChartTemplateParams {
|
||||
|
||||
@@ -139,7 +139,7 @@ fn mcp_end_to_end_over_stdio() {
|
||||
// Tool listing: 23 narrow tools, chart is read-only.
|
||||
let list = mcp.request("tools/list", json!({}));
|
||||
let tools = list["result"]["tools"].as_array().unwrap();
|
||||
assert_eq!(tools.len(), 59, "expected 59 tools, got {}", tools.len());
|
||||
assert_eq!(tools.len(), 60, "expected 60 tools, got {}", tools.len());
|
||||
let chart = tools.iter().find(|t| t["name"] == "books.chart").unwrap();
|
||||
assert_eq!(chart["annotations"]["readOnlyHint"], true);
|
||||
assert!(
|
||||
@@ -1350,3 +1350,106 @@ fn mcp_schedules_and_recurring_over_stdio() {
|
||||
let more = mcp.call("books.run_recurring", json!({"through": "2026-12-31"}));
|
||||
assert!(more["drafts"].as_array().unwrap().is_empty(), "idempotent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_payout_gross_up_over_stdio() {
|
||||
let dir = test_dir("payout");
|
||||
let mut mcp = Mcp::spawn(&dir);
|
||||
mcp.request(
|
||||
"initialize",
|
||||
json!({
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
|
||||
}),
|
||||
);
|
||||
mcp.notify("notifications/initialized");
|
||||
for (code, name, typ) in [
|
||||
("1000", "Bank", "asset"),
|
||||
("4000", "App Sales", "revenue"),
|
||||
("4100", "Refunds", "expense"),
|
||||
("6000", "Platform Fees", "expense"),
|
||||
] {
|
||||
mcp.call(
|
||||
"books.add_account",
|
||||
json!({"code": code, "name": name, "account_type": typ}),
|
||||
);
|
||||
}
|
||||
mcp.call("books.add_class", json!({"name": "App A"}));
|
||||
mcp.call("books.add_class", json!({"name": "App B"}));
|
||||
|
||||
// 1,000.00 + 500.00 gross, 225.00 commission, a 9.99 refund: 1,265.01.
|
||||
let mut payout = json!({
|
||||
"date": "2026-11-30",
|
||||
"description": "App Store payout, November 2026",
|
||||
"deposit_account": "1000",
|
||||
"deposit": "1265.00",
|
||||
"revenue": [
|
||||
{"account": "4000", "amount": "1000.00", "class": "App A"},
|
||||
{"account": "4000", "amount": "500.00", "class": "App B"},
|
||||
],
|
||||
"fees": [
|
||||
{"account": "6000", "amount": "150.00", "class": "App A"},
|
||||
{"account": "6000", "amount": "75.00", "class": "App B"},
|
||||
],
|
||||
"adjustments": [
|
||||
{"account": "4100", "side": "debit", "amount": "9.99", "class": "App A"},
|
||||
],
|
||||
});
|
||||
let err = mcp.call_err("books.record_payout", payout.clone());
|
||||
assert!(err.contains("off by -0.01"), "{err}");
|
||||
payout["deposit"] = json!("1265.01");
|
||||
let out = mcp.call("books.record_payout", payout);
|
||||
assert_eq!(out["status"], "draft");
|
||||
assert_eq!(out["gross"], "1500.00");
|
||||
assert_eq!(out["adjustments"], "-9.99");
|
||||
assert_eq!(
|
||||
out["lines"],
|
||||
json!([
|
||||
"Debit 1000 1265.01",
|
||||
"Debit 6000 150.00",
|
||||
"Debit 6000 75.00",
|
||||
"Debit 4100 9.99",
|
||||
"Credit 4000 1000.00",
|
||||
"Credit 4000 500.00"
|
||||
])
|
||||
);
|
||||
|
||||
// The statement is the receipt; then it posts like any entry.
|
||||
let entry_id = out["entry_id"].as_u64().unwrap();
|
||||
mcp.call(
|
||||
"books.attach_receipt",
|
||||
json!({
|
||||
"entry_id": entry_id,
|
||||
"file_name": "payout.pdf",
|
||||
"mime_type": "application/pdf",
|
||||
"content_base64": base64_like(b"%PDF-1.4 statement"),
|
||||
}),
|
||||
);
|
||||
mcp.call(
|
||||
"books.post_entry",
|
||||
json!({"entry_id": entry_id, "confirmed": true}),
|
||||
);
|
||||
let by_class = mcp.call(
|
||||
"books.profit_and_loss_by_class",
|
||||
json!({"start": "2026-11-01", "end": "2026-11-30"}),
|
||||
);
|
||||
let net: Vec<(String, String)> = by_class["classes"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|c| {
|
||||
(
|
||||
c["class"].as_str().unwrap_or("").to_string(),
|
||||
c["net_income"].as_str().unwrap().to_string(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
net,
|
||||
[
|
||||
("App A".into(), "840.01".into()),
|
||||
("App B".into(), "425.00".into())
|
||||
]
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user