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

+4
View File
@@ -137,6 +137,10 @@ pub enum LedgerError {
#[error("invalid schedule: {0}")]
InvalidSchedule(String),
/// A payout doesn't reconcile or uses the wrong accounts.
#[error("invalid payout: {0}")]
InvalidPayout(String),
/// A tax category doesn't exist, doesn't fit the account, or has a bad
/// deductible percent.
#[error("invalid tax category: {0}")]
+2
View File
@@ -24,6 +24,7 @@ pub mod error;
pub mod journal;
pub mod ledger;
pub mod money;
pub mod payouts;
pub mod payroll;
pub mod periods;
pub mod reports;
@@ -44,6 +45,7 @@ pub use error::LedgerError;
pub use journal::{EntryId, EntryStatus, JournalEntry, JournalLine};
pub use ledger::{Ledger, ReconciliationReport, UnclearedLine};
pub use money::{Currency, Money};
pub use payouts::{Payout, PayoutAdjustment, PayoutLine, PayoutTotals, payout_plan};
pub use payroll::{PayrollLine, PayrollRun, PayrollRunId};
pub use periods::{CloseTo, MemberClose, Period, PeriodId, PeriodStatus, RETAINED_EARNINGS_CODE};
pub use reports::{
+204
View File
@@ -0,0 +1,204 @@
//! Payouts: a platform deposit grossed up into what really happened.
//!
//! App stores, payment processors, and marketplaces collect revenue, keep
//! their fees, and deposit the difference. Booking only the deposit as
//! revenue understates income and hides the fees; the correct entry grosses
//! it up:
//!
//! ```text
//! Dr bank net deposit
//! Dr platform fees fees withheld
//! Dr/Cr adjustments refunds, chargebacks, reserves, tax withheld, ...
//! Cr revenue gross sales (one line per product or class)
//! ```
//!
//! [`payout_plan`] builds those lines and refuses unless gross − fees ±
//! adjustments equals the deposit exactly, naming the difference. It knows
//! no platform: callers (or a format-specific parser) supply the numbers
//! from the payout statement, which is the entry's receipt.
use crate::{AccountId, AccountType, ChartOfAccounts, ClassId, LedgerError, Side};
use rust_decimal::Decimal;
/// A revenue or fee line of a payout.
#[derive(Clone, Debug, PartialEq)]
pub struct PayoutLine {
/// The account.
pub account: AccountId,
/// Positive amount.
pub amount: Decimal,
/// Optional class (product line, app, store).
pub class: Option<ClassId>,
}
/// Anything else on the statement: refunds, chargebacks, reserves held or
/// released, taxes withheld, currency conversion. A debit reduces the
/// deposit; a credit increases it.
#[derive(Clone, Debug, PartialEq)]
pub struct PayoutAdjustment {
/// The account.
pub account: AccountId,
/// Debit (reduces the deposit) or credit (increases it).
pub side: Side,
/// Positive amount.
pub amount: Decimal,
/// Optional class.
pub class: Option<ClassId>,
}
/// A payout to gross up.
#[derive(Clone, Debug, PartialEq)]
pub struct Payout {
/// Where the money landed.
pub deposit_account: AccountId,
/// The net deposit. Negative when the platform debited the account
/// (fees and refunds exceeded sales).
pub deposit: Decimal,
/// Gross revenue, credited.
pub revenue: Vec<PayoutLine>,
/// Fees withheld, debited.
pub fees: Vec<PayoutLine>,
/// Everything else.
pub adjustments: Vec<PayoutAdjustment>,
}
/// The payout's totals.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PayoutTotals {
/// Sum of revenue lines.
pub gross: Decimal,
/// Sum of fee lines.
pub fees: Decimal,
/// Net effect of adjustments on the deposit (credits − debits).
pub adjustments: Decimal,
/// The deposit.
pub deposit: Decimal,
}
/// One planned line: account, side, amount, class.
pub type PayoutPlanLine = (AccountId, Side, Decimal, Option<ClassId>);
/// Validate a payout and build its entry's lines: the deposit first, then
/// fees, adjustments, and revenue. Errors unless every amount is positive,
/// every account is postable and fits its role (revenue lines credit
/// revenue or liability accounts, e.g. deferred revenue or sales tax
/// collected; fees debit expense accounts), and the payout reconciles to
/// the cent.
pub fn payout_plan(
payout: &Payout,
chart: &ChartOfAccounts,
) -> Result<(Vec<PayoutPlanLine>, PayoutTotals), LedgerError> {
let bad = |m: String| Err(LedgerError::InvalidPayout(m));
if payout.revenue.is_empty() {
return bad("a payout needs at least one revenue line".into());
}
let check = |account: AccountId,
amount: Decimal,
role: &str,
allowed: &[AccountType]|
-> Result<(), LedgerError> {
let a = chart
.get(account)
.ok_or(LedgerError::UnknownAccount(account))?;
if a.placeholder {
return Err(LedgerError::InvalidPayout(format!(
"{role} account {} is a placeholder",
a.code
)));
}
if !allowed.is_empty() && !allowed.contains(&a.account_type) {
return Err(LedgerError::InvalidPayout(format!(
"{role} account {} is {:?}; expected {}",
a.code,
a.account_type,
allowed
.iter()
.map(|t| format!("{t:?}"))
.collect::<Vec<_>>()
.join(" or ")
)));
}
if amount <= Decimal::ZERO {
return Err(LedgerError::InvalidPayout(format!(
"{role} amounts must be positive, got {amount} on {}",
a.code
)));
}
Ok(())
};
let deposit_account = chart
.get(payout.deposit_account)
.ok_or(LedgerError::UnknownAccount(payout.deposit_account))?;
if deposit_account.placeholder || deposit_account.account_type != AccountType::Asset {
return bad(format!(
"deposit account {} must be a postable asset account",
deposit_account.code
));
}
for l in &payout.revenue {
check(
l.account,
l.amount,
"revenue",
&[AccountType::Revenue, AccountType::Liability],
)?;
}
for l in &payout.fees {
check(l.account, l.amount, "fee", &[AccountType::Expense])?;
}
for a in &payout.adjustments {
check(a.account, a.amount, "adjustment", &[])?;
}
let gross: Decimal = payout.revenue.iter().map(|l| l.amount).sum();
let fees: Decimal = payout.fees.iter().map(|l| l.amount).sum();
let adjustments: Decimal = payout
.adjustments
.iter()
.map(|a| match a.side {
Side::Credit => a.amount,
Side::Debit => -a.amount,
})
.sum();
let expected = gross - fees + adjustments;
if expected != payout.deposit {
return bad(format!(
"payout doesn't reconcile: gross {gross} − fees {fees} + adjustments \
{adjustments} = {expected}, but the deposit is {} (off by {})",
payout.deposit,
payout.deposit - expected
));
}
let mut plan = Vec::new();
if payout.deposit > Decimal::ZERO {
plan.push((payout.deposit_account, Side::Debit, payout.deposit, None));
} else if payout.deposit < Decimal::ZERO {
plan.push((payout.deposit_account, Side::Credit, -payout.deposit, None));
}
plan.extend(
payout
.fees
.iter()
.map(|l| (l.account, Side::Debit, l.amount, l.class)),
);
plan.extend(
payout
.adjustments
.iter()
.map(|a| (a.account, a.side, a.amount, a.class)),
);
plan.extend(
payout
.revenue
.iter()
.map(|l| (l.account, Side::Credit, l.amount, l.class)),
);
Ok((
plan,
PayoutTotals {
gross,
fees,
adjustments,
deposit: payout.deposit,
},
))
}
+155
View File
@@ -0,0 +1,155 @@
//! Payout gross-up. Numbers computed by hand.
//!
//! An app-store month: App A sold 1,000.00, App B 500.00; the store kept
//! 15% (225.00) and clawed back a 9.99 refund. Deposit: 1,500.00 − 225.00
//! − 9.99 = 1,265.01.
use chrono::NaiveDate;
use ledger_core::reports::profit_and_loss_for_period;
use ledger_core::{
AccountType, Currency, Ledger, LedgerError, Money, Payout, PayoutAdjustment, PayoutLine, Side,
payout_plan,
};
use rust_decimal::Decimal;
use std::str::FromStr;
fn dec(s: &str) -> Decimal {
Decimal::from_str(s).unwrap()
}
fn d(day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(2026, 11, day).unwrap()
}
struct Books {
l: Ledger,
bank: u64,
sales: u64,
fees: u64,
refunds: u64,
}
fn books() -> Books {
let mut l = Ledger::new(Currency::USD);
let mut a = |code: &str, name: &str, t| l.add_account(code, name, t, None, false).unwrap();
let bank = a("1000", "Bank", AccountType::Asset);
let sales = a("4000", "App Sales", AccountType::Revenue);
let fees = a("6000", "Platform Fees", AccountType::Expense);
let refunds = a("4100", "Refunds", AccountType::Expense);
Books {
l,
bank,
sales,
fees,
refunds,
}
}
fn line(account: u64, amount: &str) -> PayoutLine {
PayoutLine {
account,
amount: dec(amount),
class: None,
}
}
fn month(b: &Books, deposit: &str) -> Payout {
Payout {
deposit_account: b.bank,
deposit: dec(deposit),
revenue: vec![line(b.sales, "1000.00"), line(b.sales, "500.00")],
fees: vec![line(b.fees, "225.00")],
adjustments: vec![PayoutAdjustment {
account: b.refunds,
side: Side::Debit,
amount: dec("9.99"),
class: None,
}],
}
}
#[test]
fn a_payout_is_grossed_up_to_revenue_and_fees() {
let mut b = books();
let (plan, totals) = payout_plan(&month(&b, "1265.01"), b.l.chart()).unwrap();
assert_eq!(totals.gross, dec("1500.00"));
assert_eq!(totals.fees, dec("225.00"));
assert_eq!(totals.adjustments, dec("-9.99"));
assert_eq!(
plan.iter()
.map(|(a, s, m, _)| (*a, *s, *m))
.collect::<Vec<_>>(),
[
(b.bank, Side::Debit, dec("1265.01")),
(b.fees, Side::Debit, dec("225.00")),
(b.refunds, Side::Debit, dec("9.99")),
(b.sales, Side::Credit, dec("1000.00")),
(b.sales, Side::Credit, dec("500.00")),
]
);
let e = b.l.create_entry(d(30), "App store payout");
for (a, s, m, _) in plan {
b.l.add_line(e, a, s, Money::new(m, Currency::USD)).unwrap();
}
b.l.post_entry(e).unwrap();
let pl = profit_and_loss_for_period(&b.l, d(1), d(30), None).unwrap();
assert_eq!(pl.total_revenue, dec("1500.00"));
assert_eq!(pl.total_expenses, dec("234.99"));
}
#[test]
fn a_payout_must_reconcile_to_the_cent() {
let b = books();
let err = payout_plan(&month(&b, "1265.00"), b.l.chart()).unwrap_err();
let LedgerError::InvalidPayout(msg) = err else {
panic!("{err:?}")
};
assert!(msg.contains("= 1265.01"), "{msg}");
assert!(msg.contains("off by -0.01"), "{msg}");
}
#[test]
fn a_negative_payout_credits_the_bank() {
let b = books();
// 10.00 of sales, 3.00 of fees, a 50.00 refund: the store debits 43.00.
let p = Payout {
deposit_account: b.bank,
deposit: dec("-43.00"),
revenue: vec![line(b.sales, "10.00")],
fees: vec![line(b.fees, "3.00")],
adjustments: vec![PayoutAdjustment {
account: b.refunds,
side: Side::Debit,
amount: dec("50.00"),
class: None,
}],
};
let (plan, _) = payout_plan(&p, b.l.chart()).unwrap();
assert_eq!(plan[0], (b.bank, Side::Credit, dec("43.00"), None));
}
#[test]
fn accounts_must_fit_their_roles() {
let b = books();
let invalid = |p: Payout| {
matches!(
payout_plan(&p, b.l.chart()),
Err(LedgerError::InvalidPayout(_))
)
};
let mut p = month(&b, "1265.01");
p.fees[0].account = b.sales;
assert!(invalid(p), "fees must be expenses");
let mut p = month(&b, "1265.01");
p.revenue[0].account = b.fees;
assert!(invalid(p), "revenue must be revenue or a liability");
let mut p = month(&b, "1265.01");
p.deposit_account = b.sales;
assert!(invalid(p), "deposits land in an asset");
let mut p = month(&b, "1265.01");
p.fees[0].amount = dec("-225.00");
assert!(invalid(p), "amounts are positive");
let mut p = month(&b, "1265.01");
p.revenue.clear();
assert!(invalid(p), "needs revenue");
}
+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.",
+68
View File
@@ -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 {
+104 -1
View File
@@ -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())
]
);
}