1use std::collections::BTreeMap;
21use std::str::FromStr;
22
23use chrono::NaiveDate;
24use rust_decimal::Decimal;
25
26use crate::engine::RuleId;
27use crate::rules::Area;
28use crate::schema::{Event, Example, Expect, Values};
29
30#[derive(Clone, Debug, PartialEq, Eq)]
32pub enum Derived {
33 Agrees {
35 rule: RuleId,
37 },
38 Disagrees {
41 rule: RuleId,
43 why: String,
45 },
46 Skipped {
48 why: String,
50 },
51}
52
53#[derive(Clone, Debug, PartialEq, Eq)]
56pub struct Derivation {
57 pub example: String,
59 pub results: Vec<(String, Derived)>,
61}
62
63impl Derivation {
64 pub fn disagreements(&self) -> Vec<String> {
66 self.results
67 .iter()
68 .filter_map(|(what, d)| match d {
69 Derived::Disagrees { rule, why } => {
70 Some(format!("{}: {what}: {why} ({rule})", self.example))
71 }
72 _ => None,
73 })
74 .collect()
75 }
76
77 pub fn agreed(&self) -> usize {
79 self.results
80 .iter()
81 .filter(|(_, d)| matches!(d, Derived::Agrees { .. }))
82 .count()
83 }
84}
85
86#[derive(Clone, Copy, Debug, PartialEq, Eq)]
87enum Side {
88 Debit,
89 Credit,
90}
91
92#[derive(Clone, Debug)]
93struct Line {
94 account: String,
95 side: Side,
96 amount: Decimal,
97}
98
99#[derive(Clone, Debug)]
100struct Posted {
101 date: NaiveDate,
102 lines: Vec<Line>,
103 closing: bool,
104 reversed: bool,
105}
106
107struct Model {
109 types: BTreeMap<String, String>,
110 entries: Vec<Posted>,
111 event_entry: BTreeMap<usize, usize>,
113 closed_through: Vec<NaiveDate>,
114 minor_digits: u32,
115}
116
117fn sign(side: Side) -> Decimal {
118 match side {
119 Side::Debit => Decimal::ONE,
120 Side::Credit => -Decimal::ONE,
121 }
122}
123
124fn dec(s: &str) -> Option<Decimal> {
125 Decimal::from_str(s).ok()
126}
127
128fn date(s: &str) -> Option<NaiveDate> {
129 NaiveDate::parse_from_str(s, "%Y-%m-%d").ok()
130}
131
132fn rule(area: Area, n: u32) -> RuleId {
133 RuleId::new(area, n)
134}
135
136fn role_type(code: &str) -> Option<&'static str> {
139 match code {
140 "1200" => Some("asset"),
141 "2000" => Some("liability"),
142 "3999" => Some("equity"),
143 "4000" => Some("revenue"),
144 "6999" => Some("expense"),
145 _ => None,
146 }
147}
148
149impl Model {
150 fn new(ex: &Example) -> Self {
151 let mut types: BTreeMap<String, String> = ex
152 .setup
153 .accounts
154 .iter()
155 .map(|a| (a.code.clone(), a.kind.clone()))
156 .collect();
157 for code in ["1200", "2000", "3999", "4000", "6999"] {
158 types
159 .entry(code.to_string())
160 .or_insert_with(|| role_type(code).unwrap().to_string());
161 }
162 let minor_digits = match ex.setup.currency.as_str() {
164 "JPY" | "KRW" | "VND" | "CLP" | "ISK" => 0,
165 "BHD" | "KWD" | "OMR" | "JOD" => 3,
166 _ => 2,
167 };
168 Self {
169 types,
170 entries: Vec::new(),
171 event_entry: BTreeMap::new(),
172 closed_through: Vec::new(),
173 minor_digits,
174 }
175 }
176
177 fn signed(
181 &self,
182 code: &str,
183 from: Option<NaiveDate>,
184 through: NaiveDate,
185 closings: bool,
186 ) -> Decimal {
187 self.entries
188 .iter()
189 .filter(|e| {
190 e.date <= through && from.is_none_or(|f| e.date >= f) && (closings || !e.closing)
191 })
192 .flat_map(|e| e.lines.iter())
193 .filter(|l| l.account == code)
194 .map(|l| sign(l.side) * l.amount)
195 .sum()
196 }
197
198 fn is_debit_normal(&self, code: &str) -> bool {
199 matches!(
200 self.types.get(code).map(String::as_str),
201 Some("asset") | Some("expense")
202 )
203 }
204
205 fn on_normal_side(&self, code: &str, signed: Decimal) -> Decimal {
206 if self.is_debit_normal(code) {
207 signed
208 } else {
209 -signed
210 }
211 }
212
213 fn refusal_justified(&self, e: &Event, reason: &str) -> Result<RuleId, String> {
215 let on = |side: &str| -> Vec<Decimal> {
216 e.lines
217 .iter()
218 .filter(|l| l.side == side)
219 .filter_map(|l| dec(&l.amount))
220 .collect()
221 };
222 let d: Decimal = on("debit").into_iter().sum();
223 let c: Decimal = on("credit").into_iter().sum();
224 let amounts: Vec<Decimal> = e.lines.iter().filter_map(|l| dec(&l.amount)).collect();
225 match reason {
226 "unbalanced" if d != c => Ok(rule(Area::Entry, 1)),
227 "unbalanced" => Err(format!("debits {d} equal credits {c}")),
228 "too_few_lines" if e.lines.len() < 2 => Ok(rule(Area::Entry, 1)),
229 "negative_amount" if amounts.iter().any(|a| *a < Decimal::ZERO) => {
230 Ok(rule(Area::Entry, 2))
231 }
232 "zero_amount" if amounts.iter().any(|a| a.is_zero()) => Ok(rule(Area::Entry, 2)),
233 "finer_than_minor_unit"
234 if amounts
235 .iter()
236 .any(|a| a.normalize().scale() > self.minor_digits) =>
237 {
238 Ok(rule(Area::Ledger, 1))
239 }
240 "period_closed" => {
241 let day = e.date.as_deref().and_then(date).ok_or("no date")?;
242 if self.closed_through.iter().any(|end| day <= *end) {
243 Ok(rule(Area::Period, 2))
244 } else {
245 Err(format!("{day} is in no closed period"))
246 }
247 }
248 "already_reversed" => {
249 let of = e.reverses.ok_or("no reversal named")?;
250 let idx = self.event_entry.get(&of).ok_or("names no entry")?;
251 if self.entries[*idx].reversed {
252 Ok(rule(Area::Ledger, 6))
253 } else {
254 Err(format!("event {of} was not yet reversed"))
255 }
256 }
257 other => Err(format!("{other:?} is not derived by the reference model")),
258 }
259 }
260
261 fn post(&mut self, n: usize, e: &Event) -> Result<(), String> {
263 let day = e.date.as_deref().and_then(date);
264 let lines = |ls: &[crate::schema::LineSpec]| -> Option<Vec<Line>> {
265 ls.iter()
266 .map(|l| {
267 Some(Line {
268 account: l.account.clone(),
269 side: if l.side == "debit" {
270 Side::Debit
271 } else {
272 Side::Credit
273 },
274 amount: dec(&l.amount)?,
275 })
276 })
277 .collect()
278 };
279 let posted = if !e.lines.is_empty() {
280 Posted {
281 date: day.ok_or("undated")?,
282 lines: lines(&e.lines).ok_or("an unreadable amount")?,
283 closing: false,
284 reversed: false,
285 }
286 } else if let Some(of) = e.reverses {
287 let idx = *self
288 .event_entry
289 .get(&of)
290 .ok_or("reverses an event that posted nothing")?;
291 let original = self.entries[idx].clone();
292 self.entries[idx].reversed = true;
293 Posted {
294 date: day.ok_or("undated")?,
295 lines: original
296 .lines
297 .iter()
298 .map(|l| Line {
299 account: l.account.clone(),
300 side: match l.side {
301 Side::Debit => Side::Credit,
302 Side::Credit => Side::Debit,
303 },
304 amount: l.amount,
305 })
306 .collect(),
307 closing: false,
308 reversed: false,
309 }
310 } else if let Some(b) = &e.bill {
311 let total = document_total(
312 b.amount.as_deref(),
313 &b.lines.iter().map(|l| l.amount.clone()).collect::<Vec<_>>(),
314 )?;
315 if b.lines.iter().any(|l| l.project.is_some()) {
316 return Err("project tags are not modelled".into());
317 }
318 Posted {
319 date: day.ok_or("undated")?,
320 lines: vec![
321 Line {
322 account: "6999".into(),
323 side: Side::Debit,
324 amount: total,
325 },
326 Line {
327 account: "2000".into(),
328 side: Side::Credit,
329 amount: total,
330 },
331 ],
332 closing: false,
333 reversed: false,
334 }
335 } else if let Some(i) = &e.invoice {
336 let total = document_total(
337 i.amount.as_deref(),
338 &i.lines.iter().map(|l| l.amount.clone()).collect::<Vec<_>>(),
339 )?;
340 if i.lines.iter().any(|l| l.project.is_some()) {
341 return Err("project tags are not modelled".into());
342 }
343 Posted {
344 date: day.ok_or("undated")?,
345 lines: vec![
346 Line {
347 account: "1200".into(),
348 side: Side::Debit,
349 amount: total,
350 },
351 Line {
352 account: "4000".into(),
353 side: Side::Credit,
354 amount: total,
355 },
356 ],
357 closing: false,
358 reversed: false,
359 }
360 } else if let Some(c) = &e.close {
361 if c.to != "retained_earnings" {
362 return Err("a close to the members is not modelled".into());
363 }
364 let end = day.ok_or("undated")?;
365 let mut out = Vec::new();
366 let mut net = Decimal::ZERO;
367 let codes: Vec<String> = self.types.keys().cloned().collect();
368 for code in codes {
369 let ty = self.types[&code].as_str();
370 if ty != "revenue" && ty != "expense" {
371 continue;
372 }
373 let s = self.signed(&code, None, end, true);
374 if s.is_zero() {
375 continue;
376 }
377 net -= s; out.push(Line {
379 account: code,
380 side: if s > Decimal::ZERO {
381 Side::Credit
382 } else {
383 Side::Debit
384 },
385 amount: s.abs(),
386 });
387 }
388 if !net.is_zero() {
389 out.push(Line {
390 account: "3999".into(),
391 side: if net > Decimal::ZERO {
392 Side::Credit
393 } else {
394 Side::Debit
395 },
396 amount: net.abs(),
397 });
398 }
399 self.closed_through.push(end);
400 Posted {
401 date: end,
402 lines: out,
403 closing: true,
404 reversed: false,
405 }
406 } else {
407 return Err("an event the reference model does not post".into());
408 };
409 let (d, c): (Decimal, Decimal) =
411 posted
412 .lines
413 .iter()
414 .fold((Decimal::ZERO, Decimal::ZERO), |(d, c), l| match l.side {
415 Side::Debit => (d + l.amount, c),
416 Side::Credit => (d, c + l.amount),
417 });
418 if d != c {
419 return Err(format!(
420 "the example posts an unbalanced entry (debits {d}, credits {c})"
421 ));
422 }
423 self.entries.push(posted);
424 self.event_entry.insert(n, self.entries.len() - 1);
425 Ok(())
426 }
427}
428
429fn document_total(amount: Option<&str>, lines: &[String]) -> Result<Decimal, String> {
430 match amount {
431 Some(a) => dec(a).ok_or_else(|| "an unreadable amount".into()),
432 None => lines
433 .iter()
434 .map(|l| dec(l).ok_or_else(|| "an unreadable amount".to_string()))
435 .sum(),
436 }
437}
438
439pub fn derive(ex: &Example) -> Derivation {
441 let mut results = Vec::new();
442 let mut model = Model::new(ex);
443 let mut usable = true;
446 let mut why_not = String::new();
447 let unsupported_setup = !ex.setup.assets.is_empty()
448 || !ex.setup.schedules.is_empty()
449 || !ex.setup.fixed_assets.is_empty()
450 || !ex.setup.members.is_empty();
451 if unsupported_setup {
452 usable = false;
453 why_not = "the setup holds assets, schedules or members, which the reference model does not carry".into();
454 }
455 for (i, e) in ex.events.iter().enumerate() {
456 let n = i + 1;
457 if usable {
458 match &e.refused {
459 Some(reason) => match model.refusal_justified(e, reason) {
460 Ok(rule) => results.push((
461 format!("event {n} refused {reason}"),
462 Derived::Agrees { rule },
463 )),
464 Err(why) => results.push((
465 format!("event {n} refused {reason}"),
466 if why.contains("not derived") {
467 Derived::Skipped { why }
468 } else {
469 Derived::Disagrees {
470 rule: rule(Area::Entry, 1),
471 why,
472 }
473 },
474 )),
475 },
476 None => {
477 if let Err(why) = model.post(n, e) {
478 usable = false;
479 why_not = format!("event {n}: {why}");
480 }
481 }
482 }
483 }
484 for x in ex.expect.iter().filter(|x| x.after == Some(n)) {
485 results.push(expectation(&model, x, usable, &why_not));
486 }
487 }
488 for x in ex.expect.iter().filter(|x| x.after.is_none()) {
489 results.push(expectation(&model, x, usable, &why_not));
490 }
491 Derivation {
492 example: ex.id.clone(),
493 results,
494 }
495}
496
497fn expectation(model: &Model, x: &Expect, usable: bool, why_not: &str) -> (String, Derived) {
498 let what = format!(
499 "{} {}",
500 x.query,
501 x.as_of.as_deref().or(x.to.as_deref()).unwrap_or("")
502 );
503 if !usable {
504 return (
505 what,
506 Derived::Skipped {
507 why: why_not.to_string(),
508 },
509 );
510 }
511 let Some(Values::Figures(want)) = &x.values else {
512 return (
513 what,
514 Derived::Skipped {
515 why: "no figures to derive".into(),
516 },
517 );
518 };
519 let derived = match x.query.as_str() {
520 "trial_balance" => x.as_of.as_deref().and_then(date).map(|d| {
521 (
522 rule(Area::Statement, 1),
523 want.keys()
524 .map(|k| (k.clone(), model.signed(k, None, d, true)))
525 .collect::<Vec<_>>(),
526 )
527 }),
528 "balances" => x.as_of.as_deref().and_then(date).map(|d| {
529 (
530 rule(Area::Ledger, 3),
531 want.keys()
532 .map(|k| {
533 (
534 k.clone(),
535 model.on_normal_side(k, model.signed(k, None, d, true)),
536 )
537 })
538 .collect(),
539 )
540 }),
541 "profit_and_loss" => {
542 let from = x.from.as_deref().and_then(date);
543 let to = x.to.as_deref().and_then(date);
544 from.zip(to).map(|(f, t)| {
545 let mut figs = Vec::new();
546 for k in want.keys() {
547 if k == "net_income" {
548 let mut net = Decimal::ZERO;
549 for (code, ty) in &model.types {
550 if ty == "revenue" || ty == "expense" {
551 net += model
552 .on_normal_side(code, model.signed(code, Some(f), t, false))
553 * if ty == "revenue" {
554 Decimal::ONE
555 } else {
556 -Decimal::ONE
557 };
558 }
559 }
560 figs.push((k.clone(), net));
561 } else {
562 figs.push((
563 k.clone(),
564 model.on_normal_side(k, model.signed(k, Some(f), t, false)),
565 ));
566 }
567 }
568 (rule(Area::Statement, 3), figs)
569 })
570 }
571 "entry" => x.entry.and_then(|n| model.event_entry.get(&n)).map(|idx| {
572 let e = &model.entries[*idx];
573 let figs = want
574 .keys()
575 .map(|k| {
576 let (code, side) = k.split_once('.').unwrap_or((k, ""));
577 let v: Decimal = e
578 .lines
579 .iter()
580 .filter(|l| {
581 l.account == code
582 && matches!(
583 (side, l.side),
584 ("debit", Side::Debit) | ("credit", Side::Credit)
585 )
586 })
587 .map(|l| l.amount)
588 .sum();
589 (k.clone(), v)
590 })
591 .collect();
592 (rule(Area::Ledger, 4), figs)
593 }),
594 other => {
595 return (
596 what,
597 Derived::Skipped {
598 why: format!("{other} is not derived by the reference model"),
599 },
600 );
601 }
602 };
603 let Some((rule, figs)) = derived else {
604 return (
605 what,
606 Derived::Skipped {
607 why: "an undated or unresolved query".into(),
608 },
609 );
610 };
611 for (k, got) in figs {
612 match want.get(&k).and_then(|w| dec(w)) {
613 Some(w) if w == got => {}
614 Some(w) => {
615 return (
616 what,
617 Derived::Disagrees {
618 rule,
619 why: format!("{k}: the example says {w}, the rule derives {got}"),
620 },
621 );
622 }
623 None => {
624 return (
625 what,
626 Derived::Disagrees {
627 rule,
628 why: format!("{k}: no exact figure"),
629 },
630 );
631 }
632 }
633 }
634 if let (true, Some(d)) = (
636 matches!(x.query.as_str(), "trial_balance" | "balances"),
637 x.as_of.as_deref().and_then(date),
638 ) {
639 let (mut a, mut l, mut e, mut r, mut xp) = (
640 Decimal::ZERO,
641 Decimal::ZERO,
642 Decimal::ZERO,
643 Decimal::ZERO,
644 Decimal::ZERO,
645 );
646 let mut net = Decimal::ZERO;
647 for (code, ty) in &model.types {
648 let s = model.signed(code, None, d, true);
649 net += s;
650 let n = model.on_normal_side(code, s);
651 match ty.as_str() {
652 "asset" => a += n,
653 "liability" => l += n,
654 "equity" => e += n,
655 "revenue" => r += n,
656 "expense" => xp += n,
657 _ => {}
658 }
659 }
660 if !net.is_zero() {
661 return (
662 what,
663 Derived::Disagrees {
664 rule: self::rule(Area::Statement, 1),
665 why: format!("the books do not net to zero ({net})"),
666 },
667 );
668 }
669 if a != l + e + (r - xp) {
670 return (
671 what,
672 Derived::Disagrees {
673 rule: self::rule(Area::Statement, 2),
674 why: format!("A {a} != L {l} + E {e} + (R {r} - X {xp})"),
675 },
676 );
677 }
678 }
679 (what, Derived::Agrees { rule })
680}