@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.
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/core/core.datev.d.ts +36 -6
- package/dist_ts/core/core.datev.js +177 -6
- package/dist_ts/index.d.ts +2 -0
- package/dist_ts/index.js +2 -1
- package/dist_ts/skr.api.d.ts +5 -0
- package/dist_ts/skr.api.js +14 -1
- package/dist_ts/skr.ustva.d.ts +31 -0
- package/dist_ts/skr.ustva.js +99 -0
- package/package.json +19 -5
- package/readme.md +2 -2
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/core/core.datev.ts +197 -7
- package/ts/index.ts +6 -0
- package/ts/skr.api.ts +18 -0
- package/ts/skr.ustva.ts +151 -0
- package/readme.hints.md +0 -103
- package/readme.plan.md +0 -219
package/ts/core/core.datev.ts
CHANGED
|
@@ -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
|
-
*
|
|
89
|
-
*
|
|
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?:
|
|
272
|
+
options?: IDatevRowsOptions,
|
|
94
273
|
): IDatevBuchungsstapelRow[] {
|
|
95
274
|
const rows: IDatevBuchungsstapelRow[] = [];
|
|
96
275
|
for (const draft of drafts) {
|
|
97
|
-
|
|
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] =
|
|
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;
|
package/ts/skr.ustva.ts
ADDED
|
@@ -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
|