1use std::sync::LazyLock;
12
13use crate::engine::RuleId;
14
15#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
17pub enum Area {
18 Entry,
20 Ledger,
22 Statement,
24 Period,
26 Partner,
28 Asset,
30 Prepaid,
32 Arap,
34 Revenue,
36 Tax1099,
38 Format,
40}
41
42impl Area {
43 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 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 pub fn parse(key: &str) -> Option<Self> {
77 Self::ALL.into_iter().find(|a| a.key() == key)
78 }
79}
80
81#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
86pub enum Status {
87 Authority,
89 Convention,
91 Choice,
94}
95
96impl Status {
97 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 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#[derive(Clone, Debug, PartialEq, Eq)]
116pub struct Rule {
117 pub b: u8,
119 pub id: RuleId,
121 pub area: Area,
123 pub title: &'static str,
125 pub status: Option<Status>,
128 pub examples: &'static [&'static str],
130}
131
132impl Rule {
133 pub fn has_examples(&self) -> bool {
135 !self.examples.is_empty()
136 }
137}
138
139pub fn rules() -> &'static [Rule] {
141 static RULES: LazyLock<Vec<Rule>> = LazyLock::new(build);
142 &RULES
143}
144
145pub fn rule(b: u8) -> Option<&'static Rule> {
147 rules().iter().find(|r| r.b == b)
148}
149
150pub fn rule_for_id(id: &RuleId) -> Option<&'static Rule> {
152 rules().iter().find(|r| &r.id == id)
153}
154
155pub 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
180pub fn covered_rules() -> Vec<&'static Rule> {
184 rules().iter().filter(|r| r.has_examples()).collect()
185}
186
187fn build() -> Vec<Rule> {
188 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];