@fin.cx/compatibility
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:
@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.@fin.cx/compatibility/belegbilder: the document-image archive, a DATEV Document-Package:document.xmlafter DATEV'sDocument_v060.xsdplus 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").@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/skrjournal drafts, one per row, as skr reads a Buchungsstapel.
It reads no clock, keeps no state and writes nothing anywhere: files go back to the caller as bytes.
Issue Reporting and Security
For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.
Install
pnpm install @fin.cx/compatibility @fin.cx/skr
@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.
Usage
import * as skrCore from '@fin.cx/skr/core';
import { extf } from '@fin.cx/compatibility';
// or: import * as extf from '@fin.cx/compatibility/extf';
const file = extf.writeBuchungsstapel({
drafts, // skrCore.IJournalDraft[] of the period
policy: skrCore.SKR03_DEFAULT_POLICY,
header: {
createdAt: new Date(),
consultantNumber: 1001,
clientNumber: 1,
fiscalYearStart: new Date('2026-01-01T00:00:00Z'),
dateFrom: new Date('2026-03-01T00:00:00Z'),
dateTo: new Date('2026-03-31T00:00:00Z'),
label: 'Buchungsstapel 2026-03',
festschreibung: false,
},
filename: extf.extfFilename('2026-03'), // EXTF_Buchungsstapel_2026-03.csv
});
file.bytes; // Uint8Array in Windows-1252, served as extf.EXTF_CONTENT_TYPE
file.bookings; // what each row says as the file states it, for keeping and comparing
file.replacedCharacters; // [{ offset, character: 'ő', row: 1, field: 14 }, …]: written as "?"
-
Where a character that became
?stands. Each entry ofreplacedCharactersnames itsoffsetin the text and, for a booking row, the index of that row inrows(from 0) and the number of its DATEV field (from 1; 14 is the Buchungstext). A character of the header line (the label) has neither. -
The same without a file.
extf.replacedCharactersOfRows(rows)names the characters of each row the file could not hold ({ row, field, character }), without a header and without writing anything: for a preview, before the consultant and client numbers of the header are known. It reads each row's line as the writer writes it (@fin.cx/skrdatevRowFields), so a character a field is cut off before (the Buchungstext keeps 60 characters) is not named. -
The same file again, of rows stored.
extf.writeBuchungsstapelOfRows({ rows, header, filename })writes the Buchungsstapel of rows given, for exampleextf.datevRowsOf(drafts, policy)stored when the Stapel was first written: the same rows and header give the same file, byte for byte, with the samebookingsandreplacedCharacters. It is the one writer:writeBuchungsstapelwritesdatevRowsOf(drafts, policy)through it, and it refuses whatwriteBuchungsstapelrefuses. Each Date of a row (belegDatum,leistungsdatum,datumZuordSteuerperiode) must be aDate: rows stored as JSON state them as ISO strings, and a row with a Date field that is noDate, or an invalid one, is refused with codeinvalid_bookings, naming the row and the field; the writer revives no Date, so make each aDateagain before writing. -
A draft the account policy refuses throws
CompatibilityErrorwith codeinvalid_bookingsand a message naming the draft. -
What the reader would refuse is refused before writing. A header whose period ends before it begins or leaves the Wirtschaftsjahr beginning on its Wirtschaftsjahr-Beginn throws code
invalid_header; a row whose Belegdatum lies outside that period or is no day (noDate, or an invalid one), whose Leistungsdatum or Datum Zuord. Steuerperiode is given and is no day, or whose amount is not positive throwsinvalid_bookings, naming the row and, for a day, the field. -
A failed conversion throws code
export_failed. -
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. -
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, Belegfeld 1, cost centres (KOST1, KOST2) and whether it is a general reversal, as a multiset, and ignores wording and document links. A booking stored before 4.1 has no cost-centre fields: its record says nothing of them, and they never make it differ; comparebookingsOfRowsof the rows sent for an answer that counts them. -
The comparison key.
extf.comparisonKeyOf(booking)gives, as a string, the identitydifferingBookingCountandcorrectionOfpair a booking on: its day, amount, side, accounts, BU key, Belegfeld 1, cost centres (KOST1, KOST2) and whether it is a general reversal, taken as a file states them; wording, Belegfeld 2 and document link do not count. Two bookings that record their cost centres are the same booking to the comparison exactly when their keys are equal, so rows can be counted by key (aMapkey) without rebuilding the comparison, and a booking stored of rows before writing has the key of the same booking read back from the file. A booking stored before 4.1, which records no cost centres, pairs with any cost centres and has no one key: it is refused with codehanded_over_cost_centres_unknown; takebookingsOfRowsof the rows sent. The bookingscorrectionOfgives and itsaddedrows taken as a file states them (bookingsAsWritten) all record them. -
The bookings as the file states them.
extf.bookingsAsWritten(rows)(andextf.rowsAsWritten(rows)) writes the rows with skr's writer, encodes them in Windows-1252 and reads them back with skr's reader: a"in a text field becomes', Belegfeld 1 is written as skr's writer writes it (cut to 36 characters before skr 7.3.0; since then a value DATEV does not take is mapped,datevBelegfeldOf, with the original in a Beleginfo), a line break becomes a space and a character Windows-1252 cannot hold becomes?, as in the file.writeBuchungsstapel'sbookingsare read back from the bytes it wrote.differingBookingCountand a correction take both sides as a file states them, so bookings stored before writing (4.1 and before), bookings of this version and rows read back from the file sent compare alike; compare with them, not withbookingsOfRowsof rows that were not written, which keep what the file does not. -
A period whose bookings were stored before 4.1. Such bookings record no cost centres, although the Stapel sent stated them (fields 37 and 38), and
differingBookingCountnever counts them as differing on cost centres alone: a change of a cost centre only is not seen. For such a period read the file handed over back (readExtfBuchungsstapel(bytes).rows) and comparebookingsOfRowsof those rows, or pass them to a correction ashandedOverRows. -
A correction of a period handed over.
writeCorrectionBuchungsstapel({ handedOver, drafts, policy, header, filename })writes only what changed since the state handed over: each booking handed over that the drafts no longer hold is written again with field 118 "Generalumkehr" set, so DATEV takes it back on its own side and day (a general reversal handed over that stands is written as the ordinary row), then each row of the drafts that was not handed over; what stands in both is not written. Each side is compared as what stands of it (bookingsHeldOf), so a Storno in the books, posted on the day of the booking it takes back or on a later one, is no change. A changed text or document link is no change; a changed cost centre is, so the booking is taken back on the cost centre DATEV received and booked on the new one. Every row keeps its Belegdatum, so it counts in the period it belongs to; a booking handed over is never replaced (§ 146 Abs. 4 AO), which also holds once DATEV has fixed the period (Festschreibung). The file names what it takes back (reversed), what it books (added) and the bookings after it (bookingsAfter: each booking it keeps as it was handed over, text and document link included, then the rows it books anew as the correction file states them). Both sides are compared as a file states them, so a reference the writer changes (a", more than 36 characters, a character Windows-1252 cannot hold, a line break) is no change.correctionOf(handedOver, rows)givesreversed,keptandaddedwithout a file, androwOfBooking(booking)the row a booking was written as. -
The state handed over is given once: the bookings stored of it (
handedOver: thebookingsof its Buchungsstapel as they were stored, by this version or by 4.1, or the last correction'sbookingsAfter), or the rows of the Stapel as it was sent (handedOverRows: the transmitted file read back with@fin.cx/skrreadExtfBuchungsstapel(bytes).rows, or therowsgiven to the writer). Every form compares alike. A booking stored before 4.1 has no cost-centre fields, although the Stapel sent stated them (fields 37 and 38): a correction refuses it with codehanded_over_cost_centres_unknown, naming it, since whether it must be taken back turns on the cost centres DATEV holds and a reversal without them would not mirror it; passhandedOverRowsfor such a period. Nothing changed is refused withnothing_to_correct; a state given twice or not at all withinvalid_bookings. -
A period's Stapel one after the other.
bookingsHeldOf(stapel)gives the bookings DATEV holds of a period after its Stapel, handed over in the order given: the period's Buchungsstapel, the additional Stapel after it and its correction Stapel, each as the bookings it stated (bookingsas stored, orbookingsOfRowsof its file read back). A booking takes back one that stands before it and mirrors it: it says what it says (day, amount, side, accounts, BU key, Belegfeld 1 and cost centres, compared as a file states them; wording and document link do not count), and one of the two is a general reversal. Neither stands: a general reversal takes back the booking it mirrors, and an ordinary row the general reversal it mirrors, as a correction takes back a general reversal that stands. Of several it takes the one on its own cost centres first, then one stored before 4.1, which records none, and of several the last. A booking that mirrors none stands, a general reversal too: DATEV books it as the counter-booking on its own day, as it books a Storno posted on a later day than the booking it takes back, or in a later period. The bookings that stand, as they were given, are thehandedOverof the next correction; after one correction they are itsbookingsAfter. Where a Stapel's bookings were stored before 4.1, which record no cost centres, fold the bookings of its file read back (bookingsOfRows) instead, or the next correction refuses them (handed_over_cost_centres_unknown).const handedOver = extf.bookingsHeldOf([monthStapel.bookings, additionalStapel.bookings, correction.bookings]); const next = extf.writeCorrectionBuchungsstapel({ handedOver, drafts, policy, header, filename }); -
A booking keeps its general reversal (
IExportBooking.generalumkehr, from field 118) and its cost centres (kost1,kost2, empty where the row states none).
import { belegbilder } from '@fin.cx/compatibility';
const zip = await belegbilder.createBelegbilderZip(
[
{ guid, description: 'Rechnung 7', bytes: pdfBytes },
{ guid: photoGuid, description: 'Kassenbon', bytes: jpegBytes, format: 'jpeg' },
],
{ createdAt: new Date(), generatingSystem: 'My app' },
);
// served as belegbilder.BELEGBILDER_CONTENT_TYPE under belegbilder.belegbilderFilename('2026-03')
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):
document.xmlin the namespacehttp://xml.datev.de/bedi/tps/document/v06.0,version="6.0", with the header'sdate(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).- One
<document guid="…">per entry, itsdescriptioncut 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 form8-4-4-4-12hexadecimal digits. formatispdf(the default),jpeg,png,gif,tifforbmp(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.- 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.
- 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
generatingSystembefore they are cut. - Refused with
CompatibilityErrorinvalid_documents: no document, more than 4999 (the schema's bound), a GUID of another form, a format outsideBELEGBILD_FORMATS, one GUID for two different files (another format, or other bytes).
Buchungsliste
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.
import * as skrCore from '@fin.cx/skr/core';
import { buchungsliste } from '@fin.cx/compatibility';
// or: import * as buchungsliste from '@fin.cx/compatibility/buchungsliste';
const { entries, warnings } = buchungsliste.importBuchungsliste(bytes, skrCore.SKR03_DEFAULT_POLICY);
entries[0].draft; // skrCore.IJournalDraft, dated by the row's Belegdatum
entries[0].datevRow; // the Buchungsstapel row it was booked from (buchungslisteRowToDatevRow)
entries[0].buchungslisteRow; // the row as the file states it: { row, line, belegDatum, betragCents, sollkonto, … }
warnings; // ['column 9 "Lohnart" is no field of a Buchungsliste this reader knows; its values are not read']
const { rows } = buchungsliste.readBuchungsliste(bytes); // the rows alone, without booking them
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"):
- 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. - 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).
- 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.
How a row is read:
- 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. - 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:
Hand the amount without its sign. It is no general reversal (field 118). - 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 on an Automatikkonto, for example, is read by skr 8.3.1 as the automatism where it books the same tax (key 3 on SKR 03 8611) and refused by skr's check where it books another (key 2 there; skr 8.0.0 to 8.3.0 refuse both). 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. - 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).
- Cost centres: the Kostenstelle is KOST1, the Kostenträger KOST2.
- 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. - 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.
Refused with CompatibilityError invalid_buchungsliste, the message naming the line and the row, and details why (reason, TBuchungslisteRefusalReason), the line, the row, the field (its column's name as the file states it) and its value:
unreadable: 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; a required column missing (Belegdatum, Buchungsbetrag, Sollkonto, Habenkonto), each named, or two columns of one field; a row of another number of fields than the first line; a quote never closed, or text after a closing quote;field_malformed: 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 Buchungsperiode not of 1 or 2 digits;bu_key_unknown: a Steuerschlüssel skr does not know;currency_unsupported: a Währung other than EUR (or empty);field_unmapped: 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);tax_account_unclear: the first row skr cannot book, a tax whose account cannot be told;row_invalid: the first row whose draft skr's check refuses (details.errors).
The reason lets a consumer tell its user why without the message, which states the file's values.
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.
Windows-1252
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 € ‚ ƒ „ … † ‡ ˆ ‰ Š ‹ Œ Ž ‘ ’ “ ” • – — ˜ ™ š › œ ž Ÿ.
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.
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.
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:
- U+FFFD: iconv-lite writes byte 0x9D, a C1 control; this module writes
?. - A character outside the BMP: iconv-lite writes
??, one per UTF-16 unit; this module writes one?.
Open
- 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).
- The
document.xmllayout (<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.
License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the license.md file.
Please note: The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
Trademarks
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
Company Information
Task Venture Capital GmbH Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or further information, please contact us via email at hello@task.vc.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.