@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/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/core/core.accountpolicy.js +2 -2
- package/dist_ts/index.d.ts +2 -0
- package/dist_ts/index.js +2 -1
- package/dist_ts/skr.api.d.ts +29 -0
- package/dist_ts/skr.api.js +46 -1
- package/dist_ts/skr.classes.account.js +4 -6
- package/dist_ts/skr.opos.d.ts +80 -0
- package/dist_ts/skr.opos.js +182 -0
- package/dist_ts/skr03.data.d.ts +1 -16
- package/dist_ts/skr03.data.js +482 -720
- package/dist_ts/skr04.data.d.ts +1 -16
- package/dist_ts/skr04.data.js +404 -755
- package/package.json +2 -1
- package/readme.hints.md +10 -0
- package/readme.md +8 -8
- package/readme.plan.md +39 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/core/core.accountpolicy.ts +2 -2
- package/ts/index.ts +7 -0
- package/ts/skr.api.ts +78 -0
- package/ts/skr.classes.account.ts +3 -4
- package/ts/skr.opos.ts +258 -0
- package/ts/skr03.data.ts +471 -717
- package/ts/skr04.data.ts +406 -765
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fin.cx/skr",
|
|
3
|
-
"version": "
|
|
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: '
|
|
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
|
|
100
|
-
- Class
|
|
101
|
-
- Class
|
|
102
|
-
- Class
|
|
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
|
|
107
|
-
- Class
|
|
108
|
-
- Class
|
|
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/:
|
package/ts/00_commitinfo_data.ts
CHANGED
|
@@ -80,7 +80,7 @@ export const SKR03_DEFAULT_POLICY: IAccountPolicy = {
|
|
|
80
80
|
export: '8120',
|
|
81
81
|
reverseCharge: '8337',
|
|
82
82
|
},
|
|
83
|
-
expenseDefault: '
|
|
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: '
|
|
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:
|
|
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
|
+
}
|