@fin.cx/skr 1.3.1 → 2.0.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.
Files changed (90) hide show
  1. package/.smartconfig.json +33 -6
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts/core/core.accountpolicy.d.ts +67 -0
  4. package/dist_ts/core/core.accountpolicy.js +91 -0
  5. package/dist_ts/core/core.bukeys.d.ts +38 -0
  6. package/dist_ts/core/core.bukeys.js +117 -0
  7. package/dist_ts/core/core.datev.captions.d.ts +7 -0
  8. package/dist_ts/core/core.datev.captions.js +134 -0
  9. package/dist_ts/core/core.datev.d.ts +47 -0
  10. package/dist_ts/core/core.datev.js +129 -0
  11. package/dist_ts/core/core.hash.d.ts +27 -0
  12. package/dist_ts/core/core.hash.js +72 -0
  13. package/dist_ts/core/core.plugins.d.ts +3 -0
  14. package/dist_ts/core/core.plugins.js +9 -0
  15. package/dist_ts/core/core.recipes.d.ts +118 -0
  16. package/dist_ts/core/core.recipes.js +504 -0
  17. package/dist_ts/core/core.taxscenario.d.ts +27 -0
  18. package/dist_ts/core/core.taxscenario.js +85 -0
  19. package/dist_ts/core/core.types.d.ts +48 -0
  20. package/dist_ts/core/core.types.js +2 -0
  21. package/dist_ts/core/core.validate.d.ts +17 -0
  22. package/dist_ts/core/core.validate.js +89 -0
  23. package/dist_ts/core/index.d.ts +15 -0
  24. package/dist_ts/core/index.js +16 -0
  25. package/dist_ts/index.d.ts +15 -0
  26. package/dist_ts/index.js +10 -1
  27. package/dist_ts/plugins.d.ts +2 -1
  28. package/dist_ts/plugins.js +4 -2
  29. package/dist_ts/skr.api.d.ts +91 -22
  30. package/dist_ts/skr.api.js +357 -42
  31. package/dist_ts/skr.balances.d.ts +28 -0
  32. package/dist_ts/skr.balances.js +96 -0
  33. package/dist_ts/skr.classes.journalentry.d.ts +19 -3
  34. package/dist_ts/skr.classes.journalentry.js +67 -4
  35. package/dist_ts/skr.classes.reports.js +49 -102
  36. package/dist_ts/skr.database.d.ts +2 -0
  37. package/dist_ts/skr.database.js +1 -1
  38. package/dist_ts/skr.errors.d.ts +18 -0
  39. package/dist_ts/skr.errors.js +34 -0
  40. package/dist_ts/skr.export.js +4 -7
  41. package/dist_ts/skr.invoice.booking.js +14 -28
  42. package/dist_ts/skr.migrate.d.ts +20 -0
  43. package/dist_ts/skr.migrate.js +223 -0
  44. package/dist_ts/skr.period.d.ts +47 -0
  45. package/dist_ts/skr.period.js +95 -0
  46. package/dist_ts/skr.posting.d.ts +74 -0
  47. package/dist_ts/skr.posting.js +209 -0
  48. package/dist_ts/skr.security.d.ts +6 -9
  49. package/dist_ts/skr.security.js +16 -89
  50. package/dist_ts/skr.sequence.d.ts +32 -0
  51. package/dist_ts/skr.sequence.js +51 -0
  52. package/dist_ts/skr.txview.d.ts +46 -0
  53. package/dist_ts/skr.txview.js +235 -0
  54. package/dist_ts/skr.types.d.ts +35 -1
  55. package/dist_ts/skr.verify.d.ts +13 -0
  56. package/dist_ts/skr.verify.js +84 -0
  57. package/package.json +5 -4
  58. package/readme.hints.md +37 -0
  59. package/readme.md +69 -1
  60. package/readme.plan.md +180 -243
  61. package/ts/00_commitinfo_data.ts +1 -1
  62. package/ts/core/core.accountpolicy.ts +154 -0
  63. package/ts/core/core.bukeys.ts +146 -0
  64. package/ts/core/core.datev.captions.ts +133 -0
  65. package/ts/core/core.datev.ts +174 -0
  66. package/ts/core/core.hash.ts +76 -0
  67. package/ts/core/core.plugins.ts +9 -0
  68. package/ts/core/core.recipes.ts +659 -0
  69. package/ts/core/core.taxscenario.ts +156 -0
  70. package/ts/core/core.types.ts +74 -0
  71. package/ts/core/core.validate.ts +119 -0
  72. package/ts/core/index.ts +15 -0
  73. package/ts/index.ts +31 -0
  74. package/ts/plugins.ts +4 -0
  75. package/ts/skr.api.ts +432 -46
  76. package/ts/skr.balances.ts +127 -0
  77. package/ts/skr.classes.journalentry.ts +54 -5
  78. package/ts/skr.classes.reports.ts +54 -126
  79. package/ts/skr.database.ts +3 -0
  80. package/ts/skr.errors.ts +44 -0
  81. package/ts/skr.export.ts +3 -6
  82. package/ts/skr.invoice.booking.ts +22 -34
  83. package/ts/skr.migrate.ts +268 -0
  84. package/ts/skr.period.ts +139 -0
  85. package/ts/skr.posting.ts +273 -0
  86. package/ts/skr.security.ts +18 -93
  87. package/ts/skr.sequence.ts +89 -0
  88. package/ts/skr.txview.ts +268 -0
  89. package/ts/skr.types.ts +37 -1
  90. package/ts/skr.verify.ts +99 -0
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Tax scenario detection — a pure, confidence-scored cascade:
3
+ * document text patterns → counterparty country → amount plausibility →
4
+ * default. Ported from the finance.plus VatRuleDetector design, with the
5
+ * scenario vocabulary of core.types.
6
+ */
7
+ import type { TBuKey, TCents, TTaxScenario } from './core.types.js';
8
+ import { suggestBuKey } from './core.bukeys.js';
9
+
10
+ export interface ITaxScenarioInput {
11
+ direction: 'inbound' | 'outbound';
12
+ /** ISO 3166-1 alpha-2, defaults to 'DE' */
13
+ homeCountry?: string;
14
+ counterpartyCountry?: string;
15
+ counterpartyVatId?: string;
16
+ documentText?: string;
17
+ netCents?: TCents;
18
+ vatCents?: TCents;
19
+ vatRatePercent?: number;
20
+ }
21
+
22
+ export interface ITaxScenarioDetection {
23
+ scenario: TTaxScenario;
24
+ confidence: number; // 0-100
25
+ detectedBy: 'text' | 'country' | 'amounts' | 'default';
26
+ reasoning: string;
27
+ suggestedBuKey: TBuKey;
28
+ }
29
+
30
+ export const EU_COUNTRIES = new Set([
31
+ 'AT', 'BE', 'BG', 'CY', 'CZ', 'DK', 'EE', 'ES', 'FI', 'FR', 'GR', 'HR', 'HU',
32
+ 'IE', 'IT', 'LT', 'LU', 'LV', 'MT', 'NL', 'PL', 'PT', 'RO', 'SE', 'SI', 'SK',
33
+ ]);
34
+
35
+ const REVERSE_CHARGE_PATTERN =
36
+ /reverse[\s-]?charge|steuerschuldnerschaft\s+des\s+leistungsempfängers|§\s*13\s*b/i;
37
+ const INTRA_EU_PATTERN =
38
+ /innergemeinschaftlich|intra[\s-]?community|steuerfreie\s+ig[\s-]?lieferung/i;
39
+ const TAX_FREE_PATTERN = /steuerfrei|tax[\s-]?free|umsatzsteuerbefreit|§\s*4\s+ustg/i;
40
+ const EXPORT_PATTERN = /ausfuhr|ausfuhrlieferung|export\s+delivery/i;
41
+
42
+ function rateOf(input: ITaxScenarioInput): number {
43
+ if (typeof input.vatRatePercent === 'number') return input.vatRatePercent;
44
+ if (
45
+ typeof input.netCents === 'number' &&
46
+ typeof input.vatCents === 'number' &&
47
+ input.netCents > 0
48
+ ) {
49
+ const ratio = (input.vatCents / input.netCents) * 100;
50
+ if (Math.abs(ratio - 19) <= 0.5) return 19;
51
+ if (Math.abs(ratio - 7) <= 0.5) return 7;
52
+ if (ratio < 0.5) return 0;
53
+ return ratio;
54
+ }
55
+ return 19;
56
+ }
57
+
58
+ function detection(
59
+ scenario: TTaxScenario,
60
+ confidence: number,
61
+ detectedBy: ITaxScenarioDetection['detectedBy'],
62
+ reasoning: string,
63
+ input: ITaxScenarioInput,
64
+ ): ITaxScenarioDetection {
65
+ return {
66
+ scenario,
67
+ confidence,
68
+ detectedBy,
69
+ reasoning,
70
+ suggestedBuKey: suggestBuKey({
71
+ direction: input.direction,
72
+ scenario,
73
+ ratePercent: rateOf(input) === 7 ? 7 : rateOf(input) === 0 ? 0 : 19,
74
+ targetIsAutomatik: false,
75
+ hasExplicitVatLines: true,
76
+ }),
77
+ };
78
+ }
79
+
80
+ export function detectTaxScenario(input: ITaxScenarioInput): ITaxScenarioDetection {
81
+ const home = (input.homeCountry ?? 'DE').toUpperCase();
82
+ const text = input.documentText ?? '';
83
+ const rate = rateOf(input);
84
+
85
+ // Stage 1: explicit wording on the document
86
+ if (text) {
87
+ if (REVERSE_CHARGE_PATTERN.test(text)) {
88
+ return detection(
89
+ 'reverse_charge_13b',
90
+ 90,
91
+ 'text',
92
+ 'Document mentions reverse charge / §13b UStG',
93
+ input,
94
+ );
95
+ }
96
+ if (INTRA_EU_PATTERN.test(text)) {
97
+ return detection(
98
+ input.direction === 'inbound' ? 'intra_eu_acquisition' : 'intra_eu_supply',
99
+ 90,
100
+ 'text',
101
+ 'Document mentions an intra-community transaction',
102
+ input,
103
+ );
104
+ }
105
+ if (EXPORT_PATTERN.test(text) && input.direction === 'outbound') {
106
+ return detection('export_third_country', 90, 'text', 'Document mentions Ausfuhr/export', input);
107
+ }
108
+ if (TAX_FREE_PATTERN.test(text)) {
109
+ return detection('domestic_tax_free', 85, 'text', 'Document mentions tax exemption', input);
110
+ }
111
+ }
112
+
113
+ // Stage 2: counterparty country (B2B assumed when a VAT ID is present)
114
+ const country = input.counterpartyCountry?.toUpperCase();
115
+ if (country && country !== home) {
116
+ if (EU_COUNTRIES.has(country)) {
117
+ const b2b = !!input.counterpartyVatId;
118
+ return detection(
119
+ input.direction === 'inbound' ? 'intra_eu_acquisition' : 'intra_eu_supply',
120
+ b2b ? 80 : 60,
121
+ 'country',
122
+ `Counterparty in EU country ${country}${b2b ? ' with VAT ID' : ' without VAT ID'}`,
123
+ input,
124
+ );
125
+ }
126
+ return detection(
127
+ input.direction === 'inbound' ? 'reverse_charge_13b' : 'export_third_country',
128
+ 75,
129
+ 'country',
130
+ `Counterparty in third country ${country}`,
131
+ input,
132
+ );
133
+ }
134
+
135
+ // Stage 3: amount plausibility
136
+ if (typeof input.netCents === 'number' && typeof input.vatCents === 'number') {
137
+ if (input.netCents > 0 && input.vatCents === 0) {
138
+ return detection('domestic_tax_free', 60, 'amounts', 'Positive net with zero VAT', input);
139
+ }
140
+ if (rate === 7) {
141
+ return detection('domestic_reduced', 70, 'amounts', 'VAT/net ratio matches 7 %', input);
142
+ }
143
+ if (rate === 19) {
144
+ return detection('domestic_standard', 70, 'amounts', 'VAT/net ratio matches 19 %', input);
145
+ }
146
+ }
147
+
148
+ // Default
149
+ return detection(
150
+ rate === 7 ? 'domestic_reduced' : 'domestic_standard',
151
+ 50,
152
+ 'default',
153
+ 'No signals — assuming domestic transaction at standard assumptions',
154
+ input,
155
+ );
156
+ }
@@ -0,0 +1,74 @@
1
+ import type { TCents } from '@fin.cx/calculation';
2
+
3
+ export type { TCents };
4
+
5
+ export type TSKRType = 'SKR03' | 'SKR04';
6
+
7
+ /**
8
+ * DATEV BU-Schlüssel (Steuerschlüssel), field 9 of the Buchungsstapel row.
9
+ * String-typed: '' means "no key" and keys keep leading semantics ('40').
10
+ * The set is intentionally conservative — only keys the core emits.
11
+ */
12
+ export type TBuKey =
13
+ | ''
14
+ | '1'
15
+ | '2'
16
+ | '3'
17
+ | '8'
18
+ | '9'
19
+ | '18'
20
+ | '19'
21
+ | '40'
22
+ | '91'
23
+ | '94';
24
+
25
+ export type TTaxScenario =
26
+ | 'domestic_standard'
27
+ | 'domestic_reduced'
28
+ | 'domestic_tax_free'
29
+ | 'intra_eu_supply'
30
+ | 'intra_eu_acquisition'
31
+ | 'reverse_charge_13b'
32
+ | 'export_third_country';
33
+
34
+ export type TLineSide = 'debit' | 'credit';
35
+
36
+ /**
37
+ * One journal line. counterAccount is the explicit DATEV Gegenkonto for this
38
+ * line — recipes know the pairing, so nothing has to be reconstructed later
39
+ * (v1 derived pairs greedily and lossily).
40
+ */
41
+ export interface IJournalLineDraft {
42
+ accountNumber: string;
43
+ counterAccount: string;
44
+ side: TLineSide;
45
+ amountCents: TCents;
46
+ buKey: TBuKey;
47
+ buchungstext: string;
48
+ belegfeld1?: string;
49
+ belegfeld2?: string;
50
+ costCenter1?: string;
51
+ costCenter2?: string;
52
+ docLink?: string;
53
+ /** informational VAT share carried on the line (v1 compatibility) */
54
+ vatAmountCents?: TCents;
55
+ }
56
+
57
+ export interface IJournalDraft {
58
+ date: Date;
59
+ description: string;
60
+ reference?: string;
61
+ skrType: TSKRType;
62
+ currency: 'EUR';
63
+ taxScenario?: TTaxScenario;
64
+ /** id of the entry this draft reverses (Storno linkage, part of the hash) */
65
+ reversalOf?: string;
66
+ lines: IJournalLineDraft[];
67
+ }
68
+
69
+ export interface IValidationResult {
70
+ valid: boolean;
71
+ errors: string[];
72
+ warnings: string[];
73
+ normalized: IJournalDraft;
74
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Pure journal validation. No chart lookups, no persistence — structural and
3
+ * arithmetic invariants only. Account-existence checks belong to the
4
+ * persistence layer, which owns the chart of accounts.
5
+ */
6
+ import type { IJournalDraft, IJournalLineDraft, IValidationResult } from './core.types.js';
7
+ import { isKnownBuKey } from './core.bukeys.js';
8
+ import type { IAccountPolicy } from './core.accountpolicy.js';
9
+ import { isAutomatikAccount } from './core.accountpolicy.js';
10
+
11
+ export const DATEV_MAX_BUCHUNGSTEXT = 60;
12
+ export const DATEV_MAX_BELEGFELD = 36;
13
+
14
+ /**
15
+ * Validate (and normalize) a journal draft.
16
+ *
17
+ * Normalization: negative amounts are flipped to the opposite side so the
18
+ * ledger only ever stores positive amounts with an explicit side.
19
+ * Balance check is EXACT — integer cents, no tolerance.
20
+ */
21
+ export function validateJournalDraft(
22
+ draft: IJournalDraft,
23
+ policy?: IAccountPolicy,
24
+ ): IValidationResult {
25
+ const errors: string[] = [];
26
+ const warnings: string[] = [];
27
+
28
+ const normalizedLines: IJournalLineDraft[] = draft.lines.map((line) => {
29
+ if (line.amountCents < 0) {
30
+ return {
31
+ ...line,
32
+ amountCents: -line.amountCents,
33
+ side: line.side === 'debit' ? 'credit' : 'debit',
34
+ };
35
+ }
36
+ return { ...line };
37
+ });
38
+ const normalized: IJournalDraft = { ...draft, lines: normalizedLines };
39
+
40
+ if (!(draft.date instanceof Date) || Number.isNaN(draft.date.getTime())) {
41
+ errors.push('Journal draft requires a valid date');
42
+ }
43
+ if (!draft.description) {
44
+ warnings.push('Journal draft has no description');
45
+ }
46
+ if (draft.currency !== 'EUR') {
47
+ errors.push(`Core booking currency must be EUR, got '${draft.currency}'`);
48
+ }
49
+ if (normalizedLines.length < 2) {
50
+ errors.push('A journal entry requires at least two lines');
51
+ }
52
+
53
+ let debitTotal = 0;
54
+ let creditTotal = 0;
55
+
56
+ normalizedLines.forEach((line, index) => {
57
+ const label = `line ${index + 1} (${line.accountNumber || '?'})`;
58
+
59
+ if (!Number.isSafeInteger(line.amountCents)) {
60
+ errors.push(`${label}: amountCents must be a safe integer, got ${line.amountCents}`);
61
+ return;
62
+ }
63
+ if (line.amountCents === 0) {
64
+ errors.push(`${label}: amount must not be zero`);
65
+ return;
66
+ }
67
+ if (!line.accountNumber) {
68
+ errors.push(`${label}: accountNumber is required`);
69
+ }
70
+ if (!line.counterAccount) {
71
+ errors.push(`${label}: counterAccount (Gegenkonto) is required`);
72
+ } else if (line.counterAccount === line.accountNumber) {
73
+ errors.push(`${label}: account and counterAccount must differ`);
74
+ }
75
+ if (!isKnownBuKey(line.buKey)) {
76
+ errors.push(`${label}: unknown BU key '${line.buKey}'`);
77
+ }
78
+ if (line.buchungstext && line.buchungstext.length > DATEV_MAX_BUCHUNGSTEXT) {
79
+ warnings.push(
80
+ `${label}: buchungstext exceeds ${DATEV_MAX_BUCHUNGSTEXT} chars and will be truncated in DATEV export`,
81
+ );
82
+ }
83
+ if (line.belegfeld1 && line.belegfeld1.length > DATEV_MAX_BELEGFELD) {
84
+ warnings.push(
85
+ `${label}: belegfeld1 exceeds ${DATEV_MAX_BELEGFELD} chars and will be truncated in DATEV export`,
86
+ );
87
+ }
88
+
89
+ if (policy) {
90
+ if (isAutomatikAccount(policy, line.accountNumber) && line.buKey !== '' && line.buKey !== '40') {
91
+ errors.push(
92
+ `${label}: Automatikkonto must not carry BU key '${line.buKey}' (only none or 40)`,
93
+ );
94
+ }
95
+ if (
96
+ line.accountNumber === policy.debtorsControl ||
97
+ line.accountNumber === policy.creditorsControl
98
+ ) {
99
+ errors.push(
100
+ `${label}: direct posting to control account ${line.accountNumber} — use a personal account`,
101
+ );
102
+ }
103
+ }
104
+
105
+ if (line.side === 'debit') {
106
+ debitTotal += line.amountCents;
107
+ } else {
108
+ creditTotal += line.amountCents;
109
+ }
110
+ });
111
+
112
+ if (errors.length === 0 && debitTotal !== creditTotal) {
113
+ errors.push(
114
+ `Entry is not balanced: debit ${debitTotal} cents !== credit ${creditTotal} cents`,
115
+ );
116
+ }
117
+
118
+ return { valid: errors.length === 0, errors, warnings, normalized };
119
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Stateless booking core: pure types, BU keys, tax scenario detection,
3
+ * account policies, booking recipes, validation, DATEV Buchungsstapel writer,
4
+ * and hash chaining. Nothing in here touches MongoDB, the filesystem, or the
5
+ * network — plain inputs in, validated journal drafts / DATEV rows out.
6
+ */
7
+ export * from './core.types.js';
8
+ export * from './core.bukeys.js';
9
+ export * from './core.taxscenario.js';
10
+ export * from './core.accountpolicy.js';
11
+ export * from './core.validate.js';
12
+ export * from './core.recipes.js';
13
+ export * from './core.datev.js';
14
+ export { DATEV_COLUMN_CAPTIONS } from './core.datev.captions.js';
15
+ export * from './core.hash.js';
package/ts/index.ts CHANGED
@@ -1,4 +1,35 @@
1
1
  export * from './skr.types.js';
2
+ export * as core from './core/index.js';
3
+ export * from './skr.errors.js';
4
+ export { JournalPoster, buildHashPayload } from './skr.posting.js';
5
+ export type { IPostDraftOptions } from './skr.posting.js';
6
+ export {
7
+ festschreibePeriod,
8
+ getPeriodState,
9
+ assertPeriodOpen,
10
+ periodOf,
11
+ fiscalYearOf,
12
+ } from './skr.period.js';
13
+ export type { ILedgerPeriodState } from './skr.period.js';
14
+ export { getSequenceState } from './skr.sequence.js';
15
+ export type { ILedgerSequenceState } from './skr.sequence.js';
16
+ export { aggregateTrialBalance, aggregateAccountBalance } from './skr.balances.js';
17
+ export type {
18
+ IAccountBalanceCents,
19
+ ITrialBalanceCents,
20
+ IBalanceQueryOptions,
21
+ } from './skr.balances.js';
22
+ export { verifyHashChain } from './skr.verify.js';
23
+ export type { IChainVerificationResult } from './skr.verify.js';
24
+ export { migrateToV2 } from './skr.migrate.js';
25
+ export type { IMigrationReport } from './skr.migrate.js';
26
+ export {
27
+ transactionDataToDraft,
28
+ journalInputToDraft,
29
+ entryToTransactionView,
30
+ accountLineViews,
31
+ mapNumericPostingKey,
32
+ } from './skr.txview.js';
2
33
  export { Account } from './skr.classes.account.js';
3
34
  export { Transaction } from './skr.classes.transaction.js';
4
35
  export { JournalEntry } from './skr.classes.journalentry.js';
package/ts/plugins.ts CHANGED
@@ -1,3 +1,6 @@
1
+ // @fin.cx scope
2
+ import * as calculation from '@fin.cx/calculation';
3
+
1
4
  // @push.rocks scope
2
5
  import * as smartdata from '@push.rocks/smartdata';
3
6
  import * as smartunique from '@push.rocks/smartunique';
@@ -53,6 +56,7 @@ const smartfile = {
53
56
  };
54
57
 
55
58
  export {
59
+ calculation,
56
60
  smartdata,
57
61
  smartunique,
58
62
  smarttime,