diff --git a/crates/ledger-import/src/chart_csv.rs b/crates/ledger-import/src/chart_csv.rs index b3bfad2..47d1d89 100644 --- a/crates/ledger-import/src/chart_csv.rs +++ b/crates/ledger-import/src/chart_csv.rs @@ -56,6 +56,21 @@ pub enum ChartCsvError { /// Parse a chart CSV. Returns rows in file order (parents should come first, /// but order is not validated here). +/// +/// ```text +/// GOAL: read a chart of accounts a person hands the agent. +/// SOURCE: docs/mcp-interface.md (books.import_chart_csv); CONVENTION for +/// the layout. +/// INVARIANTS: +/// 1. The first non-blank row is the header, exactly code,name,type, +/// parent_code with an optional fifth tax_category (any case); +/// anything else is BadHeader. +/// 2. Every later row has the header's width (BadColumnCount), a type +/// among asset, liability, equity, revenue, expense, any case +/// (BadType), and a non-blank code and name (Blank, with its row). +/// 3. A blank parent or tax category is None; blank lines are skipped; +/// an empty file is no rows. +/// ``` pub fn parse_chart_csv(text: &str) -> Result, ChartCsvError> { let mut reader = csv::ReaderBuilder::new() .has_headers(false) @@ -64,13 +79,15 @@ pub fn parse_chart_csv(text: &str) -> Result, ChartCsvError> { .from_reader(text.as_bytes()); let mut out = Vec::new(); let mut width = 4; + let mut header_seen = false; for (idx, record) in reader.records().enumerate() { let record = record.map_err(|e| ChartCsvError::Csv(e.to_string()))?; let fields: Vec<&str> = record.iter().collect(); if fields.iter().all(|f| f.is_empty()) { continue; } - if idx == 0 { + if !header_seen { + header_seen = true; let lower: Vec = fields.iter().map(|f| f.to_lowercase()).collect(); width = if lower == ["code", "name", "type", "parent_code"] { 4 diff --git a/crates/ledger-import/src/pdf.rs b/crates/ledger-import/src/pdf.rs index e653617..aaaeb7f 100644 --- a/crates/ledger-import/src/pdf.rs +++ b/crates/ledger-import/src/pdf.rs @@ -98,6 +98,19 @@ fn typeset(st: &Statement, meta: &ReportMeta) -> Result } /// Render a laid-out statement (see [`crate::report`]) as a PDF. +/// +/// ```text +/// GOAL: a statement a CPA can print and file. +/// SOURCE: CONVENTION (layout); docs/spec/sources/irs-records.md (records +/// "capable of being processed", reproducible). +/// INVARIANTS: +/// 1. Deterministic: the same statement and metadata render to the same +/// bytes. +/// 2. Every label is data, never markup: a name holding Typst syntax +/// ("#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. +/// ``` pub fn statement_pdf(st: &Statement, meta: &ReportMeta) -> Result, PdfError> { let doc = typeset(st, meta)?; typst_pdf::pdf(&doc, &typst_pdf::PdfOptions::default()) diff --git a/crates/ledger-import/src/xlsx.rs b/crates/ledger-import/src/xlsx.rs index da33a65..e55643c 100644 --- a/crates/ledger-import/src/xlsx.rs +++ b/crates/ledger-import/src/xlsx.rs @@ -163,6 +163,19 @@ fn number( } /// Render a report to XLSX bytes. +/// +/// ```text +/// GOAL: a statement as a spreadsheet a CPA can sum and check. +/// SOURCE: docs/spec/sources/xlsx-numbers.md (a cell number is a binary +/// double; Excel honors 15 significant digits). +/// INVARIANTS: +/// 1. An amount is a number cell, in an accounting format (thousands +/// separators, negatives in parentheses), unless Excel couldn't show +/// it exactly (finer than a cent, or past 15 digits): then its exact +/// text, right-aligned. NEVER a number Excel would show wrong. +/// 2. Every statement row is a row, in order, with its label. +/// 3. The output is a valid XLSX (a zip holding a workbook). +/// ``` pub fn report_to_xlsx(report: &XlsxReport<'_>, meta: &ReportMeta) -> Result, XlsxError> { let mut workbook = Workbook::new(); let sheet = workbook.add_worksheet();