@fin.cx/opendata 4.6.0 → 4.8.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 +6 -2
- package/dist_ts/00_commitinfo_data.js +3 -3
- package/dist_ts/financialstatements/amounts.d.ts +58 -0
- package/dist_ts/financialstatements/amounts.js +130 -0
- package/dist_ts/financialstatements/classes.financialstatementerror.d.ts +24 -0
- package/dist_ts/financialstatements/classes.financialstatementerror.js +10 -0
- package/dist_ts/financialstatements/constants.d.ts +16 -0
- package/dist_ts/financialstatements/constants.js +20 -0
- package/dist_ts/financialstatements/dates.d.ts +16 -0
- package/dist_ts/financialstatements/dates.js +72 -0
- package/dist_ts/financialstatements/html.d.ts +21 -0
- package/dist_ts/financialstatements/html.js +92 -0
- package/dist_ts/financialstatements/index.d.ts +6 -0
- package/dist_ts/financialstatements/index.js +11 -0
- package/dist_ts/financialstatements/interfaces.d.ts +258 -0
- package/dist_ts/financialstatements/interfaces.js +2 -0
- package/dist_ts/financialstatements/layout.d.ts +20 -0
- package/dist_ts/financialstatements/layout.html.d.ts +8 -0
- package/dist_ts/financialstatements/layout.html.js +125 -0
- package/dist_ts/financialstatements/layout.js +2 -0
- package/dist_ts/financialstatements/layout.pdf.d.ts +6 -0
- package/dist_ts/financialstatements/layout.pdf.js +229 -0
- package/dist_ts/financialstatements/mediatypes.d.ts +3 -0
- package/dist_ts/financialstatements/mediatypes.js +11 -0
- package/dist_ts/financialstatements/outline.d.ts +22 -0
- package/dist_ts/financialstatements/outline.js +26 -0
- package/dist_ts/financialstatements/parse.esef.d.ts +13 -0
- package/dist_ts/financialstatements/parse.esef.js +349 -0
- package/dist_ts/financialstatements/parse.hgb.d.ts +9 -0
- package/dist_ts/financialstatements/parse.hgb.js +1014 -0
- package/dist_ts/financialstatements/parse.statement.d.ts +8 -0
- package/dist_ts/financialstatements/parse.statement.js +25 -0
- package/dist_ts/financialstatements/plugins.d.ts +10 -0
- package/dist_ts/financialstatements/plugins.js +15 -0
- package/dist_ts/financialstatements/read.d.ts +10 -0
- package/dist_ts/financialstatements/read.js +113 -0
- package/dist_ts/handelsregister/classes.handelsregisterclient.d.ts +8 -2
- package/dist_ts/handelsregister/classes.handelsregisterclient.js +14 -5
- package/dist_ts/handelsregister/classes.handelsregistererror.d.ts +3 -1
- package/dist_ts/handelsregister/classes.handelsregistererror.js +1 -1
- package/dist_ts/handelsregister/classes.portalsession.d.ts +19 -3
- package/dist_ts/handelsregister/classes.portalsession.js +67 -9
- package/dist_ts/handelsregister/interfaces.d.ts +9 -2
- package/package.json +16 -6
- package/readme.md +86 -5
- package/ts/00_commitinfo_data.ts +2 -2
- package/ts/financialstatements/amounts.ts +181 -0
- package/ts/financialstatements/classes.financialstatementerror.ts +35 -0
- package/ts/financialstatements/constants.ts +24 -0
- package/ts/financialstatements/dates.ts +89 -0
- package/ts/financialstatements/html.ts +107 -0
- package/ts/financialstatements/index.ts +17 -0
- package/ts/financialstatements/interfaces.ts +268 -0
- package/ts/financialstatements/layout.html.ts +130 -0
- package/ts/financialstatements/layout.pdf.ts +268 -0
- package/ts/financialstatements/layout.ts +16 -0
- package/ts/financialstatements/mediatypes.ts +14 -0
- package/ts/financialstatements/outline.ts +44 -0
- package/ts/financialstatements/parse.esef.ts +394 -0
- package/ts/financialstatements/parse.hgb.ts +1235 -0
- package/ts/financialstatements/parse.statement.ts +26 -0
- package/ts/financialstatements/plugins.ts +29 -0
- package/ts/financialstatements/read.ts +135 -0
- package/ts/handelsregister/classes.handelsregisterclient.ts +18 -4
- package/ts/handelsregister/classes.handelsregistererror.ts +3 -0
- package/ts/handelsregister/classes.portalsession.ts +78 -9
- package/ts/handelsregister/interfaces.ts +9 -2
|
@@ -83,7 +83,10 @@ export interface IRegisterSearchResult {
|
|
|
83
83
|
portalNotice?: string;
|
|
84
84
|
/** epoch ms: when the result list arrived */
|
|
85
85
|
retrievedAt: number;
|
|
86
|
-
/**
|
|
86
|
+
/**
|
|
87
|
+
* requests of this call that count against the portal's limit of 60 per hour: 1, or 2 when the
|
|
88
|
+
* portal dropped the search without an answer and it was sent once more
|
|
89
|
+
*/
|
|
87
90
|
countedRequests: number;
|
|
88
91
|
notice: IStructuredContentNotice;
|
|
89
92
|
}
|
|
@@ -178,7 +181,11 @@ export interface IRegisterEntry {
|
|
|
178
181
|
structuredContent: IRegisterStructuredContent | null;
|
|
179
182
|
/** epoch ms: when the last answer of the portal arrived */
|
|
180
183
|
retrievedAt: number;
|
|
181
|
-
/**
|
|
184
|
+
/**
|
|
185
|
+
* requests of this call that count against the portal's limit: 2, or 1 without structured content;
|
|
186
|
+
* one more for the search or the retrieval when the portal dropped it without an answer and it was
|
|
187
|
+
* sent once more
|
|
188
|
+
*/
|
|
182
189
|
countedRequests: number;
|
|
183
190
|
notice: IStructuredContentNotice;
|
|
184
191
|
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fin.cx/opendata",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.8.0",
|
|
4
4
|
"private": false,
|
|
5
|
-
"description": "Open business and financial data for TypeScript: register lookups on the German register portal (handelsregister.de) without storage, the statutory public holidays of the German federal states, the base rate of § 247 BGB, the German consumer price index with the arithmetic of index clauses, and stock and crypto prices and fundamentals from several providers.",
|
|
5
|
+
"description": "Open business and financial data for TypeScript: register lookups on the German register portal (handelsregister.de) without storage, the statutory public holidays of the German federal states, the base rate of § 247 BGB, the German consumer price index with the arithmetic of index clauses, published financial statements read from their HTML, PDF or ESEF documents, and stock and crypto prices and fundamentals from several providers.",
|
|
6
6
|
"main": "dist_ts/index.js",
|
|
7
7
|
"typings": "dist_ts/index.d.ts",
|
|
8
8
|
"exports": {
|
|
@@ -30,6 +30,10 @@
|
|
|
30
30
|
"types": "./dist_ts/cpi/index.d.ts",
|
|
31
31
|
"import": "./dist_ts/cpi/index.js"
|
|
32
32
|
},
|
|
33
|
+
"./financialstatements": {
|
|
34
|
+
"types": "./dist_ts/financialstatements/index.d.ts",
|
|
35
|
+
"import": "./dist_ts/financialstatements/index.js"
|
|
36
|
+
},
|
|
33
37
|
"./package.json": "./package.json"
|
|
34
38
|
},
|
|
35
39
|
"type": "module",
|
|
@@ -37,17 +41,19 @@
|
|
|
37
41
|
"license": "MIT",
|
|
38
42
|
"devDependencies": {
|
|
39
43
|
"@git.zone/tsbuild": "^6.0.2",
|
|
40
|
-
"@git.zone/tsrun": "^3.0.
|
|
44
|
+
"@git.zone/tsrun": "^3.0.3",
|
|
41
45
|
"@git.zone/tstest": "^7.2.0",
|
|
42
46
|
"@push.rocks/qenv": "^6.1.3",
|
|
43
|
-
"@types/node": "
|
|
47
|
+
"@types/node": "26.6.4"
|
|
44
48
|
},
|
|
45
49
|
"dependencies": {
|
|
50
|
+
"@push.rocks/smartarchive": "^5.5.2",
|
|
46
51
|
"@push.rocks/smartpath": "^6.0.0",
|
|
47
52
|
"@push.rocks/smartpromise": "^4.3.0",
|
|
48
53
|
"@push.rocks/smartrequest": "^5.0.1",
|
|
49
54
|
"@push.rocks/smartxml": "^2.1.0",
|
|
50
|
-
"parse5": "^8.0.1"
|
|
55
|
+
"parse5": "^8.0.1",
|
|
56
|
+
"pdfjs-dist": "^6.3.289"
|
|
51
57
|
},
|
|
52
58
|
"repository": {
|
|
53
59
|
"type": "git",
|
|
@@ -93,7 +99,11 @@
|
|
|
93
99
|
"base rate",
|
|
94
100
|
"consumer price index",
|
|
95
101
|
"Verbraucherpreisindex",
|
|
96
|
-
"index clause"
|
|
102
|
+
"index clause",
|
|
103
|
+
"Jahresabschluss",
|
|
104
|
+
"financial statements",
|
|
105
|
+
"HGB",
|
|
106
|
+
"ESEF"
|
|
97
107
|
],
|
|
98
108
|
"scripts": {
|
|
99
109
|
"test": "(tstest test/ --verbose)",
|
package/readme.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# @fin.cx/opendata
|
|
2
2
|
|
|
3
|
-
`@fin.cx/opendata` is a TypeScript toolbox for open business and market data: stock and crypto prices, SEC fundamentals, lookups in the German registers through the register portal of the federal states, the statutory public holidays of the German federal states, the base rate of § 247 BGB,
|
|
3
|
+
`@fin.cx/opendata` is a TypeScript toolbox for open business and market data: stock and crypto prices, SEC fundamentals, lookups in the German registers through the register portal of the federal states, the statutory public holidays of the German federal states, the base rate of § 247 BGB, the German consumer price index for index clauses, and the figures of published financial statements. It is built for programmers who want scriptable APIs and real workflows instead of a thin wrapper around one remote endpoint.
|
|
4
4
|
|
|
5
|
-
It gives you
|
|
5
|
+
It gives you eight practical lanes in one package:
|
|
6
6
|
|
|
7
7
|
- `StockPriceService` for stock and crypto prices with caching, retries, failover, historical ranges, and intraday support
|
|
8
8
|
- `FundamentalsService` and `StockDataService` for SEC fundamentals and combined price + fundamentals payloads
|
|
@@ -11,6 +11,7 @@ It gives you seven practical lanes in one package:
|
|
|
11
11
|
- the base rate, from `@fin.cx/opendata/baserate`, for the Basiszinssatz of § 247 BGB per half-year as the Deutsche Bundesbank publishes it, for default interest under § 288 BGB
|
|
12
12
|
- VAT ID confirmation, from `@fin.cx/opendata/vatid`, over the BZSt's eVatR REST API (the confirmation of § 18e UStG, simple or qualified) and the European Commission's VIES REST API, without any storage
|
|
13
13
|
- the consumer price index, from `@fin.cx/opendata/cpi`, for the Verbraucherpreisindex für Deutschland of the Statistisches Bundesamt as the Deutsche Bundesbank republishes it, with the arithmetic of index clauses (Wertsicherungsklauseln, § 557b BGB), without any storage
|
|
14
|
+
- published financial statements, from `@fin.cx/opendata/financialstatements`, for the balance sheet and the income statement under the HGB from the HTML or the PDF the Unternehmensregister shows, and the facts of an ESEF report, read from a document the caller obtained, without any request or storage
|
|
14
15
|
|
|
15
16
|
## Issue Reporting and Security
|
|
16
17
|
|
|
@@ -179,6 +180,69 @@ The figures are the Statistisches Bundesamt's, under the Datenlizenz Deutschland
|
|
|
179
180
|
|
|
180
181
|
A change in percent, a threshold or a scaled amount is not a published figure. Mark it as such, for example "eigene Berechnung", next to the source note.
|
|
181
182
|
|
|
183
|
+
### Published Financial Statements
|
|
184
|
+
|
|
185
|
+
Everything here comes from `@fin.cx/opendata/financialstatements`. That entry point imports nothing else of the package, makes no request and keeps nothing. It reads a statement from the bytes the caller obtained, such as a statement saved from the Unternehmensregister, and returns what the caller stores: the filing, the document byte for byte, and what could be read from it. Its types hold no storage of their own, so any store can keep them.
|
|
186
|
+
|
|
187
|
+
- `readFinancialStatement({ filing, bytes, mediaType, now? })`
|
|
188
|
+
A statement as a caller keeps it (`IFinancialStatement`): the filing as the caller states it (`filing`, an `IFiling`), the document with its media type and SHA-256 (`raw`), the statement read from it (`parsed`), why none was (`parseFailure`), when it was obtained (`obtainedAt`) and the publisher's origin note (`notice`). An ESEF report, on its own or in its package, goes to the ESEF reader, and so does HTML that holds inline XBRL; any other HTML or PDF goes to `parseStatementDocument`.
|
|
189
|
+
- `parseStatementDocument(bytes, mediaType)`
|
|
190
|
+
A statement under the HGB from the HTML or the PDF a register shows (`IHgbStatement`): the balance sheet (§ 266 HGB) and, where one is published, the income statement (§ 275 HGB, by the total cost or the cost of sales method), each printed line with its outline code, label, kind and the amounts of both years, the unit (EUR, TEUR or Mio. EUR), the periods, and the key figures: balance sheet total, equity, net income and revenue. A PDF is read from its text layer.
|
|
191
|
+
- `parseEsefPackage(zipBytes)`, `parseEsefReport(xhtmlBytes, reportPath?)`
|
|
192
|
+
A listed issuer's annual financial report in the European Single Electronic Format (`IEsefStatement`): the LEI, every numeric fact its inline XBRL tags (concept, period, dimensions, unit, and the value with scale and sign applied), and the key figures of its consolidated statements from the IFRS concepts `Assets`, `Equity`, `ProfitLoss` and `Revenue`. A package must hold exactly one report under `reports/`.
|
|
193
|
+
- `unternehmensregisterNotice(publishedOn, deposited?)`, `unternehmensregisterOrigin`
|
|
194
|
+
The Unternehmensregister's origin note, "Auszug aus dem Unternehmensregister", with the day a document was posted or deposited.
|
|
195
|
+
- `maxStatementBytes` (100 MiB), `maxPdfPages` (500), `maxEsefFacts` (100,000), `maxEsefPackageEntries` (10,000)
|
|
196
|
+
- `FinancialStatementError` with the codes `invalid_input`, `unsupported_media_type`, `too_large`, `invalid_package`, `unreadable`, `no_text` and `unparseable`
|
|
197
|
+
|
|
198
|
+
Amounts are decimal strings in the currency itself, the unit's factor applied (`1.234` printed in TEUR is `'1234000'`), never floats.
|
|
199
|
+
|
|
200
|
+
```typescript
|
|
201
|
+
import { FinancialStatementError, readFinancialStatement } from '@fin.cx/opendata/financialstatements';
|
|
202
|
+
|
|
203
|
+
const statement = await readFinancialStatement({
|
|
204
|
+
filing: {
|
|
205
|
+
source: 'unternehmensregister',
|
|
206
|
+
kind: 'annual_accounts',
|
|
207
|
+
companyName: 'Muster Werkzeugbau GmbH',
|
|
208
|
+
fiscalYearStart: '2024-01-01',
|
|
209
|
+
fiscalYearEnd: '2024-12-31',
|
|
210
|
+
publishedOn: '2025-11-03',
|
|
211
|
+
},
|
|
212
|
+
bytes, // the PDF as saved from the register, a Uint8Array
|
|
213
|
+
mediaType: 'application/pdf',
|
|
214
|
+
});
|
|
215
|
+
// statement.raw: { bytes, mediaType: 'application/pdf', sha256: '...' }
|
|
216
|
+
// statement.notice: 'Auszug aus dem Unternehmensregister, eingestellt am 03.11.2025'
|
|
217
|
+
|
|
218
|
+
if (statement.parsed?.standard === 'hgb') {
|
|
219
|
+
statement.parsed.keyFigures.current;
|
|
220
|
+
// { totalAssets: '500000.00', equity: '300000.00', netIncome: '40000.00', revenue: '1000000.00' }
|
|
221
|
+
} else if (statement.parseFailure) {
|
|
222
|
+
statement.parseFailure; // { code: 'unparseable', message: 'No balance sheet was found.' }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
await readFinancialStatement({ filing: statement.filing, bytes: scan, mediaType: 'image/png' });
|
|
227
|
+
} catch (error) {
|
|
228
|
+
if (error instanceof FinancialStatementError) {
|
|
229
|
+
console.log(error.code); // 'unsupported_media_type': nothing is kept
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
#### Read, never guessed
|
|
235
|
+
|
|
236
|
+
`parsed` is null rather than guessed. Everything `parseStatementDocument` returns was printed and adds up: each position with parts equals their sum, each side of the balance sheet its total, both totals each other, and the lines of the income statement its net income, whether it prints its expenses with a minus or as amounts to subtract. A balance sheet whose current year does not add up, whose columns do not state their year or unit, that holds text where an amount belongs, or that is in a currency other than the euro ends in `unparseable`; `readFinancialStatement` then keeps the document with `parsed` null and the reason in `parseFailure`. A prior year that does not add up, and an income statement that does not, are left out with a note in `notes`. A PDF without a text layer, such as a scan, ends in `no_text`: nothing is recognised from images.
|
|
237
|
+
|
|
238
|
+
The ESEF reader takes values as the report tags them. It reads the formats `num-dot-decimal`, `num-comma-decimal`, `fixed-zero` and `zerodash` (and the older `numdotdecimal` and `numcommadecimal`); a fact in another format is left out and counted in `notes`. A key figure tagged twice with values that differ at the coarser of their `decimals` is left out with a note.
|
|
239
|
+
|
|
240
|
+
#### Where the documents come from
|
|
241
|
+
|
|
242
|
+
The entry point makes no request; the caller obtains each document and passes its bytes in. The statements of financial years beginning after 31 December 2021 are published in the Unternehmensregister, earlier ones in the Bundesanzeiger. Both show a statement only after a security check (a captcha); the Unternehmensregister shows it as HTML and usually also as PDF. A balance sheet a micro company deposited (§ 326 Abs. 2 HGB) is retrieved only for a fee and after registration. Listed issuers publish their ESEF reports on their own sites as well.
|
|
243
|
+
|
|
244
|
+
The Unternehmensregister's terms of use allow copying the statements it makes accessible by print or as a file (§ 3 Abs. 3) and mark them with the origin note "Auszug aus dem Unternehmensregister" and the day the document was posted, or for a deposited balance sheet the day it was deposited (§ 3 Abs. 5). A filing from the Unternehmensregister therefore needs `publishedOn`, and `notice` gives the note, so that a kept copy keeps it.
|
|
245
|
+
|
|
182
246
|
## Before You Start
|
|
183
247
|
|
|
184
248
|
- No stock or fundamentals providers are pre-registered. You must register them before calling the services.
|
|
@@ -398,7 +462,9 @@ Searches take a name or a register number:
|
|
|
398
462
|
| `search()` | 1 search | the search form and the result list |
|
|
399
463
|
| `getEntry()` | 1 search, plus 1 retrieval when the portal offers structured content | the search form and the result list |
|
|
400
464
|
|
|
401
|
-
The client asks the gate before each counted request and before that step sends anything, so a refusal costs no traffic. A slot taken for a step that then fails before its request is sent stays taken; the count errs on the safe side. The client never
|
|
465
|
+
The client asks the gate before each counted request and before that step sends anything, so a refusal costs no traffic. A slot taken for a step that then fails before its request is sent stays taken; the count errs on the safe side. The client never caches and never writes to the console. Every call opens its own visit to the portal and forgets it afterwards.
|
|
466
|
+
|
|
467
|
+
The client repeats a request in one case only, and only once: when the portal closed the connection without any answer. The portal's front end, Apache httpd before its application servers, closes a kept-alive connection that way when it could not read its back end's answer, so that the client sends the request again, as a browser does. A search or retrieval sent once more asks the gate again and counts again, so `countedRequests` can be one higher than the table says. Closed a second time, the call ends in `dropped`.
|
|
402
468
|
|
|
403
469
|
A refusal of `SlidingWindowGate` is a `HandelsregisterError` with the code `rate_limited` and `retryAt`, the moment the next request is possible. Like every error of the client it carries the refused `step` (`search` or `structured_content`) and the `countedRequests` made before it: a `getEntry()` refused at the retrieval has already counted its search. A gate of your own may refuse with any error; a `HandelsregisterError` gains the step and the count the same way, and any other error reaches the caller unchanged.
|
|
404
470
|
|
|
@@ -429,6 +495,7 @@ A refusal of `SlidingWindowGate` is a `HandelsregisterError` with the code `rate
|
|
|
429
495
|
| Portal answers with an HTTP error (blocked address, overload) | `refused` with `status` |
|
|
430
496
|
| Portal shows its notice that it refuses retrievals from this address | `refused` with its words in `portalMessage` |
|
|
431
497
|
| Portal too slow, unreachable, or call aborted | `timeout`, `unreachable`, `aborted` |
|
|
498
|
+
| Portal closes the connection without answering, also when the request is sent once more | `dropped` |
|
|
432
499
|
| Portal changed its pages or answered in another language | `unexpected_response`, with the portal's own message in `portalMessage` when it showed one |
|
|
433
500
|
| Structured content that cannot be read | `invalid_structured_content` |
|
|
434
501
|
|
|
@@ -610,9 +677,18 @@ Four holidays apply only in part of a state:
|
|
|
610
677
|
- `bundesbankCpiBaseUrl`, `bundesbankVpiMonthlyKey`, `bundesbankVpiAnnualKey`, `bundesbankVpiFirstMonth`, `bundesbankVpiFirstYear`, `destatisLicence`, `defaultCpiTimeoutMs`
|
|
611
678
|
- `CpiError` with the codes `invalid_input`, `before_first_period`, `not_published`, `base_mismatch`, `series_withdrawn`, `busy`, `unavailable`, `unreachable`, `timeout`, `aborted` and `unexpected_answer`
|
|
612
679
|
|
|
680
|
+
### Financial statements (`@fin.cx/opendata/financialstatements`)
|
|
681
|
+
|
|
682
|
+
- `readFinancialStatement({ filing, bytes, mediaType, now? })`
|
|
683
|
+
- `parseStatementDocument(bytes, mediaType)`
|
|
684
|
+
- `parseEsefPackage(zipBytes)`, `parseEsefReport(xhtmlBytes, reportPath?)`
|
|
685
|
+
- `unternehmensregisterNotice(publishedOn, deposited?)`, `unternehmensregisterOrigin`, `maxStatementBytes`, `maxPdfPages`, `maxEsefFacts`, `maxEsefPackageEntries`
|
|
686
|
+
- the types `IFiling`, `IFinancialStatement`, `IHgbStatement`, `IEsefStatement`, `IKeyFigures`, `IStatementPosition` and their parts
|
|
687
|
+
- `FinancialStatementError` with the codes `invalid_input`, `unsupported_media_type`, `too_large`, `invalid_package`, `unreadable`, `no_text` and `unparseable`
|
|
688
|
+
|
|
613
689
|
## Data Sources
|
|
614
690
|
|
|
615
|
-
The package ships no market or register data. Prices and fundamentals come at run time from the providers you register (Marketstack, CoinGecko, SEC EDGAR), and register entries come at run time from the register portal of the federal states (handelsregister.de), one lookup at a time.
|
|
691
|
+
The package ships no market or register data. Prices and fundamentals come at run time from the providers you register (Marketstack, CoinGecko, SEC EDGAR), and register entries come at run time from the register portal of the federal states (handelsregister.de), one lookup at a time. Financial statements come from the caller: `@fin.cx/opendata/financialstatements` reads the documents it is given and fetches none.
|
|
616
692
|
|
|
617
693
|
The package carries two kinds of data: the holiday rules of `@fin.cx/opendata/holidays` (`ts/holidays/data.holidays.ts`), described at the end of this section, and the code tables that `@fin.cx/opendata/handelsregister` uses to decode the structured register content (`ts/handelsregister/data.codelists.ts`). The code tables are official code lists, published on XRepository (https://www.xrepository.de), and were taken on 2026-09-23:
|
|
618
694
|
|
|
@@ -671,6 +747,10 @@ The consumer price index of `@fin.cx/opendata/cpi` is read at run time, one requ
|
|
|
671
747
|
|
|
672
748
|
- Not verified: limits on the number of requests to the Bundesbank's API (none is documented) and terms of use specific to it. The Bundesbank's terms for reusing statistics exclude data of third parties, so the figures are used under the Statistisches Bundesamt's licence. Whether the Bundesbank keeps the series of an old base for a while after a change of base year is not known; today the key of 2015=100 answers 404.
|
|
673
749
|
|
|
750
|
+
The reader of `@fin.cx/opendata/financialstatements` follows the outlines of the balance sheet (§ 266 HGB) and the income statement (§ 275 HGB), the terms of use of the Unternehmensregister (`https://www.unternehmensregister.de/i18n-doc/D061_UReg_nutz_0118_de.pdf`, § 3) and its help page on searching, which names the security check, both read on 2026-10-10. The ESEF reader was also run on two reports issuers published, which are not part of the repository.
|
|
751
|
+
|
|
752
|
+
- Not verified: the markup the Unternehmensregister and the Bundesanzeiger show a statement in today, and the PDF they make of it. No live statement was opened, to stay clear of the security check; the HTML and PDF readers are tested against documents written in the shape of published statements, not against saved ones. Whether readers can obtain a statement as filed in XML or XBRL is not known either; the help pages name HTML and PDF only.
|
|
753
|
+
|
|
674
754
|
## Behavior And Caveats
|
|
675
755
|
|
|
676
756
|
- Historical market data is cached aggressively because it is treated as immutable.
|
|
@@ -678,6 +758,7 @@ The consumer price index of `@fin.cx/opendata/cpi` is read at run time, one requ
|
|
|
678
758
|
- `StockDataService` is convenience-first. `StockPriceService` is the better low-level choice if you care about request-shape-specific cache behavior.
|
|
679
759
|
- The register lookup reads the portal's German pages and asks for German explicitly. It depends on the portal's current page structure: when that changes, calls end in `unexpected_response` rather than in guessed data.
|
|
680
760
|
- The structured register content is a non-binding service of the portal; the current printout is binding. Results carry `binding: false` and the portal's note.
|
|
761
|
+
- The statement reader depends on how a statement is laid out: when a layout is not one it can read with certainty, the document is kept with `parsed` null rather than read in part or guessed. It reads the balance sheet and the income statement under the HGB and the numeric facts of ESEF reports; the notes and the management report are not read.
|
|
681
762
|
- There is no hosted backend in this package. Everything is meant to run from your own TypeScript/Node process.
|
|
682
763
|
|
|
683
764
|
## Testing
|
|
@@ -686,7 +767,7 @@ The consumer price index of `@fin.cx/opendata/cpi` is read at run time, one requ
|
|
|
686
767
|
pnpm test
|
|
687
768
|
```
|
|
688
769
|
|
|
689
|
-
Some market data tests hit live remote services, so they may depend on network access or optional API credentials. The register lookup is tested against minimal hand-written pages and structured content with synthetic values only; the tests never contact the register portal. The VAT ID clients are tested against the answers the two services gave on 2026-09-27 to their documented test values (`test/fixtures/vatid/`); the tests never contact them. The consumer price index client is tested against the answers the Bundesbank's API gave on 2026-10-05 and two answers written by hand from them (`test/fixtures/cpi/`); the tests never contact it.
|
|
770
|
+
Some market data tests hit live remote services, so they may depend on network access or optional API credentials. The register lookup is tested against minimal hand-written pages and structured content with synthetic values only; the tests never contact the register portal. The VAT ID clients are tested against the answers the two services gave on 2026-09-27 to their documented test values (`test/fixtures/vatid/`); the tests never contact them. The consumer price index client is tested against the answers the Bundesbank's API gave on 2026-10-05 and two answers written by hand from them (`test/fixtures/cpi/`); the tests never contact it. The financial statement reader is tested against HTML statements written by hand in the shape of the register's (`test/fixtures/financialstatements/`), PDFs the tests write themselves, and ESEF reports and packages the tests write, all with synthetic values; the tests contact no register.
|
|
690
771
|
|
|
691
772
|
## License and Legal Information
|
|
692
773
|
|
package/ts/00_commitinfo_data.ts
CHANGED
|
@@ -3,6 +3,6 @@
|
|
|
3
3
|
*/
|
|
4
4
|
export const commitinfo = {
|
|
5
5
|
name: '@fin.cx/opendata',
|
|
6
|
-
version: '4.
|
|
7
|
-
description: 'Open business and financial data for TypeScript: register lookups on the German register portal (handelsregister.de) without storage, the statutory public holidays of the German federal states, the base rate of § 247 BGB, the German consumer price index with the arithmetic of index clauses, and stock and crypto prices and fundamentals from several providers.'
|
|
6
|
+
version: '4.8.0',
|
|
7
|
+
description: 'Open business and financial data for TypeScript: register lookups on the German register portal (handelsregister.de) without storage, the statutory public holidays of the German federal states, the base rate of § 247 BGB, the German consumer price index with the arithmetic of index clauses, published financial statements read from their HTML, PDF or ESEF documents, and stock and crypto prices and fundamentals from several providers.'
|
|
8
8
|
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import type { IStatementUnit } from './interfaces.js';
|
|
2
|
+
|
|
3
|
+
/** millionths of a euro per euro: amounts are kept as whole millionths, so no sum loses a digit */
|
|
4
|
+
const microsPerEuro = 1_000_000n;
|
|
5
|
+
|
|
6
|
+
/** a step at or below one cent: the amount was printed to the cent and is exact */
|
|
7
|
+
const exactStep = 10_000n;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* An amount as this entry point computes with it: millionths of a euro, and the rounding it may
|
|
11
|
+
* carry. `slack` is twice the largest rounding error, also in millionths: zero for an amount printed
|
|
12
|
+
* to the cent, the step of the last printed digit for one printed rounded, and the sum of its terms'
|
|
13
|
+
* slack for a sum.
|
|
14
|
+
*/
|
|
15
|
+
export interface IAmount {
|
|
16
|
+
micros: bigint;
|
|
17
|
+
slack: bigint;
|
|
18
|
+
/** the digits after the decimal point the amount is written with */
|
|
19
|
+
fractionDigits: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** a number as printed, before its unit is applied */
|
|
23
|
+
export interface IPrintedAmount {
|
|
24
|
+
/** the printed digits as one integer: `1.234,5` is 12345 */
|
|
25
|
+
digits: bigint;
|
|
26
|
+
/** the digits after the decimal comma */
|
|
27
|
+
decimals: number;
|
|
28
|
+
/** the unit printed with the number, where one is */
|
|
29
|
+
unit: IStatementUnit | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const unitEur: IStatementUnit = { label: 'EUR', factor: '1' };
|
|
33
|
+
export const unitTeur: IStatementUnit = { label: 'TEUR', factor: '1000' };
|
|
34
|
+
export const unitMioEur: IStatementUnit = { label: 'Mio. EUR', factor: '1000000' };
|
|
35
|
+
|
|
36
|
+
/** the euro after a prefix such as `Mio.` or `T` */
|
|
37
|
+
const euroSuffix = String.raw`(?:€|EUR\b|Euro\b|EURO\b)`;
|
|
38
|
+
|
|
39
|
+
/** the units the statements print, millions and thousands before plain euros */
|
|
40
|
+
const unitPatterns: Array<{ unit: IStatementUnit; pattern: string }> = [
|
|
41
|
+
{ unit: unitMioEur, pattern: String.raw`\b(?:Mio\.?|Millionen)\s*${euroSuffix}` },
|
|
42
|
+
{ unit: unitTeur, pattern: String.raw`\b(?:T|Tsd\.?\s*|Tausend\s+)${euroSuffix}` },
|
|
43
|
+
{ unit: unitEur, pattern: String.raw`(?:€|\b${euroSuffix})` },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
const unitAlternation = unitPatterns.map((entryArg) => `(${entryArg.pattern})`).join('|');
|
|
47
|
+
|
|
48
|
+
/** the units a text names, in the order it names them */
|
|
49
|
+
export const unitsIn = (textArg: string): IStatementUnit[] => {
|
|
50
|
+
const result: IStatementUnit[] = [];
|
|
51
|
+
for (const match of textArg.matchAll(new RegExp(unitAlternation, 'g'))) {
|
|
52
|
+
const index = match.slice(1).findIndex((groupArg) => groupArg !== undefined);
|
|
53
|
+
result.push(unitPatterns[index].unit);
|
|
54
|
+
}
|
|
55
|
+
return result;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** a text with the units it names blanked out */
|
|
59
|
+
export const withoutUnits = (textArg: string): string => textArg.replace(new RegExp(unitAlternation, 'g'), ' ');
|
|
60
|
+
|
|
61
|
+
/** the currencies other than the euro a statement may name */
|
|
62
|
+
const otherCurrencySource = String.raw`\b(?:USD|US-Dollar|CHF|GBP|JPY|CNY|DKK|SEK|NOK|PLN|CZK|HUF)\b|\$|£|¥`;
|
|
63
|
+
|
|
64
|
+
/** whether a text names a currency other than the euro */
|
|
65
|
+
export const namesOtherCurrency = (textArg: string): boolean => new RegExp(otherCurrencySource).test(textArg);
|
|
66
|
+
|
|
67
|
+
/** a text with the currencies other than the euro it names blanked out */
|
|
68
|
+
export const withoutOtherCurrencies = (textArg: string): string => textArg.replace(new RegExp(otherCurrencySource, 'g'), ' ');
|
|
69
|
+
|
|
70
|
+
/** the unit alone, `TEUR` or `(in TEUR)`, as a line of its own */
|
|
71
|
+
const unitOnlyPattern = new RegExp(String.raw`^\(?\s*(?:in\s+)?(?:${unitAlternation})\s*\)?$`);
|
|
72
|
+
|
|
73
|
+
/** the explicit phrases a statement states its unit with: `Angaben in TEUR`, `(Beträge in Euro)` */
|
|
74
|
+
const unitPhrasePattern = new RegExp(
|
|
75
|
+
String.raw`(?:\(\s*in\s+|\b(?:Angaben|Beträge|Betrag|Werte|Zahlen|Alle Beträge|Zahlenangaben)\s+in\s+)(?:${unitAlternation})`
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
/** the unit a line of context states, `Angaben in TEUR` or a line that is only a unit; null if none */
|
|
79
|
+
export const unitStatedBy = (textArg: string): IStatementUnit | null => {
|
|
80
|
+
const text = normalizeSpaces(textArg);
|
|
81
|
+
if (text.length > 200) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
const phrase = unitPhrasePattern.exec(text);
|
|
85
|
+
if (phrase) {
|
|
86
|
+
return unitsIn(phrase[0])[0] ?? null;
|
|
87
|
+
}
|
|
88
|
+
return unitOnlyPattern.test(text) ? unitsIn(text)[0] ?? null : null;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** collapses the spaces statements print, no-break and narrow ones among them */
|
|
92
|
+
export const normalizeSpaces = (textArg: string): string => textArg.replace(/\s+/g, ' ').trim();
|
|
93
|
+
|
|
94
|
+
const dashOnlyPattern = /^[-–—−]{1,3}$/;
|
|
95
|
+
const numberPattern = /^([-–—−])?\s*(\d{1,3}(?:[. ]\d{3})+|\d+)(?:,(\d{1,6}))?$/;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Reads an amount as German statements print it: `1.234.567,89`, `-1 234`, `– 12,5`, a dash alone
|
|
99
|
+
* for zero, with or without a unit before or after it (`EUR 1.234,56`, `1.234 T€`). Returns null for
|
|
100
|
+
* anything else, a year or a note number among them for the caller to tell apart.
|
|
101
|
+
*/
|
|
102
|
+
export const parsePrintedAmount = (textArg: string): IPrintedAmount | null => {
|
|
103
|
+
let text = normalizeSpaces(textArg);
|
|
104
|
+
if (text === '') {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
let unit: IStatementUnit | null = null;
|
|
108
|
+
const leading = new RegExp(String.raw`^(?:${unitAlternation})\s*`).exec(text);
|
|
109
|
+
if (leading) {
|
|
110
|
+
unit = unitsIn(leading[0])[0];
|
|
111
|
+
text = text.slice(leading[0].length);
|
|
112
|
+
} else {
|
|
113
|
+
const trailing = new RegExp(String.raw`\s*(?:${unitAlternation})$`).exec(text);
|
|
114
|
+
if (trailing && trailing.index > 0) {
|
|
115
|
+
unit = unitsIn(trailing[0])[0];
|
|
116
|
+
text = text.slice(0, trailing.index);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (dashOnlyPattern.test(text)) {
|
|
120
|
+
return { digits: 0n, decimals: 0, unit };
|
|
121
|
+
}
|
|
122
|
+
const match = numberPattern.exec(text);
|
|
123
|
+
if (!match) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
const decimals = match[3]?.length ?? 0;
|
|
127
|
+
const magnitude = BigInt(match[2].replace(/[. ]/g, '') + (match[3] ?? ''));
|
|
128
|
+
return { digits: match[1] ? -magnitude : magnitude, decimals, unit };
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/** whether a text reads as an amount rather than as a year such as `2024` or `2024 EUR` */
|
|
132
|
+
export const readsAsAmount = (textArg: string): boolean =>
|
|
133
|
+
parsePrintedAmount(textArg) !== null && !/^(?:19|20)\d{2}$/.test(normalizeSpaces(withoutUnits(textArg)));
|
|
134
|
+
|
|
135
|
+
/** an amount in a unit: `1.234` in TEUR is 1,234,000 euros, rounded to the thousand */
|
|
136
|
+
export const toAmount = (printedArg: IPrintedAmount, unitArg: IStatementUnit): IAmount => {
|
|
137
|
+
const step = BigInt(unitArg.factor) * 10n ** BigInt(6 - printedArg.decimals);
|
|
138
|
+
return {
|
|
139
|
+
micros: printedArg.digits * step,
|
|
140
|
+
slack: step <= exactStep ? 0n : step,
|
|
141
|
+
fractionDigits: fractionDigitsOf(step),
|
|
142
|
+
};
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const fractionDigitsOf = (stepArg: bigint): number => {
|
|
146
|
+
let digits = 6;
|
|
147
|
+
for (let step = stepArg; digits > 0 && step % 10n === 0n; step /= 10n) {
|
|
148
|
+
digits--;
|
|
149
|
+
}
|
|
150
|
+
return digits;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
export const zeroAmount: IAmount = { micros: 0n, slack: 0n, fractionDigits: 0 };
|
|
154
|
+
|
|
155
|
+
export const addAmounts = (leftArg: IAmount, rightArg: IAmount): IAmount => ({
|
|
156
|
+
micros: leftArg.micros + rightArg.micros,
|
|
157
|
+
slack: leftArg.slack + rightArg.slack,
|
|
158
|
+
fractionDigits: Math.max(leftArg.fractionDigits, rightArg.fractionDigits),
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
export const negateAmount = (amountArg: IAmount): IAmount => ({ ...amountArg, micros: -amountArg.micros });
|
|
162
|
+
|
|
163
|
+
export const sumAmounts = (amountsArg: IAmount[]): IAmount => amountsArg.reduce(addAmounts, zeroAmount);
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Whether two amounts can be the same figure: equal, or for figures printed rounded, no further
|
|
167
|
+
* apart than both their roundings together allow.
|
|
168
|
+
*/
|
|
169
|
+
export const amountsAgree = (leftArg: IAmount, rightArg: IAmount): boolean => {
|
|
170
|
+
const difference = leftArg.micros - rightArg.micros;
|
|
171
|
+
return (difference < 0n ? -difference : difference) * 2n <= leftArg.slack + rightArg.slack;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/** an amount as a decimal string in euros, written with its own digits: `1234000`, `-12.50` */
|
|
175
|
+
export const formatAmount = (amountArg: IAmount): string => {
|
|
176
|
+
const negative = amountArg.micros < 0n;
|
|
177
|
+
const magnitude = negative ? -amountArg.micros : amountArg.micros;
|
|
178
|
+
const whole = (magnitude / microsPerEuro).toString();
|
|
179
|
+
const fraction = (magnitude % microsPerEuro).toString().padStart(6, '0').slice(0, amountArg.fractionDigits);
|
|
180
|
+
return `${negative ? '-' : ''}${whole}${fraction === '' ? '' : `.${fraction}`}`;
|
|
181
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Why a document was not read:
|
|
3
|
+
* - `invalid_input`: arguments this entry point does not take: no bytes, a date that is not one, a
|
|
4
|
+
* statement from the Unternehmensregister without the day it was posted;
|
|
5
|
+
* - `unsupported_media_type`: a media type other than HTML, PDF, XHTML or a ZIP package;
|
|
6
|
+
* - `too_large`: a document, or the report in a package, larger than `maxStatementBytes`, a package
|
|
7
|
+
* with more than `maxEsefPackageEntries` entries, a PDF with more than `maxPdfPages` pages, or a
|
|
8
|
+
* report with more than `maxEsefFacts` facts;
|
|
9
|
+
* - `invalid_package`: a package that is not a readable ZIP, is encrypted, or does not hold exactly
|
|
10
|
+
* one report under `reports/`;
|
|
11
|
+
* - `unreadable`: a document that does not open as what its media type says: a damaged or
|
|
12
|
+
* password-protected PDF, HTML in an unknown encoding, XHTML that is not well-formed;
|
|
13
|
+
* - `no_text`: a PDF without a text layer, such as a scan;
|
|
14
|
+
* - `unparseable`: a readable document in which no statement was found with certainty: no balance
|
|
15
|
+
* sheet, columns that do not say their year or unit, or current-year figures that do not add up.
|
|
16
|
+
*/
|
|
17
|
+
export type TFinancialStatementErrorCode =
|
|
18
|
+
| 'invalid_input'
|
|
19
|
+
| 'unsupported_media_type'
|
|
20
|
+
| 'too_large'
|
|
21
|
+
| 'invalid_package'
|
|
22
|
+
| 'unreadable'
|
|
23
|
+
| 'no_text'
|
|
24
|
+
| 'unparseable';
|
|
25
|
+
|
|
26
|
+
/** The typed error of `@fin.cx/opendata/financialstatements`. */
|
|
27
|
+
export class FinancialStatementError extends Error {
|
|
28
|
+
public readonly code: TFinancialStatementErrorCode;
|
|
29
|
+
|
|
30
|
+
constructor(code: TFinancialStatementErrorCode, message: string, options?: { cause?: unknown }) {
|
|
31
|
+
super(message, options);
|
|
32
|
+
this.name = 'FinancialStatementError';
|
|
33
|
+
this.code = code;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** The largest document read, and the largest report a package may hold: 100 MiB. */
|
|
2
|
+
export const maxStatementBytes = 100 * 1024 * 1024;
|
|
3
|
+
|
|
4
|
+
/** The most pages a PDF may have. */
|
|
5
|
+
export const maxPdfPages = 500;
|
|
6
|
+
|
|
7
|
+
/** The most numeric facts an ESEF report may tag. */
|
|
8
|
+
export const maxEsefFacts = 100_000;
|
|
9
|
+
|
|
10
|
+
/** The most entries an ESEF package may hold. */
|
|
11
|
+
export const maxEsefPackageEntries = 10_000;
|
|
12
|
+
|
|
13
|
+
/** The origin note the Unternehmensregister marks what it gives out with (Nutzungsbedingungen § 3 Abs. 5). */
|
|
14
|
+
export const unternehmensregisterOrigin = 'Auszug aus dem Unternehmensregister';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The full origin note of a document from the Unternehmensregister: "Auszug aus dem
|
|
18
|
+
* Unternehmensregister" and the day the document was posted, or for a deposited balance sheet the day
|
|
19
|
+
* it was deposited (§ 326 Abs. 2 HGB).
|
|
20
|
+
*/
|
|
21
|
+
export const unternehmensregisterNotice = (publishedOnArg: string, depositedArg = false): string => {
|
|
22
|
+
const [year, month, day] = publishedOnArg.split('-');
|
|
23
|
+
return `${unternehmensregisterOrigin}, ${depositedArg ? 'hinterlegt' : 'eingestellt'} am ${day}.${month}.${year}`;
|
|
24
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { TIsoDate } from './interfaces.js';
|
|
2
|
+
|
|
3
|
+
const monthNumbers: Record<string, number> = {
|
|
4
|
+
januar: 1, jan: 1, jänner: 1, februar: 2, feb: 2, märz: 3, mär: 3, maerz: 3, april: 4, apr: 4, mai: 5,
|
|
5
|
+
juni: 6, jun: 6, juli: 7, jul: 7, august: 8, aug: 8, september: 9, sep: 9, sept: 9, oktober: 10, okt: 10,
|
|
6
|
+
november: 11, nov: 11, dezember: 12, dez: 12,
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/** `31.12.2024`, `31. Dezember 2024`, `1.1.`, `01. Jan.`: a day, a month and maybe a year */
|
|
10
|
+
const datePattern = /(\d{1,2})\.\s*(?:(\d{1,2})\.|([A-Za-zÄäÖöÜü]{3,9})\.?(?=[\s\d]|$))\s*((?:19|20)\d{2})?/g;
|
|
11
|
+
|
|
12
|
+
interface IDateMatch {
|
|
13
|
+
day: number;
|
|
14
|
+
month: number;
|
|
15
|
+
year: number | null;
|
|
16
|
+
index: number;
|
|
17
|
+
end: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const isoOf = (yearArg: number, monthArg: number, dayArg: number): TIsoDate | null => {
|
|
21
|
+
const date = new Date(Date.UTC(yearArg, monthArg - 1, dayArg));
|
|
22
|
+
if (date.getUTCFullYear() !== yearArg || date.getUTCMonth() !== monthArg - 1 || date.getUTCDate() !== dayArg) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
return date.toISOString().slice(0, 10);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const dateMatches = (textArg: string): IDateMatch[] => {
|
|
29
|
+
const result: IDateMatch[] = [];
|
|
30
|
+
for (const match of textArg.matchAll(datePattern)) {
|
|
31
|
+
const month = match[2] !== undefined ? Number(match[2]) : monthNumbers[match[3].toLowerCase()];
|
|
32
|
+
const day = Number(match[1]);
|
|
33
|
+
if (month === undefined || month < 1 || month > 12 || day < 1 || day > 31) {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
result.push({
|
|
37
|
+
day,
|
|
38
|
+
month,
|
|
39
|
+
year: match[4] !== undefined ? Number(match[4]) : null,
|
|
40
|
+
index: match.index,
|
|
41
|
+
end: match.index + match[0].length,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return result;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** the last full date a text names, `31.12.2024` or `31. Dezember 2024`; null if none */
|
|
48
|
+
export const lastDateIn = (textArg: string): TIsoDate | null => {
|
|
49
|
+
const full = dateMatches(textArg).filter((matchArg) => matchArg.year !== null);
|
|
50
|
+
const last = full[full.length - 1];
|
|
51
|
+
return last ? isoOf(last.year as number, last.month, last.day) : null;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** a text with the dates it names blanked out, `31.12.2024`, `1. Januar` and the like */
|
|
55
|
+
export const withoutDates = (textArg: string): string => {
|
|
56
|
+
let text = textArg;
|
|
57
|
+
for (const match of dateMatches(textArg).reverse()) {
|
|
58
|
+
text = text.slice(0, match.index) + ' '.repeat(match.end - match.index) + text.slice(match.end);
|
|
59
|
+
}
|
|
60
|
+
return text;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** the last year a text names on its own, outside a date; null if none */
|
|
64
|
+
export const lastYearIn = (textArg: string): number | null => {
|
|
65
|
+
const years = [...withoutDates(textArg).matchAll(/\b((?:19|20)\d{2})\b/g)];
|
|
66
|
+
return years.length > 0 ? Number(years[years.length - 1][1]) : null;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The period a line of a statement names: `vom 1. Januar bis 31. Dezember 2024` and
|
|
71
|
+
* `01.01.-31.12.2024` give both days, `zum 31. Dezember 2024` the last. A first day printed without
|
|
72
|
+
* its year is in the last day's year, or the year before where it would fall after the last day.
|
|
73
|
+
*/
|
|
74
|
+
export const periodIn = (textArg: string): { start: TIsoDate | null; end: TIsoDate | null } => {
|
|
75
|
+
const matches = dateMatches(textArg);
|
|
76
|
+
for (let index = 0; index + 1 < matches.length; index++) {
|
|
77
|
+
const first = matches[index];
|
|
78
|
+
const second = matches[index + 1];
|
|
79
|
+
const between = textArg.slice(first.end, second.index);
|
|
80
|
+
if (second.year === null || !/^\s*(?:bis(?:\s+(?:zum|einschließlich))?|[-–—])\s*$/i.test(between)) {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const end = isoOf(second.year, second.month, second.day);
|
|
84
|
+
const startYear =
|
|
85
|
+
first.year ?? (first.month * 100 + first.day <= second.month * 100 + second.day ? second.year : second.year - 1);
|
|
86
|
+
return { start: end ? isoOf(startYear, first.month, first.day) : null, end };
|
|
87
|
+
}
|
|
88
|
+
return { start: null, end: lastDateIn(textArg) };
|
|
89
|
+
};
|