1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum MappingStatus {
18 Draft,
20 Reviewed,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct VatAccounts {
27 pub output: Vec<AccountId>,
29 pub input: Vec<AccountId>,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct VatSettlement {
36 #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "crate::present_text")]
38 pub debit: Option<String>,
39 #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "crate::present_text")]
41 pub credit: Option<String>,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46pub struct TaxRule {
47 pub tax: TaxCode,
49 #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "crate::present_text")]
51 pub base: Option<String>,
52 #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "crate::present_text")]
54 pub output: Option<String>,
55 #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "crate::present_text")]
57 pub input: Option<String>,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62pub struct ReportMapping {
63 pub report: String,
65 pub country: Country,
67 pub version: String,
69 pub valid_from: Date,
71 pub into: String,
73 pub status: MappingStatus,
75 #[serde(default, skip_serializing_if = "BTreeMap::is_empty", deserialize_with = "crate::non_empty_map")]
78 pub accounts: BTreeMap<AccountId, String>,
79 #[serde(default, skip_serializing_if = "BTreeMap::is_empty", deserialize_with = "crate::non_empty_map")]
81 pub credit_accounts: BTreeMap<AccountId, String>,
82 #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "crate::present")]
84 pub vat_accounts: Option<VatAccounts>,
85 #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "crate::present")]
87 pub vat_settlement: Option<VatSettlement>,
88 #[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 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 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 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 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 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
137fn 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
146fn 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; }
153 chain.push(parent);
154 }
155 chain
156}
157
158impl EventSet {
159 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 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 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 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 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 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 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 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 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 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}