@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.
- package/CHANGELOG.md +154 -0
- package/README.md +158 -2
- package/dist/cjs/client/aufrufe.d.ts +1 -1
- package/dist/cjs/client/aufrufe.js +20 -0
- package/dist/cjs/client/errors.d.ts +31 -1
- package/dist/cjs/client/errors.js +98 -1
- package/dist/cjs/client/transport.js +7 -7
- package/dist/cjs/kasse/index.d.ts +1 -0
- package/dist/cjs/kasse/index.js +3 -0
- package/dist/cjs/kasse/trinkgeld.d.ts +10 -0
- package/dist/cjs/kasse/trinkgeld.js +27 -0
- package/dist/cjs/partner/ablauf.d.ts +47 -0
- package/dist/cjs/partner/ablauf.js +99 -0
- package/dist/cjs/partner/api.d.ts +68 -0
- package/dist/cjs/partner/api.js +44 -0
- package/dist/cjs/partner/auth.d.ts +33 -0
- package/dist/cjs/partner/auth.js +52 -0
- package/dist/cjs/partner/betrieb.d.ts +48 -0
- package/dist/cjs/partner/betrieb.js +136 -0
- package/dist/cjs/partner/endpunkte.d.ts +142 -0
- package/dist/cjs/partner/endpunkte.js +488 -0
- package/dist/cjs/partner/fehler.d.ts +72 -0
- package/dist/cjs/partner/fehler.js +199 -0
- package/dist/cjs/partner/index.d.ts +28 -0
- package/dist/cjs/partner/index.js +84 -0
- package/dist/cjs/partner/secret.d.ts +66 -0
- package/dist/cjs/partner/secret.js +95 -0
- package/dist/cjs/partner/typen.d.ts +399 -0
- package/dist/cjs/partner/typen.js +33 -0
- package/dist/cjs/partner/webhook-signatur.d.ts +95 -0
- package/dist/cjs/partner/webhook-signatur.js +158 -0
- package/dist/cjs/partner/webhooks.d.ts +211 -0
- package/dist/cjs/partner/webhooks.js +269 -0
- package/dist/cjs/register/pairing.d.ts +8 -1
- package/dist/cjs/register/pairing.js +8 -1
- package/dist/esm/client/aufrufe.d.ts +1 -1
- package/dist/esm/client/aufrufe.js +20 -0
- package/dist/esm/client/errors.d.ts +31 -1
- package/dist/esm/client/errors.js +97 -1
- package/dist/esm/client/transport.js +8 -8
- package/dist/esm/kasse/index.d.ts +1 -0
- package/dist/esm/kasse/index.js +1 -0
- package/dist/esm/kasse/trinkgeld.d.ts +10 -0
- package/dist/esm/kasse/trinkgeld.js +24 -0
- package/dist/esm/partner/ablauf.d.ts +47 -0
- package/dist/esm/partner/ablauf.js +95 -0
- package/dist/esm/partner/api.d.ts +68 -0
- package/dist/esm/partner/api.js +41 -0
- package/dist/esm/partner/auth.d.ts +33 -0
- package/dist/esm/partner/auth.js +48 -0
- package/dist/esm/partner/betrieb.d.ts +48 -0
- package/dist/esm/partner/betrieb.js +132 -0
- package/dist/esm/partner/endpunkte.d.ts +142 -0
- package/dist/esm/partner/endpunkte.js +474 -0
- package/dist/esm/partner/fehler.d.ts +72 -0
- package/dist/esm/partner/fehler.js +189 -0
- package/dist/esm/partner/index.d.ts +28 -0
- package/dist/esm/partner/index.js +27 -0
- package/dist/esm/partner/secret.d.ts +66 -0
- package/dist/esm/partner/secret.js +90 -0
- package/dist/esm/partner/typen.d.ts +399 -0
- package/dist/esm/partner/typen.js +30 -0
- package/dist/esm/partner/webhook-signatur.d.ts +95 -0
- package/dist/esm/partner/webhook-signatur.js +153 -0
- package/dist/esm/partner/webhooks.d.ts +211 -0
- package/dist/esm/partner/webhooks.js +257 -0
- package/dist/esm/register/pairing.d.ts +8 -1
- package/dist/esm/register/pairing.js +8 -1
- package/fixtures/oberflaeche.json +132 -3
- package/package.json +13 -1
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhooks: Endpunkte verwalten und eingehende Zustellungen auswerten.
|
|
3
|
+
*
|
|
4
|
+
* Die Signaturpruefung liegt daneben in `webhook-signatur.ts` — sie braucht
|
|
5
|
+
* weder Transport noch Schluessel und laeuft in jedem Empfaenger, auch in
|
|
6
|
+
* einem, der sonst nichts von diesem Paket benutzt.
|
|
7
|
+
*/
|
|
8
|
+
import type { InternerTransport } from '../client/aufrufe.js';
|
|
9
|
+
import { type VerifyWebhookOptions, type WebhookVerifyReason } from './webhook-signatur.js';
|
|
10
|
+
/**
|
|
11
|
+
* Alle Ereignisse, die ein Webhook abonnieren **und proben** kann. Ein
|
|
12
|
+
* Endpunkt bekommt ausschliesslich die, die in seiner `events`-Liste stehen.
|
|
13
|
+
*
|
|
14
|
+
* Kasseneck fuehrt daneben interne Ereignisse (etwa den Abschluss eines
|
|
15
|
+
* Auftragsverarbeitungsvertrags). Sie stehen hier bewusst nicht: sie lassen
|
|
16
|
+
* sich weder abonnieren noch mit [sendPartnerWebhookTest] ausloesen, und ein
|
|
17
|
+
* Name in dieser Liste, den niemand bestellen kann, waere ein Versprechen ohne
|
|
18
|
+
* Deckung.
|
|
19
|
+
*/
|
|
20
|
+
export declare const PARTNER_WEBHOOK_EVENTS: readonly ["customer.created", "customer.updated", "customer.status_changed", "customer.fon_verified", "customer.live_enabled", "signature.requested", "signature.ready", "signature.failed", "cashregister.created", "cashregister.live", "cashregister.failed", "app.version.accepted", "app.version.rejected", "webhook.test"];
|
|
21
|
+
export type PartnerWebhookEventType = typeof PARTNER_WEBHOOK_EVENTS[number];
|
|
22
|
+
export declare function istPartnerWebhookEvent(wert: unknown): wert is PartnerWebhookEventType;
|
|
23
|
+
/**
|
|
24
|
+
* Die Felder des Umschlags, so wie er auf der Leitung liegt.
|
|
25
|
+
*
|
|
26
|
+
* Als Liste, damit der Zwilling sie nachhaelt: `test` kam spaeter dazu, und
|
|
27
|
+
* genau ein solches Feld verschwindet sonst auf einer Seite, ohne dass etwas
|
|
28
|
+
* rot wird.
|
|
29
|
+
*/
|
|
30
|
+
export declare const WEBHOOK_UMSCHLAG_FELDER: readonly ["id", "type", "createdAt", "partnerId", "test", "data"];
|
|
31
|
+
/**
|
|
32
|
+
* Die Huelle jeder Zustellung. `type` bleibt bewusst offen fuer unbekannte
|
|
33
|
+
* Werte (`(string & {})`): ein spaeter ergaenztes Ereignis soll einen
|
|
34
|
+
* Empfaenger nicht zum Absturz bringen, sondern in seinem `default`-Zweig
|
|
35
|
+
* landen.
|
|
36
|
+
*/
|
|
37
|
+
export interface PartnerWebhookEvent<T = Record<string, unknown>> {
|
|
38
|
+
/** `evt_…` — die Kennung, auf die **entdoppelt** wird. */
|
|
39
|
+
id: string;
|
|
40
|
+
type: PartnerWebhookEventType | (string & {});
|
|
41
|
+
createdAt: number;
|
|
42
|
+
partnerId: string;
|
|
43
|
+
/**
|
|
44
|
+
* **Probe oder Ernstfall.** Eine mit [sendPartnerWebhookTest] ausgeloeste
|
|
45
|
+
* Zustellung traegt `test: true` im Umschlag; ein echtes Ereignis fuehrt das
|
|
46
|
+
* Feld gar nicht, hier steht dann `false`.
|
|
47
|
+
*
|
|
48
|
+
* Diese Zeile gehoert an den Anfang jedes Handlers:
|
|
49
|
+
*
|
|
50
|
+
* ```ts
|
|
51
|
+
* if (ereignis.test) return;
|
|
52
|
+
* ```
|
|
53
|
+
*
|
|
54
|
+
* Ohne sie haelt jemand eine Probe fuer echt und schreibt seinem Kunden, die
|
|
55
|
+
* Kasse sei fertig. Eine Probe traegt eine erkennbar erfundene Nutzlast — nur
|
|
56
|
+
* sieht man das erst, wenn man hinsieht.
|
|
57
|
+
*/
|
|
58
|
+
test: boolean;
|
|
59
|
+
data: T;
|
|
60
|
+
}
|
|
61
|
+
export type WebhookEventResult = {
|
|
62
|
+
ok: true;
|
|
63
|
+
event: PartnerWebhookEvent;
|
|
64
|
+
timestampSec: number;
|
|
65
|
+
} | {
|
|
66
|
+
ok: false;
|
|
67
|
+
reason: WebhookVerifyReason | 'body-not-json' | 'body-not-event';
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* Prueft die Signatur **und** liest das Ereignis — in dieser Reihenfolge. Wer
|
|
71
|
+
* zuerst parst und dann prueft, hat den fremden Rumpf schon durch seinen Code
|
|
72
|
+
* laufen lassen.
|
|
73
|
+
*
|
|
74
|
+
* **Entdoppeln nicht vergessen:** dieselbe Zustellung kann mehrfach ankommen
|
|
75
|
+
* (Wiederholung nach einer Antwort, die unterwegs verloren ging). Massgeblich
|
|
76
|
+
* ist `event.id`, nicht der Kopf `X-Kasseneck-Delivery` — der ist bei
|
|
77
|
+
* Wiederholungen derselbe, aber er beantwortet die Frage "habe ich dieses
|
|
78
|
+
* Ereignis schon verarbeitet?" nur fuer genau diesen Endpunkt.
|
|
79
|
+
*
|
|
80
|
+
* Antworte innerhalb von 10 Sekunden mit 2xx und erledige die Arbeit danach.
|
|
81
|
+
* Eine ausbleibende Antwort wird bis zu fuenfmal wiederholt (1 min, 5 min,
|
|
82
|
+
* 30 min, 2 h, 12 h) und gilt dann als fehlgeschlagen.
|
|
83
|
+
*/
|
|
84
|
+
export declare function parseWebhookEvent(optionen: VerifyWebhookOptions): Promise<WebhookEventResult>;
|
|
85
|
+
export interface PartnerWebhook {
|
|
86
|
+
webhookId: string;
|
|
87
|
+
url: string;
|
|
88
|
+
events: string[];
|
|
89
|
+
active: boolean;
|
|
90
|
+
description: string | null;
|
|
91
|
+
createdAt: number | null;
|
|
92
|
+
lastDelivery: number | null;
|
|
93
|
+
/** Fehlversuche in Folge — steigt der Wert, stimmt beim Empfaenger etwas nicht. */
|
|
94
|
+
consecutiveFailures: number;
|
|
95
|
+
}
|
|
96
|
+
export interface CreateWebhookOptions {
|
|
97
|
+
/** Vollstaendige `https://`-Adresse. */
|
|
98
|
+
url: string;
|
|
99
|
+
/** Mindestens eines; unbekannte Namen lehnt das Backend ab. */
|
|
100
|
+
events: (PartnerWebhookEventType | (string & {}))[];
|
|
101
|
+
/** Hoechstens 120 Zeichen. */
|
|
102
|
+
description?: string;
|
|
103
|
+
active?: boolean;
|
|
104
|
+
}
|
|
105
|
+
export interface CreateWebhookResult {
|
|
106
|
+
webhook: PartnerWebhook;
|
|
107
|
+
/**
|
|
108
|
+
* Das Secret fuer die Signaturpruefung. **Es kommt genau einmal** — beim
|
|
109
|
+
* Anlegen. Danach gibt das Backend es nie wieder aus; wer es verliert, legt
|
|
110
|
+
* einen neuen Endpunkt an.
|
|
111
|
+
*
|
|
112
|
+
* Bewusst ein `string` und kein [KasseneckSecret]: es gehoert dem Partner
|
|
113
|
+
* selbst und nicht einem fremden Betrieb, und es muss unveraendert in die
|
|
114
|
+
* eigene Konfiguration wandern. Verschluesselt speichern gilt trotzdem.
|
|
115
|
+
*/
|
|
116
|
+
secret: string;
|
|
117
|
+
}
|
|
118
|
+
export interface WebhookPatch {
|
|
119
|
+
url?: string;
|
|
120
|
+
events?: (PartnerWebhookEventType | (string & {}))[];
|
|
121
|
+
description?: string;
|
|
122
|
+
active?: boolean;
|
|
123
|
+
}
|
|
124
|
+
export interface WebhookListe {
|
|
125
|
+
webhooks: PartnerWebhook[];
|
|
126
|
+
/** Der Katalog: Ereignisname und deutscher Text, so wie das Panel ihn zeigt. */
|
|
127
|
+
events: {
|
|
128
|
+
key: string;
|
|
129
|
+
text: string;
|
|
130
|
+
}[];
|
|
131
|
+
}
|
|
132
|
+
export interface WebhookZustellung {
|
|
133
|
+
deliveryId: string;
|
|
134
|
+
webhookId: string;
|
|
135
|
+
event: string;
|
|
136
|
+
eventId: string;
|
|
137
|
+
/** `offen`, `zugestellt` oder `fehlgeschlagen`. */
|
|
138
|
+
status: string;
|
|
139
|
+
attempts: number;
|
|
140
|
+
letzterVersuchAt: number | null;
|
|
141
|
+
naechsterVersuchAt: number | null;
|
|
142
|
+
statusCode: number | null;
|
|
143
|
+
/** Auszug der Antwort des Empfaengers, hoechstens 500 Zeichen. */
|
|
144
|
+
response: string | null;
|
|
145
|
+
createdAt: number | null;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Legt einen Webhook-Endpunkt an. Hoechstens [WEBHOOK_LIMIT] je Partner
|
|
149
|
+
* (`webhook_limit`).
|
|
150
|
+
*
|
|
151
|
+
* **Das Secret in der Antwort ist der einzige Weg dazu.** Es sofort dorthin
|
|
152
|
+
* schreiben, wo der Empfaenger es liest — nicht in ein Protokoll.
|
|
153
|
+
*/
|
|
154
|
+
export declare function createPartnerWebhook(rufen: InternerTransport, optionen: CreateWebhookOptions): Promise<CreateWebhookResult>;
|
|
155
|
+
/**
|
|
156
|
+
* Ein neues Secret fuer denselben Endpunkt.
|
|
157
|
+
*
|
|
158
|
+
* Der Webhook behaelt seine `webhookId` und seine Ereignisse — nur das
|
|
159
|
+
* Geheimnis wechselt. Wer den Endpunkt stattdessen loescht und neu anlegt,
|
|
160
|
+
* verliert seine Zustellungshistorie und muss die Ereignisse neu ankreuzen.
|
|
161
|
+
*
|
|
162
|
+
* Das alte Secret gilt ab der Antwort NICHT mehr: die naechste Zustellung ist
|
|
163
|
+
* schon mit dem neuen signiert. Erst speichern, dann weiterarbeiten.
|
|
164
|
+
*/
|
|
165
|
+
export declare function rotatePartnerWebhookSecret(rufen: InternerTransport, webhookId: string): Promise<CreateWebhookResult>;
|
|
166
|
+
/** Die Webhook-Endpunkte dieses Partners samt Ereignis-Katalog. */
|
|
167
|
+
export declare function listPartnerWebhooks(rufen: InternerTransport): Promise<WebhookListe>;
|
|
168
|
+
/**
|
|
169
|
+
* Aendert einen Endpunkt. Nur die genannten Felder — ein leeres `patch` lehnt
|
|
170
|
+
* das Backend ab (`validation`, Feld `patch`), statt stillschweigend nichts zu
|
|
171
|
+
* tun.
|
|
172
|
+
*/
|
|
173
|
+
export declare function updatePartnerWebhook(rufen: InternerTransport, webhookId: string, patch: WebhookPatch): Promise<PartnerWebhook>;
|
|
174
|
+
/** Loescht einen Endpunkt. Danach kommt dort nichts mehr an. */
|
|
175
|
+
export declare function deletePartnerWebhook(rufen: InternerTransport, webhookId: string): Promise<string>;
|
|
176
|
+
export interface WebhookTestResult {
|
|
177
|
+
eventId: string;
|
|
178
|
+
/** Welches Ereignis geprobt wurde — ohne Angabe `webhook.test`. */
|
|
179
|
+
ereignis: string;
|
|
180
|
+
deliveries: unknown[];
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Schickt eine Probe an genau diesen Endpunkt.
|
|
184
|
+
*
|
|
185
|
+
* Ohne `event` kommt `webhook.test` — der Nachweis, dass die Leitung steht und
|
|
186
|
+
* die eigene Signaturpruefung gegen echte Bytes laeuft. **Mit `event` kommt
|
|
187
|
+
* genau das Ereignis, das der Empfaenger behandeln soll**, mit einer
|
|
188
|
+
* glaubwuerdigen Nutzlast: wer auf `signature.ready` hin seinen Kunden
|
|
189
|
+
* benachrichtigt, probt das einmal, statt auf eine echte Karte zu warten. Eine
|
|
190
|
+
* Leitungsprobe beweist nichts ueber die Behandlung des Ernstfalls.
|
|
191
|
+
*
|
|
192
|
+
* Der Endpunkt muss das Ereignis abonnieren (`event_not_subscribed`) und aktiv
|
|
193
|
+
* sein (`webhook_inactive`); ein unbekannter Name ist ein `validation`-Fehler
|
|
194
|
+
* auf dem Feld `event`.
|
|
195
|
+
*
|
|
196
|
+
* **Jede Probe traegt `test: true` im Umschlag** ([PartnerWebhookEvent.test]).
|
|
197
|
+
*
|
|
198
|
+
* Der Backend-Endpunkt heisst `sendPartnerWebhookTest`; dieser Client behaelt
|
|
199
|
+
* den Namen bei, damit ein Leser der Doku und ein Leser des Codes dasselbe
|
|
200
|
+
* suchen.
|
|
201
|
+
*/
|
|
202
|
+
export declare function sendPartnerWebhookTest(rufen: InternerTransport, webhookId: string, event?: PartnerWebhookEventType | (string & {})): Promise<WebhookTestResult>;
|
|
203
|
+
/**
|
|
204
|
+
* Die letzten Zustellversuche — mit `webhookId` fuer einen Endpunkt, ohne ihn
|
|
205
|
+
* fuer alle. Die Stelle, an der sich „mein Server bekommt nichts" klaeren
|
|
206
|
+
* laesst, ohne bei Kasseneck nachzufragen.
|
|
207
|
+
*/
|
|
208
|
+
export declare function listPartnerWebhookDeliveries(rufen: InternerTransport, optionen?: {
|
|
209
|
+
webhookId?: string;
|
|
210
|
+
limit?: number;
|
|
211
|
+
}): Promise<WebhookZustellung[]>;
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhooks: Endpunkte verwalten und eingehende Zustellungen auswerten.
|
|
3
|
+
*
|
|
4
|
+
* Die Signaturpruefung liegt daneben in `webhook-signatur.ts` — sie braucht
|
|
5
|
+
* weder Transport noch Schluessel und laeuft in jedem Empfaenger, auch in
|
|
6
|
+
* einem, der sonst nichts von diesem Paket benutzt.
|
|
7
|
+
*/
|
|
8
|
+
import { KasseneckValidationError } from '../client/errors.js';
|
|
9
|
+
import { verifyWebhookSignature, } from './webhook-signatur.js';
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Ereignisse
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
/**
|
|
14
|
+
* Alle Ereignisse, die ein Webhook abonnieren **und proben** kann. Ein
|
|
15
|
+
* Endpunkt bekommt ausschliesslich die, die in seiner `events`-Liste stehen.
|
|
16
|
+
*
|
|
17
|
+
* Kasseneck fuehrt daneben interne Ereignisse (etwa den Abschluss eines
|
|
18
|
+
* Auftragsverarbeitungsvertrags). Sie stehen hier bewusst nicht: sie lassen
|
|
19
|
+
* sich weder abonnieren noch mit [sendPartnerWebhookTest] ausloesen, und ein
|
|
20
|
+
* Name in dieser Liste, den niemand bestellen kann, waere ein Versprechen ohne
|
|
21
|
+
* Deckung.
|
|
22
|
+
*/
|
|
23
|
+
export const PARTNER_WEBHOOK_EVENTS = [
|
|
24
|
+
'customer.created',
|
|
25
|
+
'customer.updated',
|
|
26
|
+
'customer.status_changed',
|
|
27
|
+
'customer.fon_verified',
|
|
28
|
+
'customer.live_enabled',
|
|
29
|
+
'signature.requested',
|
|
30
|
+
'signature.ready',
|
|
31
|
+
'signature.failed',
|
|
32
|
+
'cashregister.created',
|
|
33
|
+
'cashregister.live',
|
|
34
|
+
'cashregister.failed',
|
|
35
|
+
'app.version.accepted',
|
|
36
|
+
'app.version.rejected',
|
|
37
|
+
'webhook.test',
|
|
38
|
+
];
|
|
39
|
+
export function istPartnerWebhookEvent(wert) {
|
|
40
|
+
return typeof wert === 'string' && PARTNER_WEBHOOK_EVENTS.includes(wert);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Die Felder des Umschlags, so wie er auf der Leitung liegt.
|
|
44
|
+
*
|
|
45
|
+
* Als Liste, damit der Zwilling sie nachhaelt: `test` kam spaeter dazu, und
|
|
46
|
+
* genau ein solches Feld verschwindet sonst auf einer Seite, ohne dass etwas
|
|
47
|
+
* rot wird.
|
|
48
|
+
*/
|
|
49
|
+
export const WEBHOOK_UMSCHLAG_FELDER = ['id', 'type', 'createdAt', 'partnerId', 'test', 'data'];
|
|
50
|
+
/**
|
|
51
|
+
* Prueft die Signatur **und** liest das Ereignis — in dieser Reihenfolge. Wer
|
|
52
|
+
* zuerst parst und dann prueft, hat den fremden Rumpf schon durch seinen Code
|
|
53
|
+
* laufen lassen.
|
|
54
|
+
*
|
|
55
|
+
* **Entdoppeln nicht vergessen:** dieselbe Zustellung kann mehrfach ankommen
|
|
56
|
+
* (Wiederholung nach einer Antwort, die unterwegs verloren ging). Massgeblich
|
|
57
|
+
* ist `event.id`, nicht der Kopf `X-Kasseneck-Delivery` — der ist bei
|
|
58
|
+
* Wiederholungen derselbe, aber er beantwortet die Frage "habe ich dieses
|
|
59
|
+
* Ereignis schon verarbeitet?" nur fuer genau diesen Endpunkt.
|
|
60
|
+
*
|
|
61
|
+
* Antworte innerhalb von 10 Sekunden mit 2xx und erledige die Arbeit danach.
|
|
62
|
+
* Eine ausbleibende Antwort wird bis zu fuenfmal wiederholt (1 min, 5 min,
|
|
63
|
+
* 30 min, 2 h, 12 h) und gilt dann als fehlgeschlagen.
|
|
64
|
+
*/
|
|
65
|
+
export async function parseWebhookEvent(optionen) {
|
|
66
|
+
const geprueft = await verifyWebhookSignature(optionen);
|
|
67
|
+
if (!geprueft.ok)
|
|
68
|
+
return { ok: false, reason: geprueft.reason };
|
|
69
|
+
const text = typeof optionen.body === 'string' ? optionen.body : new TextDecoder('utf-8').decode(optionen.body);
|
|
70
|
+
let roh;
|
|
71
|
+
try {
|
|
72
|
+
roh = JSON.parse(text);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return { ok: false, reason: 'body-not-json' };
|
|
76
|
+
}
|
|
77
|
+
if (roh === null || typeof roh !== 'object' || Array.isArray(roh))
|
|
78
|
+
return { ok: false, reason: 'body-not-event' };
|
|
79
|
+
const e = roh;
|
|
80
|
+
if (typeof e['id'] !== 'string' || typeof e['type'] !== 'string')
|
|
81
|
+
return { ok: false, reason: 'body-not-event' };
|
|
82
|
+
return {
|
|
83
|
+
ok: true,
|
|
84
|
+
timestampSec: geprueft.timestampSec,
|
|
85
|
+
event: {
|
|
86
|
+
id: e['id'],
|
|
87
|
+
type: e['type'],
|
|
88
|
+
createdAt: typeof e['createdAt'] === 'number' ? e['createdAt'] : 0,
|
|
89
|
+
partnerId: typeof e['partnerId'] === 'string' ? e['partnerId'] : '',
|
|
90
|
+
// Nur ein ausdrueckliches `true` ist eine Probe. Alles andere — auch ein
|
|
91
|
+
// fehlendes Feld — ist der Ernstfall; im Zweifel lieber einmal zu viel
|
|
92
|
+
// gearbeitet als eine echte Kasse fuer eine Probe gehalten.
|
|
93
|
+
test: e['test'] === true,
|
|
94
|
+
data: e['data'] !== null && typeof e['data'] === 'object' && !Array.isArray(e['data'])
|
|
95
|
+
? e['data']
|
|
96
|
+
: {},
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
function objekt(wert) {
|
|
101
|
+
return wert !== null && typeof wert === 'object' && !Array.isArray(wert) ? wert : {};
|
|
102
|
+
}
|
|
103
|
+
function webhook(eintrag) {
|
|
104
|
+
const w = objekt(eintrag);
|
|
105
|
+
return {
|
|
106
|
+
webhookId: typeof w['webhookId'] === 'string' ? w['webhookId'] : '',
|
|
107
|
+
url: typeof w['url'] === 'string' ? w['url'] : '',
|
|
108
|
+
events: Array.isArray(w['events']) ? w['events'].filter((e) => typeof e === 'string') : [],
|
|
109
|
+
active: w['active'] !== false,
|
|
110
|
+
description: typeof w['description'] === 'string' ? w['description'] : null,
|
|
111
|
+
createdAt: typeof w['createdAt'] === 'number' ? w['createdAt'] : null,
|
|
112
|
+
lastDelivery: typeof w['lastDelivery'] === 'number' ? w['lastDelivery'] : null,
|
|
113
|
+
consecutiveFailures: typeof w['consecutiveFailures'] === 'number' ? w['consecutiveFailures'] : 0,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Legt einen Webhook-Endpunkt an. Hoechstens [WEBHOOK_LIMIT] je Partner
|
|
118
|
+
* (`webhook_limit`).
|
|
119
|
+
*
|
|
120
|
+
* **Das Secret in der Antwort ist der einzige Weg dazu.** Es sofort dorthin
|
|
121
|
+
* schreiben, wo der Empfaenger es liest — nicht in ein Protokoll.
|
|
122
|
+
*/
|
|
123
|
+
export async function createPartnerWebhook(rufen, optionen) {
|
|
124
|
+
const url = typeof optionen?.url === 'string' ? optionen.url.trim() : '';
|
|
125
|
+
if (!url)
|
|
126
|
+
throw new KasseneckValidationError('createPartnerWebhook', 'url fehlt', 'request');
|
|
127
|
+
if (!Array.isArray(optionen.events) || optionen.events.length === 0) {
|
|
128
|
+
throw new KasseneckValidationError('createPartnerWebhook', 'events ist leer — ein Endpunkt ohne Ereignis bekaeme nie etwas', 'request');
|
|
129
|
+
}
|
|
130
|
+
const daten = objekt(await rufen('createPartnerWebhook', {
|
|
131
|
+
url,
|
|
132
|
+
events: optionen.events,
|
|
133
|
+
description: optionen.description,
|
|
134
|
+
active: optionen.active,
|
|
135
|
+
}));
|
|
136
|
+
const secret = daten['secret'];
|
|
137
|
+
if (typeof secret !== 'string' || !secret) {
|
|
138
|
+
throw new KasseneckValidationError('createPartnerWebhook', 'Antwort enthaelt kein secret — ohne es laesst sich keine Zustellung pruefen', 'response');
|
|
139
|
+
}
|
|
140
|
+
return { webhook: webhook(daten['webhook']), secret };
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Ein neues Secret fuer denselben Endpunkt.
|
|
144
|
+
*
|
|
145
|
+
* Der Webhook behaelt seine `webhookId` und seine Ereignisse — nur das
|
|
146
|
+
* Geheimnis wechselt. Wer den Endpunkt stattdessen loescht und neu anlegt,
|
|
147
|
+
* verliert seine Zustellungshistorie und muss die Ereignisse neu ankreuzen.
|
|
148
|
+
*
|
|
149
|
+
* Das alte Secret gilt ab der Antwort NICHT mehr: die naechste Zustellung ist
|
|
150
|
+
* schon mit dem neuen signiert. Erst speichern, dann weiterarbeiten.
|
|
151
|
+
*/
|
|
152
|
+
export async function rotatePartnerWebhookSecret(rufen, webhookId) {
|
|
153
|
+
const id = typeof webhookId === 'string' ? webhookId.trim() : '';
|
|
154
|
+
if (!id)
|
|
155
|
+
throw new KasseneckValidationError('rotatePartnerWebhookSecret', 'webhookId fehlt', 'request');
|
|
156
|
+
const daten = objekt(await rufen('rotatePartnerWebhookSecret', { webhookId: id }));
|
|
157
|
+
const secret = daten['secret'];
|
|
158
|
+
if (typeof secret !== 'string' || !secret) {
|
|
159
|
+
throw new KasseneckValidationError('rotatePartnerWebhookSecret', 'Antwort enthaelt kein secret — dann waere der Wechsel nicht nachvollziehbar', 'response');
|
|
160
|
+
}
|
|
161
|
+
return { webhook: webhook(daten['webhook']), secret };
|
|
162
|
+
}
|
|
163
|
+
/** Die Webhook-Endpunkte dieses Partners samt Ereignis-Katalog. */
|
|
164
|
+
export async function listPartnerWebhooks(rufen) {
|
|
165
|
+
const daten = objekt(await rufen('listPartnerWebhooks'));
|
|
166
|
+
return {
|
|
167
|
+
webhooks: (Array.isArray(daten['webhooks']) ? daten['webhooks'] : []).map(webhook),
|
|
168
|
+
events: (Array.isArray(daten['events']) ? daten['events'] : []).map((e) => ({
|
|
169
|
+
key: typeof objekt(e)['key'] === 'string' ? objekt(e)['key'] : '',
|
|
170
|
+
text: typeof objekt(e)['text'] === 'string' ? objekt(e)['text'] : '',
|
|
171
|
+
})),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Aendert einen Endpunkt. Nur die genannten Felder — ein leeres `patch` lehnt
|
|
176
|
+
* das Backend ab (`validation`, Feld `patch`), statt stillschweigend nichts zu
|
|
177
|
+
* tun.
|
|
178
|
+
*/
|
|
179
|
+
export async function updatePartnerWebhook(rufen, webhookId, patch) {
|
|
180
|
+
const id = typeof webhookId === 'string' ? webhookId.trim() : '';
|
|
181
|
+
if (!id)
|
|
182
|
+
throw new KasseneckValidationError('updatePartnerWebhook', 'webhookId fehlt', 'request');
|
|
183
|
+
if (patch === null || typeof patch !== 'object' || Object.keys(patch).length === 0) {
|
|
184
|
+
throw new KasseneckValidationError('updatePartnerWebhook', 'patch nennt keine Aenderung', 'request');
|
|
185
|
+
}
|
|
186
|
+
const daten = objekt(await rufen('updatePartnerWebhook', { webhookId: id, patch }));
|
|
187
|
+
return webhook(daten['webhook']);
|
|
188
|
+
}
|
|
189
|
+
/** Loescht einen Endpunkt. Danach kommt dort nichts mehr an. */
|
|
190
|
+
export async function deletePartnerWebhook(rufen, webhookId) {
|
|
191
|
+
const id = typeof webhookId === 'string' ? webhookId.trim() : '';
|
|
192
|
+
if (!id)
|
|
193
|
+
throw new KasseneckValidationError('deletePartnerWebhook', 'webhookId fehlt', 'request');
|
|
194
|
+
const daten = objekt(await rufen('deletePartnerWebhook', { webhookId: id }));
|
|
195
|
+
return typeof daten['webhookId'] === 'string' ? daten['webhookId'] : id;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Schickt eine Probe an genau diesen Endpunkt.
|
|
199
|
+
*
|
|
200
|
+
* Ohne `event` kommt `webhook.test` — der Nachweis, dass die Leitung steht und
|
|
201
|
+
* die eigene Signaturpruefung gegen echte Bytes laeuft. **Mit `event` kommt
|
|
202
|
+
* genau das Ereignis, das der Empfaenger behandeln soll**, mit einer
|
|
203
|
+
* glaubwuerdigen Nutzlast: wer auf `signature.ready` hin seinen Kunden
|
|
204
|
+
* benachrichtigt, probt das einmal, statt auf eine echte Karte zu warten. Eine
|
|
205
|
+
* Leitungsprobe beweist nichts ueber die Behandlung des Ernstfalls.
|
|
206
|
+
*
|
|
207
|
+
* Der Endpunkt muss das Ereignis abonnieren (`event_not_subscribed`) und aktiv
|
|
208
|
+
* sein (`webhook_inactive`); ein unbekannter Name ist ein `validation`-Fehler
|
|
209
|
+
* auf dem Feld `event`.
|
|
210
|
+
*
|
|
211
|
+
* **Jede Probe traegt `test: true` im Umschlag** ([PartnerWebhookEvent.test]).
|
|
212
|
+
*
|
|
213
|
+
* Der Backend-Endpunkt heisst `sendPartnerWebhookTest`; dieser Client behaelt
|
|
214
|
+
* den Namen bei, damit ein Leser der Doku und ein Leser des Codes dasselbe
|
|
215
|
+
* suchen.
|
|
216
|
+
*/
|
|
217
|
+
export async function sendPartnerWebhookTest(rufen, webhookId, event) {
|
|
218
|
+
const id = typeof webhookId === 'string' ? webhookId.trim() : '';
|
|
219
|
+
if (!id)
|
|
220
|
+
throw new KasseneckValidationError('sendPartnerWebhookTest', 'webhookId fehlt', 'request');
|
|
221
|
+
const ereignis = typeof event === 'string' ? event.trim() : '';
|
|
222
|
+
const daten = objekt(await rufen('sendPartnerWebhookTest', { webhookId: id, event: ereignis || undefined }));
|
|
223
|
+
return {
|
|
224
|
+
eventId: typeof daten['eventId'] === 'string' ? daten['eventId'] : '',
|
|
225
|
+
ereignis: typeof daten['ereignis'] === 'string' ? daten['ereignis'] : ereignis || 'webhook.test',
|
|
226
|
+
deliveries: Array.isArray(daten['deliveries']) ? daten['deliveries'] : [],
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Die letzten Zustellversuche — mit `webhookId` fuer einen Endpunkt, ohne ihn
|
|
231
|
+
* fuer alle. Die Stelle, an der sich „mein Server bekommt nichts" klaeren
|
|
232
|
+
* laesst, ohne bei Kasseneck nachzufragen.
|
|
233
|
+
*/
|
|
234
|
+
export async function listPartnerWebhookDeliveries(rufen, optionen = {}) {
|
|
235
|
+
if (optionen.limit !== undefined && (!Number.isInteger(optionen.limit) || optionen.limit < 1 || optionen.limit > 200)) {
|
|
236
|
+
throw new KasseneckValidationError('listPartnerWebhookDeliveries', 'limit muss zwischen 1 und 200 liegen', 'request');
|
|
237
|
+
}
|
|
238
|
+
const daten = objekt(await rufen('listPartnerWebhookDeliveries', { webhookId: optionen.webhookId, limit: optionen.limit }));
|
|
239
|
+
return (Array.isArray(daten['deliveries']) ? daten['deliveries'] : []).map((eintrag) => {
|
|
240
|
+
const z = objekt(eintrag);
|
|
241
|
+
const zahl = (wert) => (typeof wert === 'number' && Number.isFinite(wert) ? wert : null);
|
|
242
|
+
const txt = (wert) => (typeof wert === 'string' ? wert : null);
|
|
243
|
+
return {
|
|
244
|
+
deliveryId: txt(z['deliveryId']) ?? '',
|
|
245
|
+
webhookId: txt(z['webhookId']) ?? '',
|
|
246
|
+
event: txt(z['event']) ?? '',
|
|
247
|
+
eventId: txt(z['eventId']) ?? '',
|
|
248
|
+
status: txt(z['status']) ?? '',
|
|
249
|
+
attempts: zahl(z['attempts']) ?? 0,
|
|
250
|
+
letzterVersuchAt: zahl(z['letzterVersuchAt']),
|
|
251
|
+
naechsterVersuchAt: zahl(z['naechsterVersuchAt']),
|
|
252
|
+
statusCode: zahl(z['statusCode']),
|
|
253
|
+
response: txt(z['response']),
|
|
254
|
+
createdAt: zahl(z['createdAt']),
|
|
255
|
+
};
|
|
256
|
+
});
|
|
257
|
+
}
|
|
@@ -159,7 +159,14 @@ export interface RegisterUserPerms {
|
|
|
159
159
|
tipAssign?: boolean;
|
|
160
160
|
[weiteresRecht: string]: boolean | RegisterScope | undefined;
|
|
161
161
|
}
|
|
162
|
-
/**
|
|
162
|
+
/**
|
|
163
|
+
* Alle Rechte-Schluessel, die dieses Paket kennt — die Zwillinge pruefen
|
|
164
|
+
* dagegen, und ueber `fixtures/oberflaeche.json` steht die Liste im Vertrag.
|
|
165
|
+
*
|
|
166
|
+
* `satisfies` schliesst die eine Richtung: ein Name, den [RegisterUserPerms]
|
|
167
|
+
* nicht fuehrt (Tippfehler, umbenanntes Recht), kommt hier nicht durch.
|
|
168
|
+
* Die Gegenrichtung schliesst [_RechteVollstaendig] direkt darunter.
|
|
169
|
+
*/
|
|
163
170
|
export declare const REGISTER_PERMS: readonly ["sell", "cancel", "articles", "layout", "reports", "takeover", "cancelScope", "receiptsScope", "drawer", "discount", "tipAssign"];
|
|
164
171
|
/** Reichweite lesen, mit der Migration des Backends (register-auth.js). */
|
|
165
172
|
export declare function cancelScopeOf(perms: RegisterUserPerms | null | undefined): RegisterScope;
|
|
@@ -38,7 +38,14 @@ import { KASSE_BETRIEB_STANDARD, KASSE_GERAET_STANDARD, mergeKasseSettings, } fr
|
|
|
38
38
|
* es auch halten.
|
|
39
39
|
*/
|
|
40
40
|
const ohneAnmeldung = () => ({ headers: {}, params: {} });
|
|
41
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* Alle Rechte-Schluessel, die dieses Paket kennt — die Zwillinge pruefen
|
|
43
|
+
* dagegen, und ueber `fixtures/oberflaeche.json` steht die Liste im Vertrag.
|
|
44
|
+
*
|
|
45
|
+
* `satisfies` schliesst die eine Richtung: ein Name, den [RegisterUserPerms]
|
|
46
|
+
* nicht fuehrt (Tippfehler, umbenanntes Recht), kommt hier nicht durch.
|
|
47
|
+
* Die Gegenrichtung schliesst [_RechteVollstaendig] direkt darunter.
|
|
48
|
+
*/
|
|
42
49
|
export const REGISTER_PERMS = [
|
|
43
50
|
'sell', 'cancel', 'articles', 'layout', 'reports', 'takeover',
|
|
44
51
|
'cancelScope', 'receiptsScope', 'drawer', 'discount', 'tipAssign',
|
|
@@ -1,35 +1,55 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.
|
|
2
|
+
"version": "0.7.0",
|
|
3
3
|
"aufrufe": [
|
|
4
|
+
"activateCashregister",
|
|
4
5
|
"cancelReceipt",
|
|
6
|
+
"createCustomerCashregister",
|
|
7
|
+
"createPartnerCustomer",
|
|
8
|
+
"checkPartnerCustomerEmail",
|
|
9
|
+
"createPartnerWebhook",
|
|
5
10
|
"createPaymentLinkStripe",
|
|
6
11
|
"createPrintJob",
|
|
7
12
|
"createReceipt",
|
|
13
|
+
"deletePartnerWebhook",
|
|
8
14
|
"downloadDailyReport",
|
|
9
15
|
"downloadReport",
|
|
10
16
|
"endRegisterSession",
|
|
11
17
|
"financeWebService",
|
|
12
18
|
"generateFullReceiptId",
|
|
19
|
+
"getCustomerCredentials",
|
|
20
|
+
"getCustomerSignatureStatus",
|
|
13
21
|
"getFirstReceiptDate",
|
|
14
22
|
"getKasseSettings",
|
|
23
|
+
"getPartnerCustomer",
|
|
24
|
+
"getPartnerInfo",
|
|
15
25
|
"getPrintJob",
|
|
16
26
|
"getReceipt",
|
|
17
27
|
"hobexPayApi",
|
|
18
28
|
"hobexRefundApi",
|
|
29
|
+
"listCustomerCashregisters",
|
|
19
30
|
"listMyArticleGroups",
|
|
20
31
|
"listMyArticles",
|
|
21
32
|
"listMyCashregisters",
|
|
22
33
|
"listMyPrinters",
|
|
23
34
|
"listMyReceipts",
|
|
35
|
+
"listMyTipRecipients",
|
|
36
|
+
"listPartnerCustomers",
|
|
37
|
+
"listPartnerWebhookDeliveries",
|
|
38
|
+
"listPartnerWebhooks",
|
|
24
39
|
"listRegisterUsersForDevice",
|
|
25
40
|
"pairRegisterDevice",
|
|
26
41
|
"registerPinLogin",
|
|
27
42
|
"registerUserLogin",
|
|
28
43
|
"renewRegisterSession",
|
|
44
|
+
"requestCustomerSignature",
|
|
45
|
+
"sendPartnerCustomerFonLink",
|
|
46
|
+
"sendPartnerWebhookTest",
|
|
29
47
|
"setMyKasseSettings",
|
|
30
48
|
"setMyRegisterDeviceSettings",
|
|
31
49
|
"stripeCaptureIntent",
|
|
32
|
-
"unpairRegisterDevice"
|
|
50
|
+
"unpairRegisterDevice",
|
|
51
|
+
"rotatePartnerWebhookSecret",
|
|
52
|
+
"updatePartnerWebhook"
|
|
33
53
|
],
|
|
34
54
|
"enums": {
|
|
35
55
|
"autoAbMin": [
|
|
@@ -212,5 +232,114 @@
|
|
|
212
232
|
"vollbild",
|
|
213
233
|
"gegebenLeeren",
|
|
214
234
|
"korbLeeren"
|
|
215
|
-
]
|
|
235
|
+
],
|
|
236
|
+
"partner": {
|
|
237
|
+
"betriebFelder": [
|
|
238
|
+
"companyName",
|
|
239
|
+
"legalForm",
|
|
240
|
+
"state",
|
|
241
|
+
"industry",
|
|
242
|
+
"companyRegister",
|
|
243
|
+
"court",
|
|
244
|
+
"web",
|
|
245
|
+
"phone",
|
|
246
|
+
"email",
|
|
247
|
+
"billingEmail",
|
|
248
|
+
"address.street",
|
|
249
|
+
"address.number",
|
|
250
|
+
"address.zip",
|
|
251
|
+
"address.city",
|
|
252
|
+
"taxDetails.taxNumber",
|
|
253
|
+
"taxDetails.vatId",
|
|
254
|
+
"taxDetails.gln",
|
|
255
|
+
"taxDetails.smallBusiness",
|
|
256
|
+
"contacts[].name",
|
|
257
|
+
"contacts[].email",
|
|
258
|
+
"contacts[].phone",
|
|
259
|
+
"contacts[].roles",
|
|
260
|
+
"taxAdvisor.name",
|
|
261
|
+
"taxAdvisor.email",
|
|
262
|
+
"taxAdvisor.phone",
|
|
263
|
+
"taxAdvisor.mayContact"
|
|
264
|
+
],
|
|
265
|
+
"partnerEnvs": [
|
|
266
|
+
"live",
|
|
267
|
+
"test"
|
|
268
|
+
],
|
|
269
|
+
"partnerFehlerCodes": [
|
|
270
|
+
"validation",
|
|
271
|
+
"rate_limited",
|
|
272
|
+
"app_not_found",
|
|
273
|
+
"app_not_accepted",
|
|
274
|
+
"kein_partnerbetrieb",
|
|
275
|
+
"live_not_allowed",
|
|
276
|
+
"customer_exists",
|
|
277
|
+
"customer_conflict",
|
|
278
|
+
"customer_limit",
|
|
279
|
+
"zugang_nicht_erlaubt",
|
|
280
|
+
"email_taken",
|
|
281
|
+
"no_email",
|
|
282
|
+
"fon_missing",
|
|
283
|
+
"signature_pending",
|
|
284
|
+
"request_not_found",
|
|
285
|
+
"signature_missing",
|
|
286
|
+
"signature_unknown",
|
|
287
|
+
"signature_ambiguous",
|
|
288
|
+
"signature_not_ready",
|
|
289
|
+
"signature_limit",
|
|
290
|
+
"signature_failed",
|
|
291
|
+
"module_inactive",
|
|
292
|
+
"cashregister_limit",
|
|
293
|
+
"cashregister_not_found",
|
|
294
|
+
"activation_failed",
|
|
295
|
+
"webhook_limit",
|
|
296
|
+
"webhook_inactive",
|
|
297
|
+
"event_not_subscribed"
|
|
298
|
+
],
|
|
299
|
+
"partnerPortalFehlerCodes": [
|
|
300
|
+
"app_locked",
|
|
301
|
+
"version_locked",
|
|
302
|
+
"invalid_transition",
|
|
303
|
+
"no_accepted_app",
|
|
304
|
+
"consent",
|
|
305
|
+
"key_limit",
|
|
306
|
+
"last_owner",
|
|
307
|
+
"auth_user_exists",
|
|
308
|
+
"card_missing",
|
|
309
|
+
"card_duplicate",
|
|
310
|
+
"card_not_verified",
|
|
311
|
+
"already_assigned"
|
|
312
|
+
],
|
|
313
|
+
"partnerWebhookEvents": [
|
|
314
|
+
"customer.created",
|
|
315
|
+
"customer.updated",
|
|
316
|
+
"customer.status_changed",
|
|
317
|
+
"customer.fon_verified",
|
|
318
|
+
"customer.live_enabled",
|
|
319
|
+
"signature.requested",
|
|
320
|
+
"signature.ready",
|
|
321
|
+
"signature.failed",
|
|
322
|
+
"cashregister.created",
|
|
323
|
+
"cashregister.live",
|
|
324
|
+
"cashregister.failed",
|
|
325
|
+
"app.version.accepted",
|
|
326
|
+
"app.version.rejected",
|
|
327
|
+
"webhook.test"
|
|
328
|
+
],
|
|
329
|
+
"webhookRetryPlanSec": [
|
|
330
|
+
60,
|
|
331
|
+
300,
|
|
332
|
+
1800,
|
|
333
|
+
7200,
|
|
334
|
+
43200
|
|
335
|
+
],
|
|
336
|
+
"webhookUmschlagFelder": [
|
|
337
|
+
"id",
|
|
338
|
+
"type",
|
|
339
|
+
"createdAt",
|
|
340
|
+
"partnerId",
|
|
341
|
+
"test",
|
|
342
|
+
"data"
|
|
343
|
+
]
|
|
344
|
+
}
|
|
216
345
|
}
|