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
@@ -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}")]
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
))
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
Reference in new issue
Block a user