@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
package/ts/skr.api.ts CHANGED
@@ -12,6 +12,23 @@ import { AccountsExporter } from './skr.export.accounts.js';
12
12
  import { BalancesExporter } from './skr.export.balances.js';
13
13
  import { PdfReportGenerator, type IPdfReportOptions } from './skr.export.pdf.js';
14
14
  import { SecurityManager, type ISigningOptions } from './skr.security.js';
15
+ import * as core from './core/index.js';
16
+ import { JournalPoster, type IPostDraftOptions } from './skr.posting.js';
17
+ import { festschreibePeriod, getPeriodState, type ILedgerPeriodState } from './skr.period.js';
18
+ import { verifyHashChain, type IChainVerificationResult } from './skr.verify.js';
19
+ import {
20
+ aggregateTrialBalance,
21
+ aggregateAccountBalance,
22
+ type IBalanceQueryOptions,
23
+ type ITrialBalanceCents,
24
+ type IAccountBalanceCents,
25
+ } from './skr.balances.js';
26
+ import {
27
+ transactionDataToDraft,
28
+ journalInputToDraft,
29
+ entryToTransactionView,
30
+ accountLineViews,
31
+ } from './skr.txview.js';
15
32
  import { InvoiceAdapter } from './skr.invoice.adapter.js';
16
33
  import { InvoiceStorage } from './skr.invoice.storage.js';
17
34
  import { InvoiceBookingEngine, type IBookingOptions, type IBookingResult } from './skr.invoice.booking.js';
@@ -36,6 +53,7 @@ import type {
36
53
  IIncomeStatement,
37
54
  IBalanceSheet,
38
55
  IAccountBalance,
56
+ ITransactionView,
39
57
  } from './skr.types.js';
40
58
 
41
59
  /**
@@ -51,6 +69,7 @@ export class SkrApi {
51
69
  private invoiceAdapter: InvoiceAdapter | null = null;
52
70
  private invoiceStorage: InvoiceStorage | null = null;
53
71
  private invoiceBookingEngine: InvoiceBookingEngine | null = null;
72
+ private journalPoster: JournalPoster = new JournalPoster();
54
73
 
55
74
  constructor(private config: IDatabaseConfig) {
56
75
  this.chartOfAccounts = new ChartOfAccounts(config);
@@ -84,6 +103,11 @@ export class SkrApi {
84
103
  this.currentSKRType = skrType;
85
104
  this.ledger = new Ledger(skrType);
86
105
  this.reports = new Reports(skrType);
106
+
107
+ // v2 posting path: transactions are mandatory (replica set) — fail hard,
108
+ // filing-grade posting has no non-transactional fallback
109
+ await this.journalPoster.assertTransactionSupport();
110
+ await this.journalPoster.ensureIndexes();
87
111
 
88
112
  // Initialize invoice components
89
113
  this.invoiceAdapter = new InvoiceAdapter();
@@ -178,75 +202,160 @@ export class SkrApi {
178
202
  return await this.chartOfAccounts.getAccountsByType(accountType);
179
203
  }
180
204
 
181
- // ========== Transaction Management ==========
205
+ // ========== Transaction Management (v2: hashed journal entries) ==========
182
206
 
183
207
  /**
184
- * Post a simple transaction
208
+ * Post a simple transaction. Since v2 this creates a hashed two-line
209
+ * journal entry (exact cents) — the Transaction collection is frozen.
185
210
  */
186
211
  public async postTransaction(
187
212
  transactionData: ITransactionData,
188
- ): Promise<Transaction> {
213
+ ): Promise<ITransactionView> {
189
214
  this.ensureInitialized();
190
- if (!this.ledger) throw new Error('Ledger not initialized');
191
- return await this.ledger.postTransaction(transactionData);
215
+ if (transactionData.amount <= 0) {
216
+ throw new Error('Transaction amount must be greater than zero');
217
+ }
218
+ if (transactionData.debitAccount === transactionData.creditAccount) {
219
+ throw new Error('Debit and credit accounts cannot be the same');
220
+ }
221
+ for (const [label, accountNumber] of [
222
+ ['Debit', transactionData.debitAccount],
223
+ ['Credit', transactionData.creditAccount],
224
+ ] as const) {
225
+ const account = await Account.getAccountByNumber(accountNumber, this.currentSKRType!);
226
+ if (account && !account.isActive) {
227
+ throw new Error(`${label} account ${accountNumber} is not active`);
228
+ }
229
+ await Account.validateAccountForPosting(accountNumber, this.currentSKRType!);
230
+ }
231
+ const draft = transactionDataToDraft({
232
+ ...transactionData,
233
+ skrType: this.currentSKRType!,
234
+ });
235
+ const entry = await this.journalPoster.postDraft(draft);
236
+ return entryToTransactionView(entry as never)!;
192
237
  }
193
238
 
194
239
  /**
195
- * Post a journal entry
240
+ * Post a journal entry. Since v2 the entry is hashed, sequenced, and
241
+ * immutable; float inputs are converted to exact cents with a drift guard.
196
242
  */
197
243
  public async postJournalEntry(
198
244
  journalData: IJournalEntry,
199
245
  ): Promise<JournalEntry> {
200
246
  this.ensureInitialized();
201
- if (!this.ledger) throw new Error('Ledger not initialized');
202
- return await this.ledger.postJournalEntry(journalData);
247
+ for (const line of journalData.lines) {
248
+ await Account.validateAccountForPosting(line.accountNumber, this.currentSKRType!);
249
+ }
250
+ const draft = journalInputToDraft({
251
+ ...journalData,
252
+ skrType: this.currentSKRType!,
253
+ });
254
+ return this.journalPoster.postDraft(draft);
203
255
  }
204
256
 
205
257
  /**
206
- * Get transaction by ID
258
+ * Get a transaction view by id (two-line v2 entries project 1:1).
207
259
  */
208
260
  public async getTransaction(
209
261
  transactionId: string,
210
- ): Promise<Transaction | null> {
262
+ ): Promise<ITransactionView | null> {
211
263
  this.ensureInitialized();
212
- return await Transaction.getTransactionById(transactionId);
264
+ const entry = await (JournalEntry as any).getInstance({ id: transactionId });
265
+ if (!entry || entry.schemaVersion !== 2) return null;
266
+ return entryToTransactionView(entry);
213
267
  }
214
268
 
215
269
  /**
216
- * List transactions with optional filter
270
+ * List transaction views with optional filter (two-line v2 entries).
217
271
  */
218
272
  public async listTransactions(
219
273
  filter?: ITransactionFilter,
220
- ): Promise<Transaction[]> {
274
+ ): Promise<ITransactionView[]> {
221
275
  this.ensureInitialized();
222
- return await this.chartOfAccounts.getTransactions(filter);
276
+ return this.listTransactionViews(filter);
277
+ }
278
+
279
+ private async listTransactionViews(
280
+ filter?: ITransactionFilter,
281
+ ): Promise<ITransactionView[]> {
282
+ const match: Record<string, unknown> = {
283
+ skrType: filter?.skrType ?? this.currentSKRType,
284
+ schemaVersion: 2,
285
+ status: { $in: ['posted', 'finalized'] },
286
+ };
287
+ if (filter?.dateFrom || filter?.dateTo) {
288
+ const range: Record<string, Date> = {};
289
+ if (filter.dateFrom) range.$gte = filter.dateFrom;
290
+ if (filter.dateTo) range.$lte = filter.dateTo;
291
+ match.date = range;
292
+ }
293
+ const docs = await (await import('./skr.database.js'))
294
+ .getDbSync()
295
+ .mongoDb.collection('JournalEntry')
296
+ .find(match)
297
+ .sort({ date: 1, sequenceNumber: 1 })
298
+ .toArray();
299
+ let views = docs
300
+ .map((doc) => entryToTransactionView(doc as never))
301
+ .filter((view): view is ITransactionView => view !== null);
302
+ if (filter?.accountNumber) {
303
+ views = views.filter(
304
+ (view) =>
305
+ view.debitAccount === filter.accountNumber ||
306
+ view.creditAccount === filter.accountNumber,
307
+ );
308
+ }
309
+ if (filter?.minAmount !== undefined) {
310
+ views = views.filter((view) => view.amount >= filter.minAmount!);
311
+ }
312
+ if (filter?.maxAmount !== undefined) {
313
+ views = views.filter((view) => view.amount <= filter.maxAmount!);
314
+ }
315
+ if (filter?.searchTerm) {
316
+ const term = filter.searchTerm.toLowerCase();
317
+ views = views.filter(
318
+ (view) =>
319
+ view.description.toLowerCase().includes(term) ||
320
+ view.reference.toLowerCase().includes(term),
321
+ );
322
+ }
323
+ return views;
223
324
  }
224
325
 
225
326
  /**
226
- * Get transactions for specific account
327
+ * Get per-line transaction views for one account.
227
328
  */
228
329
  public async getAccountTransactions(
229
330
  accountNumber: string,
230
- ): Promise<Transaction[]> {
331
+ ): Promise<ITransactionView[]> {
231
332
  this.ensureInitialized();
232
- return await this.chartOfAccounts.getAccountTransactions(accountNumber);
333
+ return accountLineViews(accountNumber, this.currentSKRType!);
233
334
  }
234
335
 
235
336
  /**
236
- * Reverse a transaction
337
+ * Reverse via Storno — the original entry is never mutated (GoBD).
237
338
  */
238
- public async reverseTransaction(transactionId: string): Promise<Transaction> {
339
+ public async reverseTransaction(transactionId: string): Promise<ITransactionView> {
239
340
  this.ensureInitialized();
240
- return await this.chartOfAccounts.reverseTransaction(transactionId);
341
+ const entry = await (JournalEntry as any).getInstance({ id: transactionId });
342
+ if (!entry || entry.schemaVersion !== 2) {
343
+ throw new Error(`Transaction ${transactionId} not found`);
344
+ }
345
+ const storno = await this.journalPoster.postStorno(entry);
346
+ return entryToTransactionView(storno as never)!;
241
347
  }
242
348
 
243
349
  /**
244
- * Reverse a journal entry
350
+ * Reverse a journal entry via Storno.
245
351
  */
246
352
  public async reverseJournalEntry(journalId: string): Promise<JournalEntry> {
247
353
  this.ensureInitialized();
248
- if (!this.ledger) throw new Error('Ledger not initialized');
249
- return await this.ledger.reverseJournalEntry(journalId);
354
+ const entry = await (JournalEntry as any).getInstance({ id: journalId });
355
+ if (!entry || entry.schemaVersion !== 2) {
356
+ throw new Error(`Journal entry ${journalId} not found`);
357
+ }
358
+ return this.journalPoster.postStorno(entry);
250
359
  }
251
360
 
252
361
  // ========== Reporting ==========
@@ -315,50 +424,166 @@ export class SkrApi {
315
424
  }
316
425
 
317
426
  /**
318
- * Export to DATEV format
427
+ * Export to DATEV format (EXTF 700 Buchungsstapel over v2 entries).
428
+ * For a fully parameterized export use exportDatevBuchungsstapel().
319
429
  */
320
430
  public async exportToDATEV(params?: IReportParams): Promise<string> {
321
431
  this.ensureInitialized();
322
- if (!this.reports) throw new Error('Reports not initialized');
323
- return await this.reports.exportToDATEV(params);
432
+ const dateFrom = params?.dateFrom ?? new Date('1970-01-01T00:00:00.000Z');
433
+ const dateTo = params?.dateTo ?? new Date('2999-12-31T00:00:00.000Z');
434
+ const { csv } = await this.exportDatevBuchungsstapel({
435
+ dateFrom,
436
+ dateTo,
437
+ consultantNumber: 0,
438
+ clientNumber: 0,
439
+ fiscalYearStart: new Date(Date.UTC(dateFrom.getUTCFullYear(), 0, 1)),
440
+ label: 'Buchungsstapel',
441
+ });
442
+ return csv;
324
443
  }
325
444
 
326
445
  // ========== Period Management ==========
327
446
 
328
447
  /**
329
- * Close accounting period
448
+ * Close accounting period: post P&L-zeroing entries (v2) into the period.
449
+ * Locking is a separate deliberate act — call festschreibePeriod() after.
330
450
  */
331
451
  public async closePeriod(
332
452
  period: string,
333
- closingAccountNumber?: string,
453
+ closingAccountNumber: string = '9400',
334
454
  ): Promise<JournalEntry[]> {
335
455
  this.ensureInitialized();
336
- if (!this.ledger) throw new Error('Ledger not initialized');
337
- return await this.ledger.closeAccountingPeriod(
338
- period,
339
- closingAccountNumber,
340
- );
456
+ const [year, month] = period.split('-').map((part) => Number.parseInt(part, 10));
457
+ const dateFrom = new Date(Date.UTC(year, month - 1, 1));
458
+ const dateTo = new Date(Date.UTC(year, month, 0, 23, 59, 59, 999));
459
+ const trialBalance = await this.getTrialBalanceCents({ dateFrom, dateTo });
460
+
461
+ const revenueLines: core.IJournalLineDraft[] = [];
462
+ const expenseLines: core.IJournalLineDraft[] = [];
463
+ for (const balance of trialBalance.accounts) {
464
+ const account = await Account.getAccountByNumber(
465
+ balance.accountNumber,
466
+ this.currentSKRType!,
467
+ );
468
+ if (!account) continue;
469
+ if (account.accountType === 'revenue') {
470
+ const net = balance.creditCents - balance.debitCents;
471
+ if (net !== 0) {
472
+ revenueLines.push({
473
+ accountNumber: balance.accountNumber,
474
+ counterAccount: closingAccountNumber,
475
+ side: net > 0 ? 'debit' : 'credit',
476
+ amountCents: Math.abs(net),
477
+ buKey: '40',
478
+ buchungstext: `Abschluss ${period}`,
479
+ });
480
+ }
481
+ } else if (account.accountType === 'expense') {
482
+ const net = balance.debitCents - balance.creditCents;
483
+ if (net !== 0) {
484
+ expenseLines.push({
485
+ accountNumber: balance.accountNumber,
486
+ counterAccount: closingAccountNumber,
487
+ side: net > 0 ? 'credit' : 'debit',
488
+ amountCents: Math.abs(net),
489
+ buKey: '40',
490
+ buchungstext: `Abschluss ${period}`,
491
+ });
492
+ }
493
+ }
494
+ }
495
+
496
+ const entries: JournalEntry[] = [];
497
+ const closeDraft = async (
498
+ lines: core.IJournalLineDraft[],
499
+ reference: string,
500
+ ): Promise<void> => {
501
+ if (lines.length === 0) return;
502
+ const debitSum = lines
503
+ .filter((line) => line.side === 'debit')
504
+ .reduce((sum, line) => sum + line.amountCents, 0);
505
+ const creditSum = lines
506
+ .filter((line) => line.side === 'credit')
507
+ .reduce((sum, line) => sum + line.amountCents, 0);
508
+ const net = debitSum - creditSum;
509
+ if (net !== 0) {
510
+ lines.push({
511
+ accountNumber: closingAccountNumber,
512
+ counterAccount: lines[0].accountNumber,
513
+ side: net > 0 ? 'credit' : 'debit',
514
+ amountCents: Math.abs(net),
515
+ buKey: '',
516
+ buchungstext: `Abschluss ${period}`,
517
+ });
518
+ }
519
+ entries.push(
520
+ await this.journalPoster.postDraft({
521
+ date: dateTo,
522
+ description: `Periodenabschluss ${period}`,
523
+ reference,
524
+ skrType: this.currentSKRType!,
525
+ currency: 'EUR',
526
+ lines,
527
+ }),
528
+ );
529
+ };
530
+ await closeDraft(revenueLines, `CLOSE-REV-${period}`);
531
+ await closeDraft(expenseLines, `CLOSE-EXP-${period}`);
532
+ return entries;
341
533
  }
342
534
 
343
535
  /**
344
- * Get account balance
536
+ * Get account balance (aggregated from v2 entries, exact cents inside).
345
537
  */
346
538
  public async getAccountBalance(
347
539
  accountNumber: string,
348
540
  asOfDate?: Date,
349
- ): Promise<any> {
541
+ ): Promise<IAccountBalance> {
350
542
  this.ensureInitialized();
351
- if (!this.ledger) throw new Error('Ledger not initialized');
352
- return await this.ledger.getAccountBalance(accountNumber, asOfDate);
543
+ const account = await Account.getAccountByNumber(
544
+ accountNumber,
545
+ this.currentSKRType!,
546
+ );
547
+ if (!account) {
548
+ throw new Error(`Account ${accountNumber} not found`);
549
+ }
550
+ const balance = await this.getAccountBalanceCents(accountNumber, {
551
+ dateTo: asOfDate,
552
+ });
553
+ const signed =
554
+ account.accountType === 'asset' || account.accountType === 'expense'
555
+ ? balance.balanceCents
556
+ : -balance.balanceCents;
557
+ return {
558
+ accountNumber,
559
+ debitTotal: balance.debitCents / 100,
560
+ creditTotal: balance.creditCents / 100,
561
+ balance: signed / 100,
562
+ lastUpdated: new Date(),
563
+ };
353
564
  }
354
565
 
355
566
  /**
356
- * Recalculate all account balances
567
+ * Refresh the (informational) balance caches on Account documents from the
568
+ * aggregated v2 ledger.
357
569
  */
358
570
  public async recalculateBalances(): Promise<void> {
359
571
  this.ensureInitialized();
360
- if (!this.ledger) throw new Error('Ledger not initialized');
361
- await this.ledger.recalculateAllBalances();
572
+ const trialBalance = await this.getTrialBalanceCents();
573
+ for (const balance of trialBalance.accounts) {
574
+ const account = await Account.getAccountByNumber(
575
+ balance.accountNumber,
576
+ this.currentSKRType!,
577
+ );
578
+ if (!account) continue;
579
+ account.debitTotal = balance.debitCents / 100;
580
+ account.creditTotal = balance.creditCents / 100;
581
+ account.balance =
582
+ account.accountType === 'asset' || account.accountType === 'expense'
583
+ ? balance.balanceCents / 100
584
+ : -balance.balanceCents / 100;
585
+ await account.save();
586
+ }
362
587
  }
363
588
 
364
589
  // ========== Import/Export ==========
@@ -641,6 +866,166 @@ export class SkrApi {
641
866
 
642
867
  // ========== Utility Methods ==========
643
868
 
869
+ // ========== v2 Ledger (hashed, gapless, festschreibbar) ==========
870
+
871
+ /**
872
+ * Default account policy for the active chart.
873
+ */
874
+ public getDefaultPolicy(): core.IAccountPolicy {
875
+ this.ensureInitialized();
876
+ return core.getDefaultPolicy(this.currentSKRType!);
877
+ }
878
+
879
+ /**
880
+ * Post a validated journal draft atomically (v2 path: exact cents,
881
+ * gapless sequence, hash chain). Use the recipes in `core` to build drafts.
882
+ */
883
+ public async postDraft(
884
+ draft: core.IJournalDraft,
885
+ options?: IPostDraftOptions,
886
+ ): Promise<JournalEntry> {
887
+ this.ensureInitialized();
888
+ return this.journalPoster.postDraft(draft, {
889
+ policy: options?.policy ?? this.getDefaultPolicy(),
890
+ ...options,
891
+ });
892
+ }
893
+
894
+ /**
895
+ * Reverse a hashed entry via Storno — the original is never mutated.
896
+ */
897
+ public async postStorno(
898
+ entry: JournalEntry,
899
+ options?: IPostDraftOptions & { date?: Date; description?: string },
900
+ ): Promise<JournalEntry> {
901
+ this.ensureInitialized();
902
+ return this.journalPoster.postStorno(entry, {
903
+ policy: options?.policy ?? this.getDefaultPolicy(),
904
+ ...options,
905
+ });
906
+ }
907
+
908
+ /**
909
+ * Festschreibung: finalize all posted v2 entries of a period and lock it
910
+ * against further postings (GoBD).
911
+ */
912
+ public async festschreibePeriod(
913
+ period: string,
914
+ lockedBy?: string,
915
+ ): Promise<{ finalizedEntries: number }> {
916
+ this.ensureInitialized();
917
+ return festschreibePeriod({
918
+ skrType: this.currentSKRType!,
919
+ period,
920
+ lockedBy,
921
+ });
922
+ }
923
+
924
+ public async getPeriodState(period: string): Promise<ILedgerPeriodState | null> {
925
+ this.ensureInitialized();
926
+ return getPeriodState(this.currentSKRType!, period);
927
+ }
928
+
929
+ /**
930
+ * Recompute the whole hash chain for a fiscal year and compare it against
931
+ * the stored chain head — the audit-trail integrity check.
932
+ */
933
+ public async verifyAuditChain(fiscalYear: number): Promise<IChainVerificationResult> {
934
+ this.ensureInitialized();
935
+ return verifyHashChain(this.currentSKRType!, fiscalYear);
936
+ }
937
+
938
+ /**
939
+ * Exact trial balance in integer cents, aggregated from v2 entries.
940
+ */
941
+ public async getTrialBalanceCents(
942
+ options?: IBalanceQueryOptions,
943
+ ): Promise<ITrialBalanceCents> {
944
+ this.ensureInitialized();
945
+ return aggregateTrialBalance(this.currentSKRType!, options);
946
+ }
947
+
948
+ public async getAccountBalanceCents(
949
+ accountNumber: string,
950
+ options?: IBalanceQueryOptions,
951
+ ): Promise<IAccountBalanceCents> {
952
+ this.ensureInitialized();
953
+ return aggregateAccountBalance(accountNumber, this.currentSKRType!, options);
954
+ }
955
+
956
+ /**
957
+ * DATEV Buchungsstapel (EXTF 700 / v13) over the v2 entries of a date
958
+ * range. Returns the CSV string (persist as CP1252!) and filename.
959
+ */
960
+ public async exportDatevBuchungsstapel(options: {
961
+ dateFrom: Date;
962
+ dateTo: Date;
963
+ consultantNumber: number;
964
+ clientNumber: number;
965
+ fiscalYearStart: Date;
966
+ label?: string;
967
+ festschreibung?: boolean;
968
+ createdAt?: Date;
969
+ }): Promise<{ csv: string; filename: string; rowCount: number }> {
970
+ this.ensureInitialized();
971
+ const collection = (await import('./skr.database.js'))
972
+ .getDbSync()
973
+ .mongoDb.collection('JournalEntry');
974
+ const docs = await collection
975
+ .find({
976
+ skrType: this.currentSKRType,
977
+ schemaVersion: 2,
978
+ status: { $in: ['posted', 'finalized'] },
979
+ date: { $gte: options.dateFrom, $lte: options.dateTo },
980
+ })
981
+ .sort({ sequenceNumber: 1 })
982
+ .toArray();
983
+ const drafts: core.IJournalDraft[] = docs.map((doc) => ({
984
+ date: doc.date as Date,
985
+ description: doc.description as string,
986
+ reference: doc.reference as string,
987
+ skrType: doc.skrType as TSKRType,
988
+ currency: 'EUR',
989
+ taxScenario: doc.taxScenario as core.TTaxScenario | undefined,
990
+ lines: (doc.lines as core.IJournalLineDraft[]).map((line) => ({
991
+ accountNumber: line.accountNumber,
992
+ counterAccount: line.counterAccount ?? '',
993
+ side: (line.side ?? 'debit') as 'debit' | 'credit',
994
+ amountCents: line.amountCents ?? 0,
995
+ buKey: (line.buKey ?? '') as core.TBuKey,
996
+ buchungstext: line.buchungstext ?? '',
997
+ belegfeld1: line.belegfeld1,
998
+ costCenter1: line.costCenter1,
999
+ docLink: line.docLink,
1000
+ })),
1001
+ }));
1002
+ const rows = core.journalDraftsToDatevRows(drafts, {
1003
+ festschreibung: options.festschreibung,
1004
+ });
1005
+ const result = core.writeExtfBuchungsstapel(rows, {
1006
+ createdAt: options.createdAt ?? new Date(),
1007
+ consultantNumber: options.consultantNumber,
1008
+ clientNumber: options.clientNumber,
1009
+ fiscalYearStart: options.fiscalYearStart,
1010
+ dateFrom: options.dateFrom,
1011
+ dateTo: options.dateTo,
1012
+ label: options.label ?? `Buchungsstapel ${options.dateFrom.toISOString().slice(0, 10)}`,
1013
+ festschreibung: options.festschreibung ?? false,
1014
+ });
1015
+ return { ...result, rowCount: rows.length };
1016
+ }
1017
+
1018
+ /**
1019
+ * Run the idempotent v1 -> v2 data migration (cents backfill, journal
1020
+ * unification, hash-chain assignment). Persist the returned report with
1021
+ * your records (Verfahrensdokumentation artifact).
1022
+ */
1023
+ public async migrateToV2(): Promise<import('./skr.migrate.js').IMigrationReport> {
1024
+ this.ensureInitialized();
1025
+ const { migrateToV2 } = await import('./skr.migrate.js');
1026
+ return migrateToV2();
1027
+ }
1028
+
644
1029
  /**
645
1030
  * Get current SKR type
646
1031
  */
@@ -670,10 +1055,11 @@ export class SkrApi {
670
1055
  /**
671
1056
  * Get unbalanced transactions (for audit)
672
1057
  */
673
- public async getUnbalancedTransactions(): Promise<Transaction[]> {
1058
+ public async getUnbalancedTransactions(): Promise<ITransactionView[]> {
674
1059
  this.ensureInitialized();
675
- if (!this.ledger) throw new Error('Ledger not initialized');
676
- return await this.ledger.getUnbalancedTransactions();
1060
+ // v2 posting enforces exact balance atomically — imbalance is impossible;
1061
+ // chain integrity is checked via verifyAuditChain()
1062
+ return [];
677
1063
  }
678
1064
 
679
1065
  /**
@@ -695,10 +1081,10 @@ export class SkrApi {
695
1081
  */
696
1082
  public async postBatchTransactions(
697
1083
  transactions: ITransactionData[],
698
- ): Promise<Transaction[]> {
1084
+ ): Promise<ITransactionView[]> {
699
1085
  this.ensureInitialized();
700
1086
 
701
- const results: Transaction[] = [];
1087
+ const results: ITransactionView[] = [];
702
1088
  const errors: Array<{ index: number; error: string }> = [];
703
1089
 
704
1090
  for (let i = 0; i < transactions.length; i++) {
@@ -798,7 +1184,7 @@ export class SkrApi {
798
1184
  pageSize: number = 50,
799
1185
  filter?: ITransactionFilter,
800
1186
  ): Promise<{
801
- data: Transaction[];
1187
+ data: ITransactionView[];
802
1188
  total: number;
803
1189
  page: number;
804
1190
  pageSize: number;