@kreiseck/kasseneck-api 0.6.45 → 0.7.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.
Files changed (70) hide show
  1. package/CHANGELOG.md +154 -0
  2. package/README.md +158 -2
  3. package/dist/cjs/client/aufrufe.d.ts +1 -1
  4. package/dist/cjs/client/aufrufe.js +20 -0
  5. package/dist/cjs/client/errors.d.ts +31 -1
  6. package/dist/cjs/client/errors.js +98 -1
  7. package/dist/cjs/client/transport.js +7 -7
  8. package/dist/cjs/kasse/index.d.ts +1 -0
  9. package/dist/cjs/kasse/index.js +3 -0
  10. package/dist/cjs/kasse/trinkgeld.d.ts +10 -0
  11. package/dist/cjs/kasse/trinkgeld.js +27 -0
  12. package/dist/cjs/partner/ablauf.d.ts +47 -0
  13. package/dist/cjs/partner/ablauf.js +99 -0
  14. package/dist/cjs/partner/api.d.ts +68 -0
  15. package/dist/cjs/partner/api.js +44 -0
  16. package/dist/cjs/partner/auth.d.ts +33 -0
  17. package/dist/cjs/partner/auth.js +52 -0
  18. package/dist/cjs/partner/betrieb.d.ts +48 -0
  19. package/dist/cjs/partner/betrieb.js +136 -0
  20. package/dist/cjs/partner/endpunkte.d.ts +142 -0
  21. package/dist/cjs/partner/endpunkte.js +488 -0
  22. package/dist/cjs/partner/fehler.d.ts +72 -0
  23. package/dist/cjs/partner/fehler.js +199 -0
  24. package/dist/cjs/partner/index.d.ts +28 -0
  25. package/dist/cjs/partner/index.js +84 -0
  26. package/dist/cjs/partner/secret.d.ts +66 -0
  27. package/dist/cjs/partner/secret.js +95 -0
  28. package/dist/cjs/partner/typen.d.ts +399 -0
  29. package/dist/cjs/partner/typen.js +33 -0
  30. package/dist/cjs/partner/webhook-signatur.d.ts +95 -0
  31. package/dist/cjs/partner/webhook-signatur.js +158 -0
  32. package/dist/cjs/partner/webhooks.d.ts +211 -0
  33. package/dist/cjs/partner/webhooks.js +269 -0
  34. package/dist/cjs/register/pairing.d.ts +8 -1
  35. package/dist/cjs/register/pairing.js +8 -1
  36. package/dist/esm/client/aufrufe.d.ts +1 -1
  37. package/dist/esm/client/aufrufe.js +20 -0
  38. package/dist/esm/client/errors.d.ts +31 -1
  39. package/dist/esm/client/errors.js +97 -1
  40. package/dist/esm/client/transport.js +8 -8
  41. package/dist/esm/kasse/index.d.ts +1 -0
  42. package/dist/esm/kasse/index.js +1 -0
  43. package/dist/esm/kasse/trinkgeld.d.ts +10 -0
  44. package/dist/esm/kasse/trinkgeld.js +24 -0
  45. package/dist/esm/partner/ablauf.d.ts +47 -0
  46. package/dist/esm/partner/ablauf.js +95 -0
  47. package/dist/esm/partner/api.d.ts +68 -0
  48. package/dist/esm/partner/api.js +41 -0
  49. package/dist/esm/partner/auth.d.ts +33 -0
  50. package/dist/esm/partner/auth.js +48 -0
  51. package/dist/esm/partner/betrieb.d.ts +48 -0
  52. package/dist/esm/partner/betrieb.js +132 -0
  53. package/dist/esm/partner/endpunkte.d.ts +142 -0
  54. package/dist/esm/partner/endpunkte.js +474 -0
  55. package/dist/esm/partner/fehler.d.ts +72 -0
  56. package/dist/esm/partner/fehler.js +189 -0
  57. package/dist/esm/partner/index.d.ts +28 -0
  58. package/dist/esm/partner/index.js +27 -0
  59. package/dist/esm/partner/secret.d.ts +66 -0
  60. package/dist/esm/partner/secret.js +90 -0
  61. package/dist/esm/partner/typen.d.ts +399 -0
  62. package/dist/esm/partner/typen.js +30 -0
  63. package/dist/esm/partner/webhook-signatur.d.ts +95 -0
  64. package/dist/esm/partner/webhook-signatur.js +153 -0
  65. package/dist/esm/partner/webhooks.d.ts +211 -0
  66. package/dist/esm/partner/webhooks.js +257 -0
  67. package/dist/esm/register/pairing.d.ts +8 -1
  68. package/dist/esm/register/pairing.js +8 -1
  69. package/fixtures/oberflaeche.json +132 -3
  70. package/package.json +13 -1
@@ -0,0 +1,399 @@
1
+ /**
2
+ * Die Formen, die die Partner-API sendet und zurueckgibt.
3
+ *
4
+ * **Die Referenz ist das Backend**, nicht diese Datei: `docs/api/partner.md`
5
+ * (ausfuehrlich) und `docs/api/partner.llms.txt` (kompakt) beschreiben Felder,
6
+ * Fehlercodes und Ereignisse. Hier stehen sie als Typen, damit ein Aufrufer
7
+ * beim Tippen sieht, was es gibt — nicht als zweiter Text daneben.
8
+ *
9
+ * **Lesen ist tolerant, Schreiben ist streng.** Antworttypen fuehren `null`
10
+ * dort, wo das Backend `null` schickt, und lassen unbekannte Statuswerte als
11
+ * `string` durch (`(string & {})`): eine Fassung, die einen neuen Status
12
+ * einfuehrt, soll diesen Client nicht zum Absturz bringen, sondern ihn
13
+ * durchreichen. Eingabetypen sind dagegen eng — ein Tippfehler soll ein
14
+ * Compilerfehler sein und keine `validation`-Antwort vom Server.
15
+ */
16
+ import type { KasseneckSecret } from './secret.js';
17
+ /**
18
+ * Die beiden Umgebungen. Als Liste und nicht nur als Typ, damit ein Aufrufer
19
+ * sie zur Laufzeit pruefen kann — und damit der Zwilling sie nachhaelt.
20
+ */
21
+ export declare const PARTNER_ENVS: readonly ["live", "test"];
22
+ /** Umgebung, in der ein Partner-Schluessel bzw. ein Betrieb lebt. */
23
+ export type PartnerEnv = typeof PARTNER_ENVS[number];
24
+ /** Berechtigungen eines Partner-Schluessels. */
25
+ export type PartnerScope = 'partner:read' | 'customers:read' | 'customers:write' | 'webhooks:read' | 'webhooks:write' | 'credentials:read' | (string & {});
26
+ /**
27
+ * `credentials:read` gehoert **nicht** zum Standardsatz und wird keinem
28
+ * bestehenden Schluessel nachtraeglich hinzugefuegt: wer ihn hat, kann im Namen
29
+ * fremder Betriebe Belege signieren. Dafuer wird ein eigener Schluessel
30
+ * angelegt.
31
+ */
32
+ export declare const SCOPE_CREDENTIALS: PartnerScope;
33
+ export interface PartnerApp {
34
+ id: string;
35
+ name: string;
36
+ status: string;
37
+ platform: string | null;
38
+ distributions: unknown[];
39
+ platforms: string[];
40
+ symbol: {
41
+ url: string;
42
+ } | null;
43
+ published: boolean;
44
+ listingAllowed: boolean;
45
+ }
46
+ export interface PartnerInfo {
47
+ partner: {
48
+ id: string;
49
+ name: string;
50
+ status: string;
51
+ /**
52
+ * Darf dieser Partner fuer seine Betriebe einen Zugang zum Kundenpanel
53
+ * einrichten lassen? **Vorgabe `false`** — die Freischaltung setzt
54
+ * Kasseneck je Partner. Ohne sie antworten `access:{invite:true}` und
55
+ * `resendPartnerCustomerInvite` mit `zugang_nicht_erlaubt`, und es
56
+ * entsteht nichts, auch kein Betrieb.
57
+ */
58
+ canCreateAccess: boolean;
59
+ };
60
+ env: PartnerEnv;
61
+ scopes: PartnerScope[];
62
+ key: {
63
+ hint: string | null;
64
+ label: string | null;
65
+ createdAt: number | null;
66
+ scopes: PartnerScope[];
67
+ };
68
+ apps: PartnerApp[];
69
+ }
70
+ export type Rechtsform = 'einzel' | 'eu' | 'og' | 'kg' | 'gmbh' | 'gmbhcokg' | 'ag' | 'verein' | 'sonstige';
71
+ export type Bundesland = 'burgenland' | 'kaernten' | 'niederoesterreich' | 'oberoesterreich' | 'salzburg' | 'steiermark' | 'tirol' | 'vorarlberg' | 'wien';
72
+ export type KontaktRolle = 'geschaeftsfuehrung' | 'buchhaltung' | 'technik' | 'kasse';
73
+ export interface BetriebAdresse {
74
+ street: string;
75
+ /** Hausnummer, kurz und alphanumerisch: `49`, `12a`, `49/5`. */
76
+ number?: string;
77
+ /** Oesterreichische Postleitzahl, vier Ziffern. */
78
+ zip: string;
79
+ city: string;
80
+ }
81
+ export interface BetriebSteuer {
82
+ /** Steuernummer im Format `12-345/6789`; die Pruefziffer wird geprueft. */
83
+ taxNumber: string;
84
+ smallBusiness: boolean;
85
+ /** UID, z. B. `ATU12345675`. */
86
+ uid?: string;
87
+ /** GLN, 13 Ziffern. */
88
+ gln?: string;
89
+ }
90
+ export interface BetriebKontakt {
91
+ name: string;
92
+ email: string;
93
+ phone?: string;
94
+ roles?: KontaktRolle[];
95
+ }
96
+ export interface BetriebSteuerberater {
97
+ name: string;
98
+ email: string;
99
+ phone: string;
100
+ mayContact: boolean;
101
+ }
102
+ /**
103
+ * Die Stammdaten eines Betriebs. Geprueft wird mit denselben Regeln wie in
104
+ * Kasseneckens eigener Kundenaufnahme (`@kreiseck/validator`); bei einem
105
+ * Formfehler entsteht **nichts** — die Antwort traegt `code:"validation"` und
106
+ * `data.errors[{field,message}]`.
107
+ *
108
+ * **Genau diese Felder, kein weiteres.** Das Backend weist ein unbekanntes
109
+ * Feld ab, statt es stillschweigend zu verwerfen, und nennt seinen vollen
110
+ * Pfad (`address.land`, `contacts.0.rolle`). Deshalb hat dieser Typ dieselbe
111
+ * Liste wie `partner-core.BETRIEB_FELDER` — ein ueberzaehliges Feld ist hier
112
+ * ein Compilerfehler und nicht erst eine Antwort vom Server. Fuer Daten, die
113
+ * nicht durch die Typpruefung kommen (Datenbank, Formular), beantwortet
114
+ * [unbekannteBetriebsfelder] dieselbe Frage zur Laufzeit.
115
+ */
116
+ export interface Betrieb {
117
+ companyName: string;
118
+ legalForm: Rechtsform;
119
+ /** Anmeldung des Betriebs im Kasseneck-Panel; darf dort noch keinen Zugang haben. */
120
+ email: string;
121
+ address: BetriebAdresse;
122
+ state: Bundesland;
123
+ taxDetails: BetriebSteuer;
124
+ /** Mindestens einer, hoechstens zehn. */
125
+ contacts: BetriebKontakt[];
126
+ billingEmail?: string;
127
+ /** Firmenbuchnummer, z. B. `FN 123456 a`. */
128
+ companyRegister?: string;
129
+ /** Gericht: Code (`LG_SALZBURG`), amtlicher Name oder Freitext. */
130
+ court?: string;
131
+ web?: string;
132
+ phone?: string;
133
+ industry?: string;
134
+ taxAdvisor?: BetriebSteuerberater;
135
+ }
136
+ export interface CreateCustomerOptions {
137
+ appId: string;
138
+ business: Betrieb;
139
+ /**
140
+ * Eigener Schluessel gegen Doppelanlage, hoechstens 120 Zeichen. Derselbe
141
+ * Schluessel liefert die gespeicherte Antwort zurueck — auch bei abweichendem
142
+ * Rumpf. Die eigene Kundennummer ist der natuerliche Wert dafuer.
143
+ */
144
+ idempotencyKey?: string;
145
+ /**
146
+ * `invite:true` legt zusaetzlich einen Zugang zum Kundenpanel an und
147
+ * schickt die Einladung an `betrieb.email`. **Vorgabe ist `false`:** viele
148
+ * Betriebe arbeiten ausschliesslich in der App des Partners, und ein
149
+ * stillschweigend erzeugter Login samt Mail waere dort etwas, das niemand
150
+ * erwartet. Nachholen laesst er sich mit `resendPartnerCustomerInvite`.
151
+ *
152
+ * Nur erlaubt, wenn `getPartnerInfo().partner.canCreateAccess` gilt —
153
+ * sonst `zugang_nicht_erlaubt`, und es entsteht nichts, auch kein Betrieb.
154
+ */
155
+ access?: {
156
+ invite: boolean;
157
+ };
158
+ /**
159
+ * In welcher Umgebung der Betrieb entsteht. Ohne Angabe entscheidet der
160
+ * Schluessel.
161
+ *
162
+ * Ein LIVE-Schluessel darf `env:"test"` verlangen — das ist der vorgesehene
163
+ * Weg, die ganze Kette zu proben, ohne sich einen zweiten Schluessel zu
164
+ * holen. Umgekehrt nie: ein Test-Schluessel mit `env:"live"` bekommt
165
+ * `live_not_allowed`, und es entsteht nichts.
166
+ *
167
+ * Ein so angelegter Live-Betrieb ist **sofort freigeschaltet** und traegt das
168
+ * Modul `registrierkasse`; es wird auf keine Freigabe durch Kasseneck
169
+ * gewartet.
170
+ */
171
+ env?: PartnerEnv;
172
+ }
173
+ export type KundenStatus = 'created' | 'fon_configured' | 'signature_requested' | 'signature_ready' | 'cashregister_created' | 'live' | 'blocked' | (string & {});
174
+ export interface CreateCustomerResult {
175
+ customerId: string;
176
+ status: KundenStatus;
177
+ env: PartnerEnv;
178
+ companyName: string;
179
+ appId: string;
180
+ access: {
181
+ invited: boolean;
182
+ sentTo: string | null;
183
+ };
184
+ nextSteps: string[];
185
+ /** `true`, wenn derselbe `idempotencyKey` schon einmal ankam. */
186
+ replayed: boolean;
187
+ }
188
+ /**
189
+ * Stand des Auftragsverarbeitungsvertrags eines Betriebs.
190
+ *
191
+ * **Vertraege wirken im Partner-Weg nicht mehr** (Stand 2026-08-31): keine
192
+ * Antwort fuehrt dieses Feld, kein Schritt in `naechsteSchritte` verlangt
193
+ * einen Vertrag, und eine Kasse geht deswegen nicht weniger live. Der Typ
194
+ * bleibt, damit eine Antwort, die ihn doch noch traegt, lesbar durchkommt —
195
+ * **vorausgesetzt wird er nirgends**. Fuer selbst registrierte Kunden gibt es
196
+ * die Maschinerie weiterhin, aber nicht ueber diese Schnittstelle.
197
+ */
198
+ export interface AvvStand {
199
+ status: string;
200
+ version: string | null;
201
+ confirmedAt: number | null;
202
+ mode: string | null;
203
+ }
204
+ export interface KundenZeile {
205
+ customerId: string;
206
+ companyName: string;
207
+ status: KundenStatus;
208
+ appId: string | null;
209
+ env: PartnerEnv;
210
+ createdAt: number | null;
211
+ /**
212
+ * Vertragsstand, falls die Antwort ihn ueberhaupt fuehrt — heute tut sie das
213
+ * nicht, der Wert ist dann `null`. Siehe [AvvStand]: nichts in diesem Client
214
+ * setzt ihn voraus.
215
+ */
216
+ avv: AvvStand | null;
217
+ }
218
+ export interface ListCustomersOptions {
219
+ status?: KundenStatus;
220
+ /** 1 bis 200; Vorgabe 50. */
221
+ limit?: number;
222
+ cursor?: string;
223
+ }
224
+ export interface KundenListe {
225
+ customers: KundenZeile[];
226
+ /** Weiter mit diesem Wert als `cursor`; `null` heisst: das war alles. */
227
+ cursor: string | null;
228
+ total: number;
229
+ }
230
+ export interface Kunde extends KundenZeile {
231
+ statusAt: number | null;
232
+ liveEnabled: boolean;
233
+ createdAt: number | null;
234
+ createdVia: string | null;
235
+ business: Record<string, unknown>;
236
+ fon: {
237
+ configured: boolean;
238
+ verifiedAt: number | null;
239
+ };
240
+ access: {
241
+ email: string | null;
242
+ invitedAt: number | null;
243
+ acceptedAt: number | null;
244
+ } | null;
245
+ }
246
+ export interface FonLinkResult {
247
+ customerId: string;
248
+ /** Empfaenger, maskiert — das Backend gibt die Adresse nie im Klartext aus. */
249
+ sentTo: string;
250
+ expiresAt: number;
251
+ }
252
+ /**
253
+ * `beantragt → zugeteilt → registriert → bereit`. `registriert` heisst: die
254
+ * Einheit ist FinanzOnline bekannt; `bereit` heisst: sie darf signieren. In der
255
+ * Testumgebung wird ohne `registriert` direkt `bereit` erreicht.
256
+ */
257
+ export type SignaturAntragStatus = 'requested' | 'assigned' | 'registered' | 'ready' | 'failed' | 'cancelled' | (string & {});
258
+ export interface SignaturHistorieEintrag {
259
+ von: string | null;
260
+ nach: string;
261
+ at: number;
262
+ reason: string | null;
263
+ }
264
+ export interface SignaturAntrag {
265
+ requestId: string;
266
+ status: SignaturAntragStatus;
267
+ statusText: string;
268
+ art: string;
269
+ vdaId: string | null;
270
+ signatureId: string | null;
271
+ error: {
272
+ code: string | null;
273
+ message: string | null;
274
+ rc: string | null;
275
+ } | null;
276
+ requestedVia: string | null;
277
+ createdAt: number | null;
278
+ updatedAt: number | null;
279
+ history: SignaturHistorieEintrag[];
280
+ }
281
+ export interface RequestSignatureResult {
282
+ request: SignaturAntrag;
283
+ /** `true`, wenn schon ein Antrag lief — dann ist es der laufende. */
284
+ replayed: boolean;
285
+ note: string | null;
286
+ }
287
+ export interface SignaturStand {
288
+ signatur: {
289
+ ready: boolean;
290
+ signatureId: string | null;
291
+ vdaId: string | null;
292
+ };
293
+ requests: SignaturAntrag[];
294
+ fon: {
295
+ present: boolean;
296
+ verifiedAt: number | null;
297
+ };
298
+ }
299
+ /** Die Schritte der Inbetriebnahme, in dieser Reihenfolge. */
300
+ export type KassenSchritt = 'signatur' | 'register_cashregister' | 'start_receipt' | 'transmit_start_receipt' | (string & {});
301
+ export type KassenStatus = 'draft' | 'laeuft' | 'live' | 'failed' | (string & {});
302
+ export interface Kasse {
303
+ cashregisterId: string;
304
+ name: string | null;
305
+ status: KassenStatus;
306
+ statusText: string;
307
+ /** `true`: die Kasse geht von selbst live, sobald die Signatur bereit ist. */
308
+ automatic: boolean;
309
+ /** Der naechste offene Schritt; `null`, wenn die Kasse live ist. */
310
+ step: KassenSchritt | null;
311
+ stepText: string | null;
312
+ completedSteps: KassenSchritt[];
313
+ /** Die Schritte, die fuer genau diese Kasse gelten (Testumgebung: weniger). */
314
+ steps: {
315
+ key: KassenSchritt;
316
+ text: string;
317
+ }[];
318
+ signatureId: string | null;
319
+ attempts: number;
320
+ lastError: {
321
+ code: string | null;
322
+ message: string | null;
323
+ rc: string | null;
324
+ step: KassenSchritt | null;
325
+ at: number | null;
326
+ } | null;
327
+ createdAt: number | null;
328
+ }
329
+ export interface CreateCashregisterOptions {
330
+ customerId: string;
331
+ /** Vorgabe `true`: die Kasse geht von selbst live, sobald die Signatur da ist. */
332
+ automatic?: boolean;
333
+ /**
334
+ * Auf welche Signatur sich die Kasse bezieht. **Jede Kasse bezieht sich auf
335
+ * eine**; ohne eine einzige entsteht keine (`signature_missing`).
336
+ *
337
+ * Hat der Betrieb genau eine, ist sie vorausgewaehlt und dieses Feld
338
+ * ueberfluessig. Bei mehreren muss es dastehen, sonst `signature_ambiguous`
339
+ * samt `data.choices[]`; eine fremde Kennung ist `signature_unknown`. Die
340
+ * Kennungen nennt `getCustomerSignatureStatus`.
341
+ *
342
+ * **Einen Namen gibt es hier nicht.** Kassennamen vergibt Kasseneck, sie
343
+ * sind gleich der `cashregisterId`; ein mitgesendetes `name` waere ein
344
+ * `validation`-Fehler.
345
+ */
346
+ signatureRequestId?: string;
347
+ }
348
+ export interface CreateCashregisterResult {
349
+ cashregister: Kasse;
350
+ activation: {
351
+ started: boolean;
352
+ ok: boolean | null;
353
+ step: KassenSchritt | null;
354
+ /** `signature_not_ready` oder `automatik_aus`, wenn nicht gestartet wurde. */
355
+ reason: string | null;
356
+ };
357
+ }
358
+ export interface ActivateCashregisterResult {
359
+ cashregister: Kasse;
360
+ /** `true`: die Kasse war schon live, es wurde nichts getan. */
361
+ unchanged: boolean;
362
+ }
363
+ export interface KassenListe {
364
+ customerId: string;
365
+ cashregisters: Kasse[];
366
+ signatureReady: boolean;
367
+ }
368
+ /**
369
+ * Eine Kasse samt ihrem Token. **Der Token ist ein Geheimnis des Betriebs** —
370
+ * deshalb steht er als [KasseneckSecret] und nicht als `string` darin.
371
+ */
372
+ export interface CustomerCashregisterCredential {
373
+ cashregisterId: string;
374
+ name: string | null;
375
+ live: boolean;
376
+ /** Kopfzeile `cashregister-token` fuer `createReceipt`. Verschluesselt speichern. */
377
+ cashregisterToken: KasseneckSecret;
378
+ }
379
+ /**
380
+ * Die beiden Geheimnisse, die eine App braucht, um im Namen des Betriebs
381
+ * Belege zu signieren.
382
+ *
383
+ * **Nur verschluesselt speichern. Nie protokollieren, nie in eine Mail, nie in
384
+ * einen Fehlerbericht.** Jeder Abruf wird mitgeschrieben (Partner, Schluessel,
385
+ * Zeitpunkt) und ist fuer den Betrieb und fuer Kasseneck sichtbar.
386
+ *
387
+ * Die Werte stecken in [KasseneckSecret]: `console.log`, `JSON.stringify` und
388
+ * jede Zeichenketten-Umwandlung zeigen eine Maske. Heraus kommt man nur ueber
389
+ * `.reveal()` — und genau diese Stellen findet eine Suche.
390
+ */
391
+ export interface CustomerCredentials {
392
+ customerId: string;
393
+ companyName: string;
394
+ env: PartnerEnv;
395
+ /** Bearer-Schluessel des Betriebs (`kr_…`). Verschluesselt speichern. */
396
+ apiKey: KasseneckSecret;
397
+ cashregisters: CustomerCashregisterCredential[];
398
+ note: string;
399
+ }
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+ /**
3
+ * Die Formen, die die Partner-API sendet und zurueckgibt.
4
+ *
5
+ * **Die Referenz ist das Backend**, nicht diese Datei: `docs/api/partner.md`
6
+ * (ausfuehrlich) und `docs/api/partner.llms.txt` (kompakt) beschreiben Felder,
7
+ * Fehlercodes und Ereignisse. Hier stehen sie als Typen, damit ein Aufrufer
8
+ * beim Tippen sieht, was es gibt — nicht als zweiter Text daneben.
9
+ *
10
+ * **Lesen ist tolerant, Schreiben ist streng.** Antworttypen fuehren `null`
11
+ * dort, wo das Backend `null` schickt, und lassen unbekannte Statuswerte als
12
+ * `string` durch (`(string & {})`): eine Fassung, die einen neuen Status
13
+ * einfuehrt, soll diesen Client nicht zum Absturz bringen, sondern ihn
14
+ * durchreichen. Eingabetypen sind dagegen eng — ein Tippfehler soll ein
15
+ * Compilerfehler sein und keine `validation`-Antwort vom Server.
16
+ */
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.SCOPE_CREDENTIALS = exports.PARTNER_ENVS = void 0;
19
+ // ---------------------------------------------------------------------------
20
+ // Partner und Schluessel
21
+ // ---------------------------------------------------------------------------
22
+ /**
23
+ * Die beiden Umgebungen. Als Liste und nicht nur als Typ, damit ein Aufrufer
24
+ * sie zur Laufzeit pruefen kann — und damit der Zwilling sie nachhaelt.
25
+ */
26
+ exports.PARTNER_ENVS = ['live', 'test'];
27
+ /**
28
+ * `credentials:read` gehoert **nicht** zum Standardsatz und wird keinem
29
+ * bestehenden Schluessel nachtraeglich hinzugefuegt: wer ihn hat, kann im Namen
30
+ * fremder Betriebe Belege signieren. Dafuer wird ein eigener Schluessel
31
+ * angelegt.
32
+ */
33
+ exports.SCOPE_CREDENTIALS = 'credentials:read';
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Pruefung der Signatur eingehender Kasseneck-Webhooks.
3
+ *
4
+ * **Das ist der Teil, den Integratoren am haeufigsten falsch bauen** — und der
5
+ * einzige, bei dem ein Fehler nicht auffaellt: eine zu lasche Pruefung laesst
6
+ * jeden durch, der die Adresse kennt, und meldet dabei nie etwas. Deshalb
7
+ * liegt sie fertig im Paket und nicht als Beispielschnipsel in der Doku.
8
+ *
9
+ * Vier Dinge muessen stimmen, und jedes einzelne fehlt in der Praxis regelmaessig:
10
+ *
11
+ * 1. **Der ROHE Rumpf.** Signiert werden die Bytes, die ankommen — nicht das
12
+ * Ergebnis von `JSON.parse` und erneutem `JSON.stringify`. Schluesselreihen-
13
+ * folge, Zahlenschreibweise und Leerraum aendern sich dabei, und die
14
+ * Signatur passt nicht mehr. In Express heisst das `express.raw({type:'*​/*'})`
15
+ * **vor** jedem JSON-Parser fuer diesen Pfad.
16
+ * 2. **Das Zeitfenster.** Ohne Pruefung von `t` ist eine einmal mitgeschnittene,
17
+ * gueltig signierte Zustellung fuer immer wiederverwendbar. 300 Sekunden in
18
+ * beide Richtungen — auch in die Zukunft, sonst hilft eine falsch gestellte
19
+ * Uhr auf der Gegenseite dem Angreifer.
20
+ * 3. **Der zeitkonstante Vergleich.** Ein `===` auf Hex-Zeichenketten bricht
21
+ * beim ersten abweichenden Zeichen ab. Wer messen kann, wie lange die
22
+ * Ablehnung dauert, raet die Signatur Zeichen fuer Zeichen.
23
+ * 4. **Jede Ausnahme ist eine Ablehnung.** Ein `catch`, das weiterlaufen laesst,
24
+ * macht aus einem Formfehler ein Ja.
25
+ *
26
+ * Der Kopf lautet `X-Kasseneck-Signature: t=<unix-sekunden>,v1=<hex>` mit
27
+ * `v1 = HMAC-SHA256(secret, "<t>.<roher Rumpf>")`. **Mehrere `v1=`-Anteile sind
28
+ * erlaubt** — so laeuft ein Schluesselwechsel ohne Zustellungsluecke; es reicht,
29
+ * wenn einer passt.
30
+ *
31
+ * Umgesetzt mit WebCrypto (`crypto.subtle`) statt `node:crypto`: dieses Paket
32
+ * haengt an keiner Laufzeit-Abhaengigkeit und wird auch fuer den Browser
33
+ * gebuendelt. Deshalb ist die Pruefung **asynchron**.
34
+ */
35
+ /** Der Kopf, in dem die Signatur steht. */
36
+ export declare const WEBHOOK_SIGNATURE_HEADER = "X-Kasseneck-Signature";
37
+ /** Der Kopf mit dem Ereignisnamen (dasselbe wie `body.type`). */
38
+ export declare const WEBHOOK_EVENT_HEADER = "X-Kasseneck-Event";
39
+ /** Der Kopf mit der Zustell-Kennung — bei Wiederholungen **dieselbe**. */
40
+ export declare const WEBHOOK_DELIVERY_HEADER = "X-Kasseneck-Delivery";
41
+ /** Erlaubte Abweichung des Zeitstempels, in Sekunden (in beide Richtungen). */
42
+ export declare const WEBHOOK_TOLERANCE_SEC = 300;
43
+ /** Wartezeiten zwischen den Zustellversuchen, in Sekunden. */
44
+ export declare const WEBHOOK_RETRY_PLAN_SEC: readonly number[];
45
+ /** Erstversuch plus je eine Wiederholung pro Planeintrag. */
46
+ export declare const WEBHOOK_MAX_ATTEMPTS: number;
47
+ /** So lange wartet Kasseneck auf eine 2xx-Antwort. */
48
+ export declare const WEBHOOK_TIMEOUT_MS = 10000;
49
+ /** Hoechstzahl der Webhook-Endpunkte je Partner. */
50
+ export declare const WEBHOOK_LIMIT = 10;
51
+ /** Warum eine Zustellung abgelehnt wurde. Jeder Grund ist ein Nein. */
52
+ export type WebhookVerifyReason = 'secret-missing' | 'header-missing' | 'header-malformed' | 'body-missing' | 'timestamp-outside-window' | 'signature-mismatch';
53
+ export type WebhookVerifyResult = {
54
+ ok: true;
55
+ timestampSec: number;
56
+ } | {
57
+ ok: false;
58
+ reason: WebhookVerifyReason;
59
+ };
60
+ export interface VerifyWebhookOptions {
61
+ /**
62
+ * Das Secret aus `createPartnerWebhook` — es verlaesst den Server genau
63
+ * einmal. Eine Liste erlaubt den Schluesselwechsel: es reicht, wenn einer
64
+ * passt.
65
+ */
66
+ secret: string | readonly string[];
67
+ /** Der Wert des Kopfes `X-Kasseneck-Signature`, unveraendert. */
68
+ signatureHeader: string | null | undefined;
69
+ /**
70
+ * Der **rohe** Rumpf — Bytes oder die daraus gelesene Zeichenkette. Nicht
71
+ * das Ergebnis von `JSON.parse`, und nichts, was danach wieder
72
+ * zusammengesetzt wurde.
73
+ */
74
+ body: string | Uint8Array;
75
+ /** Jetzt, in Unix-Sekunden. Vorgabe: die Systemuhr. Fuer Tests setzbar. */
76
+ nowSec?: number;
77
+ /** Abweichender Toleranzrahmen in Sekunden; Vorgabe [WEBHOOK_TOLERANCE_SEC]. */
78
+ toleranceSec?: number;
79
+ }
80
+ /**
81
+ * Prueft die Signatur einer eingehenden Zustellung.
82
+ *
83
+ * Antwortet **nie** mit einem geworfenen Fehler auf schlechte Eingaben: jede
84
+ * Ausnahme im Inneren wird zur Ablehnung. Ein Aufrufer, der nur `ok` abfragt,
85
+ * kann damit nichts falsch machen.
86
+ */
87
+ export declare function verifyWebhookSignature(optionen: VerifyWebhookOptions): Promise<WebhookVerifyResult>;
88
+ /**
89
+ * Zerlegt den Signaturkopf. `null`, wenn kein brauchbarer Zeitstempel oder gar
90
+ * kein `v1=`-Anteil darin steht.
91
+ */
92
+ export declare function parseSignatureHeader(kopf: string): {
93
+ t: number;
94
+ v1: string[];
95
+ } | null;
@@ -0,0 +1,158 @@
1
+ "use strict";
2
+ /**
3
+ * Pruefung der Signatur eingehender Kasseneck-Webhooks.
4
+ *
5
+ * **Das ist der Teil, den Integratoren am haeufigsten falsch bauen** — und der
6
+ * einzige, bei dem ein Fehler nicht auffaellt: eine zu lasche Pruefung laesst
7
+ * jeden durch, der die Adresse kennt, und meldet dabei nie etwas. Deshalb
8
+ * liegt sie fertig im Paket und nicht als Beispielschnipsel in der Doku.
9
+ *
10
+ * Vier Dinge muessen stimmen, und jedes einzelne fehlt in der Praxis regelmaessig:
11
+ *
12
+ * 1. **Der ROHE Rumpf.** Signiert werden die Bytes, die ankommen — nicht das
13
+ * Ergebnis von `JSON.parse` und erneutem `JSON.stringify`. Schluesselreihen-
14
+ * folge, Zahlenschreibweise und Leerraum aendern sich dabei, und die
15
+ * Signatur passt nicht mehr. In Express heisst das `express.raw({type:'*​/*'})`
16
+ * **vor** jedem JSON-Parser fuer diesen Pfad.
17
+ * 2. **Das Zeitfenster.** Ohne Pruefung von `t` ist eine einmal mitgeschnittene,
18
+ * gueltig signierte Zustellung fuer immer wiederverwendbar. 300 Sekunden in
19
+ * beide Richtungen — auch in die Zukunft, sonst hilft eine falsch gestellte
20
+ * Uhr auf der Gegenseite dem Angreifer.
21
+ * 3. **Der zeitkonstante Vergleich.** Ein `===` auf Hex-Zeichenketten bricht
22
+ * beim ersten abweichenden Zeichen ab. Wer messen kann, wie lange die
23
+ * Ablehnung dauert, raet die Signatur Zeichen fuer Zeichen.
24
+ * 4. **Jede Ausnahme ist eine Ablehnung.** Ein `catch`, das weiterlaufen laesst,
25
+ * macht aus einem Formfehler ein Ja.
26
+ *
27
+ * Der Kopf lautet `X-Kasseneck-Signature: t=<unix-sekunden>,v1=<hex>` mit
28
+ * `v1 = HMAC-SHA256(secret, "<t>.<roher Rumpf>")`. **Mehrere `v1=`-Anteile sind
29
+ * erlaubt** — so laeuft ein Schluesselwechsel ohne Zustellungsluecke; es reicht,
30
+ * wenn einer passt.
31
+ *
32
+ * Umgesetzt mit WebCrypto (`crypto.subtle`) statt `node:crypto`: dieses Paket
33
+ * haengt an keiner Laufzeit-Abhaengigkeit und wird auch fuer den Browser
34
+ * gebuendelt. Deshalb ist die Pruefung **asynchron**.
35
+ */
36
+ Object.defineProperty(exports, "__esModule", { value: true });
37
+ exports.WEBHOOK_LIMIT = exports.WEBHOOK_TIMEOUT_MS = exports.WEBHOOK_MAX_ATTEMPTS = exports.WEBHOOK_RETRY_PLAN_SEC = exports.WEBHOOK_TOLERANCE_SEC = exports.WEBHOOK_DELIVERY_HEADER = exports.WEBHOOK_EVENT_HEADER = exports.WEBHOOK_SIGNATURE_HEADER = void 0;
38
+ exports.verifyWebhookSignature = verifyWebhookSignature;
39
+ exports.parseSignatureHeader = parseSignatureHeader;
40
+ /** Der Kopf, in dem die Signatur steht. */
41
+ exports.WEBHOOK_SIGNATURE_HEADER = 'X-Kasseneck-Signature';
42
+ /** Der Kopf mit dem Ereignisnamen (dasselbe wie `body.type`). */
43
+ exports.WEBHOOK_EVENT_HEADER = 'X-Kasseneck-Event';
44
+ /** Der Kopf mit der Zustell-Kennung — bei Wiederholungen **dieselbe**. */
45
+ exports.WEBHOOK_DELIVERY_HEADER = 'X-Kasseneck-Delivery';
46
+ /** Erlaubte Abweichung des Zeitstempels, in Sekunden (in beide Richtungen). */
47
+ exports.WEBHOOK_TOLERANCE_SEC = 300;
48
+ /** Wartezeiten zwischen den Zustellversuchen, in Sekunden. */
49
+ exports.WEBHOOK_RETRY_PLAN_SEC = [60, 300, 1800, 7200, 43200];
50
+ /** Erstversuch plus je eine Wiederholung pro Planeintrag. */
51
+ exports.WEBHOOK_MAX_ATTEMPTS = exports.WEBHOOK_RETRY_PLAN_SEC.length + 1;
52
+ /** So lange wartet Kasseneck auf eine 2xx-Antwort. */
53
+ exports.WEBHOOK_TIMEOUT_MS = 10_000;
54
+ /** Hoechstzahl der Webhook-Endpunkte je Partner. */
55
+ exports.WEBHOOK_LIMIT = 10;
56
+ /**
57
+ * Prueft die Signatur einer eingehenden Zustellung.
58
+ *
59
+ * Antwortet **nie** mit einem geworfenen Fehler auf schlechte Eingaben: jede
60
+ * Ausnahme im Inneren wird zur Ablehnung. Ein Aufrufer, der nur `ok` abfragt,
61
+ * kann damit nichts falsch machen.
62
+ */
63
+ async function verifyWebhookSignature(optionen) {
64
+ try {
65
+ const secrets = (Array.isArray(optionen.secret) ? optionen.secret : [optionen.secret]).filter((s) => typeof s === 'string' && s.length > 0);
66
+ if (!secrets.length)
67
+ return { ok: false, reason: 'secret-missing' };
68
+ if (typeof optionen.signatureHeader !== 'string' || !optionen.signatureHeader.trim()) {
69
+ return { ok: false, reason: 'header-missing' };
70
+ }
71
+ const kopf = parseSignatureHeader(optionen.signatureHeader);
72
+ if (kopf === null)
73
+ return { ok: false, reason: 'header-malformed' };
74
+ if (optionen.body === undefined || optionen.body === null)
75
+ return { ok: false, reason: 'body-missing' };
76
+ const jetzt = typeof optionen.nowSec === 'number' && Number.isFinite(optionen.nowSec)
77
+ ? optionen.nowSec
78
+ : Math.floor(Date.now() / 1000);
79
+ const toleranz = typeof optionen.toleranceSec === 'number' && Number.isFinite(optionen.toleranceSec)
80
+ ? optionen.toleranceSec
81
+ : exports.WEBHOOK_TOLERANCE_SEC;
82
+ // In BEIDE Richtungen: eine vorgehende Uhr auf der Gegenseite darf ein
83
+ // altes Ereignis nicht wieder gueltig machen.
84
+ if (Math.abs(jetzt - kopf.t) > toleranz)
85
+ return { ok: false, reason: 'timestamp-outside-window' };
86
+ const nachricht = signierteBytes(kopf.t, optionen.body);
87
+ for (const secret of secrets) {
88
+ const soll = await hmacSha256(secret, nachricht);
89
+ for (const v1 of kopf.v1) {
90
+ const ist = hexZuBytes(v1);
91
+ if (ist !== null && gleichZeitkonstant(ist, soll))
92
+ return { ok: true, timestampSec: kopf.t };
93
+ }
94
+ }
95
+ return { ok: false, reason: 'signature-mismatch' };
96
+ }
97
+ catch {
98
+ // Punkt 4 im Modulkommentar: eine Ausnahme ist eine Ablehnung, nie ein Ja.
99
+ return { ok: false, reason: 'signature-mismatch' };
100
+ }
101
+ }
102
+ /**
103
+ * Zerlegt den Signaturkopf. `null`, wenn kein brauchbarer Zeitstempel oder gar
104
+ * kein `v1=`-Anteil darin steht.
105
+ */
106
+ function parseSignatureHeader(kopf) {
107
+ const teile = String(kopf).split(',').map((x) => x.trim());
108
+ const tTeil = teile.find((x) => x.startsWith('t='));
109
+ const v1 = teile.filter((x) => x.startsWith('v1=')).map((x) => x.slice(3));
110
+ if (!tTeil || !v1.length)
111
+ return null;
112
+ const roh = tTeil.slice(2);
113
+ // Nur ganze Zahlen: `Number('1e9')` und `Number(' 12 ')` waeren sonst gueltige
114
+ // Zeitstempel, und `parseInt('12abc')` ebenfalls.
115
+ if (!/^\d{1,15}$/.test(roh))
116
+ return null;
117
+ return { t: Number(roh), v1 };
118
+ }
119
+ /** `<t>.<roher Rumpf>` als Bytes — genau das, was das Backend signiert. */
120
+ function signierteBytes(t, body) {
121
+ const praefix = new TextEncoder().encode(`${t}.`);
122
+ const rumpf = typeof body === 'string' ? new TextEncoder().encode(body) : body;
123
+ const zusammen = new Uint8Array(praefix.length + rumpf.length);
124
+ zusammen.set(praefix, 0);
125
+ zusammen.set(rumpf, praefix.length);
126
+ return zusammen;
127
+ }
128
+ async function hmacSha256(secret, nachricht) {
129
+ const subtle = globalThis.crypto?.subtle;
130
+ if (!subtle) {
131
+ throw new Error('verifyWebhookSignature: kein WebCrypto vorhanden — Node >= 20.18 verwenden (globalThis.crypto.subtle)');
132
+ }
133
+ const key = await subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
134
+ const signatur = await subtle.sign('HMAC', key, nachricht);
135
+ return new Uint8Array(signatur);
136
+ }
137
+ /** Hex zu Bytes; `null` bei ungerader Laenge oder Nicht-Hex. */
138
+ function hexZuBytes(hex) {
139
+ if (hex.length === 0 || hex.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(hex))
140
+ return null;
141
+ const bytes = new Uint8Array(hex.length / 2);
142
+ for (let i = 0; i < bytes.length; i++)
143
+ bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
144
+ return bytes;
145
+ }
146
+ /**
147
+ * Vergleich ohne frueh Abbrechen. Die Laengenpruefung davor verraet nur die
148
+ * Laenge des Hashs, und die ist bekannt (SHA-256, 32 Bytes); der Inhalt wird
149
+ * immer vollstaendig durchlaufen.
150
+ */
151
+ function gleichZeitkonstant(a, b) {
152
+ if (a.length !== b.length)
153
+ return false;
154
+ let unterschied = 0;
155
+ for (let i = 0; i < a.length; i++)
156
+ unterschied |= a[i] ^ b[i];
157
+ return unterschied === 0;
158
+ }