@fin.cx/skr 1.3.1 → 2.0.1

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 +6 -6
  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
@@ -1,8 +1,9 @@
1
1
  import * as plugins from './plugins.js';
2
2
  import * as path from 'path';
3
3
  import * as crypto from 'crypto';
4
- import * as https from 'https';
4
+
5
5
  import * as nodeForge from 'node-forge';
6
+ import { NotImplementedError } from './skr.errors.js';
6
7
 
7
8
  export interface ISigningOptions {
8
9
  certificatePem?: string;
@@ -115,16 +116,13 @@ export class SecurityManager {
115
116
  certificateChain: [cert]
116
117
  };
117
118
 
118
- // Add timestamp if requested
119
+ // Add timestamp if requested — no silent downgrade: if a timestamp was
120
+ // asked for and cannot be produced, the signing call fails loudly.
119
121
  if (this.options.includeTimestamp && this.options.timestampServerUrl) {
120
- try {
121
- const timestampResponse = await this.requestTimestamp(signature);
122
- result.timestampToken = timestampResponse.token;
123
- result.timestampTime = timestampResponse.time;
124
- result.signatureFormat = 'CAdES-T';
125
- } catch (error) {
126
- this.logger.log('warn', `Failed to obtain timestamp: ${error}`);
127
- }
122
+ const timestampResponse = await this.requestTimestamp(signature);
123
+ result.timestampToken = timestampResponse.token;
124
+ result.timestampTime = timestampResponse.time;
125
+ result.signatureFormat = 'CAdES-T';
128
126
  }
129
127
 
130
128
  return result;
@@ -134,93 +132,20 @@ export class SecurityManager {
134
132
  }
135
133
 
136
134
  /**
137
- * Requests an RFC 3161 timestamp from a TSA
135
+ * RFC 3161 timestamping NOT implemented.
136
+ *
137
+ * The previous implementation posted a JSON blob pretending to be an ASN.1
138
+ * TimeStampReq — every real TSA rejects that, and failures were silently
139
+ * swallowed (fake CAdES-T). Until a real RFC 3161 client (DER-encoded TSQ)
140
+ * exists, this fails loudly; use CAdES-B (includeTimestamp: false).
138
141
  */
139
142
  public async requestTimestamp(dataHash: string | Buffer): Promise<ITimestampResponse> {
140
- try {
141
- // Create hash of the data
142
- let hash: Buffer;
143
- if (typeof dataHash === 'string') {
144
- hash = crypto.createHash('sha256').update(dataHash).digest();
145
- } else {
146
- hash = crypto.createHash('sha256').update(dataHash).digest();
147
- }
148
-
149
- // Create timestamp request (simplified - in production use proper ASN.1 encoding)
150
- const tsRequest = this.createTimestampRequest(hash);
151
-
152
- // Send request to TSA
153
- const response = await this.sendTimestampRequest(tsRequest);
154
-
155
- return {
156
- token: response.toString('base64'),
157
- time: new Date().toISOString(),
158
- serverUrl: this.options.timestampServerUrl!,
159
- hashAlgorithm: 'sha256'
160
- };
161
- } catch (error) {
162
- throw new Error(`Failed to obtain timestamp: ${error}`);
163
- }
164
- }
165
-
166
- /**
167
- * Creates a timestamp request (simplified version)
168
- */
169
- private createTimestampRequest(hash: Buffer): Buffer {
170
- // In production, use proper ASN.1 encoding library
171
- // This is a simplified placeholder
172
- const request = {
173
- version: 1,
174
- messageImprint: {
175
- hashAlgorithm: { algorithm: '2.16.840.1.101.3.4.2.1' }, // SHA-256 OID
176
- hashedMessage: hash
177
- },
178
- reqPolicy: null,
179
- nonce: crypto.randomBytes(8),
180
- certReq: true
181
- };
182
-
183
- // Convert to DER-encoded ASN.1 (simplified)
184
- return Buffer.from(JSON.stringify(request));
143
+ void dataHash;
144
+ throw new NotImplementedError(
145
+ 'RFC 3161 timestamping is not implemented — CAdES-T/LT unavailable, use CAdES-B (includeTimestamp: false)',
146
+ );
185
147
  }
186
148
 
187
- /**
188
- * Sends timestamp request to TSA server
189
- */
190
- private async sendTimestampRequest(request: Buffer): Promise<Buffer> {
191
- return new Promise((resolve, reject) => {
192
- const url = new URL(this.options.timestampServerUrl!);
193
-
194
- const options = {
195
- hostname: url.hostname,
196
- port: url.port || 443,
197
- path: url.pathname,
198
- method: 'POST',
199
- headers: {
200
- 'Content-Type': 'application/timestamp-query',
201
- 'Content-Length': request.length
202
- }
203
- };
204
-
205
- const req = https.request(options, (res) => {
206
- const chunks: Buffer[] = [];
207
-
208
- res.on('data', (chunk) => chunks.push(chunk));
209
- res.on('end', () => {
210
- const response = Buffer.concat(chunks);
211
- if (res.statusCode === 200) {
212
- resolve(response);
213
- } else {
214
- reject(new Error(`TSA server returned status ${res.statusCode}`));
215
- }
216
- });
217
- });
218
-
219
- req.on('error', reject);
220
- req.write(request);
221
- req.end();
222
- });
223
- }
224
149
 
225
150
  /**
226
151
  * Verifies a CAdES signature
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Gapless ledger sequences + hash-chain heads, one document per
3
+ * (skrType, fiscalYear). This is the only module that touches the raw
4
+ * MongoDB driver for sequence allocation — everything runs inside the
5
+ * caller's transaction session, so an aborted posting rolls the $inc back
6
+ * and gaplessness holds.
7
+ */
8
+ import { getDbSync, type TDbSession } from './skr.database.js';
9
+ import { genesisHash } from './core/core.hash.js';
10
+ import { SequenceConflictError } from './skr.errors.js';
11
+ import type { TSKRType } from './skr.types.js';
12
+
13
+ export const LEDGER_SEQUENCE_COLLECTION = 'LedgerSequence';
14
+
15
+ export interface ILedgerSequenceAllocation {
16
+ sequenceNumber: number;
17
+ prevHash: string;
18
+ }
19
+
20
+ export interface ILedgerSequenceState {
21
+ skrType: TSKRType;
22
+ fiscalYear: number;
23
+ value: number;
24
+ headHash: string;
25
+ }
26
+
27
+ function sequenceCollection() {
28
+ return getDbSync().mongoDb.collection(LEDGER_SEQUENCE_COLLECTION);
29
+ }
30
+
31
+ export async function ensureSequenceIndexes(): Promise<void> {
32
+ await sequenceCollection().createIndex({ skrType: 1, fiscalYear: 1 }, { unique: true });
33
+ }
34
+
35
+ /**
36
+ * Allocate the next sequence number and return the current chain head.
37
+ * Must run inside a transaction — the $inc participates in the rollback.
38
+ */
39
+ export async function allocateSequence(
40
+ skrType: TSKRType,
41
+ fiscalYear: number,
42
+ session: TDbSession,
43
+ ): Promise<ILedgerSequenceAllocation> {
44
+ const genesis = genesisHash(skrType, fiscalYear);
45
+ const doc = await sequenceCollection().findOneAndUpdate(
46
+ { skrType, fiscalYear },
47
+ { $inc: { value: 1 }, $setOnInsert: { headHash: genesis } },
48
+ { upsert: true, returnDocument: 'after', session },
49
+ );
50
+ if (!doc) {
51
+ throw new Error(`Sequence allocation failed for ${skrType}/${fiscalYear}`);
52
+ }
53
+ return { sequenceNumber: doc.value as number, prevHash: doc.headHash as string };
54
+ }
55
+
56
+ /**
57
+ * Compare-and-swap the chain head. The filter on the old head serializes
58
+ * concurrent posters — a lost race aborts the surrounding transaction.
59
+ */
60
+ export async function advanceHead(
61
+ skrType: TSKRType,
62
+ fiscalYear: number,
63
+ prevHash: string,
64
+ newHash: string,
65
+ session: TDbSession,
66
+ ): Promise<void> {
67
+ const result = await sequenceCollection().updateOne(
68
+ { skrType, fiscalYear, headHash: prevHash },
69
+ { $set: { headHash: newHash } },
70
+ { session },
71
+ );
72
+ if (result.matchedCount !== 1) {
73
+ throw new SequenceConflictError(skrType, fiscalYear);
74
+ }
75
+ }
76
+
77
+ export async function getSequenceState(
78
+ skrType: TSKRType,
79
+ fiscalYear: number,
80
+ ): Promise<ILedgerSequenceState | null> {
81
+ const doc = await sequenceCollection().findOne({ skrType, fiscalYear });
82
+ if (!doc) return null;
83
+ return {
84
+ skrType,
85
+ fiscalYear,
86
+ value: doc.value as number,
87
+ headHash: doc.headHash as string,
88
+ };
89
+ }
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Bridges between the legacy v1 shapes (float euros, numeric posting keys,
3
+ * debit/credit account pairs) and the v2 ledger (cents, string BU keys,
4
+ * per-line Gegenkonto):
5
+ *
6
+ * - input converters: ITransactionData / IJournalEntry -> core.IJournalDraft
7
+ * - output projections: v2 JournalEntry (lines) -> transaction-shaped views
8
+ *
9
+ * The Transaction collection is FROZEN in v2 — these projections are what
10
+ * keeps transaction-style reads working.
11
+ */
12
+ import * as plugins from './plugins.js';
13
+ import * as core from './core/index.js';
14
+ import { getDbSync } from './skr.database.js';
15
+ import { JOURNAL_ENTRY_COLLECTION } from './skr.period.js';
16
+ import type {
17
+ IJournalEntry,
18
+ IJournalEntryLine,
19
+ ITransactionData,
20
+ ITransactionView,
21
+ IReportParams,
22
+ TSKRType,
23
+ } from './skr.types.js';
24
+
25
+ const { centsFromFloat } = plugins.calculation;
26
+
27
+ /** v1 numeric posting keys that map 1:1 onto the v2 string BU keys */
28
+ const NUMERIC_KEY_MAP: Record<number, core.TBuKey> = {
29
+ 1: '1',
30
+ 2: '2',
31
+ 3: '3',
32
+ 8: '8',
33
+ 9: '9',
34
+ 18: '18',
35
+ 19: '19',
36
+ 40: '40',
37
+ 91: '91',
38
+ 94: '94',
39
+ };
40
+
41
+ export function mapNumericPostingKey(postingKey?: number): core.TBuKey {
42
+ if (postingKey === undefined || postingKey === null) return '';
43
+ return NUMERIC_KEY_MAP[postingKey] ?? '';
44
+ }
45
+
46
+ /**
47
+ * v1 postTransaction input -> two-line v2 draft. The amount is NOT split into
48
+ * net/VAT (v1 semantics: vatAmount was informational) so ledger figures stay
49
+ * identical across the migration.
50
+ */
51
+ export function transactionDataToDraft(data: ITransactionData): core.IJournalDraft {
52
+ const amountCents = centsFromFloat(data.amount);
53
+ const vatCents = data.vatAmount ? centsFromFloat(data.vatAmount) : 0;
54
+ const lines: core.IJournalLineDraft[] = [
55
+ {
56
+ accountNumber: data.debitAccount,
57
+ counterAccount: data.creditAccount,
58
+ side: 'debit',
59
+ amountCents,
60
+ buKey: '',
61
+ buchungstext: data.description,
62
+ belegfeld1: data.reference,
63
+ costCenter1: data.costCenter,
64
+ },
65
+ {
66
+ accountNumber: data.creditAccount,
67
+ counterAccount: data.debitAccount,
68
+ side: 'credit',
69
+ amountCents,
70
+ buKey: '',
71
+ buchungstext: data.description,
72
+ belegfeld1: data.reference,
73
+ costCenter1: data.costCenter,
74
+ },
75
+ ];
76
+ lines[0].vatAmountCents = vatCents;
77
+ return {
78
+ date: data.date,
79
+ description: data.description,
80
+ reference: data.reference,
81
+ skrType: data.skrType,
82
+ currency: 'EUR',
83
+ lines,
84
+ };
85
+ }
86
+
87
+ /**
88
+ * v1 postJournalEntry input -> v2 draft. Gegenkonto derivation: when one side
89
+ * of the entry has exactly one line, it is the counter account for every line
90
+ * of the other side (the dominant v1 usage); otherwise the first line of the
91
+ * opposite side is used (documented heuristic — v1 stored no Gegenkonto).
92
+ */
93
+ export function journalInputToDraft(entry: IJournalEntry): core.IJournalDraft {
94
+ const debitLines = entry.lines.filter((line) => (line.debit ?? 0) > 0);
95
+ const creditLines = entry.lines.filter((line) => (line.credit ?? 0) > 0);
96
+ const counterFor = (side: 'debit' | 'credit'): string => {
97
+ const opposite = side === 'debit' ? creditLines : debitLines;
98
+ return opposite[0]?.accountNumber ?? '';
99
+ };
100
+ const lines: core.IJournalLineDraft[] = entry.lines.map((line) => {
101
+ const isDebit = (line.debit ?? 0) > 0;
102
+ return {
103
+ accountNumber: line.accountNumber,
104
+ counterAccount: counterFor(isDebit ? 'debit' : 'credit'),
105
+ side: isDebit ? 'debit' : 'credit',
106
+ amountCents: centsFromFloat(isDebit ? line.debit! : line.credit!),
107
+ buKey: mapNumericPostingKey(line.postingKey),
108
+ buchungstext: line.description ?? entry.description,
109
+ costCenter1: line.costCenter,
110
+ };
111
+ });
112
+ return {
113
+ date: entry.date,
114
+ description: entry.description,
115
+ reference: entry.reference,
116
+ skrType: entry.skrType,
117
+ currency: 'EUR',
118
+ lines,
119
+ };
120
+ }
121
+
122
+ interface IEntryDocLike {
123
+ id: string;
124
+ journalNumber?: string;
125
+ date: Date;
126
+ description: string;
127
+ reference?: string;
128
+ skrType: TSKRType;
129
+ status: string;
130
+ period?: string;
131
+ fiscalYear?: number;
132
+ postedAt?: Date | null;
133
+ reversalOf?: string;
134
+ lines: IJournalEntryLine[];
135
+ }
136
+
137
+ /**
138
+ * Project a two-line v2 entry back into the classic transaction shape.
139
+ * Returns null for entries with more than two lines (journal-only view).
140
+ */
141
+ export function entryToTransactionView(entry: IEntryDocLike): ITransactionView | null {
142
+ const debitLine = entry.lines.find((line) => line.side === 'debit');
143
+ const creditLine = entry.lines.find((line) => line.side === 'credit');
144
+ if (!debitLine || !creditLine || entry.lines.length !== 2) return null;
145
+ return {
146
+ id: entry.id,
147
+ transactionNumber: entry.journalNumber ?? entry.id,
148
+ date: entry.date,
149
+ debitAccount: debitLine.accountNumber,
150
+ creditAccount: creditLine.accountNumber,
151
+ amount: (debitLine.amountCents ?? 0) / 100,
152
+ vatAmount: (debitLine.vatAmountCents ?? 0) / 100,
153
+ description: entry.description,
154
+ reference: entry.reference ?? '',
155
+ skrType: entry.skrType,
156
+ status: entry.status,
157
+ period: entry.period ?? '',
158
+ fiscalYear: entry.fiscalYear ?? entry.date.getUTCFullYear(),
159
+ costCenter: debitLine.costCenter1 ?? creditLine.costCenter1,
160
+ reversalOf: entry.reversalOf,
161
+ };
162
+ }
163
+
164
+ function dayRange(params?: IReportParams): { from: Date | null; to: Date | null } {
165
+ const from = params?.dateFrom ? new Date(params.dateFrom) : null;
166
+ const to = params?.dateTo ? new Date(params.dateTo) : null;
167
+ if (from) from.setHours(0, 0, 0, 0);
168
+ if (to) to.setHours(23, 59, 59, 999);
169
+ return { from, to };
170
+ }
171
+
172
+ /**
173
+ * Load v2 entries of a date range as core drafts (for DATEV export).
174
+ */
175
+ export async function loadDraftsForExport(
176
+ skrType: TSKRType,
177
+ dateFrom: Date,
178
+ dateTo: Date,
179
+ ): Promise<core.IJournalDraft[]> {
180
+ const docs = await getDbSync()
181
+ .mongoDb.collection(JOURNAL_ENTRY_COLLECTION)
182
+ .find({
183
+ skrType,
184
+ schemaVersion: 2,
185
+ status: { $in: ['posted', 'finalized'] },
186
+ date: { $gte: dateFrom, $lte: dateTo },
187
+ })
188
+ .sort({ sequenceNumber: 1 })
189
+ .toArray();
190
+ return docs.map((doc) => {
191
+ const entry = doc as unknown as IEntryDocLike;
192
+ return {
193
+ date: entry.date,
194
+ description: entry.description,
195
+ reference: entry.reference,
196
+ skrType: entry.skrType,
197
+ currency: 'EUR' as const,
198
+ lines: entry.lines.map((line) => ({
199
+ accountNumber: line.accountNumber,
200
+ counterAccount: line.counterAccount ?? '',
201
+ side: (line.side ?? 'debit') as 'debit' | 'credit',
202
+ amountCents: line.amountCents ?? 0,
203
+ buKey: (line.buKey ?? '') as core.TBuKey,
204
+ buchungstext: line.buchungstext ?? entry.description,
205
+ belegfeld1: line.belegfeld1,
206
+ costCenter1: line.costCenter1,
207
+ docLink: line.docLink,
208
+ })),
209
+ };
210
+ });
211
+ }
212
+
213
+ /**
214
+ * Per-account activity as transaction-shaped line views from v2 entries —
215
+ * the data source for reports (trial balance, general ledger, cash flow).
216
+ * Each view carries the line's own side and its Gegenkonto.
217
+ */
218
+ export async function accountLineViews(
219
+ accountNumber: string,
220
+ skrType: TSKRType,
221
+ params?: IReportParams,
222
+ ): Promise<ITransactionView[]> {
223
+ const { from, to } = dayRange(params);
224
+ const match: Record<string, unknown> = {
225
+ skrType,
226
+ schemaVersion: 2,
227
+ status: { $in: ['posted', 'finalized'] },
228
+ 'lines.accountNumber': accountNumber,
229
+ };
230
+ if (from || to) {
231
+ const range: Record<string, Date> = {};
232
+ if (from) range.$gte = from;
233
+ if (to) range.$lte = to;
234
+ match.date = range;
235
+ }
236
+ const docs = await getDbSync()
237
+ .mongoDb.collection(JOURNAL_ENTRY_COLLECTION)
238
+ .find(match)
239
+ .sort({ date: 1, sequenceNumber: 1 })
240
+ .toArray();
241
+
242
+ const views: ITransactionView[] = [];
243
+ for (const doc of docs) {
244
+ const entry = doc as unknown as IEntryDocLike;
245
+ for (const line of entry.lines) {
246
+ if (line.accountNumber !== accountNumber) continue;
247
+ const isDebit = line.side === 'debit';
248
+ views.push({
249
+ id: entry.id,
250
+ transactionNumber: entry.journalNumber ?? entry.id,
251
+ date: entry.date,
252
+ debitAccount: isDebit ? accountNumber : line.counterAccount ?? '',
253
+ creditAccount: isDebit ? line.counterAccount ?? '' : accountNumber,
254
+ amount: (line.amountCents ?? 0) / 100,
255
+ vatAmount: (line.vatAmountCents ?? 0) / 100,
256
+ description: line.buchungstext ?? entry.description,
257
+ reference: entry.reference ?? '',
258
+ skrType: entry.skrType,
259
+ status: entry.status,
260
+ period: entry.period ?? '',
261
+ fiscalYear: entry.fiscalYear ?? entry.date.getUTCFullYear(),
262
+ costCenter: line.costCenter1,
263
+ reversalOf: entry.reversalOf,
264
+ });
265
+ }
266
+ }
267
+ return views;
268
+ }
package/ts/skr.types.ts CHANGED
@@ -78,7 +78,43 @@ export interface IJournalEntryLine {
78
78
  credit?: number;
79
79
  description?: string;
80
80
  costCenter?: string;
81
- postingKey: TPostingKey; // REQUIRED: DATEV posting key for VAT automation control
81
+ /** v1 numeric DATEV posting key; optional since schemaVersion 2 (see buKey) */
82
+ postingKey?: TPostingKey;
83
+ // --- schemaVersion 2 fields (exact cents, explicit side and Gegenkonto) ---
84
+ side?: 'debit' | 'credit';
85
+ amountCents?: number;
86
+ counterAccount?: string;
87
+ /** string-typed DATEV BU-Schlüssel ('' = none) */
88
+ buKey?: string;
89
+ buchungstext?: string;
90
+ belegfeld1?: string;
91
+ costCenter1?: string;
92
+ docLink?: string;
93
+ /** informational VAT share carried on the line (v1 compatibility) */
94
+ vatAmountCents?: number;
95
+ }
96
+
97
+ /**
98
+ * Transaction-shaped projection of a v2 journal entry (or one of its lines).
99
+ * The Transaction collection is frozen in v2 — reads go through these views.
100
+ */
101
+ export interface ITransactionView {
102
+ id: string;
103
+ transactionNumber: string;
104
+ date: Date;
105
+ debitAccount: string;
106
+ creditAccount: string;
107
+ amount: number;
108
+ vatAmount: number;
109
+ description: string;
110
+ reference: string;
111
+ skrType: TSKRType;
112
+ status: string;
113
+ period: string;
114
+ fiscalYear: number;
115
+ costCenter?: string;
116
+ /** id of the entry this one reverses (Storno linkage) */
117
+ reversalOf?: string;
82
118
  }
83
119
 
84
120
  export interface ITrialBalanceEntry {
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Cryptographic audit-trail verification. Prevention of tampering is
3
+ * best-effort (doc-level guards); detection is this module: recompute the
4
+ * whole hash chain from the persisted entries and compare against the stored
5
+ * chain head.
6
+ */
7
+ import * as core from './core/index.js';
8
+ import { getDbSync } from './skr.database.js';
9
+ import { JOURNAL_ENTRY_COLLECTION } from './skr.period.js';
10
+ import { getSequenceState } from './skr.sequence.js';
11
+ import { buildHashPayload } from './skr.posting.js';
12
+ import type { TSKRType } from './skr.types.js';
13
+
14
+ export interface IChainVerificationResult {
15
+ valid: boolean;
16
+ entriesChecked: number;
17
+ issues: string[];
18
+ headHash: string | null;
19
+ }
20
+
21
+ /**
22
+ * Walk sequence 1..N for (skrType, fiscalYear): check gaplessness, recompute
23
+ * every entry hash from the persisted payload, verify the chain links, and
24
+ * compare the final hash against the stored chain head.
25
+ */
26
+ export async function verifyHashChain(
27
+ skrType: TSKRType,
28
+ fiscalYear: number,
29
+ ): Promise<IChainVerificationResult> {
30
+ const issues: string[] = [];
31
+ const collection = getDbSync().mongoDb.collection(JOURNAL_ENTRY_COLLECTION);
32
+ const docs = await collection
33
+ .find({ skrType, fiscalYear, schemaVersion: 2 })
34
+ .sort({ sequenceNumber: 1 })
35
+ .toArray();
36
+
37
+ const state = await getSequenceState(skrType, fiscalYear);
38
+ if (docs.length === 0) {
39
+ return {
40
+ valid: !state || state.value === 0,
41
+ entriesChecked: 0,
42
+ issues: state && state.value !== 0 ? [`sequence counter is ${state.value} but no entries exist`] : [],
43
+ headHash: state?.headHash ?? null,
44
+ };
45
+ }
46
+
47
+ // gaplessness
48
+ docs.forEach((doc, index) => {
49
+ const expected = index + 1;
50
+ if (doc.sequenceNumber !== expected) {
51
+ issues.push(`sequence gap: expected ${expected}, found ${doc.sequenceNumber}`);
52
+ }
53
+ });
54
+
55
+ // chain
56
+ const genesis = core.genesisHash(skrType, fiscalYear);
57
+ const chain = docs.map((doc) => ({
58
+ sequenceNumber: doc.sequenceNumber as number,
59
+ prevHash: doc.prevHash as string,
60
+ entryHash: doc.entryHash as string,
61
+ payload: buildHashPayload({
62
+ sequenceNumber: doc.sequenceNumber as number,
63
+ skrType: doc.skrType as string,
64
+ fiscalYear: doc.fiscalYear as number,
65
+ date: doc.date as Date,
66
+ postedAt: doc.postedAt as Date,
67
+ description: doc.description as string,
68
+ reference: doc.reference as string,
69
+ taxScenario: doc.taxScenario as string,
70
+ currency: doc.currency as string,
71
+ reversalOf: doc.reversalOf as string,
72
+ lines: (doc.lines ?? []) as never[],
73
+ }),
74
+ }));
75
+ const failure = core.verifyChain(chain, genesis);
76
+ if (failure) {
77
+ issues.push(failure.reason);
78
+ }
79
+
80
+ // head comparison
81
+ const lastHash = chain[chain.length - 1].entryHash;
82
+ if (!state) {
83
+ issues.push('no sequence state document found');
84
+ } else {
85
+ if (state.headHash !== lastHash) {
86
+ issues.push(`stored chain head ${state.headHash} !== last entry hash ${lastHash}`);
87
+ }
88
+ if (state.value !== docs.length) {
89
+ issues.push(`sequence counter ${state.value} !== entry count ${docs.length}`);
90
+ }
91
+ }
92
+
93
+ return {
94
+ valid: issues.length === 0,
95
+ entriesChecked: docs.length,
96
+ issues,
97
+ headHash: state?.headHash ?? null,
98
+ };
99
+ }