1use 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#[derive(Clone, Debug, PartialEq, Deserialize)]
22#[serde(deny_unknown_fields)]
23pub struct Example {
24 pub id: String,
26 pub title: String,
28 pub rule: String,
31 pub status: String,
34 pub sources: Vec<String>,
36 pub tags: Vec<String>,
38 #[serde(default)]
41 pub appendix: Option<String>,
42 pub setup: Setup,
44 pub events: Vec<Event>,
46 #[serde(default)]
48 pub changes: Vec<ChangeSpec>,
49 pub expect: Vec<Expect>,
51}
52
53#[derive(Clone, Debug, PartialEq, Deserialize)]
55#[serde(deny_unknown_fields)]
56pub struct Setup {
57 pub currency: String,
59 pub fiscal_year_start: String,
61 pub basis: String,
63 pub rounding: String,
66 pub accounts: Vec<AccountSpec>,
68 #[serde(default)]
70 pub cash_accounts: Vec<String>,
71 #[serde(default)]
73 pub financing_accounts: Vec<String>,
74 #[serde(default)]
76 pub investing_accounts: Vec<String>,
77 #[serde(default)]
80 pub fixed_assets: Vec<FixedAsset>,
81 #[serde(default)]
83 pub assets: Vec<AssetSpec>,
84 #[serde(default)]
86 pub schedules: Vec<ScheduleSpec>,
87 #[serde(default)]
89 pub parties: Vec<PartySpec>,
90 #[serde(default)]
92 pub members: Vec<MemberSpec>,
93 #[serde(default)]
95 pub payees: Vec<PayeeSpec>,
96 #[serde(default)]
98 pub status_changes: Vec<StatusChangeSpec>,
99 #[serde(default)]
101 pub instruments: Vec<InstrumentSpec>,
102 #[serde(default)]
104 pub projects: Vec<ProjectSpec>,
105 #[serde(default)]
107 pub today: Option<String>,
108}
109
110#[derive(Clone, Debug, PartialEq, Deserialize)]
112#[serde(deny_unknown_fields)]
113pub struct ProjectSpec {
114 pub name: String,
116 #[serde(default)]
118 pub kind: Option<String>,
119 #[serde(default)]
121 pub status: Option<String>,
122 #[serde(default)]
124 pub party: Option<String>,
125}
126
127#[derive(Clone, Debug, PartialEq, Deserialize)]
129#[serde(deny_unknown_fields)]
130pub struct AccountSpec {
131 pub code: String,
133 pub name: String,
135 #[serde(rename = "type")]
137 pub kind: String,
138 #[serde(default)]
140 pub parent: Option<String>,
141 #[serde(default)]
143 pub header: bool,
144 #[serde(default)]
146 pub tax_category: Option<String>,
147}
148
149#[derive(Clone, Debug, PartialEq, Deserialize)]
152#[serde(deny_unknown_fields)]
153pub struct FixedAsset {
154 pub cost_account: String,
156 pub accumulated_account: String,
158 pub expense_account: String,
160}
161
162#[derive(Clone, Debug, PartialEq, Deserialize)]
164#[serde(deny_unknown_fields)]
165pub struct AssetSpec {
166 pub id: String,
168 pub description: String,
170 pub cost: String,
172 pub salvage: String,
174 pub life_months: u32,
176 pub in_service: String,
178 pub method: String,
180 pub cost_account: String,
182 pub accumulated_account: String,
184 pub expense_account: String,
186 #[serde(default)]
188 pub acquired_by: Option<usize>,
189 #[serde(default)]
192 pub basis: Option<BasisSpec>,
193}
194
195#[derive(Clone, Debug, PartialEq, Deserialize)]
197#[serde(deny_unknown_fields)]
198pub struct BasisSpec {
199 pub rule: String,
201 #[serde(default)]
203 pub contributor_adjusted_basis: Option<String>,
204 #[serde(default)]
206 pub fmv_at_contribution: Option<String>,
207 #[serde(default)]
209 pub sec704c_method: Option<String>,
210}
211
212#[derive(Clone, Debug, PartialEq, Deserialize)]
215#[serde(deny_unknown_fields)]
216pub struct ScheduleSpec {
217 pub id: String,
219 pub kind: String,
221 pub description: String,
223 pub total: String,
225 pub start: String,
227 pub months: u32,
229 pub convention: String,
231 pub balance_account: String,
233 pub pl_account: String,
235}
236
237#[derive(Clone, Debug, PartialEq, Deserialize)]
239#[serde(deny_unknown_fields)]
240pub struct MemberSpec {
241 pub name: String,
244 pub capital_account: String,
246 #[serde(default)]
249 pub share: Option<String>,
250 #[serde(default)]
252 pub distributions_account: Option<String>,
253}
254
255#[derive(Clone, Debug, PartialEq, Deserialize)]
257#[serde(deny_unknown_fields)]
258pub struct PayeeSpec {
259 pub name: String,
261 pub form: String,
263}
264
265#[derive(Clone, Debug, PartialEq, Deserialize)]
267#[serde(deny_unknown_fields)]
268pub struct PartySpec {
269 pub name: String,
271 pub roles: Vec<String>,
274 #[serde(default)]
277 pub creditor_of: Vec<String>,
278 #[serde(default)]
280 pub tin_last4: Option<String>,
281 #[serde(default)]
283 pub w9_tin_last4: Option<String>,
284 #[serde(default)]
287 pub tin_purged: bool,
288}
289
290#[derive(Clone, Debug, PartialEq, Deserialize)]
292#[serde(deny_unknown_fields)]
293pub struct StatusChangeSpec {
294 pub payee: String,
296 pub effective: String,
298 pub decision: String,
301 #[serde(default)]
303 pub reason_kind: Option<String>,
304 #[serde(default)]
306 pub reason: Option<String>,
307}
308
309#[derive(Clone, Debug, PartialEq, Deserialize)]
311#[serde(deny_unknown_fields)]
312pub struct InstrumentSpec {
313 pub label: String,
315 pub kind: String,
318 pub account: String,
320 #[serde(default)]
322 pub last4: Option<String>,
323 #[serde(default)]
325 pub owner: Option<String>,
326}
327
328#[derive(Clone, Debug, PartialEq, Deserialize)]
330#[serde(deny_unknown_fields)]
331pub struct ShareSpec {
332 pub party: String,
334 #[serde(default)]
336 pub amount: Option<String>,
337 #[serde(default)]
339 pub percent: Option<String>,
340}
341
342#[derive(Clone, Debug, PartialEq, Deserialize)]
345#[serde(deny_unknown_fields)]
346pub struct Event {
347 #[serde(default)]
350 pub date: Option<String>,
351 #[serde(default)]
354 pub description: Option<String>,
355 #[serde(default)]
357 pub lines: Vec<LineSpec>,
358 #[serde(default)]
360 pub reverses: Option<usize>,
361 #[serde(default)]
363 pub close: Option<CloseSpec>,
364 #[serde(default)]
366 pub bill: Option<BillSpec>,
367 #[serde(default)]
369 pub invoice: Option<InvoiceSpec>,
370 #[serde(default)]
372 pub write_off: Option<WriteOffSpec>,
373 #[serde(default)]
375 pub run_depreciation: Option<RunSpec>,
376 #[serde(default)]
378 pub dispose_asset: Option<DisposeSpec>,
379 #[serde(default)]
381 pub run_schedules: Option<RunSchedulesSpec>,
382 #[serde(default)]
384 pub register_schedule: Option<ScheduleSpec>,
385 #[serde(default)]
388 pub refused: Option<String>,
389 #[serde(default)]
391 pub payee: Option<String>,
392 #[serde(default)]
395 pub parties: Vec<ShareSpec>,
396 #[serde(default)]
398 pub instrument: Option<String>,
399 #[serde(default)]
403 pub older: bool,
404}
405
406impl Event {
407 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
441pub enum EventKind {
442 Lines,
444 Reverses,
446 Close,
448 Bill,
450 Invoice,
452 WriteOff,
454 RunDepreciation,
456 DisposeAsset,
458 RunSchedules,
460 RegisterSchedule,
462}
463
464impl EventKind {
465 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 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 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 pub fn may_refuse(self) -> bool {
515 !matches!(
516 self,
517 EventKind::Close | EventKind::Bill | EventKind::Invoice
518 )
519 }
520
521 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#[derive(Clone, Debug, PartialEq, Deserialize)]
538#[serde(deny_unknown_fields)]
539pub struct RunSpec {
540 pub asset: String,
542 pub period: String,
544}
545
546#[derive(Clone, Debug, PartialEq, Deserialize)]
548#[serde(deny_unknown_fields)]
549pub struct DisposeSpec {
550 pub asset: String,
552 pub proceeds: String,
554 pub proceeds_account: String,
556 pub gain_loss_account: String,
558}
559
560#[derive(Clone, Debug, PartialEq, Deserialize)]
562#[serde(deny_unknown_fields)]
563pub struct RunSchedulesSpec {
564 pub through: String,
566}
567
568#[derive(Clone, Debug, PartialEq, Deserialize)]
570#[serde(deny_unknown_fields)]
571pub struct BillSpec {
572 pub vendor: String,
574 #[serde(default)]
576 pub amount: Option<String>,
577 #[serde(default)]
579 pub lines: Vec<DocumentLineSpec>,
580}
581
582#[derive(Clone, Debug, PartialEq, Deserialize)]
584#[serde(deny_unknown_fields)]
585pub struct InvoiceSpec {
586 pub customer: String,
588 #[serde(default)]
590 pub amount: Option<String>,
591 #[serde(default)]
593 pub lines: Vec<DocumentLineSpec>,
594}
595
596#[derive(Clone, Debug, PartialEq, Deserialize)]
599#[serde(deny_unknown_fields)]
600pub struct WriteOffSpec {
601 pub invoice: usize,
604 #[serde(default)]
607 pub amount: Option<String>,
608 pub reason: String,
611}
612
613#[derive(Clone, Debug, PartialEq, Deserialize)]
615#[serde(deny_unknown_fields)]
616pub struct DocumentLineSpec {
617 pub amount: String,
619 #[serde(default)]
621 pub description: Option<String>,
622 #[serde(default)]
624 pub project: Option<String>,
625}
626
627#[derive(Clone, Debug, PartialEq, Deserialize)]
631#[serde(deny_unknown_fields)]
632pub struct ChangeSpec {
633 pub after: usize,
635 #[serde(default)]
637 pub rename: Option<RenameSpec>,
638 #[serde(default)]
641 pub require_tax_category: Option<String>,
642 #[serde(default)]
644 pub correct_asset: Option<CorrectSpec>,
645}
646
647#[derive(Clone, Debug, PartialEq, Deserialize)]
650#[serde(deny_unknown_fields)]
651pub struct CorrectSpec {
652 pub asset: String,
654 #[serde(default)]
656 pub cost: Option<String>,
657 #[serde(default)]
659 pub salvage: Option<String>,
660 #[serde(default)]
662 pub life_months: Option<u32>,
663 #[serde(default)]
665 pub in_service: Option<String>,
666 pub reason: String,
668}
669
670#[derive(Clone, Debug, PartialEq, Deserialize)]
672#[serde(deny_unknown_fields)]
673pub struct RenameSpec {
674 pub code: String,
676 pub name: String,
678}
679
680#[derive(Clone, Debug, PartialEq, Deserialize)]
682#[serde(deny_unknown_fields)]
683pub struct LineSpec {
684 pub account: String,
686 pub side: String,
688 pub amount: String,
690 #[serde(default)]
692 pub project: Option<String>,
693}
694
695#[derive(Clone, Debug, PartialEq, Deserialize)]
698#[serde(deny_unknown_fields)]
699pub struct CloseSpec {
700 pub start: String,
702 pub to: String,
704 #[serde(default)]
706 pub rounding: Option<String>,
707}
708
709#[derive(Clone, Debug, PartialEq, Deserialize)]
712#[serde(deny_unknown_fields)]
713pub struct Expect {
714 pub query: String,
716 #[serde(default)]
718 pub from: Option<String>,
719 #[serde(default)]
721 pub to: Option<String>,
722 #[serde(default)]
724 pub as_of: Option<String>,
725 #[serde(default)]
727 pub entry: Option<usize>,
728 #[serde(default)]
730 pub event: Option<usize>,
731 #[serde(default)]
734 pub asset: Option<String>,
735 #[serde(default)]
737 pub schedule: Option<String>,
738 #[serde(default)]
741 pub party: Option<String>,
742 #[serde(default)]
744 pub project: Option<String>,
745 #[serde(default)]
747 pub basis: Option<String>,
748 #[serde(default)]
750 pub payee: Option<String>,
751 #[serde(default)]
754 pub after: Option<usize>,
755 #[serde(default)]
757 pub code: Option<String>,
758 #[serde(default)]
760 pub name: Option<String>,
761 #[serde(default)]
764 pub category: Option<String>,
765 #[serde(default)]
767 pub account: Option<AccountSpec>,
768 #[serde(default)]
770 pub accepted: Option<bool>,
771 #[serde(default)]
774 pub refused: Option<String>,
775 #[serde(default)]
777 pub values: Option<Values>,
778}
779
780#[derive(Clone, Debug, PartialEq, Deserialize)]
783#[serde(untagged)]
784pub enum Values {
785 Figures(BTreeMap<String, String>),
787 Lines(Vec<String>),
789 Rows(Vec<Vec<String>>),
791}
792
793impl Values {
794 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 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 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 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#[derive(Clone, Debug, PartialEq)]
846pub struct Query {
847 pub name: String,
849 pub from: Option<String>,
851 pub to: Option<String>,
853 pub as_of: Option<String>,
855 pub entry: Option<usize>,
857 pub event: Option<usize>,
859 pub asset: Option<String>,
861 pub schedule: Option<String>,
863 pub party: Option<String>,
865 pub project: Option<String>,
867 pub basis: Option<String>,
869 pub payee: Option<String>,
871 pub after: Option<usize>,
873 pub code: Option<String>,
875 pub new_name: Option<String>,
877 pub category: Option<String>,
879 pub account: Option<AccountSpec>,
881}
882
883pub 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
921pub const COMMANDS: &[&str] = &["rename_account", "add_account", "set_tax_category"];
924
925pub const FORMAT_QUERIES: &[&str] = &["iif_lines", "iif_changes"];
927
928pub 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
946pub 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 pub fn is_command(&self) -> bool {
972 COMMANDS.contains(&self.name.as_str())
973 }
974
975 pub fn is_format(&self) -> bool {
977 FORMAT_QUERIES.contains(&self.name.as_str())
978 }
979
980 pub fn is_complete(&self) -> bool {
982 COMPLETE_QUERIES.contains(&self.name.as_str())
983 }
984}
985
986pub 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#[derive(Clone, Debug, PartialEq, Eq)]
1011pub struct SchemaError {
1012 pub id: String,
1014 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 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 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 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 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
1760pub 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
1774pub 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
1787pub fn is_date(s: &str) -> bool {
1789 s.len() == 10 && NaiveDate::parse_from_str(s, "%Y-%m-%d").is_ok()
1790}
1791
1792pub fn is_month(s: &str) -> bool {
1794 s.len() == 7 && NaiveDate::parse_from_str(&format!("{s}-01"), "%Y-%m-%d").is_ok()
1795}
1796
1797pub 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
1880fn 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}