diff --git a/api/create_checkout.py b/api/create_checkout.py new file mode 100644 index 000000000..7ac704acf --- /dev/null +++ b/api/create_checkout.py @@ -0,0 +1,108 @@ +import sys +import uuid +import requests + +TOKEN_FILE = "/var/www/html/config/sumup_token.txt" + +ACCESS_TOKEN = "" +MERCHANT_CODE = "" +AMOUNT_CENTS = 0 +RETURN_URL = "" + +API_BASE_V01 = "https://api.sumup.com/v0.1" + + +def load_access_token(): + global ACCESS_TOKEN + + try: + with open(TOKEN_FILE, "r", encoding="utf-8") as f: + ACCESS_TOKEN = f.read().strip() + except Exception as e: + print(f"ERROR: token file unreadable: {e}", file=sys.stderr) + sys.exit(1) + + if not ACCESS_TOKEN: + print("ERROR: token file is empty", file=sys.stderr) + sys.exit(1) + + +def auth_headers(): + return { + "Authorization": f"Bearer {ACCESS_TOKEN}", + "Content-Type": "application/json", + } + + +def parse_args(): + global MERCHANT_CODE, AMOUNT_CENTS, RETURN_URL + + if len(sys.argv) < 4: + print( + "Usage: create_checkout.py MERCHANT_CODE AMOUNT_CENTS RETURN_URL", + file=sys.stderr, + ) + sys.exit(1) + + MERCHANT_CODE = sys.argv[1].strip() + + try: + AMOUNT_CENTS = int(sys.argv[2]) + except ValueError: + print("ERROR: AMOUNT_CENTS must be an integer", file=sys.stderr) + sys.exit(1) + + RETURN_URL = sys.argv[3].strip() + + if not MERCHANT_CODE: + print("ERROR: MERCHANT_CODE missing", file=sys.stderr) + sys.exit(1) + + if AMOUNT_CENTS <= 0: + print("ERROR: AMOUNT_CENTS invalid", file=sys.stderr) + sys.exit(1) + + if not RETURN_URL: + print("ERROR: RETURN_URL missing", file=sys.stderr) + sys.exit(1) + + +def main(): + load_access_token() + parse_args() + + checkout_reference = "PHOTO-" + str(uuid.uuid4())[:8] + amount_eur = AMOUNT_CENTS / 100.0 + + url = f"{API_BASE_V01}/checkouts" + + payload = { + "checkout_reference": checkout_reference, + "amount": amount_eur, + "currency": "EUR", + "merchant_code": MERCHANT_CODE, + "description": "Fotobox Ausdruck", + "hosted_checkout": {"enabled": True}, + "return_url": RETURN_URL, + } + + response = requests.post(url, json=payload, headers=auth_headers(), timeout=30) + + print("Status:", response.status_code) + print("Antwort:", response.text) + + if response.status_code != 201: + sys.exit(1) + + data = response.json() + payment_link = data.get("hosted_checkout_url", "").strip() + + if not payment_link: + print("ERROR: hosted_checkout_url missing", file=sys.stderr) + sys.exit(1) + + print(payment_link) + + +if __name__ == "__main__": + main() diff --git a/api/paymentStatus.php b/api/paymentStatus.php new file mode 100644 index 000000000..d3dfc0608 --- /dev/null +++ b/api/paymentStatus.php @@ -0,0 +1,38 @@ + 'missing', + 'paid' => false, + 'printed' => false, + ]); + exit; +} + +$data = json_decode((string)file_get_contents($jobFile), true); + +if (!is_array($data)) { + echo json_encode([ + 'status' => 'invalid', + 'paid' => false, + 'printed' => false, + ]); + exit; +} + +echo json_encode([ + 'status' => 'ok', + 'paid' => (bool)($data['paid'] ?? false), + 'printed' => (bool)($data['printed'] ?? false), + 'filename' => (string)($data['filename'] ?? ''), +]); diff --git a/api/runConfiguredPrint.php b/api/runConfiguredPrint.php new file mode 100644 index 000000000..d9d0e7efc --- /dev/null +++ b/api/runConfiguredPrint.php @@ -0,0 +1,94 @@ +getConfiguration(); +$printCmd = (string)($config['commands']['print'] ?? ''); + +if ($printCmd === '') { + fwrite(STDERR, "ERROR: commands.print is empty in Photobooth config\n"); + exit(1); +} + +// Logger initialisieren (Schreibt in var/log/main.log) +$logger = LoggerService::getInstance()->getLogger('main'); + +// Pfad zum Bild auflösen +$resolvedFilename = $filename; +if (!str_starts_with($resolvedFilename, '/')) { + $resolvedFilename = PathUtility::getAbsolutePath('data/print/' . ltrim($resolvedFilename, '/')); +} + +// Druckbefehl vorbereiten (Platzhalter %s ersetzen) +$cmd = str_replace('%s', escapeshellarg($resolvedFilename), $printCmd); + +$output = []; +$returnVar = 1; + +// Logge den Start des Vorgangs +$logger->info('Payment Print: Starte System-Druckbefehl', [ + 'file' => $resolvedFilename, + 'copies' => $copies +]); + +// Befehl ausführen und Rückgabe sowie Fehler (2>&1) abfangen +exec($cmd . ' 2>&1', $output, $returnVar); + +$outputString = implode(' ', $output); + +// Ergebnis verarbeiten und loggen +if ($returnVar === 0) { + // Erfolgreich (an das Betriebssystem übergeben) + $logger->info('Payment Print: Druck erfolgreich ausgelöst', [ + 'output' => $outputString + ]); +} else { + // Fehler beim Druckbefehl + $logger->error('Payment Print: Fehler beim Ausführen des Druckbefehls', [ + 'return_code' => $returnVar, + 'cmd' => $cmd, + 'output' => $outputString + ]); +} + +// Backup-Log in private/ (hilfreich für Standalone-Debugging) +$logFile = PathUtility::getAbsolutePath('private/payment-print.log'); +$logEntry = sprintf( + "[%s] File: %s | Return: %d | Output: %s\n", + date('c'), + $resolvedFilename, + $returnVar, + $outputString +); +file_put_contents($logFile, $logEntry, FILE_APPEND); + +exit($returnVar); diff --git a/api/startPaymentPrint.php b/api/startPaymentPrint.php new file mode 100644 index 000000000..af750b7c6 --- /dev/null +++ b/api/startPaymentPrint.php @@ -0,0 +1,307 @@ +getConfiguration(); + + if (empty($config['payments']['enabled'])) { + echo json_encode([ + 'status' => 'disabled', + 'error' => 'Payment system disabled', + ]); + exit; + } + + $filename = trim((string)($_POST['filename'] ?? '')); + $copies = (int)($_POST['copies'] ?? 1); + + if ($filename === '') { + http_response_code(400); + echo json_encode([ + 'status' => 'error', + 'error' => 'Missing filename', + ]); + exit; + } + + $provider = trim((string)($config['payments']['provider'] ?? 'none')); + $displayMode = trim((string)($config['payments']['display_mode'] ?? 'solo')); + $webhookUrl = rtrim(trim((string)($config['payments']['webhook_url'] ?? '')), '/'); + + $merchantCode = trim((string)($config['payments']['sumup']['merchant_code'] ?? '')); + $readerId = trim((string)($config['payments']['sumup']['reader_id'] ?? '')); + $affiliateKey = trim((string)($config['payments']['sumup']['affiliate_key'] ?? '')); + + $amountCentsRaw = $config['payments']['price_cents'] ?? 0; + $amountCents = (int)$amountCentsRaw; + + $python = '/usr/bin/python3'; + $soloScript = PathUtility::getAbsolutePath('api/sumup_solo.py'); + $checkoutScript = PathUtility::getAbsolutePath('api/create_checkout.py'); + + $logFile = PathUtility::getAbsolutePath('private/payment-print.log'); + $jobFile = PathUtility::getAbsolutePath('private/photobooth_current_print.json'); + $soloBgLog = '/tmp/sumup_solo_both.log'; + + $logLines = [ + '[' . date('c') . '] startPaymentPrint', + 'filename=' . $filename, + 'copies=' . $copies, + 'provider=' . $provider, + 'display_mode=' . $displayMode, + 'merchant_code=' . $merchantCode, + 'reader_id=' . $readerId, + 'affiliate_key_present=' . ($affiliateKey !== '' ? 'yes' : 'no'), + 'amount_cents=' . $amountCents, + 'webhook_url=' . $webhookUrl, + ]; + + if ($provider !== 'sumup') { + http_response_code(500); + echo json_encode([ + 'status' => 'error', + 'error' => 'Payment provider is not SumUp', + ]); + exit; + } + + if ($merchantCode === '') { + http_response_code(500); + echo json_encode([ + 'status' => 'error', + 'error' => 'SumUp Merchant Code missing', + ]); + exit; + } + + if ($amountCents <= 0) { + http_response_code(500); + echo json_encode([ + 'status' => 'error', + 'error' => 'Price (cents) is invalid or missing', + ]); + exit; + } + + if ($displayMode === 'solo') { + if ($readerId === '') { + http_response_code(500); + echo json_encode([ + 'status' => 'error', + 'error' => 'SumUp Reader ID missing', + ]); + exit; + } + + if ($affiliateKey === '') { + http_response_code(500); + echo json_encode([ + 'status' => 'error', + 'error' => 'SumUp Affiliate Key missing', + ]); + exit; + } + + if (!is_file($soloScript)) { + http_response_code(500); + echo json_encode([ + 'status' => 'error', + 'error' => 'sumup_solo.py not found', + ]); + exit; + } + + $cmd = escapeshellcmd($python) . ' ' . + escapeshellarg($soloScript) . ' ' . + escapeshellarg($merchantCode) . ' ' . + escapeshellarg($readerId) . ' ' . + escapeshellarg($affiliateKey) . ' ' . + escapeshellarg((string)$amountCents) . ' 2>&1'; + + $output = []; + $returnVar = 1; + exec($cmd, $output, $returnVar); + + $logLines[] = 'command=' . $cmd; + $logLines[] = 'return_code=' . $returnVar; + $logLines[] = 'output:'; + $logLines[] = implode(PHP_EOL, $output); + $logLines[] = ''; + + file_put_contents($logFile, implode(PHP_EOL, $logLines) . PHP_EOL, FILE_APPEND); + + if ($returnVar === 0) { + echo json_encode([ + 'status' => 'success', + 'message' => 'Payment successful - printing starts...', + ]); + exit; + } + + echo json_encode([ + 'status' => 'error', + 'error' => 'Payment failed or was cancelled', + 'log' => implode("\n", $output), + ]); + exit; + } + + if ($displayMode === 'qr' || $displayMode === 'both') { + if ($webhookUrl === '') { + http_response_code(500); + echo json_encode([ + 'status' => 'error', + 'error' => 'ngrok URL / webhook_url missing', + ]); + exit; + } + + if (!is_file($checkoutScript)) { + http_response_code(500); + echo json_encode([ + 'status' => 'error', + 'error' => 'create_checkout.py not found', + ]); + exit; + } + + $returnUrl = $webhookUrl . '/sumup/webhook'; + + $checkoutCmd = escapeshellcmd($python) . ' ' . + escapeshellarg($checkoutScript) . ' ' . + escapeshellarg($merchantCode) . ' ' . + escapeshellarg((string)$amountCents) . ' ' . + escapeshellarg($returnUrl) . ' 2>&1'; + + $checkoutOutput = []; + $checkoutReturnVar = 1; + exec($checkoutCmd, $checkoutOutput, $checkoutReturnVar); + + $paymentUrl = ''; + if (!empty($checkoutOutput)) { + $paymentUrl = trim(end($checkoutOutput)); + } + + $logLines[] = 'checkout_command=' . $checkoutCmd; + $logLines[] = 'checkout_return_code=' . $checkoutReturnVar; + $logLines[] = 'checkout_output:'; + $logLines[] = implode(PHP_EOL, $checkoutOutput); + + if ($checkoutReturnVar !== 0 || $paymentUrl === '' || strpos($paymentUrl, 'https://') !== 0) { + $logLines[] = ''; + file_put_contents($logFile, implode(PHP_EOL, $logLines) . PHP_EOL, FILE_APPEND); + + http_response_code(500); + echo json_encode([ + 'status' => 'error', + 'error' => 'The QR payment link could not be generated.', + 'log' => implode("\n", $checkoutOutput), + ]); + exit; + } + + $jobData = json_encode([ + 'filename' => $filename, + 'copies' => $copies, + 'printed' => false, + 'paid' => false, + 'created_at' => date('c'), + ], JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); + + $jobWriteResult = file_put_contents($jobFile, $jobData); + + $logLines[] = 'job_file=' . $jobFile; + $logLines[] = 'job_write_result=' . var_export($jobWriteResult, true); + $logLines[] = 'payment_url=' . $paymentUrl; + + if ($displayMode === 'qr') { + $logLines[] = ''; + file_put_contents($logFile, implode(PHP_EOL, $logLines) . PHP_EOL, FILE_APPEND); + + echo json_encode([ + 'status' => 'qr', + 'payment_url' => $paymentUrl, + 'message' => 'QR payment ready', + ]); + exit; + } + + if ($readerId === '') { + http_response_code(500); + echo json_encode([ + 'status' => 'error', + 'error' => 'SumUp Reader ID missing', + ]); + exit; + } + + if ($affiliateKey === '') { + http_response_code(500); + echo json_encode([ + 'status' => 'error', + 'error' => 'SumUp Affiliate Key missing', + ]); + exit; + } + + if (!is_file($soloScript)) { + http_response_code(500); + echo json_encode([ + 'status' => 'error', + 'error' => 'sumup_solo.py not found', + ]); + exit; + } + + $soloCmd = 'nohup ' . + escapeshellcmd($python) . ' ' . + escapeshellarg($soloScript) . ' ' . + escapeshellarg($merchantCode) . ' ' . + escapeshellarg($readerId) . ' ' . + escapeshellarg($affiliateKey) . ' ' . + escapeshellarg((string)$amountCents) . + ' >> ' . escapeshellarg($soloBgLog) . ' 2>&1 &'; + + exec($soloCmd); + + $logLines[] = 'solo_background_command=' . $soloCmd; + $logLines[] = ''; + + file_put_contents($logFile, implode(PHP_EOL, $logLines) . PHP_EOL, FILE_APPEND); + + echo json_encode([ + 'status' => 'both', + 'payment_url' => $paymentUrl, + 'message' => 'QR payment ready, Terminal started in background', + ]); + exit; + } + + http_response_code(500); + echo json_encode([ + 'status' => 'error', + 'error' => 'Invalid payment method', + ]); +} catch (\Throwable $e) { + http_response_code(500); + + $logFile = PathUtility::getAbsolutePath('private/payment-print.log'); + file_put_contents( + $logFile, + '[' . date('c') . '] EXCEPTION startPaymentPrint: ' . $e->getMessage() . PHP_EOL, + FILE_APPEND + ); + + echo json_encode([ + 'status' => 'error', + 'error' => $e->getMessage(), + ]); +} diff --git a/api/sumup_print_wrapper.php b/api/sumup_print_wrapper.php new file mode 100644 index 000000000..e3f987ef1 --- /dev/null +++ b/api/sumup_print_wrapper.php @@ -0,0 +1,46 @@ + { - remoteBuzzerClient.inProgress(false); - buttonPrint.trigger('blur'); - }); + if (config.payments.enabled) { + photoboothTools.printPayment(filename, copies, () => { + remoteBuzzerClient.inProgress(false); + buttonPrint.trigger('blur'); + }); + } else { + photoboothTools.printImage(filename, copies, () => { + remoteBuzzerClient.inProgress(false); + buttonPrint.trigger('blur'); + }); + } } }); diff --git a/assets/js/payment_poll.js b/assets/js/payment_poll.js new file mode 100644 index 000000000..dd6b20e3a --- /dev/null +++ b/assets/js/payment_poll.js @@ -0,0 +1,54 @@ +(function () { + let pollTimer = null; + + function startPolling() { + stopPolling(); + + pollTimer = setInterval(() => { + $.ajax({ + url: '/api/paymentStatus.php', + method: 'GET', + dataType: 'json', + success: function (data) { + console.log('Payment poll:', data); + + if (data.paid && data.printed) { + stopPolling(); + + const overlay = document.querySelector('.overlay'); + if (overlay) { + overlay.innerHTML = '✅ Zahlung erfolgreich – Druck abgeschlossen'; + setTimeout(() => { + overlay.remove(); + }, 1200); + } + } + }, + error: function (xhr, status, err) { + console.log('Payment poll failed:', status, err); + } + }); + }, 2000); + } + + function stopPolling() { + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + } + + const observer = new MutationObserver(() => { + const overlay = document.querySelector('.overlay'); + + if (!overlay) { + return; + } + + if (overlay.classList.contains('overlay-qr') || overlay.classList.contains('overlay-both')) { + startPolling(); + } + }); + + observer.observe(document.body, { childList: true, subtree: true }); +})(); diff --git a/assets/js/photoswipe.js b/assets/js/photoswipe.js index 62dcc4a94..c3395fbff 100644 --- a/assets/js/photoswipe.js +++ b/assets/js/photoswipe.js @@ -160,11 +160,19 @@ function initPhotoSwipeFromDOM(gallerySelector) { const copies = config.print.max_multi === 1 ? 1 : await photoboothTools.askCopies(); if (copies && !isNaN(copies)) { - photoboothTools.printImage(img, copies, () => { - if (typeof remoteBuzzerClient !== 'undefined') { - remoteBuzzerClient.inProgress(false); - } - }); + if (config.payments.enabled) { + photoboothTools.printPayment(img, copies, () => { + if (typeof remoteBuzzerClient !== 'undefined') { + remoteBuzzerClient.inProgress(false); + } + }); + } else { + photoboothTools.printImage(img, copies, () => { + if (typeof remoteBuzzerClient !== 'undefined') { + remoteBuzzerClient.inProgress(false); + } + }); + } } } } diff --git a/assets/js/tools.js b/assets/js/tools.js index 6c9770dcd..a0767dd22 100644 --- a/assets/js/tools.js +++ b/assets/js/tools.js @@ -629,6 +629,77 @@ const photoboothTools = (function () { } }; + api.printPayment = function (imageSrc, copies, cb) { + const priceCents = Number(config.payments?.price_cents || 0); + const priceEuro = (priceCents / 100).toFixed(2).replace('.', ','); + const paymentMessage = (config.payments?.message || api.getTranslation('payments_message')).replace( + '%price%', + priceEuro + ); + const paymentQrMsg = api.getTranslation('payments_qr_message'); + const paymentTerminalMsg = api.getTranslation('payments_terminal_message'); + + api.overlay.show(paymentMessage); + api.isPrinting = true; + if (typeof remoteBuzzerClient !== 'undefined') { + remoteBuzzerClient.inProgress('print'); + } + + $.ajax({ + method: 'POST', + url: environment.publicFolders.api + '/startPaymentPrint.php', + dataType: 'json', + data: api.addCsrfToPayload({ filename: imageSrc, copies: copies }), + success: (data) => { + if (data.status === 'disabled') { + api.overlay.close(); + api.isPrinting = false; + api.printImage(imageSrc, copies, cb); + } else if (data.status === 'success') { + api.overlay.show(data.message || api.getTranslation('payments_success')); + setTimeout(() => { + api.overlay.close(); + api.isPrinting = false; + api.printImage(imageSrc, copies, cb); + }, 1200); + } else if (data.status === 'qr' || data.status === 'both') { + if (!data.payment_url) { + api.overlay.showError(api.getTranslation('payments_url_missing')); + api.resetPrintErrorMessage(cb, notificationTimeout); + return; + } + const qrUrl = + 'https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=' + + encodeURIComponent(data.payment_url); + const statusClass = { + both: 'overlay-both', + qr: 'overlay-qr' + }[data.status]; + const overlay = document.querySelector('.overlay'); + + api.overlay.show(` +