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:
48 files changed
+5661
-317
No files matched your search
Generated
+11
@@ -1564,6 +1564,12 @@ dependencies = [
|
|||||||
"syn 3.0.7",
|
"syn 3.0.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "deunicode"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "339544cc9e2c4dc3fc7149fd630c5f22263a4fdf18a98afd0075784968b5cf00"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dify"
|
name = "dify"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@@ -3925,6 +3931,9 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"csv",
|
"csv",
|
||||||
|
"deunicode",
|
||||||
|
"hayro-interpret 0.8.0",
|
||||||
|
"kurbo",
|
||||||
"ledger-core",
|
"ledger-core",
|
||||||
"proptest",
|
"proptest",
|
||||||
"qrcode",
|
"qrcode",
|
||||||
@@ -3985,8 +3994,10 @@ dependencies = [
|
|||||||
"ledger-import",
|
"ledger-import",
|
||||||
"ledger-store",
|
"ledger-store",
|
||||||
"ledger-vault",
|
"ledger-vault",
|
||||||
|
"proptest",
|
||||||
"rust_decimal",
|
"rust_decimal",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
|
"toml 1.1.8+spec-1.1.0",
|
||||||
"zip 9.0.2",
|
"zip 9.0.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
# What a 1099 worksheet as of a day counts toward a payee: the payments
|
||||||
|
# of that day's calendar year made from January 1 through the day.
|
||||||
|
#
|
||||||
|
# wanderbooks: P3 defect 21 and decision D-36 (the CPA package's 1099
|
||||||
|
# worksheet counted the whole calendar year in a package as of June 30);
|
||||||
|
# rules.md B-19, B-33; regression test `the_1099_worksheet_stops_at_as_of`
|
||||||
|
# (ledger-package tests/package.rs).
|
||||||
|
#
|
||||||
|
# Setup beyond the accounts: `cash_accounts` are the accounts payments
|
||||||
|
# are made from; each `[[setup.payees]]` is a payee marked for a form;
|
||||||
|
# an event's `payee` tags the payment to them. Expected values:
|
||||||
|
# vendor_1099_paid gives each marked payee's payments counted from `from`
|
||||||
|
# through `to` (both in one calendar year). The 2026 threshold is $2,000
|
||||||
|
# (B-19), so the payee is not yet reportable as of June 30 (1400.00) and
|
||||||
|
# is for the year (3050.00).
|
||||||
|
|
||||||
|
id = "1099-worksheet-through-as-of"
|
||||||
|
title = "A 1099 worksheet as of a day counts the year's payments through that day"
|
||||||
|
rule = "A payee's 1099 amount as of a day D MUST be the payments to the payee made from January 1 of D's calendar year through D, excluding payments made with a payment card; as of December 31 it is the whole calendar year's."
|
||||||
|
status = "AUTHORITY"
|
||||||
|
sources = [
|
||||||
|
"26 U.S.C. §6041(a), as amended by Pub. L. 119-21 §70433: payments \"of $2,000 or more in any calendar year\" (docs/spec/sources/irs-1099-thresholds.md)",
|
||||||
|
"26 U.S.C. §6041A(a)(2): \"the aggregate of such remuneration paid to such person during such calendar year\" (docs/spec/sources/irs-1099-thresholds.md)",
|
||||||
|
"IRS, Instructions for Forms 1099-MISC and 1099-NEC (Rev. December 2026), Form 1099-K: \"Payments made with a credit card or payment card and certain other types of payments, including third-party network transactions, must be reported on Form 1099-K by the payment settlement entity under section 6050W and are not subject to reporting on Form 1099-MISC or Form 1099-NEC.\" (docs/spec/sources/irs-1099-thresholds.md)",
|
||||||
|
]
|
||||||
|
tags = ["1099", "as-of", "calendar-year", "information-return", "period-boundary"]
|
||||||
|
|
||||||
|
[setup]
|
||||||
|
currency = "USD"
|
||||||
|
fiscal_year_start = "01-01"
|
||||||
|
basis = "cash"
|
||||||
|
rounding = "half_away_from_zero"
|
||||||
|
cash_accounts = ["1000"]
|
||||||
|
|
||||||
|
[[setup.accounts]]
|
||||||
|
code = "1000"
|
||||||
|
name = "Checking"
|
||||||
|
type = "asset"
|
||||||
|
|
||||||
|
[[setup.accounts]]
|
||||||
|
code = "2200"
|
||||||
|
name = "Credit card"
|
||||||
|
type = "liability"
|
||||||
|
|
||||||
|
[[setup.accounts]]
|
||||||
|
code = "3000"
|
||||||
|
name = "Owner capital"
|
||||||
|
type = "equity"
|
||||||
|
|
||||||
|
[[setup.accounts]]
|
||||||
|
code = "6100"
|
||||||
|
name = "Contract labor"
|
||||||
|
type = "expense"
|
||||||
|
|
||||||
|
[[setup.payees]]
|
||||||
|
name = "Contractor A"
|
||||||
|
form = "1099-NEC"
|
||||||
|
|
||||||
|
# 0: the year before.
|
||||||
|
[[events]]
|
||||||
|
date = "2025-12-31"
|
||||||
|
description = "Contract work, December"
|
||||||
|
payee = "Contractor A"
|
||||||
|
lines = [
|
||||||
|
{ account = "6100", side = "debit", amount = "100.00" },
|
||||||
|
{ account = "1000", side = "credit", amount = "100.00" },
|
||||||
|
]
|
||||||
|
|
||||||
|
# 1
|
||||||
|
[[events]]
|
||||||
|
date = "2026-01-02"
|
||||||
|
description = "Owner contribution"
|
||||||
|
lines = [
|
||||||
|
{ account = "1000", side = "debit", amount = "10000.00" },
|
||||||
|
{ account = "3000", side = "credit", amount = "10000.00" },
|
||||||
|
]
|
||||||
|
|
||||||
|
# 2
|
||||||
|
[[events]]
|
||||||
|
date = "2026-03-15"
|
||||||
|
description = "Contract work, March"
|
||||||
|
payee = "Contractor A"
|
||||||
|
lines = [
|
||||||
|
{ account = "6100", side = "debit", amount = "900.00" },
|
||||||
|
{ account = "1000", side = "credit", amount = "900.00" },
|
||||||
|
]
|
||||||
|
|
||||||
|
# 3: by card, so reported on Form 1099-K, never counted here.
|
||||||
|
[[events]]
|
||||||
|
date = "2026-06-10"
|
||||||
|
description = "Contract work, June, by card"
|
||||||
|
payee = "Contractor A"
|
||||||
|
lines = [
|
||||||
|
{ account = "6100", side = "debit", amount = "300.00" },
|
||||||
|
{ account = "2200", side = "credit", amount = "300.00" },
|
||||||
|
]
|
||||||
|
|
||||||
|
# 4: made on the day itself, so counted.
|
||||||
|
[[events]]
|
||||||
|
date = "2026-06-30"
|
||||||
|
description = "Contract work, June"
|
||||||
|
payee = "Contractor A"
|
||||||
|
lines = [
|
||||||
|
{ account = "6100", side = "debit", amount = "500.00" },
|
||||||
|
{ account = "1000", side = "credit", amount = "500.00" },
|
||||||
|
]
|
||||||
|
|
||||||
|
# 5: the day after.
|
||||||
|
[[events]]
|
||||||
|
date = "2026-07-01"
|
||||||
|
description = "Contract work, July"
|
||||||
|
payee = "Contractor A"
|
||||||
|
lines = [
|
||||||
|
{ account = "6100", side = "debit", amount = "700.00" },
|
||||||
|
{ account = "1000", side = "credit", amount = "700.00" },
|
||||||
|
]
|
||||||
|
|
||||||
|
# 6
|
||||||
|
[[events]]
|
||||||
|
date = "2026-11-20"
|
||||||
|
description = "Contract work, November"
|
||||||
|
payee = "Contractor A"
|
||||||
|
lines = [
|
||||||
|
{ account = "6100", side = "debit", amount = "950.00" },
|
||||||
|
{ account = "1000", side = "credit", amount = "950.00" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[expect]]
|
||||||
|
query = "vendor_1099_paid"
|
||||||
|
from = "2026-01-01"
|
||||||
|
to = "2026-06-30"
|
||||||
|
values = { "Contractor A" = "1400.00" }
|
||||||
|
|
||||||
|
[[expect]]
|
||||||
|
query = "vendor_1099_paid"
|
||||||
|
from = "2026-01-01"
|
||||||
|
to = "2026-12-31"
|
||||||
|
values = { "Contractor A" = "3050.00" }
|
||||||
|
|
||||||
|
[[expect]]
|
||||||
|
query = "vendor_1099_paid"
|
||||||
|
from = "2025-01-01"
|
||||||
|
to = "2025-12-31"
|
||||||
|
values = { "Contractor A" = "100.00" }
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
# A report as of a day shows the books through that day, and nothing
|
||||||
|
# dated after it, however long after the day it is made.
|
||||||
|
#
|
||||||
|
# wanderbooks: P3 defect 21 (the CPA package's QuickBooks file held entries
|
||||||
|
# dated after the package's as_of); rules.md B-33; regression tests
|
||||||
|
# `every_file_in_an_as_of_package_stops_at_as_of` and
|
||||||
|
# `as_of_package_iif_stops_at_as_of` (ledger-package tests/package.rs).
|
||||||
|
#
|
||||||
|
# Expected values: trial_balance gives each account's balance, debit
|
||||||
|
# positive and credit negative, every account listed; profit_and_loss
|
||||||
|
# gives each revenue and expense account's activity for the period as a
|
||||||
|
# positive amount, and net_income (revenue less expenses).
|
||||||
|
|
||||||
|
id = "as-of-package-stops-at-as-of"
|
||||||
|
title = "A report as of a day leaves out what is dated after it"
|
||||||
|
rule = "A balance or report as of a day D MUST include every posted entry dated on or before D and MUST NOT include any entry dated after D, whenever it is produced; a period report through D covers the entries dated from the period's first day through D."
|
||||||
|
status = "CONVENTION"
|
||||||
|
sources = [
|
||||||
|
"IRS Publication 538 (Rev. January 2022), Tax year: \"A tax year is an annual accounting period for keeping records and reporting income and expenses.\" (docs/spec/sources/irs-periods-and-methods.md)",
|
||||||
|
"Pacioli, Summa de arithmetica (1494), ch. 36, the trial balance: the total of the debits equals the total of the credits (Geijsbeek translation, 1914; docs/spec/sources/pacioli-1494.md)",
|
||||||
|
]
|
||||||
|
tags = ["as-of", "trial-balance", "profit-and-loss", "reversal", "period-boundary"]
|
||||||
|
|
||||||
|
[setup]
|
||||||
|
currency = "USD"
|
||||||
|
fiscal_year_start = "01-01"
|
||||||
|
basis = "accrual"
|
||||||
|
rounding = "half_away_from_zero"
|
||||||
|
|
||||||
|
[[setup.accounts]]
|
||||||
|
code = "1000"
|
||||||
|
name = "Checking"
|
||||||
|
type = "asset"
|
||||||
|
|
||||||
|
[[setup.accounts]]
|
||||||
|
code = "3000"
|
||||||
|
name = "Owner capital"
|
||||||
|
type = "equity"
|
||||||
|
|
||||||
|
[[setup.accounts]]
|
||||||
|
code = "4000"
|
||||||
|
name = "Sales"
|
||||||
|
type = "revenue"
|
||||||
|
|
||||||
|
[[setup.accounts]]
|
||||||
|
code = "6000"
|
||||||
|
name = "Rent"
|
||||||
|
type = "expense"
|
||||||
|
|
||||||
|
# 0
|
||||||
|
[[events]]
|
||||||
|
date = "2026-01-02"
|
||||||
|
description = "Owner contribution"
|
||||||
|
lines = [
|
||||||
|
{ account = "1000", side = "debit", amount = "10000.00" },
|
||||||
|
{ account = "3000", side = "credit", amount = "10000.00" },
|
||||||
|
]
|
||||||
|
|
||||||
|
# 1
|
||||||
|
[[events]]
|
||||||
|
date = "2026-02-01"
|
||||||
|
description = "Sale"
|
||||||
|
lines = [
|
||||||
|
{ account = "1000", side = "debit", amount = "4000.00" },
|
||||||
|
{ account = "4000", side = "credit", amount = "4000.00" },
|
||||||
|
]
|
||||||
|
|
||||||
|
# 2: dated the day itself, so in.
|
||||||
|
[[events]]
|
||||||
|
date = "2026-06-30"
|
||||||
|
description = "June rent"
|
||||||
|
lines = [
|
||||||
|
{ account = "6000", side = "debit", amount = "1200.00" },
|
||||||
|
{ account = "1000", side = "credit", amount = "1200.00" },
|
||||||
|
]
|
||||||
|
|
||||||
|
# 3: the day after, so out of anything as of 2026-06-30.
|
||||||
|
[[events]]
|
||||||
|
date = "2026-07-01"
|
||||||
|
description = "July rent"
|
||||||
|
lines = [
|
||||||
|
{ account = "6000", side = "debit", amount = "1200.00" },
|
||||||
|
{ account = "1000", side = "credit", amount = "1200.00" },
|
||||||
|
]
|
||||||
|
|
||||||
|
# 4: a reversal dated after the day leaves its original in.
|
||||||
|
[[events]]
|
||||||
|
date = "2026-07-15"
|
||||||
|
description = "Reversal of the February sale"
|
||||||
|
reverses = 1
|
||||||
|
lines = [
|
||||||
|
{ account = "4000", side = "debit", amount = "4000.00" },
|
||||||
|
{ account = "1000", side = "credit", amount = "4000.00" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[expect]]
|
||||||
|
query = "trial_balance"
|
||||||
|
from = "2026-01-01"
|
||||||
|
to = "2026-06-30"
|
||||||
|
values = { "1000" = "12800.00", "3000" = "-10000.00", "4000" = "-4000.00", "6000" = "1200.00" }
|
||||||
|
|
||||||
|
[[expect]]
|
||||||
|
query = "profit_and_loss"
|
||||||
|
from = "2026-01-01"
|
||||||
|
to = "2026-06-30"
|
||||||
|
values = { "4000" = "4000.00", "6000" = "1200.00", net_income = "2800.00" }
|
||||||
|
|
||||||
|
# The same books a month later: the July rent and the reversal are in.
|
||||||
|
[[expect]]
|
||||||
|
query = "trial_balance"
|
||||||
|
from = "2026-01-01"
|
||||||
|
to = "2026-07-31"
|
||||||
|
values = { "1000" = "7600.00", "3000" = "-10000.00", "4000" = "0.00", "6000" = "2400.00" }
|
||||||
|
|
||||||
|
[[expect]]
|
||||||
|
query = "profit_and_loss"
|
||||||
|
from = "2026-01-01"
|
||||||
|
to = "2026-07-31"
|
||||||
|
values = { "4000" = "0.00", "6000" = "2400.00", net_income = "-2400.00" }
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
# The journal file QuickBooks Desktop imports (IIF), as of a day. A format,
|
||||||
|
# not accounting: P13's appendix of interchange formats.
|
||||||
|
#
|
||||||
|
# wanderbooks: P3 defects 7 and 21, decision D-36 and the P3 review (the
|
||||||
|
# file numbered transactions by their place in it, held entries dated after
|
||||||
|
# the package's as_of, wrote '?' for every character past ASCII, and knew
|
||||||
|
# nothing of QuickBooks' length limits, its double quote, or its rule that
|
||||||
|
# a subaccount has its parent's type, and could declare a subaccount
|
||||||
|
# before its parent); rules.md B-34; regression tests
|
||||||
|
# `iif_docnum_is_the_entry_id`,
|
||||||
|
# `iif_writes_plain_ascii_and_lists_every_change`,
|
||||||
|
# `iif_fits_quickbooks_desktop_limits`,
|
||||||
|
# `iif_a_subaccount_of_another_type_stands_at_the_top_level`,
|
||||||
|
# `iif_declares_each_parent_before_its_subaccounts` (ledger-import
|
||||||
|
# tests/spec_import.rs) and `as_of_package_iif_stops_at_as_of`
|
||||||
|
# (ledger-package tests/package.rs).
|
||||||
|
#
|
||||||
|
# Setup beyond the accounts: `cash_accounts` are the bank accounts (BANK);
|
||||||
|
# an account's QuickBooks type otherwise follows its type (asset OCASSET,
|
||||||
|
# liability OCLIAB, equity EQUITY, revenue INC, expense EXP); an account's
|
||||||
|
# `parent` is its parent's code. Each event is a posted entry, its id one
|
||||||
|
# more than its index. Expected values: iif_lines gives the file's lines
|
||||||
|
# in order, each a string of fields joined by tabs ("\t"), every line
|
||||||
|
# ending CR LF in the file; iif_changes gives each text written
|
||||||
|
# differently from the books, in file order (the accounts as declared:
|
||||||
|
# by code, each after its parent; then the entries), as [what, books'
|
||||||
|
# text, file's text], where what is "name <code>", "number" (left out of
|
||||||
|
# ACCNUM when the file's text is empty), "top level <code>" (written
|
||||||
|
# apart from its parent, the books' text: the parent's code) or
|
||||||
|
# "memo <entry id>".
|
||||||
|
|
||||||
|
id = "iif-quickbooks-journal-file"
|
||||||
|
appendix = "formats"
|
||||||
|
title = "A QuickBooks Desktop journal file as of a day"
|
||||||
|
rule = "An IIF journal file as of a day D MUST declare every account first (NAME, ACCNTTYPE, ACCNUM), then hold one GENERAL JOURNAL transaction per posted entry dated on or before D, period closes excepted, in the order posted: a TRNS line and SPL lines, debits positive and credits negative, dated M/D/YYYY, each numbered (DOCNUM) with the entry's id; then ENDTRNS. It MUST be ASCII text, every line ending CR LF, no field holding a tab, a line break or a double quote; an account name level MUST be at most 31 characters and no two NAMEs alike, an account number at most 7 characters and no two alike (else left out), a memo at most 4,095 characters; each parent MUST be declared before its subaccounts, and a subaccount whose QuickBooks type differs from its parent's MUST be written at the top level. Each text written differently from the books MUST be reported."
|
||||||
|
status = "AUTHORITY"
|
||||||
|
sources = [
|
||||||
|
"Intuit, IIF Overview: import kit, sample files, and headers: IIF files are ASCII text, tab-separated; an account not already in the company file is created as a new bank account (docs/spec/sources/intuit-iif.md)",
|
||||||
|
"Intuit, IIF Import Kit, IIF Header Help !TRNS, !SPL, !ACCNT: TRNS and SPL lines then ENDTRNS; \"Debit amounts are always positive, credit amounts are always negative\"; DOCNUM is the transaction's number; a subaccount's NAME its parents' names first, joined by colons (docs/spec/sources/intuit-iif.md)",
|
||||||
|
"Intuit, IIF Import Kit, 2007 General Journal Entry example file: GENERAL JOURNAL, M/D/YYYY, every line ending CR LF (docs/spec/sources/intuit-iif.md)",
|
||||||
|
"Intuit, Character limitations for fields in QuickBooks (US, updated 8/3/2026): Account Name 31, Account Numbers 7, Memo 4,095 (docs/spec/sources/intuit-iif.md)",
|
||||||
|
"Intuit, Add, edit, or delete accounts in QuickBooks Desktop (updated 8/4/2026): \"The parent and subaccount must be the same account type.\" (docs/spec/sources/intuit-iif.md)",
|
||||||
|
"Reckon, IIF import changes a double quote to a single one by design; J. Clark (2003): QuickBooks uses double quotes as text delimiters (docs/spec/sources/intuit-iif.md; not Intuit)",
|
||||||
|
]
|
||||||
|
tags = ["iif", "quickbooks", "as-of", "format", "transliteration"]
|
||||||
|
|
||||||
|
[setup]
|
||||||
|
currency = "USD"
|
||||||
|
fiscal_year_start = "01-01"
|
||||||
|
basis = "cash"
|
||||||
|
rounding = "half_away_from_zero"
|
||||||
|
cash_accounts = ["1000"]
|
||||||
|
|
||||||
|
[[setup.accounts]]
|
||||||
|
code = "1090"
|
||||||
|
name = "Bank accounts"
|
||||||
|
type = "asset"
|
||||||
|
|
||||||
|
# A bank account (BANK) under a header that is no bank (OCASSET): written
|
||||||
|
# at the top level.
|
||||||
|
[[setup.accounts]]
|
||||||
|
code = "1000"
|
||||||
|
name = "Checking"
|
||||||
|
type = "asset"
|
||||||
|
parent = "1090"
|
||||||
|
|
||||||
|
[[setup.accounts]]
|
||||||
|
code = "12345678"
|
||||||
|
name = "Suspense"
|
||||||
|
type = "expense"
|
||||||
|
|
||||||
|
[[setup.accounts]]
|
||||||
|
code = "3000"
|
||||||
|
name = "Owner capital"
|
||||||
|
type = "equity"
|
||||||
|
|
||||||
|
[[setup.accounts]]
|
||||||
|
code = "4000"
|
||||||
|
name = "Sales"
|
||||||
|
type = "revenue"
|
||||||
|
|
||||||
|
[[setup.accounts]]
|
||||||
|
code = "6000"
|
||||||
|
name = "Café meals"
|
||||||
|
type = "expense"
|
||||||
|
|
||||||
|
# Coded before its parent: declared after it.
|
||||||
|
[[setup.accounts]]
|
||||||
|
code = "5900"
|
||||||
|
name = "Snacks"
|
||||||
|
type = "expense"
|
||||||
|
parent = "6000"
|
||||||
|
|
||||||
|
[[setup.accounts]]
|
||||||
|
code = "6100"
|
||||||
|
name = "Professional services and consulting fees"
|
||||||
|
type = "expense"
|
||||||
|
|
||||||
|
# 0: entry 1.
|
||||||
|
[[events]]
|
||||||
|
date = "2026-01-02"
|
||||||
|
description = "Owner contribution"
|
||||||
|
lines = [
|
||||||
|
{ account = "1000", side = "debit", amount = "5000.00" },
|
||||||
|
{ account = "3000", side = "credit", amount = "5000.00" },
|
||||||
|
]
|
||||||
|
|
||||||
|
# 1: entry 2. An em dash and double quotes.
|
||||||
|
[[events]]
|
||||||
|
date = "2026-01-15"
|
||||||
|
description = "Lunch — \"team\""
|
||||||
|
lines = [
|
||||||
|
{ account = "6000", side = "debit", amount = "42.50" },
|
||||||
|
{ account = "1000", side = "credit", amount = "42.50" },
|
||||||
|
]
|
||||||
|
|
||||||
|
# 2: entry 3. An account named past 31 characters.
|
||||||
|
[[events]]
|
||||||
|
date = "2026-01-20"
|
||||||
|
description = "Consulting"
|
||||||
|
lines = [
|
||||||
|
{ account = "6100", side = "debit", amount = "300.00" },
|
||||||
|
{ account = "1000", side = "credit", amount = "300.00" },
|
||||||
|
]
|
||||||
|
|
||||||
|
# 3: entry 4, dated the day itself: in.
|
||||||
|
[[events]]
|
||||||
|
date = "2026-02-01"
|
||||||
|
description = "Sale"
|
||||||
|
lines = [
|
||||||
|
{ account = "1000", side = "debit", amount = "1000.00" },
|
||||||
|
{ account = "4000", side = "credit", amount = "1000.00" },
|
||||||
|
]
|
||||||
|
|
||||||
|
# 4: entry 5, the day after: out.
|
||||||
|
[[events]]
|
||||||
|
date = "2026-02-02"
|
||||||
|
description = "Second sale"
|
||||||
|
lines = [
|
||||||
|
{ account = "1000", side = "debit", amount = "250.00" },
|
||||||
|
{ account = "4000", side = "credit", amount = "250.00" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[expect]]
|
||||||
|
query = "iif_lines"
|
||||||
|
as_of = "2026-02-01"
|
||||||
|
values = [
|
||||||
|
"!ACCNT\tNAME\tACCNTTYPE\tACCNUM",
|
||||||
|
"ACCNT\tChecking\tBANK\t1000",
|
||||||
|
"ACCNT\tBank accounts\tOCASSET\t1090",
|
||||||
|
"ACCNT\tSuspense\tEXP\t",
|
||||||
|
"ACCNT\tOwner capital\tEQUITY\t3000",
|
||||||
|
"ACCNT\tSales\tINC\t4000",
|
||||||
|
"ACCNT\tCafe meals\tEXP\t6000",
|
||||||
|
"ACCNT\tCafe meals:Snacks\tEXP\t5900",
|
||||||
|
"ACCNT\tProfessional services and consu\tEXP\t6100",
|
||||||
|
"!TRNS\tTRNSID\tTRNSTYPE\tDATE\tACCNT\tCLASS\tAMOUNT\tDOCNUM\tMEMO",
|
||||||
|
"!SPL\tSPLID\tTRNSTYPE\tDATE\tACCNT\tCLASS\tAMOUNT\tDOCNUM\tMEMO",
|
||||||
|
"!ENDTRNS",
|
||||||
|
"TRNS\t\tGENERAL JOURNAL\t1/2/2026\tChecking\t\t5000.00\t1\tOwner contribution",
|
||||||
|
"SPL\t\tGENERAL JOURNAL\t1/2/2026\tOwner capital\t\t-5000.00\t1\tOwner contribution",
|
||||||
|
"ENDTRNS",
|
||||||
|
"TRNS\t\tGENERAL JOURNAL\t1/15/2026\tCafe meals\t\t42.50\t2\tLunch -- 'team'",
|
||||||
|
"SPL\t\tGENERAL JOURNAL\t1/15/2026\tChecking\t\t-42.50\t2\tLunch -- 'team'",
|
||||||
|
"ENDTRNS",
|
||||||
|
"TRNS\t\tGENERAL JOURNAL\t1/20/2026\tProfessional services and consu\t\t300.00\t3\tConsulting",
|
||||||
|
"SPL\t\tGENERAL JOURNAL\t1/20/2026\tChecking\t\t-300.00\t3\tConsulting",
|
||||||
|
"ENDTRNS",
|
||||||
|
"TRNS\t\tGENERAL JOURNAL\t2/1/2026\tChecking\t\t1000.00\t4\tSale",
|
||||||
|
"SPL\t\tGENERAL JOURNAL\t2/1/2026\tSales\t\t-1000.00\t4\tSale",
|
||||||
|
"ENDTRNS",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[expect]]
|
||||||
|
query = "iif_changes"
|
||||||
|
as_of = "2026-02-01"
|
||||||
|
values = [
|
||||||
|
["top level 1000", "1090", ""],
|
||||||
|
["number", "12345678", ""],
|
||||||
|
["name 6000", "Café meals", "Cafe meals"],
|
||||||
|
["name 6100", "Professional services and consulting fees", "Professional services and consu"],
|
||||||
|
["memo 2", "Lunch — \"team\"", "Lunch -- 'team'"],
|
||||||
|
]
|
||||||
@@ -171,7 +171,7 @@ pub fn vendor_1099_report_with(
|
|||||||
threshold: Option<Decimal>,
|
threshold: Option<Decimal>,
|
||||||
sources: &PaymentAccounts,
|
sources: &PaymentAccounts,
|
||||||
) -> Result<Vendor1099Report, LedgerError> {
|
) -> 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.
|
/// 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_1099_report`], counting payments out of `sources` made in
|
||||||
/// vendor's part of an entry with several parties resolved with `members`
|
/// `year` (through `through`, when given), each vendor's part of an entry
|
||||||
/// (the company's members, from the settings).
|
/// with several parties resolved with `members` (the company's members,
|
||||||
|
/// from the settings).
|
||||||
///
|
///
|
||||||
/// ```text
|
/// ```text
|
||||||
/// SOURCE: docs/spec/rules.md B-19 (26 U.S.C. §6041(a): payments made in
|
/// 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
|
/// the year; card payments go on 1099-K), B-33 (a worksheet as of
|
||||||
/// with several parties, each with their share; DECISION, owner,
|
/// a day); crate::shares (an entry with several parties, each with
|
||||||
/// 2026-10-15).
|
/// their share; DECISION, owner, 2026-10-15).
|
||||||
/// DEFINITIONS:
|
/// DEFINITIONS:
|
||||||
/// payment(e): credits minus debits to `sources` on entry e.
|
/// payment(e): credits minus debits to `sources` on entry e.
|
||||||
/// money: crate::parties::money_accounts(ledger, sources.cash), what the
|
/// 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,
|
/// target(e): crate::shares::share_target(e, money): the entry_flow net,
|
||||||
/// else the total debits.
|
/// else the total debits.
|
||||||
/// INVARIANTS:
|
/// INVARIANTS:
|
||||||
/// 1. Only posted entries dated in `year` and tagged with a vendor
|
/// 1. Only posted entries dated in `year` (and, given `through`, on or
|
||||||
/// (Ledger::entry_vendor) count, toward that vendor.
|
/// 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
|
/// 2. An entry naming at most one party (Ledger::entry_parties): the
|
||||||
/// vendor's amount is payment(e), exactly as before shares.
|
/// vendor's amount is payment(e), exactly as before shares.
|
||||||
/// 3. An entry naming several parties (one of them the vendor's party,
|
/// 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(
|
pub fn vendor_1099_report_with_members(
|
||||||
ledger: &Ledger,
|
ledger: &Ledger,
|
||||||
year: i32,
|
year: i32,
|
||||||
|
through: Option<chrono::NaiveDate>,
|
||||||
threshold: Option<Decimal>,
|
threshold: Option<Decimal>,
|
||||||
sources: &PaymentAccounts,
|
sources: &PaymentAccounts,
|
||||||
members: &[crate::PartyMember],
|
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 paid: std::collections::HashMap<VendorId, Decimal> = Default::default();
|
||||||
let mut unresolved = Vec::new();
|
let mut unresolved = Vec::new();
|
||||||
for entry in ledger.posted_entries() {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
let Some(vendor) = ledger.entry_vendor(entry.id()) else {
|
let Some(vendor) = ledger.entry_vendor(entry.id()) else {
|
||||||
|
|||||||
@@ -13,8 +13,10 @@
|
|||||||
//! 2026-10-15); IRS truncated TINs: Treas. Reg. §301.6109-4.
|
//! 2026-10-15); IRS truncated TINs: Treas. Reg. §301.6109-4.
|
||||||
//! INVARIANTS:
|
//! INVARIANTS:
|
||||||
//! 1. No party field holds a full taxpayer identification number: any
|
//! 1. No party field holds a full taxpayer identification number: any
|
||||||
//! run of nine digits (single spaces or hyphens between them allowed)
|
//! run of nine digits, in any script, separated or not (`has_full_tin`:
|
||||||
//! is refused. A W-9's TIN is kept as its last four digits only.
|
//! 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
|
//! 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,
|
//! the employee role. Taxpayer-ID facts (`TinFacts`: the TIN's type,
|
||||||
//! last four, and vault record) are allowed for any role: members
|
//! 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()
|
name.trim().to_lowercase()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The characters that may sit between a TIN's digits: space, hyphen,
|
/// The characters that may sit between a TIN's digits (`291-12-2846`,
|
||||||
/// dot, slash, underscore, and the Unicode dashes and no-break space
|
/// `291.12.2846`, `291/12/2846`, `291 - 12 - 2846`): in ASCII a space,
|
||||||
/// (`291-12-2846`, `291.12.2846`, `291/12/2846`, `291 - 12 - 2846`).
|
/// 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 {
|
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!(
|
matches!(
|
||||||
c,
|
c,
|
||||||
' ' | '-'
|
'\u{AD}'
|
||||||
| '.'
|
| '\u{34F}'
|
||||||
| '/'
|
| '\u{61C}'
|
||||||
| '_'
|
| '\u{115F}'..='\u{1160}'
|
||||||
| '\u{a0}'
|
| '\u{17B4}'..='\u{17B5}'
|
||||||
| '\u{2010}'
|
| '\u{180B}'..='\u{180F}'
|
||||||
| '\u{2011}'
|
| '\u{200B}'..='\u{200F}'
|
||||||
| '\u{2012}'
|
| '\u{202A}'..='\u{202E}'
|
||||||
| '\u{2013}'
|
| '\u{2060}'..='\u{206F}'
|
||||||
| '\u{2014}'
|
| '\u{3164}'
|
||||||
| '\u{2212}'
|
| '\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
|
/// 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;
|
const MAX_TIN_GAP: usize = 3;
|
||||||
|
|
||||||
/// Each run in `chars` that holds nine digits or more, as (first, last)
|
/// 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`]
|
/// 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)> {
|
fn full_tin_runs(chars: &[char]) -> Vec<(usize, usize)> {
|
||||||
let mut runs = Vec::new();
|
let mut runs = Vec::new();
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
@@ -315,11 +384,19 @@ fn full_tin_runs(chars: &[char]) -> Vec<(usize, usize)> {
|
|||||||
j += 1;
|
j += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let gap = chars[j..].iter().take_while(|c| tin_separator(**c)).count();
|
let between = chars[j..]
|
||||||
if (1..=MAX_TIN_GAP).contains(&gap)
|
.iter()
|
||||||
&& chars.get(j + gap).is_some_and(|c| c.is_numeric())
|
.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 {
|
} else {
|
||||||
break;
|
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
|
/// Whether `text` holds a run of nine digits, with at most three
|
||||||
/// separators (space, hyphen, dot, slash, underscore, a Unicode dash or
|
/// separators (a space, hyphen, dot, slash or underscore, in any of
|
||||||
/// no-break space) between neighbors: an SSN, EIN, or ITIN however it's
|
/// their Unicode forms) between neighbors, invisible
|
||||||
/// written (`291-12-2846`, `291.12.2846`, `291 - 12 - 2846`). Any Unicode
|
/// characters not counted: an SSN, EIN, or ITIN however it's written
|
||||||
/// numeral counts as a digit (full-width, Arabic-Indic, ...), so a number
|
/// (`291-12-2846`, `291.12.2846`, `291 - 12 - 2846`, `291-12-2846`).
|
||||||
/// can't slip through in another script.
|
/// 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 {
|
pub fn has_full_tin(text: &str) -> bool {
|
||||||
let chars: Vec<char> = text.chars().collect();
|
let chars: Vec<char> = text.chars().collect();
|
||||||
!full_tin_runs(&chars).is_empty()
|
!full_tin_runs(&chars).is_empty()
|
||||||
|
|||||||
@@ -365,6 +365,7 @@ fn a_shared_payment_counts_the_vendors_share() {
|
|||||||
&s.l,
|
&s.l,
|
||||||
2026,
|
2026,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
&ledger_core::PaymentAccounts {
|
&ledger_core::PaymentAccounts {
|
||||||
cash: vec![bank],
|
cash: vec![bank],
|
||||||
financing: vec![],
|
financing: vec![],
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ fn has_full_tin_truth_table() {
|
|||||||
("291\u{2013}12\u{2013}2846", true), // en dashes
|
("291\u{2013}12\u{2013}2846", true), // en dashes
|
||||||
("291\u{a0}12\u{a0}2846", true), // no-break spaces
|
("291\u{a0}12\u{a0}2846", true), // no-break spaces
|
||||||
("12 3456789", false), // four separators break the run: 2 + 7
|
("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
|
("12\t3456789", false), // a tab is not a space
|
||||||
("12345678", false), // eight digits
|
("12345678", false), // eight digits
|
||||||
("123-45-678", false),
|
("123-45-678", false),
|
||||||
@@ -241,6 +241,145 @@ fn has_full_tin_unicode_digits() {
|
|||||||
assert!(has_full_tin("١٢٣٤٥٦٧٨٩")); // Arabic-Indic 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
|
// ---------------------------------------------------------------- check_text
|
||||||
|
|
||||||
/// Oracle: `check_text` doc: not a full TIN, at most 500 characters, no
|
/// Oracle: `check_text` doc: not a full TIN, at most 500 characters, no
|
||||||
|
|||||||
@@ -1823,9 +1823,15 @@ impl Books {
|
|||||||
cash: self.money_cash_accounts.clone(),
|
cash: self.money_cash_accounts.clone(),
|
||||||
financing: self.financing_accounts.clone(),
|
financing: self.financing_accounts.clone(),
|
||||||
};
|
};
|
||||||
let r =
|
let r = ledger_core::vendor_1099_report_with_members(
|
||||||
ledger_core::vendor_1099_report_with_members(l, year, None, &sources, &self.members)
|
l,
|
||||||
.ok()?;
|
year,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
&sources,
|
||||||
|
&self.members,
|
||||||
|
)
|
||||||
|
.ok()?;
|
||||||
let line = r.vendors.iter().find(|v| v.vendor_id == vendor)?.clone();
|
let line = r.vendors.iter().find(|v| v.vendor_id == vendor)?.clone();
|
||||||
Some((line, r.threshold))
|
Some((line, r.threshold))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,12 +179,20 @@ impl Job {
|
|||||||
)?;
|
)?;
|
||||||
let (stem, ext) = crate::export::split_name(&p.file_name);
|
let (stem, ext) = crate::export::split_name(&p.file_name);
|
||||||
let path = crate::export::save(stem, ext, &p.zip)?;
|
let path = crate::export::save(stem, ext, &p.zip)?;
|
||||||
let note = (!p.tin_gaps.is_empty()).then(|| {
|
let mut notes = Vec::new();
|
||||||
format!(
|
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.",
|
"{} TIN(s) aren't in it (the README says which): ask your agent to collect them.",
|
||||||
p.tin_gaps.len()
|
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))
|
Ok((path, note))
|
||||||
}
|
}
|
||||||
Job::Worksheet(year) => {
|
Job::Worksheet(year) => {
|
||||||
|
|||||||
@@ -117,10 +117,21 @@ pub fn bold(size: f32) -> FontId {
|
|||||||
FontId::new(size, FontFamily::Name(BOLD.into()))
|
FontId::new(size, FontFamily::Name(BOLD.into()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Install fonts: Inter with tabular figures frozen in, so every number in
|
/// Install fonts ([`font_definitions`]).
|
||||||
/// the app lines up digit for digit. Phosphor icons ride along as a
|
|
||||||
/// fallback in every family.
|
|
||||||
pub fn install_fonts(ctx: &Context) {
|
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 mut fonts = FontDefinitions::default();
|
||||||
let add = |fonts: &mut FontDefinitions, name: &str, bytes: &'static [u8]| {
|
let add = |fonts: &mut FontDefinitions, name: &str, bytes: &'static [u8]| {
|
||||||
fonts
|
fonts
|
||||||
@@ -166,7 +177,13 @@ pub fn install_fonts(ctx: &Context) {
|
|||||||
fonts
|
fonts
|
||||||
.families
|
.families
|
||||||
.insert(FontFamily::Name(BOLD.into()), family("InterTab-Bold"));
|
.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).
|
/// 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;
|
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:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -250,6 +250,12 @@ fn the_menu_makes_the_cpa_package_with_full_tins() {
|
|||||||
.read_to_string(&mut readme)
|
.read_to_string(&mut readme)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(readme.starts_with("Acme LLC"), "{readme}");
|
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.
|
// The member's K-1 row carries their full SSN, from the book's vault.
|
||||||
let mut k1 = String::new();
|
let mut k1 = String::new();
|
||||||
zip.by_name("k1-members.csv")
|
zip.by_name("k1-members.csv")
|
||||||
|
|||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,10 +18,17 @@ typst = "0.15.1"
|
|||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
typst-layout = "0.15.1"
|
typst-layout = "0.15.1"
|
||||||
qrcode = { version = "0.14.1", default-features = false, features = ["svg"] }
|
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]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
proptest = "1"
|
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 }
|
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.
|
||||||
Binary file not shown.
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -46,7 +46,7 @@
|
|||||||
//! SOURCE (7-9): docs/spec/rules.md B-25;
|
//! SOURCE (7-9): docs/spec/rules.md B-25;
|
||||||
//! docs/spec/sources/irs-partnership-basis.md.
|
//! 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::report::{ReportMeta, amount_parts, format_amount, long_date};
|
||||||
use crate::xlsx::{Formats, XlsxError, footer, header, number};
|
use crate::xlsx::{Formats, XlsxError, footer, header, number};
|
||||||
use ledger_core::TaxBasisRule;
|
use ledger_core::TaxBasisRule;
|
||||||
@@ -512,7 +512,7 @@ fn engine() -> &'static TypstEngine<TypstTemplateMainFile> {
|
|||||||
ENGINE.get_or_init(|| {
|
ENGINE.get_or_init(|| {
|
||||||
TypstEngine::builder()
|
TypstEngine::builder()
|
||||||
.main_file(TEMPLATE)
|
.main_file(TEMPLATE)
|
||||||
.fonts(INTER)
|
.fonts(crate::fonts::typst_fonts())
|
||||||
.build()
|
.build()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+900
-93
File diff suppressed because it is too large.
Load diff
@@ -3,7 +3,7 @@
|
|||||||
//! Each label carries the company name, the asset tag (its headline), what
|
//! 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
|
//! 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
|
//! 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
|
//! JSON data (`sys.inputs.data`), never as markup. The sheet geometry comes
|
||||||
//! from the label maker's own specification, vendored in
|
//! from the label maker's own specification, vendored in
|
||||||
//! `docs/spec/sources/avery-5160.md`.
|
//! `docs/spec/sources/avery-5160.md`.
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
//! - A partly used sheet with 7 labels left: skip 23.
|
//! - 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::render::svg;
|
||||||
use qrcode::{EcLevel, QrCode};
|
use qrcode::{EcLevel, QrCode};
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
@@ -145,7 +145,7 @@ fn engine() -> &'static TypstEngine<TypstTemplateMainFile> {
|
|||||||
ENGINE.get_or_init(|| {
|
ENGINE.get_or_init(|| {
|
||||||
TypstEngine::builder()
|
TypstEngine::builder()
|
||||||
.main_file(TEMPLATE)
|
.main_file(TEMPLATE)
|
||||||
.fonts(INTER)
|
.fonts(crate::fonts::typst_fonts())
|
||||||
.build()
|
.build()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,14 @@
|
|||||||
//! - [`pdf`]: generate PDF financial statements (CPA handoff).
|
//! - [`pdf`]: generate PDF financial statements (CPA handoff).
|
||||||
//! - [`asset_register`]: the fixed asset register as XLSX and PDF.
|
//! - [`asset_register`]: the fixed asset register as XLSX and PDF.
|
||||||
//! - [`labels`]: print asset label sheets with QR codes.
|
//! - [`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.
|
//! All money stays `Decimal`; no floats in money paths.
|
||||||
|
|
||||||
pub mod asset_register;
|
pub mod asset_register;
|
||||||
pub mod bank_csv;
|
pub mod bank_csv;
|
||||||
pub mod chart_csv;
|
pub mod chart_csv;
|
||||||
|
pub mod fonts;
|
||||||
pub mod iif;
|
pub mod iif;
|
||||||
pub mod labels;
|
pub mod labels;
|
||||||
pub mod pdf;
|
pub mod pdf;
|
||||||
@@ -24,7 +26,10 @@ pub mod xlsx;
|
|||||||
pub use asset_register::{asset_register_pdf, asset_register_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 bank_csv::{BankCsvError, BankTransaction, DateOrder, parse_bank_csv, parse_bank_csv_with};
|
||||||
pub use chart_csv::{ChartRow, parse_chart_csv};
|
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 labels::{AssetLabel, LabelSheet, asset_labels_pdf};
|
||||||
pub use pdf::{PdfError, statement_pdf};
|
pub use pdf::{PdfError, statement_pdf};
|
||||||
pub use report::{Member, ReportMeta, Row, RowKind, Statement, YearEarnings, format_amount};
|
pub use report::{Member, ReportMeta, Row, RowKind, Statement, YearEarnings, format_amount};
|
||||||
|
|||||||
+121
-10
@@ -4,10 +4,14 @@
|
|||||||
//! rows to `templates/statement.typ` as JSON data (`sys.inputs.data`) and
|
//! rows to `templates/statement.typ` as JSON data (`sys.inputs.data`) and
|
||||||
//! renders the PDF. Amounts arrive already split by
|
//! renders the PDF. Amounts arrive already split by
|
||||||
//! [`crate::report::amount_parts`] — Typst never does arithmetic on money,
|
//! [`crate::report::amount_parts`] — Typst never does arithmetic on money,
|
||||||
//! and labels are data, never markup. The font (Inter, SIL OFL 1.1; see
|
//! and labels are data, never markup. The fonts are bundled, so output
|
||||||
//! `fonts/Inter-LICENSE.txt`) is bundled, so output doesn't depend on
|
//! doesn't depend on what's installed, and it is deterministic: the same
|
||||||
//! what's installed, and it is deterministic: the same statement and
|
//! statement and report metadata render to the same bytes. Inter sets
|
||||||
//! report metadata render to the same bytes.
|
//! 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 crate::report::{ReportMeta, RowKind, Statement, amount_parts};
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
@@ -42,11 +46,6 @@ pub enum PdfError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static TEMPLATE: &str = include_str!("../templates/statement.typ");
|
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.
|
/// The engine, built once: parsing the bundled fonts is the expensive part.
|
||||||
fn engine() -> &'static TypstEngine<TypstTemplateMainFile> {
|
fn engine() -> &'static TypstEngine<TypstTemplateMainFile> {
|
||||||
@@ -54,7 +53,7 @@ fn engine() -> &'static TypstEngine<TypstTemplateMainFile> {
|
|||||||
ENGINE.get_or_init(|| {
|
ENGINE.get_or_init(|| {
|
||||||
TypstEngine::builder()
|
TypstEngine::builder()
|
||||||
.main_file(TEMPLATE)
|
.main_file(TEMPLATE)
|
||||||
.fonts(INTER)
|
.fonts(crate::fonts::typst_fonts())
|
||||||
.build()
|
.build()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -124,6 +123,10 @@ fn typeset(st: &Statement, meta: &ReportMeta) -> Result<PagedDocument, PdfError>
|
|||||||
/// ("#panic()", "*", "$", "[") prints as text and NEVER runs.
|
/// ("#panic()", "*", "$", "[") prints as text and NEVER runs.
|
||||||
/// 3. Every row reaches the page: template_data carries each row's label,
|
/// 3. Every row reaches the page: template_data carries each row's label,
|
||||||
/// kind, and amounts split by amount_parts, in order.
|
/// 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> {
|
pub fn statement_pdf(st: &Statement, meta: &ReportMeta) -> Result<Vec<u8>, PdfError> {
|
||||||
let doc = typeset(st, meta)?;
|
let doc = typeset(st, meta)?;
|
||||||
@@ -221,4 +224,112 @@ mod tests {
|
|||||||
assert!(doc.pages().len() > 1, "200 rows should span pages");
|
assert!(doc.pages().len() > 1, "200 rows should span pages");
|
||||||
assert!(statement_pdf(&st, &meta()).unwrap().starts_with(b"%PDF"));
|
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
|
#let firm = 0.6pt + ink
|
||||||
|
|
||||||
#set document(title: d.company + " — " + d.title, author: d.company)
|
#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(
|
#set page(
|
||||||
paper: "us-letter",
|
paper: "us-letter",
|
||||||
flipped: true,
|
flipped: true,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
#let quiet = luma(90)
|
#let quiet = luma(90)
|
||||||
|
|
||||||
#set document(title: d.company + " — Asset labels", author: d.company)
|
#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 par(leading: 0.28em, spacing: 0pt)
|
||||||
#set page(width: g.page_width * pt1, height: g.page_height * pt1, margin: 0pt)
|
#set page(width: g.page_width * pt1, height: g.page_height * pt1, margin: 0pt)
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
#let firm = 0.6pt + ink
|
#let firm = 0.6pt + ink
|
||||||
|
|
||||||
#set document(title: d.company + " — " + d.title, author: d.company)
|
#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(
|
#set page(
|
||||||
paper: "us-letter",
|
paper: "us-letter",
|
||||||
flipped: wide,
|
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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
@@ -557,7 +557,7 @@ fn vendor_1099_report(
|
|||||||
year: i32,
|
year: i32,
|
||||||
threshold: Option<Decimal>,
|
threshold: Option<Decimal>,
|
||||||
) -> Result<ledger_core::Vendor1099Report, ledger_core::LedgerError> {
|
) -> 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 {
|
impl BooksServer {
|
||||||
@@ -5330,7 +5330,7 @@ impl BooksServer {
|
|||||||
#[tool(
|
#[tool(
|
||||||
name = "books.export",
|
name = "books.export",
|
||||||
title = "Export a report",
|
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)
|
annotations(read_only_hint = true, open_world_hint = false)
|
||||||
)]
|
)]
|
||||||
async fn export(&self, params: Parameters<ExportParams>) -> Result<Json<ExportOut>, String> {
|
async fn export(&self, params: Parameters<ExportParams>) -> Result<Json<ExportOut>, String> {
|
||||||
@@ -5343,8 +5343,30 @@ impl BooksServer {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
let report = p.report.to_lowercase();
|
let report = p.report.to_lowercase();
|
||||||
if format == "iif" && report != "journal" {
|
if format == "iif" {
|
||||||
return Err("iif format is only valid for the journal report".to_string());
|
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" {
|
if format == "xlsx" || format == "pdf" {
|
||||||
let bytes = if format == "xlsx" {
|
let bytes = if format == "xlsx" {
|
||||||
@@ -5357,15 +5379,7 @@ impl BooksServer {
|
|||||||
report,
|
report,
|
||||||
content: base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bytes),
|
content: base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bytes),
|
||||||
content_encoding: Some("base64".to_string()),
|
content_encoding: Some("base64".to_string()),
|
||||||
}));
|
warnings: Vec::new(),
|
||||||
}
|
|
||||||
if format == "iif" {
|
|
||||||
let content = self.export_iif()?;
|
|
||||||
return Ok(Json(ExportOut {
|
|
||||||
format,
|
|
||||||
report,
|
|
||||||
content,
|
|
||||||
content_encoding: None,
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
let content = match report.as_str() {
|
let content = match report.as_str() {
|
||||||
@@ -5392,6 +5406,7 @@ impl BooksServer {
|
|||||||
report,
|
report,
|
||||||
content,
|
content,
|
||||||
content_encoding: None,
|
content_encoding: None,
|
||||||
|
warnings: Vec::new(),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5508,15 +5523,25 @@ impl BooksServer {
|
|||||||
ledger_import::statement_pdf(&st, &meta).map_err(|e| e.to_string())
|
ledger_import::statement_pdf(&st, &meta).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render the journal as IIF text via ledger-import, declaring every
|
/// The journal through `as_of` as IIF via ledger-package (the CPA
|
||||||
/// account with its QuickBooks type: the `[books] cash_accounts` are
|
/// package's own general-journal.iif): the cash accounts (`cash`, else
|
||||||
/// BANK, the receivable and payable control accounts AR and AP, a
|
/// the settings') BANK, the receivable and payable control accounts AR
|
||||||
/// registered asset's cost and accumulated depreciation FIXASSET, and
|
/// and AP, the accounts of an asset bought by `as_of` FIXASSET, the
|
||||||
/// the rest follow their wanderbooks type (other current asset, other
|
/// rest by their wanderbooks type; every posted entry from the first,
|
||||||
/// current liability, equity, income, expense).
|
/// closes left out.
|
||||||
fn export_iif(&self) -> Result<String, String> {
|
fn export_iif(
|
||||||
|
&self,
|
||||||
|
as_of: chrono::NaiveDate,
|
||||||
|
cash: &[String],
|
||||||
|
) -> Result<ledger_import::Iif, String> {
|
||||||
let store = self.lock()?;
|
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(
|
#[tool(
|
||||||
name = "books.export_cpa_package",
|
name = "books.export_cpa_package",
|
||||||
title = "CPA handoff 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(
|
annotations(
|
||||||
read_only_hint = false,
|
read_only_hint = false,
|
||||||
destructive_hint = false,
|
destructive_hint = false,
|
||||||
@@ -8133,18 +8158,26 @@ impl BooksServer {
|
|||||||
tin_files::record(&vault_dir, &package.zip)?;
|
tin_files::record(&vault_dir, &package.zip)?;
|
||||||
}
|
}
|
||||||
let tin_gaps = package.tin_gaps;
|
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(),
|
(false, _) => "The package, with last fours only.".to_string(),
|
||||||
(true, 0) => "The package, with full TINs, for the CPA.".to_string(),
|
(true, 0) => "The package, with full TINs, for the CPA.".to_string(),
|
||||||
(true, n) => format!(
|
(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."
|
"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)?;
|
let file = self.hand_over(&book, &package.file_name, "application/zip", package.zip)?;
|
||||||
Ok(Json(ExportCpaPackageOut {
|
Ok(Json(ExportCpaPackageOut {
|
||||||
file,
|
file,
|
||||||
tins_included: p.include_tins,
|
tins_included: p.include_tins,
|
||||||
tin_gaps,
|
tin_gaps,
|
||||||
|
warnings,
|
||||||
note,
|
note,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1682,7 +1682,8 @@ pub struct StaleRule {
|
|||||||
pub struct ExportParams {
|
pub struct ExportParams {
|
||||||
/// "csv", "json", "xlsx", "pdf", or "iif". XLSX and PDF are
|
/// "csv", "json", "xlsx", "pdf", or "iif". XLSX and PDF are
|
||||||
/// base64-encoded; PDF covers profit_and_loss, balance_sheet, and
|
/// 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,
|
pub format: String,
|
||||||
/// "journal", "trial_balance", "profit_and_loss", "balance_sheet", or
|
/// "journal", "trial_balance", "profit_and_loss", "balance_sheet", or
|
||||||
/// "cash_flow".
|
/// "cash_flow".
|
||||||
@@ -1691,10 +1692,13 @@ pub struct ExportParams {
|
|||||||
pub start: Option<String>,
|
pub start: Option<String>,
|
||||||
/// Period end for profit_and_loss and cash_flow, YYYY-MM-DD.
|
/// Period end for profit_and_loss and cash_flow, YYYY-MM-DD.
|
||||||
pub end: Option<String>,
|
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>,
|
pub as_of: Option<String>,
|
||||||
/// Cash account codes for cash_flow. Defaults to `[books]
|
/// Cash account codes for cash_flow, and for iif its BANK accounts.
|
||||||
/// cash_accounts`; one or the other is required for that report.
|
/// Defaults to `[books] cash_accounts`; one or the other is required
|
||||||
|
/// for cash_flow.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub cash_accounts: Vec<String>,
|
pub cash_accounts: Vec<String>,
|
||||||
/// Liability account codes treated as financing for cash_flow.
|
/// Liability account codes treated as financing for cash_flow.
|
||||||
@@ -1721,6 +1725,16 @@ pub struct ExportOut {
|
|||||||
/// "base64" for XLSX and PDF, otherwise absent.
|
/// "base64" for XLSX and PDF, otherwise absent.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub content_encoding: Option<String>,
|
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.
|
/// with books.set_tin or books.set_w9.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub tin_gaps: Vec<String>,
|
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.
|
/// What to tell the human.
|
||||||
pub note: String,
|
pub note: String,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1185,15 +1185,27 @@ fn mcp_import_export_over_stdio() {
|
|||||||
|
|
||||||
let chart = mcp.call(
|
let chart = mcp.call(
|
||||||
"books.import_chart_csv",
|
"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(
|
let iif = mcp.call(
|
||||||
"books.export",
|
"books.export",
|
||||||
json!({"format": "iif", "report": "journal"}),
|
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(
|
let xlsx = mcp.call(
|
||||||
"books.export",
|
"books.export",
|
||||||
|
|||||||
@@ -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
|
/// IIF: accounts declared with QuickBooks types per the documented mapping
|
||||||
/// (cash accounts BANK, 1200 AR, 2000 AP, a registered asset's accounts
|
/// (cash accounts BANK, 1200 AR, 2000 AP, a registered asset's accounts
|
||||||
/// FIXASSET, the rest by type), then one balanced TRNS/SPL/ENDTRNS block
|
/// 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]
|
#[test]
|
||||||
fn iif_declares_accounts_by_role_and_balances_every_transaction() {
|
fn iif_declares_accounts_by_role_and_balances_every_transaction() {
|
||||||
let dir = test_dir("iif");
|
let dir = test_dir("iif");
|
||||||
let mut s = new_store(&dir);
|
let mut s = new_store(&dir);
|
||||||
rich_books(&mut s);
|
rich_books(&mut s);
|
||||||
let posted = s.ledger().posted_entries().count();
|
let ledger = s.ledger().clone();
|
||||||
drop(s);
|
drop(s);
|
||||||
let mut m = Mcp::start(&dir, "1000000.00");
|
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 types: HashMap<String, String> = HashMap::new();
|
||||||
let mut header: Vec<&str> = Vec::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 sum = Decimal::ZERO;
|
||||||
let mut in_trns = false;
|
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();
|
let f: Vec<&str> = line.split('\t').collect();
|
||||||
match f[0] {
|
match f[0] {
|
||||||
"!ACCNT" => header = f.clone(),
|
"!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");
|
assert!(!in_trns, "TRNS inside a transaction");
|
||||||
in_trns = true;
|
in_trns = true;
|
||||||
sum = Decimal::ZERO;
|
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]);
|
sum += dec(f[6]);
|
||||||
}
|
}
|
||||||
"ENDTRNS" => {
|
"ENDTRNS" => {
|
||||||
assert!(in_trns);
|
assert!(in_trns);
|
||||||
assert_eq!(sum, Decimal::ZERO, "an IIF transaction sums to zero");
|
assert_eq!(sum, Decimal::ZERO, "an IIF transaction sums to zero");
|
||||||
in_trns = false;
|
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 [
|
for (code, ty) in [
|
||||||
("1000", "BANK"),
|
("1000", "BANK"),
|
||||||
("1200", "AR"),
|
("1200", "AR"),
|
||||||
@@ -1283,6 +1327,69 @@ fn iif_declares_accounts_by_role_and_balances_every_transaction() {
|
|||||||
"account {code}'s IIF type"
|
"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
|
// ------------------------------------------------- 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
|
/// the receipts behind the year's posted entries (and only those), indexed
|
||||||
/// with each entry's date, description, and debits; the company's
|
/// with each entry's date, description, and debits; the company's
|
||||||
/// documents but not loose receipts or withdrawn files; a README counting
|
/// 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
|
/// include_receipts false, no evidence. Kills: export_cpa_package's
|
||||||
/// receipt and document guards, the receipt count, the file-name cleaner.
|
/// receipt and document guards, the receipt count, the file-name cleaner.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2775,6 +2884,8 @@ fn cpa_package_holds_the_year_and_its_evidence() {
|
|||||||
let dir = test_dir("cpa");
|
let dir = test_dir("cpa");
|
||||||
let mut s = new_store(&dir);
|
let mut s = new_store(&dir);
|
||||||
let r = rich_books(&mut s);
|
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.
|
// Two receipts on the rent, same-length names.
|
||||||
let rent_a: &[u8] = b"rent receipt A";
|
let rent_a: &[u8] = b"rent receipt A";
|
||||||
let rent_b: &[u8] = b"rent receipt B";
|
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"]);
|
let gj = xlsx_text(&files["general-journal.xlsx"]);
|
||||||
assert!(gj.contains("Rent January") && !gj.contains("Prior-year sale"));
|
assert!(gj.contains("Rent January") && !gj.contains("Prior-year sale"));
|
||||||
assert!(!gj.contains("After mid-year"), "fiscal year to as_of only");
|
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.
|
// Receipts: exactly the two behind the rent.
|
||||||
let receipt_files: Vec<&str> = names
|
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(),
|
std::str::from_utf8(&files["1099-worksheet-2025.csv"]).unwrap(),
|
||||||
&["vendor", "form", "w9_on_file", "paid", "reportable"],
|
&["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());
|
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) {
|
for (row, v) in w.iter().zip(&want.vendors) {
|
||||||
assert_eq!(row[0], v.name);
|
assert_eq!(row[0], v.name);
|
||||||
assert_eq!(row[1], v.form.map(|f| f.label()).unwrap_or(""));
|
assert_eq!(row[1], v.form.map(|f| f.label()).unwrap_or(""));
|
||||||
|
|||||||
@@ -1261,7 +1261,6 @@ fn happy_path(name: &str) -> Mcp {
|
|||||||
("xlsx", "balance_sheet"),
|
("xlsx", "balance_sheet"),
|
||||||
("pdf", "profit_and_loss"),
|
("pdf", "profit_and_loss"),
|
||||||
("csv", "cash_flow"),
|
("csv", "cash_flow"),
|
||||||
("iif", "journal"),
|
|
||||||
("csv", "general_ledger"),
|
("csv", "general_ledger"),
|
||||||
("json", "members_equity"),
|
("json", "members_equity"),
|
||||||
("csv", "tax_summary"),
|
("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"}),
|
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(
|
m.ok(
|
||||||
"books.export_cpa_package",
|
"books.export_cpa_package",
|
||||||
json!({"as_of": "2026-12-31", "include_receipts": false, "include_tins": false}),
|
json!({"as_of": "2026-12-31", "include_receipts": false, "include_tins": false}),
|
||||||
|
|||||||
@@ -19,4 +19,6 @@ zip = "9.0.1"
|
|||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
proptest = "1"
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
toml = "1"
|
||||||
@@ -2,29 +2,44 @@
|
|||||||
//! and the package's `general-journal.iif`.
|
//! and the package's `general-journal.iif`.
|
||||||
//!
|
//!
|
||||||
//! ```text
|
//! ```text
|
||||||
//! GOAL: the CPA imports every posted entry into QuickBooks Desktop, each
|
//! GOAL: the CPA imports the books through a day into QuickBooks Desktop,
|
||||||
//! account declared with the type QuickBooks needs.
|
//! each account declared with the type QuickBooks needs, and gets
|
||||||
//! SOURCE: ledger_import::iif (the IIF format and account types).
|
//! 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:
|
//! INVARIANTS:
|
||||||
//! 1. The cash accounts are BANK; the receivable and payable control
|
//! 1. The cash accounts are BANK; the receivable and payable control
|
||||||
//! accounts AR and AP; a registered asset's cost and accumulated
|
//! accounts AR and AP; the cost and accumulated depreciation accounts
|
||||||
//! depreciation FIXASSET; every other account follows its type.
|
//! of an asset bought on or before as_of FIXASSET; every other
|
||||||
//! 2. Every posted entry, and only those.
|
//! 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};
|
use ledger_core::{AccountId, AccountType, Ledger};
|
||||||
|
|
||||||
/// Render the journal as IIF text via ledger-import, declaring every
|
/// The journal through `as_of` as IIF, via ledger-import (INVARIANTS 1-2),
|
||||||
/// account with its QuickBooks type: the cash accounts (`cash_codes`, the
|
/// declaring every account with its QuickBooks type: the cash accounts
|
||||||
/// books' `cash_accounts`) are BANK, the receivable and payable control
|
/// (`cash_codes`, the books' `cash_accounts`) are BANK, the receivable and
|
||||||
/// accounts AR and AP, a registered asset's cost and accumulated
|
/// payable control accounts AR and AP, the cost and accumulated
|
||||||
/// depreciation FIXASSET, and the rest follow their wanderbooks type
|
/// depreciation accounts of an asset bought by `as_of` FIXASSET, and the
|
||||||
/// (other current asset, other current liability, equity, income,
|
/// rest follow their wanderbooks type (other current asset, other current
|
||||||
/// expense).
|
/// liability, equity, income, expense). Returns the file and every text it
|
||||||
pub fn export_iif(ledger: &Ledger, cash_codes: &[String]) -> Result<String, String> {
|
/// 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};
|
use ledger_import::{QbAccountType as Qb, iif_accounts, journal_to_iif};
|
||||||
let fixed: std::collections::HashSet<AccountId> = ledger
|
let fixed: std::collections::HashSet<AccountId> = ledger
|
||||||
.registered_assets()
|
.registered_assets()
|
||||||
|
.filter(|a| a.purchase_date <= as_of)
|
||||||
.flat_map(|a| [a.cost_account, a.accum_depr_account])
|
.flat_map(|a| [a.cost_account, a.accum_depr_account])
|
||||||
.collect();
|
.collect();
|
||||||
let receivables = ledger.role_account(&ledger_core::RECEIVABLES);
|
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())
|
journal_to_iif(&accounts, &entries).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
@@ -6,16 +6,18 @@
|
|||||||
//! GOAL: everything the CPA needs for the return in one zip, the K-1s'
|
//! 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
|
//! and 1099s' TINs included when asked, built the same by the
|
||||||
//! server and the viewer.
|
//! server and the viewer.
|
||||||
//! SOURCE: docs/mcp-interface.md; B-26; DECISION (owner, 2026-10-15):
|
//! SOURCE: docs/mcp-interface.md; B-26; B-33 (as of one day); B-34 (the
|
||||||
//! "it needs to be in the damn CPA package."
|
//! IIF); DECISION (owner, 2026-10-15): "it needs to be in the damn
|
||||||
|
//! CPA package."
|
||||||
//! INVARIANTS:
|
//! INVARIANTS:
|
||||||
//! 1. The statements (profit and loss, balance sheet, cash flow, members'
|
//! 1. The statements (profit and loss, balance sheet, cash flow, members'
|
||||||
//! equity with members, tax summary, trial balance) each as PDF and
|
//! equity with members, tax summary, trial balance) each as PDF and
|
||||||
//! XLSX, for the fiscal year containing as_of, through as_of; the
|
//! XLSX, for the fiscal year containing as_of, through as_of; the
|
||||||
//! fixed asset register when there are fixed assets; the general
|
//! fixed asset register when an asset was bought by as_of; the
|
||||||
//! ledger and journal (XLSX); the journal as IIF; the 1099 worksheet
|
//! general ledger and journal (XLSX); the journal through as_of as
|
||||||
//! for as_of's calendar year (or why it can't be made); with members,
|
//! IIF; the 1099 worksheet for as_of's calendar year through as_of
|
||||||
//! the K-1 worksheet; a README first.
|
//! (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,
|
//! 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
|
//! share, capital account, TIN and type (or last four without a TIN
|
||||||
//! source), and the K-1 worksheet's capital figures (ledger_core::k1)
|
//! source), and the K-1 worksheet's capital figures (ledger_core::k1)
|
||||||
@@ -32,6 +34,20 @@
|
|||||||
//! Withdrawn files never.
|
//! Withdrawn files never.
|
||||||
//! 5. Read-only (crate INVARIANT 1): the package is returned, its files
|
//! 5. Read-only (crate INVARIANT 1): the package is returned, its files
|
||||||
//! and the zip, and written nowhere.
|
//! 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 chrono::NaiveDate;
|
||||||
@@ -84,6 +100,14 @@ pub struct Package {
|
|||||||
pub tin_gaps: Vec<String>,
|
pub tin_gaps: Vec<String>,
|
||||||
/// The receipts in receipts/ (one per entry they are behind).
|
/// The receipts in receipts/ (one per entry they are behind).
|
||||||
pub receipt_count: usize,
|
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`.
|
/// 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 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 fy = NaiveDate::parse_from_str(&fy_start, "%Y-%m-%d").map_err(|e| e.to_string())?;
|
||||||
let members = report_members(company);
|
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.
|
// The financial statements, each as PDF and XLSX.
|
||||||
let mut statements: Vec<(&str, ledger_import::Statement)> = vec![
|
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)));
|
statements.push(("members-equity", report::members_equity(&me)));
|
||||||
// The K-1 worksheet: each member's capital account for the
|
// The K-1 worksheet: each member's capital account for the
|
||||||
// year (Schedule K-1 Item L) and their TIN (INVARIANT 2).
|
// 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
|
let cash: Vec<AccountId> = cash_codes
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|c| ledger.chart().find_by_code(c).map(|a| a.id))
|
.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}.pdf"), pdf(st)?));
|
||||||
files.push((format!("{name}.xlsx"), xlsx(XlsxReport::Statement(st))?));
|
files.push((format!("{name}.xlsx"), xlsx(XlsxReport::Statement(st))?));
|
||||||
}
|
}
|
||||||
// The fixed asset register, when there are fixed assets.
|
// The fixed asset register, when an asset was bought by as_of (one
|
||||||
if ledger.registered_assets().next().is_some() {
|
// 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())?;
|
let r = ledger_core::fixed_asset_register(ledger, as_of).map_err(|e| e.to_string())?;
|
||||||
files.push((
|
files.push((
|
||||||
"fixed-asset-register.pdf".into(),
|
"fixed-asset-register.pdf".into(),
|
||||||
@@ -334,10 +365,17 @@ pub fn build_package<B: PackageBooks + ?Sized>(
|
|||||||
"general-journal.xlsx".into(),
|
"general-journal.xlsx".into(),
|
||||||
xlsx(XlsxReport::GeneralJournal(ledger, fy, as_of))?,
|
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 year = chrono::Datelike::year(&as_of);
|
||||||
let mut worksheet_1099 = false;
|
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) => {
|
Ok(r1099) => {
|
||||||
let mut w = String::from("vendor,form,w9_on_file,paid,reportable");
|
let mut w = String::from("vendor,form,w9_on_file,paid,reportable");
|
||||||
if tins.is_some() {
|
if tins.is_some() {
|
||||||
@@ -516,13 +554,20 @@ pub fn build_package<B: PackageBooks + ?Sized>(
|
|||||||
files.push(k1);
|
files.push(k1);
|
||||||
}
|
}
|
||||||
let seq = books.journal_seq();
|
let seq = books.journal_seq();
|
||||||
files.push((
|
// The journal through as_of for QuickBooks, closes left out, and what
|
||||||
"general-journal.iif".into(),
|
// it writes differently (INVARIANT 8).
|
||||||
export_iif(ledger, settings.cash_accounts)?.into_bytes(),
|
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 include_tins = tins.is_some();
|
||||||
let readme = format!(
|
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,
|
name = company.name,
|
||||||
basis = company.basis,
|
basis = company.basis,
|
||||||
members = if company.members.is_empty() {
|
members = if company.members.is_empty() {
|
||||||
@@ -530,9 +575,43 @@ pub fn build_package<B: PackageBooks + ?Sized>(
|
|||||||
} else {
|
} else {
|
||||||
"members' equity, "
|
"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 {
|
worksheet = if worksheet_1099 {
|
||||||
format!(
|
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 {
|
if include_tins {
|
||||||
", with each payee's TIN"
|
", with each payee's TIN"
|
||||||
} else {
|
} else {
|
||||||
@@ -623,5 +702,7 @@ pub fn build_package<B: PackageBooks + ?Sized>(
|
|||||||
zip: zip_buf,
|
zip: zip_buf,
|
||||||
tin_gaps,
|
tin_gaps,
|
||||||
receipt_count,
|
receipt_count,
|
||||||
|
warnings,
|
||||||
|
iif_changes,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -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
|
/// The 1099 report for `year`, the one every tool, the worksheet, and the
|
||||||
/// CPA package use: payments out of [`payment_accounts`], an entry's
|
/// 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(
|
pub fn vendor_1099_report(
|
||||||
ledger: &Ledger,
|
ledger: &Ledger,
|
||||||
settings: &Settings<'_>,
|
settings: &Settings<'_>,
|
||||||
year: i32,
|
year: i32,
|
||||||
|
through: Option<chrono::NaiveDate>,
|
||||||
threshold: Option<Decimal>,
|
threshold: Option<Decimal>,
|
||||||
) -> Result<ledger_core::Vendor1099Report, ledger_core::LedgerError> {
|
) -> Result<ledger_core::Vendor1099Report, ledger_core::LedgerError> {
|
||||||
ledger_core::vendor_1099_report_with_members(
|
ledger_core::vendor_1099_report_with_members(
|
||||||
ledger,
|
ledger,
|
||||||
year,
|
year,
|
||||||
|
through,
|
||||||
threshold,
|
threshold,
|
||||||
&payment_accounts(ledger, settings),
|
&payment_accounts(ledger, settings),
|
||||||
&settings.company.party_members(ledger),
|
&settings.company.party_members(ledger),
|
||||||
@@ -128,7 +132,8 @@ pub fn vendor_1099_worksheet(
|
|||||||
) -> Result<Vendor1099Worksheet, String> {
|
) -> Result<Vendor1099Worksheet, String> {
|
||||||
// A year past what wanderbooks knows is refused with guidance
|
// A year past what wanderbooks knows is refused with guidance
|
||||||
// (research or ask, pass `threshold`, then update the rule).
|
// (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
|
let missing_w9 = r
|
||||||
.vendors
|
.vendors
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -4,13 +4,16 @@
|
|||||||
|
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use ledger_core::settings::{Company, Member};
|
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::{
|
use ledger_package::{
|
||||||
Package, PackageBooks, PackageRequest, Settings, build_package, vendor_1099_csv,
|
Package, PackageBooks, PackageRequest, Settings, build_package, vendor_1099_csv,
|
||||||
vendor_1099_csv_name, vendor_1099_worksheet,
|
vendor_1099_csv_name, vendor_1099_worksheet,
|
||||||
};
|
};
|
||||||
use ledger_store::{Snapshot, Store, Subject};
|
use ledger_store::{Snapshot, Store, Subject};
|
||||||
use rust_decimal::Decimal;
|
use rust_decimal::Decimal;
|
||||||
|
use std::collections::{BTreeMap, HashMap};
|
||||||
use std::io::Read;
|
use std::io::Read;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::str::FromStr;
|
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"));
|
assert!(!names(&p).contains(&"k1-members.csv"));
|
||||||
let iif = text(&p, "general-journal.iif");
|
let iif = text(&p, "general-journal.iif");
|
||||||
assert!(iif.contains("Checking") && iif.contains("BANK"), "{iif}");
|
assert!(iif.contains("Checking") && iif.contains("BANK"), "{iif}");
|
||||||
// Every posted entry, the year after as_of's included.
|
// Every posted entry through as_of; the next year's rent is not.
|
||||||
assert!(iif.contains("Next year's rent"), "{iif}");
|
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);
|
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[4], "100.00", "{row}");
|
||||||
assert_eq!(fields[5], format!("receipts/2026-06-01-entry-{e}-june.pdf"));
|
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);
|
||||||
|
}
|
||||||
@@ -1858,8 +1858,15 @@ fn every_figure_of(l: &ledger_core::Ledger) -> (usize, usize) {
|
|||||||
financing: vec![],
|
financing: vec![],
|
||||||
};
|
};
|
||||||
tally(
|
tally(
|
||||||
ledger_core::form1099::vendor_1099_report_with_members(l, year, None, &sources, &[])
|
ledger_core::form1099::vendor_1099_report_with_members(
|
||||||
.map(|_| ()),
|
l,
|
||||||
|
year,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
&sources,
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.map(|_| ()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let money = ledger_core::parties::money_accounts(l, &cash);
|
let money = ledger_core::parties::money_accounts(l, &cash);
|
||||||
|
|||||||
+59
-9
@@ -1131,7 +1131,8 @@ posting.
|
|||||||
## Tools — export
|
## Tools — export
|
||||||
|
|
||||||
- `books.export { format, report, start?, end?, as_of?, cash_accounts?,
|
- `books.export { format, report, start?, end?, as_of?, cash_accounts?,
|
||||||
financing_accounts?, investing_accounts? }` → `{ content, content_encoding? }`.
|
financing_accounts?, investing_accounts? }` → `{ content,
|
||||||
|
content_encoding?, warnings? }`.
|
||||||
- Reports: `profit_and_loss`, `balance_sheet`, `cash_flow` (needs
|
- Reports: `profit_and_loss`, `balance_sheet`, `cash_flow` (needs
|
||||||
`cash_accounts`), `trial_balance`, `members_equity` (statement of
|
`cash_accounts`), `trial_balance`, `members_equity` (statement of
|
||||||
changes in members' equity; members from `wanderbooks.toml`; earnings
|
changes in members' equity; members from `wanderbooks.toml`; earnings
|
||||||
@@ -1142,11 +1143,40 @@ posting.
|
|||||||
balance and split, ending balance), `general_journal` (alias
|
balance and split, ending balance), `general_journal` (alias
|
||||||
`journal`: every entry, line by line, in date order).
|
`journal`: every entry, line by line, in date order).
|
||||||
- Formats: `pdf` and `xlsx` (base64) for the five statements; `xlsx`,
|
- Formats: `pdf` and `xlsx` (base64) for the five statements; `xlsx`,
|
||||||
`csv`, `json` for every report; `iif` for the journal (QuickBooks
|
`csv`, `json` for every report; `iif` for `general_journal` (alias
|
||||||
Desktop).
|
`journal`), for QuickBooks Desktop.
|
||||||
- Dates: period reports take `start` (default: first entry) and `end`
|
- Dates: period reports take `start` (default: first entry) and `end`
|
||||||
(default: today, local time); `balance_sheet` and `trial_balance`
|
(default: today, local time); `balance_sheet`, `trial_balance`,
|
||||||
take `as_of`.
|
`fixed_asset_register` and `iif` take `as_of` (default today).
|
||||||
|
- IIF (`docs/spec/rules.md` B-34; plan D-36): plain ASCII text, every
|
||||||
|
line ending CR LF, inline (no `content_encoding`): every posted entry
|
||||||
|
from the first through `as_of`, period-closing entries left out
|
||||||
|
(QuickBooks keeps none; imported, a close would zero its period's
|
||||||
|
profit), each transaction's DOCNUM its entry id; its BANK accounts are
|
||||||
|
`cash_accounts`, else the settings'. `start` or `end` is refused: the
|
||||||
|
file is cumulative, so QuickBooks' balances match the books'. A
|
||||||
|
character past ASCII is transliterated ("René" "Rene", "北京" "Bei
|
||||||
|
Jing", "—" "--"), a double quote is a single one (QuickBooks reads it
|
||||||
|
as a text delimiter), a tab or line break is a space, a control
|
||||||
|
character '?'. Within QuickBooks Desktop's limits (Intuit): an account
|
||||||
|
name level past 31 characters is cut there; accounts whose names would
|
||||||
|
then match get " (code)" (QuickBooks would merge them); a subaccount
|
||||||
|
whose QuickBooks type differs from its parent's is written at the top
|
||||||
|
level (QuickBooks gives a subaccount its parent's type); each parent
|
||||||
|
is declared before its subaccounts; an account number past 7
|
||||||
|
characters, or written like another's, is left out of ACCNUM; a memo
|
||||||
|
past 4,095 characters is cut. No line names a customer or vendor (no
|
||||||
|
NAME column; see the package's README). `warnings` (absent when
|
||||||
|
there are none) says what the file writes differently from the books,
|
||||||
|
for the human: each account's name, number and place a line (`account 6010's
|
||||||
|
name "Café meals" is written "Cafe meals"`, `account number
|
||||||
|
"12345678" is left out: QuickBooks Desktop holds 7 characters at
|
||||||
|
most`); the memos grouped, each thing written differently once with
|
||||||
|
every entry it is in (`"—" is written "--" in the memos of entries 3,
|
||||||
|
5, 9`; a run of changed characters is one thing), then the memos cut,
|
||||||
|
so a hundred memos with an em dash make one line. No line holds a full
|
||||||
|
TIN: a run of nine digits, or every numeral of a text holding one in
|
||||||
|
another script, is masked `•`.
|
||||||
- Presentation (PDF and XLSX alike, QuickBooks-style): centered company,
|
- Presentation (PDF and XLSX alike, QuickBooks-style): centered company,
|
||||||
title, and period; a column-header row; "$" on subtotals and totals;
|
title, and period; a column-header row; "$" on subtotals and totals;
|
||||||
negatives in parentheses with decimal points aligned; a rule above every
|
negatives in parentheses with decimal points aligned; a rule above every
|
||||||
@@ -1414,8 +1444,25 @@ entries.
|
|||||||
|
|
||||||
- `books.export_cpa_package { as_of?, cash_accounts?, financing_accounts?,
|
- `books.export_cpa_package { as_of?, cash_accounts?, financing_accounts?,
|
||||||
investing_accounts?, include_receipts?, include_tins? }` → `{ file,
|
investing_accounts?, include_receipts?, include_tins? }` → `{ file,
|
||||||
tins_included, tin_gaps, note }`: the zip, `cpa-package-<as_of>.zip`,
|
tins_included, tin_gaps, warnings?, note }`: the zip,
|
||||||
handed over as `file` (a `HandedFile`, "Tools — the TIN vault").
|
`cpa-package-<as_of>.zip`, handed over as `file` (a `HandedFile`,
|
||||||
|
"Tools — the TIN vault").
|
||||||
|
**As of one day** (`docs/spec/rules.md` B-33): nothing in it reflects
|
||||||
|
anything dated after `as_of` (default today): no figure, entry,
|
||||||
|
receipt or worksheet row; the chart and `documents/` are as they stand.
|
||||||
|
One list of cash accounts and one of financing accounts serve the
|
||||||
|
whole package: the request's, else the settings'.
|
||||||
|
**QuickBooks file** (`general-journal.iif`, B-34): as `books.export`
|
||||||
|
iif makes it, through `as_of`; `warnings` says what it writes
|
||||||
|
differently from the books, in the same lines as `books.export` iif's
|
||||||
|
(account names and numbers one each, the memos grouped; every other
|
||||||
|
file keeps the books' text), the README lists the same lines (and
|
||||||
|
names the entries with a line on accounts receivable or payable, to
|
||||||
|
which QuickBooks Desktop may ask the CPA to add a customer or vendor),
|
||||||
|
and `note` ends "N 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.", N
|
||||||
|
counting each name, number, place and memo.
|
||||||
**Receipts** (`include_receipts`, default true): `receipts/` holds every
|
**Receipts** (`include_receipts`, default true): `receipts/` holds every
|
||||||
current file behind each posted entry of the fiscal year to date, filed
|
current file behind each posted entry of the fiscal year to date, filed
|
||||||
on it or linked to it (`books.link_attachment`; a linked file counts as
|
on it or linked to it (`books.link_attachment`; a linked file counts as
|
||||||
@@ -1453,8 +1500,11 @@ entries.
|
|||||||
party is the party with the member's name (not a display name) and the
|
party is the party with the member's name (not a display name) and the
|
||||||
member role, as the viewer's capital statement finds it; with none, the
|
member role, as the viewer's capital statement finds it; with none, the
|
||||||
member's TIN is a gap.
|
member's TIN is a gap.
|
||||||
**1099 worksheet** with TINs adds `party_id, tin_last4, tin, tin_type,
|
**1099 worksheet** (`1099-worksheet-<year>.csv`, `as_of`'s calendar
|
||||||
tin_note`. A zip with the full set, each
|
year): the payments from January 1 through `as_of` (the year to date;
|
||||||
|
the whole year for a December 31 package; DECISION D-36), and the
|
||||||
|
README says which. With TINs it adds `party_id, tin_last4, tin,
|
||||||
|
tin_type, tin_note`. A zip with the full set, each
|
||||||
statement as PDF and XLSX: `profit-and-loss`, `balance-sheet`,
|
statement as PDF and XLSX: `profit-and-loss`, `balance-sheet`,
|
||||||
`cash-flow` (reconciled to `cash_accounts`), `members-equity`
|
`cash-flow` (reconciled to `cash_accounts`), `members-equity`
|
||||||
(multi-member LLCs), `trial-balance`; plus `general-ledger.xlsx` and
|
(multi-member LLCs), `trial-balance`; plus `general-ledger.xlsx` and
|
||||||
|
|||||||
+175
-2
@@ -490,7 +490,8 @@ years after 2026, indexed to inflation and rounded to the nearest $100
|
|||||||
Attorneys' gross proceeds stay at $600 (not modeled).
|
Attorneys' gross proceeds stay at $600 (not modeled).
|
||||||
|
|
||||||
What counts as paid: payments made in the calendar year (§6041(a)), so a
|
What counts as paid: payments made in the calendar year (§6041(a)), so a
|
||||||
prepayment counts when made. The engine counts credits minus debits to the
|
prepayment counts when made. A worksheet as of a day (the CPA package's,
|
||||||
|
B-33) counts the year's payments from January 1 through that day. The engine counts credits minus debits to the
|
||||||
payment sources on a vendor's tagged entries: `[books] cash_accounts` and
|
payment sources on a vendor's tagged entries: `[books] cash_accounts` and
|
||||||
`financing_accounts` (a member or lender paying on the company's behalf;
|
`financing_accounts` (a member or lender paying on the company's behalf;
|
||||||
the company is still the payer). A card charge leaves neither (1099-K); a
|
the company is still the payer). A card charge leaves neither (1099-K); a
|
||||||
@@ -680,7 +681,12 @@ with `include_tins` (DECISION, the owner, 2026-10-15: for the CPA, when
|
|||||||
asked): a refusal that quotes its input masks any TIN in it
|
asked): a refusal that quotes its input masks any TIN in it
|
||||||
(`parties::redact_full_tins`, checked by `refusals_never_echo_a_tin`),
|
(`parties::redact_full_tins`, checked by `refusals_never_echo_a_tin`),
|
||||||
and a party text field refuses nine digits however they are separated
|
and a party text field refuses nine digits however they are separated
|
||||||
(space, hyphen, dot, slash, underscore; `has_full_tin`). Files holding one (`books.get_w9`, `books.vendor_1099`'s
|
(`has_full_tin`: up to three spaces, hyphens, dots, slashes or
|
||||||
|
underscores between neighbors, in any script's digits; past ASCII every
|
||||||
|
Unicode space and dash and the fullwidth and small forms count, and
|
||||||
|
invisible characters such as a zero-width space or a right-to-left mark
|
||||||
|
don't count toward the three: `sources/unicode-tin-text.md`, Unicode
|
||||||
|
17.0). Files holding one (`books.get_w9`, `books.vendor_1099`'s
|
||||||
`csv` with `include_tins`, and the CPA package, whose K-1 and 1099
|
`csv` with `include_tins`, and the CPA package, whose K-1 and 1099
|
||||||
worksheets carry every member's and payee's TIN by default) are never
|
worksheets carry every member's and payee's TIN by default) are never
|
||||||
written to a path: no tool takes one (D-29, P2 slice 6). Each is handed
|
written to a path: no tool takes one (D-29, P2 slice 6). Each is handed
|
||||||
@@ -1015,3 +1021,170 @@ checked to change no figure or entry; `approval_rules_off_by_default`,
|
|||||||
(`exports_round_trip`) and codec unit tests; `ledger-mcp`
|
(`exports_round_trip`) and codec unit tests; `ledger-mcp`
|
||||||
`tests/spec_kept.rs`; Lean `Step.record_changes_no_figure`,
|
`tests/spec_kept.rs`; Lean `Step.record_changes_no_figure`,
|
||||||
`Reachable.records`; fuzz `fuzz_settings_replay`.
|
`Reachable.records`; fuzz `fuzz_settings_replay`.
|
||||||
|
|
||||||
|
## B-33 A package as of a day shows the books through that day
|
||||||
|
|
||||||
|
**Status:** CONVENTION (a report as of a day reports the books on that
|
||||||
|
day: balances through it, a period's activity from the period's start
|
||||||
|
through it; nearest authorities the accounting period, IRS Pub. 538,
|
||||||
|
`sources/irs-periods-and-methods.md`, and the information returns'
|
||||||
|
calendar year, `sources/irs-1099-thresholds.md`); DECISION (owner,
|
||||||
|
2026-10-20, plan D-36: the 1099 worksheet stops at as_of too)
|
||||||
|
|
||||||
|
Nothing in the CPA package made as of a day (`books.export_cpa_package`,
|
||||||
|
the viewer's Export for CPA) reflects anything dated after it: no figure,
|
||||||
|
entry, receipt or worksheet row. The statements and the ledgers cover
|
||||||
|
the fiscal year containing as_of, through it; the balance sheet, the
|
||||||
|
trial balance and the fixed asset register are as of it (the register
|
||||||
|
only when an asset was bought by then); receipts/ holds the evidence of
|
||||||
|
the year's entries through it; the members' equity statement and the
|
||||||
|
K-1 worksheet, the year to date; general-journal.iif, every posted entry
|
||||||
|
from the first through it (B-34). The 1099 worksheet counts the payments
|
||||||
|
of as_of's calendar year from January 1 through as_of: information
|
||||||
|
returns add up what is paid "in any calendar year" (26 U.S.C. §6041(a);
|
||||||
|
"during such calendar year", §6041A(a)(2)), so a worksheet as of June 30
|
||||||
|
is the year to date, and a vendor not yet at the threshold is not yet
|
||||||
|
reportable; a December 31 package counts the whole year, as before. The
|
||||||
|
chart of accounts and documents/ are as they stand when the package is
|
||||||
|
made. One list of cash accounts and one of financing accounts serve the
|
||||||
|
whole package (the request's, else the settings'): the cash flow, the
|
||||||
|
K-1 worksheet, the IIF's bank accounts and the 1099 payments read the
|
||||||
|
same.
|
||||||
|
|
||||||
|
**Enforced:** `ledger_package::build_package` (INVARIANTS 1, 6, 7),
|
||||||
|
`ledger_package::export_iif`, `ledger_package::vendor_1099_report` and
|
||||||
|
`ledger_core::vendor_1099_report_with_members` (`through`).
|
||||||
|
**Checked:** `ledger-package` `tests/package.rs`
|
||||||
|
(`every_file_in_an_as_of_package_stops_at_as_of`: books the same through
|
||||||
|
as_of, with and without activity after it (a 1099 payment with its
|
||||||
|
receipt, an asset bought, a reversal of an entry dated as_of, the
|
||||||
|
members' contributions, an earlier asset sold), make the same package
|
||||||
|
byte for byte, every file and which files there are, with and without a
|
||||||
|
fixed asset register, mid-year and at year end; `as_of_package_iif_stops_at_as_of`,
|
||||||
|
`the_iif_through_as_of_at_its_edges`,
|
||||||
|
`the_1099_worksheet_stops_at_as_of`,
|
||||||
|
`one_list_of_cash_accounts_per_package`; the property
|
||||||
|
`iif_ties_to_the_trial_balance_as_of`, 2,000 cases); `ledger-mcp`
|
||||||
|
`oracle_mcp.rs` (`cpa_package_holds_the_year_and_its_evidence`); the
|
||||||
|
bookspec examples `bookspec/examples/as-of-package-stops-at-as-of.toml`
|
||||||
|
and `bookspec/examples/1099-worksheet-through-as-of.toml`.
|
||||||
|
|
||||||
|
## B-34 The QuickBooks Desktop file (IIF): plain ASCII, every entry through as_of, closes left out
|
||||||
|
|
||||||
|
**Status:** AUTHORITY (Intuit, `sources/intuit-iif.md`: IIF files are
|
||||||
|
ASCII text, tab-separated, with no escape; an account is known by its
|
||||||
|
NAME, a subaccount's NAME its path joined by colons; lists come before
|
||||||
|
what uses them; DOCNUM is "the transaction's number"; the kit's 2007+
|
||||||
|
example file ends every line CR LF; QuickBooks Desktop holds an account
|
||||||
|
name of 31 characters, an account number of 7 and a memo of 4,095; "The
|
||||||
|
parent and subaccount must be the same account type"; a journal entry
|
||||||
|
to accounts receivable or payable needs a customer or vendor, and takes
|
||||||
|
one such account a transaction); CONVENTION (a QuickBooks integrator and
|
||||||
|
an IIF importer, same file: a longer name is cut on a chart import but not
|
||||||
|
in a journal's lines; a double quote is a text delimiter); DECISION (owner, 2026-10-20, plan D-36: plain ASCII, what isn't
|
||||||
|
ASCII transliterated and listed; CR LF; DOCNUM the entry's id; closing
|
||||||
|
entries left out; every entry since the company began until the CPA
|
||||||
|
says otherwise); Unicode Standard Annex #14 (the mandatory line breaks)
|
||||||
|
|
||||||
|
1. **One transaction per posted entry dated on or before as_of**, from
|
||||||
|
the first, in the order posted, **except period-closing entries**:
|
||||||
|
QuickBooks keeps no closing entries (it works out retained earnings
|
||||||
|
itself; its "closing date" only locks a period), 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, and its income and expenses
|
||||||
|
each period's profit and loss; the members' shares are in the
|
||||||
|
package's members' equity statement and K-1 worksheet.
|
||||||
|
`books.export` iif takes as_of (default today), never a range.
|
||||||
|
Whether the file should hold only the fiscal year instead goes to the
|
||||||
|
CPA before the 2027 package (D-36); for the first year the two are
|
||||||
|
the same.
|
||||||
|
2. **DOCNUM is the entry's id** in the books, on the TRNS and every SPL,
|
||||||
|
so the CPA can trace a QuickBooks transaction to its entry and its
|
||||||
|
receipts.
|
||||||
|
3. **The file is ASCII and every line ends CR LF.** In a field (a NAME,
|
||||||
|
an ACCNUM, a MEMO) a tab or a mandatory line break (LF, VT, FF, CR,
|
||||||
|
NEL, U+2028, U+2029) is a space; any other control character is '?';
|
||||||
|
printable ASCII is itself, but a double quote, which is a single one
|
||||||
|
(QuickBooks reads a double quote as a text delimiter); any other
|
||||||
|
character is its transliteration by deunicode 1.6.0, pinned exactly
|
||||||
|
("René" "Rene", "北京" "Bei Jing", "—" "--", "“" "'", "😀" "grinning";
|
||||||
|
a transliterated word's trailing space dropped at the end or before a
|
||||||
|
space), or '?' when it has none. Every other file in the package keeps
|
||||||
|
the books' text (the PDFs carry Inter and Noto fallbacks for Chinese,
|
||||||
|
Japanese, Korean, Arabic, Hebrew, Thai and Devanagari, Arabic and
|
||||||
|
Hebrew set right to left; no emoji font, so an emoji prints as an
|
||||||
|
empty box).
|
||||||
|
4. **An account's own name**, so written, has each ':' replaced by '-'
|
||||||
|
(a fullwidth colon too), is trimmed ('?' if nothing is left) and cut
|
||||||
|
to 31 characters (QuickBooks Desktop's limit; cut by QuickBooks, the
|
||||||
|
ACCNT line and the transactions' would no longer match); its NAME is
|
||||||
|
its parents' NAMEs and its own, joined by ':'. A subaccount whose
|
||||||
|
QuickBooks type differs from its parent's (a bank account under a
|
||||||
|
header that is no bank, an asset's cost under a "Fixed assets" header)
|
||||||
|
is written at the top level, its own subaccounts with it: QuickBooks
|
||||||
|
gives a subaccount its parent's type, and the type decides where its
|
||||||
|
balance shows. Each parent is declared before its subaccounts, the
|
||||||
|
accounts otherwise in the order of their codes. Accounts whose NAMEs
|
||||||
|
would match ignoring case, which QuickBooks would merge, get
|
||||||
|
" (code)" after their own names (the name cut to make room, the code
|
||||||
|
written like a name, "no number" for a blank one), level by level from
|
||||||
|
the top and again until no two match; a match left after that (two
|
||||||
|
codes written alike, ignoring case, or alike in their first 30
|
||||||
|
characters) is refused.
|
||||||
|
5. **ACCNUM is the account's code**, written as a field and trimmed, or
|
||||||
|
left blank (ACCNUM is optional; the NAME identifies the account) when
|
||||||
|
it is longer than 7 characters (QuickBooks Desktop's limit) or another
|
||||||
|
account's would be written the same, ignoring case. **A MEMO** is cut
|
||||||
|
to 4,095 characters (QuickBooks Desktop's limit).
|
||||||
|
6. **Nothing changes silently.** Each account name, number or place and
|
||||||
|
each memo the file writes differently from the books (`Iif::changes`)
|
||||||
|
is said, both texts quoted and escaped, one line each, in the
|
||||||
|
`warnings` of `books.export` and `books.export_cpa_package` and in the
|
||||||
|
package README (the viewer's note counts them): each account's name,
|
||||||
|
number and place a line; the memos grouped by what is written
|
||||||
|
differently, each run of changed characters once with every entry it
|
||||||
|
is in (`"—" is written "--" in the memos of entries 3, 5, 9`), then
|
||||||
|
the memos cut. No line holds a full TIN (B-26): a run of nine digits,
|
||||||
|
in any script's digits and separators, is masked, and so is every
|
||||||
|
numeral of a text that holds one once written as a field (digits a
|
||||||
|
tab apart, letters deunicode writes as digits); a memo line whose
|
||||||
|
digits are masked says so.
|
||||||
|
7. **No customer or vendor NAME.** Intuit's IIF help makes NAME
|
||||||
|
optional and states no need of it in a journal; QuickBooks Desktop
|
||||||
|
asks for a customer or vendor on a journal entry to accounts
|
||||||
|
receivable or payable, and takes one A/R or A/P account a transaction
|
||||||
|
(Intuit staff and help, `sources/intuit-iif.md`). Writing names would
|
||||||
|
need the customer and vendor lists declared as well, with names unique
|
||||||
|
across them. So the file names none, and the package README names the
|
||||||
|
entries with a line on an A/R or A/P account (`Iif::receivable_payable`),
|
||||||
|
so the CPA can add the name if QuickBooks asks.
|
||||||
|
|
||||||
|
**Enforced:** `ledger_import::iif` (`field`, `iif_accounts`,
|
||||||
|
`journal_to_iif`, `Iif::warnings`), `ledger_package::export_iif` and
|
||||||
|
`build_package`, `ledger-mcp` `books.export` and
|
||||||
|
`books.export_cpa_package`.
|
||||||
|
**Checked:** `ledger-import` `iif.rs` unit tests (every one of the
|
||||||
|
1,112,064 Unicode scalar values written as printable ASCII, alone and
|
||||||
|
beside a letter, and kept by a second pass; the transliteration table
|
||||||
|
pinned); `tests/spec_import.rs` (`iif_docnum_is_the_entry_id`,
|
||||||
|
`iif_writes_plain_ascii_and_lists_every_change`,
|
||||||
|
`iif_every_line_ends_cr_lf`, `iif_distinct_accounts_never_share_a_name`,
|
||||||
|
`iif_a_subaccount_of_another_type_stands_at_the_top_level`,
|
||||||
|
`iif_declares_each_parent_before_its_subaccounts`,
|
||||||
|
`iif_fits_quickbooks_desktop_limits`,
|
||||||
|
`iif_warnings_group_the_memos_by_what_is_written_differently`,
|
||||||
|
`iif_warnings_never_hold_a_tin`,
|
||||||
|
`iif_change_warnings_are_one_line_each`,
|
||||||
|
`iif_keeps_a_tin_readable_to_the_package_scan`, the properties
|
||||||
|
`iif_round_trips_random_books` and
|
||||||
|
`iif_names_never_clash_and_every_change_is_listed`); `ledger-package`
|
||||||
|
`tests/package.rs` (`iif_leaves_out_period_closes`,
|
||||||
|
`the_readme_lists_what_the_iif_writes_differently`,
|
||||||
|
`the_readme_names_the_entries_on_receivables_and_payables`,
|
||||||
|
`the_bookspec_iif_example_matches_the_engine`, and B-33's); the bookspec
|
||||||
|
example `bookspec/examples/iif-quickbooks-journal-file.toml` (formats
|
||||||
|
appendix: the file line by line and its changes, derived by hand);
|
||||||
|
`ledger-mcp` `oracle_mcp.rs`
|
||||||
|
(`iif_declares_accounts_by_role_and_balances_every_transaction`,
|
||||||
|
`iif_without_as_of_is_as_of_today_and_ties_to_the_trial_balance`),
|
||||||
|
`mcp_stdio.rs` (`mcp_import_export_over_stdio`).
|
||||||
@@ -107,12 +107,84 @@ SHA-256 of the retrieved file (`journal.zip`): `4f578d227810f5bfadde142cdab9f941
|
|||||||
|
|
||||||
Cited (not quoted): its read-me says list declarations (accounts, classes, customers, vendors, other names) come first so names land on the correct lists, and that header columns must be present even when left blank. Its `!ACCNT` rows show a subaccount written `Construction:Labor`. Its address fields that contain a comma are wrapped in double quotes (`"Anywhere, AZ 85740"`), as a spreadsheet writes tab-delimited text; the kit does not say whether QuickBooks strips those quotes.
|
Cited (not quoted): its read-me says list declarations (accounts, classes, customers, vendors, other names) come first so names land on the correct lists, and that header columns must be present even when left blank. Its `!ACCNT` rows show a subaccount written `Construction:Labor`. Its address fields that contain a comma are wrapped in double quotes (`"Anywhere, AZ 85740"`), as a spreadsheet writes tab-delimited text; the kit does not say whether QuickBooks strips those quotes.
|
||||||
|
|
||||||
|
## Character limitations for fields in QuickBooks (US, updated 8/3/2026)
|
||||||
|
Source: <https://quickbooks.intuit.com/learn-support/en-us/help-article/printing-preferences/character-limitations-fields-quickbooks/L7eIy5gE3_US_en_US>
|
||||||
|
Retrieved: 2026-10-20
|
||||||
|
SHA-256: not available (read through a fetch tool; a direct download was refused by the server)
|
||||||
|
|
||||||
|
Tagged for QuickBooks Desktop (Mac Plus, Premier Plus, Pro Plus). Its one sentence of text:
|
||||||
|
|
||||||
|
> Each field in your company data file has a maximum number of characters that can be entered.
|
||||||
|
|
||||||
|
Cited (not quoted), from its table: Account Name 31; Account Numbers 7; Memo 4,095 (of which printed checks show about 30 to 60 and the Transaction Detail report 96); invoice number 11; customer, vendor and other names 41. It has no row for a journal entry's number, and says nothing of what QuickBooks does with longer text, on import or otherwise.
|
||||||
|
|
||||||
|
The same article for the UK (`.../en-uk/help-article/printing-preferences/character-limitations-fields-quickbooks/L7eIy5gE3_GB_en_GB`, retrieved 2026-10-20) gives Account Name 31 but Account Numbers 25: a different product. The books are a US company's, so the US figures hold.
|
||||||
|
|
||||||
|
## Intuit's .NET SDK reference: `Account.Name`
|
||||||
|
Source: <https://static.developer.intuit.com/sdkdocs/qbv3doc/ippdotnetdevkitv3/html/cd41295e-8e12-8b96-1ff3-728a807fa9cd.htm>
|
||||||
|
Retrieved: 2026-10-20
|
||||||
|
|
||||||
|
Cited (not quoted): "User recognizable name for the Account", with ValidRange "QBW: Max=31" (QuickBooks Desktop; QuickBooks Online: 100). Its `Account.AcctNum` page (`.../95a0835b-80bd-4fe1-2d7f-96ea2c372442.htm`) states no length.
|
||||||
|
|
||||||
|
So the limits are Intuit's own: an account name (one level of a subaccount's NAME, as the account's own name is entered) at most 31 characters, an account number at most 7, a memo at most 4,095. The books write each within them: a longer level is cut to 31 (then no two NAMEs may match), a longer number is left out of ACCNUM (it is optional; the NAME identifies the account), a longer memo is cut to 4,095, and every one is listed (rules.md B-34).
|
||||||
|
|
||||||
|
## Add, edit, or delete accounts in QuickBooks Desktop (US, updated 8/4/2026)
|
||||||
|
Source: <https://quickbooks.intuit.com/learn-support/en-us/help-article/chart-accounts/add-edit-delete-accounts-quickbooks-desktop-mac/L95VT34uN_US_en_US>
|
||||||
|
Retrieved: 2026-10-20
|
||||||
|
SHA-256: not available (read through a fetch tool)
|
||||||
|
|
||||||
|
Tagged for QuickBooks Desktop (Mac Plus, Premier Plus, Pro Plus, Accountant, Enterprise). Under "Add a subaccount", a note:
|
||||||
|
|
||||||
|
> The parent and subaccount must be the same account type.
|
||||||
|
|
||||||
|
So the books write a subaccount whose QuickBooks type differs from its parent's (a bank account under a header that is no bank, an asset's cost account under a "Fixed assets" header) at the top level, its own subaccounts with it, and list it (rules.md B-34). Intuit's own journal sample (`journal.zip`, above) declares `Construction:Labor` without ever declaring `Construction`, so QuickBooks makes a missing parent itself; the books declare each parent before its subaccounts all the same, so a parent keeps its own type and number rather than taking ones QuickBooks guesses.
|
||||||
|
|
||||||
|
## Customer and vendor names on accounts receivable and payable
|
||||||
|
|
||||||
|
Searched 2026-10-20. What Intuit says:
|
||||||
|
|
||||||
|
- The IIF Import Kit's header help (`!TRNS.htm`, `!SPL.htm`, above) marks TRNSTYPE, ACCNT and AMOUNT required; NAME is "the name of the customer, vendor, payee, or employee", not marked required, with no word on accounts receivable or payable. The kit's General Journal example and the journal sample have no NAME column at all.
|
||||||
|
- The journal sample's read-me (`journal.zip`, `General Journal IIF file read me.txt`, SHA-256 `2e45b0f7a15d78f487d510547084d401d99bda02630d10747e37e00ca56f2c52`): "A customer name attached to the spl line of a General Journal transaction will import to the vendor list and the vendor name attached to the trns line of a General Journal transaction will import to the other names list unless the names are already on the correct lists." So a NAME written without declaring the customer and vendor lists first lands on the wrong list.
|
||||||
|
- Intuit's QuickBooks Team, answering "Can I post a journal entry to Accounts Receivable without specifying a customer name?" (Maybelle_S, QuickBooks Team badge, 12 January 2024), <https://quickbooks.intuit.com/community/reports-and-accounting-5/can-i-post-a-journal-entry-to-accounts-receivable-without-specifying-a-customer-name-64395> (retrieved 2026-10-20), about QuickBooks Desktop:
|
||||||
|
|
||||||
|
> Accounts Receivable (AR) or Accounts Payable (AP) requires a customer or vendor name assignment.
|
||||||
|
|
||||||
|
- "Troubleshoot ... Warning: You Cannot Use More Than One" (US, updated 8/5/2026; QuickBooks Desktop Mac Plus, Premier Plus, Pro Plus), <https://quickbooks.intuit.com/learn-support/en-us/help-article/accounts-receivable/warning-cannot-use-one-ar-ap-account-transaction/L7xqDWcmE_US_en_US> (retrieved 2026-10-20): QuickBooks will not record a transaction with two A/R accounts, two A/P accounts, or one of each; its message:
|
||||||
|
|
||||||
|
> Warning: You cannot use more than one A/R or A/P account in the same transaction.
|
||||||
|
|
||||||
|
Not Intuit, for the record: a third-party IIF vendor answering on Intuit's community (BigRedConsulting, 14 April 2020, <https://quickbooks.intuit.com/learn-support/en-us/other-questions/iif-import-error-3040/00/535675>) wrote that "a NAME is required for any AR row", about a bill, not a journal; QuickBooks Desktop sync tools quote the refusal "Transaction split lines to accounts receivable must include a customer on that split line" (Acctivate, <https://hub.acctivate.com/articles/troubleshoot/synchronization/transaction-split-lines-to-accounts-receivable-must-include-a-customer-on-that-split-line>).
|
||||||
|
|
||||||
|
No Intuit source says what an IIF journal import does with an A/R or A/P line that has no NAME (refuses it, imports it, or asks). So the books write no NAME (writing names would need the customer and vendor lists declared too, with a name unique across them) and the package README names the entries with an A/R or A/P line, so the CPA can add the name in QuickBooks if it asks (rules.md B-34 rule 7).
|
||||||
|
|
||||||
|
## What QuickBooks does with an over-long name (not Intuit)
|
||||||
|
Source: Thomson Reuters, Accounting CS help, "Export payroll journal entries", <https://thomsonreuters.com/en-us/help/accounting-cs/integrate-data-with-other-applications/export-payroll-journal-entries>
|
||||||
|
Retrieved: 2026-10-20
|
||||||
|
|
||||||
|
Cited (not quoted), a QuickBooks integrator's documentation, not Intuit's: QuickBooks limits an account's name to 31 characters; importing a chart of accounts IIF file cuts a longer name to 31, but importing a journal IIF file does not, so the journal's accounts no longer match and QuickBooks creates a new (duplicate) bank account for each it doesn't recognize. It advises names of 31 characters or fewer. This is why the books cut each level themselves: the ACCNT line and every TRNS and SPL line then carry the same name.
|
||||||
|
|
||||||
|
## Double quotes in an IIF field (not Intuit)
|
||||||
|
Intuit's own journal sample (above) wraps fields holding a comma in double quotes, as a spreadsheet writes tab-delimited text, so QuickBooks' reader treats a double quote as more than a character. Two non-Intuit sources say so outright:
|
||||||
|
|
||||||
|
- Reckon, "Importing the IIF file in Reckon Accounts Business, changes the Inch “ symbol to Feet ‘ symbol", <https://help.reckon.com/article/1ap8nwl0v2-kba-5589-importing-the-iif-file-in-reckon-accounts-business-changes-the-inch-symbol-to-feet-symbol> (retrieved 2026-10-20, no date given). Cited (not quoted): Reckon Accounts Business, which imports IIF files, changes each double quote to a single one by design, because many file formats treat the double quote as a special character.
|
||||||
|
- Jonathan Clark, interchange-users mailing list, 28 July 2003, <https://www.interchangecommerce.org/pipermail/interchange-users/2003-July/034578.html>:
|
||||||
|
|
||||||
|
> From what we can tell, QB uses double quotation marks (") as text delimiters when there are certain characters in the field.
|
||||||
|
|
||||||
|
> For imports, you will break the import if you have double quotes within your text
|
||||||
|
|
||||||
|
So the books write a double quote as a single one, listed like any other change (rules.md B-34).
|
||||||
|
|
||||||
## Not found in any Intuit source
|
## Not found in any Intuit source
|
||||||
|
|
||||||
Searched 2026-10-13 (the help articles above and every document in the IIF Import Kit and journal sample); none states:
|
Searched 2026-10-13 and 2026-10-20 (the help articles above and every document in the IIF Import Kit and journal sample); none states:
|
||||||
|
|
||||||
- an escaping mechanism for tabs, line breaks, or double quotes inside a field (no escape syntax is documented; the only safe reading is that fields must not contain tabs or line breaks);
|
- an escaping mechanism for tabs, line breaks, or double quotes inside a field (no escape syntax is documented; the only safe reading is that fields must not contain tabs or line breaks, and, from the reports above, double quotes);
|
||||||
- an explicit sentence that a transaction's TRNS and SPL amounts must sum to zero (every example does; the sign rule implies it);
|
- an explicit sentence that a transaction's TRNS and SPL amounts must sum to zero (every example does; the sign rule implies it);
|
||||||
- the allowed number of decimal places, or whether thousands separators or currency symbols are accepted in AMOUNT;
|
- the allowed number of decimal places, or whether thousands separators or currency symbols are accepted in AMOUNT;
|
||||||
- the character encoding beyond "ASCII text";
|
- the character encoding beyond "ASCII text" (so the books write plain ASCII, transliterating anything else and listing what changed: rules.md B-34, DECISION D-36);
|
||||||
- whether a four-digit year is accepted everywhere despite the "MM/DD/YY" wording (Intuit's 2007+ example uses one).
|
- whether a four-digit year is accepted everywhere despite the "MM/DD/YY" wording (Intuit's 2007+ example uses one);
|
||||||
|
- what QuickBooks Desktop does on import with an account name, account number or memo longer than its limits (only the non-Intuit note above, for names), how many levels of subaccounts it allows, or a limit on a whole NAME with its parents;
|
||||||
|
- how long a journal entry's number (DOCNUM) may be (the limits article has no row for it; the books' entry ids are a few digits);
|
||||||
|
- whether QuickBooks matches account names or numbers ignoring case (the books keep no two alike even ignoring case);
|
||||||
|
- what an IIF journal import does with a line on accounts receivable or payable that names no customer or vendor (above).
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# Unicode character properties: how a TIN can be written
|
||||||
|
|
||||||
|
Governs `ledger_core::parties::has_full_tin` (rules.md B-26): which
|
||||||
|
characters separate a TIN's digits, and which are invisible between them.
|
||||||
|
The Unicode Character Database is © Unicode, Inc., under the Unicode
|
||||||
|
License v3, which permits copying the data files; the code point lists
|
||||||
|
below are transcribed from them (facts, not prose).
|
||||||
|
|
||||||
|
## PropList.txt, Unicode 17.0.0 (2025-06-30)
|
||||||
|
Source: <https://www.unicode.org/Public/17.0.0/ucd/PropList.txt>
|
||||||
|
Retrieved: 2026-10-20
|
||||||
|
SHA-256 of the retrieved file: `130dcddcaadaf071008bdfce1e7743e04fdfbc910886f017d9f9ac931d8c64dd`
|
||||||
|
|
||||||
|
**Dash** (every dash and hyphen): U+002D, U+058A ARMENIAN HYPHEN, U+05BE
|
||||||
|
HEBREW PUNCTUATION MAQAF, U+1400, U+1806, U+2010..U+2015 (HYPHEN,
|
||||||
|
NON-BREAKING HYPHEN, FIGURE DASH, EN DASH, EM DASH, HORIZONTAL BAR),
|
||||||
|
U+2053 SWUNG DASH, U+207B SUPERSCRIPT MINUS, U+208B SUBSCRIPT MINUS,
|
||||||
|
U+2212 MINUS SIGN, U+2E17, U+2E1A, U+2E3A..U+2E3B, U+2E40, U+2E5D,
|
||||||
|
U+301C WAVE DASH, U+3030 WAVY DASH, U+30A0, U+FE31..U+FE32, U+FE58 SMALL
|
||||||
|
EM DASH, U+FE63 SMALL HYPHEN-MINUS, U+FF0D FULLWIDTH HYPHEN-MINUS,
|
||||||
|
U+10D6E, U+10EAD.
|
||||||
|
|
||||||
|
**White_Space**: U+0009..U+000D, U+0020, U+0085, U+00A0, U+1680,
|
||||||
|
U+2000..U+200A, U+2028 LINE SEPARATOR, U+2029 PARAGRAPH SEPARATOR,
|
||||||
|
U+202F, U+205F, U+3000 IDEOGRAPHIC SPACE. Less the controls and the two
|
||||||
|
line and paragraph separators, these are the space separators (general
|
||||||
|
category Zs). Rust's `char::is_whitespace` is this property.
|
||||||
|
|
||||||
|
## DerivedCoreProperties.txt, Unicode 17.0.0
|
||||||
|
Source: <https://www.unicode.org/Public/17.0.0/ucd/DerivedCoreProperties.txt>
|
||||||
|
Retrieved: 2026-10-20
|
||||||
|
SHA-256 of the retrieved file: `24c7fed1195c482faaefd5c1e7eb821c5ee1fb6de07ecdbaa64b56a99da22c08`
|
||||||
|
|
||||||
|
**Default_Ignorable_Code_Point** (drawn as nothing when not supported):
|
||||||
|
U+00AD SOFT HYPHEN, U+034F, U+061C ARABIC LETTER MARK, U+115F..U+1160,
|
||||||
|
U+17B4..U+17B5, U+180B..U+180F, U+200B..U+200F (ZERO WIDTH SPACE, the
|
||||||
|
joiners, LEFT-TO-RIGHT and RIGHT-TO-LEFT MARK), U+202A..U+202E,
|
||||||
|
U+2060..U+206F (WORD JOINER, the invisible operators, the directional
|
||||||
|
isolates), U+3164, U+FE00..U+FE0F (variation selectors), U+FEFF ZERO
|
||||||
|
WIDTH NO-BREAK SPACE, U+FFA0, U+FFF0..U+FFF8, U+1BCA0..U+1BCA3,
|
||||||
|
U+1D173..U+1D17A, U+E0000..U+E0FFF (tags, variation selectors
|
||||||
|
supplement).
|
||||||
|
|
||||||
|
## Compatibility decompositions (UnicodeData.txt)
|
||||||
|
Derived 2026-10-20 with Python's `unicodedata` (Unicode 16.0.0; these
|
||||||
|
mappings are unchanged in 17.0): the characters past ASCII whose NFKC
|
||||||
|
form is one of the ASCII separators.
|
||||||
|
|
||||||
|
- `' '`: U+00A0, U+2000..U+200A, U+202F, U+205F, U+3000 (all Zs).
|
||||||
|
- `'-'`: U+FE63, U+FF0D (both Dash).
|
||||||
|
- `'.'`: U+2024 ONE DOT LEADER, U+FE52 SMALL FULL STOP, U+FF0E FULLWIDTH
|
||||||
|
FULL STOP.
|
||||||
|
- `'/'`: U+FF0F FULLWIDTH SOLIDUS.
|
||||||
|
- `'_'`: U+FE33, U+FE34, U+FE4D..U+FE4F, U+FF3F FULLWIDTH LOW LINE.
|
||||||
|
|
||||||
|
## How the books read them (rules.md B-26)
|
||||||
|
|
||||||
|
A TIN is nine numerals (any script's: `char::is_numeric`) with at most
|
||||||
|
three separators between neighbors. In ASCII the separators are exactly
|
||||||
|
the space, hyphen, dot, slash and underscore (a tab or line break is
|
||||||
|
not one). Past ASCII they are every Zs space, every Dash character, and
|
||||||
|
the compatibility forms of the dot, slash and underscore above; the line
|
||||||
|
and paragraph separators and NEL are line breaks, as in ASCII, and end a
|
||||||
|
run. Default-ignorable characters are invisible, so any number of them
|
||||||
|
may sit between two digits without counting toward the three.
|
||||||
+195
@@ -700,6 +700,201 @@ fields; `Currency::allocation_places`. P3's accept counts `ledger-core` and
|
|||||||
`ledger-import` (R-6); the store and MCP mutants above are for the whole
|
`ledger-import` (R-6); the store and MCP mutants above are for the whole
|
||||||
pass.
|
pass.
|
||||||
|
|
||||||
|
## P3: the IIF and as-of (2026-10-20)
|
||||||
|
|
||||||
|
Defects 7 and 21 (plan P3; the owner's decisions D-36). Rules
|
||||||
|
`docs/spec/rules.md` B-33 (a package as of a day) and B-34 (the IIF). No
|
||||||
|
mutation run (D-33): what P12 must cover is at the end.
|
||||||
|
|
||||||
|
**Named regression tests**, each from its reproduction, on anonymized
|
||||||
|
fixtures:
|
||||||
|
- Defect 7, DOCNUM: `ledger-import` `tests/spec_import.rs`
|
||||||
|
`iif_docnum_is_the_entry_id` (a draft never posted, three entries
|
||||||
|
posted out of id and date order: DOCNUM 3, 2, 4 on the TRNS and every
|
||||||
|
SPL; the file had written 1, 2, 3).
|
||||||
|
- Defect 7, the characters (D-36, plain ASCII):
|
||||||
|
`iif_writes_plain_ascii_and_lists_every_change` (accented letters,
|
||||||
|
Chinese, emoji, a fullwidth colon and fullwidth digits, a tab in a
|
||||||
|
name, controls, a decomposed accent, private use: each written form
|
||||||
|
exact, every change listed exactly), `iif_every_line_ends_cr_lf`,
|
||||||
|
`iif_change_warnings_are_one_line_each`,
|
||||||
|
`iif_keeps_a_tin_readable_to_the_package_scan` (an en-dash or
|
||||||
|
no-break-space TIN in a memo stays a TIN to the package's TIN scan),
|
||||||
|
`iif_distinct_accounts_never_share_a_name` (review R2: duplicate names,
|
||||||
|
by case, siblings, a fullwidth colon against a hyphen, two private-use
|
||||||
|
names; a name that is another's tagged form tagged in turn; the clash
|
||||||
|
left when two codes are written alike refused),
|
||||||
|
`iif_fits_quickbooks_desktop_limits` (Intuit's limits, found in the P3
|
||||||
|
review: a level past 31 characters cut, two alike once cut tagged, a
|
||||||
|
subaccount under its cut parent; an account number past 7 characters
|
||||||
|
and two written alike left out of ACCNUM; a memo past 4,095 cut; every
|
||||||
|
line of the warnings exact; a hand-built level past 31 refused),
|
||||||
|
`iif_warnings_group_the_memos_by_what_is_written_differently` (the
|
||||||
|
lead's call: 100 depreciation memos with an em dash make one line),
|
||||||
|
`iif_warnings_never_hold_a_tin` (a TIN in a memo or an account name,
|
||||||
|
in ASCII or fullwidth digits, never in a warning),
|
||||||
|
`iif_field_transliteration_exact` and `iif_paths_and_transliteration`
|
||||||
|
(the old expectations, "Caf? meals" among them, rewritten to the rule).
|
||||||
|
- Defect 21, the package's IIF: `ledger-package` `tests/package.rs`
|
||||||
|
`as_of_package_iif_stops_at_as_of` (next year's rent was in the file;
|
||||||
|
now the four entries of the year, by id, tied to the trial balance),
|
||||||
|
`the_iif_through_as_of_at_its_edges` (an entry on as_of in, the day
|
||||||
|
after out, a reversal after as_of leaving its original, as of a day
|
||||||
|
before the first entry: the accounts and no transaction).
|
||||||
|
- Defect 21, every file: `every_file_in_an_as_of_package_stops_at_as_of`
|
||||||
|
(review R4: books the same through as_of, one set with a 1099 payment,
|
||||||
|
its receipt and an asset bought the day after, a reversal of the entry
|
||||||
|
dated as_of, the members' contributions and an earlier desk sold, build
|
||||||
|
the same package byte for byte, every file and which files there are,
|
||||||
|
PDFs included, with and without TINs and receipts, with and without a
|
||||||
|
fixed asset register, mid-year and at year end; a package days later
|
||||||
|
sees the activity). Undoing any one of the as-of
|
||||||
|
bounds (the IIF's entries, its FIXASSET typing, the 1099 worksheet's
|
||||||
|
`through`, the fixed asset register's presence) fails it; checked by
|
||||||
|
hand.
|
||||||
|
- D-36, the 1099 worksheet: `the_1099_worksheet_stops_at_as_of` (a
|
||||||
|
payment on as_of counts, the day after doesn't; a vendor under the
|
||||||
|
threshold so far is not yet reportable; a year-end package and the
|
||||||
|
tools' worksheet unchanged).
|
||||||
|
- D-36, closes: `iif_leaves_out_period_closes` (two months closed: no
|
||||||
|
close written, income and expenses equal the year's profit and loss,
|
||||||
|
balances the trial balance without the closes).
|
||||||
|
- Plan P3's accept, a bookspec example per regression test: the as-of
|
||||||
|
ones (`bookspec/examples/as-of-package-stops-at-as-of.toml`,
|
||||||
|
`1099-worksheet-through-as-of.toml`) and the IIF's
|
||||||
|
(`iif-quickbooks-journal-file.toml`, formats appendix), each expected
|
||||||
|
value derived by hand; `the_bookspec_iif_example_matches_the_engine`
|
||||||
|
reads the IIF example and checks the engine gives exactly its file and
|
||||||
|
changes.
|
||||||
|
- Review R18 and the README: `one_list_of_cash_accounts_per_package`,
|
||||||
|
`the_readme_lists_what_the_iif_writes_differently` (the memos grouped:
|
||||||
|
six depreciation memos and a lunch with an em dash, one line).
|
||||||
|
|
||||||
|
**Exhaustive and property tests.** `iif.rs`'s unit tests write every
|
||||||
|
one of the 1,112,064 Unicode scalar values (alone, after a letter, before
|
||||||
|
one) and check the field is printable ASCII without a double quote and a
|
||||||
|
fixed point, its changed runs empty exactly when it is unchanged, and a
|
||||||
|
level never empty, colon-free, trimmed and at most 31 characters (a
|
||||||
|
complete check over the finite alphabet; review R15); `with_tag`'s and
|
||||||
|
`changed_runs`' edges by hand; `the_transliteration_table_is_pinned`
|
||||||
|
samples deunicode 1.6.0, pinned exactly in `Cargo.toml` (R14).
|
||||||
|
Properties: `iif_round_trips_random_books` (memos mixing ASCII, Latin-1,
|
||||||
|
dashes and quotes, every kind of line break, controls, CJK, emoji,
|
||||||
|
combining marks, private use; DOCNUM, CR LF, the changes list exact),
|
||||||
|
`iif_names_never_clash_and_every_change_is_listed` (random charts from a
|
||||||
|
pool of clashing names, long names alike once cut, codes with colons,
|
||||||
|
past 7 characters, and one alike another once trimmed; every warning
|
||||||
|
line checked against the books and the file), `iif_ties_to_the_trial_balance_as_of` (2,000
|
||||||
|
cases: random books with reversals and closed months, random as_of; the
|
||||||
|
file's entries and balances against the trial balance without the
|
||||||
|
closes).
|
||||||
|
|
||||||
|
**End to end.** `ledger-mcp` `oracle_mcp.rs`:
|
||||||
|
`iif_declares_accounts_by_role_and_balances_every_transaction` (as_of,
|
||||||
|
DOCNUM the ids, the warnings exact: the fixture's line-broken memo and
|
||||||
|
the engine's own "Depreciation — Laptop — 2025-03" memos, the alias, a
|
||||||
|
range refused), `iif_without_as_of_is_as_of_today_and_ties_to_the_trial_balance`
|
||||||
|
(review R5), `cpa_package_holds_the_year_and_its_evidence` (the IIF from
|
||||||
|
the prior year's sale through as_of; a payment after as_of to the
|
||||||
|
contractor left out of the 1099 worksheet; the note's count);
|
||||||
|
`mcp_stdio.rs` (a Chinese account name over stdio: ASCII text, CR LF,
|
||||||
|
`warnings`); `spec_mcp.rs` (iif with as_of); `ledger-gui`
|
||||||
|
`tests/exports.rs` (the README's IIF line names the as_of).
|
||||||
|
|
||||||
|
**Names in every script, in the PDFs and the viewer.** Inter has no
|
||||||
|
Chinese, Japanese, Korean, Arabic, Hebrew, Thai or Devanagari glyphs:
|
||||||
|
every such character printed as Inter's empty box (glyph 0), and the
|
||||||
|
viewer drew boxes too. `ledger-import` `src/fonts.rs` now bundles the
|
||||||
|
fallbacks, each once: a subset of Noto Sans CJK SC
|
||||||
|
(`fonts/NotoSansCJK-LICENSE.txt` says how it was made) and Noto Sans
|
||||||
|
Arabic UI, Hebrew, Thai and Devanagari, unmodified
|
||||||
|
(`fonts/NotoSans-LICENSE.txt`: versions, upstream commits, SHA-256; 412,968
|
||||||
|
bytes for the four). Arabic is the UI form because Noto Sans Arabic 2.013
|
||||||
|
draws a letter's dots as marks of their own, which Typst writes as text
|
||||||
|
runs of their own: its PDFs' text read "شلام" for "سلام", with letters
|
||||||
|
doubled. No emoji font (about 10 MB): an emoji prints as an empty box in
|
||||||
|
the PDFs. Tests: `fonts.rs`
|
||||||
|
`every_template_names_every_fallback_in_order`; `pdf.rs`
|
||||||
|
`chinese_japanese_and_korean_print_as_written` and
|
||||||
|
`arabic_hebrew_thai_and_devanagari_print_as_written` (every glyph from
|
||||||
|
its script's font, none glyph 0); `spec_import.rs`
|
||||||
|
`pdf_keeps_chinese_japanese_and_korean_names` (the ToUnicode map) and
|
||||||
|
`pdf_prints_every_bundled_script_as_written` (the PDF read back with
|
||||||
|
hayro, the viewer's own reader: Arabic and Hebrew read right to left give
|
||||||
|
the words, the lam-alef ligature included; Thai, Devanagari and Chinese
|
||||||
|
left to right; every font embedded; failed with Noto Sans Arabic 2.013).
|
||||||
|
The viewer registers the same `FALLBACKS` after its own fonts in every
|
||||||
|
family (`ledger-gui` `theme.rs` `every_family_falls_back_to_the_noto_fonts`:
|
||||||
|
the very bytes, by pointer, and a real glyph for each script's letters in
|
||||||
|
every family) and `tests/scripts.rs`
|
||||||
|
`account_names_in_other_scripts_show_as_written` (the Accounts page paints
|
||||||
|
each name; with `SHOT_DIR` set, it saves `scripts.png`: looked at,
|
||||||
|
2026-10-20). egui shapes and joins each word, right to left for Arabic
|
||||||
|
and Hebrew, but doesn't reorder the words of a right-to-left phrase:
|
||||||
|
"مكتب الرياض" shows its two words left to right.
|
||||||
|
|
||||||
|
**TINs in any script (B-26).** `ledger_core::parties::has_full_tin`
|
||||||
|
missed "123-45-6789": the fullwidth hyphen-minus, the ideographic
|
||||||
|
and thin spaces, the Hebrew maqaf and the other Unicode dashes weren't
|
||||||
|
separators, and a zero-width space or a right-to-left mark between digits
|
||||||
|
ended a run. Past ASCII every Zs space, every Dash character and the
|
||||||
|
compatibility forms of the dot, slash and underscore now separate, and
|
||||||
|
default-ignorable characters don't count (`docs/spec/sources/unicode-tin-text.md`,
|
||||||
|
Unicode 17.0); ASCII reads as before. `spec_parties.rs`
|
||||||
|
`has_full_tin_reads_every_script_and_separator` and the property
|
||||||
|
`has_full_tin_keeps_its_ascii_reading` (2,000 cases: on ASCII the same
|
||||||
|
answer as the old reading, kept in the test; elsewhere it finds at least
|
||||||
|
what the old one did).
|
||||||
|
|
||||||
|
**Second review (2026-10-20).** QuickBooks gives a subaccount its
|
||||||
|
parent's type (Intuit): `iif_a_subaccount_of_another_type_stands_at_the_top_level`
|
||||||
|
(a bank account under a header that is no bank, an asset's cost under
|
||||||
|
"Fixed assets": each written at the top level, keeping its type, listed,
|
||||||
|
its own subaccount with it, a name it then shares told apart by code);
|
||||||
|
`check_iif` now checks every subaccount's type is its parent's and every
|
||||||
|
parent is declared before it; `iif_declares_each_parent_before_its_subaccounts`.
|
||||||
|
The file names no customer or vendor (Intuit states no need for an IIF
|
||||||
|
journal; QuickBooks Desktop asks for one on A/R or A/P):
|
||||||
|
`the_readme_names_the_entries_on_receivables_and_payables`. A memo line
|
||||||
|
whose digits are masked says so (`iif_warnings_never_hold_a_tin`, which
|
||||||
|
now also holds a name and a memo that are a TIN only once written, digits
|
||||||
|
a tab apart: with `has_full_tin` reading every script, nothing else
|
||||||
|
tested the masks' second pass). `iif_fits_quickbooks_desktop_limits` now
|
||||||
|
holds a number of exactly 7 characters (kept) and two alike but for case
|
||||||
|
(left out): undoing either bound of the 7 had failed no test. Each was
|
||||||
|
checked by undoing it and seeing its test fail.
|
||||||
|
|
||||||
|
The GnuCash differential (`oracle/`) compares the engine's figures and
|
||||||
|
has no IIF and no package, so it has nothing to re-adjudicate here.
|
||||||
|
|
||||||
|
Each of the review's fixes was checked by undoing it by hand and seeing
|
||||||
|
its test fail (2026-10-20): the 31-character cut, the clash loop's
|
||||||
|
second round, the double quote, the TIN mask, the memo cut, each reason
|
||||||
|
a number is left out, the memo grouping, the tag's colon; and, for the
|
||||||
|
builder's, the as_of `<=`, the register's presence, the 1099 `through`,
|
||||||
|
the closes filter, the FIXASSET date filter and the package's cash list.
|
||||||
|
|
||||||
|
**For P12:** mutation over `ledger-import` `iif.rs` (each `breaks` arm,
|
||||||
|
the control, quote and `None` arms of `each_piece`, the deunicode
|
||||||
|
post-mapping, `segment`'s colon, trim, cut and "?" fallback, `tag`,
|
||||||
|
`with_tag`'s room, the clash loop's depth bound, rounds and tag,
|
||||||
|
`journal_to_iif`'s clash and length refusals, the number rules, the memo
|
||||||
|
cut, each `changes` comparison and its order, `warnings`' grouping,
|
||||||
|
order and masks, `shown`, DOCNUM, `EOL`), `pdf.rs` (the fallback font),
|
||||||
|
`ledger-package` `iif.rs` (`<=` in the as_of filter, the closes filter,
|
||||||
|
the FIXASSET date filter) and `package.rs` (the cash and financing
|
||||||
|
resolution, the register's presence, the 1099 `through`, the README's
|
||||||
|
IIF line, closes sentence, warnings block and 1099 wording), and
|
||||||
|
`ledger-core` `form1099.rs` (the `through` bound), `ledger-mcp`
|
||||||
|
`server.rs` (the iif report, range and as_of checks, `warnings`, the
|
||||||
|
package note), `ledger-gui` `menu.rs` (the note). From the second
|
||||||
|
review: `iif.rs` `iif_accounts`' type filter on parents and
|
||||||
|
`apart_from`, `parents_first`, the `AtTopLevel` change, the TIN note's
|
||||||
|
condition and `receivable_payable`; `fonts.rs` and `theme.rs`
|
||||||
|
`font_definitions`; the README's A/R and A/P sentence; `ledger-core`
|
||||||
|
`parties.rs` `tin_separator`'s arms and `tin_invisible`, and the gap that
|
||||||
|
doesn't count them.
|
||||||
|
|
||||||
## Benchmarks (baseline 2026-10-13)
|
## Benchmarks (baseline 2026-10-13)
|
||||||
|
|
||||||
No budget, by decision (the owner, 2026-10-13: the books do little work
|
No budget, by decision (the owner, 2026-10-13: the books do little work
|
||||||
|
|||||||
Reference in new issue
Block a user