@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 (
coreexport, also importable on its own as@fin.cx/skr/corewithout 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);
journalDraftsToDatevRowsconsolidates every draft into one row per booking (VAT lines folded into the gross row with the BU key, §13b / intra-EU pairs dropped, split lines against their aggregate), and document links are written asBEDI "GUID"
Breaking changes in v2
- The database must support transactions;
initialize()fails hard without them. postTransaction()/reverseTransaction()return transaction-shaped views (ITransactionView), notTransactiondocuments; the Transaction collection is frozen as a legacy read model.- Posted entries are immutable: reversing never mutates the original (
statusstaysposted; linkage viareversalOf). exportToDATEV()now emits the EXTF 700 format; persist the string as CP1252.IJournalEntryLine.postingKeyis optional; the v2 line fields (side,amountCents,counterAccount, stringbuKey) are authoritative.- Existing v1 data is migrated by
initialize()(see Version 7).
Breaking changes in v7
- Persistence moved from the deprecated
@push.rocks/smartdatato@lossless.org/client/nosqldb. TheAccount,TransactionandJournalEntrymodels extend itsSmartDataDbDoc; skr adds theLedgerSequence,LedgerPeriodandOposMetamodels over its existing collections. IDatabaseConfigis replaced byTDatabaseConfig: either{ mongoDbUrl, dbName? }(skr opens and closes the connection) or{ db }, aSmartdataDbyou connected yourself — skr prepares its collections in it and never closes it.migrateToV2()andSkrApi.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
legacyTransactionIdholds 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 — makesinitialize()fail withLedgerMigrationConflictError, naming it; nothing is deleted. A migration whose lease passed to another process stops before its next write withMigrationLeaseLostError; start skr again once the other process is done. JournalPoster.ensureIndexes()is removed: the models install their indexes atinitialize(), and the migrations create the journal's partial unique indexes.assertPeriodOpen(skrType, period, session?)takes aSmartdataSessionfrom@lossless.org/client/nosqldb(db.createSession()) instead of a MongoDB driverClientSession.- 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.OposMetagains a unique index over (skrType,accountNumber,reference); a database holding duplicate dunning records for one open item makesinitialize()fail withOposMetaDuplicatesError, naming them — merge them by hand, skr deletes nothing. LedgerandChartOfAccountsbook on the hashed journal likeSkrApi(same validation, gapless sequence, hash chain and transaction):postTransaction()returns anITransactionView,postJournalEntry()the hashedJournalEntry,reverseTransaction()/reverseJournalEntry()post Storno entries and leave the original untouched. Their reads come from the journal too:Ledger.getAccountHistory(),ChartOfAccounts.getAccountTransactions()andgetTransactions()returnITransactionView[];Ledger.getAccountBalance(),closeAccountingPeriod()andrecalculateAllBalances()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 onSkrApi.- The model-level writers that cannot map onto the journal throw
RetiredLedgerWriteError, naming the API to use:Transaction.createTransaction(),transaction.validateAndPost()andtransaction.reverseTransaction()(the Transaction collection is a frozen read model),JournalEntry.createJournalEntry()(the journal stores no unposted drafts; drafts arecore.IJournalDraft),journalEntry.post()(a posting is a new hashed entry, not an instance posted in place) andjournalEntry.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
SkrApior build around the primitives
Requirements
- Node.js 20+ with ESM support
- A database with transactions: a
@lossless.org/nosqldbengine, 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
expenseorrevenuewith an income-statement position (HGB § 275), listed inCHART_PROFIT_AND_LOSS_ACCOUNTS— and an expense for an outgoing excess, income for an incoming one; - none the chart marks for the UStVA or with a DATEV tax automatism
(
CHART_VAT_ACCOUNTS), and no Automatikkonto or VAT account of the policy (automatikkonten,vat,vatSettlement).
Every other account is refused: every balance-sheet account (banks, VAT
receivables and liabilities such as SKR 03 1770 or SKR 04 3800, personal and
clearing accounts), an account the chart does not hold, and an income account
for a payout. Both lists are generated from @fin.cx/chartdata by
tools/generate-chart-data.ts into ts/core/core.chartaccounts.ts. An
excess is refused as well when nothing moved beyond expectedCents. Named, it
takes an excess within diffToleranceCents as well, and expectedCents may be
0: a payment wholly beyond an open item already settled is all excess, and the
personal account is not touched. Without it, an excess above the tolerance is
refused, as before.
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 |
Legal basis
- Which period. § 17 Abs. 1 Satz 1 UStG: "Hat sich die Bemessungsgrundlage
für einen steuerpflichtigen Umsatz im Sinne des § 1 Abs. 1 Nr. 1 geändert, hat
der Unternehmer, der diesen Umsatz ausgeführt hat, den dafür geschuldeten
Steuerbetrag zu berichtigen." Satz 8: "Die Berichtigungen nach den Sätzen 1
und 2 sind für den Besteuerungszeitraum vorzunehmen, in dem die Änderung der
Bemessungsgrundlage eingetreten ist." § 17 Abs. 2 Nr. 3 UStG applies Absatz 1
"sinngemäß" when "eine steuerpflichtige Lieferung, sonstige Leistung oder
ein steuerpflichtiger innergemeinschaftlicher Erwerb rückgängig gemacht worden
ist". So a cancellation is corrected in the period in which the base
changed, not by reopening the period of the invoice, and the original entry
is left alone. The entry's
datedecides 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 asissueDate(ICustomerCreditNoteInput.issueDate, ordocument.issueDateof acredit_notedocument). Whether the correction predates the invoice it corrects is read from the two documents' own dates,issueDateandcorrectedInvoice.date; withoutissueDatefromdate, as before. AnissueDatethat is given and is no validDateis refused (credit_note_issue_date_invalid). skr does not refuse adatebeforeissueDate: 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.invoiceNumberandcreditNoteNumberare both required — the first as the Belegfeld 1 clearing key, the second as the entryreference. The booking itself only has to stay traceable (§ 145, § 146 AO). A credit note dated before the calendar day of the invoice it corrects is refused. (https://www.gesetze-im-internet.de/ustdv_1980/__31.html, https://www.gesetze-im-internet.de/ustg_1980/__14.html) - Never by deletion. § 146 Abs. 4 AO: "Eine Buchung oder eine Aufzeichnung
darf nicht in einer Weise verändert werden, dass der ursprüngliche Inhalt nicht
mehr feststellbar ist." (identically § 239 Abs. 3 HGB), and § 145 Abs. 1 AO
requires the books to give "einem sachverständigen Dritten innerhalb
angemessener Zeit einen Überblick über die Geschäftsvorfälle". The credit note
is an additional entry;
IJournalDraft.reversalOfstays unset, because that field marks the technical reversal of a wrong posting —SkrApi.postStornoorcore.reversalOf(see "Reversals" below). A credit note is not a wrong posting. (https://www.gesetze-im-internet.de/ao_1977/__146.html, https://www.gesetze-im-internet.de/hgb/__239.html, https://www.gesetze-im-internet.de/ao_1977/__145.html) - Not an Erlösschmälerung. Erlösschmälerungen accounts (SKR03 8730er,
Gewährte Skonti 8736/8731) carry price reductions on a supply that still
happened. A cancelled or returned supply removes the turnover itself, so it
belongs on the original revenue account.
bookBankPayment'sskontobranch is the Skonto path and stays separate. - Wrong tax on the invoice is a different case. If the invoice showed too much tax, § 14c Abs. 1 Satz 1 UStG makes the supplier owe the surplus and Satz 2 routes the correction through § 17 Abs. 1 as well ("Berichtigt er den Steuerbetrag gegenüber dem Leistungsempfänger, ist § 17 Abs. 1 entsprechend anzuwenden."); § 14c Abs. 2 (unberechtigter Steuerausweis) additionally needs the Gefährdung des Steueraufkommens removed and a written application to the Finanzamt. This recipe covers the § 17 case; a § 14c Abs. 2 correction needs that separate procedure. (https://www.gesetze-im-internet.de/ustg_1980/__14c.html)
Documents With VAT Groups Of Both Signs
A document can carry VAT groups of either sign: an invoice with a credited line
at another rate (goods at 19 % and a returned item at −7 %), or a correction
that lowers one rate and raises another. bookCustomerDocument and
bookVendorDocument book such a document as one set of entries, one per VAT
group, each with its own sign:
const entries = core.bookCustomerDocument(
{
date: new Date('2026-03-15'),
customerAccount: '10001',
document: { kind: 'invoice', invoiceNumber: 'AR-2001' },
groups: [
{ amountCents: 119000, vatRatePercent: 19, scenario: 'domestic_standard' },
{ amountCents: -10700, vatRatePercent: 7, scenario: 'domestic_reduced' },
],
description: 'AR-2001 Kunde',
},
policy,
);
// entries[0]: 10001 Soll 1 190,00 · 8400 Haben 1 000,00 · 1776 Haben 190,00
// entries[1]: 8300 Soll 100,00 · 1771 Soll 7,00 · 10001 Haben 107,00
// DATEV: 119000 S 10001/8400 and 10700 S 8300/10001, Belegfeld 1 = AR-2001
for (const entry of entries) {
await api.postDraft(entry);
}
- Signs.
amountCentsis the group's amount as it changes the open item of the counterparty: positive raises it, negative lowers it (the gross for domestic scenarios, the net for § 13b and intra-EU acquisitions). A positive group books likebookCustomerInvoice/bookVendorInvoice, a negative one like a correction of it — the same accounts with both sides swapped. A group of zero books nothing. The document's total may be of either sign or zero. - Numbers.
documentsays what the document is. An invoice ({ kind: 'invoice', invoiceNumber }) puts its number inreferenceand Belegfeld 1 of every entry; a correction ({ kind: 'credit_note', creditNoteNumber, correctedInvoice }) puts its own number inreferenceand the corrected invoice's number in Belegfeld 1, with the same checks asbookCustomerCreditNote. So every entry of the set books on one open item. On the vendor side a vendor's credit note names the vendor invoice it corrects the same way. - 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 ofamountCents. 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 (consolidateJournalDraftForDatevleaves 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.
passThroughCentsstates 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'spassThroughaccount (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 followsamountCents. 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:
signedCentsand every balance are debit positive, credit negative; an opening balance is given the same way. - Without
opening, no opening balances are recorded:openingCentsisnullon every account, never a 0 that was not given. With it, an account that has none reads 0, and an account that has only an opening balance is listed. - A period is two inclusive days (
YYYY-MM-DD, UTC). Lines beforeperiodFromare brought forward (prior…,carriedCents); lines afterperiodToare left out. - Refused, because the figures would be false: an entry whose debits and credits differ, an amount that is not a whole number of cents, an entry id given twice, a date that is no date, a period that is no day or ends before it begins, and an account's opening balance given twice. A negative line amount books on the other side; a line of 0 is no line.
- The Summen- und Saldenliste lists the accounts by account number, a
number of digits by its value: the general-ledger accounts (four digits)
first, then the debtors (10000–69999) and the creditors (70000–99999). Of
equal value (
0027,27) the text decides; a number with other characters comes after them all. - The Kontenblatt and the open items read the lines by day, and lines of one day in the order the entries came in, so the order the entries are handed in does not change an item's first and last day or its aging; its entry ids follow the days, and within a day that order.
deriveOpenItems,bucketOpenItemsandplanOpenItemSettlementare part of the core as well; the top-level exports of the same names are the same functions, andlistOpenItems/getAgingkeep reading the database ledger through them.
The Main Book In The Core
The rules a posted general ledger (Hauptbuch) needs, as pure functions: the application stores the entries, the core says what they are.
Reversals: Generalumkehr and Gegenbuchung
const reversal = core.reversalOf({ id: 'org1:2026-01-01:17', ...entry });
// same date, reference, accounts, sides, amounts, Belegfeld 1 and 2;
// generalumkehr: true, reversalOf: 'org1:2026-01-01:17', texts "Storno: …"
const gegen = core.reversalOf({ id: 'org1:2026-01-01:17', ...entry }, { form: 'gegenbuchung' });
const { lines, generalumkehr } = core.reversalLinesOf(postedLines, false, 'generalumkehr');
- A draft marked
generalumkehrstates its lines on their sides with positive amounts, and every line counts negative there (DATEV Buchungsstapel field 118). An entry and its Generalumkehr leave the turnover of every account as if neither had been booked:trialBalanceOflowers the sum of the side,accountSheetOfshows the line with a negative amount on its side,openItemsOftakes back the claim instead of settling it, andjournalDraftsToDatevRowswrites the rows of the reversed booking with field 118 set. - Without the mark nothing changed: a negative amount books on the other side. With the mark a negative amount is refused.
core.reversalLinesOf(lines, generalumkehr, form)turns posted lines into the lines of their reversal:generalumkehrkeeps sides and amounts and flips the mark (the reversal of a Generalumkehr is a plain entry),gegenbuchungswaps the sides and keeps the mark. Belegfeld 1 and 2, cost centres, document link and VAT facts are kept, so the reversal stays on the Beleg and on the open item of the entry it reverses (GoBD Rz. 64: "Korrektur- bzw. Stornobuchungen müssen auf die ursprüngliche Buchung rückbeziehbar sein"); the Buchungstext becomesStorno: …, cut to 60 characters.core.reversalOf(entry, { form? })builds the whole draft, linked byreversalOf.SkrApi.postStorno/JournalPoster.postStornoof the database ledger is unchanged and different: a Gegenbuchung whose Belegfeld 1 is replaced by the journal number. The database ledger records no Generalumkehr, sopostDraftrefuses a draft markedgeneralumkehr.
Opening entries in the ledger
An entry handed to the ledger functions with opening: true (the EB-Werte of
a fiscal year) is read as the opening balances: trialBalanceOf puts its lines
into openingCents, not into the turnover, and accountSheetOf into the
account's openingCents. With the opening option or openingCents given as
well, both add up; with neither, openingCents stays null.
Fiscal years
const years = core.fiscalYearsOf({
regimes: [{ from: '2020-01-01', startMonth: 1 }, { from: '2026-07-01', startMonth: 7 }],
firstDay: '2024-01-01',
through: '2026-08-01',
});
// 2024, 2025, 2026-01-01..2026-06-30 (short, 'change', consentRequired), 2026-07-01..2027-06-30 ('2026/27')
core.fiscalYearOfDay(years, '2026-03-15');
- A regime says that from its
fromday on, the years start on daystartDayofstartMonth(startDayis 1 when absent, and at most the day the month has in every year: 28 for February, 30 for April, June, September and November). A year starts on the first day of the books, on everystartDayof the governing start month and on every regime'sfrom, and ends the day before the next start or onlastDay(closing or sale). - Each
IFiscalYearSpanhas its days, the label (2026,2025/26) andmonths: the begun months, the fewest months after its start that reach past its end (2026-03-15..2026-12-31 is 10, a regular year from the 15th is 12; a month begun counts whole). - Months are counted as § 108 Abs. 1 AO with § 188 Abs. 2 and 3 BGB counts them: a period of months from the start of a day ends on the day before the day of the same number, and where the last month has no such day, on that month's last day. So 2026-01-31 + 1 month is 2026-03-01 (2026-01-31..2026-02-28 is one month), 2026-08-31 + 6 months is 2027-03-01, and 2028-02-29..2029-02-28 is twelve months.
shortmarks a Rumpfwirtschaftsjahr: the day after its end is not twelve months after its start, or it opens the books on a day that is no start of a year. A business opened on 2028-02-29 under years from 28 February has a first year to 2029-02-27, one day short of the twelve months that would end on 2029-02-28. Its reason isopening,closing,opening_and_closingorchange, as § 8b Satz 2 EStDV allows them. No year is longer than twelve months (§ 240 Abs. 2 Satz 2 HGB, § 8b Satz 1 EStDV).consentRequiredmarks the short year of a change to a closing day other than 31 December: it counts for tax only in agreement with the Finanzamt (§ 8b Satz 3 EStDV, § 4a Abs. 1 Nr. 2 Satz 2 EStG). Whether the consent was given, and whether the business may have a deviating year at all (§ 4a Abs. 1 EStG), is the caller's to know.taxYearis the calendar year the fiscal year ends in (§ 4a Abs. 2 Nr. 2 EStG, for traders).
Posted entries and their hash chain
const { seq, prevHash } = core.planNextEntry(lastEntry, ledgerId); // 1 and the genesis after none
const payload: core.IPostedEntryPayload = { ...record, ledgerId, seq };
const { valid, errors } = core.validatePostedEntry(payload, { policy, fiscalYear });
const entry = { ...payload, prevHash, entryHash: core.postedEntryHash(prevHash, payload) };
core.verifyLedgerChain(ledgerId, entriesOfTheLedger); // null, or { index, seq, reason }
IPostedEntryPayloadrecords what GoBD Rz. 94 lists: Belegdatum (documentDate), Buchungsdatum (postingDate), Erfassungsdatum (recordedAt), Buchungsperiode (period) and Voranmeldungszeitraum (vatPeriod), the Leistungsdatum, the lines with account, counter account, side, amount, BU key, Belegfeld 1 and 2 and VAT rate, amount and account, the foreign amount with its rate and the rate's source, the party, the Belege, the source with its key and digest, who recorded and confirmed it, the chart, and the rules version; with the ledger (ledgerId, one per organization and fiscal year), the numberseqand the owner'sentryNumberandschemaVersion.- A bank movement nothing else explains is its own Beleg: its electronic
account statement data, kept unchanged (GoBD Rz. 76-78).
movementIdsnames such movements (opaque ids of the owner's store) beside or instead ofdocumentIds; 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;validatePostedEntrychecks the shape of both lists. validatePostedEntrynormalises nothing, and refuses what a draft would only be warned about, because a posted entry can never be put right: an unbalanced or negative line, a Buchungstext over 60 characters, a Belegfeld that is not 1 to 36 of the characters DATEV takes, a period that is not the posting date's month, a posting date outside the fiscal year, and more.- 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_rateotherwise), 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)isentryHash(prevHash, payload)over exactly the payload's fields — an absent field and one read back asnullalike,generalumkehr: falseas absent, fields a store adds left out — so an entry changed, removed or put in between afterwards is found (§ 146 Abs. 4 AO, § 239 Abs. 3 HGB).ledgerGenesisHash(ledgerId)is what the first entry chains to.verifyLedgerChainanswers the firstgap,duplicate, entry of anotherledger,prev_hashorentry_hashbreak.
Opening balances, carry-forward, private movements, VAT prepayments
core.bookOpeningBalances({ date, description: 'EB-Werte 2026', balances: [
{ accountNumber: '1200', signedCents: 500000 },
{ accountNumber: '10001', signedCents: 119000, belegfeld1: 'AR-7' }, // per open item
] }, policy);
core.carryForwardOf({ date, description: 'Saldenvortrag 2027', closing: 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. carryForwardOfcarries a closed year's balance-sheet accounts into the next year (§ 252 Abs. 1 Nr. 1 HGB) and the year's result to equity by the legal form: a company to Gewinnvortrag or Verlustvortrag vor Verwendung (resultCarryForward: SKR 03 0860 / 0868, SKR 04 2970 / 2978), a sole trader to the capital account with the private accounts, a partnership by the partners' shares. The closing balances must sum to 0.- 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).bookVatPrepaymenttakes the account numbers of the chart in use and refuses, naming them, when it lacks the prepayment or the bank account; skr's own charts (SKR03_ACCOUNTS,SKR04_ACCOUNTS, from@fin.cx/chartdata2.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 withbookBankPayment;docLinkleads 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
expectedCentsthe 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 Skontoskontonames 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 withindiffToleranceCentson the rounding accounts; beyond them it is refused (shortfall_beyond_tolerance,excess_beyond_tolerance). A Skonto or a tolerance withoutexpectedCentsis 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_freeontaxFreeRevenueAccountFor('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
nonDeductibleVatCentsof 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
deductAdvancePaidInputTaxmoves 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 ofpaidOnandinvoiceAtHandOn, 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.customsnames 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
journalDraftsToDatevRowsfolds 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 inwarnings(row, line and field). - Every refusal is a
DatevImportErrorwith acode(TDatevImportErrorCode) anddetailsnaming 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:
inboundoutbound
Supported formats in the invoice model:
xrechnungzugferdfacturxpeppolubl
Example import and booking flow:
const invoice = await api.importInvoice('./fixtures/invoice.xml', 'inbound', {
autoBook: true,
confidenceThreshold: 80,
});
const hits = await api.searchInvoices({
invoiceNumber: invoice.invoiceNumber,
});
const exported = await api.exportInvoice(invoice, {
format: 'xrechnung',
embedInPdf: true,
});
The API also exposes:
bookInvoice()getInvoice()getInvoiceStatistics()createInvoiceComplianceReport()generateInvoice()
Invoice imports are stored by content hash, deduplicated, indexed in an NDJSON registry, and searchable by direction, date range, party, amount range, invoice number, and status metadata.
The document type (BT-3) is not interpreted here: the adapter reads and writes
it through getDocumentTypeCode / getAccountingDocType, the table exported by
@fin.cx/einvoice (>= 8), so IInvoice.invoiceTypeCode round-trips 380
(invoice), 381 (credit note), 383 (debit note) and 389 (self-billed invoice).
The other credit-note codes of EN 16931 rule BR-CL-01 (81, 83, 261, 262, 296,
308, 396, 420, 458, 532) decode as credit notes as well and are re-encoded as
the canonical 381; every remaining code decodes as an invoice. The booking side
still treats a credit note as the sign-reversing case.
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:
SkrApiAccountTransactionJournalEntryChartOfAccountsLedgerReportsSkrExportLedgerExporterAccountsExporterBalancesExporterPdfReportGeneratorSecurityManagerSKR03_ACCOUNTS,SKR04_ACCOUNTSSKR03_ACCOUNT_CLASSES,SKR04_ACCOUNT_CLASSES
This makes the package usable as both an application-facing API and a toolkit for custom accounting workflows.
Development
Build:
pnpm build
Test:
pnpm test
Current project checks include:
- runtime tests for SKR03 and SKR04 flows
- transaction and journal validation
- report generation
- DATEV export
- published type consumption through
test/fixtures/strict-consumer
License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the license file.
Please note: The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
Trademarks
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
Company Information
Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or further information, please contact us via email at hello@task.vc.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.