@fin.cx/skr 3.1.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,13 +7,20 @@
7
7
  * STRING with CRLF; persisting it as CP1252 is the IO layer's job.
8
8
  * One manual import into real DATEV per release candidate remains the final
9
9
  * conformance gate (readme.plan.md, M0).
10
+ *
11
+ * A DATEV row is one Buchungssatz: the amount is booked on Konto with the
12
+ * Soll/Haben mark and on Gegenkonto with the opposite side. A journal draft
13
+ * is a balanced set of lines, so it is consolidated into bookings before it
14
+ * is written (see journalDraftsToDatevRows); writing one row per line would
15
+ * book every draft twice on import.
10
16
  */
11
17
  import * as plugins from './core.plugins.js';
12
- import type { IJournalDraft, TBuKey, TCents } from './core.types.js';
18
+ import type { IJournalDraft, IJournalLineDraft, TBuKey, TCents, TLineSide } from './core.types.js';
19
+ import { getDefaultPolicy, isAutomatikAccount, type IAccountPolicy } from './core.accountpolicy.js';
13
20
  import { DATEV_COLUMN_CAPTIONS } from './core.datev.captions.js';
14
21
  import { DATEV_MAX_BELEGFELD, DATEV_MAX_BUCHUNGSTEXT } from './core.validate.js';
15
22
 
16
- const { centsToString } = plugins.calculation;
23
+ const { allocateCents, centsToString } = plugins.calculation;
17
24
 
18
25
  export const DATEV_COLUMN_COUNT = 125;
19
26
  export const DATEV_HEADER_FIELD_COUNT = 31;
@@ -28,6 +35,7 @@ export interface IDatevBuchungsstapelRow {
28
35
  belegfeld1?: string;
29
36
  belegfeld2?: string;
30
37
  buchungstext: string;
38
+ /** document GUID; written as `BEDI "GUID"` (field 20) */
31
39
  belegLink?: string;
32
40
  kost1?: string;
33
41
  kost2?: string;
@@ -84,17 +92,189 @@ function quote(value: string, maxLength?: number): string {
84
92
  return `"${sanitized}"`;
85
93
  }
86
94
 
95
+ export interface IDatevRowsOptions {
96
+ festschreibung?: boolean;
97
+ /**
98
+ * Recognises VAT lines and Automatikkonten while consolidating; defaults to
99
+ * the default policy of each draft's chart.
100
+ */
101
+ policy?: IAccountPolicy;
102
+ }
103
+
104
+ /** Domestic tax keys whose row carries the gross amount; DATEV computes the VAT. */
105
+ const VAT_RATE_OF_KEY: Partial<Record<TBuKey, number>> = { '9': 19, '8': 7, '3': 19, '2': 7 };
106
+
107
+ function isVatAccount(policy: IAccountPolicy, accountNumber: string): boolean {
108
+ return Object.values(policy.vat).includes(accountNumber);
109
+ }
110
+
111
+ /** Rate of a domestic VAT account; intra-EU and §13b accounts only ever appear as pairs. */
112
+ function vatRateOfAccount(policy: IAccountPolicy, accountNumber: string): number | undefined {
113
+ const { vat } = policy;
114
+ if (accountNumber === vat.inputStandard || accountNumber === vat.outputStandard) return 19;
115
+ if (accountNumber === vat.inputReduced || accountNumber === vat.outputReduced) return 7;
116
+ return undefined;
117
+ }
118
+
119
+ function signedCents(line: IJournalLineDraft): TCents {
120
+ return line.side === 'debit' ? line.amountCents : -line.amountCents;
121
+ }
122
+
123
+ function mirrors(left: IJournalLineDraft, right: IJournalLineDraft): boolean {
124
+ return (
125
+ left.counterAccount === right.accountNumber &&
126
+ right.counterAccount === left.accountNumber &&
127
+ signedCents(left) + signedCents(right) === 0
128
+ );
129
+ }
130
+
131
+ /** Keys that make DATEV compute a self-assessed VAT pair (input against output VAT). */
132
+ function selfAssessedKeysOf(policy: IAccountPolicy, accountA: string, accountB: string): TBuKey[] | undefined {
133
+ if (accountA === accountB) return undefined;
134
+ const reverseCharge = [policy.vat.reverseChargeInput, policy.vat.reverseChargeOutput];
135
+ const intraEu = [policy.vat.intraEuAcqInput, policy.vat.intraEuAcqOutput];
136
+ if (reverseCharge.includes(accountA) && reverseCharge.includes(accountB)) return ['94', '91'];
137
+ if (intraEu.includes(accountA) && intraEu.includes(accountB)) return ['19', '18'];
138
+ return undefined;
139
+ }
140
+
141
+ /**
142
+ * Consolidate one balanced draft into the lines that become DATEV bookings.
143
+ *
144
+ * 1. VAT lines. A §13b / intra-EU pair (input and output VAT booked against
145
+ * each other) is dropped when a base line of the draft carries the key
146
+ * that makes DATEV compute both sides (94/91, 19/18); without such a key
147
+ * the pair stays and becomes one explicit booking. Domestic VAT lines are
148
+ * folded, per Gegenkonto and side, into the base lines whose BU key
149
+ * carries their rate (9/8, 3/2), sharing the VAT in proportion; a VAT
150
+ * line without a keyed base is folded into an Automatikkonto base (key
151
+ * 40 becomes '') only when it is the single remaining VAT line and that
152
+ * base is the single Automatikkonto candidate, because the policy does
153
+ * not know the rate of an Automatikkonto. Everything else stays an
154
+ * explicit booking on the tax account, next to a base that keeps key 40.
155
+ * 2. Mirror lines (A against B and B against A with the same amount) become
156
+ * one booking, taken from the line with a BU key, else from the debit line.
157
+ * 3. Split lines against one aggregate line (their signed sum cancels the
158
+ * aggregate) become one booking each; the aggregate line is dropped.
159
+ * Anything left over throws: writing it would double the booking on import.
160
+ */
161
+ export function consolidateJournalDraftForDatev(
162
+ draft: IJournalDraft,
163
+ policy: IAccountPolicy,
164
+ ): IJournalLineDraft[] {
165
+ const lines: IJournalLineDraft[] = draft.lines
166
+ .map((line) =>
167
+ line.amountCents < 0
168
+ ? { ...line, amountCents: -line.amountCents, side: (line.side === 'debit' ? 'credit' : 'debit') as TLineSide }
169
+ : { ...line },
170
+ )
171
+ .filter((line) => line.amountCents !== 0);
172
+ const consumed = new Set<number>();
173
+ const open = (): number[] => lines.map((_, index) => index).filter((index) => !consumed.has(index));
174
+ const isVat = (index: number): boolean => isVatAccount(policy, lines[index].accountNumber);
175
+
176
+ // 1a self-assessed VAT pairs
177
+ for (const i of open()) {
178
+ if (consumed.has(i) || !isVat(i)) continue;
179
+ for (const j of open()) {
180
+ if (j <= i || !isVat(j) || !mirrors(lines[i], lines[j])) continue;
181
+ const keys = selfAssessedKeysOf(policy, lines[i].accountNumber, lines[j].accountNumber);
182
+ if (keys && open().some((k) => !isVat(k) && keys.includes(lines[k].buKey))) {
183
+ consumed.add(i);
184
+ consumed.add(j);
185
+ }
186
+ break;
187
+ }
188
+ }
189
+ // 1b domestic VAT lines folded into their base lines, per Gegenkonto and side
190
+ const fold = (vatIndex: number, bases: number[], clearKey: boolean): void => {
191
+ const shares = allocateCents(lines[vatIndex].amountCents, bases.map((j) => lines[j].amountCents));
192
+ bases.forEach((j, position) => {
193
+ lines[j] = { ...lines[j], amountCents: lines[j].amountCents + shares[position], buKey: clearKey ? '' : lines[j].buKey };
194
+ });
195
+ consumed.add(vatIndex);
196
+ };
197
+ const groups = new Map<string, number[]>();
198
+ for (const i of open()) {
199
+ if (!isVat(i) || vatRateOfAccount(policy, lines[i].accountNumber) === undefined) continue;
200
+ const key = `${lines[i].counterAccount}|${lines[i].side}`;
201
+ groups.set(key, [...(groups.get(key) ?? []), i]);
202
+ }
203
+ for (const [key, vatLines] of groups) {
204
+ const bases = open().filter((j) => !isVat(j) && `${lines[j].counterAccount}|${lines[j].side}` === key);
205
+ const remaining: number[] = [];
206
+ for (const i of vatLines) {
207
+ const rate = vatRateOfAccount(policy, lines[i].accountNumber);
208
+ const keyed = bases.filter((j) => VAT_RATE_OF_KEY[lines[j].buKey] === rate);
209
+ if (keyed.length === 0) {
210
+ remaining.push(i);
211
+ continue;
212
+ }
213
+ fold(i, keyed, false);
214
+ }
215
+ const automatik = bases.filter(
216
+ (j) => isAutomatikAccount(policy, lines[j].accountNumber) && (lines[j].buKey === '' || lines[j].buKey === '40'),
217
+ );
218
+ if (remaining.length === 1 && automatik.length === 1) {
219
+ fold(remaining[0], automatik, true);
220
+ }
221
+ }
222
+
223
+ const bookings: IJournalLineDraft[] = [];
224
+ // 2 mirror lines
225
+ for (const i of open()) {
226
+ if (consumed.has(i)) continue;
227
+ for (const j of open()) {
228
+ if (j <= i) continue;
229
+ if (mirrors(lines[i], lines[j])) {
230
+ const keep = lines[i].buKey !== '' ? i : lines[j].buKey !== '' ? j : lines[i].side === 'debit' ? i : j;
231
+ bookings.push(lines[keep]);
232
+ consumed.add(i);
233
+ consumed.add(j);
234
+ break;
235
+ }
236
+ }
237
+ }
238
+ // 3 split lines against their aggregate line
239
+ for (const aggregateIndex of open()) {
240
+ if (consumed.has(aggregateIndex)) continue;
241
+ const aggregate = lines[aggregateIndex];
242
+ const group = open().filter((index) => index !== aggregateIndex && lines[index].counterAccount === aggregate.accountNumber);
243
+ if (group.length === 0) continue;
244
+ if (signedCents(aggregate) + group.reduce((sum, index) => sum + signedCents(lines[index]), 0) !== 0) continue;
245
+ for (const index of group) {
246
+ bookings.push(lines[index]);
247
+ consumed.add(index);
248
+ }
249
+ consumed.add(aggregateIndex);
250
+ }
251
+
252
+ const leftover = open();
253
+ if (leftover.length > 0) {
254
+ const detail = leftover
255
+ .map((index) => `line ${index + 1} (${lines[index].accountNumber} ${lines[index].side} ${lines[index].amountCents} against ${lines[index].counterAccount})`)
256
+ .join(', ');
257
+ const when = draft.date instanceof Date && !Number.isNaN(draft.date.getTime()) ? draft.date.toISOString().slice(0, 10) : 'undated';
258
+ throw new Error(
259
+ `Journal draft "${draft.description}" (${when}${draft.reference ? `, ${draft.reference}` : ''}) cannot be written as DATEV bookings: ${detail} has no counter line`,
260
+ );
261
+ }
262
+ return bookings;
263
+ }
264
+
87
265
  /**
88
- * Flatten journal drafts into DATEV rows one row per line, with the line's
89
- * explicit counterAccount as Gegenkonto (no pair reconstruction).
266
+ * Convert journal drafts into DATEV rows: one row per booking after
267
+ * consolidateJournalDraftForDatev, with the line's counterAccount as
268
+ * Gegenkonto.
90
269
  */
91
270
  export function journalDraftsToDatevRows(
92
271
  drafts: IJournalDraft[],
93
- options?: { festschreibung?: boolean },
272
+ options?: IDatevRowsOptions,
94
273
  ): IDatevBuchungsstapelRow[] {
95
274
  const rows: IDatevBuchungsstapelRow[] = [];
96
275
  for (const draft of drafts) {
97
- for (const line of draft.lines) {
276
+ const policy = options?.policy ?? getDefaultPolicy(draft.skrType);
277
+ for (const line of consolidateJournalDraftForDatev(draft, policy)) {
98
278
  rows.push({
99
279
  umsatzCents: line.amountCents,
100
280
  sollHaben: line.side === 'debit' ? 'S' : 'H',
@@ -115,6 +295,16 @@ export function journalDraftsToDatevRows(
115
295
  return rows;
116
296
  }
117
297
 
298
+ /** Field 20 links a document image: `BEDI "GUID"`, quotes doubled inside the CSV field. */
299
+ function formatBelegLink(value: string): string {
300
+ const guid = value
301
+ .trim()
302
+ .replace(/^BEDI\s*/iu, '')
303
+ .replace(/^"+|"+$/gu, '')
304
+ .replace(/"/g, "'");
305
+ return `"BEDI ""${guid}"""`;
306
+ }
307
+
118
308
  function rowToFields(row: IDatevBuchungsstapelRow): string[] {
119
309
  const fields = new Array<string>(DATEV_COLUMN_COUNT).fill('');
120
310
  fields[0] = formatAmount(row.umsatzCents); // 1 Umsatz
@@ -126,7 +316,7 @@ function rowToFields(row: IDatevBuchungsstapelRow): string[] {
126
316
  if (row.belegfeld1) fields[10] = quote(row.belegfeld1, DATEV_MAX_BELEGFELD); // 11
127
317
  if (row.belegfeld2) fields[11] = quote(row.belegfeld2, DATEV_MAX_BELEGFELD); // 12
128
318
  fields[13] = quote(row.buchungstext, DATEV_MAX_BUCHUNGSTEXT); // 14 Buchungstext
129
- if (row.belegLink) fields[19] = quote(row.belegLink); // 20 Beleglink
319
+ if (row.belegLink) fields[19] = formatBelegLink(row.belegLink); // 20 Beleglink
130
320
  if (row.kost1) fields[36] = quote(row.kost1); // 37 KOST1
131
321
  if (row.kost2) fields[37] = quote(row.kost2); // 38 KOST2
132
322
  if (row.festschreibung !== undefined) fields[113] = row.festschreibung ? '1' : '0'; // 114
package/ts/index.ts CHANGED
@@ -26,6 +26,12 @@ export {
26
26
  setOposMeta,
27
27
  } from './skr.opos.js';
28
28
  export type { IOpenItem, IAgingBucket, IControlTotals, TOposKind } from './skr.opos.js';
29
+ export { getUstvaPeriodData } from './skr.ustva.js';
30
+ export type {
31
+ IUstvaPeriodData,
32
+ IUstvaMovement,
33
+ IUstvaScenarioBases,
34
+ } from './skr.ustva.js';
29
35
  export { verifyHashChain } from './skr.verify.js';
30
36
  export type { IChainVerificationResult } from './skr.verify.js';
31
37
  export { migrateToV2 } from './skr.migrate.js';
package/ts/skr.api.ts CHANGED
@@ -1055,6 +1055,24 @@ export class SkrApi {
1055
1055
  return getControlTotals(this.currentSKRType!, this.getDefaultPolicy(), asOf);
1056
1056
  }
1057
1057
 
1058
+ // ========== UStVA extraction ==========
1059
+
1060
+ /**
1061
+ * One period's movements + exact igE/§13b scenario bases, shaped for
1062
+ * @fin.cx/tax `computeUstva()`. Review `warnings` before filing.
1063
+ */
1064
+ public async getUstvaPeriodData(
1065
+ dateFrom: Date,
1066
+ dateTo: Date,
1067
+ ): Promise<import('./skr.ustva.js').IUstvaPeriodData> {
1068
+ this.ensureInitialized();
1069
+ const { getUstvaPeriodData } = await import('./skr.ustva.js');
1070
+ return getUstvaPeriodData(this.currentSKRType!, this.getDefaultPolicy(), {
1071
+ dateFrom,
1072
+ dateTo,
1073
+ });
1074
+ }
1075
+
1058
1076
  public async setDunning(params: {
1059
1077
  accountNumber: string;
1060
1078
  reference: string;
@@ -0,0 +1,151 @@
1
+ /**
2
+ * UStVA period extraction: one period's account movements plus the EXACT
3
+ * scenario Bemessungsgrundlagen (igE / §13b) taken from taxScenario-tagged
4
+ * journal entries. The result feeds @fin.cx/tax `computeUstva()` directly —
5
+ * movements as `IAccountMovementInput[]`, scenarioBases as
6
+ * `IScenarioBaseInput`.
7
+ *
8
+ * Base extraction per tagged entry: sum of (debit - credit) over lines that
9
+ * sit neither on a policy VAT account nor on a personal account (>= 10000).
10
+ * For the recipe shapes that is exactly the net expense line; Stornos flip
11
+ * sides and cancel. Every extracted base is cross-checked against the
12
+ * entry's own input-VAT line — disagreement lands in `warnings`.
13
+ */
14
+ import { vatFromNetCents } from '@fin.cx/calculation';
15
+ import { getDbSync } from './skr.database.js';
16
+ import { JOURNAL_ENTRY_COLLECTION } from './skr.period.js';
17
+ import { aggregateTrialBalance } from './skr.balances.js';
18
+ import type { IAccountPolicy } from './core/index.js';
19
+ import type { TSKRType } from './skr.types.js';
20
+
21
+ export interface IUstvaMovement {
22
+ accountNumber: string;
23
+ /** debitCents - creditCents over the period */
24
+ signedCents: number;
25
+ }
26
+
27
+ export interface IUstvaScenarioBases {
28
+ /** igE 19 % net bases (Kz 89) */
29
+ intraEuAcquisitionStandardCents: number;
30
+ /** igE 7 % net bases (Kz 93) */
31
+ intraEuAcquisitionReducedCents: number;
32
+ /**
33
+ * §13b bases (Kz 46). The scenario tag does not distinguish EU services
34
+ * from other §13b cases — everything lands here; reclassify to Kz 84
35
+ * manually if a non-EU-services §13b case ever occurs.
36
+ */
37
+ reverseChargeServicesEuCents: number;
38
+ }
39
+
40
+ export interface IUstvaPeriodData {
41
+ movements: IUstvaMovement[];
42
+ scenarioBases: IUstvaScenarioBases;
43
+ /** tagged igE/§13b entries examined */
44
+ taggedEntryCount: number;
45
+ /** extraction anomalies — review before filing */
46
+ warnings: string[];
47
+ }
48
+
49
+ interface IRawEntry {
50
+ sequenceNumber?: number;
51
+ journalNumber?: string;
52
+ taxScenario?: string;
53
+ lines: Array<{ accountNumber: string; side?: string; amountCents?: number }>;
54
+ }
55
+
56
+ function isPersonalAccount(accountNumber: string): boolean {
57
+ const numeric = Number.parseInt(accountNumber, 10);
58
+ return numeric >= 10000 && numeric <= 99999;
59
+ }
60
+
61
+ export async function getUstvaPeriodData(
62
+ skrType: TSKRType,
63
+ policy: IAccountPolicy,
64
+ options: { dateFrom: Date; dateTo: Date },
65
+ ): Promise<IUstvaPeriodData> {
66
+ const trialBalance = await aggregateTrialBalance(skrType, options);
67
+ const movements: IUstvaMovement[] = trialBalance.accounts
68
+ .filter((account) => account.balanceCents !== 0)
69
+ .map((account) => ({
70
+ accountNumber: account.accountNumber,
71
+ signedCents: account.balanceCents,
72
+ }));
73
+
74
+ const vatAccounts = new Set(Object.values(policy.vat));
75
+ const warnings: string[] = [];
76
+ const scenarioBases: IUstvaScenarioBases = {
77
+ intraEuAcquisitionStandardCents: 0,
78
+ intraEuAcquisitionReducedCents: 0,
79
+ reverseChargeServicesEuCents: 0,
80
+ };
81
+
82
+ const collection = getDbSync().mongoDb.collection(JOURNAL_ENTRY_COLLECTION);
83
+ const tagged = (await collection
84
+ .find(
85
+ {
86
+ schemaVersion: 2,
87
+ skrType,
88
+ status: { $in: ['posted', 'finalized'] },
89
+ taxScenario: { $in: ['intra_eu_acquisition', 'reverse_charge_13b'] },
90
+ date: { $gte: options.dateFrom, $lte: options.dateTo },
91
+ },
92
+ { projection: { sequenceNumber: 1, journalNumber: 1, taxScenario: 1, lines: 1 } },
93
+ )
94
+ .toArray()) as unknown as IRawEntry[];
95
+
96
+ for (const entry of tagged) {
97
+ const label = entry.journalNumber ?? `seq ${entry.sequenceNumber}`;
98
+ const inputVatAccount =
99
+ entry.taxScenario === 'intra_eu_acquisition'
100
+ ? policy.vat.intraEuAcqInput
101
+ : policy.vat.reverseChargeInput;
102
+
103
+ let baseCents = 0;
104
+ let inputVatCents = 0;
105
+ for (const line of entry.lines) {
106
+ const amount = line.amountCents ?? 0;
107
+ const signed = line.side === 'debit' ? amount : -amount;
108
+ if (line.accountNumber === inputVatAccount) {
109
+ inputVatCents += signed;
110
+ continue;
111
+ }
112
+ if (vatAccounts.has(line.accountNumber)) continue;
113
+ if (isPersonalAccount(line.accountNumber)) continue;
114
+ baseCents += signed;
115
+ }
116
+
117
+ if (baseCents === 0 && inputVatCents !== 0) {
118
+ warnings.push(
119
+ `entry ${label}: no base extractable (counterparty not a personal account?) — VAT line is ${inputVatCents} cents`,
120
+ );
121
+ continue;
122
+ }
123
+ if (baseCents === 0) continue;
124
+
125
+ // rate detection from the entry's own VAT line (19 % vs 7 %)
126
+ const absBase = Math.abs(baseCents);
127
+ const absVat = Math.abs(inputVatCents);
128
+ const reduced = Math.abs(absVat * 100 - 7 * absBase) < Math.abs(absVat * 100 - 19 * absBase);
129
+ const rate = reduced ? 7 : 19;
130
+ const expectedVat = vatFromNetCents(baseCents, rate);
131
+ if (Math.abs(inputVatCents - expectedVat) > 2) {
132
+ warnings.push(
133
+ `entry ${label}: base ${baseCents} cents at ${rate} % expects VAT ${expectedVat}, ledger line says ${inputVatCents}`,
134
+ );
135
+ }
136
+
137
+ if (entry.taxScenario === 'intra_eu_acquisition') {
138
+ if (reduced) scenarioBases.intraEuAcquisitionReducedCents += baseCents;
139
+ else scenarioBases.intraEuAcquisitionStandardCents += baseCents;
140
+ } else {
141
+ scenarioBases.reverseChargeServicesEuCents += baseCents;
142
+ }
143
+ }
144
+
145
+ return {
146
+ movements,
147
+ scenarioBases,
148
+ taggedEntryCount: tagged.length,
149
+ warnings,
150
+ };
151
+ }
package/readme.hints.md DELETED
@@ -1,103 +0,0 @@
1
- # Project Readme Hints
2
-
3
- ## Dev Environment (2026-07-09)
4
-
5
- - Local services are managed by `gitzone services` (@git.zone/cli >= 2.23.0); the old
6
- `services.sh` was removed. `gitzone services start` creates MongoDB as a **single-node
7
- replica set** (rs0, keyfile auth, mongod on the mapped port inside the container) —
8
- required because skr v2 posting uses MongoDB transactions. Verify with
9
- `.nogit/debug/check-replicaset.ts` (tsx).
10
- - Service selection lives in `.smartconfig.json` under `@git.zone/cli.services`;
11
- runtime config in `.nogit/env.json`. Old standalone data was backed up to
12
- `.nogit/mongodata.pre-replset.bak` (disposable test data).
13
- - Program plan for the accounting roadmap ("no external tax accountant") is in
14
- `readme.plan.md`; M0 = skr v2 core hardening. @fin.cx/calculation ships the cents
15
- module (TCents) that skr v2 money handling builds on.
16
-
17
- ## Chart rebase (2026-07-09, v3)
18
-
19
- - The built-in SKR03/04 charts are now GENERATED from @fin.cx/chartdata >= 2.1.0
20
- (`tsx tools/generate-chart-data.ts`) — the v1 hand-written charts were an
21
- invented hybrid layout and are gone. Notable corrections: SKR03 revenue lives
22
- in class 8 (8400/8300), expenses in class 4 (default 4900 — 4980 is
23
- Mietleasing!), VSt/USt rate accounts fixed (1571/1771 = 7 %); SKR04 bank is
24
- 1800, Kasse 1600, controls 1200/3300, default expense 6300.
25
- - Account.isAutomaticAccount: SKR04 controls corrected to 1200/3300.
26
-
27
- ## M0 Phase 2 — stateless core (2026-07-09)
28
-
29
- - `ts/core/` is the pure booking core (exported as `core` from the package index):
30
- types, BU keys, tax-scenario cascade, account policies, recipes, validation,
31
- DATEV EXTF writer, hash chain. Hard rule: core imports only @fin.cx/calculation
32
- (+ smarthash for SHA-256) — no smartdata/fs/network.
33
- - **BU-key corrections vs v1/finance.plus** (cross-checked 2026-07-09): 18/19 =
34
- steuerpflichtiger innergemeinschaftlicher Erwerb 7%/19% (§1a UStG); 91/94 =
35
- Leistungsempfänger schuldet die Steuer 7%/19% (§13b UStG); 40 = Aufhebung der
36
- Automatik. v1's `skr.postingkeys.ts` (numeric keys with partly different meanings)
37
- is superseded by `ts/core/core.bukeys.ts`. Official DATEV table (Hilfe-Center doc
38
- 0904313) is portal-gated — final verification = manual DATEV import per RC.
39
- - **DATEV EXTF v13 facts** (verified against a DATEV-importable reference file):
40
- 125 data columns, 31 header fields, field 114 = Festschreibung, decimal-comma
41
- amounts, Belegdatum TTMM, text fields quoted, CRLF; file encoding must be CP1252
42
- (conversion happens at the IO layer, core emits a JS string).
43
- `ts/core/core.datev.captions.ts` is generated from that reference — do not edit.
44
- - Automatik convention in recipes: a line on an Automatikkonto carries key '' when
45
- the entry has no explicit VAT lines, and key '40' when explicit VAT lines exist
46
- (automatism must be cancelled so DATEV import doesn't double the tax).
47
- - PROVISIONAL policy accounts (verify in M-A): reduced-rate Skonto accounts
48
- (3731/8731, 5731/4731) and rounding-diff accounts (2700/2300, 4830/6300).
49
-
50
- ## Current Status (2025-10-27)
51
-
52
- ### Test Results
53
- ✅ **ALL 65/65 TESTS PASSING** (100%)
54
-
55
- ### Recent Fixes
56
-
57
- #### Fixed: SKR04 Bug (Account 3300 Misclassification)
58
- **Problem**: Account 3300 was incorrectly hardcoded as an automatic account for SKR04
59
- **Root Cause**: Bug in `ts/skr.classes.account.ts:192` - account 3300 is "Fahrzeugkosten" (vehicle costs), NOT an automatic account
60
- **Solution**:
61
- 1. Removed 3300 from automatic accounts list in `isAutomaticAccount()` method
62
- 2. Updated test.skr04.ts to use timestamped database names to avoid conflicts
63
- **Files Changed**:
64
- - `ts/skr.classes.account.ts` - Fixed automatic account detection
65
- - `test/test.skr04.ts` - Added timestamp to database name
66
-
67
- **Result**: ✅ All SKR04 tests now passing (jahresabschluss.skr04 + basic SKR04 tests)
68
-
69
- ### Architecture Notes
70
-
71
- #### VAT Validation Logic (Recent Changes)
72
- - **skr.classes.journalentry.ts:224-273**: Detects VAT lines in entries to enable smart validation
73
- - **skr.postingkeys.ts:87-100**: Exempts VAT accounts and debtor/creditor accounts from VAT amount requirements
74
- - **Rationale**: VAT accounts ARE the VAT; settlement transactions don't need VAT details again
75
-
76
- #### Posting Key Usage Pattern
77
- - **Tax-free operations** (key 40): Internal adjustments, depreciation, closing entries
78
- - **VAT operations** (keys 3, 8, 9, 19, 94): Customer/supplier transactions
79
- - **Best practice**: Use posting key 40 for non-VAT lines in mixed entries
80
-
81
- #### Account Structure
82
- - **Automatic accounts**: Cannot be posted to directly (1400 Debtors, 1600 Creditors, 3300 Bank)
83
- - **Personal accounts**: Created in ranges 10000-69999 (debtors), 70000-99999 (creditors)
84
- - **System enforces**: Must use personal variants instead of automatic accounts
85
-
86
- ### Validation Pipeline
87
- 1. **Line-level**: Posting key required, account exists, VAT rules
88
- 2. **Posting key level**: VAT amount requirements (with exemptions)
89
- 3. **Consistency level**: No mixing tax-free and taxed (unless intentional)
90
- 4. **Balance level**: Debits must equal credits (0.01 tolerance)
91
-
92
- ### Test Coverage
93
- - 65 test cases covering full accounting cycle
94
- - Complete Jahresabschluss (annual closing) workflow in SKR03
95
- - Report generation (Trial Balance, Income Statement, Balance Sheet)
96
- - Transaction reversal and audit trails
97
- - DATEV posting key validation
98
-
99
- ### Dependencies
100
- - MongoDB via @push.rocks/smartdata for persistence
101
- - TypeScript 5.8.3 with strict mode
102
- - @git.zone/tstest for testing framework
103
- - @push.rocks/smartexpect for assertions