@fin.cx/opendata 4.1.0 → 4.3.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 +4 -2
- package/dist_ts/00_commitinfo_data.js +3 -3
- package/dist_ts/baserate/baserate.d.ts +9 -0
- package/dist_ts/baserate/baserate.js +49 -0
- package/dist_ts/baserate/classes.baserateerror.d.ts +14 -0
- package/dist_ts/baserate/classes.baserateerror.js +10 -0
- package/dist_ts/baserate/data.baserate.d.ts +18 -0
- package/dist_ts/baserate/data.baserate.js +63 -0
- package/dist_ts/baserate/index.d.ts +4 -0
- package/dist_ts/baserate/index.js +7 -0
- package/dist_ts/baserate/interfaces.d.ts +19 -0
- package/dist_ts/baserate/interfaces.js +2 -0
- package/dist_ts/vatid/classes.evatrclient.d.ts +15 -0
- package/dist_ts/vatid/classes.evatrclient.js +169 -0
- package/dist_ts/vatid/classes.vatiderror.d.ts +51 -0
- package/dist_ts/vatid/classes.vatiderror.js +29 -0
- package/dist_ts/vatid/classes.viesclient.d.ts +12 -0
- package/dist_ts/vatid/classes.viesclient.js +118 -0
- package/dist_ts/vatid/constants.d.ts +28 -0
- package/dist_ts/vatid/constants.js +37 -0
- package/dist_ts/vatid/index.d.ts +6 -0
- package/dist_ts/vatid/index.js +10 -0
- package/dist_ts/vatid/interfaces.d.ts +141 -0
- package/dist_ts/vatid/interfaces.js +2 -0
- package/dist_ts/vatid/request.d.ts +16 -0
- package/dist_ts/vatid/request.js +65 -0
- package/dist_ts/vatid/vatid.d.ts +8 -0
- package/dist_ts/vatid/vatid.js +22 -0
- package/package.json +16 -6
- package/readme.md +70 -3
- package/ts/00_commitinfo_data.ts +2 -2
- package/ts/baserate/baserate.ts +57 -0
- package/ts/baserate/classes.baserateerror.ts +20 -0
- package/ts/baserate/data.baserate.ts +81 -0
- package/ts/baserate/index.ts +6 -0
- package/ts/baserate/interfaces.ts +21 -0
- package/ts/vatid/classes.evatrclient.ts +182 -0
- package/ts/vatid/classes.vatiderror.ts +83 -0
- package/ts/vatid/classes.viesclient.ts +123 -0
- package/ts/vatid/constants.ts +40 -0
- package/ts/vatid/index.ts +17 -0
- package/ts/vatid/interfaces.ts +154 -0
- package/ts/vatid/request.ts +77 -0
- package/ts/vatid/vatid.ts +25 -0
package/readme.md
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
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,
|
|
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, and the base rate of § 247 BGB. 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 six 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
|
|
9
9
|
- `HandelsregisterClient`, from `@fin.cx/opendata/handelsregister`, for single lookups in the commercial, cooperative, partnership, association and civil-law partnership registers over plain HTTP, without any storage
|
|
10
10
|
- the holiday calendar, from `@fin.cx/opendata/holidays`, for the statutory public holidays of each German federal state and the working days of § 193 BGB, computed per year without data files or requests
|
|
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
|
+
- 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
|
|
11
13
|
|
|
12
14
|
## Issue Reporting and Security
|
|
13
15
|
|
|
@@ -59,6 +61,50 @@ Everything here comes from `@fin.cx/opendata/holidays`. That entry point imports
|
|
|
59
61
|
Holidays and working days in the sense of § 193 BGB: neither a Saturday, nor a Sunday, nor a statutory public holiday.
|
|
60
62
|
- `easterSunday(year)`, `germanStates`, `findGermanState()`
|
|
61
63
|
|
|
64
|
+
### The Base Rate Of § 247 BGB
|
|
65
|
+
|
|
66
|
+
Everything here comes from `@fin.cx/opendata/baserate`. That entry point imports nothing else of the package, has no dependencies, and makes no request.
|
|
67
|
+
|
|
68
|
+
- `getBaseRate(date)`
|
|
69
|
+
The base rate in force on a day: its half-year period with the rate in percent a year.
|
|
70
|
+
- `splitByBaseRate(from, to)`
|
|
71
|
+
A span of days split where the rate changes, as interest under § 288 BGB is computed.
|
|
72
|
+
- `baseRatePeriods`, `baseRateFirstDay`, `baseRatesReadOn`, `baseRateSource`
|
|
73
|
+
|
|
74
|
+
### VAT ID Confirmation
|
|
75
|
+
|
|
76
|
+
Everything here comes from `@fin.cx/opendata/vatid`. That entry point imports nothing else of the package and has no dependencies. It keeps nothing: each answer goes to the caller, with the answer as it arrived (`answer`: the HTTP status and the body, byte for byte), so the caller can keep it as evidence where the law asks for it. For an intra-Community supply that is the books' record of the buyer's foreign VAT ID (§ 17d Abs. 1 UStDV); a confirmation also shows the care of a prudent businessman on which the exemption rests in good faith (§ 6a Abs. 4 UStG).
|
|
77
|
+
|
|
78
|
+
- `EvatrClient#confirm({ requesterVatId, vatId, trader? })`
|
|
79
|
+
The BZSt's confirmation of a foreign VAT ID (§ 18e Nr. 1 UStG). The requester is a business with a German VAT ID. With `trader` (name and city, optionally street and postal code) it is a qualified confirmation. Where the VAT ID is valid the answer carries the member state's result for each field (`qualified.answered: true`; `A` matches, `B` does not match, `C` not asked, `D` not given), or, for `evatr-2008`, that the comparison has a particularity the BZSt explains on request (`qualified.answered: false`). A valid answer to a qualified request without a documented result for each field sent is refused as `unexpected_answer`, never passed on as a simple confirmation.
|
|
80
|
+
- `ViesClient#check({ vatId, requesterVatId?, trader? })`
|
|
81
|
+
The European Commission's VIES check, with the registered name and address where the member state gives them, a comparison of the trader data (`VALID`, `INVALID`, `NOT_PROCESSED`), and VIES's request identifier where the requester is named. The name is VIES's `name`, or, where it gives none, its `traderName`: the contract lists `traderName` in the answer but does not say whether it is the registered name or the one asked about; the test service answers a comparison with the registered name there. Everything else VIES answers is in `answer`.
|
|
82
|
+
- `splitVatId(value)`, `vatIdCountryCodes`, `evatrStatusCodes`
|
|
83
|
+
- `VatIdError` with a code for every reason no answer about validity arrived, and the answer as it arrived where one did (`answer`)
|
|
84
|
+
|
|
85
|
+
Both clients take a `fetch` (for example `createPublicOnlyFetch` of `@push.rocks/smartrequest`), a `baseUrl` for a test server, a `timeoutMs` (30 seconds by default; its timer ends with the request) and a caller's `signal`. Nothing is retried.
|
|
86
|
+
|
|
87
|
+
```typescript
|
|
88
|
+
import { EvatrClient, ViesClient, VatIdError } from '@fin.cx/opendata/vatid';
|
|
89
|
+
|
|
90
|
+
const evatr = new EvatrClient();
|
|
91
|
+
const confirmation = await evatr.confirm({
|
|
92
|
+
requesterVatId: 'DE123456789',
|
|
93
|
+
vatId: 'ATU12345678',
|
|
94
|
+
trader: { name: 'Musterhaus GmbH & Co KG', street: 'Musterstrasse 22', postalCode: '12345', city: 'musterort' },
|
|
95
|
+
});
|
|
96
|
+
// { service: 'bzst-evatr', requestId, requestedAt, status: 'evatr-0000', valid: true,
|
|
97
|
+
// qualified: { answered: true, name: 'A', city: 'A', ... }, answer: { httpStatus: 200, text: '{"id":…}' } }
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
await new ViesClient().check({ vatId: 'ATU12345678', requesterVatId: 'DE123456789' });
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (error instanceof VatIdError) {
|
|
103
|
+
console.log(error.code, error.serviceCode); // for example 'member_state_unavailable', 'MS_UNAVAILABLE'
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
62
108
|
## Before You Start
|
|
63
109
|
|
|
64
110
|
- No stock or fundamentals providers are pre-registered. You must register them before calling the services.
|
|
@@ -450,6 +496,19 @@ Four holidays apply only in part of a state:
|
|
|
450
496
|
- `holidayCalendarFirstYear`, `holidayLawsReadOn`
|
|
451
497
|
- `HolidayCalendarError` with the codes `invalid_date`, `unsupported_year`, `unknown_state` and `invalid_options`
|
|
452
498
|
|
|
499
|
+
### VAT ID (`@fin.cx/opendata/vatid`)
|
|
500
|
+
|
|
501
|
+
- `EvatrClient` with `confirm(input)`; `ViesClient` with `check(input)`
|
|
502
|
+
- `splitVatId(value)`, `vatIdCountryCodes`, `evatrStatusCodes`, `evatrBaseUrl`, `viesBaseUrl`, `defaultVatIdTimeoutMs`
|
|
503
|
+
- `VatIdError` with the codes `invalid_input`, `vat_id_malformed`, `requester_invalid`, `not_permitted`, `qualified_limit_reached`, `unavailable`, `member_state_unavailable`, `busy`, `blocked`, `unreachable`, `timeout`, `aborted` and `unexpected_answer`
|
|
504
|
+
|
|
505
|
+
### Base rate (`@fin.cx/opendata/baserate`)
|
|
506
|
+
|
|
507
|
+
- `getBaseRate(date)`
|
|
508
|
+
- `splitByBaseRate(from, to)`
|
|
509
|
+
- `baseRatePeriods`, `baseRateFirstDay`, `baseRatesReadOn`, `baseRateSource`
|
|
510
|
+
- `BaseRateError` with the codes `invalid_date`, `invalid_range`, `before_first_period` and `not_published`
|
|
511
|
+
|
|
453
512
|
## Data Sources
|
|
454
513
|
|
|
455
514
|
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.
|
|
@@ -497,6 +556,14 @@ The holiday calendar is derived from the holiday laws of the federal states, rea
|
|
|
497
556
|
| Thüringen | [Thüringer Feier- und Gedenktagsgesetz (ThürFGtG)](https://landesrecht.thueringen.de/perma?j=FeiertG_TH_!_2) | § 2, § 10 Abs. 1 |
|
|
498
557
|
| all | [Bürgerliches Gesetzbuch](https://www.gesetze-im-internet.de/bgb/__193.html) | § 193 (Sonntag, Feiertag, Sonnabend) |
|
|
499
558
|
|
|
559
|
+
The base rate of `@fin.cx/opendata/baserate` (`ts/baserate/data.baserate.ts`) is the table "Basiszinssatz nach § 247 BGB" of the Deutsche Bundesbank (https://www.bundesbank.de/de/bundesbank/organisation/agb-und-regelungen/basiszinssatz-607820), which the Bundesbank publishes under § 247 Abs. 2 BGB, read on 2026-09-27 and reproduced unaltered: every half-year from 1 July 2002, the first row it lists, to the half-year from 1 July 2026 (1,52 %). The rate changes on 1 January and 1 July (§ 247 Abs. 1 Satz 2 BGB); a day after 31 December 2026 answers `not_published` until a release carries the next row.
|
|
560
|
+
|
|
561
|
+
The VAT ID clients of `@fin.cx/opendata/vatid` ask two official services at run time, one request at a time, read on 2026-09-27:
|
|
562
|
+
|
|
563
|
+
- The BZSt's eVatR REST API (`https://api.evatr.vies.bzst.de/app`, OpenAPI at `https://api.evatr.vies.bzst.de/api-docs`, version "v:1.2.3.19.886"): `POST /v1/abfrage` with `anfragendeUstid`, `angefragteUstid` and, for a qualified confirmation, `firmenname` and `ort` (and optionally `strasse`, `plz`). The status codes are those of `/v1/info/statusmeldungen`, and the country codes those of `/v1/info/eu_mitgliedstaaten`. The BZSt's page on foreign VAT IDs names the online form (`https://www.bzst.de/evatr`) and the REST API, and says the former XML-RPC interface is obsolete from 30 November 2025.
|
|
564
|
+
- The European Commission's VIES REST API (`https://ec.europa.eu/taxation_customs/vies/rest-api`, contract `swagger_publicVAT.yaml`): `POST /check-vat-number`. Its errors arrive with HTTP 200 and `actionSucceed: false`, as its test service (`/check-vat-test-service`) shows.
|
|
565
|
+
- Not verified: the terms of use of either service, whether eVatR needs a registration (its documentation names none), how many qualified confirmations the BZSt allows per session (`evatr-0008`), and the form of `gueltigAb`/`gueltigBis`, which are passed on as the BZSt writes them. The OpenAPI document lists `evatr-0008` under HTTP 400 and the status list under 403, so the client decides by the code.
|
|
566
|
+
|
|
500
567
|
## Behavior And Caveats
|
|
501
568
|
|
|
502
569
|
- Historical market data is cached aggressively because it is treated as immutable.
|
|
@@ -512,7 +579,7 @@ The holiday calendar is derived from the holiday laws of the federal states, rea
|
|
|
512
579
|
pnpm test
|
|
513
580
|
```
|
|
514
581
|
|
|
515
|
-
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.
|
|
582
|
+
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.
|
|
516
583
|
|
|
517
584
|
## License and Legal Information
|
|
518
585
|
|
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, and stock and crypto prices and fundamentals from several providers.'
|
|
6
|
+
version: '4.3.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, and stock and crypto prices and fundamentals from several providers.'
|
|
8
8
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { BaseRateError } from './classes.baserateerror.js';
|
|
2
|
+
import { baseRateFirstDay, baseRatePeriods, baseRatesReadOn } from './data.baserate.js';
|
|
3
|
+
import type { IBaseRatePeriod, IBaseRateSpan, TIsoDate } from './interfaces.js';
|
|
4
|
+
|
|
5
|
+
const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
6
|
+
|
|
7
|
+
const checkDate = (dateArg: TIsoDate): void => {
|
|
8
|
+
const match = typeof dateArg === 'string' ? ISO_DATE.exec(dateArg) : null;
|
|
9
|
+
const utc = match ? new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]))) : null;
|
|
10
|
+
if (!match || !utc || utc.toISOString().slice(0, 10) !== dateArg) {
|
|
11
|
+
throw new BaseRateError('invalid_date', `${JSON.stringify(dateArg)} is not a calendar date written YYYY-MM-DD.`);
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const addDay = (dateArg: TIsoDate): TIsoDate => {
|
|
16
|
+
const utc = new Date(`${dateArg}T00:00:00Z`);
|
|
17
|
+
utc.setUTCDate(utc.getUTCDate() + 1);
|
|
18
|
+
return utc.toISOString().slice(0, 10);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** The base rate in force on a day: the published period that contains it. */
|
|
22
|
+
export const getBaseRate = (dateArg: TIsoDate): IBaseRatePeriod => {
|
|
23
|
+
checkDate(dateArg);
|
|
24
|
+
if (dateArg < baseRateFirstDay) {
|
|
25
|
+
throw new BaseRateError('before_first_period', `${dateArg} lies before ${baseRateFirstDay}, the first day of the Bundesbank's table.`);
|
|
26
|
+
}
|
|
27
|
+
const period = baseRatePeriods.find((periodArg) => periodArg.validFrom <= dateArg && dateArg <= periodArg.validTo);
|
|
28
|
+
if (!period) {
|
|
29
|
+
throw new BaseRateError(
|
|
30
|
+
'not_published',
|
|
31
|
+
`${dateArg} lies after ${baseRatePeriods[baseRatePeriods.length - 1].validTo}; the base rate of its half-year was not published when the table was read on ${baseRatesReadOn}.`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
return period;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A span of days, both inclusive, split where the base rate changes: one piece per published
|
|
39
|
+
* period it touches, oldest first. Interest under § 288 BGB is computed per piece, since the rate
|
|
40
|
+
* of each half-year applies to the days in it.
|
|
41
|
+
*/
|
|
42
|
+
export const splitByBaseRate = (fromArg: TIsoDate, toArg: TIsoDate): IBaseRateSpan[] => {
|
|
43
|
+
checkDate(fromArg);
|
|
44
|
+
checkDate(toArg);
|
|
45
|
+
if (toArg < fromArg) {
|
|
46
|
+
throw new BaseRateError('invalid_range', `${toArg} lies before ${fromArg}.`);
|
|
47
|
+
}
|
|
48
|
+
const spans: IBaseRateSpan[] = [];
|
|
49
|
+
let day = fromArg;
|
|
50
|
+
while (day <= toArg) {
|
|
51
|
+
const period = getBaseRate(day);
|
|
52
|
+
const to = period.validTo < toArg ? period.validTo : toArg;
|
|
53
|
+
spans.push({ from: day, to, ratePercent: period.ratePercent });
|
|
54
|
+
day = addDay(to);
|
|
55
|
+
}
|
|
56
|
+
return spans;
|
|
57
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Why a question to the base-rate table was refused:
|
|
3
|
+
* - `invalid_date`: the value is not a calendar date written `YYYY-MM-DD`;
|
|
4
|
+
* - `invalid_range`: a span ends before it begins;
|
|
5
|
+
* - `before_first_period`: the day lies before the first period the table holds (1 July 2002);
|
|
6
|
+
* - `not_published`: the day lies after the half-year of the last period the table holds, whose
|
|
7
|
+
* rate the Bundesbank had not published when the table was read (`baseRatesReadOn`).
|
|
8
|
+
*/
|
|
9
|
+
export type TBaseRateErrorCode = 'invalid_date' | 'invalid_range' | 'before_first_period' | 'not_published';
|
|
10
|
+
|
|
11
|
+
/** The typed error of the base-rate table. */
|
|
12
|
+
export class BaseRateError extends Error {
|
|
13
|
+
public readonly code: TBaseRateErrorCode;
|
|
14
|
+
|
|
15
|
+
constructor(code: TBaseRateErrorCode, message: string) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = 'BaseRateError';
|
|
18
|
+
this.code = code;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The base rate (Basiszinssatz) of § 247 BGB, as the Deutsche Bundesbank publishes it: "Gemäß
|
|
3
|
+
* § 247 Abs. 2 BGB ist die Deutsche Bundesbank verpflichtet, den aktuellen Stand des
|
|
4
|
+
* Basiszinssatzes im Bundesanzeiger zu veröffentlichen." It changes on 1 January and 1 July
|
|
5
|
+
* (§ 247 Abs. 1 Satz 2 BGB). The table below is the Bundesbank's table "Basiszinssatz nach § 247
|
|
6
|
+
* BGB" (https://www.bundesbank.de/de/bundesbank/organisation/agb-und-regelungen/basiszinssatz-607820),
|
|
7
|
+
* read on 2026-09-27, unaltered: every row from 1 July 2002, the first one it lists, to the
|
|
8
|
+
* current one. A later half-year needs a new release.
|
|
9
|
+
*/
|
|
10
|
+
import type { IBaseRatePeriod } from './interfaces.js';
|
|
11
|
+
|
|
12
|
+
/** the first day the table answers for */
|
|
13
|
+
export const baseRateFirstDay = '2002-07-01';
|
|
14
|
+
|
|
15
|
+
/** the day the Bundesbank's table was read; later half-years need a new release */
|
|
16
|
+
export const baseRatesReadOn = '2026-09-27';
|
|
17
|
+
|
|
18
|
+
/** the page the table was read from */
|
|
19
|
+
export const baseRateSource = 'https://www.bundesbank.de/de/bundesbank/organisation/agb-und-regelungen/basiszinssatz-607820';
|
|
20
|
+
|
|
21
|
+
const rows: ReadonlyArray<readonly [string, number]> = [
|
|
22
|
+
// gültig ab, Basiszinssatz in Prozent, as the table of the Deutsche Bundesbank states them
|
|
23
|
+
['2002-07-01', 2.47],
|
|
24
|
+
['2003-01-01', 1.97],
|
|
25
|
+
['2003-07-01', 1.22],
|
|
26
|
+
['2004-01-01', 1.14],
|
|
27
|
+
['2004-07-01', 1.13],
|
|
28
|
+
['2005-01-01', 1.21],
|
|
29
|
+
['2005-07-01', 1.17],
|
|
30
|
+
['2006-01-01', 1.37],
|
|
31
|
+
['2006-07-01', 1.95],
|
|
32
|
+
['2007-01-01', 2.70],
|
|
33
|
+
['2007-07-01', 3.19],
|
|
34
|
+
['2008-01-01', 3.32],
|
|
35
|
+
['2008-07-01', 3.19],
|
|
36
|
+
['2009-01-01', 1.62],
|
|
37
|
+
['2009-07-01', 0.12],
|
|
38
|
+
['2010-01-01', 0.12],
|
|
39
|
+
['2010-07-01', 0.12],
|
|
40
|
+
['2011-01-01', 0.12],
|
|
41
|
+
['2011-07-01', 0.37],
|
|
42
|
+
['2012-01-01', 0.12],
|
|
43
|
+
['2012-07-01', 0.12],
|
|
44
|
+
['2013-01-01', -0.13],
|
|
45
|
+
['2013-07-01', -0.38],
|
|
46
|
+
['2014-01-01', -0.63],
|
|
47
|
+
['2014-07-01', -0.73],
|
|
48
|
+
['2015-01-01', -0.83],
|
|
49
|
+
['2015-07-01', -0.83],
|
|
50
|
+
['2016-01-01', -0.83],
|
|
51
|
+
['2016-07-01', -0.88],
|
|
52
|
+
['2017-01-01', -0.88],
|
|
53
|
+
['2017-07-01', -0.88],
|
|
54
|
+
['2018-01-01', -0.88],
|
|
55
|
+
['2018-07-01', -0.88],
|
|
56
|
+
['2019-01-01', -0.88],
|
|
57
|
+
['2019-07-01', -0.88],
|
|
58
|
+
['2020-01-01', -0.88],
|
|
59
|
+
['2020-07-01', -0.88],
|
|
60
|
+
['2021-01-01', -0.88],
|
|
61
|
+
['2021-07-01', -0.88],
|
|
62
|
+
['2022-01-01', -0.88],
|
|
63
|
+
['2022-07-01', -0.88],
|
|
64
|
+
['2023-01-01', 1.62],
|
|
65
|
+
['2023-07-01', 3.12],
|
|
66
|
+
['2024-01-01', 3.62],
|
|
67
|
+
['2024-07-01', 3.37],
|
|
68
|
+
['2025-01-01', 2.27],
|
|
69
|
+
['2025-07-01', 1.27],
|
|
70
|
+
['2026-01-01', 1.27],
|
|
71
|
+
['2026-07-01', 1.52],
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
/** the last day of the half-year a period begins in */
|
|
75
|
+
const halfYearEndOf = (validFromArg: string): string =>
|
|
76
|
+
`${validFromArg.slice(0, 4)}-${validFromArg.slice(5, 7) === '01' ? '06-30' : '12-31'}`;
|
|
77
|
+
|
|
78
|
+
/** every published period, oldest first */
|
|
79
|
+
export const baseRatePeriods: readonly IBaseRatePeriod[] = Object.freeze(
|
|
80
|
+
rows.map(([validFrom, ratePercent]) => Object.freeze({ validFrom, validTo: halfYearEndOf(validFrom), ratePercent })),
|
|
81
|
+
);
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// This entry point stands alone: it imports nothing from the rest of the package, has no
|
|
2
|
+
// dependencies and needs no storage or network.
|
|
3
|
+
export * from './interfaces.js';
|
|
4
|
+
export { BaseRateError, type TBaseRateErrorCode } from './classes.baserateerror.js';
|
|
5
|
+
export { baseRateFirstDay, baseRatePeriods, baseRateSource, baseRatesReadOn } from './data.baserate.js';
|
|
6
|
+
export { getBaseRate, splitByBaseRate } from './baserate.js';
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** A plain calendar date, `YYYY-MM-DD`. */
|
|
2
|
+
export type TIsoDate = string;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One half-year's base rate (Basiszinssatz, § 247 BGB) as the Deutsche
|
|
6
|
+
* Bundesbank publishes it: valid from 1 January or 1 July to the last day of
|
|
7
|
+
* that half-year, both inclusive.
|
|
8
|
+
*/
|
|
9
|
+
export interface IBaseRatePeriod {
|
|
10
|
+
validFrom: TIsoDate;
|
|
11
|
+
validTo: TIsoDate;
|
|
12
|
+
/** percent a year; it can be negative (2013 to 2022) */
|
|
13
|
+
ratePercent: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A span of days within one base-rate period, both days inclusive. */
|
|
17
|
+
export interface IBaseRateSpan {
|
|
18
|
+
from: TIsoDate;
|
|
19
|
+
to: TIsoDate;
|
|
20
|
+
ratePercent: number;
|
|
21
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { VatIdError, type TVatIdErrorCode } from './classes.vatiderror.js';
|
|
2
|
+
import { evatrBaseUrl, evatrConfirmationPath, evatrStatusCodes } from './constants.js';
|
|
3
|
+
import type {
|
|
4
|
+
IEvatrConfirmation,
|
|
5
|
+
IEvatrConfirmationInput,
|
|
6
|
+
IVatIdClientOptions,
|
|
7
|
+
IVatIdTraderData,
|
|
8
|
+
TEvatrFieldResult,
|
|
9
|
+
TEvatrQualifiedResult,
|
|
10
|
+
TEvatrStatusCode,
|
|
11
|
+
} from './interfaces.js';
|
|
12
|
+
import { postJson, textField } from './request.js';
|
|
13
|
+
import { splitVatId } from './vatid.js';
|
|
14
|
+
|
|
15
|
+
/** The status codes that are an answer about the VAT ID asked about, and whether they say it is valid. */
|
|
16
|
+
const ANSWERS: Readonly<Partial<Record<TEvatrStatusCode, boolean>>> = {
|
|
17
|
+
'evatr-0000': true,
|
|
18
|
+
'evatr-2008': true,
|
|
19
|
+
'evatr-2001': false,
|
|
20
|
+
'evatr-2002': false,
|
|
21
|
+
'evatr-2006': false,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** What every other status code means for the caller (`VatIdError`). */
|
|
25
|
+
const REFUSALS: Readonly<Partial<Record<TEvatrStatusCode, TVatIdErrorCode>>> = {
|
|
26
|
+
'evatr-0001': 'invalid_input',
|
|
27
|
+
'evatr-0002': 'invalid_input',
|
|
28
|
+
'evatr-0003': 'invalid_input',
|
|
29
|
+
'evatr-0004': 'requester_invalid',
|
|
30
|
+
'evatr-0005': 'vat_id_malformed',
|
|
31
|
+
'evatr-0006': 'not_permitted',
|
|
32
|
+
'evatr-0007': 'invalid_input',
|
|
33
|
+
'evatr-0008': 'qualified_limit_reached',
|
|
34
|
+
'evatr-0011': 'unavailable',
|
|
35
|
+
'evatr-0012': 'vat_id_malformed',
|
|
36
|
+
'evatr-0013': 'unavailable',
|
|
37
|
+
'evatr-1001': 'unavailable',
|
|
38
|
+
'evatr-1002': 'unavailable',
|
|
39
|
+
'evatr-1003': 'unavailable',
|
|
40
|
+
'evatr-1004': 'unavailable',
|
|
41
|
+
'evatr-2003': 'vat_id_malformed',
|
|
42
|
+
'evatr-2004': 'unavailable',
|
|
43
|
+
'evatr-2005': 'requester_invalid',
|
|
44
|
+
'evatr-2007': 'member_state_unavailable',
|
|
45
|
+
'evatr-2011': 'unavailable',
|
|
46
|
+
'evatr-3011': 'unavailable',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const FIELD_RESULTS: readonly TEvatrFieldResult[] = ['A', 'B', 'C', 'D'];
|
|
50
|
+
|
|
51
|
+
const fieldResult = (bodyArg: unknown, keyArg: string): TEvatrFieldResult | undefined => {
|
|
52
|
+
const value = textField(bodyArg, keyArg);
|
|
53
|
+
return value !== undefined && (FIELD_RESULTS as readonly string[]).includes(value) ? (value as TEvatrFieldResult) : undefined;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The BZSt's confirmation of foreign VAT IDs (§ 18e Nr. 1 UStG) over its eVatR REST API
|
|
58
|
+
* (`https://api.evatr.vies.bzst.de/api-docs`). A simple confirmation says whether a VAT ID is valid
|
|
59
|
+
* when asked; a qualified one also compares the business's name, city, and where given street and
|
|
60
|
+
* postal code with what its member state registered. The BZSt confirms to a business with a German
|
|
61
|
+
* VAT ID of its own, which every request names. Nothing is kept: each answer goes to the caller.
|
|
62
|
+
*/
|
|
63
|
+
export class EvatrClient {
|
|
64
|
+
constructor(private readonly options: IVatIdClientOptions = {}) {}
|
|
65
|
+
|
|
66
|
+
public async confirm(inputArg: IEvatrConfirmationInput): Promise<IEvatrConfirmation> {
|
|
67
|
+
const requester = splitVatId(inputArg.requesterVatId);
|
|
68
|
+
if (!requester || requester.countryCode !== 'DE') {
|
|
69
|
+
throw new VatIdError('invalid_input', 'the requester is named by its German VAT ID', { service: 'bzst-evatr', field: 'requesterVatId' });
|
|
70
|
+
}
|
|
71
|
+
const asked = splitVatId(inputArg.vatId);
|
|
72
|
+
if (!asked) {
|
|
73
|
+
throw new VatIdError('invalid_input', 'the VAT ID asked about starts with no member state\'s code', { service: 'bzst-evatr', field: 'vatId' });
|
|
74
|
+
}
|
|
75
|
+
const trader = inputArg.trader ? traderOf(inputArg.trader) : undefined;
|
|
76
|
+
if (inputArg.trader && !trader) {
|
|
77
|
+
throw new VatIdError('invalid_input', 'a qualified confirmation needs the name and the city', { service: 'bzst-evatr', field: 'trader' });
|
|
78
|
+
}
|
|
79
|
+
const answer = await postJson(this.options, 'bzst-evatr', `${this.options.baseUrl ?? evatrBaseUrl}${evatrConfirmationPath}`, {
|
|
80
|
+
anfragendeUstid: requester.vatId,
|
|
81
|
+
angefragteUstid: asked.vatId,
|
|
82
|
+
...(trader
|
|
83
|
+
? {
|
|
84
|
+
firmenname: trader.name,
|
|
85
|
+
ort: trader.city,
|
|
86
|
+
...(trader.street ? { strasse: trader.street } : {}),
|
|
87
|
+
...(trader.postalCode ? { plz: trader.postalCode } : {}),
|
|
88
|
+
}
|
|
89
|
+
: {}),
|
|
90
|
+
}, inputArg.signal);
|
|
91
|
+
const status = textField(answer.body, 'status');
|
|
92
|
+
if (!status || !(evatrStatusCodes as readonly string[]).includes(status)) {
|
|
93
|
+
throw new VatIdError('unexpected_answer', `the BZSt answered HTTP ${answer.httpStatus} without a status it documents`, {
|
|
94
|
+
service: 'bzst-evatr',
|
|
95
|
+
httpStatus: answer.httpStatus,
|
|
96
|
+
...(status ? { serviceCode: status } : {}),
|
|
97
|
+
answer: answer.answer,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
const code = status as TEvatrStatusCode;
|
|
101
|
+
const valid = ANSWERS[code];
|
|
102
|
+
if (valid === undefined) {
|
|
103
|
+
throw new VatIdError(REFUSALS[code] ?? 'unexpected_answer', `the BZSt answered ${code}`, {
|
|
104
|
+
service: 'bzst-evatr',
|
|
105
|
+
serviceCode: code,
|
|
106
|
+
httpStatus: answer.httpStatus,
|
|
107
|
+
answer: answer.answer,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
const requestedAt = textField(answer.body, 'anfrageZeitpunkt');
|
|
111
|
+
const requestedAtMs = requestedAt ? Date.parse(requestedAt) : Number.NaN;
|
|
112
|
+
if (!requestedAt || Number.isNaN(requestedAtMs)) {
|
|
113
|
+
throw new VatIdError('unexpected_answer', 'the BZSt answered without the moment of the request', {
|
|
114
|
+
service: 'bzst-evatr',
|
|
115
|
+
serviceCode: code,
|
|
116
|
+
httpStatus: answer.httpStatus,
|
|
117
|
+
answer: answer.answer,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
const requestId = textField(answer.body, 'id');
|
|
121
|
+
const validFrom = textField(answer.body, 'gueltigAb');
|
|
122
|
+
const validTo = textField(answer.body, 'gueltigBis');
|
|
123
|
+
const qualified = trader && valid ? qualifiedResultOf(trader, code, answer.body) : undefined;
|
|
124
|
+
if (qualified === null) {
|
|
125
|
+
// a qualified request answered valid without a result for each field sent would read as a simple
|
|
126
|
+
// confirmation; it is refused instead of being passed on as one
|
|
127
|
+
throw new VatIdError('unexpected_answer', `the BZSt answered ${code} to a qualified request without a documented result for each field sent`, {
|
|
128
|
+
service: 'bzst-evatr',
|
|
129
|
+
serviceCode: code,
|
|
130
|
+
httpStatus: answer.httpStatus,
|
|
131
|
+
answer: answer.answer,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
service: 'bzst-evatr',
|
|
136
|
+
...(requestId ? { requestId } : {}),
|
|
137
|
+
requestedAt,
|
|
138
|
+
requestedAtMs,
|
|
139
|
+
status: code as IEvatrConfirmation['status'],
|
|
140
|
+
valid,
|
|
141
|
+
...(validFrom ? { validFrom } : {}),
|
|
142
|
+
...(validTo ? { validTo } : {}),
|
|
143
|
+
requesterVatId: requester.vatId,
|
|
144
|
+
vatId: asked.vatId,
|
|
145
|
+
...(qualified ? { qualified } : {}),
|
|
146
|
+
answer: answer.answer,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The comparison of a qualified request whose VAT ID is valid: `evatr-2008` says it has a
|
|
153
|
+
* particularity and gives no result (`answered: false`); otherwise every field sent has a documented
|
|
154
|
+
* result (`A`…`D`), or the answer is not one this client reads (`null`). A result the BZSt gives for a
|
|
155
|
+
* field not sent is kept as it is.
|
|
156
|
+
*/
|
|
157
|
+
const qualifiedResultOf = (traderArg: IVatIdTraderData, codeArg: TEvatrStatusCode, bodyArg: unknown): TEvatrQualifiedResult | null => {
|
|
158
|
+
if (codeArg === 'evatr-2008') {
|
|
159
|
+
return { asked: traderArg, answered: false };
|
|
160
|
+
}
|
|
161
|
+
const name = fieldResult(bodyArg, 'ergFirmenname');
|
|
162
|
+
const city = fieldResult(bodyArg, 'ergOrt');
|
|
163
|
+
// a field not sent may still carry its result (`C`, not asked); one sent must
|
|
164
|
+
const street = fieldResult(bodyArg, 'ergStrasse');
|
|
165
|
+
const postalCode = fieldResult(bodyArg, 'ergPlz');
|
|
166
|
+
if (!name || !city || (traderArg.street && !street) || (traderArg.postalCode && !postalCode)) {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
return { asked: traderArg, answered: true, name, city, ...(street ? { street } : {}), ...(postalCode ? { postalCode } : {}) };
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
/** The business data as a qualified confirmation sends it: trimmed, with the name and the city required. */
|
|
173
|
+
export const traderOf = (traderArg: IVatIdTraderData): IVatIdTraderData | undefined => {
|
|
174
|
+
const name = traderArg.name.trim();
|
|
175
|
+
const city = traderArg.city.trim();
|
|
176
|
+
if (!name || !city) {
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
const street = traderArg.street?.trim();
|
|
180
|
+
const postalCode = traderArg.postalCode?.trim();
|
|
181
|
+
return { name, city, ...(street ? { street } : {}), ...(postalCode ? { postalCode } : {}) };
|
|
182
|
+
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { IVatIdServiceAnswer } from './interfaces.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Why a VAT ID could not be confirmed. Every code means that the service gave no answer about the
|
|
5
|
+
* validity of the VAT ID asked about:
|
|
6
|
+
* - `invalid_input`: what was asked is incomplete or not a VAT ID of a member state (checked before
|
|
7
|
+
* sending, or refused by the service: eVatR `evatr-0002`, `evatr-0003`, `evatr-0007`, VIES
|
|
8
|
+
* `INVALID_INPUT`);
|
|
9
|
+
* - `vat_id_malformed`: the service says the VAT ID asked about does not have the form its member
|
|
10
|
+
* state gives (eVatR `evatr-0005`, `evatr-0012`, `evatr-2003`);
|
|
11
|
+
* - `requester_invalid`: the requester's own VAT ID is malformed or not valid (eVatR `evatr-0004`,
|
|
12
|
+
* `evatr-2005`, VIES `INVALID_REQUESTER_INFO`);
|
|
13
|
+
* - `not_permitted`: a German VAT ID asked about by a German one (eVatR `evatr-0006`);
|
|
14
|
+
* - `qualified_limit_reached`: the qualified confirmations of this session are used up (eVatR
|
|
15
|
+
* `evatr-0008`); a simple one is still possible;
|
|
16
|
+
* - `unavailable`: the service cannot answer now (eVatR `evatr-0011`, `-0013`, `-1001`…`-1004`,
|
|
17
|
+
* `-2004`, `-2011`, `-3011`; VIES `SERVICE_UNAVAILABLE`);
|
|
18
|
+
* - `member_state_unavailable`: the member state's system did not answer or failed (eVatR
|
|
19
|
+
* `evatr-2007`; VIES `MS_UNAVAILABLE`, `TIMEOUT`);
|
|
20
|
+
* - `busy`: too many requests at once (VIES `GLOBAL_MAX_CONCURRENT_REQ`, `MS_MAX_CONCURRENT_REQ`
|
|
21
|
+
* and their `_TIME` forms);
|
|
22
|
+
* - `blocked`: the service blocks this address or this VAT ID (VIES `IP_BLOCKED`, `VAT_BLOCKED`);
|
|
23
|
+
* - `unreachable`: no answer arrived — the name, the connection or TLS failed;
|
|
24
|
+
* - `timeout`: the answer did not arrive within the client's time;
|
|
25
|
+
* - `aborted`: the caller's signal ended the request;
|
|
26
|
+
* - `unexpected_answer`: an answer this client cannot read, or a status it does not know.
|
|
27
|
+
*/
|
|
28
|
+
export type TVatIdErrorCode =
|
|
29
|
+
| 'invalid_input'
|
|
30
|
+
| 'vat_id_malformed'
|
|
31
|
+
| 'requester_invalid'
|
|
32
|
+
| 'not_permitted'
|
|
33
|
+
| 'qualified_limit_reached'
|
|
34
|
+
| 'unavailable'
|
|
35
|
+
| 'member_state_unavailable'
|
|
36
|
+
| 'busy'
|
|
37
|
+
| 'blocked'
|
|
38
|
+
| 'unreachable'
|
|
39
|
+
| 'timeout'
|
|
40
|
+
| 'aborted'
|
|
41
|
+
| 'unexpected_answer';
|
|
42
|
+
|
|
43
|
+
export interface IVatIdErrorDetails {
|
|
44
|
+
service: 'bzst-evatr' | 'vies';
|
|
45
|
+
/** the service's own code: `evatr-0008`, `MS_UNAVAILABLE` */
|
|
46
|
+
serviceCode?: string;
|
|
47
|
+
/** the HTTP status of the answer, where one arrived */
|
|
48
|
+
httpStatus?: number;
|
|
49
|
+
/** which input a local check refused */
|
|
50
|
+
field?: 'requesterVatId' | 'vatId' | 'trader';
|
|
51
|
+
/** the answer as it arrived, where one did */
|
|
52
|
+
answer?: IVatIdServiceAnswer;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The typed error of the VAT ID clients. */
|
|
56
|
+
export class VatIdError extends Error {
|
|
57
|
+
public readonly code: TVatIdErrorCode;
|
|
58
|
+
public readonly service: IVatIdErrorDetails['service'];
|
|
59
|
+
public readonly serviceCode?: string;
|
|
60
|
+
public readonly httpStatus?: number;
|
|
61
|
+
public readonly field?: IVatIdErrorDetails['field'];
|
|
62
|
+
/** the answer as it arrived, where one did: a refusal, or an answer this client cannot read */
|
|
63
|
+
public readonly answer?: IVatIdServiceAnswer;
|
|
64
|
+
|
|
65
|
+
constructor(code: TVatIdErrorCode, message: string, detailsArg: IVatIdErrorDetails, options?: { cause?: unknown }) {
|
|
66
|
+
super(message, options);
|
|
67
|
+
this.name = 'VatIdError';
|
|
68
|
+
this.code = code;
|
|
69
|
+
this.service = detailsArg.service;
|
|
70
|
+
if (detailsArg.serviceCode !== undefined) {
|
|
71
|
+
this.serviceCode = detailsArg.serviceCode;
|
|
72
|
+
}
|
|
73
|
+
if (detailsArg.httpStatus !== undefined) {
|
|
74
|
+
this.httpStatus = detailsArg.httpStatus;
|
|
75
|
+
}
|
|
76
|
+
if (detailsArg.field !== undefined) {
|
|
77
|
+
this.field = detailsArg.field;
|
|
78
|
+
}
|
|
79
|
+
if (detailsArg.answer !== undefined) {
|
|
80
|
+
this.answer = detailsArg.answer;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|