@kreiseck/kasseneck-api 1.0.0-rc.1 → 1.0.0-rc.3

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 (40) hide show
  1. package/CHANGELOG.md +44 -8
  2. package/README.md +38 -11
  3. package/dist/cjs/client/errors.d.ts +26 -10
  4. package/dist/cjs/client/errors.js +35 -10
  5. package/dist/cjs/client/receipts.d.ts +10 -3
  6. package/dist/cjs/client/receipts.js +14 -29
  7. package/dist/cjs/client/transport.js +25 -13
  8. package/dist/cjs/models/cashregister.d.ts +14 -8
  9. package/dist/cjs/models/cashregister.js +4 -4
  10. package/dist/cjs/payments/hobex.d.ts +9 -1
  11. package/dist/cjs/payments/hobex.js +14 -2
  12. package/dist/cjs/payments/stripe.d.ts +3 -0
  13. package/dist/cjs/payments/stripe.js +6 -1
  14. package/dist/cjs/pos/client.d.ts +6 -1
  15. package/dist/cjs/pos/client.js +15 -3
  16. package/dist/cjs/pos/drucker.js +22 -3
  17. package/dist/cjs/version.d.ts +1 -1
  18. package/dist/cjs/version.js +1 -1
  19. package/dist/esm/client/errors.d.ts +26 -10
  20. package/dist/esm/client/errors.js +34 -10
  21. package/dist/esm/client/receipts.d.ts +10 -3
  22. package/dist/esm/client/receipts.js +12 -27
  23. package/dist/esm/client/transport.js +25 -13
  24. package/dist/esm/models/cashregister.d.ts +14 -8
  25. package/dist/esm/models/cashregister.js +4 -4
  26. package/dist/esm/payments/hobex.d.ts +9 -1
  27. package/dist/esm/payments/hobex.js +15 -3
  28. package/dist/esm/payments/stripe.d.ts +3 -0
  29. package/dist/esm/payments/stripe.js +7 -2
  30. package/dist/esm/pos/client.d.ts +6 -1
  31. package/dist/esm/pos/client.js +15 -3
  32. package/dist/esm/pos/drucker.js +22 -3
  33. package/dist/esm/version.d.ts +1 -1
  34. package/dist/esm/version.js +1 -1
  35. package/fixtures/hobex-hps-codes.json +1 -1
  36. package/fixtures/invoice-api.schema.json +1 -1
  37. package/fixtures/invoice-texts.json +1 -1
  38. package/fixtures/pos-texts.json +1 -1
  39. package/fixtures/surface.json +1 -1
  40. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -104,13 +104,49 @@ an upgrade as long as `/v1` is served.
104
104
  means the operation may have happened: `dialect_mismatch`,
105
105
  `receipt_outcome_unknown`, `cancellation_outcome_unknown`,
106
106
  `response_translation_failed` (unless `details.handled === false`),
107
- `response_unreadable` (a signing call reported success but the response
108
- lacks the receipt, the reference or the remaining quantities), and on
109
- `createReceipt`, `cancelReceipt` and `financeWebService` a network error,
110
- timeout or HTTP 5xx after sending as well as HTTP 200 with the `/v3` marker
111
- but an empty, non-JSON or status-less body. Never retry those; read the
112
- result back. Reason: a retried receipt is a second signed receipt in the
113
- chain.
107
+ `response_unreadable` (a signing or money call reported success but the
108
+ response lacks the receipt, the reference, the remaining quantities, the
109
+ Hobex receipt or the captured payment intent), and on `createReceipt`,
110
+ `cancelReceipt`, `financeWebService`, `hobexPayApi`, `hobexRefundApi` and
111
+ `stripeCaptureIntent` a network error, timeout or HTTP 5xx after sending as
112
+ well as HTTP 200 with the `/v3` marker but an empty, non-JSON (also HTML) or
113
+ status-less body. Never retry those; read the result back. Reason: a
114
+ retried receipt is a second signed receipt in the chain.
115
+ - **Money calls have an unknown outcome too (1.0.0-rc.2).** `hobexPay`,
116
+ `hobexRefund` and `stripeCaptureIntent` joined the calls above. In rc.1 a
117
+ timeout, network error or HTTP 5xx on them was `'rejected'`, and a success
118
+ reply without a readable Hobex receipt or payment intent was a
119
+ `KasseneckValidationError` (`scope: 'response'`); now both are `'unknown'`
120
+ (the latter as `KasseneckApiError` `response_unreadable`), and HTML with the
121
+ `/v3` marker is `not-json` with `'unknown'` instead of `route_missing`. An
122
+ error envelope throws a `KasseneckApiError` with its `code`; `hobexRefund`
123
+ never returns `false`. **Callers must never retry these calls, and must not
124
+ pass a `fetch` that retries by itself or wrap the package's requests in a
125
+ retrying layer** (proxy, service worker, HTTP client with a retry policy).
126
+ Reason: an app that read `'rejected'` could charge or refund a card twice;
127
+ a silent resend below the package does the same without any error. Same
128
+ change as in the Dart twin `kasseneck_api` (no `RetryClient`).
129
+ - **Findings of the Dart twin (1.0.0-rc.3).** `listMyCashregisters` reads
130
+ the start receipt under `onboarding.start_receipt_created`/`_transmitted`
131
+ (`_at`) as `/v3` sends it; rc.1 and rc.2 read the internal
132
+ `startbeleg_*` names and reported every register as having no start
133
+ receipt (`CashregisterOnboardingPayload` changes accordingly). The fields
134
+ of `CashregisterOnboarding` are English now as well: `startbelegCreated`
135
+ is `startReceiptCreated`, `startbelegTransmitted` is
136
+ `startReceiptTransmitted`, `startbelegCreatedAt` is `startReceiptCreatedAt`,
137
+ `startbelegTransmittedAt` is `startReceiptTransmittedAt`; the German-name
138
+ guard no longer exempts `startbeleg`.
139
+ `sendReceiptEmail` reads its success reply leniently: without `to` it
140
+ returns the address you sent, without `at` it returns `at: null`
141
+ (`SendReceiptEmailResult.at` is `string | null`); throwing there would
142
+ invite a second email. `listMyPrinters` without `data.printers` and
143
+ `createPrintJob`/`getPrintJob` without `data.jobId` throw a
144
+ `KasseneckValidationError` (`scope: 'response'`) instead of returning an
145
+ empty list or an empty id: "no printer yet" must not look like a broken
146
+ reply, and a job without an id cannot be polled, so the cashier would print
147
+ again. `setMyRegisterDeviceSettings` takes `shortcuts` only as the whole map
148
+ of known actions (as `posSettingsChanges` produces it): the server checks
149
+ keys bound twice only within the map it receives.
114
150
  - **Strict validation before sending.** The 0.x payment fields
115
151
  (`paymentMethod`, `paymentMethodFromServer`, `creditCardProvider`,
116
152
  `cardPaymentId`, `cardPaymentData`) throw, also from plain JavaScript.
@@ -237,7 +273,7 @@ texts are English: `storno.ergebnis_unklar` is `cancellation.outcome_unknown`,
237
273
  `tax.intra_community_supply.title`. Every part is lower case with underscores,
238
274
  only country codes stay upper case (`country.AT`). Placeholders are English
239
275
  too: `{betrag}` is `{amount}`, `{grund}` is `{reason}`, `{sekunden}` is
240
- `{seconds}`, `{uid}` is `{vatId}`, and so on for 23 of 30 names; pass the
276
+ `{seconds}`, `{uid}` is `{vatId}`, and so on for 25 of 30 names; pass the
241
277
  values under the new names (`messageText('checkout.locked', { reason })`). An
242
278
  old name throws as a missing value. `ERROR_RULES` entries use
243
279
  `kind`/`behavior`/`key` with the kinds `api`, `plain_text`, `timeout`,
package/README.md CHANGED
@@ -427,9 +427,10 @@ silently. For a layout option of 0.x the message names its English successor.
427
427
  ### `outcome: 'unknown'`: never retry, look it up
428
428
 
429
429
  `KasseneckApiError`, `KasseneckHttpError` and `KasseneckNetworkError` carry
430
- `outcome`. `'rejected'` means no signing operation is left open: no receipt was
431
- signed and nothing went to FinanzOnline, so retrying the same request does not
432
- create a second receipt (whether it helps depends on the code). `'unknown'` means the operation **may have been carried out**, for
430
+ `outcome`. `'rejected'` means no operation with an effect is left open: no
431
+ receipt was signed, nothing went to FinanzOnline and no money moved, so
432
+ retrying the same request does not create a second receipt or charge (whether
433
+ it helps depends on the code). `'unknown'` means the operation **may have been carried out**, for
433
434
  `createReceipt` a signed receipt in the chain. Then never send it again: read
434
435
  the result back (`getReceipt`, `listMyReceipts`, the original of a
435
436
  cancellation) and continue from there. `isOutcomeUnknown(error)` covers all
@@ -437,18 +438,35 @@ three classes. The outcome is unknown for:
437
438
 
438
439
  - `dialect_mismatch`, `receipt_outcome_unknown`, `cancellation_outcome_unknown`;
439
440
  - `response_translation_failed`, unless `details.handled === false`;
440
- - `response_unreadable`: a signing call reported success, but the response
441
- lacks what the call promises (no receipt, no reference, no remaining
442
- quantities);
443
- - on the signing calls `createReceipt`, `cancelReceipt` and
444
- `financeWebService`: a network error or timeout after sending began, HTTP
441
+ - `response_unreadable`: a signing or money-moving call reported success,
442
+ but the response lacks what the call promises (no receipt, no reference, no
443
+ remaining quantities, no Hobex receipt, no captured payment intent);
444
+ - on the calls with an effect: the signing calls `createReceipt`,
445
+ `cancelReceipt` and `financeWebService`, and the money calls `hobexPay`
446
+ (`hobexPayApi`, charges a card), `hobexRefund` (`hobexRefundApi`) and
447
+ `stripeCaptureIntent`: a network error or timeout after sending began, HTTP
445
448
  5xx, and HTTP 200 with the `Kasseneck-Api-Version: v3` marker but an empty,
446
449
  non-JSON (also `text/html`) or status-less body (`KasseneckHttpError`,
447
450
  `reason` `empty-body`, `not-json` or `missing-status`). HTML without the
448
451
  marker stays `route_missing` with `'rejected'`: no function saw the call.
449
452
 
450
- `outcome` only covers signing. After a network error on `issueInvoice` an
451
- invoice may still have been issued; retry it with the same `idempotencyKey`.
453
+ On the money calls an unknown outcome means the card may have been charged,
454
+ the refund may have gone through, the payment may have been captured. A
455
+ rejection of `hobexRefund` throws a `KasseneckApiError` with its `code`; it
456
+ never returns `false`, because a `false` on an unclear outcome invites a
457
+ second refund.
458
+
459
+ **Never retry these six calls, and never put a retrying layer under the
460
+ package.** A `fetch` passed in `options.fetch` (or a proxy or service worker in
461
+ front of it) must not resend a request by itself after a network error, a
462
+ timeout or a 5xx: a silent second send is a second signed receipt, a second
463
+ charge or a second refund, and the package cannot see it. Look the result up
464
+ instead (`getReceipt`, `listMyReceipts`, the Hobex transaction by its
465
+ `transactionId`, the Stripe session).
466
+
467
+ `outcome` only covers signing and the money calls. After a network error on
468
+ `issueInvoice` an invoice may still have been issued; retry it with the same
469
+ `idempotencyKey`.
452
470
 
453
471
  ```ts
454
472
  import { isOutcomeUnknown, paymentsExpectedCents } from '@kreiseck/kasseneck-api';
@@ -597,10 +615,14 @@ const confirmation = await api.sendReceiptEmail({
597
615
  language: 'de', // optional; the backend currently only uses 'de'
598
616
  });
599
617
  confirmation.to; // address as the backend logged it (trimmed, lower case)
600
- confirmation.at; // time, ISO with Vienna offset
618
+ confirmation.at; // time, ISO with Vienna offset, or null
601
619
  confirmation.via; // 'own' | 'platform' | 'platform_fallback' | null
602
620
  ```
603
621
 
622
+ A success response means the email has gone out. If it lacks `to` or `at`,
623
+ the call still succeeds: `to` falls back to the address you sent and `at` is
624
+ `null`. Throwing there would invite sending the email a second time.
625
+
604
626
  The email contains a **link to the public receipt page**, not a PDF
605
627
  attachment: the receipt page uses the same line model as screen and printed
606
628
  receipt and offers a PDF there. The receipt itself stays untouched (BAO § 131,
@@ -792,6 +814,11 @@ process:
792
814
  - **Hobex cloud** (`hobexPay`, `hobexRefund`): a terminal registered with
793
815
  Hobex, controlled over the network through the backend. Amounts are passed in
794
816
  cents; the package converts to euros for Hobex.
817
+
818
+ `hobexPay`, `hobexRefund` and `stripeCaptureIntent` move money. They are never
819
+ retried, neither by the app nor by a retrying `fetch` under the package: on
820
+ `isOutcomeUnknown(error)` look the payment up (see
821
+ [`outcome: 'unknown'`](#outcome-unknown-never-retry-look-it-up)).
795
822
  - **Hobex HPS via Kasseneck Connect**, described below.
796
823
 
797
824
  ### Hobex HPS via Kasseneck Connect
@@ -20,7 +20,7 @@
20
20
  * Single-Page-App hat geantwortet, der Aufruf kam nie an),
21
21
  * `dialect_mismatch` (Antwort ohne Kennzeichen `Kasseneck-Api-Version: v3`:
22
22
  * ein Rand ohne `/v3` hat geantwortet, **Ausgang unklar**) und
23
- * `response_unreadable` (ein signierender Aufruf meldete Erfolg, die
23
+ * `response_unreadable` (ein Aufruf mit Wirkung meldete Erfolg, die
24
24
  * Antwort traegt aber nicht, was er zusagt: **Ausgang unklar**).
25
25
  * **Entscheidend ist `outcome`:** `'rejected'` heisst abgelehnt, nichts
26
26
  * geschehen, Wiederholen hilft nicht. `'unknown'` heisst: der Vorgang kann
@@ -29,14 +29,16 @@
29
29
  * - `KasseneckHttpError` — die Antwort war **keine** verwertbare Huelle:
30
30
  * HTTP 500/404 ohne Huelle, leerer Rumpf oder Text statt JSON. Beim
31
31
  * Bericht-Download gelten dieselben Gruende fuer alles, was kein PDF ist.
32
- * `reason` trennt die Faelle maschinenlesbar. Auf einem signierenden
33
- * Aufruf hat HTTP 5xx `outcome: 'unknown'`, ebenso HTTP 200 mit
34
- * Kennzeichen, aber leerem oder unlesbarem Rumpf.
32
+ * `reason` trennt die Faelle maschinenlesbar. Auf einem Aufruf mit
33
+ * Wirkung (signierend oder geldbewegend) hat HTTP 5xx
34
+ * `outcome: 'unknown'`, ebenso HTTP 200 mit Kennzeichen, aber leerem oder
35
+ * unlesbarem Rumpf.
35
36
  * - `KasseneckNetworkError` — die Antwort kam gar nicht: Netz weg, DNS,
36
37
  * abgebrochene Verbindung oder Zeitueberschreitung (`timedOut`). Auch hier
37
38
  * gilt `outcome`: war die Anfrage schon unterwegs und ist der Aufruf einer
38
- * der signierenden (`createReceipt`, `cancelReceipt`, `financeWebService`),
39
- * ist er `'unknown'`.
39
+ * mit Wirkung (signierend: `createReceipt`, `cancelReceipt`,
40
+ * `financeWebService`; geldbewegend: `hobexPayApi`, `hobexRefundApi`,
41
+ * `stripeCaptureIntent`), ist er `'unknown'`.
40
42
  * - `KasseneckAuthError` — es kam nicht einmal zur Anfrage, weil die Anmeldung
41
43
  * scheiterte (fehlende Zugangsdaten, oder der Token-/Sitzungsgeber warf).
42
44
  * In der Browser-Kasse mit ihrer 90-Sekunden-Sitzung ist das Alltag, kein
@@ -111,7 +113,7 @@ export type ErrorOutcome = 'unknown' | 'rejected';
111
113
  /**
112
114
  * Codes, die das Paket selbst vergibt, nicht der Server: `route_missing`
113
115
  * (HTML statt Backend, der Aufruf kam nie an) und `response_unreadable`
114
- * (ein signierender Aufruf meldete Erfolg, die Antwort ist aber unlesbar;
116
+ * (ein Aufruf mit Wirkung meldete Erfolg, die Antwort ist aber unlesbar;
115
117
  * Ausgang unklar). `dialect_mismatch` vergibt das Paket ebenfalls, der Code
116
118
  * gehoert aber schon zum Rand des Servers (`errorCodes.edge`).
117
119
  */
@@ -142,7 +144,7 @@ export declare class KasseneckApiError extends Error {
142
144
  /**
143
145
  * `'unknown'` bei `dialect_mismatch`, `receipt_outcome_unknown`,
144
146
  * `cancellation_outcome_unknown`, `response_unreadable` (Erfolg gemeldet,
145
- * Antwort eines signierenden Aufrufs aber unlesbar) und
147
+ * Antwort eines Aufrufs mit Wirkung aber unlesbar) und
146
148
  * `response_translation_failed` (ausser mit `details.handled === false`);
147
149
  * sonst `'rejected'`. Bei `'unknown'` nie wiederholen, sondern nachlesen.
148
150
  */
@@ -162,8 +164,9 @@ export declare class KasseneckHttpError extends Error {
162
164
  /** Maschinenlesbarer Grund — trennt den Rewrite-Fall vom 500er ohne Textparsen. */
163
165
  readonly reason: HttpFailureReason;
164
166
  /**
165
- * `'unknown'` auf einem signierenden Aufruf (`createReceipt`,
166
- * `cancelReceipt`, `financeWebService`) bei HTTP 5xx und bei HTTP 200 mit
167
+ * `'unknown'` auf einem Aufruf mit Wirkung (`createReceipt`,
168
+ * `cancelReceipt`, `financeWebService`, `hobexPayApi`, `hobexRefundApi`,
169
+ * `stripeCaptureIntent`) bei HTTP 5xx und bei HTTP 200 mit
167
170
  * Kennzeichen, aber unlesbarem Rumpf (`empty-body`, `not-json` auch bei
168
171
  * `text/html`, `missing-status`): der Handler kann gelaufen sein, nie wiederholen,
169
172
  * sondern nachlesen. Sonst `'rejected'` (auch 4xx).
@@ -249,3 +252,16 @@ export declare function isKasseneckHttpError(error: unknown): error is Kasseneck
249
252
  export declare function isKasseneckNetworkError(error: unknown): error is KasseneckNetworkError;
250
253
  export declare function isKasseneckAuthError(error: unknown): error is KasseneckAuthError;
251
254
  export declare function isKasseneckValidationError(error: unknown): error is KasseneckValidationError;
255
+ /**
256
+ * Liest die Erfolgsantwort eines Aufrufs **mit Wirkung** (signierend oder
257
+ * geldbewegend). Scheitert das Lesen (fehlender Beleg, fehlender Bezug,
258
+ * unbrauchbares Feld oder ein Laufzeitfehler beim Umwandeln), hat der Server
259
+ * trotzdem Erfolg gemeldet: der Beleg ist signiert und im DEP, die Karte
260
+ * belastet bzw. der Einzug gelaufen. Das darf nie als gewoehnlicher Fehler
261
+ * enden, sonst kassiert die Kasse ein zweites Mal. Darum wird daraus
262
+ * `KasseneckApiError` mit Code `response_unreadable` und `outcome: 'unknown'`.
263
+ * Der Grund stammt vom Paket; aus der Antwort selbst wird nichts uebernommen.
264
+ *
265
+ * Paketintern (receipts.ts, payments/); nicht Teil der Paketoberflaeche.
266
+ */
267
+ export declare function signiertGelesen<T>(functionName: string, lesen: () => T): T;
@@ -21,7 +21,7 @@
21
21
  * Single-Page-App hat geantwortet, der Aufruf kam nie an),
22
22
  * `dialect_mismatch` (Antwort ohne Kennzeichen `Kasseneck-Api-Version: v3`:
23
23
  * ein Rand ohne `/v3` hat geantwortet, **Ausgang unklar**) und
24
- * `response_unreadable` (ein signierender Aufruf meldete Erfolg, die
24
+ * `response_unreadable` (ein Aufruf mit Wirkung meldete Erfolg, die
25
25
  * Antwort traegt aber nicht, was er zusagt: **Ausgang unklar**).
26
26
  * **Entscheidend ist `outcome`:** `'rejected'` heisst abgelehnt, nichts
27
27
  * geschehen, Wiederholen hilft nicht. `'unknown'` heisst: der Vorgang kann
@@ -30,14 +30,16 @@
30
30
  * - `KasseneckHttpError` — die Antwort war **keine** verwertbare Huelle:
31
31
  * HTTP 500/404 ohne Huelle, leerer Rumpf oder Text statt JSON. Beim
32
32
  * Bericht-Download gelten dieselben Gruende fuer alles, was kein PDF ist.
33
- * `reason` trennt die Faelle maschinenlesbar. Auf einem signierenden
34
- * Aufruf hat HTTP 5xx `outcome: 'unknown'`, ebenso HTTP 200 mit
35
- * Kennzeichen, aber leerem oder unlesbarem Rumpf.
33
+ * `reason` trennt die Faelle maschinenlesbar. Auf einem Aufruf mit
34
+ * Wirkung (signierend oder geldbewegend) hat HTTP 5xx
35
+ * `outcome: 'unknown'`, ebenso HTTP 200 mit Kennzeichen, aber leerem oder
36
+ * unlesbarem Rumpf.
36
37
  * - `KasseneckNetworkError` — die Antwort kam gar nicht: Netz weg, DNS,
37
38
  * abgebrochene Verbindung oder Zeitueberschreitung (`timedOut`). Auch hier
38
39
  * gilt `outcome`: war die Anfrage schon unterwegs und ist der Aufruf einer
39
- * der signierenden (`createReceipt`, `cancelReceipt`, `financeWebService`),
40
- * ist er `'unknown'`.
40
+ * mit Wirkung (signierend: `createReceipt`, `cancelReceipt`,
41
+ * `financeWebService`; geldbewegend: `hobexPayApi`, `hobexRefundApi`,
42
+ * `stripeCaptureIntent`), ist er `'unknown'`.
41
43
  * - `KasseneckAuthError` — es kam nicht einmal zur Anfrage, weil die Anmeldung
42
44
  * scheiterte (fehlende Zugangsdaten, oder der Token-/Sitzungsgeber warf).
43
45
  * In der Browser-Kasse mit ihrer 90-Sekunden-Sitzung ist das Alltag, kein
@@ -80,6 +82,7 @@ exports.isKasseneckHttpError = isKasseneckHttpError;
80
82
  exports.isKasseneckNetworkError = isKasseneckNetworkError;
81
83
  exports.isKasseneckAuthError = isKasseneckAuthError;
82
84
  exports.isKasseneckValidationError = isKasseneckValidationError;
85
+ exports.signiertGelesen = signiertGelesen;
83
86
  // Bezeichner-artig: Buchstabe vorn, danach nur Bezeichnerzeichen, hoechstens
84
87
  // 64 Zeichen. Das laesst `TypeError`, `ECONNREFUSED` und `auth/internal-error`
85
88
  // durch, aber keinen Freitext und kein ID-Token (~900 Zeichen).
@@ -236,7 +239,7 @@ function ausgangAusCode(code, details) {
236
239
  /**
237
240
  * Codes, die das Paket selbst vergibt, nicht der Server: `route_missing`
238
241
  * (HTML statt Backend, der Aufruf kam nie an) und `response_unreadable`
239
- * (ein signierender Aufruf meldete Erfolg, die Antwort ist aber unlesbar;
242
+ * (ein Aufruf mit Wirkung meldete Erfolg, die Antwort ist aber unlesbar;
240
243
  * Ausgang unklar). `dialect_mismatch` vergibt das Paket ebenfalls, der Code
241
244
  * gehoert aber schon zum Rand des Servers (`errorCodes.edge`).
242
245
  */
@@ -266,7 +269,7 @@ class KasseneckApiError extends Error {
266
269
  /**
267
270
  * `'unknown'` bei `dialect_mismatch`, `receipt_outcome_unknown`,
268
271
  * `cancellation_outcome_unknown`, `response_unreadable` (Erfolg gemeldet,
269
- * Antwort eines signierenden Aufrufs aber unlesbar) und
272
+ * Antwort eines Aufrufs mit Wirkung aber unlesbar) und
270
273
  * `response_translation_failed` (ausser mit `details.handled === false`);
271
274
  * sonst `'rejected'`. Bei `'unknown'` nie wiederholen, sondern nachlesen.
272
275
  */
@@ -301,8 +304,9 @@ class KasseneckHttpError extends Error {
301
304
  /** Maschinenlesbarer Grund — trennt den Rewrite-Fall vom 500er ohne Textparsen. */
302
305
  reason;
303
306
  /**
304
- * `'unknown'` auf einem signierenden Aufruf (`createReceipt`,
305
- * `cancelReceipt`, `financeWebService`) bei HTTP 5xx und bei HTTP 200 mit
307
+ * `'unknown'` auf einem Aufruf mit Wirkung (`createReceipt`,
308
+ * `cancelReceipt`, `financeWebService`, `hobexPayApi`, `hobexRefundApi`,
309
+ * `stripeCaptureIntent`) bei HTTP 5xx und bei HTTP 200 mit
306
310
  * Kennzeichen, aber unlesbarem Rumpf (`empty-body`, `not-json` auch bei
307
311
  * `text/html`, `missing-status`): der Handler kann gelaufen sein, nie wiederholen,
308
312
  * sondern nachlesen. Sonst `'rejected'` (auch 4xx).
@@ -426,3 +430,24 @@ function isKasseneckAuthError(error) {
426
430
  function isKasseneckValidationError(error) {
427
431
  return error instanceof KasseneckValidationError;
428
432
  }
433
+ /**
434
+ * Liest die Erfolgsantwort eines Aufrufs **mit Wirkung** (signierend oder
435
+ * geldbewegend). Scheitert das Lesen (fehlender Beleg, fehlender Bezug,
436
+ * unbrauchbares Feld oder ein Laufzeitfehler beim Umwandeln), hat der Server
437
+ * trotzdem Erfolg gemeldet: der Beleg ist signiert und im DEP, die Karte
438
+ * belastet bzw. der Einzug gelaufen. Das darf nie als gewoehnlicher Fehler
439
+ * enden, sonst kassiert die Kasse ein zweites Mal. Darum wird daraus
440
+ * `KasseneckApiError` mit Code `response_unreadable` und `outcome: 'unknown'`.
441
+ * Der Grund stammt vom Paket; aus der Antwort selbst wird nichts uebernommen.
442
+ *
443
+ * Paketintern (receipts.ts, payments/); nicht Teil der Paketoberflaeche.
444
+ */
445
+ function signiertGelesen(functionName, lesen) {
446
+ try {
447
+ return lesen();
448
+ }
449
+ catch (ursache) {
450
+ const grund = ursache instanceof KasseneckValidationError ? ursache.reason : 'Antwort nicht lesbar';
451
+ throw new KasseneckApiError(functionName, `Erfolg gemeldet, Antwort aber unlesbar (${grund}). Der Vorgang kann ausgefuehrt sein: nicht wiederholen, sondern nachlesen.`, {}, 'response_unreadable');
452
+ }
453
+ }
@@ -315,10 +315,17 @@ export interface SendReceiptEmailOptions {
315
315
  }
316
316
  /** Was der Versand bestaetigt (Backend: beleg-mail-endpoints.js). */
317
317
  export interface SendReceiptEmailResult {
318
- /** Adresse in der Form, in der das Backend sie protokolliert hat (getrimmt, klein). */
318
+ /**
319
+ * Adresse in der Form, in der das Backend sie protokolliert hat (getrimmt,
320
+ * klein). Nennt die Antwort keine, die gesendete (getrimmte) Adresse.
321
+ */
319
322
  to: string;
320
- /** Zeitpunkt des Versands, ISO mit Wiener Zonenoffset (`2026-09-11T14:05:00+02:00`). */
321
- at: string;
323
+ /**
324
+ * Zeitpunkt des Versands, ISO mit Wiener Zonenoffset
325
+ * (`2026-09-11T14:05:00+02:00`). `null`, wenn die Antwort keinen nennt: der
326
+ * Versand ist trotzdem bestaetigt.
327
+ */
328
+ at: string | null;
322
329
  /**
323
330
  * Versandweg: `own` (Postfach des Betriebs), `platform` oder
324
331
  * `platform_fallback` ([RECEIPT_EMAIL_VIAS]). `null`, wenn die Antwort ihn
@@ -58,12 +58,12 @@ function receiptLayoutFromResult(result, options = {}) {
58
58
  */
59
59
  async function createReceipt(rufen, options) {
60
60
  const daten = await rufen('createReceipt', createReceiptParams(options));
61
- return signiertGelesen('createReceipt', () => belegAusHuelle(daten, 'createReceipt'));
61
+ return (0, errors_js_1.signiertGelesen)('createReceipt', () => belegAusHuelle(daten, 'createReceipt'));
62
62
  }
63
63
  /** Wie [createReceipt], liest aus derselben Antwort zusaetzlich die Firmendaten. */
64
64
  async function createReceiptWithCompany(rufen, options) {
65
65
  const daten = await rufen('createReceipt', createReceiptParams(options));
66
- return signiertGelesen('createReceipt', () => belegMitFirmaAusHuelle(daten, 'createReceipt'));
66
+ return (0, errors_js_1.signiertGelesen)('createReceipt', () => belegMitFirmaAusHuelle(daten, 'createReceipt'));
67
67
  }
68
68
  /**
69
69
  * Baut die Nutzlast von `createReceipt` und prueft die Eingabe. Wirft, bevor
@@ -199,7 +199,7 @@ async function cancelReceipt(transport, options) {
199
199
  if (zahlungen !== undefined)
200
200
  params.payments = zahlungen;
201
201
  const daten = await transport('cancelReceipt', params);
202
- return signiertGelesen('cancelReceipt', () => stornoAusHuelle(daten));
202
+ return (0, errors_js_1.signiertGelesen)('cancelReceipt', () => stornoAusHuelle(daten));
203
203
  }
204
204
  /** Liest die Storno-Antwort `{ receipt, cancellationOf, remaining }`. */
205
205
  function stornoAusHuelle(daten) {
@@ -395,14 +395,17 @@ async function sendReceiptEmail(transport, options) {
395
395
  if (options.language !== undefined && options.language !== '')
396
396
  params.language = options.language;
397
397
  const daten = await transport('sendReceiptEmail', params);
398
- if (typeof daten?.to !== 'string' || daten.to === '') {
399
- throw antwortfehler('sendReceiptEmail', 'Antwort nennt keine Empfaengeradresse (data.to fehlt)');
400
- }
401
- if (typeof daten.at !== 'string' || daten.at === '') {
402
- throw antwortfehler('sendReceiptEmail', 'Antwort nennt keinen Zeitpunkt (data.at fehlt)');
403
- }
404
- const via = index_js_2.RECEIPT_EMAIL_VIAS.includes(daten.via) ? daten.via : null;
405
- return { to: daten.to, at: daten.at, via };
398
+ // Nachsichtig gelesen (wie der Dart-Zwilling): eine Erfolgsantwort heisst,
399
+ // die Mail ist schon verschickt. Ein Fehler wegen eines fehlenden
400
+ // Antwortfelds luede zum zweiten Versand ein; also zurueck, was da ist.
401
+ const gemeldet = daten?.to;
402
+ const zeit = daten?.at;
403
+ const via = index_js_2.RECEIPT_EMAIL_VIAS.includes(daten?.via) ? daten.via : null;
404
+ return {
405
+ to: typeof gemeldet === 'string' && gemeldet.trim() !== '' ? gemeldet : to,
406
+ at: typeof zeit === 'string' && zeit !== '' ? zeit : null,
407
+ via,
408
+ };
406
409
  }
407
410
  /**
408
411
  * Prueft die Gutschein-Kombination eines Belegs und liefert den ersten
@@ -685,24 +688,6 @@ function kartenanbieter(wert) {
685
688
  function eingabefehler(grund) {
686
689
  return new errors_js_1.KasseneckValidationError('createReceipt', grund, 'request');
687
690
  }
688
- /**
689
- * Liest die Erfolgsantwort eines **signierenden** Aufrufs. Scheitert das
690
- * Lesen (fehlender Beleg, fehlender Bezug, unbrauchbares Feld oder ein
691
- * Laufzeitfehler beim Umwandeln), hat der Server trotzdem Erfolg gemeldet:
692
- * der Beleg ist signiert und im DEP. Das darf nie als gewoehnlicher Fehler
693
- * enden, sonst kassiert die Kasse ein zweites Mal. Darum wird daraus
694
- * `KasseneckApiError` mit Code `response_unreadable` und `outcome: 'unknown'`.
695
- * Der Grund stammt vom Paket; aus der Antwort selbst wird nichts uebernommen.
696
- */
697
- function signiertGelesen(functionName, lesen) {
698
- try {
699
- return lesen();
700
- }
701
- catch (ursache) {
702
- const grund = ursache instanceof errors_js_1.KasseneckValidationError ? ursache.reason : 'Antwort nicht lesbar';
703
- throw new errors_js_1.KasseneckApiError(functionName, `Erfolg gemeldet, Antwort aber unlesbar (${grund}). Der Vorgang kann ausgefuehrt sein: nicht wiederholen, sondern nachlesen.`, {}, 'response_unreadable');
704
- }
705
- }
706
691
  /** Die Antwort meldete Erfolg, trug aber nicht, was der Aufruf zusagt. */
707
692
  function antwortfehler(functionName, grund) {
708
693
  return new errors_js_1.KasseneckValidationError(functionName, grund, 'response');
@@ -62,10 +62,22 @@ const KASSENECK_HOSTS = new Set(['api.kasseneck.at', 'kasse.kasseneck.at']);
62
62
  /** Die 1.x-Linie spricht nur `/v3`: jede Basis endet so (`/v3` oder `/api/v3`). */
63
63
  const V3_ENDE = /\/v3$/;
64
64
  /**
65
- * Aufrufe, die signieren bzw. bei FinanzOnline etwas ausloesen. Ein Netzfehler,
66
- * nachdem die Anfrage unterwegs war, laesst ihren Ausgang offen.
65
+ * Aufrufe mit Wirkung, die nie blind wiederholt werden duerfen: sie signieren
66
+ * (`createReceipt`, `cancelReceipt`), loesen bei FinanzOnline etwas aus
67
+ * (`financeWebService`) oder bewegen Geld (`hobexPayApi` belastet eine Karte,
68
+ * `hobexRefundApi` erstattet, `stripeCaptureIntent` zieht eine vorgemerkte
69
+ * Zahlung ein). Scheitert einer, nachdem die Anfrage unterwegs war (Netz,
70
+ * Zeitlimit, HTTP 5xx, unlesbare Erfolgsantwort, HTML mit Kennzeichen), ist
71
+ * sein Ausgang offen.
67
72
  */
68
- const SIGNIERENDE_AUFRUFE = new Set(['createReceipt', 'cancelReceipt', 'financeWebService']);
73
+ const UNKNOWN_OUTCOME_CALLS = new Set([
74
+ 'createReceipt',
75
+ 'cancelReceipt',
76
+ 'financeWebService',
77
+ 'hobexPayApi',
78
+ 'hobexRefundApi',
79
+ 'stripeCaptureIntent',
80
+ ]);
69
81
  /**
70
82
  * Produkte, die das Backend in `Kasseneck-Client` zaehlt (Positivliste,
71
83
  * Nachtrag §6); alles andere zaehlte dort als `ungueltig`. Das Paket weist
@@ -162,9 +174,9 @@ function createCore(options) {
162
174
  return ursache;
163
175
  }
164
176
  // Bis hierher kam keine verwertbare Antwort: Netz weg oder Zeitlimit.
165
- // Die Anfrage war schon unterwegs: bei einem signierenden Aufruf kann
166
- // der Beleg entstanden sein (Ausgang unklar, nachlesen).
167
- return new errors_js_1.KasseneckNetworkError(fehlerName, abbruch.signal.aborted, zeitlimitMs, (0, errors_js_1.causeDigest)(ursache, geheimnisse), SIGNIERENDE_AUFRUFE.has(functionName) ? 'unknown' : 'rejected');
177
+ // Die Anfrage war schon unterwegs: bei einem Aufruf mit Wirkung kann
178
+ // der Beleg bzw. die Zahlung entstanden sein (Ausgang unklar, nachlesen).
179
+ return new errors_js_1.KasseneckNetworkError(fehlerName, abbruch.signal.aborted, zeitlimitMs, (0, errors_js_1.causeDigest)(ursache, geheimnisse), UNKNOWN_OUTCOME_CALLS.has(functionName) ? 'unknown' : 'rejected');
168
180
  };
169
181
  const basis = basisFuer(functionName);
170
182
  const url = `${basis}/${encodeURIComponent(functionName)}`;
@@ -217,18 +229,18 @@ function createCore(options) {
217
229
  if (fehler)
218
230
  throw fehler;
219
231
  }
220
- // 5xx auf einem signierenden Aufruf: der Handler kann gelaufen sein.
221
- const ausgang = antwort.status >= 500 && SIGNIERENDE_AUFRUFE.has(functionName) ? 'unknown' : 'rejected';
232
+ // 5xx auf einem Aufruf mit Wirkung: der Handler kann gelaufen sein.
233
+ const ausgang = antwort.status >= 500 && UNKNOWN_OUTCOME_CALLS.has(functionName) ? 'unknown' : 'rejected';
222
234
  throw new errors_js_1.KasseneckHttpError(fehlerName, antwort.status, inhaltstyp, 'server-error', ausgang);
223
235
  }
224
236
  // HTTP 200 mit HTML: die Auffangregel der Single-Page-App hat den Aufruf
225
237
  // bedient, keine Function hat ihn gesehen (Nachtrag §5.4, R15).
226
238
  if (inhaltstyp !== undefined && /^\s*text\/html\b/i.test(inhaltstyp)) {
227
239
  // Mit Kennzeichen hat der `/v3`-Rand den Aufruf gesehen (etwa ein
228
- // Proxy, der nur den Inhaltstyp umschreibt). Bei einem signierenden
229
- // Aufruf kann der Beleg dann entstanden sein: unlesbarer Rumpf,
240
+ // Proxy, der nur den Inhaltstyp umschreibt). Bei einem Aufruf mit
241
+ // Wirkung kann der Beleg bzw. die Zahlung dann entstanden sein: unlesbarer Rumpf,
230
242
  // Ausgang unklar, statt `route_missing`.
231
- if (SIGNIERENDE_AUFRUFE.has(functionName) && traegtKennzeichen(antwort)) {
243
+ if (UNKNOWN_OUTCOME_CALLS.has(functionName) && traegtKennzeichen(antwort)) {
232
244
  throw new errors_js_1.KasseneckHttpError(fehlerName, antwort.status, inhaltstyp, 'not-json', 'unknown');
233
245
  }
234
246
  throw new errors_js_1.KasseneckApiError(fehlerName, 'Route fehlt: die Antwort ist eine HTML-Seite statt des Backends', {}, 'route_missing');
@@ -245,8 +257,8 @@ function createCore(options) {
245
257
  }
246
258
  // Ab hier kam HTTP 200 mit Kennzeichen: der `/v3`-Rand hat den Aufruf
247
259
  // gesehen. Ist der Rumpf dann unlesbar (gekuerzt von einem Proxy,
248
- // abgebrochene Verbindung), kann ein signierender Handler gelaufen sein.
249
- const unlesbar = SIGNIERENDE_AUFRUFE.has(functionName) ? 'unknown' : 'rejected';
260
+ // abgebrochene Verbindung), kann ein Handler mit Wirkung gelaufen sein.
261
+ const unlesbar = UNKNOWN_OUTCOME_CALLS.has(functionName) ? 'unknown' : 'rejected';
250
262
  return auswerten(koerper, fehlerName, antwort.status, inhaltstyp, geheimnisse, unlesbar);
251
263
  }
252
264
  finally {
@@ -45,11 +45,11 @@ export interface Cashregister {
45
45
  */
46
46
  export interface CashregisterOnboarding {
47
47
  cashboxRegistered: boolean;
48
- startbelegCreated: boolean;
49
- startbelegTransmitted: boolean;
48
+ startReceiptCreated: boolean;
49
+ startReceiptTransmitted: boolean;
50
50
  cashboxRegisteredAt?: Date;
51
- startbelegCreatedAt?: Date;
52
- startbelegTransmittedAt?: Date;
51
+ startReceiptCreatedAt?: Date;
52
+ startReceiptTransmittedAt?: Date;
53
53
  }
54
54
  /** Nutzlast-Form, die dieses Paket liest — die Feldnamen von `listMyCashregisters`. */
55
55
  export interface CashregisterPayload {
@@ -61,13 +61,19 @@ export interface CashregisterPayload {
61
61
  token?: string | null;
62
62
  onboarding?: CashregisterOnboardingPayload | null;
63
63
  }
64
+ /**
65
+ * `onboarding` am Draht `/v3` (fixtures/v3/antworten/kasse.json): der
66
+ * Startbeleg heisst dort `start_receipt_*`. Die inneren Namen
67
+ * `startbeleg_*` sendet nur `/v1`; wer sie hier laese, meldete jede Kasse
68
+ * als „Startbeleg fehlt“.
69
+ */
64
70
  export interface CashregisterOnboardingPayload {
65
71
  cashbox_registered?: boolean | null;
66
- startbeleg_created?: boolean | null;
67
- startbeleg_transmitted?: boolean | null;
72
+ start_receipt_created?: boolean | null;
73
+ start_receipt_transmitted?: boolean | null;
68
74
  cashbox_registered_at?: string | null;
69
- startbeleg_created_at?: string | null;
70
- startbeleg_transmitted_at?: string | null;
75
+ start_receipt_created_at?: string | null;
76
+ start_receipt_transmitted_at?: string | null;
71
77
  }
72
78
  /**
73
79
  * Liest eine Kasse aus der Antwort. `id` gewinnt aus der Nutzlast, faellt aber
@@ -18,11 +18,11 @@ function fromCashregisterPayload(payload, id) {
18
18
  ...(payload.signature_id ? { signatureId: payload.signature_id } : {}),
19
19
  onboarding: {
20
20
  cashboxRegistered: ob.cashbox_registered === true,
21
- startbelegCreated: ob.startbeleg_created === true,
22
- startbelegTransmitted: ob.startbeleg_transmitted === true,
21
+ startReceiptCreated: ob.start_receipt_created === true,
22
+ startReceiptTransmitted: ob.start_receipt_transmitted === true,
23
23
  ...zeitfeld('cashboxRegisteredAt', ob.cashbox_registered_at),
24
- ...zeitfeld('startbelegCreatedAt', ob.startbeleg_created_at),
25
- ...zeitfeld('startbelegTransmittedAt', ob.startbeleg_transmitted_at),
24
+ ...zeitfeld('startReceiptCreatedAt', ob.start_receipt_created_at),
25
+ ...zeitfeld('startReceiptTransmittedAt', ob.start_receipt_transmitted_at),
26
26
  },
27
27
  };
28
28
  }
@@ -45,6 +45,10 @@ export interface HobexTransactionIdOptions {
45
45
  *
46
46
  * **Der Endpunkt heisst `hobexPayApi`** — mit Suffix; ohne ihn gibt es ihn
47
47
  * nicht.
48
+ *
49
+ * **Nie wiederholen.** Bei `isOutcomeUnknown(e)` (Netz, Zeitlimit, HTTP 5xx,
50
+ * unlesbare Antwort) kann die Karte belastet sein: den Stand ueber die
51
+ * Transaktionskennung nachlesen, nicht ein zweites Mal belasten.
48
52
  */
49
53
  export declare function hobexPay(transport: InternerTransport, options: HobexPayOptions): Promise<HobexReceipt>;
50
54
  /**
@@ -57,7 +61,11 @@ export declare function hobexPay(transport: InternerTransport, options: HobexPay
57
61
  * schon im Transport, sobald der Status nicht ausdruecklich Erfolg ist. Ein
58
62
  * Wahrheitswert waere hier also immer `true` — eine Luege ueber den
59
63
  * Informationsgehalt, an der ein Aufrufer ein `if` aufhaengt, das nie greift.
60
- * Misserfolg kommt als geworfener Fehler.
64
+ * Misserfolg kommt als geworfener Fehler: eine Fehlerhuelle als
65
+ * [KasseneckApiError] mit Code, Netz, Zeitlimit, HTTP 5xx und eine unlesbare
66
+ * Erfolgsantwort mit `outcome: 'unknown'` (die Erstattung kann gelaufen sein).
67
+ * Ein `false` an dieser Stelle luede zum zweiten Versuch ein, also zur
68
+ * doppelten Erstattung. **Nie wiederholen**, sondern den Stand nachlesen.
61
69
  *
62
70
  * **Der Endpunkt heisst `hobexRefundApi`** — mit Suffix.
63
71
  */
@@ -41,6 +41,10 @@ const ENDPUNKT_REFUND = 'hobexRefundApi';
41
41
  *
42
42
  * **Der Endpunkt heisst `hobexPayApi`** — mit Suffix; ohne ihn gibt es ihn
43
43
  * nicht.
44
+ *
45
+ * **Nie wiederholen.** Bei `isOutcomeUnknown(e)` (Netz, Zeitlimit, HTTP 5xx,
46
+ * unlesbare Antwort) kann die Karte belastet sein: den Stand ueber die
47
+ * Transaktionskennung nachlesen, nicht ein zweites Mal belasten.
44
48
  */
45
49
  async function hobexPay(transport, options) {
46
50
  const params = zahlungsNutzlast(ENDPUNKT_PAY, options);
@@ -48,7 +52,11 @@ async function hobexPay(transport, options) {
48
52
  // geht sie als null raus, nicht gar nicht. Der Transport wirft nur
49
53
  // `undefined` weg, `null` bleibt erhalten — genau diese Unterscheidung.
50
54
  params['reference'] = options.reference ?? null;
51
- return belegAusNutzlast(await transport(ENDPUNKT_PAY, params));
55
+ const daten = await transport(ENDPUNKT_PAY, params);
56
+ // Erfolg gemeldet heisst: die Karte ist belastet. Ist der Beleg dann
57
+ // unlesbar, bleibt der Ausgang unklar (`response_unreadable`), damit keine
58
+ // App ein zweites Mal belastet.
59
+ return (0, errors_js_1.signiertGelesen)(ENDPUNKT_PAY, () => belegAusNutzlast(daten));
52
60
  }
53
61
  /**
54
62
  * Erstattet eine zuvor ueber die Hobex-Cloud getaetigte Zahlung. Das Backend
@@ -60,7 +68,11 @@ async function hobexPay(transport, options) {
60
68
  * schon im Transport, sobald der Status nicht ausdruecklich Erfolg ist. Ein
61
69
  * Wahrheitswert waere hier also immer `true` — eine Luege ueber den
62
70
  * Informationsgehalt, an der ein Aufrufer ein `if` aufhaengt, das nie greift.
63
- * Misserfolg kommt als geworfener Fehler.
71
+ * Misserfolg kommt als geworfener Fehler: eine Fehlerhuelle als
72
+ * [KasseneckApiError] mit Code, Netz, Zeitlimit, HTTP 5xx und eine unlesbare
73
+ * Erfolgsantwort mit `outcome: 'unknown'` (die Erstattung kann gelaufen sein).
74
+ * Ein `false` an dieser Stelle luede zum zweiten Versuch ein, also zur
75
+ * doppelten Erstattung. **Nie wiederholen**, sondern den Stand nachlesen.
64
76
  *
65
77
  * **Der Endpunkt heisst `hobexRefundApi`** — mit Suffix.
66
78
  */
@@ -57,5 +57,8 @@ export declare function createStripeLink(transport: InternerTransport, options:
57
57
  * **Der Parameter heisst `stripe_sessions_id`** — mit "sessions" im Plural, so
58
58
  * das Vorbild und so das Backend. Der naheliegende Singular waere ein fehlender
59
59
  * Pflichtparameter.
60
+ *
61
+ * **Nie wiederholen.** Bei `isOutcomeUnknown(e)` (Netz, Zeitlimit, HTTP 5xx,
62
+ * unlesbare Antwort) kann der Einzug gelaufen sein: den Stand nachlesen.
60
63
  */
61
64
  export declare function stripeCaptureIntent(transport: InternerTransport, stripeSessionId: string): Promise<StripeCaptureResult>;