@kreiseck/kasseneck-api 0.21.0 → 0.23.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/CHANGELOG.md +94 -0
- package/README.md +69 -2
- package/dist/cjs/client/errors.d.ts +0 -1
- package/dist/cjs/client/errors.js +24 -1
- package/dist/cjs/rechnung/api.d.ts +3 -1
- package/dist/cjs/rechnung/api.js +1 -0
- package/dist/cjs/rechnung/endpunkte.d.ts +12 -1
- package/dist/cjs/rechnung/endpunkte.js +56 -3
- package/dist/cjs/rechnung/index.d.ts +4 -2
- package/dist/cjs/rechnung/index.js +16 -1
- package/dist/cjs/rechnung/rechnen.d.ts +123 -0
- package/dist/cjs/rechnung/rechnen.js +327 -0
- package/dist/cjs/rechnung/summen.d.ts +42 -0
- package/dist/cjs/rechnung/summen.js +74 -0
- package/dist/cjs/rechnung/texte.d.ts +4 -0
- package/dist/cjs/rechnung/texte.js +8 -0
- package/dist/cjs/rechnung/typen.d.ts +51 -7
- package/dist/cjs/rechnung/vertrag.d.ts +1 -1
- package/dist/cjs/rechnung/vertrag.js +8 -0
- package/dist/esm/client/errors.d.ts +0 -1
- package/dist/esm/client/errors.js +24 -1
- package/dist/esm/rechnung/api.d.ts +3 -1
- package/dist/esm/rechnung/api.js +2 -1
- package/dist/esm/rechnung/endpunkte.d.ts +12 -1
- package/dist/esm/rechnung/endpunkte.js +55 -3
- package/dist/esm/rechnung/index.d.ts +4 -2
- package/dist/esm/rechnung/index.js +3 -1
- package/dist/esm/rechnung/rechnen.d.ts +123 -0
- package/dist/esm/rechnung/rechnen.js +316 -0
- package/dist/esm/rechnung/summen.d.ts +42 -0
- package/dist/esm/rechnung/summen.js +71 -0
- package/dist/esm/rechnung/texte.d.ts +4 -0
- package/dist/esm/rechnung/texte.js +8 -0
- package/dist/esm/rechnung/typen.d.ts +51 -7
- package/dist/esm/rechnung/vertrag.d.ts +1 -1
- package/dist/esm/rechnung/vertrag.js +8 -0
- package/fixtures/hobex-hps-codes.json +1 -1
- package/fixtures/kasse-texte.json +1 -1
- package/fixtures/oberflaeche.json +12 -2
- package/fixtures/position-aus-euro.json +107 -0
- package/fixtures/rechnung-api.schema.json +7 -2
- package/fixtures/rechnung-rechnen-zufall.json +8451 -0
- package/fixtures/rechnung-rechnen.json +304 -0
- package/fixtures/rechnung-summen.json +154 -0
- package/fixtures/rechnung-texte.json +9 -1
- package/package.json +13 -2
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Der Rechenkern der Rechnung — exakt, in ganzen Zahlen.
|
|
4
|
+
*
|
|
5
|
+
* Eine Zeile ist ein Bruch: Preis (µ€) × Menge (Tausendstel) × Rabattanteil
|
|
6
|
+
* (Hundertstel-Prozent). Multipliziert man die Skalen, ist die kleinste Einheit
|
|
7
|
+
* 10⁻¹⁷ Cent — das passt in keine Gleitkommazahl und in keine 53-Bit-Ganzzahl,
|
|
8
|
+
* also rechnet der Kern mit BigInt. Gerundet wird genau EINMAL je USt-Satz, auf
|
|
9
|
+
* dem Bruch, kaufmaennisch (halbe Einheit vom Nullpunkt weg).
|
|
10
|
+
*
|
|
11
|
+
* Warum das so streng ist: Vier Umsetzungen (Server, dieses Paket, Dart, Panel)
|
|
12
|
+
* mussten bisher dieselbe Reihenfolge von Gleitkomma-Schritten nachbauen, damit
|
|
13
|
+
* Grenzfaelle gleich runden. Mit Ganzzahlen gibt es keine Reihenfolge mehr, an
|
|
14
|
+
* der etwas auseinanderlaufen koennte.
|
|
15
|
+
*
|
|
16
|
+
* Dieser Unterpfad ist rein: kein Transport, kein Zugangsschluessel, keine
|
|
17
|
+
* Abhaengigkeit ausser Typen. Er wird im Browser gebuendelt (Panel) und im
|
|
18
|
+
* Server geladen.
|
|
19
|
+
*
|
|
20
|
+
* Spec: docs/specs/2026-09-17-rechnung-ganzzahlen-design.md § 5.
|
|
21
|
+
*/
|
|
22
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
23
|
+
exports.RechenFehler = exports.STEUERFREIE_FAELLE = exports.BETRAG_GRENZE_CENTS = void 0;
|
|
24
|
+
exports.rund = rund;
|
|
25
|
+
exports.rechnungRechnen = rechnungRechnen;
|
|
26
|
+
exports.positionAusEuro = positionAusEuro;
|
|
27
|
+
exports.anteiligerPreis = anteiligerPreis;
|
|
28
|
+
exports.satzSchluessel = satzSchluessel;
|
|
29
|
+
exports.satzText = satzText;
|
|
30
|
+
exports.preisText = preisText;
|
|
31
|
+
/** Skala des Zeilenbruchs: 10⁻¹⁷ Cent. */
|
|
32
|
+
const E = 10n ** 17n;
|
|
33
|
+
/** Hoechster Betrag je Zeile und je Rechnung: 999.999.999,99 €. */
|
|
34
|
+
exports.BETRAG_GRENZE_CENTS = 99_999_999_999;
|
|
35
|
+
/** Steuerfaelle, in denen die Rechnung keine Steuer ausweist: jede Zeile zaehlt zu 0 %. */
|
|
36
|
+
exports.STEUERFREIE_FAELLE = Object.freeze([
|
|
37
|
+
'smallBusiness',
|
|
38
|
+
'reverseCharge',
|
|
39
|
+
'igLieferung',
|
|
40
|
+
'exportThirdCountry',
|
|
41
|
+
'domesticReverseCharge',
|
|
42
|
+
'outsideScope',
|
|
43
|
+
]);
|
|
44
|
+
/** Fehler des Kerns — mit Code, Feld und Index, damit die API daraus einen Feldfehler machen kann. */
|
|
45
|
+
class RechenFehler extends Error {
|
|
46
|
+
code;
|
|
47
|
+
feld;
|
|
48
|
+
index;
|
|
49
|
+
constructor(code, nachricht, feld, index) {
|
|
50
|
+
super(nachricht);
|
|
51
|
+
this.name = 'RechenFehler';
|
|
52
|
+
this.code = code;
|
|
53
|
+
this.feld = feld;
|
|
54
|
+
this.index = index;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
exports.RechenFehler = RechenFehler;
|
|
58
|
+
/**
|
|
59
|
+
* `a / b` kaufmaennisch gerundet, halbe Einheit vom Nullpunkt weg; `b` > 0.
|
|
60
|
+
*
|
|
61
|
+
* Ohne Gleitkomma: `(2·|a| + b) / (2·b)` ist genau dann eins groesser, wenn der
|
|
62
|
+
* Rest mindestens die halbe Einheit betraegt.
|
|
63
|
+
*/
|
|
64
|
+
function rund(a, b) {
|
|
65
|
+
const negativ = a < 0n;
|
|
66
|
+
const betrag = negativ ? -a : a;
|
|
67
|
+
const ganz = (2n * betrag + b) / (2n * b);
|
|
68
|
+
return negativ ? -ganz : ganz;
|
|
69
|
+
}
|
|
70
|
+
const GRENZEN = {
|
|
71
|
+
unitPriceMicros: [0, 1_000_000_000_000],
|
|
72
|
+
quantityMilli: [-1_000_000_000_000, 1_000_000_000_000],
|
|
73
|
+
discountBp: [0, 10_000],
|
|
74
|
+
vatRateBp: [0, 10_000],
|
|
75
|
+
};
|
|
76
|
+
function ganzzahl(wert, feld, index) {
|
|
77
|
+
if (typeof wert !== 'number' || !Number.isInteger(wert)) {
|
|
78
|
+
throw new RechenFehler('kein_ganzzahlwert', `items[${index}].${feld} muss eine ganze Zahl sein`, feld, index);
|
|
79
|
+
}
|
|
80
|
+
const [min, max] = GRENZEN[feld];
|
|
81
|
+
if (wert < min || wert > max) {
|
|
82
|
+
throw new RechenFehler('ausserhalb', `items[${index}].${feld} liegt ausserhalb von ${min} … ${max}`, feld, index);
|
|
83
|
+
}
|
|
84
|
+
return wert;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Verteilt die Zeilenbetraege eines Satzes so, dass sie genau die Satzsumme
|
|
88
|
+
* ergeben. Startwert ist die kaufmaennisch gerundete Zeile; der Rest geht
|
|
89
|
+
* einzeln an die Zeilen, bei denen das Runden am meisten weggenommen (bzw.
|
|
90
|
+
* zugegeben) hat. Verglichen wird auf dem Bruch, nie in Gleitkomma; bei
|
|
91
|
+
* Gleichstand bekommt die fruehere Zeile den Cent. Eine Zeile ueber 0 bekommt
|
|
92
|
+
* nie einen Cent — sonst stuende auf dem Blatt ein Betrag, den die Position
|
|
93
|
+
* nicht hat.
|
|
94
|
+
*/
|
|
95
|
+
function verteile(gruppe, feld, faktor, nenner, ziel) {
|
|
96
|
+
const werte = gruppe.map((z) => {
|
|
97
|
+
const zaehler = z.L * faktor;
|
|
98
|
+
return { z, zaehler, wert: rund(zaehler, nenner) };
|
|
99
|
+
});
|
|
100
|
+
let rest = ziel - werte.reduce((s, w) => s + w.wert, 0n);
|
|
101
|
+
if (rest !== 0n) {
|
|
102
|
+
const schritt = rest > 0n ? 1n : -1n;
|
|
103
|
+
const kandidaten = werte
|
|
104
|
+
.filter((w) => w.zaehler !== 0n)
|
|
105
|
+
.sort((a, b) => {
|
|
106
|
+
const da = (a.zaehler - a.wert * nenner) * schritt;
|
|
107
|
+
const db = (b.zaehler - b.wert * nenner) * schritt;
|
|
108
|
+
if (da !== db)
|
|
109
|
+
return db > da ? 1 : -1;
|
|
110
|
+
return a.z.index - b.z.index;
|
|
111
|
+
});
|
|
112
|
+
for (let k = 0; rest !== 0n && kandidaten.length > 0; k = (k + 1) % kandidaten.length) {
|
|
113
|
+
kandidaten[k].wert += schritt;
|
|
114
|
+
rest -= schritt;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
for (const w of werte)
|
|
118
|
+
w.z[feld] = Number(w.wert);
|
|
119
|
+
}
|
|
120
|
+
function rechnungRechnen(positionen, optionen) {
|
|
121
|
+
const steuerfrei = exports.STEUERFREIE_FAELLE.includes(optionen.taxScheme ?? 'normal');
|
|
122
|
+
const bruttoPreise = optionen.priceMode === 'gross' && !steuerfrei;
|
|
123
|
+
const grenze = BigInt(exports.BETRAG_GRENZE_CENTS) * E;
|
|
124
|
+
const zeilen = positionen.map((p, index) => {
|
|
125
|
+
const preis = BigInt(ganzzahl(p.unitPriceMicros, 'unitPriceMicros', index));
|
|
126
|
+
const menge = BigInt(ganzzahl(p.quantityMilli, 'quantityMilli', index));
|
|
127
|
+
const rabatt = BigInt(ganzzahl(p.discountBp ?? 0, 'discountBp', index));
|
|
128
|
+
const satz = ganzzahl(p.vatRateBp ?? 0, 'vatRateBp', index);
|
|
129
|
+
const L = preis * menge * (10000n - rabatt) * 1000000n;
|
|
130
|
+
if ((L < 0n ? -L : L) > grenze) {
|
|
131
|
+
throw new RechenFehler('amount_too_large', `items[${index}] uebersteigt ${exports.BETRAG_GRENZE_CENTS} Cent`, 'unitPriceMicros', index);
|
|
132
|
+
}
|
|
133
|
+
return { index, rateBp: steuerfrei ? 0 : satz, L, netCents: 0, grossCents: 0 };
|
|
134
|
+
});
|
|
135
|
+
const jeSatz = new Map();
|
|
136
|
+
for (const z of zeilen) {
|
|
137
|
+
const gruppe = jeSatz.get(z.rateBp);
|
|
138
|
+
if (gruppe)
|
|
139
|
+
gruppe.push(z);
|
|
140
|
+
else
|
|
141
|
+
jeSatz.set(z.rateBp, [z]);
|
|
142
|
+
}
|
|
143
|
+
const byRate = [];
|
|
144
|
+
for (const [rateBp, gruppe] of jeSatz) {
|
|
145
|
+
const r = BigInt(rateBp);
|
|
146
|
+
const S = gruppe.reduce((s, z) => s + z.L, 0n);
|
|
147
|
+
let netCents;
|
|
148
|
+
let vatCents;
|
|
149
|
+
let grossCents;
|
|
150
|
+
if (bruttoPreise) {
|
|
151
|
+
grossCents = rund(S, E);
|
|
152
|
+
netCents = rund(grossCents * 10000n, 10000n + r);
|
|
153
|
+
vatCents = grossCents - netCents;
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
netCents = rund(S, E);
|
|
157
|
+
vatCents = rund(S * r, E * 10000n);
|
|
158
|
+
grossCents = netCents + vatCents;
|
|
159
|
+
}
|
|
160
|
+
if (bruttoPreise) {
|
|
161
|
+
verteile(gruppe, 'netCents', 10000n, E * (10000n + r), netCents);
|
|
162
|
+
verteile(gruppe, 'grossCents', 1n, E, grossCents);
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
verteile(gruppe, 'netCents', 1n, E, netCents);
|
|
166
|
+
verteile(gruppe, 'grossCents', 10000n + r, E * 10000n, grossCents);
|
|
167
|
+
}
|
|
168
|
+
byRate.push({
|
|
169
|
+
rateBp,
|
|
170
|
+
netCents: Number(netCents),
|
|
171
|
+
vatCents: Number(vatCents),
|
|
172
|
+
grossCents: Number(grossCents),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
byRate.sort((a, b) => b.rateBp - a.rateBp);
|
|
176
|
+
const summe = (feld) => byRate.reduce((s, r) => s + r[feld], 0);
|
|
177
|
+
const ergebnis = {
|
|
178
|
+
netCents: summe('netCents'),
|
|
179
|
+
vatCents: summe('vatCents'),
|
|
180
|
+
grossCents: summe('grossCents'),
|
|
181
|
+
byRate,
|
|
182
|
+
lines: zeilen.map((z) => ({ netCents: z.netCents, grossCents: z.grossCents, rateBp: z.rateBp })),
|
|
183
|
+
};
|
|
184
|
+
for (const feld of ['netCents', 'vatCents', 'grossCents']) {
|
|
185
|
+
if (Math.abs(ergebnis[feld]) > exports.BETRAG_GRENZE_CENTS) {
|
|
186
|
+
throw new RechenFehler('amount_too_large', `Die Rechnung uebersteigt ${exports.BETRAG_GRENZE_CENTS} Cent (${feld})`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return ergebnis;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Wandelt eine Zahl ueber ihren kuerzesten Dezimaltext in eine Ganzzahl mit
|
|
193
|
+
* `stellen` Nachkommastellen. Kein Gleitkomma-Rechnen: `0.1 * 1000` ist
|
|
194
|
+
* 100.00000000000001, `String(0.1)` dagegen genau "0.1". Mehr Stellen als
|
|
195
|
+
* erlaubt ergeben `null` — auch Gleitkomma-Rauschen wie 0.30000000000000004,
|
|
196
|
+
* das mit 17 Stellen dasteht.
|
|
197
|
+
*/
|
|
198
|
+
function ganzAusDezimaltext(wert, stellen) {
|
|
199
|
+
if (typeof wert !== 'number' || !Number.isFinite(wert))
|
|
200
|
+
return null;
|
|
201
|
+
const treffer = /^(-?)(\d+)(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/.exec(String(wert));
|
|
202
|
+
if (!treffer)
|
|
203
|
+
return null;
|
|
204
|
+
const [, vorzeichen = '', ganzTeil = '0', bruchTeil = '', hoch = '0'] = treffer;
|
|
205
|
+
const verschiebung = Number(hoch);
|
|
206
|
+
let ziffern = `${ganzTeil}${bruchTeil}`;
|
|
207
|
+
let nachkomma = bruchTeil.length - verschiebung;
|
|
208
|
+
if (nachkomma < 0) {
|
|
209
|
+
ziffern += '0'.repeat(-nachkomma);
|
|
210
|
+
nachkomma = 0;
|
|
211
|
+
}
|
|
212
|
+
if (nachkomma > stellen) {
|
|
213
|
+
// Nur echte Nullen am Ende duerfen wegfallen (1.20 bei 1 Stelle).
|
|
214
|
+
const zuviel = nachkomma - stellen;
|
|
215
|
+
if (!/^0+$/.test(ziffern.slice(-zuviel)))
|
|
216
|
+
return null;
|
|
217
|
+
ziffern = ziffern.slice(0, -zuviel);
|
|
218
|
+
nachkomma = stellen;
|
|
219
|
+
}
|
|
220
|
+
const skaliert = `${ziffern}${'0'.repeat(stellen - nachkomma)}`;
|
|
221
|
+
const zahl = Number(`${vorzeichen}${skaliert}`);
|
|
222
|
+
return Number.isSafeInteger(zahl) ? zahl : null;
|
|
223
|
+
}
|
|
224
|
+
const EURO_FELDER = ['unitPrice', 'quantity', 'vatRate', 'discountPct'];
|
|
225
|
+
/**
|
|
226
|
+
* Euro-Position → Ganzzahl-Form, verlustfrei oder mit Grund abgelehnt. Die
|
|
227
|
+
* EINE Stelle, die das tut: Panel (beim Lesen), SEPA-Lauf, Umrechnungsskript
|
|
228
|
+
* und das Festschreiben benutzen alle diese Funktion, damit niemand eine
|
|
229
|
+
* zweite Auslegung baut.
|
|
230
|
+
*
|
|
231
|
+
* Eine Position, die schon `unitPriceMicros` traegt, kommt unveraendert
|
|
232
|
+
* zurueck — die Funktion ist die Formweiche, nicht eine blinde Umrechnung.
|
|
233
|
+
*
|
|
234
|
+
* Fehlende Menge und fehlender Satz gelten als 0, weil der Server bisher so
|
|
235
|
+
* abgerechnet hat. Ein fehlender Preis dagegen ist NIE 0: sonst wuerde eine
|
|
236
|
+
* Position, der nur der Preis fehlt, still zur 0-€-Zeile.
|
|
237
|
+
*/
|
|
238
|
+
function positionAusEuro(item) {
|
|
239
|
+
if (typeof item.unitPriceMicros === 'number') {
|
|
240
|
+
return { ok: true, position: item };
|
|
241
|
+
}
|
|
242
|
+
const roh = item.unitPrice;
|
|
243
|
+
if (typeof roh !== 'number' || !Number.isFinite(roh)) {
|
|
244
|
+
return { ok: false, feld: 'unitPrice', grund: 'kein_zahlwert' };
|
|
245
|
+
}
|
|
246
|
+
const micros = ganzAusDezimaltext(Math.abs(roh), 6);
|
|
247
|
+
if (micros === null)
|
|
248
|
+
return { ok: false, feld: 'unitPrice', grund: 'nachkommastellen' };
|
|
249
|
+
if (micros > GRENZEN.unitPriceMicros[1])
|
|
250
|
+
return { ok: false, feld: 'unitPrice', grund: 'ausserhalb' };
|
|
251
|
+
const zahl = (feld, stellen, max) => {
|
|
252
|
+
const w = item[feld];
|
|
253
|
+
if (w === undefined || w === null)
|
|
254
|
+
return { ok: true, wert: 0 };
|
|
255
|
+
if (typeof w !== 'number' || !Number.isFinite(w))
|
|
256
|
+
return { ok: false, grund: 'kein_zahlwert' };
|
|
257
|
+
const ganz = ganzAusDezimaltext(w, stellen);
|
|
258
|
+
if (ganz === null)
|
|
259
|
+
return { ok: false, grund: 'nachkommastellen' };
|
|
260
|
+
if (ganz < (feld === 'quantity' ? -max : 0) || ganz > max)
|
|
261
|
+
return { ok: false, grund: 'ausserhalb' };
|
|
262
|
+
return { ok: true, wert: ganz };
|
|
263
|
+
};
|
|
264
|
+
const menge = zahl('quantity', 3, GRENZEN.quantityMilli[1]);
|
|
265
|
+
if (!menge.ok)
|
|
266
|
+
return { ok: false, feld: 'quantity', grund: menge.grund };
|
|
267
|
+
const satz = zahl('vatRate', 2, 10_000);
|
|
268
|
+
if (!satz.ok)
|
|
269
|
+
return { ok: false, feld: 'vatRate', grund: satz.grund };
|
|
270
|
+
const rabatt = zahl('discountPct', 2, 10_000);
|
|
271
|
+
if (!rabatt.ok)
|
|
272
|
+
return { ok: false, feld: 'discountPct', grund: rabatt.grund };
|
|
273
|
+
const rest = { ...item };
|
|
274
|
+
for (const feld of EURO_FELDER)
|
|
275
|
+
delete rest[feld];
|
|
276
|
+
return {
|
|
277
|
+
ok: true,
|
|
278
|
+
position: {
|
|
279
|
+
...rest,
|
|
280
|
+
unitPriceMicros: micros,
|
|
281
|
+
quantityMilli: roh < 0 ? -menge.wert : menge.wert,
|
|
282
|
+
discountBp: rabatt.wert,
|
|
283
|
+
vatRateBp: satz.wert,
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Anteiliger Preis fuer einen Teilzeitraum (erste Rechnung eines Abos).
|
|
289
|
+
* War der Ursprungspreis centgenau, bleibt auch der Anteil centgenau — sonst
|
|
290
|
+
* stuende auf der ersten Rechnung ein Preis mit sechs Stellen, wo bisher zwei
|
|
291
|
+
* standen.
|
|
292
|
+
*/
|
|
293
|
+
function anteiligerPreis(unitPriceMicros, monate, intervall) {
|
|
294
|
+
const micros = BigInt(unitPriceMicros);
|
|
295
|
+
const anteil = rund(micros * BigInt(monate), BigInt(intervall));
|
|
296
|
+
if (unitPriceMicros % 10_000 !== 0)
|
|
297
|
+
return Number(anteil);
|
|
298
|
+
return Number(rund(anteil, 10000n) * 10000n);
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Schluessel eines USt-Satzes in den gespeicherten Maps (`creditedCents.byRate`,
|
|
302
|
+
* `invoice_stats.vatByRate`): der Prozenttext mit Punkt, genau wie
|
|
303
|
+
* `String(satz)` ihn bisher gebildet hat. NICHT fuer die Anzeige — dafuer gibt
|
|
304
|
+
* es `satzText`.
|
|
305
|
+
*/
|
|
306
|
+
function satzSchluessel(rateBp) {
|
|
307
|
+
return String(rateBp / 100);
|
|
308
|
+
}
|
|
309
|
+
/** Ein USt-Satz fuer die Anzeige: oesterreichisch mit Komma, ohne nachlaufende Nullen. */
|
|
310
|
+
function satzText(rateBp) {
|
|
311
|
+
return satzSchluessel(rateBp).replace('.', ',');
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Ein Einzelpreis fuer die Anzeige: mindestens zwei, hoechstens sechs
|
|
315
|
+
* Nachkommastellen, Tausenderpunkt, Komma als Trennzeichen — in jeder Sprache
|
|
316
|
+
* oesterreichisch, wie die Betraege auf dem Blatt.
|
|
317
|
+
*/
|
|
318
|
+
function preisText(unitPriceMicros) {
|
|
319
|
+
const negativ = unitPriceMicros < 0;
|
|
320
|
+
const betrag = Math.abs(unitPriceMicros);
|
|
321
|
+
const ganz = Math.trunc(betrag / 1_000_000);
|
|
322
|
+
let bruch = String(betrag % 1_000_000).padStart(6, '0');
|
|
323
|
+
while (bruch.length > 2 && bruch.endsWith('0'))
|
|
324
|
+
bruch = bruch.slice(0, -1);
|
|
325
|
+
const mitPunkten = String(ganz).replace(/\B(?=(\d{3})+(?!\d))/g, '.');
|
|
326
|
+
return `${negativ ? '-' : ''}${mitPunkten},${bruch}`;
|
|
327
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Summen einer Rechnung vorab rechnen — genau so, wie der Server sie beim
|
|
3
|
+
* Ausstellen rechnet. Fuer Shops, die kassieren, bevor die Rechnung entsteht,
|
|
4
|
+
* und denselben Betrag brauchen, den die Rechnung spaeter ausweist.
|
|
5
|
+
*
|
|
6
|
+
* Die Regel (je USt-Satz, Betraege in Cent, kaufmaennisch gerundet):
|
|
7
|
+
*
|
|
8
|
+
* | Modus | Netto | USt | Brutto |
|
|
9
|
+
* |---------|--------------------------------|----------------------------|--------------------|
|
|
10
|
+
* | `net` | round(Σ Zeilen) | round(Σ Zeilen × Satz/100) | Netto + USt |
|
|
11
|
+
* | `gross` | round(B × 100 / (100 + Satz)) | B − Netto | B = round(Σ Zeilen)|
|
|
12
|
+
*
|
|
13
|
+
* Zeile = Einzelpreis × Menge × (1 − Rabatt/100), ungerundet. Im Brutto-Modus
|
|
14
|
+
* ist das Brutto der vereinbarte Preis und bleibt, wie die Zeilen es ergeben.
|
|
15
|
+
* Bei einem steuerfreien Steuerfall zaehlt jede Zeile zu 0 %.
|
|
16
|
+
*
|
|
17
|
+
* `fixtures/rechnung-summen.json` haelt die Prueffaelle; Backend und
|
|
18
|
+
* Dart-Zwilling pruefen gegen dieselbe Datei. Verbindlich bleibt, was der
|
|
19
|
+
* Server rechnet — mit `previewInvoice` laesst sich das vorab abfragen.
|
|
20
|
+
*/
|
|
21
|
+
import type { InvoiceTotals } from './typen.js';
|
|
22
|
+
import type { PriceMode, TaxScheme } from './vertrag.js';
|
|
23
|
+
import { STEUERFREIE_FAELLE } from './rechnen.js';
|
|
24
|
+
/** Aus dem Kern uebernommen, damit Kern und Huelle nie auseinanderlaufen. */
|
|
25
|
+
export { STEUERFREIE_FAELLE };
|
|
26
|
+
/** Was fuer die Summe zaehlt — `InvoiceItemInput` passt unveraendert. */
|
|
27
|
+
export interface SummenPosition {
|
|
28
|
+
quantity: number;
|
|
29
|
+
unitPriceCents: number;
|
|
30
|
+
vatRate: number;
|
|
31
|
+
discountPct?: number;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Netto, USt und Brutto einer Rechnung in Cent, je Satz absteigend — dieselbe
|
|
35
|
+
* Form wie `invoice.totals` in den Antworten.
|
|
36
|
+
*
|
|
37
|
+
* `taxScheme` ist der Steuerfall der Rechnung; ohne Angabe `normal`. Leitet der
|
|
38
|
+
* Server einen steuerfreien Fall ab (etwa eine ig. Lieferung), gehoert dieser
|
|
39
|
+
* Fall hierher — sonst rechnet die Funktion Steuer, die die Rechnung nicht
|
|
40
|
+
* ausweist.
|
|
41
|
+
*/
|
|
42
|
+
export declare function rechnungSummen(items: readonly SummenPosition[], priceMode: PriceMode, taxScheme?: TaxScheme): InvoiceTotals;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Summen einer Rechnung vorab rechnen — genau so, wie der Server sie beim
|
|
4
|
+
* Ausstellen rechnet. Fuer Shops, die kassieren, bevor die Rechnung entsteht,
|
|
5
|
+
* und denselben Betrag brauchen, den die Rechnung spaeter ausweist.
|
|
6
|
+
*
|
|
7
|
+
* Die Regel (je USt-Satz, Betraege in Cent, kaufmaennisch gerundet):
|
|
8
|
+
*
|
|
9
|
+
* | Modus | Netto | USt | Brutto |
|
|
10
|
+
* |---------|--------------------------------|----------------------------|--------------------|
|
|
11
|
+
* | `net` | round(Σ Zeilen) | round(Σ Zeilen × Satz/100) | Netto + USt |
|
|
12
|
+
* | `gross` | round(B × 100 / (100 + Satz)) | B − Netto | B = round(Σ Zeilen)|
|
|
13
|
+
*
|
|
14
|
+
* Zeile = Einzelpreis × Menge × (1 − Rabatt/100), ungerundet. Im Brutto-Modus
|
|
15
|
+
* ist das Brutto der vereinbarte Preis und bleibt, wie die Zeilen es ergeben.
|
|
16
|
+
* Bei einem steuerfreien Steuerfall zaehlt jede Zeile zu 0 %.
|
|
17
|
+
*
|
|
18
|
+
* `fixtures/rechnung-summen.json` haelt die Prueffaelle; Backend und
|
|
19
|
+
* Dart-Zwilling pruefen gegen dieselbe Datei. Verbindlich bleibt, was der
|
|
20
|
+
* Server rechnet — mit `previewInvoice` laesst sich das vorab abfragen.
|
|
21
|
+
*/
|
|
22
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
23
|
+
exports.STEUERFREIE_FAELLE = void 0;
|
|
24
|
+
exports.rechnungSummen = rechnungSummen;
|
|
25
|
+
const rechnen_js_1 = require("./rechnen.js");
|
|
26
|
+
Object.defineProperty(exports, "STEUERFREIE_FAELLE", { enumerable: true, get: function () { return rechnen_js_1.STEUERFREIE_FAELLE; } });
|
|
27
|
+
/** Auf Cent gerundete Euro — dieselbe Rundung wie am Server (halber Cent vom Nullpunkt weg). */
|
|
28
|
+
function euroRund(n) {
|
|
29
|
+
return ((n < 0 ? -1 : 1) * Math.round((Math.abs(n) + Number.EPSILON) * 100)) / 100;
|
|
30
|
+
}
|
|
31
|
+
const euroZuCent = (euro) => Math.round(euroRund(euro) * 100) || 0;
|
|
32
|
+
/** Ganze Cent kaufmaennisch; ein Gleitkomma-Rest wie x,4999999… zaehlt als halber Cent. */
|
|
33
|
+
function centRund(x) {
|
|
34
|
+
const betrag = Math.round(Math.abs(x) * 1e6) / 1e6;
|
|
35
|
+
return (x < 0 ? -1 : 1) * Math.round(betrag) || 0;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Netto, USt und Brutto einer Rechnung in Cent, je Satz absteigend — dieselbe
|
|
39
|
+
* Form wie `invoice.totals` in den Antworten.
|
|
40
|
+
*
|
|
41
|
+
* `taxScheme` ist der Steuerfall der Rechnung; ohne Angabe `normal`. Leitet der
|
|
42
|
+
* Server einen steuerfreien Fall ab (etwa eine ig. Lieferung), gehoert dieser
|
|
43
|
+
* Fall hierher — sonst rechnet die Funktion Steuer, die die Rechnung nicht
|
|
44
|
+
* ausweist.
|
|
45
|
+
*/
|
|
46
|
+
function rechnungSummen(items, priceMode, taxScheme = 'normal') {
|
|
47
|
+
const steuerfrei = rechnen_js_1.STEUERFREIE_FAELLE.includes(taxScheme);
|
|
48
|
+
const bruttoPreise = priceMode === 'gross' && !steuerfrei;
|
|
49
|
+
// Ungerundete Zeilen je Satz, in Euro wie am Server.
|
|
50
|
+
const jeSatz = new Map();
|
|
51
|
+
for (const p of items) {
|
|
52
|
+
const satz = steuerfrei ? 0 : p.vatRate;
|
|
53
|
+
const zeile = (p.unitPriceCents / 100) * p.quantity * (1 - (p.discountPct ?? 0) / 100);
|
|
54
|
+
jeSatz.set(satz, (jeSatz.get(satz) ?? 0) + zeile);
|
|
55
|
+
}
|
|
56
|
+
const byRate = [];
|
|
57
|
+
for (const [rate, roh] of jeSatz) {
|
|
58
|
+
let netCents;
|
|
59
|
+
let vatCents;
|
|
60
|
+
if (bruttoPreise) {
|
|
61
|
+
const grossCents = euroZuCent(roh);
|
|
62
|
+
netCents = centRund((grossCents * 100) / (100 + rate));
|
|
63
|
+
vatCents = grossCents - netCents;
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
netCents = euroZuCent(roh);
|
|
67
|
+
vatCents = euroZuCent((roh * rate) / 100);
|
|
68
|
+
}
|
|
69
|
+
byRate.push({ rate, netCents, vatCents, grossCents: netCents + vatCents });
|
|
70
|
+
}
|
|
71
|
+
byRate.sort((a, b) => b.rate - a.rate);
|
|
72
|
+
const summe = (feld) => byRate.reduce((s, r) => s + r[feld], 0);
|
|
73
|
+
return { netCents: summe('netCents'), vatCents: summe('vatCents'), grossCents: summe('grossCents'), byRate };
|
|
74
|
+
}
|
|
@@ -35,8 +35,12 @@ declare const DE: {
|
|
|
35
35
|
readonly 'pdf.tabelle.ust': "UST";
|
|
36
36
|
readonly 'pdf.tabelle.einzelNetto': "EINZEL NETTO";
|
|
37
37
|
readonly 'pdf.tabelle.betragNetto': "BETRAG NETTO";
|
|
38
|
+
readonly 'pdf.tabelle.einzelBrutto': "EINZEL BRUTTO";
|
|
39
|
+
readonly 'pdf.tabelle.betragBrutto': "BETRAG BRUTTO";
|
|
40
|
+
readonly 'pdf.position.rabatt': "abzüglich {prozent} % Rabatt";
|
|
38
41
|
readonly 'pdf.summe.zwischensumme': "Zwischensumme netto";
|
|
39
42
|
readonly 'pdf.summe.umsatzsteuer': "Umsatzsteuer {satz}%";
|
|
43
|
+
readonly 'pdf.summe.darinUst': "darin {satz}% USt von {netto} netto";
|
|
40
44
|
readonly 'pdf.summe.gesamt.rechnung': "Gesamtbetrag";
|
|
41
45
|
readonly 'pdf.summe.gesamt.gutschrift': "Gutschriftsbetrag";
|
|
42
46
|
readonly 'steuer.kleinunternehmer': "Gemäß § 6 Abs. 1 Z 27 UStG wird keine Umsatzsteuer verrechnet (Kleinunternehmer).";
|
|
@@ -24,8 +24,12 @@ const DE = {
|
|
|
24
24
|
'pdf.tabelle.ust': 'UST',
|
|
25
25
|
'pdf.tabelle.einzelNetto': 'EINZEL NETTO',
|
|
26
26
|
'pdf.tabelle.betragNetto': 'BETRAG NETTO',
|
|
27
|
+
'pdf.tabelle.einzelBrutto': 'EINZEL BRUTTO',
|
|
28
|
+
'pdf.tabelle.betragBrutto': 'BETRAG BRUTTO',
|
|
29
|
+
'pdf.position.rabatt': 'abzüglich {prozent} % Rabatt',
|
|
27
30
|
'pdf.summe.zwischensumme': 'Zwischensumme netto',
|
|
28
31
|
'pdf.summe.umsatzsteuer': 'Umsatzsteuer {satz}%',
|
|
32
|
+
'pdf.summe.darinUst': 'darin {satz}% USt von {netto} netto',
|
|
29
33
|
'pdf.summe.gesamt.rechnung': 'Gesamtbetrag',
|
|
30
34
|
'pdf.summe.gesamt.gutschrift': 'Gutschriftsbetrag',
|
|
31
35
|
'steuer.kleinunternehmer': 'Gemäß § 6 Abs. 1 Z 27 UStG wird keine Umsatzsteuer verrechnet (Kleinunternehmer).',
|
|
@@ -230,8 +234,12 @@ const EN = {
|
|
|
230
234
|
'pdf.tabelle.ust': 'VAT',
|
|
231
235
|
'pdf.tabelle.einzelNetto': 'UNIT NET',
|
|
232
236
|
'pdf.tabelle.betragNetto': 'AMOUNT NET',
|
|
237
|
+
'pdf.tabelle.einzelBrutto': 'UNIT GROSS',
|
|
238
|
+
'pdf.tabelle.betragBrutto': 'AMOUNT GROSS',
|
|
239
|
+
'pdf.position.rabatt': 'less {prozent}% discount',
|
|
233
240
|
'pdf.summe.zwischensumme': 'Subtotal net',
|
|
234
241
|
'pdf.summe.umsatzsteuer': 'VAT {satz}%',
|
|
242
|
+
'pdf.summe.darinUst': 'incl. {satz}% VAT on {netto} net',
|
|
235
243
|
'pdf.summe.gesamt.rechnung': 'Total',
|
|
236
244
|
'pdf.summe.gesamt.gutschrift': 'Credit total',
|
|
237
245
|
'steuer.kleinunternehmer': 'No VAT charged pursuant to § 6 (1) no. 27 UStG (small business exemption).',
|
|
@@ -146,7 +146,7 @@ export interface InvoicePayment {
|
|
|
146
146
|
}
|
|
147
147
|
/**
|
|
148
148
|
* Ein Hinweis zu einer erfolgreichen Antwort — kein Fehler, sondern etwas,
|
|
149
|
-
* das der Aufrufer wissen sollte
|
|
149
|
+
* das der Aufrufer wissen sollte. Die Codes stehen in `INVOICE_NOTICE_CODES`.
|
|
150
150
|
*/
|
|
151
151
|
export interface InvoiceNotice {
|
|
152
152
|
code: InvoiceNoticeCode;
|
|
@@ -156,7 +156,11 @@ export interface RecordPaymentResult {
|
|
|
156
156
|
invoice: Invoice;
|
|
157
157
|
payment: InvoicePayment;
|
|
158
158
|
replayed: boolean;
|
|
159
|
-
|
|
159
|
+
/**
|
|
160
|
+
* Hinweise zu dieser Zahlung — eine **Liste** wie bei `issueInvoice`
|
|
161
|
+
* (bis 0.21.0 ein einzelnes Objekt). Fehlt, wenn es keinen gibt.
|
|
162
|
+
*/
|
|
163
|
+
notice?: InvoiceNotice[];
|
|
160
164
|
}
|
|
161
165
|
export interface CancelInvoiceRequest {
|
|
162
166
|
idempotencyKey: string;
|
|
@@ -167,15 +171,23 @@ export interface CancelInvoiceRequest {
|
|
|
167
171
|
export interface CreditNoteRequest extends CancelInvoiceRequest {
|
|
168
172
|
items: InvoiceItemInput[];
|
|
169
173
|
}
|
|
174
|
+
/**
|
|
175
|
+
* Summen einer Rechnung in Cent, **immer positiv** — auch bei einer Gutschrift
|
|
176
|
+
* (`docType: 'GU'`); das Vorzeichen steht im Belegtyp, nicht im Betrag.
|
|
177
|
+
* Gerechnet wird je Satz wie in [rechnungSummen].
|
|
178
|
+
*/
|
|
170
179
|
export interface InvoiceTotals {
|
|
171
180
|
netCents: number;
|
|
172
181
|
vatCents: number;
|
|
173
182
|
grossCents: number;
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
183
|
+
/** Je USt-Satz, absteigend. `grossCents` ist `netCents + vatCents` (seit 0.22.0 mitgeliefert). */
|
|
184
|
+
byRate: InvoiceRateTotals[];
|
|
185
|
+
}
|
|
186
|
+
export interface InvoiceRateTotals {
|
|
187
|
+
rate: number;
|
|
188
|
+
netCents: number;
|
|
189
|
+
vatCents: number;
|
|
190
|
+
grossCents: number;
|
|
179
191
|
}
|
|
180
192
|
export interface EInvoiceStatus {
|
|
181
193
|
level: string;
|
|
@@ -299,6 +311,38 @@ export interface IssueResult {
|
|
|
299
311
|
*/
|
|
300
312
|
notice?: InvoiceNotice[];
|
|
301
313
|
}
|
|
314
|
+
/**
|
|
315
|
+
* Was `issueInvoice` mit dieser Anfrage ausstellen wuerde — ohne Nummer, ohne
|
|
316
|
+
* Dokument, ohne Zahlung. Der Probelauf prueft wie das Ausstellen (Einrichtung,
|
|
317
|
+
* Kunde, Steuerfall, Pflichtangaben, Marke, Zahlung) und scheitert mit
|
|
318
|
+
* denselben Fehlercodes.
|
|
319
|
+
*/
|
|
320
|
+
export interface InvoicePreview {
|
|
321
|
+
docType: 'RE';
|
|
322
|
+
/** Heutiger Wiener Tag — der Tag, den eine sofort ausgestellte Rechnung truege. */
|
|
323
|
+
invoiceDate: string;
|
|
324
|
+
dueDate: string | null;
|
|
325
|
+
customerId: string | null;
|
|
326
|
+
/** Der abgeleitete Steuerfall. */
|
|
327
|
+
taxScheme: TaxScheme;
|
|
328
|
+
/** Warum dieser Fall gilt (z. B. `customer_country_eu_with_vat_id`). */
|
|
329
|
+
taxSchemeReason: string;
|
|
330
|
+
reverseChargeReason: ReverseChargeReason | null;
|
|
331
|
+
taxCountry: string;
|
|
332
|
+
priceMode: PriceMode;
|
|
333
|
+
totals: InvoiceTotals;
|
|
334
|
+
language: InvoiceLanguage;
|
|
335
|
+
brand: {
|
|
336
|
+
id: string | null;
|
|
337
|
+
name: string | null;
|
|
338
|
+
} | null;
|
|
339
|
+
einvoice: EInvoiceStatus;
|
|
340
|
+
}
|
|
341
|
+
export interface PreviewResult {
|
|
342
|
+
preview: InvoicePreview;
|
|
343
|
+
/** Dieselben Hinweise, die das Ausstellen liefern wuerde. */
|
|
344
|
+
notice?: InvoiceNotice[];
|
|
345
|
+
}
|
|
302
346
|
export interface CancelResult {
|
|
303
347
|
creditNote: Invoice;
|
|
304
348
|
original: {
|
|
@@ -24,7 +24,7 @@ export type RechnungAufruf = (typeof RECHNUNG_AUFRUFE)[number];
|
|
|
24
24
|
* Stabile Fehlercodes. Das Fremdsystem verzweigt am Code, der Text darf sich
|
|
25
25
|
* aendern. `validation` traegt zusaetzlich `errors: [{ field, message }]`.
|
|
26
26
|
*/
|
|
27
|
-
export declare const INVOICE_ERROR_CODES: readonly ["validation", "idempotency_conflict", "module_inactive", "customer_not_found", "customer_exists", "short_code_taken", "short_code_immutable", "recipient_required", "invoice_requirements_missing", "invoice_not_found", "invoice_ambiguous", "not_cancellable", "partial_credit_exists", "credit_exceeds_invoice", "einvoice_incomplete", "invoice_api_not_enabled", "invoice_setup_incomplete", "language_not_allowed", "brand_not_found", "not_payable", "payment_exceeds_invoice", "tax_scheme_mismatch", "vat_rate_not_in_country", "reverse_charge_reason_required", "reverse_charge_threshold", "mixed_supply_not_allowed", "oss_not_enabled"];
|
|
27
|
+
export declare const INVOICE_ERROR_CODES: readonly ["validation", "idempotency_conflict", "module_inactive", "customer_not_found", "customer_exists", "short_code_taken", "short_code_immutable", "recipient_required", "invoice_requirements_missing", "invoice_not_found", "invoice_ambiguous", "not_cancellable", "partial_credit_exists", "credit_exceeds_invoice", "einvoice_incomplete", "invoice_api_not_enabled", "invoice_setup_incomplete", "language_not_allowed", "brand_not_found", "not_payable", "payment_exceeds_invoice", "tax_scheme_mismatch", "vat_rate_not_in_country", "reverse_charge_reason_required", "reverse_charge_threshold", "mixed_supply_not_allowed", "oss_not_enabled", "einvoice_unavailable", "amount_too_large"];
|
|
28
28
|
export type InvoiceErrorCode = (typeof INVOICE_ERROR_CODES)[number];
|
|
29
29
|
/** Gruende einer Gutschrift. Der Server druckt den deutschen Anzeigetext. */
|
|
30
30
|
export declare const CREDIT_NOTE_REASONS: readonly ["cancellation", "price_reduction", "return", "incorrect_invoice", "other"];
|
|
@@ -69,6 +69,8 @@ exports.INVOICE_ERROR_CODES = [
|
|
|
69
69
|
'reverse_charge_threshold',
|
|
70
70
|
'mixed_supply_not_allowed',
|
|
71
71
|
'oss_not_enabled',
|
|
72
|
+
'einvoice_unavailable', // zu dieser Rechnung entsteht keine E-Rechnung, Grund in `reason`
|
|
73
|
+
'amount_too_large', // Betrag ueber der Grenze des Ganzzahlkerns; erst ab dessen Umstieg gesendet
|
|
72
74
|
];
|
|
73
75
|
/** Gruende einer Gutschrift. Der Server druckt den deutschen Anzeigetext. */
|
|
74
76
|
exports.CREDIT_NOTE_REASONS = [
|
|
@@ -420,6 +422,12 @@ exports.RECHNUNG_ANFRAGEN = Object.freeze({
|
|
|
420
422
|
* und ein sofort geholtes PDF Zahlungsinformationen traegt.
|
|
421
423
|
*/
|
|
422
424
|
payment: { typ: 'object', pflicht: false, felder: exports.PAYMENT_FELDER },
|
|
425
|
+
/**
|
|
426
|
+
* Probelauf: alles pruefen und rechnen wie beim Ausstellen, aber nichts
|
|
427
|
+
* festschreiben — keine Nummer, kein Dokument, keine Zahlung, kein
|
|
428
|
+
* Idempotenz-Eintrag. Die Antwort traegt `preview` statt `invoice`.
|
|
429
|
+
*/
|
|
430
|
+
dryRun: { typ: 'boolean', pflicht: false },
|
|
423
431
|
},
|
|
424
432
|
cancelInvoice: {
|
|
425
433
|
idempotencyKey: idempotencyKey(true),
|
|
@@ -87,7 +87,6 @@ export declare function causeDigest(ursache: unknown, geheimnisse: readonly stri
|
|
|
87
87
|
* demselben Grund **keinen** Vorgabewert.
|
|
88
88
|
*/
|
|
89
89
|
export declare function fehlerDetails(daten: unknown, geheimnisse: readonly string[]): Record<string, unknown>;
|
|
90
|
-
/** Fachlicher Fehler: HTTP 200, aber `status: 'error'` im Rumpf. */
|
|
91
90
|
export declare class KasseneckApiError extends Error {
|
|
92
91
|
readonly name = "KasseneckApiError";
|
|
93
92
|
/** Aufgerufene Backend-Funktion, z. B. `createReceipt`. */
|
|
@@ -164,6 +164,29 @@ function sieben(wert, geheimnisse, tiefe) {
|
|
|
164
164
|
return raus;
|
|
165
165
|
}
|
|
166
166
|
/** Fachlicher Fehler: HTTP 200, aber `status: 'error'` im Rumpf. */
|
|
167
|
+
/**
|
|
168
|
+
* Die Feldfehler einer Pruefantwort (`errors: [{ field, message }]`) als
|
|
169
|
+
* Anhang der Fehlermeldung — sonst stuende im Log nur „Bitte Eingaben
|
|
170
|
+
* pruefen." und niemand wuesste, welches Feld gemeint ist. Hoechstens fuenf;
|
|
171
|
+
* vollstaendig bleiben sie in `details.errors`.
|
|
172
|
+
*/
|
|
173
|
+
function feldHinweis(details) {
|
|
174
|
+
const roh = details['errors'];
|
|
175
|
+
if (!Array.isArray(roh))
|
|
176
|
+
return '';
|
|
177
|
+
const teile = [];
|
|
178
|
+
for (const e of roh) {
|
|
179
|
+
if (e === null || typeof e !== 'object')
|
|
180
|
+
continue;
|
|
181
|
+
const { field, message } = e;
|
|
182
|
+
if (typeof field === 'string' && typeof message === 'string')
|
|
183
|
+
teile.push(`${field}: ${message}`);
|
|
184
|
+
}
|
|
185
|
+
if (!teile.length)
|
|
186
|
+
return '';
|
|
187
|
+
const mehr = teile.length > 5 ? ` (+${teile.length - 5} weitere)` : '';
|
|
188
|
+
return ` [${teile.slice(0, 5).join('; ')}${mehr}]`;
|
|
189
|
+
}
|
|
167
190
|
export class KasseneckApiError extends Error {
|
|
168
191
|
name = 'KasseneckApiError';
|
|
169
192
|
/** Aufgerufene Backend-Funktion, z. B. `createReceipt`. */
|
|
@@ -187,7 +210,7 @@ export class KasseneckApiError extends Error {
|
|
|
187
210
|
*/
|
|
188
211
|
details;
|
|
189
212
|
constructor(functionName, serverMessage, details = {}, code) {
|
|
190
|
-
super(`${functionName} fehlgeschlagen: ${serverMessage}`);
|
|
213
|
+
super(`${functionName} fehlgeschlagen: ${serverMessage}${feldHinweis(details)}`);
|
|
191
214
|
this.functionName = functionName;
|
|
192
215
|
this.serverMessage = serverMessage;
|
|
193
216
|
this.details = details;
|