@fin.cx/compatibility 5.2.0 → 5.3.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/ts/error.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  * centres its record does not state (stored before 4.1) and no rows sent were given; the message names it;
15
15
  * - `invalid_buchungsliste`: a Buchungsliste that cannot be read or booked as it is: no header line, a required
16
16
  * column missing, a field that is not of its format, a field this version does not map, or a row that gives no
17
- * valid entry; `details` names the line, the row and the field where it can.
17
+ * valid entry; `details` names why (`reason`), the line, the row and the field where it can.
18
18
  */
19
19
  export type TCompatibilityErrorCode =
20
20
  | 'invalid_bookings'
@@ -25,8 +25,35 @@ export type TCompatibilityErrorCode =
25
25
  | 'handed_over_cost_centres_unknown'
26
26
  | 'invalid_buchungsliste';
27
27
 
28
+ /**
29
+ * Why a Buchungsliste was refused (`invalid_buchungsliste`), so a consumer can tell its user without reading the
30
+ * message:
31
+ * - `unreadable`: the text is no list of rows of the first line's fields: a byte order mark of UTF-8, no first line,
32
+ * no delimiter, a quote never closed or text after a closing quote, a required column missing or two columns of
33
+ * one field, a row of another number of fields;
34
+ * - `field_malformed`: a field not of its format (a day, an amount or 0, an account, a Buchungsperiode), or no
35
+ * Belegdatum;
36
+ * - `field_unmapped`: a row that states Zusatzangaben, an EU Land or an EU %, which this version does not map;
37
+ * - `currency_unsupported`: a Währung other than EUR;
38
+ * - `bu_key_unknown`: a Steuerschlüssel `@fin.cx/skr` does not know;
39
+ * - `tax_account_unclear`: a row with a tax where the account it is computed on cannot be told (skr's
40
+ * `extf_tax_account_unclear`);
41
+ * - `row_invalid`: a row whose entry skr refuses otherwise; where its check (`validateJournalDraft`) refused it, what
42
+ * the check found in `details.errors`.
43
+ */
44
+ export type TBuchungslisteRefusalReason =
45
+ | 'unreadable'
46
+ | 'field_malformed'
47
+ | 'field_unmapped'
48
+ | 'currency_unsupported'
49
+ | 'bu_key_unknown'
50
+ | 'tax_account_unclear'
51
+ | 'row_invalid';
52
+
28
53
  /** Where a refusal of a file read stands, as far as it is known; empty for a file written. */
29
54
  export interface ICompatibilityErrorDetails {
55
+ /** why a Buchungsliste was refused; stated on every `invalid_buchungsliste` */
56
+ reason?: TBuchungslisteRefusalReason;
30
57
  /** the data row, from 1 */
31
58
  row?: number;
32
59
  /** the line of the file, from 1 */
package/ts/extf/index.ts CHANGED
@@ -36,8 +36,9 @@ export interface IExportBooking {
36
36
  kost1?: string;
37
37
  kost2?: string;
38
38
  /**
39
- * The row is a general reversal (field 118 "Generalumkehr", written "1"): it takes back the booking of the same
40
- * fields on DATEV's side. Absent on an ordinary row.
39
+ * The row is a general reversal (field 118 "Generalumkehr", written "1"): DATEV books it on the other side, so it
40
+ * takes back a booking of the same fields, and one that mirrors none (a Storno posted on a later day) stands as the
41
+ * counter-booking on its own day. Absent on an ordinary row.
41
42
  */
42
43
  generalumkehr?: true;
43
44
  }
@@ -470,6 +471,65 @@ const bookingNameOf = (bookingArg: IExportBooking, indexArg: number): string =>
470
471
  `booking ${indexArg + 1} (${bookingArg.date}, ${bookingArg.umsatzCents} ${bookingArg.sollHaben} ${bookingArg.konto}/${bookingArg.gegenkonto}`
471
472
  + `${bookingArg.belegfeld1 ? `, Belegfeld 1 ${bookingArg.belegfeld1}` : ''})`;
472
473
 
474
+ /** The booking that takes a booking back on DATEV's side: what it says, its general reversal (field 118) turned over. */
475
+ const mirrorOf = (bookingArg: IExportBooking): IExportBooking => {
476
+ const { generalumkehr, ...fields } = bookingArg;
477
+ return generalumkehr ? fields : { ...fields, generalumkehr: true };
478
+ };
479
+
480
+ /**
481
+ * The indexes of the bookings that stand on DATEV's side after all of them, handed over in their order and compared as
482
+ * a file states them (`comparableOf`), in their order. A booking that mirrors one standing before it (`mirrorOf`: it
483
+ * says what it says, `differingBookingCount`'s fields, the general reversal turned over) takes it back, and neither
484
+ * stands: a general reversal takes back the booking it mirrors, and an ordinary row the general reversal it mirrors. A
485
+ * booking that mirrors none stands, a general reversal too: DATEV books it as the counter-booking on its own day. Of the
486
+ * bookings a booking mirrors, one on the cost centres it states is taken first, then one stored before 4.1, which records
487
+ * none; of several, the last. Linear in their number.
488
+ */
489
+ const standingIndexesOf = (comparableArg: readonly IExportBooking[]): number[] => {
490
+ const standing = comparableArg.map(() => true);
491
+ // the bookings that stand, by what they say: recorded ones on every key field and on the rest, unrecorded ones on the rest
492
+ const recordedByFullKey = new Map<string, number[]>();
493
+ const recordedByBaseKey = new Map<string, number[]>();
494
+ const unrecordedByBaseKey = new Map<string, number[]>();
495
+ const stand = (queuesArg: Map<string, number[]>, keyArg: string, indexArg: number): void => {
496
+ const queue = queuesArg.get(keyArg);
497
+ if (queue) {
498
+ queue.push(indexArg);
499
+ } else {
500
+ queuesArg.set(keyArg, [indexArg]);
501
+ }
502
+ };
503
+ // the last booking of a queue that still stands; a booking taken back through another queue is passed over for good
504
+ const lastStanding = (queuesArg: Map<string, number[]>, keyArg: string): number | undefined => {
505
+ const queue = queuesArg.get(keyArg);
506
+ while (queue && queue.length > 0 && !standing[queue[queue.length - 1]]) {
507
+ queue.pop();
508
+ }
509
+ return queue?.[queue.length - 1];
510
+ };
511
+ comparableArg.forEach((bookingArg, indexArg) => {
512
+ const mirrored = mirrorOf(bookingArg);
513
+ const baseKey = baseKeyOf(mirrored);
514
+ const unrecorded = lastStanding(unrecordedByBaseKey, baseKey);
515
+ const taken = recordsCostCentres(mirrored)
516
+ ? lastStanding(recordedByFullKey, fullKeyOf(mirrored)) ?? unrecorded
517
+ : Math.max(lastStanding(recordedByBaseKey, baseKey) ?? -1, unrecorded ?? -1);
518
+ if (taken !== undefined && taken >= 0) {
519
+ standing[taken] = false;
520
+ standing[indexArg] = false;
521
+ return;
522
+ }
523
+ if (recordsCostCentres(bookingArg)) {
524
+ stand(recordedByFullKey, fullKeyOf(bookingArg), indexArg);
525
+ stand(recordedByBaseKey, baseKeyOf(bookingArg), indexArg);
526
+ } else {
527
+ stand(unrecordedByBaseKey, baseKeyOf(bookingArg), indexArg);
528
+ }
529
+ });
530
+ return comparableArg.flatMap((_bookingArg, indexArg) => (standing[indexArg] ? [indexArg] : []));
531
+ };
532
+
473
533
  /**
474
534
  * Refuses a state handed over that holds a booking whose cost centres its record does not state (stored before 4.1).
475
535
  * The Stapel sent stated them (fields 37 and 38), so DATEV holds them: whether the booking must be taken back turns on
@@ -491,7 +551,10 @@ const refuseCostCentresUnknown = (handedOverArg: readonly IExportBooking[]): voi
491
551
  * handed over that the current ones no longer hold (`reversed`, each once for each time it is missing), the ones they
492
552
  * still hold (`kept`, as they were handed over) and the current rows the bookings handed over do not hold (`added`),
493
553
  * each in its order. A booking that stands in both is kept: a changed text or document link is no changed booking,
494
- * a changed cost centre is. The current rows are compared as a file states them (`bookingsAsWritten`), as the
554
+ * a changed cost centre is. Each side is compared as what stands of it (`bookingsHeldOf`): a general reversal and the
555
+ * booking before it that it mirrors take each other back, among the bookings handed over and among the current rows (a
556
+ * Storno on the day of the booking it takes back), and a general reversal that mirrors none (a Storno posted on a later
557
+ * day) is a booking as any other. The current rows are compared as a file states them (`bookingsAsWritten`), as the
495
558
  * bookings handed over are; `added` are the rows themselves, which the writer writes. A booking handed over whose
496
559
  * cost centres its record does not state (stored before 4.1) is refused (`handed_over_cost_centres_unknown`): pass
497
560
  * `bookingsOfRows` of the rows sent instead.
@@ -502,18 +565,23 @@ export const correctionOf = (
502
565
  ): { reversed: IExportBooking[]; kept: IExportBooking[]; added: IDatevBuchungsstapelRow[] } => {
503
566
  refuseCostCentresUnknown(handedOverArg);
504
567
  // the current rows as a file states them, as the state handed over was: a reference the writer changes is the same
505
- const { leftPaired, rightPaired } = pairBookings(comparableOf(handedOverArg), bookingsAsWritten(currentArg));
568
+ const handedOver = comparableOf(handedOverArg);
569
+ const current = bookingsAsWritten(currentArg);
570
+ // what stands of each
571
+ const held = standingIndexesOf(handedOver);
572
+ const standing = standingIndexesOf(current);
573
+ const { leftPaired, rightPaired } = pairBookings(held.map((indexArg) => handedOver[indexArg]), standing.map((indexArg) => current[indexArg]));
506
574
  return {
507
- reversed: handedOverArg.filter((_bookingArg, indexArg) => !leftPaired[indexArg]),
508
- kept: handedOverArg.filter((_bookingArg, indexArg) => leftPaired[indexArg]),
509
- added: currentArg.filter((_rowArg, indexArg) => !rightPaired[indexArg]),
575
+ reversed: held.filter((_indexArg, positionArg) => !leftPaired[positionArg]).map((indexArg) => handedOverArg[indexArg]),
576
+ kept: held.filter((_indexArg, positionArg) => leftPaired[positionArg]).map((indexArg) => handedOverArg[indexArg]),
577
+ added: standing.filter((_indexArg, positionArg) => !rightPaired[positionArg]).map((indexArg) => currentArg[indexArg]),
510
578
  };
511
579
  };
512
580
 
513
581
  /**
514
582
  * The state of a period handed over, given once: the bookings stored of it (`handedOver`) or the rows of the Stapel
515
- * sent (`handedOverRows`). A general reversal in it is refused: the state handed over is compared, not a correction
516
- * file.
583
+ * sent (`handedOverRows`). What stands of it is compared (`bookingsHeldOf`): a general reversal in it takes back the
584
+ * booking before it that it mirrors, and one that mirrors none (a Storno posted on a later day) stands.
517
585
  */
518
586
  export interface ICorrectionBuchungsstapelOptions {
519
587
  /**
@@ -544,7 +612,10 @@ export interface ICorrectionBuchungsstapelOptions {
544
612
  }
545
613
 
546
614
  export interface ICorrectionBuchungsstapelFile extends IBuchungsstapelFile {
547
- /** the bookings handed over it takes back, each written as a general reversal */
615
+ /**
616
+ * The bookings handed over it takes back, each written as the row that mirrors it: an ordinary booking with field
617
+ * 118 "Generalumkehr" set, a general reversal that stands as the ordinary row.
618
+ */
548
619
  reversed: IExportBooking[];
549
620
  /** the rows it books anew */
550
621
  added: IDatevBuchungsstapelRow[];
@@ -559,11 +630,12 @@ export interface ICorrectionBuchungsstapelFile extends IBuchungsstapelFile {
559
630
  /**
560
631
  * Writes the correction Buchungsstapel of a period handed over whose bookings changed since: only what changed. Each
561
632
  * booking handed over that the drafts no longer hold is written again with field 118 "Generalumkehr" set, so DATEV
562
- * takes it back on its side and day, and each row the drafts hold that was not handed over is written as it is; rows
563
- * in both are not written. Every row keeps its Belegdatum, so it counts in the period it belongs to. A booking handed
564
- * over is never changed or replaced (§ 146 Abs. 4 AO): it is taken back and booked anew. Throws `nothing_to_correct`
565
- * when nothing changed; `invalid_bookings` for a general reversal in the state handed over, or for a state given
566
- * twice or not at all; `handed_over_cost_centres_unknown` for a booking whose cost centres are not known.
633
+ * takes it back on its side and day (a general reversal handed over that stands, as the ordinary row), and each row
634
+ * the drafts hold that was not handed over is written as it is; rows in both are not written. Both are compared as
635
+ * what stands of them (`correctionOf`). Every row keeps its Belegdatum, so it counts in the period it belongs to. A
636
+ * booking handed over is never changed or replaced (§ 146 Abs. 4 AO): it is taken back and booked anew. Throws
637
+ * `nothing_to_correct` when nothing changed; `invalid_bookings` for a state given twice or not at all;
638
+ * `handed_over_cost_centres_unknown` for a booking whose cost centres are not known.
567
639
  */
568
640
  export const writeCorrectionBuchungsstapel = (optionsArg: ICorrectionBuchungsstapelOptions): ICorrectionBuchungsstapelFile => {
569
641
  // rows read back from the file sent are as written already; rows given to the writer become what it wrote
@@ -571,16 +643,12 @@ export const writeCorrectionBuchungsstapel = (optionsArg: ICorrectionBuchungssta
571
643
  if (handedOver === undefined || (optionsArg.handedOver !== undefined && optionsArg.handedOverRows !== undefined)) {
572
644
  throw new CompatibilityError('invalid_bookings', 'pass the state handed over once: the bookings stored (handedOver) or the rows sent (handedOverRows)');
573
645
  }
574
- const reversal = handedOver.findIndex((bookingArg) => bookingArg.generalumkehr === true);
575
- if (reversal >= 0) {
576
- throw new CompatibilityError('invalid_bookings', `the state handed over holds a general reversal (booking ${reversal + 1}); pass the period's bookings as they stood, not a correction file`);
577
- }
578
646
  const current = datevRowsOf(optionsArg.drafts, optionsArg.policy);
579
647
  const { reversed, kept, added } = correctionOf(handedOver, current);
580
648
  if (reversed.length === 0 && added.length === 0) {
581
649
  throw new CompatibilityError('nothing_to_correct', 'the bookings of the period are the ones handed over; there is nothing to correct');
582
650
  }
583
- const rows = [...reversed.map((bookingArg) => ({ ...rowOfBooking(bookingArg), generalumkehr: true })), ...added];
651
+ const rows = [...reversed.map((bookingArg) => rowOfBooking(mirrorOf(bookingArg))), ...added];
584
652
  const file = fileOfRows(rows, optionsArg.header, optionsArg.filename);
585
653
  // the rows booked anew as the correction file states them, after the reversals
586
654
  return { ...file, reversed, added, bookingsAfter: [...kept, ...file.bookings.slice(reversed.length)] };
@@ -590,64 +658,19 @@ export const writeCorrectionBuchungsstapel = (optionsArg: ICorrectionBuchungssta
590
658
  * The bookings of a period as DATEV holds them after its Stapel, handed over in the order given: the period's
591
659
  * Buchungsstapel, the additional Stapel after it and its correction Stapel (`writeCorrectionBuchungsstapel`), each as
592
660
  * the bookings it stated (`IBuchungsstapelFile.bookings` as stored, or `bookingsOfRows` of its file read back), in
593
- * their order. An ordinary booking stands. A general reversal (field 118 "Generalumkehr") takes back one booking that
594
- * stands before it, in an earlier Stapel or earlier in its own, and does not stand itself: the booking it mirrors says
595
- * what it says (`differingBookingCount`'s fields, the reversal apart), compared as a file states them
596
- * (`comparableOf`), so wording and document link do not count. A booking on the cost centres the reversal states is
597
- * taken first, then one stored before 4.1, which records none; of several, the last, as a correction reverses the
598
- * last of several bookings it no longer holds. A reversal of no booking that stands before it is refused
599
- * (`invalid_bookings`, naming its Stapel and place): DATEV would hold a counter-booking of nothing. The bookings that
600
- * stand, as they were given and in their order: what the next correction is compared against (`handedOver`), and
601
- * after one correction its `bookingsAfter`. A booking a Buchungsstapel cannot hold is refused (`invalid_bookings`).
661
+ * their order. A booking that mirrors one standing before it, in an earlier Stapel or earlier in its own, takes it
662
+ * back, and neither stands: the booking it mirrors says what it says (`differingBookingCount`'s fields), compared as a
663
+ * file states them (`comparableOf`), so wording and document link do not count, and is a general reversal (field 118
664
+ * "Generalumkehr") where it is not. A general reversal takes back the booking it mirrors, and an ordinary row the general
665
+ * reversal it mirrors, as a correction takes back a general reversal that stands. A booking that mirrors none stands,
666
+ * a general reversal too: DATEV books it as the counter-booking on its own day, as it books a Storno posted on a later
667
+ * day than the booking it takes back, or in a later period. A booking on the cost centres the reversal states is taken
668
+ * first, then one stored before 4.1, which records none; of several, the last, as a correction reverses the last of
669
+ * several bookings it no longer holds. The bookings that stand, as they were given and in their order: what the next
670
+ * correction is compared against (`handedOver`), and after one correction its `bookingsAfter`. A booking a
671
+ * Buchungsstapel cannot hold is refused (`invalid_bookings`).
602
672
  */
603
673
  export const bookingsHeldOf = (stapelArg: readonly (readonly IExportBooking[])[]): IExportBooking[] => {
604
674
  const given = stapelArg.flat();
605
- const comparable = comparableOf(given);
606
- // where each booking stands: its Stapel and its place in it, for a refusal
607
- const places = stapelArg.flatMap((stapelBookingsArg, stapelIndexArg) => stapelBookingsArg.map((_bookingArg, indexArg) => ({ stapel: stapelIndexArg, index: indexArg })));
608
- const takenBack = given.map(() => false);
609
- // the bookings that stand, by what they say: recorded ones on every key field and on the rest, unrecorded ones on the rest
610
- const recordedByFullKey = new Map<string, number[]>();
611
- const recordedByBaseKey = new Map<string, number[]>();
612
- const unrecordedByBaseKey = new Map<string, number[]>();
613
- const stand = (queuesArg: Map<string, number[]>, keyArg: string, indexArg: number): void => {
614
- const queue = queuesArg.get(keyArg);
615
- if (queue) {
616
- queue.push(indexArg);
617
- } else {
618
- queuesArg.set(keyArg, [indexArg]);
619
- }
620
- };
621
- // the last booking of a queue that still stands; a booking taken back through another queue is passed over for good
622
- const lastStanding = (queuesArg: Map<string, number[]>, keyArg: string): number | undefined => {
623
- const queue = queuesArg.get(keyArg);
624
- while (queue && queue.length > 0 && takenBack[queue[queue.length - 1]]) {
625
- queue.pop();
626
- }
627
- return queue?.[queue.length - 1];
628
- };
629
- comparable.forEach((bookingArg, indexArg) => {
630
- if (!bookingArg.generalumkehr) {
631
- if (recordsCostCentres(bookingArg)) {
632
- stand(recordedByFullKey, fullKeyOf(bookingArg), indexArg);
633
- stand(recordedByBaseKey, baseKeyOf(bookingArg), indexArg);
634
- } else {
635
- stand(unrecordedByBaseKey, baseKeyOf(bookingArg), indexArg);
636
- }
637
- return;
638
- }
639
- // the booking the reversal mirrors says what it says
640
- const { generalumkehr: _generalumkehr, ...mirrored } = bookingArg;
641
- const baseKey = baseKeyOf(mirrored);
642
- const unrecorded = lastStanding(unrecordedByBaseKey, baseKey);
643
- const taken = recordsCostCentres(mirrored)
644
- ? lastStanding(recordedByFullKey, fullKeyOf(mirrored)) ?? unrecorded
645
- : Math.max(lastStanding(recordedByBaseKey, baseKey) ?? -1, unrecorded ?? -1);
646
- if (taken === undefined || taken < 0) {
647
- const { stapel, index } = places[indexArg];
648
- throw new CompatibilityError('invalid_bookings', `Stapel ${stapel + 1}, ${bookingNameOf(given[indexArg], index)} is a general reversal of no booking handed over before it`);
649
- }
650
- takenBack[taken] = true;
651
- });
652
- return given.filter((_bookingArg, indexArg) => !comparable[indexArg].generalumkehr && !takenBack[indexArg]);
675
+ return standingIndexesOf(comparableOf(given)).map((indexArg) => given[indexArg]);
653
676
  };