Files
wanderbooks/crates/ledger-gui/src/demo.rs
T
swaits 13282cf80e feat(arap): terms, numbers and line accounts in the core (P4b S4.1)
Documents could name only one revenue or general-expenses account per
project, had no number of their own, and took a due date the caller had
to work out. This slice gives the core the pieces the rest of S4 needs,
and journals nothing: an old-shape document still writes byte for byte,
so the owner's books need no upgrade for it.

- Payment terms (B-39): Net n, Net n EOM, due on receipt and a custom
  date, with the early-payment discount and its exact bound
  (kernel::discount_max, Kani + bridge). A due date past the calendar's
  end is refused, never a panic, and a discount is refused with terms
  that have no net days.
- Invoice numbers (B-40, A-2): first_number plus the count of numbered
  invoices, zero-padded to four digits and wider past 9,999, stored at
  issue, kept by a void; legacy invoices are uncounted.
- Line accounts (A-10): a document line may name its account, and the
  entry posts one line per (account, project) part. An invoice line takes
  revenue or a liability holding no role (deferred revenue); a bill line
  an expense or a non-cash asset holding no role. Roles, cash accounts
  and headers are refused.
- A line's amount is exactly its quantity times its unit price
  (kernel::line_amount, Kani + bridge): both or neither, never rounded.
- A vendor's invoice number names one standing bill per vendor, with
  case, spaces and hyphens ignored, unless a duplicate reason says why
  (B-40).
- The record fields S4.3 will journal: number, terms, bill-to and seller
  snapshots, customer reference, memo, the empty sends list S5 fills, and
  the bill's received_on, all optional and defaulted.

The store refuses a line carrying an account, quantity or unit price
before it rehearses or journals anything: those fields are journaled from
S4.3's documents_v2 on, and I-3 will not let a public write replay
without them. `document_line_fields_wait_for_documents_v2` proves the
refusal, the unchanged journal and allocators, and that an ordinary line
journals exactly as before. S4.3 removes this guard in the same change
that journals the fields and adds its byte-for-byte test.

The document commands keep their signatures; create_invoice_with and
create_bill_with take the optional fields for S4.3 and for tests. They
are the existing issue commands with more fields, not a new state
transition, so no Lean Step is added. Kani: line_amount_is_exact runs in
`just prove`; discount_max_is_bounded relates a product to a quotient and
runs in `prove-slow` (163 s), as the proration charge harnesses do.

just check green; just prove green.
2026-10-27 00:02:51 -06:00

1157 lines
40 KiB
Rust

//! A demo company, built through the real engine: fifteen months of a
//! small design studio (single-member LLC) with client invoices, template
//! sales paid out by a platform, contractors (one without a W-9), rent on a
//! recurring schedule, a credit card, a laptop on depreciation, prepaid
//! insurance, owner draws, a closed year, PDF receipts, bank statements
//! with reconciliations (one voided and redone), documents linked to the
//! company, an asset, and a party, and a few loose ends for the "needs
//! attention" list. Deterministic for a given `today`.
use chrono::{Datelike, Days, Months, NaiveDate};
use ledger_core::{
AccountType, CloseTo, Currency, Dated1099, Decision1099, Form1099, Frequency, InstrumentKind,
Money, NewAsset, NewRecurring, NewSchedule, ScheduleKind, Side, arap::DocumentLine,
};
use ledger_store::{Statement, Store, StoreError, Subject};
use rust_decimal::Decimal;
use std::path::Path;
/// The demo's settings, written as a `wanderbooks.toml` and imported into
/// its journal, as real books keep theirs.
pub const BOOKS_TOML: &str = r#"[company]
name = "Juniper Studio LLC"
entity_type = "LLC, single-member"
tax_classification = "disregarded entity" # Schedule C
fiscal_year_start = "01-01"
formation_state = "OR"
formed = "2025-06-02"
ein = "00-0000000"
address = "1200 NW Everett St, Portland, OR 97209"
owner = "Alex Rivera"
[books]
accounting_basis = "accrual"
receipt_threshold = "25.00"
close_into = "3000"
close_sweep = ["3100"]
cash_accounts = ["1000", "1010"]
"#;
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
self.0 = self
.0
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
self.0 >> 33
}
/// Uniform in [lo, hi] dollars, to the cent.
fn amount(&mut self, lo: u64, hi: u64) -> Decimal {
let cents = lo * 100 + self.next() % ((hi - lo) * 100 + 1);
Decimal::new(cents as i64, 2)
}
fn pick(&mut self, n: u64) -> u64 {
self.next() % n
}
}
fn usd(d: Decimal) -> Money {
Money::new(d, Currency::USD)
}
fn dec(n: i64) -> Decimal {
Decimal::from(n)
}
fn day(month_start: NaiveDate, d: u32) -> NaiveDate {
let last = (month_start + Months::new(1) - Days::new(1)).day();
NaiveDate::from_ymd_opt(month_start.year(), month_start.month(), d.min(last)).expect("valid")
}
/// A one-page PDF receipt with the given lines (Helvetica).
pub fn receipt_pdf(title: &str, lines: &[String]) -> Vec<u8> {
let esc = |s: &str| {
s.replace('\\', "\\\\")
.replace('(', "\\(")
.replace(')', "\\)")
};
let mut content = String::new();
content.push_str("BT /F2 22 Tf 56 760 Td (");
content.push_str(&esc(title));
content.push_str(") Tj ET\n");
content.push_str("0.85 0.85 0.85 RG 56 742 m 556 742 l S\n");
let mut y = 712;
for l in lines {
let (font, size) = if l.starts_with("TOTAL") {
("F2", 13)
} else {
("F1", 11)
};
content.push_str(&format!(
"BT /{font} {size} Tf 56 {y} Td ({}) Tj ET\n",
esc(l)
));
y -= 20;
}
let objects = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R /F2 5 0 R >> >> /Contents 6 0 R >>".to_string(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>".to_string(),
format!("<< /Length {} >>\nstream\n{content}endstream", content.len()),
];
let mut pdf = b"%PDF-1.4\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objects.iter().enumerate() {
offsets.push(pdf.len());
pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = pdf.len();
pdf.extend_from_slice(
format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes(),
);
for off in offsets {
pdf.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
}
pdf.extend_from_slice(
format!(
"trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n",
objects.len() + 1
)
.as_bytes(),
);
pdf
}
fn receipt(vendor: &str, date: NaiveDate, item: &str, amount: Decimal, paid: &str) -> Vec<u8> {
receipt_pdf(
vendor,
&[
format!("Receipt {}", date.format("%B %-d, %Y")),
format!(
"Order {:08}",
(date.num_days_from_ce() as u64 * 7919
+ (amount * dec(100)).try_into().unwrap_or(0u64))
% 100_000_000
),
String::new(),
item.to_string(),
format!("Amount: ${amount}"),
String::new(),
format!("TOTAL ${amount}"),
format!("Paid with {paid}"),
String::new(),
"Thank you for your business.".into(),
],
)
}
/// Build the demo books in `dir` (which must not already hold books): the
/// journal and blobs under `dir/books-data`, its settings in the journal.
pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> {
std::fs::create_dir_all(dir)?;
let mut s = Store::create(&dir.join("books-data"), Currency::USD)?;
let import = ledger_config::codec::import(BOOKS_TOML, None, s.ledger().settings(), "USD")
.map_err(|e| StoreError::Io(std::io::Error::other(e)))?;
s.adopt_settings(
&import.changes,
&ledger_core::settings::Source {
decided_by: "the demo".into(),
decided_on: NaiveDate::from_ymd_opt(2025, 6, 2).expect("a date"),
citation: None,
note: None,
},
)?;
let mut rng = Rng(0x5eed_1234);
let start =
NaiveDate::from_ymd_opt(today.year(), today.month(), 1).expect("valid") - Months::new(15);
let this_month = NaiveDate::from_ymd_opt(today.year(), today.month(), 1).expect("valid");
let acct =
|s: &mut Store, code: &str, name: &str, t, tax: Option<&str>| -> Result<u64, StoreError> {
let id = s.add_account(code, name, t, None, false)?;
if let Some(c) = tax {
s.set_account_tax(id, Some(c), None)?;
}
Ok(id)
};
use AccountType::*;
let checking = acct(&mut s, "1000", "Operating Checking", Asset, None)?;
let savings = acct(&mut s, "1010", "Savings", Asset, None)?;
let prepaid = acct(&mut s, "1300", "Prepaid Insurance", Asset, None)?;
let equipment = acct(&mut s, "1500", "Computer Equipment", Asset, None)?;
let accum = acct(&mut s, "1510", "Accumulated Depreciation", Asset, None)?;
let card = acct(&mut s, "2100", "Business Credit Card", Liability, None)?;
let capital = acct(&mut s, "3000", "Owner's Capital", Equity, None)?;
let draws = acct(&mut s, "3100", "Owner's Draws", Equity, None)?;
let _services = acct(
&mut s,
"4000",
"Design Services",
Revenue,
Some("gross_receipts"),
)?;
let templates = acct(
&mut s,
"4100",
"Template Sales",
Revenue,
Some("gross_receipts"),
)?;
let fees = acct(
&mut s,
"6000",
"Platform Fees",
Expense,
Some("commissions_fees"),
)?;
let contract = acct(
&mut s,
"6050",
"Contract Labor",
Expense,
Some("contract_labor"),
)?;
let software = acct(
&mut s,
"6100",
"Software & Subscriptions",
Expense,
Some("office_expense"),
)?;
let rent = acct(
&mut s,
"6200",
"Studio Rent",
Expense,
Some("rent_property"),
)?;
let insurance = acct(&mut s, "6300", "Insurance", Expense, Some("insurance"))?;
let advertising = acct(&mut s, "6400", "Advertising", Expense, Some("advertising"))?;
let meals = acct(&mut s, "6500", "Meals", Expense, Some("meals"))?;
let travel = acct(&mut s, "6600", "Travel", Expense, Some("travel"))?;
let supplies = acct(&mut s, "6700", "Office Supplies", Expense, Some("supplies"))?;
let depreciation = acct(
&mut s,
"6800",
"Depreciation",
Expense,
Some("depreciation"),
)?;
let bank_fees = acct(&mut s, "6900", "Bank Fees", Expense, None)?;
// The projects (classes with their facts, rules.md B-37).
let client_class = s.add_project(
"Client work",
&ledger_core::ProjectFacts {
// Its invoices' lines and the contractors it pays are tagged
// to it (rules.md B-37 item 8), so its snapshot is its
// revenue less its direct costs.
description: Some(
"Design work for the studio's clients: every client invoice, and the contractors who work on it."
.into(),
),
kind: Some(ledger_core::ProjectKind::Client),
..Default::default()
},
)?;
let template_class = s.add_project(
"Templates",
&ledger_core::ProjectFacts {
description: Some("Notion and Figma templates sold on Gumroad".into()),
kind: Some(ledger_core::ProjectKind::Internal),
started_on: Some(start),
links: vec!["https://juniper.example/templates".into()],
..Default::default()
},
)?;
// Each vendor's 1099 decision, as the owner made it when they were
// first paid; the card is a payment method, so what was charged to it
// shows on the 1099 worksheet as excluded (Form 1099-K's).
let decided = |form| {
Dated1099::from_beginning(
Decision1099::Files(form),
Some(ledger_core::settings::Source {
decided_by: "Alex Rivera".into(),
decided_on: start,
citation: None,
note: None,
}),
)
};
let sam = s.add_vendor("Sam Okafor")?;
s.set_vendor_1099(sam, &decided(Form1099::Nec), Some(true))?;
let riley = s.add_vendor("Riley Chen")?;
s.set_vendor_1099(riley, &decided(Form1099::Nec), Some(false))?;
let landlord = s.add_vendor("Everett Lofts LLC")?;
s.set_vendor_1099(landlord, &decided(Form1099::Misc), Some(true))?;
s.add_instrument(
InstrumentKind::BusinessCard,
"Business credit card",
Some("4242"),
None,
card,
)?;
let customers: Vec<u64> = [
"Fern & Field Coffee",
"Bramble Outdoor Co.",
"Northwind Clinics",
"Tidepool Records",
"Larkspur Bakery",
]
.iter()
.map(|n| s.add_customer(n))
.collect::<Result<_, _>>()?;
// Work on spec for a customer, hoping they adopt it: its costs are
// expenses, and nothing is receivable (rules.md B-37 item 4).
let tidepool_started = start + Months::new(13);
let tidepool = s.add_project(
"Tidepool app",
&ledger_core::ProjectFacts {
description: Some(
"An events app built for Tidepool Records ahead of a contract".into(),
),
kind: Some(ledger_core::ProjectKind::OnSpec),
party: s.ledger().party_of_customer(customers[3]),
started_on: Some(tidepool_started),
..Default::default()
},
)?;
// The party directory: who they are, beyond the vendor and customer
// records (roles, W-9 facts with only the TIN's last four, sites).
{
use ledger_core::{PartyField, Role, W9Facts};
let party_of = |s: &Store, v: u64| s.ledger().party_of_vendor(v).expect("linked");
let sam_p = party_of(&s, sam);
s.set_party_role(sam_p, Role::Contractor, true)?;
s.set_party_w9(
sam_p,
Some(W9Facts {
received: start + Days::new(20),
entity_type: "Individual/sole proprietor".into(),
exempt_payee_code: None,
fatca_code: None,
tin_last4: "4821".into(),
kept_at: "wanderbooks vault".into(),
vault_record: None,
}),
)?;
s.update_party(
sam_p,
PartyField::Notes(Some(
"Illustrator for client projects; invoices after each delivery.".into(),
)),
)?;
let riley_p = party_of(&s, riley);
s.set_party_role(riley_p, Role::Contractor, true)?;
s.update_party(riley_p, PartyField::Website(Some("rileychen.dev".into())))?;
let lofts = party_of(&s, landlord);
s.set_party_role(lofts, Role::Landlord, true)?;
s.set_party_w9(
lofts,
Some(W9Facts {
received: start + Days::new(3),
entity_type: "LLC taxed as a partnership".into(),
exempt_payee_code: None,
fatca_code: None,
tin_last4: "3307".into(),
kept_at: "CPA portal".into(),
vault_record: None,
}),
)?;
s.update_party(
lofts,
PartyField::Address(Some("1200 NW Everett St, Portland, OR 97209".into())),
)?;
s.update_party(lofts, PartyField::DisplayName(Some("Everett Lofts".into())))?;
if let Some(fern) = s.ledger().party_of_customer(customers[0]) {
s.update_party(
fern,
PartyField::Website(Some("fernandfield.coffee".into())),
)?;
s.update_party(
fern,
PartyField::Address(Some("88 SE Division St, Portland, OR 97202".into())),
)?;
}
let irs = s.add_party("Internal Revenue Service", &[Role::TaxAgency])?;
s.update_party(irs, PartyField::DisplayName(Some("IRS".into())))?;
s.update_party(irs, PartyField::Website(Some("irs.gov".into())))?;
let odr = s.add_party("Oregon Department of Revenue", &[Role::TaxAgency])?;
s.update_party(odr, PartyField::Website(Some("oregon.gov/dor".into())))?;
let old = s.add_party("Etsy, Inc.", &[Role::PayoutPlatform])?;
s.update_party(
old,
PartyField::Notes(Some("Sold templates here before moving to Gumroad.".into())),
)?;
s.update_party(old, PartyField::Archived(true))?;
}
let gumroad = s.add_party("Gumroad, Inc.", &[ledger_core::Role::PayoutPlatform])?;
s.update_party(
gumroad,
ledger_core::PartyField::DisplayName(Some("Gumroad".into())),
)?;
s.update_party(
gumroad,
ledger_core::PartyField::Website(Some("gumroad.com".into())),
)?;
let chase = s.add_party(
"JPMorgan Chase Bank, N.A.",
&[ledger_core::Role::FinancialInstitution],
)?;
s.update_party(
chase,
ledger_core::PartyField::DisplayName(Some("Chase".into())),
)?;
let alex = s.add_party("Alex Rivera", &[ledger_core::Role::Member])?;
// Formation documents.
let (articles, _, _) = s.file_document(
"formation",
"Articles of Organization.pdf",
"application/pdf",
&receipt_pdf(
"Articles of Organization",
&[
"Juniper Studio LLC".into(),
"Oregon Secretary of State".into(),
format!("Filed {}", start.format("%B %-d, %Y")),
],
),
Some("Filed with the state"),
)?;
let (ein_letter, _, _) = s.file_document(
"tax",
"IRS EIN confirmation.pdf",
"application/pdf",
&receipt_pdf(
"EIN Assignment",
&[
"Juniper Studio LLC".into(),
"Employer Identification Number assigned".into(),
],
),
Some("CP 575"),
)?;
// Helper: post a two-line entry with an optional receipt and vendor.
#[allow(clippy::too_many_arguments)]
fn post(
s: &mut Store,
date: NaiveDate,
desc: &str,
dr: u64,
cr: u64,
amount: Decimal,
receipt: Option<(&str, Vec<u8>)>,
vendor: Option<u64>,
class: Option<u32>,
) -> Result<u64, StoreError> {
let e = s.create_entry(date, desc)?;
s.add_line(e, dr, Side::Debit, usd(amount))?;
s.add_line(e, cr, Side::Credit, usd(amount))?;
if let Some(c) = class {
s.set_line_class(e, 0, Some(c))?;
s.set_line_class(e, 1, Some(c))?;
}
if let Some((name, bytes)) = receipt {
s.attach_receipt(Some(e), name, "application/pdf", &bytes, None)?;
}
s.post_entry(e)?;
if vendor.is_some() {
s.set_entry_vendor(e, vendor)?;
}
Ok(e)
}
post(
&mut s,
day(start, 2),
"Owner contribution — startup capital",
checking,
capital,
dec(15000),
Some((
"bank-transfer.pdf",
receipt(
"Transfer confirmation",
day(start, 2),
"Transfer from Alex Rivera personal checking",
dec(15000),
"ACH",
),
)),
None,
None,
)?;
// Rent: a recurring entry, posted every month with its invoice; this
// month's is left as a draft.
s.register_recurring(NewRecurring {
description: "Studio rent — Everett Lofts".into(),
lines: vec![
(rent, Side::Debit, usd(dec(1800))),
(checking, Side::Credit, usd(dec(1800))),
],
frequency: Frequency::Monthly,
start: day(start, 1),
end: None,
})?;
// Laptop and its depreciation.
let laptop_date = day(start + Months::new(1), 9);
let laptop = dec(3199);
post(
&mut s,
laptop_date,
"MacBook Pro 16\" — studio workstation",
equipment,
card,
laptop,
Some((
"apple-store.pdf",
receipt(
"Apple Store",
laptop_date,
"MacBook Pro 16-inch, M-series",
laptop,
"Visa ending 4242",
),
)),
None,
None,
)?;
let asset = s.register_asset(NewAsset {
description: "MacBook Pro 16\"".into(),
in_service_date: laptop_date,
cost: usd(laptop),
salvage_value: usd(dec(400)),
useful_life_months: 36,
cost_account: equipment,
accum_depr_account: accum,
expense_account: depreciation,
})?;
// The label on the laptop, and its serial number.
s.set_asset_tag(asset, "JS-0001")?;
s.set_asset_serial(asset, Some("C02FK3XJQ6L4"))?;
// Bought by the company, so its federal tax basis is its cost.
s.set_asset_basis(asset, ledger_core::AssetBasis::purchase())?;
// Prepaid insurance, spread over a year.
let ins_date = day(start + Months::new(2), 15);
post(
&mut s,
ins_date,
"General liability insurance — annual premium",
prepaid,
checking,
dec(1440),
Some((
"hiscox-policy.pdf",
receipt(
"Hiscox",
ins_date,
"General liability, 12 months",
dec(1440),
"ACH",
),
)),
None,
None,
)?;
s.register_schedule(NewSchedule {
description: "General liability insurance".into(),
kind: ScheduleKind::PrepaidExpense,
total: usd(dec(1440)),
start: ins_date,
months: 12,
balance_account: prepaid,
pl_account: insurance,
// A new schedule spreads by the day (D-11, D-37): a small first
// and last month around eleven full ones.
convention: ledger_core::ScheduleConvention::Daily,
})?;
let mut open_invoices: Vec<(u64, NaiveDate, Decimal)> = Vec::new();
let mut card_balance = Decimal::ZERO;
let mut m = start;
let mut month_index = 0;
while m <= this_month {
let current = m == this_month;
let in_range = |d: NaiveDate| d <= today;
// Software on the card.
for (vendor, item, lo, hi, d) in [
("Figma", "Figma Professional — monthly", 45, 45, 3),
("Adobe", "Creative Cloud All Apps", 59, 60, 7),
("Google Workspace", "Business Starter — 2 users", 28, 28, 11),
] {
let date = day(m, d);
if !in_range(date) {
continue;
}
let amt = rng.amount(lo, hi);
post(
&mut s,
date,
&format!("{vendor} — {item}"),
software,
card,
amt,
Some((
&format!("{}-{}.pdf", vendor.to_lowercase().replace(' ', "-"), date),
receipt(vendor, date, item, amt, "Visa ending 4242"),
)),
None,
None,
)?;
card_balance += amt;
}
// Advertising some months.
if month_index % 3 == 1 {
let date = day(m, 14);
if in_range(date) {
let amt = rng.amount(150, 420);
post(
&mut s,
date,
"Instagram ads — portfolio campaign",
advertising,
card,
amt,
Some((
"meta-ads.pdf",
receipt("Meta Ads", date, "Sponsored posts", amt, "Visa ending 4242"),
)),
None,
None,
)?;
card_balance += amt;
}
}
// Meals and supplies.
let date = day(m, 17);
if in_range(date) {
let amt = rng.amount(28, 96);
let missing = current || month_index == 13;
post(
&mut s,
date,
"Client lunch — project kickoff",
meals,
card,
amt,
(!missing).then(|| {
(
"lunch.pdf",
receipt(
"Tasty n Alder",
date,
"Lunch for 2",
amt,
"Visa ending 4242",
),
)
}),
None,
None,
)?;
card_balance += amt;
}
if month_index % 4 == 2 {
let date = day(m, 21);
if in_range(date) {
let amt = rng.amount(40, 180);
post(
&mut s,
date,
"Paper, ink, and sketchbooks",
supplies,
card,
amt,
Some((
"blick.pdf",
receipt(
"Blick Art Materials",
date,
"Supplies",
amt,
"Visa ending 4242",
),
)),
None,
None,
)?;
card_balance += amt;
}
}
if month_index == 7 {
let date = day(m, 9);
if in_range(date) {
let amt = dec(1186) + rng.amount(0, 90);
post(
&mut s,
date,
"Flights and hotel — client workshop, Austin",
travel,
card,
amt,
Some((
"alaska-air.pdf",
receipt(
"Alaska Airlines",
date,
"PDX-AUS round trip + 2 nights",
amt,
"Visa ending 4242",
),
)),
None,
None,
)?;
card_balance += amt;
}
}
// Pay the card in full on the 25th.
let date = day(m, 25);
if in_range(date) && card_balance > Decimal::ZERO {
let paid = post(
&mut s,
date,
"Credit card payment",
card,
checking,
card_balance,
Some((
"card-statement.pdf",
receipt(
"Chase Ink",
date,
"Statement balance payment",
card_balance,
"ACH from Operating Checking",
),
)),
None,
None,
)?;
s.set_entry_party(paid, Some(chase))?;
card_balance = Decimal::ZERO;
}
// Client invoices: two or three a month, net 30.
for k in 0..(2 + rng.pick(2)) {
let date = day(m, 5 + 9 * k as u32);
if !in_range(date) {
continue;
}
let cust = customers[rng.pick(customers.len() as u64) as usize];
let amt = (rng.amount(1800, 7800) / dec(50)).round() * dec(50);
let inv = s.create_invoice(
cust,
date,
date + Days::new(30),
vec![DocumentLine {
description: [
"Brand identity",
"Website design",
"Packaging",
"Illustration set",
"Design retainer",
][rng.pick(5) as usize]
.into(),
amount: amt,
class_id: Some(client_class),
account: None,
quantity: None,
unit_price: None,
}],
)?;
open_invoices.push((inv, date, amt));
}
// Collect invoices 20-50 days out; leave the latest ones open and one overdue.
let mut still_open = Vec::new();
for (inv, date, amt) in open_invoices.drain(..) {
let paid_on = (date + Days::new(20 + rng.pick(30))).max(day(m, 2));
let overdue_one = month_index == 12 && inv % 2 == 0;
if paid_on <= today && paid_on < m + Months::new(1) && !overdue_one {
s.receive_payment(inv, paid_on, amt, checking)?;
} else {
still_open.push((inv, date, amt));
}
}
open_invoices = still_open;
// Template sales, paid out by the platform on the 1st for last month.
if month_index > 1 {
let date = day(m, 1);
if in_range(date) {
let gross = rng.amount(450, 1600);
let fee = (gross * Decimal::new(10, 2)).round_dp(2) + Decimal::new(30, 2);
let net = gross - fee;
let e = s.create_entry(date, "Gumroad payout — template sales")?;
s.add_line(e, checking, Side::Debit, usd(net))?;
s.add_line(e, fees, Side::Debit, usd(fee))?;
s.add_line(e, templates, Side::Credit, usd(gross))?;
for i in 0..3 {
s.set_line_class(e, i, Some(template_class))?;
}
s.attach_receipt(
Some(e),
"gumroad-payout.pdf",
"application/pdf",
&receipt_pdf(
"Gumroad payout",
&[
format!("Payout {}", date.format("%B %-d, %Y")),
format!("Gross sales: ${gross}"),
format!("Fees: ${fee}"),
format!("TOTAL ${net}"),
"Deposited to checking ending 0187".into(),
],
),
None,
)?;
s.post_entry(e)?;
s.set_entry_party(e, Some(gumroad))?;
}
}
// Contractors.
if month_index % 2 == 0 && month_index > 0 {
let date = day(m, 28);
if in_range(date) {
let amt = (rng.amount(900, 2200) / dec(25)).round() * dec(25);
post(
&mut s,
date,
"Sam Okafor — illustration (invoice)",
contract,
checking,
amt,
Some((
"sam-okafor-invoice.pdf",
receipt(
"Sam Okafor Illustration",
date,
"Illustration for client projects",
amt,
"ACH",
),
)),
Some(sam),
Some(client_class),
)?;
}
}
if month_index == 11 || month_index == 13 {
let date = day(m, 19);
if in_range(date) {
post(
&mut s,
date,
"Riley Chen — front-end development",
contract,
checking,
dec(1400),
Some((
"riley-chen.pdf",
receipt(
"Riley Chen",
date,
"Website build, milestone",
dec(1400),
"ACH",
),
)),
Some(riley),
// The second milestone builds the Tidepool app.
Some(if month_index == 13 {
tidepool
} else {
client_class
}),
)?;
}
}
// Owner draws and savings.
if month_index >= 3 {
let date = day(m, 28);
if in_range(date) {
let draw = post(
&mut s,
date,
"Owner draw",
draws,
checking,
dec(3500),
None,
None,
None,
)?;
s.set_entry_party(draw, Some(alex))?;
}
}
if month_index % 3 == 2 {
let date = day(m, 26);
if in_range(date) {
post(
&mut s,
date,
"Transfer to savings",
savings,
checking,
dec(4000),
None,
None,
None,
)?;
}
}
let date = day(m, 30);
if in_range(date) && month_index % 5 == 4 {
post(
&mut s,
date,
"Wire fee",
bank_fees,
checking,
dec(30),
None,
None,
None,
)?;
}
// Month-end: rent occurrences, depreciation, amortization.
let month_end = m + Months::new(1) - Days::new(1);
let through = if current { today } else { month_end };
for (_, date, entry) in s.run_recurring(through)? {
if current {
continue; // this month's rent waits as a draft
}
s.attach_receipt(
Some(entry),
&format!("rent-{date}.pdf"),
"application/pdf",
&receipt(
"Everett Lofts LLC",
date,
"Studio 4B rent",
dec(1800),
"ACH",
),
None,
)?;
s.post_entry(entry)?;
s.set_entry_vendor(entry, Some(landlord))?;
}
if !current {
if m >= laptop_date.with_day(1).expect("valid") {
s.run_depreciation(asset, &m.format("%Y-%m").to_string())?;
}
s.run_schedules(month_end)?;
}
// Close each finished calendar year into capital, draws swept in.
if m.month() == 12 && !current {
let ys = NaiveDate::from_ymd_opt(m.year(), 1, 1).expect("valid");
let p = s.create_period(&m.year().to_string(), ys.max(start), month_end)?;
s.close_period_to(
p,
&CloseTo::Account {
into: capital,
sweep: vec![draws],
},
)?;
}
m = m + Months::new(1);
month_index += 1;
}
let _ = (bank_fees, client_class);
// The company's papers, linked to it; the laptop's warranty and
// photo, linked to the asset; Sam's contract, linked to Sam and the
// company.
s.link_attachment(articles, Subject::Company)?;
s.link_attachment(ein_letter, Subject::Company)?;
let (warranty, _, _) = s.file_document(
"warranty",
"MacBook Pro AppleCare.pdf",
"application/pdf",
&receipt_pdf(
"AppleCare+ for Mac",
&[
"Agreement for MacBook Pro 14-inch".into(),
format!("Coverage from {}", laptop_date.format("%B %-d, %Y")),
"Three years of hardware coverage".into(),
],
),
Some("Warranty, three years"),
)?;
s.link_attachment(warranty, Subject::Asset(asset))?;
let sam_party = s.ledger().party_of_vendor(sam).expect("linked");
let (contract_doc, _, _) = s.file_document(
"contract",
"Sam Okafor services agreement.pdf",
"application/pdf",
&receipt_pdf(
"Independent Contractor Agreement",
&[
"Juniper Studio LLC and Sam Okafor".into(),
"Illustration services, billed per project".into(),
format!("Signed {}", (start + Months::new(1)).format("%B %-d, %Y")),
],
),
Some("Signed agreement"),
)?;
s.link_attachment(contract_doc, Subject::Party(sam_party))?;
s.link_attachment(contract_doc, Subject::Company)?;
// The Tidepool app's pitch, linked to it, and the decision to build it
// on spec.
let (pitch, _, _) = s.file_document(
"other",
"Tidepool app pitch.pdf",
"application/pdf",
&receipt_pdf(
"Tidepool app pitch",
&[
"Juniper Studio LLC for Tidepool Records".into(),
"An events app: shows, tickets, a mailing list".into(),
format!("Sent {}", tidepool_started.format("%B %-d, %Y")),
],
),
Some("The pitch"),
)?;
s.link_attachment(pitch, Subject::Project(u64::from(tidepool)))?;
s.record_decision(&ledger_core::conventions::NewDecision {
subject: "The Tidepool app".into(),
decision_md: "Build the Tidepool app on spec; bill only if they adopt it.".into(),
decided_by: vec!["Alex Rivera".into()],
decided_on: tidepool_started,
evidence_attachment: Some(pitch),
supersedes: None,
citation: None,
projects: vec![tidepool],
})?;
// Statements and reconciliations: savings reconciled through last
// month; checking through the month before (its first reconciliation
// voided and redone against the bank's reissued statement, one check
// outstanding at the first statement); the card never.
let month_end = |m: NaiveDate| m + Months::new(1) - Days::new(1);
let lines_on = |s: &Store, account: u64, through: NaiveDate| -> Vec<(u64, usize, NaiveDate)> {
let mut out = Vec::new();
for e in s.ledger().posted_entries() {
if e.date() <= through {
for (i, l) in e.lines().iter().enumerate() {
if l.account == account && !s.ledger().is_cleared(e.id(), i) {
out.push((e.id(), i, e.date()));
}
}
}
}
out.sort_by_key(|x| (x.2, x.0, x.1));
out
};
let statement = |s: &mut Store,
account: u64,
name: &str,
month: NaiveDate,
note: &str,
opening: Option<Decimal>|
-> Result<u64, StoreError> {
let end = month_end(month);
// The bank's opening is its last closing: what had cleared by then.
let opening = match opening {
Some(o) => o,
None => {
s.ledger()
.reconciliation_report(account, month - Days::new(1), Decimal::ZERO)?
.cleared_balance
}
};
let closing = s
.ledger()
.reconciliation_report(account, end, Decimal::ZERO)?
.cleared_balance;
let (att, _, _) = s.file_document(
"statement",
&format!(
"{name} statement {}{}.pdf",
month.format("%Y-%m"),
if note.is_empty() {
String::new()
} else {
format!(" ({})", note.to_lowercase())
}
),
"application/pdf",
&receipt_pdf(
&format!("{name} statement"),
&[
format!(
"{} through {}",
month.format("%B %-d, %Y"),
end.format("%B %-d, %Y")
),
format!("Opening balance: ${opening}"),
format!("Closing balance: ${closing}"),
note.to_string(),
],
),
Some(&format!("{name}, {}", month.format("%B %Y"))),
)?;
s.record_statement(Statement {
attachment_id: att,
account_id: account,
period_start: month,
period_end: end,
opening_balance: opening,
closing_balance: closing,
})?;
Ok(att)
};
let last_month = this_month - Months::new(1);
// Savings: every line through last month cleared.
for (e, i, _) in lines_on(&s, savings, month_end(last_month)) {
s.mark_cleared(e, i, true)?;
}
let st = statement(&mut s, savings, "Savings", last_month, "", None)?;
s.complete_reconciliation(st)?;
// Checking, three months back: the month's last check still out.
let m1 = this_month - Months::new(3);
let pending = lines_on(&s, checking, month_end(m1));
let outstanding = pending.last().map(|x| (x.0, x.1));
for (e, i, _) in pending {
if Some((e, i)) != outstanding {
s.mark_cleared(e, i, true)?;
}
}
let first = statement(&mut s, checking, "Operating Checking", m1, "", None)?;
let voided = s.complete_reconciliation(first)?;
s.void_reconciliation(
voided.id,
"The bank reissued this statement; reconciled again against the corrected copy",
)?;
let reissued = statement(&mut s, checking, "Operating Checking", m1, "Reissued", None)?;
let m1_closing = s
.ledger()
.reconciliation_report(checking, month_end(m1), Decimal::ZERO)?
.cleared_balance;
s.complete_reconciliation(reissued)?;
// Two months back: everything through it cleared.
let m2 = this_month - Months::new(2);
for (e, i, _) in lines_on(&s, checking, month_end(m2)) {
s.mark_cleared(e, i, true)?;
}
let second = statement(
&mut s,
checking,
"Operating Checking",
m2,
"",
Some(m1_closing),
)?;
s.complete_reconciliation(second)?;
Ok(())
}