// ============================================================================ // Sunntech ZVT-Bruecke — Kartenterminal fuer die Browser-Kassa // (Umsetzung von SPEC-ZVT-BRUECKE.md, 30.08.2026) // // WARUM: Die Kassa laeuft im Browser; ein Browser kann kein rohes TCP/ZVT. // Dieses kleine Programm laeuft AM KASSEN-PC, nimmt HTTP von der Kassa an // (127.0.0.1:9440) und spricht ZVT ueber LAN (Port 20007) mit dem Terminal // (PAYONE Gmunden / Hobex Kirchdorf — beide ZVT v13.08). // // START AM KASSEN-PC (Windows): // 1) Deno einmalig installieren: PowerShell: irm https://deno.land/install.ps1 | iex // 2) Diese Datei herunterladen: https://app.sunntech.at/kassa/zvt-bruecke.ts // 3) Starten: deno run --allow-net --allow-read --allow-write --allow-run=cmd zvt-bruecke.ts // (--allow-run=cmd: Druck/Lade/Etiketten gehen per "copy /b" an die Windows-Druckerfreigabe) // Beim ersten Start entsteht zvt-bruecke.json daneben — dort die // TERMINAL-IP eintragen (steht im Menue des PAYONE-Terminals) und neu starten. // Autostart: Verknuepfung mit diesem Befehl in shell:startup legen. // // In der KASSA einstellen: Zahlungsterminal = PAYONE, Terminal-Adresse = // 127.0.0.1:9440 (die Adresse DIESER Bruecke — die Terminal-IP kennt die // Bruecke selbst aus ihrer zvt-bruecke.json). // // EHRLICHER STAND: Das ZVT-Protokoll ist nach Spezifikation v13.08 umgesetzt, // aber noch nie gegen ein echtes Terminal gelaufen. Der erste Echttest // braucht das Terminal im Laden; jede Phase wird hier in der Konsole // mitgeschrieben, damit wir Abweichungen sofort sehen und nachziehen. // ============================================================================ // ── Konfiguration (Datei neben dem Script) ─────────────────────────────────── type Konfig = { terminal_ip: string; // IP des Kartenterminals im Laden-Netz terminal_port: number; // ZVT-Standard 20007 http_port: number; // Bruecken-Port fuer die Kassa (Default 9440) passwort: string; // ZVT-Kassenpasswort, ueblich "000000" config_byte: number; // Registrierungs-Konfiguration (siehe Kommentar unten) // Optional: Windows-FREIGABENAME des Bondruckers (Drucker-Einstellungen -> // Freigabe, z. B. "BON"). Dann oeffnet die Bruecke die Kassenlade auf Anfrage // der Kassa (POST /lade) per ESC/POS-Impuls — nur bei Barzahlung, statt der // Treiber-Automatik "Lade bei jedem Druck". Leer = Funktion aus. drucker_freigabe: string; // Optional: Windows-FREIGABENAME des Etikettendruckers (z. B. "ZEBRA"). // Dann kann die Warenwirtschaft Etiketten ohne Druckdialog direkt drucken. etikett_freigabe: string; }; const KONFIG_DATEI = "zvt-bruecke.json"; const VORGABE: Konfig = { terminal_ip: "HIER-TERMINAL-IP-EINTRAGEN", terminal_port: 20007, http_port: 9440, passwort: "000000", // 0x8E = ECR (Kassa) uebernimmt die Belegausgabe (Zahlung 0x02 + Verwaltung // 0x04), will Zwischenstatus (0x08) und Druckzeilen als 06 D1/D3 (0x80). // Falls das Terminal die Registrierung ablehnt, hier justieren (z. B. 0x0E). config_byte: 0x8e, drucker_freigabe: "", etikett_freigabe: "", }; let K: Konfig; try { K = { ...VORGABE, ...JSON.parse(await Deno.readTextFile(KONFIG_DATEI)) }; } catch { await Deno.writeTextFile(KONFIG_DATEI, JSON.stringify(VORGABE, null, 2)); console.log(`\n${KONFIG_DATEI} wurde angelegt.`); console.log("Bitte dort die terminal_ip eintragen (steht im Menue des Terminals) und neu starten.\n"); Deno.exit(1); } if (K.terminal_ip.includes("HIER")) { console.log(`Bitte zuerst die terminal_ip in ${KONFIG_DATEI} eintragen und neu starten.`); Deno.exit(1); } // ── ZVT-Grundbausteine ─────────────────────────────────────────────────────── const hex = (b: Uint8Array) => [...b].map((x) => x.toString(16).padStart(2, "0")).join(" "); function bcd(zahl: number, bytes: number): Uint8Array { const s = String(Math.round(zahl)).padStart(bytes * 2, "0"); const aus = new Uint8Array(bytes); for (let i = 0; i < bytes; i++) aus[i] = parseInt(s.slice(i * 2, i * 2 + 2), 16); return aus; } function bcdLesen(b: Uint8Array): string { return [...b].map((x) => x.toString(16).padStart(2, "0")).join(""); } function apdu(klasse: number, instr: number, daten: Uint8Array): Uint8Array { if (daten.length <= 254) return new Uint8Array([klasse, instr, daten.length, ...daten]); return new Uint8Array([klasse, instr, 0xff, daten.length & 0xff, (daten.length >> 8) & 0xff, ...daten]); } const ACK = new Uint8Array([0x80, 0x00, 0x00]); // Ergebnis-Codes (Auszug ZVT) fuer verstaendliche Ablehnungsgruende. const GRUENDE: Record = { 0x00: "ok", 0x05: "Abgelehnt", 0x06: "Abbruch am Terminal", 0x13: "Betrag unzulaessig", 0x6c: "Abbruch/Zeit abgelaufen", 0x9c: "Falsche Geheimzahl", 0xff: "Fehler (siehe Terminal)", }; // Haeufigste Ursache (Hardware-Test Kirchdorf 08.10.2026): Hobex nimmt nur EINE Verbindung -- haengt eine andere // Kasse (KORONA) am Terminal, oder ist die ZVT-Schnittstelle nicht freigeschaltet, kommt keine Antwort. const KEINE_ANTWORT = "Terminal antwortet nicht. Ist eine andere Kasse (z. B. KORONA) mit dem Terminal verbunden oder die ZVT-Schnittstelle nicht freigeschaltet?"; // Eine ZVT-Sitzung: verbinden, Kommando senden, alle Terminal-Nachrichten // einsammeln (ACKen), bis Abschluss (06 0F) oder Abbruch (06 1E) kommt. // WICHTIG (Lehre vom Echttest 30.08., Gmunden): NIE ein read() gegen einen // Timer "racen" — das verwaiste read() frisst das naechste Paket, seine Daten // gehen verloren, das Terminal wartet vergeblich auf unser ACK und bricht ab // ("abgelehnt"). Stattdessen: EIN fortlaufendes read(), und die Zeitgrenze // schliesst einfach die Verbindung (dann endet das read von selbst). async function zvtDialog(kommando: Uint8Array, zeitLimitMs: number): Promise<{ ok: boolean; grund?: string; status?: Uint8Array; druck: string[]; }> { const verbindung = await Deno.connect({ hostname: K.terminal_ip, port: K.terminal_port }); const druck: string[] = []; let status: Uint8Array | undefined; let zeitAbgelaufen = false; const wecker = setTimeout(() => { zeitAbgelaufen = true; try { verbindung.close(); } catch { /* schon zu */ } }, zeitLimitMs); try { console.log("->", hex(kommando)); await verbindung.write(kommando); let puffer = new Uint8Array(0); const stueck = new Uint8Array(4096); while (true) { let n: number | null; try { n = await verbindung.read(stueck); } catch { break; // Verbindung wurde (vom Wecker) geschlossen } if (n === null || n <= 0) break; // Verbindung zu const neu = new Uint8Array(puffer.length + n); neu.set(puffer); neu.set(stueck.subarray(0, n), puffer.length); puffer = neu; // Nachrichten aus dem Strom schneiden while (puffer.length >= 3) { let len = puffer[2]; let kopf = 3; if (len === 0xff) { if (puffer.length < 5) break; len = puffer[3] | (puffer[4] << 8); kopf = 5; } if (puffer.length < kopf + len) break; const klasse = puffer[0], instr = puffer[1]; const daten = puffer.subarray(kopf, kopf + len); puffer = puffer.subarray(kopf + len); console.log("<-", klasse.toString(16).padStart(2, "0"), instr.toString(16).padStart(2, "0"), "len", len); if (klasse === 0x80 || klasse === 0x84) { // Quittung des Terminals auf UNSER Kommando (84 xx = Fehler). if (klasse === 0x84 && instr !== 0x00) { return { ok: false, grund: "Terminal lehnt Kommando ab (84 " + instr.toString(16) + " — " + (GRUENDE[instr] || "Code " + instr) + ")", druck }; } continue; } // Alles vom Terminal Gesendete quittieren. await verbindung.write(ACK); if (klasse === 0x04 && instr === 0xff) continue; // Zwischenstatus if (klasse === 0x04 && instr === 0x0f) { status = daten.slice(); continue; } // Status-Information if (klasse === 0x06 && instr === 0xd1) { // Druckzeile druck.push(new TextDecoder("latin1").decode(daten.subarray(1))); continue; } if (klasse === 0x06 && instr === 0xd3) { continue; } // Druckblock (TLV) — v1: ueberspringen if (klasse === 0x06 && instr === 0x0f) return { ok: true, status, druck }; // Vorgang fertig if (klasse === 0x06 && instr === 0x1e) { // Abbruch const code = daten[0] ?? 0xff; const text = GRUENDE[code] || "Abbruch"; const terminalText = druck.map((z) => z.trim()).filter(Boolean).join(" / "); return { ok: false, grund: text + " (Terminal-Code 0x" + code.toString(16).padStart(2, "0") + ")" + (terminalText ? " -- " + terminalText : ""), status, druck }; } } } return { ok: false, grund: zeitAbgelaufen ? KEINE_ANTWORT : "Terminal hat die Verbindung beendet (ohne Abschlussmeldung) -- ist gleichzeitig eine andere Kasse (z. B. KORONA) mit dem Terminal verbunden?", status, druck, }; } finally { clearTimeout(wecker); try { verbindung.close(); } catch { /* schon zu */ } } } // Status-Information (04 0F) nach Belegnummer/Trace durchsuchen (BMP-Felder). function statusFelder(daten?: Uint8Array): { beleg_nr?: string; trace?: string } { if (!daten) return {}; const aus: { beleg_nr?: string; trace?: string } = {}; let i = 0; while (i < daten.length - 1) { const bmp = daten[i]; if (bmp === 0x0b) { aus.trace = bcdLesen(daten.subarray(i + 1, i + 4)); i += 4; continue; } // Trace, 3 BCD if (bmp === 0x87) { aus.beleg_nr = bcdLesen(daten.subarray(i + 1, i + 3)); i += 3; continue; } // Beleg-Nr, 2 BCD if (bmp === 0x04) { i += 7; continue; } // Betrag, 6 BCD if (bmp === 0x49) { i += 3; continue; } // Waehrung if (bmp === 0x0c) { i += 4; continue; } // Zeit if (bmp === 0x0d) { i += 3; continue; } // Datum i += 1; // unbekanntes Feld: Byte fuer Byte weiter (defensiv) } return aus; } const pw = bcd(parseInt(K.passwort, 10), 3); async function registrieren(): Promise<{ ok: boolean; grund?: string }> { // 06 00: Passwort + config-byte + Waehrung EUR (09 78) const daten = new Uint8Array([...pw, K.config_byte, 0x09, 0x78]); const r = await zvtDialog(apdu(0x06, 0x00, daten), 10000); return { ok: r.ok, grund: r.grund }; } async function zahlung(betragCent: number): Promise> { const reg = await registrieren(); if (!reg.ok) return { genehmigt: false, grund: "Registrierung: " + (reg.grund || "fehlgeschlagen") }; // 06 01: 04 = Betrag (6 BCD), 49 = Waehrung EUR const daten = new Uint8Array([0x04, ...bcd(betragCent, 6), 0x49, 0x09, 0x78]); const r = await zvtDialog(apdu(0x06, 0x01, daten), 95000); const felder = statusFelder(r.status); return { genehmigt: r.ok, grund: r.ok ? undefined : r.grund, beleg_nr: felder.beleg_nr, trace: felder.trace, beleg_text: r.druck.length ? r.druck.join("\n") : undefined, }; } // Zahlungs-Storno (06 30): storniert die ORIGINALZAHLUNG anhand ihrer // Terminal-Belegnummer (BMP 87). Der bevorzugte Rueckgabe-Weg — Gutschriften // (06 31) sind bei PAYONE werkseitig gesperrt (Code 83, Echttest 30.08.2026). async function zahlungsStorno(belegNr: string): Promise> { const nr = parseInt(String(belegNr).replace(/[^0-9]/g, ""), 10); if (!Number.isFinite(nr)) return { genehmigt: false, grund: "Terminal-Belegnummer fehlt/ungueltig" }; const reg = await registrieren(); if (!reg.ok) return { genehmigt: false, grund: "Registrierung: " + (reg.grund || "fehlgeschlagen") }; const daten = new Uint8Array([...pw, 0x87, ...bcd(nr, 2)]); const r = await zvtDialog(apdu(0x06, 0x30, daten), 95000); const felder = statusFelder(r.status); return { genehmigt: r.ok, grund: r.ok ? undefined : r.grund, beleg_nr: felder.beleg_nr, trace: felder.trace, beleg_text: r.druck.join("\n") || undefined }; } async function gutschrift(betragCent: number): Promise> { const reg = await registrieren(); if (!reg.ok) return { genehmigt: false, grund: "Registrierung: " + (reg.grund || "fehlgeschlagen") }; // 06 31: Passwort + Betrag + Waehrung const daten = new Uint8Array([...pw, 0x04, ...bcd(betragCent, 6), 0x49, 0x09, 0x78]); const r = await zvtDialog(apdu(0x06, 0x31, daten), 95000); const felder = statusFelder(r.status); return { genehmigt: r.ok, grund: r.ok ? undefined : r.grund, beleg_nr: felder.beleg_nr, trace: felder.trace, beleg_text: r.druck.join("\n") || undefined }; } async function tagesabschluss(): Promise> { const reg = await registrieren(); if (!reg.ok) return { genehmigt: false, grund: "Registrierung: " + (reg.grund || "fehlgeschlagen") }; const r = await zvtDialog(apdu(0x06, 0x50, pw), 100000); return { genehmigt: r.ok, grund: r.ok ? undefined : r.grund, beleg_text: r.druck.join("\n") || undefined }; } // ── Rohdaten an eine Windows-Druckerfreigabe senden ────────────────────────── // Hardware-Test Kirchdorf (Deno 2.9.6, Windows 11): Deno.writeFile("\\\\127.0.0.1\\BON") schlaegt IMMER mit // "os error 2" fehl, obwohl `copy /b datei \\127.0.0.1\BON` in der Eingabeaufforderung geht. Darum: Daten in eine // Temp-Datei schreiben und mit `cmd /c copy /b` an die Freigabe senden (braucht --allow-run=cmd), danach die Temp-Datei // loeschen. Ohne diese Berechtigung faellt es auf das alte direkte Schreiben zurueck (aeltere Deno-Versionen). async function anFreigabeSenden(freigabe: string, daten: Uint8Array): Promise { if (!/^[A-Za-z0-9 ._-]{1,80}$/.test(freigabe)) throw new Error("ungueltiger Freigabename \"" + freigabe + "\""); const ziel = "\\\\127.0.0.1\\" + freigabe; let temp = ""; try { temp = await Deno.makeTempFile({ prefix: "sunntech-druck-", suffix: ".bin" }); await Deno.writeFile(temp, daten); const aus = await new Deno.Command("cmd", { args: ["/c", "copy", "/b", "/y", temp, ziel], stdout: "piped", stderr: "piped" }).output(); if (!aus.success) { const text = new TextDecoder("latin1").decode(aus.stdout) + " " + new TextDecoder("latin1").decode(aus.stderr); throw new Error("copy /b an " + ziel + " fehlgeschlagen: " + text.replace(/\s+/g, " ").trim().slice(0, 200)); } } catch (e) { if (e instanceof Deno.errors.NotCapable || e instanceof Deno.errors.PermissionDenied) { // keine --allow-run-Berechtigung: alter Weg await Deno.writeFile(ziel, daten); return; } throw e; } finally { if (temp) { try { await Deno.remove(temp); } catch { /* weg oder gesperrt -- unkritisch */ } } } } // ── HTTP-Seite (das erwartet geraete.js) ───────────────────────────────────── let beschaeftigt = false; // laeuft gerade ein Terminal-Vorgang? // HERKUNFT-START // Nur die eigenen Apps duerfen die Bruecke ansprechen (Bon, Kassenlade, Etiketten, Kartenzahlung). Vorher "*": jede besuchte Webseite haette // Bon/Lade/Zahlung am Kassen-PC anstossen koennen. Der Browser schickt bei fremden Seiten immer "Origin" mit -> serverseitig abgelehnt // (CORS allein reicht nicht: ein POST mit Text-Inhalt wird auch ohne Vorabfrage ausgefuehrt). Ohne Origin (eigene Werkzeuge am PC) bleibt es erlaubt. const ERLAUBTE_HERKUENFTE = [ "https://app.sunntech.at", "https://sunntech.at", "https://www.sunntech.at", "https://team.sunntech.at", "https://team.sunnseit.shop", "https://sunnseit-team.pages.dev", "https://late-hall-025f.andrea-drack.workers.dev", ]; function herkunftErlaubt(o: string | null): boolean { if (!o) return true; return ERLAUBTE_HERKUENFTE.includes(o) || /^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(o); } // HERKUNFT-ENDE const corsBasis = { "Access-Control-Allow-Methods": "POST, GET, OPTIONS", "Access-Control-Allow-Headers": "content-type", // Chrome fragt bei Zugriffen einer Internetseite auf den eigenen PC (127.0.0.1) per Vorabfrage nach (Private Network Access) "Access-Control-Allow-Private-Network": "true", }; console.log(`\nSunntech ZVT-Bruecke laeuft: http://127.0.0.1:${K.http_port} -> Terminal ${K.terminal_ip}:${K.terminal_port}`); console.log("Dieses Fenster offen lassen (minimieren ist ok). Beenden mit Strg+C.\n"); Deno.serve({ hostname: "127.0.0.1", port: K.http_port }, async (req) => { const origin = req.headers.get("origin"); if (!herkunftErlaubt(origin)) { console.log(`[${new Date().toLocaleTimeString("de-AT")}] ABGELEHNT: fremde Herkunft ${origin}`); return new Response("Herkunft nicht erlaubt", { status: 403 }); // bewusst OHNE CORS-Header } const cors = { ...corsBasis, "Access-Control-Allow-Origin": origin || "*", "Vary": "Origin" }; if (req.method === "OPTIONS") return new Response("ok", { headers: cors }); const url = new URL(req.url); if (req.method === "GET") { return new Response("Sunntech ZVT-Bruecke aktiv. Terminal: " + K.terminal_ip + ":" + K.terminal_port, { headers: { ...cors, "content-type": "text/plain; charset=utf-8" } }); } if (req.method === "POST" && url.pathname === "/etikett") { // Etiketten-Direktdruck: Der Browser kann keine Rohdaten an einen Drucker // schicken. Die WaWi sendet uns hier die fertigen Druckerbefehle (ZPL beim // Zebra, spaeter andere Sprachen), wir schreiben sie an den freigegebenen // Windows-Drucker — ohne Druckdialog, ohne PDF (Wunsch Andrea 01.09.). const b = await req.json().catch(() => ({})); const freigabe = String(b.drucker || K.etikett_freigabe || "").trim(); const daten = String(b.daten || ""); if (!freigabe) return new Response(JSON.stringify({ ok: false, grund: "keine etikett_freigabe in zvt-bruecke.json konfiguriert" }), { headers: { ...cors, "content-type": "application/json" } }); if (!daten) return new Response(JSON.stringify({ ok: false, grund: "keine Druckdaten uebergeben" }), { headers: { ...cors, "content-type": "application/json" } }); // Der Windows-Druckerpfad (\\127.0.0.1\NAME) ist auf macOS/Linux nur ein // Dateiname — dort wuerde ein Schreibversuch faelschlich "gedruckt" melden. // Lieber ehrlich absagen als Erfolg vortaeuschen. if (Deno.build.os !== "windows") { return new Response(JSON.stringify({ ok: false, grund: "Direktdruck laeuft nur unter Windows (Drucker-Freigabe). Bitte den normalen Druckweg benutzen." }), { headers: { ...cors, "content-type": "application/json" } }); } try { // latin1: ZPL-Feldinhalte sind Ein-Byte-Zeichen; ^CI28 im Datenstrom // schaltet den Drucker auf UTF-8, darum die Bytes unveraendert lassen. await anFreigabeSenden(freigabe, new TextEncoder().encode(daten)); console.log(`[${new Date().toLocaleTimeString("de-AT")}] Etikettendruck an Freigabe ${freigabe} (${daten.length} Zeichen)`); return new Response(JSON.stringify({ ok: true }), { headers: { ...cors, "content-type": "application/json" } }); } catch (e) { const grund = "Etikettendruck fehlgeschlagen (" + ((e as Error).message || e) + ")"; console.log("FEHLER:", grund); return new Response(JSON.stringify({ ok: false, grund }), { headers: { ...cors, "content-type": "application/json" } }); } } if (req.method === "POST" && url.pathname === "/bon") { // Bon-Druck (Andrea 08.10.2026): Die Kassa schickt fertige ESC/POS-Bytes (Base64) -- wir schreiben sie roh an // die Windows-Druckerfreigabe (drucker_freigabe, z. B. "BON"). Kein Browser-Druck, kein Fenster, kein // --kiosk-printing noetig; Etiketten aus der WaWi duerfen weiter normal im Druckdialog gewaehlt werden. const antwort = (o: Record) => new Response(JSON.stringify(o), { headers: { ...cors, "content-type": "application/json" } }); if (!K.drucker_freigabe) return antwort({ ok: false, keine_freigabe: true, grund: "keine drucker_freigabe in zvt-bruecke.json konfiguriert" }); if (Deno.build.os !== "windows") return antwort({ ok: false, keine_freigabe: true, grund: "Bon-Direktdruck laeuft nur unter Windows (Drucker-Freigabe)" }); const b = await req.json().catch(() => ({})); let daten: Uint8Array; try { const roh = atob(String(b.daten_b64 || "")); daten = Uint8Array.from(roh, (c) => c.charCodeAt(0)); } catch { return antwort({ ok: false, grund: "Druckdaten ungueltig" }); } if (!daten.length || daten.length > 200_000) return antwort({ ok: false, grund: "Druckdaten leer oder zu gross" }); try { await anFreigabeSenden(K.drucker_freigabe, daten); console.log(`[${new Date().toLocaleTimeString("de-AT")}] Bon an Freigabe ${K.drucker_freigabe} (${daten.length} Bytes)`); return antwort({ ok: true }); } catch (e) { const grund = "Bon-Druck fehlgeschlagen (" + ((e as Error).message || e) + ") -- Drucker an/Papier/Freigabename \"" + K.drucker_freigabe + "\" pruefen"; console.log("FEHLER:", grund); return antwort({ ok: false, grund }); } } if (req.method === "POST" && url.pathname === "/lade") { // Kassenlade oeffnen: ESC/POS-Impuls (ESC p 0 25 250) roh an den freigegebenen // Windows-Drucker schreiben (\\127.0.0.1\ nimmt Rohdaten an). if (!K.drucker_freigabe) { return new Response(JSON.stringify({ ok: false, grund: "keine drucker_freigabe in zvt-bruecke.json konfiguriert" }), { headers: { ...cors, "content-type": "application/json" } }); } try { await anFreigabeSenden(K.drucker_freigabe, new Uint8Array([0x1b, 0x70, 0x00, 0x19, 0xfa])); console.log(`[${new Date().toLocaleTimeString("de-AT")}] Kassenlade-Impuls an Freigabe ${K.drucker_freigabe}`); return new Response(JSON.stringify({ ok: true }), { headers: { ...cors, "content-type": "application/json" } }); } catch (e) { const grund = "Lade-Impuls fehlgeschlagen (" + ((e as Error).message || e) + ")"; console.log("FEHLER:", grund); return new Response(JSON.stringify({ ok: false, grund }), { headers: { ...cors, "content-type": "application/json" } }); } } if (req.method === "POST" && url.pathname === "/zvt") { // Nur EIN Vorgang gleichzeitig: startet die Kassa erneut, waehrend das // Terminal noch beschaeftigt ist, gibt es sofort eine ehrliche Antwort // statt eines zweiten Dialogs, der das Terminal durcheinanderbringt. if (beschaeftigt) { return new Response(JSON.stringify({ genehmigt: false, grund: "Terminal ist noch mit dem vorigen Vorgang beschaeftigt -- bitte kurz warten und neu versuchen" }), { headers: { ...cors, "content-type": "application/json" } }); } beschaeftigt = true; const b = await req.json().catch(() => ({})); const betrag = Math.max(0, Math.round(Number(b.betrag_cent) || 0)); console.log(`\n[${new Date().toLocaleTimeString("de-AT")}] ${b.aktion || "?"} ${betrag ? (betrag / 100).toFixed(2) + " EUR" : ""}`); try { let aus: Record; if (b.aktion === "zahlung") aus = await zahlung(betrag); else if (b.aktion === "zahlungsstorno") aus = await zahlungsStorno(String(b.beleg_nr || "")); else if (b.aktion === "gutschrift" || b.aktion === "storno") aus = await gutschrift(betrag); else if (b.aktion === "tagesabschluss") aus = await tagesabschluss(); else aus = { genehmigt: false, grund: "Unbekannte aktion" }; console.log("Ergebnis:", JSON.stringify({ genehmigt: aus.genehmigt, grund: aus.grund, beleg_nr: aus.beleg_nr })); return new Response(JSON.stringify(aus), { headers: { ...cors, "content-type": "application/json" } }); } catch (e) { const grund = "Terminal nicht erreichbar (" + ((e as Error).message || e) + ") -- Terminal-IP in zvt-bruecke.json und Netzwerk pruefen"; console.log("FEHLER:", grund); return new Response(JSON.stringify({ genehmigt: false, grund }), { headers: { ...cors, "content-type": "application/json" } }); } finally { beschaeftigt = false; } } return new Response("Nicht gefunden", { status: 404, headers: cors }); });