diff --git a/crates/ledger-core/src/error.rs b/crates/ledger-core/src/error.rs index 5359fb6..d035021 100644 --- a/crates/ledger-core/src/error.rs +++ b/crates/ledger-core/src/error.rs @@ -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}")] diff --git a/crates/ledger-core/src/lib.rs b/crates/ledger-core/src/lib.rs index 61a9d5f..ecd3f40 100644 --- a/crates/ledger-core/src/lib.rs +++ b/crates/ledger-core/src/lib.rs @@ -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::{ diff --git a/crates/ledger-core/src/payouts.rs b/crates/ledger-core/src/payouts.rs new file mode 100644 index 0000000..57f7677 --- /dev/null +++ b/crates/ledger-core/src/payouts.rs @@ -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, +} + +/// 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, +} + +/// 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, + /// Fees withheld, debited. + pub fees: Vec, + /// Everything else. + pub adjustments: Vec, +} + +/// 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); + +/// 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, 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::>() + .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, + }, + )) +} diff --git a/crates/ledger-core/tests/payouts.rs b/crates/ledger-core/tests/payouts.rs new file mode 100644 index 0000000..a00618b --- /dev/null +++ b/crates/ledger-core/tests/payouts.rs @@ -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::>(), + [ + (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"); +} diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index 0d57f36..7d5c08c 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -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, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + let class = |store: &Store, c: &Option| -> Result, 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::, 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::, 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.", diff --git a/crates/ledger-mcp/src/types.rs b/crates/ledger-mcp/src/types.rs index 8bf2c94..2327438 100644 --- a/crates/ledger-mcp/src/types.rs +++ b/crates/ledger-mcp/src/types.rs @@ -223,6 +223,74 @@ pub struct RunRecurringOut { pub drafts: Vec, } +/// 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, +} + +/// 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, +} + +/// 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, + /// Fees withheld (debited). + #[serde(default)] + pub fees: Vec, + /// Everything else on the statement. + #[serde(default)] + pub adjustments: Vec, +} + +/// 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, + /// What to do next. + pub next: String, +} + /// Parameters for `books.chart_template`. #[derive(Debug, Deserialize, JsonSchema)] pub struct ChartTemplateParams { diff --git a/crates/ledger-mcp/tests/mcp_stdio.rs b/crates/ledger-mcp/tests/mcp_stdio.rs index 9f609ef..d477631 100644 --- a/crates/ledger-mcp/tests/mcp_stdio.rs +++ b/crates/ledger-mcp/tests/mcp_stdio.rs @@ -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()) + ] + ); +} diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index 3a0cff9..ea8a62e 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -162,6 +162,20 @@ through the policy. - `books.run_recurring { through? }` → `{ drafts: [{ recurring_id, date, entry_id }] }`: one draft per occurrence on or before `through`, once. +## Tools — payouts + +- `books.record_payout { date, description, deposit_account, deposit, + revenue: [{ account, amount, class? }], fees?: [{ account, amount, + class? }], adjustments?: [{ account, side, amount, class? }] }` → + `{ entry_id, status: "draft", gross, fees, adjustments, deposit, lines, + next }`. Builds the grossed-up entry — Dr deposit (net; Cr when negative), + Dr fees, Dr/Cr adjustments (a debit reduces the deposit: refunds, + chargebacks, reserves held, tax withheld), Cr revenue (gross; revenue or + liability accounts, e.g. deferred revenue or sales tax collected) — and + refuses unless gross − fees ± adjustments equals the deposit to the cent, + naming the difference. A draft: attach the payout statement, preview, + post. Platform-agnostic; format-specific statement parsers can feed it. + ## Tools — receipts & documents - `books.attach_receipt { entry_id?, file_name, mime_type, content_base64 }` diff --git a/docs/parity.md b/docs/parity.md index b6de902..67ba12e 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -42,6 +42,7 @@ No other statuses. | CPA handoff package (TB + GL xlsx, P&L + BS pdf, zip) | Reports → export | done | — | | Budgets | Budgets | todo | R-FUTURE-6 (declined as bloat) | | Scheduled transactions (recurring entries → drafts) | Scheduled txns / Recurring transactions | partial | hand-verified fixture incl. month-end dates; drafts only, by design | +| Payout gross-up (deposit → revenue, fees, adjustments) | Bank deposit with fee lines; Xero/QBO Stripe & Shopify integrations | partial | hand-verified fixture incl. classes and a negative payout; no statement parsers yet | | Prepaid expense + deferred revenue amortization | Amortization schedules (Xero/QBO add-ons) | partial | hand-verified fixture, to the cent; no golden file yet | | Automated GnuCash diff harness | — (our `xtask oracle`) | todo | roadmap phase 3 | diff --git a/docs/requirements.md b/docs/requirements.md index c235647..1da802d 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -140,6 +140,9 @@ requirement conflict, the requirement wins. the journal records each posting with its full entry and replay checks it. Recurring entries generate drafts only, never posts, so the receipt policy always applies. +- **R-? — Payout gross-up.** Platform deposits are recorded gross: + revenue, fees, and adjustments, reconciled to the deposit to the cent, + as a draft that carries the payout statement as its receipt. - **R-? — A/R and A/P workflows.** Was R-FUTURE-5. `ledger-core::arap`: customers, vendors, invoices, bills, payments, AR/AP aging. MCP tools for the full workflow. diff --git a/plan/STATUS.md b/plan/STATUS.md index 5fbbd6e..ed352a9 100644 --- a/plan/STATUS.md +++ b/plan/STATUS.md @@ -235,3 +235,9 @@ interface is MCP, not a cloned GUI. The first storage + MCP slice is done entries generate drafts, never posts. Journal events schedule.registered, schedule.posted (with the full entry, checked on replay), recurring.registered, recurring.generated. No schema change. 59 tools. +- 2026-10-11: Payouts. books.record_payout grosses a platform deposit up + into revenue (by class), fees, and adjustments, refusing unless it + reconciles to the cent; it creates a draft so the payout statement is + attached as the receipt. Pure core logic (payouts.rs), no new journal + events (it journals as a draft and lines). 60 tools. Statement parsers + (Apple, Google, Stripe) wait for sample files.