@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 (
coreexport, also importable on its own as@fin.cx/skr/corewithout 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);
journalDraftsToDatevRowsconsolidates 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 asBEDI "GUID"
Breaking changes in v2
- MongoDB must run as a replica set (single-node is fine);
initialize()fails hard without transaction support.gitzone services startcreates a suitable MongoDB automatically. postTransaction()/reverseTransaction()return transaction-shaped views (ITransactionView), notTransactiondocuments; the Transaction collection is frozen as a legacy read model.- Posted entries are immutable: reversing never mutates the original (
statusstaysposted; linkage viareversalOf). exportToDATEV()now emits the EXTF 700 format; persist the string as CP1252.IJournalEntryLine.postingKeyis optional; the v2 line fields (side,amountCents,counterAccount, stringbuKey) 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
SkrApior 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 startprovides 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
expenseorrevenuewith an income-statement position (HGB § 275), listed inCHART_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) |
Legal basis
- 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
dateis 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.invoiceNumberandcreditNoteNumberare both required — the first as the Belegfeld 1 clearing key, the second as the entryreference. 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.reversalOfstays unset, because that field marks the technical reversal of a wrong posting —SkrApi.postStornoorcore.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'sskontobranch 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.
amountCentsis 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 likebookCustomerInvoice/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.
documentsays what the document is. An invoice ({ kind: 'invoice', invoiceNumber }) puts its number inreferenceand Belegfeld 1 of every entry; a correction ({ kind: 'credit_note', creditNoteNumber, correctedInvoice }) puts its own number inreferenceand the corrected invoice's number in Belegfeld 1, with the same checks asbookCustomerCreditNote. 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:
signedCentsand every balance are debit positive, credit negative; an opening balance is given the same way. - Without
opening, no opening balances are recorded:openingCentsisnullon 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 beforeperiodFromare brought forward (prior…,carriedCents); lines afterperiodToare 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,bucketOpenItemsandplanOpenItemSettlementare part of the core as well; the top-level exports of the same names are the same functions, andlistOpenItems/getAgingkeep 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
generalumkehrstates 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:trialBalanceOflowers the sum of the side,accountSheetOfshows the line with a negative amount on its side,openItemsOftakes back the claim instead of settling it, andjournalDraftsToDatevRowswrites 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:generalumkehrkeeps sides and amounts and flips the mark (the reversal of a Generalumkehr is a plain entry),gegenbuchungswaps 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 becomesStorno: …, cut to 60 characters.core.reversalOf(entry, { form? })builds the whole draft, linked byreversalOf.SkrApi.postStorno/JournalPoster.postStornoof the database ledger is unchanged and different: a Gegenbuchung whose Belegfeld 1 is replaced by the journal number. The database ledger records no Generalumkehr, sopostDraftrefuses a draft markedgeneralumkehr.
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
fromday on, the years start on daystartDayofstartMonth(startDayis 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 everystartDayof the governing start month and on every regime'sfrom, and ends the day before the next start or onlastDay(closing or sale). - Each
IFiscalYearSpanhas its days, the label (2026,2025/26) andmonths: 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.
shortmarks 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 isopening,closing,opening_and_closingorchange, as § 8b Satz 2 EStDV allows them. No year is longer than twelve months (§ 240 Abs. 2 Satz 2 HGB, § 8b Satz 1 EStDV).consentRequiredmarks 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.taxYearis 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 }
IPostedEntryPayloadrecords 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 numberseqand the owner'sentryNumberandschemaVersion.validatePostedEntrynormalises 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)isentryHash(prevHash, payload)over exactly the payload's fields — an absent field and one read back asnullalike,generalumkehr: falseas 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.verifyLedgerChainanswers the firstgap,duplicate, entry of anotherledger,prev_hashorentry_hashbreak.
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. carryForwardOfcarries 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.bookVatPrepaymenttakes 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/chartdata2.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:
inboundoutbound
Supported formats in the invoice model:
xrechnungzugferdfacturxpeppolubl
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:
SkrApiAccountTransactionJournalEntryChartOfAccountsLedgerReportsSkrExportLedgerExporterAccountsExporterBalancesExporterPdfReportGeneratorSecurityManagerSKR03_ACCOUNTS,SKR04_ACCOUNTSSKR03_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
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 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.