Skip to main content

bookspec_checker/
rules.rs

1//! bookspec's rule identifiers and their catalogue.
2//!
3//! Every rule of `docs/spec/rules.md` (B-1 to B-45) has one bookspec
4//! identifier, `BS-<area>-<n>`, allocated by [`Area`] and numbered in
5//! B-rule order within its area. `bookspec/spec/rules-index.md` is the
6//! table, and a test keeps the two in step. The rules from B-39 on are
7//! allocated in `plan/served.md` 8.0.3 but not yet stated; they carry an
8//! identifier and an area and no status, so a later unit fills them in
9//! without moving anyone else's number.
10
11use std::sync::LazyLock;
12
13use crate::engine::RuleId;
14
15/// One of bookspec's areas: the subject a rule belongs to.
16#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
17pub enum Area {
18    /// The mechanics of an entry: lines, sides, amounts, dates.
19    Entry,
20    /// The ledger: the chart, accounts, the journal, the format.
21    Ledger,
22    /// Reports: the trial balance, the accounting equation, statements.
23    Statement,
24    /// Periods: closes, closed periods, what an omitted date means.
25    Period,
26    /// Partners and parties: shares, members' capital, the directory.
27    Partner,
28    /// Fixed assets and capitalization.
29    Asset,
30    /// Prepaid expenses and deferred revenue.
31    Prepaid,
32    /// Receivables and payables: documents, aging, settlements.
33    Arap,
34    /// Revenue: when it is recognized and how it is presented.
35    Revenue,
36    /// Information returns: 1099s and their thresholds.
37    Tax1099,
38    /// Interchange formats: the journal format gate and the IIF.
39    Format,
40}
41
42impl Area {
43    /// Every area, in the order the specification lists them.
44    pub const ALL: [Area; 11] = [
45        Area::Entry,
46        Area::Ledger,
47        Area::Statement,
48        Area::Period,
49        Area::Partner,
50        Area::Asset,
51        Area::Prepaid,
52        Area::Arap,
53        Area::Revenue,
54        Area::Tax1099,
55        Area::Format,
56    ];
57
58    /// The area's key in a rule identifier.
59    pub fn key(self) -> &'static str {
60        match self {
61            Area::Entry => "entry",
62            Area::Ledger => "ledger",
63            Area::Statement => "statement",
64            Area::Period => "period",
65            Area::Partner => "partner",
66            Area::Asset => "asset",
67            Area::Prepaid => "prepaid",
68            Area::Arap => "arap",
69            Area::Revenue => "revenue",
70            Area::Tax1099 => "1099",
71            Area::Format => "format",
72        }
73    }
74
75    /// The area `key` names.
76    pub fn parse(key: &str) -> Option<Self> {
77        Self::ALL.into_iter().find(|a| a.key() == key)
78    }
79}
80
81/// The status of a rule, in bookspec's vocabulary: what its statement
82/// rests on. `docs/spec/rules.md`'s "DECISION" is bookspec's [`Status::Choice`]
83/// (`plan/served.md` P13 step 3): the specification names the options, and
84/// an engine declares its pick through [`crate::Claims`].
85#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
86pub enum Status {
87    /// A cited authority states the rule.
88    Authority,
89    /// Common practice, where no authority states the rule.
90    Convention,
91    /// A human's or the house's choice among options the specification
92    /// names.
93    Choice,
94}
95
96impl Status {
97    /// The status as the specification writes it.
98    pub fn key(self) -> &'static str {
99        match self {
100            Status::Authority => "AUTHORITY",
101            Status::Convention => "CONVENTION",
102            Status::Choice => "CHOICE",
103        }
104    }
105
106    /// The status `key` names.
107    pub fn parse(key: &str) -> Option<Self> {
108        [Status::Authority, Status::Convention, Status::Choice]
109            .into_iter()
110            .find(|s| s.key() == key)
111    }
112}
113
114/// One rule of the specification.
115#[derive(Clone, Debug, PartialEq, Eq)]
116pub struct Rule {
117    /// Its number in `docs/spec/rules.md`, 1 to 45.
118    pub b: u8,
119    /// Its bookspec identifier.
120    pub id: RuleId,
121    /// Its area.
122    pub area: Area,
123    /// Its normative title, as `docs/spec/rules.md` states it.
124    pub title: &'static str,
125    /// What its statement rests on; `None` for a rule allocated but not
126    /// yet stated (B-39, B-40 and B-43, `plan/served.md` 8.0.3).
127    pub status: Option<Status>,
128    /// The example ids that demonstrate it, by `Example::id`.
129    pub examples: &'static [&'static str],
130}
131
132impl Rule {
133    /// True when the rule has at least one example to check it.
134    pub fn has_examples(&self) -> bool {
135        !self.examples.is_empty()
136    }
137}
138
139/// The catalogue: every rule, in B-rule order.
140pub fn rules() -> &'static [Rule] {
141    static RULES: LazyLock<Vec<Rule>> = LazyLock::new(build);
142    &RULES
143}
144
145/// The rule `b` (1 to 45).
146pub fn rule(b: u8) -> Option<&'static Rule> {
147    rules().iter().find(|r| r.b == b)
148}
149
150/// The rule `id` names, if the specification states it.
151pub fn rule_for_id(id: &RuleId) -> Option<&'static Rule> {
152    rules().iter().find(|r| &r.id == id)
153}
154
155/// The rule a refusal belongs to, by its reason in the examples' words
156/// (bookspec/README.md).
157pub fn rule_for_refusal(reason: &str) -> Option<&'static Rule> {
158    let b = match reason {
159        "unbalanced" | "too_few_lines" => 1,
160        "negative_amount" | "zero_amount" => 2,
161        "finer_than_minor_unit" => 3,
162        "parent_type_mismatch" => 4,
163        "already_reversed" => 10,
164        "period_closed" => 12,
165        "end_before_start" | "start_after_today" => 30,
166        "blank" | "unchanged" | "sibling_has_it" | "holds_a_tin" | "not_one_line"
167        | "code_taken" => 31,
168        "tax_category_required" => 32,
169        "depreciation_past_disposal"
170        | "entry_irreversible"
171        | "already_disposed"
172        | "invalid_depreciation_period" => 15,
173        "invalid_schedule" => 16,
174        "untagged_control_line" => 38,
175        _ => return None,
176    };
177    rule(b)
178}
179
180/// The rules an engine can claim today: those with at least one example.
181/// A rule with no example has no evidence, so an engine cannot claim it
182/// (`plan/served.md` P13 step 4 fills the rest in).
183pub fn covered_rules() -> Vec<&'static Rule> {
184    rules().iter().filter(|r| r.has_examples()).collect()
185}
186
187fn build() -> Vec<Rule> {
188    // The areas' ordinals are assigned here in B-rule order, one line per
189    // rule: an area's n is its place in `Area::ALL`'s set of rules.
190    let mut next = std::collections::BTreeMap::new();
191    let mut out = Vec::with_capacity(45);
192    for (b, area, title, status, examples) in SPEC {
193        let n: u32 = *next.entry(*area).and_modify(|n| *n += 1).or_insert(1);
194        out.push(Rule {
195            b: *b,
196            id: RuleId::new(*area, n),
197            area: *area,
198            title,
199            status: *status,
200            examples,
201        });
202    }
203    out
204}
205
206type Spec = &'static [(
207    u8,
208    Area,
209    &'static str,
210    Option<Status>,
211    &'static [&'static str],
212)];
213
214const SPEC: Spec = &[
215    (
216        1,
217        Area::Entry,
218        "Every entry is a double entry: debits equal credits",
219        Some(Status::Authority),
220        &[
221            "entry-an-unbalanced-entry-is-refused",
222            "statement-the-books-balance-and-the-equation-holds",
223        ],
224    ),
225    (
226        2,
227        Area::Entry,
228        "A line's amount is positive; its side gives the direction",
229        Some(Status::Convention),
230        &["entry-a-line-amount-is-positive"],
231    ),
232    (
233        3,
234        Area::Ledger,
235        "One money through the ledger; foreign amounts at the transaction-date rate",
236        Some(Status::Authority),
237        &["ledger-a-fraction-of-a-cent-is-refused"],
238    ),
239    (
240        4,
241        Area::Ledger,
242        "Five elements, closed list",
243        Some(Status::Authority),
244        &["ledger-a-child-account-has-its-parents-type"],
245    ),
246    (
247        5,
248        Area::Ledger,
249        "Normal balances",
250        Some(Status::Convention),
251        &["statement-the-books-balance-and-the-equation-holds"],
252    ),
253    (
254        6,
255        Area::Statement,
256        "The trial balance balances",
257        Some(Status::Authority),
258        &["statement-the-books-balance-and-the-equation-holds"],
259    ),
260    (
261        7,
262        Area::Statement,
263        "The accounting equation",
264        Some(Status::Authority),
265        &["statement-the-books-balance-and-the-equation-holds"],
266    ),
267    (
268        8,
269        Area::Ledger,
270        "Posted is forever: the book is written only forward",
271        Some(Status::Authority),
272        &["ledger-a-mistake-is-reversed-never-erased"],
273    ),
274    (
275        9,
276        Area::Ledger,
277        "A mistake is corrected by an opposite entry, never erased",
278        Some(Status::Authority),
279        &["ledger-a-mistake-is-reversed-never-erased"],
280    ),
281    (
282        10,
283        Area::Ledger,
284        "An entry is reversed at most once; a closing entry never",
285        Some(Status::Choice),
286        &["ledger-a-mistake-is-reversed-never-erased"],
287    ),
288    (
289        11,
290        Area::Period,
291        "Revenue and expenses close into profit and loss, and that into capital",
292        Some(Status::Authority),
293        &["period-a-close-sweeps-the-year-into-capital"],
294    ),
295    (
296        12,
297        Area::Period,
298        "A closed period takes no new postings; periods close in order",
299        Some(Status::Convention),
300        &["period-a-close-sweeps-the-year-into-capital"],
301    ),
302    (
303        13,
304        Area::Partner,
305        "Partners share income by the partnership agreement; capital accounts carry it",
306        Some(Status::Authority),
307        &[
308            "members-close.odd-cent-across-closes",
309            "members-close.statement-before-and-after-a-close",
310            "members-close.thirds-over-twelve-months",
311            "members-close.true-up-of-a-per-close-split",
312            "p3-party-members-equity-and-k1",
313        ],
314    ),
315    (
316        14,
317        Area::Statement,
318        "Statements: P&L for a period; cash flows by the direct method",
319        Some(Status::Authority),
320        &[
321            "cash-flow.debt-to-equity",
322            "cash-flow.government-fees",
323            "cash-flow.paid-on-the-companys-behalf",
324            "cash-flow.part-cash-purchase",
325            "cash-flow.reversal-of-a-reversal",
326            "cash-flow.reversed-property-contribution",
327            "cash-flow.two-member-llc-first-year",
328            "period-a-close-sweeps-the-year-into-capital",
329        ],
330    ),
331    (
332        15,
333        Area::Asset,
334        "Depreciation: straight line, salvage never depreciated, partial months by days",
335        Some(Status::Convention),
336        &[
337            "assets-acquired-before-in-service",
338            "assets-a-reversed-run-is-not-posted",
339            "assets-book-value-is-what-the-books-carry",
340            "depreciation-catch-up-month",
341            "depreciation-disposal-after-a-late-change-undone",
342            "depreciation-disposal-before-a-later-run",
343            "depreciation-disposal-in-final-month",
344            "depreciation-disposal-inside-a-posted-month",
345            "depreciation-disposal-mid-month",
346            "depreciation-final-month-by-days",
347            "depreciation-final-month-drift",
348            "depreciation-reversal-before-its-run-is-refused",
349            "depreciation-run-reversed-after-its-month",
350        ],
351    ),
352    (
353        16,
354        Area::Prepaid,
355        "Prepaid expenses and deferred revenue are recognized over the period benefited or earned",
356        Some(Status::Authority),
357        &[
358            "schedule-daily-deferred-revenue",
359            "schedule-daily-small-first-and-last-month",
360            "schedule-equal-months-start-on-the-first",
361        ],
362    ),
363    (
364        17,
365        Area::Revenue,
366        "Platform payouts are recorded gross when the business is the principal",
367        Some(Status::Authority),
368        &[],
369    ),
370    (
371        18,
372        Area::Arap,
373        "Aging buckets: Current, 1-30, 31-60, 61-90, 90+ days past due",
374        Some(Status::Convention),
375        &[],
376    ),
377    (
378        19,
379        Area::Tax1099,
380        "1099 reporting thresholds, payment methods, and decisions",
381        Some(Status::Authority),
382        &[
383            "1099-a-dated-status-applies-by-payment-date",
384            "1099-a-members-check-through-financing-counts",
385            "1099-and-party-totals-for-a-members-payment",
386            "1099-card-payments-are-excluded",
387            "1099-money-between-own-accounts-pays-no-one",
388            "1099-money-from-a-payee-is-netted-only-as-a-refund",
389            "1099-the-threshold-is-the-years",
390            "1099-worksheet-through-as-of",
391        ],
392    ),
393    (
394        20,
395        Area::Ledger,
396        "Every transaction has supporting documents; records are kept",
397        Some(Status::Authority),
398        &[],
399    ),
400    (
401        21,
402        Area::Entry,
403        "Exact amounts in the books; rounding only on returns",
404        Some(Status::Authority),
405        &["entry-cents-add-exactly"],
406    ),
407    (
408        22,
409        Area::Asset,
410        "Expense or capitalize (book policy, mirrors tax)",
411        Some(Status::Authority),
412        &[],
413    ),
414    (
415        23,
416        Area::Ledger,
417        "Control roles: one account each, of the right kind, moved only when empty",
418        Some(Status::Convention),
419        &["arap-hand-control-posting-refused"],
420    ),
421    (
422        24,
423        Area::Asset,
424        "Asset tags: one label per asset, never reused",
425        Some(Status::Choice),
426        &[],
427    ),
428    (
429        25,
430        Area::Asset,
431        "Tax figures beside book value: the section 723 basis, the depreciable basis, the section 704(c) built-in gain",
432        Some(Status::Authority),
433        &[
434            "assets-723-basis-apart-from-depreciable-basis",
435            "assets-built-in-gain-fixed-at-contribution",
436            "assets-four-contributed-devices",
437        ],
438    ),
439    (
440        26,
441        Area::Partner,
442        "Parties: one directory, no full TIN in the books",
443        Some(Status::Authority),
444        &["p3-party-tin-last4"],
445    ),
446    (
447        27,
448        Area::Format,
449        "The journal format gate: newer is not corrupt",
450        Some(Status::Choice),
451        &[],
452    ),
453    (
454        28,
455        Area::Entry,
456        "What new commands take: four-digit years, amounts a line can carry",
457        Some(Status::Convention),
458        &[],
459    ),
460    (
461        29,
462        Area::Ledger,
463        "The books keep how they are kept",
464        Some(Status::Choice),
465        &[],
466    ),
467    (
468        30,
469        Area::Period,
470        "A report asked for without dates covers this fiscal year so far",
471        Some(Status::Authority),
472        &["default-period-fiscal-year-to-date"],
473    ),
474    (
475        31,
476        Area::Ledger,
477        "Renaming an account changes its label, nothing else",
478        Some(Status::Choice),
479        &["rename-changes-no-amount"],
480    ),
481    (
482        32,
483        Area::Ledger,
484        "Every revenue and expense account maps to a line of the return",
485        Some(Status::Authority),
486        &["tax-category-required"],
487    ),
488    (
489        33,
490        Area::Statement,
491        "A package as of a day shows the books through that day",
492        Some(Status::Convention),
493        &["as-of-package-stops-at-as-of"],
494    ),
495    (
496        34,
497        Area::Format,
498        "The QuickBooks Desktop file (IIF): plain ASCII, every entry through as_of, closes left out, classes carried",
499        Some(Status::Authority),
500        &["iif-quickbooks-journal-file"],
501    ),
502    (
503        35,
504        Area::Partner,
505        "A party's part of an entry, and a party's totals",
506        Some(Status::Convention),
507        &[
508            "p3-party-accrual-counts-once",
509            "p3-party-distribution-still-owed",
510            "p3-party-gift-card-is-a-promise",
511            "p3-party-members-equity-and-k1",
512            "p3-party-own-liability-never-pays",
513            "p3-party-reversed-card-payment",
514            "p3-party-signs-and-reversals",
515        ],
516    ),
517    (
518        36,
519        Area::Partner,
520        "The K-1 worksheet reports tax-basis capital beside book capital",
521        Some(Status::Authority),
522        &[
523            "partnership-k1-tax-basis-capital",
524            "partnership-k1-tax-basis-odd-cents",
525        ],
526    ),
527    (
528        37,
529        Area::Statement,
530        "A project is a class with its facts; its figures are its tagged lines",
531        Some(Status::Choice),
532        &[
533            "projects-invoice-and-bill-lines-count-by-project",
534            "projects-net-is-its-tagged-lines",
535            "projects-on-spec-costs-are-expenses",
536        ],
537    ),
538    (
539        38,
540        Area::Arap,
541        "A document's figures are its ledger lines that stand; a control account is its documents",
542        Some(Status::Choice),
543        &["arap-hand-control-posting-refused"],
544    ),
545    (
546        39,
547        Area::Arap,
548        "Terms: when a document is due (allocated, not yet stated)",
549        None,
550        &[],
551    ),
552    (
553        40,
554        Area::Arap,
555        "Numbers and the issued copy (allocated, not yet stated)",
556        None,
557        &[],
558    ),
559    (
560        41,
561        Area::Arap,
562        "A debt that will not be collected is written off, and a recovery is income",
563        Some(Status::Choice),
564        &["bad-debt-direct-write-off"],
565    ),
566    (
567        42,
568        Area::Arap,
569        "A settlement is exact, one party, and names its lines; the gross discount and the gross fee",
570        Some(Status::Convention),
571        &[],
572    ),
573    (
574        43,
575        Area::Entry,
576        "Drafts and evidence (allocated, not yet stated)",
577        None,
578        &[],
579    ),
580    (
581        44,
582        Area::Ledger,
583        "Evidence is read from the bytes, never trusted over them; identical files are kept once",
584        Some(Status::Convention),
585        &[],
586    ),
587    (
588        45,
589        Area::Ledger,
590        "An audit is deterministic over the books; an attestation is a claim by a named client",
591        Some(Status::Convention),
592        &[],
593    ),
594];