jkunz bbed8b2171
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / metadata (push) Skipped
v4.1.0
2026-09-27 16:44:57 +00:00
2026-09-27 16:44:57 +00:00
2025-08-10 17:11:53 +00:00
2026-09-27 16:44:57 +00:00
2026-09-27 16:44:57 +00:00

@fin.cx/skr

@fin.cx/skr is a TypeScript library for German double-entry bookkeeping with built-in SKR03 and SKR04 chart initialization, MongoDB-backed persistence, reporting, DATEV export, GoBD-oriented Jahresabschluss export, and e-invoice workflows.

It is built for developers who need a programmable accounting core instead of a pile of CSV glue code: initialize a chart of accounts, post validated transactions and journal entries, generate reports, and archive year-end data in a structured export format.

Version 2 makes the ledger filing-grade:

  • Exact integer-cent amounts internally (no float drift; balance checks are ===)
  • Atomic posting via MongoDB transactions (replica set required)
  • GoBD audit trail: gapless sequence numbers, SHA-256 hash chain, immutable posted entries, Storno-only corrections
  • Festschreibung: period locking with cryptographic chain verification
  • A pure, stateless booking core (core export, also importable on its own as @fin.cx/skr/core without loading the MongoDB ledger): BU keys, tax-scenario detection, booking recipes, validation — no database needed
  • DATEV Buchungsstapel export in the real EXTF 700 / Formatversion 13 layout (125 columns); journalDraftsToDatevRows consolidates every draft into one row per booking (VAT lines folded into the gross row with the BU key, §13b / intra-EU pairs dropped, split lines against their aggregate), and document links are written as BEDI "GUID"

Breaking changes in v2

  • MongoDB must run as a replica set (single-node is fine); initialize() fails hard without transaction support. gitzone services start creates a suitable MongoDB automatically.
  • postTransaction() / reverseTransaction() return transaction-shaped views (ITransactionView), not Transaction documents; the Transaction collection is frozen as a legacy read model.
  • Posted entries are immutable: reversing never mutates the original (status stays posted; linkage via reversalOf).
  • exportToDATEV() now emits the EXTF 700 format; persist the string as CP1252.
  • IJournalEntryLine.postingKey is optional; the v2 line fields (side, amountCents, counterAccount, string buKey) are authoritative.
  • Existing v1 data: run api.migrateToV2() once (idempotent; keeps all legacy fields; returns a report to archive).

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.

What This Library Does

  • Initializes SKR03 or SKR04 account sets in MongoDB
  • Enforces double-entry bookkeeping rules for transactions and journal entries
  • Supports DATEV posting keys and VAT-aware journal lines
  • Prevents direct posting to automatic accounts that require personal accounts
  • Generates trial balance, income statement, balance sheet, general ledger, and cash flow reports
  • Exports accounting data as CSV, DATEV, and GoBD-style Jahresabschluss packages
  • Imports, stores, searches, books, and exports EN16931-style e-invoices
  • Adds signing and timestamp helpers for audit-oriented export workflows

Why It Is Useful

  • You get a real accounting domain model, not just account lists
  • SKR03 and SKR04 are both supported behind one API
  • Tests cover initialization, posting, reversals, reports, pagination, DATEV export, and full year-end flows
  • The package exports the lower-level classes too, so you can stay high-level with SkrApi or build around the primitives

Requirements

  • Node.js 20+ with ESM support
  • A reachable MongoDB instance running as a replica set (transactions are mandatory for atomic posting; a single-node replica set is sufficient — gitzone services start provides one locally)
  • pnpm

The test setup reads MongoDB connection details from .nogit/ via @push.rocks/qenv, but the runtime API only needs a mongoDbUrl and an optional dbName.

If you use the invoice helpers, pass invoiceExportPath to control where content-addressed invoice XML, PDF files, metadata, validation output, and the registry are stored. Without it, invoices are stored under ./exports/invoices from the current working directory.

Installation

pnpm add @fin.cx/skr

Quick Start

import { SkrApi } from '@fin.cx/skr';

const api = new SkrApi({
  mongoDbUrl: 'mongodb://localhost:27017',
  dbName: 'accounting_demo',
  invoiceExportPath: './exports/invoices',
});

await api.initialize('SKR03');

await api.postTransaction({
  date: new Date(),
  debitAccount: '1200',
  creditAccount: '8400',
  amount: 1000,
  description: 'Test sale',
  reference: 'INV-001',
  skrType: 'SKR03',
});

const trialBalance = await api.generateTrialBalance();

console.log(trialBalance.isBalanced);

await api.close();

SKR03 vs SKR04

initialize('SKR03') loads the process-oriented chart.

  • Class 3: Wareneingang
  • Class 4: betriebliche Aufwendungen
  • Class 8: Erlöse
  • Class 9: Vortrags- und Abschlusskonten

initialize('SKR04') loads the financial-statement-oriented chart.

  • Class 4: betriebliche Erträge
  • Class 5 and 6: betriebliche Aufwendungen
  • Class 2: Eigenkapital, Class 3: Fremdkapital

The test suite exercises both variants and includes full Jahresabschluss scenarios for each.

Posting Model

Simple postings use postTransaction().

await api.postTransaction({
  date: new Date(),
  debitAccount: '5400',
  creditAccount: '70001',
  amount: 119,
  description: 'Purchase including VAT',
  skrType: 'SKR03',
  vatAmount: 19,
  reference: 'VAT-001',
});

Complex bookings use postJournalEntry() with explicit DATEV posting keys.

await api.postJournalEntry({
  date: new Date(),
  description: 'Complex distribution',
  reference: 'COMPLEX-001',
  lines: [
    { accountNumber: '5000', debit: 500, description: 'Materials', postingKey: 40 },
    { accountNumber: '6000', debit: 300, description: 'Wages', postingKey: 40 },
    { accountNumber: '7100', debit: 200, description: 'Rent', postingKey: 40 },
    { accountNumber: '1200', credit: 1000, description: 'Bank payment', postingKey: 40 },
  ],
  skrType: 'SKR03',
});

Important behavior from the code and tests:

  • debit and credit totals must balance
  • debit and credit account cannot be the same in a simple transaction
  • inactive accounts cannot be posted to
  • automatic accounts such as debtor or creditor control accounts are meant to be replaced by personal accounts for direct postings

Common Workflows

Create custom accounts:

await api.createAccount({
  accountNumber: '4999',
  accountName: 'Custom Revenue Account',
  accountClass: 4,
  accountType: 'revenue',
  description: 'Test custom account',
});

Batch operations:

await api.createBatchAccounts([
  {
    accountNumber: '10001',
    accountName: 'Kunde Mustermann GmbH',
    accountClass: 1,
    accountType: 'asset',
    skrType: 'SKR03',
  },
  {
    accountNumber: '70001',
    accountName: 'Lieferant Test GmbH',
    accountClass: 7,
    accountType: 'liability',
    skrType: 'SKR03',
  },
]);

Pagination:

const page1 = await api.getAccountsPaginated(1, 10);
console.log(page1.total, page1.totalPages, page1.data.length);

Reversals and validation:

const ok = api.validateDoubleEntry(100, 100);
const reversed = await api.reverseTransaction(transactionId);

Period, balance, and audit helpers:

await api.closePeriod('2024-12', '9400');

const bankBalance = await api.getAccountBalance('1200', new Date('2024-12-31'));

await api.recalculateBalances();

const auditFindings = await api.getUnbalancedTransactions();

Account CSV import and export:

const imported = await api.importAccountsFromCSV(csvContent);
const accountCsv = await api.exportAccountsToCSV();

v2 Ledger: Recipes, Festschreibung, Audit Chain

The stateless core builds validated drafts; the poster writes them atomically:

import { SkrApi, core } from '@fin.cx/skr';

const api = new SkrApi({ mongoDbUrl, dbName });
await api.initialize('SKR03');
const policy = api.getDefaultPolicy();

// booking recipes are pure functions — usable without any database
const draft = core.bookVendorInvoice(
  {
    date: new Date(),
    vendorAccount: '70001',
    grossCents: 11900,
    scenario: 'domestic_standard',
    invoiceNumber: 'RE-1001',
    description: 'Bürobedarf',
  },
  policy,
);

const entry = await api.postDraft(draft); // gapless sequence + hash chain
const storno = await api.postStorno(entry); // corrections never mutate

await api.festschreibePeriod('2026-03'); // GoBD period lock
const audit = await api.verifyAuditChain(2026); // recompute the whole chain
const balance = await api.getAccountBalanceCents('1200'); // exact cents

const { csv, filename } = await api.exportDatevBuchungsstapel({
  dateFrom: new Date('2026-03-01'),
  dateTo: new Date('2026-03-31'),
  consultantNumber: 1001,
  clientNumber: 456,
  fiscalYearStart: new Date('2026-01-01'),
  festschreibung: true,
});
// persist csv as CP1252 — DATEV expects ANSI encoding

Also in core: bookCustomerInvoice (incl. intra-EU supply, export, §13b), bookCustomerCreditNote (see below), bookCustomerDocument and bookVendorDocument for documents whose VAT groups have both signs (see below), bookBankPayment (Skonto with VAT correction, tolerance write-offs, an excess to a named account — see below), bookInternalTransfer (Geldtransit), bookToSuspense/resolveSuspense, fxDifferenceLines, detectTaxScenario (confidence-scored cascade), the BU-key table (BU_KEY_RULES), and validateJournalDraft.

A Payment Beyond The Open Item

bookBankPayment books a difference above the tolerance only when the caller says what it is. Money that moved beyond expectedCents — a payout made knowingly beyond what was owed, or money received beyond the open item — is booked to an account the caller names with excess: { account }, as a plain amount without VAT and without a BU key; the personal account is settled by exactly expectedCents:

const draft = core.bookBankPayment(
  {
    date: new Date('2026-04-12'),
    direction: 'outgoing',
    counterpartyAccount: '10001', // the debtor the credit note is owed to
    expectedCents: 10000, // the credit
    paidCents: 15000, // what left the bank
    excess: { account: '4900' }, // SKR 03 sonstige betriebliche Aufwendungen
    description: 'Erstattung GS-3001',
    reference: 'AR-2001',
  },
  core.SKR03_DEFAULT_POLICY,
);
// 10001 S 10000 · 1200 H 15000 · 4900 S 5000 (against 1200)

What the excess is, and so which account takes it, is the caller's decision: the recipe does not classify it. The account must be one the excess can take as a plain amount in the profit and loss, outside the VAT return:

  • an account of the chart in use (policy.skrType, @fin.cx/chartdata 2026);
  • a profit-and-loss account by that chart's classification — type expense or revenue with an income-statement position (HGB § 275), listed in CHART_PROFIT_AND_LOSS_ACCOUNTS — and an expense for an outgoing excess, income for an incoming one;
  • none the chart marks for the UStVA or with a DATEV tax automatism (CHART_VAT_ACCOUNTS), and no Automatikkonto or VAT account of the policy (automatikkonten, vat, vatSettlement).

Every other account is refused: every balance-sheet account (banks, VAT receivables and liabilities such as SKR 03 1770 or SKR 04 3800, personal and clearing accounts), an account the chart does not hold, and an income account for a payout. Both lists are generated from @fin.cx/chartdata by tools/generate-chart-data.ts into ts/core/core.chartaccounts.ts. An excess is refused as well when nothing moved beyond expectedCents. Named, it takes an excess within diffToleranceCents as well, and expectedCents may be 0: a payment wholly beyond an open item already settled is all excess, and the personal account is not touched. Without it, an excess above the tolerance is refused, as before.

Customer Credit Notes (Rechnungskorrektur / Stornorechnung)

bookCustomerCreditNote books a credit note for a customer invoice that was validly issued: the same accounts as the invoice, both sides swapped.

const creditNote = core.bookCustomerCreditNote(
  {
    date: new Date('2026-04-10'),   // the credit note's own date — see below
    customerAccount: '10001',
    grossCents: 11900,              // positive: the whole invoice or a part of it
    scenario: 'domestic_standard',
    creditNoteNumber: 'GS-3001',
    correctedInvoice: { invoiceNumber: 'AR-2001', date: new Date('2026-03-15') },
    description: 'GS-3001 Storno zu AR-2001',   // Buchungstext: name both
  },
  policy,
);
// 8400 Soll 100,00 (BU 40) · 1776 Soll 19,00 · 10001 Haben 119,00
// one DATEV booking: 11900 S 8400/10001 — the invoice row with Konto and
// Gegenkonto exchanged, Belegfeld 1 = AR-2001

Which number goes where is DATEV's rule. Belegfeld 1 is the key "für den Ausgleich offener Rechnungen", so every line of the credit note repeats the CORRECTED INVOICE's number there — that is what settles the invoice's open item instead of opening a second one. The credit note's own number is the entry reference, and description becomes the Buchungstext, the one DATEV field that can show it: name both documents there. Belegfeld 2 stays empty; the full reasoning, the DATEV quotes and the expected DATEV rows are in docs/credit-notes.md.

Open items follow from that key: a full credit note settles the invoice (gone from listOpenItems, gone from the aging list, nothing left to dun), a partial one reduces it and a normal incoming payment settles the rest, and a credit note on an invoice that was already paid leaves a NEGATIVE open amount — money owed to the customer. SkrApi.settleOpenItem reads that sign and pays it back instead of collecting it:

const [credit] = await api.listOpenItems({ kind: 'debtor', accountNumber: '10001' });
// credit.openCents === -11900
await api.settleOpenItem({ item: credit, paidCents: 11900, date: new Date() });
// outgoing: 10001 Soll 119,00 against the bank — the item is settled

The same logic is available without a database: deriveOpenItems turns personal-account lines (IOposLineRow) into open items, bucketOpenItems ages them, and planOpenItemSettlement decides direction and amounts of a settlement (IOpenItemSettlementPlan) from the sign of the open amount.

Refunding by hand is bookBankPayment({ direction: 'outgoing', counterpartyAccount: <the debtor>, reference: <the INVOICE number>, … }). Do not pass skonto on such a refund — Skonto on an outgoing payment is erhaltene Skonto with an input-VAT correction, which is wrong for a debtor, so the recipe refuses the combination (and the mirror case, skonto on an incoming payment from a creditor).

Per tax scenario the credit note reverses the revenue account the invoice used, which is also the account that carries the UStVA Kennzahl (@fin.cx/chartdata), so the correction lands on the same figure as the original turnover:

scenario revenue account (SKR03) UStVA Kennzahl corrected
domestic_standard 8400 Kz 81 base + Umsatzsteuer 19 %
domestic_reduced 8300 Kz 86 base + Umsatzsteuer 7 %
intra_eu_supply 8125 Kz 41
export_third_country 8120 Kz 43
reverse_charge_13b (outbound) 8337 Kz 60
domestic_tax_free no policy default — name the account (e.g. 8100 → Kz 48)
  • Which period. § 17 Abs. 1 Satz 1 UStG: "Hat sich die Bemessungsgrundlage für einen steuerpflichtigen Umsatz im Sinne des § 1 Abs. 1 Nr. 1 geändert, hat der Unternehmer, der diesen Umsatz ausgeführt hat, den dafür geschuldeten Steuerbetrag zu berichtigen." Satz 8: "Die Berichtigungen nach den Sätzen 1 und 2 sind für den Besteuerungszeitraum vorzunehmen, in dem die Änderung der Bemessungsgrundlage eingetreten ist." § 17 Abs. 2 Nr. 3 UStG applies Absatz 1 "sinngemäß" when "eine steuerpflichtige Lieferung, sonstige Leistung oder ein steuerpflichtiger innergemeinschaftlicher Erwerb rückgängig gemacht worden ist". So a cancellation is corrected in the period of the credit note, not by reopening the period of the invoice — which is why date is the credit note's date and the original entry is left alone. (https://www.gesetze-im-internet.de/ustg_1980/__17.html)
  • What it must reference. § 31 Abs. 5 UStDV: a Rechnung may be corrected, and "Es müssen nur die fehlenden oder unzutreffenden Angaben durch ein Dokument, das spezifisch und eindeutig auf die Rechnung bezogen ist, übermittelt werden. Es gelten die gleichen Anforderungen an Form und Inhalt wie in § 14 des Gesetzes." The DOCUMENT carries that reference, and it carries its own sequential number (§ 14 Abs. 4 Satz 1 Nr. 4 UStG: "eine fortlaufende Nummer … die zur Identifizierung der Rechnung vom Rechnungsaussteller einmalig vergeben wird"), so correctedInvoice.invoiceNumber and creditNoteNumber are both required — the first as the Belegfeld 1 clearing key, the second as the entry reference. The booking itself only has to stay traceable (§ 145, § 146 AO). A credit note dated before the calendar day of the invoice it corrects is refused. (https://www.gesetze-im-internet.de/ustdv_1980/__31.html, https://www.gesetze-im-internet.de/ustg_1980/__14.html)
  • Never by deletion. § 146 Abs. 4 AO: "Eine Buchung oder eine Aufzeichnung darf nicht in einer Weise verändert werden, dass der ursprüngliche Inhalt nicht mehr feststellbar ist." (identically § 239 Abs. 3 HGB), and § 145 Abs. 1 AO requires the books to give "einem sachverständigen Dritten innerhalb angemessener Zeit einen Überblick über die Geschäftsvorfälle". The credit note is an additional entry; IJournalDraft.reversalOf stays unset, because that field marks the technical reversal of a wrong posting — SkrApi.postStorno or core.reversalOf (see "Reversals" below). A credit note is not a wrong posting. (https://www.gesetze-im-internet.de/ao_1977/__146.html, https://www.gesetze-im-internet.de/hgb/__239.html, https://www.gesetze-im-internet.de/ao_1977/__145.html)
  • Not an Erlösschmälerung. Erlösschmälerungen accounts (SKR03 8730er, Gewährte Skonti 8736/8731) carry price reductions on a supply that still happened. A cancelled or returned supply removes the turnover itself, so it belongs on the original revenue account. bookBankPayment's skonto branch is the Skonto path and stays separate.
  • Wrong tax on the invoice is a different case. If the invoice showed too much tax, § 14c Abs. 1 Satz 1 UStG makes the supplier owe the surplus and Satz 2 routes the correction through § 17 Abs. 1 as well ("Berichtigt er den Steuerbetrag gegenüber dem Leistungsempfänger, ist § 17 Abs. 1 entsprechend anzuwenden."); § 14c Abs. 2 (unberechtigter Steuerausweis) additionally needs the Gefährdung des Steueraufkommens removed and a written application to the Finanzamt. This recipe covers the § 17 case; a § 14c Abs. 2 correction needs that separate procedure. (https://www.gesetze-im-internet.de/ustg_1980/__14c.html)

Documents With VAT Groups Of Both Signs

A document can carry VAT groups of either sign: an invoice with a credited line at another rate (goods at 19 % and a returned item at −7 %), or a correction that lowers one rate and raises another. bookCustomerDocument and bookVendorDocument book such a document as one set of entries, one per VAT group, each with its own sign:

const entries = core.bookCustomerDocument(
  {
    date: new Date('2026-03-15'),
    customerAccount: '10001',
    document: { kind: 'invoice', invoiceNumber: 'AR-2001' },
    groups: [
      { amountCents: 119000, vatRatePercent: 19, scenario: 'domestic_standard' },
      { amountCents: -10700, vatRatePercent: 7, scenario: 'domestic_reduced' },
    ],
    description: 'AR-2001 Kunde',
  },
  policy,
);
// entries[0]: 10001 Soll 1 190,00 · 8400 Haben 1 000,00 · 1776 Haben 190,00
// entries[1]: 8300 Soll 100,00 · 1771 Soll 7,00 · 10001 Haben 107,00
// DATEV: 119000 S 10001/8400 and 10700 S 8300/10001, Belegfeld 1 = AR-2001
for (const entry of entries) {
  await api.postDraft(entry);
}
  • Signs. amountCents is the group's amount as it changes the open item of the counterparty: positive raises it, negative lowers it (the gross for domestic scenarios, the net for § 13b and intra-EU acquisitions). A positive group books like bookCustomerInvoice / bookVendorInvoice, a negative one like a correction of it — the same accounts with both sides swapped. A group of zero books nothing. The document's total may be of either sign or zero.
  • Numbers. document says what the document is. An invoice ({ kind: 'invoice', invoiceNumber }) puts its number in reference and Belegfeld 1 of every entry; a correction ({ kind: 'credit_note', creditNoteNumber, correctedInvoice }) puts its own number in reference and the corrected invoice's number in Belegfeld 1, with the same checks as bookCustomerCreditNote. So every entry of the set books on one open item. On the vendor side a vendor's credit note names the vendor invoice it corrects the same way.
  • Why one entry per group. Each entry consolidates into one DATEV Buchungssatz — the revenue or expense account against the personal account with the group's BU key — exactly as a single-group invoice or credit note does, so the export stays valid for every scenario and every mix of signs.
  • Tax. Each group is taxed on its own rate: § 17 Abs. 1 Satz 1 UStG corrects the tax of the supplier, Satz 2 the Vorsteuer of the recipient, Satz 5 applies both to intra-Community acquisitions (§ 1 Abs. 1 Nr. 5) and to § 13b, and the UStVA reads each group on its own revenue or expense account — a negative 7 % group lowers Kz 86 while the 19 % group raises Kz 81.

The Ledger Without A Database (core)

The core also reads a ledger from journal entries you hand in — the drafts of the recipes with an id each, or posted entries of any ledger — without a database. Every figure is in exact integer cents.

import { core } from '@fin.cx/skr'; // or: import * as core from '@fin.cx/skr/core';

const entries: core.ILedgerEntryInput[] = drafts.map((draft, index) => ({ id: `e${index}`, ...draft }));

// Summen- und Saldenliste, by account number: opening, before the period, the period, cumulated, balance
const balance = core.trialBalanceOf(entries, {
  periodFrom: '2026-03-01',
  periodTo: '2026-03-31',
  opening: [{ accountNumber: '1200', signedCents: 500000 }, { accountNumber: '0800', signedCents: -500000 }],
});
balance.isBalanced; // debits equal credits and the opening balances sum to 0

// Kontenblatt: the account's lines in the period with the running balance
const sheet = core.accountSheetOf(entries, '1200', { periodFrom: '2026-03-01', openingCents: 500000 });

// open items of the personal accounts, keyed by Belegfeld 1, as on a day
const open = core.openItemsOf(entries, core.SKR03_DEFAULT_POLICY, { kind: 'debtor', asOf: '2026-12-31' });

// the lines themselves, each with its side and signed amount
const lines = core.ledgerLinesOf(entries);
  • Signs: signedCents and every balance are debit positive, credit negative; an opening balance is given the same way.
  • Without opening, no opening balances are recorded: openingCents is null on every account, never a 0 that was not given. With it, an account that has none reads 0, and an account that has only an opening balance is listed.
  • A period is two inclusive days (YYYY-MM-DD, UTC). Lines before periodFrom are brought forward (prior…, carriedCents); lines after periodTo are left out.
  • Refused, because the figures would be false: an entry whose debits and credits differ, an amount that is not a whole number of cents, an entry id given twice, a date that is no date, a period that is no day or ends before it begins, and an account's opening balance given twice. A negative line amount books on the other side; a line of 0 is no line.
  • The Summen- und Saldenliste lists the accounts by account number, a number of digits by its value: the general-ledger accounts (four digits) first, then the debtors (10000–69999) and the creditors (70000–99999). Of equal value (0027, 27) the text decides; a number with other characters comes after them all.
  • The Kontenblatt and the open items read the lines by day, and lines of one day in the order the entries came in, so the order the entries are handed in does not change an item's first and last day or its aging; its entry ids follow the days, and within a day that order.
  • deriveOpenItems, bucketOpenItems and planOpenItemSettlement are part of the core as well; the top-level exports of the same names are the same functions, and listOpenItems / getAging keep reading the database ledger through them.

The Main Book In The Core

The rules a posted general ledger (Hauptbuch) needs, as pure functions: the application stores the entries, the core says what they are.

Reversals: Generalumkehr and Gegenbuchung

const reversal = core.reversalOf({ id: 'org1:2026-01-01:17', ...entry });
// same date, reference, accounts, sides, amounts, Belegfeld 1 and 2;
// generalumkehr: true, reversalOf: 'org1:2026-01-01:17', texts "Storno: …"
const gegen = core.reversalOf({ id: 'org1:2026-01-01:17', ...entry }, { form: 'gegenbuchung' });
const { lines, generalumkehr } = core.reversalLinesOf(postedLines, false, 'generalumkehr');
  • A draft marked generalumkehr states its lines on their sides with positive amounts, and every line counts negative there (DATEV Buchungsstapel field 118). An entry and its Generalumkehr leave the turnover of every account as if neither had been booked: trialBalanceOf lowers the sum of the side, accountSheetOf shows the line with a negative amount on its side, openItemsOf takes back the claim instead of settling it, and journalDraftsToDatevRows writes the rows of the reversed booking with field 118 set.
  • Without the mark nothing changed: a negative amount books on the other side. With the mark a negative amount is refused.
  • core.reversalLinesOf(lines, generalumkehr, form) turns posted lines into the lines of their reversal: generalumkehr keeps sides and amounts and flips the mark (the reversal of a Generalumkehr is a plain entry), gegenbuchung swaps the sides and keeps the mark. Belegfeld 1 and 2, cost centres, document link and VAT facts are kept, so the reversal stays on the Beleg and on the open item of the entry it reverses (GoBD Rz. 64: "Korrektur- bzw. Stornobuchungen müssen auf die ursprüngliche Buchung rückbeziehbar sein"); the Buchungstext becomes Storno: …, cut to 60 characters. core.reversalOf(entry, { form? }) builds the whole draft, linked by reversalOf.
  • SkrApi.postStorno / JournalPoster.postStorno of the database ledger is unchanged and different: a Gegenbuchung whose Belegfeld 1 is replaced by the journal number. The database ledger records no Generalumkehr, so postDraft refuses a draft marked generalumkehr.

Opening entries in the ledger

An entry handed to the ledger functions with opening: true (the EB-Werte of a fiscal year) is read as the opening balances: trialBalanceOf puts its lines into openingCents, not into the turnover, and accountSheetOf into the account's openingCents. With the opening option or openingCents given as well, both add up; with neither, openingCents stays null.

Fiscal years

const years = core.fiscalYearsOf({
  regimes: [{ from: '2020-01-01', startMonth: 1 }, { from: '2026-07-01', startMonth: 7 }],
  firstDay: '2024-01-01',
  through: '2026-08-01',
});
// 2024, 2025, 2026-01-01..2026-06-30 (short, 'change', consentRequired), 2026-07-01..2027-06-30 ('2026/27')
core.fiscalYearOfDay(years, '2026-03-15');
  • A regime says that from its from day on, the years start on day startDay of startMonth (startDay is 1 when absent, and at most the day the month has in every year: 28 for February, 30 for April, June, September and November). A year starts on the first day of the books, on every startDay of the governing start month and on every regime's from, and ends the day before the next start or on lastDay (closing or sale).
  • Each IFiscalYearSpan has its days, the label (2026, 2025/26) and months: the begun months, the fewest months after its start that reach past its end (2026-03-15..2026-12-31 is 10, a regular year from the 15th is 12; a month begun counts whole).
  • Months are counted as § 108 Abs. 1 AO with § 188 Abs. 2 and 3 BGB counts them: a period of months from the start of a day ends on the day before the day of the same number, and where the last month has no such day, on that month's last day. So 2026-01-31 + 1 month is 2026-03-01 (2026-01-31..2026-02-28 is one month), 2026-08-31 + 6 months is 2027-03-01, and 2028-02-29..2029-02-28 is twelve months.
  • short marks a Rumpfwirtschaftsjahr: the day after its end is not twelve months after its start, or it opens the books on a day that is no start of a year. A business opened on 2028-02-29 under years from 28 February has a first year to 2029-02-27, one day short of the twelve months that would end on 2029-02-28. Its reason is opening, closing, opening_and_closing or change, as § 8b Satz 2 EStDV allows them. No year is longer than twelve months (§ 240 Abs. 2 Satz 2 HGB, § 8b Satz 1 EStDV).
  • consentRequired marks the short year of a change to a closing day other than 31 December: it counts for tax only in agreement with the Finanzamt (§ 8b Satz 3 EStDV, § 4a Abs. 1 Nr. 2 Satz 2 EStG). Whether the consent was given, and whether the business may have a deviating year at all (§ 4a Abs. 1 EStG), is the caller's to know.
  • taxYear is the calendar year the fiscal year ends in (§ 4a Abs. 2 Nr. 2 EStG, for traders).

Posted entries and their hash chain

const { seq, prevHash } = core.planNextEntry(lastEntry, ledgerId); // 1 and the genesis after none
const payload: core.IPostedEntryPayload = { ...record, ledgerId, seq };
const { valid, errors } = core.validatePostedEntry(payload, { policy, fiscalYear });
const entry = { ...payload, prevHash, entryHash: core.postedEntryHash(prevHash, payload) };
core.verifyLedgerChain(ledgerId, entriesOfTheLedger); // null, or { index, seq, reason }
  • IPostedEntryPayload records what GoBD Rz. 94 lists: Belegdatum (documentDate), Buchungsdatum (postingDate), Erfassungsdatum (recordedAt), Buchungsperiode (period) and Voranmeldungszeitraum (vatPeriod), the Leistungsdatum, the lines with account, counter account, side, amount, BU key, Belegfeld 1 and 2 and VAT rate, amount and account, the foreign amount with its rate and the rate's source, the party, the Belege, the source with its key and digest, who recorded and confirmed it, the chart, and the rules version; with the ledger (ledgerId, one per organization and fiscal year), the number seq and the owner's entryNumber and schemaVersion.
  • validatePostedEntry normalises nothing, and refuses what a draft would only be warned about, because a posted entry can never be put right: an unbalanced or negative line, a Buchungstext over 60 characters, a Belegfeld that is not 1 to 36 of the characters DATEV takes, a period that is not the posting date's month, a posting date outside the fiscal year, and more.
  • The characters of Belegfeld 1 and 2 are those of the DATEV format description (Buchungsstapel fields 11 and 12): ASCII letters, digits and $ & % * + - /; "Andere Zeichen sind unzulässig (insbesondere Leerzeichen, Umlaute, Punkt, Komma, Semikolon und Doppelpunkt)". Its expression [\w$&%*+\-\/] would also take _, which its list does not name, so _ is refused, the narrower of the two. On the length of Belegfeld 2 the German page gives {0,36} and the English page ("Booking batch") {0,12}; skr keeps 36 (DATEV_MAX_BELEGFELD) for both fields.
  • postedEntryHash(prevHash, entry) is entryHash(prevHash, payload) over exactly the payload's fields — an absent field and one read back as null alike, generalumkehr: false as absent, fields a store adds left out — so an entry changed, removed or put in between afterwards is found (§ 146 Abs. 4 AO, § 239 Abs. 3 HGB). ledgerGenesisHash(ledgerId) is what the first entry chains to.
  • verifyLedgerChain answers the first gap, duplicate, entry of another ledger, prev_hash or entry_hash break.

Opening balances, carry-forward, private movements, VAT prepayments

core.bookOpeningBalances({ date, description: 'EB-Werte 2026', balances: [
  { accountNumber: '1200', signedCents: 500000 },
  { accountNumber: '10001', signedCents: 119000, belegfeld1: 'AR-7' }, // per open item
] }, policy);
core.carryForwardOf({ date, description: 'Saldenvortrag 2027', closing, equity: { kind: 'company' } }, policy);
core.bookPrivateWithdrawal({ date, amountCents: 150000, description: 'Privatentnahme' }, policy);
core.bookPrivateDeposit({ date, amountCents: 11900, account: '70001', reference: 'ER-5', description: 'privat bezahlt' }, policy);
core.bookVatPrepayment({ date, direction: 'payment', amountCents: 250000, description: 'USt-VA 03/2026' }, policy, chartAccountNumbers);
  • Opening balances go against the Saldenvortragskonten (openingBalance: 9000 general ledger, 9008 debtors, 9009 creditors in SKR 03 and SKR 04), a personal account best per open item with its invoice number in Belegfeld 1.
  • carryForwardOf carries a closed year's balance-sheet accounts into the next year (§ 252 Abs. 1 Nr. 1 HGB) and the year's result to equity by the legal form: a company to Gewinnvortrag or Verlustvortrag vor Verwendung (resultCarryForward: SKR 03 0860 / 0868, SKR 04 2970 / 2978), a sole trader to the capital account with the private accounts, a partnership by the partners' shares. The closing balances must sum to 0.
  • Private accounts (privateAccounts): SKR 03 1800 / 1890, SKR 04 2100 / 2180. A company has none.
  • Umsatzsteuer-Vorauszahlungen (vatSettlement): SKR 03 1780, and 1781 for the Sondervorauszahlung 1/11; SKR 04 3820 and 3830. bookVatPrepayment takes the account numbers of the chart in use and refuses, naming them, when it lacks the prepayment or the bank account: skr's own charts (SKR03_ACCOUNTS, SKR04_ACCOUNTS, from @fin.cx/chartdata 2.2.0) do not have these accounts yet. missingChartAccountsOf(draft, chartAccounts) answers the same for any draft.
  • The account numbers are those of the DATEV Kontenrahmen SKR 03 (Art.-Nr. 11174) and SKR 04 (Art.-Nr. 11175), gültig für 2026.

DATEV fields 115 and 116

IDatevBuchungsstapelRow.leistungsdatum writes field 115 "Leistungsdatum" and datumZuordSteuerperiode field 116 "Datum Zuord. Steuerperiode", both as TTMMJJJJ. The format description says field 116 must be filled when field 115 is, and that the Leistungsdatum is used in agreement with the tax advisor; a row with 115 and without 116 is refused, and both take the years 2000 to 2099 only, as its expression does. journalDraftsToDatevRows sets neither.

Reports And Exports

Available reporting methods on SkrApi:

  • generateTrialBalance()
  • generateIncomeStatement()
  • generateBalanceSheet()
  • generateGeneralLedger()
  • generateCashFlowStatement()
  • exportReportToCSV()
  • exportToDATEV()

Year-end archival export:

const exportPath = await api.exportJahresabschluss({
  exportPath: './exports',
  fiscalYear: 2024,
  dateFrom: new Date('2024-01-01'),
  dateTo: new Date('2024-12-31'),
  includeDocuments: true,
  generatePdfReports: true,
  signExport: false,
  timestampExport: false,
  companyInfo: {
    name: 'Example GmbH',
    taxId: 'DE123456789',
    registrationNumber: 'HRB 12345',
    address: 'Example Street 1, 28195 Bremen',
  },
});

console.log(exportPath);

The export code creates a BagIt-style folder structure with metadata, accounting data, report output, document storage, and manifest hashes.

E-Invoice Workflows

The package includes invoice types and API helpers for importing, storing, booking, searching, exporting, and generating e-invoices.

Supported invoice directions:

  • inbound
  • outbound

Supported formats in the invoice model:

  • xrechnung
  • zugferd
  • facturx
  • peppol
  • ubl

Example import and booking flow:

const invoice = await api.importInvoice('./fixtures/invoice.xml', 'inbound', {
  autoBook: true,
  confidenceThreshold: 80,
});

const hits = await api.searchInvoices({
  invoiceNumber: invoice.invoiceNumber,
});

const exported = await api.exportInvoice(invoice, {
  format: 'xrechnung',
  embedInPdf: true,
});

The API also exposes:

  • bookInvoice()
  • getInvoice()
  • getInvoiceStatistics()
  • createInvoiceComplianceReport()
  • generateInvoice()

Invoice imports are stored by content hash, deduplicated, indexed in an NDJSON registry, and searchable by direction, date range, party, amount range, invoice number, and status metadata.

The document type (BT-3) is not interpreted here: the adapter reads and writes it through getDocumentTypeCode / getAccountingDocType, the table exported by @fin.cx/einvoice (>= 8), so IInvoice.invoiceTypeCode round-trips 380 (invoice), 381 (credit note), 383 (debit note) and 389 (self-billed invoice). The other credit-note codes of EN 16931 rule BR-CL-01 (81, 83, 261, 262, 296, 308, 396, 420, 458, 532) decode as credit notes as well and are re-encoded as the canonical 381; every remaining code decodes as an invoice. The booking side still treats a credit note as the sign-reversing case.

Public Exports

Top-level exports include:

  • SkrApi
  • Account
  • Transaction
  • JournalEntry
  • ChartOfAccounts
  • Ledger
  • Reports
  • SkrExport
  • LedgerExporter
  • AccountsExporter
  • BalancesExporter
  • PdfReportGenerator
  • SecurityManager
  • SKR03_ACCOUNTS, SKR04_ACCOUNTS
  • SKR03_ACCOUNT_CLASSES, SKR04_ACCOUNT_CLASSES

This makes the package usable as both an application-facing API and a toolkit for custom accounting workflows.

Development

Build:

pnpm build

Test:

pnpm test

Current project checks include:

  • runtime tests for SKR03 and SKR04 flows
  • transaction and journal validation
  • report generation
  • DATEV export
  • published type consumption through test/fixtures/strict-consumer

This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the license 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.

S
Description
a module implementing german SKR03/SKR04 account structures.
Readme
2 MiB
Languages
TypeScript 100%