P3: the IIF in plain ASCII with every change listed, and the CPA package as of one day (defects 7, 21)

The CPA package's general-journal.iif now holds every posted entry through
as_of (closing entries left out, D-36), written as plain ASCII with CR LF,
DOCNUM the entry id, QuickBooks Desktop's field limits and subaccount types
honoured, and every character written differently listed in the export's
warnings and the README (grouped by substitution, TINs masked). The 1099
worksheet stops at as_of too. PDFs print CJK, Arabic, Hebrew, Thai and
Devanagari (Noto fallbacks, OFL), and so does the viewer. has_full_tin reads
Unicode separators.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-21 09:35:32 -06:00
1 parent 1e8f9275b9
commit 2c29d48ab9
48 files changed
+5661 -317

No files matched your search

+16 -10
View File
@@ -171,7 +171,7 @@ pub fn vendor_1099_report_with(
threshold: Option<Decimal>,
sources: &PaymentAccounts,
) -> Result<Vendor1099Report, LedgerError> {
vendor_1099_report_with_members(ledger, year, threshold, sources, &[])
vendor_1099_report_with_members(ledger, year, None, threshold, sources, &[])
}
/// What an entry paid out of `sources`: credits minus debits to them.
@@ -211,15 +211,16 @@ fn vendor_part(payment: Decimal, share: Decimal, target: Decimal) -> Result<Deci
}
}
/// [`vendor_1099_report`], counting payments out of `sources`, each
/// vendor's part of an entry with several parties resolved with `members`
/// (the company's members, from the settings).
/// [`vendor_1099_report`], counting payments out of `sources` made in
/// `year` (through `through`, when given), each vendor's part of an entry
/// with several parties resolved with `members` (the company's members,
/// from the settings).
///
/// ```text
/// SOURCE: docs/spec/rules.md B-19 (26 U.S.C. §6041(a): payments made in
/// the year; card payments go on 1099-K); crate::shares (an entry
/// with several parties, each with their share; DECISION, owner,
/// 2026-10-15).
/// the year; card payments go on 1099-K), B-33 (a worksheet as of
/// a day); crate::shares (an entry with several parties, each with
/// their share; DECISION, owner, 2026-10-15).
/// DEFINITIONS:
/// payment(e): credits minus debits to `sources` on entry e.
/// money: crate::parties::money_accounts(ledger, sources.cash), what the
@@ -227,8 +228,12 @@ fn vendor_part(payment: Decimal, share: Decimal, target: Decimal) -> Result<Deci
/// target(e): crate::shares::share_target(e, money): the entry_flow net,
/// else the total debits.
/// INVARIANTS:
/// 1. Only posted entries dated in `year` and tagged with a vendor
/// (Ledger::entry_vendor) count, toward that vendor.
/// 1. Only posted entries dated in `year` (and, given `through`, on or
/// before it) and tagged with a vendor (Ledger::entry_vendor) count,
/// toward that vendor. `through` makes a worksheet as of a day: the
/// payments from January 1 through it (§6041(a) and §6041A(a)(2)
/// count what is paid "in any calendar year"; a CPA package as of
/// June 30 shows the year to date, rules.md B-33).
/// 2. An entry naming at most one party (Ledger::entry_parties): the
/// vendor's amount is payment(e), exactly as before shares.
/// 3. An entry naming several parties (one of them the vendor's party,
@@ -263,6 +268,7 @@ fn vendor_part(payment: Decimal, share: Decimal, target: Decimal) -> Result<Deci
pub fn vendor_1099_report_with_members(
ledger: &Ledger,
year: i32,
through: Option<chrono::NaiveDate>,
threshold: Option<Decimal>,
sources: &PaymentAccounts,
members: &[crate::PartyMember],
@@ -275,7 +281,7 @@ pub fn vendor_1099_report_with_members(
let mut paid: std::collections::HashMap<VendorId, Decimal> = Default::default();
let mut unresolved = Vec::new();
for entry in ledger.posted_entries() {
if entry.date().year() != year {
if entry.date().year() != year || through.is_some_and(|t| entry.date() > t) {
continue;
}
let Some(vendor) = ledger.entry_vendor(entry.id()) else {
+106 -27
View File
@@ -13,8 +13,10 @@
//! 2026-10-15); IRS truncated TINs: Treas. Reg. §301.6109-4.
//! INVARIANTS:
//! 1. No party field holds a full taxpayer identification number: any
//! run of nine digits (single spaces or hyphens between them allowed)
//! is refused. A W-9's TIN is kept as its last four digits only.
//! run of nine digits, in any script, separated or not (`has_full_tin`:
//! up to three spaces, hyphens, dots, slashes or underscores, in any
//! of their Unicode forms, between neighbors) is refused. A W-9's TIN
//! is kept as its last four digits only.
//! 2. W-9 facts need a 1099 role (contractor or landlord); W-4 facts need
//! the employee role. Taxpayer-ID facts (`TinFacts`: the TIN's type,
//! last four, and vault record) are allowed for any role: members
@@ -272,33 +274,100 @@ pub fn name_key(name: &str) -> String {
name.trim().to_lowercase()
}
/// The characters that may sit between a TIN's digits: space, hyphen,
/// dot, slash, underscore, and the Unicode dashes and no-break space
/// (`291-12-2846`, `291.12.2846`, `291/12/2846`, `291 - 12 - 2846`).
/// The characters that may sit between a TIN's digits (`291-12-2846`,
/// `291.12.2846`, `291/12/2846`, `291 - 12 - 2846`): in ASCII a space,
/// hyphen, dot, slash or underscore (no other, not a tab or a line
/// break); past ASCII, every form of them Unicode has, so a TIN can't slip
/// through typed in another script (`docs/spec/sources/unicode-tin-text.md`,
/// Unicode 17.0): every space (general category Zs: the no-break, thin and
/// ideographic spaces), every dash (property Dash: the hyphens, en and em
/// dashes, minus sign, Hebrew maqaf, fullwidth and small hyphen-minus),
/// and the characters whose compatibility form is a dot, slash or
/// underscore (the fullwidth and small full stops, the one dot leader,
/// the fullwidth solidus, the fullwidth and presentation low lines).
fn tin_separator(c: char) -> bool {
match c {
' ' | '-' | '.' | '/' | '_' => true,
c if c.is_ascii() => false,
// White_Space less the line breaks (NEL, LS, PS): the Zs spaces.
c if c.is_whitespace() => !matches!(c, '\u{85}' | '\u{2028}' | '\u{2029}'),
c => matches!(
c,
// Dash (PropList.txt), less U+002D above.
'\u{58A}'
| '\u{5BE}'
| '\u{1400}'
| '\u{1806}'
| '\u{2010}'..='\u{2015}'
| '\u{2053}'
| '\u{207B}'
| '\u{208B}'
| '\u{2212}'
| '\u{2E17}'
| '\u{2E1A}'
| '\u{2E3A}'..='\u{2E3B}'
| '\u{2E40}'
| '\u{2E5D}'
| '\u{301C}'
| '\u{3030}'
| '\u{30A0}'
| '\u{FE31}'..='\u{FE32}'
| '\u{FE58}'
| '\u{FE63}'
| '\u{FF0D}'
| '\u{10D6E}'
| '\u{10EAD}'
// NFKC '.', '/', '_' (UnicodeData.txt decompositions).
| '\u{2024}'
| '\u{FE52}'
| '\u{FF0E}'
| '\u{FF0F}'
| '\u{FE33}'..='\u{FE34}'
| '\u{FE4D}'..='\u{FE4F}'
| '\u{FF3F}'
),
}
}
/// Whether `c` is invisible, so a TIN's digits read as neighbors across it
/// however many there are (`1\u{200B}2` shows as `12`): Unicode's
/// Default_Ignorable_Code_Point (DerivedCoreProperties.txt, 17.0): the
/// soft hyphen, the zero-width space and joiners, the left-to-right and
/// right-to-left marks and the other directional formatting characters,
/// the word joiner, the byte-order mark, the variation selectors, the tags
/// and the fillers. None is ASCII.
fn tin_invisible(c: char) -> bool {
matches!(
c,
' ' | '-'
| '.'
| '/'
| '_'
| '\u{a0}'
| '\u{2010}'
| '\u{2011}'
| '\u{2012}'
| '\u{2013}'
| '\u{2014}'
| '\u{2212}'
'\u{AD}'
| '\u{34F}'
| '\u{61C}'
| '\u{115F}'..='\u{1160}'
| '\u{17B4}'..='\u{17B5}'
| '\u{180B}'..='\u{180F}'
| '\u{200B}'..='\u{200F}'
| '\u{202A}'..='\u{202E}'
| '\u{2060}'..='\u{206F}'
| '\u{3164}'
| '\u{FE00}'..='\u{FE0F}'
| '\u{FEFF}'
| '\u{FFA0}'
| '\u{FFF0}'..='\u{FFF8}'
| '\u{1BCA0}'..='\u{1BCA3}'
| '\u{1D173}'..='\u{1D17A}'
| '\u{E0000}'..='\u{E0FFF}'
)
}
/// The most separators that may sit between two of a TIN's digits
/// (`" - "` is three).
/// (`" - "` is three); invisible characters ([`tin_invisible`]) don't
/// count.
const MAX_TIN_GAP: usize = 3;
/// Each run in `chars` that holds nine digits or more, as (first, last)
/// char indices of its digits: digits with at most [`MAX_TIN_GAP`]
/// separators ([`tin_separator`]) between neighbors.
/// separators ([`tin_separator`]) between neighbors, and any number of
/// invisible characters.
fn full_tin_runs(chars: &[char]) -> Vec<(usize, usize)> {
let mut runs = Vec::new();
let mut i = 0;
@@ -315,11 +384,19 @@ fn full_tin_runs(chars: &[char]) -> Vec<(usize, usize)> {
j += 1;
continue;
}
let gap = chars[j..].iter().take_while(|c| tin_separator(**c)).count();
if (1..=MAX_TIN_GAP).contains(&gap)
&& chars.get(j + gap).is_some_and(|c| c.is_numeric())
let between = chars[j..]
.iter()
.take_while(|c| tin_separator(**c) || tin_invisible(**c))
.count();
let gap = chars[j..j + between]
.iter()
.filter(|c| !tin_invisible(**c))
.count();
if between > 0
&& gap <= MAX_TIN_GAP
&& chars.get(j + between).is_some_and(|c| c.is_numeric())
{
j += gap;
j += between;
} else {
break;
}
@@ -333,11 +410,13 @@ fn full_tin_runs(chars: &[char]) -> Vec<(usize, usize)> {
}
/// Whether `text` holds a run of nine digits, with at most three
/// separators (space, hyphen, dot, slash, underscore, a Unicode dash or
/// no-break space) between neighbors: an SSN, EIN, or ITIN however it's
/// written (`291-12-2846`, `291.12.2846`, `291 - 12 - 2846`). Any Unicode
/// numeral counts as a digit (full-width, Arabic-Indic, ...), so a number
/// can't slip through in another script.
/// separators (a space, hyphen, dot, slash or underscore, in any of
/// their Unicode forms) between neighbors, invisible
/// characters not counted: an SSN, EIN, or ITIN however it's written
/// (`291-12-2846`, `291.12.2846`, `291 - 12 - 2846`, `291-12-2846`).
/// Any Unicode numeral counts as a digit (full-width, Arabic-Indic, ...),
/// so a number can't slip through in another script. In ASCII, exactly
/// the space, hyphen, dot, slash and underscore separate.
pub fn has_full_tin(text: &str) -> bool {
let chars: Vec<char> = text.chars().collect();
!full_tin_runs(&chars).is_empty()
+1
View File
@@ -365,6 +365,7 @@ fn a_shared_payment_counts_the_vendors_share() {
&s.l,
2026,
None,
None,
&ledger_core::PaymentAccounts {
cash: vec![bank],
financing: vec![],
+140 -1
View File
@@ -177,7 +177,7 @@ fn has_full_tin_truth_table() {
("291\u{2013}12\u{2013}2846", true), // en dashes
("291\u{a0}12\u{a0}2846", true), // no-break spaces
("12 3456789", false), // four separators break the run: 2 + 7
("571, 93, 2846", false), // a comma is not a separator
("291, 12, 2846", false), // a comma is not a separator
("12\t3456789", false), // a tab is not a space
("12345678", false), // eight digits
("123-45-678", false),
@@ -241,6 +241,145 @@ fn has_full_tin_unicode_digits() {
assert!(has_full_tin("١٢٣٤٥٦٧٨٩")); // Arabic-Indic digits
}
/// Oracle: `has_full_tin` doc (P3 review round 2): past ASCII, every form
/// of the five separators Unicode has separates (every Zs space, every
/// Dash character, the compatibility forms of the dot, slash and
/// underscore; `docs/spec/sources/unicode-tin-text.md`, Unicode 17.0),
/// and invisible characters (Default_Ignorable_Code_Point) don't count
/// toward the gap. A TIN typed in fullwidth forms, Arabic-Indic digits,
/// with an en dash, a non-breaking hyphen, a Hebrew maqaf, any space, or
/// with right-to-left marks around its hyphens is still a full TIN; line
/// breaks, commas and a fourth visible separator still end a run.
/// (Before, "123-45-6789" passed: the fullwidth hyphen-minus wasn't
/// a separator, nor the ideographic or thin space.)
#[test]
fn has_full_tin_reads_every_script_and_separator() {
let tins = [
// Fullwidth digits and hyphen-minus.
"\u{FF11}\u{FF12}\u{FF13}\u{FF0D}\u{FF14}\u{FF15}\u{FF0D}\u{FF16}\u{FF17}\u{FF18}\u{FF19}",
"291\u{FF0D}12\u{FF0D}2846", // fullwidth hyphen-minus
"291\u{FE63}12\u{FE63}2846", // small hyphen-minus
// Arabic-Indic, Extended Arabic-Indic, Devanagari (en dashes).
"\u{661}\u{662}\u{663}-\u{664}\u{665}-\u{666}\u{667}\u{668}\u{669}",
"\u{6F1}\u{6F2}\u{6F3}\u{6F4}\u{6F5}\u{6F6}\u{6F7}\u{6F8}\u{6F9}",
"\u{967}\u{968}\u{969}\u{2013}\u{96A}\u{96B}\u{2013}\u{96C}\u{96D}\u{96E}\u{96F}",
"291\u{2013}12\u{2013}2846", // en dash
"291\u{2011}12\u{2011}2846", // non-breaking hyphen
"291\u{2012}12\u{2012}2846", // figure dash
"291\u{2015}12\u{2015}2846", // horizontal bar
"291\u{5BE}12\u{5BE}2846", // Hebrew maqaf
"291\u{301C}12\u{301C}2846", // wave dash
"291\u{A0}12\u{A0}2846", // no-break space
"291\u{3000}12\u{3000}2846", // ideographic space
"291\u{2009}12\u{2009}2846", // thin space
"291\u{202F}12\u{202F}2846", // narrow no-break space
"291 \u{2014} 12 \u{2014} 2846", // three: space, em dash, space
"291\u{FF0E}12\u{FF0E}2846", // fullwidth full stop
"291\u{2024}12\u{2024}2846", // one dot leader
"291\u{FF0F}12\u{FF0F}2846", // fullwidth solidus
"291\u{FF3F}12\u{FF3F}2846", // fullwidth low line
// Zero-width spaces between every digit.
"2\u{200B}9\u{200B}1\u{200B}1\u{200B}2\u{200B}2\u{200B}8\u{200B}4\u{200B}6",
"291\u{200F}-\u{200F}12\u{200F}-\u{200F}2846", // right-to-left marks around hyphens
"291\u{200E}\u{200E}\u{200E}\u{200E} 12 2846", // invisible marks don't count
"291\u{AD}12\u{AD}2846", // soft hyphens
"291\u{2060}122846", // word joiner
"\u{FEFF}291122846",
];
for t in tins {
assert!(has_full_tin(t), "has_full_tin({t:?})");
let masked = redact_full_tins(t);
assert!(!has_full_tin(&masked), "{t:?} masked: {masked:?}");
assert_eq!(
masked.chars().count(),
t.chars().count(),
"only digits change: {masked:?}"
);
}
assert_eq!(
redact_full_tins(
"ref \u{FF11}\u{FF12}\u{FF13}\u{FF0D}\u{FF14}\u{FF15}\u{FF0D}\u{FF16}\u{FF17}\u{FF18}\u{FF19}"
),
"ref •••\u{FF0D}••\u{FF0D}••••"
);
assert_eq!(
redact_full_tins("291\u{200F}-\u{200F}12-2846 ok"),
"•••\u{200F}-\u{200F}••-•••• ok"
);
let not = [
"12\u{3000}\u{3000}\u{3000}\u{3000}3456789", // four visible separators
"12 \u{2014} \u{200B}-3456789", // four, an invisible one among them
"291\u{2028}12\u{2028}2846", // line separators end a run
"291\u{2029}12\u{2029}2846", // paragraph separators
"291\u{85}12\u{85}2846", // next line
"291\u{FF0C}12\u{FF0C}2846", // fullwidth commas
"291\u{3001}12\u{3001}2846", // ideographic commas
"\u{FF11}\u{FF12}\u{FF13}\u{FF14}\u{FF15}\u{FF16}\u{FF17}\u{FF18}", // eight digits
"291\u{200B}12284", // eight, invisibly joined
];
for t in not {
assert!(!has_full_tin(t), "has_full_tin({t:?})");
}
}
/// The reading before P3 review round 2, the oracle for ASCII: past it,
/// only the no-break space and a few dashes separated, and every
/// separator counted.
fn has_full_tin_before(text: &str) -> bool {
let sep = |c: char| {
matches!(
c,
' ' | '-' | '.' | '/' | '_' | '\u{a0}' | '\u{2010}'..='\u{2014}' | '\u{2212}'
)
};
let chars: Vec<char> = text.chars().collect();
let mut i = 0;
while i < chars.len() {
if !chars[i].is_numeric() {
i += 1;
continue;
}
let (mut digits, mut last, mut j) = (1, i, i + 1);
while j < chars.len() {
if chars[j].is_numeric() {
digits += 1;
last = j;
j += 1;
continue;
}
let gap = chars[j..].iter().take_while(|c| sep(**c)).count();
if (1..=3).contains(&gap) && chars.get(j + gap).is_some_and(|c| c.is_numeric()) {
j += gap;
} else {
break;
}
}
if digits >= 9 {
return true;
}
i = last + 1;
}
false
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(2000))]
/// `has_full_tin` reads ASCII exactly as it did (the fix only widens
/// what separates past ASCII), and what it found before in any text
/// it still finds: it only ever finds more.
#[test]
fn has_full_tin_keeps_its_ascii_reading(
ascii in "[0-9 ./_\t\n\r,:a-]{0,40}",
any in "[0-9 ./_\t\n,a\u{A0}\u{2010}-\u{2015}\u{2212}\u{FF0D}\u{3000}\u{200B}\u{200F}\u{FF10}-\u{FF19}\u{660}-\u{669}\u{2028}-]{0,40}",
) {
prop_assert_eq!(has_full_tin(&ascii), has_full_tin_before(&ascii), "{:?}", ascii);
if has_full_tin_before(&any) {
prop_assert!(has_full_tin(&any), "{:?}", any);
}
}
}
// ---------------------------------------------------------------- check_text
/// Oracle: `check_text` doc: not a full TIN, at most 500 characters, no
+9 -3
View File
@@ -1823,9 +1823,15 @@ impl Books {
cash: self.money_cash_accounts.clone(),
financing: self.financing_accounts.clone(),
};
let r =
ledger_core::vendor_1099_report_with_members(l, year, None, &sources, &self.members)
.ok()?;
let r = ledger_core::vendor_1099_report_with_members(
l,
year,
None,
None,
&sources,
&self.members,
)
.ok()?;
let line = r.vendors.iter().find(|v| v.vendor_id == vendor)?.clone();
Some((line, r.threshold))
}
+12 -4
View File
@@ -179,12 +179,20 @@ impl Job {
)?;
let (stem, ext) = crate::export::split_name(&p.file_name);
let path = crate::export::save(stem, ext, &p.zip)?;
let note = (!p.tin_gaps.is_empty()).then(|| {
format!(
let mut notes = Vec::new();
if !p.tin_gaps.is_empty() {
notes.push(format!(
"{} TIN(s) aren't in it (the README says which): ask your agent to collect them.",
p.tin_gaps.len()
)
});
));
}
if p.iif_changes > 0 {
notes.push(format!(
"{} account name(s), number(s), place(s) or memo(s) are written differently in its QuickBooks file, to fit QuickBooks Desktop: the README says how.",
p.iif_changes
));
}
let note = (!notes.is_empty()).then(|| notes.join(" "));
Ok((path, note))
}
Job::Worksheet(year) => {
+84 -4
View File
@@ -117,10 +117,21 @@ pub fn bold(size: f32) -> FontId {
FontId::new(size, FontFamily::Name(BOLD.into()))
}
/// Install fonts: Inter with tabular figures frozen in, so every number in
/// the app lines up digit for digit. Phosphor icons ride along as a
/// fallback in every family.
/// Install fonts ([`font_definitions`]).
pub fn install_fonts(ctx: &Context) {
ctx.set_fonts(font_definitions());
}
/// The viewer's fonts: Inter with tabular figures frozen in, so every
/// number in the app lines up digit for digit; egui's own fonts and
/// Phosphor's icons as fallbacks in every family; then the PDFs' Noto
/// fallbacks (`ledger_import::fonts::FALLBACKS`: Chinese, Japanese,
/// Korean, Arabic, Hebrew, Thai, Devanagari), the very bytes the PDFs use,
/// so a name in any of those scripts shows as written, never as empty
/// boxes, and no font is in the binary twice. (egui shapes each word,
/// joining Arabic and setting it right to left, but doesn't reorder the
/// words of a right-to-left phrase: they read left to right.)
pub fn font_definitions() -> FontDefinitions {
let mut fonts = FontDefinitions::default();
let add = |fonts: &mut FontDefinitions, name: &str, bytes: &'static [u8]| {
fonts
@@ -166,7 +177,13 @@ pub fn install_fonts(ctx: &Context) {
fonts
.families
.insert(FontFamily::Name(BOLD.into()), family("InterTab-Bold"));
ctx.set_fonts(fonts);
for (name, bytes) in &ledger_import::fonts::FALLBACKS {
add(&mut fonts, name, bytes);
for list in fonts.families.values_mut() {
list.push((*name).to_owned());
}
}
fonts
}
/// Apply the viewer's own style for light or dark mode (tests).
@@ -255,3 +272,66 @@ pub fn apply_scheme(ctx: &Context, scheme: &crate::schemes::Scheme) {
style.interaction.selectable_labels = false;
});
}
#[cfg(test)]
mod tests {
use super::*;
/// font_definitions: every family ends with the PDFs' Noto fallbacks,
/// in their order, after Inter, egui's fonts and the icons, each font
/// the very bytes ledger-import bundles (the same pointer: one copy in
/// the binary); and a name in each script shows with real glyphs in
/// every family the viewer uses.
#[test]
fn every_family_falls_back_to_the_noto_fonts() {
let defs = font_definitions();
let names: Vec<String> = ledger_import::fonts::FALLBACKS
.iter()
.map(|(n, _)| (*n).to_owned())
.collect();
for family in [
FontFamily::Proportional,
FontFamily::Monospace,
FontFamily::Name(SEMIBOLD.into()),
FontFamily::Name(BOLD.into()),
] {
let list = &defs.families[&family];
assert!(list.len() > names.len(), "{family:?}: {list:?}");
assert_eq!(list[list.len() - names.len()..], names[..], "{family:?}");
}
assert!(defs.families[&FontFamily::Proportional][0] == "InterTab-Regular");
for (name, bytes) in &ledger_import::fonts::FALLBACKS {
let data: &[u8] = &defs.font_data[*name].font;
assert!(
std::ptr::eq(data, *bytes),
"{name} is ledger-import's own bytes, not a copy"
);
}
let ctx = Context::default();
ctx.set_fonts(defs);
let mut out = ctx.run_ui(egui::RawInput::default(), |_| {});
out.textures_delta.clear();
for text in [
"北京 東京 서울",
"مكتب الرياض",
"שלום עולם",
"สวัสดีครับ",
"नमस्ते",
] {
for id in [
regular(14.0),
semibold(14.0),
bold(14.0),
FontId::monospace(12.5),
] {
// Letters only: egui says a character the replacement
// glyph's own font draws is missing, the space in Hack.
let missing: Vec<char> = text
.chars()
.filter(|c| !c.is_whitespace() && !ctx.fonts_mut(|f| f.has_glyph(&id, *c)))
.collect();
assert!(missing.is_empty(), "{text} in {id:?}: {missing:?}");
}
}
}
}
+6
View File
@@ -250,6 +250,12 @@ fn the_menu_makes_the_cpa_package_with_full_tins() {
.read_to_string(&mut readme)
.unwrap();
assert!(readme.starts_with("Acme LLC"), "{readme}");
// The QuickBooks file stops at the package's day, like every file.
assert!(
readme
.contains("general-journal.iif: every posted entry from the first through 2026-10-15,"),
"{readme}"
);
// The member's K-1 row carries their full SSN, from the book's vault.
let mut k1 = String::new();
zip.by_name("k1-members.csv")
+100
View File
@@ -0,0 +1,100 @@
//! Names in other scripts show as written (P3 review round 2): the viewer
//! falls back on the PDFs' own Noto fonts (`ledger_import::fonts`), so an
//! account named in Chinese, Arabic, Hebrew, Thai or Hindi is drawn with
//! its own letters, never as empty boxes.
//!
//! ```text
//! GOAL: the owner reads every name the books hold, in any script.
//! SOURCE: DECISION (lead, P3 review round 2, 2026-10-20).
//! ```
//!
//! Set `SHOT_DIR` to also save the Accounts page as `scripts.png` there,
//! to look at.
use egui_kittest::Harness;
use ledger_core::{AccountType, Currency, Money, Side};
use ledger_gui::nav::Route;
use ledger_gui::{App, Source, data};
use ledger_store::Store;
use rust_decimal::Decimal;
use std::path::PathBuf;
/// A test's dir, removed when the test ends, pass or panic.
struct TestDir(PathBuf);
impl Drop for TestDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// The names, each its own script.
const NAMES: [&str; 5] = ["北京 办公室", "مكتب الرياض", "שכר דירה", "ค่าเช่า", "किराया"];
#[test]
fn account_names_in_other_scripts_show_as_written() {
let dir =
TestDir(std::env::temp_dir().join(format!("ledger-gui-scripts-{}", std::process::id())));
let _ = std::fs::remove_dir_all(&dir.0);
let data_dir = dir.0.join("books-data");
let mut s = Store::create(&data_dir, Currency::USD).unwrap();
let cash = s
.add_account("1000", "Checking", AccountType::Asset, None, false)
.unwrap();
// Each account with a payment on it, so the page lists it.
for (i, name) in NAMES.iter().enumerate() {
let id = s
.add_account(&format!("6{i}00"), name, AccountType::Expense, None, false)
.unwrap();
let day = chrono::NaiveDate::from_ymd_opt(2026, 10, 1 + i as u32).unwrap();
let e = s.create_entry(day, name).unwrap();
let m = Money::new(Decimal::new(10_000 * (i as i64 + 1), 2), Currency::USD);
s.add_line(e, id, Side::Debit, m).unwrap();
s.add_line(e, cash, Side::Credit, m).unwrap();
s.attach_receipt(Some(e), "r.pdf", "application/pdf", b"%PDF r", None)
.unwrap();
s.post_entry(e).unwrap();
}
drop(s);
let books = data::load_once(&Source {
data_dir,
today: Some(chrono::NaiveDate::from_ymd_opt(2026, 10, 20).unwrap()),
})
.unwrap();
let mut h = Harness::builder()
.with_size(egui::vec2(1100.0, 640.0))
.build_eframe(|cc| App::with_books(&cc.egui_ctx, books, false, Route::Accounts));
h.run_steps(4);
let ctx = h.ctx.clone();
h.state_mut().settle(&ctx);
h.run_steps(4);
if let Ok(out) = std::env::var("SHOT_DIR") {
let out = PathBuf::from(out);
std::fs::create_dir_all(&out).unwrap();
h.render().unwrap().save(out.join("scripts.png")).unwrap();
}
// Every text the page painted.
fn walk(s: &egui::Shape, out: &mut Vec<String>) {
match s {
egui::Shape::Text(t) => out.push(t.galley.text().to_string()),
egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)),
_ => {}
}
}
let mut painted = Vec::new();
for c in &h.output().shapes {
walk(&c.shape, &mut painted);
}
for name in NAMES {
assert!(
painted.iter().any(|t| t.contains(name)),
"{name} is on the Accounts page: {painted:?}"
);
for c in name.chars().filter(|c| !c.is_whitespace()) {
assert!(
ctx.fonts_mut(|f| f.has_glyph(&egui::FontId::proportional(14.0), c)),
"{c} of {name} has a glyph"
);
}
}
}
+7
View File
@@ -18,10 +18,17 @@ typst = "0.15.1"
serde_json = "1"
typst-layout = "0.15.1"
qrcode = { version = "0.14.1", default-features = false, features = ["svg"] }
# BSD-3-Clause: the IIF's transliteration to ASCII (iif.rs, rules.md B-34).
# Pinned exactly: a new table would change what a CPA's file says.
deunicode = { version = "=1.6.0", default-features = false }
[lints]
workspace = true
[dev-dependencies]
proptest = "1"
# The viewer's PDF reader (ledger-gui's hayro): reads back the text a PDF
# draws, glyph by glyph, in the tests.
hayro-interpret = "0.8"
kurbo = "0.13"
rqrr = { version = "0.11.0", default-features = false }
@@ -0,0 +1,144 @@
Noto Sans Arabic UI, Noto Sans Hebrew, Noto Sans Thai and Noto Sans
Devanagari, Regular (the PDFs' and the viewer's fallbacks for Arabic,
Hebrew, Thai and Devanagari text, which Inter lacks), unmodified:
NotoSansArabicUI-Regular.ttf Version 2.011 190,944 bytes
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/arabic)
SHA-256 f4fe1866a64cba1e8b8d08a67cd29070f919edf778ca5de983c1f2a690fc953b
NotoSansHebrew-Regular.ttf Version 3.001 16,836 bytes
Copyright 2024 The Noto Project Authors (https://github.com/notofonts/hebrew)
SHA-256 04272f5600d0ec816d31d0df73b23aa8d3501ea359ebe820da31c11ffcf00853
NotoSansThai-Regular.ttf Version 2.002 20,960 bytes
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/thai)
SHA-256 d4303fe9c63ebb72759ca8b6d2040c8ae81689f7d08d7b91c656154382b49313
NotoSansDevanagari-Regular.ttf Version 2.007 184,228 bytes
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/devanagari)
SHA-256 9c7d935139ea6a1e6ad9dbac4f6d27ece1e04bca8123c8888d00a0f9df4724cd
Noto is a trademark of Google LLC. (These fonts name no Reserved Font
Name.)
Each is the static, unhinted Regular weight as the Noto project
publishes it (https://github.com/notofonts/notofonts.github.io), at the
last commit that changed it, retrieved 2026-10-20:
fonts/NotoSansArabicUI/unhinted/ttf/NotoSansArabicUI-Regular.ttf
commit 1ed0aaad710107ac0d6663ece26a4bb82ce6cff8
fonts/NotoSansHebrew/unhinted/ttf/NotoSansHebrew-Regular.ttf
commit 40ae54ff25c9e0ad997f62fde48297dd9dfe51c8
fonts/NotoSansThai/unhinted/ttf/NotoSansThai-Regular.ttf
commit 6da9d59fd18f429cb8dbfa87e87669204f77ddab
fonts/NotoSansDevanagari/unhinted/ttf/NotoSansDevanagari-Regular.ttf
commit 92d2ea744479d3379927f1fb5f8b0945dd0349b7
https://raw.githubusercontent.com/notofonts/notofonts.github.io/<commit>/<path>
Arabic in its UI form, not Noto Sans Arabic itself (2.013): the same
design and project, but 2.013 draws a letter's dots as marks of their
own, which the PDFs' typesetter (Typst 0.15) writes as text runs of
their own, each carrying the letter again, so a PDF's text (copied,
searched, read aloud) would say "شش" for "ش" and "سلام" as "شلام"; the
UI form, whose letters keep their dots and whose height fits a Latin
line, reads back as written.
Unhinted, not hinted: the outlines are the same, the hinting tables
(about 180 KB across the four) serve only Windows' old rasterizer, and
neither the PDFs nor the viewer use them. Kept whole: Arabic and
Devanagari need their shaping tables to join and order their letters.
Emoji are not bundled: a colour emoji font is about 10 MB, so an emoji
in a name prints as an empty box in the PDFs (the viewer draws egui's
own small emoji set).
This Font Software is licensed under the SIL Open Font License,
Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font
creation efforts of academic and linguistic communities, and to
provide a free and open framework in which fonts may be shared and
improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply to
any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software
components as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to,
deleting, or substituting -- in part or in whole -- any of the
components of the Original Version, by changing formats or by porting
the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed,
modify, redistribute, and sell modified and unmodified copies of the
Font Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components, in
Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the
corresponding Copyright Holder. This restriction only applies to the
primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created using
the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,125 @@
Noto Sans CJK SC Regular, version 2.004, subset:
NotoSansCJKsc-Regular-subset.otf (the PDFs' fallback for Chinese,
Japanese and Korean text, and the other characters below that Inter
lacks).
Copyright (c) 2014-2021 Adobe (http://www.adobe.com/).
Noto is a trademark of Google Inc.
(Noto Sans CJK names no Reserved Font Name.)
Made from Noto Sans CJK 2.004 (https://github.com/notofonts/noto-cjk),
NotoSansCJK-Regular.ttc as Arch Linux packages it (noto-fonts-cjk
20240730-1; SHA-256
b76b0433203017ca80401b2ee0dd69350349871c4b19d504c34dbdd80541690a),
face 2 (Noto Sans CJK SC), with fontTools 4.65.0, 2026-10-20:
every code point at or above U+1100 that face maps and Inter Regular
(Inter-Regular.ttf here) doesn't: 44,128 code points, among them all
of Hangul, kana, bopomofo, CJK punctuation and fullwidth forms, and
every CJK ideograph the face draws (20,976 of the basic block, 6,582
of Extension A, the compatibility ideographs, and 2,310 from
Extensions B to G), written as U+XXXX-YYYY ranges to cjk.txt, then
pyftsubset NotoSansCJK-Regular.ttc --font-number=2 \
--unicodes-file=cjk.txt --no-hinting --layout-features='' \
--drop-tables+=BASE,VORG,vhea,vmtx,GPOS,GSUB \
--output-file=NotoSansCJKsc-Regular-subset.otf
SHA-256 of the result:
ebf572d736e30b54bf5db087b15f323c16c465052aec788d9cc1d8db41884929
The subset is a Modified Version under the license below, which it
keeps.
This Font Software is licensed under the SIL Open Font License,
Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font
creation efforts of academic and linguistic communities, and to
provide a free and open framework in which fonts may be shared and
improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply to
any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software
components as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to,
deleting, or substituting -- in part or in whole -- any of the
components of the Original Version, by changing formats or by porting
the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed,
modify, redistribute, and sell modified and unmodified copies of the
Font Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components, in
Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the
corresponding Copyright Holder. This restriction only applies to the
primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created using
the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+2 -2
View File
@@ -46,7 +46,7 @@
//! SOURCE (7-9): docs/spec/rules.md B-25;
//! docs/spec/sources/irs-partnership-basis.md.
use crate::pdf::{INTER, PdfError};
use crate::pdf::PdfError;
use crate::report::{ReportMeta, amount_parts, format_amount, long_date};
use crate::xlsx::{Formats, XlsxError, footer, header, number};
use ledger_core::TaxBasisRule;
@@ -512,7 +512,7 @@ fn engine() -> &'static TypstEngine<TypstTemplateMainFile> {
ENGINE.get_or_init(|| {
TypstEngine::builder()
.main_file(TEMPLATE)
.fonts(INTER)
.fonts(crate::fonts::typst_fonts())
.build()
})
}
+111
View File
@@ -0,0 +1,111 @@
//! The fonts the books print in, bundled so nothing depends on what's
//! installed: Inter for the text, and a Noto fallback for each script
//! Inter lacks, so a vendor or an account named in Chinese, Japanese,
//! Korean, Arabic, Hebrew, Thai or Hindi prints as written, never as
//! empty boxes. Each font's provenance and license is in `fonts/`
//! (`Inter-LICENSE.txt`, `NotoSansCJK-LICENSE.txt`, `NotoSans-LICENSE.txt`;
//! all SIL OFL 1.1).
//!
//! ```text
//! GOAL: every name prints as the books keep it, in the PDFs and in the
//! viewer, from one copy of each font in the binary.
//! SOURCE: DECISION (lead, P3 review round 2, 2026-10-20: CJK, Arabic,
//! Hebrew, Thai and Devanagari; no emoji, a colour emoji font
//! being about 10 MB).
//! INVARIANTS:
//! 1. The PDFs (crate::pdf, crate::asset_register, crate::labels) set
//! their text in Inter, then try FALLBACKS in order, as each template
//! names them (`font: ("Inter", "Noto Sans CJK SC", ...)`).
//! 2. The viewer (ledger-gui) registers FALLBACKS, these same bytes,
//! after its own fonts: each font is in the binary once.
//! ```
/// Inter, the PDFs' text face, in the three weights the templates use.
pub static INTER: [&[u8]; 3] = [
include_bytes!("../fonts/Inter-Regular.ttf"),
include_bytes!("../fonts/Inter-SemiBold.ttf"),
include_bytes!("../fonts/Inter-Bold.ttf"),
];
/// The fallbacks for the scripts Inter lacks, each its family name and
/// its bytes, in the order tried: Noto Sans CJK SC (a subset: every
/// character from U+1100 on that the face draws and Inter doesn't: Hangul,
/// kana, the CJK ideographs, fullwidth forms), Noto Sans Arabic UI (its
/// letters keep their dots, so a PDF's text reads back as written:
/// `fonts/NotoSans-LICENSE.txt`), Noto Sans Hebrew, Noto Sans Thai and
/// Noto Sans Devanagari (each whole, its static Regular weight). Emoji
/// have none.
pub static FALLBACKS: [(&str, &[u8]); 5] = [
(
"Noto Sans CJK SC",
include_bytes!("../fonts/NotoSansCJKsc-Regular-subset.otf"),
),
(
"Noto Sans Arabic UI",
include_bytes!("../fonts/NotoSansArabicUI-Regular.ttf"),
),
(
"Noto Sans Hebrew",
include_bytes!("../fonts/NotoSansHebrew-Regular.ttf"),
),
(
"Noto Sans Thai",
include_bytes!("../fonts/NotoSansThai-Regular.ttf"),
),
(
"Noto Sans Devanagari",
include_bytes!("../fonts/NotoSansDevanagari-Regular.ttf"),
),
];
/// Every font the PDFs use (INVARIANT 1), as Typst takes them: the
/// bundled bytes themselves, not a copy of them.
pub(crate) fn typst_fonts() -> Vec<typst::foundations::Bytes> {
INTER
.iter()
.copied()
.chain(FALLBACKS.iter().map(|(_, bytes)| *bytes))
.map(typst::foundations::Bytes::new)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
/// INVARIANT 1: every template names Inter, then every fallback, in
/// FALLBACKS' order; and each font's family is the one it is named
/// by.
#[test]
fn every_template_names_every_fallback_in_order() {
let mut want = String::from("font: (\"Inter\"");
for (family, _) in &FALLBACKS {
want.push_str(&format!(", \"{family}\""));
}
want.push(')');
for (name, t) in [
("statement.typ", include_str!("../templates/statement.typ")),
(
"asset_register.typ",
include_str!("../templates/asset_register.typ"),
),
("labels.typ", include_str!("../templates/labels.typ")),
] {
assert!(t.contains(&want), "{name} names {want}");
}
let book = typst::text::FontBook::from_fonts(
typst_fonts()
.into_iter()
.flat_map(|b| typst::text::Font::iter(b).collect::<Vec<_>>())
.collect::<Vec<_>>()
.iter(),
);
for (family, _) in &FALLBACKS {
assert!(
book.contains_family(&family.to_lowercase()),
"{family} is a family the fonts hold"
);
}
assert!(book.contains_family("inter"));
}
}
File diff suppressed because it is too large. Load diff
+3 -3
View File
@@ -3,7 +3,7 @@
//! Each label carries the company name, the asset tag (its headline), what
//! the asset is, its serial number when recorded, and a QR code holding
//! exactly the tag. Labels are typeset by `templates/labels.typ` with the
//! same bundled Inter fonts as [`crate::pdf`]; the labels reach Typst as
//! same bundled fonts as [`crate::pdf`]; the labels reach Typst as
//! JSON data (`sys.inputs.data`), never as markup. The sheet geometry comes
//! from the label maker's own specification, vendored in
//! `docs/spec/sources/avery-5160.md`.
@@ -38,7 +38,7 @@
//! - A partly used sheet with 7 labels left: skip 23.
//! ```
use crate::pdf::{INTER, PdfError};
use crate::pdf::PdfError;
use qrcode::render::svg;
use qrcode::{EcLevel, QrCode};
use std::sync::OnceLock;
@@ -145,7 +145,7 @@ fn engine() -> &'static TypstEngine<TypstTemplateMainFile> {
ENGINE.get_or_init(|| {
TypstEngine::builder()
.main_file(TEMPLATE)
.fonts(INTER)
.fonts(crate::fonts::typst_fonts())
.build()
})
}
+6 -1
View File
@@ -8,12 +8,14 @@
//! - [`pdf`]: generate PDF financial statements (CPA handoff).
//! - [`asset_register`]: the fixed asset register as XLSX and PDF.
//! - [`labels`]: print asset label sheets with QR codes.
//! - [`fonts`]: the bundled fonts the PDFs and the viewer share.
//!
//! All money stays `Decimal`; no floats in money paths.
pub mod asset_register;
pub mod bank_csv;
pub mod chart_csv;
pub mod fonts;
pub mod iif;
pub mod labels;
pub mod pdf;
@@ -24,7 +26,10 @@ pub mod xlsx;
pub use asset_register::{asset_register_pdf, asset_register_xlsx};
pub use bank_csv::{BankCsvError, BankTransaction, DateOrder, parse_bank_csv, parse_bank_csv_with};
pub use chart_csv::{ChartRow, parse_chart_csv};
pub use iif::{IifAccount, IifError, QbAccountType, iif_accounts, journal_to_iif};
pub use iif::{
Iif, IifAccount, IifChange, IifError, IifField, LeftOut, MEMO_MAX, NAME_MAX, NUMBER_MAX,
QbAccountType, iif_accounts, journal_to_iif,
};
pub use labels::{AssetLabel, LabelSheet, asset_labels_pdf};
pub use pdf::{PdfError, statement_pdf};
pub use report::{Member, ReportMeta, Row, RowKind, Statement, YearEarnings, format_amount};
+121 -10
View File
@@ -4,10 +4,14 @@
//! rows to `templates/statement.typ` as JSON data (`sys.inputs.data`) and
//! renders the PDF. Amounts arrive already split by
//! [`crate::report::amount_parts`] — Typst never does arithmetic on money,
//! and labels are data, never markup. The font (Inter, SIL OFL 1.1; see
//! `fonts/Inter-LICENSE.txt`) is bundled, so output doesn't depend on
//! what's installed, and it is deterministic: the same statement and
//! report metadata render to the same bytes.
//! and labels are data, never markup. The fonts are bundled, so output
//! doesn't depend on what's installed, and it is deterministic: the same
//! statement and report metadata render to the same bytes. Inter sets
//! the text; the Noto fallbacks ([`crate::fonts`]) draw what Inter can't:
//! Chinese, Japanese, Korean, Arabic, Hebrew, Thai and Devanagari, so a
//! vendor or an account named in them prints as written, right-to-left
//! where it is written so, never as empty boxes. (Emoji have no font
//! here: an emoji prints as an empty box.)
use crate::report::{ReportMeta, RowKind, Statement, amount_parts};
use std::sync::OnceLock;
@@ -42,11 +46,6 @@ pub enum PdfError {
}
static TEMPLATE: &str = include_str!("../templates/statement.typ");
pub(crate) static INTER: [&[u8]; 3] = [
include_bytes!("../fonts/Inter-Regular.ttf"),
include_bytes!("../fonts/Inter-SemiBold.ttf"),
include_bytes!("../fonts/Inter-Bold.ttf"),
];
/// The engine, built once: parsing the bundled fonts is the expensive part.
fn engine() -> &'static TypstEngine<TypstTemplateMainFile> {
@@ -54,7 +53,7 @@ fn engine() -> &'static TypstEngine<TypstTemplateMainFile> {
ENGINE.get_or_init(|| {
TypstEngine::builder()
.main_file(TEMPLATE)
.fonts(INTER)
.fonts(crate::fonts::typst_fonts())
.build()
})
}
@@ -124,6 +123,10 @@ fn typeset(st: &Statement, meta: &ReportMeta) -> Result<PagedDocument, PdfError>
/// ("#panic()", "*", "$", "[") prints as text and NEVER runs.
/// 3. Every row reaches the page: template_data carries each row's label,
/// kind, and amounts split by amount_parts, in order.
/// 4. Every character prints as itself when a bundled font has it:
/// Chinese, Japanese, Korean, Arabic, Hebrew, Thai and Devanagari
/// from the Noto fallbacks (crate::fonts), Arabic and Hebrew right to
/// left, never an empty box.
/// ```
pub fn statement_pdf(st: &Statement, meta: &ReportMeta) -> Result<Vec<u8>, PdfError> {
let doc = typeset(st, meta)?;
@@ -221,4 +224,112 @@ mod tests {
assert!(doc.pages().len() > 1, "200 rows should span pages");
assert!(statement_pdf(&st, &meta()).unwrap().starts_with(b"%PDF"));
}
/// Each text run on the pages: its text, its font's family, and its
/// glyphs' ids in that font (0 is the font's empty box, "notdef").
fn runs(doc: &PagedDocument) -> Vec<(String, String, Vec<u16>)> {
use typst::layout::{Frame, FrameItem};
fn walk(frame: &Frame, out: &mut Vec<(String, String, Vec<u16>)>) {
for (_, item) in frame.items() {
match item {
FrameItem::Group(g) => walk(&g.frame, out),
FrameItem::Text(t) => out.push((
t.text.to_string(),
t.font.font().info().family.clone(),
t.glyphs.iter().map(|g| g.id).collect(),
)),
_ => {}
}
}
}
let mut out = Vec::new();
for p in doc.pages() {
walk(&p.frame, &mut out);
}
out
}
/// INVARIANT 4: a company, an account and a vendor named in Chinese,
/// Japanese and Korean print in the CJK fallback, every glyph a real
/// one; the rest stays Inter. (With Inter alone, every one of these
/// was glyph 0, an empty box.)
#[test]
fn chinese_japanese_and_korean_print_as_written() {
let mut m = meta();
m.company = "北京 Trading 株式会社".to_string();
let mut st = profit_and_loss(&pl());
st.rows.push(Row {
kind: RowKind::Line,
label: "6100 東京 서울 ありがとう ABC".into(),
amounts: vec![Some(Decimal::ONE)],
currency: false,
rule_above: false,
});
let runs = runs(&typeset(&st, &m).unwrap());
let mut seen = String::new();
for (text, family, glyphs) in &runs {
assert!(
!glyphs.contains(&0),
"{text:?} has an empty box in {family}"
);
let cjk = text.chars().any(|c| {
matches!(u32::from(c), 0x1100..=0x11FF | 0x3000..=0x9FFF | 0xAC00..=0xD7AF | 0xFF00..=0xFFEF)
});
let ascii = text.is_ascii();
if cjk {
assert_eq!(family, "Noto Sans CJK SC", "{text:?}");
seen.push_str(text);
} else if ascii {
assert_eq!(family, "Inter", "{text:?}");
}
}
for want in ["北京", "株式会社", "東京", "서울", "ありがとう", "ABC"] {
assert!(seen.contains(want), "{want} printed: {runs:?}");
}
}
/// INVARIANT 4 for the other bundled scripts: Arabic, Hebrew, Thai and
/// Devanagari each print in their own Noto fallback, every glyph a
/// real one (the right-to-left reading is checked on the PDF itself,
/// `tests/spec_import.rs` `pdf_prints_every_bundled_script_as_written`).
#[test]
fn arabic_hebrew_thai_and_devanagari_print_as_written() {
let mut m = meta();
m.company = "شركة Example".to_string();
let mut st = profit_and_loss(&pl());
for label in ["مكتب", "שלום", "สวัสดี", "नमस्ते"] {
st.rows.push(Row {
kind: RowKind::Line,
label: label.into(),
amounts: vec![Some(Decimal::ONE)],
currency: false,
rule_above: false,
});
}
let runs = runs(&typeset(&st, &m).unwrap());
let family_of = |c: char| match u32::from(c) {
0x0600..=0x06FF => Some("Noto Sans Arabic UI"),
0x0590..=0x05FF => Some("Noto Sans Hebrew"),
0x0E00..=0x0E7F => Some("Noto Sans Thai"),
0x0900..=0x097F => Some("Noto Sans Devanagari"),
_ => None,
};
for (text, family, glyphs) in &runs {
assert!(
!glyphs.contains(&0),
"{text:?} has an empty box in {family}"
);
if let Some(want) = text.chars().find_map(family_of) {
assert_eq!(family, want, "{text:?}");
}
}
// Every letter printed in its script's font.
for c in "شركةمكتبשלוםสวัสดีनमस्ते".chars() {
let want = family_of(c).unwrap();
assert!(
runs.iter().any(|(t, f, _)| f == want && t.contains(c)),
"{c} printed in {want}: {runs:?}"
);
}
}
}
@@ -26,7 +26,7 @@
#let firm = 0.6pt + ink
#set document(title: d.company + " — " + d.title, author: d.company)
#set text(font: "Inter", size: 8pt, fill: ink, number-width: "tabular", hyphenate: false)
#set text(font: ("Inter", "Noto Sans CJK SC", "Noto Sans Arabic UI", "Noto Sans Hebrew", "Noto Sans Thai", "Noto Sans Devanagari"), size: 8pt, fill: ink, number-width: "tabular", hyphenate: false)
#set page(
paper: "us-letter",
flipped: true,
+1 -1
View File
@@ -20,7 +20,7 @@
#let quiet = luma(90)
#set document(title: d.company + " — Asset labels", author: d.company)
#set text(font: "Inter", fill: ink, number-width: "tabular", hyphenate: false)
#set text(font: ("Inter", "Noto Sans CJK SC", "Noto Sans Arabic UI", "Noto Sans Hebrew", "Noto Sans Thai", "Noto Sans Devanagari"), fill: ink, number-width: "tabular", hyphenate: false)
#set par(leading: 0.28em, spacing: 0pt)
#set page(width: g.page_width * pt1, height: g.page_height * pt1, margin: 0pt)
+1 -1
View File
@@ -28,7 +28,7 @@
#let firm = 0.6pt + ink
#set document(title: d.company + " — " + d.title, author: d.company)
#set text(font: "Inter", size: if dense { 7pt } else { 9pt }, fill: ink, number-width: "tabular")
#set text(font: ("Inter", "Noto Sans CJK SC", "Noto Sans Arabic UI", "Noto Sans Hebrew", "Noto Sans Thai", "Noto Sans Devanagari"), size: if dense { 7pt } else { 9pt }, fill: ink, number-width: "tabular")
#set page(
paper: "us-letter",
flipped: wide,
File diff suppressed because it is too large. Load diff
@@ -3,3 +3,4 @@
- books.export_cpa_package carries every member's and 1099 payee's full TIN from the vault (the K-1 worksheet and the 1099 worksheet). It hands the zip over: run its download command with the folder the human wants it in (books served by `wanderbooks mcp`), then tell them where it is. The viewer's "Export for CPA" makes the same package.
- Never file the package, or a file from it, into the books.
- tin_gaps lists each member or payee whose TIN it lacks and why: collect them (books.set_tin, books.set_w9) and export again.
- Everything in it is as of its as_of: the 1099 worksheet counts the year's payments through that day, and general-journal.iif (the QuickBooks Desktop file) every posted entry from the first through it, closing entries left out. That file is plain ASCII within QuickBooks Desktop's limits: warnings (and the README) say what it writes differently from the books ("Café" as "Cafe", a name cut to 31 characters, a subaccount of another type at the top level, the memos grouped); tell the human. It names no customer or vendor: the README lists the entries on accounts receivable or payable, where QuickBooks may ask the CPA for one.
+56 -23
View File
@@ -557,7 +557,7 @@ fn vendor_1099_report(
year: i32,
threshold: Option<Decimal>,
) -> Result<ledger_core::Vendor1099Report, ledger_core::LedgerError> {
ledger_package::vendor_1099_report(ledger, &package_settings(settings), year, threshold)
ledger_package::vendor_1099_report(ledger, &package_settings(settings), year, None, threshold)
}
impl BooksServer {
@@ -5330,7 +5330,7 @@ impl BooksServer {
#[tool(
name = "books.export",
title = "Export a report",
description = "Export a report. Reports: profit_and_loss, balance_sheet, cash_flow (needs cash_accounts), trial_balance (as_of), members_equity (multi-member LLC; members from the settings), general_ledger (by account: running balances, splits), general_journal (every entry in date order; alias journal), fixed_asset_register (as_of: every asset with tag, serial, cost, depreciation, book value, reconciled to the books). Formats: pdf and xlsx (base64) for the five statements and fixed_asset_register; xlsx, csv, json for every report; iif for general_journal (QuickBooks Desktop). Dates: start/end for period reports (start defaults to the first entry, end to today), as_of for balance_sheet, trial_balance, and fixed_asset_register.",
description = "Export a report. Reports: profit_and_loss, balance_sheet, cash_flow (needs cash_accounts), trial_balance (as_of), members_equity (multi-member LLC; members from the settings), general_ledger (by account: running balances, splits), general_journal (every entry in date order; alias journal), fixed_asset_register (as_of: every asset with tag, serial, cost, depreciation, book value, reconciled to the books). Formats: pdf and xlsx (base64) for the five statements and fixed_asset_register; xlsx, csv, json for every report; iif for general_journal (QuickBooks Desktop: plain ASCII text, CR LF; every posted entry from the first through as_of, period closes left out, DOCNUM the entry id; names, numbers and memos fit QuickBooks' limits; warnings says what is written differently from the books, memos grouped, to tell the human). Dates: start/end for period reports (start defaults to the first entry, end to today), as_of for balance_sheet, trial_balance, fixed_asset_register, and iif (default today).",
annotations(read_only_hint = true, open_world_hint = false)
)]
async fn export(&self, params: Parameters<ExportParams>) -> Result<Json<ExportOut>, String> {
@@ -5343,8 +5343,30 @@ impl BooksServer {
));
}
let report = p.report.to_lowercase();
if format == "iif" && report != "journal" {
return Err("iif format is only valid for the journal report".to_string());
if format == "iif" {
if !matches!(report.as_str(), "journal" | "general_journal") {
return Err(
"iif format is only valid for the general_journal report (alias journal)"
.to_string(),
);
}
if p.start.is_some() || p.end.is_some() {
return Err("iif takes as_of, not start or end: the file holds every posted entry from the first through as_of, so QuickBooks' balances match the books'".to_string());
}
// As of today by default, like every other as-of export.
let as_of = match p.as_of.as_deref() {
Some(s) => parse_date(s)?,
None => today(),
};
let iif = self.export_iif(as_of, &p.cash_accounts)?;
let warnings = iif.warnings();
return Ok(Json(ExportOut {
format,
report,
content: iif.text,
content_encoding: None,
warnings,
}));
}
if format == "xlsx" || format == "pdf" {
let bytes = if format == "xlsx" {
@@ -5357,15 +5379,7 @@ impl BooksServer {
report,
content: base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bytes),
content_encoding: Some("base64".to_string()),
}));
}
if format == "iif" {
let content = self.export_iif()?;
return Ok(Json(ExportOut {
format,
report,
content,
content_encoding: None,
warnings: Vec::new(),
}));
}
let content = match report.as_str() {
@@ -5392,6 +5406,7 @@ impl BooksServer {
report,
content,
content_encoding: None,
warnings: Vec::new(),
}))
}
@@ -5508,15 +5523,25 @@ impl BooksServer {
ledger_import::statement_pdf(&st, &meta).map_err(|e| e.to_string())
}
/// Render the journal as IIF text via ledger-import, declaring every
/// account with its QuickBooks type: the `[books] cash_accounts` are
/// BANK, the receivable and payable control accounts AR and AP, a
/// registered asset's cost and accumulated depreciation FIXASSET, and
/// the rest follow their wanderbooks type (other current asset, other
/// current liability, equity, income, expense).
fn export_iif(&self) -> Result<String, String> {
/// The journal through `as_of` as IIF via ledger-package (the CPA
/// package's own general-journal.iif): the cash accounts (`cash`, else
/// the settings') BANK, the receivable and payable control accounts AR
/// and AP, the accounts of an asset bought by `as_of` FIXASSET, the
/// rest by their wanderbooks type; every posted entry from the first,
/// closes left out.
fn export_iif(
&self,
as_of: chrono::NaiveDate,
cash: &[String],
) -> Result<ledger_import::Iif, String> {
let store = self.lock()?;
ledger_package::export_iif(store.ledger(), &self.settings().roles.cash)
let settings = self.settings();
let cash = if cash.is_empty() {
settings.roles.cash.as_slice()
} else {
cash
};
ledger_package::export_iif(store.ledger(), cash, as_of)
}
// ------------------------------------------------------------------
@@ -8073,7 +8098,7 @@ impl BooksServer {
#[tool(
name = "books.export_cpa_package",
title = "CPA handoff package",
description = "Build the CPA handoff package as a zip: the profit and loss, balance sheet, cash flow, statement of changes in members' equity (multi-member LLCs), tax summary, and trial balance, each as PDF and XLSX; the fixed asset register as PDF and XLSX when there are fixed assets; the general ledger (by account) and general journal (entry by entry) as XLSX; the journal as IIF (imports into QuickBooks Desktop); the 1099 worksheet as CSV; with members, the K-1 worksheet (k1-members.csv: per member their party, share, capital account, TIN, and beginning capital, contributions in cash and in property, share of income, distributions, and ending capital for the fiscal year to date); every receipt behind the year's entries, filed on them or linked to them (receipts/, with an index.csv tying each file to its entry) and the company's documents (documents/), unless include_receipts is false; and a README with the journal's verified hash. By default (include_tins) the K-1 and 1099 worksheets carry every member's and payee's full TIN from the TIN vault; include_tins: false, last fours only. The zip is handed over: run its download command to save it in a folder (books served by `wanderbooks mcp`; over stdio it comes back inline). Never file it into the books. A locked vault or a missing TIN doesn't stop the package: tin_gaps lists each one (collect them with books.set_tin or books.set_w9 and export again). cash_accounts (e.g. [\"1000\"]) is required: the cash flow statement reconciles to them. as_of defaults to today; period reports cover the fiscal year to date.",
description = "Build the CPA handoff package as a zip: the profit and loss, balance sheet, cash flow, statement of changes in members' equity (multi-member LLCs), tax summary, and trial balance, each as PDF and XLSX; the fixed asset register as PDF and XLSX when there are fixed assets; the general ledger (by account) and general journal (entry by entry) as XLSX; the journal through as_of as IIF (imports into QuickBooks Desktop; plain ASCII within its limits: warnings says what is written differently from the books, and so does the README); the 1099 worksheet as CSV (payments from January 1 through as_of); with members, the K-1 worksheet (k1-members.csv: per member their party, share, capital account, TIN, and beginning capital, contributions in cash and in property, share of income, distributions, and ending capital for the fiscal year to date); every receipt behind the year's entries, filed on them or linked to them (receipts/, with an index.csv tying each file to its entry) and the company's documents (documents/), unless include_receipts is false; and a README with the journal's verified hash. By default (include_tins) the K-1 and 1099 worksheets carry every member's and payee's full TIN from the TIN vault; include_tins: false, last fours only. The zip is handed over: run its download command to save it in a folder (books served by `wanderbooks mcp`; over stdio it comes back inline). Never file it into the books. A locked vault or a missing TIN doesn't stop the package: tin_gaps lists each one (collect them with books.set_tin or books.set_w9 and export again). cash_accounts (e.g. [\"1000\"]) is required: the cash flow statement reconciles to them. as_of defaults to today; period reports cover the fiscal year to date.",
annotations(
read_only_hint = false,
destructive_hint = false,
@@ -8133,18 +8158,26 @@ impl BooksServer {
tin_files::record(&vault_dir, &package.zip)?;
}
let tin_gaps = package.tin_gaps;
let note = match (p.include_tins, tin_gaps.len()) {
let mut note = match (p.include_tins, tin_gaps.len()) {
(false, _) => "The package, with last fours only.".to_string(),
(true, 0) => "The package, with full TINs, for the CPA.".to_string(),
(true, n) => format!(
"The package, with full TINs, for the CPA. {n} TIN(s) are missing (tin_gaps): collect them with books.set_tin or books.set_w9 and export again."
),
};
let warnings = package.warnings;
if package.iif_changes > 0 {
note.push_str(&format!(
" {} account name(s), number(s), place(s) or memo(s) are written differently in general-journal.iif to fit QuickBooks Desktop (warnings says how; so does the README): tell the human.",
package.iif_changes
));
}
let file = self.hand_over(&book, &package.file_name, "application/zip", package.zip)?;
Ok(Json(ExportCpaPackageOut {
file,
tins_included: p.include_tins,
tin_gaps,
warnings,
note,
}))
}
+23 -4
View File
@@ -1682,7 +1682,8 @@ pub struct StaleRule {
pub struct ExportParams {
/// "csv", "json", "xlsx", "pdf", or "iif". XLSX and PDF are
/// base64-encoded; PDF covers profit_and_loss, balance_sheet, and
/// cash_flow; IIF is only valid with report "journal".
/// cash_flow; IIF (plain ASCII text) is only valid with report
/// "general_journal" (alias "journal").
pub format: String,
/// "journal", "trial_balance", "profit_and_loss", "balance_sheet", or
/// "cash_flow".
@@ -1691,10 +1692,13 @@ pub struct ExportParams {
pub start: Option<String>,
/// Period end for profit_and_loss and cash_flow, YYYY-MM-DD.
pub end: Option<String>,
/// As-of date for balance_sheet, YYYY-MM-DD.
/// As-of date for balance_sheet, trial_balance, fixed_asset_register,
/// and iif (every posted entry dated on or before it), YYYY-MM-DD.
/// Defaults to today.
pub as_of: Option<String>,
/// Cash account codes for cash_flow. Defaults to `[books]
/// cash_accounts`; one or the other is required for that report.
/// Cash account codes for cash_flow, and for iif its BANK accounts.
/// Defaults to `[books] cash_accounts`; one or the other is required
/// for cash_flow.
#[serde(default)]
pub cash_accounts: Vec<String>,
/// Liability account codes treated as financing for cash_flow.
@@ -1721,6 +1725,16 @@ pub struct ExportOut {
/// "base64" for XLSX and PDF, otherwise absent.
#[serde(skip_serializing_if = "Option::is_none")]
pub content_encoding: Option<String>,
/// IIF: what the file writes differently from the books to fit
/// QuickBooks Desktop (plain ASCII; account names at most 31
/// characters a level, numbers 7, memos 4,095; no two names or numbers
/// alike; a subaccount of another type than its parent's at the top
/// level): each account's name, number and place a line, and each thing written
/// differently in the memos a line naming the entries it is in
/// (`"—" is written "--" in the memos of entries 3, 5, 9`). No full
/// TIN. Tell the human. Absent when there are none.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<String>,
}
// ---------------------------------------------------------------------------
@@ -2668,6 +2682,11 @@ pub struct ExportCpaPackageOut {
/// with books.set_tin or books.set_w9.
#[serde(default)]
pub tin_gaps: Vec<String>,
/// What general-journal.iif writes differently from the books, as
/// `books.export` iif's `warnings` says it (the memos grouped); the
/// README lists them too. Absent when there are none.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<String>,
/// What to tell the human.
pub note: String,
}
+15 -3
View File
@@ -1185,15 +1185,27 @@ fn mcp_import_export_over_stdio() {
let chart = mcp.call(
"books.import_chart_csv",
json!({"csv_text": "code,name,type,parent_code\n1000,Cash,asset,\n4000,Revenue,revenue,\n"}),
json!({"csv_text": "code,name,type,parent_code\n1000,Cash,asset,\n4000,Revenue,revenue,\n6010,Café 北京,expense,\n"}),
);
assert_eq!(chart["count"], 2);
assert_eq!(chart["count"], 3);
// The IIF is plain ASCII text with CR LF line endings, inline; what it
// writes differently from the books is in warnings (rules.md B-34).
let iif = mcp.call(
"books.export",
json!({"format": "iif", "report": "journal"}),
);
assert!(iif["content"].as_str().unwrap().contains("!TRNS"));
let text = iif["content"].as_str().unwrap();
assert!(text.contains("!TRNS") && text.is_ascii() && text.ends_with("\r\n"));
assert!(
text.contains("\r\nACCNT\tCafe Bei Jing\tEXP\t6010\r\n"),
"{text}"
);
assert!(iif.get("content_encoding").is_none(), "{iif}");
assert_eq!(
iif["warnings"],
json!(["account 6010's name \"Café 北京\" is written \"Cafe Bei Jing\""])
);
let xlsx = mcp.call(
"books.export",
+157 -9
View File
@@ -1223,23 +1223,57 @@ fn xlsx_and_pdf_exports_are_real_files_with_the_books_in_them() {
/// IIF: accounts declared with QuickBooks types per the documented mapping
/// (cash accounts BANK, 1200 AR, 2000 AP, a registered asset's accounts
/// FIXASSET, the rest by type), then one balanced TRNS/SPL/ENDTRNS block
/// per posted entry. Kills: export_iif's AR and AP comparisons.
/// per posted entry dated on or before as_of, numbered (DOCNUM) with its
/// entry id (rules.md B-33, B-34); plain ASCII text with CR LF, what it
/// writes differently (a line break and double quotes, the engine's own em
/// dashes) in warnings, grouped. Kills: export_iif's AR and AP comparisons.
#[test]
fn iif_declares_accounts_by_role_and_balances_every_transaction() {
let dir = test_dir("iif");
let mut s = new_store(&dir);
rich_books(&mut s);
let posted = s.ledger().posted_entries().count();
let ledger = s.ledger().clone();
drop(s);
let mut m = Mcp::start(&dir, "1000000.00");
let iif = String::from_utf8(m.export(json!({"format": "iif", "report": "journal"}))).unwrap();
let out = m.ok(
"books.export",
json!({"format": "iif", "report": "journal", "as_of": END}),
);
assert!(out.get("content_encoding").is_none(), "plain text: {out}");
let lunch = ledger
.posted_entries()
.find(|e| e.description().starts_with("Lunch"))
.unwrap()
.id();
// The chart's `Meals, "client"`, its double quotes written single;
// then the memos, grouped by what is written differently: the lunch's
// double quotes and line break, and the em dashes of every
// depreciation memo in one line.
let months: Vec<String> = ledger
.posted_entries()
.filter(|e| e.description().starts_with("Depreciation"))
.map(|e| e.id().to_string())
.collect();
assert_eq!(months.len(), 3);
let warnings = vec![
"account 6010's name \"Meals, \\\"client\\\"\" is written \"Meals, 'client'\"".to_string(),
format!("\"\\\"\" is written \"'\" in the memo of entry {lunch}"),
format!("\"\\\"\\n\" is written \"' \" in the memo of entry {lunch}"),
format!(
"\"—\" is written \"--\" in the memos of entries {}",
months.join(", ")
),
];
assert_eq!(out["warnings"], json!(warnings));
let iif = out["content"].as_str().unwrap();
assert!(iif.ends_with("\r\n") && !iif.replace("\r\n", "").contains(['\r', '\n']));
let mut types: HashMap<String, String> = HashMap::new();
let mut header: Vec<&str> = Vec::new();
let mut blocks = 0;
let mut docnums: Vec<u64> = Vec::new();
let mut sum = Decimal::ZERO;
let mut in_trns = false;
for line in iif.lines() {
for line in iif.split("\r\n") {
let f: Vec<&str> = line.split('\t').collect();
match f[0] {
"!ACCNT" => header = f.clone(),
@@ -1252,19 +1286,29 @@ fn iif_declares_accounts_by_role_and_balances_every_transaction() {
assert!(!in_trns, "TRNS inside a transaction");
in_trns = true;
sum = Decimal::ZERO;
docnums.push(f[7].parse().unwrap());
}
assert_eq!(
f[7],
docnums.last().unwrap().to_string(),
"DOCNUM on every line"
);
sum += dec(f[6]);
}
"ENDTRNS" => {
assert!(in_trns);
assert_eq!(sum, Decimal::ZERO, "an IIF transaction sums to zero");
in_trns = false;
blocks += 1;
}
_ => {}
}
}
assert_eq!(blocks, posted, "a transaction per posted entry");
let through: Vec<u64> = ledger
.posted_entries()
.filter(|e| e.date() <= d(END))
.map(|e| e.id())
.collect();
assert_eq!(docnums, through, "every posted entry through as_of, by id");
for (code, ty) in [
("1000", "BANK"),
("1200", "AR"),
@@ -1283,6 +1327,69 @@ fn iif_declares_accounts_by_role_and_balances_every_transaction() {
"account {code}'s IIF type"
);
}
// general_journal is the report's name; journal its alias (both work).
let alias = m.ok(
"books.export",
json!({"format": "iif", "report": "general_journal", "as_of": END}),
);
assert_eq!(alias["content"], out["content"]);
// The file holds everything from the first entry: a range is refused,
// and so is another report.
let why = m.refuse(
"books.export",
json!({"format": "iif", "report": "journal", "start": START, "end": END}),
);
assert!(why.contains("iif takes as_of, not start or end"), "{why}");
let why = m.refuse(
"books.export",
json!({"format": "iif", "report": "trial_balance"}),
);
assert!(
why.contains("only valid for the general_journal report"),
"{why}"
);
}
/// P3 review R5: books.export iif without as_of is as of today, like the
/// trial balance: an entry dated later is left out, and the two exports
/// made with no dates tie, account by account.
#[test]
fn iif_without_as_of_is_as_of_today_and_ties_to_the_trial_balance() {
let dir = test_dir("iif-today");
let mut s = new_store(&dir);
rich_books(&mut s);
post(
&mut s,
"2099-01-04",
"Far future rent",
&[("6000", Dr, "1234.00"), ("1000", Cr, "1234.00")],
);
drop(s);
let mut m = Mcp::start(&dir, "1000000.00");
let iif = m.export_text(json!({"format": "iif", "report": "journal"}));
assert!(!iif.contains("Far future rent"));
let mut number: HashMap<String, String> = HashMap::new();
let mut iif_bal: BTreeMap<String, Decimal> = BTreeMap::new();
for line in iif.split("\r\n") {
let f: Vec<&str> = line.split('\t').collect();
match f[0] {
"ACCNT" => {
number.insert(f[1].to_string(), f[3].to_string());
}
"TRNS" | "SPL" => *iif_bal.entry(number[f[4]].clone()).or_default() += dec(f[6]),
_ => {}
}
}
iif_bal.retain(|_, v| !v.is_zero());
let tb = m.export_text(json!({"format": "csv", "report": "trial_balance"}));
let mut tb_bal: BTreeMap<String, Decimal> = BTreeMap::new();
for row in csv_rows(&tb, &["code", "name", "debit", "credit"]) {
if row[0] != "TOTAL" {
tb_bal.insert(row[0].clone(), dec(&row[2]) - dec(&row[3]));
}
}
tb_bal.retain(|_, v| !v.is_zero());
assert_eq!(iif_bal, tb_bal);
}
// ------------------------------------------------- P&L by class
@@ -2767,7 +2874,9 @@ fn previews_move_balances_by_normal_side() {
/// the receipts behind the year's posted entries (and only those), indexed
/// with each entry's date, description, and debits; the company's
/// documents but not loose receipts or withdrawn files; a README counting
/// the receipts; and a 1099 worksheet equal to `vendor_1099_report`. With
/// the receipts; a 1099 worksheet equal to `vendor_1099_report` through
/// as_of (a payment after it to the same contractor left out); and the
/// journal as IIF from the first entry through as_of. With
/// include_receipts false, no evidence. Kills: export_cpa_package's
/// receipt and document guards, the receipt count, the file-name cleaner.
#[test]
@@ -2775,6 +2884,8 @@ fn cpa_package_holds_the_year_and_its_evidence() {
let dir = test_dir("cpa");
let mut s = new_store(&dir);
let r = rich_books(&mut s);
// The payment after as_of went to the contractor too.
s.set_entry_vendor(r.after, Some(r.contractor)).unwrap();
// Two receipts on the rent, same-length names.
let rent_a: &[u8] = b"rent receipt A";
let rent_b: &[u8] = b"rent receipt B";
@@ -2879,6 +2990,29 @@ fn cpa_package_holds_the_year_and_its_evidence() {
let gj = xlsx_text(&files["general-journal.xlsx"]);
assert!(gj.contains("Rent January") && !gj.contains("Prior-year sale"));
assert!(!gj.contains("After mid-year"), "fiscal year to as_of only");
// The IIF: from the first entry (the prior year's sale) through as_of.
let iif = std::str::from_utf8(&files["general-journal.iif"]).unwrap();
assert!(iif.contains("\tPrior-year sale\r\n") && iif.contains("\tRent January\r\n"));
assert!(!iif.contains("After mid-year"), "through as_of only");
assert!(iif.contains("\tLunch, 'team' quarterly\r\n"));
// The meals account's double quotes, the lunch's and its line break,
// and the em dashes of the depreciation memos: written differently, so
// said, the memos grouped (four lines for one name and four memos), in
// warnings and the README alike.
let warnings: Vec<String> = out["warnings"]
.as_array()
.unwrap()
.iter()
.map(|w| w.as_str().unwrap().to_string())
.collect();
assert_eq!(warnings.len(), 4, "{out}");
let readme = std::str::from_utf8(&files["README.txt"]).unwrap();
for w in &warnings {
assert!(readme.contains(&format!("\n- {w}\n")), "{w} in {readme}");
}
assert!(out["note"].as_str().unwrap().ends_with(
" 5 account name(s), number(s), place(s) or memo(s) are written differently in general-journal.iif to fit QuickBooks Desktop (warnings says how; so does the README): tell the human."
));
// Receipts: exactly the two behind the rent.
let receipt_files: Vec<&str> = names
@@ -2940,8 +3074,22 @@ fn cpa_package_holds_the_year_and_its_evidence() {
std::str::from_utf8(&files["1099-worksheet-2025.csv"]).unwrap(),
&["vendor", "form", "w9_on_file", "paid", "reportable"],
);
let want = ledger_core::vendor_1099_report(&ledger, 2025, None).unwrap();
let want = ledger_core::vendor_1099_report_with_members(
&ledger,
2025,
Some(d(END)),
None,
&ledger_core::PaymentAccounts::default(),
&[],
)
.unwrap();
assert_eq!(w.len(), want.vendors.len());
let contractor = w.iter().find(|row| row[0] == "Contractor").unwrap();
assert_eq!(
amt(&contractor[3]),
dec("900.00"),
"the payment after as_of is left out"
);
for (row, v) in w.iter().zip(&want.vendors) {
assert_eq!(row[0], v.name);
assert_eq!(row[1], v.form.map(|f| f.label()).unwrap_or(""));
+6 -1
View File
@@ -1261,7 +1261,6 @@ fn happy_path(name: &str) -> Mcp {
("xlsx", "balance_sheet"),
("pdf", "profit_and_loss"),
("csv", "cash_flow"),
("iif", "journal"),
("csv", "general_ledger"),
("json", "members_equity"),
("csv", "tax_summary"),
@@ -1271,6 +1270,12 @@ fn happy_path(name: &str) -> Mcp {
json!({"format": format, "report": report, "start": "2026-01-01", "end": "2026-12-31"}),
);
}
// The IIF takes as_of (every posted entry from the first through it),
// never a range.
m.ok(
"books.export",
json!({"format": "iif", "report": "general_journal", "as_of": "2026-12-31"}),
);
m.ok(
"books.export_cpa_package",
json!({"as_of": "2026-12-31", "include_receipts": false, "include_tins": false}),
+2
View File
@@ -19,4 +19,6 @@ zip = "9.0.1"
workspace = true
[dev-dependencies]
proptest = "1"
tempfile = "3"
toml = "1"
+34 -15
View File
@@ -2,29 +2,44 @@
//! and the package's `general-journal.iif`.
//!
//! ```text
//! GOAL: the CPA imports every posted entry into QuickBooks Desktop, each
//! account declared with the type QuickBooks needs.
//! SOURCE: ledger_import::iif (the IIF format and account types).
//! GOAL: the CPA imports the books through a day into QuickBooks Desktop,
//! each account declared with the type QuickBooks needs, and gets
//! the balances and the profit of the package's statements.
//! SOURCE: ledger_import::iif (the IIF format and account types); rules.md
//! B-33 (as of a day), B-34; DECISION (owner, 2026-10-20, plan
//! D-36: closing entries are left out).
//! INVARIANTS:
//! 1. The cash accounts are BANK; the receivable and payable control
//! accounts AR and AP; a registered asset's cost and accumulated
//! depreciation FIXASSET; every other account follows its type.
//! 2. Every posted entry, and only those.
//! accounts AR and AP; the cost and accumulated depreciation accounts
//! of an asset bought on or before as_of FIXASSET; every other
//! account follows its type. Every account in the chart is declared.
//! 2. Every posted entry dated on or before as_of, from the first, in
//! the order posted, except period closes: QuickBooks keeps no
//! closing entries (it works out retained earnings itself), so an
//! imported close would zero its period's profit. The file's
//! balances are the trial balance as of as_of without the closes.
//! ```
use chrono::NaiveDate;
use ledger_core::{AccountId, AccountType, Ledger};
/// Render the journal as IIF text via ledger-import, declaring every
/// account with its QuickBooks type: the cash accounts (`cash_codes`, the
/// books' `cash_accounts`) are BANK, the receivable and payable control
/// accounts AR and AP, a registered asset's cost and accumulated
/// depreciation FIXASSET, and the rest follow their wanderbooks type
/// (other current asset, other current liability, equity, income,
/// expense).
pub fn export_iif(ledger: &Ledger, cash_codes: &[String]) -> Result<String, String> {
/// The journal through `as_of` as IIF, via ledger-import (INVARIANTS 1-2),
/// declaring every account with its QuickBooks type: the cash accounts
/// (`cash_codes`, the books' `cash_accounts`) are BANK, the receivable and
/// payable control accounts AR and AP, the cost and accumulated
/// depreciation accounts of an asset bought by `as_of` FIXASSET, and the
/// rest follow their wanderbooks type (other current asset, other current
/// liability, equity, income, expense). Returns the file and every text it
/// writes differently from the books.
pub fn export_iif(
ledger: &Ledger,
cash_codes: &[String],
as_of: NaiveDate,
) -> Result<ledger_import::Iif, String> {
use ledger_import::{QbAccountType as Qb, iif_accounts, journal_to_iif};
let fixed: std::collections::HashSet<AccountId> = ledger
.registered_assets()
.filter(|a| a.purchase_date <= as_of)
.flat_map(|a| [a.cost_account, a.accum_depr_account])
.collect();
let receivables = ledger.role_account(&ledger_core::RECEIVABLES);
@@ -48,6 +63,10 @@ pub fn export_iif(ledger: &Ledger, cash_codes: &[String]) -> Result<String, Stri
}
}
});
let entries: Vec<_> = ledger.posted_entries().collect();
let closes = ledger.closing_entry_ids();
let entries: Vec<_> = ledger
.posted_entries()
.filter(|e| e.date() <= as_of && !closes.contains(&e.id()))
.collect();
journal_to_iif(&accounts, &entries).map_err(|e| e.to_string())
}
+102 -21
View File
@@ -6,16 +6,18 @@
//! GOAL: everything the CPA needs for the return in one zip, the K-1s'
//! and 1099s' TINs included when asked, built the same by the
//! server and the viewer.
//! SOURCE: docs/mcp-interface.md; B-26; DECISION (owner, 2026-10-15):
//! "it needs to be in the damn CPA package."
//! SOURCE: docs/mcp-interface.md; B-26; B-33 (as of one day); B-34 (the
//! IIF); DECISION (owner, 2026-10-15): "it needs to be in the damn
//! CPA package."
//! INVARIANTS:
//! 1. The statements (profit and loss, balance sheet, cash flow, members'
//! equity with members, tax summary, trial balance) each as PDF and
//! XLSX, for the fiscal year containing as_of, through as_of; the
//! fixed asset register when there are fixed assets; the general
//! ledger and journal (XLSX); the journal as IIF; the 1099 worksheet
//! for as_of's calendar year (or why it can't be made); with members,
//! the K-1 worksheet; a README first.
//! fixed asset register when an asset was bought by as_of; the
//! general ledger and journal (XLSX); the journal through as_of as
//! IIF; the 1099 worksheet for as_of's calendar year through as_of
//! (or why it can't be made); with members, the K-1 worksheet; a
//! README first.
//! 2. With members, k1-members.csv has one row per member: name, party,
//! share, capital account, TIN and type (or last four without a TIN
//! source), and the K-1 worksheet's capital figures (ledger_core::k1)
@@ -32,6 +34,20 @@
//! Withdrawn files never.
//! 5. Read-only (crate INVARIANT 1): the package is returned, its files
//! and the zip, and written nowhere.
//! 6. As of one day (B-33): no figure, entry, receipt or worksheet row
//! reflects anything dated after as_of; the chart and documents/ are
//! as they stand when the package is made.
//! 7. One list of cash accounts and one of financing accounts per
//! package: the request's, else the settings'. The cash flow, the K-1
//! worksheet, the IIF's BANK accounts and the 1099 worksheet's
//! payments all read the same.
//! 8. Every account name, number or place and every memo
//! general-journal.iif writes differently from the books (it holds
//! plain ASCII, within QuickBooks Desktop's limits and its rule that
//! a subaccount has its parent's type: B-34) is in `warnings` and the
//! README, the memos grouped by what is written differently; the
//! README also names the entries with a line on accounts receivable
//! or payable, which the file gives no customer or vendor.
//! ```
use chrono::NaiveDate;
@@ -84,6 +100,14 @@ pub struct Package {
pub tin_gaps: Vec<String>,
/// The receipts in receipts/ (one per entry they are behind).
pub receipt_count: usize,
/// What general-journal.iif writes differently from the books to fit
/// QuickBooks Desktop (INVARIANT 8), as `ledger_import::Iif::warnings`
/// says it: each account name and number a line, each thing written
/// differently in the memos a line with the entries it is in.
pub warnings: Vec<String>,
/// How many account names, numbers and memos general-journal.iif
/// writes differently (each one the warnings cover).
pub iif_changes: usize,
}
/// The package's file name for `as_of`.
@@ -112,6 +136,17 @@ pub fn build_package<B: PackageBooks + ?Sized>(
let pdf = |st: &ledger_import::Statement| statement_pdf(st, &meta).map_err(|e| e.to_string());
let fy = NaiveDate::parse_from_str(&fy_start, "%Y-%m-%d").map_err(|e| e.to_string())?;
let members = report_members(company);
// One list of each per package (INVARIANT 7).
let cash_codes = if req.cash_accounts.is_empty() {
settings.cash_accounts
} else {
req.cash_accounts
};
let financing_codes = if req.financing_accounts.is_empty() {
settings.financing_accounts
} else {
req.financing_accounts
};
// The financial statements, each as PDF and XLSX.
let mut statements: Vec<(&str, ledger_import::Statement)> = vec![
@@ -158,11 +193,6 @@ pub fn build_package<B: PackageBooks + ?Sized>(
statements.push(("members-equity", report::members_equity(&me)));
// The K-1 worksheet: each member's capital account for the
// year (Schedule K-1 Item L) and their TIN (INVARIANT 2).
let cash_codes = if req.cash_accounts.is_empty() {
settings.cash_accounts
} else {
req.cash_accounts
};
let cash: Vec<AccountId> = cash_codes
.iter()
.filter_map(|c| ledger.chart().find_by_code(c).map(|a| a.id))
@@ -312,8 +342,9 @@ pub fn build_package<B: PackageBooks + ?Sized>(
files.push((format!("{name}.pdf"), pdf(st)?));
files.push((format!("{name}.xlsx"), xlsx(XlsxReport::Statement(st))?));
}
// The fixed asset register, when there are fixed assets.
if ledger.registered_assets().next().is_some() {
// The fixed asset register, when an asset was bought by as_of (one
// bought later is not in it, so neither is an empty register).
if ledger.registered_assets().any(|a| a.purchase_date <= as_of) {
let r = ledger_core::fixed_asset_register(ledger, as_of).map_err(|e| e.to_string())?;
files.push((
"fixed-asset-register.pdf".into(),
@@ -334,10 +365,17 @@ pub fn build_package<B: PackageBooks + ?Sized>(
"general-journal.xlsx".into(),
xlsx(XlsxReport::GeneralJournal(ledger, fy, as_of))?,
));
// 1099 worksheet for the calendar year of as_of.
// 1099 worksheet for the calendar year of as_of, through as_of
// (INVARIANT 6): the payments from January 1, out of the package's
// cash and financing accounts (INVARIANT 7).
let year = chrono::Datelike::year(&as_of);
let mut worksheet_1099 = false;
match vendor_1099_report(ledger, settings, year, None) {
let payments = Settings {
cash_accounts: cash_codes,
financing_accounts: financing_codes,
..*settings
};
match vendor_1099_report(ledger, &payments, year, Some(as_of), None) {
Ok(r1099) => {
let mut w = String::from("vendor,form,w9_on_file,paid,reportable");
if tins.is_some() {
@@ -516,13 +554,20 @@ pub fn build_package<B: PackageBooks + ?Sized>(
files.push(k1);
}
let seq = books.journal_seq();
files.push((
"general-journal.iif".into(),
export_iif(ledger, settings.cash_accounts)?.into_bytes(),
));
// The journal through as_of for QuickBooks, closes left out, and what
// it writes differently (INVARIANT 8).
let iif = export_iif(ledger, cash_codes, as_of)?;
let (warnings, iif_changes) = (iif.warnings(), iif.changes.len());
let receivable_payable: Vec<String> = iif
.receivable_payable
.iter()
.map(ToString::to_string)
.collect();
files.push(("general-journal.iif".into(), iif.text.into_bytes()));
let year_end = NaiveDate::from_ymd_opt(year, 12, 31) == Some(as_of);
let include_tins = tins.is_some();
let readme = format!(
"{name} — books package for the CPA\n\nFiscal year to date: {fy_start} through {as_of_s}. {basis} basis.\nKept in wanderbooks: double-entry books from an append-only, hash-chained journal ({seq} events, verified).\n\nStatements (PDF and XLSX): profit and loss, balance sheet, cash flow, {members}tax summary, trial balance.\nLedgers (XLSX): general ledger by account; general journal entry by entry.\ngeneral-journal.iif: every posted entry, for import into QuickBooks Desktop.\n{worksheet}{k1}{receipts}{tins}\nThe tax summary maps accounts to return categories; it's a worksheet, not a filing. Questions go to the owners, who can ask their bookkeeping agent to trace any number to its entries and receipts.\n",
"{name} — books package for the CPA\n\nFiscal year to date: {fy_start} through {as_of_s}. {basis} basis.\nKept in wanderbooks: double-entry books from an append-only, hash-chained journal ({seq} events, verified).\n\nStatements (PDF and XLSX): profit and loss, balance sheet, cash flow, {members}tax summary, trial balance.\nLedgers (XLSX): general ledger by account; general journal entry by entry.\n{iif}{worksheet}{k1}{receipts}{tins}\nThe tax summary maps accounts to return categories; it's a worksheet, not a filing. Questions go to the owners, who can ask their bookkeeping agent to trace any number to its entries and receipts.\n",
name = company.name,
basis = company.basis,
members = if company.members.is_empty() {
@@ -530,9 +575,43 @@ pub fn build_package<B: PackageBooks + ?Sized>(
} else {
"members' equity, "
},
iif = {
let mut t = format!(
"general-journal.iif: every posted entry from the first through {as_of_s}, for import into QuickBooks Desktop. Plain ASCII text (its import turns anything else into \"?\"), CR LF line endings; DOCNUM is each entry's id in these books. Period-closing entries are left out: QuickBooks keeps none and works out retained earnings itself, so an imported close would zero its period's profit; trial-balance includes them{}.\n",
if company.members.is_empty() {
""
} else {
", and the members' equity statement and the K-1 worksheet show each member's share"
}
);
if !receivable_payable.is_empty() {
t.push_str(&format!(
"It names no customer or vendor on its lines to accounts receivable or payable, in {} {}: QuickBooks Desktop asks for one on a journal entry to A/R or A/P (and takes only one A/R or A/P account a transaction); add it there if QuickBooks asks.\n",
if receivable_payable.len() == 1 { "entry" } else { "entries" },
receivable_payable.join(", ")
));
}
if !warnings.is_empty() {
t.push_str(&format!(
"Written differently in general-journal.iif to fit QuickBooks Desktop (plain ASCII; account names at most {} characters a level, no two alike; a subaccount of another QuickBooks type than its parent's at the top level; account numbers at most {} characters, no two alike; memos at most {} characters); every other file keeps the books' text:\n",
ledger_import::NAME_MAX,
ledger_import::NUMBER_MAX,
ledger_import::MEMO_MAX
));
for w in &warnings {
t.push_str(&format!("- {w}\n"));
}
}
t
},
worksheet = if worksheet_1099 {
format!(
"1099-worksheet-{year}.csv: cash paid per vendor marked for a 1099, against the year's threshold{}.\n",
"1099-worksheet-{year}.csv: cash paid per vendor marked for a 1099{}, against the year's threshold{}.\n",
if year_end {
String::new()
} else {
format!(" from January 1 through {as_of_s}")
},
if include_tins {
", with each payee's TIN"
} else {
@@ -623,5 +702,7 @@ pub fn build_package<B: PackageBooks + ?Sized>(
zip: zip_buf,
tin_gaps,
receipt_count,
warnings,
iif_changes,
})
}
+7 -2
View File
@@ -45,16 +45,20 @@ pub fn payment_accounts(ledger: &Ledger, settings: &Settings<'_>) -> ledger_core
/// The 1099 report for `year`, the one every tool, the worksheet, and the
/// CPA package use: payments out of [`payment_accounts`], an entry's
/// shares resolved with the settings' members.
/// shares resolved with the settings' members. Given `through`, only the
/// payments from January 1 through it (the CPA package as of a day,
/// rules.md B-33).
pub fn vendor_1099_report(
ledger: &Ledger,
settings: &Settings<'_>,
year: i32,
through: Option<chrono::NaiveDate>,
threshold: Option<Decimal>,
) -> Result<ledger_core::Vendor1099Report, ledger_core::LedgerError> {
ledger_core::vendor_1099_report_with_members(
ledger,
year,
through,
threshold,
&payment_accounts(ledger, settings),
&settings.company.party_members(ledger),
@@ -128,7 +132,8 @@ pub fn vendor_1099_worksheet(
) -> Result<Vendor1099Worksheet, String> {
// A year past what wanderbooks knows is refused with guidance
// (research or ask, pass `threshold`, then update the rule).
let r = vendor_1099_report(ledger, settings, year, threshold).map_err(|e| e.to_string())?;
let r =
vendor_1099_report(ledger, settings, year, None, threshold).map_err(|e| e.to_string())?;
let missing_w9 = r
.vendors
.iter()
+962 -3
View File
@@ -4,13 +4,16 @@
use chrono::NaiveDate;
use ledger_core::settings::{Company, Member};
use ledger_core::{AccountType, Currency, Form1099, Money, PartyId, Role, Side, TinFacts, TinType};
use ledger_core::{
AccountType, Currency, Form1099, Ledger, Money, PartyId, Role, Side, TinFacts, TinType,
};
use ledger_package::{
Package, PackageBooks, PackageRequest, Settings, build_package, vendor_1099_csv,
vendor_1099_csv_name, vendor_1099_worksheet,
};
use ledger_store::{Snapshot, Store, Subject};
use rust_decimal::Decimal;
use std::collections::{BTreeMap, HashMap};
use std::io::Read;
use std::path::Path;
use std::str::FromStr;
@@ -366,8 +369,19 @@ fn a_single_member_package_holds_the_documented_files_in_order() {
assert!(!names(&p).contains(&"k1-members.csv"));
let iif = text(&p, "general-journal.iif");
assert!(iif.contains("Checking") && iif.contains("BANK"), "{iif}");
// Every posted entry, the year after as_of's included.
assert!(iif.contains("Next year's rent"), "{iif}");
// Every posted entry through as_of; the next year's rent is not.
assert!(!iif.contains("Next year's rent"), "{iif}");
// The rent's memo, `Rent, "March"`: its double quotes written single
// (QuickBooks reads one as a text delimiter), and said.
assert!(iif.contains("\tRent, 'March'\r\n"), "{iif}");
assert_eq!(
p.warnings,
[format!(
"\"\\\"\" is written \"'\" in the memo of entry {}",
b.rent
)]
);
assert_eq!(p.iif_changes, 1);
assert_zip_is_the_files(&p);
}
@@ -988,3 +1002,948 @@ fn a_receipts_index_amount_is_the_entrys_debit_total_as_written() {
assert_eq!(fields[4], "100.00", "{row}");
assert_eq!(fields[5], format!("receipts/2026-06-01-entry-{e}-june.pdf"));
}
// ---------------------------------------------------------------------------
// As of one day (rules.md B-33), and the QuickBooks file (B-34)
// ---------------------------------------------------------------------------
/// The IIF's transactions in file order: each one's DOCNUM, memo, and
/// lines as (account NAME, signed amount).
type IifTxn = (u64, String, Vec<(String, Decimal)>);
fn iif_txns(iif: &str) -> Vec<IifTxn> {
let mut out: Vec<IifTxn> = Vec::new();
for l in iif.split("\r\n") {
let f: Vec<&str> = l.split('\t').collect();
let line = || (f[4].to_string(), Decimal::from_str(f[6]).unwrap());
match f[0] {
"TRNS" => out.push((f[7].parse().unwrap(), f[8].to_string(), vec![line()])),
"SPL" => out.last_mut().unwrap().2.push(line()),
_ => {}
}
}
out
}
/// Each account's balance in the IIF, debit positive, by ACCNUM; zero
/// balances left out.
fn iif_balances(iif: &str) -> BTreeMap<String, Decimal> {
let number: HashMap<String, String> = iif
.split("\r\n")
.map(|l| l.split('\t').collect::<Vec<_>>())
.filter(|f| f[0] == "ACCNT")
.map(|f| (f[1].to_string(), f[3].to_string()))
.collect();
let mut out: BTreeMap<String, Decimal> = BTreeMap::new();
for (_, _, lines) in iif_txns(iif) {
for (name, a) in lines {
*out.entry(number[&name].clone()).or_default() += a;
}
}
out.retain(|_, v| !v.is_zero());
out
}
/// The trial balance as of `as_of` (ledger_core::reports), debit
/// positive, by code, without the closing entries' lines; zero balances
/// left out.
fn tb_without_closes(ledger: &Ledger, as_of: NaiveDate) -> BTreeMap<String, Decimal> {
let mut out: BTreeMap<String, Decimal> =
ledger_core::reports::trial_balance_as_of(ledger, as_of)
.lines
.into_iter()
.map(|l| (l.code, l.debit - l.credit))
.collect();
for id in ledger.closing_entry_ids() {
let e = ledger.get_entry(id).unwrap();
if e.date() > as_of {
continue;
}
for l in e.lines() {
let code = ledger.chart().get(l.account).unwrap().code.clone();
let signed = match l.side {
Side::Debit => l.amount.amount,
Side::Credit => -l.amount.amount,
};
*out.entry(code).or_default() -= signed;
}
}
out.retain(|_, v| !v.is_zero());
out
}
/// Defect 21 (P3; found in P0, 2026-10-16): the package's
/// general-journal.iif held every posted entry, the ones after as_of too,
/// while every other file stopped at as_of. Now it holds every posted
/// entry from the first through as_of, each with its id as DOCNUM, and its
/// balances are the trial balance as of as_of.
#[test]
fn as_of_package_iif_stops_at_as_of() {
let dir = tempfile::tempdir().unwrap();
let b = books(dir.path());
let company = company(false);
let cash = cash();
let p = build_package(&b.store, &settings(&company, &cash), &request(false, None)).unwrap();
let iif = text(&p, "general-journal.iif");
let ledger = b.store.ledger();
let as_of = d("2026-12-31");
let through: Vec<u64> = ledger
.posted_entries()
.filter(|e| e.date() <= as_of)
.map(|e| e.id())
.collect();
assert_eq!(
through.len(),
4,
"the year's four entries; next year's rent is the fifth"
);
let txns = iif_txns(iif);
assert_eq!(txns.iter().map(|t| t.0).collect::<Vec<_>>(), through);
assert!(txns.iter().all(|t| t.1 != "Next year's rent"), "{iif}");
assert_eq!(iif_balances(iif), tb_without_closes(ledger, as_of));
assert!(text(&p, "README.txt").contains(
"general-journal.iif: every posted entry from the first through 2026-12-31, for import into QuickBooks Desktop. Plain ASCII text (its import turns anything else into \"?\"), CR LF line endings; DOCNUM is each entry's id in these books."
));
}
/// B-33 at its edges, through ledger_package::export_iif (the package's
/// own): an entry dated as_of is in and one dated the day after is not; a
/// reversal after as_of leaves its original in, under the original's id;
/// as of a day before the first entry the file declares the accounts and
/// holds no transaction.
#[test]
fn the_iif_through_as_of_at_its_edges() {
let dir = tempfile::tempdir().unwrap();
let (mut s, rent) = plain_books(dir.path());
let fee = post(
&mut s,
"2026-03-31",
"Bank fee",
&[
("6000", Side::Debit, "5.00"),
("1000", Side::Credit, "5.00"),
],
);
let reversal = s
.reverse_entry(rent, d("2026-04-01"), "Rent refunded")
.unwrap();
let ledger = s.ledger();
let cash = cash();
let ids = |as_of: &str| -> Vec<u64> {
let iif = ledger_package::export_iif(ledger, &cash, d(as_of)).unwrap();
assert!(iif.changes.is_empty());
assert_eq!(iif_balances(&iif.text), tb_without_closes(ledger, d(as_of)));
iif_txns(&iif.text).iter().map(|t| t.0).collect()
};
assert_eq!(ids("2026-02-28"), Vec::<u64>::new());
assert_eq!(ids("2026-03-01"), [rent]);
assert_eq!(ids("2026-03-30"), [rent]);
assert_eq!(ids("2026-03-31"), [rent, fee]);
assert_eq!(ids("2026-04-01"), [rent, fee, reversal]);
// Before the first entry: every account, no transaction.
let empty = ledger_package::export_iif(ledger, &cash, d("2026-02-28")).unwrap();
let accnt = empty
.text
.split("\r\n")
.filter(|l| l.starts_with("ACCNT\t"))
.count();
assert_eq!(accnt, ledger.chart().len());
assert!(!empty.text.contains("\r\nTRNS\t"));
assert!(empty.text.ends_with("!ENDTRNS\r\n"));
}
/// The books as the package reads them, the journal's length fixed: it is
/// the one thing later activity rightly changes in a package as of an
/// earlier day (every report's footer and the README name it).
struct AtSeq<'a>(&'a Store, u64);
impl PackageBooks for AtSeq<'_> {
fn ledger(&self) -> &Ledger {
PackageBooks::ledger(self.0)
}
fn journal_seq(&self) -> u64 {
self.1
}
fn all_attachments(&self) -> Result<Vec<ledger_store::Attachment>, ledger_store::StoreError> {
PackageBooks::all_attachments(self.0)
}
fn links_of(&self, id: u64) -> Result<Vec<Subject>, ledger_store::StoreError> {
PackageBooks::links_of(self.0, id)
}
fn attachment_bytes(&self, id: u64) -> Result<Vec<u8>, ledger_store::StoreError> {
PackageBooks::attachment_bytes(self.0, id)
}
}
/// [`books`] with equipment accounts and a bank fee dated `as_of` with its
/// receipt; with `desk`, a desk bought in January and registered, its
/// depreciation posted through as_of's month. With `later`, the days after
/// as well: a payment to the 1099 vendor marked "AFTER AS_OF" with its
/// receipt, a laptop bought and registered as a fixed asset, the bank fee
/// reversed, the members contributing again, and the desk (if any) sold.
fn books_around(dir: &Path, as_of: NaiveDate, later: bool, desk: bool) -> Books {
let mut b = books(dir);
let s = &mut b.store;
for (code, name, ty) in [
("1500", "Equipment", AccountType::Asset),
("1510", "Accumulated depreciation", AccountType::Asset),
("1600", "Furniture", AccountType::Asset),
(
"1610",
"Accumulated depreciation, furniture",
AccountType::Asset,
),
("6500", "Depreciation", AccountType::Expense),
("7900", "Gain or loss on disposal", AccountType::Revenue),
] {
s.add_account(code, name, ty, None, false).unwrap();
}
let fee = post(
s,
&as_of.to_string(),
"Bank fee on the day",
&[
("6000", Side::Debit, "35.00"),
("1000", Side::Credit, "35.00"),
],
);
s.attach_receipt(Some(fee), "fee.pdf", "application/pdf", b"%PDF fee", None)
.unwrap();
let desk = desk.then(|| {
post(
s,
"2026-01-10",
"Desk",
&[
("1600", Side::Debit, "1200.00"),
("1000", Side::Credit, "1200.00"),
],
);
let (cost, accum, expense) = (id_of(s, "1600"), id_of(s, "1610"), id_of(s, "6500"));
let id = s
.register_asset(ledger_core::NewAsset {
description: "Desk".into(),
purchase_date: d("2026-01-10"),
cost: usd("1200.00"),
salvage_value: usd("0.00"),
useful_life_months: 60,
cost_account: cost,
accum_depr_account: accum,
expense_account: expense,
})
.unwrap();
for m in 1..=chrono::Datelike::month(&as_of) {
s.run_depreciation(id, &format!("2026-{m:02}")).unwrap();
}
id
});
if later {
let next = as_of.succ_opt().unwrap();
let e = s.create_entry(next, "AFTER AS_OF").unwrap();
s.add_line(e, id_of(s, "6100"), Side::Debit, usd("777.77"))
.unwrap();
s.add_line(e, id_of(s, "1000"), Side::Credit, usd("777.77"))
.unwrap();
s.set_entry_vendor(e, Some(b.vendor)).unwrap();
s.post_entry(e).unwrap();
s.attach_receipt(Some(e), "after.pdf", "application/pdf", b"%PDF after", None)
.unwrap();
let bought = next.succ_opt().unwrap();
post(
s,
&bought.to_string(),
"Laptop",
&[
("1500", Side::Debit, "1800.00"),
("1000", Side::Credit, "1800.00"),
],
);
let (cost, accum, expense) = (id_of(s, "1500"), id_of(s, "1510"), id_of(s, "6500"));
s.register_asset(ledger_core::NewAsset {
description: "Laptop".into(),
purchase_date: bought,
cost: usd("1800.00"),
salvage_value: usd("0.00"),
useful_life_months: 36,
cost_account: cost,
accum_depr_account: accum,
expense_account: expense,
})
.unwrap();
s.reverse_entry(fee, next, "Bank fee refunded").unwrap();
post(
s,
&next.to_string(),
"Members' contributions, again",
&[
("1000", Side::Debit, "600.00"),
("3000", Side::Credit, "300.00"),
("3001", Side::Credit, "300.00"),
],
);
if let Some(desk) = desk {
let (cash, gain) = (id_of(s, "1000"), id_of(s, "7900"));
// Sold at a gain (its book value is at most 1,080.00), credited
// to 7900.
s.dispose_asset(desk, bought, usd("1150.00"), cash, gain)
.unwrap();
}
}
b
}
/// Plan P3, defect 21: "a test pins every file in an as-of package to
/// it". B-33: no figure, entry, receipt or worksheet row reflects anything
/// dated after as_of. Two sets of books, the same through as_of, one with
/// activity in the days after (a 1099 payment with its receipt, an asset
/// bought, a reversal of an entry dated as_of, the members' contributions,
/// a desk bought before as_of sold), make the same package byte for byte,
/// every file and which files there are: statements (PDF and XLSX),
/// ledgers, the fixed asset register, the IIF, the 1099 and K-1
/// worksheets, the receipts and the README; with and without TINs and
/// receipts; with and without the desk (so with and without a register);
/// mid-year and at year end. And the later activity is no phantom: a
/// package a few days later shows it.
#[test]
fn every_file_in_an_as_of_package_stops_at_as_of() {
let company = company(true);
let cash = cash();
for (as_of, desk) in [
("2026-06-30", false),
("2026-06-30", true),
("2026-12-31", false),
("2026-12-31", true),
] {
let as_of = d(as_of);
let dir = tempfile::tempdir().unwrap();
let without = books_around(&dir.path().join("without"), as_of, false, desk);
let with = books_around(&dir.path().join("with"), as_of, true, desk);
let (src_without, src_with) = (tin_source(&without), tin_source(&with));
for receipts in [true, false] {
for tins in [false, true] {
let build =
|b: &Books, src: &dyn Fn(PartyId) -> Result<ledger_vault::Tin, String>| {
let req = PackageRequest {
as_of,
..request(receipts, tins.then_some(src))
};
build_package(&AtSeq(&b.store, 99), &settings(&company, &cash), &req)
.unwrap()
};
let a = build(&without, &src_without);
let b = build(&with, &src_with);
assert_eq!(names(&a), names(&b), "as of {as_of}");
assert_eq!(
names(&a).contains(&"fixed-asset-register.pdf"),
desk,
"as of {as_of}"
);
for ((name, x), (_, y)) in stable(&a.files).iter().zip(stable(&b.files)) {
assert!(*x == y, "{name}, as of {as_of}, shows what came after");
}
assert_eq!(a.tin_gaps, b.tin_gaps);
assert_eq!(a.receipt_count, b.receipt_count);
assert_eq!(a.warnings, b.warnings);
assert_eq!(a.iif_changes, b.iif_changes);
}
}
// A package days on sees the later activity.
let later = PackageRequest {
as_of: as_of + chrono::Duration::days(3),
..request(true, None)
};
let p = build_package(&with.store, &settings(&company, &cash), &later).unwrap();
let iif = text(&p, "general-journal.iif");
for memo in [
"AFTER AS_OF",
"Bank fee refunded",
"Members' contributions, again",
] {
assert!(iif.contains(memo), "{memo}");
}
assert!(names(&p).contains(&"fixed-asset-register.pdf"));
assert!(names(&p).iter().any(|n| n.ends_with("-after.pdf")));
}
}
/// Plan D-36 (P3, defect 21): the 1099 worksheet stops at the package's
/// as_of, like every other file in it: the payments from January 1
/// through as_of (§6041(a) and §6041A(a)(2) count what is paid in the
/// calendar year, so a package as of June 30 shows the year to date, and a
/// vendor not yet at the threshold is not yet reportable). A payment dated
/// as_of counts; one the day after doesn't. A year-end package is
/// unchanged, its README too.
#[test]
fn the_1099_worksheet_stops_at_as_of() {
let dir = tempfile::tempdir().unwrap();
let (mut s, _) = plain_books(dir.path());
s.add_account("6100", "Contract labor", AccountType::Expense, None, false)
.unwrap();
let vendor = s.add_vendor("Contractor Example").unwrap();
s.set_vendor_1099(vendor, Some(Form1099::Nec), false)
.unwrap();
for (date, amount) in [("2026-06-30", "1500.00"), ("2026-07-01", "1000.00")] {
let e = s.create_entry(d(date), "Contract work").unwrap();
s.add_line(e, id_of(&s, "6100"), Side::Debit, usd(amount))
.unwrap();
s.add_line(e, id_of(&s, "1000"), Side::Credit, usd(amount))
.unwrap();
s.set_entry_vendor(e, Some(vendor)).unwrap();
s.post_entry(e).unwrap();
}
let company = company(false);
let cash = cash();
let at = |as_of: &str| {
let req = PackageRequest {
as_of: d(as_of),
..request(false, None)
};
build_package(&s, &settings(&company, &cash), &req).unwrap()
};
let mid = at("2026-06-30");
assert_eq!(
text(&mid, "1099-worksheet-2026.csv"),
"vendor,form,w9_on_file,paid,reportable\n\"Contractor Example\",1099-NEC,false,1500.00,false\n"
);
assert!(text(&mid, "README.txt").contains(
"\n1099-worksheet-2026.csv: cash paid per vendor marked for a 1099 from January 1 through 2026-06-30, against the year's threshold.\n"
));
let year_end = at("2026-12-31");
assert_eq!(
text(&year_end, "1099-worksheet-2026.csv"),
"vendor,form,w9_on_file,paid,reportable\n\"Contractor Example\",1099-NEC,false,2500.00,true\n"
);
assert!(text(&year_end, "README.txt").contains(
"\n1099-worksheet-2026.csv: cash paid per vendor marked for a 1099, against the year's threshold.\n"
));
// The worksheet the tools make for the whole year is unchanged.
let w =
vendor_1099_worksheet(s.ledger(), &settings(&company, &cash), 2026, None, None).unwrap();
assert_eq!(w.lines[0].paid, Decimal::from(2500));
}
/// Plan D-36: the IIF leaves the books' period closes out. QuickBooks
/// keeps no closing entries (it works out retained earnings itself), so an
/// imported close would zero its period's profit there. Two months
/// closed: no close is written; the file's income and expenses are the
/// profit and loss for the year to date; its balances are the trial
/// balance without the closes; and the README says so.
#[test]
fn iif_leaves_out_period_closes() {
let dir = tempfile::tempdir().unwrap();
let (mut s, _) = plain_books(dir.path());
s.add_account("4000", "Sales", AccountType::Revenue, None, false)
.unwrap();
post(
&mut s,
"2026-01-15",
"January sale",
&[
("1000", Side::Debit, "5000.00"),
("4000", Side::Credit, "5000.00"),
],
);
post(
&mut s,
"2026-02-10",
"February sale",
&[
("1000", Side::Debit, "700.00"),
("4000", Side::Credit, "700.00"),
],
);
let mut closes = Vec::new();
for (name, start, end) in [
("2026-01", "2026-01-01", "2026-01-31"),
("2026-02", "2026-02-01", "2026-02-28"),
] {
let id = s.create_period(name, d(start), d(end)).unwrap();
closes.push(s.close_period(id).unwrap().expect("income to close"));
}
let ledger = s.ledger();
assert_eq!(
ledger.closing_entry_ids().into_iter().collect::<Vec<_>>(),
closes
);
let as_of = d("2026-03-31");
let company = company(false);
let cash = cash();
let req = PackageRequest {
as_of,
..request(false, None)
};
let p = build_package(&s, &settings(&company, &cash), &req).unwrap();
let iif = text(&p, "general-journal.iif");
let written: Vec<u64> = iif_txns(iif).iter().map(|t| t.0).collect();
let want: Vec<u64> = ledger
.posted_entries()
.filter(|e| e.date() <= as_of && !closes.contains(&e.id()))
.map(|e| e.id())
.collect();
assert_eq!(written, want);
assert_eq!(want.len(), 3, "two sales and the rent");
let balances = iif_balances(iif);
assert_eq!(balances, tb_without_closes(ledger, as_of));
// Income and expenses: the year to date's profit and loss.
let pl = ledger_core::reports::profit_and_loss_for_period(ledger, d("2026-01-01"), as_of, None)
.unwrap();
for l in &pl.revenue {
assert_eq!(balances[&l.code], -l.amount, "{}", l.code);
}
for l in &pl.expenses {
assert_eq!(balances[&l.code], l.amount, "{}", l.code);
}
assert_eq!(pl.total_revenue - pl.total_expenses, Decimal::from(4500));
// The package's own trial balance keeps the closes: income zeroed into
// Retained Earnings through February.
let with_closes = ledger_core::reports::trial_balance_as_of(ledger, as_of);
assert!(with_closes.lines.iter().any(|l| l.code == "3999"));
assert!(!balances.contains_key("3999"));
assert!(text(&p, "README.txt").contains(
"Period-closing entries are left out: QuickBooks keeps none and works out retained earnings itself, so an imported close would zero its period's profit; trial-balance includes them.\n"
));
}
/// Plan P3 review R18: one list of cash accounts per package. The
/// request's (savings with checking) types the IIF's BANK accounts and
/// counts the 1099 payments the same as it reconciles the cash flow;
/// without one, the settings' (checking alone) do all three.
#[test]
fn one_list_of_cash_accounts_per_package() {
let dir = tempfile::tempdir().unwrap();
let (mut s, _) = plain_books(dir.path());
s.add_account("1010", "Savings", AccountType::Asset, None, false)
.unwrap();
s.add_account("6100", "Contract labor", AccountType::Expense, None, false)
.unwrap();
post(
&mut s,
"2026-01-05",
"Transfer to savings",
&[
("1010", Side::Debit, "3000.00"),
("1000", Side::Credit, "3000.00"),
],
);
let vendor = s.add_vendor("Contractor Example").unwrap();
s.set_vendor_1099(vendor, Some(Form1099::Nec), false)
.unwrap();
let e = s.create_entry(d("2026-05-01"), "Contract work").unwrap();
s.add_line(e, id_of(&s, "6100"), Side::Debit, usd("2100.00"))
.unwrap();
s.add_line(e, id_of(&s, "1010"), Side::Credit, usd("2100.00"))
.unwrap();
s.set_entry_vendor(e, Some(vendor)).unwrap();
s.post_entry(e).unwrap();
let company = company(false);
let cash = cash();
let both = vec!["1000".to_string(), "1010".to_string()];
let mut req = request(false, None);
req.cash_accounts = &both;
let given = build_package(&s, &settings(&company, &cash), &req).unwrap();
let plain = build_package(&s, &settings(&company, &cash), &request(false, None)).unwrap();
let savings = |p: &Package| {
text(p, "general-journal.iif")
.split("\r\n")
.find(|l| l.starts_with("ACCNT\tSavings\t"))
.unwrap()
.to_string()
};
assert_eq!(savings(&given), "ACCNT\tSavings\tBANK\t1010");
assert_eq!(savings(&plain), "ACCNT\tSavings\tOCASSET\t1010");
assert!(text(&given, "1099-worksheet-2026.csv").contains(",2100.00,true\n"));
assert!(text(&plain, "1099-worksheet-2026.csv").contains(",0.00,false\n"));
}
/// Plan D-36 (P3, defect 7): general-journal.iif is plain ASCII, and
/// everything it writes differently is in the package's warnings and the
/// README: each account name a line; the memos grouped by what is written
/// differently, each with the entries it is in (lead's call, 2026-10-20),
/// so the em dash of many memos is one line; every other file keeps the
/// books' text. Books whose names and memos are plain ASCII get no list.
#[test]
fn the_readme_lists_what_the_iif_writes_differently() {
let dir = tempfile::tempdir().unwrap();
let (mut s, _) = plain_books(dir.path());
s.add_account("6200", "Café 北京", AccountType::Expense, None, false)
.unwrap();
let lunch = post(
&mut s,
"2026-04-02",
"Lunch \u{2014} \u{201C}Zoë\u{201D}\nteam",
&[
("6200", Side::Debit, "42.00"),
("1000", Side::Credit, "42.00"),
],
);
let mut months = Vec::new();
for m in 4..=9 {
months.push(post(
&mut s,
&format!("2026-{m:02}-28"),
&format!("Depreciation \u{2014} Laptop \u{2014} 2026-{m:02}"),
&[
("6000", Side::Debit, "50.00"),
("1000", Side::Credit, "50.00"),
],
));
}
let company = company(false);
let cash = cash();
let p = build_package(&s, &settings(&company, &cash), &request(false, None)).unwrap();
let dashes: Vec<String> = std::iter::once(lunch)
.chain(months.iter().copied())
.map(|e| e.to_string())
.collect();
let want = [
"account 6200's name \"Café 北京\" is written \"Cafe Bei Jing\"".to_string(),
format!(
"\"—\" is written \"--\" in the memos of entries {}",
dashes.join(", ")
),
format!("\"“\" is written \"'\" in the memo of entry {lunch}"),
// "ë", "”" and the line break are side by side: one run.
format!("\"ë”\\n\" is written \"e' \" in the memo of entry {lunch}"),
];
assert_eq!(p.warnings, want);
assert_eq!(p.iif_changes, 1 + 1 + 6, "the name, the lunch, six months");
let readme = text(&p, "README.txt");
let mut block = String::from(
"Written differently in general-journal.iif to fit QuickBooks Desktop (plain ASCII; account names at most 31 characters a level, no two alike; a subaccount of another QuickBooks type than its parent's at the top level; account numbers at most 7 characters, no two alike; memos at most 4095 characters); every other file keeps the books' text:\n",
);
for w in &want {
block.push_str(&format!("- {w}\n"));
}
assert!(readme.contains(&block), "{readme}");
let iif = text(&p, "general-journal.iif");
assert!(iif.is_ascii() && iif.contains("\tCafe Bei Jing\t"));
// The general journal workbook keeps the books' own text.
let gj = xlsx_strings(&p, "general-journal.xlsx");
assert!(
gj.contains("Café 北京") && gj.contains("Lunch — “Zoë”"),
"{gj}"
);
// ASCII books: no list.
let dir = tempfile::tempdir().unwrap();
let (s, _) = plain_books(dir.path());
let p = build_package(&s, &settings(&company, &cash), &request(false, None)).unwrap();
assert!(p.warnings.is_empty() && p.iif_changes == 0);
assert!(!text(&p, "README.txt").contains("Written differently"));
}
/// B-34 rule 7 (P3 review round 2): the file names no customer or vendor
/// (Intuit states no such need for an IIF journal; QuickBooks Desktop asks
/// for one on a journal entry to accounts receivable or payable, and takes
/// one such account a transaction: sources/intuit-iif.md), so the README
/// names the entries with a line on either, in file order, and says what
/// QuickBooks may ask. An entry on both is among them. Books with neither
/// get no such sentence.
#[test]
fn the_readme_names_the_entries_on_receivables_and_payables() {
let dir = tempfile::tempdir().unwrap();
let (mut s, rent) = plain_books(dir.path());
for (code, name, ty) in [
("1200", "Accounts Receivable", AccountType::Asset),
("2000", "Accounts Payable", AccountType::Liability),
("4000", "Sales", AccountType::Revenue),
] {
s.add_account(code, name, ty, None, false).unwrap();
}
let invoiced = post(
&mut s,
"2026-04-01",
"Invoice 7",
&[
("1200", Side::Debit, "500.00"),
("4000", Side::Credit, "500.00"),
],
);
let billed = post(
&mut s,
"2026-04-02",
"Bill 3",
&[
("6000", Side::Debit, "80.00"),
("2000", Side::Credit, "80.00"),
],
);
let netted = post(
&mut s,
"2026-04-03",
"Netted",
&[
("2000", Side::Debit, "80.00"),
("1200", Side::Credit, "80.00"),
],
);
let company = company(false);
let cash = cash();
let p = build_package(&s, &settings(&company, &cash), &request(false, None)).unwrap();
let iif = text(&p, "general-journal.iif");
assert!(
iif.contains("ACCNT\tAccounts Receivable\tAR\t1200\r\n"),
"{iif}"
);
assert!(
iif.contains("ACCNT\tAccounts Payable\tAP\t2000\r\n"),
"{iif}"
);
// No NAME column on the transactions.
assert!(
iif.contains("\r\n!TRNS\tTRNSID\tTRNSTYPE\tDATE\tACCNT\tCLASS\tAMOUNT\tDOCNUM\tMEMO\r\n")
);
assert_eq!(
iif_txns(iif).iter().map(|t| t.0).collect::<Vec<_>>(),
[rent, invoiced, billed, netted]
);
let readme = text(&p, "README.txt");
assert!(
readme.contains(&format!(
"\nIt names no customer or vendor on its lines to accounts receivable or payable, in entries {invoiced}, {billed}, {netted}: QuickBooks Desktop asks for one on a journal entry to A/R or A/P (and takes only one A/R or A/P account a transaction); add it there if QuickBooks asks.\n"
)),
"{readme}"
);
// One entry: "entry", not "entries".
let dir = tempfile::tempdir().unwrap();
let (mut s, _) = plain_books(dir.path());
s.add_account(
"1200",
"Accounts Receivable",
AccountType::Asset,
None,
false,
)
.unwrap();
let one = post(
&mut s,
"2026-04-01",
"Owed",
&[
("1200", Side::Debit, "5.00"),
("1000", Side::Credit, "5.00"),
],
);
let p = build_package(&s, &settings(&company, &cash), &request(false, None)).unwrap();
assert!(text(&p, "README.txt").contains(&format!(
"lines to accounts receivable or payable, in entry {one}: "
)));
// Neither: no sentence.
let dir = tempfile::tempdir().unwrap();
let (s, _) = plain_books(dir.path());
let p = build_package(&s, &settings(&company, &cash), &request(false, None)).unwrap();
assert!(!text(&p, "README.txt").contains("customer or vendor"));
}
/// An XLSX's shared strings, as text.
fn xlsx_strings(p: &Package, name: &str) -> String {
let (_, bytes) = p.files.iter().find(|(n, _)| n == name).unwrap();
let mut x = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut out = String::new();
x.by_name("xl/sharedStrings.xml")
.unwrap()
.read_to_string(&mut out)
.unwrap();
out
}
/// Random books for [`iif_ties_to_the_trial_balance_as_of`]: entries of
/// (day of 2026, debit account, credit account, cents), reversals of some
/// of them a few days on, and the first `closed` months closed.
fn random_ledger(
entries: &[(i64, usize, usize, i64)],
reversals: &[(proptest::sample::Index, i64)],
closed: u32,
) -> Ledger {
let mut l = Ledger::new(Currency::USD);
let mut ids = Vec::new();
for (code, name, ty) in [
("1000", "Checking", AccountType::Asset),
("1100", "Receivables", AccountType::Asset),
("2000", "Card", AccountType::Liability),
("3000", "Capital", AccountType::Equity),
("4000", "Sales", AccountType::Revenue),
("6000", "Rent", AccountType::Expense),
] {
ids.push(l.add_account(code, name, ty, None, false).unwrap());
}
let jan1 = d("2026-01-01");
let mut posted = Vec::new();
for (i, &(day, dr, cr, cents)) in entries.iter().enumerate() {
let cr = if cr == dr { (dr + 1) % ids.len() } else { cr };
let e = l.create_entry(jan1 + chrono::Duration::days(day), &format!("Entry {i}"));
let m = Money::new(Decimal::new(cents, 2), Currency::USD);
l.add_line(e, ids[dr], Side::Debit, m).unwrap();
l.add_line(e, ids[cr], Side::Credit, m).unwrap();
l.post_entry(e).unwrap();
posted.push(e);
}
for (pick, days) in reversals {
if posted.is_empty() {
break;
}
let e = posted[pick.index(posted.len())];
let on = l.get_entry(e).unwrap().date() + chrono::Duration::days(*days);
// An entry is reversed at most once (B-10): a second pick is skipped.
let _ = l.reverse_entry(e, on, "Reversal");
}
for m in 1..=closed {
let start = NaiveDate::from_ymd_opt(2026, m, 1).unwrap();
let end = NaiveDate::from_ymd_opt(2026, m + 1, 1)
.unwrap()
.pred_opt()
.unwrap();
let p = l
.create_period(&format!("2026-{m:02}"), start, end)
.unwrap();
l.close_period(p).unwrap();
}
l
}
proptest::proptest! {
#![proptest_config(proptest::prelude::ProptestConfig::with_cases(2000))]
/// B-33 and B-34 over random books, random as_of (before the first
/// entry, on an entry's date, after the last), reversals on either side
/// of it and closed months: the IIF holds exactly the posted entries
/// dated on or before as_of that aren't closes, in posting order, each
/// numbered with its id and summing to zero; it declares every account;
/// its balances are the trial balance as of as_of without the closes.
#[test]
fn iif_ties_to_the_trial_balance_as_of(
entries in proptest::collection::vec((0i64..120, 0usize..6, 0usize..6, 1i64..10_000_000), 0..20),
reversals in proptest::collection::vec((proptest::prelude::any::<proptest::sample::Index>(), 0i64..40), 0..4),
closed in 0u32..4,
as_of in -5i64..170,
) {
let l = random_ledger(&entries, &reversals, closed);
let as_of = d("2026-01-01") + chrono::Duration::days(as_of);
let iif = ledger_package::export_iif(&l, &cash(), as_of).unwrap();
let closes = l.closing_entry_ids();
let want: Vec<u64> = l
.posted_entries()
.filter(|e| e.date() <= as_of && !closes.contains(&e.id()))
.map(|e| e.id())
.collect();
let txns = iif_txns(&iif.text);
proptest::prop_assert_eq!(txns.iter().map(|t| t.0).collect::<Vec<_>>(), want);
for (id, _, lines) in &txns {
proptest::prop_assert_eq!(lines.iter().map(|(_, a)| *a).sum::<Decimal>(), Decimal::ZERO, "entry {}", id);
}
let declared = iif.text.split("\r\n").filter(|l| l.starts_with("ACCNT\t")).count();
proptest::prop_assert_eq!(declared, l.chart().len());
proptest::prop_assert_eq!(iif_balances(&iif.text), tb_without_closes(&l, as_of));
proptest::prop_assert!(iif.changes.is_empty());
}
}
/// The bookspec example `bookspec/examples/iif-quickbooks-journal-file.toml`
/// (P3's accept: each regression test also a bookspec example), its
/// expected values derived by hand, against the engine: the example's
/// books, built from its setup and events, export the very file and
/// changes it says.
#[test]
fn the_bookspec_iif_example_matches_the_engine() {
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../bookspec/examples/iif-quickbooks-journal-file.toml"
);
let spec: toml::Value = toml::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
let setup = &spec["setup"];
let mut l = Ledger::new(Currency::USD);
for a in setup["accounts"].as_array().unwrap() {
let ty = match a["type"].as_str().unwrap() {
"asset" => AccountType::Asset,
"liability" => AccountType::Liability,
"equity" => AccountType::Equity,
"revenue" => AccountType::Revenue,
_ => AccountType::Expense,
};
let parent = a
.get("parent")
.map(|p| l.chart().find_by_code(p.as_str().unwrap()).unwrap().id);
l.add_account(
a["code"].as_str().unwrap(),
a["name"].as_str().unwrap(),
ty,
parent,
false,
)
.unwrap();
}
for (i, e) in spec["events"].as_array().unwrap().iter().enumerate() {
let id = l.create_entry(
d(e["date"].as_str().unwrap()),
e["description"].as_str().unwrap(),
);
assert_eq!(
id,
i as u64 + 1,
"an event's entry id is its index plus one"
);
for line in e["lines"].as_array().unwrap() {
let account = l
.chart()
.find_by_code(line["account"].as_str().unwrap())
.unwrap()
.id;
let side = match line["side"].as_str().unwrap() {
"debit" => Side::Debit,
_ => Side::Credit,
};
l.add_line(id, account, side, usd(line["amount"].as_str().unwrap()))
.unwrap();
}
l.post_entry(id).unwrap();
}
let cash: Vec<String> = setup["cash_accounts"]
.as_array()
.unwrap()
.iter()
.map(|c| c.as_str().unwrap().to_string())
.collect();
let strs = |v: &toml::Value| -> Vec<String> {
v.as_array()
.unwrap()
.iter()
.map(|s| s.as_str().unwrap().to_string())
.collect()
};
let mut checked = 0;
for x in spec["expect"].as_array().unwrap() {
let iif = ledger_package::export_iif(&l, &cash, d(x["as_of"].as_str().unwrap())).unwrap();
match x["query"].as_str().unwrap() {
"iif_lines" => {
let mut want = strs(&x["values"]).join("\r\n");
want.push_str("\r\n");
assert_eq!(iif.text, want);
}
"iif_changes" => {
let got: Vec<Vec<String>> = iif
.changes
.iter()
.map(|c| {
let what = match &c.field {
ledger_import::IifField::AccountName { code } => format!("name {code}"),
ledger_import::IifField::Memo { entry } => format!("memo {entry}"),
ledger_import::IifField::AccountNumber
| ledger_import::IifField::AccountNumberLeftOut { .. } => {
"number".to_string()
}
ledger_import::IifField::AtTopLevel { code, .. } => {
format!("top level {code}")
}
};
vec![what, c.books.clone(), c.written.clone()]
})
.collect();
let want: Vec<Vec<String>> =
x["values"].as_array().unwrap().iter().map(strs).collect();
assert_eq!(got, want);
}
q => panic!("unknown query {q}"),
}
checked += 1;
}
assert_eq!(checked, 2);
}
+9 -2
View File
@@ -1858,8 +1858,15 @@ fn every_figure_of(l: &ledger_core::Ledger) -> (usize, usize) {
financing: vec![],
};
tally(
ledger_core::form1099::vendor_1099_report_with_members(l, year, None, &sources, &[])
.map(|_| ()),
ledger_core::form1099::vendor_1099_report_with_members(
l,
year,
None,
None,
&sources,
&[],
)
.map(|_| ()),
);
}
let money = ledger_core::parties::money_accounts(l, &cash);