@fin.cx/tax 1.1.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/index.d.ts +2 -0
- package/dist_ts/index.js +3 -1
- package/dist_ts/rates.d.ts +112 -0
- package/dist_ts/rates.js +136 -0
- package/dist_ts/ustva.accounts.d.ts +3 -1
- package/dist_ts/ustva.accounts.js +4 -2
- package/dist_ts/ustva.d.ts +17 -0
- package/dist_ts/ustva.js +33 -6
- package/dist_ts/ustva.types.d.ts +6 -0
- package/dist_ts/zm.d.ts +92 -0
- package/dist_ts/zm.js +100 -0
- package/package.json +6 -6
- package/readme.md +25 -2
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/index.ts +2 -0
- package/ts/rates.ts +180 -0
- package/ts/ustva.accounts.ts +6 -2
- package/ts/ustva.ts +36 -5
- package/ts/ustva.types.ts +6 -0
- package/ts/zm.ts +166 -0
package/ts/zm.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Zusammenfassende Meldung (§ 18a UStG): its lines, and the CSV the
|
|
3
|
+
* ELSTER (or BZSt-Online-Portal) online form imports. Pure; the caller
|
|
4
|
+
* decides which supplies of which period go in (§ 18a Abs. 1, 2, 8 UStG) and
|
|
5
|
+
* transmits the form itself, authenticated, as § 18a requires.
|
|
6
|
+
*
|
|
7
|
+
* Sources, read 2026-09-25:
|
|
8
|
+
* - ELSTER, "Import von Meldelisten zur Zusammenfassenden Meldung"
|
|
9
|
+
* (helpGlobal `zmdo_import_eop`, CSV version 3.0) and the general
|
|
10
|
+
* "Anleitung zur Erstellung von CSV-Dateien für die Importfunktion":
|
|
11
|
+
* UTF-8; line 1 `#v3.0`; the form's example adds `#ve3.2.1`; one line of
|
|
12
|
+
* field names; then the lines, one per record "Meldezeilen (Satzart 1)":
|
|
13
|
+
* USt-IdNr. AN(7,14) beginning with a country code of the list; Summe
|
|
14
|
+
* (Euro) N(11), "eine ganze Zahl", minus allowed; Art der Leistung A(1),
|
|
15
|
+
* `L` innergemeinschaftliche Lieferung, `S` sonstige Leistung, `D`
|
|
16
|
+
* Dreiecksgeschäft. At most 1 500 lines. The example separates its lines
|
|
17
|
+
* with a line feed and its fields with `,`.
|
|
18
|
+
* - BZSt, "Zusammenfassende Meldung - Fragen und Antworten"
|
|
19
|
+
* (bzst.de …/ZusammenfassendeMeldung/FAQ/faq_zm.html, read 2026-09-27),
|
|
20
|
+
* Brexit: a Northern Irish business has a VAT ID with `XI`; the ZM states
|
|
21
|
+
* the intra-Community supplies, triangular trades and § 6b transfers to it,
|
|
22
|
+
* while services to recipients in Northern Ireland follow the rules for the
|
|
23
|
+
* third-country territory and are not reported (Art. 8 of the Protocol on
|
|
24
|
+
* Ireland/Northern Ireland covers goods only).
|
|
25
|
+
* - BZSt, "Anleitung zur Zusammenfassenden Meldung" (USt ZM 1/2021): one line
|
|
26
|
+
* per recipient and kind in a period, the bases summed (Tz. 8, 11); a later
|
|
27
|
+
* change of the base netted into the period of the change, possibly
|
|
28
|
+
* negative (Tz. 10); no "Nullmeldungen" (III.1); a correction states the
|
|
29
|
+
* correct totals, and `0` to storno a line (Tz. 12.2, 12.3).
|
|
30
|
+
*/
|
|
31
|
+
import type { TCents } from '@fin.cx/calculation';
|
|
32
|
+
|
|
33
|
+
/** `L` innergemeinschaftliche Warenlieferung, `S` sonstige Leistung (§ 3a Abs. 2), `D` Dreiecksgeschäft (§ 25b Abs. 2) */
|
|
34
|
+
export type TZmKind = 'L' | 'S' | 'D';
|
|
35
|
+
|
|
36
|
+
/** One supply (or a later change of its base) of the period: the recipient's VAT ID, its kind, the base in euro cents */
|
|
37
|
+
export interface IZmSupplyInput {
|
|
38
|
+
vatId: string;
|
|
39
|
+
kind: TZmKind;
|
|
40
|
+
/** signed: a later reduction of the base is negative */
|
|
41
|
+
baseCents: TCents;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** One line of the report: the recipient, the kind, the summed base in euro cents */
|
|
45
|
+
export interface IZmLine {
|
|
46
|
+
vatId: string;
|
|
47
|
+
kind: TZmKind;
|
|
48
|
+
baseCents: TCents;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface IZmCsv {
|
|
52
|
+
text: string;
|
|
53
|
+
/** the text as UTF-8, as the import requires */
|
|
54
|
+
bytes: Uint8Array;
|
|
55
|
+
lineCount: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The country codes the ZM takes (ELSTER, "Liste der Länderkennzeichen"); `GB` only for periods before 2021, so not here. */
|
|
59
|
+
export const ZM_COUNTRY_CODES: readonly string[] = [
|
|
60
|
+
'AT', 'BE', 'BG', 'CY', 'CZ', 'DK', 'EE', 'EL', 'ES', 'FI', 'FR', 'HR', 'HU', 'IE', 'IT', 'LT', 'LU', 'LV', 'MT', 'NL', 'PL', 'PT', 'RO', 'SE', 'SI', 'SK', 'XI',
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
export const ZM_MAX_LINES = 1500;
|
|
64
|
+
|
|
65
|
+
/** The CSV version lines and the field names, as the form's example gives them. */
|
|
66
|
+
export const ZM_CSV_HEAD: readonly string[] = [
|
|
67
|
+
'#v3.0',
|
|
68
|
+
'#ve3.2.1',
|
|
69
|
+
'Umsatzsteuer-Identifikationsnummer (USt-IdNr.),Summe (Euro),Art der Leistung',
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* How a sum with cents becomes the whole euros the CSV's "Summe (Euro)"
|
|
74
|
+
* takes. No source read states it (ELSTER: "eine ganze Zahl"; ELMA5: "in Euro
|
|
75
|
+
* ohne Cent"; the paper form: "volle EUR | Ct"). The cents are dropped toward
|
|
76
|
+
* zero, as the UStVA drops those of its bases ("in vollen Euro (ohne
|
|
77
|
+
* Centbeträge)", Anleitung zur Umsatzsteuer-Voranmeldung 2026), so that the
|
|
78
|
+
* ZM and the UStVA's Kz 41 and Kz 21, which the BZSt compares, are cut alike.
|
|
79
|
+
* This is a recorded decision of 2026-09-27, pending the tax advisor's
|
|
80
|
+
* confirmation; it is not a rule a source states.
|
|
81
|
+
*/
|
|
82
|
+
export const ZM_EUROS_RULE =
|
|
83
|
+
'the cents of each line\'s sum are dropped toward zero, as the UStVA drops those of its bases (a recorded decision of 2026-09-27, pending the tax advisor\'s confirmation; no source states the rule)';
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The whole euros of a sum in cents, as the CSV's "Summe (Euro)" takes them:
|
|
87
|
+
* the cents dropped toward zero (`ZM_EUROS_RULE`), so 2 000,50 € is 2000 and
|
|
88
|
+
* -2 000,50 € is -2000. At most 11 digits (N(11)).
|
|
89
|
+
*/
|
|
90
|
+
export function zmEurosOf(centsArg: TCents): number {
|
|
91
|
+
if (!Number.isSafeInteger(centsArg)) {
|
|
92
|
+
throw new Error(`the ZM sum must be an integer number of cents, got ${centsArg}`);
|
|
93
|
+
}
|
|
94
|
+
// Math.trunc of a small negative sum is -0, which is written "0"; `+ 0` makes it a plain 0
|
|
95
|
+
const euros = Math.trunc(centsArg / 100) + 0;
|
|
96
|
+
if (String(Math.abs(euros)).length > 11) {
|
|
97
|
+
throw new Error(`the ZM sum has at most 11 digits (N(11)), got ${euros} euros`);
|
|
98
|
+
}
|
|
99
|
+
return euros;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* A recipient's VAT ID as the form takes it: upper case, without spaces, the
|
|
104
|
+
* country code first (`GR` written `EL`, as the list has it); 7 to 14
|
|
105
|
+
* characters of letters, digits, `+` and `*`; a country code of the list.
|
|
106
|
+
*/
|
|
107
|
+
export function normalizeZmVatId(valueArg: string): string {
|
|
108
|
+
let id = String(valueArg ?? '').replace(/\s+/g, '').toUpperCase();
|
|
109
|
+
if (id.startsWith('GR')) {
|
|
110
|
+
id = `EL${id.slice(2)}`;
|
|
111
|
+
}
|
|
112
|
+
if (id.length < 7 || id.length > 14) {
|
|
113
|
+
throw new Error(`a ZM VAT ID has 7 to 14 characters, got '${id}'`);
|
|
114
|
+
}
|
|
115
|
+
if (!/^[A-Z]{2}[A-Z0-9+*]+$/.test(id)) {
|
|
116
|
+
throw new Error(`a ZM VAT ID has a country code and then letters, digits, + or *, got '${id}'`);
|
|
117
|
+
}
|
|
118
|
+
if (!ZM_COUNTRY_CODES.includes(id.slice(0, 2))) {
|
|
119
|
+
throw new Error(`'${id.slice(0, 2)}' is no country code the ZM takes (another member state's, or XI)`);
|
|
120
|
+
}
|
|
121
|
+
return id;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The lines of a period: one per recipient VAT ID and kind, in the order they first appear, the bases summed. */
|
|
125
|
+
export function aggregateZmLines(suppliesArg: readonly IZmSupplyInput[]): IZmLine[] {
|
|
126
|
+
const lines = new Map<string, IZmLine>();
|
|
127
|
+
for (const supply of suppliesArg) {
|
|
128
|
+
if (supply.kind !== 'L' && supply.kind !== 'S' && supply.kind !== 'D') {
|
|
129
|
+
throw new Error(`a ZM kind is L, S or D, got '${String(supply.kind)}'`);
|
|
130
|
+
}
|
|
131
|
+
if (!Number.isSafeInteger(supply.baseCents)) {
|
|
132
|
+
throw new Error(`a ZM base must be an integer number of cents, got ${supply.baseCents}`);
|
|
133
|
+
}
|
|
134
|
+
const vatId = normalizeZmVatId(supply.vatId);
|
|
135
|
+
if (vatId.startsWith('XI') && supply.kind === 'S') {
|
|
136
|
+
throw new Error(
|
|
137
|
+
`'${vatId}' is a recipient in Northern Ireland, which is Community territory for goods only: a service to it follows the rules for the third-country territory and is not reported in the ZM (BZSt, Zusammenfassende Meldung - Fragen und Antworten, Brexit)`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
const key = `${vatId}|${supply.kind}`;
|
|
141
|
+
const line = lines.get(key) ?? { vatId, kind: supply.kind, baseCents: 0 };
|
|
142
|
+
line.baseCents += supply.baseCents;
|
|
143
|
+
lines.set(key, line);
|
|
144
|
+
}
|
|
145
|
+
return [...lines.values()];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The CSV of one report. An original report leaves out a line that nets to
|
|
150
|
+
* zero and refuses to be written with no line left (no "Nullmeldung"); a
|
|
151
|
+
* correction keeps a `0` line, which stornos what was reported under it.
|
|
152
|
+
* Each line's sum is cut to whole euros by `zmEurosOf`. Refused beyond 1 500
|
|
153
|
+
* lines.
|
|
154
|
+
*/
|
|
155
|
+
export function writeZmCsv(linesArg: readonly IZmLine[], optionsArg: { purpose: 'original' | 'correction' }): IZmCsv {
|
|
156
|
+
const lines = aggregateZmLines(linesArg).filter((lineArg) => optionsArg.purpose === 'correction' || lineArg.baseCents !== 0);
|
|
157
|
+
if (lines.length === 0) {
|
|
158
|
+
throw new Error('a ZM with no line is no report: "Nullmeldungen" are not given (BZSt Anleitung III.1)');
|
|
159
|
+
}
|
|
160
|
+
if (lines.length > ZM_MAX_LINES) {
|
|
161
|
+
throw new Error(`the ZM online form imports at most ${ZM_MAX_LINES} lines, got ${lines.length}`);
|
|
162
|
+
}
|
|
163
|
+
const rows = lines.map((lineArg) => `${lineArg.vatId},${zmEurosOf(lineArg.baseCents)},${lineArg.kind}`);
|
|
164
|
+
const text = [...ZM_CSV_HEAD, ...rows].join('\n') + '\n';
|
|
165
|
+
return { text, bytes: new TextEncoder().encode(text), lineCount: rows.length };
|
|
166
|
+
}
|