jkunz 4f795266da
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / metadata (push) Skipped
v7.4.2
2026-10-07 02:00:53 +00:00
…
…
2026-10-07 02:00:53 +00:00
2026-10-07 02:00:53 +00:00
…
2026-10-07 02:00:53 +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, persistence through @lossless.org/client (a NoSQLDB engine or MongoDB), 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 database transactions (a NoSQLDB engine, or a MongoDB replica set)
  • 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 database 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

  • The database must support transactions; initialize() fails hard without them.
  • 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 is migrated by initialize() (see Version 7).

Breaking changes in v7

  • Persistence moved from the deprecated @push.rocks/smartdata to @lossless.org/client/nosqldb. The Account, Transaction and JournalEntry models extend its SmartDataDbDoc; skr adds the LedgerSequence, LedgerPeriod and OposMeta models over its existing collections.
  • IDatabaseConfig is replaced by TDatabaseConfig: either { mongoDbUrl, dbName? } (skr opens and closes the connection) or { db }, a SmartdataDb you connected yourself — skr prepares its collections in it and never closes it.
  • migrateToV2() and SkrApi.migrateToV2() are gone. initialize() runs the versioned migrations (ts_migration/) before anything else touches the ledger: each step runs once per database under a lease, so concurrent starts converge, and records its report. SkrApi.getMigrationRecords() returns them (archive them with your Verfahrensdokumentation). Take a backup before the first start of 7.x.
  • The ledger migration resumes after a crash at any point without booking anything twice: a legacy transaction becomes one journal entry whose id derives from the transaction's id, committed in one transaction with the transaction's marker (a unique index over legacyTransactionId holds the rule), and each hash chain resumes from its highest chained entry, after verifying it, with every entry committed together with the counter and head it advances. Conflicting data — an earlier import that differs, a counter ahead of the journal, a chain that does not verify — makes initialize() fail with LedgerMigrationConflictError, naming it; nothing is deleted. A migration whose lease passed to another process stops before its next write with MigrationLeaseLostError; start skr again once the other process is done.
  • JournalPoster.ensureIndexes() is removed: the models install their indexes at initialize(), and the migrations create the journal's partial unique indexes.
  • assertPeriodOpen(skrType, period, session?) takes a SmartdataSession from @lossless.org/client/nosqldb (db.createSession()) instead of a MongoDB driver ClientSession.
  • The migration drops SmartData 7's smartdata_text_index (skr searches accounts and transactions in memory) and creates the journal's partial unique sequence index. OposMeta gains a unique index over (skrType, accountNumber, reference); a database holding duplicate dunning records for one open item makes initialize() fail with OposMetaDuplicatesError, naming them — merge them by hand, skr deletes nothing.
  • Ledger and ChartOfAccounts book on the hashed journal like SkrApi (same validation, gapless sequence, hash chain and transaction): postTransaction() returns an ITransactionView, postJournalEntry() the hashed JournalEntry, reverseTransaction() / reverseJournalEntry() post Storno entries and leave the original untouched. Their reads come from the journal too: Ledger.getAccountHistory(), ChartOfAccounts.getAccountTransactions() and getTransactions() return ITransactionView[]; Ledger.getAccountBalance(), closeAccountingPeriod() and recalculateAllBalances() use the journal's balances; deleteAccount() refuses an account any journal entry books on. Posting to an Automatikkonto (1400/1600, SKR04 1200/3300) is refused on these paths as on SkrApi.
  • The model-level writers that cannot map onto the journal throw RetiredLedgerWriteError, naming the API to use: Transaction.createTransaction(), transaction.validateAndPost() and transaction.reverseTransaction() (the Transaction collection is a frozen read model), JournalEntry.createJournalEntry() (the journal stores no unposted drafts; drafts are core.IJournalDraft), journalEntry.post() (a posting is a new hashed entry, not an instance posted in place) and journalEntry.reverse() (a posted entry is immutable; a Storno reverses it).

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 the database
  • 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 database with transactions: a @lossless.org/nosqldb engine, or MongoDB running as a replica set (a single-node replica set is sufficient)
  • pnpm

The runtime API needs a mongoDbUrl and an optional dbName, or a connected SmartdataDb from @lossless.org/client/nosqldb as db. The tests start an in-memory NoSQLDB engine per test file and hand skr a disposable database from @lossless.org/client/testsupport; they need no external services.

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.

Those two names, spelled exactly so, are the only charts skr keeps. A chart name read at run time (stored settings, an imported file) is checked with core.isSkrType(value) or core.assertSkrType(value); every entry point that decides by the chart — core.getDefaultPolicy, the recipes through core.chartOf(policy), Account.isAutomaticAccount, the Ledger and Reports constructors, SkrApi.initialize, the balance and open-item readings — refuses any other value, another spelling ('skr03') or none with core.UnknownChartError (code 'chart_unknown', details.chart the value as given) and never reads it as SKR03 or SKR04:

try {
  core.getDefaultPolicy(storedChart);
} catch (error) {
  if (error instanceof core.UnknownChartError) {
    // error.details.chart: what the settings hold; refuse it where it was read
  }
}

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, a sale taxable in another member state), bookCustomerCreditNote (see below), bookCustomerDocument and bookVendorDocument for documents whose VAT groups have both signs (see below), bookBankPayment (Skonto with the VAT correction at each rate, tolerance write-offs, an excess to a named account — see below), bookInternalTransfer (Geldtransit), bookPaymentFee (a fee a bank or payment provider kept or gave back — see below), bookToSuspense/resolveSuspense, fxDifferenceLines, detectTaxScenario (confidence-scored cascade), the BU-key table (BU_KEY_RULES), and validateJournalDraft.

The Automatikkonten are the chart's: CHART_AUTOMATIK_ACCOUNTS lists every account @fin.cx/chartdata gives a DATEV tax automatism ("AM" and "AV" in the DATEV Kontenrahmen SKR 03 and SKR 04 2026: the revenue at 19, 7 and 0 %, the tax-free revenue of the § 4 exemptions, intra-Community supplies and exports, Kz 21, the outbound § 13b revenue, Kz 45, the purchases, the Skonto accounts, and the taxed advances paid (AV) and received (AM) at 7 and 19 %), and the default policies' automatikkonten are that list. A line on an Automatikkonto carries no BU key, or 40 next to an explicit VAT line. isAutomatikAccount takes every Automatikkonto of the chart as one whatever a policy lists, since DATEV applies the chart's automatism on import; a custom policy's automatikkonten adds accounts set up as Automatikkonten beyond the chart.

When A Recipe Refuses

A recipe that cannot book what it is asked throws a RecipeRefusalError (exported from the package root and from core): the input is the problem, not the code. It carries a stable code (TRecipeRefusalCode), the message as before, and details (IRecipeRefusalDetails) where they help to show or resolve it — the field, the account or accounts, the amount, the difference and the tolerance, the direction, the rate, the scenario, the validation errors. Every other Error a recipe throws is a programming error: a value its type does not allow, or an invariant the recipe broke.

TRecipeRefusalCode grows in minor releases: a new refusal gets a new code. A consumer handles a code it does not know — shows the message, treats it as a refusal — rather than switching over the codes exhaustively or keeping a Record of all of them.

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

try {
  core.bookBankPayment(input, policy);
} catch (error) {
  if (error instanceof core.RecipeRefusalError && error.code === 'shortfall_beyond_tolerance') {
    // error.details: { direction, differenceCents, toleranceCents } — book a Skonto, a partial payment, or leave it open
  } else {
    throw error;
  }
}
Code Refused
amount_not_positive an amount that must be positive integer cents (field, amountCents)
amount_not_whole_cents a group amount, an opening or a closing balance that is not whole cents
revenue_account_required a customer document in a scenario without a policy revenue account (scenario)
corrected_invoice_required a credit note without the number of the invoice it corrects
credit_note_number_required a credit note without its own number
credit_note_predates_invoice a credit note dated before the invoice it corrects (its issueDate, else the entry's date)
credit_note_issue_date_invalid a credit note's issueDate that is given and is no valid Date (field)
document_groups_missing a document without VAT groups
document_without_amount a document whose VAT groups are all 0 and that has no durchlaufende Posten
non_deductible_vat_invalid a purchase's tax excluded from the deduction (nonDeductibleVatCents) that is no whole cents, beyond the tax, of another sign than the group's amount, on a scenario without German VAT, or on a sale; a Skonto share's nonDeductible without selfAssessed (field, amountCents, scenario)
vat_amount_off_rate a group's stated tax (vatCents) more than a cent off the tax its rate gives, of another sign than its amount, or on a scenario without German VAT (field, amountCents, differenceCents, toleranceCents, ratePercent, scenario)
pass_through_account_missing a document with durchlaufende Posten (passThroughCents) under a policy without the passThrough account (field)
pass_through_account_not_allowed a passThrough account with a DATEV tax automatism or a UStVA mark (field, account)
other_income_accounts_missing otherIncomeAccount under a policy without the otherIncome accounts (field)
payment_fees_account_missing paymentFeesAccount or bookPaymentFee under a policy without the paymentFees account (field)
automatik_account_not_allowed a revenue or expense account whose DATEV automatism books German VAT at a rate (SKR03 8400, 8300, 3400, …) named for a booking without German VAT: a tax-free, intra-Community, export, outbound § 13b, foreign-taxed, Kz 21 or not-taxable-here sale, or a purchase without German VAT (field, account, scenario, ratePercent)
account_rate_mismatch a domestic booking at 19 or 7 % on a revenue or expense account the chart ties to the other rate (SKR03 8300 for a sale at 19 %, 3300 for a purchase at 19 %) or to no German VAT (8125, 8320, …), whose base DATEV would report under that rate's or a tax-free Kennzahl; a zero-rated sale (domestic_zero_rate) on an account other than the chart's accounts of Kz 87 (CHART_ZERO_RATE_REVENUE_ACCOUNTS), or another sale on one of them; a zero-rated sale or purchase stated at another rate (field: 'vatRatePercent') (field, account, scenario, ratePercent)
scenario_not_for_purchases a vendor document under a scenario of sales only: other_eu_state_taxable, other_eu_state_recipient_liable, not_taxable_here (scenario)
other_eu_state_rate_required a sale taxable in another member state without that state's vatRatePercent, or with one below 0, and a Skonto part's otherEuState.ratePercent alike (field, scenario, ratePercent)
other_eu_state_supply_required a sale taxable in another member state without its otherEuStateSupply, and a Skonto part without otherEuState.supply (field, scenario)
place_of_supply_required a sale not taxable here (not_taxable_here) without its placeOfSupply (field, scenario)
shortfall_beyond_tolerance a payment short of the open amount beyond the tolerance, without Skonto (direction, differenceCents, toleranceCents)
excess_beyond_tolerance a payment beyond the open amount beyond the tolerance, without excess (same details)
excess_account_required an excess that names no account
excess_account_not_allowed an excess.account that cannot take the excess (account)
excess_not_applicable an excess when nothing moved beyond expectedCents
skonto_wrong_counterparty a Skonto on a refund to a debtor or on a payment from a creditor
skonto_rate_unsupported a Skonto at a rate other than 19 or 7 (ratePercent)
skonto_shares_missing skonto.shares without a share
skonto_account_required a part of a Skonto that must name its account and names none: a part at 0 %, an empty account, an invoice group under the net method without lines
skonto_account_not_allowed a part of a Skonto on an account it cannot book on: the wrong side of the P&L, a balance-sheet account, an automatism at another rate, a UStVA mark without VAT at the rate (account)
skonto_share_duplicate a rate on the same account (or on the Skonto account) named twice
skonto_exceeds_shares a Skonto larger than the shares it was granted on
skonto_scenario_unsupported a Skonto on a § 13b or intra-Community acquisition invoice in InvoiceBookingEngine.bookPayment, which does not book the correction of the self-assessed tax (returned as success: false with the message in errors); bookBankPayment books it with a share selfAssessed
skonto_account_ambiguous a Skonto on an invoice whose lines at one rate went to several accounts (accounts; raised inside InvoiceBookingEngine.bookPayment, which returns it as success: false with the message in errors)
skonto_other_eu_state_required a part at 0 % on revenue the chart marks for the UStVA without an automatism, taxed in another member state (SKR03 8320, 8331), without that state's rate in otherEuState (field, account)
skonto_other_eu_state_case_required a part at 0 % on an account that takes both services taxed in another member state and turnover not taxable here (8339 / 4339, revenue.otherEuStateServices and revenue.notTaxableHereEu) without otherEuState or notTaxableHere: true, with both, or notTaxableHere on a part at a German rate (field, account)
skonto_other_eu_state_on_purchase a part with otherEuState in the Skonto of a purchase (field, direction)
skonto_other_eu_state_with_german_rate a part with otherEuState at a German rate other than 0 (field, ratePercent)
skonto_self_assessed_on_sale a part with selfAssessed in the Skonto of a sale (field, direction)
skonto_self_assessed_conflict a part with selfAssessed that also names otherEuState or notTaxableHere, or a selfAssessed other than reverse_charge_13b and intra_eu_acquisition (field)
opening_balance_account_not_allowed an opening balance on a Saldenvortragskonto or a control account
opening_balance_duplicate an account or open item opened twice
opening_balances_empty no opening balance other than 0
saldenvortrag_not_cleared a Saldenvortragskonto that closes with a balance
closing_balances_unbalanced closing balances that do not sum to 0 (differenceCents)
vat_year_accounts_missing the VAT of past years reclassified on a policy without vatSettlement.previousYear and .earlierYears
vat_account_per_open_item a VAT account given per open item to vatOfPastYearsReclassified (account)
private_accounts_of_company private accounts of a company (accounts)
partner_shares_mismatch partners' shares that do not sum to the result
private_account_without_partner a private account no partner holds
result_account_per_open_item the account that takes the result, given per open item
open_items_mismatch open items that do not sum to their account's balance
chart_account_missing a VAT prepayment on a chart without its accounts (accounts)
reversal_entry_without_id a reversal of an entry without its id
reversal_entry_invalid a reversal of an entry that is not valid (errors)
reversal_line_not_positive a reversal of lines that are not positive whole cents
open_item_settled a settlement plan for an open item already settled
debtor_account_required a receivable settled outside on no personal account of a customer
settlement_account_required a receivable settled outside naming no account the money went to
settlement_account_not_allowed a receivable settled outside onto any account but the till, the Privatentnahmen or the claim against a shareholder on its form's account: a claim account without its form, or an unknown form (shareholderForm); another form's claim account, a form passed with the till or the Privatentnahmen, or any other account (account)
settlement_reference_required a receivable settled outside with a reference given but blank
settlement_open_item_required a receivable settled outside with a Skonto or a tolerance but no open item (expectedCents)
advance_accounts_missing an advance on a policy that names no advance accounts (IAccountPolicy.advances)
advance_rate_unsupported an advance at a rate other than 0, 7 or 19 %, or an advance's input tax deducted at 0 % (field, ratePercent)
advance_party_account_required an advance cleared against no personal account of a customer (received) or a supplier (paid) (field, account)
advance_deduction_beyond_payment an advance's input tax deducted for more than was paid of it (field, amountCents, differenceCents)
zero_rate_account_missing a zero-rated sale (domestic_zero_rate) without a revenueAccount on a policy that names no revenue.domesticZeroRate (scenario)
personal_account_required a side of an offset between personal accounts on no personal account of a customer or a supplier (field, account)
offset_same_account an offset between personal accounts with one open item on both sides (one account, the same number or none on either): the claims meet on it already (accounts)
offset_reference_required a side of an offset between personal accounts with its open item's number given but blank (field)
goods_accounts_missing a Bestandsveränderung on a policy that names no goods accounts (IAccountPolicy.goods; field)
goods_account_not_allowed a goods stock or stock-change account with a DATEV tax automatism or marked for the UStVA (field, account)
inventory_value_negative a stock value below 0, the one booked or the one the Inventar gives (field, amountCents)
inventory_unchanged a Bestandsveränderung whose closing value equals the stock booked

The recipes are those of core: the documents and payments (bookVendorInvoice to resolveSuspense, bookReceivableSettledOutside), the advances (bookAdvanceReceived, bookAdvancePaid, deductAdvancePaidInputTax, clearAdvanceReceived, clearAdvancePaid), the offset between two personal accounts (bookPersonalAccountOffset), the main book (bookOpeningBalances, carryForwardOf, vatOfPastYearsReclassified, bookPrivateWithdrawal, bookPrivateDeposit, bookVatPrepayment, bookInventoryChange), the reversals (reversalOf, reversalLinesOf) and planOpenItemSettlement, and so SkrApi.settleOpenItem, which books through them.

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.

Income that is no consideration of a supply has accounts of its own in the policy, otherIncome (optional): damages for damages received, such as a reminder fee or the lump sum of § 288 Abs. 5 BGB a debtor in default pays (SKR 03 2742, SKR 04 4970 "Versicherungsentschädigungen und Schadenersatzleistungen"), and interest for interest received, such as default interest (SKR 03 2650, SKR 04 7100 "Sonstige Zinsen und ähnliche Erträge"). otherIncomeAccount(policy, kind) answers the account, and refuses a policy without the role (other_income_accounts_missing). Both are booked when received and bear no VAT, so they take the part of a payment that pays them as an excess: the part that settles the invoice as expectedCents of one payment, and each charge as a payment of its own with expectedCents 0 and excess.account its account, which leaves the personal account untouched. The default policies name both.

A fee a bank or a payment provider (PayPal, Wise) keeps from the balance of the account it holds is its own expense, "Nebenkosten des Geldverkehrs" (SKR 03 4970, SKR 04 6855), the policy's paymentFees (optional; the default policies name it). bookPaymentFee({ date, direction, amountCents, bankAccount?, description, reference? }, policy) books a charged fee on that account against the provider's account (the policy's bank where none is named), and a refunded one the other way. It bears no input VAT and no BU key, as a payment service is exempt (§ 4 Nr. 8 UStG). The payment the fee was kept from settles its open item by its whole amount (bookBankPayment), so the fee is no difference of the payment. paymentFeesAccount(policy) answers the account, and both refuse a policy without the role (payment_fees_account_missing).

Skonto On An Invoice With Several VAT Rates

A Skonto lowers the Bemessungsgrundlage of every part of the invoice it was granted on, so the tax is corrected at each rate by that rate's share. For an invoice at one rate, skonto: { ratePercent } (19 or 7) books the whole difference at that rate, as before. For an invoice at several rates, pass the gross amount of each part in skonto: { shares }: the recipe splits the Skonto in proportion to them and books each share on the Skonto account of its rate, with its VAT correction:

// 119,00 € at 19 % and 107,00 € at 7 %, paid with 3 % Skonto: 219,22 € of 226,00 €
const draft = core.bookBankPayment(
  {
    date: new Date('2026-03-15'),
    direction: 'incoming',
    counterpartyAccount: '10001',
    expectedCents: 22600,
    paidCents: 21922,
    skonto: {
      shares: [
        { ratePercent: 19, grossCents: 11900 },
        { ratePercent: 7, grossCents: 10700 },
      ],
    },
    description: 'Eingang AR-1001 mit Skonto',
    reference: 'AR-1001',
  },
  core.SKR03_DEFAULT_POLICY,
);
// 1200 S 21922 · 10001 H 22600
// 8736 S 300 (BU 40) · 1776 S 57   — 3,57 € of the 19 % part
// 8731 S 300 (BU 40) · 1771 S 21   — 3,21 € of the 7 % part
rate sale (gewährte Skonti), SKR03 / SKR04 purchase (erhaltene Skonti), SKR03 / SKR04
19 % 8736 + USt 1776 / 4736 + USt 3806 3736 + VSt 1576 / 5736 + VSt 1406
7 % 8731 + USt 1771 / 4731 + USt 3801 3731 + VSt 1571 / 5731 + VSt 1401
0 % the named account, no VAT the named account, no VAT
19 % or 7 % with account the named income account + USt of the rate the named expense account + VSt of the rate
0 % with otherEuState (a sale taxed in another member state) the named revenue account net + that state's VAT on 1767 / 1768 / 1728 (3817 / 3818 / 3798), split at its rate refused

The accounts are the policy's (policy.skonto, policy.vat); the Skonto accounts are Automatikkonten of @fin.cx/chartdata 2026 at their rate, so the net line carries key 40 next to the explicit VAT line. The cents the split leaves go by the largest remainder (allocateCents, @fin.cx/calculation), so the shares add up to the Skonto exactly; each share is then split into net and VAT at its rate. Pass as grossCents what the Skonto was granted on: the whole invoice for a Skonto on the invoice price, only the parts it covers where the parties agreed a Skonto on some positions only.

A part of a sale taxed in another member state (other_eu_state_taxable) names that state's rate and the kind of supply in otherEuState and the revenue account it lowers in account, at ratePercent: 0: the Skonto lowers that state's Bemessungsgrundlage and its tax with it (Art. 90 Abs. 1 MwStSystRL), so its share is split at that rate into the net on the revenue account and the tax on the policy's VAT account of that state, none of it German VAT. A part at 0 % on 8320 or 8331 without otherEuState is refused (skonto_other_eu_state_required): its gross share would carry that state's tax into the revenue. 8339 (SKR04 4339) takes both services taxed in another member state and turnover not taxable here, so a part at 0 % on it says which: otherEuState, or notTaxableHere: true, which lowers the revenue alone (skonto_other_eu_state_case_required without either).

A part at 19 % or 7 % may name an account as well: the share then reduces that account — commonly the revenue or expense account the part was booked on — instead of going to the Skonto account, with the same VAT correction at the rate. The account must be an income account of the chart for a sale and an expense account for a purchase; if the chart gives it a DATEV automatism, the automatism must book VAT at the part's rate (CHART_VAT_AUTOMATIK_RATES: SKR03 8400 takes a part at 19 %, 8300 one at 7 %), and an account the chart marks for the UStVA without such an automatism (tax-free revenue such as SKR03 8125, revenue taxable abroad such as 8338) is refused. An Automatikkonto carries key 40 next to the explicit VAT line, as the Skonto accounts do; any other account carries the key of its rate (9 or 8 for a purchase, 3 or 2 for a sale), so the DATEV export folds the VAT line into it as one booking at the gross share:

// a purchase booked on 4930 at 19 % and at 7 %, paid with 6,78 € Skonto
skonto: {
  shares: [
    { ratePercent: 19, grossCents: 11900, account: '4930' },
    { ratePercent: 7, grossCents: 10700, account: '4930' },
  ],
},
// 4930 H 300 (BU 9) · 1576 H 57 · 4930 H 300 (BU 8) · 1571 H 21
// DATEV: 357 H 4930/70001 BU 9 · 321 H 4930/70001 BU 8

A part without German VAT — a tax-free supply, a 0 % supply, a supplier under § 19 UStG — takes its share without a VAT correction on the account named in the share ({ ratePercent: 0, grossCents, account }). The chart holds no Skonto account without VAT, and which UStVA figure the share lowers depends on why the part carries none, so the caller names it: commonly the revenue or expense account the part was booked on (SKR03 8125 lowers Kz 41 with it, SKR04 4100 Kz 48, SKR03 8290 at 0 % under § 12 Abs. 3 UStG Kz 87). The account must be an income account of the chart for a sale and an expense account for a purchase (CHART_PROFIT_AND_LOSS_ACCOUNTS), and none whose DATEV automatism books VAT at a rate above 0 (CHART_VAT_RATE_AUTOMATIK_ACCOUNTS) nor a Skonto or VAT account of the policy. A part whose recipient self-assesses the tax (§ 13b UStG, intra-Community acquisition) is not a part at 0 %; see below.

A part of a purchase whose recipient owes the German tax — under § 13b UStG or as an intra-Community acquisition — names the scenario in selfAssessed, the German rate the tax is owed at in ratePercent (19 or 7), its net amount in grossCents (the supplier charged no German tax, so the open item is the net), and the expense account it was booked on in account. The Skonto lowers its Bemessungsgrundlage, and § 17 Abs. 1 Satz 5 UStG applies the correction to it: "Die Sätze 1 bis 4 gelten in den Fällen des § 1 Abs. 1 Nr. 5 und des § 13b sinngemäß." (read at https://www.gesetze-im-internet.de/ustg_1980/__17.html, 2026-09-28). So the tax the recipient owes and its input-tax deduction are corrected together, each by the tax on the reduction at the part's rate (vatFromNetCents, as the invoice computed its tax). The share is booked net on the account, with the scenario's key (§ 13b 94 / 91, acquisition 19 / 18), and the invoice's pair of tax lines is swapped (vat.reverseChargeInput / vat.reverseChargeOutput, vat.intraEuAcqInput / vat.intraEuAcqOutput); DATEV books the pair from the key, so the export writes one booking.

// 100,00 € for a service of an Austrian entrepreneur (§ 13b), paid with 2,00 € Skonto
core.bookBankPayment(
  {
    date: new Date('2026-05-20'),
    direction: 'outgoing',
    counterpartyAccount: '70001',
    expectedCents: 10000,
    paidCents: 9800,
    skonto: { shares: [{ ratePercent: 19, grossCents: 10000, account: '4900', selfAssessed: 'reverse_charge_13b' }] },
    description: 'Zahlung ER-AT-1 mit Skonto',
    reference: 'ER-AT-1',
  },
  core.SKR03_DEFAULT_POLICY,
);
// 4900 H 200 (BU 94) · 1577 H 38 · 1787 S 38
// DATEV: 200 H 4900/70001 BU 94

Refused: such a part in the Skonto of a sale (skonto_self_assessed_on_sale), one that also names otherEuState or notTaxableHere (skonto_self_assessed_conflict), a rate other than 19 or 7 (skonto_rate_unsupported), no account (skonto_account_required), an account that is no expense account of the chart, the bank, the creditor, or a Skonto or VAT account of the policy (skonto_account_not_allowed), and an account whose DATEV automatism books domestic Vorsteuer at a rate (automatik_account_not_allowed: DATEV would book it on import).

Refused are: a rate other than 19, 7 or 0 (the policy holds no Skonto or VAT account for it — { ratePercent: 0 } too, which used to land on the 19 % Skonto account), a part at 0 % without an account, an account a part cannot book on, a rate named twice on the same account (or twice without one), no shares, a share without a positive integer amount, and a Skonto larger than the shares it was granted on. SkrApi.settleOpenItem takes the same skonto.

Legal basis: § 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 2 — "Ebenfalls ist der Vorsteuerabzug bei dem Unternehmer, an den dieser Umsatz ausgeführt wurde, zu berichtigen." The correction follows each Umsatz, and so each rate. § 22 Abs. 2 Nr. 1 Satz 2 UStG requires the records to show "wie sich die Entgelte auf die steuerpflichtigen Umsätze, getrennt nach Steuersätzen, und auf die steuerfreien Umsätze verteilen", and UStAE 22.2 Abs. 2 Satz 2 applies that "entsprechend für nachträgliche Entgeltänderungen". A split by the ratio of a whole period's turnover instead is a simplification the Finanzamt grants (UStAE 22.2 Abs. 8 Sätze 2 und 3, 22.6 Abs. 20 und 21); this recipe splits by the invoice. UStAE 3.11 Abs. 5 Satz 1: a Skonto taken on early payment "führt … zu einer Entgeltminderung". (https://www.gesetze-im-internet.de/ustg_1980/__17.html, https://www.gesetze-im-internet.de/ustg_1980/__22.html)

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
domestic_zero_rate (§ 12 Abs. 3 UStG) 8290 (SKR04 4290), an Automatikkonto at 0 %: no VAT line, no BU key Kz 87
reverse_charge_13b (outbound) 8337 Kz 60
domestic_tax_free no policy default — name the account; taxFreeRevenueAccountFor(exemption, skrType) gives it per § 4 exemption: 8150 (§ 4 Nr. 2 bis 7, Kz 43), 8100 (§ 4 Nr. 8 ff., Kz 48), 8105 (§ 4 Nr. 12, Kz 48), 8192 (§ 19 Abs. 1, a Kleinunternehmer, on no Kennzahl) Kz 43 or Kz 48
other_eu_state_taxable 8320 (goods), 8331 (electronic services), 8339 (other services and Werklieferungen) Kz 45 base; the other state's VAT on 1767 / 1768 / 1728, outside the UStVA
other_eu_state_recipient_liable 8336 Kz 21
not_taxable_here 8339 (placeOfSupply: 'eu'), 8338 ('third_country') Kz 45
  • 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 in which the base changed, not by reopening the period of the invoice, and the original entry is left alone. The entry's date decides that period: it is the credit note's date where the caller books the correction on it. (https://www.gesetze-im-internet.de/ustg_1980/__17.html)
  • Booked on another day. Booking a correction on another day than its own date is the caller's decision — for example books that date every row by the payment or the settlement that books it, which a Buchungsstapel needs inside its period (DATEV reads the Belegdatum as TTMM within the period, header fields 15 and 16). Such a caller passes that day as date, the Belegdatum, which then decides the period of the correction, and the correction's own date as issueDate (ICustomerCreditNoteInput.issueDate, or document.issueDate of a credit_note document). Whether the correction predates the invoice it corrects is read from the two documents' own dates, issueDate and correctedInvoice.date; without issueDate from date, as before. An issueDate that is given and is no valid Date is refused (credit_note_issue_date_invalid). skr does not refuse a date before issueDate: the base may change before the document states it (a refund paid before the credit note is written), and which day books it is the caller's.
  • 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.
  • A tax as the document states it. A group may carry vatCents, its tax as the document states it (or the part of it this entry books), with the sign of amountCents. It is booked as given in place of what the rate gives, so the parts of one document's tax add up to the tax the document states (§ 14 Abs. 4 Satz 1 Nr. 8, § 14c UStG): three parts of an invoice of 1 190,00 € at 19 % whose rates give 0,16 €, 0,16 € and 189,67 € book 0,16 €, 0,16 € and 189,68 €. It may differ from what the rate gives by one cent at most; more, another sign, or any tax on a scenario without German VAT is refused (vat_amount_off_rate). Where it equals what the rate gives the entry is the one without it. Where it differs, DATEV must not compute the tax from the base: the base carries no key (key 40 on an Automatikkonto), and the export writes the tax as a booking of its own on the tax account (consolidateJournalDraftForDatev leaves a tax to an Automatikkonto only where its automatism computes the same from the gross).
  • 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.
  • Durchlaufende Posten. passThroughCents states the document's amounts received and spent in the name and for the account of another (§ 10 Abs. 1 Satz 6 UStG), such as court fees an attorney passes on. They are no part of the consideration, bear no VAT and stand outside the UStVA, so they are no VAT group: they are booked as one entry of their own after the groups' entries, the personal account against the policy's passThrough account (DATEV SKR 03 1590, SKR 04 1370), without a BU key and with the document's numbers, so they book on the same open item. The sign follows amountCents. A document of durchlaufende Posten alone needs no group with an amount. On the vendor side they are what the supplier's document asks for that the books spend in the name and for the account of another (a fee paid on a client's behalf), booked on the same account, so what the books pay out and what they charge on clear each other there. The account may carry no DATEV tax automatism and no UStVA mark (pass_through_account_not_allowed). The role is optional: a policy without it books every document as before and refuses one with durchlaufende Posten (pass_through_account_missing). The default policies name it (SKR 03 1590, SKR 04 1370).

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.
  • A bank movement nothing else explains is its own Beleg: its electronic account statement data, kept unchanged (GoBD Rz. 76-78). movementIds names such movements (opaque ids of the owner's store) beside or instead of documentIds; the hash covers it where given, and an entry without it hashes as one made before the field existed. Whether an entry names a Beleg at all is the owner's rule; validatePostedEntry checks the shape of both lists.
  • 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.
  • With a policy it also refuses a line on an Automatikkonto whose German VAT does not fit the account, as the recipes refuse such a booking (account_rate_mismatch, automatik_account_not_allowed): a sale at 7 % or without VAT on SKR03 8400 or 8820 (SKR04 4400, 4845), which the Buchungsstapel writes as one gross row without a BU key and DATEV books at 19 %, or German VAT on a tax-free automatism. The line's VAT is that of the domestic VAT lines with its counter account and side, which the Buchungsstapel folds into it, each assigned by rate and amount: the VAT at a rate is that rate's tax on the group's Automatikkonten of the rate, within a cent per line (vat_amount_off_rate otherwise), more only as far as the accounts without an automatism take it (the tax at the rate on their amounts), and VAT no line of the group books or carries is refused. So 100 € on 8400 with 7 € VAT beside 200 € on 8300 with 38 € is refused.
  • The Buchungsstapel folds a VAT line into an Automatikkonto only at the rate of the account's own automatism. A VAT line at another rate, or beside a tax-free automatism, stays a booking of its own, and an Automatikkonto of its group whose automatism books a tax is written with key 40, so DATEV books what the draft holds and no tax of its own on top.
  • 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: core.vatOfPastYearsReclassified(closing, policy), 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.
  • The VAT of past years (vatOfPastYearsReclassified(closing, policy), balance-sheet books): before the carry-forward, the closing balances of the year's German VAT accounts (germanVatAccountsOf: the output and input tax at each rate, the paired tax of § 13b UStG and of intra-Community acquisitions, the prepayments and the Sondervorauszahlung; another member state's VAT is not German tax and stays) open together on the previous year's account (vatSettlement.previousYear: SKR 03 1790, SKR 04 3841 "Umsatzsteuerverbindlichkeiten Vorjahr"), and that account's old balance on the earlier years' (vatSettlement.earlierYears: SKR 03 1791, SKR 04 3845 "… frühere Jahre"). The new year's VAT accounts start at 0 and hold only its own advance returns; no entry of the closed year books the move. A VAT account the books kept beyond the policy's is named with { vatAccounts: [...germanVatAccountsOf(policy), '1588'] }. Refused: a policy without the two accounts (vat_year_accounts_missing), a VAT account given per open item (vat_account_per_open_item).
  • Private accounts (privateAccounts): SKR 03 1800 / 1890, SKR 04 2100 / 2180. A company has none.
  • A company's claim against a shareholder, by its form (SHAREHOLDER_CLAIM_ACCOUNTS[skrType][form], shareholderClaimAccount(skrType, form), beside the policy): a GmbH or UG on "Forderungen gegen GmbH-Gesellschafter" (§ 42 Abs. 3 GmbHG, SKR 03 1381, SKR 04 1307); an AG, an SE, and a KGaA against a Kommanditaktionär on "Forderungen gegen sonstige Gesellschafter" (SKR 03 1507, DATEV's first account of that group, with a remaining term of up to one year; SKR 04 1330); a KGaA against a persönlich haftender Gesellschafter on "Forderungen gegen persönlich haftende Gesellschafter" (§ 286 Abs. 2 Satz 4 HGB, SKR 03 1385, SKR 04 1317); a cooperative on the other assets (SKR 03 1500, SKR 04 1300).
  • Umsatzsteuer-Vorauszahlungen (vatSettlement): SKR 03 1780, and 1781 for the Sondervorauszahlung 1/11; SKR 04 3820 and 3830; the VAT of the previous year and of earlier years SKR 03 1790 / 1791, SKR 04 3841 / 3845 (optional in a policy). 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.13) hold them. 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.

A customer's payment outside the bank accounts

A customer who paid in cash, or into the owner's or a shareholder's private account, settled the invoice's open item without a bank movement of these books. bookReceivableSettledOutside books the account the money went to against the customer's personal account, with the invoice number in Belegfeld 1:

core.bookReceivableSettledOutside({
  date, // the day of the payment: under the Istversteuerung the VAT falls due then
  debtorAccount: '10001',
  account: policy.cash, // or policy.privateAccounts.withdrawal, or core.shareholderClaimAccount(policy.skrType, form) with shareholderForm: form
  amountCents: 119000,
  reference: 'RE-2026-001',
  description: 'bar bezahlt RE-2026-001',
}, policy);
  • The till (cash) fits any business; the Privatentnahmen (privateAccounts.withdrawal) a sole trader or a partner, whose private receipt of business money is a withdrawal; a company's claim against a shareholder, who owes the company what they received for it, on the account of the company's form, which the input names (shareholderForm: gmbh, ug, ag, se, kgaa_general_partner, kgaa_limited_shareholder, eg). The form is left unset for the till and the Privatentnahmen. A claim account without its form, with another form's, or a form with the till or the Privatentnahmen, is refused. The legal form is the caller's to know.
  • The invoice booked the revenue and the VAT, so no line carries VAT or a BU key. The invoice number (reference) goes into Belegfeld 1, where it settles the open item. An invoice that names none (a Kleinbetragsrechnung, § 33 UStDV) leaves it unset: its open item carries no Belegfeld 1, and neither does the payment, as with bookBankPayment; docLink leads to the Beleg.
  • Only those three accounts are allowed. Every other account is refused (settlement_account_not_allowed): a second bank account (SKR 03 1210, SKR 04 1810), PayPal or any other account of the chart or outside it belongs in the books and is booked from its own statement; the Privateinlagen too, as a customer's payment is never the owner's deposit. Refused as well are a debtor account outside the debtor range (debtor_account_required), no account (settlement_account_required) and a reference given but blank (settlement_reference_required).
  • Without expectedCents the payment settles exactly what it paid: all of the open item, or a part whose rest stays open. With it, the open item is settled in full and the difference is booked as a bank payment's is (bookBankPayment, incoming), on the account the money went to: the Skonto skonto names at each rate on the gewährte Skonti with the output VAT corrected (§ 17 Abs. 1 UStG; skonto.grantedStandard / grantedReduced: SKR 03 8736 / 8731, SKR 04 4736 / 4731), or a difference within diffToleranceCents on the rounding accounts; beyond them it is refused (shortfall_beyond_tolerance, excess_beyond_tolerance). A Skonto or a tolerance without expectedCents is refused (settlement_open_item_required).
core.bookReceivableSettledOutside({
  date, debtorAccount: '10001', account: policy.privateAccounts.withdrawal,
  amountCents: 169050, expectedCents: 172500, // 2 % Skonto on 1 725,00 €
  skonto: { shares: [{ ratePercent: 19, grossCents: 119000 }, { ratePercent: 7, grossCents: 53500 }] },
  reference: 'RE-2026-002', description: 'privat vereinnahmt RE-2026-002',
}, policy);

A purchase whose tax is not deducted

The tax of a purchase used for tax-exempt sales is excluded from the input-tax deduction (§ 15 Abs. 2 Satz 1 Nr. 1 UStG): every purchase of a Kleinunternehmer, whose supplies are exempt since 2025 (§ 19 Abs. 1 Satz 1 UStG), and the part of a purchase that serves exempt sales (§ 15 Abs. 4). nonDeductibleVatCents on IVendorInvoiceInput and on a vendor document's IDocumentVatGroup (with the group's sign) names that part, at most the tax; it is cost and booked with the base on the expense account, and only the rest on the input-tax account:

purchase, 119,00 € at 19 %, all excluded lines (SKR03)
domestic 4930 debit 119,00 / 70001 credit 119,00 — no key, no tax line
§ 13b, 100,00 € net 4900 debit 119,00 / 1787 credit 19,00 / 70001 credit 100,00 — the tax is owed, deducted nowhere

Under § 13b and an intra-Community acquisition the tax is owed all the same (§ 13b Abs. 5, § 1a UStG) and only the rest is deducted. No key has DATEV compute a tax that is deducted in part or not at all: the base line carries none and the tax lines are bookings of their own. An expense account whose DATEV automatism books input tax at a rate (SKR03 3400) is refused (automatik_account_not_allowed), since the import would deduct the tax again. A Skonto of such a § 13b or acquisition part is given with selfAssessed and nonDeductible: true: it lowers the tax owed and the expense by its tax. A domestic part the recipient does not deduct is given at 0 % on its expense account, gross.

A Kleinunternehmer (§ 19 UStG)

Since 2025 a Kleinunternehmer's supplies are tax-exempt (§ 19 Abs. 1 Satz 1 UStG) while its total turnover stays within the thresholds; whether a business is one, and for which year, is the caller's to decide, and skr books what it is told:

  • a sale under domestic_tax_free on taxFreeRevenueAccountFor('section_19', skrType): SKR03 8192, SKR04 4184, "Steuerfreie Erlöse Kleinunternehmer nach § 19 Abs. 1 UStG" (DATEV Kontenrahmen 2026), an Automatikkonto without key and on no UStVA field: the Erklärungspflichten of § 18 Abs. 1 bis 4 do not apply (§ 19 Abs. 1 Satz 2 UStG), nor does the exemption of an intra-Community supply, so every supply is booked there;
  • a purchase with nonDeductibleVatCents of all its tax (above): a Kleinunternehmer deducts no input tax, and the tax it owes under § 13b or on an intra-Community acquisition stays owed.
const account = core.taxFreeRevenueAccountFor('section_19', 'SKR03'); // '8192'
core.bookCustomerInvoice({
  date, customerAccount: '10001', revenueAccount: account,
  grossCents: 50000, scenario: 'domestic_tax_free',
  invoiceNumber: 'RE-2026-010', description: 'Webdesign',
}, policy);

Advances (Anzahlungen)

Money received or paid before the supply is an advance: a liability or an asset until the supply (erhaltene Anzahlungen auf Bestellungen, § 266 Abs. 3 C.3 HGB; geleistete Anzahlungen, § 266 Abs. 2 B.I.4 HGB). The policy's advances names the accounts, from the DATEV Kontenrahmen 2026:

untaxed 7 % 19 %
paid, SKR 03 1510 1511 (AV 7 %) 1518 (AV 19 %)
received, SKR 03 1710 1711 (AM 7 %) 1718 (AM 19 %)
paid, SKR 04 1180 1181 (AV 7 %) 1186 (AV 19 %)
received, SKR 04 3250 3260 (AM 7 %) 3272 (AM 19 %)
core.bookAdvanceReceived({ date, grossCents: 11900, vatRatePercent: 19, description: 'Anzahlung', reference: 'AB-7' }, policy);
core.bookAdvancePaid({ date, grossCents: 11900, vatRatePercent: 0, description: 'Anzahlung ohne Rechnung' }, policy);
core.deductAdvancePaidInputTax({ paidOn, invoiceAtHandOn, paidCents: 11900, grossCents: 11900, vatRatePercent: 19, description: 'Anzahlungsrechnung', reference: 'AR-5' }, policy);
core.clearAdvanceReceived({ date, partyAccount: '10001', grossCents: 11900, vatRatePercent: 19, description: 'Anzahlung verrechnet', reference: 'RE-9' }, policy);
  • A received advance's output tax arises when it is received (§ 13 Abs. 1 Nr. 1 Buchst. a Satz 4 UStG): the bank against the "versteuerte" account of its rate, with key 40 on that Automatikkonto next to the tax on its own line on the output tax account. An advance for a supply without German VAT is booked at 0 % on the account without tax.
  • A paid advance's input tax is deducted once it is paid and the invoice for it is at hand (§ 15 Abs. 1 Satz 1 Nr. 1 Satz 3 UStG): at its rate with the input tax on its own line, else at 0 % on the account without tax, and deductAdvancePaidInputTax moves it, or the part the invoice states, to the account of its rate with its input tax once it is both paid and invoiced: dated the later of paidOn and invoiceAtHandOn, and refused for more than was paid (advance_deduction_beyond_payment).
  • The final invoice is booked in full at the supply (bookCustomerDocument, bookVendorDocument), and the advance is cleared at the rate it was booked at against the personal account (clearAdvanceReceived, clearAdvancePaid), with the final invoice's number in Belegfeld 1: the advance account is cleared, and the tax of the invoice's month is the invoice's less the advance's (§ 14 Abs. 5 Satz 2 UStG).
  • A policy without advances (one made before them) refuses (advance_accounts_missing); so do a rate other than 0, 7 or 19 % (advance_rate_unsupported), a clearing against no personal account of the right kind (advance_party_account_required), and an advance account whose automatism books another rate (account_rate_mismatch).

Goods at the year's end (Bestandsveränderung)

A business that books its purchases of goods as an expense (Wareneingang, SKR 03 3200/3300/3400, SKR 04 5200/5300/5400) books the stock only at the year's end: the stock its Inventar values on the Stichtag (§ 240 Abs. 2 HGB, at no more than cost and lower where the market is lower, § 253 Abs. 1 and 4 HGB) replaces the stock the books carry, and the difference is the change in the stock of goods, part of the Materialaufwand (§ 275 Abs. 2 Nr. 5 HGB). The policy's goods names the accounts, from the DATEV Kontenrahmen 2026:

stock change in stock customs duties
SKR 03 3980 Waren (Bestand) 3950 Bestandsveränderungen Waren 3850 Zölle und Einfuhrabgaben
SKR 04 1140 Waren (Bestand) 5881 Bestandsveränderungen Waren 5840 Zölle und Einfuhrabgaben
core.bookInventoryChange({
  date: new Date('2026-12-31T00:00:00.000Z'), // the Stichtag
  bookedCents: 500_000,                        // the balance of goods.stock before this entry
  closingCents: 620_050,                       // the Inventar's value
  description: 'Bestandsveränderung Waren 2026',
  reference: 'INV-2026',
  docLink: 'BEDI "inventar-2026"',
}, policy);
// debit 3980 / credit 3950 1 200,50 € (SKR 03); a stock that shrank books the reverse
  • No VAT and no BU key: a change in stock is no supply. The stock carries into the next year with the carry-forward (carryForwardOf); nothing reverses the entry, and the next year books its own difference.
  • The recipe is pure: posting it as one of the year's closing entries is the caller's. The change in the stock of the products a business makes is another account (SKR 03 8980 "Bestandsveränderungen - fertige Erzeugnisse", § 275 Abs. 2 Nr. 2 HGB) and not this recipe's.
  • goods.customs names where customs and import duties on goods bought are booked, part of their cost (§ 255 Abs. 1 Satz 2 HGB).
  • Refused: a policy without goods, one made before the role (goods_accounts_missing); a stock or stock-change account with a DATEV tax automatism or marked for the UStVA (goods_account_not_allowed); an amount that is no whole cents (amount_not_whole_cents) or below 0 (inventory_value_negative); a stock that did not change (inventory_unchanged).

An offset between two personal accounts

core.bookPersonalAccountOffset books a set-off (Aufrechnung, §§ 387–389 BGB) whose two claims stand on two personal accounts: a party that is customer and supplier (Kreditor an Debitor), or one party under two accounts of one kind (a renamed customer, a supplier with two creditor accounts); or on two open items of one account (a credit owed back on the item of the invoice it corrects, set off against a later invoice of the same party). owedByBusiness names the account and open item of what the business owes the party — debited — and owedToBusiness those of what the party owes the business — credited. No money moves and no line carries VAT or a BU key: each document booked its tax.

Belegfeld 1 settles an open item only where it is the item's number. Where both sides name the same number, or neither names one, the entry is one booking between the two accounts. Otherwise each side is booked against the clearing account (policy.clearing) with its own item's number, two DATEV rows that net to 0 there, so each item is settled by its own number; on one account the two rows move what is set off from one item to the other and leave the balance as it was. A side that is no personal account is refused (personal_account_required), one open item on both sides (offset_same_account: one account, the same number or none on either), a number given but blank (offset_reference_required), an amount that is not positive whole cents (amount_not_positive).

core.bookPersonalAccountOffset({
  date, amountCents: 23800, description: 'Aufrechnung ER-7 gegen RE-2026-012',
  owedByBusiness: { account: '70001', reference: 'ER-7' },        // the supplier's invoice we owe
  owedToBusiness: { account: '10001', reference: 'RE-2026-012' }, // our invoice to the same party
}, policy);
// 23800 S 70001/<clearing> Belegfeld 1 ER-7 · 23800 S <clearing>/10001 Belegfeld 1 RE-2026-012

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.

One row's fields

datevRowFields(row) answers the 125 fields of one row exactly as writeExtfBuchungsstapel writes them, quoted and cut to their lengths: the row's line in the file is datevRowFields(row).join(';'). A caller that needs to know something of one row without writing a whole file (for example which of its characters the file's encoding cannot carry) reads it there. It refuses a row as the writer does.

Every text field the writer writes (the Buchungstext, the Beleginfo, the document link, the cost centres, and the header's label, origin and exporter) is written with a " as ' and each line break (CRLF counting as one) or other control character as one space, so a row stays on its line of the file, and then cut to its length. Belegfeld 1 and 2 are written as DATEV takes them instead (below).

Belegfeld 1 and 2, and the Beleginfo

DATEV takes 1 to 36 of letters, digits and $ & % * + - / in Belegfeld 1 and 2 (isDatevBelegfeld), and clears an open item only where the payment repeats the invoice's Belegfeld 1. datevBelegfeldOf(text) maps any text to one DATEV takes, deterministically, so an invoice and what clears it share their key when both state the same text:

core.datevBelegfeldOf('RE-2026-001'); // 'RE-2026-001' (unchanged)
core.datevBelegfeldOf('RE 1');        // 'RE-1-' + six base-36 characters of the text's SHA-256
core.datevBelegfeldOf('Müller Nr. 7'); // 'Mueller-Nr-7-…'
core.datevBelegfeldWithInfoOf('RE 1');
// { belegfeld: 'RE-1-…', belegInfo: [{ art: 'Rechnungsnummer', inhalt: 'RE 1' }] }

A mapped value always carries the hash, so RE 1 never keys the item of RE-1, and two numbers that share their first 36 characters stay apart. journalDraftsToDatevRows, datevRowFields and writeExtfBuchungsstapel write every Belegfeld through it (datevBelegfelderOf) and add the original text as a Beleginfo pair (fields 21 to 36: Art of at most 20 characters, Inhalt of at most 210, both given, at most eight) where the mapping changed it, unless the line (IJournalLineDraft.belegInfo) or the draft with its reference (IJournalDraft.belegInfo) states its own. validateJournalDraft names such a Belegfeld as a warning and leaves the draft as it was given.

Reading a Buchungsstapel back

A tax advisor who keeps the year's close in DATEV hands its entries back as a Buchungsstapel (EXTF 700, Datenkategorie 21). readExtfBuchungsstapel reads the file as DATEV writes it, Windows-1252 bytes, into its header and the rows writeExtfBuchungsstapel writes; journalDraftOfDatevRow gives each row's journal draft; importExtfBuchungsstapel(bytes, policy) does both and checks each draft (validateJournalDraft):

const { header, entries, warnings } = core.importExtfBuchungsstapel(bytes, policy);
for (const { row, datevRow, draft } of entries) {
  // post draft as an entry taken over from another system; datevRow keeps fields 115 to 118
}
  • A row's Belegdatum is TTMM: its year is the one of the Stapel's period (header fields 15 and 16) the day falls in, counted from the Wirtschaftsjahr-Beginn (field 13), so a fiscal year from July places March in the next calendar year. A day outside the period is refused (extf_date_outside_period).
  • Only the BU keys skr's table knows (BU_KEY_RULES: 1, 2, 3, 8, 9, 18, 19, 40, 91, 94) are taken; any other is refused, naming the row (extf_bu_key_unknown). A Stapel or a row in another currency than the euro is refused (extf_currency_unsupported).
  • The tax is unfolded as journalDraftsToDatevRows folds it: a key of a domestic rate, or an Automatikkonto at a rate without a key, books the tax of the gross on its own line (the Automatikkonto keeps key 40 next to it); a key of a tax the recipient owes (18, 19, 91, 94) books the pair of input and output tax on the net; keys 1 and 40 book none. The account the tax is computed on is chosen by a rule of skr's own, not confirmed against DATEV's documentation: the row's profit-and-loss account or Automatikkonto, else the side that is no personal or money account of the policy, whether DATEV wrote it as Konto or as Gegenkonto. A keyed row between two accounts that could both carry the tax, or neither, is refused (extf_tax_account_unclear). A Stapel skr wrote reads back into drafts that write the same rows again; whether the rule reads a Stapel DATEV itself exported as DATEV books it is to be checked against a real advisor's Stapel.
  • A row must have every field of the caption line (125), with every quote closed and nothing after a closing quote; the Stapel's period (fields 15 and 16) must lie within the one Wirtschaftsjahr that begins on field 13. Otherwise the row or the header is refused (extf_row_malformed, extf_header_invalid).
  • Field 118 (Generalumkehr) marks the draft generalumkehr; fields 115 and 116 (Leistungsdatum, Datum Zuord. Steuerperiode) stay on the row.
  • Fields 21 to 36 are read as the row's Beleginfo pairs (belegInfo), which the draft's lines carry and the writer writes again. A pair DATEV would not take (one part only, an Art over 20 or an Inhalt over 210 characters) is skipped, never refusing the row, and named in warnings (row, line and field).
  • Every refusal is a DatevImportError with a code (TDatevImportErrorCode) and details naming the data row (row, from 1) and the file's line (line), the field or the key.

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.

Buchungsstapel rows (IDatevBuchungsstapelRow) can carry euVatIdDestination, written to field 40 "EU-Mitgliedstaat u. UStID (Bestimmung)": a country code (EL for Greece, XI for Northern Ireland) and at most 13 characters of the recipient's VAT ID, quoted, as the DATEV format description gives the field (^(["].{0,15}["])$). A value of another shape is refused. journalDraftsToDatevRows sets it on no row: what DATEV derives from it is not public, so the caller decides where to state it.

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.

The payment of an invoice with Skonto

InvoiceBookingEngine is not a root export of the package, and SkrApi does not call bookPayment: it is reached only by the deep import @fin.cx/skr/dist_ts/skr.invoice.booking.js.

InvoiceBookingEngine.bookPayment(invoice, payment, rules) books the payment through core.bookBankPayment: the control account is settled by payment.amount plus payment.skontoTaken, against the bank, and the Skonto is split over the invoice's vatBreakdown in proportion to the gross amount of each group (see "Skonto On An Invoice With Several VAT Rates"), so the input or output tax of each rate is corrected by its share (§ 17 Abs. 1 Sätze 1 und 2 UStG). rules.skontoMethod decides where the net share of each rate goes; the VAT correction is the same under both:

skontoMethod net share of a group at 19 % or 7 % e.g. SKR03 purchase, 3,57 € at 19 %
'gross' (default) the policy's Skonto account of the rate (erhaltene / gewährte Skonti) 3736 H 300 (BU 40) · 1576 H 57
'net' the expense or revenue account the group's lines were booked on, which the Skonto reduces 4930 H 300 (BU 9) · 1576 H 57

A group without VAT takes its share, without a correction, on the account its lines were booked on under both methods. Where a group needs its account and its lines went to several accounts, the payment is not booked (skonto_account_ambiguous) rather than guessed, nor is a § 13b or intra-Community acquisition invoice with Skonto (skonto_scenario_unsupported): its Skonto corrects the tax the recipient owes itself, which no recipe books yet. The engine and core.bookBankPayment refuse such a payment with a RecipeRefusalError of that code; bookPayment catches it and, as for every failure, returns { success: false, confidence: 0, errors: ['Payment booking failed: <message>'] }, so the code and details do not reach its caller.

The bank line and the control-account line carry no BU key. A BU key tells DATEV to compute or cancel tax on the line it stands on; the tax of a Skonto arises on the Skonto, expense or revenue line, and that line carries the key (40 on an Automatikkonto next to the explicit VAT line, else the key of its rate), while the VAT is booked on its own line. The payment itself carries no tax, so a key on the bank or control-account line would ask DATEV for a tax the payment does not have, and neither account is an Automatikkonto whose automatism key 40 would cancel. The DATEV export consolidates a payment the same way whatever the method (journalDraftsToDatevRows).

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
7 MiB
Languages
TypeScript 100%