@fin.cx/opendata 4.7.0 → 4.9.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/gleif/classes.gleifclient.d.ts +26 -0
- package/dist_ts/gleif/classes.gleifclient.js +214 -0
- package/dist_ts/gleif/classes.gleiferror.d.ts +6 -0
- package/dist_ts/gleif/classes.gleiferror.js +11 -0
- package/dist_ts/gleif/crosswalk.d.ts +7 -0
- package/dist_ts/gleif/crosswalk.js +25 -0
- package/dist_ts/gleif/csv.d.ts +2 -0
- package/dist_ts/gleif/csv.js +75 -0
- package/dist_ts/gleif/data.crosswalk.d.ts +12 -0
- package/dist_ts/gleif/data.crosswalk.js +1265 -0
- package/dist_ts/gleif/index.d.ts +6 -0
- package/dist_ts/gleif/index.js +7 -0
- package/dist_ts/gleif/interfaces.d.ts +101 -0
- package/dist_ts/gleif/interfaces.js +2 -0
- package/dist_ts/gleif/plugins.d.ts +1 -0
- package/dist_ts/gleif/plugins.js +3 -0
- package/dist_ts/gleif/records.d.ts +6 -0
- package/dist_ts/gleif/records.js +107 -0
- package/dist_ts/gleif/stream.zip.d.ts +2 -0
- package/dist_ts/gleif/stream.zip.js +54 -0
- package/dist_ts/plugins.d.ts +2 -0
- package/dist_ts/plugins.js +4 -1
- package/package.json +20 -5
- package/readme.md +156 -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/gleif/classes.gleifclient.ts +170 -0
- package/ts/gleif/classes.gleiferror.ts +8 -0
- package/ts/gleif/crosswalk.ts +29 -0
- package/ts/gleif/csv.ts +60 -0
- package/ts/gleif/data.crosswalk.ts +1271 -0
- package/ts/gleif/index.ts +6 -0
- package/ts/gleif/interfaces.ts +107 -0
- package/ts/gleif/plugins.ts +2 -0
- package/ts/gleif/records.ts +104 -0
- package/ts/gleif/stream.zip.ts +43 -0
- package/ts/plugins.ts +4 -0
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 nine 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,8 @@ 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
|
|
15
|
+
- GLEIF Level 1 data, from `@fin.cx/opendata/gleif`, for streamed Golden Copy full/delta imports, single-LEI lookup, and German registration-authority codes mapped to XJustiz register courts
|
|
14
16
|
|
|
15
17
|
## Issue Reporting and Security
|
|
16
18
|
|
|
@@ -179,6 +181,69 @@ The figures are the Statistisches Bundesamt's, under the Datenlizenz Deutschland
|
|
|
179
181
|
|
|
180
182
|
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
183
|
|
|
184
|
+
### Published Financial Statements
|
|
185
|
+
|
|
186
|
+
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.
|
|
187
|
+
|
|
188
|
+
- `readFinancialStatement({ filing, bytes, mediaType, now? })`
|
|
189
|
+
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`.
|
|
190
|
+
- `parseStatementDocument(bytes, mediaType)`
|
|
191
|
+
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.
|
|
192
|
+
- `parseEsefPackage(zipBytes)`, `parseEsefReport(xhtmlBytes, reportPath?)`
|
|
193
|
+
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/`.
|
|
194
|
+
- `unternehmensregisterNotice(publishedOn, deposited?)`, `unternehmensregisterOrigin`
|
|
195
|
+
The Unternehmensregister's origin note, "Auszug aus dem Unternehmensregister", with the day a document was posted or deposited.
|
|
196
|
+
- `maxStatementBytes` (100 MiB), `maxPdfPages` (500), `maxEsefFacts` (100,000), `maxEsefPackageEntries` (10,000)
|
|
197
|
+
- `FinancialStatementError` with the codes `invalid_input`, `unsupported_media_type`, `too_large`, `invalid_package`, `unreadable`, `no_text` and `unparseable`
|
|
198
|
+
|
|
199
|
+
Amounts are decimal strings in the currency itself, the unit's factor applied (`1.234` printed in TEUR is `'1234000'`), never floats.
|
|
200
|
+
|
|
201
|
+
```typescript
|
|
202
|
+
import { FinancialStatementError, readFinancialStatement } from '@fin.cx/opendata/financialstatements';
|
|
203
|
+
|
|
204
|
+
const statement = await readFinancialStatement({
|
|
205
|
+
filing: {
|
|
206
|
+
source: 'unternehmensregister',
|
|
207
|
+
kind: 'annual_accounts',
|
|
208
|
+
companyName: 'Muster Werkzeugbau GmbH',
|
|
209
|
+
fiscalYearStart: '2024-01-01',
|
|
210
|
+
fiscalYearEnd: '2024-12-31',
|
|
211
|
+
publishedOn: '2025-11-03',
|
|
212
|
+
},
|
|
213
|
+
bytes, // the PDF as saved from the register, a Uint8Array
|
|
214
|
+
mediaType: 'application/pdf',
|
|
215
|
+
});
|
|
216
|
+
// statement.raw: { bytes, mediaType: 'application/pdf', sha256: '...' }
|
|
217
|
+
// statement.notice: 'Auszug aus dem Unternehmensregister, eingestellt am 03.11.2025'
|
|
218
|
+
|
|
219
|
+
if (statement.parsed?.standard === 'hgb') {
|
|
220
|
+
statement.parsed.keyFigures.current;
|
|
221
|
+
// { totalAssets: '500000.00', equity: '300000.00', netIncome: '40000.00', revenue: '1000000.00' }
|
|
222
|
+
} else if (statement.parseFailure) {
|
|
223
|
+
statement.parseFailure; // { code: 'unparseable', message: 'No balance sheet was found.' }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
await readFinancialStatement({ filing: statement.filing, bytes: scan, mediaType: 'image/png' });
|
|
228
|
+
} catch (error) {
|
|
229
|
+
if (error instanceof FinancialStatementError) {
|
|
230
|
+
console.log(error.code); // 'unsupported_media_type': nothing is kept
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
#### Read, never guessed
|
|
236
|
+
|
|
237
|
+
`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.
|
|
238
|
+
|
|
239
|
+
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.
|
|
240
|
+
|
|
241
|
+
#### Where the documents come from
|
|
242
|
+
|
|
243
|
+
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.
|
|
244
|
+
|
|
245
|
+
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.
|
|
246
|
+
|
|
182
247
|
## Before You Start
|
|
183
248
|
|
|
184
249
|
- No stock or fundamentals providers are pre-registered. You must register them before calling the services.
|
|
@@ -323,6 +388,78 @@ console.log({
|
|
|
323
388
|
});
|
|
324
389
|
```
|
|
325
390
|
|
|
391
|
+
### GLEIF Level 1 data (`@fin.cx/opendata/gleif`)
|
|
392
|
+
|
|
393
|
+
`GleifClient` discovers the current GLEIF Golden Copy publication, streams its LEI-CDF 3.1 ZIP/CSV records, or looks up one LEI through GLEIF's public JSON:API. It keeps no files or data. `fetch` is injectable; every request has a signal and a time limit, with no retries. Breaking a `for await` loop cancels the download. Only publication metadata and single-record JSON responses are collected (at most 2 MiB); the ZIP and CSV are read incrementally with backpressure and a default 1,048,576-character limit per CSV row. A completed stream checks the compressed byte and unfiltered record counts against the publication descriptor.
|
|
394
|
+
|
|
395
|
+
```ts
|
|
396
|
+
import {
|
|
397
|
+
GleifClient,
|
|
398
|
+
courtFromRegistrationAuthority,
|
|
399
|
+
registrationAuthorityFromCourt,
|
|
400
|
+
parseRegistrationAuthorityEntityId,
|
|
401
|
+
registerReferenceFromRegistrationAuthority,
|
|
402
|
+
} from '@fin.cx/opendata/gleif';
|
|
403
|
+
|
|
404
|
+
const gleif = new GleifClient({ userAgent: 'Example Books (+https://example.com/contact)' });
|
|
405
|
+
const publish = await gleif.getLatestPublish();
|
|
406
|
+
// Save this descriptor in your own store to identify the import's publication.
|
|
407
|
+
for await (const record of gleif.readGoldenCopy(publish.fullFile, { country: 'DE' })) {
|
|
408
|
+
console.log(record.lei, record.legalName.name, record.registrationAuthority);
|
|
409
|
+
}
|
|
410
|
+
// Or discover and stream in one call. Other kinds: GoldenCopy, LastDay, LastWeek, LastMonth.
|
|
411
|
+
for await (const record of gleif.streamRecords({ kind: 'IntraDay', jurisdiction: 'DE' })) {
|
|
412
|
+
console.log(record.lei, record.lastUpdateDate);
|
|
413
|
+
}
|
|
414
|
+
const record = await gleif.lookup('529900W18LQJJN6SJ336');
|
|
415
|
+
const court = courtFromRegistrationAuthority('RA000222'); // { code: 'H1101', name: 'Bremen' }
|
|
416
|
+
const raCode = registrationAuthorityFromCourt('H1101'); // RA000222
|
|
417
|
+
const number = parseRegistrationAuthorityEntityId('HRB 35230HB'); // { type: 'HRB', number: '35230 HB' }
|
|
418
|
+
const reference = registerReferenceFromRegistrationAuthority('RA000222', 'HRB 35230HB');
|
|
419
|
+
// IRegisterReference: { court, type: 'HRB', number: '35230 HB' }
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
The exact yielded/lookup contract is `IGleifRecord`:
|
|
423
|
+
|
|
424
|
+
```ts
|
|
425
|
+
interface IGleifRecord {
|
|
426
|
+
lei: string;
|
|
427
|
+
legalName: IGleifName;
|
|
428
|
+
otherNames: IGleifName[];
|
|
429
|
+
transliteratedOtherNames: IGleifName[];
|
|
430
|
+
legalForm?: { elfCode?: string; other?: string };
|
|
431
|
+
legalAddress: IGleifAddress;
|
|
432
|
+
headquartersAddress: IGleifAddress;
|
|
433
|
+
jurisdiction?: string;
|
|
434
|
+
entityStatus: string;
|
|
435
|
+
entityCategory?: string;
|
|
436
|
+
registrationAuthority?: { id?: string; otherId?: string; entityId?: string };
|
|
437
|
+
registrationStatus: string;
|
|
438
|
+
initialRegistrationDate: string;
|
|
439
|
+
lastUpdateDate: string;
|
|
440
|
+
}
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
`IGleifName` carries `name`, optional `language`, and optional published other-name `type`. `IGleifAddress` carries `addressLines`, `city`, `country`, and optional `language`, `region`, `postalCode`, `addressNumber`, `addressNumberWithinBuilding` and `mailRouting`. Empty optional source fields are absent, other-name lists are arrays, and dates remain the published ISO 8601 strings, including fractional seconds. ELF codes remain codes; no legal-form designation is invented. Status and category strings remain as published. `country` compares the legal address's ISO country, `jurisdiction` compares the exact legal jurisdiction; when both are supplied, both must match. Filtering happens after parsing each record, before yielding it.
|
|
444
|
+
|
|
445
|
+
The full file and delta descriptors are `IGleifGoldenCopyFile` (`url`, `format: 'csv'`, `cdfVersion: 'LEI_3.1'`, `kind`, `recordCount`, `size`). `getLatestPublish()` returns `IGleifPublish` (`publishDate`, `fullFile`, `deltaFiles`). Options include `fetch`, `goldenCopyBaseUrl`, `restBaseUrl`, `userAgent` and `timeoutMs` (default 30 minutes per request, including consumer time). Read calls accept `country`, `jurisdiction`, `signal` and `maxRowChars`. `lookup()` and discovery accept `signal`. `parseGleifRestRecord(json)` maps an already obtained JSON:API response without fetching. `GleifError` exposes `code` and, for HTTP errors, `httpStatus`.
|
|
446
|
+
|
|
447
|
+
GLEIF publishes full files three times daily and deltas for the preceding 8 hours (`IntraDay`), 24 hours (`LastDay`), 7 days (`LastWeek`) and 31 days (`LastMonth`). Level 1 deltas contain the current version of new or changed LEI records. They contain no per-record change kind: this module does not infer insert/update/delete events. The deletion flag described in the manual concerns Level 2 relationships, which this module does not read. A delta overlaps earlier windows; callers own their import checkpoint and idempotent persistence. Discovery selects the CSV format from GLEIF's descriptors rather than constructing file download URLs. Unsupported CDF versions or missing columns fail explicitly.
|
|
448
|
+
|
|
449
|
+
The generated German RA ↔ XJustiz crosswalk uses the official **GLEIF Registration Authorities List v1.9, 2026-09-30**. Its 177 German authorities contain 147 mapped courts and 30 non-court authorities with unmapped reasons. Of opendata's 150 court codes, 147 are mapped; `Y1303` (Arnstadt Zweigstelle Ilmenau), `Y1208` (Pößneck Zweigstelle Bad Lobenstein) and `R1105` (Langenfeld) have no distinct RA in that version. No parent/successor mapping is inferred. `germanRegistrationAuthorities`, `gleifUnmappedCourts` and `gleifRegistrationAuthoritiesSource` expose the list and its provenance. Unknown/unmapped codes and unparseable register identifiers return `undefined`. Register identifiers must state their type (HRA, HRB, GnR, PR, VR or GsR); leading zeros and location suffixes are preserved.
|
|
450
|
+
|
|
451
|
+
Reproduce the code list offline with `tsx scripts/generate.gleif-crosswalk.ts`, or verify it with `--check`. `--refresh` fetches only the small official RA CSV and updates the committed German subset and generated list. For a new RA-list version, update the version/date constants in the generator, review new names and mapping ambiguity, then refresh and run the exhaustive tests. The generator never contacts a register portal.
|
|
452
|
+
|
|
453
|
+
Official sources, read on 2026-10-13:
|
|
454
|
+
|
|
455
|
+
- [Golden Copy and Delta Files overview](https://www.gleif.org/en/lei-data/gleif-golden-copy) and [specification/user manual v2.2](https://www.gleif.org/lei-data/gleif-golden-copy/2022-02-23_gleif-golden-copy-and-delta-files_v2.2-final.pdf), especially §§1.5–1.6 and 2.2: ZIP compression, formats, delta windows and API discovery at `https://goldencopy.gleif.org/api/v2/golden-copies/publishes/latest`. `leidata-preview.gleif.org` is the documented legacy domain for the same API.
|
|
456
|
+
- [LEI-CDF 3.1 format](https://www.gleif.org/en/lei-data/access-and-use-lei-data/level-1-data-lei-cdf-3-1-format) and [official XSD](https://www.gleif.org/lei-data/access-and-use-lei-data/level-1-data-lei-cdf-3-1-format/2021-03-04_lei-cdf-v3-1.xsd). CSV uses the published dotted columns, including `Entity.LegalName`, numbered other/transliterated names, `Entity.RegistrationAuthority.RegistrationAuthorityID` / `RegistrationAuthorityEntityID`, `Entity.LegalForm.EntityLegalFormCode`, addresses and registration dates. The parser reads the header rather than assuming column positions; fixtures preserve all 338 published columns.
|
|
457
|
+
- [GLEIF REST API documentation](https://api.gleif.org/docs): `GET https://api.gleif.org/api/v1/lei-records/{lei}`, JSON:API `data.attributes.entity` and `data.attributes.registration`, including `registeredAt.id` and `registeredAs`.
|
|
458
|
+
- [RA-list publication page](https://www.gleif.org/en/about-lei/code-lists/gleif-registration-authorities-list) and [v1.9 CSV](https://www.gleif.org/lei-data/code-lists/gleif-registration-authorities-list/2026-09-30_ra-list-v1.9.csv).
|
|
459
|
+
- [GLEIF LEI Data Terms of Use](https://www.gleif.org/en/meta/lei-data-terms-of-use): data available through its access service are provided under [CC0 1.0 Universal](https://creativecommons.org/publicdomain/zero/1.0/).
|
|
460
|
+
|
|
461
|
+
Fixtures are 100 records each from the 2026-10-13 00:00 full and intra-day CSVs, publication descriptors, and the REST documentation's single-record response. Tests make no GLEIF requests; ZIPs are created from these small CSV samples in memory. Provenance is in `test/fixtures/gleif/readme.md`. Full-population imports and GLEIF request-rate limits have not been verified; only bounded live samples, documented shapes and offline fixtures were checked.
|
|
462
|
+
|
|
326
463
|
### Register Lookups (Handelsregister)
|
|
327
464
|
|
|
328
465
|
`HandelsregisterClient` speaks to the register portal of the federal states over plain HTTP, the way a browser would: it opens the search form, sends the search, reads the result list and, for one entry, downloads the structured register content (SI, an XJustiz XML file) and parses it.
|
|
@@ -613,11 +750,20 @@ Four holidays apply only in part of a state:
|
|
|
613
750
|
- `bundesbankCpiBaseUrl`, `bundesbankVpiMonthlyKey`, `bundesbankVpiAnnualKey`, `bundesbankVpiFirstMonth`, `bundesbankVpiFirstYear`, `destatisLicence`, `defaultCpiTimeoutMs`
|
|
614
751
|
- `CpiError` with the codes `invalid_input`, `before_first_period`, `not_published`, `base_mismatch`, `series_withdrawn`, `busy`, `unavailable`, `unreachable`, `timeout`, `aborted` and `unexpected_answer`
|
|
615
752
|
|
|
753
|
+
### Financial statements (`@fin.cx/opendata/financialstatements`)
|
|
754
|
+
|
|
755
|
+
- `readFinancialStatement({ filing, bytes, mediaType, now? })`
|
|
756
|
+
- `parseStatementDocument(bytes, mediaType)`
|
|
757
|
+
- `parseEsefPackage(zipBytes)`, `parseEsefReport(xhtmlBytes, reportPath?)`
|
|
758
|
+
- `unternehmensregisterNotice(publishedOn, deposited?)`, `unternehmensregisterOrigin`, `maxStatementBytes`, `maxPdfPages`, `maxEsefFacts`, `maxEsefPackageEntries`
|
|
759
|
+
- the types `IFiling`, `IFinancialStatement`, `IHgbStatement`, `IEsefStatement`, `IKeyFigures`, `IStatementPosition` and their parts
|
|
760
|
+
- `FinancialStatementError` with the codes `invalid_input`, `unsupported_media_type`, `too_large`, `invalid_package`, `unreadable`, `no_text` and `unparseable`
|
|
761
|
+
|
|
616
762
|
## Data Sources
|
|
617
763
|
|
|
618
|
-
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.
|
|
764
|
+
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.
|
|
619
765
|
|
|
620
|
-
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:
|
|
766
|
+
The GLEIF module also carries the generated RA ↔ court crosswalk described above. The package carries two other 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:
|
|
621
767
|
|
|
622
768
|
| Table | Code list | Issued by |
|
|
623
769
|
| --- | --- | --- |
|
|
@@ -674,6 +820,10 @@ The consumer price index of `@fin.cx/opendata/cpi` is read at run time, one requ
|
|
|
674
820
|
|
|
675
821
|
- 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.
|
|
676
822
|
|
|
823
|
+
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.
|
|
824
|
+
|
|
825
|
+
- 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.
|
|
826
|
+
|
|
677
827
|
## Behavior And Caveats
|
|
678
828
|
|
|
679
829
|
- Historical market data is cached aggressively because it is treated as immutable.
|
|
@@ -681,6 +831,7 @@ The consumer price index of `@fin.cx/opendata/cpi` is read at run time, one requ
|
|
|
681
831
|
- `StockDataService` is convenience-first. `StockPriceService` is the better low-level choice if you care about request-shape-specific cache behavior.
|
|
682
832
|
- 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.
|
|
683
833
|
- 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.
|
|
834
|
+
- 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.
|
|
684
835
|
- There is no hosted backend in this package. Everything is meant to run from your own TypeScript/Node process.
|
|
685
836
|
|
|
686
837
|
## Testing
|
|
@@ -689,7 +840,7 @@ The consumer price index of `@fin.cx/opendata/cpi` is read at run time, one requ
|
|
|
689
840
|
pnpm test
|
|
690
841
|
```
|
|
691
842
|
|
|
692
|
-
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.
|
|
843
|
+
GLEIF tests use only small real published fixtures (`test/fixtures/gleif/`) and injected fetches, including cancellation, malformed input, optional names and exhaustive crosswalk coverage. 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.
|
|
693
844
|
|
|
694
845
|
## License and Legal Information
|
|
695
846
|
|
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.9.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
|
+
};
|