Skip to main content

librotenado/
report.rs

1// SPDX-License-Identifier: MIT
2//! Report mappings (spec part 0009): how the harmonised accounts and the tax codes of a set of books fall into the
3//! positions of a national report – a balance sheet, a VAT return – in one version of that report.
4
5use std::collections::BTreeMap;
6
7use serde::{Deserialize, Serialize};
8
9use crate::{
10    AccountId, AccountKind, BalanceError, Chart, Country, Date, EventSet, Object, PartyId, Payload, TaxCode,
11    TaxDirection, TaxTable,
12};
13
14/// Whether a mapping has been reviewed by tax advisers of its country.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum MappingStatus {
18    /// Not yet reviewed.
19    Draft,
20    /// Reviewed.
21    Reviewed,
22}
23
24/// The accounts that hold VAT, for a report computed from tax codes.
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct VatAccounts {
27    /// Accounts of VAT owed: collected, and owed by the recipient (reverse charge). Their refinements count, too.
28    pub output: Vec<AccountId>,
29    /// Accounts of deductible VAT. Their refinements count, too.
30    pub input: Vec<AccountId>,
31}
32
33/// The fields for settling VAT with the tax office: postings on VAT accounts without a tax code.
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct VatSettlement {
36    /// The field for a debit: VAT paid to the tax office.
37    #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "crate::present_text")]
38    pub debit: Option<String>,
39    /// The field for a credit: VAT refunded by the tax office.
40    #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "crate::present_text")]
41    pub credit: Option<String>,
42}
43
44/// Which fields of a report a tax code fills.
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46pub struct TaxRule {
47    /// The tax code.
48    pub tax: TaxCode,
49    /// The field for the net amounts: postings with the code on accounts that hold no VAT.
50    #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "crate::present_text")]
51    pub base: Option<String>,
52    /// The field for VAT owed: postings with the code on output VAT accounts.
53    #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "crate::present_text")]
54    pub output: Option<String>,
55    /// The field for deductible VAT: postings with the code on input VAT accounts.
56    #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "crate::present_text")]
57    pub input: Option<String>,
58}
59
60/// How the harmonised chart and the tax codes fall into one version of a national report.
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62pub struct ReportMapping {
63    /// The report, e.g. `de-ustva`, `de-hgb-266`.
64    pub report: String,
65    /// The country.
66    pub country: Country,
67    /// The version of the report, e.g. a year or a taxonomy version.
68    pub version: String,
69    /// The first day of the periods it applies to.
70    pub valid_from: Date,
71    /// The harmonised chart it maps, as `name/version`.
72    pub into: String,
73    /// Whether tax advisers have reviewed it.
74    pub status: MappingStatus,
75    /// Account → position. A mapping holds for the account and every account that refines it, unless a refinement
76    /// has its own; left out when the report is computed from tax codes alone.
77    #[serde(default, skip_serializing_if = "BTreeMap::is_empty", deserialize_with = "crate::non_empty_map")]
78    pub accounts: BTreeMap<AccountId, String>,
79    /// Account → position for a credit balance, where it differs (a bank account in overdraft).
80    #[serde(default, skip_serializing_if = "BTreeMap::is_empty", deserialize_with = "crate::non_empty_map")]
81    pub credit_accounts: BTreeMap<AccountId, String>,
82    /// The accounts that hold VAT; with `taxes`.
83    #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "crate::present")]
84    pub vat_accounts: Option<VatAccounts>,
85    /// The fields for postings on VAT accounts without a tax code: payments to and refunds from the tax office.
86    #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "crate::present")]
87    pub vat_settlement: Option<VatSettlement>,
88    /// The fields each tax code fills.
89    #[serde(default, skip_serializing_if = "Vec::is_empty", deserialize_with = "crate::non_empty")]
90    pub taxes: Vec<TaxRule>,
91}
92
93impl Object for ReportMapping {
94    const TYPE: &'static str = "report_mapping";
95}
96
97impl ReportMapping {
98    /// The German VAT return of 2026 (Umsatzsteuer-Voranmeldung), from tax codes (`spec/data/reports/`).
99    pub fn de_ustva_2026() -> Self {
100        crate::from_root(include_bytes!("../../../spec/data/reports/de-ustva-2026.json"))
101            .expect("the published mapping is valid")
102    }
103
104    /// The German balance sheet of 2026 (HGB § 266).
105    pub fn de_hgb_266_2026() -> Self {
106        crate::from_root(include_bytes!("../../../spec/data/reports/de-hgb-266-2026.json"))
107            .expect("the published mapping is valid")
108    }
109
110    /// The German profit and loss account of 2026, by nature of expense (HGB § 275 Abs. 2).
111    pub fn de_hgb_275_gkv_2026() -> Self {
112        crate::from_root(include_bytes!("../../../spec/data/reports/de-hgb-275-gkv-2026.json"))
113            .expect("the published mapping is valid")
114    }
115
116    /// The German income statement on a cash basis of 2025 (Anlage EÜR, § 4 Abs. 3 EStG).
117    pub fn de_euer_2025() -> Self {
118        crate::from_root(include_bytes!("../../../spec/data/reports/de-euer-2025.json"))
119            .expect("the published mapping is valid")
120    }
121
122    /// The position of an account: its own mapping or the nearest of the accounts it refines; for a credit balance
123    /// the credit mapping, where there is one.
124    pub fn position(
125        &self,
126        account: &AccountId,
127        parents: &BTreeMap<AccountId, AccountId>,
128        credit: bool,
129    ) -> Option<&str> {
130        if credit && let Some(position) = nearest(&self.credit_accounts, account, parents) {
131            return Some(position);
132        }
133        nearest(&self.accounts, account, parents)
134    }
135}
136
137/// The mapping of an account or of the nearest account it refines.
138fn nearest<'m>(
139    map: &'m BTreeMap<AccountId, String>,
140    account: &AccountId,
141    parents: &BTreeMap<AccountId, AccountId>,
142) -> Option<&'m str> {
143    ancestry(account, parents).into_iter().find_map(|id| map.get(id)).map(String::as_str)
144}
145
146/// An account and the accounts it refines, nearest first.
147fn ancestry<'a>(account: &'a AccountId, parents: &'a BTreeMap<AccountId, AccountId>) -> Vec<&'a AccountId> {
148    let mut chain = vec![account];
149    while let Some(parent) = parents.get(chain[chain.len() - 1]) {
150        if chain.len() > parents.len() {
151            break; // a cycle; the review of accounts reports it
152        }
153        chain.push(parent);
154    }
155    chain
156}
157
158impl EventSet {
159    /// The fields of a report over a period – a VAT return, a profit and loss account, an income statement on a
160    /// cash basis – for `owner`, from the bookings dated from `from` to `to` inclusive (spec part 0009). Per posting:
161    ///
162    /// - with a tax code that has a rule: on VAT accounts its `output` or `input` field, elsewhere its `base` field;
163    /// - without a tax code on a VAT account: the `vat_settlement` field of its side – a payment to or a refund from
164    ///   the tax office;
165    /// - otherwise, and where the rule has no `base`: the position of its account.
166    ///
167    /// Amounts are in base units and positive as the report expects them: sales and VAT owed as credit, purchases
168    /// and deductible VAT as debit; positions of accounts on the normal side of their kind, or – where the mapping
169    /// distinguishes the sides – on the side mapped.
170    pub fn period_report(
171        &self,
172        mapping: &ReportMapping,
173        taxes: &TaxTable,
174        chart: &Chart,
175        owner: &PartyId,
176        from: Date,
177        to: Date,
178    ) -> Result<BTreeMap<String, i128>, BalanceError> {
179        let parents = self.account_parents(chart);
180        let kinds = self.account_kinds(chart);
181        let none = VatAccounts { output: vec![], input: vec![] };
182        let vat = mapping.vat_accounts.as_ref().unwrap_or(&none);
183        let within = |account: &AccountId, roots: &[AccountId]| {
184            ancestry(account, &parents).into_iter().any(|id| roots.contains(id))
185        };
186        let mut fields: BTreeMap<String, i128> = BTreeMap::new();
187        let mut add = |field: &str, amount: i128| -> Result<(), BalanceError> {
188            let sum = fields.entry(field.to_owned()).or_default();
189            *sum = sum.checked_add(amount).ok_or(BalanceError::Overflow)?;
190            Ok(())
191        };
192        for (_, event) in self.ordered() {
193            let booking = match &event.payload {
194                Payload::BookingRecorded { booking } | Payload::BookingReversed { booking, .. } => booking,
195                _ => continue,
196            };
197            if booking.date < from || booking.date > to {
198                continue;
199            }
200            for posting in booking.postings.iter().filter(|p| p.owner == *owner) {
201                let units = posting.amount.base_units();
202                let on_output = within(&posting.account, &vat.output);
203                let on_input = within(&posting.account, &vat.input);
204                let rule = posting.tax.as_ref().and_then(|code| mapping.taxes.iter().find(|rule| rule.tax == *code));
205                if let Some(rule) = rule {
206                    let sales = taxes.get(&rule.tax).is_some_and(|d| d.direction == TaxDirection::Output);
207                    let (field, amount) = if on_output {
208                        (rule.output.as_ref(), -units)
209                    } else if on_input {
210                        (rule.input.as_ref(), units)
211                    } else {
212                        (rule.base.as_ref(), if sales { -units } else { units })
213                    };
214                    if let Some(field) = field {
215                        add(field, amount)?;
216                    }
217                    if on_output || on_input || rule.base.is_some() {
218                        continue;
219                    }
220                } else if on_output || on_input {
221                    if let Some(settlement) = &mapping.vat_settlement {
222                        let field = if units < 0 { &settlement.credit } else { &settlement.debit };
223                        if let Some(field) = field {
224                            add(field, units.abs())?;
225                        }
226                    }
227                    continue;
228                }
229                let credit = units < 0;
230                let sided =
231                    ancestry(&posting.account, &parents).into_iter().any(|id| mapping.credit_accounts.contains_key(id));
232                if let Some(position) = mapping.position(&posting.account, &parents, credit) {
233                    let debit_positive = if sided {
234                        !credit
235                    } else {
236                        kinds.get(&posting.account).is_none_or(|kind| kind.debit_normal() || *kind == AccountKind::Memo)
237                    };
238                    add(position, if debit_positive { units } else { -units })?;
239                }
240            }
241        }
242        Ok(fields)
243    }
244
245    /// The kind of every account: of `chart`, and opened in the books.
246    pub fn account_kinds(&self, chart: &Chart) -> BTreeMap<AccountId, AccountKind> {
247        let mut kinds: BTreeMap<AccountId, AccountKind> =
248            chart.accounts.iter().map(|account| (account.id.clone(), account.kind)).collect();
249        for (_, event) in self.ordered() {
250            if let Payload::AccountOpened { account } = &event.payload {
251                kinds.entry(account.id.clone()).or_insert(account.kind);
252            }
253        }
254        kinds
255    }
256
257    /// The positions of a report computed from accounts – a balance sheet, a profit and loss account – for `owner`
258    /// on `until`: per position, the sum of the balances of the accounts that fall into it, in base units, debit
259    /// positive. Accounts without a position are left out.
260    pub fn account_report(
261        &self,
262        mapping: &ReportMapping,
263        chart: &Chart,
264        owner: &PartyId,
265        until: Option<Date>,
266    ) -> Result<BTreeMap<String, i128>, BalanceError> {
267        let parents = self.account_parents(chart);
268        let mut positions: BTreeMap<String, i128> = BTreeMap::new();
269        for (party, account, _, units) in self.balances(until)?.iter() {
270            if party != owner {
271                continue;
272            }
273            if let Some(position) = mapping.position(account, &parents, units < 0) {
274                let sum = positions.entry(position.to_owned()).or_default();
275                *sum = sum.checked_add(units).ok_or(BalanceError::Overflow)?;
276            }
277        }
278        Ok(positions)
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use crate::{Booking, ChartTranslation, Clock, Event, Money, Posting, party::tests::party};
286
287    /// A posting of `units` cents for owner 1 on the PCG account `number`, with an optional tax code.
288    fn posting(number: &str, units: i128, tax: Option<&str>) -> Posting {
289        Posting {
290            owner: party(1),
291            account: ChartTranslation::fr_pcg().accounts[number].clone(),
292            amount: Money::new(units, "EUR".parse().unwrap()).unwrap(),
293            note: None,
294            tax: tax.map(|t| t.parse().unwrap()),
295        }
296    }
297
298    fn books(bookings: Vec<Vec<Posting>>) -> EventSet {
299        let mut set = EventSet::new();
300        let mut clock = Clock::new("a".parse().unwrap());
301        for (ms, postings) in bookings.into_iter().enumerate() {
302            let booking =
303                Booking { date: "2026-03-15".parse().unwrap(), text: "test".into(), postings, documents: vec![] };
304            booking.validate().unwrap();
305            let (seq, prev) = set.chain_head(&"a".parse().unwrap());
306            set.insert(Event { time: clock.now(ms as u64), seq, prev, payload: Payload::BookingRecorded { booking } });
307        }
308        set
309    }
310
311    #[test]
312    fn a_vat_return_from_tax_codes() {
313        let set = books(vec![
314            // A sale: 1,000 + 190 VAT.
315            vec![
316                posting("411", 119_000, None),
317                posting("706", -100_000, Some("de-out-19")),
318                posting("44571", -19_000, Some("de-out-19")),
319            ],
320            // A purchase: 200 + 38 VAT.
321            vec![
322                posting("6064", 20_000, Some("de-in-19")),
323                posting("44566", 3_800, Some("de-in-19")),
324                posting("401", -23_800, None),
325            ],
326            // A service from another EU country, reverse charge: 500, VAT owed and deducted.
327            vec![
328                posting("6226", 50_000, Some("de-in-13b-eu-service")),
329                posting("4452", -9_500, Some("de-in-13b-eu-service")),
330                posting("44566", 9_500, Some("de-in-13b-eu-service")),
331                posting("401", -50_000, None),
332            ],
333        ]);
334        let report = set
335            .period_report(
336                &ReportMapping::de_ustva_2026(),
337                &TaxTable::de(),
338                &Chart::v0_1(),
339                &party(1),
340                "2026-03-01".parse().unwrap(),
341                "2026-03-31".parse().unwrap(),
342            )
343            .unwrap();
344        let field = |kz: &str| report.get(kz).copied().unwrap_or(0);
345        assert_eq!(field("81"), 100_000, "net sales at 19 %");
346        assert_eq!(field("66"), 3_800, "deductible VAT from invoices");
347        assert_eq!((field("46"), field("47"), field("67")), (50_000, 9_500, 9_500), "reverse charge");
348        assert_eq!(report.len(), 5, "81, 66, 46, 47, 67");
349    }
350
351    #[test]
352    fn every_account_of_the_chart_has_a_german_position() {
353        use crate::AccountKind::*;
354        let chart = Chart::v0_1();
355        let parents = EventSet::new().account_parents(&chart);
356        let (bs, gkv) = (ReportMapping::de_hgb_266_2026(), ReportMapping::de_hgb_275_gkv_2026());
357        let refined: std::collections::BTreeSet<&AccountId> = parents.values().collect();
358        for account in chart.accounts.iter().filter(|a| !refined.contains(&a.id)) {
359            let mapping = match account.kind {
360                Asset | Liability | Equity => &bs,
361                Income | Expense => &gkv,
362                Memo => continue,
363            };
364            assert!(mapping.position(&account.id, &parents, false).is_some(), "{} has no position", account.id);
365        }
366        let pcg = ChartTranslation::fr_pcg();
367        let position =
368            |m: &ReportMapping, n: &str, credit| m.position(&pcg.accounts[n], &parents, credit).map(str::to_owned);
369        assert_eq!(position(&bs, "5121", false), Some("aktiv.B.IV".into()));
370        assert_eq!(position(&bs, "5121", true), Some("passiv.C.2".into()));
371        assert_eq!(position(&bs, "44566", false), Some("aktiv.B.II.4".into()));
372        assert_eq!(position(&gkv, "6411", false), Some("gkv.6.a".into()));
373        assert_eq!(position(&gkv, "706", false), Some("gkv.1".into()));
374    }
375
376    #[test]
377    fn an_income_statement_on_a_cash_basis() {
378        let names = crate::Names::v0_1("en").unwrap();
379        let grown =
380            |name: &str| names.names.iter().find(|(_, n)| n.as_str() == name).map(|(id, _)| id.clone()).unwrap();
381        let on = |id: String, units: i128, tax: Option<&str>| Posting {
382            account: id.parse().unwrap(),
383            ..posting("512", units, tax)
384        };
385        let set = books(vec![
386            // A sale at 19 %, received in the bank.
387            vec![
388                posting("5121", 119_000, None),
389                posting("706", -100_000, Some("de-out-19")),
390                posting("44571", -19_000, Some("de-out-19")),
391            ],
392            // A phone bill, paid.
393            vec![
394                on(grown("Telecommunications"), 5_000, Some("de-in-19")),
395                posting("44566", 950, Some("de-in-19")),
396                posting("5121", -5_950, None),
397            ],
398            // The VAT paid to the tax office, and a withdrawal by the proprietor.
399            vec![posting("44551", 18_050, None), posting("5121", -18_050, None)],
400            vec![posting("108", 30_000, None), posting("5121", -30_000, None)],
401        ]);
402        let report = set
403            .period_report(
404                &ReportMapping::de_euer_2025(),
405                &TaxTable::de(),
406                &Chart::v0_1(),
407                &party(1),
408                "2026-01-01".parse().unwrap(),
409                "2026-12-31".parse().unwrap(),
410            )
411            .unwrap();
412        let field = |f: &str| report.get(f).copied().unwrap_or(0);
413        assert_eq!((field("1.112"), field("1.140")), (100_000, 19_000), "sales and VAT received");
414        assert_eq!((field("2.280"), field("2.185")), (5_000, 950), "telecommunications and input VAT paid");
415        assert_eq!(field("2.186"), 18_050, "VAT paid to the tax office");
416        assert_eq!(field("5.122"), 30_000, "withdrawal");
417        assert_eq!(report.len(), 6, "the bank account is no line of the EÜR: {report:?}");
418    }
419
420    #[test]
421    fn every_expense_has_a_line_of_the_euer() {
422        let chart = Chart::v0_1();
423        let parents = EventSet::new().account_parents(&chart);
424        let refined: std::collections::BTreeSet<&AccountId> = parents.values().collect();
425        let euer = ReportMapping::de_euer_2025();
426        for account in chart.accounts.iter().filter(|a| a.kind == AccountKind::Expense && !refined.contains(&a.id)) {
427            assert!(euer.position(&account.id, &parents, false).is_some(), "{} has no line", account.id);
428        }
429    }
430
431    #[test]
432    fn positions_follow_the_nearest_mapping_and_the_credit_side() {
433        let pcg = ChartTranslation::fr_pcg();
434        let mut accounts = BTreeMap::new();
435        accounts.insert(pcg.accounts["51"].clone(), "aktiv.B.IV".to_owned());
436        accounts.insert(pcg.accounts["41"].clone(), "aktiv.B.II.1".to_owned());
437        let mut credit_accounts = BTreeMap::new();
438        credit_accounts.insert(pcg.accounts["512"].clone(), "passiv.C.2".to_owned());
439        let mapping = ReportMapping {
440            report: "test".into(),
441            country: "DE".parse().unwrap(),
442            version: "1".into(),
443            valid_from: "2026-01-01".parse().unwrap(),
444            into: "librotenado/0.1".into(),
445            status: MappingStatus::Draft,
446            accounts,
447            credit_accounts,
448            vat_accounts: None,
449            vat_settlement: None,
450            taxes: vec![],
451        };
452        let set = books(vec![vec![posting("4111", 5_000, None), posting("5121", -5_000, None)]]);
453        let report = set.account_report(&mapping, &Chart::v0_1(), &party(1), None).unwrap();
454        assert_eq!(report.get("aktiv.B.II.1"), Some(&5_000), "4111 refines 41");
455        assert_eq!(report.get("passiv.C.2"), Some(&-5_000), "an overdrawn bank account is a liability");
456    }
457}