@fin.cx/compatibility 2.0.0 → 4.0.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.
@@ -3,7 +3,7 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@fin.cx/compatibility',
6
- version: '2.0.0',
6
+ version: '4.0.0',
7
7
  description: 'Exchange formats of third-party accounting systems: the EXTF Buchungsstapel in Windows-1252 on the rows of @fin.cx/skr, and the document-image archive (document.xml and ZIP)'
8
8
  };
9
9
  //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiMDBfY29tbWl0aW5mb19kYXRhLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vdHMvMDBfY29tbWl0aW5mb19kYXRhLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBOztHQUVHO0FBQ0gsTUFBTSxDQUFDLE1BQU0sVUFBVSxHQUFHO0lBQ3hCLElBQUksRUFBRSx1QkFBdUI7SUFDN0IsT0FBTyxFQUFFLE9BQU87SUFDaEIsV0FBVyxFQUFFLCtLQUErSztDQUM3TCxDQUFBIn0=
@@ -1,19 +1,39 @@
1
- /** A document image of the archive, linked to its bookings through the GUID in their Beleglink (field 20). */
1
+ /** The formats a document image can have here: PDF and the picture formats DATEV takes as a File (not WebP, AVIF or HEIC). */
2
+ export type TBelegbildFormat = 'pdf' | 'jpeg' | 'png' | 'gif' | 'tiff' | 'bmp';
3
+ export declare const BELEGBILD_FORMATS: readonly TBelegbildFormat[];
4
+ /** A document of the archive, linked to its bookings through the GUID in their Beleglink (field 20). */
2
5
  export interface IBelegbilderDocument {
6
+ /** RFC 4122, `8-4-4-4-12` hexadecimal digits, as DATEV takes it */
3
7
  guid: string;
8
+ /** cut to DATEV's 40 characters; empty, none is written */
4
9
  description: string;
10
+ /** what the file is; a PDF when absent */
11
+ format?: TBelegbildFormat;
5
12
  }
6
13
  export interface IBelegbilderEntry extends IBelegbilderDocument {
7
- /** the document as PDF */
14
+ /** the file as it is: the PDF, or the picture in `format` */
8
15
  bytes: Uint8Array;
9
16
  }
17
+ /** What the archive says of itself. */
18
+ export interface IBelegbilderArchive {
19
+ /** when the archive was written (DATEV's header `date`, required); its UTC clock time is written, as the Buchungsstapel header writes its own. The package reads no clock. */
20
+ createdAt: Date;
21
+ /** the software that wrote it (DATEV's `generatingSystem`, up to 40 characters) */
22
+ generatingSystem?: string;
23
+ }
10
24
  export declare const BELEGBILDER_CONTENT_TYPE = "application/zip";
11
25
  /** `DATEV_Belege_<label>.zip`, for example `DATEV_Belege_2026-03.zip`. */
12
26
  export declare const belegbilderFilename: (labelArg: string) => string;
27
+ /** The name of a document's file in the archive: its GUID and the extension of its format, for example `<GUID>.jpg`. */
28
+ export declare const belegbildFileName: (guidArg: string, formatArg?: TBelegbildFormat) => string;
13
29
  /**
14
- * The manifest of the archive, `document.xml`: one `<document>` per PDF,
15
- * named by its GUID. UTF-8, unlike the Buchungsstapel.
30
+ * The manifest of the archive, `document.xml` after DATEV's `Document_v060.xsd`:
31
+ * one `<document>` per file, named by its GUID, with its description and one
32
+ * `File` extension. A GUID listed twice for the same format is one document;
33
+ * characters XML 1.0 does not allow are left out of the texts. UTF-8, unlike
34
+ * the Buchungsstapel. Refuses, as a `CompatibilityError` `invalid_documents`,
35
+ * what DATEV would refuse.
16
36
  */
17
- export declare const buildDocumentXml: (documentsArg: readonly IBelegbilderDocument[]) => string;
18
- /** `document.xml` and one `<GUID>.pdf` per document, as one ZIP. */
19
- export declare const createBelegbilderZip: (entriesArg: readonly IBelegbilderEntry[]) => Promise<Uint8Array>;
37
+ export declare const buildDocumentXml: (documentsArg: readonly IBelegbilderDocument[], archiveArg: IBelegbilderArchive) => string;
38
+ /** `document.xml` and each document's file under its name (`belegbildFileName`), as one ZIP: a DATEV Document-Package. */
39
+ export declare const createBelegbilderZip: (entriesArg: readonly IBelegbilderEntry[], archiveArg: IBelegbilderArchive) => Promise<Uint8Array>;
@@ -1,7 +1,23 @@
1
1
  import * as plugins from './plugins.js';
2
+ import { CompatibilityError } from '../error.js';
3
+ export const BELEGBILD_FORMATS = ['pdf', 'jpeg', 'png', 'gif', 'tiff', 'bmp'];
4
+ const EXTENSIONS = {
5
+ pdf: 'pdf',
6
+ jpeg: 'jpg',
7
+ png: 'png',
8
+ gif: 'gif',
9
+ tiff: 'tif',
10
+ bmp: 'bmp',
11
+ };
2
12
  export const BELEGBILDER_CONTENT_TYPE = 'application/zip';
3
13
  /** `DATEV_Belege_<label>.zip`, for example `DATEV_Belege_2026-03.zip`. */
4
14
  export const belegbilderFilename = (labelArg) => `DATEV_Belege_${labelArg}.zip`;
15
+ /** The name of a document's file in the archive: its GUID and the extension of its format, for example `<GUID>.jpg`. */
16
+ export const belegbildFileName = (guidArg, formatArg = 'pdf') => `${guidArg}.${EXTENSIONS[formatArg]}`;
17
+ const GUID_PATTERN = /^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$/u;
18
+ /** DATEV's `document` of `content`: 1 to 4999 (`Document_v060.xsd`) */
19
+ const MAX_DOCUMENTS = 4999;
20
+ const MAX_DESCRIPTION = 40;
5
21
  const escapeXml = (valueArg) => valueArg
6
22
  .replace(/&/gu, '&amp;')
7
23
  .replace(/</gu, '&lt;')
@@ -9,23 +25,97 @@ const escapeXml = (valueArg) => valueArg
9
25
  .replace(/"/gu, '&quot;')
10
26
  .replace(/'/gu, '&apos;');
11
27
  /**
12
- * The manifest of the archive, `document.xml`: one `<document>` per PDF,
13
- * named by its GUID. UTF-8, unlike the Buchungsstapel.
28
+ * The characters XML 1.0 does not allow (its production `Char`): C0 controls
29
+ * other than tab, line feed and carriage return, a surrogate not in a pair,
30
+ * U+FFFE and U+FFFF. Left out of a text before it is cut.
31
+ */
32
+ const XML_FORBIDDEN = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/gu;
33
+ /** The characters XML allows, at most `maxArg` of them (characters, not UTF-16 units), trimmed. */
34
+ const clip = (valueArg, maxArg) => [...valueArg.replace(XML_FORBIDDEN, '').trim()].slice(0, maxArg).join('').trim();
35
+ const pad = (valueArg) => String(valueArg).padStart(2, '0');
36
+ /** `YYYY-MM-DDThh:mm:ss` of the date's UTC clock time, without a zone, as DATEV's `p10048` takes it. */
37
+ const headerDate = (dateArg) => `${dateArg.getUTCFullYear()}-${pad(dateArg.getUTCMonth() + 1)}-${pad(dateArg.getUTCDate())}`
38
+ + `T${pad(dateArg.getUTCHours())}:${pad(dateArg.getUTCMinutes())}:${pad(dateArg.getUTCSeconds())}`;
39
+ /**
40
+ * The documents once each: a GUID names one file, so a document linked by
41
+ * two bookings is listed once, under its first description; a GUID that names
42
+ * two different files (another format, or other bytes when `sameFileArg` can
43
+ * tell) is refused.
44
+ */
45
+ const distinctDocuments = (documentsArg, sameFileArg = () => true) => {
46
+ const firstOf = new Map();
47
+ const distinct = [];
48
+ documentsArg.forEach((documentArg, indexArg) => {
49
+ const key = documentArg.guid.toUpperCase();
50
+ const first = firstOf.get(key);
51
+ if (!first) {
52
+ firstOf.set(key, { document: documentArg, index: indexArg });
53
+ distinct.push(documentArg);
54
+ return;
55
+ }
56
+ if ((first.document.format ?? 'pdf') !== (documentArg.format ?? 'pdf') || !sameFileArg(first.document, documentArg)) {
57
+ throw new CompatibilityError('invalid_documents', `The GUID "${documentArg.guid}" names two different files (documents ${first.index + 1} and ${indexArg + 1}).`);
58
+ }
59
+ });
60
+ return distinct;
61
+ };
62
+ const sameBytes = (leftArg, rightArg) => leftArg.bytes.length === rightArg.bytes.length && leftArg.bytes.every((byteArg, indexArg) => byteArg === rightArg.bytes[indexArg]);
63
+ /** Refuses what DATEV does not take: no document or too many, a GUID not of the RFC 4122 form, a format it does not take. */
64
+ const checkDocuments = (documentsArg) => {
65
+ if (documentsArg.length === 0) {
66
+ throw new CompatibilityError('invalid_documents', 'A document package holds at least one document.');
67
+ }
68
+ if (documentsArg.length > MAX_DOCUMENTS) {
69
+ throw new CompatibilityError('invalid_documents', `A document package holds at most ${MAX_DOCUMENTS} documents, not ${documentsArg.length}.`);
70
+ }
71
+ documentsArg.forEach((documentArg, indexArg) => {
72
+ if (!GUID_PATTERN.test(documentArg.guid)) {
73
+ throw new CompatibilityError('invalid_documents', `The GUID "${documentArg.guid}" of document ${indexArg + 1} is not the 8-4-4-4-12 hexadecimal form DATEV takes.`);
74
+ }
75
+ if (documentArg.format !== undefined && !BELEGBILD_FORMATS.includes(documentArg.format)) {
76
+ throw new CompatibilityError('invalid_documents', `Document ${indexArg + 1} is in the format "${String(documentArg.format)}", which a DATEV Document-Package does not take.`);
77
+ }
78
+ });
79
+ };
80
+ /**
81
+ * The manifest of the archive, `document.xml` after DATEV's `Document_v060.xsd`:
82
+ * one `<document>` per file, named by its GUID, with its description and one
83
+ * `File` extension. A GUID listed twice for the same format is one document;
84
+ * characters XML 1.0 does not allow are left out of the texts. UTF-8, unlike
85
+ * the Buchungsstapel. Refuses, as a `CompatibilityError` `invalid_documents`,
86
+ * what DATEV would refuse.
14
87
  */
15
- export const buildDocumentXml = (documentsArg) => {
16
- const lines = ['<?xml version="1.0" encoding="UTF-8"?>', '<archive>', ' <content>'];
17
- for (const document of documentsArg) {
18
- lines.push(` <document guid="${escapeXml(document.guid)}">`, ' <extension>PDF</extension>', ` <filename>${escapeXml(document.guid)}.pdf</filename>`, ' <beleg_type>1</beleg_type>', ...(document.description ? [` <description>${escapeXml(document.description)}</description>`] : []), ' </document>');
88
+ export const buildDocumentXml = (documentsArg, archiveArg) => {
89
+ checkDocuments(documentsArg);
90
+ const documents = distinctDocuments(documentsArg);
91
+ const generatingSystem = archiveArg.generatingSystem === undefined ? '' : clip(archiveArg.generatingSystem, MAX_DESCRIPTION);
92
+ const lines = [
93
+ '<?xml version="1.0" encoding="UTF-8"?>',
94
+ '<archive xmlns="http://xml.datev.de/bedi/tps/document/v06.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
95
+ + ' xsi:schemaLocation="http://xml.datev.de/bedi/tps/document/v06.0 Document_v060.xsd" version="6.0"'
96
+ + `${generatingSystem ? ` generatingSystem="${escapeXml(generatingSystem)}"` : ''}>`,
97
+ ' <header>',
98
+ ` <date>${headerDate(archiveArg.createdAt)}</date>`,
99
+ ' </header>',
100
+ ' <content>',
101
+ ];
102
+ for (const document of documents) {
103
+ const description = clip(document.description, MAX_DESCRIPTION);
104
+ lines.push(` <document guid="${escapeXml(document.guid)}">`, ...(description ? [` <description>${escapeXml(description)}</description>`] : []), ` <extension xsi:type="File" name="${escapeXml(belegbildFileName(document.guid, document.format))}"/>`, ' </document>');
19
105
  }
20
106
  lines.push(' </content>', '</archive>', '');
21
107
  return lines.join('\n');
22
108
  };
23
- /** `document.xml` and one `<GUID>.pdf` per document, as one ZIP. */
24
- export const createBelegbilderZip = async (entriesArg) => {
109
+ /** `document.xml` and each document's file under its name (`belegbildFileName`), as one ZIP: a DATEV Document-Package. */
110
+ export const createBelegbilderZip = async (entriesArg, archiveArg) => {
111
+ checkDocuments(entriesArg);
112
+ // one file per GUID: the same document linked by two bookings once, one GUID for two different files refused
113
+ const entries = distinctDocuments(entriesArg, sameBytes);
114
+ const manifest = buildDocumentXml(entries, archiveArg);
25
115
  const zipTools = new plugins.smartarchiveZip.ZipTools();
26
116
  return zipTools.createZip([
27
- { archivePath: 'document.xml', content: buildDocumentXml(entriesArg) },
28
- ...entriesArg.map((entryArg) => ({ archivePath: `${entryArg.guid}.pdf`, content: entryArg.bytes })),
117
+ { archivePath: 'document.xml', content: manifest },
118
+ ...entries.map((entryArg) => ({ archivePath: belegbildFileName(entryArg.guid, entryArg.format), content: entryArg.bytes })),
29
119
  ]);
30
120
  };
31
- //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5kZXguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi90cy9iZWxlZ2JpbGRlci9pbmRleC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxPQUFPLEtBQUssT0FBTyxNQUFNLGNBQWMsQ0FBQztBQWF4QyxNQUFNLENBQUMsTUFBTSx3QkFBd0IsR0FBRyxpQkFBaUIsQ0FBQztBQUUxRCwwRUFBMEU7QUFDMUUsTUFBTSxDQUFDLE1BQU0sbUJBQW1CLEdBQUcsQ0FBQyxRQUFnQixFQUFVLEVBQUUsQ0FBQyxnQkFBZ0IsUUFBUSxNQUFNLENBQUM7QUFFaEcsTUFBTSxTQUFTLEdBQUcsQ0FBQyxRQUFnQixFQUFVLEVBQUUsQ0FDN0MsUUFBUTtLQUNMLE9BQU8sQ0FBQyxLQUFLLEVBQUUsT0FBTyxDQUFDO0tBQ3ZCLE9BQU8sQ0FBQyxLQUFLLEVBQUUsTUFBTSxDQUFDO0tBQ3RCLE9BQU8sQ0FBQyxLQUFLLEVBQUUsTUFBTSxDQUFDO0tBQ3RCLE9BQU8sQ0FBQyxLQUFLLEVBQUUsUUFBUSxDQUFDO0tBQ3hCLE9BQU8sQ0FBQyxLQUFLLEVBQUUsUUFBUSxDQUFDLENBQUM7QUFFOUI7OztHQUdHO0FBQ0gsTUFBTSxDQUFDLE1BQU0sZ0JBQWdCLEdBQUcsQ0FBQyxZQUE2QyxFQUFVLEVBQUU7SUFDeEYsTUFBTSxLQUFLLEdBQUcsQ0FBQyx3Q0FBd0MsRUFBRSxXQUFXLEVBQUUsYUFBYSxDQUFDLENBQUM7SUFDckYsS0FBSyxNQUFNLFFBQVEsSUFBSSxZQUFZLEVBQUUsQ0FBQztRQUNwQyxLQUFLLENBQUMsSUFBSSxDQUNSLHVCQUF1QixTQUFTLENBQUMsUUFBUSxDQUFDLElBQUksQ0FBQyxJQUFJLEVBQ25ELGtDQUFrQyxFQUNsQyxtQkFBbUIsU0FBUyxDQUFDLFFBQVEsQ0FBQyxJQUFJLENBQUMsaUJBQWlCLEVBQzVELGtDQUFrQyxFQUNsQyxHQUFHLENBQUMsUUFBUSxDQUFDLFdBQVcsQ0FBQyxDQUFDLENBQUMsQ0FBQyxzQkFBc0IsU0FBUyxDQUFDLFFBQVEsQ0FBQyxXQUFXLENBQUMsZ0JBQWdCLENBQUMsQ0FBQyxDQUFDLENBQUMsRUFBRSxDQUFDLEVBQ3hHLGlCQUFpQixDQUNsQixDQUFDO0lBQ0osQ0FBQztJQUNELEtBQUssQ0FBQyxJQUFJLENBQUMsY0FBYyxFQUFFLFlBQVksRUFBRSxFQUFFLENBQUMsQ0FBQztJQUM3QyxPQUFPLEtBQUssQ0FBQyxJQUFJLENBQUMsSUFBSSxDQUFDLENBQUM7QUFDMUIsQ0FBQyxDQUFDO0FBRUYsb0VBQW9FO0FBQ3BFLE1BQU0sQ0FBQyxNQUFNLG9CQUFvQixHQUFHLEtBQUssRUFBRSxVQUF3QyxFQUF1QixFQUFFO0lBQzFHLE1BQU0sUUFBUSxHQUFHLElBQUksT0FBTyxDQUFDLGVBQWUsQ0FBQyxRQUFRLEVBQUUsQ0FBQztJQUN4RCxPQUFPLFFBQVEsQ0FBQyxTQUFTLENBQUM7UUFDeEIsRUFBRSxXQUFXLEVBQUUsY0FBYyxFQUFFLE9BQU8sRUFBRSxnQkFBZ0IsQ0FBQyxVQUFVLENBQUMsRUFBRTtRQUN0RSxHQUFHLFVBQVUsQ0FBQyxHQUFHLENBQUMsQ0FBQyxRQUFRLEVBQUUsRUFBRSxDQUFDLENBQUMsRUFBRSxXQUFXLEVBQUUsR0FBRyxRQUFRLENBQUMsSUFBSSxNQUFNLEVBQUUsT0FBTyxFQUFFLFFBQVEsQ0FBQyxLQUFLLEVBQUUsQ0FBQyxDQUFDO0tBQ3BHLENBQUMsQ0FBQztBQUNMLENBQUMsQ0FBQyJ9
121
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5kZXguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi90cy9iZWxlZ2JpbGRlci9pbmRleC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxPQUFPLEtBQUssT0FBTyxNQUFNLGNBQWMsQ0FBQztBQUN4QyxPQUFPLEVBQUUsa0JBQWtCLEVBQUUsTUFBTSxhQUFhLENBQUM7QUFxQmpELE1BQU0sQ0FBQyxNQUFNLGlCQUFpQixHQUFnQyxDQUFDLEtBQUssRUFBRSxNQUFNLEVBQUUsS0FBSyxFQUFFLEtBQUssRUFBRSxNQUFNLEVBQUUsS0FBSyxDQUFDLENBQUM7QUFFM0csTUFBTSxVQUFVLEdBQStDO0lBQzdELEdBQUcsRUFBRSxLQUFLO0lBQ1YsSUFBSSxFQUFFLEtBQUs7SUFDWCxHQUFHLEVBQUUsS0FBSztJQUNWLEdBQUcsRUFBRSxLQUFLO0lBQ1YsSUFBSSxFQUFFLEtBQUs7SUFDWCxHQUFHLEVBQUUsS0FBSztDQUNYLENBQUM7QUF5QkYsTUFBTSxDQUFDLE1BQU0sd0JBQXdCLEdBQUcsaUJBQWlCLENBQUM7QUFFMUQsMEVBQTBFO0FBQzFFLE1BQU0sQ0FBQyxNQUFNLG1CQUFtQixHQUFHLENBQUMsUUFBZ0IsRUFBVSxFQUFFLENBQUMsZ0JBQWdCLFFBQVEsTUFBTSxDQUFDO0FBRWhHLHdIQUF3SDtBQUN4SCxNQUFNLENBQUMsTUFBTSxpQkFBaUIsR0FBRyxDQUFDLE9BQWUsRUFBRSxZQUE4QixLQUFLLEVBQVUsRUFBRSxDQUFDLEdBQUcsT0FBTyxJQUFJLFVBQVUsQ0FBQyxTQUFTLENBQUMsRUFBRSxDQUFDO0FBRXpJLE1BQU0sWUFBWSxHQUFHLGdGQUFnRixDQUFDO0FBQ3RHLHVFQUF1RTtBQUN2RSxNQUFNLGFBQWEsR0FBRyxJQUFJLENBQUM7QUFDM0IsTUFBTSxlQUFlLEdBQUcsRUFBRSxDQUFDO0FBRTNCLE1BQU0sU0FBUyxHQUFHLENBQUMsUUFBZ0IsRUFBVSxFQUFFLENBQzdDLFFBQVE7S0FDTCxPQUFPLENBQUMsS0FB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
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Why an exchange file could not be written:
3
3
  * - `invalid_bookings`: a draft the account policy refuses; the message names it;
4
- * - `export_failed`: the conversion into rows failed.
4
+ * - `export_failed`: the conversion into rows failed;
5
+ * - `invalid_documents`: a document of a document-image archive DATEV does not take (its GUID, its format), or none.
5
6
  */
6
- export type TCompatibilityErrorCode = 'invalid_bookings' | 'export_failed';
7
+ export type TCompatibilityErrorCode = 'invalid_bookings' | 'export_failed' | 'invalid_documents';
7
8
  export declare class CompatibilityError extends Error {
8
9
  readonly code: TCompatibilityErrorCode;
9
10
  constructor(codeArg: TCompatibilityErrorCode, messageArg: string);
package/dist_ts/error.js CHANGED
@@ -5,4 +5,4 @@ export class CompatibilityError extends Error {
5
5
  this.code = codeArg;
6
6
  }
7
7
  }
8
- //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiZXJyb3IuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi90cy9lcnJvci50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFPQSxNQUFNLE9BQU8sa0JBQW1CLFNBQVEsS0FBSztJQUczQyxZQUFZLE9BQWdDLEVBQUUsVUFBa0I7UUFDOUQsS0FBSyxDQUFDLFVBQVUsQ0FBQyxDQUFDO1FBQ2xCLElBQUksQ0FBQyxJQUFJLEdBQUcsb0JBQW9CLENBQUM7UUFDakMsSUFBSSxDQUFDLElBQUksR0FBRyxPQUFPLENBQUM7SUFDdEIsQ0FBQztDQUNGIn0=
8
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiZXJyb3IuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi90cy9lcnJvci50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFRQSxNQUFNLE9BQU8sa0JBQW1CLFNBQVEsS0FBSztJQUczQyxZQUFZLE9BQWdDLEVBQUUsVUFBa0I7UUFDOUQsS0FBSyxDQUFDLFVBQVUsQ0FBQyxDQUFDO1FBQ2xCLElBQUksQ0FBQyxJQUFJLEdBQUcsb0JBQW9CLENBQUM7UUFDakMsSUFBSSxDQUFDLElBQUksR0FBRyxPQUFPLENBQUM7SUFDdEIsQ0FBQztDQUNGIn0=
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fin.cx/compatibility",
3
- "version": "2.0.0",
3
+ "version": "4.0.0",
4
4
  "private": false,
5
5
  "description": "Exchange formats of third-party accounting systems: the EXTF Buchungsstapel in Windows-1252 on the rows of @fin.cx/skr, and the document-image archive (document.xml and ZIP)",
6
6
  "main": "dist_ts/index.js",
@@ -42,12 +42,12 @@
42
42
  },
43
43
  "homepage": "https://code.foss.global/fin.cx/compatibility#readme",
44
44
  "dependencies": {
45
- "@fin.cx/skr": "^5.2.0",
46
45
  "@push.rocks/smartarchive": "^5.4.1"
47
46
  },
48
47
  "devDependencies": {
48
+ "@fin.cx/skr": "^6.0.0",
49
49
  "@git.zone/tsbuild": "^5.0.0",
50
- "@git.zone/tsrun": "^3.0.1",
50
+ "@git.zone/tsrun": "^3.0.2",
51
51
  "@git.zone/tstest": "^6.3.2",
52
52
  "@types/node": "^26.6.2"
53
53
  },
@@ -57,6 +57,9 @@
57
57
  "readme.md",
58
58
  "license.md"
59
59
  ],
60
+ "peerDependencies": {
61
+ "@fin.cx/skr": "^6.0.0"
62
+ },
60
63
  "scripts": {
61
64
  "test": "(tstest test/ --verbose)",
62
65
  "build": "(tsbuild)",
package/readme.md CHANGED
@@ -3,7 +3,7 @@
3
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). Two 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
- - `@fin.cx/compatibility/belegbilder`: the **document-image archive**, a `document.xml` manifest plus one `<GUID>.pdf` per document, as one ZIP, linked to the bookings through the GUID in their Beleglink (field 20).
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
7
 
8
8
  It reads no clock, keeps no state and writes nothing anywhere: files go back to the caller as bytes.
9
9
 
@@ -14,9 +14,11 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
14
14
  ## Install
15
15
 
16
16
  ```bash
17
- pnpm install @fin.cx/compatibility
17
+ pnpm install @fin.cx/compatibility @fin.cx/skr
18
18
  ```
19
19
 
20
+ `@fin.cx/skr` (^6.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). 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
+
20
22
  ## Usage
21
23
 
22
24
  ```typescript
@@ -49,15 +51,31 @@ file.replacedCharacters; // [{ offset, character: 'ő', row: 1, field: 14 }, …
49
51
 
50
52
  - **A draft the account policy refuses** throws `CompatibilityError` with code `invalid_bookings` and a message naming the draft.
51
53
  - **A failed conversion** throws code `export_failed`.
54
+ - **A document-image archive DATEV would not take** (no document, a GUID of another form, a format it does not take) throws code `invalid_documents`.
52
55
  - **`differingBookingCount(left, right)`** counts the rows two Stapel of one period do not share. It compares each booking's day, amount, side, accounts, BU key and Belegfeld 1, as a multiset, and ignores wording and document links.
53
56
 
54
57
  ```typescript
55
58
  import { belegbilder } from '@fin.cx/compatibility';
56
59
 
57
- const zip = await belegbilder.createBelegbilderZip([{ guid, description: 'Rechnung 7', bytes: pdfBytes }]);
60
+ const zip = await belegbilder.createBelegbilderZip(
61
+ [
62
+ { guid, description: 'Rechnung 7', bytes: pdfBytes },
63
+ { guid: photoGuid, description: 'Kassenbon', bytes: jpegBytes, format: 'jpeg' },
64
+ ],
65
+ { createdAt: new Date(), generatingSystem: 'My app' },
66
+ );
58
67
  // served as belegbilder.BELEGBILDER_CONTENT_TYPE under belegbilder.belegbilderFilename('2026-03')
59
68
  ```
60
69
 
70
+ The archive is what DATEV's XML-Schnittstelle online takes as a Document-Package (developer.datev.de, "DATEV XML interface online", format specification and XSD files, read 2026-09-28):
71
+
72
+ - `document.xml` in the namespace `http://xml.datev.de/bedi/tps/document/v06.0`, `version="6.0"`, with the header's `date` (`createdAt`'s UTC clock time without a zone, as the Buchungsstapel header writes its own; the package reads no clock) and, when given, `generatingSystem` (up to 40 characters).
73
+ - One `<document guid="…">` per entry, its `description` cut to DATEV's 40 characters (none when empty), and one `<extension xsi:type="File" name="<GUID>.<extension>"/>`. The GUID is what DATEV Unternehmen online files the document under and what the Beleglink names; it must have the RFC 4122 form `8-4-4-4-12` hexadecimal digits.
74
+ - `format` is `pdf` (the default), `jpeg`, `png`, `gif`, `tiff` or `bmp` (`BELEGBILD_FORMATS`), written as `.pdf`, `.jpg`, `.png`, `.gif`, `.tif`, `.bmp` (`belegbildFileName`). DATEV takes these among its File types (PDF, XML, TIF, TIFF, BMP, CSV, DOC, DOCX, GIF, JPEG, JPG, ODS, ODT, PKCS7, PNG, RTF, TXT, XLS, XLSX); it does not take WebP, AVIF or HEIC, so a picture in one of them must be converted by the caller first.
75
+ - One file per GUID: a document listed twice (the same document linked by two bookings: the same GUID, format and bytes) is in the archive once, under its first description.
76
+ - 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.
77
+ - 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).
78
+
61
79
  ## Windows-1252
62
80
 
63
81
  `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 € ‚ ƒ „ … † ‡ ˆ ‰ Š ‹ Œ Ž ‘ ’ “ ” • – — ˜ ™ š › œ ž Ÿ.
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@fin.cx/compatibility',
6
- version: '2.0.0',
6
+ version: '4.0.0',
7
7
  description: 'Exchange formats of third-party accounting systems: the EXTF Buchungsstapel in Windows-1252 on the rows of @fin.cx/skr, and the document-image archive (document.xml and ZIP)'
8
8
  }
@@ -1,21 +1,72 @@
1
1
  import * as plugins from './plugins.js';
2
+ import { CompatibilityError } from '../error.js';
2
3
 
3
- /** A document image of the archive, linked to its bookings through the GUID in their Beleglink (field 20). */
4
+ /*
5
+ * The document-image archive is a DATEV Document-Package of the DATEV
6
+ * XML-Schnittstelle online: a ZIP of `document.xml`, written after DATEV's
7
+ * schema `Document_v060.xsd` (namespace `http://xml.datev.de/bedi/tps/document/v06.0`),
8
+ * and the files it names. Source: developer.datev.de, "DATEV XML interface
9
+ * online", format specification and XSD files (read 2026-09-28).
10
+ * - Each `<document>` carries its GUID (`guid`, RFC 4122, `[0-9A-F]{8}-…-[0-9A-F]{12}`),
11
+ * under which DATEV Unternehmen online files the document; a booking's
12
+ * Beleglink (EXTF field 20, `BEDI "GUID"`) names it.
13
+ * - Its file is an `<extension xsi:type="File" name="…"/>`. DATEV takes as a
14
+ * File: PDF, XML, TIF, TIFF, BMP, CSV, DOC, DOCX, GIF, JPEG, JPG, ODS, ODT,
15
+ * PKCS7, PNG, RTF, TXT, XLS, XLSX; file names in ASCII (the GUID is).
16
+ * - The header's `date` is a date-time without a zone (`.+T[^Z+-]+`);
17
+ * a document's `description` has 1 to 40 characters.
18
+ */
19
+
20
+ /** The formats a document image can have here: PDF and the picture formats DATEV takes as a File (not WebP, AVIF or HEIC). */
21
+ export type TBelegbildFormat = 'pdf' | 'jpeg' | 'png' | 'gif' | 'tiff' | 'bmp';
22
+
23
+ export const BELEGBILD_FORMATS: readonly TBelegbildFormat[] = ['pdf', 'jpeg', 'png', 'gif', 'tiff', 'bmp'];
24
+
25
+ const EXTENSIONS: Readonly<Record<TBelegbildFormat, string>> = {
26
+ pdf: 'pdf',
27
+ jpeg: 'jpg',
28
+ png: 'png',
29
+ gif: 'gif',
30
+ tiff: 'tif',
31
+ bmp: 'bmp',
32
+ };
33
+
34
+ /** A document of the archive, linked to its bookings through the GUID in their Beleglink (field 20). */
4
35
  export interface IBelegbilderDocument {
36
+ /** RFC 4122, `8-4-4-4-12` hexadecimal digits, as DATEV takes it */
5
37
  guid: string;
38
+ /** cut to DATEV's 40 characters; empty, none is written */
6
39
  description: string;
40
+ /** what the file is; a PDF when absent */
41
+ format?: TBelegbildFormat;
7
42
  }
8
43
 
9
44
  export interface IBelegbilderEntry extends IBelegbilderDocument {
10
- /** the document as PDF */
45
+ /** the file as it is: the PDF, or the picture in `format` */
11
46
  bytes: Uint8Array;
12
47
  }
13
48
 
49
+ /** What the archive says of itself. */
50
+ export interface IBelegbilderArchive {
51
+ /** when the archive was written (DATEV's header `date`, required); its UTC clock time is written, as the Buchungsstapel header writes its own. The package reads no clock. */
52
+ createdAt: Date;
53
+ /** the software that wrote it (DATEV's `generatingSystem`, up to 40 characters) */
54
+ generatingSystem?: string;
55
+ }
56
+
14
57
  export const BELEGBILDER_CONTENT_TYPE = 'application/zip';
15
58
 
16
59
  /** `DATEV_Belege_<label>.zip`, for example `DATEV_Belege_2026-03.zip`. */
17
60
  export const belegbilderFilename = (labelArg: string): string => `DATEV_Belege_${labelArg}.zip`;
18
61
 
62
+ /** The name of a document's file in the archive: its GUID and the extension of its format, for example `<GUID>.jpg`. */
63
+ export const belegbildFileName = (guidArg: string, formatArg: TBelegbildFormat = 'pdf'): string => `${guidArg}.${EXTENSIONS[formatArg]}`;
64
+
65
+ const GUID_PATTERN = /^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$/u;
66
+ /** DATEV's `document` of `content`: 1 to 4999 (`Document_v060.xsd`) */
67
+ const MAX_DOCUMENTS = 4999;
68
+ const MAX_DESCRIPTION = 40;
69
+
19
70
  const escapeXml = (valueArg: string): string =>
20
71
  valueArg
21
72
  .replace(/&/gu, '&amp;')
@@ -25,18 +76,98 @@ const escapeXml = (valueArg: string): string =>
25
76
  .replace(/'/gu, '&apos;');
26
77
 
27
78
  /**
28
- * The manifest of the archive, `document.xml`: one `<document>` per PDF,
29
- * named by its GUID. UTF-8, unlike the Buchungsstapel.
79
+ * The characters XML 1.0 does not allow (its production `Char`): C0 controls
80
+ * other than tab, line feed and carriage return, a surrogate not in a pair,
81
+ * U+FFFE and U+FFFF. Left out of a text before it is cut.
82
+ */
83
+ const XML_FORBIDDEN = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/gu;
84
+
85
+ /** The characters XML allows, at most `maxArg` of them (characters, not UTF-16 units), trimmed. */
86
+ const clip = (valueArg: string, maxArg: number): string => [...valueArg.replace(XML_FORBIDDEN, '').trim()].slice(0, maxArg).join('').trim();
87
+
88
+ const pad = (valueArg: number): string => String(valueArg).padStart(2, '0');
89
+
90
+ /** `YYYY-MM-DDThh:mm:ss` of the date's UTC clock time, without a zone, as DATEV's `p10048` takes it. */
91
+ const headerDate = (dateArg: Date): string =>
92
+ `${dateArg.getUTCFullYear()}-${pad(dateArg.getUTCMonth() + 1)}-${pad(dateArg.getUTCDate())}`
93
+ + `T${pad(dateArg.getUTCHours())}:${pad(dateArg.getUTCMinutes())}:${pad(dateArg.getUTCSeconds())}`;
94
+
95
+ /**
96
+ * The documents once each: a GUID names one file, so a document linked by
97
+ * two bookings is listed once, under its first description; a GUID that names
98
+ * two different files (another format, or other bytes when `sameFileArg` can
99
+ * tell) is refused.
100
+ */
101
+ const distinctDocuments = <TDocument extends IBelegbilderDocument>(
102
+ documentsArg: readonly TDocument[],
103
+ sameFileArg: (leftArg: TDocument, rightArg: TDocument) => boolean = () => true,
104
+ ): TDocument[] => {
105
+ const firstOf = new Map<string, { document: TDocument; index: number }>();
106
+ const distinct: TDocument[] = [];
107
+ documentsArg.forEach((documentArg, indexArg) => {
108
+ const key = documentArg.guid.toUpperCase();
109
+ const first = firstOf.get(key);
110
+ if (!first) {
111
+ firstOf.set(key, { document: documentArg, index: indexArg });
112
+ distinct.push(documentArg);
113
+ return;
114
+ }
115
+ if ((first.document.format ?? 'pdf') !== (documentArg.format ?? 'pdf') || !sameFileArg(first.document, documentArg)) {
116
+ throw new CompatibilityError('invalid_documents', `The GUID "${documentArg.guid}" names two different files (documents ${first.index + 1} and ${indexArg + 1}).`);
117
+ }
118
+ });
119
+ return distinct;
120
+ };
121
+
122
+ const sameBytes = (leftArg: IBelegbilderEntry, rightArg: IBelegbilderEntry): boolean =>
123
+ leftArg.bytes.length === rightArg.bytes.length && leftArg.bytes.every((byteArg, indexArg) => byteArg === rightArg.bytes[indexArg]);
124
+
125
+ /** Refuses what DATEV does not take: no document or too many, a GUID not of the RFC 4122 form, a format it does not take. */
126
+ const checkDocuments = (documentsArg: readonly IBelegbilderDocument[]): void => {
127
+ if (documentsArg.length === 0) {
128
+ throw new CompatibilityError('invalid_documents', 'A document package holds at least one document.');
129
+ }
130
+ if (documentsArg.length > MAX_DOCUMENTS) {
131
+ throw new CompatibilityError('invalid_documents', `A document package holds at most ${MAX_DOCUMENTS} documents, not ${documentsArg.length}.`);
132
+ }
133
+ documentsArg.forEach((documentArg, indexArg) => {
134
+ if (!GUID_PATTERN.test(documentArg.guid)) {
135
+ throw new CompatibilityError('invalid_documents', `The GUID "${documentArg.guid}" of document ${indexArg + 1} is not the 8-4-4-4-12 hexadecimal form DATEV takes.`);
136
+ }
137
+ if (documentArg.format !== undefined && !BELEGBILD_FORMATS.includes(documentArg.format)) {
138
+ throw new CompatibilityError('invalid_documents', `Document ${indexArg + 1} is in the format "${String(documentArg.format)}", which a DATEV Document-Package does not take.`);
139
+ }
140
+ });
141
+ };
142
+
143
+ /**
144
+ * The manifest of the archive, `document.xml` after DATEV's `Document_v060.xsd`:
145
+ * one `<document>` per file, named by its GUID, with its description and one
146
+ * `File` extension. A GUID listed twice for the same format is one document;
147
+ * characters XML 1.0 does not allow are left out of the texts. UTF-8, unlike
148
+ * the Buchungsstapel. Refuses, as a `CompatibilityError` `invalid_documents`,
149
+ * what DATEV would refuse.
30
150
  */
31
- export const buildDocumentXml = (documentsArg: readonly IBelegbilderDocument[]): string => {
32
- const lines = ['<?xml version="1.0" encoding="UTF-8"?>', '<archive>', ' <content>'];
33
- for (const document of documentsArg) {
151
+ export const buildDocumentXml = (documentsArg: readonly IBelegbilderDocument[], archiveArg: IBelegbilderArchive): string => {
152
+ checkDocuments(documentsArg);
153
+ const documents = distinctDocuments(documentsArg);
154
+ const generatingSystem = archiveArg.generatingSystem === undefined ? '' : clip(archiveArg.generatingSystem, MAX_DESCRIPTION);
155
+ const lines = [
156
+ '<?xml version="1.0" encoding="UTF-8"?>',
157
+ '<archive xmlns="http://xml.datev.de/bedi/tps/document/v06.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
158
+ + ' xsi:schemaLocation="http://xml.datev.de/bedi/tps/document/v06.0 Document_v060.xsd" version="6.0"'
159
+ + `${generatingSystem ? ` generatingSystem="${escapeXml(generatingSystem)}"` : ''}>`,
160
+ ' <header>',
161
+ ` <date>${headerDate(archiveArg.createdAt)}</date>`,
162
+ ' </header>',
163
+ ' <content>',
164
+ ];
165
+ for (const document of documents) {
166
+ const description = clip(document.description, MAX_DESCRIPTION);
34
167
  lines.push(
35
168
  ` <document guid="${escapeXml(document.guid)}">`,
36
- ' <extension>PDF</extension>',
37
- ` <filename>${escapeXml(document.guid)}.pdf</filename>`,
38
- ' <beleg_type>1</beleg_type>',
39
- ...(document.description ? [` <description>${escapeXml(document.description)}</description>`] : []),
169
+ ...(description ? [` <description>${escapeXml(description)}</description>`] : []),
170
+ ` <extension xsi:type="File" name="${escapeXml(belegbildFileName(document.guid, document.format))}"/>`,
40
171
  ' </document>',
41
172
  );
42
173
  }
@@ -44,11 +175,15 @@ export const buildDocumentXml = (documentsArg: readonly IBelegbilderDocument[]):
44
175
  return lines.join('\n');
45
176
  };
46
177
 
47
- /** `document.xml` and one `<GUID>.pdf` per document, as one ZIP. */
48
- export const createBelegbilderZip = async (entriesArg: readonly IBelegbilderEntry[]): Promise<Uint8Array> => {
178
+ /** `document.xml` and each document's file under its name (`belegbildFileName`), as one ZIP: a DATEV Document-Package. */
179
+ export const createBelegbilderZip = async (entriesArg: readonly IBelegbilderEntry[], archiveArg: IBelegbilderArchive): Promise<Uint8Array> => {
180
+ checkDocuments(entriesArg);
181
+ // one file per GUID: the same document linked by two bookings once, one GUID for two different files refused
182
+ const entries = distinctDocuments(entriesArg, sameBytes);
183
+ const manifest = buildDocumentXml(entries, archiveArg);
49
184
  const zipTools = new plugins.smartarchiveZip.ZipTools();
50
185
  return zipTools.createZip([
51
- { archivePath: 'document.xml', content: buildDocumentXml(entriesArg) },
52
- ...entriesArg.map((entryArg) => ({ archivePath: `${entryArg.guid}.pdf`, content: entryArg.bytes })),
186
+ { archivePath: 'document.xml', content: manifest },
187
+ ...entries.map((entryArg) => ({ archivePath: belegbildFileName(entryArg.guid, entryArg.format), content: entryArg.bytes })),
53
188
  ]);
54
189
  };
package/ts/error.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Why an exchange file could not be written:
3
3
  * - `invalid_bookings`: a draft the account policy refuses; the message names it;
4
- * - `export_failed`: the conversion into rows failed.
4
+ * - `export_failed`: the conversion into rows failed;
5
+ * - `invalid_documents`: a document of a document-image archive DATEV does not take (its GUID, its format), or none.
5
6
  */
6
- export type TCompatibilityErrorCode = 'invalid_bookings' | 'export_failed';
7
+ export type TCompatibilityErrorCode = 'invalid_bookings' | 'export_failed' | 'invalid_documents';
7
8
 
8
9
  export class CompatibilityError extends Error {
9
10
  public readonly code: TCompatibilityErrorCode;