@fin.cx/compatibility 5.0.0 → 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.
@@ -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
+ };
@@ -0,0 +1,3 @@
1
+ import * as skrCore from '@fin.cx/skr/core';
2
+
3
+ export { skrCore };
@@ -57,3 +57,20 @@ export const encodeWindows1252 = (textArg: string): IWindows1252Result => {
57
57
  }
58
58
  return { bytes: Uint8Array.from(bytes), replaced };
59
59
  };
60
+
61
+ const CODE_POINT_OF_HIGH_BYTE = new Map<number, number>(HIGH_BYTES.map(([codePointArg, byteArg]) => [byteArg, codePointArg]));
62
+
63
+ /**
64
+ * Decodes Windows-1252 as the WHATWG Encoding Standard does: the 27 characters
65
+ * of 0x80–0x9F by the table above, every other byte as the code point of its
66
+ * own number (the five bytes the standard leaves to C1 controls included).
67
+ * It does not rest on the platform's decoder: Node 25.2.1 reads 0x96 as
68
+ * U+0096, not as the dash.
69
+ */
70
+ export const decodeWindows1252 = (bytesArg: Uint8Array): string => {
71
+ let text = '';
72
+ for (const byte of bytesArg) {
73
+ text += String.fromCharCode(CODE_POINT_OF_HIGH_BYTE.get(byte) ?? byte);
74
+ }
75
+ return text;
76
+ };
package/ts/error.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Why an exchange file could not be written:
2
+ * Why an exchange file could not be written or read:
3
3
  * - `invalid_bookings`: a draft the account policy refuses, the message names it; a general reversal among the
4
4
  * bookings handed over to a correction Buchungsstapel (a correction file passed as the state handed over); or a row
5
5
  * or booking a Buchungsstapel does not hold (no day, an amount that is not positive) or whose Belegdatum lies
@@ -10,16 +10,42 @@
10
10
  * - `invalid_documents`: a document of a document-image archive DATEV does not take (its GUID, its format), or none;
11
11
  * - `nothing_to_correct`: a correction Buchungsstapel whose current bookings are the ones handed over;
12
12
  * - `handed_over_cost_centres_unknown`: a correction of a period whose bookings handed over include one whose cost
13
- * centres its record does not state (stored before 4.1) and no rows sent were given; the message names it.
13
+ * centres its record does not state (stored before 4.1) and no rows sent were given; the message names it;
14
+ * - `invalid_buchungsliste`: a Buchungsliste that cannot be read or booked as it is: no header line, a required
15
+ * column missing, a field that is not of its format, a field this version does not map, or a row that gives no
16
+ * valid entry; `details` names the line, the row and the field where it can.
14
17
  */
15
- export type TCompatibilityErrorCode = 'invalid_bookings' | 'invalid_header' | 'export_failed' | 'invalid_documents' | 'nothing_to_correct' | 'handed_over_cost_centres_unknown';
18
+ export type TCompatibilityErrorCode =
19
+ | 'invalid_bookings'
20
+ | 'invalid_header'
21
+ | 'export_failed'
22
+ | 'invalid_documents'
23
+ | 'nothing_to_correct'
24
+ | 'handed_over_cost_centres_unknown'
25
+ | 'invalid_buchungsliste';
26
+
27
+ /** Where a refusal of a file read stands, as far as it is known; empty for a file written. */
28
+ export interface ICompatibilityErrorDetails {
29
+ /** the data row, from 1 */
30
+ row?: number;
31
+ /** the line of the file, from 1 */
32
+ line?: number;
33
+ /** the field, by the name of its column as the file states it */
34
+ field?: string;
35
+ /** the field's value as the file states it */
36
+ value?: string;
37
+ /** what the check of the entry found (`@fin.cx/skr` `validateJournalDraft`) */
38
+ errors?: string[];
39
+ }
16
40
 
17
41
  export class CompatibilityError extends Error {
18
42
  public readonly code: TCompatibilityErrorCode;
43
+ public readonly details: ICompatibilityErrorDetails;
19
44
 
20
- constructor(codeArg: TCompatibilityErrorCode, messageArg: string) {
45
+ constructor(codeArg: TCompatibilityErrorCode, messageArg: string, detailsArg: ICompatibilityErrorDetails = {}) {
21
46
  super(messageArg);
22
47
  this.name = 'CompatibilityError';
23
48
  this.code = codeArg;
49
+ this.details = detailsArg;
24
50
  }
25
51
  }
package/ts/extf/index.ts CHANGED
@@ -164,9 +164,9 @@ const comparableOf = (bookingsArg: readonly IExportBooking[]): IExportBooking[]
164
164
  * what a booking says (its day, amount, side, accounts, BU key, Belegfeld 1,
165
165
  * cost centres, and whether it is a general reversal) and not on its wording or its document link. The rows are
166
166
  * compared as a multiset: a row that stands twice in one and once in the other differs once. Both sides are compared
167
- * as a file states them (`comparableOf`: a `"` as `'`, Belegfeld 1 cut to 36 characters, a character Windows-1252
168
- * cannot hold as `?`), so bookings stored of rows before writing and bookings read from a file compare alike. A
169
- * booking stored before 4.1, which does not record its cost centres, never differs on them (`pairBookings`); compare
167
+ * as a file states them (`comparableOf`: a `"` in a text field as `'`, Belegfeld 1 as skr's writer writes it, a
168
+ * character Windows-1252 cannot hold as `?`), so bookings stored of rows before writing and bookings read from a
169
+ * file compare alike. A booking stored before 4.1, which does not record its cost centres, never differs on them (`pairBookings`); compare
170
170
  * `bookingsOfRows` of the rows sent for an answer that counts them. A booking a Buchungsstapel cannot hold (no day,
171
171
  * no positive amount) is refused (`invalid_bookings`).
172
172
  */
@@ -280,7 +280,8 @@ export interface IBuchungsstapelFile {
280
280
  rows: IDatevBuchungsstapelRow[];
281
281
  /**
282
282
  * The bookings the file states, read back from its bytes (`@fin.cx/skr` `readExtfBuchungsstapel`): a `"` in a
283
- * text field as `'`, Belegfeld 1 cut to 36 characters, a character Windows-1252 cannot hold as `?`. What a file
283
+ * text field as `'`, Belegfeld 1 as skr's writer writes it (cut to 36 characters before skr 7.3.0; since then a value
284
+ * DATEV does not take mapped, `datevBelegfeldOf`), a character Windows-1252 cannot hold as `?`. What a file
284
285
  * read back is compared with, and what a correction's `handedOver` is (4.1 and before: the rows' bookings before
285
286
  * writing).
286
287
  */
@@ -433,8 +434,9 @@ export const rowsAsWritten = (rowsArg: readonly IDatevBuchungsstapelRow[]): IDat
433
434
  /**
434
435
  * The bookings of rows as a Buchungsstapel file states them (`rowsAsWritten`): what a file handed over, read back
435
436
  * with `readExtfBuchungsstapel`, is compared with. `bookingsOfRows` of the rows before writing keeps what the file
436
- * does not (a `"` in Belegfeld 1, a Belegfeld 1 longer than its 36 characters, a character Windows-1252 cannot
437
- * hold), and Belegfeld 1 is compared, so such a booking would differ from the file forever.
437
+ * does not (a Belegfeld 1 the writer changes: cut to 36 characters before skr 7.3.0, mapped since where DATEV does
438
+ * not take it; a character Windows-1252 cannot hold), and Belegfeld 1 is compared, so such a booking would differ
439
+ * from the file forever.
438
440
  */
439
441
  export const bookingsAsWritten = (rowsArg: readonly IDatevBuchungsstapelRow[]): IExportBooking[] => bookingsOfRows(rowsAsWritten(rowsArg));
440
442
 
package/ts/index.ts CHANGED
@@ -2,3 +2,4 @@ export * from './error.js';
2
2
  export * from './encoding/index.js';
3
3
  export * as extf from './extf/index.js';
4
4
  export * as belegbilder from './belegbilder/index.js';
5
+ export * as buchungsliste from './buchungsliste/index.js';