@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',
});
An account on a number DATEV leaves free after one of its accounts, which it
belongs to, is an individual account: declare it with
api.declareIndividualAccount ("Individual accounts (Individualkonten)"),
which chartdata checks and which takes its parent's type.
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, on its own account and with its tax where it
has one — see below),
bookToSuspense/resolveSuspense, fxDifferenceLines, detectTaxScenario
(confidence-scored cascade), the BU-key table (BU_KEY_RULES), and
validateJournalDraft. EU_COUNTRIES lists today's member states beside
Germany; isEuMemberOn(country, day) answers for a day (a Date by its UTC
day, or an ISO day YYYY-MM-DD): the United Kingdom through 2020-12-31, the
end of the Withdrawal Agreement's transition period, Croatia from 2013-07-01.
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, or a tax split by a key (deductibleVatCentsOf), 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; a key of a part deducted (deductiblePercent) that is not above 0 and below 100 with up to two places, on a sale's Skonto, beside nonDeductible, on a part without German VAT, or on a fee beside nonDeductibleVatCents; a fee's nonDeductible beside deductiblePercent or nonDeductibleVatCents, on a fee without German VAT, or other than true (field, amountCents, scenario) |
reverse_charge_paragraph_invalid |
a reverseChargeParagraph that is none of TReverseChargeParagraph, on a purchase, group or fee not under § 13b, a Skonto share without selfAssessed: 'reverse_charge_13b', a customer document's group, or abs_2_nr_3 on a fee (field, 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) |
payment_fee_input_invalid |
bookPaymentFee under a scenario no fee is taxed under (one of goods or of sales; PAYMENT_FEE_SCENARIOS lists the four), or a vatRatePercent other than 0 on a fee without German VAT (field, scenario, ratePercent) |
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; any Automatikkonto (SKR03 3300, 3400, 3731, 3736, SKR04 5300, 5400, 5731, 5736, or one the policy lists) as the account of a purchase, fee or Skonto share whose recipient owes the tax (§ 13b, intra-Community acquisition), where its base would carry key 40 instead of the scenario's key (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 %, a domestic part deducted by a key (deductiblePercent), 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 (at any rate for a part deducted by a key), 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).
A fee for another service than moving money names its account and its tax: expenseAccount (the
policy's paymentFees where absent; named, the policy need not name that role) and scenario, one of
PAYMENT_FEE_SCENARIOS (TPaymentFeeScenario). domestic_tax_free, or none, books as above. Under
domestic_standard or domestic_reduced the fee is the gross the provider charged, booked as a vendor
invoice paid from the balance: net on the account with key 9 or 8, the input tax on its own line. A
depot's custody fee is one, since § 4 Nr. 8 e UStG exempts no Verwahrung und Verwaltung von
Wertpapieren. Under reverse_charge_13b a provider abroad charged the net and the books owe the tax
(§ 13b Abs. 1 and 2 Nr. 1 UStG), as for a broker's market data from another member state: the fee on
the account with key 94 or 91 and the tax owed and deducted as a pair; reverseChargeParagraph
(abs_1 for a provider in another member state, abs_2 for one in a third country; abs_2_nr_3 is
refused) is stamped on that line. vatRatePercent is the scenario's where absent, and
nonDeductibleVatCents books the part of the tax the books may not deduct, as on a vendor
invoice: with the fee at a rate, as its own correction under § 13b. The recipe computes that part
from the tax as the scenario books it (from the gross at a rate, from the net under § 13b) where the
fee states why instead: nonDeductible: true excludes all of it, for books that deduct none (exempt
sales; a Kleinunternehmer, § 19 Abs. 1 UStG), and deductiblePercent, the key of § 15 Abs. 4 UStG,
the rest beside its deductible part (deductibleVatCentsOf). Any two of the three are refused
together, and each on a fee without German VAT (non_deductible_vat_invalid). A fee given back
corrects it the other way (§ 17 Abs. 1 UStG). The account is checked as a purchase's: at a rate, one
the chart ties to another rate or to none is refused (account_rate_mismatch); without German VAT,
one whose automatism books input tax (automatik_account_not_allowed), where the fee names its
account or its scenario; under § 13b any Automatikkonto (automatik_account_not_allowed, field
expenseAccount), whose base would carry key 40 instead of 94 or 91: the refusal names an account
without an automatism, such as the policy's paymentFees.
core.bookPaymentFee({
date: new Date('2026-05-04'),
direction: 'charged',
amountCents: 1_000,
bankAccount: '1210',
expenseAccount: '4900',
scenario: 'reverse_charge_13b',
reverseChargeParagraph: 'abs_1',
description: 'Marktdaten Mai',
reference: 'MD-0526',
}, core.SKR03_DEFAULT_POLICY);
// 4900 / 1210 10,00 € (BU 94, abs_1), 1577 / 1787 1,90 €
// with nonDeductible: true also 4900 / 1577 1,90 € without a key, the deduction taken back
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. Under § 13b the share names its
invoice's paragraph in reverseChargeParagraph, stamped on the keyed share,
so the reduction lowers the UStVA field the invoice's base stands in; it is
refused on any other part (reverse_charge_paragraph_invalid).
// 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
Automatikkonto, of the chart (its automatism books domestic Vorsteuer at a
rate, which DATEV would book on import) or one the policy lists
(automatik_account_not_allowed: the share would carry key 40 instead of the
scenario's key).
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.
bucketOpenItems(items, asOf, bucketDays) always returns a leading not_due
band (maxAgeDays: -1) for items whose due date is after asOf, followed by
the due bands. Without a due date it uses the item's first posting date.
The default bands are not_due, 0-30, 31-60, 61-90 and > 90; items
due on the key date remain in 0-30. Custom thresholds, such as [7, 14],
produce not_due, 0-7, 8-14 and > 14. Every item's signed outstanding
amount contributes to exactly one band's totalCents, including credits
and partially settled claims. The database-backed getOposAging returns
the same bands.
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. - A ledger line keeps what its line states of Belegfeld 1 and 2, the cost
centres (
costCenter1, KOST1, andcostCenter2, KOST2) and the document link, and its entry's reference; one left empty, the line carries none. - 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 Einnahmenüberschussrechnung (Anlage EÜR)
euerOf sums a period's entries onto the lines of the Anlage EÜR (§ 4 Abs. 3
EStG, § 60 Abs. 4 EStDV) of a form year. It sits beside trialBalanceOf and
reads the same entries. Which line each account goes to, and why an account
is no part of the form, comes from @fin.cx/chartdata (euer,
euerExclusion, ANLAGE_EUER_FORMS). The generator copies it into the core
(core.euerdata.ts), as it does the other chart facts.
const euer = core.euerOf(entries, {
policy: core.SKR03_DEFAULT_POLICY, // the chart, and the personal accounts' ranges
formYear: 2026,
periodFrom: '2026-01-01',
periodTo: '2026-09-30',
// smallBusiness: true — a Kleinunternehmer's receipts go to Zeile 12 (Kz 111)
});
euer.lines; // every line of the 2026 form in its order: { kennzahl, line, label, section, sign, side, cents, accounts }
euer.incomeCents; // Summe Betriebseinnahmen (Zeile 23)
euer.expenseCents; // Summe Betriebsausgaben (Zeile 76)
euer.resultCents; // income less expenses, before the corrections of Zeilen 79 ff.
euer.unmapped; // accounts with movements that no line takes: do not state the form while any is listed
// the same days a year earlier, on the current form year, line by line
const prior = core.priorYearPeriodOf({ periodFrom: '2026-01-01', periodTo: '2026-09-30' });
const previous = core.euerOf(priorYearEntries, { policy: core.SKR03_DEFAULT_POLICY, formYear: 2026, ...prior });
const comparison = core.euerComparisonOf(euer, previous); // lines with `cents` and `previousCents`
- The form counts the period's movements. The lines of opening entries (the EB-Werte) are left out, as are the days before and after the period. A Generalumkehr counts negative on its side, so an entry and its reversal leave nothing.
- An account's movements are netted toward its line's side: receipts count credits less debits (a credit note lowers them), expenses count debits less credits. The VAT prepayment and past-year accounts put what was paid to the tax office on Kz 186 and what it refunded on Kz 141, unnetted.
- The personal accounts (
debtorRange,creditorRange) are claims and debts, no part of the form. An account the chart excludes (euerExclusion) stays off the form. Any other account with movements that the chart neither maps nor excludes is listed inunmapped, and so is one it excludes asout_of_period(SKR03 2020/2520, SKR04 6960/4960): a cash-basis book has no periodenfremd category, so the host refuses or moves such an amount. - The sums count each line by its
sign. The column "nicht abziehbar", the "davon" lines and the Entnahmen and Einlagen (Kz 122 and 123) are reported but not summed. smallBusinesstakes the taxable (Kz 112) and the tax-free or non-taxable receipts (Kz 103) to Zeile 12 (Kz 111). Zeile 13 (Kz 119) is no account's and stays with the caller.- When a payment counts is its posting date. A host that applies the ten-day rule (§ 11 Abs. 1 Satz 2 and Abs. 2 Satz 2 EStG) to a VAT refund or prepayment posts it on the prior year's last day.
priorYearPeriodOfkeeps a period that ends with February ending with February a year earlier, on its 28th or its 29th.- The prior year compares on the current form year. The Kennzahlen stay the
same from year to year, so this works even for a year whose form the data
does not hold. Refused: a form year the core does not hold, a period that is
no period, and anything
ledgerLinesOfrefuses.ledgerPeriodOfis the check of a period that the ledger functions share.
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).
UStVA period data
SkrApi.getUstvaPeriodData(dateFrom, dateTo) (and getUstvaPeriodData) answers a period's account
movements and the bases of the self-assessed scenarios for @fin.cx/tax computeUstva
(movements, scenarioBases). An entry tagged intra_eu_acquisition or reverse_charge_13b gives
its lines with the scenario's key as its base (its non-VAT, non-personal lines where none has a key, a
tax booked as stated), never a line against a VAT account, the correction of a deduction excluded in
part. An untagged entry, such as a payment whose Skonto lowers such a purchase, gives its keyed lines; the query loads only the tagged entries and those with a line keyed 19, 18, 94 or 91.
A § 13b base goes into the field of its line's reverseChargeParagraph: abs_1 Kz 46
(reverseChargeServicesEuCents), abs_2_nr_3 Kz 73 (reverseChargeRealEstateCents), abs_2 Kz 84
(reverseChargeOtherCents); a line without one, posted before the paragraph was recorded or with a
tax as stated, stays in Kz 46, to be reclassified by hand where it falls under Abs. 2. Review
warnings before filing. individualAccounts carries the policy's individual accounts (under
SkrApi, the books' declarations; empty where there are none), the individualAccounts of
computeUstva (@fin.cx/tax ≥ 2.7.0), which counts a movement on one where its UStVA mapping places
it, such as SKR 03 8111 declared under 8110 in Kz 48; without them such a movement is an unknown
account.
const data = await api.getUstvaPeriodData(dateFrom, dateTo);
const result = computeUstva({
chart: 'SKR03',
year: 2026,
period: '2026-09',
movements: data.movements,
scenarioBases: data.scenarioBases,
individualAccounts: data.individualAccounts,
});
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. -
inputTaxUse(IInputTaxUse) states what the entry's supply is used for, as far as its input tax is concerned (§ 15 Abs. 2 and 4 UStG):taxable,exempt, ormixedwith its key of § 15 Abs. 4 as a decimal text (deductiblePercent: '62.5'; a text, since the hash covers integers only), above 0 and below 100 with up to two places in its one canonical form, so one key hashes one way: no leading zero but a single one before the point, no trailing zero in the fraction and no bare point ('62.5','62.05','50','0.5'; never'62.50','62.0','062.5','62.'or'.5'). It is a statement, never a computation: the amounts are the lines'. Covered by the hash where given.validatePostedEntryrefuses another kind or a malformed key (input_tax_use_invalid),mixedwithout its key (input_tax_use_key_missing) and, with a policy,exempton an entry whose lines on any of the policy's input-tax accounts do not net to zero, each account apart (input_tax_use_exempt_deducts), a line on the account it declares as (declaresAs); a § 13b pair whose deduction a correction takes back in full passes, as does its reversal. -
A line with a key of § 13b (91, 94) may state the paragraph its base falls under,
reverseChargeParagraph(abs_1Kz 46,abs_2_nr_3Kz 73,abs_2Kz 84), covered by the hash where given; on another line it is refused (reverse_charge_paragraph_without_key). The vendor, fee and Skonto recipes stamp it from their inputs. A base whose tax is booked as stated has no key and so no paragraph. -
A line may name the account it counts as in the VAT return,
declaresAs, where that is not its own: a VAT line of a closed year carried into a later year onto the VAT of the previous year (SKR03 1790, SKR04 3841) still counts as the account it was booked on (SKR03 1776, SKR04 3806). The consuming app checks the account; the core keeps it, hashes it where given (a line without it hashes as before), and a reversal keeps it.validatePostedEntryrefuses one that is no non-empty text (declares_as_invalid). A P&L line of the closed year carried onto the periodenfremde account (SKR03 2020/2520, SKR04 6960/4960) names its account the same way and keeps its key, rate, VAT account and § 13b paragraph. The readers of the VAT return take the line as that account: theexemptcheck (input_tax_use_exempt_deducts; input tax carried onto 1790 still deducts) andcomputeUstVerprobung. The checks of DATEV's behaviour read the line's own account, so an Automatikkonto line that stays put beside its tax carried onto 1790 posts with key 40 and is refused without it (automatik_account_not_allowed). DATEV gets such a line as a plain booking (consolidateJournalDraftForDatev): no BU key and none of the fields 40, 41, 43, 44, 115, 116, 119, 123, 124, so DATEV books no tax of 2026 from it. Carried lines keep their counter accounts as booked; a counter account no line of the draft is on any more is written as the account the lines declaring it sit on (the vendor line against 4920 against 2020; the § 15 Abs. 4 correction, 2020 against 1577 and 1790 against 4920, one row 2020 against 1790), and one declared on two accounts throws. Two carried lines on one account that cancel, each against the account the other declares as (the § 13b pair 1577 and 1787 both on 1790), give no row. No row books an account against itself: that throws, asvalidateJournalDraftrefuses such a line (same_account). With a policy,validateJournalDraftandvalidatePostedEntryrefuse a line withdeclaresAson an Automatikkonto (declares_as_automatik_account): DATEV gets it without key 40, so the automatism would book a tax from it; a line that stays put there carries none. -
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. -
cause(IPostedEntryCause:kind,id,recordedAtin epoch milliseconds, opaque to the core likesource) names what caused the entry to be posted, for example the statement that made a Storno and Neubuchung necessary; it is absent on entries posted for their own source. The hash covers it where given, and an entry without it hashes as one made before the field existed.validatePostedEntryrefuses another shape (cause_invalid): no object, an empty or missingkindorid, arecordedAtthat is no whole number from 0, or any other key. -
readThrough(Record<string, number>) names, per store of the consuming app, the time in epoch milliseconds through which the history the entry was decided on was read. It is the app's provenance: the core keeps it and gives it no meaning;reversalOfleaves it to the reversal's poster, as it doescause. The hash covers it where given (key order does not count), and an entry without it hashes as one made before the field existed.validatePostedEntryrefuses (read_through_invalid) no plain object, an empty one (it would hash otherwise than an absent one), a key that is no letter followed by letters and digits (keys become document field names), and a time that is no whole number from 0. -
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. A line with key 40 lifts its account's automatism: DATEV books it "als ob keine Automatik-Funktion vorhanden ist" (DATEV Dok.-Nr. 1046108), so it needs no VAT; the lines with key 40 of a rate against one account carry all their tax at that rate, within a cent each, or none of it, never a part of it. A net reclass between two Automatikkonten keyed 40 (8400 to 8820) and the accruals of a close (8400 against 0990 or 1500, 0980 against 3400) are posted; the same lines without the key are 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. -
Each error of
validatePostedEntryandvalidateJournalDrafthas its issue beside it inissues, in the same order (IEntryIssue): the rule as a code that stays (TEntryIssueCode, such asunbalanced,bu_key_unknown,posting_date_outside_fiscal_yearoraccount_rate_mismatch), the field as a path into what was validated (postingDate,foreign.rate,linesfor the lines together,lines.2.buKeyfor one line's,fiscalYearfor the options' fiscal year), the line's place from 0 where it is a line's, and the error's words. A caller points at the row and the field and says it in its own words without reading the error's, which can quote the entry's texts:const { valid, issues } = core.validatePostedEntry(payload, { policy, fiscalYear }); // [{ code: 'bu_key_unknown', field: 'lines.1.buKey', lineIndex: 1, message: "line 2 (8400): unknown BU key '77'" }]The rules
validatePostedEntrytakes fromvalidateJournalDraftname the entry's fields (chartfor the draft'sskrType); a draft's warnings have no issue. -
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'] }. An individual account under one of the year's VAT accounts (SKR 04 3821–3829 under the prepayments' 3820; SKR 03 leaves no number free after any of them) is moved as its parent is, unless its declaration changes its UStVA mapping: then it stands for itself, as in the VAT checks (vatCheckAccountOf), and is moved only wherevatAccountsnames it. An individual account under the previous or earlier years' account itself (SKR 04 3842–3844 under 3841, 3846–3849 under 3845) stays on its own number: it holds what the business keeps apart, such as a year under audit or in dispute, and moving it is a booking of the new year. 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/chartdata) 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 liability funded by a shareholder or partner
shareholderLiabilityAccountOf(policy, { kind, account }) validates and resolves
the liability account explicitly selected in the books' settings. It never
supplies a default when account is absent. kind describes the established
creditor relationship, not a guess from the business's legal form:
kind |
DATEV account group | SKR03 | SKR04 |
|---|---|---|---|
shareholder |
Verbindlichkeiten gegenüber Gesellschaftern | 0730 | 3510 |
gmbh_shareholder |
Verbindlichkeiten gegenüber GmbH-Gesellschaftern (GmbH/UG) | 1665 | 3640 |
general_partner |
Verbindlichkeiten gegenüber persönlich haftenden Gesellschaftern | 1670 | 3645 |
limited_partner |
Verbindlichkeiten gegenüber Kommanditisten | 1675 | 3650 |
SHAREHOLDER_LIABILITY_ACCOUNTS lists these standard account groups, not
settings or fallback accounts. The configured account must be the stated
group's classified liability account, or an individual account belonging to
it. Any established category may instead name the configured general
shareholder-liability group (0730/3510); it must not name another category's
specific group. The account must remain a tax-free balance-sheet liability,
without UStVA or an EÜR income/expense line. An account named as an
Automatikkonto or VAT account in the policy is refused too.
const fundedAccount = core.shareholderLiabilityAccountOf(policy, {
kind: establishedCreditorKind,
account: booksSelectedLiabilityAccount,
});
const settlement = core.bookBankPayment({
date: paymentDay,
direction: 'outgoing',
bankAccount: fundedAccount,
counterpartyAccount: personCreditorAccount,
expectedCents: dischargedCents,
paidCents: dischargedCents,
reference: claimNumber,
description: 'Reimbursement liability privately discharged',
}, policy);
This reuses the ordinary outgoing settlement: debit the original creditor, credit the liability to the actual funding creditor. DATEV receives one row without a BU key, not a second expense or input-tax booking. The host must establish the actual payer, legal form in force, shareholder/partner status, the discharge of the original liability and reimbursement/repayment rights. Legal form alone does not decide equity versus debt; explicitly agreed equity needs its lawful contribution account, not this liability resolver. Cooperative member funding, waivers, unclassified maturity accounts and special agreements are not inferred. The host refuses unresolved facts and missing settings. Cash, payroll and private-contribution settings remain separate. Ordinary payroll clearing remains 1755/3790; 1756/3791 describe the special recurring expense timing of § 11 Abs. 2 Satz 2 EStG for § 4 Abs. 3 EStG, not all EÜR payroll.
| Refusal | Actionable field and remedy |
|---|---|
shareholder_liability_kind_required |
kind: establish the creditor's shareholder/partner category; do not infer it from a payment label |
shareholder_liability_account_required |
account: select the funding creditor's account in the books' settings |
shareholder_liability_account_not_allowed |
account: select a classified, tax-free liability of that category in this chart; details.accounts names its standard groups |
Sources read via curl: DATEV SKR03 2026 (Art.-Nr. 11174, pp. 3, 9-11), DATEV SKR04 2026 (Art.-Nr. 11175, pp. 12-13), GoB traceability/correct recording (§ 238 HGB, § 239 HGB), reimbursement (§ 670 BGB), equity payments (§ 4 EStG, § 272 Abs. 2 Nr. 4 HGB) and the original receipt's input tax (§ 15 UStG).
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 100,00 (BU 94) / 1577 debit 19,00 / 1787 credit 19,00 / 70001 credit 100,00, and 4900 debit 19,00 / 1577 credit 19,00 — the tax is owed and its deduction taken back |
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. The base keeps the
scenario's key (94 / 91, 19 / 18), so DATEV declares the base and the tax in
full, owed and deducted, in Kz 46/73/84 or 89/93 and Kz 67 or 61; the part
excluded is taken back from the input tax as a booking of its own without a
key, Dr the expense account, Cr the scenario's input-tax account (§ 9b Abs. 1
EStG): the export writes 100,00 S 4900/70001 BU 94 and 19,00 S 4900/1577.
An Automatikkonto is refused as the expense account of any § 13b or
intra-Community purchase, its tax deducted or not (automatik_account_not_allowed):
its base would carry key 40 instead of the scenario's key, which DATEV reports as
a domestic purchase at the account's rate, outside Kz 46/73/84 or 89/93, and the
import would deduct the tax again. The refusal names an account without an
automatism (Wareneingang, SKR03 3200, SKR04 5200; the policy's paymentFees).
Entries posted before 8.14.0 with key 40 on such a base stay valid. A Skonto of such a § 13b or acquisition part is given
with selfAssessed and nonDeductible: true: its share keeps the key, which
lowers the tax owed and its deduction, and the deduction is given back from
the expense by its tax (Dr input tax, Cr the account). A domestic part the
recipient does not deduct is given at 0 % on its expense account, gross.
Entries posted before 8.12.0 keep the form they were posted in: the base on a
key-less line with the excluded tax, the tax owed split by its counter lines.
A purchase that serves taxable and exempt sales alike is deducted by a key (§ 15
Abs. 4 UStG). deductibleVatCentsOf(vatCents, deductiblePercent) gives the part
deducted, rounded half up to the cent with the sign of the tax; the rest is the
nonDeductibleVatCents of the group. A key is above 0 and below 100 with up to
two places, else it is refused (non_deductible_vat_invalid). A Skonto share of
such a purchase names the key as deductiblePercent and is split by the same
rule: the key's part of its tax lowers the input tax, the rest lowers the
expense account with the net. A domestic share names its expense account, which
may not be one whose automatism books input tax (skonto_account_required,
skonto_account_not_allowed); a selfAssessed share lowers the tax owed in
full and its deduction by the key.
| 119,00 € at 19 %, 62.5 % deducted, 2 % Skonto | lines (SKR03) |
|---|---|
| invoice | 4930 debit 107,12 / 1576 debit 11,88 / 70001 credit 119,00 |
| Skonto, domestic share | 4930 credit 2,14 / 1576 credit 0,24, both against 70001 |
| Skonto, § 13b share (100,00 € net) | 4900 credit 2,00 (BU 94) against 70001 / 1577 credit 0,38 / 1787 debit 0,38, and 1577 debit 0,14 / 4900 credit 0,14 |
const vatCents = 1900;
const deducted = core.deductibleVatCentsOf(vatCents, 62.5); // 1188
core.bookVendorDocument({
date, vendorAccount: '70001', document: { kind: 'invoice', invoiceNumber: 'ER-1' },
groups: [{ amountCents: 11900, vatRatePercent: 19, account: '4930', nonDeductibleVatCents: vatCents - deducted }],
description: 'ER-1',
}, policy);
core.bookBankPayment({
date, direction: 'outgoing', counterpartyAccount: '70001',
expectedCents: 11900, paidCents: 11662,
skonto: { shares: [{ ratePercent: 19, grossCents: 11900, account: '4930', deductiblePercent: 62.5 }] },
reference: 'ER-1', description: 'Zahlung ER-1 mit Skonto',
}, policy);
A part of the expense that the income tax does not deduct, such as the 30 % of a
business meal (§ 4 Abs. 5 Satz 1 Nr. 2 EStG), is no tax excluded:
IVendorDocumentInput.nondeductibleExpense ({ account, amountCents }, the
amount as businessMealOf of @fin.cx/expenses computes it) books it on its own
expense account (SKR03 4654, SKR04 6644) beside each group's account, split over
the groups by their expense with the largest remainder. The input tax stays
deducted in full (§ 15 Abs. 1a Satz 2 UStG), as a booking of its own: with two
expense lines on one tax, neither carries a key, and DATEV books each line as it
stands. costCenter2 gives every line of a vendor document its KOST2.
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
Individual accounts (Individualkonten)
A policy may declare the books' own accounts (individualAccounts, the
declarations of @fin.cx/chartdata): accounts on the numbers DATEV leaves free
after one of its accounts, which they belong to. An individual account is what
its parent is — its type, HGB position, UStVA and Anlage EÜR mapping, tax rule
— without the parent's DATEV tax automatism, unless its declaration changes
one deliberately. The core reads them through chartdata's BooksChart
(booksChartOf), which refuses declarations chartdata refuses
(IndividualAccountsError: a number DATEV assigns, a parent the number does
not follow, …).
const policy: core.IAccountPolicy = {
...core.SKR03_DEFAULT_POLICY,
individualAccounts: [
{ accountNumber: '2701', accountName: 'Erträge aus Termingeschäften', parentAccountNumber: '2700', securitiesRole: 'termingeschaeft_gain' },
{ accountNumber: '2301', accountName: 'Verluste aus Termingeschäften', parentAccountNumber: '2300', securitiesRole: 'termingeschaeft_loss' },
],
};
core.isProfitAndLossAccount(policy, '2701', 'income'); // true, as 2700
- The checks of what an account is see an individual account as its parent:
a bank payment's excess and a Skonto share go to an individual income or
expense account (
isProfitAndLossAccount), and one under an account marked for the UStVA is refused where VAT accounts are (isVatMarkedAccount); a booking's VAT is checked against the tax its parent is tied to — tax-free revenue, the 0 % of § 12 Abs. 3 UStG, revenue taxed abroad, an automatism's rate (vatCheckAccountOf) — unless its declaration changes its UStVA mapping; the DATEV reader takes one as a profit-and-loss account; the Anlage EÜR (euerOf) puts its movements on its parent's line, or on the one its declaration names. missingChartAccountsOftakes the accounts the caller hands it: a caller hands it the books chart's (booksChartOf(policy).accounts).- An individual account that names a securities role is the account the securities recipes book that role on.
Books kept in the database declare their individual accounts on the chart.
declareIndividualAccount has chartdata check the declaration together with
the ones stored before and refuses it with chartdata's
IndividualAccountsError (problems), storing nothing; a number another
account of the books holds is refused too. A declaration chartdata takes is
stored as an Account of the chart: its number and name, its parent's type,
no system account, with the declaration (parentAccountNumber,
securitiesRole, overrides) on it. From then on it is posted on, read
(getAccount, getAccountBalance, the ledger's history) and checked as any
account, and Ledger checks its type against its parent's instead of warning
that it is no standard account.
await api.declareIndividualAccount({
accountNumber: '2701',
accountName: 'Erträge aus Termingeschäften',
parentAccountNumber: '2700',
securitiesRole: 'termingeschaeft_gain',
}); // an Account of type 'revenue', as 2700
await api.getIndividualAccounts(); // the stored declarations, by number
api.getDefaultPolicy().individualAccounts; // the same: postDraft and the recipes see them
await api.postDraft(core.bookContractClose({ date, description, variationMarginCents: 20_000 }, api.getDefaultPolicy())); // 1700 / 2701
SkrApi.getDefaultPolicy()carries the declarations stored when the API was initialized and those changed through it since (declareIndividualAccount,updateAccount,deleteAccount);getIndividualAccounts()reads them anew, so declarations another instance stored are taken. Books without declarations get the chart's default policy as before.ChartOfAccounts.declareIndividualAccountandgetIndividualAccountsdo the same on the chart, andAccount.declareIndividualAccount(skrType, …)andAccount.getIndividualAccountDeclarations(skrType)on the model.- An individual account is only declared:
createAccount(andAccount.createAccount) refuses account data that names a parent, a securities role or overrides. A new name (updateAccount) is checked as its declaration. An individual account without postings is deleted withdeleteAccount, which drops its declaration.
Securities and derivatives (a depot)
The securities recipes book what a securities ledger decided — @fin.cx/depot's
movements: what units cost and brought, the book value released, the kind of
result — on the accounts of the securities roles of @fin.cx/chartdata. The
account of a role is the one DATEV keeps for it and its tax rule, or the books'
individual account that names it (securitiesAccountOf); for a role DATEV
keeps no account of its own for, the account of its position
(SECURITIES_POSITION_ACCOUNTS). The results the law keeps apart — of
Termingeschäfte, whose losses § 15 Abs. 4 Satz 3 EStG offsets only against
their gains, and of short sales — go only to individual accounts: until the
books declare them, those recipes refuse (securities_account_missing, with
the parent chartdata suggests and the numbers free after it).
| Recipe | Books (SKR 03; SKR 04 by the same roles) |
|---|---|
bookSecuritiesPurchase |
the cost on the holding of its classification and kind (1348; fixed: 0530 shares, 0535 bonds, 0525 other) against the cash; a call exercised hands its book value over from the options bought |
bookSecuritiesSale |
by default (override with taxTreatment), current assets net: a share's result on 2726/2326 (§ 8b) with the costs apart on 4976/4977, anything else's on 2725/2325 net of its costs; fixed assets gross on the disposal accounts (8839/8819 and 2318/2313 for a share, 8838/8818 and 2317/2312 otherwise); the accounts of a gain or of a loss as the result net of costs is one; a put exercised lowers the proceeds |
bookOptionPurchase, bookTermingeschaeftClose |
options bought on 1500 (other assets); their sale, expiry or cash settlement on the books' Termingeschäft accounts |
bookVariationMargin, bookContractClose, bookVariationMarginWriteOff |
a future's or a CFD's daily settlement, one net balance per contract on the side it stands on (1500 paid, 1700 received); its close the Termingeschäft's result; a net paid margin written off at a closing date on 4886 |
bookWrittenOptionOpen, bookWrittenOptionClose, bookWrittenOptionProvision |
the premium an obligation (1700) until expiry, assignment or buy-back; earned on 2700, a cash settlement's payment a Termingeschäft's loss; the provision for an expected loss on 0976 (HB), formed on 2300, released on 2735 |
bookShortSaleOpen, bookShortSaleCover, bookShortSaleValuation |
the obligation to deliver (1700), its cover and its valuation, the result on the books' short-sale accounts |
bookSecuritiesLending |
securities lent: the claim to their return (1500) for the holding, at book value |
bookSecuritiesIncome |
dividends, distributions, coupons, gross: 2654 or 2620; a dividend of a holding of 10 % or more on 2655 or 2625 (§ 8b Abs. 1 and 4 KStG) |
bookWithholdingTax |
Kapitalertragsteuer and its Solidaritätszuschlag (2213, 2216), split as the broker states it (soliCents) or as § 4 SolZG gives it (5.5 % of the tax, fractions of a cent dropped); foreign tax to credit (2219), on income exempt at home (2218), to be refunded (1542) |
bookDepotInterest |
credit interest and lending fees on the policy's interest account, margin interest (2110), the fees of securities borrowed (2140), compensations received (2700) and paid on securities sold short (individual account) |
bookAccruedInterest, bookInterestAccrual, bookBondInterestAdjustment |
Stückzinsen paid a claim (1500), set off by the coupon or the sale; interest accrued at a closing date; a premium spread or a zero bond's interest accreted against the interest income |
bookSecuritiesWriteDown, bookSecuritiesWriteUp |
§ 253 HGB: a current asset's write-down on 4875 (a share's 4876), a fixed asset's for a lasting decline on 4870 (4871), for a temporary one by choice on 4866 (HB); options bought on 4886; write-ups on 2715, 2712, a share's on 2714 |
bookSecuritiesAllocation, bookSecuritiesExchange |
a corporate action as a person decided it: the book value carried over to the securities received, moved to the holdings of their kinds (a spin-off at book value); or the securities given realised as in a sale for the securities received at their cost (§ 6 Abs. 6 Satz 1 EStG), with money received alongside or paid; what moves nothing gives a draft without lines |
bookSecuritiesReclassification, bookCurrencyConversion |
an Umwidmung at book value; a conversion between two cash accounts at its euros |
core.bookSecuritiesSale({
date: new Date('2026-05-04T00:00:00.000Z'),
classification: 'current',
kind: 'share',
proceedsCents: 119_000, // net of costs: the depot's close.amountCents
costsCents: 1_000,
bookCents: 100_000,
cashAccount: '1210',
description: 'Verkauf 100 ACME',
reference: 'U1234567-T-88',
}, core.SKR03_DEFAULT_POLICY);
// 4976 / 1210 10,00 € (costs of a § 8b sale), 1210 / 1348 1 000,00 €, 1210 / 2726 200,00 €
securitiesHoldingAccountOfnames the holding of a classification and kind: 1348 for every current asset, a fixed asset's by its kind.ISecuritiesSaleInputandISecuritiesExchangeInputaccept an explicittaxTreatment: TSecuritiesTaxTreatment:ordinaryorestg_3_40_kstg_8b. It selects the tax accounts of the securities disposed of, including a non-cash disposal booked through a sale. Without it, every existing call keeps its kind-derived treatment: shares use the § 8b accounts, other kinds the ordinary accounts. An ordinary-tax share keeps its share holding (fixed: SKR03 0530 / SKR04 0910); its sale costs stay net, with no separate § 8b costs line. An exchange still chooses both holding accounts by their respective classification and kind. Unknown explicit treatments are refused withsecurities_input_invalid.- The caller supplies the recorded tax-treatment decision; the recipes do not infer a REIT's treatment from a broker subclass. This disposal input does not change dividends, purchases, write-downs or write-ups.
- Units that come in or leave without money through the depot (a
contribution, a withdrawal at their value, a sale settled elsewhere) are a
purchase or a sale against the account they come in or leave against, named
as the
cashAccount. - Every line pairs with its counter account; no BU key, no VAT: securities
trading is exempt (§ 4 Nr. 8 e UStG). A fee of the depot is
bookPaymentFeewith the depot's cash asbankAccount; a custody fee with its VAT, or a fee of a broker abroad under § 13b, names itsexpenseAccountandscenario. - Signed amounts (income, interest, withholding, margins, lending, valuations of obligations) book a negative amount the other way: a reversal or a release.
- A fund's sale takes the ordinary accounts (§ 16 Abs. 3 InvStG); its tax — partial exemption, Vorabpauschale — lives in the depot's tax ledger, off the books.
What a tax advisor should confirm:
- The placement of the individual accounts in SKR 04: DATEV leaves one number after 4830 and one after 6300, which the Termingeschäfte take; where the writer's premium and the short sales' results go is the advisor's.
- That options bought, variation margins, Stückzinsen and lent securities belong on the other assets, a writer's obligation, a variation margin received and a short sale's obligation on the other liabilities, absent individual accounts.
- That payments in lieu of a dividend received on lent securities are other operating income and a lender's fee interest-like income.
- That a bond's premium and a zero bond's accretion are spread against the interest income.
- That the provision for an expected loss from a written option, marked HB, is formed on the other operating expenses and corrected outside the books for tax (§ 5 Abs. 4a EStG).
Payroll (a run's Buchungsbeleg and its payments)
The payroll recipes book what a payroll decided — a run's Buchungsbeleg: its
totals by account role, with no person's line — on the accounts of the payroll
roles of @fin.cx/chartdata, which every role has in both charts
(payrollAccountOf). A run goes through the Lohn- und Gehaltsverrechnungskonto
(SKR 03 1755, SKR 04 3790), as DATEV's payroll bookings do: every cost against
it, every debt out of it. A cost DATEV keeps no debt account for (an insurer's
premium for a company pension) stays on it until a payment from it settles it;
the run states that amount (leftOnClearingCents), and a run that does not add
up is refused (payroll_run_unbalanced, with the difference).
| Recipe | Books (SKR 03; SKR 04 by the same roles) |
|---|---|
bookPayrollRun |
each cost on its role's account against 1755: the pay (4110 Löhne, 4120 Gehälter, 4124, 4190, 4195, 4152, 4170, 4175), the flat taxes (4194, 4198, 4199), the employer's contributions (4130, 4144, 4138, 4165, 4167); each debt out of 1755: the net pay (1740), wage and church tax with the Solidaritätszuschlag (1741), the contributions (1742), withholdings (1748), capital formation (1750), the estimated contributions (1759); a company car's private use out of 1755 on 8611 net of its VAT (key 40) and on the output VAT; the levy refunds claimed on 1520 against 2749 |
bookPayrollObligationPayment |
a debt (or what a run left on 1755) paid from the bank account; with the contributions, the U1 and U2 refunds the insurer set off against the claim on 1520; a negative amount (a transfer sent back) books the other way |
core.bookPayrollRun({
date: new Date('2026-03-31T00:00:00.000Z'),
description: 'Lohn 03/2026',
reference: 'LOHN-2026-03',
costs: { salaries: 400_000, employer_social_security: 80_000 },
debts: { net_pay: 223_000, wage_tax: 55_000, social_security: 162_000 },
companyCarBenefit: { amountCents: 40_000, vatCents: 5_109 }, // the VAT the payroll computed on the supply
}, core.SKR03_DEFAULT_POLICY);
// 4120 / 1755 4 000,00 €, 4130 / 1755 800,00 €, 1755 / 1740 2 230,00 €, 1755 / 1741 550,00 €,
// 1755 / 1742 1 620,00 €, 1755 / 8611 348,91 € (key 40), 1755 / 1776 51,09 €
- Every line pairs with its counter account; only the company car's revenue line carries a key, and only the company car carries VAT.
- Amounts are signed: a correction run states negative amounts and books the other way.
- An individual account takes no payroll role: a role names DATEV's account.
What a tax advisor should confirm:
- The VAT on a company car's private use: the recipe books the VAT the payroll
computed (
vatCents), not one it derives from the amount in the pay. - Which debt account a payroll's deductions and contributions go to beyond the ones DATEV names (a company pension's premium left on the clearing account).
Expense claims (Auslagenersatz, Reisekostenabrechnung)
A person who laid money out for the business is owed it back. A claim books
against the person's own creditor account ({ kind: 'person', account }, in
policy.creditorRange), or, where the books keep no account per person,
against the Lohn- und Gehaltsverrechnungskonto ({ kind: 'payroll_clearing' }:
SKR 03 1755, SKR 04 3790, which keeps no open items). Every line carries the
claim's number as Belegfeld 1, the key of its open item. The recipes book what
the claim decided — the allowances, the receipts, a business meal's split, as
@fin.cx/expenses computes them — on the accounts of the expense roles of
@fin.cx/chartdata (expenseAccountOf), on the day the caller gives.
| Recipe | Books (SKR 03; SKR 04) |
|---|---|
bookExpenseClaimAllowances |
per diems on 4664 (6664), mileage on 4668 (6668), lodging flats on 4666 (6660), each against the person's account, without a key, with its cost centre and project (KOST1, KOST2) |
bookExpenseClaimReceipt |
a receipt as a vendor document (bookVendorDocument) with the person in the supplier's place: each VAT group on the account of its role (EXPENSE_CLAIM_RECEIPT_ROLES: tickets 4663, lodging 4666, parking 4660, office supplies 4930, …), its input tax by key; a tax that may not be deducted as cost; with deductiblePercent, a receipt deducted in part by a key of § 15 Abs. 4 UStG, each group's tax split by deductibleVatCentsOf, the rest as cost, no key; a business meal 70 % on 4650 (6640) and 30 % on 4654 (6644), its tip without VAT sharing the split, the input tax in full as a booking of its own, no key |
bookPersonReceivable |
private use of a company card: the person's account against the card's account (1730 Kreditkartenabrechnung, 3610; or the bank account of a debit card), through bookBankPayment, under the receivable's own number |
const claim = { claimNumber: 'AE-2026-0012', settlement: { kind: 'person', account: '70123' } } as const;
core.bookExpenseClaimAllowances({
...claim,
date: new Date('2026-03-20T00:00:00.000Z'), // the approval day
description: 'Reisekosten Hamburg',
allowances: [
{ kind: 'per_diem', amountCents: 5_600, costCenter1: '100', costCenter2: 'P-ACME' },
{ kind: 'mileage', amountCents: 17_400, costCenter1: '100' },
{ kind: 'lodging_flat', amountCents: 4_000, costCenter1: '100' },
],
}, core.SKR03_DEFAULT_POLICY);
// 4664 / 70123 56,00 €, 4668 / 70123 174,00 €, 4666 / 70123 40,00 €
core.bookExpenseClaimReceipt({
...claim,
date: new Date('2026-03-12T00:00:00.000Z'), // the voucher's day
description: 'Bewirtung Kunde Muster',
role: 'business_meals',
groups: [{ amountCents: 1_234, vatRatePercent: 7, scenario: 'domestic_reduced' }, { amountCents: 595, vatRatePercent: 19 }],
inputVat: 'deductible',
businessMeal: { nondeductibleCents: 526, tipCents: 100 }, // businessMealOf: 30 % of the expense of 17,53 €
}, core.SKR03_DEFAULT_POLICY);
// 4650 8,07 € + 4654 3,46 € + 1571 0,81 € / 70123 12,34 €
// 4650 3,50 € + 4654 1,50 € + 1576 0,95 € / 70123 5,95 €
// 4650 0,70 € + 4654 0,30 € / 70123 1,00 € (the tip)
The payout is a payment of the claim, through bookBankPayment, one per claim
(direction: 'outgoing', counterpartyAccount the person's account, the claim's
number as reference). A receivable of the person is set off against the claim
with bookPersonalAccountOffset (each open item by its own number, through the
clearing account), and the payout pays the rest:
core.bookPersonReceivable({
date: new Date('2026-03-14T00:00:00.000Z'),
settlement: claim.settlement,
cardAccount: core.expenseAccountOf(core.SKR03_DEFAULT_POLICY, 'credit_card_settlement'), // 1730
amountCents: 2_500,
description: 'Privatnutzung Firmenkarte',
reference: 'PU-2026-0003',
}, core.SKR03_DEFAULT_POLICY); // 70123 / 1730 25,00 €
core.bookPersonalAccountOffset({
date: new Date('2026-03-20T00:00:00.000Z'),
owedByBusiness: { account: '70123', reference: 'AE-2026-0012' },
owedToBusiness: { account: '70123', reference: 'PU-2026-0003' },
amountCents: 2_500,
description: 'Verrechnung Privatnutzung',
}, core.SKR03_DEFAULT_POLICY); // 70123 / 1792 and 1792 / 70123, 25,00 € each
core.bookBankPayment({
date: new Date('2026-03-25T00:00:00.000Z'),
direction: 'outgoing',
counterpartyAccount: '70123',
expectedCents: 24_500,
paidCents: 24_500,
description: 'Auslagenerstattung AE-2026-0012',
reference: 'AE-2026-0012',
}, core.SKR03_DEFAULT_POLICY); // 70123 / 1200 245,00 €
- On the person's creditor account the receivable is an open item of its own
number with
openCentsbelow 0, a credit in the business's favour (openItemsOf); the set-off settles it and lowers the claim's item. - Refusals:
expense_claim_input_invalid(a role no receipt takes, a meal without its split, a negative group, a group under § 13b, no claim number, a card account that is the settlement account, …),personal_account_required(a settlement account outside the creditor range),expense_account_missing,nondeductible_expense_invalid. - An individual account takes no expense role: a role names DATEV's account.
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. A key on an Automatikkonto that books the tax of its automatism, at its rate on its tax account (key 3 on SKR03 8611, key 9 on 3400), is read as the automatism, as the row without it: the account never carries that key. A key of another tax stays on it, and the row is refused (extf_row_invalid;validateJournalDraftnames the ruleautomatik_bu_key). 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
Pure BWA, GuV, Bilanz And USt-Verprobung
The core entry point computes these reports without a database, using the
books chart from @fin.cx/chartdata (2026, including individual accounts):
import * as core from '@fin.cx/skr/core';
const policy = core.SKR03_DEFAULT_POLICY;
const period = { policy, periodFrom: '2026-01-01', periodTo: '2026-02-28' };
const guv = core.computeGuv(entries, period);
const bilanz = core.computeBilanz(entries, { ...period, legalForm: 'corporation' });
const bwa = core.computeBwa(entries, { policy, month: '2026-02' });
const vat = core.computeUstVerprobung(entries, {
...period,
taxationMethod: 'soll',
returns: [{ month: '2026-02', kennzahlen: [
{ kennzahl: 81, kind: 'baseEur', value: 1000 },
{ kennzahl: 86, kind: 'baseEur', value: 100 },
] }],
});
entries are ILedgerEntryInput[], as for trialBalanceOf. Both period ends
are inclusive UTC days. All money is safe integer cents; percentages are
display numbers, not amounts used in reconciliation. Functions do not mutate
the input or invent missing mappings. ReportError.code distinguishes invalid
trial-balance columns, unbalanced books, unknown/missing account mappings,
nonzero technical accounts and missing/incompatible legal forms. Invalid
journals and dates retain the ledger's existing errors.
- GuV:
computeGuv(entriesOrTrialBalance, options): IGuvgives the §275 Abs. 2 Gesamtkostenverfahren positions, operating/financial/pre-tax subtotals andresultCents(GuV 17). Expense positions are debit-positive withsign: -1; income positions are credit-positive withsign: 1. Non-subtotal lines reconcile exactly bysum(cents * sign)to the period's P&L turnover. The existing chart groups material expenses and depreciation rather than supplying every statutory sub-disclosure; the report is not a signed set of annual accounts. - Bilanz:
computeBilanz(entriesOrTrialBalance, options): IBilanzgivesaktiva,passiva, section subtotals and equalaktivaCents/passivaCents. It uses closing balances (opening plus all turnover throughperiodTo), chart-owned side-switch rules and current-year VAT net groups. Personal accounts are shown individually by sign, not netted across customers or suppliers.resultCentsis the closing P&L balance transferred to Passiva A.V;periodResultCentsis the selected period's GuV. These coincide for a fiscal-year trial balance without prior-year P&L balances. Pass a complete, pre-closing fiscal-year journal and balanced opening accounts. The policy's Saldenvortragskonten (openingBalance: 9000, 9008, 9009) each carry the opposite of what they opened, debtors on 9008 and creditors on 9009 (bookOpeningBalances), so they are left out and only their sum must be zero; a nonzero sum, and a nonzero 9400/9998 balance, is refused (report_technical_balance), not silently called equity. Drawings requirelegalForm: 'sole_trader' | 'partnership'; a corporation is refused and must use its properly classified owner/shareholder accounts. - BWA:
computeBwa(entriesOrPeriods, { policy, month, fiscalYearStart? }): IBwagives month, fiscal-year-to-date, previous-year month and previous-year cumulative columns on the independent chartdata KER structure, not DATEV's proprietary mapping. Each column has cents, posting-account contributions andpercentOfTotalOutput; the percentage isnullif Gesamtleistung is zero. Missing prior-year data arenull, not invented zero comparatives; an available prior fiscal year with no movement in the matching month is zero. The default fiscal-year start is January 1.IBwaPeriodsaccepts separately preparedmonth,yearToDate, optionalpreviousMonthandpreviousYearToDatetrial balances with their exact inclusive periods. Comparatives use the current books chart: confirm historical account-number compatibility or hand in appropriately reclassified prior-year balances. - USt-Verprobung:
computeUstVerprobung(entriesOrMonthlyBalances, options): IUstVerprobungcompares monthly revenue × rate with booked domestic output VAT and chart-mapped UStVA sales bases (81/86/87). It returnsrates,differences,unresolvedandisComplete. Each rate exposes its revenue and VAT accounts,bookedDifferenceCentsandmappedDifferenceCents. Optional independently supplied returnkennzahlenuse the same units as@fin.cx/tax:baseEuris whole euros,taxCentsinteger cents. A supplied return omitting a sales Kennzahl states zero; no supplied return givesfiled…: null.filedBaseDifferenceCentsandfiledDifferenceCentskeep filing truncation and tax differences visible. Expected tax is rounded half-up on each month's aggregate base; document-level rounding can produce small legitimate differences. Unassigned generic revenue/VAT is listed, never guessed: eachunresolveditem carries a stablecode(TUstVerprobungUnresolvedCode:revenue_rate_unknown,output_vat_unassigned) to branch on and areasonin words. A journal can split one revenue account byvatRatePercentor a tax BU key; a monthly trial balance cannot recover such splits and reports them unresolved. A journal line that names the account it counts as in the VAT return (declaresAs) counts as that account, revenue, VAT and base alike; a monthly trial balance has no lines and counts its accounts. A journal entry counts in the VAT month it is declared in,ILedgerEntryInput.vatPeriod(YYYY-MM), else the month of its date: a correction of an earlier return dated later (§ 16 Abs. 1 Satz 2 UStG) counts in that return's month, its revenue, its VAT and the period's bounds alike, and a period that takes only part of such a month is refused.IMonthlyTrialBalance[]supplies{ month, balance }with exact full-month periods. Partial-month slicing of that input is refused.
The VAT comparison checks domestic sales tax, not a full UStVA calculation:
intra-community acquisitions and §13b need scenario bases and belong to
@fin.cx/tax. taxationMethod records Soll/Ist, as that package does; it does
not infer receipt dates or turn unpaid invoice turnover into taxable cash
receipts. An Ist caller must supply the correctly selected/recognized journal.
EÜR and HGB profit generally differ (especially VAT, accruals and unpaid
invoices); equality applies to an immediate-payment case without those
differences, not to every cash-basis book.
An advisor should confirm the business's legal form, fiscal-year opening and cutoff, the permitted VAT offset entity/period, materiality of VAT rounding, Soll/Ist recognition, equity deficit disclosures and required HGB sub-disclosures. No missing valuation, accrual or closing entry is estimated.
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.