@fin.cx/skr 1.3.0 → 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.
- package/.smartconfig.json +33 -6
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/core/core.accountpolicy.d.ts +67 -0
- package/dist_ts/core/core.accountpolicy.js +91 -0
- package/dist_ts/core/core.bukeys.d.ts +38 -0
- package/dist_ts/core/core.bukeys.js +117 -0
- package/dist_ts/core/core.datev.captions.d.ts +7 -0
- package/dist_ts/core/core.datev.captions.js +134 -0
- package/dist_ts/core/core.datev.d.ts +47 -0
- package/dist_ts/core/core.datev.js +129 -0
- package/dist_ts/core/core.hash.d.ts +27 -0
- package/dist_ts/core/core.hash.js +72 -0
- package/dist_ts/core/core.plugins.d.ts +3 -0
- package/dist_ts/core/core.plugins.js +9 -0
- package/dist_ts/core/core.recipes.d.ts +118 -0
- package/dist_ts/core/core.recipes.js +504 -0
- package/dist_ts/core/core.taxscenario.d.ts +27 -0
- package/dist_ts/core/core.taxscenario.js +85 -0
- package/dist_ts/core/core.types.d.ts +48 -0
- package/dist_ts/core/core.types.js +2 -0
- package/dist_ts/core/core.validate.d.ts +17 -0
- package/dist_ts/core/core.validate.js +89 -0
- package/dist_ts/core/index.d.ts +15 -0
- package/dist_ts/core/index.js +16 -0
- package/dist_ts/index.d.ts +15 -0
- package/dist_ts/index.js +10 -1
- package/dist_ts/plugins.d.ts +2 -1
- package/dist_ts/plugins.js +4 -2
- package/dist_ts/skr.api.d.ts +91 -22
- package/dist_ts/skr.api.js +357 -42
- package/dist_ts/skr.balances.d.ts +28 -0
- package/dist_ts/skr.balances.js +96 -0
- package/dist_ts/skr.classes.journalentry.d.ts +19 -3
- package/dist_ts/skr.classes.journalentry.js +67 -4
- package/dist_ts/skr.classes.reports.js +49 -102
- package/dist_ts/skr.database.d.ts +2 -0
- package/dist_ts/skr.database.js +1 -1
- package/dist_ts/skr.errors.d.ts +18 -0
- package/dist_ts/skr.errors.js +34 -0
- package/dist_ts/skr.export.js +4 -7
- package/dist_ts/skr.invoice.adapter.d.ts +15 -1
- package/dist_ts/skr.invoice.adapter.js +253 -41
- package/dist_ts/skr.invoice.booking.js +14 -28
- package/dist_ts/skr.migrate.d.ts +20 -0
- package/dist_ts/skr.migrate.js +223 -0
- package/dist_ts/skr.period.d.ts +47 -0
- package/dist_ts/skr.period.js +95 -0
- package/dist_ts/skr.posting.d.ts +74 -0
- package/dist_ts/skr.posting.js +209 -0
- package/dist_ts/skr.security.d.ts +6 -9
- package/dist_ts/skr.security.js +16 -89
- package/dist_ts/skr.sequence.d.ts +32 -0
- package/dist_ts/skr.sequence.js +51 -0
- package/dist_ts/skr.txview.d.ts +46 -0
- package/dist_ts/skr.txview.js +235 -0
- package/dist_ts/skr.types.d.ts +35 -1
- package/dist_ts/skr.verify.d.ts +13 -0
- package/dist_ts/skr.verify.js +84 -0
- package/package.json +21 -24
- package/readme.hints.md +37 -0
- package/readme.md +94 -1
- package/readme.plan.md +180 -243
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/core/core.accountpolicy.ts +154 -0
- package/ts/core/core.bukeys.ts +146 -0
- package/ts/core/core.datev.captions.ts +133 -0
- package/ts/core/core.datev.ts +174 -0
- package/ts/core/core.hash.ts +76 -0
- package/ts/core/core.plugins.ts +9 -0
- package/ts/core/core.recipes.ts +659 -0
- package/ts/core/core.taxscenario.ts +156 -0
- package/ts/core/core.types.ts +74 -0
- package/ts/core/core.validate.ts +119 -0
- package/ts/core/index.ts +15 -0
- package/ts/index.ts +31 -0
- package/ts/plugins.ts +4 -0
- package/ts/skr.api.ts +432 -46
- package/ts/skr.balances.ts +127 -0
- package/ts/skr.classes.journalentry.ts +54 -5
- package/ts/skr.classes.reports.ts +54 -126
- package/ts/skr.database.ts +3 -0
- package/ts/skr.errors.ts +44 -0
- package/ts/skr.export.ts +3 -6
- package/ts/skr.invoice.adapter.ts +291 -45
- package/ts/skr.invoice.booking.ts +22 -34
- package/ts/skr.migrate.ts +268 -0
- package/ts/skr.period.ts +139 -0
- package/ts/skr.posting.ts +273 -0
- package/ts/skr.security.ts +18 -93
- package/ts/skr.sequence.ts +89 -0
- package/ts/skr.txview.ts +268 -0
- package/ts/skr.types.ts +37 -1
- package/ts/skr.verify.ts +99 -0
- /package/{license.md → license} +0 -0
package/ts/skr.security.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
+
}
|
package/ts/skr.txview.ts
ADDED
|
@@ -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
|
-
|
|
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 {
|
package/ts/skr.verify.ts
ADDED
|
@@ -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
|
+
}
|
/package/{license.md → license}
RENAMED
|
File without changes
|