Skip to main content

bookspec_checker/
schema.rs

1//! The shape of a bookspec example: the TOML schema, the parser, and the
2//! rules a well-formed example follows whatever engine runs it.
3//!
4//! One file per example, named for its `id` (`bookspec/README.md` gives the
5//! shape in prose; this module is its executable form). Every structure
6//! refuses a key it does not know (`deny_unknown_fields`): a typo is an
7//! error, never silently ignored. [`Example::from_toml`] parses and then
8//! checks the shape; an engine's own adapter only has to execute it.
9
10use std::collections::{BTreeMap, BTreeSet};
11use std::fmt;
12
13use chrono::NaiveDate;
14use rust_decimal::Decimal;
15use serde::Deserialize;
16
17use crate::rules::rule_for_refusal;
18
19/// One bookspec example: a setup, its events and changes, and what it
20/// expects of the books.
21#[derive(Clone, Debug, PartialEq, Deserialize)]
22#[serde(deny_unknown_fields)]
23pub struct Example {
24    /// Stable, never reused; the file is named for it.
25    pub id: String,
26    /// What the example shows, in words.
27    pub title: String,
28    /// The normative sentence the example demonstrates: what MUST (or MUST
29    /// NOT) hold.
30    pub rule: String,
31    /// What the expected values rest on: `AUTHORITY`, `CONVENTION` or
32    /// `CHOICE`.
33    pub status: String,
34    /// Exact citations, non-empty.
35    pub sources: Vec<String>,
36    /// Applicability tags, non-empty.
37    pub tags: Vec<String>,
38    /// `"formats"`: an interchange format's example rather than an
39    /// accounting rule's (P13's appendix of formats); absent otherwise.
40    #[serde(default)]
41    pub appendix: Option<String>,
42    /// The books before the first event.
43    pub setup: Setup,
44    /// The events, in order, counting from 1.
45    pub events: Vec<Event>,
46    /// Changes that post no entry, each made after the event it names.
47    #[serde(default)]
48    pub changes: Vec<ChangeSpec>,
49    /// What is expected of the books, at least one.
50    pub expect: Vec<Expect>,
51}
52
53/// The books an example sets up, before any event.
54#[derive(Clone, Debug, PartialEq, Deserialize)]
55#[serde(deny_unknown_fields)]
56pub struct Setup {
57    /// ISO 4217 currency code.
58    pub currency: String,
59    /// `MM-DD`.
60    pub fiscal_year_start: String,
61    /// `accrual` or `cash`.
62    pub basis: String,
63    /// How a figure is rounded to the currency's minor unit: `half-even`,
64    /// `half-away-from-zero` or `largest-remainder`.
65    pub rounding: String,
66    /// The chart, parents before their subaccounts.
67    pub accounts: Vec<AccountSpec>,
68    /// The cash accounts (ASC 230-10-45-10 to 45-15).
69    #[serde(default)]
70    pub cash_accounts: Vec<String>,
71    /// The financing accounts.
72    #[serde(default)]
73    pub financing_accounts: Vec<String>,
74    /// The investing accounts.
75    #[serde(default)]
76    pub investing_accounts: Vec<String>,
77    /// Accounts that hold property and equipment, for an example whose
78    /// individual assets do not matter.
79    #[serde(default)]
80    pub fixed_assets: Vec<FixedAsset>,
81    /// Fixed assets registered, with their figures (B-15).
82    #[serde(default)]
83    pub assets: Vec<AssetSpec>,
84    /// Prepaid-expense and deferred-revenue schedules (B-16).
85    #[serde(default)]
86    pub schedules: Vec<ScheduleSpec>,
87    /// The company's directory (B-26).
88    #[serde(default)]
89    pub parties: Vec<PartySpec>,
90    /// The members of a multi-member LLC (B-13).
91    #[serde(default)]
92    pub members: Vec<MemberSpec>,
93    /// Payees marked for an information return (B-19).
94    #[serde(default)]
95    pub payees: Vec<PayeeSpec>,
96    /// A payee's 1099 decision from a date on (B-19, D-40).
97    #[serde(default)]
98    pub status_changes: Vec<StatusChangeSpec>,
99    /// The ways the company (or a member, for it) pays (B-19).
100    #[serde(default)]
101    pub instruments: Vec<InstrumentSpec>,
102    /// The projects (B-37).
103    #[serde(default)]
104    pub projects: Vec<ProjectSpec>,
105    /// The day the books are asked on: what an omitted date means (B-30).
106    #[serde(default)]
107    pub today: Option<String>,
108}
109
110/// A project (B-37): its name, and its kind, status and party if given.
111#[derive(Clone, Debug, PartialEq, Deserialize)]
112#[serde(deny_unknown_fields)]
113pub struct ProjectSpec {
114    /// Its name, once each.
115    pub name: String,
116    /// `internal`, `client`, `on_spec` or `other`.
117    #[serde(default)]
118    pub kind: Option<String>,
119    /// `active`, `paused`, `finished` or `abandoned`.
120    #[serde(default)]
121    pub status: Option<String>,
122    /// A `setup.parties` name.
123    #[serde(default)]
124    pub party: Option<String>,
125}
126
127/// One account of the chart.
128#[derive(Clone, Debug, PartialEq, Deserialize)]
129#[serde(deny_unknown_fields)]
130pub struct AccountSpec {
131    /// Its code.
132    pub code: String,
133    /// Its name.
134    pub name: String,
135    /// `asset`, `liability`, `equity`, `revenue` or `expense`.
136    #[serde(rename = "type")]
137    pub kind: String,
138    /// Its parent's code, declared before it.
139    #[serde(default)]
140    pub parent: Option<String>,
141    /// A header groups other accounts and takes no postings.
142    #[serde(default)]
143    pub header: bool,
144    /// Its tax category, a key of the catalog (B-32).
145    #[serde(default)]
146    pub tax_category: Option<String>,
147}
148
149/// Accounts that hold property and equipment, for an example no asset's
150/// own figures matter to.
151#[derive(Clone, Debug, PartialEq, Deserialize)]
152#[serde(deny_unknown_fields)]
153pub struct FixedAsset {
154    /// The cost account.
155    pub cost_account: String,
156    /// The accumulated depreciation account.
157    pub accumulated_account: String,
158    /// The depreciation expense account.
159    pub expense_account: String,
160}
161
162/// A fixed asset registered before the first event (B-15).
163#[derive(Clone, Debug, PartialEq, Deserialize)]
164#[serde(deny_unknown_fields)]
165pub struct AssetSpec {
166    /// Its name in this example.
167    pub id: String,
168    /// What it is.
169    pub description: String,
170    /// Its cost, an exact decimal string.
171    pub cost: String,
172    /// Its salvage value.
173    pub salvage: String,
174    /// Its useful life in months, above zero.
175    pub life_months: u32,
176    /// The day depreciation starts, `YYYY-MM-DD`.
177    pub in_service: String,
178    /// `straight_line`, the one method the books have.
179    pub method: String,
180    /// The cost account.
181    pub cost_account: String,
182    /// The accumulated depreciation account.
183    pub accumulated_account: String,
184    /// The depreciation expense account.
185    pub expense_account: String,
186    /// The event whose entry put it on the books (B-15 rule 7).
187    #[serde(default)]
188    pub acquired_by: Option<usize>,
189    /// How the company got it and the facts its tax figures follow from
190    /// (B-25).
191    #[serde(default)]
192    pub basis: Option<BasisSpec>,
193}
194
195/// An asset's basis facts (B-25).
196#[derive(Clone, Debug, PartialEq, Deserialize)]
197#[serde(deny_unknown_fields)]
198pub struct BasisSpec {
199    /// `purchase`, `carryover_723` or `personal_use_conversion`.
200    pub rule: String,
201    /// A contribution's contributor's adjusted basis.
202    #[serde(default)]
203    pub contributor_adjusted_basis: Option<String>,
204    /// A contribution's fair market value.
205    #[serde(default)]
206    pub fmv_at_contribution: Option<String>,
207    /// The section 704(c) method, if chosen.
208    #[serde(default)]
209    pub sec704c_method: Option<String>,
210}
211
212/// A prepaid expense or deferred revenue recognized over its months
213/// (B-16), in `setup.schedules` or registered by an event.
214#[derive(Clone, Debug, PartialEq, Deserialize)]
215#[serde(deny_unknown_fields)]
216pub struct ScheduleSpec {
217    /// Its name in this example.
218    pub id: String,
219    /// `prepaid_expense` or `deferred_revenue`.
220    pub kind: String,
221    /// What it is.
222    pub description: String,
223    /// The whole amount, an exact decimal string.
224    pub total: String,
225    /// The day it starts, `YYYY-MM-DD`.
226    pub start: String,
227    /// Its months, above zero.
228    pub months: u32,
229    /// `daily` or `monthly_even`.
230    pub convention: String,
231    /// The balance-sheet account it sits in.
232    pub balance_account: String,
233    /// The profit-and-loss account it recognizes to.
234    pub pl_account: String,
235}
236
237/// One member of a multi-member LLC (B-13).
238#[derive(Clone, Debug, PartialEq, Deserialize)]
239#[serde(deny_unknown_fields)]
240pub struct MemberSpec {
241    /// The member's name; a party's, with the member role, when the
242    /// example asks about the member as a party.
243    pub name: String,
244    /// Their capital account.
245    pub capital_account: String,
246    /// Their share of income, an exact decimal fraction above zero; every
247    /// member has one or none does, and the shares sum to 1.
248    #[serde(default)]
249    pub share: Option<String>,
250    /// Their distributions account (B-13).
251    #[serde(default)]
252    pub distributions_account: Option<String>,
253}
254
255/// A payee marked for an information return from the beginning (B-19).
256#[derive(Clone, Debug, PartialEq, Deserialize)]
257#[serde(deny_unknown_fields)]
258pub struct PayeeSpec {
259    /// Its name, once each.
260    pub name: String,
261    /// `1099-NEC` or `1099-MISC`.
262    pub form: String,
263}
264
265/// One party in the directory (B-26, B-35).
266#[derive(Clone, Debug, PartialEq, Deserialize)]
267#[serde(deny_unknown_fields)]
268pub struct PartySpec {
269    /// Its name, once each.
270    pub name: String,
271    /// Role keys; `vendor` and `customer` give the party its record (one
272    /// of them at most).
273    pub roles: Vec<String>,
274    /// The liabilities, by code, the company owes the party itself
275    /// (B-35 item 6).
276    #[serde(default)]
277    pub creditor_of: Vec<String>,
278    /// Its TIN facts' last four (an SSN's).
279    #[serde(default)]
280    pub tin_last4: Option<String>,
281    /// Its W-9 facts' last four (a 1099 role's).
282    #[serde(default)]
283    pub w9_tin_last4: Option<String>,
284    /// The vault's copy of the TIN purged, the facts kept (needs
285    /// `tin_last4`).
286    #[serde(default)]
287    pub tin_purged: bool,
288}
289
290/// A payee's 1099 decision from `effective` on (B-19, D-40).
291#[derive(Clone, Debug, PartialEq, Deserialize)]
292#[serde(deny_unknown_fields)]
293pub struct StatusChangeSpec {
294    /// A `setup.payees` name.
295    pub payee: String,
296    /// `YYYY-MM-DD`.
297    pub effective: String,
298    /// `1099-NEC`, `1099-MISC`, `undetermined`, or `not_reportable` (with
299    /// `reason_kind` and `reason`).
300    pub decision: String,
301    /// Why it is not reportable, from the closed list.
302    #[serde(default)]
303    pub reason_kind: Option<String>,
304    /// The facts, in words.
305    #[serde(default)]
306    pub reason: Option<String>,
307}
308
309/// A payment method (B-19).
310#[derive(Clone, Debug, PartialEq, Deserialize)]
311#[serde(deny_unknown_fields)]
312pub struct InstrumentSpec {
313    /// Its label, once each.
314    pub label: String,
315    /// `bank`, `business_card`, `member_card`, `member_cash_or_check` or
316    /// `platform`.
317    pub kind: String,
318    /// The account it pays through, by code.
319    pub account: String,
320    /// The card's or account's last four, if known.
321    #[serde(default)]
322    pub last4: Option<String>,
323    /// The party it belongs to, by name (informational).
324    #[serde(default)]
325    pub owner: Option<String>,
326}
327
328/// A party named on an entry, with its share (B-35).
329#[derive(Clone, Debug, PartialEq, Deserialize)]
330#[serde(deny_unknown_fields)]
331pub struct ShareSpec {
332    /// A `setup.parties` name.
333    pub party: String,
334    /// Its share as an exact decimal string.
335    #[serde(default)]
336    pub amount: Option<String>,
337    /// Its share as an exact decimal fraction.
338    #[serde(default)]
339    pub percent: Option<String>,
340}
341
342/// One event: an entry, a reversal, a close, a bill, an invoice, a
343/// command that posts what is due, or a schedule's registration.
344#[derive(Clone, Debug, PartialEq, Deserialize)]
345#[serde(deny_unknown_fields)]
346pub struct Event {
347    /// The day of an entry, a reversal, a close, a bill, an invoice or a
348    /// disposal; the other commands date their own entries.
349    #[serde(default)]
350    pub date: Option<String>,
351    /// An entry's, a reversal's, a close's or a bill's; a command writes
352    /// its own.
353    #[serde(default)]
354    pub description: Option<String>,
355    /// An entry's lines.
356    #[serde(default)]
357    pub lines: Vec<LineSpec>,
358    /// The event (1-based) whose entry this one reverses.
359    #[serde(default)]
360    pub reverses: Option<usize>,
361    /// A close of a period.
362    #[serde(default)]
363    pub close: Option<CloseSpec>,
364    /// A bill.
365    #[serde(default)]
366    pub bill: Option<BillSpec>,
367    /// An invoice.
368    #[serde(default)]
369    pub invoice: Option<InvoiceSpec>,
370    /// A bad-debt write-off of an earlier invoice.
371    #[serde(default)]
372    pub write_off: Option<WriteOffSpec>,
373    /// A depreciation run.
374    #[serde(default)]
375    pub run_depreciation: Option<RunSpec>,
376    /// A disposal.
377    #[serde(default)]
378    pub dispose_asset: Option<DisposeSpec>,
379    /// A run of every schedule due through a day.
380    #[serde(default)]
381    pub run_schedules: Option<RunSchedulesSpec>,
382    /// A schedule registered by an event.
383    #[serde(default)]
384    pub register_schedule: Option<ScheduleSpec>,
385    /// The engine refuses the event, for this reason in the examples'
386    /// words (`refusal_reasons`); it changes nothing.
387    #[serde(default)]
388    pub refused: Option<String>,
389    /// The payee (a `setup.payees` name) the entry paid.
390    #[serde(default)]
391    pub payee: Option<String>,
392    /// The parties (a `setup.parties` name) the entry is with, and their
393    /// shares.
394    #[serde(default)]
395    pub parties: Vec<ShareSpec>,
396    /// The payment method (a `setup.instruments` label) it was paid with.
397    #[serde(default)]
398    pub instrument: Option<String>,
399    /// An entry as an earlier version could write it and this version's
400    /// commands refuse (the control guard, B-38): posted as replay posts
401    /// it, since books that hold it must still read right.
402    #[serde(default)]
403    pub older: bool,
404}
405
406impl Event {
407    /// Which of the event's kinds it is, or why it is not exactly one.
408    pub fn kind(&self) -> Result<EventKind, String> {
409        let kinds = [
410            (EventKind::Lines, !self.lines.is_empty()),
411            (EventKind::Reverses, self.reverses.is_some()),
412            (EventKind::Close, self.close.is_some()),
413            (EventKind::Bill, self.bill.is_some()),
414            (EventKind::Invoice, self.invoice.is_some()),
415            (EventKind::WriteOff, self.write_off.is_some()),
416            (EventKind::RunDepreciation, self.run_depreciation.is_some()),
417            (EventKind::DisposeAsset, self.dispose_asset.is_some()),
418            (EventKind::RunSchedules, self.run_schedules.is_some()),
419            (
420                EventKind::RegisterSchedule,
421                self.register_schedule.is_some(),
422            ),
423        ];
424        let given: Vec<EventKind> = kinds
425            .iter()
426            .filter_map(|(kind, given)| given.then_some(*kind))
427            .collect();
428        match given.as_slice() {
429            [one] => Ok(*one),
430            _ => Err(
431                "an event is one of lines, reverses, close, bill, invoice, run_depreciation, \
432                 dispose_asset, run_schedules or register_schedule"
433                    .to_string(),
434            ),
435        }
436    }
437}
438
439/// The kind of an event.
440#[derive(Clone, Copy, Debug, PartialEq, Eq)]
441pub enum EventKind {
442    /// An entry, by its `lines`.
443    Lines,
444    /// A reversal of an earlier event's entry.
445    Reverses,
446    /// A close of a period.
447    Close,
448    /// A bill.
449    Bill,
450    /// An invoice.
451    Invoice,
452    /// A bad-debt write-off of an earlier invoice (rules.md B-41).
453    WriteOff,
454    /// A depreciation run.
455    RunDepreciation,
456    /// A fixed asset's disposal.
457    DisposeAsset,
458    /// Every schedule's months due through a day.
459    RunSchedules,
460    /// A schedule's registration (posts nothing).
461    RegisterSchedule,
462}
463
464impl EventKind {
465    /// The key the example spells it with.
466    pub fn key(self) -> &'static str {
467        match self {
468            EventKind::Lines => "lines",
469            EventKind::Reverses => "reverses",
470            EventKind::Close => "close",
471            EventKind::Bill => "bill",
472            EventKind::Invoice => "invoice",
473            EventKind::WriteOff => "write_off",
474            EventKind::RunDepreciation => "run_depreciation",
475            EventKind::DisposeAsset => "dispose_asset",
476            EventKind::RunSchedules => "run_schedules",
477            EventKind::RegisterSchedule => "register_schedule",
478        }
479    }
480
481    /// True when the example gives the event's day (a command that dates
482    /// its own entries takes none).
483    pub fn is_dated(self) -> bool {
484        matches!(
485            self,
486            EventKind::Lines
487                | EventKind::Reverses
488                | EventKind::Close
489                | EventKind::Bill
490                | EventKind::Invoice
491                | EventKind::WriteOff
492                | EventKind::DisposeAsset
493        )
494    }
495
496    /// True when the example describes the event (a command writes its own
497    /// descriptions).
498    pub fn is_described(self) -> bool {
499        matches!(
500            self,
501            EventKind::Lines
502                | EventKind::Reverses
503                | EventKind::Close
504                | EventKind::Bill
505                | EventKind::Invoice
506        )
507    }
508
509    /// True when the books take it whole or not at all, so `refused` may
510    /// be given: a reversal, an entry or a command, never a close, a bill
511    /// or an invoice (those are corrected by a reversal). An entry posts
512    /// whole or not at all, so a hand line the control guard refuses is
513    /// one too.
514    pub fn may_refuse(self) -> bool {
515        !matches!(
516            self,
517            EventKind::Close | EventKind::Bill | EventKind::Invoice
518        )
519    }
520
521    /// True when the example may tag it with parties and a payment method.
522    pub fn may_tag(self) -> bool {
523        !matches!(
524            self,
525            EventKind::Reverses
526                | EventKind::WriteOff
527                | EventKind::RunDepreciation
528                | EventKind::DisposeAsset
529                | EventKind::RunSchedules
530                | EventKind::RegisterSchedule
531        )
532    }
533}
534
535/// A depreciation run of `asset` for `period` (`YYYY-MM`), dated at the
536/// month's end (B-15).
537#[derive(Clone, Debug, PartialEq, Deserialize)]
538#[serde(deny_unknown_fields)]
539pub struct RunSpec {
540    /// A `setup.assets` id.
541    pub asset: String,
542    /// `YYYY-MM`.
543    pub period: String,
544}
545
546/// A fixed asset's disposal (B-15).
547#[derive(Clone, Debug, PartialEq, Deserialize)]
548#[serde(deny_unknown_fields)]
549pub struct DisposeSpec {
550    /// A `setup.assets` id.
551    pub asset: String,
552    /// What it sold for, zero allowed.
553    pub proceeds: String,
554    /// The account the proceeds were received into.
555    pub proceeds_account: String,
556    /// The gain-or-loss account.
557    pub gain_loss_account: String,
558}
559
560/// Every schedule's months due through `through` (B-16).
561#[derive(Clone, Debug, PartialEq, Deserialize)]
562#[serde(deny_unknown_fields)]
563pub struct RunSchedulesSpec {
564    /// `YYYY-MM-DD`.
565    pub through: String,
566}
567
568/// A bill from `vendor` (B-38).
569#[derive(Clone, Debug, PartialEq, Deserialize)]
570#[serde(deny_unknown_fields)]
571pub struct BillSpec {
572    /// The vendor's name.
573    pub vendor: String,
574    /// One line's amount, exactly one of `amount` and `lines`.
575    #[serde(default)]
576    pub amount: Option<String>,
577    /// Its lines.
578    #[serde(default)]
579    pub lines: Vec<DocumentLineSpec>,
580}
581
582/// An invoice to `customer` (B-38).
583#[derive(Clone, Debug, PartialEq, Deserialize)]
584#[serde(deny_unknown_fields)]
585pub struct InvoiceSpec {
586    /// The customer's name.
587    pub customer: String,
588    /// One line's amount, exactly one of `amount` and `lines`.
589    #[serde(default)]
590    pub amount: Option<String>,
591    /// Its lines.
592    #[serde(default)]
593    pub lines: Vec<DocumentLineSpec>,
594}
595
596/// A write-off of the invoice an earlier event posted (rules.md B-41;
597/// plan P4b S7): the invoice's event, what to write off and why.
598#[derive(Clone, Debug, PartialEq, Deserialize)]
599#[serde(deny_unknown_fields)]
600pub struct WriteOffSpec {
601    /// The event (1-based) whose invoice this writes off: an `invoice`
602    /// event before it.
603    pub invoice: usize,
604    /// How much to write off, an exact decimal string above zero; left
605    /// out, all the invoice owes on the day.
606    #[serde(default)]
607    pub amount: Option<String>,
608    /// Why it will not be collected (collection correspondence, a
609    /// bankruptcy), one line.
610    pub reason: String,
611}
612
613/// One line of an invoice or a bill.
614#[derive(Clone, Debug, PartialEq, Deserialize)]
615#[serde(deny_unknown_fields)]
616pub struct DocumentLineSpec {
617    /// Its amount, an exact decimal string above zero.
618    pub amount: String,
619    /// What it is (the event's description when left out).
620    #[serde(default)]
621    pub description: Option<String>,
622    /// The project it belongs to.
623    #[serde(default)]
624    pub project: Option<String>,
625}
626
627/// A change to the books that posts no entry, made right after event
628/// `after` once that event's expectations are checked: exactly one of
629/// `rename`, `require_tax_category` and `correct_asset`.
630#[derive(Clone, Debug, PartialEq, Deserialize)]
631#[serde(deny_unknown_fields)]
632pub struct ChangeSpec {
633    /// The event after which it is made (1 to the number of events).
634    pub after: usize,
635    /// A rename.
636    #[serde(default)]
637    pub rename: Option<RenameSpec>,
638    /// The tax-category policy: `warn` (the books' default) or `refuse`
639    /// (B-32).
640    #[serde(default)]
641    pub require_tax_category: Option<String>,
642    /// An asset's correction.
643    #[serde(default)]
644    pub correct_asset: Option<CorrectSpec>,
645}
646
647/// A registered asset's economics corrected for `reason`: each figure
648/// given replaces the asset's; the rest stay.
649#[derive(Clone, Debug, PartialEq, Deserialize)]
650#[serde(deny_unknown_fields)]
651pub struct CorrectSpec {
652    /// A `setup.assets` id.
653    pub asset: String,
654    /// Its new cost.
655    #[serde(default)]
656    pub cost: Option<String>,
657    /// Its new salvage value.
658    #[serde(default)]
659    pub salvage: Option<String>,
660    /// Its new useful life, above zero.
661    #[serde(default)]
662    pub life_months: Option<u32>,
663    /// Its new in-service day.
664    #[serde(default)]
665    pub in_service: Option<String>,
666    /// Why it is corrected.
667    pub reason: String,
668}
669
670/// Rename the account `code` to `name` (B-31).
671#[derive(Clone, Debug, PartialEq, Deserialize)]
672#[serde(deny_unknown_fields)]
673pub struct RenameSpec {
674    /// The account's code.
675    pub code: String,
676    /// Its new name.
677    pub name: String,
678}
679
680/// One line of an entry.
681#[derive(Clone, Debug, PartialEq, Deserialize)]
682#[serde(deny_unknown_fields)]
683pub struct LineSpec {
684    /// The account's code.
685    pub account: String,
686    /// `debit` or `credit`.
687    pub side: String,
688    /// The amount, an exact decimal string above zero.
689    pub amount: String,
690    /// The project the line is tagged with.
691    #[serde(default)]
692    pub project: Option<String>,
693}
694
695/// A close of the period from `start` through the event's date (B-11,
696/// B-13).
697#[derive(Clone, Debug, PartialEq, Deserialize)]
698#[serde(deny_unknown_fields)]
699pub struct CloseSpec {
700    /// `YYYY-MM-DD`.
701    pub start: String,
702    /// `members` or `retained_earnings`.
703    pub to: String,
704    /// `per-close`: a members close split on its own, not cumulatively.
705    #[serde(default)]
706    pub rounding: Option<String>,
707}
708
709/// One expectation: a query, the dates or things it asks about, and what
710/// it expects.
711#[derive(Clone, Debug, PartialEq, Deserialize)]
712#[serde(deny_unknown_fields)]
713pub struct Expect {
714    /// The query's name (`QUERIES`).
715    pub query: String,
716    /// A period query's first day, `YYYY-MM-DD`.
717    #[serde(default)]
718    pub from: Option<String>,
719    /// A period query's last day.
720    #[serde(default)]
721    pub to: Option<String>,
722    /// An as-of query's day.
723    #[serde(default)]
724    pub as_of: Option<String>,
725    /// The event whose entry an `entry` or `party_part` query asks about.
726    #[serde(default)]
727    pub entry: Option<usize>,
728    /// The `run_depreciation` event a `run` query asks about.
729    #[serde(default)]
730    pub event: Option<usize>,
731    /// The fixed asset a `disposal`, `depreciation_schedule`,
732    /// `register_row` or `asset_basis` query asks about.
733    #[serde(default)]
734    pub asset: Option<String>,
735    /// The schedule a `schedule_plan` query asks about.
736    #[serde(default)]
737    pub schedule: Option<String>,
738    /// The party a `party_part`, `party_totals` or `party_tin_last4` query
739    /// asks about.
740    #[serde(default)]
741    pub party: Option<String>,
742    /// The project a `project` query asks about.
743    #[serde(default)]
744    pub project: Option<String>,
745    /// `party_totals`' basis: `cash` or `all` (B-35 item 4).
746    #[serde(default)]
747    pub basis: Option<String>,
748    /// The payee a `vendor_1099` query asks about.
749    #[serde(default)]
750    pub payee: Option<String>,
751    /// The books after event N, not after the last (a format query may
752    /// not name it).
753    #[serde(default)]
754    pub after: Option<usize>,
755    /// The account a command names, by code.
756    #[serde(default)]
757    pub code: Option<String>,
758    /// The new name a `rename_account` command gives.
759    #[serde(default)]
760    pub name: Option<String>,
761    /// The category a `set_tax_category` command sets (absent: it clears
762    /// the account's).
763    #[serde(default)]
764    pub category: Option<String>,
765    /// The account an `add_account` command adds.
766    #[serde(default)]
767    pub account: Option<AccountSpec>,
768    /// A command the engine accepts: `true` (never `false`).
769    #[serde(default)]
770    pub accepted: Option<bool>,
771    /// A command, or a `period`, the engine refuses: why, in the
772    /// examples' words.
773    #[serde(default)]
774    pub refused: Option<String>,
775    /// What the query expects.
776    #[serde(default)]
777    pub values: Option<Values>,
778}
779
780/// What a query expects: figures by key (every figure an exact decimal
781/// string), or, for a file format, its lines or its rows of fields.
782#[derive(Clone, Debug, PartialEq, Deserialize)]
783#[serde(untagged)]
784pub enum Values {
785    /// A table of named values, every one a string.
786    Figures(BTreeMap<String, String>),
787    /// A list of lines.
788    Lines(Vec<String>),
789    /// A list of rows, each a list of fields.
790    Rows(Vec<Vec<String>>),
791}
792
793impl Values {
794    /// True when it holds nothing.
795    pub fn is_empty(&self) -> bool {
796        match self {
797            Values::Figures(f) => f.is_empty(),
798            Values::Lines(l) => l.is_empty(),
799            Values::Rows(r) => r.is_empty(),
800        }
801    }
802}
803
804impl Expect {
805    /// The values the query expects; panics when it gives none.
806    pub fn values(&self, id: &str) -> &Values {
807        self.values
808            .as_ref()
809            .unwrap_or_else(|| panic!("{id}: {} expects values", self.query))
810    }
811
812    /// The figures the query expects; panics on lines or rows.
813    pub fn figures(&self, id: &str) -> &BTreeMap<String, String> {
814        match self.values(id) {
815            Values::Figures(f) => f,
816            _ => panic!("{id}: {} expects figures (a table)", self.query),
817        }
818    }
819
820    /// The question this expectation asks, as the engine receives it.
821    pub fn query(&self) -> Query {
822        Query {
823            name: self.query.clone(),
824            from: self.from.clone(),
825            to: self.to.clone(),
826            as_of: self.as_of.clone(),
827            entry: self.entry,
828            event: self.event,
829            asset: self.asset.clone(),
830            schedule: self.schedule.clone(),
831            party: self.party.clone(),
832            project: self.project.clone(),
833            basis: self.basis.clone(),
834            payee: self.payee.clone(),
835            after: self.after,
836            code: self.code.clone(),
837            new_name: self.name.clone(),
838            category: self.category.clone(),
839            account: self.account.clone(),
840        }
841    }
842}
843
844/// The question an expectation asks: everything but the answer.
845#[derive(Clone, Debug, PartialEq)]
846pub struct Query {
847    /// The query's name.
848    pub name: String,
849    /// A period's first day (raw, `YYYY-MM-DD`).
850    pub from: Option<String>,
851    /// A period's last day.
852    pub to: Option<String>,
853    /// An as-of query's day.
854    pub as_of: Option<String>,
855    /// The event whose entry is asked about.
856    pub entry: Option<usize>,
857    /// The event a `run` query asks about.
858    pub event: Option<usize>,
859    /// The asset asked about.
860    pub asset: Option<String>,
861    /// The schedule asked about.
862    pub schedule: Option<String>,
863    /// The party asked about.
864    pub party: Option<String>,
865    /// The project asked about.
866    pub project: Option<String>,
867    /// `party_totals`' basis.
868    pub basis: Option<String>,
869    /// The payee asked about.
870    pub payee: Option<String>,
871    /// The books after event N (the runner has already applied them).
872    pub after: Option<usize>,
873    /// The account a command names.
874    pub code: Option<String>,
875    /// The new name a rename command gives.
876    pub new_name: Option<String>,
877    /// The category a set-tax-category command sets.
878    pub category: Option<String>,
879    /// The account an add-account command adds.
880    pub account: Option<AccountSpec>,
881}
882
883/// The queries every engine must answer: the figure queries first, then
884/// the calendar ones, then the commands (tried on a copy of the books),
885/// then the formats appendix's.
886pub const QUERIES: &[&str] = &[
887    "cash_flow",
888    "trial_balance",
889    "profit_and_loss",
890    "entry",
891    "balances",
892    "members_equity",
893    "k1",
894    "k1_tax_basis",
895    "k1_reconciliation",
896    "vendor_1099_paid",
897    "vendor_1099",
898    "party_part",
899    "party_totals",
900    "party_tin_last4",
901    "period",
902    "day",
903    "calendar_year",
904    "account_names",
905    "uncategorized_accounts",
906    "run",
907    "disposal",
908    "depreciation_schedule",
909    "schedule_plan",
910    "register_row",
911    "register_totals",
912    "asset_basis",
913    "project",
914    "rename_account",
915    "add_account",
916    "set_tax_category",
917    "iif_lines",
918    "iif_changes",
919];
920
921/// The commands an example may try: each on a copy of the books, so it
922/// changes nothing checked after it.
923pub const COMMANDS: &[&str] = &["rename_account", "add_account", "set_tax_category"];
924
925/// The formats appendix's queries.
926pub const FORMAT_QUERIES: &[&str] = &["iif_lines", "iif_changes"];
927
928/// The queries whose figures may be text rather than an exact decimal
929/// string: a status, a direction, a date, a year, a name, a rule name,
930/// `"none"`, `"open"`, `"true"` or `"false"`, and the register's dates
931/// beside its figures.
932pub const TEXT_VALUED: &[&str] = &[
933    "vendor_1099",
934    "party_part",
935    "party_tin_last4",
936    "period",
937    "day",
938    "calendar_year",
939    "register_row",
940    "project",
941    "asset_basis",
942    "k1_tax_basis",
943    "account_names",
944];
945
946/// The queries whose example lists every value it covers, so a value left
947/// out of an example is an error. The others name the figures they show:
948/// `cash_flow`, the statements, the register, a schedule's plan and an
949/// asset's basis.
950pub const COMPLETE_QUERIES: &[&str] = &[
951    "trial_balance",
952    "profit_and_loss",
953    "entry",
954    "vendor_1099_paid",
955    "vendor_1099",
956    "party_part",
957    "party_totals",
958    "party_tin_last4",
959    "period",
960    "day",
961    "calendar_year",
962    "account_names",
963    "uncategorized_accounts",
964    "run",
965    "disposal",
966    "project",
967];
968
969impl Query {
970    /// True when it is a command, tried on a copy of the books.
971    pub fn is_command(&self) -> bool {
972        COMMANDS.contains(&self.name.as_str())
973    }
974
975    /// True when it is a formats appendix's query.
976    pub fn is_format(&self) -> bool {
977        FORMAT_QUERIES.contains(&self.name.as_str())
978    }
979
980    /// True when the example must list every value the query covers.
981    pub fn is_complete(&self) -> bool {
982        COMPLETE_QUERIES.contains(&self.name.as_str())
983    }
984}
985
986/// Every refusal reason the specification names (bookspec/README.md), each
987/// the reason of exactly one rule (see [`crate::rules::rule_for_refusal`]).
988pub fn refusal_reasons() -> impl Iterator<Item = &'static str> {
989    [
990        "end_before_start",
991        "start_after_today",
992        "blank",
993        "unchanged",
994        "sibling_has_it",
995        "holds_a_tin",
996        "not_one_line",
997        "code_taken",
998        "tax_category_required",
999        "depreciation_past_disposal",
1000        "entry_irreversible",
1001        "already_disposed",
1002        "invalid_depreciation_period",
1003        "invalid_schedule",
1004        "untagged_control_line",
1005    ]
1006    .into_iter()
1007}
1008
1009/// Why an example is not well formed.
1010#[derive(Clone, Debug, PartialEq, Eq)]
1011pub struct SchemaError {
1012    /// The example's id, when it has one.
1013    pub id: String,
1014    /// What is wrong.
1015    pub message: String,
1016}
1017
1018impl fmt::Display for SchemaError {
1019    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1020        if self.id.is_empty() {
1021            write!(f, "{}", self.message)
1022        } else {
1023            write!(f, "{}: {}", self.id, self.message)
1024        }
1025    }
1026}
1027
1028impl std::error::Error for SchemaError {}
1029
1030impl SchemaError {
1031    fn new(id: &str, message: impl Into<String>) -> Self {
1032        Self {
1033            id: id.to_string(),
1034            message: message.into(),
1035        }
1036    }
1037}
1038
1039impl Example {
1040    /// Parse an example from its TOML text and check its shape.
1041    pub fn from_toml(text: &str) -> Result<Self, SchemaError> {
1042        let ex: Self = toml::from_str(text).map_err(|e| SchemaError::new("", e.to_string()))?;
1043        ex.validate()?;
1044        Ok(ex)
1045    }
1046
1047    /// Check the example's shape: its closed lists, its dates and amounts,
1048    /// its references and its events' and expectations' forms.
1049    pub fn validate(&self) -> Result<(), SchemaError> {
1050        let id = self.id.as_str();
1051        let e = |m: String| SchemaError::new(id, m);
1052        if id.is_empty() {
1053            return Err(SchemaError::new("", "an example has an id"));
1054        }
1055        if self.title.is_empty() || self.rule.is_empty() {
1056            return Err(e("an example has a title and a rule".into()));
1057        }
1058        if self.sources.is_empty() || self.tags.is_empty() {
1059            return Err(e("an example is sourced and tagged".into()));
1060        }
1061        if !matches!(self.status.as_str(), "AUTHORITY" | "CONVENTION" | "CHOICE") {
1062            return Err(e(format!("status {:?}", self.status)));
1063        }
1064        if let Some(a) = &self.appendix
1065            && a != "formats"
1066        {
1067            return Err(e(format!("appendix {a:?}")));
1068        }
1069        if self.expect.is_empty() {
1070            return Err(e("an example expects something".into()));
1071        }
1072        self.setup.validate(id)?;
1073
1074        let mut codes = BTreeSet::new();
1075        for a in &self.setup.accounts {
1076            codes.insert(a.code.as_str());
1077        }
1078        for a in &self.setup.assets {
1079            for c in [&a.cost_account, &a.accumulated_account, &a.expense_account] {
1080                if !codes.contains(c.as_str()) {
1081                    return Err(e(format!("asset {}: no account {c}", a.id)));
1082                }
1083            }
1084            if let Some(n) = a.acquired_by
1085                && (n < 1 || n > self.events.len())
1086            {
1087                return Err(e(format!(
1088                    "asset {}: acquired_by {n}: events count from 1, to {}",
1089                    a.id,
1090                    self.events.len()
1091                )));
1092            }
1093        }
1094        for s in &self.setup.schedules {
1095            s.validate(id, &codes)?;
1096        }
1097        let parties: BTreeSet<&str> = self.setup.parties.iter().map(|p| p.name.as_str()).collect();
1098        let projects: BTreeSet<&str> = self
1099            .setup
1100            .projects
1101            .iter()
1102            .map(|p| p.name.as_str())
1103            .collect();
1104        let payees: BTreeSet<&str> = self.setup.payees.iter().map(|p| p.name.as_str()).collect();
1105        let instruments: BTreeSet<&str> = self
1106            .setup
1107            .instruments
1108            .iter()
1109            .map(|i| i.label.as_str())
1110            .collect();
1111        for (n, ev) in self.events.iter().enumerate() {
1112            let n = n + 1;
1113            let what = |m: String| e(format!("event {n}: {m}"));
1114            let kind = ev.kind().map_err(what)?;
1115            if ev.date.is_some() != kind.is_dated() {
1116                return Err(what(if kind.is_dated() {
1117                    format!("{} is dated (date)", kind.key())
1118                } else {
1119                    format!("{} dates its own entries: no date", kind.key())
1120                }));
1121            }
1122            if let Some(d) = &ev.date {
1123                check_date(d, &what)?;
1124            }
1125            if ev.description.is_some() != kind.is_described() {
1126                return Err(what(if kind.is_described() {
1127                    format!("{} is described (description)", kind.key())
1128                } else {
1129                    format!("{} describes its own entries: no description", kind.key())
1130                }));
1131            }
1132            if ev.older && kind != EventKind::Lines {
1133                return Err(what(
1134                    "older is an entry's lines, as an earlier version wrote them".into(),
1135                ));
1136            }
1137            if ev.older && ev.refused.is_some() {
1138                return Err(what(
1139                    "an entry posted as an earlier version wrote it (older) can't also be \
1140                     refused"
1141                        .into(),
1142                ));
1143            }
1144            if ev.payee.is_some() && kind != EventKind::Lines {
1145                return Err(what(
1146                    "a payee is an entry's: a reversal takes its original's, a bill names \
1147                     its vendor, an invoice its customer, a close and a command have none"
1148                        .into(),
1149                ));
1150            }
1151            if (!ev.parties.is_empty() || ev.instrument.is_some()) && !kind.may_tag() {
1152                return Err(what(format!(
1153                    "{} is tagged with no parties or payment method",
1154                    kind.key()
1155                )));
1156            }
1157            if ev.refused.is_some() && !kind.may_refuse() {
1158                return Err(what(
1159                    "refused is for a reversal or a command, which the books take whole or \
1160                     not at all"
1161                        .into(),
1162                ));
1163            }
1164            if let Some(r) = &ev.refused
1165                && rule_for_refusal(r).is_none()
1166            {
1167                return Err(what(format!("{r:?} is no refusal the specification names")));
1168            }
1169            if let Some(p) = &ev.payee
1170                && !payees.contains(p.as_str())
1171            {
1172                return Err(what(format!("payee {p:?} not in setup.payees")));
1173            }
1174            if let Some(i) = &ev.instrument
1175                && !instruments.contains(i.as_str())
1176            {
1177                return Err(what(format!(
1178                    "payment method {i:?} not in setup.instruments"
1179                )));
1180            }
1181            if let Some(of) = ev.reverses {
1182                if of < 1 {
1183                    return Err(what(format!("reverses = {of}: events count from 1")));
1184                }
1185                if of >= n {
1186                    return Err(what(format!("reverses a later event ({of})")));
1187                }
1188            }
1189            for line in &ev.lines {
1190                if !codes.contains(line.account.as_str()) {
1191                    return Err(what(format!("no account {}", line.account)));
1192                }
1193                if !matches!(line.side.as_str(), "debit" | "credit") {
1194                    return Err(what(format!("side {:?}", line.side)));
1195                }
1196                // A refused entry may carry the amount the books refuse (zero or
1197                // negative, B-2); an accepted one carries a positive amount.
1198                check_amount(&line.amount, ev.refused.is_none(), &what)?;
1199                if let Some(p) = &line.project
1200                    && !projects.contains(p.as_str())
1201                {
1202                    return Err(what(format!("project {p:?} not in setup.projects")));
1203                }
1204            }
1205            for sh in &ev.parties {
1206                if !parties.contains(sh.party.as_str()) {
1207                    return Err(what(format!("party {:?} not in setup.parties", sh.party)));
1208                }
1209                match (&sh.amount, &sh.percent) {
1210                    (None, None) => {}
1211                    (Some(a), None) => check_amount(a, false, &what)?,
1212                    (None, Some(p)) => check_amount(p, false, &what)?,
1213                    _ => return Err(what("a share is an amount or a percent".into())),
1214                }
1215            }
1216            if let Some(c) = &ev.close {
1217                check_date(&c.start, &what)?;
1218                if !matches!(c.to.as_str(), "members" | "retained_earnings") {
1219                    return Err(what(format!("close to {:?}", c.to)));
1220                }
1221                if let Some(r) = &c.rounding
1222                    && !(r == "per-close" && c.to == "members")
1223                {
1224                    return Err(what(format!("close rounding {r:?}")));
1225                }
1226            }
1227            if let Some(b) = &ev.bill {
1228                check_document(&b.amount, &b.lines, &projects, &what)?;
1229                if b.vendor.is_empty() {
1230                    return Err(what("a bill names its vendor".into()));
1231                }
1232            }
1233            if let Some(i) = &ev.invoice {
1234                check_document(&i.amount, &i.lines, &projects, &what)?;
1235                if i.customer.is_empty() {
1236                    return Err(what("an invoice names its customer".into()));
1237                }
1238            }
1239            if let Some(w) = &ev.write_off {
1240                if w.invoice < 1 || w.invoice >= n {
1241                    return Err(what(format!(
1242                        "write_off names event {}: an earlier invoice",
1243                        w.invoice
1244                    )));
1245                }
1246                if self.events[w.invoice - 1].invoice.is_none() {
1247                    return Err(what(format!(
1248                        "write_off names event {}, which is no invoice",
1249                        w.invoice
1250                    )));
1251                }
1252                if let Some(a) = &w.amount {
1253                    check_amount(a, true, &what)?;
1254                }
1255                if w.reason.trim().is_empty() {
1256                    return Err(what("a write-off says why it will not be collected".into()));
1257                }
1258            }
1259            if let Some(r) = &ev.run_depreciation {
1260                check_month(&r.period, &what)?;
1261                if !self.setup.assets.iter().any(|a| a.id == r.asset) {
1262                    return Err(what(format!("no asset {:?} in setup.assets", r.asset)));
1263                }
1264            }
1265            if let Some(d) = &ev.dispose_asset {
1266                if !self.setup.assets.iter().any(|a| a.id == d.asset) {
1267                    return Err(what(format!("no asset {:?} in setup.assets", d.asset)));
1268                }
1269                check_amount(&d.proceeds, false, &what)?;
1270                for c in [&d.proceeds_account, &d.gain_loss_account] {
1271                    if !codes.contains(c.as_str()) {
1272                        return Err(what(format!("no account {c}")));
1273                    }
1274                }
1275            }
1276            if let Some(r) = &ev.run_schedules {
1277                check_date(&r.through, &what)?;
1278            }
1279            if let Some(s) = &ev.register_schedule
1280                && ev.refused.is_none()
1281            {
1282                s.validate(id, &codes)?;
1283            }
1284        }
1285        for c in &self.changes {
1286            let what = |m: String| e(format!("change after {}: {m}", c.after));
1287            if c.after < 1 || c.after > self.events.len() {
1288                return Err(what(format!(
1289                    "after = {}: events count from 1, to {}",
1290                    c.after,
1291                    self.events.len()
1292                )));
1293            }
1294            let given = [
1295                c.rename.is_some(),
1296                c.require_tax_category.is_some(),
1297                c.correct_asset.is_some(),
1298            ];
1299            if given.iter().filter(|x| **x).count() != 1 {
1300                return Err(what(
1301                    "a change is one of rename, require_tax_category or correct_asset".into(),
1302                ));
1303            }
1304            if let Some(r) = &c.rename {
1305                if !codes.contains(r.code.as_str()) {
1306                    return Err(what(format!("no account {}", r.code)));
1307                }
1308                if r.name.trim().is_empty() {
1309                    return Err(what("a rename names the account".into()));
1310                }
1311            }
1312            if let Some(p) = &c.require_tax_category
1313                && !matches!(p.as_str(), "warn" | "refuse")
1314            {
1315                return Err(what(format!("require_tax_category {p:?}")));
1316            }
1317            if let Some(f) = &c.correct_asset {
1318                if !self.setup.assets.iter().any(|a| a.id == f.asset) {
1319                    return Err(what(format!("no asset {:?} in setup.assets", f.asset)));
1320                }
1321                for a in [&f.cost, &f.salvage].into_iter().flatten() {
1322                    check_amount(a, false, &what)?;
1323                }
1324                if let Some(d) = &f.in_service {
1325                    check_date(d, &what)?;
1326                }
1327                if f.life_months == Some(0) {
1328                    return Err(what("life_months is above zero".into()));
1329                }
1330                if f.reason.trim().is_empty() {
1331                    return Err(what("a correction gives its reason".into()));
1332                }
1333            }
1334        }
1335        for x in &self.expect {
1336            let q = x.query.as_str();
1337            let what = |m: String| e(format!("{q}: {m}"));
1338            if !QUERIES.contains(&q) {
1339                return Err(e(format!("no query answers {q:?}")));
1340            }
1341            if let Some(n) = x.after
1342                && (n < 1 || n > self.events.len())
1343            {
1344                return Err(what(format!(
1345                    "after = {n}: events count from 1, to {}",
1346                    self.events.len()
1347                )));
1348            }
1349            if x.after.is_some() && FORMAT_QUERIES.contains(&q) {
1350                return Err(what(
1351                    "a format query is of the books after every event".into(),
1352                ));
1353            }
1354            if FORMAT_QUERIES.contains(&q) && self.appendix.as_deref() != Some("formats") {
1355                return Err(what(
1356                    "a format query belongs to an example in appendix \"formats\"".into(),
1357                ));
1358            }
1359            for d in [&x.from, &x.to, &x.as_of].into_iter().flatten() {
1360                check_date(d, &what)?;
1361            }
1362            only(x, &what)?;
1363            if COMMANDS.contains(&q) {
1364                if x.values.is_some() || (x.accepted == Some(true)) == x.refused.is_some() {
1365                    return Err(what(
1366                        "a command is accepted = true, or refused, and shows no values".into(),
1367                    ));
1368                }
1369                if x.accepted == Some(false) {
1370                    return Err(what("a command is accepted = true, or refused".into()));
1371                }
1372                if let Some(r) = &x.refused
1373                    && rule_for_refusal(r).is_none()
1374                {
1375                    return Err(what(format!("{r:?} is no refusal the specification names")));
1376                }
1377                if x.account.as_ref().is_some_and(|a| a.code.trim().is_empty()) {
1378                    return Err(what("add_account names the account's code".into()));
1379                }
1380            } else {
1381                match (&x.values, &x.refused) {
1382                    (Some(v), None) if !v.is_empty() => {}
1383                    (None, Some(r)) => {
1384                        if q != "period" {
1385                            return Err(what(
1386                                "only a period may be refused instead of answering".into(),
1387                            ));
1388                        }
1389                        if rule_for_refusal(r).is_none() {
1390                            return Err(what(format!(
1391                                "{r:?} is no refusal the specification names"
1392                            )));
1393                        }
1394                    }
1395                    _ => return Err(what("expects values (a period: values or refused)".into())),
1396                }
1397            }
1398            if let Some(v) = &x.values {
1399                match v {
1400                    Values::Figures(f) => {
1401                        for (k, val) in f {
1402                            if k.is_empty() {
1403                                return Err(what("a figure's key is empty".into()));
1404                            }
1405                            if !TEXT_VALUED.contains(&q) && !is_exact_decimal(val) {
1406                                return Err(what(format!(
1407                                    "{k}: {val:?} is no exact decimal string"
1408                                )));
1409                            }
1410                        }
1411                    }
1412                    Values::Lines(l) => {
1413                        if !matches!(q, "iif_lines" | "uncategorized_accounts") {
1414                            return Err(what("expects figures (a table), not lines".into()));
1415                        }
1416                        if l.is_empty() {
1417                            return Err(what("an empty list expects nothing".into()));
1418                        }
1419                    }
1420                    Values::Rows(rows) => {
1421                        if q != "iif_changes" {
1422                            return Err(what("expects figures (a table), not rows".into()));
1423                        }
1424                        if rows.iter().any(|r| r.len() != 3) {
1425                            return Err(what("an iif_changes row has three fields".into()));
1426                        }
1427                    }
1428                }
1429            }
1430            if FORMAT_QUERIES.contains(&q) && x.values.is_none() {
1431                return Err(what("a format query expects its lines or rows".into()));
1432            }
1433        }
1434        Ok(())
1435    }
1436}
1437
1438impl Setup {
1439    /// Check the setup's shape: its closed lists, its dates, its accounts
1440    /// and its directory.
1441    pub fn validate(&self, id: &str) -> Result<(), SchemaError> {
1442        let e = |m: String| SchemaError::new(id, m);
1443        if self.currency.len() != 3 || !self.currency.bytes().all(|b| b.is_ascii_uppercase()) {
1444            return Err(e(format!("currency {:?}", self.currency)));
1445        }
1446        if !is_month_day(&self.fiscal_year_start) {
1447            return Err(e(format!("fiscal_year_start {:?}", self.fiscal_year_start)));
1448        }
1449        if !matches!(self.basis.as_str(), "accrual" | "cash") {
1450            return Err(e(format!("basis {:?}", self.basis)));
1451        }
1452        if !matches!(
1453            self.rounding.as_str(),
1454            "half-even" | "half-away-from-zero" | "largest-remainder"
1455        ) {
1456            return Err(e(format!("rounding {:?}", self.rounding)));
1457        }
1458        if let Some(t) = &self.today {
1459            check_date(t, &e)?;
1460        }
1461        let mut codes: BTreeSet<&str> = BTreeSet::new();
1462        for a in &self.accounts {
1463            if a.code.trim().is_empty() || a.name.trim().is_empty() {
1464                return Err(e("an account has a code and a name".into()));
1465            }
1466            if !matches!(
1467                a.kind.as_str(),
1468                "asset" | "liability" | "equity" | "revenue" | "expense"
1469            ) {
1470                return Err(e(format!("account {}: type {:?}", a.code, a.kind)));
1471            }
1472            if !codes.insert(a.code.as_str()) {
1473                return Err(e(format!("account {}: code used twice", a.code)));
1474            }
1475            if let Some(p) = &a.parent
1476                && !codes.contains(p.as_str())
1477            {
1478                return Err(e(format!(
1479                    "account {}: parent {p} is not declared before it",
1480                    a.code
1481                )));
1482            }
1483        }
1484        for fa in &self.fixed_assets {
1485            for c in [
1486                &fa.cost_account,
1487                &fa.accumulated_account,
1488                &fa.expense_account,
1489            ] {
1490                if !codes.contains(c.as_str()) {
1491                    return Err(e(format!("fixed asset: no account {c}")));
1492                }
1493            }
1494        }
1495        let mut ids = BTreeSet::new();
1496        for a in &self.assets {
1497            if a.id.is_empty() || a.description.is_empty() {
1498                return Err(e("an asset has an id and a description".into()));
1499            }
1500            if !ids.insert(a.id.as_str()) {
1501                return Err(e(format!("asset {}: id used twice", a.id)));
1502            }
1503            if a.method != "straight_line" {
1504                return Err(e(format!("asset {}: method {:?}", a.id, a.method)));
1505            }
1506            check_amount(&a.cost, false, &e)?;
1507            check_amount(&a.salvage, false, &e)?;
1508            if a.life_months == 0 {
1509                return Err(e(format!("asset {}: life_months is above zero", a.id)));
1510            }
1511            check_date(&a.in_service, &e)?;
1512            if let Some(b) = &a.basis {
1513                if !matches!(
1514                    b.rule.as_str(),
1515                    "purchase" | "carryover_723" | "personal_use_conversion"
1516                ) {
1517                    return Err(e(format!("asset {}: basis rule {:?}", a.id, b.rule)));
1518                }
1519                for x in [&b.contributor_adjusted_basis, &b.fmv_at_contribution]
1520                    .into_iter()
1521                    .flatten()
1522                {
1523                    check_amount(x, false, &e)?;
1524                }
1525                if let Some(m) = &b.sec704c_method
1526                    && !matches!(
1527                        m.as_str(),
1528                        "traditional" | "traditional_with_curative" | "remedial"
1529                    )
1530                {
1531                    return Err(e(format!("asset {}: section 704(c) method {m:?}", a.id)));
1532                }
1533            }
1534        }
1535        let mut schedule_ids = BTreeSet::new();
1536        for s in &self.schedules {
1537            s.validate(id, &codes)?;
1538            if !schedule_ids.insert(s.id.as_str()) {
1539                return Err(e(format!("schedule {}: id used twice", s.id)));
1540            }
1541        }
1542        let mut party_names = BTreeSet::new();
1543        for p in &self.parties {
1544            if p.name.trim().is_empty() {
1545                return Err(e("a party has a name".into()));
1546            }
1547            if !party_names.insert(p.name.as_str()) {
1548                return Err(e(format!("party {}: named twice", p.name)));
1549            }
1550            if p.roles.iter().any(|r| !ROLES.contains(&r.as_str())) {
1551                return Err(e(format!("party {}: roles {:?}", p.name, p.roles)));
1552            }
1553            if p.roles
1554                .iter()
1555                .filter(|r| *r == "vendor" || *r == "customer")
1556                .count()
1557                > 1
1558            {
1559                return Err(e(format!(
1560                    "party {}: a vendor or a customer, not both",
1561                    p.name
1562                )));
1563            }
1564            for c in &p.creditor_of {
1565                if !codes.contains(c.as_str()) {
1566                    return Err(e(format!("party {}: no account {c}", p.name)));
1567                }
1568            }
1569            for l in [&p.tin_last4, &p.w9_tin_last4].into_iter().flatten() {
1570                if l.len() != 4 || !l.bytes().all(|b| b.is_ascii_digit()) {
1571                    return Err(e(format!("party {}: last four {l:?}", p.name)));
1572                }
1573            }
1574            if p.tin_purged && p.tin_last4.is_none() {
1575                return Err(e(format!("party {}: tin_purged needs tin_last4", p.name)));
1576            }
1577        }
1578        let mut member_names = BTreeSet::new();
1579        let shared = self.members.iter().filter(|m| m.share.is_some()).count();
1580        if shared != 0 && shared != self.members.len() {
1581            return Err(e("every member has a share, or none does".into()));
1582        }
1583        let mut total = Decimal::ZERO;
1584        for m in &self.members {
1585            if m.name.trim().is_empty() || !member_names.insert(m.name.as_str()) {
1586                return Err(e(format!("member {:?} is named twice", m.name)));
1587            }
1588            if !codes.contains(m.capital_account.as_str()) {
1589                return Err(e(format!(
1590                    "member {}: no account {}",
1591                    m.name, m.capital_account
1592                )));
1593            }
1594            if let Some(d) = &m.distributions_account
1595                && !codes.contains(d.as_str())
1596            {
1597                return Err(e(format!("member {}: no account {d}", m.name)));
1598            }
1599            if let Some(s) = &m.share {
1600                let share = decimal(s, &e)?;
1601                if share <= Decimal::ZERO {
1602                    return Err(e(format!("member {}: share {s:?} is above zero", m.name)));
1603                }
1604                total += share;
1605            }
1606        }
1607        if shared > 0 && total != Decimal::ONE {
1608            return Err(e("the shares sum to 1".into()));
1609        }
1610        let mut payee_names = BTreeSet::new();
1611        for p in &self.payees {
1612            if p.name.trim().is_empty() || !payee_names.insert(p.name.as_str()) {
1613                return Err(e(format!("payee {:?} is named twice", p.name)));
1614            }
1615            if !matches!(p.form.as_str(), "1099-NEC" | "1099-MISC") {
1616                return Err(e(format!("payee {}: form {:?}", p.name, p.form)));
1617            }
1618        }
1619        for c in &self.status_changes {
1620            if !payee_names.contains(c.payee.as_str()) {
1621                return Err(e(format!(
1622                    "status change: payee {:?} not in setup",
1623                    c.payee
1624                )));
1625            }
1626            check_date(&c.effective, &e)?;
1627            match c.decision.as_str() {
1628                "not_reportable" => {
1629                    if !c
1630                        .reason_kind
1631                        .as_deref()
1632                        .is_some_and(|k| EXEMPT_REASONS.contains(&k))
1633                    {
1634                        return Err(e(format!(
1635                            "status change of {}: not_reportable needs a reason_kind",
1636                            c.payee
1637                        )));
1638                    }
1639                    if c.reason.as_deref().is_none_or(|r| r.trim().is_empty()) {
1640                        return Err(e(format!(
1641                            "status change of {}: not_reportable needs a reason",
1642                            c.payee
1643                        )));
1644                    }
1645                }
1646                "undetermined" | "1099-NEC" | "1099-MISC" => {
1647                    if c.reason_kind.is_some() || c.reason.is_some() {
1648                        return Err(e(format!(
1649                            "status change of {}: only not_reportable takes reason_kind and reason",
1650                            c.payee
1651                        )));
1652                    }
1653                }
1654                other => {
1655                    return Err(e(format!(
1656                        "status change of {}: decision {other:?}",
1657                        c.payee
1658                    )));
1659                }
1660            }
1661        }
1662        let mut labels = BTreeSet::new();
1663        for i in &self.instruments {
1664            if i.label.trim().is_empty() || !labels.insert(i.label.as_str()) {
1665                return Err(e(format!("payment method {:?} is labelled twice", i.label)));
1666            }
1667            if !matches!(
1668                i.kind.as_str(),
1669                "bank" | "business_card" | "member_card" | "member_cash_or_check" | "platform"
1670            ) {
1671                return Err(e(format!("payment method {}: kind {:?}", i.label, i.kind)));
1672            }
1673            if !codes.contains(i.account.as_str()) {
1674                return Err(e(format!(
1675                    "payment method {}: no account {}",
1676                    i.label, i.account
1677                )));
1678            }
1679            if let Some(o) = &i.owner
1680                && !party_names.contains(o.as_str())
1681            {
1682                return Err(e(format!(
1683                    "payment method {}: owner {o:?} not in setup.parties",
1684                    i.label
1685                )));
1686            }
1687            for c in [&i.last4].into_iter().flatten() {
1688                check_last4(c, &e)?;
1689            }
1690        }
1691        let mut project_names = BTreeSet::new();
1692        for p in &self.projects {
1693            if p.name.trim().is_empty() || !project_names.insert(p.name.as_str()) {
1694                return Err(e(format!("project {:?} is named twice", p.name)));
1695            }
1696            if let Some(k) = &p.kind
1697                && !matches!(k.as_str(), "internal" | "client" | "on_spec" | "other")
1698            {
1699                return Err(e(format!("project {}: kind {k:?}", p.name)));
1700            }
1701            if let Some(s) = &p.status
1702                && !matches!(s.as_str(), "active" | "paused" | "finished" | "abandoned")
1703            {
1704                return Err(e(format!("project {}: status {s:?}", p.name)));
1705            }
1706            if let Some(t) = &p.party
1707                && !party_names.contains(t.as_str())
1708            {
1709                return Err(e(format!(
1710                    "project {}: party {t:?} not in setup.parties",
1711                    p.name
1712                )));
1713            }
1714        }
1715        Ok(())
1716    }
1717}
1718
1719impl ScheduleSpec {
1720    fn validate(&self, id: &str, codes: &BTreeSet<&str>) -> Result<(), SchemaError> {
1721        let e = |m: String| SchemaError::new(id, m);
1722        if self.id.trim().is_empty() || self.description.trim().is_empty() {
1723            return Err(e("a schedule has an id and a description".into()));
1724        }
1725        if !matches!(self.kind.as_str(), "prepaid_expense" | "deferred_revenue") {
1726            return Err(e(format!("schedule {}: kind {:?}", self.id, self.kind)));
1727        }
1728        check_amount(&self.total, false, &e)?;
1729        check_date(&self.start, &e)?;
1730        if self.months == 0 {
1731            return Err(e(format!("schedule {}: months is above zero", self.id)));
1732        }
1733        if !matches!(self.convention.as_str(), "daily" | "monthly_even") {
1734            return Err(e(format!(
1735                "schedule {}: convention {:?}",
1736                self.id, self.convention
1737            )));
1738        }
1739        if self.convention == "monthly_even" {
1740            let day = self
1741                .start
1742                .rsplit_once('-')
1743                .and_then(|(_, d)| d.parse::<u32>().ok());
1744            if day != Some(1) {
1745                return Err(e(format!(
1746                    "schedule {}: an equal-months schedule starts on the 1st",
1747                    self.id
1748                )));
1749            }
1750        }
1751        for c in [&self.balance_account, &self.pl_account] {
1752            if !codes.contains(c.as_str()) {
1753                return Err(e(format!("schedule {}: no account {c}", self.id)));
1754            }
1755        }
1756        Ok(())
1757    }
1758}
1759
1760/// The party roles the specification names.
1761pub const ROLES: &[&str] = &[
1762    "member",
1763    "vendor",
1764    "customer",
1765    "contractor",
1766    "landlord",
1767    "employee",
1768    "financial_institution",
1769    "payout_platform",
1770    "tax_agency",
1771    "government_agency",
1772];
1773
1774/// Why a party is not reportable (B-19).
1775pub const EXEMPT_REASONS: &[&str] = &[
1776    "corporation",
1777    "tax_exempt",
1778    "united_states",
1779    "state",
1780    "foreign_government",
1781    "foreign_person",
1782    "merchandise",
1783    "employee",
1784    "other",
1785];
1786
1787/// A date, `YYYY-MM-DD` and a real day.
1788pub fn is_date(s: &str) -> bool {
1789    s.len() == 10 && NaiveDate::parse_from_str(s, "%Y-%m-%d").is_ok()
1790}
1791
1792/// A month, `YYYY-MM`.
1793pub fn is_month(s: &str) -> bool {
1794    s.len() == 7 && NaiveDate::parse_from_str(&format!("{s}-01"), "%Y-%m-%d").is_ok()
1795}
1796
1797/// An exact decimal string: digits, an optional sign and point; never an
1798/// exponent, never a float.
1799pub fn is_exact_decimal(s: &str) -> bool {
1800    !s.is_empty()
1801        && s.chars()
1802            .all(|c| c.is_ascii_digit() || c == '.' || c == '-')
1803        && s.matches('.').count() <= 1
1804        && s.parse::<Decimal>().is_ok()
1805}
1806
1807fn decimal(s: &str, e: &impl Fn(String) -> SchemaError) -> Result<Decimal, SchemaError> {
1808    if !is_exact_decimal(s) {
1809        return Err(e(format!("{s:?} is no exact decimal string")));
1810    }
1811    s.parse()
1812        .map_err(|_| e(format!("{s:?} is no exact decimal string")))
1813}
1814
1815fn check_amount(
1816    s: &str,
1817    positive: bool,
1818    e: &impl Fn(String) -> SchemaError,
1819) -> Result<(), SchemaError> {
1820    let d = decimal(s, e)?;
1821    if positive && d <= Decimal::ZERO {
1822        return Err(e(format!("{s:?}: amounts are positive")));
1823    }
1824    Ok(())
1825}
1826
1827fn check_date(s: &str, e: &impl Fn(String) -> SchemaError) -> Result<(), SchemaError> {
1828    if !is_date(s) {
1829        return Err(e(format!("{s:?} is no date (YYYY-MM-DD)")));
1830    }
1831    Ok(())
1832}
1833
1834fn check_month(s: &str, e: &impl Fn(String) -> SchemaError) -> Result<(), SchemaError> {
1835    if !is_month(s) {
1836        return Err(e(format!("{s:?} is no month (YYYY-MM)")));
1837    }
1838    Ok(())
1839}
1840
1841fn check_last4(s: &str, e: &impl Fn(String) -> SchemaError) -> Result<(), SchemaError> {
1842    if s.len() != 4 || !s.bytes().all(|b| b.is_ascii_digit()) {
1843        return Err(e(format!("last four {s:?}")));
1844    }
1845    Ok(())
1846}
1847
1848fn check_document(
1849    amount: &Option<String>,
1850    lines: &[DocumentLineSpec],
1851    projects: &BTreeSet<&str>,
1852    e: &impl Fn(String) -> SchemaError,
1853) -> Result<(), SchemaError> {
1854    match (amount, lines) {
1855        (Some(a), []) => {
1856            if decimal(a, e)? <= Decimal::ZERO {
1857                return Err(e("amounts are positive".into()));
1858            }
1859        }
1860        (None, [_, ..]) => {
1861            for l in lines {
1862                if decimal(&l.amount, e)? <= Decimal::ZERO {
1863                    return Err(e("amounts are positive".into()));
1864                }
1865                if let Some(p) = &l.project
1866                    && !projects.contains(p.as_str())
1867                {
1868                    return Err(e(format!("project {p:?} not in setup.projects")));
1869                }
1870            }
1871        }
1872        (Some(_), [_, ..]) => {
1873            return Err(e("a document has an amount or lines, exactly one".into()));
1874        }
1875        (None, []) => return Err(e("a document has an amount or lines, exactly one".into())),
1876    }
1877    Ok(())
1878}
1879
1880/// A key only the queries that ask for it may name.
1881fn only(x: &Expect, e: &impl Fn(String) -> SchemaError) -> Result<(), SchemaError> {
1882    let q = x.query.as_str();
1883    let checks: [(&str, bool, &[&str]); 14] = [
1884        ("entry", x.entry.is_some(), &["entry", "party_part"]),
1885        ("event", x.event.is_some(), &["run"]),
1886        (
1887            "asset",
1888            x.asset.is_some(),
1889            &[
1890                "disposal",
1891                "depreciation_schedule",
1892                "register_row",
1893                "asset_basis",
1894            ],
1895        ),
1896        ("schedule", x.schedule.is_some(), &["schedule_plan"]),
1897        (
1898            "party",
1899            x.party.is_some(),
1900            &["party_part", "party_totals", "party_tin_last4"],
1901        ),
1902        ("project", x.project.is_some(), &["project"]),
1903        ("basis", x.basis.is_some(), &["party_totals"]),
1904        ("payee", x.payee.is_some(), &["vendor_1099"]),
1905        (
1906            "code",
1907            x.code.is_some(),
1908            &["rename_account", "set_tax_category"],
1909        ),
1910        ("name", x.name.is_some(), &["rename_account"]),
1911        ("category", x.category.is_some(), &["set_tax_category"]),
1912        ("account", x.account.is_some(), &["add_account"]),
1913        ("accepted", x.accepted.is_some(), COMMANDS),
1914        (
1915            "refused",
1916            x.refused.is_some(),
1917            &[
1918                "rename_account",
1919                "add_account",
1920                "set_tax_category",
1921                "period",
1922            ],
1923        ),
1924    ];
1925    for (key, given, queries) in checks {
1926        if given && !queries.contains(&q) {
1927            return Err(e(format!("{key} is for {} only", queries.join(" and "))));
1928        }
1929    }
1930    Ok(())
1931}
1932
1933fn is_month_day(s: &str) -> bool {
1934    let Some((m, d)) = s.split_once('-') else {
1935        return false;
1936    };
1937    let (Ok(m), Ok(d)) = (m.parse::<u32>(), d.parse::<u32>()) else {
1938        return false;
1939    };
1940    s.len() == 5 && s.as_bytes()[2] == b'-' && NaiveDate::from_ymd_opt(2001, m, d).is_some()
1941}