@fin.cx/opendata 4.6.0 → 4.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/.smartconfig.json +6 -2
  2. package/dist_ts/00_commitinfo_data.js +3 -3
  3. package/dist_ts/financialstatements/amounts.d.ts +58 -0
  4. package/dist_ts/financialstatements/amounts.js +130 -0
  5. package/dist_ts/financialstatements/classes.financialstatementerror.d.ts +24 -0
  6. package/dist_ts/financialstatements/classes.financialstatementerror.js +10 -0
  7. package/dist_ts/financialstatements/constants.d.ts +16 -0
  8. package/dist_ts/financialstatements/constants.js +20 -0
  9. package/dist_ts/financialstatements/dates.d.ts +16 -0
  10. package/dist_ts/financialstatements/dates.js +72 -0
  11. package/dist_ts/financialstatements/html.d.ts +21 -0
  12. package/dist_ts/financialstatements/html.js +92 -0
  13. package/dist_ts/financialstatements/index.d.ts +6 -0
  14. package/dist_ts/financialstatements/index.js +11 -0
  15. package/dist_ts/financialstatements/interfaces.d.ts +258 -0
  16. package/dist_ts/financialstatements/interfaces.js +2 -0
  17. package/dist_ts/financialstatements/layout.d.ts +20 -0
  18. package/dist_ts/financialstatements/layout.html.d.ts +8 -0
  19. package/dist_ts/financialstatements/layout.html.js +125 -0
  20. package/dist_ts/financialstatements/layout.js +2 -0
  21. package/dist_ts/financialstatements/layout.pdf.d.ts +6 -0
  22. package/dist_ts/financialstatements/layout.pdf.js +229 -0
  23. package/dist_ts/financialstatements/mediatypes.d.ts +3 -0
  24. package/dist_ts/financialstatements/mediatypes.js +11 -0
  25. package/dist_ts/financialstatements/outline.d.ts +22 -0
  26. package/dist_ts/financialstatements/outline.js +26 -0
  27. package/dist_ts/financialstatements/parse.esef.d.ts +13 -0
  28. package/dist_ts/financialstatements/parse.esef.js +349 -0
  29. package/dist_ts/financialstatements/parse.hgb.d.ts +9 -0
  30. package/dist_ts/financialstatements/parse.hgb.js +1014 -0
  31. package/dist_ts/financialstatements/parse.statement.d.ts +8 -0
  32. package/dist_ts/financialstatements/parse.statement.js +25 -0
  33. package/dist_ts/financialstatements/plugins.d.ts +10 -0
  34. package/dist_ts/financialstatements/plugins.js +15 -0
  35. package/dist_ts/financialstatements/read.d.ts +10 -0
  36. package/dist_ts/financialstatements/read.js +113 -0
  37. package/dist_ts/handelsregister/classes.handelsregisterclient.d.ts +8 -2
  38. package/dist_ts/handelsregister/classes.handelsregisterclient.js +14 -5
  39. package/dist_ts/handelsregister/classes.handelsregistererror.d.ts +3 -1
  40. package/dist_ts/handelsregister/classes.handelsregistererror.js +1 -1
  41. package/dist_ts/handelsregister/classes.portalsession.d.ts +19 -3
  42. package/dist_ts/handelsregister/classes.portalsession.js +67 -9
  43. package/dist_ts/handelsregister/interfaces.d.ts +9 -2
  44. package/package.json +16 -6
  45. package/readme.md +86 -5
  46. package/ts/00_commitinfo_data.ts +2 -2
  47. package/ts/financialstatements/amounts.ts +181 -0
  48. package/ts/financialstatements/classes.financialstatementerror.ts +35 -0
  49. package/ts/financialstatements/constants.ts +24 -0
  50. package/ts/financialstatements/dates.ts +89 -0
  51. package/ts/financialstatements/html.ts +107 -0
  52. package/ts/financialstatements/index.ts +17 -0
  53. package/ts/financialstatements/interfaces.ts +268 -0
  54. package/ts/financialstatements/layout.html.ts +130 -0
  55. package/ts/financialstatements/layout.pdf.ts +268 -0
  56. package/ts/financialstatements/layout.ts +16 -0
  57. package/ts/financialstatements/mediatypes.ts +14 -0
  58. package/ts/financialstatements/outline.ts +44 -0
  59. package/ts/financialstatements/parse.esef.ts +394 -0
  60. package/ts/financialstatements/parse.hgb.ts +1235 -0
  61. package/ts/financialstatements/parse.statement.ts +26 -0
  62. package/ts/financialstatements/plugins.ts +29 -0
  63. package/ts/financialstatements/read.ts +135 -0
  64. package/ts/handelsregister/classes.handelsregisterclient.ts +18 -4
  65. package/ts/handelsregister/classes.handelsregistererror.ts +3 -0
  66. package/ts/handelsregister/classes.portalsession.ts +78 -9
  67. package/ts/handelsregister/interfaces.ts +9 -2
@@ -0,0 +1,1235 @@
1
+ import { FinancialStatementError } from './classes.financialstatementerror.js';
2
+ import {
3
+ addAmounts,
4
+ amountsAgree,
5
+ formatAmount,
6
+ namesOtherCurrency,
7
+ negateAmount,
8
+ normalizeSpaces,
9
+ parsePrintedAmount,
10
+ readsAsAmount,
11
+ sumAmounts,
12
+ toAmount,
13
+ unitsIn,
14
+ unitStatedBy,
15
+ withoutOtherCurrencies,
16
+ withoutUnits,
17
+ zeroAmount,
18
+ type IAmount,
19
+ } from './amounts.js';
20
+ import { lastDateIn, lastYearIn, periodIn, withoutDates } from './dates.js';
21
+ import { isMemoLabel, isTotalLabel, splitOutlineCode, type IOutlineCode } from './outline.js';
22
+ import type { ILayoutCell, TLayoutBlock } from './layout.js';
23
+ import type {
24
+ IHgbStatement,
25
+ IIncomeStatement,
26
+ IKeyFigures,
27
+ IStatementPeriod,
28
+ IStatementPosition,
29
+ IStatementUnit,
30
+ TIncomeStatementMethod,
31
+ TIsoDate,
32
+ TStatementLineKind,
33
+ } from './interfaces.js';
34
+
35
+ type TPeriod = 'current' | 'prior';
36
+ /** the two columns a year may have: the Vorspalte for parts, the main column for what they add up to */
37
+ type TSlot = 'pre' | 'main';
38
+ type TSide = 'assets' | 'liabilities';
39
+ type TMarker = TSide | 'balance' | 'income' | 'other';
40
+
41
+ const statementPeriods: TPeriod[] = ['current', 'prior'];
42
+
43
+ const notNull = <T>(valueArg: T | null): valueArg is T => valueArg !== null;
44
+
45
+ // ---------------------------------------------------------------------------------------------
46
+ // headings that mark the parts of a document
47
+
48
+ /** the parts of an annual report after the statements, which end them */
49
+ const otherPartPattern =
50
+ /^(?:Anhang|Lagebericht|Anlagenspiegel|Entwicklung des (?:Konzern-?)?Anlagevermögens|Bestätigungsvermerk|Ergebnisverwendung|Vorschlag (?:für die|zur) (?:Verwendung|Ergebnisverwendung)|Bericht des Aufsichtsrat|Kapitalflussrechnung|Eigenkapitalspiegel|Angaben)/i;
51
+
52
+ /** a heading set letter by letter, `A K T I V A`, as one word */
53
+ const unspaced = (textArg: string): string => (/^(?:[A-ZÄÖÜ] )+[A-ZÄÖÜ]$/.test(textArg) ? textArg.replace(/ /g, '') : textArg);
54
+
55
+ /** the part of a document a heading opens; null for any other text */
56
+ const markerOf = (textArg: string): TMarker | null => {
57
+ const text = unspaced(normalizeSpaces(textArg)).replace(/\s*:$/, '');
58
+ if (text === '' || text.length > 120) {
59
+ return null;
60
+ }
61
+ if (/^(?:Aktiva|Aktivseite)$/i.test(text)) {
62
+ return 'assets';
63
+ }
64
+ if (/^(?:Passiva|Passivseite)$/i.test(text)) {
65
+ return 'liabilities';
66
+ }
67
+ if (/^(?:Konzern-?)?bilanz\b/i.test(text)) {
68
+ return 'balance';
69
+ }
70
+ if (/^(?:Konzern-?)?Gewinn-? und Verlustrechnung\b/i.test(text) || /^GuV\b/.test(text)) {
71
+ return 'income';
72
+ }
73
+ return otherPartPattern.test(text) && unitStatedBy(text) === null ? 'other' : null;
74
+ };
75
+
76
+ // ---------------------------------------------------------------------------------------------
77
+ // column headings
78
+
79
+ const priorWordPattern = /Vorjahr|\bVJ\b|vorhergehend|vorangegangen|Vorperiode/i;
80
+ const currentWordPattern = /Geschäftsjahr|Berichtsjahr|Berichtszeitraum|laufendes|aktuelles|\bGJ\b/i;
81
+ const periodWordPattern = /Jahr|Periode|Zeitraum|Stichtag|\bGJ\b|\bVJ\b/i;
82
+ const notesHeadingPattern = /^(?:Anhang|Anh\.|Anm\.|Tz\.|Ziffer|Ziff\.|Erl\.|Note|Notes)(?![A-Za-zÄÖÜäöüß])/i;
83
+
84
+ /** the words a column heading may hold besides dates, years and units */
85
+ const headingWords = new Set([
86
+ 'geschäftsjahr', 'geschäftsjahres', 'berichtsjahr', 'berichtsjahres', 'wirtschaftsjahr', 'wirtschaftsjahres',
87
+ 'vorjahr', 'vorjahres', 'vorjahreszahlen', 'vorjahreswerte', 'vorjahresbetrag', 'vorjahresbeträge',
88
+ 'berichtszeitraum', 'berichtszeitraums', 'berichtsperiode', 'vorperiode', 'periode', 'zeitraum', 'stichtag',
89
+ 'jahr', 'jahres', 'laufendes', 'laufenden', 'aktuelles', 'aktuellen', 'vorhergehendes', 'vorhergehenden',
90
+ 'vorangegangenes', 'vorangegangenen', 'gj', 'vj', 'betrag', 'beträge', 'stand', 'zum', 'per', 'am', 'in',
91
+ 'bis', 'vom', 'von', 'für', 'das', 'der', 'die', 'des', 'dem', 'den', 'im', 'anhang', 'anh', 'anm', 'tz',
92
+ 'ziffer', 'ziff', 'erl', 'erläuterung', 'erläuterungen', 'note', 'notes', 'nr', 'januar', 'jänner',
93
+ 'februar', 'märz', 'april', 'mai', 'juni', 'juli', 'august', 'september', 'oktober', 'november', 'dezember',
94
+ ]);
95
+
96
+ /**
97
+ * whether a cell can head a column: dates, years, units and the words for the two years only; a
98
+ * currency other than the euro heads a column too, for the statement to be refused for it
99
+ */
100
+ const isHeadingText = (textArg: string): boolean => {
101
+ if (readsAsAmount(textArg)) {
102
+ return false;
103
+ }
104
+ const rest = withoutDates(withoutOtherCurrencies(withoutUnits(textArg))).replace(/\b(?:19|20)\d{2}\b/g, ' ');
105
+ if (/\d/.test(rest)) {
106
+ return false;
107
+ }
108
+ return rest
109
+ .split(/[^A-Za-zÄÖÜäöüß]+/)
110
+ .filter((wordArg) => wordArg !== '')
111
+ .every((wordArg) => headingWords.has(wordArg.toLowerCase()));
112
+ };
113
+
114
+ /** whether a heading names a year or a unit, not only a note column */
115
+ const namesPeriodOrUnit = (textArg: string): boolean =>
116
+ unitsIn(textArg).length > 0 ||
117
+ namesOtherCurrency(textArg) ||
118
+ lastDateIn(textArg) !== null ||
119
+ lastYearIn(textArg) !== null ||
120
+ periodWordPattern.test(textArg);
121
+
122
+ /**
123
+ * A row that heads the columns below it: no outline code, and every cell right of the label a
124
+ * heading, one of them naming a year or a unit.
125
+ */
126
+ const isHeadingRow = (cellsArg: ILayoutCell[]): boolean => {
127
+ const labelCell = cellsArg.find((cellArg) => cellArg.first === 0);
128
+ if (labelCell && splitOutlineCode(labelCell.text).code !== null) {
129
+ return false;
130
+ }
131
+ const headings = cellsArg.filter((cellArg) => cellArg.first > 0);
132
+ if (headings.length === 0 || !headings.every((cellArg) => isHeadingText(cellArg.text))) {
133
+ return false;
134
+ }
135
+ if (!headings.some((cellArg) => namesPeriodOrUnit(cellArg.text))) {
136
+ return false;
137
+ }
138
+ return (
139
+ !labelCell ||
140
+ markerOf(labelCell.text) !== null ||
141
+ isHeadingText(labelCell.text) ||
142
+ /^(?:Posten|Position|Bezeichnung|Gliederung)$/i.test(labelCell.text)
143
+ );
144
+ };
145
+
146
+ // ---------------------------------------------------------------------------------------------
147
+ // the parts of a document
148
+
149
+ /** a row of a table in the balance sheet or the income statement */
150
+ interface IRowItem {
151
+ cells: ILayoutCell[];
152
+ /** the table it is in */
153
+ block: number;
154
+ side: TSide | null;
155
+ heading: boolean;
156
+ }
157
+
158
+ interface ISectionDraft {
159
+ /** the lines of text in the section, its heading among them */
160
+ contexts: string[];
161
+ items: IRowItem[];
162
+ /** the rows with amounts */
163
+ valueRows: number;
164
+ }
165
+
166
+ interface ISections {
167
+ /** the lines of text before the first statement */
168
+ documentContext: string[];
169
+ balance: ISectionDraft;
170
+ income: ISectionDraft;
171
+ sideValueRows: Record<TSide, number>;
172
+ sideBySide: boolean;
173
+ notes: string[];
174
+ }
175
+
176
+ const hasAmountCell = (cellsArg: ILayoutCell[]): boolean =>
177
+ cellsArg.some((cellArg) => cellArg.first > 0 && readsAsAmount(cellArg.text));
178
+
179
+ /**
180
+ * Splits a document into its balance sheet, its income statement and the text before them, by the
181
+ * headings that open them. A part ends at the next heading; one that comes again after it was read
182
+ * in full is left out with a note.
183
+ */
184
+ const readSections = (blocksArg: TLayoutBlock[]): ISections => {
185
+ const newDraft = (): ISectionDraft => ({ contexts: [], items: [], valueRows: 0 });
186
+ const sections: ISections = {
187
+ documentContext: [],
188
+ balance: newDraft(),
189
+ income: newDraft(),
190
+ sideValueRows: { assets: 0, liabilities: 0 },
191
+ sideBySide: false,
192
+ notes: [],
193
+ };
194
+ let section: 'none' | 'balance' | 'income' | 'other' = 'none';
195
+ let side: TSide | null = null;
196
+ const draft = (): ISectionDraft | null =>
197
+ section === 'balance' ? sections.balance : section === 'income' ? sections.income : null;
198
+ const balanceComplete = () => sections.sideValueRows.assets > 0 && sections.sideValueRows.liabilities > 0;
199
+ const addContext = (textArg: string) => {
200
+ const target = draft();
201
+ if (target) {
202
+ target.contexts.push(textArg);
203
+ } else if (section === 'none') {
204
+ sections.documentContext.push(textArg);
205
+ }
206
+ };
207
+ const noteSecond = (whatArg: string) => {
208
+ const note = `The document holds a second ${whatArg}, which was not read.`;
209
+ if (!sections.notes.includes(note)) {
210
+ sections.notes.push(note);
211
+ }
212
+ };
213
+ const enter = (markerArg: TMarker) => {
214
+ if (markerArg === 'assets' || markerArg === 'liabilities') {
215
+ if (section !== 'balance') {
216
+ if (balanceComplete()) {
217
+ return;
218
+ }
219
+ section = 'balance';
220
+ }
221
+ side = markerArg;
222
+ } else if (markerArg === 'balance') {
223
+ if (section !== 'balance') {
224
+ if (balanceComplete()) {
225
+ noteSecond('balance sheet');
226
+ section = 'other';
227
+ return;
228
+ }
229
+ section = 'balance';
230
+ }
231
+ } else if (markerArg === 'income') {
232
+ if (section !== 'income') {
233
+ if (sections.income.valueRows > 0) {
234
+ noteSecond('income statement');
235
+ section = 'other';
236
+ return;
237
+ }
238
+ section = 'income';
239
+ }
240
+ } else {
241
+ section = 'other';
242
+ }
243
+ };
244
+ blocksArg.forEach((block, blockIndex) => {
245
+ if (block.kind === 'text') {
246
+ const marker = markerOf(block.text);
247
+ if (marker) {
248
+ enter(marker);
249
+ addContext(block.text);
250
+ } else if (block.heading && (draft()?.valueRows ?? 0) > 0) {
251
+ section = 'other';
252
+ } else {
253
+ addContext(block.text);
254
+ }
255
+ return;
256
+ }
257
+ for (const cells of block.rows) {
258
+ const valued = hasAmountCell(cells);
259
+ if (
260
+ !valued &&
261
+ cells.some((cellArg) => markerOf(cellArg.text) === 'assets') &&
262
+ cells.some((cellArg) => markerOf(cellArg.text) === 'liabilities')
263
+ ) {
264
+ if (!balanceComplete()) {
265
+ section = 'balance';
266
+ sections.sideBySide = true;
267
+ }
268
+ continue;
269
+ }
270
+ const heading = !valued && isHeadingRow(cells);
271
+ if (!valued) {
272
+ const rowText = normalizeSpaces(cells.map((cellArg) => cellArg.text).join(' '));
273
+ const labelCell = cells.find((cellArg) => cellArg.first === 0);
274
+ const marker = (labelCell ? markerOf(labelCell.text) : null) ?? (heading ? null : markerOf(rowText));
275
+ if (marker) {
276
+ enter(marker);
277
+ if (!heading) {
278
+ addContext(rowText);
279
+ }
280
+ draft()?.items.push({ cells, block: blockIndex, side, heading });
281
+ continue;
282
+ }
283
+ if (!heading) {
284
+ addContext(rowText);
285
+ }
286
+ }
287
+ const target = draft();
288
+ if (!target) {
289
+ continue;
290
+ }
291
+ target.items.push({ cells, block: blockIndex, side, heading });
292
+ if (valued) {
293
+ target.valueRows++;
294
+ if (section === 'balance' && side) {
295
+ sections.sideValueRows[side]++;
296
+ }
297
+ }
298
+ }
299
+ });
300
+ return sections;
301
+ };
302
+
303
+ // ---------------------------------------------------------------------------------------------
304
+ // columns and rows
305
+
306
+ /** what a column holds: one of the two years, as its parts or as their sum */
307
+ interface IColumnRole {
308
+ period: TPeriod;
309
+ slot: TSlot;
310
+ /** the column's heading as printed */
311
+ heading: string;
312
+ /** the date the heading names */
313
+ date: TIsoDate | null;
314
+ }
315
+
316
+ /** a printed row with its amounts, each a year and a column */
317
+ interface IRow {
318
+ /** the label without its outline code */
319
+ label: string;
320
+ code: IOutlineCode | null;
321
+ memo: boolean;
322
+ /** an uncoded row that sums the rows above it: Summe, Bilanzsumme, or no label at all */
323
+ total: boolean;
324
+ values: Record<TPeriod, Record<TSlot, IAmount | null>>;
325
+ hasValues: boolean;
326
+ side: TSide | null;
327
+ }
328
+
329
+ interface ISectionRows {
330
+ rows: IRow[];
331
+ unit: IStatementUnit;
332
+ /** the years printed in two columns */
333
+ vorspalte: Set<TPeriod>;
334
+ /** the main column of each year, as the first table to show it heads it */
335
+ columns: Partial<Record<TPeriod, IColumnRole>>;
336
+ }
337
+
338
+ type TFail = (messageArg: string) => never;
339
+
340
+ /** the tables of a section split where a table or a run of column headings begins */
341
+ const scopesOf = (itemsArg: IRowItem[]): Array<{ headings: IRowItem[]; body: IRowItem[] }> => {
342
+ const scopes: Array<{ headings: IRowItem[]; body: IRowItem[] }> = [];
343
+ let previous: IRowItem | null = null;
344
+ for (const item of itemsArg) {
345
+ if (!previous || item.block !== previous.block || (item.heading && !previous.heading)) {
346
+ scopes.push({ headings: [], body: [] });
347
+ }
348
+ const scope = scopes[scopes.length - 1];
349
+ (item.heading && scope.body.length === 0 ? scope.headings : scope.body).push(item);
350
+ previous = item;
351
+ }
352
+ return scopes;
353
+ };
354
+
355
+ /** the years of a table's columns, from their headings */
356
+ const rolesOf = (headingsArg: string[], failArg: TFail): IColumnRole[] => {
357
+ const infos = headingsArg.map((headingArg) => {
358
+ const date = lastDateIn(headingArg);
359
+ return {
360
+ word: priorWordPattern.test(headingArg) ? 'prior' : currentWordPattern.test(headingArg) ? 'current' : null,
361
+ date,
362
+ year: date ? Number(date.slice(0, 4)) : lastYearIn(headingArg),
363
+ } as { word: TPeriod | null; date: TIsoDate | null; year: number | null };
364
+ });
365
+ const stated = infos.filter((infoArg) => infoArg.word !== null || infoArg.year !== null);
366
+ const periodsOf: Array<TPeriod | null> = infos.map(() => null);
367
+ const byLatest = <T>(keyOf: (infoArg: (typeof infos)[number]) => T | null) => {
368
+ const distinct = [...new Set(stated.map(keyOf))].sort();
369
+ if (distinct.length > 2) {
370
+ failArg('shows columns for more than two years.');
371
+ }
372
+ infos.forEach((infoArg, index) => {
373
+ const key = keyOf(infoArg);
374
+ if (key !== null) {
375
+ periodsOf[index] = key === distinct[distinct.length - 1] ? 'current' : 'prior';
376
+ }
377
+ });
378
+ };
379
+ if (stated.length > 0) {
380
+ if (stated.every((infoArg) => infoArg.date !== null)) {
381
+ byLatest((infoArg) => infoArg.date);
382
+ } else if (stated.every((infoArg) => infoArg.year !== null)) {
383
+ byLatest((infoArg) => infoArg.year);
384
+ } else if (stated.every((infoArg) => infoArg.word !== null)) {
385
+ infos.forEach((infoArg, index) => (periodsOf[index] = infoArg.word));
386
+ } else {
387
+ failArg('heads some columns with a date and others with a word.');
388
+ }
389
+ }
390
+ infos.forEach((infoArg, index) => {
391
+ if (infoArg.word !== null && periodsOf[index] !== null && infoArg.word !== periodsOf[index]) {
392
+ failArg(`heads a column "${headingsArg[index]}", which names another year than its date.`);
393
+ }
394
+ });
395
+ const dates = infos.map((infoArg) => infoArg.date);
396
+ for (let index = infos.length - 1; index >= 0; index--) {
397
+ if (periodsOf[index] !== null) {
398
+ continue;
399
+ }
400
+ if (index + 1 < infos.length) {
401
+ periodsOf[index] = periodsOf[index + 1];
402
+ dates[index] = dates[index + 1];
403
+ } else if (infos.length === 1) {
404
+ periodsOf[index] = 'current';
405
+ } else {
406
+ failArg('has columns whose year it does not state.');
407
+ }
408
+ }
409
+ if (!periodsOf.includes('current')) {
410
+ failArg('has no column for the current year.');
411
+ }
412
+ const roles: IColumnRole[] = [];
413
+ for (const period of statementPeriods) {
414
+ const indexes = periodsOf.flatMap((periodArg, index) => (periodArg === period ? [index] : []));
415
+ if (indexes.length > 2) {
416
+ failArg(`has more than two columns for the ${period} year.`);
417
+ }
418
+ indexes.forEach((columnIndex, position) => {
419
+ roles[columnIndex] = {
420
+ period,
421
+ slot: indexes.length === 2 && position === 0 ? 'pre' : 'main',
422
+ heading: headingsArg[columnIndex],
423
+ date: dates[columnIndex],
424
+ };
425
+ });
426
+ }
427
+ return roles;
428
+ };
429
+
430
+ /** the one unit some lines state; null if none does */
431
+ const unitStatedIn = (linesArg: string[], failArg: TFail): IStatementUnit | null => {
432
+ const units = new Map(linesArg.map(unitStatedBy).filter(notNull).map((unitArg) => [unitArg.label, unitArg]));
433
+ if (units.size > 1) {
434
+ failArg(`states more than one unit (${[...units.keys()].join(', ')}).`);
435
+ }
436
+ return [...units.values()][0] ?? null;
437
+ };
438
+
439
+ /** a cell that holds only a unit, `T€` or `(EUR)` */
440
+ const isUnitOnly = (textArg: string): boolean =>
441
+ unitsIn(textArg).length > 0 && /^[\s()]*$/.test(withoutUnits(textArg));
442
+
443
+ /**
444
+ * Reads the rows of a section: finds each table's amount columns and the year and unit each stands
445
+ * for, and the label and amounts of each row. Throws `unparseable` where a column's year or unit is
446
+ * not stated with certainty, or where a row holds text in an amount column.
447
+ */
448
+ const readSectionRows = (draftArg: ISectionDraft, titleArg: string, documentContextArg: string[]): ISectionRows => {
449
+ const fail: TFail = (messageArg) => {
450
+ throw new FinancialStatementError('unparseable', `The ${titleArg} ${messageArg}`);
451
+ };
452
+ const contextUnit = unitStatedIn(draftArg.contexts, fail) ?? unitStatedIn(documentContextArg, fail);
453
+ const result: ISectionRows = { rows: [], unit: null as unknown as IStatementUnit, vorspalte: new Set(), columns: {} };
454
+ let previous: { columns: number[]; roles: IColumnRole[]; unit: IStatementUnit } | null = null;
455
+ let sectionUnit: IStatementUnit | null = null;
456
+ for (const scope of scopesOf(draftArg.items)) {
457
+ const headingCells = scope.headings.flatMap((itemArg) => itemArg.cells).filter((cellArg) => cellArg.first > 0);
458
+ const notesColumns = new Set<number>();
459
+ for (const cell of headingCells) {
460
+ if (notesHeadingPattern.test(cell.text)) {
461
+ for (let column = cell.first; column <= cell.last; column++) {
462
+ notesColumns.add(column);
463
+ }
464
+ }
465
+ }
466
+ const inNotesColumn = (cellArg: ILayoutCell) => {
467
+ for (let column = cellArg.first; column <= cellArg.last; column++) {
468
+ if (!notesColumns.has(column)) {
469
+ return false;
470
+ }
471
+ }
472
+ return true;
473
+ };
474
+ const bodies = scope.body.map((itemArg) => {
475
+ const cells = itemArg.cells.filter((cellArg) => cellArg.first === 0 || !inNotesColumn(cellArg));
476
+ return {
477
+ item: itemArg,
478
+ cells: cells.filter((cellArg) => cellArg.first === 0 || !isUnitOnly(cellArg.text)),
479
+ unitCells: cells.filter((cellArg) => cellArg.first > 0 && isUnitOnly(cellArg.text)),
480
+ };
481
+ });
482
+ const columns = [
483
+ ...new Set(
484
+ bodies.flatMap((bodyArg) =>
485
+ bodyArg.cells.filter((cellArg) => cellArg.first > 0 && readsAsAmount(cellArg.text)).map((cellArg) => cellArg.last)
486
+ )
487
+ ),
488
+ ].sort((leftArg, rightArg) => leftArg - rightArg);
489
+ let roles: IColumnRole[] = [];
490
+ let unit: IStatementUnit | null = null;
491
+ if (columns.length > 0) {
492
+ if (scope.headings.length === 0 && previous) {
493
+ if (previous.columns.length !== columns.length) {
494
+ fail('changes its columns without heading them anew.');
495
+ }
496
+ roles = previous.roles;
497
+ unit = previous.unit;
498
+ } else {
499
+ const headingOf = (columnArg: number) =>
500
+ normalizeSpaces(
501
+ headingCells
502
+ .filter((cellArg) => cellArg.first <= columnArg && columnArg <= cellArg.last)
503
+ .map((cellArg) => cellArg.text)
504
+ .join(' ')
505
+ );
506
+ roles = rolesOf(columns.map(headingOf), fail);
507
+ const headingText = headingCells.map((cellArg) => cellArg.text).join(' ');
508
+ if (namesOtherCurrency(headingText)) {
509
+ fail('is in a currency other than the euro.');
510
+ }
511
+ const headingUnits = new Map(unitsIn(headingText).map((unitArg) => [unitArg.label, unitArg]));
512
+ if (headingUnits.size > 1) {
513
+ fail(`heads its columns with more than one unit (${[...headingUnits.keys()].join(', ')}).`);
514
+ }
515
+ const printedUnits = new Map(
516
+ bodies
517
+ .flatMap((bodyArg) => [
518
+ ...bodyArg.cells
519
+ .filter((cellArg) => columns.includes(cellArg.last) && cellArg.first > 0)
520
+ .map((cellArg) => parsePrintedAmount(cellArg.text)?.unit ?? null),
521
+ ...bodyArg.unitCells.map((cellArg) => unitsIn(cellArg.text)[0]),
522
+ ])
523
+ .filter(notNull)
524
+ .map((unitArg) => [unitArg.label, unitArg])
525
+ );
526
+ if (printedUnits.size > 1) {
527
+ fail(`prints amounts in more than one unit (${[...printedUnits.keys()].join(', ')}).`);
528
+ }
529
+ unit = [...headingUnits.values()][0] ?? contextUnit ?? [...printedUnits.values()][0] ?? null;
530
+ if (!unit) {
531
+ fail('does not state its unit (EUR, TEUR or Mio. EUR).');
532
+ }
533
+ }
534
+ const scopeUnit = unit as IStatementUnit;
535
+ if (sectionUnit && sectionUnit.label !== scopeUnit.label) {
536
+ fail(`is printed in more than one unit (${sectionUnit.label}, ${scopeUnit.label}).`);
537
+ }
538
+ sectionUnit = scopeUnit;
539
+ previous = { columns, roles, unit: scopeUnit };
540
+ for (const role of roles) {
541
+ if (role.slot === 'pre') {
542
+ result.vorspalte.add(role.period);
543
+ } else {
544
+ result.columns[role.period] ??= role;
545
+ }
546
+ }
547
+ }
548
+ const labelLimit = columns.length > 0 ? columns[0] : Number.POSITIVE_INFINITY;
549
+ for (const body of bodies) {
550
+ const fullLabel = normalizeSpaces(
551
+ body.cells.filter((cellArg) => cellArg.first < labelLimit).map((cellArg) => cellArg.text).join(' ')
552
+ );
553
+ const values: IRow['values'] = { current: { pre: null, main: null }, prior: { pre: null, main: null } };
554
+ let amounts = 0;
555
+ let text: string | null = null;
556
+ for (const cell of body.cells.filter((cellArg) => cellArg.first >= labelLimit)) {
557
+ const index = columns.indexOf(cell.last);
558
+ const printed = index >= 0 ? parsePrintedAmount(cell.text) : null;
559
+ if (!printed) {
560
+ text ??= cell.text;
561
+ continue;
562
+ }
563
+ const scopeUnit = unit as IStatementUnit;
564
+ if (printed.unit && printed.unit.label !== scopeUnit.label) {
565
+ fail(`prints "${cell.text}" in a table in ${scopeUnit.label}.`);
566
+ }
567
+ const role = roles[index];
568
+ if (values[role.period][role.slot]) {
569
+ fail(`prints two amounts in one column of "${fullLabel}".`);
570
+ }
571
+ values[role.period][role.slot] = toAmount(printed, scopeUnit);
572
+ amounts++;
573
+ }
574
+ if (amounts > 0 && text !== null) {
575
+ fail(`holds text where an amount belongs: "${text}" in "${fullLabel}".`);
576
+ }
577
+ for (const cell of body.unitCells) {
578
+ if (unit && unitsIn(cell.text)[0].label !== unit.label) {
579
+ fail(`prints "${cell.text}" in a table in ${unit.label}.`);
580
+ }
581
+ }
582
+ const { code, label } = splitOutlineCode(fullLabel);
583
+ result.rows.push({
584
+ label,
585
+ code,
586
+ memo: isMemoLabel(label),
587
+ total: code === null && isTotalLabel(label),
588
+ values,
589
+ hasValues: amounts > 0,
590
+ side: body.item.side,
591
+ });
592
+ }
593
+ }
594
+ if (!sectionUnit) {
595
+ fail('prints no amounts.');
596
+ }
597
+ result.unit = sectionUnit as IStatementUnit;
598
+ return result;
599
+ };
600
+
601
+ // ---------------------------------------------------------------------------------------------
602
+ // the outline
603
+
604
+ /** a position of the outline, with the amount each year gives it */
605
+ interface INode {
606
+ row: IRow;
607
+ parent: INode | null;
608
+ children: INode[];
609
+ depth: number;
610
+ codePath: string | null;
611
+ value: Record<TPeriod, IAmount | null>;
612
+ }
613
+
614
+ /**
615
+ * A printed line in its part of a statement:
616
+ * - `node`: a position;
617
+ * - `memo`: a "davon" line, belonging to `node`;
618
+ * - `sum`: a printed sum of the positions above it, such as Summe Anlagevermögen;
619
+ * - `total`: the total of a side of the balance sheet.
620
+ */
621
+ interface IPartLine {
622
+ row: IRow;
623
+ role: 'node' | 'memo' | 'sum' | 'total';
624
+ node: INode | null;
625
+ /** the position before the line */
626
+ previous: INode | null;
627
+ /** for a sum, the position whose amount it states, per year */
628
+ closes: Record<TPeriod, INode | null>;
629
+ }
630
+
631
+ /** one side of the balance sheet, or the income statement */
632
+ interface IPart {
633
+ lines: IPartLine[];
634
+ roots: INode[];
635
+ nodes: INode[];
636
+ total: IPartLine | null;
637
+ }
638
+
639
+ /** the labels of the income statement's intermediate results */
640
+ const subtotalLabelPattern = /ergebnis|Zwischensumme|Summe|Rohertrag/i;
641
+
642
+ /**
643
+ * Builds the outline of a part from its rows. Coded rows nest by their codes; an uncoded row with
644
+ * amounts belongs to the position before it where that position prints none of its own, else stands
645
+ * beside it. In the balance sheet the last row with amounts, uncoded, is the total of its side, and
646
+ * other summing rows are printed sums; in the income statement intermediate results stand at the top.
647
+ */
648
+ const buildPart = (rowsArg: IRow[], incomeArg: boolean): IPart => {
649
+ const part: IPart = { lines: [], roots: [], nodes: [], total: null };
650
+ const valued = rowsArg.filter((rowArg) => rowArg.hasValues && !rowArg.memo);
651
+ const lastValued = valued[valued.length - 1];
652
+ const totalRow = !incomeArg && lastValued && lastValued.code === null ? lastValued : null;
653
+ const stack: INode[] = [];
654
+ let previous: INode | null = null;
655
+ const line = (rowArg: IRow, roleArg: IPartLine['role'], nodeArg: INode | null): IPartLine => {
656
+ const result: IPartLine = { row: rowArg, role: roleArg, node: nodeArg, previous, closes: { current: null, prior: null } };
657
+ part.lines.push(result);
658
+ return result;
659
+ };
660
+ const addNode = (rowArg: IRow, parentArg: INode | null): INode => {
661
+ let codeAbove: string | null = null;
662
+ for (let ancestor = parentArg; ancestor && codeAbove === null; ancestor = ancestor.parent) {
663
+ codeAbove = ancestor.codePath;
664
+ }
665
+ const node: INode = {
666
+ row: rowArg,
667
+ parent: parentArg,
668
+ children: [],
669
+ depth: parentArg ? parentArg.depth + 1 : 0,
670
+ codePath: rowArg.code ? (codeAbove ? `${codeAbove}.${rowArg.code.key}` : rowArg.code.key) : null,
671
+ value: { current: null, prior: null },
672
+ };
673
+ (parentArg ? parentArg.children : part.roots).push(node);
674
+ part.nodes.push(node);
675
+ previous = node;
676
+ line(rowArg, 'node', node);
677
+ return node;
678
+ };
679
+ for (const row of rowsArg) {
680
+ if (row === totalRow) {
681
+ part.total = line(row, 'total', null);
682
+ } else if (row.memo) {
683
+ if (row.hasValues) {
684
+ line(row, 'memo', previous);
685
+ }
686
+ } else if (row.code) {
687
+ while (stack.length > 0 && (stack[stack.length - 1].row.code as IOutlineCode).rank >= row.code.rank) {
688
+ stack.pop();
689
+ }
690
+ stack.push(addNode(row, stack[stack.length - 1] ?? null));
691
+ } else if (!row.hasValues) {
692
+ continue;
693
+ } else if (row.total && !incomeArg) {
694
+ line(row, 'sum', null);
695
+ } else if (incomeArg && (row.total || subtotalLabelPattern.test(row.label))) {
696
+ stack.length = 0;
697
+ addNode(row, null);
698
+ } else {
699
+ const before = previous as INode | null;
700
+ addNode(row, before && !before.row.hasValues ? before : (before?.parent ?? null));
701
+ }
702
+ }
703
+ return part;
704
+ };
705
+
706
+ const lastOf = (nodeArg: INode): INode => {
707
+ let node = nodeArg;
708
+ while (node.children.length > 0) {
709
+ node = node.children[node.children.length - 1];
710
+ }
711
+ return node;
712
+ };
713
+
714
+ /** what a position adds to its parent in a year: its own amount, else the sum of its parts' */
715
+ const contributionOf = (nodeArg: INode, periodArg: TPeriod): IAmount | null => {
716
+ const own = nodeArg.value[periodArg];
717
+ if (own) {
718
+ return own;
719
+ }
720
+ const parts = nodeArg.children.map((childArg) => contributionOf(childArg, periodArg)).filter(notNull);
721
+ return parts.length > 0 ? sumAmounts(parts) : null;
722
+ };
723
+
724
+ const describe = (rowArg: IRow): string =>
725
+ `"${[rowArg.code?.printed, rowArg.label].filter((partArg) => partArg).join(' ') || '(no label)'}"`;
726
+
727
+ /**
728
+ * The position a sum or a main-column amount states: the deepest of the given position and those
729
+ * above it that it closes, whose parts add up to the amount. A position without an amount of its own
730
+ * comes first; one whose own amount agrees also does.
731
+ */
732
+ const closedBy = (fromArg: INode | null, lastArg: INode, amountArg: IAmount, periodArg: TPeriod, ownAllowedArg: boolean): INode | null => {
733
+ let agreeing: INode | null = null;
734
+ for (let candidate = fromArg; candidate && lastOf(candidate) === lastArg; candidate = candidate.parent) {
735
+ const contribution = contributionOf(candidate, periodArg);
736
+ if (!contribution || !amountsAgree(contribution, amountArg)) {
737
+ continue;
738
+ }
739
+ if (candidate.value[periodArg] === null) {
740
+ return candidate;
741
+ }
742
+ agreeing ??= ownAllowedArg ? candidate : null;
743
+ }
744
+ return agreeing;
745
+ };
746
+
747
+ type TAssignMode = 'vorspalte' | 'own' | 'mirror';
748
+
749
+ /**
750
+ * Gives one year's amounts to the positions they belong to. In a year printed in two columns a row
751
+ * with both gives its first to itself and its second to the position it closes; a row with one
752
+ * gives it to itself. In a year printed in one column next to such a year, each amount goes where
753
+ * the row's main amount went (`mirror`), unless the column also prints the parts (`own`). Returns
754
+ * why the amounts do not fit, or null.
755
+ */
756
+ const assignYear = (partArg: IPart, periodArg: TPeriod, modeArg: TAssignMode, mainTargetsArg: Map<IRow, INode>): string | null => {
757
+ for (const line of partArg.lines) {
758
+ const { pre, main } = line.row.values[periodArg];
759
+ if (line.role === 'node') {
760
+ const node = line.node as INode;
761
+ if (modeArg === 'vorspalte' && pre && main) {
762
+ if (node.value[periodArg]) {
763
+ return `${describe(line.row)} receives a second amount.`;
764
+ }
765
+ node.value[periodArg] = pre;
766
+ const target = closedBy(node.parent, node, main, periodArg, false);
767
+ if (!target) {
768
+ return `${describe(line.row)} prints ${formatAmount(main)} for no position whose parts add up to it.`;
769
+ }
770
+ target.value[periodArg] = main;
771
+ mainTargetsArg.set(line.row, target);
772
+ } else if (pre || main) {
773
+ const amount = (main ?? pre) as IAmount;
774
+ const target = modeArg === 'mirror' ? (mainTargetsArg.get(line.row) ?? node) : node;
775
+ if (target.value[periodArg]) {
776
+ return `${describe(line.row)} gives a second amount to ${describe(target.row)}.`;
777
+ }
778
+ target.value[periodArg] = amount;
779
+ if (modeArg === 'vorspalte' && main) {
780
+ mainTargetsArg.set(line.row, node);
781
+ }
782
+ }
783
+ } else if (line.role === 'sum') {
784
+ const amount = main ?? pre;
785
+ if (!amount) {
786
+ continue;
787
+ }
788
+ const target = line.previous ? closedBy(line.previous, line.previous, amount, periodArg, true) : null;
789
+ if (!target) {
790
+ return `${describe(line.row)} of ${formatAmount(amount)} is not the sum of the positions above it.`;
791
+ }
792
+ target.value[periodArg] ??= amount;
793
+ line.closes[periodArg] = target;
794
+ }
795
+ }
796
+ return null;
797
+ };
798
+
799
+ /** whether every position with an amount and parts with amounts equals their sum; why not, or null */
800
+ const checkParts = (nodesArg: INode[], periodArg: TPeriod): string | null => {
801
+ for (const node of nodesArg) {
802
+ const own = node.value[periodArg];
803
+ const parts = node.children.map((childArg) => contributionOf(childArg, periodArg)).filter(notNull);
804
+ if (!own || parts.length === 0) {
805
+ continue;
806
+ }
807
+ const sum = sumAmounts(parts);
808
+ if (!amountsAgree(own, sum)) {
809
+ return `${describe(node.row)} prints ${formatAmount(own)}, but its parts add up to ${formatAmount(sum)}.`;
810
+ }
811
+ }
812
+ return null;
813
+ };
814
+
815
+ const clearYear = (partsArg: IPart[], periodArg: TPeriod) => {
816
+ for (const part of partsArg) {
817
+ part.nodes.forEach((nodeArg) => (nodeArg.value[periodArg] = null));
818
+ part.lines.forEach((lineArg) => (lineArg.closes[periodArg] = null));
819
+ }
820
+ };
821
+
822
+ const printedAmount = (rowArg: IRow, periodArg: TPeriod): IAmount | null =>
823
+ rowArg.values[periodArg].main ?? rowArg.values[periodArg].pre;
824
+
825
+ /**
826
+ * Gives each year's amounts of a section to its positions and checks them; the years printed in two
827
+ * columns come first, as the others may follow them. Returns, per year with amounts, why it does not
828
+ * add up, or null; a year that does not keeps no amounts.
829
+ */
830
+ const settleSection = (partsArg: IPart[], sectionArg: ISectionRows, checkArg: (periodArg: TPeriod) => string | null): Map<TPeriod, string | null> => {
831
+ const outcome = new Map<TPeriod, string | null>();
832
+ const years = statementPeriods.filter((periodArg) =>
833
+ sectionArg.rows.some((rowArg) => rowArg.values[periodArg].pre || rowArg.values[periodArg].main)
834
+ );
835
+ const order = [
836
+ ...years.filter((periodArg) => sectionArg.vorspalte.has(periodArg)),
837
+ ...years.filter((periodArg) => !sectionArg.vorspalte.has(periodArg)),
838
+ ];
839
+ let mainTargets: Map<IRow, INode> | null = null;
840
+ for (const period of order) {
841
+ const targets = new Map<IRow, INode>();
842
+ let mode: TAssignMode = 'vorspalte';
843
+ if (!sectionArg.vorspalte.has(period)) {
844
+ const partsPrinted = sectionArg.rows.some(
845
+ (rowArg) =>
846
+ !rowArg.memo &&
847
+ [...sectionArg.vorspalte].some((yearArg) => rowArg.values[yearArg].pre && !rowArg.values[yearArg].main) &&
848
+ printedAmount(rowArg, period) !== null
849
+ );
850
+ mode = partsPrinted ? 'own' : 'mirror';
851
+ }
852
+ let failure: string | null = null;
853
+ for (const part of partsArg) {
854
+ failure ??= assignYear(part, period, mode, mode === 'mirror' ? (mainTargets ?? targets) : targets);
855
+ }
856
+ failure ??= checkArg(period);
857
+ if (failure) {
858
+ clearYear(partsArg, period);
859
+ } else if (mode === 'vorspalte') {
860
+ mainTargets ??= targets;
861
+ }
862
+ outcome.set(period, failure);
863
+ }
864
+ return outcome;
865
+ };
866
+
867
+ // ---------------------------------------------------------------------------------------------
868
+ // the balance sheet
869
+
870
+ const checkBalanceYear = (assetsArg: IPart, liabilitiesArg: IPart, periodArg: TPeriod): string | null => {
871
+ const totals: IAmount[] = [];
872
+ for (const [part, name] of [
873
+ [assetsArg, 'assets'],
874
+ [liabilitiesArg, 'equity and liabilities'],
875
+ ] as const) {
876
+ const failure = checkParts(part.nodes, periodArg);
877
+ if (failure) {
878
+ return failure;
879
+ }
880
+ const total = part.total ? printedAmount(part.total.row, periodArg) : null;
881
+ if (!total) {
882
+ return `The ${name} print no total.`;
883
+ }
884
+ const sum = sumAmounts(part.roots.map((rootArg) => contributionOf(rootArg, periodArg)).filter(notNull));
885
+ if (!amountsAgree(total, sum)) {
886
+ return `The ${name} total ${formatAmount(total)}, but their positions add up to ${formatAmount(sum)}.`;
887
+ }
888
+ totals.push(total);
889
+ }
890
+ if (totals[0].micros !== totals[1].micros) {
891
+ return `Total assets of ${formatAmount(totals[0])} differ from total equity and liabilities of ${formatAmount(totals[1])}.`;
892
+ }
893
+ return null;
894
+ };
895
+
896
+ // ---------------------------------------------------------------------------------------------
897
+ // the income statement
898
+
899
+ /** the expenses of § 275 HGB, which a statement may print as positive amounts to subtract */
900
+ const expensePattern =
901
+ /aufwend|aufwand|abschreibung|steuern|Verminderung des Bestand|Herstellungskosten|Vertriebskosten|Verwaltungskosten|Verlustübernahme|abgeführte Gewinne/i;
902
+ const isExpense = (labelArg: string): boolean =>
903
+ expensePattern.test(labelArg) && !/Erhöhung|ergebnis/i.test(labelArg) && !/^Erträge/i.test(labelArg);
904
+
905
+ const netIncomePattern = /^(?:Konzern-?)?jahres(?:überschuss|fehlbetrag|ergebnis)/i;
906
+
907
+ interface IIncomeYear {
908
+ netIncome: IAmount;
909
+ subtotals: Set<INode>;
910
+ }
911
+
912
+ /**
913
+ * Adds an income statement's lines up to its net income, expenses as printed or, with
914
+ * `expensesPositiveArg`, subtracted. A line that states the sum of the lines before it, or of those
915
+ * since the last such line, is an intermediate result and not added again.
916
+ */
917
+ const addUpIncome = (partArg: IPart, netIndexArg: number, periodArg: TPeriod, expensesPositiveArg: boolean): IIncomeYear | string => {
918
+ let running = zeroAmount;
919
+ let segment = zeroAmount;
920
+ const subtotals = new Set<INode>();
921
+ for (const root of partArg.roots.slice(0, netIndexArg)) {
922
+ const printed = contributionOf(root, periodArg);
923
+ if (!printed) {
924
+ continue;
925
+ }
926
+ if (
927
+ (root.row.total || subtotalLabelPattern.test(root.row.label)) &&
928
+ (amountsAgree(printed, running) || amountsAgree(printed, segment))
929
+ ) {
930
+ subtotals.add(root);
931
+ segment = zeroAmount;
932
+ continue;
933
+ }
934
+ if (root.row.total) {
935
+ return `${describe(root.row)} of ${formatAmount(printed)} is the sum of no lines above it.`;
936
+ }
937
+ const signed = expensesPositiveArg && isExpense(root.row.label) ? negateAmount(printed) : printed;
938
+ running = addAmounts(running, signed);
939
+ segment = addAmounts(segment, signed);
940
+ }
941
+ const netNode = partArg.roots[netIndexArg];
942
+ const net = contributionOf(netNode, periodArg);
943
+ if (!net) {
944
+ return 'It prints no net income.';
945
+ }
946
+ if (amountsAgree(net, running)) {
947
+ return { netIncome: net, subtotals };
948
+ }
949
+ const lossOnly = /fehlbetrag/i.test(netNode.row.label) && !/überschuss|ergebnis/i.test(netNode.row.label);
950
+ if (lossOnly && net.micros > 0n && amountsAgree(negateAmount(net), running)) {
951
+ return { netIncome: negateAmount(net), subtotals };
952
+ }
953
+ return `Its lines add up to ${formatAmount(running)}, not to the net income of ${formatAmount(net)}.`;
954
+ };
955
+
956
+ const methodOf = (partArg: IPart): TIncomeStatementMethod | null => {
957
+ const labels = partArg.nodes.map((nodeArg) => nodeArg.row.label);
958
+ const totalCost = labels.some((labelArg) => /Materialaufwand|Personalaufwand/i.test(labelArg));
959
+ const costOfSales = labels.some((labelArg) => /Herstellungskosten der zur Erzielung|Vertriebskosten/i.test(labelArg));
960
+ return totalCost === costOfSales ? null : totalCost ? 'total_cost' : 'cost_of_sales';
961
+ };
962
+
963
+ interface IIncomeRead {
964
+ rows: ISectionRows;
965
+ part: IPart;
966
+ expenses: 'negative' | 'positive';
967
+ years: Map<TPeriod, IIncomeYear>;
968
+ priorFailure: string | null;
969
+ }
970
+
971
+ /** reads the income statement; throws `unparseable` where its current year cannot be read */
972
+ const readIncome = (draftArg: ISectionDraft, documentContextArg: string[]): IIncomeRead => {
973
+ const fail: TFail = (messageArg) => {
974
+ throw new FinancialStatementError('unparseable', messageArg);
975
+ };
976
+ const rows = readSectionRows(draftArg, 'income statement', documentContextArg);
977
+ const part = buildPart(rows.rows, true);
978
+ const netIndex = part.roots.findIndex((rootArg) => netIncomePattern.test(rootArg.row.label));
979
+ if (netIndex < 0) {
980
+ fail('The income statement prints no net income (Jahresüberschuss or Jahresfehlbetrag).');
981
+ }
982
+ const netLine = part.lines.findIndex((lineArg) => lineArg.node === part.roots[netIndex]);
983
+ const checked = part.lines
984
+ .slice(0, netLine + 1)
985
+ .filter((lineArg) => lineArg.role === 'node')
986
+ .map((lineArg) => lineArg.node as INode);
987
+ const outcome = settleSection([part], rows, (periodArg) => checkParts(checked, periodArg));
988
+ if (!outcome.has('current')) {
989
+ fail('The income statement prints no amounts for the current year.');
990
+ }
991
+ const currentFailure = outcome.get('current');
992
+ if (currentFailure) {
993
+ fail(`The income statement does not add up for the current year: ${currentFailure}`);
994
+ }
995
+ const years = new Map<TPeriod, IIncomeYear>();
996
+ let expenses: 'negative' | 'positive' = 'negative';
997
+ const asPrinted = addUpIncome(part, netIndex, 'current', false);
998
+ if (typeof asPrinted !== 'string') {
999
+ years.set('current', asPrinted);
1000
+ } else {
1001
+ const subtracted = addUpIncome(part, netIndex, 'current', true);
1002
+ if (typeof subtracted === 'string') {
1003
+ fail(`The income statement does not add up to its net income for the current year: ${asPrinted}`);
1004
+ }
1005
+ years.set('current', subtracted as IIncomeYear);
1006
+ expenses = 'positive';
1007
+ }
1008
+ let priorFailure = outcome.get('prior') ?? null;
1009
+ if (outcome.has('prior') && !priorFailure) {
1010
+ const prior = addUpIncome(part, netIndex, 'prior', expenses === 'positive');
1011
+ if (typeof prior === 'string') {
1012
+ priorFailure = prior;
1013
+ clearYear([part], 'prior');
1014
+ } else {
1015
+ years.set('prior', prior);
1016
+ }
1017
+ }
1018
+ return { rows, part, expenses, years, priorFailure };
1019
+ };
1020
+
1021
+ // ---------------------------------------------------------------------------------------------
1022
+ // the statement
1023
+
1024
+ /** the day a line of a statement names as its balance sheet date: `zum 31.12.2024`, or a period's end */
1025
+ const balanceDateIn = (linesArg: string[]): TIsoDate | null => {
1026
+ for (const line of linesArg) {
1027
+ const period = periodIn(line);
1028
+ if (period.start && period.end) {
1029
+ return period.end;
1030
+ }
1031
+ const date = lastDateIn(line);
1032
+ if (date && /\b(?:zum|per|am|Stichtag)\b/i.test(line)) {
1033
+ return date;
1034
+ }
1035
+ }
1036
+ return null;
1037
+ };
1038
+
1039
+ const rangeIn = (linesArg: string[]): { start: TIsoDate; end: TIsoDate } | null => {
1040
+ for (const line of linesArg) {
1041
+ const period = periodIn(line);
1042
+ if (period.start && period.end) {
1043
+ return { start: period.start, end: period.end };
1044
+ }
1045
+ }
1046
+ return null;
1047
+ };
1048
+
1049
+ const findNode = (nodesArg: INode[], patternArg: RegExp): INode | null =>
1050
+ nodesArg.find((nodeArg) => patternArg.test(nodeArg.row.label)) ?? null;
1051
+
1052
+ const descendantsOf = (nodeArg: INode): INode[] => {
1053
+ const result: INode[] = [];
1054
+ const stack = [...nodeArg.children].reverse();
1055
+ for (let node = stack.pop(); node; node = stack.pop()) {
1056
+ result.push(node);
1057
+ stack.push(...[...node.children].reverse());
1058
+ }
1059
+ return result;
1060
+ };
1061
+
1062
+ const amountText = (amountArg: IAmount | null): string | null => (amountArg ? formatAmount(amountArg) : null);
1063
+
1064
+ /** the printed lines of a part, each with the amounts its year kept */
1065
+ const positionsOf = (partArg: IPart, kindsArg: Map<INode, TStatementLineKind>, keptArg: Set<TPeriod>): IStatementPosition[] =>
1066
+ partArg.lines.map((line) => {
1067
+ const amountFor = (periodArg: TPeriod): string | null => {
1068
+ if (!keptArg.has(periodArg)) {
1069
+ return null;
1070
+ }
1071
+ const values = line.row.values[periodArg];
1072
+ if (line.role === 'node') {
1073
+ return amountText((line.node as INode).value[periodArg]);
1074
+ }
1075
+ return amountText(line.role === 'memo' ? (values.pre ?? values.main) : (values.main ?? values.pre));
1076
+ };
1077
+ const node = line.role === 'node' ? (line.node as INode) : null;
1078
+ let level = 0;
1079
+ if (node) {
1080
+ level = node.depth;
1081
+ } else if (line.role === 'memo') {
1082
+ level = line.node ? line.node.depth + 1 : 0;
1083
+ } else if (line.role === 'sum') {
1084
+ level = (line.closes.current ?? line.closes.prior ?? line.previous)?.depth ?? 0;
1085
+ }
1086
+ const kind: TStatementLineKind =
1087
+ line.role === 'memo' ? 'memo' : line.role === 'total' ? 'total' : line.role === 'sum' ? 'subtotal' : (kindsArg.get(node as INode) ?? 'position');
1088
+ return {
1089
+ code: node ? node.codePath : null,
1090
+ printedCode: node ? (line.row.code?.printed ?? null) : null,
1091
+ label: line.row.label,
1092
+ level,
1093
+ kind,
1094
+ current: amountFor('current'),
1095
+ prior: amountFor('prior'),
1096
+ };
1097
+ });
1098
+
1099
+ /**
1100
+ * Reads a statement under the HGB from the blocks of its document: the balance sheet (§ 266 HGB),
1101
+ * required, and the income statement (§ 275 HGB), where one is published. Throws `unparseable` where
1102
+ * no balance sheet is found or its current year cannot be read with certainty; leaves out with a
1103
+ * note an income statement that cannot, and a prior year that does not add up.
1104
+ */
1105
+ export const readHgbStatement = (blocksArg: TLayoutBlock[]): IHgbStatement => {
1106
+ const unparseable = (messageArg: string): never => {
1107
+ throw new FinancialStatementError('unparseable', messageArg);
1108
+ };
1109
+ const sections = readSections(blocksArg);
1110
+ const notes = [...sections.notes];
1111
+ if (sections.sideBySide) {
1112
+ unparseable('The balance sheet prints its two sides next to each other, which is not read here.');
1113
+ }
1114
+ if (sections.balance.valueRows === 0) {
1115
+ unparseable('No balance sheet was found.');
1116
+ }
1117
+ if (sections.balance.items.some((itemArg) => itemArg.side === null && hasAmountCell(itemArg.cells))) {
1118
+ unparseable('The balance sheet does not mark its sides (Aktiva, Passiva).');
1119
+ }
1120
+ const balanceRows = readSectionRows(sections.balance, 'balance sheet', sections.documentContext);
1121
+ const assets = buildPart(balanceRows.rows.filter((rowArg) => rowArg.side === 'assets'), false);
1122
+ const liabilities = buildPart(balanceRows.rows.filter((rowArg) => rowArg.side === 'liabilities'), false);
1123
+ const balanceOutcome = settleSection([assets, liabilities], balanceRows, (periodArg) =>
1124
+ checkBalanceYear(assets, liabilities, periodArg)
1125
+ );
1126
+ if (!balanceOutcome.has('current')) {
1127
+ unparseable('The balance sheet prints no amounts for the current year.');
1128
+ }
1129
+ const balanceFailure = balanceOutcome.get('current');
1130
+ if (balanceFailure) {
1131
+ unparseable(`The balance sheet does not add up for the current year: ${balanceFailure}`);
1132
+ }
1133
+
1134
+ let income: IIncomeRead | null = null;
1135
+ if (sections.income.valueRows > 0) {
1136
+ try {
1137
+ income = readIncome(sections.income, sections.documentContext);
1138
+ } catch (error) {
1139
+ if (!(error instanceof FinancialStatementError) || error.code !== 'unparseable') {
1140
+ throw error;
1141
+ }
1142
+ notes.push(`The income statement was left out: ${error.message}`);
1143
+ }
1144
+ }
1145
+
1146
+ const priorFailures = [
1147
+ balanceOutcome.get('prior') ? `the balance sheet does not add up for it: ${balanceOutcome.get('prior')}` : null,
1148
+ income?.priorFailure ? `the income statement does not add up for it: ${income.priorFailure}` : null,
1149
+ ].filter(notNull);
1150
+ const priorPrinted = balanceOutcome.has('prior') || (income?.rows.rows.some((rowArg) => printedAmount(rowArg, 'prior')) ?? false);
1151
+ const kept = new Set<TPeriod>(['current']);
1152
+ if (priorPrinted && priorFailures.length === 0) {
1153
+ kept.add('prior');
1154
+ } else if (priorFailures.length > 0) {
1155
+ notes.push(`The prior year's figures were left out: ${priorFailures.join('; ')}`);
1156
+ }
1157
+
1158
+ const equityNode = liabilities.roots.find((rootArg) => /^Eigenkapital/i.test(rootArg.row.label)) ?? null;
1159
+ const balanceNetIncome = equityNode ? findNode(descendantsOf(equityNode), /^(?:Konzern-?)?jahres(?:überschuss|fehlbetrag)/i) : null;
1160
+ const revenueNode = income ? findNode(income.part.nodes, /^Umsatzerlöse/i) : null;
1161
+ const keyFiguresOf = (periodArg: TPeriod): IKeyFigures => ({
1162
+ totalAssets: amountText(assets.total ? printedAmount(assets.total.row, periodArg) : null),
1163
+ equity: amountText(equityNode ? contributionOf(equityNode, periodArg) : null),
1164
+ netIncome: amountText(
1165
+ income?.years.get(periodArg)?.netIncome ?? (balanceNetIncome ? contributionOf(balanceNetIncome, periodArg) : null)
1166
+ ),
1167
+ revenue: amountText(revenueNode ? contributionOf(revenueNode, periodArg) : null),
1168
+ });
1169
+
1170
+ const contexts = [...sections.balance.contexts, ...sections.income.contexts, ...sections.documentContext];
1171
+ const columnOf = (periodArg: TPeriod): IColumnRole | null =>
1172
+ balanceRows.columns[periodArg] ?? income?.rows.columns[periodArg] ?? null;
1173
+ const incomeRange = rangeIn(sections.income.contexts);
1174
+ const documentRange = rangeIn(sections.documentContext);
1175
+ const currentEnd =
1176
+ balanceDateIn(sections.balance.contexts) ??
1177
+ columnOf('current')?.date ??
1178
+ incomeRange?.end ??
1179
+ documentRange?.end ??
1180
+ balanceDateIn(sections.documentContext);
1181
+ const currentStart =
1182
+ currentEnd === null
1183
+ ? null
1184
+ : ([incomeRange, documentRange, rangeIn(contexts.filter((lineArg) => periodIn(lineArg).end === currentEnd))].find(
1185
+ (rangeArg) => rangeArg?.end === currentEnd
1186
+ )?.start ?? null);
1187
+ const periodOf = (periodArg: TPeriod, startArg: TIsoDate | null, endArg: TIsoDate | null): IStatementPeriod => ({
1188
+ heading: columnOf(periodArg)?.heading || null,
1189
+ start: startArg,
1190
+ end: endArg,
1191
+ });
1192
+
1193
+ const incomeKinds = new Map<INode, TStatementLineKind>();
1194
+ let incomeStatement: IIncomeStatement | null = null;
1195
+ if (income) {
1196
+ for (const node of income.years.get('current')?.subtotals ?? []) {
1197
+ incomeKinds.set(node, 'subtotal');
1198
+ }
1199
+ for (const node of income.part.roots) {
1200
+ if (node.row.total) {
1201
+ incomeKinds.set(node, 'subtotal');
1202
+ }
1203
+ }
1204
+ const netNode = income.part.roots.find((rootArg) => netIncomePattern.test(rootArg.row.label));
1205
+ if (netNode) {
1206
+ incomeKinds.set(netNode, 'total');
1207
+ }
1208
+ incomeStatement = {
1209
+ unit: income.rows.unit,
1210
+ method: methodOf(income.part),
1211
+ expenses: income.expenses,
1212
+ positions: positionsOf(income.part, incomeKinds, kept),
1213
+ };
1214
+ }
1215
+
1216
+ return {
1217
+ standard: 'hgb',
1218
+ currency: 'EUR',
1219
+ periods: {
1220
+ current: periodOf('current', currentStart, currentEnd),
1221
+ prior: kept.has('prior') ? periodOf('prior', null, columnOf('prior')?.date ?? null) : null,
1222
+ },
1223
+ keyFigures: {
1224
+ current: keyFiguresOf('current'),
1225
+ prior: kept.has('prior') ? keyFiguresOf('prior') : null,
1226
+ },
1227
+ balanceSheet: {
1228
+ unit: balanceRows.unit,
1229
+ assets: positionsOf(assets, new Map(), kept),
1230
+ equityAndLiabilities: positionsOf(liabilities, new Map(), kept),
1231
+ },
1232
+ incomeStatement,
1233
+ notes,
1234
+ };
1235
+ };