Files
wanderbooks/crates/ledger-package/src/package.rs
T
swaitsandClaude Opus 5.5 c2ceabbae9 P3: party totals by what moved the money, once (defects 14, 11, 24)
Each party's part of an entry is signed as its own account would show it,
with a direction and its cash and in-kind amounts; a reversal is its
original negated. Totals on a cash or all basis: for a member, all equals
the members' equity statement and cash the K-1's cash. Money is cash,
cards and the financing accounts, and a liability credit never pays its
own creditor (creditor_of), so accruals and declared distributions count
once. tin_last4 from the TIN or W-9 facts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-21 12:12:26 -06:00

714 lines
31 KiB
Rust

//! The CPA handoff package: every statement, ledger, and worksheet the CPA
//! needs for the return, with the evidence behind the year's entries, in
//! one zip.
//!
//! ```text
//! 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; 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 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)
//! for the fiscal year to date; the members' total row with more than
//! one. The 1099 worksheet adds each payee's party, last four, TIN,
//! and type with a TIN source.
//! 3. A TIN the source can't give never fails the package: the row says
//! why, and so do the README and tin_gaps (members, and 1099 payees
//! that are reportable).
//! 4. With include_receipts: every current file behind the year's
//! posted entries, filed on them or linked to them, once per entry,
//! each with a row in receipts/index.csv; and the company's
//! documents, the files behind no entry (loose receipts aside).
//! 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;
use ledger_core::{AccountId, Side, reports};
use rust_decimal::Decimal;
use crate::books::PackageBooks;
use crate::iif::export_iif;
use crate::reports::{
Settings, bs_statement, cash_flow_for, member_capitals, pnl_statement, report_members,
report_meta, year_earnings,
};
use crate::text::{csv_escape, money, share_text};
use crate::tins::{TinSource, tin_type_key};
use crate::vendor_1099::vendor_1099_report;
/// What the package is built for.
#[derive(Clone, Copy)]
pub struct PackageRequest<'a> {
/// The day it is as of: the balance sheet's date, the end of every
/// period report (the fiscal year to date).
pub as_of: NaiveDate,
/// The day it is prepared, for the reports' headers.
pub prepared_on: NaiveDate,
/// Cash account codes the cash flow statement reconciles to (empty:
/// the settings' cash accounts).
pub cash_accounts: &'a [String],
/// Financing account codes (empty: the settings' financing accounts).
pub financing_accounts: &'a [String],
/// Investing account codes (empty: the settings' investing accounts).
pub investing_accounts: &'a [String],
/// Whether the receipts and the company's documents go in
/// (INVARIANT 4).
pub include_receipts: bool,
/// Where the worksheets' full TINs come from: none, last fours only.
pub tins: Option<TinSource<'a>>,
}
/// A built package.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Package {
/// The zip's name: `cpa-package-<as_of>.zip`.
pub file_name: String,
/// Every file in the zip, in its order: (name in the zip, bytes).
pub files: Vec<(String, Vec<u8>)>,
/// The zip.
pub zip: Vec<u8>,
/// With a TIN source: each member or reportable 1099 payee whose TIN
/// the package lacks, and why (INVARIANT 3).
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`.
pub fn package_file_name(as_of: NaiveDate) -> String {
format!("cpa-package-{as_of}.zip")
}
/// Build the CPA package from `books` (INVARIANTS 1-5).
pub fn build_package<B: PackageBooks + ?Sized>(
books: &B,
settings: &Settings<'_>,
req: &PackageRequest<'_>,
) -> Result<Package, String> {
use ledger_import::{XlsxReport, report, report_to_xlsx, statement_pdf};
let company = settings.company;
let (as_of, today) = (req.as_of, req.prepared_on);
let meta = report_meta(company, req.prepared_on, books.journal_seq());
let ledger = books.ledger();
let tins = req.tins;
let mut tin_gaps: Vec<String> = Vec::new();
// The fiscal year to date: the tax year holding as_of (IRS Pub. 538),
// the fiscal-year fact itself, not "what omitted dates mean" (rules.md
// B-30), so the package's year never follows a default.
let fy = ledger_core::calendar::fiscal_year_start_on(company.fiscal_year_start, as_of);
let (fy_start, as_of_s) = (fy.to_string(), as_of.to_string());
let xlsx = |r: XlsxReport<'_>| report_to_xlsx(&r, &meta).map_err(|e| e.to_string());
let pdf = |st: &ledger_import::Statement| statement_pdf(st, &meta).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![
(
"profit-and-loss",
report::profit_and_loss(&pnl_statement(
ledger,
company,
Some(&fy_start),
Some(&as_of_s),
None,
today,
)?),
),
(
"balance-sheet",
report::balance_sheet(
&bs_statement(ledger, company, Some(&as_of_s), today)?,
year_earnings(ledger, company, as_of)?,
&members,
),
),
(
"cash-flow",
// The package's one list of each (INVARIANT 7); investing
// accounts are the cash flow's alone (the request's, else the
// settings').
report::cash_flow(&cash_flow_for(
ledger,
settings,
Some(&fy_start),
Some(&as_of_s),
cash_codes,
financing_codes,
req.investing_accounts,
today,
)?),
),
];
let mut k1_csv: Option<(String, Vec<u8>)> = None;
if !company.members.is_empty() {
let capitals = member_capitals(ledger, &company.members)?;
let me =
ledger_core::members_equity(ledger, fy, as_of, &capitals).map_err(|e| e.to_string())?;
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: Vec<AccountId> = cash_codes
.iter()
.filter_map(|c| ledger.chart().find_by_code(c).map(|a| a.id))
.collect();
k1_csv = Some(
match ledger_core::k1::k1_worksheet(ledger, fy, as_of, &capitals, &cash)
.map_err(|e| e.to_string())
.and_then(|rows| {
// A row per member, in their order, or none: a row is
// never paired with another member.
if rows.len() == company.members.len() {
Ok(rows)
} else {
Err(format!(
"the K-1 worksheet has {} rows for {} members",
rows.len(),
company.members.len()
))
}
}) {
Ok(rows) => {
let mut w = String::from("member,party_id,share,capital_account,");
w.push_str(if tins.is_some() {
"tin,tin_type,tin_note,"
} else {
"tin_last4,tin_type,"
});
w.push_str("beginning_capital,contributions_cash,contributions_property,net_income,distributions,other,ending_capital\n");
for (row, m) in rows.iter().zip(&company.members) {
let party = ledger_core::k1::member_party(ledger, &m.name);
let facts = party
.and_then(|id| ledger.get_party(id))
.and_then(|p| p.tin.clone());
let mut cells = vec![
csv_escape(&m.name),
party.map(|id| id.to_string()).unwrap_or_default(),
share_text(m.share),
m.capital_account.clone(),
];
match tins {
Some(tins) => {
let got = match party {
None => Err(format!(
"TIN not in the vault: no party named {:?} with the member role (books.add_party with role member, or books.set_party_roles, then books.set_tin)",
m.name
)),
Some(id) => tins(id),
};
match got {
Ok(t) => {
cells.push(t.formatted());
cells.push(tin_type_key(t.kind).into());
cells.push(String::new());
}
Err(why) => {
tin_gaps.push(format!("member {}: {why}", m.name));
cells.push(String::new());
cells.push(
facts
.map(|f| f.tin_type.key().to_string())
.unwrap_or_default(),
);
cells.push(csv_escape(&why));
}
}
}
None => {
cells.push(
party
.and_then(|id| ledger.get_party(id))
.and_then(|p| p.tin_last4())
.unwrap_or_default()
.to_string(),
);
cells.push(
facts
.map(|f| f.tin_type.key().to_string())
.unwrap_or_default(),
);
}
}
for v in [
row.beginning,
row.contributions_cash,
row.contributions_property,
row.net_income,
row.distributions,
row.other,
row.ending,
] {
cells.push(money(v));
}
w.push_str(&cells.join(","));
w.push('\n');
}
// The members together: ties to the 1065's Schedule L
// capital and the statement of members' equity. Not
// a K-1 (each member gets their own).
if rows.len() > 1 {
let sum = |f: fn(&ledger_core::k1::K1Row) -> Decimal| {
rows.iter().map(f).sum::<Decimal>()
};
let share: Decimal = company.members.iter().map(|m| m.share).sum();
let mut cells = vec![
csv_escape("Total, all members (not a K-1)"),
String::new(),
share_text(share),
String::new(),
String::new(),
String::new(),
];
if tins.is_some() {
cells.push(String::new());
}
for v in [
sum(|r| r.beginning),
sum(|r| r.contributions_cash),
sum(|r| r.contributions_property),
sum(|r| r.net_income),
sum(|r| r.distributions),
sum(|r| r.other),
sum(|r| r.ending),
] {
cells.push(money(v));
}
w.push_str(&cells.join(","));
w.push('\n');
}
("k1-members.csv".into(), w.into_bytes())
}
Err(e) => (
"k1-members-NOT-INCLUDED.txt".into(),
format!("{e}\n").into_bytes(),
),
},
);
}
statements.push((
"tax-summary",
report::tax_summary(
&ledger_core::tax_summary(ledger, fy, as_of, company.tax_form)
.map_err(|e| e.to_string())?,
),
));
statements.push((
"trial-balance",
report::trial_balance(&reports::trial_balance_as_of(ledger, as_of), as_of),
));
let mut files: Vec<(String, Vec<u8>)> = Vec::new();
for (name, st) in &statements {
files.push((format!("{name}.pdf"), pdf(st)?));
files.push((format!("{name}.xlsx"), xlsx(XlsxReport::Statement(st))?));
}
// 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(),
ledger_import::asset_register_pdf(&r, &meta).map_err(|e| e.to_string())?,
));
files.push((
"fixed-asset-register.xlsx".into(),
ledger_import::asset_register_xlsx(&r, &meta).map_err(|e| e.to_string())?,
));
}
// The books behind them: by account, and entry by entry.
let gl = ledger_core::general_ledger(ledger, fy, as_of).map_err(|e| e.to_string())?;
files.push((
"general-ledger.xlsx".into(),
xlsx(XlsxReport::GeneralLedger(&gl))?,
));
files.push((
"general-journal.xlsx".into(),
xlsx(XlsxReport::GeneralJournal(ledger, fy, 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;
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() {
w.push_str(",party_id,tin_last4,tin,tin_type,tin_note");
}
w.push('\n');
for v in &r1099.vendors {
w.push_str(&format!(
"\"{}\",{},{},{},{}",
v.name.replace('"', "\"\""),
v.form.map(|f| f.label()).unwrap_or(""),
v.tin_on_file,
money(v.paid),
v.reportable
));
// The payee's full TIN from the source (INVARIANT 2).
if let Some(tins) = tins {
let party = ledger.party_of_vendor(v.vendor_id);
let last4 = party
.and_then(|id| ledger.get_party(id))
.and_then(|p| p.tin_last4())
.map(str::to_string);
let got = match party {
None => Err("TIN not in the vault: no party for this vendor".into()),
Some(id) => tins(id),
};
let (tin, kind, note) = match got {
Ok(t) => (
t.formatted(),
tin_type_key(t.kind).to_string(),
String::new(),
),
Err(why) => {
if v.reportable {
tin_gaps.push(format!("1099 payee {}: {why}", v.name));
}
(String::new(), String::new(), why)
}
};
w.push_str(&format!(
",{},{},{tin},{kind},{}",
party.map(|id| id.to_string()).unwrap_or_default(),
last4.unwrap_or_default(),
csv_escape(&note)
));
}
w.push('\n');
}
files.push((format!("1099-worksheet-{year}.csv"), w.into_bytes()));
worksheet_1099 = true;
}
// The threshold isn't known for this year: never guess it
// (rules.md B-19); say so in the package instead.
Err(e) => files.push((
format!("1099-worksheet-{year}-NOT-INCLUDED.txt"),
format!("{e}\n").into_bytes(),
)),
}
// The evidence: every current file behind the year's posted
// entries, filed on them or linked to them (a file linked to an
// entry counts as its receipt), one copy per entry with a row in
// the index; and the company's documents, the files behind no
// entry (INVARIANT 4).
let mut receipt_count = 0;
let mut document_count = 0;
// Each file's name in the zip is its own: a second `r.pdf` is
// `r (2).pdf` (compared ignoring case, so it unpacks the same on every
// system), and the index names the file as it is stored.
let mut used: std::collections::HashSet<String> =
files.iter().map(|(n, _)| n.to_lowercase()).collect();
let mut unique = |name: String| -> String {
// The extension is the last segment's own: a folder with a dot
// ("a.b/notes") keeps its name, and a name that is all extension
// (".env") has none.
let cut = name.rfind('/').map_or(0, |i| i + 1);
let (stem, ext) = match name[cut..].rfind('.') {
Some(i) if i > 0 => (name[..cut + i].to_string(), name[cut + i..].to_string()),
_ => (name.clone(), String::new()),
};
let mut n = 1;
let mut candidate = name;
while !used.insert(candidate.to_lowercase()) {
n += 1;
candidate = format!("{stem} ({n}){ext}");
}
candidate
};
if req.include_receipts {
let clean = |s: &str| -> String {
let c: String = s
.chars()
.map(|c| {
if c.is_alphanumeric() || ".-_ ".contains(c) {
c
} else {
'_'
}
})
.collect();
// A name of dots alone ("", ".", "..") would name no file, or
// climb out of its folder when the zip is unpacked.
if c.chars().all(|c| c == '.') {
"_".repeat(c.len().max(1))
} else {
c
}
};
let mut index = String::from("entry_id,date,description,amount,file,sha256\n");
for a in books.all_attachments().map_err(|e| e.to_string())? {
if a.withdrawn_at.is_some() {
continue;
}
let mut behind: Vec<u64> = a.entry_id.into_iter().collect();
for sub in books.links_of(a.id).map_err(|e| e.to_string())? {
if let ledger_store::Subject::Entry(e) = sub
&& !behind.contains(&e)
{
behind.push(e);
}
}
behind.sort_unstable();
let bytes = books.attachment_bytes(a.id).map_err(|e| e.to_string())?;
if behind.is_empty() {
if a.category != "receipts" {
files.push((
unique(format!(
"documents/{}/{}",
clean(&a.category),
clean(&a.file_name)
)),
bytes,
));
document_count += 1;
}
continue;
}
for e in behind.iter().filter_map(|&e| ledger.get_entry(e)) {
if e.status() != ledger_core::EntryStatus::Posted
|| e.date() < fy
|| e.date() > as_of
{
continue;
}
let name = unique(format!(
"receipts/{}-entry-{}-{}",
e.date(),
e.id(),
clean(&a.file_name)
));
let amount: Decimal = e
.lines()
.iter()
.filter(|l| l.side == Side::Debit)
.map(|l| l.amount.amount)
.sum();
index.push_str(&format!(
"{},{},\"{}\",{},{},{}\n",
e.id(),
e.date(),
e.description().replace('"', "\"\""),
money(amount),
name,
a.blob_sha
));
files.push((name, bytes.clone()));
receipt_count += 1;
}
}
files.push(("receipts/index.csv".into(), index.into_bytes()));
}
let k1_included = k1_csv
.as_ref()
.is_some_and(|(name, _)| name == "k1-members.csv");
if let Some(k1) = k1_csv {
files.push(k1);
}
let seq = books.journal_seq();
// 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.\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() {
""
} 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",
if year_end {
String::new()
} else {
format!(" from January 1 through {as_of_s}")
},
if include_tins {
", with each payee's TIN"
} else {
""
}
)
} else {
format!(
"1099-worksheet-{year}-NOT-INCLUDED.txt: why the year's 1099 worksheet isn't here.\n"
)
},
k1 = if company.members.is_empty() {
String::new()
} else if !k1_included {
"k1-members-NOT-INCLUDED.txt: why the members' K-1 worksheet isn't here.\n".to_string()
} else {
let mut k = format!(
"k1-members.csv: each member's capital account for the year (Schedule K-1 Item L: beginning, contributions in cash and in property, share of income, distributions, ending), with their {}{}.\n",
if include_tins {
"taxpayer ID"
} else {
"taxpayer ID's last four"
},
if company.members.len() > 1 {
"; the last row totals the members (not a K-1) to tie to Schedule L and the statement of members' equity"
} else {
""
}
);
let members = &company.members;
for (i, m) in members.iter().enumerate() {
if let Some(j) = &m.files_jointly_with
&& members[..i].iter().all(|o| o.name != *j)
{
k.push_str(&format!(
"{} and {j} file a joint individual return: each receives their own Schedule K-1; both are reported on the one Form 1040 (Schedule E, Part II).\n",
m.name
));
}
}
k
},
tins = if include_tins {
let mut t = String::from(
"\nThis package holds full taxpayer identification numbers: keep it out of email and shared folders, and delete copies once filed.\n",
);
if !tin_gaps.is_empty() {
t.push_str("TINs not included:\n");
for g in &tin_gaps {
t.push_str(&format!("- {g}\n"));
}
}
t
} else {
String::new()
},
receipts = if req.include_receipts {
format!(
"receipts/: the {receipt_count} receipts behind the period's entries; index.csv ties each to its entry, date, amount, and SHA-256.\n{}",
if document_count > 0 {
"documents/: the company's formation, legal, and tax documents.\n"
} else {
""
}
)
} else {
String::new()
},
);
files.insert(0, ("README.txt".into(), readme.into_bytes()));
// Zip it.
let mut zip_buf = Vec::new();
{
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut zip_buf));
let opts = zip::write::SimpleFileOptions::default();
for (name, bytes) in &files {
zip.start_file(name.as_str(), opts)
.map_err(|e| e.to_string())?;
use std::io::Write;
zip.write_all(bytes).map_err(|e| e.to_string())?;
}
zip.finish().map_err(|e| e.to_string())?;
}
Ok(Package {
file_name: package_file_name(as_of),
files,
zip: zip_buf,
tin_gaps,
receipt_count,
warnings,
iif_changes,
})
}