@kreiseck/kasseneck-api 0.6.45 → 0.6.49

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 (110) hide show
  1. package/README.md +95 -15
  2. package/dist/cjs/client/aufrufe.d.ts +1 -1
  3. package/dist/cjs/client/aufrufe.js +2 -0
  4. package/dist/cjs/client/errors.d.ts +7 -1
  5. package/dist/cjs/client/errors.js +8 -1
  6. package/dist/cjs/client/receipts.d.ts +16 -0
  7. package/dist/cjs/client/receipts.js +10 -0
  8. package/dist/cjs/client/transport.js +5 -4
  9. package/dist/cjs/index.d.ts +1 -1
  10. package/dist/cjs/index.js +4 -2
  11. package/dist/cjs/kasse/index.d.ts +1 -0
  12. package/dist/cjs/kasse/index.js +3 -0
  13. package/dist/cjs/kasse/trinkgeld.d.ts +10 -0
  14. package/dist/cjs/kasse/trinkgeld.js +27 -0
  15. package/dist/cjs/models/cancellation.d.ts +22 -0
  16. package/dist/cjs/models/cancellation.js +31 -1
  17. package/dist/cjs/models/hobex-receipt.js +9 -2
  18. package/dist/cjs/models/index.d.ts +1 -1
  19. package/dist/cjs/models/index.js +3 -1
  20. package/dist/cjs/models/receipt.js +3 -0
  21. package/dist/cjs/models/voucher.js +18 -2
  22. package/dist/cjs/payments/hobex-hps/connect-client.d.ts +104 -0
  23. package/dist/cjs/payments/hobex-hps/connect-client.js +159 -0
  24. package/dist/cjs/payments/hobex-hps/errors.d.ts +122 -0
  25. package/dist/cjs/payments/hobex-hps/errors.js +158 -0
  26. package/dist/cjs/payments/hobex-hps/events.d.ts +22 -0
  27. package/dist/cjs/payments/hobex-hps/events.js +2 -0
  28. package/dist/cjs/payments/hobex-hps/index.d.ts +27 -0
  29. package/dist/cjs/payments/hobex-hps/index.js +66 -0
  30. package/dist/cjs/payments/hobex-hps/outcome.d.ts +31 -0
  31. package/dist/cjs/payments/hobex-hps/outcome.js +7 -0
  32. package/dist/cjs/payments/hobex-hps/payments.d.ts +149 -0
  33. package/dist/cjs/payments/hobex-hps/payments.js +550 -0
  34. package/dist/cjs/payments/hobex-hps/receipt.d.ts +28 -0
  35. package/dist/cjs/payments/hobex-hps/receipt.js +60 -0
  36. package/dist/cjs/payments/hobex-hps/transaction-id.d.ts +65 -0
  37. package/dist/cjs/payments/hobex-hps/transaction-id.js +91 -0
  38. package/dist/cjs/payments/hobex-hps/transaction-response.d.ts +163 -0
  39. package/dist/cjs/payments/hobex-hps/transaction-response.js +245 -0
  40. package/dist/cjs/payments/hobex.js +9 -6
  41. package/dist/cjs/payments/index.d.ts +26 -14
  42. package/dist/cjs/payments/index.js +59 -15
  43. package/dist/cjs/printing/index.d.ts +1 -1
  44. package/dist/cjs/printing/index.js +2 -1
  45. package/dist/cjs/printing/webusb.d.ts +37 -7
  46. package/dist/cjs/printing/webusb.js +76 -18
  47. package/dist/cjs/receipt/epos.d.ts +18 -0
  48. package/dist/cjs/receipt/epos.js +78 -6
  49. package/dist/cjs/receipt/index.d.ts +1 -1
  50. package/dist/cjs/receipt/index.js +2 -1
  51. package/dist/cjs/register/index.d.ts +1 -1
  52. package/dist/cjs/register/index.js +2 -1
  53. package/dist/cjs/register/pairing.d.ts +67 -1
  54. package/dist/cjs/register/pairing.js +59 -3
  55. package/dist/esm/client/aufrufe.d.ts +1 -1
  56. package/dist/esm/client/aufrufe.js +2 -0
  57. package/dist/esm/client/errors.d.ts +7 -1
  58. package/dist/esm/client/errors.js +8 -1
  59. package/dist/esm/client/receipts.d.ts +16 -0
  60. package/dist/esm/client/receipts.js +10 -0
  61. package/dist/esm/client/transport.js +5 -4
  62. package/dist/esm/index.d.ts +1 -1
  63. package/dist/esm/index.js +1 -1
  64. package/dist/esm/kasse/index.d.ts +1 -0
  65. package/dist/esm/kasse/index.js +1 -0
  66. package/dist/esm/kasse/trinkgeld.d.ts +10 -0
  67. package/dist/esm/kasse/trinkgeld.js +24 -0
  68. package/dist/esm/models/cancellation.d.ts +22 -0
  69. package/dist/esm/models/cancellation.js +29 -0
  70. package/dist/esm/models/hobex-receipt.js +10 -3
  71. package/dist/esm/models/index.d.ts +1 -1
  72. package/dist/esm/models/index.js +1 -1
  73. package/dist/esm/models/receipt.js +3 -0
  74. package/dist/esm/models/voucher.js +18 -2
  75. package/dist/esm/payments/hobex-hps/connect-client.d.ts +104 -0
  76. package/dist/esm/payments/hobex-hps/connect-client.js +156 -0
  77. package/dist/esm/payments/hobex-hps/errors.d.ts +122 -0
  78. package/dist/esm/payments/hobex-hps/errors.js +149 -0
  79. package/dist/esm/payments/hobex-hps/events.d.ts +22 -0
  80. package/dist/esm/payments/hobex-hps/events.js +1 -0
  81. package/dist/esm/payments/hobex-hps/index.d.ts +27 -0
  82. package/dist/esm/payments/hobex-hps/index.js +26 -0
  83. package/dist/esm/payments/hobex-hps/outcome.d.ts +31 -0
  84. package/dist/esm/payments/hobex-hps/outcome.js +4 -0
  85. package/dist/esm/payments/hobex-hps/payments.d.ts +149 -0
  86. package/dist/esm/payments/hobex-hps/payments.js +547 -0
  87. package/dist/esm/payments/hobex-hps/receipt.d.ts +28 -0
  88. package/dist/esm/payments/hobex-hps/receipt.js +57 -0
  89. package/dist/esm/payments/hobex-hps/transaction-id.d.ts +65 -0
  90. package/dist/esm/payments/hobex-hps/transaction-id.js +86 -0
  91. package/dist/esm/payments/hobex-hps/transaction-response.d.ts +163 -0
  92. package/dist/esm/payments/hobex-hps/transaction-response.js +233 -0
  93. package/dist/esm/payments/hobex.js +9 -6
  94. package/dist/esm/payments/index.d.ts +26 -14
  95. package/dist/esm/payments/index.js +26 -14
  96. package/dist/esm/printing/index.d.ts +1 -1
  97. package/dist/esm/printing/index.js +1 -1
  98. package/dist/esm/printing/webusb.d.ts +37 -7
  99. package/dist/esm/printing/webusb.js +74 -17
  100. package/dist/esm/receipt/epos.d.ts +18 -0
  101. package/dist/esm/receipt/epos.js +76 -6
  102. package/dist/esm/receipt/index.d.ts +1 -1
  103. package/dist/esm/receipt/index.js +1 -1
  104. package/dist/esm/register/index.d.ts +1 -1
  105. package/dist/esm/register/index.js +1 -1
  106. package/dist/esm/register/pairing.d.ts +67 -1
  107. package/dist/esm/register/pairing.js +58 -3
  108. package/fixtures/hobex-hps-codes.json +67 -0
  109. package/fixtures/oberflaeche.json +3 -1
  110. package/package.json +4 -2
@@ -0,0 +1,547 @@
1
+ import { HpsClarifyTimeoutError, HpsConnectException, HpsConnectTerminalError, HpsPreflightError, } from './errors.js';
2
+ import { newHpsTransactionId } from './transaction-id.js';
3
+ import { isApproved, isCanceled, isConclusive, isNoStatement, isNotAbortable, isTechnicalError, isUnknownCode, NOT_ABORTABLE_CODE, TECHNICAL_ERROR_CODE, TRANSACTION_CANCELED_CODE, } from './transaction-response.js';
4
+ /** Teiler, mit dem das Abbruchbudget aus [HpsPaymentsOptions.resolveBudgetMs] entsteht. */
5
+ const ABORT_BUDGET_DIVISOR = 6;
6
+ export function createHpsPayments(client, target, options = {}) {
7
+ const resolveBudgetMs = options.resolveBudgetMs ?? 90_000;
8
+ const maxBackoffMs = options.maxBackoffMs ?? 10_000;
9
+ const maxTransportFailures = options.maxTransportFailures ?? 3;
10
+ const sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
11
+ const now = options.now ?? Date.now;
12
+ const observer = options.observer;
13
+ const abortBudgetMs = Math.trunc(resolveBudgetMs / ABORT_BUDGET_DIVISOR);
14
+ function emit(kind, message, transactionId) {
15
+ if (!observer)
16
+ return;
17
+ try {
18
+ observer({ kind, message, transactionId });
19
+ }
20
+ catch {
21
+ // bewusst still -- das Protokoll darf den Zahlweg nie mitreissen
22
+ }
23
+ }
24
+ /**
25
+ * Meldet eine Ausnahme, die KEIN erwarteter Connect-Fehler ist -- also
26
+ * einen Fehler im eigenen Auswerten statt einen an Connect oder am
27
+ * Terminal (z. B. eine unlesbare Antwortform).
28
+ */
29
+ function noteUnexpected(error, transactionId) {
30
+ if (error instanceof HpsConnectException)
31
+ return;
32
+ if (!observer)
33
+ return;
34
+ try {
35
+ observer({
36
+ kind: 'unexpectedError',
37
+ message: 'Unerwarteter Fehler beim Auswerten der Terminal-Antwort',
38
+ transactionId,
39
+ error,
40
+ });
41
+ }
42
+ catch {
43
+ // bewusst still
44
+ }
45
+ }
46
+ function describe(error) {
47
+ return error instanceof Error ? error.message : String(error);
48
+ }
49
+ /**
50
+ * `null`, wenn [error] nicht der gemessene "Terminal beschaeftigt"-Fall ist.
51
+ * Gilt AUSDRUECKLICH nur fuer die ERZEUGENDE Anfrage (siehe `errors.ts`),
52
+ * niemals beim Abbruch oder beim Pollen der Statusabfrage.
53
+ */
54
+ function fromTerminalBusy(error, id, steps) {
55
+ if (!(error instanceof HpsConnectTerminalError) || !error.isTerminalBusy)
56
+ return null;
57
+ steps.push('Terminal beschaeftigt (HTTP 409) -- die Anfrage wurde nicht angenommen, es ist nichts geschehen');
58
+ emit('resolved', steps[steps.length - 1], id);
59
+ return { outcome: 'declined', transactionId: id, steps: [...steps] };
60
+ }
61
+ /** Verlaufseintrag fuer eine direkte Antwort, die den Ausgang NICHT festschreibt. */
62
+ function offeneAntwort(res) {
63
+ if (res.responseCode === undefined) {
64
+ return 'Antwort ohne Ergebniscode -- Ausgang wird geklaert';
65
+ }
66
+ if (isTechnicalError(res)) {
67
+ return `Antwort mit technischem Fehler (${TECHNICAL_ERROR_CODE}) -- keine Aussage ueber den Vorgang, Ausgang wird geklaert`;
68
+ }
69
+ if (isUnknownCode(res)) {
70
+ return `Terminal nennt einen unbekannten Code (${res.responseCode}) -- Ausgang wird geklaert`;
71
+ }
72
+ return `Antwort ohne Aussage (${res.responseCode}) -- Ausgang wird geklaert`;
73
+ }
74
+ /** Dasselbe fuer eine Statusabfrage waehrend der Klaerung, oder `null`. */
75
+ function statusOhneErgebnis(status) {
76
+ if (isNoStatement(status)) {
77
+ return `Status: keine Auskunft (${status.responseCode})`;
78
+ }
79
+ if (isTechnicalError(status)) {
80
+ return `Status: technischer Fehler (${TECHNICAL_ERROR_CODE}) -- keine Aussage ueber den Vorgang`;
81
+ }
82
+ if (isUnknownCode(status)) {
83
+ return `Status: unbekannter Code (${status.responseCode}) -- keine Aussage`;
84
+ }
85
+ return null;
86
+ }
87
+ /** Ordnet eine Terminal-Antwort ein. `null`, wenn sie nichts entscheidet. */
88
+ function fromResponse(res, id, steps) {
89
+ if (!isConclusive(res))
90
+ return null;
91
+ const approved = res.responseCode === '0';
92
+ steps.push(approved ? 'Terminal: genehmigt' : `Terminal: abgelehnt (${res.responseCode})`);
93
+ emit('resolved', steps[steps.length - 1], id);
94
+ return { outcome: approved ? 'approved' : 'declined', transactionId: id, response: res, steps: [...steps] };
95
+ }
96
+ /**
97
+ * Ordnet die DIREKTE Antwort auf einen Aufhebungs-Request ein. Fast dasselbe
98
+ * wie [fromResponse] -- diese Antwort betrifft die Aufhebung selbst und
99
+ * traegt deren eigenen `responseCode`. Mit genau einer Ausnahme:
100
+ * [TRANSACTION_CANCELED_CODE] (`9011`).
101
+ *
102
+ * Ueber [fromResponse] wuerde `9011` zu `declined` -- also "die Aufhebung
103
+ * hat nicht gegriffen, es ist weiterhin belastet". Das waere die teure
104
+ * Richtung: es meldet "belastet" fuer einen Vorgang, der aufgehoben ist, und
105
+ * laedt zu einer Rueckerstattung ein, die der Kunde ein zweites Mal bekaeme.
106
+ * Es widerspraeche ausserdem [fromCancelStatus], die aus demselben Code das
107
+ * Gegenteil ableitet.
108
+ *
109
+ * Was `9011` auf dem DIREKTEN Weg genau heisst, ist UNGEMESSEN -- am
110
+ * naechstliegenden "der Vorgang ist (bereits) aufgehoben", aber das ist eine
111
+ * Lesart, keine Messung. Deshalb weder Erfolg noch Ablehnung hier, sondern
112
+ * NICHT SCHLUESSIG: die Klaerung fragt den Zustand der Originalzahlung ab
113
+ * und entscheidet ihn dort mit dem gemessenen Diskriminator, statt zu raten.
114
+ */
115
+ function fromCancelResponse(res, id, steps) {
116
+ if (isCanceled(res)) {
117
+ steps.push(`Aufhebung mit ${TRANSACTION_CANCELED_CODE} beantwortet -- mehrdeutig, der Zustand der Originalzahlung wird abgefragt`);
118
+ return null;
119
+ }
120
+ return fromResponse(res, id, steps);
121
+ }
122
+ /**
123
+ * Ordnet die Statusabfrage einer OFFENEN AUFHEBUNG ein. `null`, wenn sie
124
+ * nichts entscheidet.
125
+ *
126
+ * Die Abfrage laeuft auf die Kennung der ORIGINALZAHLUNG. Ihr
127
+ * `responseCode` beschreibt deshalb den Zustand DIESER Zahlung, nicht den
128
+ * Ausgang der Aufhebung -- er wird hier UEBERSETZT, nicht wie bei
129
+ * [fromResponse] gelesen. Am 26./28.08.2026 gemessen, nachdem eine
130
+ * genehmigte Zahlung per Void aufgehoben wurde:
131
+ *
132
+ * - `9011` "Transaction Canceled" -> die Aufhebung hat gewirkt -> `approved`.
133
+ * - `'0'` -> die Originalzahlung steht unveraendert -> die Aufhebung hat
134
+ * NICHT gewirkt -> `declined`. Keine schlechte Nachricht ueber die
135
+ * Zahlung, sondern ueber die Aufhebung: es ist weiterhin belastet, und
136
+ * die Aufhebung muss wiederholt werden. ABER erst ab der ZWEITEN
137
+ * beantworteten Abfrage, siehe [firstQuery].
138
+ * - `9027` und jeder andere oder fehlende Code -> keine Auskunft, weiter
139
+ * klaeren; am Ende `unresolved`, niemals ein geratenes Ergebnis.
140
+ *
141
+ * ## Warum `'0'` eine Karenz braucht
142
+ *
143
+ * Die Klaerung startet unmittelbar, nachdem der Void-Request abgerissen
144
+ * ist, und ihre erste Abfrage laeuft ohne Pause. Anders als bei [pay] liegt
145
+ * kein Abbruch-Roundtrip dazwischen, der Zeit verstreichen liesse. Genau in
146
+ * diesem Fenster kann der Void beim Terminal noch unterwegs sein und die
147
+ * Abfrage trotzdem schon `'0'` melden.
148
+ *
149
+ * Ein voreiliges "hat nicht gegriffen" ist NICHT harmlos: nach dem
150
+ * Tagesabschluss ist die Folgehandlung eine RUECKERSTATTUNG -- und dann
151
+ * bekommt der Kunde sein Geld zweimal. Deshalb entscheidet `'0'` erst ab der
152
+ * zweiten beantworteten Abfrage. Ein tatsaechlich nicht gelandeter Void
153
+ * antwortet eine Sekunde spaeter wieder `'0'`; der Preis ist diese eine
154
+ * Sekunde. Reicht das Budget nur fuer eine einzige Abfrage, endet die
155
+ * Klaerung bei `unresolved` -- wir sagen dann, dass wir es nicht wissen,
156
+ * statt es zu raten.
157
+ *
158
+ * [state] `=== 'VOID'` gilt zusaetzlich als Beleg, aber niemals als
159
+ * notwendige Bedingung -- auf der gemessenen Firmware ist `state` in jeder
160
+ * bisher gesehenen Antwort `undefined`. Bleibt nur mitgelesen, weil ein
161
+ * ausdrueckliches `'VOID'` -- wo eine Firmware es denn liefert -- eine
162
+ * unmissverstaendliche positive Aussage ist, die kein falsches `approved`
163
+ * erzeugen kann.
164
+ *
165
+ * [firstQuery] ist `true`, wenn dies die erste BEANTWORTETE Statusabfrage
166
+ * dieser Klaerung ist. Gescheiterte Abfragen zaehlen nicht mit: sie lassen
167
+ * zwar Zeit verstreichen, liefern aber keine Auskunft, an der sich ein
168
+ * `'0'` bestaetigen liesse.
169
+ */
170
+ function fromCancelStatus(status, id, steps, firstQuery) {
171
+ const voided = isCanceled(status) || status.state?.trim().toUpperCase() === 'VOID';
172
+ if (voided) {
173
+ steps.push(`Terminal: Aufhebung bestaetigt (${status.responseCode ?? status.state})`);
174
+ emit('resolved', steps[steps.length - 1], id);
175
+ return { outcome: 'approved', transactionId: id, response: status, steps: [...steps] };
176
+ }
177
+ if (isApproved(status)) {
178
+ if (firstQuery) {
179
+ steps.push('Terminal: Originalzahlung noch unveraendert (0) -- die Aufhebung koennte noch unterwegs sein, wird erneut abgefragt');
180
+ return null;
181
+ }
182
+ steps.push('Terminal: Originalzahlung steht unveraendert (0) -- die Aufhebung hat nicht gegriffen');
183
+ emit('resolved', steps[steps.length - 1], id);
184
+ return { outcome: 'declined', transactionId: id, response: status, steps: [...steps] };
185
+ }
186
+ return null;
187
+ }
188
+ function open(id, steps) {
189
+ emit('resolved', steps[steps.length - 1], id);
190
+ return { outcome: 'unresolved', transactionId: id, steps: [...steps] };
191
+ }
192
+ /**
193
+ * Fuehrt [call] aus, aber hoechstens so lange, wie vom [resolveBudgetMs]
194
+ * uebrig ist -- sonst waere die Klaerung nicht durch das Budget begrenzt,
195
+ * sondern durch das (deutlich groessere) Zeitlimit des Connect-Clients je
196
+ * einzelnem Aufruf. [cap] deckelt zusaetzlich einen einzelnen Schritt
197
+ * (siehe [abortBudgetMs]).
198
+ */
199
+ function withinBudget(elapsedMs, call, cap) {
200
+ let left = resolveBudgetMs - elapsedMs();
201
+ if (cap !== undefined && cap < left)
202
+ left = cap;
203
+ if (left <= 0) {
204
+ return Promise.reject(new HpsClarifyTimeoutError(resolveBudgetMs));
205
+ }
206
+ return withTimeout(call(), left);
207
+ }
208
+ function withTimeout(promise, ms) {
209
+ return new Promise((resolve, reject) => {
210
+ const timer = setTimeout(() => reject(new HpsClarifyTimeoutError(ms)), ms);
211
+ promise.then((v) => {
212
+ clearTimeout(timer);
213
+ resolve(v);
214
+ }, (e) => {
215
+ clearTimeout(timer);
216
+ reject(e);
217
+ });
218
+ });
219
+ }
220
+ function nextWait(current) {
221
+ if (current === 0)
222
+ return 1000;
223
+ const doubled = current * 2;
224
+ return doubled > maxBackoffMs ? maxBackoffMs : doubled;
225
+ }
226
+ /**
227
+ * Versucht den Abbruch GENAU EINMAL. Liefert ein Ergebnis nur im einen
228
+ * beweisbaren Fall: Connect quittiert den Abbruch mit `responseCode === '0'`.
229
+ * In jedem anderen Fall `null` -- der Aufrufer pollt dann weiter.
230
+ */
231
+ async function tryAbort(id, steps, elapsedMs) {
232
+ let res;
233
+ try {
234
+ res = await withinBudget(elapsedMs, () => client.abort({ ...target, transactionId: id }), abortBudgetMs);
235
+ }
236
+ catch (e) {
237
+ steps.push(`Abbruch nicht bestaetigt (${describe(e)}) -- ob er wirkte, ist offen, Ausgang wird abgefragt`);
238
+ noteUnexpected(e, id);
239
+ return null;
240
+ }
241
+ if (res.responseCode === undefined) {
242
+ steps.push('Abbruch ohne Ergebniscode quittiert -- das beweist nichts, Ausgang wird abgefragt');
243
+ return null;
244
+ }
245
+ if (res.responseCode !== '0') {
246
+ steps.push(isNotAbortable(res)
247
+ ? `Abbruch abgelehnt (${NOT_ABORTABLE_CODE}) -- der Vorgang ist nicht mehr abbrechbar, Ausgang wird abgefragt`
248
+ : `Abbruch abgelehnt (${res.responseCode}) -- Grund unbekannt, Ausgang wird abgefragt`);
249
+ return null;
250
+ }
251
+ steps.push('Abbruch bestaetigt -- der Vorgang war noch abbrechbar, es ist nichts belastet');
252
+ emit('resolved', steps[steps.length - 1], id);
253
+ return { outcome: 'declined', transactionId: id, response: res, steps: [...steps] };
254
+ }
255
+ /** Klaert einen offenen Ausgang: erst abbrechen, dann abfragen -- bis das Terminal etwas sagt oder das Budget aufgebraucht ist. */
256
+ /**
257
+ * `antwortMitCode` heisst: das Terminal hat auf die ERZEUGENDE Anfrage eine
258
+ * Antwort MIT Ergebniscode geliefert, deren Bedeutung wir nur nicht kennen.
259
+ * Dann ist der Vorgang am Geraet abgeschlossen -- und erst dadurch bekommt
260
+ * [NO_STATEMENT_CODE] (`9027`) beim Pollen einen Aussagewert, den es sonst
261
+ * nicht hat. Siehe [ausGeschlossenerAntwort].
262
+ */
263
+ async function resolve(id, steps, antwortMitCode = false) {
264
+ emit('resolving', 'Ausgang offen, Klaerung laeuft', id);
265
+ const start = now();
266
+ const elapsedMs = () => now() - start;
267
+ const aborted = await tryAbort(id, steps, elapsedMs);
268
+ if (aborted)
269
+ return aborted;
270
+ let wait = 0;
271
+ let transportFailures = 0;
272
+ let ohneAuskunft = 0;
273
+ while (elapsedMs() < resolveBudgetMs) {
274
+ if (wait > 0) {
275
+ const left = resolveBudgetMs - elapsedMs();
276
+ await sleep(Math.min(wait, left));
277
+ if (elapsedMs() >= resolveBudgetMs)
278
+ break;
279
+ }
280
+ let status;
281
+ try {
282
+ status = await withinBudget(elapsedMs, () => client.status({ ...target, transactionId: id }));
283
+ transportFailures = 0;
284
+ }
285
+ catch (e) {
286
+ transportFailures += 1;
287
+ steps.push(`Statusabfrage gescheitert (${transportFailures}): ${describe(e)}`);
288
+ noteUnexpected(e, id);
289
+ if (transportFailures >= maxTransportFailures) {
290
+ steps.push('Terminal antwortet nicht -- Ausgang bleibt offen');
291
+ break;
292
+ }
293
+ wait = nextWait(wait);
294
+ continue;
295
+ }
296
+ const settled = fromResponse(status, id, steps);
297
+ if (settled)
298
+ return settled;
299
+ if (isNoStatement(status)) {
300
+ ohneAuskunft += 1;
301
+ const geklaert = ausGeschlossenerAntwort(id, steps, status, antwortMitCode, ohneAuskunft);
302
+ if (geklaert)
303
+ return geklaert;
304
+ }
305
+ else {
306
+ ohneAuskunft = 0;
307
+ }
308
+ steps.push(statusOhneErgebnis(status) ?? 'Status: noch kein Ergebniscode');
309
+ wait = nextWait(wait);
310
+ }
311
+ steps.push('Ausgang bleibt offen');
312
+ return open(id, steps);
313
+ }
314
+ /**
315
+ * Liest `9027` beim Pollen als "nicht genehmigt" -- aber NUR, wenn das
316
+ * Terminal die erzeugende Anfrage bereits mit einem Ergebniscode beantwortet
317
+ * hat, und erst ab der ZWEITEN Abfrage in Folge.
318
+ *
319
+ * **Warum das ueberhaupt geht.** [NO_STATEMENT_CODE] ist sonst eine reine
320
+ * Nicht-Aussage: dieselbe `9027` steht fuer einen laufenden, einen
321
+ * abgebrochenen und einen nie gesehenen Vorgang. Hat das Terminal aber eine
322
+ * Antwort MIT Code geliefert, faellt "laeuft noch" weg -- der Vorgang ist
323
+ * dort beendet -- und "nie gesehen" ebenso, denn zu genau dieser Kennung
324
+ * wurde uns gerade geantwortet. Uebrig bleibt "beendet und nicht genehmigt".
325
+ *
326
+ * **Gegenprobe** (28.08.2026, TID 3600335, jeweils nach abgeschlossenem
327
+ * Vorgang): genehmigt (Beleg 408811) -> Statusabfrage `0`, dreimal
328
+ * wiederholt; abgelehnt mit `100003` -> `9027`, zweimal; abgelehnt mit
329
+ * `9003` -> `9027`. Eine genehmigte Zahlung antwortet also nicht `9027`.
330
+ *
331
+ * **Warum erst ab der zweiten Abfrage.** Die erste laeuft unmittelbar nach
332
+ * der Antwort -- das Fenster, in dem der Datensatz am Terminal noch nicht
333
+ * stehen koennte. Waere er es nicht und wir lesen `9027` als "abgelehnt",
334
+ * entstuende die Doppelbelastung vom 24.08.2026 an einer neuen Stelle.
335
+ * Dieselbe Absicherung traegt bereits [fromCancelStatus].
336
+ *
337
+ * `undefined` heisst: nicht entschieden, weiter pollen.
338
+ */
339
+ function ausGeschlossenerAntwort(id, steps, status, antwortMitCode, ohneAuskunft) {
340
+ if (!antwortMitCode)
341
+ return undefined;
342
+ if (ohneAuskunft < 2)
343
+ return undefined;
344
+ steps.push(`Statusabfrage zweimal ohne Auskunft (${status.responseCode}), obwohl das `
345
+ + 'Terminal den Vorgang bereits beantwortet hatte -- er ist beendet und '
346
+ + 'nicht genehmigt, es ist nichts belastet');
347
+ emit('resolved', steps[steps.length - 1], id);
348
+ return {
349
+ outcome: 'declined',
350
+ transactionId: id,
351
+ response: status,
352
+ steps: [...steps],
353
+ };
354
+ }
355
+ /**
356
+ * Klaert eine offene Aufhebung -- eigene Fassung statt [resolve], weil die
357
+ * Kennung hier die der URSPRUENGLICHEN Zahlung ist.
358
+ *
359
+ * Zwei Unterschiede zu [resolve]:
360
+ *
361
+ * 1. Die Antwort der Statusabfrage wird ueber [fromCancelStatus]
362
+ * eingeordnet, NICHT ueber [fromResponse] -- der `responseCode` der
363
+ * Originalkennung bedeutet hier etwas anderes als bei [pay]/[refund].
364
+ * 2. KEIN [tryAbort]-Versuch. Der Abbruch greift nur, solange ein Vorgang
365
+ * noch abbrechbar ist; die Originalzahlung, deren Kennung hier vorliegt,
366
+ * ist laengst abgeschlossen und antwortet gemessen mit `100010`. Ein
367
+ * Abbruchversuch darauf waere sinnlos und koennte hoechstens fehlleiten.
368
+ *
369
+ * Budget, Backoff und Transportfehler-Deckelung sind unveraendert aus
370
+ * [resolve] uebernommen.
371
+ */
372
+ async function resolveCancel(id, steps) {
373
+ emit('resolving', 'Ausgang offen, Klaerung laeuft', id);
374
+ const start = now();
375
+ const elapsedMs = () => now() - start;
376
+ let wait = 0;
377
+ let transportFailures = 0;
378
+ // Zaehlt nur BEANTWORTETE Statusabfragen -- Grundlage der Karenz fuer den
379
+ // `'0'`-Fall, siehe [fromCancelStatus].
380
+ let answeredQueries = 0;
381
+ while (elapsedMs() < resolveBudgetMs) {
382
+ if (wait > 0) {
383
+ const left = resolveBudgetMs - elapsedMs();
384
+ await sleep(Math.min(wait, left));
385
+ if (elapsedMs() >= resolveBudgetMs)
386
+ break;
387
+ }
388
+ let status;
389
+ try {
390
+ status = await withinBudget(elapsedMs, () => client.status({ ...target, transactionId: id }));
391
+ transportFailures = 0;
392
+ answeredQueries += 1;
393
+ }
394
+ catch (e) {
395
+ transportFailures += 1;
396
+ steps.push(`Statusabfrage gescheitert (${transportFailures}): ${describe(e)}`);
397
+ noteUnexpected(e, id);
398
+ if (transportFailures >= maxTransportFailures) {
399
+ steps.push('Terminal antwortet nicht -- Ausgang bleibt offen');
400
+ break;
401
+ }
402
+ wait = nextWait(wait);
403
+ continue;
404
+ }
405
+ const settled = fromCancelStatus(status, id, steps, answeredQueries === 1);
406
+ if (settled)
407
+ return settled;
408
+ // Der `'0'`-Karenzfall hat seinen eigenen, aussagekraeftigeren Eintrag
409
+ // schon in [fromCancelStatus] gesetzt.
410
+ if (!isApproved(status)) {
411
+ steps.push(statusOhneErgebnis(status) ?? 'Status: Aufhebung noch nicht bestaetigt');
412
+ }
413
+ wait = nextWait(wait);
414
+ }
415
+ steps.push('Ausgang bleibt offen');
416
+ return open(id, steps);
417
+ }
418
+ async function pay(paymentOptions) {
419
+ const id = paymentOptions.transactionId ?? newHpsTransactionId();
420
+ const steps = [];
421
+ // Das try liegt bewusst ENG um den Netzweg: was danach kommt, ist unser
422
+ // eigenes Auswerten und soll nicht stillschweigend als "Terminal hat
423
+ // nicht geantwortet" durchgehen.
424
+ let res;
425
+ try {
426
+ res = await client.payment({
427
+ ...target,
428
+ transactionId: id,
429
+ amountCents: paymentOptions.amountCents,
430
+ tipCents: paymentOptions.tipCents,
431
+ reference: paymentOptions.reference,
432
+ currency: paymentOptions.currency,
433
+ language: paymentOptions.language,
434
+ });
435
+ }
436
+ catch (e) {
437
+ if (e instanceof HpsPreflightError) {
438
+ // Beweisbar nichts gesendet -- kein Ausgang, sondern ein Aufruffehler.
439
+ // Wird unveraendert weitergeworfen, damit er sichtbar bleibt statt als
440
+ // offener Ausgang zu enden (Zwilling: Dart's `ArgumentError`-Zweig).
441
+ throw e;
442
+ }
443
+ const busy = fromTerminalBusy(e, id, steps);
444
+ if (busy)
445
+ return busy;
446
+ steps.push(`Zahlung abgebrochen: ${describe(e)}`);
447
+ noteUnexpected(e, id);
448
+ }
449
+ if (res) {
450
+ const settled = fromResponse(res, id, steps);
451
+ if (settled)
452
+ return settled;
453
+ steps.push(offeneAntwort(res));
454
+ }
455
+ return resolve(id, steps, res?.responseCode !== undefined);
456
+ }
457
+ /**
458
+ * Gutschrift mit geklaertem Ausgang -- EXAKT derselbe Klaerweg wie [pay]
459
+ * (Abbruch eingeschlossen), siehe Klassendoku oben. [transactionId] ist die
460
+ * Kennung des NEUEN Vorgangs (der Gutschrift selbst), nicht die der
461
+ * Zahlung, auf die sie sich ueber [HpsRefundOptions.originalTransactionId]
462
+ * referenziert.
463
+ */
464
+ async function refund(refundOptions) {
465
+ const id = refundOptions.transactionId ?? newHpsTransactionId();
466
+ const steps = [];
467
+ // Das try liegt bewusst ENG um den Netzweg -- siehe Begruendung in [pay].
468
+ let res;
469
+ try {
470
+ res = await client.refund({
471
+ ...target,
472
+ transactionId: id,
473
+ originalTransactionId: refundOptions.originalTransactionId,
474
+ amountCents: refundOptions.amountCents,
475
+ reference: refundOptions.reference,
476
+ currency: refundOptions.currency,
477
+ language: refundOptions.language,
478
+ });
479
+ }
480
+ catch (e) {
481
+ if (e instanceof HpsPreflightError) {
482
+ throw e;
483
+ }
484
+ const busy = fromTerminalBusy(e, id, steps);
485
+ if (busy)
486
+ return busy;
487
+ steps.push(`Gutschrift abgebrochen: ${describe(e)}`);
488
+ noteUnexpected(e, id);
489
+ }
490
+ if (res) {
491
+ const settled = fromResponse(res, id, steps);
492
+ if (settled)
493
+ return settled;
494
+ steps.push(offeneAntwort(res));
495
+ }
496
+ // Dieselbe Klaerfunktion wie [pay]: die Kennung ist die des NEUEN
497
+ // Vorgangs, eine Statusabfrage darauf liefert also genau dessen Ausgang,
498
+ // und der Abbruch ist derselbe Diskriminator wie bei einer Zahlung.
499
+ return resolve(id, steps, res?.responseCode !== undefined);
500
+ }
501
+ /**
502
+ * Aufhebung (Storno/Void) einer bestehenden Zahlung mit geklaertem Ausgang.
503
+ *
504
+ * [transactionId] ist die vom TERMINAL vergebene Kennung der
505
+ * URSPRUENGLICHEN Zahlung -- nicht die eines neuen Vorgangs. Der direkte
506
+ * Antwortweg wird trotzdem ueber [fromCancelResponse] eingeordnet: die
507
+ * Direktantwort auf einen Aufhebungs-Request traegt einen eigenen
508
+ * `responseCode` fuer die Aufhebung selbst. Erst wenn dieser direkte Weg
509
+ * abbricht und nachgefragt werden muss, aendert sich die Frage -- siehe
510
+ * [resolveCancel].
511
+ */
512
+ async function cancel(cancelOptions) {
513
+ const id = cancelOptions.transactionId;
514
+ const steps = [];
515
+ let res;
516
+ try {
517
+ res = await client.cancel({
518
+ ...target,
519
+ transactionId: id,
520
+ amountCents: cancelOptions.amountCents,
521
+ currency: cancelOptions.currency,
522
+ language: cancelOptions.language,
523
+ });
524
+ }
525
+ catch (e) {
526
+ if (e instanceof HpsPreflightError) {
527
+ throw e;
528
+ }
529
+ const busy = fromTerminalBusy(e, id, steps);
530
+ if (busy)
531
+ return busy;
532
+ steps.push(`Aufhebung abgebrochen: ${describe(e)}`);
533
+ noteUnexpected(e, id);
534
+ }
535
+ if (res) {
536
+ const settled = fromCancelResponse(res, id, steps);
537
+ if (settled)
538
+ return settled;
539
+ // Kein Sammel-Eintrag fuer 9011: [fromCancelResponse] hat dafuer
540
+ // bereits den zutreffenden Eintrag gesetzt.
541
+ if (!isCanceled(res))
542
+ steps.push(offeneAntwort(res));
543
+ }
544
+ return resolveCancel(id, steps);
545
+ }
546
+ return { pay, refund, cancel };
547
+ }
@@ -0,0 +1,28 @@
1
+ import type { HobexReceipt } from '../../models/hobex-receipt.js';
2
+ import type { HpsTransactionResponse } from './transaction-response.js';
3
+ /**
4
+ * Macht aus der Terminal-Antwort den Kasseneck-Beleg -- Zwilling von
5
+ * `HobexReceipt.fromHps` (kasseneck_api, `lib/models/hobex_receipt.dart`).
6
+ *
7
+ * Ohne diese Bruecke endet eine gelungene HPS-Zahlung im Nichts: der
8
+ * Buchungsweg erwartet einen Beleg (`cardPaymentData`, siehe
9
+ * [hobexReceiptToCardPaymentData]), und die Antwort des Terminals ist keiner.
10
+ *
11
+ * Drei Eigenheiten, alle am 28.08.2026 am Terminal 3600335 gemessen und
12
+ * nicht aus der Doku uebernommen:
13
+ *
14
+ * 1. **`cvm` kommt als ZAHL** (`3`), nicht als Text. Ungewandelt scheitert
15
+ * [hobexReceiptNeedsSignature] (`=== '1'`) stumm an einer `1` -- und eine
16
+ * verlangte Unterschrift bliebe ungefragt. Deshalb ueber [text].
17
+ * 2. **Nicht gefuehrte Felder kommen als `null`** (gemessen: `currency`,
18
+ * `reference`, `source`, `state`). Sie werden zu LEEREM Text, nie zu
19
+ * `"null"` -- sonst stuende das Wort auf dem Beleg.
20
+ * 3. `cvm` wird aus [HpsTransactionResponse.raw] gelesen und nicht aus einem
21
+ * eigenen Feld -- genauso wie im Dart-Vorbild (`res.raw['cvm']`). Der
22
+ * Rohsatz ist die Quelle, damit hier nichts eigenes danebenlaeuft.
23
+ *
24
+ * Der Betrag geht ueber [euroToCents], die eine gehaertete Stelle des Pakets:
25
+ * die Terminal-Antwort ist eine fremde Antwort, und ein nicht-endlicher Wert
26
+ * ergaebe sonst lautlos `NaN`.
27
+ */
28
+ export declare function hobexReceiptFromHps(res: HpsTransactionResponse): HobexReceipt;
@@ -0,0 +1,57 @@
1
+ import { CreditCardProvider } from '../../enums/index.js';
2
+ import { euroToCents } from '../../money.js';
3
+ /**
4
+ * Macht aus der Terminal-Antwort den Kasseneck-Beleg -- Zwilling von
5
+ * `HobexReceipt.fromHps` (kasseneck_api, `lib/models/hobex_receipt.dart`).
6
+ *
7
+ * Ohne diese Bruecke endet eine gelungene HPS-Zahlung im Nichts: der
8
+ * Buchungsweg erwartet einen Beleg (`cardPaymentData`, siehe
9
+ * [hobexReceiptToCardPaymentData]), und die Antwort des Terminals ist keiner.
10
+ *
11
+ * Drei Eigenheiten, alle am 28.08.2026 am Terminal 3600335 gemessen und
12
+ * nicht aus der Doku uebernommen:
13
+ *
14
+ * 1. **`cvm` kommt als ZAHL** (`3`), nicht als Text. Ungewandelt scheitert
15
+ * [hobexReceiptNeedsSignature] (`=== '1'`) stumm an einer `1` -- und eine
16
+ * verlangte Unterschrift bliebe ungefragt. Deshalb ueber [text].
17
+ * 2. **Nicht gefuehrte Felder kommen als `null`** (gemessen: `currency`,
18
+ * `reference`, `source`, `state`). Sie werden zu LEEREM Text, nie zu
19
+ * `"null"` -- sonst stuende das Wort auf dem Beleg.
20
+ * 3. `cvm` wird aus [HpsTransactionResponse.raw] gelesen und nicht aus einem
21
+ * eigenen Feld -- genauso wie im Dart-Vorbild (`res.raw['cvm']`). Der
22
+ * Rohsatz ist die Quelle, damit hier nichts eigenes danebenlaeuft.
23
+ *
24
+ * Der Betrag geht ueber [euroToCents], die eine gehaertete Stelle des Pakets:
25
+ * die Terminal-Antwort ist eine fremde Antwort, und ein nicht-endlicher Wert
26
+ * ergaebe sonst lautlos `NaN`.
27
+ */
28
+ export function hobexReceiptFromHps(res) {
29
+ return {
30
+ transactionId: text(res.transactionId),
31
+ tid: text(res.tid),
32
+ receipt: text(res.receipt),
33
+ approvalCode: text(res.approvalCode),
34
+ reference: res.reference,
35
+ // Bruchteilssekunden samt Zeitzonenversatz kappen und "T" durch ein
36
+ // Leerzeichen ersetzen -- Wort fuer Wort wie im Dart-Vorbild.
37
+ transactionDate: text(res.transactionDate).split('.')[0].split('T').join(' '),
38
+ cardNumber: text(res.cardNumber),
39
+ cardExpiry: text(res.cardExpiry),
40
+ brand: text(res.brand),
41
+ cardIssuer: text(res.cardIssuer),
42
+ responseCode: text(res.responseCode),
43
+ transactionType: text(res.transactionType),
44
+ currency: text(res.currency),
45
+ amountCents: euroToCents(res.amount ?? 0),
46
+ tipCents: euroToCents(res.tip ?? 0),
47
+ cvm: text(res.raw.cvm),
48
+ // Der Provider ergibt sich aus dem Weg, nicht aus dem JSON: was hier
49
+ // ankommt, kam ueber HPS. Genau daran haengt, ob
50
+ // [hobexReceiptToCardPaymentData] die HPS-Zusatzfelder mitgibt.
51
+ creditCardProvider: CreditCardProvider.hobexHps,
52
+ };
53
+ }
54
+ /** `null`/`undefined` werden zu leerem Text, alles andere wortgetreu. */
55
+ function text(wert) {
56
+ return wert === null || wert === undefined ? '' : String(wert);
57
+ }