@fin.cx/skr 2.0.1 → 3.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fin.cx/skr",
3
- "version": "2.0.1",
3
+ "version": "3.1.0",
4
4
  "description": "SKR03 and SKR04 German accounting standards for double-entry bookkeeping",
5
5
  "main": "dist_ts/index.js",
6
6
  "typings": "dist_ts/index.d.ts",
@@ -34,6 +34,7 @@
34
34
  "node-forge": "^1.4.0"
35
35
  },
36
36
  "devDependencies": {
37
+ "@fin.cx/chartdata": "^2.1.0",
37
38
  "@git.zone/tsbuild": "^4.4.2",
38
39
  "@git.zone/tsrun": "^2.0.5",
39
40
  "@git.zone/tstest": "^3.6.6",
package/readme.hints.md CHANGED
@@ -14,6 +14,16 @@
14
14
  `readme.plan.md`; M0 = skr v2 core hardening. @fin.cx/calculation ships the cents
15
15
  module (TCents) that skr v2 money handling builds on.
16
16
 
17
+ ## Chart rebase (2026-07-09, v3)
18
+
19
+ - The built-in SKR03/04 charts are now GENERATED from @fin.cx/chartdata >= 2.1.0
20
+ (`tsx tools/generate-chart-data.ts`) — the v1 hand-written charts were an
21
+ invented hybrid layout and are gone. Notable corrections: SKR03 revenue lives
22
+ in class 8 (8400/8300), expenses in class 4 (default 4900 — 4980 is
23
+ Mietleasing!), VSt/USt rate accounts fixed (1571/1771 = 7 %); SKR04 bank is
24
+ 1800, Kasse 1600, controls 1200/3300, default expense 6300.
25
+ - Account.isAutomaticAccount: SKR04 controls corrected to 1200/3300.
26
+
17
27
  ## M0 Phase 2 — stateless core (2026-07-09)
18
28
 
19
29
  - `ts/core/` is the pure booking core (exported as `core` from the package index):
package/readme.md CHANGED
@@ -78,7 +78,7 @@ await api.initialize('SKR03');
78
78
  await api.postTransaction({
79
79
  date: new Date(),
80
80
  debitAccount: '1200',
81
- creditAccount: '4000',
81
+ creditAccount: '8400',
82
82
  amount: 1000,
83
83
  description: 'Test sale',
84
84
  reference: 'INV-001',
@@ -96,16 +96,16 @@ await api.close();
96
96
 
97
97
  `initialize('SKR03')` loads the process-oriented chart.
98
98
 
99
- - Class 4: operating income
100
- - Class 5: material costs
101
- - Class 6: personnel costs
102
- - Class 7: other operating expenses
99
+ - Class 3: Wareneingang
100
+ - Class 4: betriebliche Aufwendungen
101
+ - Class 8: Erlöse
102
+ - Class 9: Vortrags- und Abschlusskonten
103
103
 
104
104
  `initialize('SKR04')` loads the financial-statement-oriented chart.
105
105
 
106
- - Class 2 and 3: expenses
107
- - Class 4 and 5: revenues
108
- - Class 8: reserved as `frei` for custom use
106
+ - Class 4: betriebliche Erträge
107
+ - Class 5 and 6: betriebliche Aufwendungen
108
+ - Class 2: Eigenkapital, Class 3: Fremdkapital
109
109
 
110
110
  The test suite exercises both variants and includes full Jahresabschluss scenarios for each.
111
111
 
package/readme.plan.md CHANGED
@@ -94,6 +94,35 @@ After **@fin.cx/calculation v1.1.0** (cents module — RELEASED 2026-07-09 ✓).
94
94
 
95
95
  ### M-A — `@fin.cx/chartdata` (new pkg; parallel to M0)
96
96
 
97
+ **Status 2026-07-09: A1 seed COMPLETE locally** (repo at
98
+ `/mnt/data/foss.global/fin.cx/chartdata`, committed, 12/12 tests): year-keyed
99
+ SKR03/04 2026 datasets (~125 accounts each incl. all fiscally-relevant VAT/
100
+ Skonto/igE/§13b/EUSt accounts missing from skr v1), Automatik functions +
101
+ allowed BU keys, account→UStVA-Kennzahl mappings, scenario Kennzahlen table
102
+ (89/93, 46/47, 84/85, 41, 43, 81, 86, 60, 66/61/67/62), coverage introspection,
103
+ provenance per record, reproducible seed pipeline in tools/.
104
+ **RELEASED as @fin.cx/chartdata v1.1.0** (2026-07-09, verdaccio + npmjs).
105
+ **A1 REBASE COMPLETE (2026-07-09):** chartdata v2.1.0 = curated oracle-verified
106
+ datasets (91 SKR03 / 78 SKR04 accounts, verification-only oracle, no foreign
107
+ strings); skr v3.0.0 aligned: charts GENERATED from chartdata
108
+ (tools/generate-chart-data.ts), policies corrected (expense default 4900/6300,
109
+ SKR04 controls 1200/3300, bank 1800/Kasse 1600), all 98 tests remapped to real
110
+ DATEV numbers and green. A2 HGB §266/§275 mappings DONE
111
+ (chartdata v2.2.0: every non-statistical account carries hgb266Position or
112
+ hgb275Position, validated against the statute Gliederung trees in
113
+ chartdata.hgb.ts and verified against the oracle's position tags — 11
114
+ documented modeling exceptions where German textbook treatment differs, e.g.
115
+ USt liabilities in Passiva C.8). NEXT: A3 E-Bilanz taxonomy; D1 can now build
116
+ the §266/§275 Gliederung from chartdata.
117
+
118
+ **A1 cross-diff done (2026-07-09):** Odoo l10n_de oracle proves the skr-v1 seed
119
+ charts are an invented hybrid (~50/125 SKR03 + ~66/130 SKR04 divergences);
120
+ ALL fiscal A1 additions verified clean. Rate-label fixes released (v1.1.1:
121
+ 1570/1571/1770/1771). NEXT: A1 seed-base rebase (clean-room base set, remove
122
+ invented blocks, align skr built-in charts + tests — coupled change), then A2
123
+ (HGB mappings; Odoo tags as verification oracle), then A3. Details:
124
+ chartdata/tools/a1-crossdiff-report.md.
125
+
97
126
  Year-versioned full SKR03/04: account master, Automatikkonto functions + allowed BU keys,
98
127
  UStVA-Kennzahl / HGB §266/§275 / E-Bilanz (de-gaap-ci) mappings. Pure data + lookup API;
99
128
  `getMappingCoverage()` fails loudly. Pipeline in unpublished tools/ (public sources,
@@ -104,6 +133,16 @@ oracle. Legal review before public MIT release.
104
133
 
105
134
  ### M-B — Subledgers + reconciliation
106
135
 
136
+ **B1 status 2026-07-09: package layer RELEASED** — @fin.cx/camt v1.1.0
137
+ (CAMT.052/053/054, exact cents, SEPA extraction, statement-internal balance
138
+ check) and @fin.cx/bankrec v1.1.0 (canonical signed-cent transactions, camt +
139
+ mt940 adapters, cross-statement continuity control, confidence-scored matching
140
+ engine: direction filter, Skonto window, tie-forces-review, no double
141
+ settlement). @fin.cx/mt940parser v1.1.1 published for the first time
142
+ (modernized: smartconfig v2, direct npm publish, pnpm pinned). Remaining B1:
143
+ regression run against real TVC bank statements (user to provide), then wire
144
+ auto/review/unmatched decisions to skr recipes in the consuming app.
145
+
107
146
  B1 `@fin.cx/camt` (CAMT.052/053/054; MT940 = legacy) + `@fin.cx/bankrec` (canonical
108
147
  decimal bank txn with SEPA refs, adapters, balance-chain continuity, confidence-scored
109
148
  matching, auto-post above threshold, review queue below). B2 OPOS inside skr (ts/opos/:
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@fin.cx/skr',
6
- version: '2.0.1',
6
+ version: '3.1.0',
7
7
  description: 'SKR03 and SKR04 German accounting standards for double-entry bookkeeping'
8
8
  }
@@ -80,7 +80,7 @@ export const SKR03_DEFAULT_POLICY: IAccountPolicy = {
80
80
  export: '8120',
81
81
  reverseCharge: '8337',
82
82
  },
83
- expenseDefault: '4980',
83
+ expenseDefault: '4900',
84
84
  skonto: {
85
85
  receivedStandard: '3736',
86
86
  receivedReduced: '3731', // PROVISIONAL
@@ -119,7 +119,7 @@ export const SKR04_DEFAULT_POLICY: IAccountPolicy = {
119
119
  export: '4120',
120
120
  reverseCharge: '4337',
121
121
  },
122
- expenseDefault: '6980',
122
+ expenseDefault: '6300',
123
123
  skonto: {
124
124
  receivedStandard: '5736',
125
125
  receivedReduced: '5731', // PROVISIONAL
package/ts/index.ts CHANGED
@@ -19,6 +19,13 @@ export type {
19
19
  ITrialBalanceCents,
20
20
  IBalanceQueryOptions,
21
21
  } from './skr.balances.js';
22
+ export {
23
+ listOpenItems,
24
+ getAging,
25
+ getControlTotals,
26
+ setOposMeta,
27
+ } from './skr.opos.js';
28
+ export type { IOpenItem, IAgingBucket, IControlTotals, TOposKind } from './skr.opos.js';
22
29
  export { verifyHashChain } from './skr.verify.js';
23
30
  export type { IChainVerificationResult } from './skr.verify.js';
24
31
  export { migrateToV2 } from './skr.migrate.js';
package/ts/skr.api.ts CHANGED
@@ -1026,6 +1026,84 @@ export class SkrApi {
1026
1026
  return migrateToV2();
1027
1027
  }
1028
1028
 
1029
+ // ========== OPOS (open items) ==========
1030
+
1031
+ public async listOpenItems(options?: {
1032
+ kind?: import('./skr.opos.js').TOposKind;
1033
+ accountNumber?: string;
1034
+ asOf?: Date;
1035
+ includeSettled?: boolean;
1036
+ }): Promise<import('./skr.opos.js').IOpenItem[]> {
1037
+ this.ensureInitialized();
1038
+ const { listOpenItems } = await import('./skr.opos.js');
1039
+ return listOpenItems(this.currentSKRType!, this.getDefaultPolicy(), options);
1040
+ }
1041
+
1042
+ public async getOposAging(
1043
+ kind: import('./skr.opos.js').TOposKind,
1044
+ asOf: Date,
1045
+ bucketDays?: number[],
1046
+ ): Promise<import('./skr.opos.js').IAgingBucket[]> {
1047
+ this.ensureInitialized();
1048
+ const { getAging } = await import('./skr.opos.js');
1049
+ return getAging(this.currentSKRType!, this.getDefaultPolicy(), kind, asOf, bucketDays);
1050
+ }
1051
+
1052
+ public async getControlTotals(asOf?: Date): Promise<import('./skr.opos.js').IControlTotals> {
1053
+ this.ensureInitialized();
1054
+ const { getControlTotals } = await import('./skr.opos.js');
1055
+ return getControlTotals(this.currentSKRType!, this.getDefaultPolicy(), asOf);
1056
+ }
1057
+
1058
+ public async setDunning(params: {
1059
+ accountNumber: string;
1060
+ reference: string;
1061
+ dunningLevel?: number;
1062
+ dueDate?: string;
1063
+ }): Promise<void> {
1064
+ this.ensureInitialized();
1065
+ const { setOposMeta } = await import('./skr.opos.js');
1066
+ return setOposMeta({ skrType: this.currentSKRType!, ...params });
1067
+ }
1068
+
1069
+ /**
1070
+ * Settle an open item with a bank payment (Skonto and small-difference
1071
+ * write-offs via the core recipe); ordinary v2 posting, fully audited.
1072
+ */
1073
+ public async settleOpenItem(params: {
1074
+ item: import('./skr.opos.js').IOpenItem;
1075
+ paidCents: number;
1076
+ date: Date;
1077
+ bankAccount?: string;
1078
+ skonto?: { ratePercent: number };
1079
+ diffToleranceCents?: number;
1080
+ description?: string;
1081
+ }): Promise<JournalEntry> {
1082
+ this.ensureInitialized();
1083
+ // a plain underpayment (no Skonto, beyond tolerance) is a PARTIAL
1084
+ // settlement: book only the paid amount and leave the rest open
1085
+ const shortfall = params.item.openCents - params.paidCents;
1086
+ const isPartial =
1087
+ !params.skonto && shortfall > (params.diffToleranceCents ?? 0);
1088
+ const draft = core.bookBankPayment(
1089
+ {
1090
+ date: params.date,
1091
+ direction: params.item.kind === 'debtor' ? 'incoming' : 'outgoing',
1092
+ bankAccount: params.bankAccount,
1093
+ counterpartyAccount: params.item.accountNumber,
1094
+ expectedCents: isPartial ? params.paidCents : params.item.openCents,
1095
+ paidCents: params.paidCents,
1096
+ skonto: params.skonto,
1097
+ diffToleranceCents: params.diffToleranceCents,
1098
+ description:
1099
+ params.description ?? `Ausgleich ${params.item.reference || params.item.accountNumber}`,
1100
+ reference: params.item.reference || undefined,
1101
+ },
1102
+ this.getDefaultPolicy(),
1103
+ );
1104
+ return this.journalPoster.postDraft(draft, { policy: this.getDefaultPolicy() });
1105
+ }
1106
+
1029
1107
  /**
1030
1108
  * Get current SKR type
1031
1109
  */
@@ -202,14 +202,13 @@ export class Account extends SmartDataDbDoc {
202
202
  * Automatic accounts like 1400/1600 cannot be posted to directly
203
203
  */
204
204
  public static isAutomaticAccount(accountNumber: string, skrType: TSKRType): boolean {
205
+ // Sammelkonten (control accounts) must be addressed via personal accounts
205
206
  // SKR03: 1400 (Forderungen), 1600 (Verbindlichkeiten)
206
- // SKR04: 1400 (Forderungen), 1600 (Verbindlichkeiten)
207
- // Note: In SKR04, 3300 is "Fahrzeugkosten" (vehicle costs), NOT an automatic account
207
+ // SKR04: 1200 (Forderungen), 3300 (Verbindlichkeiten)
208
208
  if (skrType === 'SKR03') {
209
209
  return accountNumber === '1400' || accountNumber === '1600';
210
- } else {
211
- return accountNumber === '1400' || accountNumber === '1600';
212
210
  }
211
+ return accountNumber === '1200' || accountNumber === '3300';
213
212
  }
214
213
 
215
214
  /**
package/ts/skr.opos.ts ADDED
@@ -0,0 +1,258 @@
1
+ /**
2
+ * OPOS (Offene-Posten-Buchhaltung): open-item accounting over the v2 ledger.
3
+ *
4
+ * Open items are DERIVED from the hashed journal — no separate bookkeeping
5
+ * state that could drift. A line on a personal account (debtors 10000-69999,
6
+ * creditors 70000-99999) belongs to the open item keyed by
7
+ * (account, belegfeld1); the item is open while its signed sum is non-zero.
8
+ * Settling an item is ordinary posting (core.bookBankPayment + JournalPoster),
9
+ * so Festschreibung and the audit chain apply unchanged.
10
+ *
11
+ * Only dunning metadata lives outside the ledger (OposMeta collection) —
12
+ * process state, not bookkeeping.
13
+ */
14
+ import * as core from './core/index.js';
15
+ import { getDbSync } from './skr.database.js';
16
+ import { JOURNAL_ENTRY_COLLECTION } from './skr.period.js';
17
+ import type { TSKRType } from './skr.types.js';
18
+
19
+ export const OPOS_META_COLLECTION = 'OposMeta';
20
+
21
+ export type TOposKind = 'debtor' | 'creditor';
22
+
23
+ export interface IOpenItem {
24
+ accountNumber: string;
25
+ kind: TOposKind;
26
+ /** belegfeld1 of the originating lines (usually the invoice number) */
27
+ reference: string;
28
+ /** original claim (sum of the claim-side lines) */
29
+ invoiceCents: number;
30
+ /** settled so far (sum of the settlement-side lines) */
31
+ clearedCents: number;
32
+ /** invoiceCents - clearedCents; > 0 means open */
33
+ openCents: number;
34
+ firstDate: string;
35
+ lastDate: string;
36
+ entryIds: string[];
37
+ dunningLevel: number;
38
+ dueDate?: string;
39
+ }
40
+
41
+ export interface IAgingBucket {
42
+ label: string;
43
+ maxAgeDays: number | null;
44
+ items: IOpenItem[];
45
+ totalCents: number;
46
+ }
47
+
48
+ export interface IControlTotals {
49
+ debtorsCents: number;
50
+ creditorsCents: number;
51
+ debtorAccounts: number;
52
+ creditorAccounts: number;
53
+ }
54
+
55
+ function kindOf(accountNumber: string, policy: core.IAccountPolicy): TOposKind | null {
56
+ if (core.isDebtorAccount(policy, accountNumber)) return 'debtor';
57
+ if (core.isCreditorAccount(policy, accountNumber)) return 'creditor';
58
+ return null;
59
+ }
60
+
61
+ interface ILineRow {
62
+ entryId: string;
63
+ accountNumber: string;
64
+ belegfeld1?: string;
65
+ side: 'debit' | 'credit';
66
+ amountCents: number;
67
+ date: Date;
68
+ }
69
+
70
+ async function loadPersonalLines(
71
+ skrType: TSKRType,
72
+ policy: core.IAccountPolicy,
73
+ accountNumber?: string,
74
+ asOf?: Date,
75
+ ): Promise<ILineRow[]> {
76
+ const match: Record<string, unknown> = {
77
+ skrType,
78
+ schemaVersion: 2,
79
+ status: { $in: ['posted', 'finalized'] },
80
+ };
81
+ if (asOf) match.date = { $lte: asOf };
82
+ if (accountNumber) match['lines.accountNumber'] = accountNumber;
83
+
84
+ const docs = await getDbSync()
85
+ .mongoDb.collection(JOURNAL_ENTRY_COLLECTION)
86
+ .find(match)
87
+ .sort({ date: 1, sequenceNumber: 1 })
88
+ .toArray();
89
+
90
+ const rows: ILineRow[] = [];
91
+ for (const doc of docs) {
92
+ for (const line of (doc.lines ?? []) as Array<Record<string, unknown>>) {
93
+ const account = line.accountNumber as string;
94
+ if (!kindOf(account, policy)) continue;
95
+ if (accountNumber && account !== accountNumber) continue;
96
+ rows.push({
97
+ entryId: doc.id as string,
98
+ accountNumber: account,
99
+ belegfeld1: (line.belegfeld1 as string) || (doc.reference as string) || '',
100
+ side: line.side as 'debit' | 'credit',
101
+ amountCents: (line.amountCents as number) ?? 0,
102
+ date: doc.date as Date,
103
+ });
104
+ }
105
+ }
106
+ return rows;
107
+ }
108
+
109
+ /**
110
+ * Derive open items. Debtors: claims are debits, settlements credits;
111
+ * creditors inverse. Items with openCents === 0 are settled and omitted
112
+ * unless includeSettled is set.
113
+ */
114
+ export async function listOpenItems(
115
+ skrType: TSKRType,
116
+ policy: core.IAccountPolicy,
117
+ options?: {
118
+ kind?: TOposKind;
119
+ accountNumber?: string;
120
+ asOf?: Date;
121
+ includeSettled?: boolean;
122
+ },
123
+ ): Promise<IOpenItem[]> {
124
+ const rows = await loadPersonalLines(skrType, policy, options?.accountNumber, options?.asOf);
125
+ const groups = new Map<string, IOpenItem>();
126
+
127
+ for (const row of rows) {
128
+ const kind = kindOf(row.accountNumber, policy)!;
129
+ if (options?.kind && kind !== options.kind) continue;
130
+ const key = `${row.accountNumber}:${row.belegfeld1}`;
131
+ let item = groups.get(key);
132
+ if (!item) {
133
+ item = {
134
+ accountNumber: row.accountNumber,
135
+ kind,
136
+ reference: row.belegfeld1 ?? '',
137
+ invoiceCents: 0,
138
+ clearedCents: 0,
139
+ openCents: 0,
140
+ firstDate: row.date.toISOString().slice(0, 10),
141
+ lastDate: row.date.toISOString().slice(0, 10),
142
+ entryIds: [],
143
+ dunningLevel: 0,
144
+ };
145
+ groups.set(key, item);
146
+ }
147
+ const isClaim = (kind === 'debtor') === (row.side === 'debit');
148
+ if (isClaim) item.invoiceCents += row.amountCents;
149
+ else item.clearedCents += row.amountCents;
150
+ item.lastDate = row.date.toISOString().slice(0, 10);
151
+ if (!item.entryIds.includes(row.entryId)) item.entryIds.push(row.entryId);
152
+ }
153
+
154
+ const meta = await getDbSync().mongoDb.collection(OPOS_META_COLLECTION).find({ skrType }).toArray();
155
+ const metaByKey = new Map(meta.map((m) => [`${m.accountNumber}:${m.reference}`, m]));
156
+
157
+ const items: IOpenItem[] = [];
158
+ for (const item of groups.values()) {
159
+ item.openCents = item.invoiceCents - item.clearedCents;
160
+ const extra = metaByKey.get(`${item.accountNumber}:${item.reference}`);
161
+ if (extra) {
162
+ item.dunningLevel = (extra.dunningLevel as number) ?? 0;
163
+ item.dueDate = extra.dueDate as string | undefined;
164
+ }
165
+ if (item.openCents !== 0 || options?.includeSettled) items.push(item);
166
+ }
167
+ return items.sort((a, b) => a.accountNumber.localeCompare(b.accountNumber) || a.firstDate.localeCompare(b.firstDate));
168
+ }
169
+
170
+ /**
171
+ * Aging list relative to asOf, bucketed by days since dueDate (falling back
172
+ * to the item's first posting date).
173
+ */
174
+ export async function getAging(
175
+ skrType: TSKRType,
176
+ policy: core.IAccountPolicy,
177
+ kind: TOposKind,
178
+ asOf: Date,
179
+ bucketDays: number[] = [30, 60, 90],
180
+ ): Promise<IAgingBucket[]> {
181
+ const items = await listOpenItems(skrType, policy, { kind, asOf });
182
+ const buckets: IAgingBucket[] = [
183
+ ...bucketDays.map((days, index) => ({
184
+ label: index === 0 ? `0-${days}` : `${bucketDays[index - 1] + 1}-${days}`,
185
+ maxAgeDays: days,
186
+ items: [] as IOpenItem[],
187
+ totalCents: 0,
188
+ })),
189
+ { label: `> ${bucketDays[bucketDays.length - 1]}`, maxAgeDays: null, items: [], totalCents: 0 },
190
+ ];
191
+ for (const item of items) {
192
+ const anchor = item.dueDate ?? item.firstDate;
193
+ const ageDays = Math.max(0, Math.floor((asOf.getTime() - new Date(anchor).getTime()) / 86_400_000));
194
+ const bucket =
195
+ buckets.find((candidate) => candidate.maxAgeDays !== null && ageDays <= candidate.maxAgeDays) ??
196
+ buckets[buckets.length - 1];
197
+ bucket.items.push(item);
198
+ bucket.totalCents += item.openCents;
199
+ }
200
+ return buckets;
201
+ }
202
+
203
+ /**
204
+ * Subledger control totals: the aggregated personal-account balances that a
205
+ * balance sheet reports as Forderungen (B.II.1) / Verbindlichkeiten (C.4)
206
+ * aus Lieferungen und Leistungen. Control accounts themselves are blocked for
207
+ * posting, so these aggregates ARE the control balances.
208
+ */
209
+ export async function getControlTotals(
210
+ skrType: TSKRType,
211
+ policy: core.IAccountPolicy,
212
+ asOf?: Date,
213
+ ): Promise<IControlTotals> {
214
+ const rows = await loadPersonalLines(skrType, policy, undefined, asOf);
215
+ let debtorsCents = 0;
216
+ let creditorsCents = 0;
217
+ const debtorAccounts = new Set<string>();
218
+ const creditorAccounts = new Set<string>();
219
+ for (const row of rows) {
220
+ const signed = row.side === 'debit' ? row.amountCents : -row.amountCents;
221
+ if (kindOf(row.accountNumber, policy) === 'debtor') {
222
+ debtorsCents += signed;
223
+ debtorAccounts.add(row.accountNumber);
224
+ } else {
225
+ creditorsCents -= signed; // creditors carry credit balances positive
226
+ creditorAccounts.add(row.accountNumber);
227
+ }
228
+ }
229
+ return {
230
+ debtorsCents,
231
+ creditorsCents,
232
+ debtorAccounts: debtorAccounts.size,
233
+ creditorAccounts: creditorAccounts.size,
234
+ };
235
+ }
236
+
237
+ /**
238
+ * Dunning metadata (process state, not bookkeeping — letters are out of
239
+ * scope; the level/due-date state machine is in).
240
+ */
241
+ export async function setOposMeta(params: {
242
+ skrType: TSKRType;
243
+ accountNumber: string;
244
+ reference: string;
245
+ dunningLevel?: number;
246
+ dueDate?: string;
247
+ }): Promise<void> {
248
+ const update: Record<string, unknown> = { updatedAt: new Date() };
249
+ if (params.dunningLevel !== undefined) update.dunningLevel = params.dunningLevel;
250
+ if (params.dueDate !== undefined) update.dueDate = params.dueDate;
251
+ await getDbSync()
252
+ .mongoDb.collection(OPOS_META_COLLECTION)
253
+ .updateOne(
254
+ { skrType: params.skrType, accountNumber: params.accountNumber, reference: params.reference },
255
+ { $set: update },
256
+ { upsert: true },
257
+ );
258
+ }