The P3 cutover review's open point 1: entries carry projects (classes, B-37), but the QuickBooks Desktop file (B-34) left the CLASS field empty on every line, and neither B-34 nor the README said so, though B-34 rule 6 says nothing changes silently. Intuit's IIF Import Kit (vendored in docs/spec/sources/intuit-iif.md, the kit's SHA-256 unchanged): the `!CLASS` header help says "Only the NAME column is required" and a subclass's NAME is its parents' names first, joined by colons; the kit's "Import First - List Declarations" example declares `!CLASS` rows before any transaction, and its "2004 Deposit (Income)" example names the class line by line in CLASS. So the file now declares every class in a `!CLASS` list after the accounts and before `!TRNS`, and writes each line's class in CLASS (empty where the line has none). Books with no class write no list, byte for byte as before. A line whose class wasn't declared is refused (IifError::UndeclaredClass), never written classless. A class's NAME follows B-34's rules for an account's own name: plain ASCII (rule 3), a colon written '-' (it would start a subclass), trimmed, '?' if nothing is left, cut to 31 characters: Intuit's qbXML Onscreen Reference gives `ClassAdd`'s Name "maxDT" : 31 (and 31 in the Canadian, UK and Australian desktop editions; 100 only in Online), the same as its `AccountAdd` Name (both JSON files vendored by SHA-256); Conductor's QuickBooks Desktop API reference agrees and says the name is case-insensitive. Classes whose NAMEs would match ignoring case get " (id)", round after round, as accounts get " (code)", except that among names that match, those already written differently from the books (made safe, or tagged) yield first, a second " (id)" if need be: a class's own name is kept unless another class's own name is the same ignoring case (classes 1 "Café", 2 "Cafe", 3 "Cafe (1)" are written "Cafe (1) (1)", "Cafe", "Cafe (1)"). Each class is tagged twice at most and ids differ, so none is left alike (a hand-built list is refused). Each class name written differently is an IifChange (IifField::ClassName), said one line each in the warnings and in the package README, whose "written differently" paragraph now names class names' limits; its IIF line says CLASS is each line's project when there are classes. The MCP and viewer notes count "account or class name(s)". API: `iif_classes(ledger)` builds the list; `journal_to_iif` takes it (`&[]` for none). Docs: rules.md B-34 rule 8 with its sources; intuit-iif.md (the `!CLASS` help, the list declarations and deposit examples, Intuit's SDK department page, the qbXML Onscreen Reference's ClassAdd and AccountAdd and Conductor's class reference, each with SHA-256); mcp-interface.md (books.export iif, the package note); the bookspec example iif-quickbooks-journal-file.toml (two projects: one carried and renamed, one only declared; every line derived by hand); testing.md; STATUS (open point 1 done; point 3 waits for the close, the owner said). Tests: ledger-import `iif_writes_each_lines_class_and_declares_the_classes_first`, `iif_class_names_fit_quickbooks_and_every_change_is_listed`, `iif_class_names_written_as_the_books_have_them_are_kept`, `iif_refuses_an_undeclared_or_unfit_class`, property `iif_class_names_never_clash`; ledger-package `the_package_iif_carries_each_lines_project` and the bookspec example. Broken to confirm: CLASS written empty fails the two exact-file tests, the package test and the bookspec example; the " (id)" tag dropped fails the names test and the property; tagging every name that meets (no yielding) fails the kept-names test and the property (its shrunk case, "A-B" beside "A\uFF1AB", kept in the regressions file). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
224 lines
8.8 KiB
Rust
224 lines
8.8 KiB
Rust
//! The viewer's menu (plan P2 "Viewer menu", D-29): the files the books
|
|
//! make for the human, made by the viewer itself into the Downloads
|
|
//! folder, with their full TINs from the vault. Nothing here writes the
|
|
//! books.
|
|
//!
|
|
//! ```text
|
|
//! GOAL: the owner gets the CPA package and the 1099 worksheet from the
|
|
//! viewer, the same files the agent would hand over.
|
|
//! SOURCE: plan P2 ("Export for CPA" (done by the viewer, into
|
|
//! Downloads)); D-29 ("The viewer's menu makes the same files into
|
|
//! Downloads"); ledger_package (the one builder).
|
|
//! INVARIANTS:
|
|
//! 1. Each is built from a fresh read-only snapshot of the books (its
|
|
//! chain checked when it loads) by ledger_package, the builder the
|
|
//! server uses: the same files, the same figures.
|
|
//! 2. The TINs come from the vault, read-only; a locked vault or a
|
|
//! missing TIN never stops the file: the package lists the gaps.
|
|
//! 3. The file is saved as a new file in Downloads (export::save), never
|
|
//! over one, off the UI thread; what came of it is shown.
|
|
//! ```
|
|
|
|
use crate::books::Books;
|
|
use chrono::Datelike;
|
|
use egui::{RichText, Ui, vec2};
|
|
use egui_phosphor::regular as icon;
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
use std::sync::mpsc::{Receiver, channel};
|
|
|
|
/// What a menu job came to: the file saved, and anything to say about it.
|
|
pub type Done = Result<(PathBuf, Option<String>), String>;
|
|
|
|
/// The menu's state: the job under way, and the last one's outcome.
|
|
#[derive(Default)]
|
|
pub struct Menu {
|
|
job: Option<(String, Receiver<Done>)>,
|
|
done: Option<(String, Done)>,
|
|
}
|
|
|
|
impl Menu {
|
|
/// The menu button, its items, and a note of the last job's outcome.
|
|
pub fn ui(&mut self, ui: &mut Ui, books: &Arc<Books>, compact: bool) {
|
|
let p = crate::theme::pal(ui.ctx());
|
|
if let Some((what, rx)) = &self.job
|
|
&& let Ok(done) = rx.try_recv()
|
|
{
|
|
self.done = Some((what.clone(), done));
|
|
self.job = None;
|
|
}
|
|
let text = if compact {
|
|
RichText::new(icon::ARCHIVE).size(16.0).color(p.muted)
|
|
} else {
|
|
RichText::new(format!("{} Export", icon::ARCHIVE))
|
|
.font(crate::theme::regular(12.5))
|
|
.color(p.muted)
|
|
};
|
|
let mut button = egui::Button::new(text).frame(false);
|
|
if compact {
|
|
button = button.min_size(vec2(32.0, ui.spacing().interact_size.y + 4.0));
|
|
}
|
|
let busy = self.job.is_some();
|
|
let year = books.today.year();
|
|
let mut start: Option<Job> = None;
|
|
let response = ui
|
|
.add_enabled_ui(!busy, |ui| {
|
|
egui::containers::menu::MenuButton::from_button(button).ui(ui, |ui| {
|
|
ui.set_min_width(240.0);
|
|
if ui
|
|
.button(format!("{} Export for CPA", icon::PACKAGE))
|
|
.on_hover_text(
|
|
"The CPA package as of today, full TINs included, into your Downloads folder",
|
|
)
|
|
.clicked()
|
|
{
|
|
start = Some(Job::Package);
|
|
}
|
|
ui.separator();
|
|
for y in [year - 1, year] {
|
|
if ui
|
|
.button(format!("{} 1099 worksheet, {y}", icon::FILE_TEXT))
|
|
.on_hover_text(
|
|
"The year's 1099 worksheet as a CSV, full TINs included, into your Downloads folder",
|
|
)
|
|
.clicked()
|
|
{
|
|
start = Some(Job::Worksheet(y));
|
|
}
|
|
}
|
|
})
|
|
})
|
|
.inner;
|
|
if let Some(job) = start {
|
|
self.start(ui.ctx(), books.clone(), job);
|
|
}
|
|
let said = if self.job.is_some() {
|
|
Some(("Making the file…".to_string(), p.muted))
|
|
} else {
|
|
self.done.as_ref().map(|(what, done)| match done {
|
|
Ok((path, None)) => (format!("{what}: saved {}", path.display()), p.muted),
|
|
Ok((path, Some(note))) => (
|
|
format!("{what}: saved {}. {note}", path.display()),
|
|
p.warning,
|
|
),
|
|
Err(why) => (format!("{what} couldn't be made: {why}"), p.warning),
|
|
})
|
|
};
|
|
// Beside the button; in the narrow rail, on hovering it.
|
|
match said {
|
|
Some((text, _)) if compact => {
|
|
response.0.on_hover_text(text);
|
|
}
|
|
Some((text, color)) => {
|
|
ui.add(egui::Label::new(RichText::new(text).size(12.0).color(color)).wrap());
|
|
}
|
|
None => {}
|
|
}
|
|
}
|
|
|
|
fn start(&mut self, ctx: &egui::Context, books: Arc<Books>, job: Job) {
|
|
let (tx, rx) = channel();
|
|
let what = job.label();
|
|
let ctx = ctx.clone();
|
|
std::thread::spawn(move || {
|
|
let _ = tx.send(job.run(&books));
|
|
ctx.request_repaint();
|
|
});
|
|
self.job = Some((what, rx));
|
|
self.done = None;
|
|
}
|
|
}
|
|
|
|
/// A file the menu makes.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum Job {
|
|
/// The CPA package as of the books' today.
|
|
Package,
|
|
/// The 1099 worksheet for a calendar year.
|
|
Worksheet(i32),
|
|
}
|
|
|
|
impl Job {
|
|
fn label(self) -> String {
|
|
match self {
|
|
Job::Package => "The CPA package".to_string(),
|
|
Job::Worksheet(y) => format!("The {y} 1099 worksheet"),
|
|
}
|
|
}
|
|
|
|
/// Make the file and save it (INVARIANTS 1-3).
|
|
pub fn run(self, books: &Books) -> Done {
|
|
let snap = ledger_store::Snapshot::load(&books.data_dir).map_err(|e| e.to_string())?;
|
|
let ledger = snap.ledger();
|
|
// The settings the rest of the viewer shows, read as the server
|
|
// reads them (data::settings: an older books dir that keeps none
|
|
// in its journal reads its settings file).
|
|
let st = &crate::data::settings_of(&books.data_dir, &snap)?;
|
|
let settings = ledger_package::Settings {
|
|
company: &st.company,
|
|
cash_accounts: &st.cash_accounts,
|
|
financing_accounts: &st.financing_accounts,
|
|
investing_accounts: &st.investing_accounts,
|
|
};
|
|
let vault = books.open_vault();
|
|
let tin = |id| ledger_package::vault_tin(ledger, &vault, id);
|
|
match self {
|
|
Job::Package => {
|
|
let p = ledger_package::build_package(
|
|
&snap,
|
|
&settings,
|
|
&ledger_package::PackageRequest {
|
|
as_of: books.today,
|
|
prepared_on: books.today,
|
|
cash_accounts: &[],
|
|
financing_accounts: &[],
|
|
investing_accounts: &[],
|
|
include_receipts: true,
|
|
tins: Some(&tin),
|
|
},
|
|
)?;
|
|
let (stem, ext) = crate::export::split_name(&p.file_name);
|
|
let path = crate::export::save(stem, ext, &p.zip)?;
|
|
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 or class 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) => {
|
|
let w = ledger_package::vendor_1099_worksheet(
|
|
ledger,
|
|
&settings,
|
|
year,
|
|
None,
|
|
None,
|
|
Some(&tin),
|
|
)?;
|
|
let csv = ledger_package::vendor_1099_csv(&w, true);
|
|
let name = ledger_package::vendor_1099_csv_name(year, true);
|
|
let (stem, ext) = crate::export::split_name(&name);
|
|
let path = crate::export::save(stem, ext, csv.as_bytes())?;
|
|
let gaps = w
|
|
.lines
|
|
.iter()
|
|
.filter(|l| l.may_file() && l.tin.is_none())
|
|
.count();
|
|
let note = (gaps > 0).then(|| {
|
|
format!("{gaps} payee(s) a 1099 is or may be due for have no TIN in it: ask your agent to collect them.")
|
|
});
|
|
Ok((path, note))
|
|
}
|
|
}
|
|
}
|
|
}
|