@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
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v1 -> v2 data migration (offline, idempotent, additive):
|
|
3
|
+
*
|
|
4
|
+
* M001 cents backfill — every legacy JournalEntry line gains exact-cent v2
|
|
5
|
+
* fields (amountCents/side/counterAccount/buKey) next to the kept
|
|
6
|
+
* float fields; sub-cent drift beyond the guard aborts loudly.
|
|
7
|
+
* M002 unify journal — standalone legacy Transactions (not referenced by any
|
|
8
|
+
* JournalEntry) become synthesized two-line entries (legacyImport);
|
|
9
|
+
* legacy entries get schemaVersion 2 so aggregations see them.
|
|
10
|
+
* v1 'reversed' status maps to 'posted' (kept as legacyStatus) so
|
|
11
|
+
* original+reversal pairs net to zero in aggregations.
|
|
12
|
+
* M003 sequence + chain — all v2 entries without a sequenceNumber are
|
|
13
|
+
* appended to the hash chain per (skrType, fiscalYear) in deterministic
|
|
14
|
+
* order (postedAt/createdAt/date, then id).
|
|
15
|
+
*
|
|
16
|
+
* Legacy float fields are never removed; the Transaction collection is kept
|
|
17
|
+
* as a frozen read model. The returned report is a Verfahrensdokumentation
|
|
18
|
+
* artifact — persist it with your records.
|
|
19
|
+
*/
|
|
20
|
+
import * as plugins from './plugins.js';
|
|
21
|
+
import * as core from './core/index.js';
|
|
22
|
+
import { getDbSync } from './skr.database.js';
|
|
23
|
+
import { JOURNAL_ENTRY_COLLECTION } from './skr.period.js';
|
|
24
|
+
import { buildHashPayload } from './skr.posting.js';
|
|
25
|
+
import { mapNumericPostingKey } from './skr.txview.js';
|
|
26
|
+
import type { TSKRType } from './skr.types.js';
|
|
27
|
+
|
|
28
|
+
const { centsFromFloat } = plugins.calculation;
|
|
29
|
+
|
|
30
|
+
export interface IMigrationReport {
|
|
31
|
+
startedAt: Date;
|
|
32
|
+
finishedAt: Date | null;
|
|
33
|
+
m001: {
|
|
34
|
+
journalEntriesBackfilled: number;
|
|
35
|
+
transactionsBackfilled: number;
|
|
36
|
+
maxDriftCents: number;
|
|
37
|
+
};
|
|
38
|
+
m002: { legacyEntriesUpgraded: number; transactionsSynthesized: number };
|
|
39
|
+
m003: Array<{
|
|
40
|
+
skrType: string;
|
|
41
|
+
fiscalYear: number;
|
|
42
|
+
chainedEntries: number;
|
|
43
|
+
headHash: string;
|
|
44
|
+
}>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** drift guard for legacy float data (plan: abort on drift > 0.5 cent) */
|
|
48
|
+
const LEGACY_DRIFT_CENTS = 0.5;
|
|
49
|
+
|
|
50
|
+
function toCents(value: number, driftTracker: { max: number }): number {
|
|
51
|
+
const exact = value * 100;
|
|
52
|
+
const rounded = Math.round(exact);
|
|
53
|
+
const drift = Math.abs(exact - rounded);
|
|
54
|
+
if (drift > driftTracker.max) driftTracker.max = drift;
|
|
55
|
+
return centsFromFloat(value, LEGACY_DRIFT_CENTS);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function migrateToV2(): Promise<IMigrationReport> {
|
|
59
|
+
const db = getDbSync();
|
|
60
|
+
const entries = db.mongoDb.collection(JOURNAL_ENTRY_COLLECTION);
|
|
61
|
+
const transactions = db.mongoDb.collection('Transaction');
|
|
62
|
+
const drift = { max: 0 };
|
|
63
|
+
const report: IMigrationReport = {
|
|
64
|
+
startedAt: new Date(),
|
|
65
|
+
finishedAt: null,
|
|
66
|
+
m001: { journalEntriesBackfilled: 0, transactionsBackfilled: 0, maxDriftCents: 0 },
|
|
67
|
+
m002: { legacyEntriesUpgraded: 0, transactionsSynthesized: 0 },
|
|
68
|
+
m003: [],
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// ---------- M001: cents backfill on legacy journal entries ----------
|
|
72
|
+
const legacyEntries = await entries
|
|
73
|
+
.find({ schemaVersion: { $exists: false } })
|
|
74
|
+
.toArray();
|
|
75
|
+
for (const doc of legacyEntries) {
|
|
76
|
+
const lines = (doc.lines ?? []) as Array<Record<string, unknown>>;
|
|
77
|
+
const debitLines = lines.filter((line) => ((line.debit as number) ?? 0) > 0);
|
|
78
|
+
const creditLines = lines.filter((line) => ((line.credit as number) ?? 0) > 0);
|
|
79
|
+
const counterFor = (isDebit: boolean): string => {
|
|
80
|
+
const opposite = isDebit ? creditLines : debitLines;
|
|
81
|
+
return (opposite[0]?.accountNumber as string) ?? '';
|
|
82
|
+
};
|
|
83
|
+
const upgraded = lines.map((line) => {
|
|
84
|
+
const isDebit = ((line.debit as number) ?? 0) > 0;
|
|
85
|
+
const amount = isDebit ? (line.debit as number) : ((line.credit as number) ?? 0);
|
|
86
|
+
return {
|
|
87
|
+
...line,
|
|
88
|
+
side: line.side ?? (isDebit ? 'debit' : 'credit'),
|
|
89
|
+
amountCents: line.amountCents ?? toCents(amount, drift),
|
|
90
|
+
counterAccount: line.counterAccount ?? counterFor(isDebit),
|
|
91
|
+
buKey: line.buKey ?? mapNumericPostingKey(line.postingKey as number),
|
|
92
|
+
buchungstext: line.buchungstext ?? line.description ?? doc.description,
|
|
93
|
+
};
|
|
94
|
+
});
|
|
95
|
+
await entries.updateOne({ _id: doc._id }, { $set: { lines: upgraded } });
|
|
96
|
+
report.m001.journalEntriesBackfilled++;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// cents mirrors on frozen legacy transactions (informational)
|
|
100
|
+
const legacyTransactions = await transactions
|
|
101
|
+
.find({ amountCents: { $exists: false } })
|
|
102
|
+
.toArray();
|
|
103
|
+
for (const doc of legacyTransactions) {
|
|
104
|
+
await transactions.updateOne(
|
|
105
|
+
{ _id: doc._id },
|
|
106
|
+
{
|
|
107
|
+
$set: {
|
|
108
|
+
amountCents: toCents((doc.amount as number) ?? 0, drift),
|
|
109
|
+
vatAmountCents: toCents((doc.vatAmount as number) ?? 0, drift),
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
);
|
|
113
|
+
report.m001.transactionsBackfilled++;
|
|
114
|
+
}
|
|
115
|
+
report.m001.maxDriftCents = drift.max;
|
|
116
|
+
|
|
117
|
+
// ---------- M002: unify into the v2 journal ----------
|
|
118
|
+
// referenced transactions already live inside a journal entry
|
|
119
|
+
const referencedIds = new Set<string>();
|
|
120
|
+
const entryDocs = await entries.find({}).toArray();
|
|
121
|
+
for (const doc of entryDocs) {
|
|
122
|
+
for (const id of (doc.transactionIds as string[]) ?? []) referencedIds.add(id);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
for (const doc of entryDocs) {
|
|
126
|
+
if (doc.schemaVersion === 2) continue;
|
|
127
|
+
await entries.updateOne(
|
|
128
|
+
{ _id: doc._id },
|
|
129
|
+
{
|
|
130
|
+
$set: {
|
|
131
|
+
schemaVersion: 2,
|
|
132
|
+
currency: doc.currency ?? 'EUR',
|
|
133
|
+
legacyImport: true,
|
|
134
|
+
legacyStatus: doc.status,
|
|
135
|
+
status: doc.status === 'reversed' ? 'posted' : doc.status,
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
);
|
|
139
|
+
report.m002.legacyEntriesUpgraded++;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const standalone = await transactions
|
|
143
|
+
.find({ synthesizedIntoJournal: { $exists: false } })
|
|
144
|
+
.toArray();
|
|
145
|
+
for (const doc of standalone) {
|
|
146
|
+
if (referencedIds.has(doc.id as string)) continue;
|
|
147
|
+
const amountCents = (doc.amountCents as number) ?? toCents((doc.amount as number) ?? 0, drift);
|
|
148
|
+
const date = doc.date as Date;
|
|
149
|
+
const synthesized = {
|
|
150
|
+
id: plugins.smartunique.shortId(),
|
|
151
|
+
journalNumber: `JRN-LEGACY-${doc.transactionNumber ?? doc.id}`,
|
|
152
|
+
date,
|
|
153
|
+
description: (doc.description as string) ?? '',
|
|
154
|
+
reference: (doc.reference as string) ?? '',
|
|
155
|
+
skrType: doc.skrType as TSKRType,
|
|
156
|
+
currency: 'EUR',
|
|
157
|
+
schemaVersion: 2,
|
|
158
|
+
legacyImport: true,
|
|
159
|
+
legacyStatus: doc.status,
|
|
160
|
+
legacyTransactionId: doc.id,
|
|
161
|
+
status: doc.status === 'reversed' ? 'posted' : (doc.status as string),
|
|
162
|
+
period: doc.period as string,
|
|
163
|
+
fiscalYear: doc.fiscalYear as number,
|
|
164
|
+
createdAt: (doc.createdAt as Date) ?? date,
|
|
165
|
+
postedAt: (doc.postedAt as Date) ?? date,
|
|
166
|
+
createdBy: (doc.createdBy as string) ?? 'migration',
|
|
167
|
+
totalDebits: ((doc.amount as number) ?? 0),
|
|
168
|
+
totalCredits: ((doc.amount as number) ?? 0),
|
|
169
|
+
isBalanced: true,
|
|
170
|
+
transactionIds: [],
|
|
171
|
+
lines: [
|
|
172
|
+
{
|
|
173
|
+
accountNumber: doc.debitAccount as string,
|
|
174
|
+
debit: (doc.amount as number) ?? 0,
|
|
175
|
+
credit: 0,
|
|
176
|
+
description: (doc.description as string) ?? '',
|
|
177
|
+
side: 'debit',
|
|
178
|
+
amountCents,
|
|
179
|
+
counterAccount: doc.creditAccount as string,
|
|
180
|
+
buKey: '',
|
|
181
|
+
buchungstext: (doc.description as string) ?? '',
|
|
182
|
+
vatAmountCents: (doc.vatAmountCents as number) ?? 0,
|
|
183
|
+
costCenter1: (doc.costCenter as string) || undefined,
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
accountNumber: doc.creditAccount as string,
|
|
187
|
+
debit: 0,
|
|
188
|
+
credit: (doc.amount as number) ?? 0,
|
|
189
|
+
description: (doc.description as string) ?? '',
|
|
190
|
+
side: 'credit',
|
|
191
|
+
amountCents,
|
|
192
|
+
counterAccount: doc.debitAccount as string,
|
|
193
|
+
buKey: '',
|
|
194
|
+
buchungstext: (doc.description as string) ?? '',
|
|
195
|
+
},
|
|
196
|
+
],
|
|
197
|
+
};
|
|
198
|
+
await entries.insertOne(synthesized as never);
|
|
199
|
+
await transactions.updateOne(
|
|
200
|
+
{ _id: doc._id },
|
|
201
|
+
{ $set: { synthesizedIntoJournal: synthesized.id } },
|
|
202
|
+
);
|
|
203
|
+
report.m002.transactionsSynthesized++;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ---------- M003: append unchained entries to the hash chain ----------
|
|
207
|
+
const unchained = await entries
|
|
208
|
+
.find({ schemaVersion: 2, sequenceNumber: { $exists: false } })
|
|
209
|
+
.toArray();
|
|
210
|
+
const groups = new Map<string, typeof unchained>();
|
|
211
|
+
for (const doc of unchained) {
|
|
212
|
+
const key = `${doc.skrType}:${doc.fiscalYear}`;
|
|
213
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
214
|
+
groups.get(key)!.push(doc);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const sequences = db.mongoDb.collection('LedgerSequence');
|
|
218
|
+
for (const [key, docs] of groups) {
|
|
219
|
+
const [skrType, yearString] = key.split(':');
|
|
220
|
+
const fiscalYear = Number.parseInt(yearString, 10);
|
|
221
|
+
docs.sort((a, b) => {
|
|
222
|
+
const timeA = ((a.postedAt ?? a.createdAt ?? a.date) as Date).getTime();
|
|
223
|
+
const timeB = ((b.postedAt ?? b.createdAt ?? b.date) as Date).getTime();
|
|
224
|
+
if (timeA !== timeB) return timeA - timeB;
|
|
225
|
+
return String(a.id).localeCompare(String(b.id));
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
const state = await sequences.findOne({ skrType, fiscalYear });
|
|
229
|
+
let sequenceNumber = (state?.value as number) ?? 0;
|
|
230
|
+
let headHash = (state?.headHash as string) ?? core.genesisHash(skrType, fiscalYear);
|
|
231
|
+
|
|
232
|
+
for (const doc of docs) {
|
|
233
|
+
sequenceNumber += 1;
|
|
234
|
+
const postedAt = (doc.postedAt ?? doc.createdAt ?? doc.date) as Date;
|
|
235
|
+
const entryHash = core.entryHash(
|
|
236
|
+
headHash,
|
|
237
|
+
buildHashPayload({
|
|
238
|
+
sequenceNumber,
|
|
239
|
+
skrType,
|
|
240
|
+
fiscalYear,
|
|
241
|
+
date: doc.date as Date,
|
|
242
|
+
postedAt,
|
|
243
|
+
description: doc.description as string,
|
|
244
|
+
reference: doc.reference as string,
|
|
245
|
+
taxScenario: doc.taxScenario as string,
|
|
246
|
+
currency: (doc.currency as string) ?? 'EUR',
|
|
247
|
+
reversalOf: doc.reversalOf as string,
|
|
248
|
+
lines: (doc.lines ?? []) as never[],
|
|
249
|
+
}),
|
|
250
|
+
);
|
|
251
|
+
await entries.updateOne(
|
|
252
|
+
{ _id: doc._id },
|
|
253
|
+
{ $set: { sequenceNumber, prevHash: headHash, entryHash, postedAt } },
|
|
254
|
+
);
|
|
255
|
+
headHash = entryHash;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
await sequences.updateOne(
|
|
259
|
+
{ skrType, fiscalYear },
|
|
260
|
+
{ $set: { value: sequenceNumber, headHash } },
|
|
261
|
+
{ upsert: true },
|
|
262
|
+
);
|
|
263
|
+
report.m003.push({ skrType, fiscalYear, chainedEntries: docs.length, headHash });
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
report.finishedAt = new Date();
|
|
267
|
+
return report;
|
|
268
|
+
}
|
package/ts/skr.period.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accounting period locks (Festschreibung). One document per
|
|
3
|
+
* (skrType, period 'YYYY-MM') in the LedgerPeriod collection.
|
|
4
|
+
*
|
|
5
|
+
* festschreibePeriod() stamps all posted schemaVersion-2 entries of the
|
|
6
|
+
* period as finalized via a raw update — deliberately bypassing the doc-level
|
|
7
|
+
* immutability guard, because the finalization fields live OUTSIDE the entry
|
|
8
|
+
* hash. Detection of any other raw-driver tampering is verifyHashChain()'s job.
|
|
9
|
+
*/
|
|
10
|
+
import { getDbSync, type TDbSession } from './skr.database.js';
|
|
11
|
+
import { PeriodLockedError } from './skr.errors.js';
|
|
12
|
+
import { getSequenceState } from './skr.sequence.js';
|
|
13
|
+
import type { TSKRType } from './skr.types.js';
|
|
14
|
+
|
|
15
|
+
export const LEDGER_PERIOD_COLLECTION = 'LedgerPeriod';
|
|
16
|
+
export const JOURNAL_ENTRY_COLLECTION = 'JournalEntry';
|
|
17
|
+
|
|
18
|
+
export interface ILedgerPeriodState {
|
|
19
|
+
skrType: TSKRType;
|
|
20
|
+
period: string; // YYYY-MM
|
|
21
|
+
status: 'open' | 'locked';
|
|
22
|
+
lockedAt?: Date;
|
|
23
|
+
lockedBy?: string;
|
|
24
|
+
chainCheckpoint?: { lastSequenceNumber: number; headHash: string } | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function periodCollection() {
|
|
28
|
+
return getDbSync().mongoDb.collection(LEDGER_PERIOD_COLLECTION);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function ensurePeriodIndexes(): Promise<void> {
|
|
32
|
+
await periodCollection().createIndex({ skrType: 1, period: 1 }, { unique: true });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function periodOf(date: Date): string {
|
|
36
|
+
return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, '0')}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function fiscalYearOf(date: Date): number {
|
|
40
|
+
return date.getUTCFullYear();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Throws PeriodLockedError when the period is festgeschrieben. Runs inside
|
|
45
|
+
* the posting transaction so a concurrent lock aborts the posting.
|
|
46
|
+
*/
|
|
47
|
+
export async function assertPeriodOpen(
|
|
48
|
+
skrType: TSKRType,
|
|
49
|
+
period: string,
|
|
50
|
+
session?: TDbSession,
|
|
51
|
+
): Promise<void> {
|
|
52
|
+
const doc = await periodCollection().findOne({ skrType, period }, { session });
|
|
53
|
+
if (doc && doc.status === 'locked') {
|
|
54
|
+
throw new PeriodLockedError(skrType, period);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function getPeriodState(
|
|
59
|
+
skrType: TSKRType,
|
|
60
|
+
period: string,
|
|
61
|
+
): Promise<ILedgerPeriodState | null> {
|
|
62
|
+
const doc = await periodCollection().findOne({ skrType, period });
|
|
63
|
+
if (!doc) return null;
|
|
64
|
+
return {
|
|
65
|
+
skrType,
|
|
66
|
+
period,
|
|
67
|
+
status: doc.status as 'open' | 'locked',
|
|
68
|
+
lockedAt: doc.lockedAt as Date | undefined,
|
|
69
|
+
lockedBy: doc.lockedBy as string | undefined,
|
|
70
|
+
chainCheckpoint: (doc.chainCheckpoint as ILedgerPeriodState['chainCheckpoint']) ?? null,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Festschreibung: lock the period against new postings and stamp all posted
|
|
76
|
+
* schemaVersion-2 entries as finalized. Idempotent — locking a locked period
|
|
77
|
+
* throws so callers notice double runs.
|
|
78
|
+
*/
|
|
79
|
+
export async function festschreibePeriod(params: {
|
|
80
|
+
skrType: TSKRType;
|
|
81
|
+
period: string;
|
|
82
|
+
lockedBy?: string;
|
|
83
|
+
lockedAt?: Date;
|
|
84
|
+
}): Promise<{ finalizedEntries: number; checkpoint: ILedgerPeriodState['chainCheckpoint'] }> {
|
|
85
|
+
const db = getDbSync();
|
|
86
|
+
const session = db.startSession();
|
|
87
|
+
const lockedAt = params.lockedAt ?? new Date();
|
|
88
|
+
try {
|
|
89
|
+
let finalizedEntries = 0;
|
|
90
|
+
let checkpoint: ILedgerPeriodState['chainCheckpoint'] = null;
|
|
91
|
+
await session.withTransaction(async () => {
|
|
92
|
+
const existing = await periodCollection().findOne(
|
|
93
|
+
{ skrType: params.skrType, period: params.period },
|
|
94
|
+
{ session },
|
|
95
|
+
);
|
|
96
|
+
if (existing && existing.status === 'locked') {
|
|
97
|
+
throw new PeriodLockedError(params.skrType, params.period);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const stamp = await db.mongoDb.collection(JOURNAL_ENTRY_COLLECTION).updateMany(
|
|
101
|
+
{
|
|
102
|
+
skrType: params.skrType,
|
|
103
|
+
period: params.period,
|
|
104
|
+
schemaVersion: 2,
|
|
105
|
+
status: 'posted',
|
|
106
|
+
},
|
|
107
|
+
{ $set: { status: 'finalized', finalizedAt: lockedAt } },
|
|
108
|
+
{ session },
|
|
109
|
+
);
|
|
110
|
+
finalizedEntries = stamp.modifiedCount;
|
|
111
|
+
|
|
112
|
+
await periodCollection().updateOne(
|
|
113
|
+
{ skrType: params.skrType, period: params.period },
|
|
114
|
+
{
|
|
115
|
+
$set: {
|
|
116
|
+
status: 'locked',
|
|
117
|
+
lockedAt,
|
|
118
|
+
lockedBy: params.lockedBy ?? 'system',
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
{ upsert: true, session },
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// checkpoint outside the txn: reads the settled chain head
|
|
126
|
+
const fiscalYear = Number.parseInt(params.period.slice(0, 4), 10);
|
|
127
|
+
const state = await getSequenceState(params.skrType, fiscalYear);
|
|
128
|
+
if (state) {
|
|
129
|
+
checkpoint = { lastSequenceNumber: state.value, headHash: state.headHash };
|
|
130
|
+
await periodCollection().updateOne(
|
|
131
|
+
{ skrType: params.skrType, period: params.period },
|
|
132
|
+
{ $set: { chainCheckpoint: checkpoint } },
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
return { finalizedEntries, checkpoint };
|
|
136
|
+
} finally {
|
|
137
|
+
await session.endSession();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JournalPoster — the atomic v2 posting path.
|
|
3
|
+
*
|
|
4
|
+
* Flow per posting: pure pre-flight validation (stateless core) → one MongoDB
|
|
5
|
+
* transaction containing period-lock re-check, gapless sequence allocation,
|
|
6
|
+
* hash computation, entry save, and a compare-and-swap on the chain head.
|
|
7
|
+
* The driver's withTransaction() retries transient conflicts; an abort rolls
|
|
8
|
+
* the sequence $inc back, so numbering stays gapless.
|
|
9
|
+
*
|
|
10
|
+
* JournalEntry (schemaVersion 2) is the single source of truth — no
|
|
11
|
+
* Transaction-pair explosion. Balances are aggregations (skr.balances.ts).
|
|
12
|
+
*/
|
|
13
|
+
import * as core from './core/index.js';
|
|
14
|
+
import { getDbSync } from './skr.database.js';
|
|
15
|
+
import { JournalEntry } from './skr.classes.journalentry.js';
|
|
16
|
+
import { allocateSequence, advanceHead, ensureSequenceIndexes } from './skr.sequence.js';
|
|
17
|
+
import {
|
|
18
|
+
assertPeriodOpen,
|
|
19
|
+
ensurePeriodIndexes,
|
|
20
|
+
fiscalYearOf,
|
|
21
|
+
periodOf,
|
|
22
|
+
JOURNAL_ENTRY_COLLECTION,
|
|
23
|
+
} from './skr.period.js';
|
|
24
|
+
import { PeriodLockedError, TransactionsUnsupportedError } from './skr.errors.js';
|
|
25
|
+
import type { IJournalEntryLine } from './skr.types.js';
|
|
26
|
+
|
|
27
|
+
export interface IPostDraftOptions {
|
|
28
|
+
policy?: core.IAccountPolicy;
|
|
29
|
+
postedAt?: Date;
|
|
30
|
+
createdBy?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The exact structure whose canonical form is hashed into the chain.
|
|
35
|
+
* Built identically by the poster (from the draft) and the verifier (from the
|
|
36
|
+
* persisted document) — every optional is normalized so both sides match.
|
|
37
|
+
*/
|
|
38
|
+
export function buildHashPayload(entry: {
|
|
39
|
+
sequenceNumber: number;
|
|
40
|
+
skrType: string;
|
|
41
|
+
fiscalYear: number;
|
|
42
|
+
date: Date;
|
|
43
|
+
postedAt: Date;
|
|
44
|
+
description: string;
|
|
45
|
+
reference?: string | null;
|
|
46
|
+
taxScenario?: string | null;
|
|
47
|
+
currency?: string | null;
|
|
48
|
+
reversalOf?: string | null;
|
|
49
|
+
lines: Array<{
|
|
50
|
+
accountNumber: string;
|
|
51
|
+
counterAccount?: string | null;
|
|
52
|
+
side?: string | null;
|
|
53
|
+
amountCents?: number | null;
|
|
54
|
+
buKey?: string | null;
|
|
55
|
+
buchungstext?: string | null;
|
|
56
|
+
belegfeld1?: string | null;
|
|
57
|
+
costCenter1?: string | null;
|
|
58
|
+
docLink?: string | null;
|
|
59
|
+
vatAmountCents?: number | null;
|
|
60
|
+
}>;
|
|
61
|
+
}): Record<string, unknown> {
|
|
62
|
+
return {
|
|
63
|
+
sequenceNumber: entry.sequenceNumber,
|
|
64
|
+
skrType: entry.skrType,
|
|
65
|
+
fiscalYear: entry.fiscalYear,
|
|
66
|
+
date: entry.date,
|
|
67
|
+
postedAt: entry.postedAt,
|
|
68
|
+
description: entry.description ?? '',
|
|
69
|
+
reference: entry.reference ?? '',
|
|
70
|
+
taxScenario: entry.taxScenario ?? '',
|
|
71
|
+
currency: entry.currency ?? 'EUR',
|
|
72
|
+
reversalOf: entry.reversalOf ?? '',
|
|
73
|
+
lines: entry.lines.map((line) => ({
|
|
74
|
+
accountNumber: line.accountNumber,
|
|
75
|
+
counterAccount: line.counterAccount ?? '',
|
|
76
|
+
side: line.side ?? '',
|
|
77
|
+
amountCents: line.amountCents ?? 0,
|
|
78
|
+
buKey: line.buKey ?? '',
|
|
79
|
+
buchungstext: line.buchungstext ?? '',
|
|
80
|
+
belegfeld1: line.belegfeld1 ?? '',
|
|
81
|
+
costCenter1: line.costCenter1 ?? '',
|
|
82
|
+
docLink: line.docLink ?? '',
|
|
83
|
+
vatAmountCents: line.vatAmountCents ?? 0,
|
|
84
|
+
})),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export class JournalPoster {
|
|
89
|
+
/**
|
|
90
|
+
* Create the indexes the v2 posting path relies on. Idempotent.
|
|
91
|
+
*/
|
|
92
|
+
public async ensureIndexes(): Promise<void> {
|
|
93
|
+
await ensureSequenceIndexes();
|
|
94
|
+
await ensurePeriodIndexes();
|
|
95
|
+
await getDbSync()
|
|
96
|
+
.mongoDb.collection(JOURNAL_ENTRY_COLLECTION)
|
|
97
|
+
.createIndex(
|
|
98
|
+
{ skrType: 1, fiscalYear: 1, sequenceNumber: 1 },
|
|
99
|
+
{
|
|
100
|
+
unique: true,
|
|
101
|
+
// only chained entries — migration inserts docs before sequencing
|
|
102
|
+
partialFilterExpression: {
|
|
103
|
+
schemaVersion: 2,
|
|
104
|
+
sequenceNumber: { $exists: true },
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Probe transaction support with a no-op transaction. Fails hard —
|
|
112
|
+
* filing-grade posting has no non-transactional fallback.
|
|
113
|
+
*/
|
|
114
|
+
public async assertTransactionSupport(): Promise<void> {
|
|
115
|
+
const db = getDbSync();
|
|
116
|
+
const session = db.startSession();
|
|
117
|
+
try {
|
|
118
|
+
await session.withTransaction(async () => {
|
|
119
|
+
await db.mongoDb
|
|
120
|
+
.collection(JOURNAL_ENTRY_COLLECTION)
|
|
121
|
+
.findOne({ _id: 'txn-probe' as never }, { session });
|
|
122
|
+
});
|
|
123
|
+
} catch (error) {
|
|
124
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
125
|
+
throw new TransactionsUnsupportedError(message);
|
|
126
|
+
} finally {
|
|
127
|
+
await session.endSession();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Validate and atomically post a journal draft. Returns the persisted,
|
|
133
|
+
* hashed, immutable JournalEntry.
|
|
134
|
+
*/
|
|
135
|
+
public async postDraft(
|
|
136
|
+
draft: core.IJournalDraft,
|
|
137
|
+
options?: IPostDraftOptions,
|
|
138
|
+
): Promise<JournalEntry> {
|
|
139
|
+
const validation = core.validateJournalDraft(draft, options?.policy);
|
|
140
|
+
if (!validation.valid) {
|
|
141
|
+
throw new Error(`Journal draft invalid: ${validation.errors.join('; ')}`);
|
|
142
|
+
}
|
|
143
|
+
const normalized = validation.normalized;
|
|
144
|
+
const period = periodOf(normalized.date);
|
|
145
|
+
const fiscalYear = fiscalYearOf(normalized.date);
|
|
146
|
+
const postedAt = options?.postedAt ?? new Date();
|
|
147
|
+
|
|
148
|
+
const db = getDbSync();
|
|
149
|
+
const session = db.startSession();
|
|
150
|
+
try {
|
|
151
|
+
let posted: JournalEntry | null = null;
|
|
152
|
+
await session.withTransaction(async () => {
|
|
153
|
+
await assertPeriodOpen(normalized.skrType, period, session);
|
|
154
|
+
|
|
155
|
+
const { sequenceNumber, prevHash } = await allocateSequence(
|
|
156
|
+
normalized.skrType,
|
|
157
|
+
fiscalYear,
|
|
158
|
+
session,
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const lines: IJournalEntryLine[] = normalized.lines.map((line) => ({
|
|
162
|
+
accountNumber: line.accountNumber,
|
|
163
|
+
// legacy float mirrors keep v1 reports readable during migration
|
|
164
|
+
debit: line.side === 'debit' ? line.amountCents / 100 : 0,
|
|
165
|
+
credit: line.side === 'credit' ? line.amountCents / 100 : 0,
|
|
166
|
+
description: line.buchungstext,
|
|
167
|
+
side: line.side,
|
|
168
|
+
amountCents: line.amountCents,
|
|
169
|
+
counterAccount: line.counterAccount,
|
|
170
|
+
buKey: line.buKey,
|
|
171
|
+
buchungstext: line.buchungstext,
|
|
172
|
+
belegfeld1: line.belegfeld1,
|
|
173
|
+
costCenter1: line.costCenter1,
|
|
174
|
+
docLink: line.docLink,
|
|
175
|
+
vatAmountCents: line.vatAmountCents,
|
|
176
|
+
}));
|
|
177
|
+
|
|
178
|
+
const entry = new JournalEntry({
|
|
179
|
+
date: normalized.date,
|
|
180
|
+
description: normalized.description,
|
|
181
|
+
reference: normalized.reference,
|
|
182
|
+
skrType: normalized.skrType,
|
|
183
|
+
lines,
|
|
184
|
+
});
|
|
185
|
+
entry.schemaVersion = 2;
|
|
186
|
+
entry.sequenceNumber = sequenceNumber;
|
|
187
|
+
entry.prevHash = prevHash;
|
|
188
|
+
entry.taxScenario = normalized.taxScenario;
|
|
189
|
+
entry.currency = normalized.currency;
|
|
190
|
+
entry.reversalOf = normalized.reversalOf;
|
|
191
|
+
entry.status = 'posted';
|
|
192
|
+
entry.postedAt = postedAt;
|
|
193
|
+
entry.finalizedAt = null;
|
|
194
|
+
entry.createdBy = options?.createdBy ?? 'system';
|
|
195
|
+
// period/fiscalYear are UTC-derived here; the constructor derives them
|
|
196
|
+
// from local time, so overwrite for determinism
|
|
197
|
+
entry.period = period;
|
|
198
|
+
entry.fiscalYear = fiscalYear;
|
|
199
|
+
|
|
200
|
+
entry.entryHash = core.entryHash(
|
|
201
|
+
prevHash,
|
|
202
|
+
buildHashPayload({
|
|
203
|
+
sequenceNumber,
|
|
204
|
+
skrType: normalized.skrType,
|
|
205
|
+
fiscalYear,
|
|
206
|
+
date: normalized.date,
|
|
207
|
+
postedAt,
|
|
208
|
+
description: normalized.description,
|
|
209
|
+
reference: normalized.reference,
|
|
210
|
+
taxScenario: normalized.taxScenario,
|
|
211
|
+
currency: normalized.currency,
|
|
212
|
+
reversalOf: normalized.reversalOf,
|
|
213
|
+
lines,
|
|
214
|
+
}),
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
await entry.save({ session });
|
|
218
|
+
await advanceHead(
|
|
219
|
+
normalized.skrType,
|
|
220
|
+
fiscalYear,
|
|
221
|
+
prevHash,
|
|
222
|
+
entry.entryHash,
|
|
223
|
+
session,
|
|
224
|
+
);
|
|
225
|
+
posted = entry;
|
|
226
|
+
});
|
|
227
|
+
if (!posted) {
|
|
228
|
+
throw new Error('Posting transaction did not complete');
|
|
229
|
+
}
|
|
230
|
+
return posted;
|
|
231
|
+
} catch (error) {
|
|
232
|
+
if (error instanceof PeriodLockedError) throw error;
|
|
233
|
+
throw error;
|
|
234
|
+
} finally {
|
|
235
|
+
await session.endSession();
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Storno: post a reversal entry for a hashed entry WITHOUT mutating the
|
|
241
|
+
* original (GoBD). The reversal references the original via belegfeld1 and
|
|
242
|
+
* reference; reversal state is derived by querying, never stored on the
|
|
243
|
+
* original.
|
|
244
|
+
*/
|
|
245
|
+
public async postStorno(
|
|
246
|
+
original: JournalEntry,
|
|
247
|
+
options?: IPostDraftOptions & { date?: Date; description?: string },
|
|
248
|
+
): Promise<JournalEntry> {
|
|
249
|
+
if (original.schemaVersion !== 2 || !original.entryHash) {
|
|
250
|
+
throw new Error('postStorno only reverses schemaVersion-2 (hashed) entries');
|
|
251
|
+
}
|
|
252
|
+
const draft: core.IJournalDraft = {
|
|
253
|
+
date: options?.date ?? new Date(),
|
|
254
|
+
description:
|
|
255
|
+
options?.description ?? `Storno ${original.journalNumber}: ${original.description}`,
|
|
256
|
+
reference: `STORNO-${original.journalNumber}`,
|
|
257
|
+
skrType: original.skrType,
|
|
258
|
+
reversalOf: original.id,
|
|
259
|
+
currency: (original.currency as 'EUR') ?? 'EUR',
|
|
260
|
+
taxScenario: original.taxScenario as core.TTaxScenario | undefined,
|
|
261
|
+
lines: original.lines.map((line) => ({
|
|
262
|
+
accountNumber: line.accountNumber,
|
|
263
|
+
counterAccount: line.counterAccount ?? '',
|
|
264
|
+
side: line.side === 'debit' ? 'credit' : 'debit',
|
|
265
|
+
amountCents: line.amountCents ?? 0,
|
|
266
|
+
buKey: (line.buKey ?? '') as core.TBuKey,
|
|
267
|
+
buchungstext: `Storno: ${line.buchungstext ?? ''}`,
|
|
268
|
+
belegfeld1: original.journalNumber,
|
|
269
|
+
})),
|
|
270
|
+
};
|
|
271
|
+
return this.postDraft(draft, options);
|
|
272
|
+
}
|
|
273
|
+
}
|