@fin.cx/compatibility 5.0.1 → 5.1.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.
- package/dist_ts/00_commitinfo_data.js +3 -3
- package/dist_ts/buchungsliste/index.d.ts +80 -0
- package/dist_ts/buchungsliste/index.js +385 -0
- package/dist_ts/buchungsliste/plugins.d.ts +2 -0
- package/dist_ts/buchungsliste/plugins.js +3 -0
- package/dist_ts/encoding/index.d.ts +8 -0
- package/dist_ts/encoding/index.js +16 -1
- package/dist_ts/error.d.ts +21 -4
- package/dist_ts/error.js +3 -2
- package/dist_ts/index.d.ts +1 -0
- package/dist_ts/index.js +2 -1
- package/package.json +12 -7
- package/readme.md +51 -2
- package/ts/00_commitinfo_data.ts +2 -2
- package/ts/buchungsliste/index.ts +499 -0
- package/ts/buchungsliste/plugins.ts +3 -0
- package/ts/encoding/index.ts +17 -0
- package/ts/error.ts +30 -4
- package/ts/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fin.cx/compatibility",
|
|
3
|
-
"version": "5.0
|
|
3
|
+
"version": "5.1.0",
|
|
4
4
|
"private": false,
|
|
5
|
-
"description": "Exchange formats of third-party accounting systems: the EXTF Buchungsstapel in Windows-1252 on the rows of @fin.cx/skr,
|
|
5
|
+
"description": "Exchange formats of third-party accounting systems: the EXTF Buchungsstapel in Windows-1252 on the rows of @fin.cx/skr, the document-image archive (document.xml and ZIP), and a payroll program's Buchungsliste read into journal drafts",
|
|
6
6
|
"main": "dist_ts/index.js",
|
|
7
7
|
"typings": "dist_ts/index.d.ts",
|
|
8
8
|
"type": "module",
|
|
@@ -19,6 +19,10 @@
|
|
|
19
19
|
"types": "./dist_ts/belegbilder/index.d.ts",
|
|
20
20
|
"default": "./dist_ts/belegbilder/index.js"
|
|
21
21
|
},
|
|
22
|
+
"./buchungsliste": {
|
|
23
|
+
"types": "./dist_ts/buchungsliste/index.d.ts",
|
|
24
|
+
"default": "./dist_ts/buchungsliste/index.js"
|
|
25
|
+
},
|
|
22
26
|
"./package.json": "./package.json"
|
|
23
27
|
},
|
|
24
28
|
"repository": {
|
|
@@ -29,6 +33,7 @@
|
|
|
29
33
|
"extf",
|
|
30
34
|
"buchungsstapel",
|
|
31
35
|
"belegbilder",
|
|
36
|
+
"buchungsliste",
|
|
32
37
|
"windows-1252",
|
|
33
38
|
"accounting",
|
|
34
39
|
"export",
|
|
@@ -45,11 +50,11 @@
|
|
|
45
50
|
"@push.rocks/smartarchive": "^5.4.1"
|
|
46
51
|
},
|
|
47
52
|
"devDependencies": {
|
|
48
|
-
"@fin.cx/skr": "^
|
|
49
|
-
"@git.zone/tsbuild": "^5.
|
|
53
|
+
"@fin.cx/skr": "^8.0.0",
|
|
54
|
+
"@git.zone/tsbuild": "^5.1.2",
|
|
50
55
|
"@git.zone/tsrun": "^3.0.2",
|
|
51
|
-
"@git.zone/tstest": "^
|
|
52
|
-
"@types/node": "^26.6.
|
|
56
|
+
"@git.zone/tstest": "^7.2.0",
|
|
57
|
+
"@types/node": "^26.6.3"
|
|
53
58
|
},
|
|
54
59
|
"files": [
|
|
55
60
|
"ts/**/*",
|
|
@@ -58,7 +63,7 @@
|
|
|
58
63
|
"license.md"
|
|
59
64
|
],
|
|
60
65
|
"peerDependencies": {
|
|
61
|
-
"@fin.cx/skr": "^7.0.1"
|
|
66
|
+
"@fin.cx/skr": "^7.0.1 || ^8.0.0"
|
|
62
67
|
},
|
|
63
68
|
"scripts": {
|
|
64
69
|
"test": "(tstest test/ --verbose)",
|
package/readme.md
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
# @fin.cx/compatibility
|
|
2
2
|
|
|
3
|
-
Exchange formats of third-party accounting systems: the files a German tax advisor's system imports. The package's name carries no vendor's name; the formats and files keep the names the importing system expects (EXTF, `DATEV_Belege_<label>.zip`, the Datev field names in `@fin.cx/skr`'s rows).
|
|
3
|
+
Exchange formats of third-party accounting systems: the files a German tax advisor's system imports. The package's name carries no vendor's name; the formats and files keep the names the importing system expects (EXTF, `DATEV_Belege_<label>.zip`, the Datev field names in `@fin.cx/skr`'s rows, the Buchungsliste). Three formats, each on its own entry point:
|
|
4
4
|
|
|
5
5
|
- `@fin.cx/compatibility/extf`: the **EXTF Buchungsstapel**. The rows and the text come from `@fin.cx/skr` (`journalDraftsToDatevRows`, `writeExtfBuchungsstapel`); this module checks the drafts, writes the file in Windows-1252 and says which characters the encoding could not hold.
|
|
6
6
|
- `@fin.cx/compatibility/belegbilder`: the **document-image archive**, a DATEV Document-Package: `document.xml` after DATEV's `Document_v060.xsd` plus one `<GUID>.<extension>` per document (a PDF or a picture), as one ZIP, linked to the bookings through the GUID in their Beleglink (field 20, `BEDI "GUID"`).
|
|
7
|
+
- `@fin.cx/compatibility/buchungsliste`: the **Buchungsliste** a payroll program exports for a bookkeeping system's ASCII import (Lexware lohn+gehalt's), read into `@fin.cx/skr` journal drafts, one per row, as skr reads a Buchungsstapel.
|
|
7
8
|
|
|
8
9
|
It reads no clock, keeps no state and writes nothing anywhere: files go back to the caller as bytes.
|
|
9
10
|
|
|
@@ -17,7 +18,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
|
|
17
18
|
pnpm install @fin.cx/compatibility @fin.cx/skr
|
|
18
19
|
```
|
|
19
20
|
|
|
20
|
-
`@fin.cx/skr` (^7.0.1) is a peer dependency: the drafts and the account policy the EXTF writer takes are built by your copy of skr, and the writer turns them into rows with the same copy, so the rows follow the chart the drafts were booked on (which accounts are Automatikkonten, which carry VAT). A second copy of skr of another major would write rows by another chart; a package manager that finds your skr outside the range says so as an unmet peer.
|
|
21
|
+
`@fin.cx/skr` (^7.0.1 || ^8.0.0) is a peer dependency: the drafts and the account policy the EXTF writer takes are built by your copy of skr, and the writer turns them into rows with the same copy, so the rows follow the chart the drafts were booked on (which accounts are Automatikkonten, which carry VAT). The Buchungsliste is booked into drafts by your copy as well. A second copy of skr of another major would write rows by another chart; a package manager that finds your skr outside the range says so as an unmet peer.
|
|
21
22
|
|
|
22
23
|
## Usage
|
|
23
24
|
|
|
@@ -82,12 +83,59 @@ The archive is what DATEV's XML-Schnittstelle online takes as a Document-Package
|
|
|
82
83
|
- Characters XML 1.0 does not allow (C0 controls other than tab, line feed and carriage return, a surrogate not in a pair, U+FFFE, U+FFFF) are left out of a description and of `generatingSystem` before they are cut.
|
|
83
84
|
- Refused with `CompatibilityError` `invalid_documents`: no document, more than 4999 (the schema's bound), a GUID of another form, a format outside `BELEGBILD_FORMATS`, one GUID for two different files (another format, or other bytes).
|
|
84
85
|
|
|
86
|
+
## Buchungsliste
|
|
87
|
+
|
|
88
|
+
The Buchungsliste is the text file a payroll program exports for the ASCII import of a bookkeeping system: Lexware lohn+gehalt writes it under 'Berichte - Buchungsliste' with the target 'Export - Datei', and Lexware buchhaltung reads it under 'Datei - Import - Text/ASCII' (Buchungsdaten). This module reads it into journal drafts, one per row, as `@fin.cx/skr` `importExtfBuchungsstapel` reads a Buchungsstapel: each row becomes a Buchungsstapel row, skr's `journalDraftOfDatevRow` books it (its tax unfolded onto the policy's tax accounts by its Steuerschlüssel or by the automatism of its account), and `validateJournalDraft` checks it against the policy.
|
|
89
|
+
|
|
90
|
+
```typescript
|
|
91
|
+
import * as skrCore from '@fin.cx/skr/core';
|
|
92
|
+
import { buchungsliste } from '@fin.cx/compatibility';
|
|
93
|
+
// or: import * as buchungsliste from '@fin.cx/compatibility/buchungsliste';
|
|
94
|
+
|
|
95
|
+
const { entries, warnings } = buchungsliste.importBuchungsliste(bytes, skrCore.SKR03_DEFAULT_POLICY);
|
|
96
|
+
entries[0].draft; // skrCore.IJournalDraft, dated by the row's Belegdatum
|
|
97
|
+
entries[0].datevRow; // the Buchungsstapel row it was booked from (buchungslisteRowToDatevRow)
|
|
98
|
+
entries[0].buchungslisteRow; // the row as the file states it: { row, line, belegDatum, betragCents, sollkonto, … }
|
|
99
|
+
warnings; // ['column 9 "Lohnart" is no field of a Buchungsliste this reader knows; its values are not read']
|
|
100
|
+
|
|
101
|
+
const { rows } = buchungsliste.readBuchungsliste(bytes); // the rows alone, without booking them
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
What the sources say (Lexware FAQ 000013133 "Export Buchungsdaten aus Lexware lohn+gehalt in Lexware buchhaltung", printed 2023-11-08; "Lexware buchhaltung pro/premium, Schnittstellenbeschreibung ASCII/ANSI-Schnittstelle", section 2 "Buchungsdaten"):
|
|
105
|
+
|
|
106
|
+
- The import reads the file as ANSI (Windows-1252) with "Erster Datensatz enthält Feldnamen": the first line names the fields. The bytes are decoded with `decodeWindows1252`.
|
|
107
|
+
- The fields and their formats: Belegdatum and Buchungsdatum (TT.MM.JJJJ), Buchungsperiode (2 digits), Belegnummernkreis (9 characters), Belegnummer (20), Buchungstext (79), Buchungsbetrag (10 digits before the decimal comma, 2 after: "250,00"), Sollkonto and Habenkonto (9 digits), Steuerschlüssel (2 digits, DATEV's keys), Kostenstelle and Kostenträger (19 characters), Zusatzangaben (2 digits, the § 13b cases), EU Land and EU % (since 2022).
|
|
108
|
+
- The payroll program writes one booking per Lohnart ('lang'; a company pension, a company car and some other cases book a Soll and a Haben of the same amount, which cancel), one per account ('kurz', each with the text 'Lohn & Gehalt Monat'), or the long list per cost centre ('nach Kostenstellen aufgeteilt'), against its Lohn- und Gehalt Verrechnungskonto (SKR 03 1755). The Belegnummer is the Nummernkreis ('LG' unless changed), the year, the month and the Lohnart; the Belegdatum is the last day of the period unless set.
|
|
109
|
+
|
|
110
|
+
How a row is read:
|
|
111
|
+
|
|
112
|
+
- **The columns are taken by their names**, in any case, with or without spaces, the umlauts written out or not: Belegdatum, Buchungsdatum, Buchungsperiode, Belegnummernkreis, Belegnummer, Buchungstext, Buchungsbetrag, Währung, Sollkonto, Habenkonto, Steuerschlüssel, Kostenstelle (or "Kostenstelle 1"), Kostenträger (or "Kostenstelle 2"), Zusatzangaben, EU Land, EU %. A column of any other name, or of none, is not read and named in `warnings`.
|
|
113
|
+
- **The booking**: the amount from the Sollkonto (Konto) to the Habenkonto (Gegenkonto), on the Belegdatum. A **negative amount** keeps both accounts in their places and books the other way, as a Buchungsstapel row states it, by its Soll/Haben mark: `H` and the amount without its sign. It is no general reversal (field 118).
|
|
114
|
+
- **The Steuerschlüssel is the BU key** of the row, one of the keys skr knows (`BU_KEY_RULES`: 1, 2, 3, 8, 9, 18, 19, 40, 91, 94). Which account it is on, and what tax it books, is skr's: a key of a tax on an Automatikkonto, for example, is refused by skr's check. The bookkeeping system's table also names keys skr does not know (4, 5, 6, 7, 16, 17, for rates of other periods, such as 5 % and 16 %); a row with one is refused.
|
|
115
|
+
- **Belegfeld 1** is the Belegnummer, led by the Belegnummernkreis where the file states both and the Belegnummer does not already begin with it, joined without a separator; either alone where the file states only one. skr's writer maps a value DATEV does not take when it writes the row (skr 7.3.0 and later).
|
|
116
|
+
- **Cost centres**: the Kostenstelle is KOST1, the Kostenträger KOST2.
|
|
117
|
+
- **Kept, not booked**: the Buchungsdatum and the Buchungsperiode stand on the row (`buchungsDatum`, `buchungsperiode`); a draft is dated by its Belegdatum, as a Buchungsstapel row is.
|
|
118
|
+
- **The chart is your skr's.** skr 8's SKR 03 knows 8611 "Verrechnete sonstige Sachbezüge aus Fahrzeug-Gestellung 19 % USt" as an Automatikkonto at 19 %: a company car's row onto it without a key books the VAT out of the amount. skr 7 knows 8611 as no Automatikkonto, and the same row books no VAT.
|
|
119
|
+
|
|
120
|
+
Refused with `CompatibilityError` `invalid_buchungsliste`, the message naming the line and the row, and `details` the `line`, the `row`, the `field` (its column's name as the file states it) and its `value`:
|
|
121
|
+
|
|
122
|
+
- a file that begins with the byte order mark of UTF-8, or has no first line; a first line whose names are separated by none of semicolon, tab and comma;
|
|
123
|
+
- a required column missing (Belegdatum, Buchungsbetrag, Sollkonto, Habenkonto), each named, or two columns of one field;
|
|
124
|
+
- a row of another number of fields than the first line; a quote never closed, or text after a closing quote;
|
|
125
|
+
- a day not TT.MM.JJJJ (or none in the Belegdatum); an amount that is not digits with, if any, a decimal comma and at most 2 decimals, at most 10 digits before it (dots may group the thousands, a minus sign may lead it), or 0; an account not of 1 to 9 digits; a Steuerschlüssel skr does not know; a Buchungsperiode not of 1 or 2 digits; a Währung other than EUR (or empty);
|
|
126
|
+
- a row that states Zusatzangaben, an EU Land or an EU %: this version maps none of them (the § 13b and OSS cases need a decision it does not make);
|
|
127
|
+
- the first row skr cannot book (a tax whose account cannot be told) or whose draft its check refuses (`details.errors`).
|
|
128
|
+
|
|
129
|
+
**Unconfirmed until a real export of lohn+gehalt has been read.** No such export has been seen. The field names lohn+gehalt writes in the first line (a secondary source names `Belegdatum;Belegnummer;Buchungstext;Buchungsbetrag;Währung;Sollkonto;Habenkonto;Kostenstelle 1`), the delimiter, the quoting and the line ending are not known; hence the reader takes the columns by name and the delimiter from the first line (the first of semicolon, tab and comma outside quotes), takes a field in double quotes with a quote written twice inside, a line break inside quotes, lines ending in CRLF, LF or CR, and skips a line that states nothing. Unconfirmed as well: that "Kostenstelle 2" is the second cost-centre level (KOST2), how the file writes an empty numeric field (a `0` in Zusatzangaben or EU % is refused, as any value there) and a Steuerschlüssel (written with a leading zero, `09`, or as `0`, it is refused), whether the Belegnummer already holds the Nummernkreis, whether a Währung column is written as `EUR`, and whether amounts are ever negative.
|
|
130
|
+
|
|
85
131
|
## Windows-1252
|
|
86
132
|
|
|
87
133
|
`encodeWindows1252(text)` follows the WHATWG Encoding Standard. Bytes 0x00–0x7F and 0xA0–0xFF are the code points of the same number, and 0x80–0x9F carry the 27 characters € ‚ ƒ „ … † ‡ ˆ ‰ Š ‹ Œ Ž ‘ ’ “ ” • – — ˜ ™ š › œ ž Ÿ.
|
|
88
134
|
|
|
89
135
|
A character outside the encoding becomes one `?` and is listed in `replaced`, so nothing is lost unreported. This includes C1 controls, U+FFFD, and anything outside the Basic Latin and Latin-1 ranges other than those 27.
|
|
90
136
|
|
|
137
|
+
`decodeWindows1252(bytes)` reads the same table back, every other byte (the five the standard leaves to C1 controls included) as the code point of its own number; it does not rest on the platform's decoder.
|
|
138
|
+
|
|
91
139
|
Compared with `iconv-lite` 0.7.3 over every code point of the Basic Multilingual Plane, the output is the same except in two cases:
|
|
92
140
|
|
|
93
141
|
- U+FFFD: iconv-lite writes byte 0x9D, a C1 control; this module writes `?`.
|
|
@@ -95,6 +143,7 @@ Compared with `iconv-lite` 0.7.3 over every code point of the Basic Multilingual
|
|
|
95
143
|
|
|
96
144
|
## Open
|
|
97
145
|
|
|
146
|
+
- **The Buchungsliste as lohn+gehalt writes it**: its field names, delimiter, quoting and line ending are unconfirmed until a real export has been read (see Buchungsliste).
|
|
98
147
|
- **The `document.xml` layout** (`<archive><content><document guid>…`, no namespace) is the one this module took over from its first user. No primary description of the importing system's schema was available when it was written. The layout should be confirmed by one import at a tax advisor before it is relied on.
|
|
99
148
|
|
|
100
149
|
## License and Legal Information
|
package/ts/00_commitinfo_data.ts
CHANGED
|
@@ -3,6 +3,6 @@
|
|
|
3
3
|
*/
|
|
4
4
|
export const commitinfo = {
|
|
5
5
|
name: '@fin.cx/compatibility',
|
|
6
|
-
version: '5.0
|
|
7
|
-
description: 'Exchange formats of third-party accounting systems: the EXTF Buchungsstapel in Windows-1252 on the rows of @fin.cx/skr,
|
|
6
|
+
version: '5.1.0',
|
|
7
|
+
description: 'Exchange formats of third-party accounting systems: the EXTF Buchungsstapel in Windows-1252 on the rows of @fin.cx/skr, the document-image archive (document.xml and ZIP), and a payroll program\'s Buchungsliste read into journal drafts'
|
|
8
8
|
}
|
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
import { CompatibilityError, type ICompatibilityErrorDetails } from '../error.js';
|
|
3
|
+
import { decodeWindows1252 } from '../encoding/index.js';
|
|
4
|
+
|
|
5
|
+
export { CompatibilityError, type TCompatibilityErrorCode, type ICompatibilityErrorDetails } from '../error.js';
|
|
6
|
+
|
|
7
|
+
export type IDatevBuchungsstapelRow = plugins.skrCore.IDatevBuchungsstapelRow;
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
* The Buchungsliste is the text file a payroll program exports for the ASCII import of a bookkeeping system (Lexware
|
|
11
|
+
* lohn+gehalt: 'Berichte - Buchungsliste', target 'Export - Datei'; read by Lexware buchhaltung's 'Datei - Import -
|
|
12
|
+
* Text/ASCII', Buchungsdaten). Sources: Lexware FAQ 000013133 "Export Buchungsdaten aus Lexware lohn+gehalt in Lexware
|
|
13
|
+
* buchhaltung" (printed 2023-11-08) and "Lexware buchhaltung pro/premium, Schnittstellenbeschreibung ASCII/ANSI-
|
|
14
|
+
* Schnittstelle", section 2 "Buchungsdaten".
|
|
15
|
+
* - The import reads the file as ANSI (Windows-1252) with "Erster Datensatz enthält Feldnamen": the first line names
|
|
16
|
+
* the fields.
|
|
17
|
+
* - Its fields: Belegdatum and Buchungsdatum (TT.MM.JJJJ), Buchungsperiode (2 digits), Belegnummernkreis (9
|
|
18
|
+
* characters), Belegnummer (20), Buchungstext (79), Buchungsbetrag (10 digits before the decimal comma, 2 after:
|
|
19
|
+
* "250,00"), Sollkonto and Habenkonto (9 digits), Steuerschlüssel (2 digits, DATEV's keys), Kostenstelle and
|
|
20
|
+
* Kostenträger (19 characters), Zusatzangaben (2 digits, the § 13b cases), EU Land (2 characters) and EU % (2 + 2
|
|
21
|
+
* digits), the last two since the 2022 version.
|
|
22
|
+
* - The payroll program writes one booking per Lohnart ('lang'; some cases, a company pension or a company car, book a
|
|
23
|
+
* Soll and a Haben of the same amount that cancel), one per account ('kurz', text 'Lohn & Gehalt Monat') or the long
|
|
24
|
+
* list per cost centre, against its Lohn- und Gehalt Verrechnungskonto; the Belegnummer is the Nummernkreis ('LG'
|
|
25
|
+
* by default), the year, the month and the Lohnart.
|
|
26
|
+
*
|
|
27
|
+
* Unconfirmed until a real export of lohn+gehalt has been read: the field names it writes in the first line (one
|
|
28
|
+
* secondary source names "Belegdatum;Belegnummer;Buchungstext;Buchungsbetrag;Währung;Sollkonto;Habenkonto;Kostenstelle
|
|
29
|
+
* 1"), the delimiter, the quoting and the line ending. The reader therefore takes its columns from the first line,
|
|
30
|
+
* by name, and the delimiter from it as well; what follows from that is marked "unconfirmed" below.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** The fields of a Buchungsliste this reader knows, by the bookkeeping import's names. */
|
|
34
|
+
type TBuchungslisteField =
|
|
35
|
+
| 'belegdatum'
|
|
36
|
+
| 'buchungsdatum'
|
|
37
|
+
| 'buchungsperiode'
|
|
38
|
+
| 'belegnummernkreis'
|
|
39
|
+
| 'belegnummer'
|
|
40
|
+
| 'buchungstext'
|
|
41
|
+
| 'buchungsbetrag'
|
|
42
|
+
| 'waehrung'
|
|
43
|
+
| 'sollkonto'
|
|
44
|
+
| 'habenkonto'
|
|
45
|
+
| 'steuerschluessel'
|
|
46
|
+
| 'kostenstelle'
|
|
47
|
+
| 'kostentraeger'
|
|
48
|
+
| 'zusatzangaben'
|
|
49
|
+
| 'euLand'
|
|
50
|
+
| 'euProzent';
|
|
51
|
+
|
|
52
|
+
/** How a field is named in a refusal when the file names no column of it. */
|
|
53
|
+
const FIELD_NAMES: Readonly<Record<TBuchungslisteField, string>> = {
|
|
54
|
+
belegdatum: 'Belegdatum',
|
|
55
|
+
buchungsdatum: 'Buchungsdatum',
|
|
56
|
+
buchungsperiode: 'Buchungsperiode',
|
|
57
|
+
belegnummernkreis: 'Belegnummernkreis',
|
|
58
|
+
belegnummer: 'Belegnummer',
|
|
59
|
+
buchungstext: 'Buchungstext',
|
|
60
|
+
buchungsbetrag: 'Buchungsbetrag',
|
|
61
|
+
waehrung: 'Währung',
|
|
62
|
+
sollkonto: 'Sollkonto',
|
|
63
|
+
habenkonto: 'Habenkonto',
|
|
64
|
+
steuerschluessel: 'Steuerschlüssel',
|
|
65
|
+
kostenstelle: 'Kostenstelle',
|
|
66
|
+
kostentraeger: 'Kostenträger',
|
|
67
|
+
zusatzangaben: 'Zusatzangaben',
|
|
68
|
+
euLand: 'EU Land',
|
|
69
|
+
euProzent: 'EU %',
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The field of each column name, as `columnKeyOf` reads it. The names are the import's (section 2 of its
|
|
74
|
+
* description), and "Währung", "Kostenstelle 1" and "Kostenstelle 2" a payroll program may write instead (unconfirmed):
|
|
75
|
+
* the second cost-centre level is DATEV's KOST2, as the Kostenträger is.
|
|
76
|
+
*/
|
|
77
|
+
const FIELD_OF_COLUMN: ReadonlyMap<string, TBuchungslisteField> = new Map<string, TBuchungslisteField>([
|
|
78
|
+
['belegdatum', 'belegdatum'],
|
|
79
|
+
['buchungsdatum', 'buchungsdatum'],
|
|
80
|
+
['buchungsperiode', 'buchungsperiode'],
|
|
81
|
+
['belegnummernkreis', 'belegnummernkreis'],
|
|
82
|
+
['belegnummer', 'belegnummer'],
|
|
83
|
+
['buchungstext', 'buchungstext'],
|
|
84
|
+
['buchungsbetrag', 'buchungsbetrag'],
|
|
85
|
+
['waehrung', 'waehrung'],
|
|
86
|
+
['sollkonto', 'sollkonto'],
|
|
87
|
+
['habenkonto', 'habenkonto'],
|
|
88
|
+
['steuerschluessel', 'steuerschluessel'],
|
|
89
|
+
['kostenstelle', 'kostenstelle'],
|
|
90
|
+
['kostenstelle1', 'kostenstelle'],
|
|
91
|
+
['kostentraeger', 'kostentraeger'],
|
|
92
|
+
['kostenstelle2', 'kostentraeger'],
|
|
93
|
+
['zusatzangaben', 'zusatzangaben'],
|
|
94
|
+
['euland', 'euLand'],
|
|
95
|
+
['eu%', 'euProzent'],
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
/** The fields a row cannot be booked without. */
|
|
99
|
+
const REQUIRED_FIELDS: readonly TBuchungslisteField[] = ['belegdatum', 'buchungsbetrag', 'sollkonto', 'habenkonto'];
|
|
100
|
+
|
|
101
|
+
/** The fields this version reads but does not map (§ 13b and the OSS cases need a decision it does not make): a row that states one is refused. */
|
|
102
|
+
const UNMAPPED_FIELDS: readonly TBuchungslisteField[] = ['zusatzangaben', 'euLand', 'euProzent'];
|
|
103
|
+
|
|
104
|
+
/** The delimiters a Buchungsliste may separate its fields by (unconfirmed), in the order the first line is tried for them. */
|
|
105
|
+
const DELIMITERS: readonly string[] = [';', '\t', ','];
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A column name as it is compared: in lower case, the umlauts and ß written out (ae, oe, ue, ss), without spaces and
|
|
109
|
+
* other punctuation but `%`. "Kostenträger", "KOSTENTRAEGER" and "Kosten träger" are one name.
|
|
110
|
+
*/
|
|
111
|
+
const columnKeyOf = (nameArg: string): string =>
|
|
112
|
+
nameArg
|
|
113
|
+
.toLowerCase()
|
|
114
|
+
.replace(/ä/gu, 'ae')
|
|
115
|
+
.replace(/ö/gu, 'oe')
|
|
116
|
+
.replace(/ü/gu, 'ue')
|
|
117
|
+
.replace(/ß/gu, 'ss')
|
|
118
|
+
.replace(/[^a-z0-9%]/gu, '');
|
|
119
|
+
|
|
120
|
+
/** One row of a Buchungsliste as the file states it: one booking of the amount from the Sollkonto to the Habenkonto. */
|
|
121
|
+
export interface IBuchungslisteRow {
|
|
122
|
+
/** the data row, from 1 */
|
|
123
|
+
row: number;
|
|
124
|
+
/** the line of the file it begins on, from 1: the first line names the fields */
|
|
125
|
+
line: number;
|
|
126
|
+
/** the Belegdatum, midnight UTC; the draft's day */
|
|
127
|
+
belegDatum: Date;
|
|
128
|
+
/** the Buchungsdatum, midnight UTC, where the file states one; kept, not booked (a draft is dated by its Belegdatum) */
|
|
129
|
+
buchungsDatum?: Date;
|
|
130
|
+
/** the Buchungsperiode, where the file states one; kept, not booked */
|
|
131
|
+
buchungsperiode?: number;
|
|
132
|
+
belegnummernkreis?: string;
|
|
133
|
+
belegnummer?: string;
|
|
134
|
+
/** empty where the file states none */
|
|
135
|
+
buchungstext: string;
|
|
136
|
+
/** the Buchungsbetrag in cents as the file states it, negative with a minus sign; never 0 */
|
|
137
|
+
betragCents: number;
|
|
138
|
+
sollkonto: string;
|
|
139
|
+
habenkonto: string;
|
|
140
|
+
/** the Steuerschlüssel, one of DATEV's keys `@fin.cx/skr` knows; empty where the file states none */
|
|
141
|
+
steuerschluessel: plugins.skrCore.TBuKey;
|
|
142
|
+
/** DATEV's KOST1 */
|
|
143
|
+
kostenstelle?: string;
|
|
144
|
+
/** DATEV's KOST2 */
|
|
145
|
+
kostentraeger?: string;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface IBuchungsliste {
|
|
149
|
+
/** in the order of the file */
|
|
150
|
+
rows: IBuchungslisteRow[];
|
|
151
|
+
/** what was read but not taken, each naming its column: a column of a name this reader does not know, or of none */
|
|
152
|
+
warnings: string[];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** One record of the text: its fields and the line it begins on. */
|
|
156
|
+
interface IRecord {
|
|
157
|
+
line: number;
|
|
158
|
+
fields: string[];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** A Buchungsliste that cannot be read as it is. */
|
|
162
|
+
const refusal = (messageArg: string, detailsArg: ICompatibilityErrorDetails): CompatibilityError =>
|
|
163
|
+
new CompatibilityError('invalid_buchungsliste', messageArg, detailsArg);
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The delimiter the first line separates its names by: the first of semicolon, tab and comma that stands in it outside
|
|
167
|
+
* double quotes (unconfirmed which one lohn+gehalt writes). Refuses a first line that has none: a single name cannot
|
|
168
|
+
* be the four fields a row needs.
|
|
169
|
+
*/
|
|
170
|
+
const delimiterOf = (firstLineArg: string, lineArg: number): string => {
|
|
171
|
+
const outside = new Set<string>();
|
|
172
|
+
let quoted = false;
|
|
173
|
+
for (const char of firstLineArg) {
|
|
174
|
+
if (char === '"') {
|
|
175
|
+
quoted = !quoted;
|
|
176
|
+
} else if (!quoted) {
|
|
177
|
+
outside.add(char);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const delimiter = DELIMITERS.find((delimiterArg) => outside.has(delimiterArg));
|
|
181
|
+
if (delimiter === undefined) {
|
|
182
|
+
throw refusal(`line ${lineArg}: the field names of the first line are separated by none of semicolon, tab and comma`, { line: lineArg });
|
|
183
|
+
}
|
|
184
|
+
return delimiter;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The records of the text, each with the line it begins on: fields separated by the delimiter, a record ending at
|
|
189
|
+
* CRLF, LF or CR outside double quotes. A field in double quotes holds the delimiter, a line break and a quote written
|
|
190
|
+
* twice. A quote left open to the end of the text, or text after a closing quote before the next delimiter, is no
|
|
191
|
+
* field of such a file: refused, naming the line.
|
|
192
|
+
*/
|
|
193
|
+
const recordsOf = (textArg: string, delimiterArg: string): IRecord[] => {
|
|
194
|
+
const records: IRecord[] = [];
|
|
195
|
+
let fields: string[] = [];
|
|
196
|
+
let current = '';
|
|
197
|
+
let quoted = false;
|
|
198
|
+
let closed = false;
|
|
199
|
+
let line = 1;
|
|
200
|
+
let recordLine = 1;
|
|
201
|
+
const endField = (): void => {
|
|
202
|
+
fields.push(current);
|
|
203
|
+
current = '';
|
|
204
|
+
closed = false;
|
|
205
|
+
};
|
|
206
|
+
for (let index = 0; index < textArg.length; index += 1) {
|
|
207
|
+
const char = textArg[index];
|
|
208
|
+
const lineBreak = char === '\n' || (char === '\r' && textArg[index + 1] !== '\n');
|
|
209
|
+
if (quoted) {
|
|
210
|
+
if (char === '"' && textArg[index + 1] === '"') {
|
|
211
|
+
current += '"';
|
|
212
|
+
index += 1;
|
|
213
|
+
} else if (char === '"') {
|
|
214
|
+
quoted = false;
|
|
215
|
+
closed = true;
|
|
216
|
+
} else {
|
|
217
|
+
current += char;
|
|
218
|
+
line += lineBreak ? 1 : 0;
|
|
219
|
+
}
|
|
220
|
+
} else if (char === delimiterArg) {
|
|
221
|
+
endField();
|
|
222
|
+
} else if (char === '\r' || char === '\n') {
|
|
223
|
+
if (lineBreak) {
|
|
224
|
+
endField();
|
|
225
|
+
records.push({ line: recordLine, fields });
|
|
226
|
+
fields = [];
|
|
227
|
+
line += 1;
|
|
228
|
+
recordLine = line;
|
|
229
|
+
}
|
|
230
|
+
} else if (closed) {
|
|
231
|
+
throw refusal(`line ${line}: field ${fields.length + 1} has text after its closing quote`, { line });
|
|
232
|
+
} else if (char === '"' && current === '') {
|
|
233
|
+
quoted = true;
|
|
234
|
+
} else {
|
|
235
|
+
current += char;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (quoted) {
|
|
239
|
+
throw refusal(`line ${recordLine}: field ${fields.length + 1} opens a quote that is never closed`, { line: recordLine });
|
|
240
|
+
}
|
|
241
|
+
// the last record, where the text does not end in a line break
|
|
242
|
+
if (current !== '' || closed || fields.length > 0) {
|
|
243
|
+
endField();
|
|
244
|
+
records.push({ line: recordLine, fields });
|
|
245
|
+
}
|
|
246
|
+
return records;
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
/** Whether a record states nothing: an empty line, or one of empty fields only. */
|
|
250
|
+
const isBlank = (recordArg: IRecord): boolean => recordArg.fields.every((fieldArg) => fieldArg.trim() === '');
|
|
251
|
+
|
|
252
|
+
/** A day from `TT.MM.JJJJ`, midnight UTC, or undefined. */
|
|
253
|
+
const dayOf = (valueArg: string): Date | undefined => {
|
|
254
|
+
const match = /^(\d{2})\.(\d{2})\.(\d{4})$/u.exec(valueArg);
|
|
255
|
+
if (!match) return undefined;
|
|
256
|
+
const [day, month, year] = [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
257
|
+
const date = new Date(Date.UTC(year, month - 1, day));
|
|
258
|
+
return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day ? date : undefined;
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* An amount in cents from a decimal comma with at most 2 digits after it, at most 10 digits before it, optionally
|
|
263
|
+
* grouped by dots in threes and led by a minus sign ("1.234,56", "-250,00", "250"), or undefined.
|
|
264
|
+
*/
|
|
265
|
+
const centsOf = (valueArg: string): number | undefined => {
|
|
266
|
+
const match = /^(-?)(\d{1,3}(?:\.\d{3})+|\d+)(?:,(\d{1,2}))?$/u.exec(valueArg);
|
|
267
|
+
if (!match) return undefined;
|
|
268
|
+
const euros = match[2].replace(/\./gu, '');
|
|
269
|
+
if (euros.length > 10) return undefined;
|
|
270
|
+
const cents = Number(euros) * 100 + Number((match[3] ?? '').padEnd(2, '0'));
|
|
271
|
+
return match[1] === '-' ? -cents : cents;
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Reads a Buchungsliste: Windows-1252 bytes, the first line naming the fields, one booking per line after it. The
|
|
276
|
+
* columns are taken by their names (in any case, with or without spaces, the umlauts written out or not), the
|
|
277
|
+
* delimiter from the first line (semicolon, tab or comma); a field may stand in double quotes, a quote written twice
|
|
278
|
+
* inside; lines end in CRLF, LF or CR; a line that states nothing is skipped. A column of a name this reader does not
|
|
279
|
+
* know is not read and named in `warnings`.
|
|
280
|
+
*
|
|
281
|
+
* Refuses with `CompatibilityError` `invalid_buchungsliste`, naming the line, the row and the field: a file in UTF-8
|
|
282
|
+
* with a byte order mark; no first line; a required column missing (Belegdatum, Buchungsbetrag, Sollkonto,
|
|
283
|
+
* Habenkonto), or two columns of one field; a row of another number of fields than the first line; a day not
|
|
284
|
+
* TT.MM.JJJJ; an amount not of the form above, or 0; an account not of 1 to 9 digits; a Steuerschlüssel `@fin.cx/skr`
|
|
285
|
+
* does not know; a Buchungsperiode not of 1 or 2 digits; a Währung other than EUR; and a row that states Zusatzangaben,
|
|
286
|
+
* an EU Land or an EU %, which this version does not map.
|
|
287
|
+
*/
|
|
288
|
+
export const readBuchungsliste = (bytesArg: Uint8Array): IBuchungsliste => {
|
|
289
|
+
if (bytesArg[0] === 0xef && bytesArg[1] === 0xbb && bytesArg[2] === 0xbf) {
|
|
290
|
+
throw refusal('the Buchungsliste begins with the byte order mark of UTF-8; it is read as Windows-1252 (ANSI)', { line: 1 });
|
|
291
|
+
}
|
|
292
|
+
const text = decodeWindows1252(bytesArg);
|
|
293
|
+
const lines = text.split(/\r\n|\n|\r/u);
|
|
294
|
+
// the first line that states anything names the fields
|
|
295
|
+
const firstLineIndex = lines.findIndex((lineArg) => /[^\s;,"]/u.test(lineArg));
|
|
296
|
+
if (firstLineIndex < 0) {
|
|
297
|
+
throw refusal('the Buchungsliste has no first line naming its fields', {});
|
|
298
|
+
}
|
|
299
|
+
const delimiter = delimiterOf(lines[firstLineIndex], firstLineIndex + 1);
|
|
300
|
+
const [captionRecord, ...dataRecords] = recordsOf(text, delimiter).filter((recordArg) => !isBlank(recordArg));
|
|
301
|
+
if (captionRecord === undefined) {
|
|
302
|
+
throw refusal('the Buchungsliste has no first line naming its fields', {});
|
|
303
|
+
}
|
|
304
|
+
const captions = captionRecord.fields.map((fieldArg) => fieldArg.trim());
|
|
305
|
+
|
|
306
|
+
// the column of each field the first line names
|
|
307
|
+
const warnings: string[] = [];
|
|
308
|
+
const columnOf = new Map<TBuchungslisteField, number>();
|
|
309
|
+
captions.forEach((captionArg, columnArg) => {
|
|
310
|
+
const field = FIELD_OF_COLUMN.get(columnKeyOf(captionArg));
|
|
311
|
+
if (field === undefined) {
|
|
312
|
+
warnings.push(
|
|
313
|
+
captionArg === ''
|
|
314
|
+
? `column ${columnArg + 1} has no name; its values are not read`
|
|
315
|
+
: `column ${columnArg + 1} "${captionArg}" is no field of a Buchungsliste this reader knows; its values are not read`,
|
|
316
|
+
);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const earlier = columnOf.get(field);
|
|
320
|
+
if (earlier !== undefined) {
|
|
321
|
+
throw refusal(
|
|
322
|
+
`line ${captionRecord.line}: columns ${earlier + 1} "${captions[earlier]}" and ${columnArg + 1} "${captionArg}" both state the ${FIELD_NAMES[field]}`,
|
|
323
|
+
{ line: captionRecord.line, field: captionArg },
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
columnOf.set(field, columnArg);
|
|
327
|
+
});
|
|
328
|
+
const missing = REQUIRED_FIELDS.filter((fieldArg) => !columnOf.has(fieldArg));
|
|
329
|
+
if (missing.length > 0) {
|
|
330
|
+
throw refusal(`line ${captionRecord.line}: the Buchungsliste has no column ${missing.map((fieldArg) => FIELD_NAMES[fieldArg]).join(', ')}`, {
|
|
331
|
+
line: captionRecord.line,
|
|
332
|
+
field: FIELD_NAMES[missing[0]],
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
/** the name of a field's column as the file states it */
|
|
336
|
+
const nameOf = (fieldArg: TBuchungslisteField): string => {
|
|
337
|
+
const column = columnOf.get(fieldArg);
|
|
338
|
+
return column === undefined ? FIELD_NAMES[fieldArg] : captions[column];
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
const rows = dataRecords.map((recordArg, indexArg): IBuchungslisteRow => {
|
|
342
|
+
const row = indexArg + 1;
|
|
343
|
+
const { line } = recordArg;
|
|
344
|
+
if (recordArg.fields.length !== captions.length) {
|
|
345
|
+
throw refusal(`row ${row} (line ${line}) has ${recordArg.fields.length} fields, not the ${captions.length} of the first line`, { row, line });
|
|
346
|
+
}
|
|
347
|
+
const at = (fieldArg: TBuchungslisteField): string => {
|
|
348
|
+
const column = columnOf.get(fieldArg);
|
|
349
|
+
return column === undefined ? '' : recordArg.fields[column].trim();
|
|
350
|
+
};
|
|
351
|
+
const malformed = (fieldArg: TBuchungslisteField, messageArg: string): CompatibilityError =>
|
|
352
|
+
refusal(`row ${row} (line ${line}): ${messageArg}`, { row, line, field: nameOf(fieldArg), value: at(fieldArg) });
|
|
353
|
+
const day = (fieldArg: TBuchungslisteField): Date | undefined => {
|
|
354
|
+
if (at(fieldArg) === '') return undefined;
|
|
355
|
+
const value = dayOf(at(fieldArg));
|
|
356
|
+
if (!value) throw malformed(fieldArg, `the ${nameOf(fieldArg)} "${at(fieldArg)}" is no day TT.MM.JJJJ`);
|
|
357
|
+
return value;
|
|
358
|
+
};
|
|
359
|
+
const account = (fieldArg: TBuchungslisteField): string => {
|
|
360
|
+
if (!/^\d{1,9}$/u.test(at(fieldArg))) throw malformed(fieldArg, `the ${nameOf(fieldArg)} "${at(fieldArg)}" is no account number of 1 to 9 digits`);
|
|
361
|
+
return at(fieldArg);
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
for (const field of UNMAPPED_FIELDS) {
|
|
365
|
+
if (at(field) !== '') {
|
|
366
|
+
throw malformed(field, `the row states ${nameOf(field)} "${at(field)}"; this version maps no Zusatzangaben (§ 13b), EU Land or EU %`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
const currency = at('waehrung');
|
|
370
|
+
if (currency !== '' && currency !== 'EUR') {
|
|
371
|
+
throw malformed('waehrung', `the row is in ${currency}; these books are in euro`);
|
|
372
|
+
}
|
|
373
|
+
const belegDatum = day('belegdatum');
|
|
374
|
+
if (!belegDatum) throw malformed('belegdatum', `the row states no ${nameOf('belegdatum')}`);
|
|
375
|
+
const buchungsDatum = day('buchungsdatum');
|
|
376
|
+
const period = at('buchungsperiode');
|
|
377
|
+
if (period !== '' && !/^\d{1,2}$/u.test(period)) {
|
|
378
|
+
throw malformed('buchungsperiode', `the ${nameOf('buchungsperiode')} "${period}" is no period of 1 or 2 digits`);
|
|
379
|
+
}
|
|
380
|
+
const betragCents = centsOf(at('buchungsbetrag'));
|
|
381
|
+
if (betragCents === undefined) {
|
|
382
|
+
throw malformed(
|
|
383
|
+
'buchungsbetrag',
|
|
384
|
+
`the ${nameOf('buchungsbetrag')} "${at('buchungsbetrag')}" is no amount of digits with, if any, a decimal comma and at most 2 decimals (at most 10 digits before it)`,
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
if (betragCents === 0) throw malformed('buchungsbetrag', `the ${nameOf('buchungsbetrag')} "${at('buchungsbetrag')}" books nothing`);
|
|
388
|
+
const sollkonto = account('sollkonto');
|
|
389
|
+
const habenkonto = account('habenkonto');
|
|
390
|
+
const key = at('steuerschluessel');
|
|
391
|
+
if (!plugins.skrCore.isKnownBuKey(key)) {
|
|
392
|
+
throw malformed(
|
|
393
|
+
'steuerschluessel',
|
|
394
|
+
`the ${nameOf('steuerschluessel')} "${key}" is no DATEV key these books know (${Object.keys(plugins.skrCore.BU_KEY_RULES).join(', ')})`,
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
const texts = {
|
|
398
|
+
...(at('belegnummernkreis') !== '' ? { belegnummernkreis: at('belegnummernkreis') } : {}),
|
|
399
|
+
...(at('belegnummer') !== '' ? { belegnummer: at('belegnummer') } : {}),
|
|
400
|
+
...(at('kostenstelle') !== '' ? { kostenstelle: at('kostenstelle') } : {}),
|
|
401
|
+
...(at('kostentraeger') !== '' ? { kostentraeger: at('kostentraeger') } : {}),
|
|
402
|
+
};
|
|
403
|
+
return {
|
|
404
|
+
row,
|
|
405
|
+
line,
|
|
406
|
+
belegDatum,
|
|
407
|
+
...(buchungsDatum ? { buchungsDatum } : {}),
|
|
408
|
+
...(period !== '' ? { buchungsperiode: Number(period) } : {}),
|
|
409
|
+
buchungstext: at('buchungstext'),
|
|
410
|
+
betragCents,
|
|
411
|
+
sollkonto,
|
|
412
|
+
habenkonto,
|
|
413
|
+
steuerschluessel: key,
|
|
414
|
+
...texts,
|
|
415
|
+
};
|
|
416
|
+
});
|
|
417
|
+
return { rows, warnings };
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Belegfeld 1 of a row: its Belegnummer, led by its Belegnummernkreis where the file states both and the Belegnummer
|
|
422
|
+
* does not already begin with it (the payroll program forms the Belegnummer from the Nummernkreis, the year, the month
|
|
423
|
+
* and the Lohnart; whether the file states the Nummernkreis inside it as well is unconfirmed), the two joined without
|
|
424
|
+
* a separator. Either alone where the file states only one.
|
|
425
|
+
*/
|
|
426
|
+
const belegfeld1Of = (rowArg: IBuchungslisteRow): string | undefined => {
|
|
427
|
+
const { belegnummernkreis, belegnummer } = rowArg;
|
|
428
|
+
if (belegnummernkreis !== undefined && belegnummer !== undefined) {
|
|
429
|
+
return belegnummer.startsWith(belegnummernkreis) ? belegnummer : `${belegnummernkreis}${belegnummer}`;
|
|
430
|
+
}
|
|
431
|
+
return belegnummer ?? belegnummernkreis;
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* The Buchungsstapel row of a Buchungsliste row (`@fin.cx/skr` `IDatevBuchungsstapelRow`): the amount on the Sollkonto
|
|
436
|
+
* as Konto, the Habenkonto as Gegenkonto. A negative amount keeps both accounts in their places and books the other
|
|
437
|
+
* way, as a Buchungsstapel states it, by its Soll/Haben mark: `H`, the amount without its sign. It is no general
|
|
438
|
+
* reversal (field 118). The Steuerschlüssel is the BU key; the Kostenstelle KOST1 and the Kostenträger KOST2;
|
|
439
|
+
* Belegfeld 1 the Belegnummernkreis and Belegnummer (`belegfeld1Of`), as the file states them: skr's writer maps a
|
|
440
|
+
* value DATEV does not take when it writes the row (skr 7.3.0 and later).
|
|
441
|
+
*/
|
|
442
|
+
export const buchungslisteRowToDatevRow = (rowArg: IBuchungslisteRow): IDatevBuchungsstapelRow => {
|
|
443
|
+
const belegfeld1 = belegfeld1Of(rowArg);
|
|
444
|
+
return {
|
|
445
|
+
umsatzCents: Math.abs(rowArg.betragCents),
|
|
446
|
+
sollHaben: rowArg.betragCents < 0 ? 'H' : 'S',
|
|
447
|
+
konto: rowArg.sollkonto,
|
|
448
|
+
gegenKonto: rowArg.habenkonto,
|
|
449
|
+
buSchluessel: rowArg.steuerschluessel,
|
|
450
|
+
belegDatum: rowArg.belegDatum,
|
|
451
|
+
...(belegfeld1 !== undefined ? { belegfeld1 } : {}),
|
|
452
|
+
buchungstext: rowArg.buchungstext,
|
|
453
|
+
...(rowArg.kostenstelle !== undefined ? { kost1: rowArg.kostenstelle } : {}),
|
|
454
|
+
...(rowArg.kostentraeger !== undefined ? { kost2: rowArg.kostentraeger } : {}),
|
|
455
|
+
};
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
/** One row of a Buchungsliste with the Buchungsstapel row and the journal draft it gives. */
|
|
459
|
+
export interface IImportedBuchungslisteRow {
|
|
460
|
+
/** the data row, from 1 */
|
|
461
|
+
row: number;
|
|
462
|
+
buchungslisteRow: IBuchungslisteRow;
|
|
463
|
+
datevRow: IDatevBuchungsstapelRow;
|
|
464
|
+
draft: plugins.skrCore.IJournalDraft;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Reads a Buchungsliste and gives each row's journal draft, as `@fin.cx/skr` `importExtfBuchungsstapel` gives a
|
|
469
|
+
* Buchungsstapel's: each row's Buchungsstapel row (`buchungslisteRowToDatevRow`) booked by skr's
|
|
470
|
+
* `journalDraftOfDatevRow` (its tax unfolded onto the policy's tax accounts by its key or by the automatism of its
|
|
471
|
+
* account) and checked against the policy (`validateJournalDraft`). Refuses the first row that cannot be read or
|
|
472
|
+
* booked with `CompatibilityError` `invalid_buchungsliste`, naming it (`details.row`, `.line`; what the check found in
|
|
473
|
+
* `details.errors`). What the reading did not take is in `warnings` (`readBuchungsliste`).
|
|
474
|
+
*/
|
|
475
|
+
export const importBuchungsliste = (
|
|
476
|
+
bytesArg: Uint8Array,
|
|
477
|
+
policyArg: plugins.skrCore.IAccountPolicy,
|
|
478
|
+
): { entries: IImportedBuchungslisteRow[]; warnings: string[] } => {
|
|
479
|
+
const { rows, warnings } = readBuchungsliste(bytesArg);
|
|
480
|
+
const entries = rows.map((buchungslisteRow): IImportedBuchungslisteRow => {
|
|
481
|
+
const { row, line } = buchungslisteRow;
|
|
482
|
+
const datevRow = buchungslisteRowToDatevRow(buchungslisteRow);
|
|
483
|
+
let draft: plugins.skrCore.IJournalDraft;
|
|
484
|
+
try {
|
|
485
|
+
draft = plugins.skrCore.journalDraftOfDatevRow(datevRow, policyArg);
|
|
486
|
+
} catch (error) {
|
|
487
|
+
if (error instanceof plugins.skrCore.DatevImportError) {
|
|
488
|
+
throw refusal(`row ${row} (line ${line}): ${error.message}`, { row, line });
|
|
489
|
+
}
|
|
490
|
+
throw error;
|
|
491
|
+
}
|
|
492
|
+
const validation = plugins.skrCore.validateJournalDraft(draft, policyArg);
|
|
493
|
+
if (!validation.valid) {
|
|
494
|
+
throw refusal(`row ${row} (line ${line}) gives no valid entry: ${validation.errors.join('; ')}`, { row, line, errors: validation.errors });
|
|
495
|
+
}
|
|
496
|
+
return { row, buchungslisteRow, datevRow, draft };
|
|
497
|
+
});
|
|
498
|
+
return { entries, warnings };
|
|
499
|
+
};
|