@kreiseck/kasseneck-api 0.22.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.
@@ -16,4 +16,5 @@ export { istRechnungFehler, rechnungFehlerCode, rechnungFeldFehler, } from './fe
16
16
  export { cancelInvoice, createCreditNote, createCustomer, getCustomer, getInvoice, getInvoicePdf, getInvoiceSetupStatus, getInvoiceXml, issueInvoice, listBrands, listInvoices, previewInvoice, recordInvoicePayment, searchCustomers, updateCustomer, } from './endpunkte.js';
17
17
  export { RECHNUNG_TEXTE, rechnungText } from './texte.js';
18
18
  export { rechnungSummen, STEUERFREIE_FAELLE } from './summen.js';
19
+ export { anteiligerPreis, BETRAG_GRENZE_CENTS, positionAusEuro, preisText, RechenFehler, rechnungRechnen, rund, satzSchluessel, satzText, } from './rechnen.js';
19
20
  export { RECHNUNG_VERTRAG_VERSION, RECHNUNG_AUFRUFE, INVOICE_ERROR_CODES, CREDIT_NOTE_REASONS, TAX_SCHEMES, PRICE_MODES, VAT_RATES, CUSTOMER_TYPES, INVOICE_LIST_STATUS, DOC_TYPES, EINVOICE_FORMATS, INVOICE_LANGUAGES, INVOICE_PAYMENT_METHODS, ITEM_KINDS, REVERSE_CHARGE_REASONS, INVOICE_NOTICE_CODES, INVOICE_UNITS, RECHNUNG_EINHEITEN_CODES, INVOICE_SETUP_REQUIREMENTS, KUNDE_FELDER, POSITION_FELDER, RECHNUNG_ANFRAGEN, RECHNUNG_GENAU_EINS, RECHNUNG_MINDESTENS_EINS, } from './vertrag.js';
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Der Rechenkern der Rechnung — exakt, in ganzen Zahlen.
3
+ *
4
+ * Eine Zeile ist ein Bruch: Preis (µ€) × Menge (Tausendstel) × Rabattanteil
5
+ * (Hundertstel-Prozent). Multipliziert man die Skalen, ist die kleinste Einheit
6
+ * 10⁻¹⁷ Cent — das passt in keine Gleitkommazahl und in keine 53-Bit-Ganzzahl,
7
+ * also rechnet der Kern mit BigInt. Gerundet wird genau EINMAL je USt-Satz, auf
8
+ * dem Bruch, kaufmaennisch (halbe Einheit vom Nullpunkt weg).
9
+ *
10
+ * Warum das so streng ist: Vier Umsetzungen (Server, dieses Paket, Dart, Panel)
11
+ * mussten bisher dieselbe Reihenfolge von Gleitkomma-Schritten nachbauen, damit
12
+ * Grenzfaelle gleich runden. Mit Ganzzahlen gibt es keine Reihenfolge mehr, an
13
+ * der etwas auseinanderlaufen koennte.
14
+ *
15
+ * Dieser Unterpfad ist rein: kein Transport, kein Zugangsschluessel, keine
16
+ * Abhaengigkeit ausser Typen. Er wird im Browser gebuendelt (Panel) und im
17
+ * Server geladen.
18
+ *
19
+ * Spec: docs/specs/2026-09-17-rechnung-ganzzahlen-design.md § 5.
20
+ */
21
+ import type { PriceMode, TaxScheme } from './vertrag.js';
22
+ /** Hoechster Betrag je Zeile und je Rechnung: 999.999.999,99 €. */
23
+ export declare const BETRAG_GRENZE_CENTS = 99999999999;
24
+ /** Steuerfaelle, in denen die Rechnung keine Steuer ausweist: jede Zeile zaehlt zu 0 %. */
25
+ export declare const STEUERFREIE_FAELLE: readonly TaxScheme[];
26
+ /** Eine Position in gespeicherter Form — alle Werte ganzzahlig. */
27
+ export interface RechenPosition {
28
+ /** Einzelpreis in Millionstel Euro, 0 … 10¹². */
29
+ unitPriceMicros: number;
30
+ /** Menge in Tausendstel; negativ = Abzugszeile, 0 = Textzeile. */
31
+ quantityMilli: number;
32
+ /** Rabatt in Hundertstel-Prozent, 0 … 10000 (ohne Angabe 0). */
33
+ discountBp?: number;
34
+ /** USt-Satz in Hundertstel-Prozent, 0 … 10000 (ohne Angabe 0). */
35
+ vatRateBp?: number;
36
+ }
37
+ export interface RechenOptionen {
38
+ priceMode: PriceMode;
39
+ /** Ohne Angabe `normal`. */
40
+ taxScheme?: TaxScheme;
41
+ }
42
+ export interface SatzSumme {
43
+ rateBp: number;
44
+ netCents: number;
45
+ vatCents: number;
46
+ grossCents: number;
47
+ }
48
+ export interface ZeilenBetrag {
49
+ netCents: number;
50
+ grossCents: number;
51
+ /** Der Satz, mit dem die Zeile gerechnet wurde (bei steuerfreiem Fall 0). */
52
+ rateBp: number;
53
+ }
54
+ export interface RechenErgebnis {
55
+ netCents: number;
56
+ vatCents: number;
57
+ grossCents: number;
58
+ /** Absteigend nach `rateBp`. */
59
+ byRate: SatzSumme[];
60
+ /** Je Position, in der Reihenfolge der Eingabe. */
61
+ lines: ZeilenBetrag[];
62
+ }
63
+ export type RechenFehlerCode = 'amount_too_large' | 'kein_ganzzahlwert' | 'ausserhalb';
64
+ /** Fehler des Kerns — mit Code, Feld und Index, damit die API daraus einen Feldfehler machen kann. */
65
+ export declare class RechenFehler extends Error {
66
+ readonly code: RechenFehlerCode;
67
+ readonly feld?: string;
68
+ readonly index?: number;
69
+ constructor(code: RechenFehlerCode, nachricht: string, feld?: string, index?: number);
70
+ }
71
+ /**
72
+ * `a / b` kaufmaennisch gerundet, halbe Einheit vom Nullpunkt weg; `b` > 0.
73
+ *
74
+ * Ohne Gleitkomma: `(2·|a| + b) / (2·b)` ist genau dann eins groesser, wenn der
75
+ * Rest mindestens die halbe Einheit betraegt.
76
+ */
77
+ export declare function rund(a: bigint, b: bigint): bigint;
78
+ export declare function rechnungRechnen(positionen: readonly RechenPosition[], optionen: RechenOptionen): RechenErgebnis;
79
+ export type UmwandlungsGrund = 'kein_zahlwert' | 'nachkommastellen' | 'ausserhalb';
80
+ export type Umwandlung = {
81
+ ok: true;
82
+ position: RechenPosition & Record<string, unknown>;
83
+ } | {
84
+ ok: false;
85
+ feld: 'unitPrice' | 'quantity' | 'vatRate' | 'discountPct';
86
+ grund: UmwandlungsGrund;
87
+ };
88
+ /**
89
+ * Euro-Position → Ganzzahl-Form, verlustfrei oder mit Grund abgelehnt. Die
90
+ * EINE Stelle, die das tut: Panel (beim Lesen), SEPA-Lauf, Umrechnungsskript
91
+ * und das Festschreiben benutzen alle diese Funktion, damit niemand eine
92
+ * zweite Auslegung baut.
93
+ *
94
+ * Eine Position, die schon `unitPriceMicros` traegt, kommt unveraendert
95
+ * zurueck — die Funktion ist die Formweiche, nicht eine blinde Umrechnung.
96
+ *
97
+ * Fehlende Menge und fehlender Satz gelten als 0, weil der Server bisher so
98
+ * abgerechnet hat. Ein fehlender Preis dagegen ist NIE 0: sonst wuerde eine
99
+ * Position, der nur der Preis fehlt, still zur 0-€-Zeile.
100
+ */
101
+ export declare function positionAusEuro(item: Record<string, unknown>): Umwandlung;
102
+ /**
103
+ * Anteiliger Preis fuer einen Teilzeitraum (erste Rechnung eines Abos).
104
+ * War der Ursprungspreis centgenau, bleibt auch der Anteil centgenau — sonst
105
+ * stuende auf der ersten Rechnung ein Preis mit sechs Stellen, wo bisher zwei
106
+ * standen.
107
+ */
108
+ export declare function anteiligerPreis(unitPriceMicros: number, monate: number, intervall: number): number;
109
+ /**
110
+ * Schluessel eines USt-Satzes in den gespeicherten Maps (`creditedCents.byRate`,
111
+ * `invoice_stats.vatByRate`): der Prozenttext mit Punkt, genau wie
112
+ * `String(satz)` ihn bisher gebildet hat. NICHT fuer die Anzeige — dafuer gibt
113
+ * es `satzText`.
114
+ */
115
+ export declare function satzSchluessel(rateBp: number): string;
116
+ /** Ein USt-Satz fuer die Anzeige: oesterreichisch mit Komma, ohne nachlaufende Nullen. */
117
+ export declare function satzText(rateBp: number): string;
118
+ /**
119
+ * Ein Einzelpreis fuer die Anzeige: mindestens zwei, hoechstens sechs
120
+ * Nachkommastellen, Tausenderpunkt, Komma als Trennzeichen — in jeder Sprache
121
+ * oesterreichisch, wie die Betraege auf dem Blatt.
122
+ */
123
+ export declare function preisText(unitPriceMicros: number): string;
@@ -0,0 +1,316 @@
1
+ /**
2
+ * Der Rechenkern der Rechnung — exakt, in ganzen Zahlen.
3
+ *
4
+ * Eine Zeile ist ein Bruch: Preis (µ€) × Menge (Tausendstel) × Rabattanteil
5
+ * (Hundertstel-Prozent). Multipliziert man die Skalen, ist die kleinste Einheit
6
+ * 10⁻¹⁷ Cent — das passt in keine Gleitkommazahl und in keine 53-Bit-Ganzzahl,
7
+ * also rechnet der Kern mit BigInt. Gerundet wird genau EINMAL je USt-Satz, auf
8
+ * dem Bruch, kaufmaennisch (halbe Einheit vom Nullpunkt weg).
9
+ *
10
+ * Warum das so streng ist: Vier Umsetzungen (Server, dieses Paket, Dart, Panel)
11
+ * mussten bisher dieselbe Reihenfolge von Gleitkomma-Schritten nachbauen, damit
12
+ * Grenzfaelle gleich runden. Mit Ganzzahlen gibt es keine Reihenfolge mehr, an
13
+ * der etwas auseinanderlaufen koennte.
14
+ *
15
+ * Dieser Unterpfad ist rein: kein Transport, kein Zugangsschluessel, keine
16
+ * Abhaengigkeit ausser Typen. Er wird im Browser gebuendelt (Panel) und im
17
+ * Server geladen.
18
+ *
19
+ * Spec: docs/specs/2026-09-17-rechnung-ganzzahlen-design.md § 5.
20
+ */
21
+ /** Skala des Zeilenbruchs: 10⁻¹⁷ Cent. */
22
+ const E = 10n ** 17n;
23
+ /** Hoechster Betrag je Zeile und je Rechnung: 999.999.999,99 €. */
24
+ export const BETRAG_GRENZE_CENTS = 99_999_999_999;
25
+ /** Steuerfaelle, in denen die Rechnung keine Steuer ausweist: jede Zeile zaehlt zu 0 %. */
26
+ export const STEUERFREIE_FAELLE = Object.freeze([
27
+ 'smallBusiness',
28
+ 'reverseCharge',
29
+ 'igLieferung',
30
+ 'exportThirdCountry',
31
+ 'domesticReverseCharge',
32
+ 'outsideScope',
33
+ ]);
34
+ /** Fehler des Kerns — mit Code, Feld und Index, damit die API daraus einen Feldfehler machen kann. */
35
+ export class RechenFehler extends Error {
36
+ code;
37
+ feld;
38
+ index;
39
+ constructor(code, nachricht, feld, index) {
40
+ super(nachricht);
41
+ this.name = 'RechenFehler';
42
+ this.code = code;
43
+ this.feld = feld;
44
+ this.index = index;
45
+ }
46
+ }
47
+ /**
48
+ * `a / b` kaufmaennisch gerundet, halbe Einheit vom Nullpunkt weg; `b` > 0.
49
+ *
50
+ * Ohne Gleitkomma: `(2·|a| + b) / (2·b)` ist genau dann eins groesser, wenn der
51
+ * Rest mindestens die halbe Einheit betraegt.
52
+ */
53
+ export function rund(a, b) {
54
+ const negativ = a < 0n;
55
+ const betrag = negativ ? -a : a;
56
+ const ganz = (2n * betrag + b) / (2n * b);
57
+ return negativ ? -ganz : ganz;
58
+ }
59
+ const GRENZEN = {
60
+ unitPriceMicros: [0, 1_000_000_000_000],
61
+ quantityMilli: [-1_000_000_000_000, 1_000_000_000_000],
62
+ discountBp: [0, 10_000],
63
+ vatRateBp: [0, 10_000],
64
+ };
65
+ function ganzzahl(wert, feld, index) {
66
+ if (typeof wert !== 'number' || !Number.isInteger(wert)) {
67
+ throw new RechenFehler('kein_ganzzahlwert', `items[${index}].${feld} muss eine ganze Zahl sein`, feld, index);
68
+ }
69
+ const [min, max] = GRENZEN[feld];
70
+ if (wert < min || wert > max) {
71
+ throw new RechenFehler('ausserhalb', `items[${index}].${feld} liegt ausserhalb von ${min} … ${max}`, feld, index);
72
+ }
73
+ return wert;
74
+ }
75
+ /**
76
+ * Verteilt die Zeilenbetraege eines Satzes so, dass sie genau die Satzsumme
77
+ * ergeben. Startwert ist die kaufmaennisch gerundete Zeile; der Rest geht
78
+ * einzeln an die Zeilen, bei denen das Runden am meisten weggenommen (bzw.
79
+ * zugegeben) hat. Verglichen wird auf dem Bruch, nie in Gleitkomma; bei
80
+ * Gleichstand bekommt die fruehere Zeile den Cent. Eine Zeile ueber 0 bekommt
81
+ * nie einen Cent — sonst stuende auf dem Blatt ein Betrag, den die Position
82
+ * nicht hat.
83
+ */
84
+ function verteile(gruppe, feld, faktor, nenner, ziel) {
85
+ const werte = gruppe.map((z) => {
86
+ const zaehler = z.L * faktor;
87
+ return { z, zaehler, wert: rund(zaehler, nenner) };
88
+ });
89
+ let rest = ziel - werte.reduce((s, w) => s + w.wert, 0n);
90
+ if (rest !== 0n) {
91
+ const schritt = rest > 0n ? 1n : -1n;
92
+ const kandidaten = werte
93
+ .filter((w) => w.zaehler !== 0n)
94
+ .sort((a, b) => {
95
+ const da = (a.zaehler - a.wert * nenner) * schritt;
96
+ const db = (b.zaehler - b.wert * nenner) * schritt;
97
+ if (da !== db)
98
+ return db > da ? 1 : -1;
99
+ return a.z.index - b.z.index;
100
+ });
101
+ for (let k = 0; rest !== 0n && kandidaten.length > 0; k = (k + 1) % kandidaten.length) {
102
+ kandidaten[k].wert += schritt;
103
+ rest -= schritt;
104
+ }
105
+ }
106
+ for (const w of werte)
107
+ w.z[feld] = Number(w.wert);
108
+ }
109
+ export function rechnungRechnen(positionen, optionen) {
110
+ const steuerfrei = STEUERFREIE_FAELLE.includes(optionen.taxScheme ?? 'normal');
111
+ const bruttoPreise = optionen.priceMode === 'gross' && !steuerfrei;
112
+ const grenze = BigInt(BETRAG_GRENZE_CENTS) * E;
113
+ const zeilen = positionen.map((p, index) => {
114
+ const preis = BigInt(ganzzahl(p.unitPriceMicros, 'unitPriceMicros', index));
115
+ const menge = BigInt(ganzzahl(p.quantityMilli, 'quantityMilli', index));
116
+ const rabatt = BigInt(ganzzahl(p.discountBp ?? 0, 'discountBp', index));
117
+ const satz = ganzzahl(p.vatRateBp ?? 0, 'vatRateBp', index);
118
+ const L = preis * menge * (10000n - rabatt) * 1000000n;
119
+ if ((L < 0n ? -L : L) > grenze) {
120
+ throw new RechenFehler('amount_too_large', `items[${index}] uebersteigt ${BETRAG_GRENZE_CENTS} Cent`, 'unitPriceMicros', index);
121
+ }
122
+ return { index, rateBp: steuerfrei ? 0 : satz, L, netCents: 0, grossCents: 0 };
123
+ });
124
+ const jeSatz = new Map();
125
+ for (const z of zeilen) {
126
+ const gruppe = jeSatz.get(z.rateBp);
127
+ if (gruppe)
128
+ gruppe.push(z);
129
+ else
130
+ jeSatz.set(z.rateBp, [z]);
131
+ }
132
+ const byRate = [];
133
+ for (const [rateBp, gruppe] of jeSatz) {
134
+ const r = BigInt(rateBp);
135
+ const S = gruppe.reduce((s, z) => s + z.L, 0n);
136
+ let netCents;
137
+ let vatCents;
138
+ let grossCents;
139
+ if (bruttoPreise) {
140
+ grossCents = rund(S, E);
141
+ netCents = rund(grossCents * 10000n, 10000n + r);
142
+ vatCents = grossCents - netCents;
143
+ }
144
+ else {
145
+ netCents = rund(S, E);
146
+ vatCents = rund(S * r, E * 10000n);
147
+ grossCents = netCents + vatCents;
148
+ }
149
+ if (bruttoPreise) {
150
+ verteile(gruppe, 'netCents', 10000n, E * (10000n + r), netCents);
151
+ verteile(gruppe, 'grossCents', 1n, E, grossCents);
152
+ }
153
+ else {
154
+ verteile(gruppe, 'netCents', 1n, E, netCents);
155
+ verteile(gruppe, 'grossCents', 10000n + r, E * 10000n, grossCents);
156
+ }
157
+ byRate.push({
158
+ rateBp,
159
+ netCents: Number(netCents),
160
+ vatCents: Number(vatCents),
161
+ grossCents: Number(grossCents),
162
+ });
163
+ }
164
+ byRate.sort((a, b) => b.rateBp - a.rateBp);
165
+ const summe = (feld) => byRate.reduce((s, r) => s + r[feld], 0);
166
+ const ergebnis = {
167
+ netCents: summe('netCents'),
168
+ vatCents: summe('vatCents'),
169
+ grossCents: summe('grossCents'),
170
+ byRate,
171
+ lines: zeilen.map((z) => ({ netCents: z.netCents, grossCents: z.grossCents, rateBp: z.rateBp })),
172
+ };
173
+ for (const feld of ['netCents', 'vatCents', 'grossCents']) {
174
+ if (Math.abs(ergebnis[feld]) > BETRAG_GRENZE_CENTS) {
175
+ throw new RechenFehler('amount_too_large', `Die Rechnung uebersteigt ${BETRAG_GRENZE_CENTS} Cent (${feld})`);
176
+ }
177
+ }
178
+ return ergebnis;
179
+ }
180
+ /**
181
+ * Wandelt eine Zahl ueber ihren kuerzesten Dezimaltext in eine Ganzzahl mit
182
+ * `stellen` Nachkommastellen. Kein Gleitkomma-Rechnen: `0.1 * 1000` ist
183
+ * 100.00000000000001, `String(0.1)` dagegen genau "0.1". Mehr Stellen als
184
+ * erlaubt ergeben `null` — auch Gleitkomma-Rauschen wie 0.30000000000000004,
185
+ * das mit 17 Stellen dasteht.
186
+ */
187
+ function ganzAusDezimaltext(wert, stellen) {
188
+ if (typeof wert !== 'number' || !Number.isFinite(wert))
189
+ return null;
190
+ const treffer = /^(-?)(\d+)(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/.exec(String(wert));
191
+ if (!treffer)
192
+ return null;
193
+ const [, vorzeichen = '', ganzTeil = '0', bruchTeil = '', hoch = '0'] = treffer;
194
+ const verschiebung = Number(hoch);
195
+ let ziffern = `${ganzTeil}${bruchTeil}`;
196
+ let nachkomma = bruchTeil.length - verschiebung;
197
+ if (nachkomma < 0) {
198
+ ziffern += '0'.repeat(-nachkomma);
199
+ nachkomma = 0;
200
+ }
201
+ if (nachkomma > stellen) {
202
+ // Nur echte Nullen am Ende duerfen wegfallen (1.20 bei 1 Stelle).
203
+ const zuviel = nachkomma - stellen;
204
+ if (!/^0+$/.test(ziffern.slice(-zuviel)))
205
+ return null;
206
+ ziffern = ziffern.slice(0, -zuviel);
207
+ nachkomma = stellen;
208
+ }
209
+ const skaliert = `${ziffern}${'0'.repeat(stellen - nachkomma)}`;
210
+ const zahl = Number(`${vorzeichen}${skaliert}`);
211
+ return Number.isSafeInteger(zahl) ? zahl : null;
212
+ }
213
+ const EURO_FELDER = ['unitPrice', 'quantity', 'vatRate', 'discountPct'];
214
+ /**
215
+ * Euro-Position → Ganzzahl-Form, verlustfrei oder mit Grund abgelehnt. Die
216
+ * EINE Stelle, die das tut: Panel (beim Lesen), SEPA-Lauf, Umrechnungsskript
217
+ * und das Festschreiben benutzen alle diese Funktion, damit niemand eine
218
+ * zweite Auslegung baut.
219
+ *
220
+ * Eine Position, die schon `unitPriceMicros` traegt, kommt unveraendert
221
+ * zurueck — die Funktion ist die Formweiche, nicht eine blinde Umrechnung.
222
+ *
223
+ * Fehlende Menge und fehlender Satz gelten als 0, weil der Server bisher so
224
+ * abgerechnet hat. Ein fehlender Preis dagegen ist NIE 0: sonst wuerde eine
225
+ * Position, der nur der Preis fehlt, still zur 0-€-Zeile.
226
+ */
227
+ export function positionAusEuro(item) {
228
+ if (typeof item.unitPriceMicros === 'number') {
229
+ return { ok: true, position: item };
230
+ }
231
+ const roh = item.unitPrice;
232
+ if (typeof roh !== 'number' || !Number.isFinite(roh)) {
233
+ return { ok: false, feld: 'unitPrice', grund: 'kein_zahlwert' };
234
+ }
235
+ const micros = ganzAusDezimaltext(Math.abs(roh), 6);
236
+ if (micros === null)
237
+ return { ok: false, feld: 'unitPrice', grund: 'nachkommastellen' };
238
+ if (micros > GRENZEN.unitPriceMicros[1])
239
+ return { ok: false, feld: 'unitPrice', grund: 'ausserhalb' };
240
+ const zahl = (feld, stellen, max) => {
241
+ const w = item[feld];
242
+ if (w === undefined || w === null)
243
+ return { ok: true, wert: 0 };
244
+ if (typeof w !== 'number' || !Number.isFinite(w))
245
+ return { ok: false, grund: 'kein_zahlwert' };
246
+ const ganz = ganzAusDezimaltext(w, stellen);
247
+ if (ganz === null)
248
+ return { ok: false, grund: 'nachkommastellen' };
249
+ if (ganz < (feld === 'quantity' ? -max : 0) || ganz > max)
250
+ return { ok: false, grund: 'ausserhalb' };
251
+ return { ok: true, wert: ganz };
252
+ };
253
+ const menge = zahl('quantity', 3, GRENZEN.quantityMilli[1]);
254
+ if (!menge.ok)
255
+ return { ok: false, feld: 'quantity', grund: menge.grund };
256
+ const satz = zahl('vatRate', 2, 10_000);
257
+ if (!satz.ok)
258
+ return { ok: false, feld: 'vatRate', grund: satz.grund };
259
+ const rabatt = zahl('discountPct', 2, 10_000);
260
+ if (!rabatt.ok)
261
+ return { ok: false, feld: 'discountPct', grund: rabatt.grund };
262
+ const rest = { ...item };
263
+ for (const feld of EURO_FELDER)
264
+ delete rest[feld];
265
+ return {
266
+ ok: true,
267
+ position: {
268
+ ...rest,
269
+ unitPriceMicros: micros,
270
+ quantityMilli: roh < 0 ? -menge.wert : menge.wert,
271
+ discountBp: rabatt.wert,
272
+ vatRateBp: satz.wert,
273
+ },
274
+ };
275
+ }
276
+ /**
277
+ * Anteiliger Preis fuer einen Teilzeitraum (erste Rechnung eines Abos).
278
+ * War der Ursprungspreis centgenau, bleibt auch der Anteil centgenau — sonst
279
+ * stuende auf der ersten Rechnung ein Preis mit sechs Stellen, wo bisher zwei
280
+ * standen.
281
+ */
282
+ export function anteiligerPreis(unitPriceMicros, monate, intervall) {
283
+ const micros = BigInt(unitPriceMicros);
284
+ const anteil = rund(micros * BigInt(monate), BigInt(intervall));
285
+ if (unitPriceMicros % 10_000 !== 0)
286
+ return Number(anteil);
287
+ return Number(rund(anteil, 10000n) * 10000n);
288
+ }
289
+ /**
290
+ * Schluessel eines USt-Satzes in den gespeicherten Maps (`creditedCents.byRate`,
291
+ * `invoice_stats.vatByRate`): der Prozenttext mit Punkt, genau wie
292
+ * `String(satz)` ihn bisher gebildet hat. NICHT fuer die Anzeige — dafuer gibt
293
+ * es `satzText`.
294
+ */
295
+ export function satzSchluessel(rateBp) {
296
+ return String(rateBp / 100);
297
+ }
298
+ /** Ein USt-Satz fuer die Anzeige: oesterreichisch mit Komma, ohne nachlaufende Nullen. */
299
+ export function satzText(rateBp) {
300
+ return satzSchluessel(rateBp).replace('.', ',');
301
+ }
302
+ /**
303
+ * Ein Einzelpreis fuer die Anzeige: mindestens zwei, hoechstens sechs
304
+ * Nachkommastellen, Tausenderpunkt, Komma als Trennzeichen — in jeder Sprache
305
+ * oesterreichisch, wie die Betraege auf dem Blatt.
306
+ */
307
+ export function preisText(unitPriceMicros) {
308
+ const negativ = unitPriceMicros < 0;
309
+ const betrag = Math.abs(unitPriceMicros);
310
+ const ganz = Math.trunc(betrag / 1_000_000);
311
+ let bruch = String(betrag % 1_000_000).padStart(6, '0');
312
+ while (bruch.length > 2 && bruch.endsWith('0'))
313
+ bruch = bruch.slice(0, -1);
314
+ const mitPunkten = String(ganz).replace(/\B(?=(\d{3})+(?!\d))/g, '.');
315
+ return `${negativ ? '-' : ''}${mitPunkten},${bruch}`;
316
+ }
@@ -20,6 +20,9 @@
20
20
  */
21
21
  import type { InvoiceTotals } from './typen.js';
22
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 };
23
26
  /** Was fuer die Summe zaehlt — `InvoiceItemInput` passt unveraendert. */
24
27
  export interface SummenPosition {
25
28
  quantity: number;
@@ -27,8 +30,6 @@ export interface SummenPosition {
27
30
  vatRate: number;
28
31
  discountPct?: number;
29
32
  }
30
- /** Steuerfaelle, in denen die Rechnung keine Steuer ausweist: jede Zeile zaehlt zu 0 %. */
31
- export declare const STEUERFREIE_FAELLE: readonly TaxScheme[];
32
33
  /**
33
34
  * Netto, USt und Brutto einer Rechnung in Cent, je Satz absteigend — dieselbe
34
35
  * Form wie `invoice.totals` in den Antworten.
@@ -18,15 +18,9 @@
18
18
  * Dart-Zwilling pruefen gegen dieselbe Datei. Verbindlich bleibt, was der
19
19
  * Server rechnet — mit `previewInvoice` laesst sich das vorab abfragen.
20
20
  */
21
- /** Steuerfaelle, in denen die Rechnung keine Steuer ausweist: jede Zeile zaehlt zu 0 %. */
22
- export const STEUERFREIE_FAELLE = Object.freeze([
23
- 'smallBusiness',
24
- 'reverseCharge',
25
- 'igLieferung',
26
- 'exportThirdCountry',
27
- 'domesticReverseCharge',
28
- 'outsideScope',
29
- ]);
21
+ import { STEUERFREIE_FAELLE } from './rechnen.js';
22
+ /** Aus dem Kern uebernommen, damit Kern und Huelle nie auseinanderlaufen. */
23
+ export { STEUERFREIE_FAELLE };
30
24
  /** Auf Cent gerundete Euro — dieselbe Rundung wie am Server (halber Cent vom Nullpunkt weg). */
31
25
  function euroRund(n) {
32
26
  return ((n < 0 ? -1 : 1) * Math.round((Math.abs(n) + Number.EPSILON) * 100)) / 100;
@@ -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"];
@@ -66,6 +66,8 @@ export const INVOICE_ERROR_CODES = [
66
66
  'reverse_charge_threshold',
67
67
  'mixed_supply_not_allowed',
68
68
  'oss_not_enabled',
69
+ 'einvoice_unavailable', // zu dieser Rechnung entsteht keine E-Rechnung, Grund in `reason`
70
+ 'amount_too_large', // Betrag ueber der Grenze des Ganzzahlkerns; erst ab dessen Umstieg gesendet
69
71
  ];
70
72
  /** Gruende einer Gutschrift. Der Server druckt den deutschen Anzeigetext. */
71
73
  export const CREDIT_NOTE_REASONS = [
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.22.0",
2
+ "version": "0.23.0",
3
3
  "gemessenAn": {
4
4
  "tid": "3600335",
5
5
  "hpsVersion": "1.10.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.22.0",
2
+ "version": "0.23.0",
3
3
  "meldungen": {
4
4
  "netz.keine_verbindung": {
5
5
  "text": "Keine Verbindung zum Server. Bitte die Internetverbindung prüfen und erneut versuchen."
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.22.0",
2
+ "version": "0.23.0",
3
3
  "aufrufe": [
4
4
  "activateCashregister",
5
5
  "cancelInvoice",
@@ -410,7 +410,9 @@
410
410
  "reverse_charge_reason_required",
411
411
  "reverse_charge_threshold",
412
412
  "mixed_supply_not_allowed",
413
- "oss_not_enabled"
413
+ "oss_not_enabled",
414
+ "einvoice_unavailable",
415
+ "amount_too_large"
414
416
  ],
415
417
  "invoiceLanguages": [
416
418
  "de",
@@ -0,0 +1,107 @@
1
+ {
2
+ "beschreibung": "Prüffälle des Umwandlers positionAusEuro (Euro-Position → Ganzzahl-Form). Preis in Millionstel Euro, Menge in Tausendstel, Rabatt und Satz in Hundertstel-Prozent. Umgewandelt wird über den kürzesten Dezimaltext der Zahl, nie per Gleitkomma-Multiplikation — Gleitkomma-Rauschen wie 0,30000000000000004 muss abgelehnt werden.",
3
+ "regel": "Fehlende Menge und fehlender Satz gelten als 0 (so rechnet der Server ab). Ein fehlender Preis ist nie 0, sondern ein Grund (kein_zahlwert). Ein Minus am Preis wandert an die Menge.",
4
+ "faelle": [
5
+ {
6
+ "name": "Ueblicher Fall: Preis, Menge, Satz und Rabatt zusammen",
7
+ "item": { "unitPrice": 9.9, "quantity": 3, "vatRate": 10, "discountPct": 5, "description": "Reinigung" },
8
+ "erwartet": {
9
+ "ok": true,
10
+ "position": { "unitPriceMicros": 9900000, "quantityMilli": 3000, "discountBp": 500, "vatRateBp": 1000, "description": "Reinigung" }
11
+ }
12
+ },
13
+ {
14
+ "name": "Schon Ganzzahl-Form kommt unveraendert zurueck",
15
+ "item": { "unitPriceMicros": 12345678, "quantityMilli": -500, "vatRateBp": 2000 },
16
+ "erwartet": {
17
+ "ok": true,
18
+ "position": { "unitPriceMicros": 12345678, "quantityMilli": -500, "vatRateBp": 2000 }
19
+ }
20
+ },
21
+ {
22
+ "name": "Fehlende Menge gilt als 0",
23
+ "item": { "unitPrice": 5.5, "vatRate": 10 },
24
+ "erwartet": {
25
+ "ok": true,
26
+ "position": { "unitPriceMicros": 5500000, "quantityMilli": 0, "discountBp": 0, "vatRateBp": 1000 }
27
+ }
28
+ },
29
+ {
30
+ "name": "Fehlender Satz gilt als 0",
31
+ "item": { "unitPrice": 3.33, "quantity": 4 },
32
+ "erwartet": {
33
+ "ok": true,
34
+ "position": { "unitPriceMicros": 3330000, "quantityMilli": 4000, "discountBp": 0, "vatRateBp": 0 }
35
+ }
36
+ },
37
+ {
38
+ "name": "Fehlender Preis ist nie 0",
39
+ "item": { "quantity": 2, "vatRate": 20 },
40
+ "erwartet": { "ok": false, "feld": "unitPrice", "grund": "kein_zahlwert" }
41
+ },
42
+ {
43
+ "name": "Negativer Preis wandert an die Menge",
44
+ "item": { "unitPrice": -7.5, "quantity": 3, "vatRate": 10 },
45
+ "erwartet": {
46
+ "ok": true,
47
+ "position": { "unitPriceMicros": 7500000, "quantityMilli": -3000, "discountBp": 0, "vatRateBp": 1000 }
48
+ }
49
+ },
50
+ {
51
+ "name": "Negative Menge bleibt negativ",
52
+ "item": { "unitPrice": 4, "quantity": -6 },
53
+ "erwartet": {
54
+ "ok": true,
55
+ "position": { "unitPriceMicros": 4000000, "quantityMilli": -6000, "discountBp": 0, "vatRateBp": 0 }
56
+ }
57
+ },
58
+ {
59
+ "name": "Gleitkomma-Rauschen am Preis (8,333333333333334) wird abgelehnt",
60
+ "item": { "unitPrice": 8.333333333333334 },
61
+ "erwartet": { "ok": false, "feld": "unitPrice", "grund": "nachkommastellen" }
62
+ },
63
+ {
64
+ "name": "Gleitkomma-Rauschen am Rabatt (0,30000000000000004) wird abgelehnt",
65
+ "item": { "unitPrice": 1, "quantity": 1, "discountPct": 0.30000000000000004 },
66
+ "erwartet": { "ok": false, "feld": "discountPct", "grund": "nachkommastellen" }
67
+ },
68
+ {
69
+ "name": "Menge mit 4 Nachkommastellen wird abgelehnt",
70
+ "item": { "unitPrice": 2, "quantity": 1.2345 },
71
+ "erwartet": { "ok": false, "feld": "quantity", "grund": "nachkommastellen" }
72
+ },
73
+ {
74
+ "name": "Rabatt 12,345 % wird abgelehnt",
75
+ "item": { "unitPrice": 1, "quantity": 1, "discountPct": 12.345 },
76
+ "erwartet": { "ok": false, "feld": "discountPct", "grund": "nachkommastellen" }
77
+ },
78
+ {
79
+ "name": "Text statt Zahl beim Preis",
80
+ "item": { "unitPrice": "9,99" },
81
+ "erwartet": { "ok": false, "feld": "unitPrice", "grund": "kein_zahlwert" }
82
+ },
83
+ {
84
+ "name": "Satz 120 % liegt ausserhalb",
85
+ "item": { "unitPrice": 1, "quantity": 1, "vatRate": 120 },
86
+ "erwartet": { "ok": false, "feld": "vatRate", "grund": "ausserhalb" }
87
+ },
88
+ {
89
+ "name": "Preis ueber 10^12 Mikro-Euro liegt ausserhalb",
90
+ "item": { "unitPrice": 1000001, "quantity": 1 },
91
+ "erwartet": { "ok": false, "feld": "unitPrice", "grund": "ausserhalb" }
92
+ },
93
+ {
94
+ "name": "Satz mit zwei Nachkommastellen (4,95 %) ist verlustfrei",
95
+ "item": { "unitPrice": 1, "quantity": 1, "vatRate": 4.95 },
96
+ "erwartet": {
97
+ "ok": true,
98
+ "position": { "unitPriceMicros": 1000000, "quantityMilli": 1000, "discountBp": 0, "vatRateBp": 495 }
99
+ }
100
+ },
101
+ {
102
+ "name": "Satz mit drei Nachkommastellen (4,955 %) wird abgelehnt",
103
+ "item": { "unitPrice": 1, "quantity": 1, "vatRate": 4.955 },
104
+ "erwartet": { "ok": false, "feld": "vatRate", "grund": "nachkommastellen" }
105
+ }
106
+ ]
107
+ }
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "title": "Kasseneck Rechnungs-API",
4
4
  "version": 1,
5
- "paket": "0.22.0",
5
+ "paket": "0.23.0",
6
6
  "aufrufe": {
7
7
  "createCustomer": {
8
8
  "anfrage": {
@@ -929,7 +929,9 @@
929
929
  "reverse_charge_reason_required",
930
930
  "reverse_charge_threshold",
931
931
  "mixed_supply_not_allowed",
932
- "oss_not_enabled"
932
+ "oss_not_enabled",
933
+ "einvoice_unavailable",
934
+ "amount_too_large"
933
935
  ],
934
936
  "gruende": [
935
937
  "cancellation",