403Webshell
Server IP : 202.61.199.114  /  Your IP : 216.73.217.139
Web Server : nginx/1.22.1
System : Linux de.arni-solutions.de 6.1.0-49-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.174-1 (2026-05-26) x86_64
User : web20 ( 1018)
PHP Version : 8.4.23
Disable Function : NONE
MySQL : OFF  |  cURL : ON  |  WGET : ON  |  Perl : ON  |  Python : OFF  |  Sudo : ON  |  Pkexec : ON
Directory :  /var/www/seaside.pacim.de/web/

Upload File :
current_dir [ Writeable ] document_root [ Writeable ]

 

Command :


[ Back ]     

Current File : /var/www/seaside.pacim.de/web//api.php
<?php
/**
 * api.php – Öffentliche, schlüsselbasierte API (Einstellungen → API).
 *
 * Authentifizierung:  Header  "Authorization: Bearer <key>"  ODER  "X-API-Key: <key>"  ODER  ?api_key=<key>
 * Anfrage:            ?resource=<booking|event|product>&action=<...>   (+ JSON- oder Form-Body bei POST)
 * Berechtigungen:     read_booking, edit_booking, read_event, edit_event, read_product, edit_product
 *
 * Antworten sind JSON (Ausnahme: PDF-Dateien werden als application/pdf gestreamt).
 */
require_once( __DIR__ . '/includes/config.inc.php' );
@ini_set( 'display_errors', '0' );
error_reporting( 0 );

/* ------------------------------------------------------------------ Helpers */
function api_out( $code, $payload ) {
    // Zugriff protokollieren (Fehlertext mitnehmen), dann ausgeben.
    if ( isset( $GLOBALS['API_LOG'] ) && function_exists( 'api_log_flush' ) ) {
        if ( is_array( $payload ) && isset( $payload['error'] ) ) $GLOBALS['API_LOG']['error'] = $payload['error'];
        api_log_flush( $code );
    }
    // Performance: gesamte API-Request-Dauer (nur wenn Logging aktiv; fail-safe, kein PII).
    if ( class_exists( 'perflog' ) && isset( $_SERVER['REQUEST_TIME_FLOAT'] ) ) {
        perflog::record( $_SERVER['REQUEST_TIME_FLOAT'], 'api',
            ( isset( $_GET['resource'] ) ? preg_replace( '/[^a-z_]/i', '', (string) $_GET['resource'] ) : '?' ) . '/' . ( isset( $_GET['action'] ) ? preg_replace( '/[^a-z_]/i', '', (string) $_GET['action'] ) : '?' ),
            'api_request', $code >= 400 ? 'error' : 'ok' );
    }
    http_response_code( $code );
    header( 'Content-Type: application/json; charset=utf-8' );
    echo json_encode( $payload );
    exit();
}
function api_ok( $data = array() ) { api_out( 200, array_merge( array( 'ok' => true ), $data ) ); }
function api_err( $code, $msg ) { api_out( $code, array( 'ok' => false, 'error' => $msg ) ); }

function api_key_from_request() {
    $h = '';
    if ( isset( $_SERVER['HTTP_AUTHORIZATION'] ) ) $h = $_SERVER['HTTP_AUTHORIZATION'];
    elseif ( isset( $_SERVER['HTTP_X_API_KEY'] ) ) return trim( $_SERVER['HTTP_X_API_KEY'] );
    elseif ( function_exists( 'apache_request_headers' ) ) {
        $hdr = apache_request_headers();
        if ( isset( $hdr['Authorization'] ) ) $h = $hdr['Authorization'];
        elseif ( isset( $hdr['X-API-Key'] ) ) return trim( $hdr['X-API-Key'] );
    }
    if ( $h !== '' && stripos( $h, 'bearer ' ) === 0 ) return trim( substr( $h, 7 ) );
    if ( isset( $_GET['api_key'] ) ) return trim( (string) $_GET['api_key'] );
    if ( isset( $_POST['api_key'] ) ) return trim( (string) $_POST['api_key'] );
    return '';
}

/* JSON- oder Form-Body einlesen */
function api_body() {
    static $body = null;
    if ( $body !== null ) return $body;
    $raw = file_get_contents( 'php://input' );
    $body = array();
    if ( $raw !== '' && $raw[0] === '{' ) { $d = json_decode( $raw, true ); if ( is_array( $d ) ) $body = $d; }
    if ( ! count( $body ) ) $body = $_POST;
    return $body;
}
function api_param( $k, $d = null ) { $b = api_body(); if ( isset( $b[$k] ) ) return $b[$k]; if ( isset( $_GET[$k] ) ) return $_GET[$k]; return $d; }

/* ------------------------------------------------------------------ Auth */
$KEY = apikeys::authenticate( api_key_from_request() );
// Ausnahme „Capability-Modus": Der Dokument-Download (booking/file) ist auch OHNE API-Key erlaubt,
// wenn ein gültiges edit_hash (Capability-Token der Buchung) mitgesendet wird. So kann die Website
// dem Kunden einen direkten Download-Link für die Buchungsbestätigung/Rechnung geben. Der Hash wird
// in der file-Aktion strikt gegen die Buchung geprüft (falsch -> 403). Gilt NUR für action=file.
$capFile = (
    in_array( strtolower( trim( (string) ( $_GET['resource'] ?? '' ) ) ), array( 'booking', 'bookings' ), true )
    && strtolower( trim( (string) ( $_GET['action'] ?? '' ) ) ) === 'file'
    && trim( (string) api_param( 'edit_hash', '' ) ) !== ''
);
if ( ! $KEY ) {
    if ( ! $capFile ) api_err( 401, 'Ungültiger oder fehlender API-Schlüssel.' );
    $KEY = array(); // Capability-Modus: kein Key – Zugriff ausschließlich per edit_hash (nur booking/file)
}
function need( $scope ) { global $KEY; if ( empty( $KEY ) ) return; /* Capability-Modus (edit_hash) ohne Key */ if ( ! apikeys::hasScope( $KEY, $scope ) ) api_err( 403, 'Fehlende Berechtigung: ' . $scope ); }

/* Partner-/Vermittler-Token? api_broker > 0 = Zugriff strikt auf EIGENE Buchungen begrenzt. */
$BROKER = (int) ( is_array( $KEY ) && isset( $KEY['api_broker'] ) ? $KEY['api_broker'] : 0 );
/** Prüft, ob eine Rechnung dem aktuellen Partner gehört (bei Admin-Token immer true). */
function broker_owns_invoice( $iid ) {
    global $BROKER, $db;
    if ( $BROKER <= 0 ) return true;
    $r = $db->rawQueryOne( "SELECT invoice_broker FROM pacim_invoice WHERE invoice_id = ?", array( (int) $iid ) );
    return $r && (int) $r['invoice_broker'] === $BROKER;
}

/** Capability-Token (edit_hash) einer Buchung.
 *  Vorrang: das von WordPress beim Anlegen übermittelte & gespeicherte booking_edit_hash;
 *  sonst (Altbuchungen) der deterministisch berechnete Hash (sha256 booking_id|booking_no|crypt_key). */
function api_booking_edit_hash( $bk ) {
    if ( isset( $bk['booking_edit_hash'] ) && trim( (string) $bk['booking_edit_hash'] ) !== '' ) return trim( (string) $bk['booking_edit_hash'] );
    $key = function_exists( 'pacim_crypt_key' ) ? pacim_crypt_key() : 'pacim';
    return substr( hash( 'sha256', $bk['booking_id'] . '|' . $bk['booking_no'] . '|' . $key ), 0, 32 );
}
/** Vom Client übermitteltes edit_hash bereinigen (32-stelliges Token; nur Hex/Alnum). */
function api_clean_edit_hash( $h ) {
    $h = trim( (string) $h );
    return preg_match( '/^[A-Za-z0-9]{8,40}$/', $h ) ? $h : '';
}
/** Falls ein edit_hash mitgesendet wurde, MUSS es zur Buchung passen (sonst 403 invalid_hash). */
function api_check_edit_hash( $bk ) {
    $h = trim( (string) api_param( 'edit_hash', '' ) );
    if ( $h !== '' && ! hash_equals( api_booking_edit_hash( $bk ), $h ) ) api_err( 403, 'invalid_hash' );
}
/** Buchung ist „vorbei": Anreise (booking_begin) liegt HEUTE oder in der Vergangenheit (<= heute).
 *  Solche Buchungen werden über die API nicht mehr ausgeliefert (get/find/file). */
function api_booking_is_past( $beginDate ) {
    $d = substr( (string) $beginDate, 0, 10 );
    if ( $d === '' || $d === '0000-00-00' ) return false; // ohne gültiges Datum nicht blocken
    return $d <= date( 'Y-m-d' );
}

/* ---- Fahrzeugtyp <-> Aufpreisprodukt ---- */
function api_car_valid( $car ) { return in_array( strtolower( trim( (string) $car ) ), array( 'car', 'transporter', 'camper', 'trailercar' ), true ); }
/** Aufpreis-Produkt-ID je Fahrzeugtyp: camper=3 (Wohnmobil), trailercar=9 (Wohnanhänger); sonst 0 (kein Aufpreis). */
function api_car_product( $car ) {
    $car = strtolower( trim( (string) $car ) );
    if ( $car === 'camper' ) return 3;
    if ( $car === 'trailercar' ) return 9;
    return 0;
}
/** Eine Zusatz-Position (Produkt) für eine Buchung an-/abwählen (idempotent). */
function api_set_addon( $invoiceId, $bookingId, $productId, $present, $year ) {
    global $db;
    $exists = $db->rawQueryOne( "SELECT invoice_pos_id FROM pacim_invoice_pos WHERE invoice_id=? AND booking_id=? AND product_id=? LIMIT 1", array( (int) $invoiceId, (int) $bookingId, (int) $productId ) );
    if ( $present ) {
        if ( $exists ) return;
        $p = bookingservice::productPriceNet( $db, (int) $productId, (int) $year );
        if ( ! $p ) return;
        $db->insert( 'pacim_invoice_pos', array( 'invoice_id' => (int) $invoiceId, 'booking_id' => (int) $bookingId, 'product_id' => (int) $productId, 'invoice_pos_title' => $p['title'], 'invoice_pos_price' => $p['net'], 'invoice_pos_quantity' => 1 ) );
    } elseif ( $exists ) {
        $db->rawQuery( "DELETE FROM pacim_invoice_pos WHERE invoice_id=? AND booking_id=? AND product_id=?", array( (int) $invoiceId, (int) $bookingId, (int) $productId ) );
    }
}
/** Stellplatz-Position (Produkt 1/2) einer Buchung auf $type setzen + Preis nach Dauer neu berechnen. */
function api_set_parking( $db, $invoiceId, $bookingId, $type, $begin, $end ) {
    $type  = ( (int) $type === 2 ) ? 2 : 1;
    $dur   = bookingservice::duration( $begin, $end );
    $year  = (int) date( 'Y', strtotime( substr( (string) $begin, 0, 10 ) ) );
    $net   = bookingservice::parkingPriceNet( $type, $dur, $year ); if ( $net === false ) $net = 0.0;
    $pt    = $db->rawQueryOne( "SELECT product_title FROM pacim_products WHERE product_id = ?", array( $type ) );
    $title = ( $pt && $pt['product_title'] !== '' ) ? $pt['product_title'] : ( $type === 2 ? 'Außenparkplatz' : 'Hallenparkplatz' );
    // Vorhandene Stellplatz-Position wiederverwenden: bevorzugt die dieser Buchung, sonst eine noch
    // NICHT zugeordnete (booking_id NULL/0 – z. B. aus dem AIDA-Import bzw. Altbestand). Verhindert
    // doppelte Stellplätze bei API-Änderungen: früher wurde nur booking_id=? geprüft, die Legacy-Position
    // mit booking_id NULL übersehen und dadurch eine ZWEITE Stellplatz-Position eingefügt.
    $exists = $db->rawQueryOne(
        "SELECT invoice_pos_id FROM pacim_invoice_pos
          WHERE invoice_id=? AND product_id IN (1,2) AND (booking_id=? OR booking_id IS NULL OR booking_id=0)
          ORDER BY (booking_id=?) DESC, invoice_pos_id ASC LIMIT 1",
        array( (int) $invoiceId, (int) $bookingId, (int) $bookingId ) );
    if ( $exists ) {
        // Genau diese Position aktualisieren und fest der Buchung zuordnen (macht künftige Edits sauber).
        $db->rawQuery( "UPDATE pacim_invoice_pos SET product_id=?, invoice_pos_title=?, invoice_pos_price=?, booking_id=? WHERE invoice_pos_id=?",
            array( $type, $title, $net, (int) $bookingId, (int) $exists['invoice_pos_id'] ) );
    } else {
        $db->insert( 'pacim_invoice_pos', array( 'invoice_id' => (int) $invoiceId, 'booking_id' => (int) $bookingId, 'product_id' => $type, 'invoice_pos_title' => $title, 'invoice_pos_price' => $net, 'invoice_pos_quantity' => 1 ) );
    }
}

$db       = new MysqliDb( MYSQL_HOST, MYSQL_USER, MYSQL_PASS, MYSQL_DB );
$resource = strtolower( trim( (string) ( $_GET['resource'] ?? '' ) ) );
$action   = strtolower( trim( (string) ( $_GET['action'] ?? '' ) ) );
$isPost   = ( $_SERVER['REQUEST_METHOD'] === 'POST' );

// Alias-/Plural-Ressourcen tolerieren (z. B. Sync nutzt ships/events/products).
// Format: alias => [ziel-resource, default-action (falls keine angegeben)]
$RES_ALIASES = array(
    'ships'    => array( 'event',   'ships' ),
    'event'    => array( 'event',   '' ),
    'events'   => array( 'event',   'list' ),
    'products' => array( 'product', 'list' ),
    'product'  => array( 'product', '' ),
    'bookings' => array( 'booking', '' ),
    'booking'  => array( 'booking', '' ),
);
if ( isset( $RES_ALIASES[ $resource ] ) ) {
    $a = $RES_ALIASES[ $resource ];
    $resource = $a[0];
    if ( $action === '' && $a[1] !== '' ) $action = $a[1];
}

/* ------------------------------------------------------------------ Zugriffs-Protokoll */
$GLOBALS['API_LOG'] = array(
    'key'      => (int) ( $KEY['api_id'] ?? 0 ),
    'key_name' => (string) ( $KEY['api_name'] ?? '' ),
    'broker'   => $BROKER,
    'ip'       => $_SERVER['REMOTE_ADDR'] ?? '',
    'method'   => $_SERVER['REQUEST_METHOD'] ?? '',
    'resource' => $resource,
    'action'   => $action,
    'kind'     => apilog::isWrite( $action ) ? 'write' : 'read',
    'params'   => array_merge( (array) $_GET, (array) api_body() ),
    'booking'  => 0,
    'invoice'  => 0,
    'target'   => '',
    'changes'  => null,
    'error'    => '',
    '_logged'  => false,
);
function api_log_set( $k, $v ) { if ( isset( $GLOBALS['API_LOG'] ) ) $GLOBALS['API_LOG'][ $k ] = $v; }
function api_log_changes( $c ) { if ( isset( $GLOBALS['API_LOG'] ) ) $GLOBALS['API_LOG']['changes'] = $c; }
/** Genau einmal in die Protokolltabelle schreiben (per api_out oder als Shutdown-Fallback). */
function api_log_flush( $status ) {
    if ( empty( $GLOBALS['API_LOG'] ) || ! empty( $GLOBALS['API_LOG']['_logged'] ) ) return;
    $GLOBALS['API_LOG']['_logged'] = true;
    $L = $GLOBALS['API_LOG'];
    $L['status'] = (int) $status;
    if ( class_exists( 'apilog' ) ) apilog::record( $L );
}
// Fallback für Antworten ohne api_out (z. B. PDF-Stream der file-Aktion).
register_shutdown_function( function () { if ( function_exists( 'api_log_flush' ) ) api_log_flush( http_response_code() ?: 200 ); } );

/* ============================================================ BOOKING */
if ( $resource === 'booking' ) {

    // --- Buchung lesen ---
    if ( $action === 'get' ) {
        need( 'read_booking' );
        $iid = (int) api_param( 'invoice_id', 0 );
        if ( $iid <= 0 ) api_err( 400, 'invoice_id erforderlich.' );
        $inv = $db->rawQueryOne( "SELECT * FROM pacim_invoice WHERE invoice_id = ?", array( $iid ) );
        if ( ! $inv ) api_err( 404, 'Rechnung nicht gefunden.' );
        if ( $BROKER > 0 && (int) $inv['invoice_broker'] !== $BROKER ) api_err( 403, 'Kein Zugriff auf diese Buchung.' );
        $cust = $db->rawQueryOne( "SELECT * FROM pacim_customer WHERE customer_id = ?", array( (int) $inv['invoice_customer'] ) );
        $bookings = $db->rawQuery( "SELECT * FROM pacim_booking WHERE booking_invoice = ?", array( $iid ) );
        // Anreise heute/vergangen → nicht mehr ausliefern.
        if ( ! empty( $bookings ) && api_booking_is_past( $bookings[0]['booking_begin'] ) ) api_err( 403, 'booking_past' );
        $pos = $db->rawQuery( "SELECT invoice_pos_id, product_id, invoice_pos_title, invoice_pos_price, invoice_pos_quantity FROM pacim_invoice_pos WHERE invoice_id = ?", array( $iid ) );
        api_log_set( 'invoice', $iid );
        if ( ! empty( $bookings ) ) api_log_set( 'booking', (int) $bookings[0]['booking_id'] );
        api_log_set( 'target', 'Buchung gelesen (Rechnung #' . $iid . ')' );
        api_ok( array(
            'invoice'   => $inv,
            'customer'  => $cust,
            'bookings'  => $bookings,
            'positions' => $pos,
            'hint'      => (string) ( ! empty( $bookings ) ? ( $bookings[0]['booking_hint'] ?? '' ) : '' ), // Kunden-/Partner-Hinweis
            'cancelled' => ( (int) $inv['invoice_status'] === 3 ),                       // true = storniert
            'status'    => ( (int) $inv['invoice_status'] === 3 ? 'cancelled' : 'active' ), // active | cancelled
            'files'     => array(
                'booking_confirmation' => SITE_URL . '/api.php?resource=booking&action=file&type=booking&invoice_id=' . $iid,
                'invoice'              => SITE_URL . '/api.php?resource=booking&action=file&type=invoice&invoice_id=' . $iid,
            ),
        ) );
    }

    // --- PDF (Buchungsbestätigung / Rechnung) als Datei streamen ---
    if ( $action === 'file' ) {
        // Dokument-Download streamt binär + exit() (kein api_out) -> ganze Request-Dauer per Shutdown messen.
        if ( class_exists( 'perflog' ) ) perflog::wholeRequest( 'api', 'booking/file', 'api_file' );
        need( 'read_booking' );
        $rawId    = (int) api_param( 'invoice_id', 0 );
        $type     = strtolower( trim( (string) api_param( 'type', 'booking' ) ) );
        $hash     = trim( (string) api_param( 'edit_hash', '' ) );
        $download = ( (int) api_param( 'download', 0 ) === 1 );
        if ( $rawId <= 0 ) api_err( 400, 'invoice_id erforderlich.' );
        if ( $type !== 'invoice' && $type !== 'booking' ) $type = 'booking';

        // ID-Mapping: WordPress übergibt teils die booking_id (aus booking/find)
        // als invoice_id. Zuerst als booking_id auflösen, sonst als invoice_id.
        $bk    = $db->rawQueryOne( "SELECT * FROM pacim_booking WHERE booking_id = ? LIMIT 1", array( $rawId ) );
        $invId = 0;
        if ( $bk ) {
            $invId = (int) $bk['booking_invoice'];
        } else {
            $inv = $db->rawQueryOne( "SELECT invoice_id FROM pacim_invoice WHERE invoice_id = ? LIMIT 1", array( $rawId ) );
            if ( $inv ) {
                $invId = (int) $inv['invoice_id'];
                $bk    = $db->rawQueryOne( "SELECT * FROM pacim_booking WHERE booking_invoice = ? ORDER BY booking_id ASC LIMIT 1", array( $invId ) );
            }
        }
        if ( ! $bk ) api_err( 404, 'not_found' );

        // Capability-Token: wird edit_hash mitgeschickt, muss es zur Buchung passen
        // (verhindert das Abgreifen fremder Dokumente über durchprobierte IDs).
        if ( $hash !== '' && ! hash_equals( api_booking_edit_hash( $bk ), $hash ) ) api_err( 403, 'invalid_hash' );

        // Partner-Token bleiben auf eigene Buchungen beschränkt.
        if ( ! broker_owns_invoice( $invId ) ) api_err( 403, 'Kein Zugriff auf diese Buchung.' );
        if ( $invId <= 0 ) api_err( 404, 'no_document' ); // kein Datensatz für ein PDF
        // Anreise heute/vergangen → Dokument nicht mehr über die API ausliefern.
        if ( api_booking_is_past( $bk['booking_begin'] ) ) api_err( 403, 'booking_past' );

        api_log_set( 'booking', (int) $bk['booking_id'] );
        api_log_set( 'invoice', $invId );
        api_log_set( 'target', ( $type === 'invoice' ? 'Rechnung' : 'Buchungsbestätigung' ) . '-PDF abgerufen' );

        global $oData, $oPDF;
        $dest = $download ? 'D' : 'I';

        if ( $type === 'invoice' ) {
            // Echte Rechnung erfordert eine vergebene Rechnungsnummer.
            $inv = $db->rawQueryOne( "SELECT invoice_no FROM pacim_invoice WHERE invoice_id = ?", array( $invId ) );
            if ( ! $inv || trim( (string) $inv['invoice_no'] ) === '' ) api_err( 404, 'no_document' );
            $oPDF->createInvoice( $oData->dataset( $invId ), FALSE, FALSE, FALSE, $dest );
        } else {
            // Buchungsbestätigung – unabhängig davon, ob schon eine Rechnung existiert.
            $oPDF->createBooking( $oData->dataset( $invId ), FALSE, $dest );
        }
        exit();
    }

    // --- Buchung suchen (Kunden-Login: Buchungsnummer + Nachname) ---
    if ( $action === 'find' ) {
        need( 'read_booking' );
        $bno  = trim( (string) api_param( 'booking_no', '' ) );
        $last = trim( (string) api_param( 'lastname', '' ) );
        $notFound = array( 'ok' => false, 'error' => 'not_found' );
        if ( $bno === '' || $last === '' ) api_out( 200, $notFound );

        $bk = $db->rawQueryOne( "SELECT * FROM pacim_booking WHERE booking_no = ? ORDER BY booking_id ASC LIMIT 1", array( $bno ) );
        if ( ! $bk ) api_out( 200, $notFound );
        // Partner-Token: nur eigene Buchungen (sonst „nicht gefunden")
        if ( ! broker_owns_invoice( (int) $bk['booking_invoice'] ) ) api_out( 200, $notFound );

        $cust = $db->rawQueryOne( "SELECT * FROM pacim_customer WHERE customer_id = ?", array( (int) $bk['booking_customer'] ) );
        if ( ! $cust ) api_out( 200, $notFound );
        // Nachname (case-insensitive); bei Firmenkunden ohne Nachname gegen Firmennamen prüfen.
        $cl = mb_strtolower( trim( (string) $cust['customer_lastname'] ), 'UTF-8' );
        $cc = mb_strtolower( trim( (string) $cust['customer_name'] ), 'UTF-8' );
        $in = mb_strtolower( $last, 'UTF-8' );
        if ( ! ( $in === $cl || ( $cl === '' && $cc !== '' && $in === $cc ) ) ) api_out( 200, $notFound ); // selber Fehler wie „nicht gefunden"

        // Storno-Status + ob dem Kunden bereits eine Storno-Mail zugegangen ist. (Für die WP-„Buchung storniert"-
        // Ansicht: die wird nur bei cancelled && storno_mail_sent gezeigt – vorher darf die Stornierung dem Kunden
        // NICHT offenbart werden.) Storno-Mail = versendete/erzeugte Nachricht mit dem Template 'cancellation'.
        $invRow      = $db->rawQueryOne( "SELECT invoice_no, invoice_status FROM pacim_invoice WHERE invoice_id = ?", array( (int) $bk['booking_invoice'] ) );
        $hasInvoice  = $invRow && trim( (string) $invRow['invoice_no'] ) !== '';   // herunterladbare Rechnung (Rechnungsnr. vergeben)
        $isCancelled = $invRow && (int) $invRow['invoice_status'] === 3;           // 3 = storniert
        $stornoMailSent = false; $cancelledAt = null;
        if ( $isCancelled ) {
            $ctpl = $db->rawQueryOne( "SELECT template_id FROM pacim_templates WHERE template_key = 'cancellation' LIMIT 1" );
            $cid  = $ctpl ? (int) $ctpl['template_id'] : 5;   // Fallback: Template 5 = Stornierung
            $sm = $db->rawQueryOne(
                "SELECT COUNT(*) AS c, MIN(message_datetime) AS first_dt FROM pacim_messages
                  WHERE message_template = ? AND message_status <> 0
                    AND ( message_booking = ? OR message_invoice = ? )",
                array( $cid, (int) $bk['booking_id'], (int) $bk['booking_invoice'] ) );
            if ( $sm && (int) $sm['c'] > 0 ) { $stornoMailSent = true; $cancelledAt = (string) $sm['first_dt']; }
        }

        // Anreise heute/vergangen → normal nicht mehr ausliefern (eigener Fehler, da Kunde sich verifiziert hat).
        // AUSNAHME: stornierte Buchung MIT bereits zugegangener Storno-Mail → trotzdem liefern, damit WP die
        // „Buchung storniert"-Ansicht (nur Reisedaten + Hinweis) zeigen kann. Ohne Storno-Mail bleibt die Zeit-Sperre.
        if ( ! ( $isCancelled && $stornoMailSent ) && api_booking_is_past( $bk['booking_begin'] ) ) {
            api_out( 200, array( 'ok' => false, 'error' => 'booking_past' ) );
        }

        // Buchungseigener Reisezeitraum (tatsächlich reservierter Park-/Reisezeitraum).
        $bookBegin = substr( (string) $bk['booking_begin'], 0, 10 );
        $bookEnd   = substr( (string) $bk['booking_end'], 0, 10 );

        // Event/Schiff – nur ein EXAKT zur Buchung passender Termin liefert event_id +
        // Kontingente (für „Weiteres Fahrzeug"). event.begin/end fällt sonst auf die
        // Buchungsdaten zurück (kein falscher Event-Zeitraum). ship_id/ship_name werden
        // auch bei nur ungefährem Treffer gefüllt (Schiff ist datumsunabhängig korrekt).
        $event = array( 'event_id' => null, 'ship_id' => null, 'ship_name' => (string) $bk['booking_event'],
            'begin' => $bookBegin, 'end' => $bookEnd,
            'indoor' => null, 'outdoor' => null, 'valet' => null );
        $title = trim( (string) $bk['booking_event'] );
        $eid   = (int) ( $bk['booking_event_id'] ?? 0 );
        $exact = null;  // verifizierter Termin der Buchung
        $shipEv = null; // beliebiger Termin desselben Schiffs (nur für ship_id/name)
        if ( $eid > 0 ) {
            $cand = $db->rawQueryOne( "SELECT * FROM pacim_events WHERE event_calendar_id = ?", array( $eid ) );
            // Verknüpften Termin nur akzeptieren, wenn er zur Buchung passt (Titel ODER Anreisedatum).
            if ( $cand ) {
                $sameTitle = $title === '' || mb_strtolower( trim( (string) $cand['event_calendar_title'] ), 'UTF-8' ) === mb_strtolower( $title, 'UTF-8' );
                $sameDate  = substr( (string) $cand['event_calendar_begin'], 0, 10 ) === $bookBegin;
                if ( $sameTitle && ( $sameDate || $title !== '' ) ) { $exact = $cand; }
                $shipEv = $cand;
            }
        }
        if ( ! $exact && $title !== '' ) {
            // exakter Treffer: gleiches Schiff/Event UND gleiches Anreisedatum
            $exact = $db->rawQueryOne( "SELECT * FROM pacim_events WHERE event_calendar_title = ? AND DATE(event_calendar_begin) = ? ORDER BY event_calendar_id ASC LIMIT 1", array( $title, $bookBegin ) );
            // sonst nächstgelegener Termin desselben Schiffs – NUR für ship_id/ship_name
            if ( ! $shipEv ) $shipEv = $db->rawQueryOne( "SELECT * FROM pacim_events WHERE event_calendar_title = ? ORDER BY ABS(DATEDIFF(event_calendar_begin, ?)) ASC, event_calendar_id ASC LIMIT 1", array( $title, $bookBegin ) );
        }
        if ( $shipEv ) {
            $event['ship_id']   = $shipEv['event_calendar_info_id'] !== null ? (int) $shipEv['event_calendar_info_id'] : null;
            $event['ship_name'] = (string) $shipEv['event_calendar_title'];
        }
        if ( $exact ) {
            $event['event_id']  = (int) $exact['event_calendar_id'];
            $event['ship_id']   = $exact['event_calendar_info_id'] !== null ? (int) $exact['event_calendar_info_id'] : $event['ship_id'];
            $event['ship_name'] = (string) $exact['event_calendar_title'];
            $event['begin']     = substr( (string) $exact['event_calendar_begin'], 0, 10 );
            $event['end']       = substr( (string) $exact['event_calendar_end'], 0, 10 );
            $event['indoor']    = $exact['event_calendar_indoor']  === null ? null : (int) $exact['event_calendar_indoor'];
            $event['outdoor']   = $exact['event_calendar_outdoor'] === null ? null : (int) $exact['event_calendar_outdoor'];
            $event['valet']     = $exact['event_calendar_valet']   === null ? null : (int) $exact['event_calendar_valet'];
        }

        // Fahrzeugtyp: gespeicherter Wert, sonst aus Positionen ableiten (3=camper,9=trailercar), sonst car
        $car = strtolower( trim( (string) ( $bk['booking_car'] ?? '' ) ) );
        // Positionen
        $pos = $db->rawQuery( "SELECT product_id, invoice_pos_title, invoice_pos_price, invoice_pos_quantity FROM pacim_invoice_pos WHERE invoice_id = ? ORDER BY invoice_pos_id ASC", array( (int) $bk['booking_invoice'] ) );
        $products = array(); $hasCamper = false; $hasTrailer = false;
        foreach ( (array) $pos as $p ) {
            $pid = (int) $p['product_id'];
            if ( $pid === 3 ) $hasCamper = true;
            if ( $pid === 9 ) $hasTrailer = true;
            $products[] = array(
                'pacim_id'   => $pid,
                'name'       => (string) $p['invoice_pos_title'],
                'unit_price' => round( (float) $p['invoice_pos_price'] * 1.19, 2 ),
                'quantity'   => (int) $p['invoice_pos_quantity'],
            );
        }
        if ( ! api_car_valid( $car ) ) $car = $hasCamper ? 'camper' : ( $hasTrailer ? 'trailercar' : 'car' );

        // Check-in als „YYYY-MM-DD HH:MM:SS"
        $ciTime = trim( (string) ( $bk['booking_cruise_checkin'] ?? '' ) );
        if ( $ciTime === '' ) $ciTime = '00:00:00';
        $checkin = substr( (string) $bk['booking_begin'], 0, 10 ) . ' ' . substr( $ciTime, 0, 8 );

        $editHash = api_booking_edit_hash( $bk );

        // has_invoice = herunterladbare Rechnung + Storno-Status: bereits oben (vor der Zeit-Sperre) berechnet.

        api_log_set( 'booking', (int) $bk['booking_id'] );
        api_log_set( 'invoice', (int) $bk['booking_invoice'] );
        api_log_set( 'target', 'Buchung gesucht (' . $bk['booking_no'] . ')' );

        api_ok( array( 'booking' => array(
            'booking_id'    => (int) $bk['booking_id'],
            'invoice_id'    => (int) $bk['booking_invoice'],
            'has_invoice'   => (bool) $hasInvoice,
            'cancelled'       => (bool) $isCancelled,                       // true = storniert
            'status'          => ( $isCancelled ? 'cancelled' : 'active' ), // active | cancelled
            'storno_mail_sent'=> (bool) $stornoMailSent,                    // true = Storno-Mail bereits zugegangen/erzeugt
            'cancelled_at'    => $cancelledAt,                              // "YYYY-MM-DD HH:MM:SS" oder null (Zeitpunkt der Storno-Mail)
            'booking_no'    => (string) $bk['booking_no'],
            'edit_hash'     => $editHash,
            'begin'         => $bookBegin, // tatsächlich gebuchter Reisezeitraum (Buchungs-Ebene)
            'end'           => $bookEnd,
            'car'           => $car,
            'license_plate' => (string) $bk['booking_serial'],
            'persons'       => (int) $bk['booking_guests'],
            'hint'          => (string) ( $bk['booking_hint'] ?? '' ), // Kunden-/Partner-Hinweis
            'checkin'       => $checkin,
            'type'          => (int) $bk['booking_type'],
            'valet'         => (int) $bk['booking_valet'],
            'event'         => $event,
            'customer'      => array(
                'firstname' => (string) $cust['customer_firstname'],
                'lastname'  => (string) $cust['customer_lastname'],
                'email'     => (string) $cust['customer_email'],
                'phone'     => (string) $cust['customer_phone'],
                'company'   => (string) $cust['customer_name'],
            ),
            'address'       => array(
                'street'  => (string) $cust['customer_street'],
                'zip'     => (string) $cust['customer_zipcode'],
                'city'    => (string) $cust['customer_city'],
                'country' => (string) $cust['customer_country'],
            ),
            'products'      => $products,
        ) ) );
    }

    // --- Buchung anlegen ---
    if ( $action === 'create' ) {
        need( 'edit_booking' );
        if ( ! $isPost ) api_err( 405, 'POST erforderlich.' );
        $in = api_body();

        // Idempotenz: gleiche booking_no schon vorhanden (Retry des WP-Push) -> bestehende ID zurück.
        $bno = trim( (string) ( $in['booking_no'] ?? '' ) );
        if ( $bno !== '' ) {
            $ex = $db->rawQueryOne( "SELECT booking_id, booking_invoice, booking_no, booking_edit_hash FROM pacim_booking WHERE booking_no = ? LIMIT 1", array( $bno ) );
            if ( $ex ) api_ok( array( 'invoice_id' => (int) $ex['booking_invoice'], 'booking_id' => (int) $ex['booking_id'], 'booking_no' => $ex['booking_no'], 'edit_hash' => api_booking_edit_hash( $ex ), 'idempotent' => true ) );
        }

        if ( isset( $in['car'] ) && $in['car'] !== '' && ! api_car_valid( $in['car'] ) ) api_err( 422, 'invalid_car' );

        // Produkte: extras (bzw. products) + Fahrzeug-Aufpreis (camper=3/trailercar=9) + VALET(8)
        $products = array();
        foreach ( (array) ( isset( $in['extras'] ) ? $in['extras'] : ( $in['products'] ?? array() ) ) as $pid ) { $pid = (int) $pid; if ( $pid > 2 ) $products[] = $pid; }
        if ( isset( $in['car'] ) ) { $cp = api_car_product( $in['car'] ); if ( $cp ) $products[] = $cp; }
        if ( filter_var( $in['valet'] ?? false, FILTER_VALIDATE_BOOLEAN ) ) $products[] = 8;
        $products = array_values( array_unique( $products ) );

        // Sichtbarkeit prüfen
        $ptype = ( (int) ( $in['type'] ?? 1 ) === 2 ) ? 2 : 1;
        $pv = $db->rawQueryOne( "SELECT product_visible FROM pacim_products WHERE product_id = ?", array( $ptype ) );
        if ( $pv && (int) $pv['product_visible'] !== 1 ) api_err( 403, 'Dieses Parkplatz-Produkt ist nicht verfügbar.' );
        if ( count( $products ) ) {
            $invis = $db->rawQuery( "SELECT product_id FROM pacim_products WHERE product_visible = 0 AND product_id IN (" . implode( ',', $products ) . ")" );
            if ( count( $invis ) ) api_err( 403, 'Ein angefordertes Produkt ist nicht verfügbar.' );
        }

        // Zusatzfahrzeug: Event/Termin von der Eltern-Buchung übernehmen, wenn event_id fehlt/0.
        $parentId = (int) ( $in['parent_booking_id'] ?? 0 );
        if ( $parentId > 0 && empty( $in['event_id'] ) ) {
            $par = $db->rawQueryOne( "SELECT booking_event, booking_event_id FROM pacim_booking WHERE booking_id = ?", array( $parentId ) );
            if ( $par ) {
                if ( ! empty( $par['booking_event_id'] ) ) $in['event_id'] = (int) $par['booking_event_id'];
                if ( empty( $in['event'] ) && ! empty( $par['booking_event'] ) ) $in['event'] = $par['booking_event'];
            }
        }
        // Event-Name aus event_id auflösen (bookingservice nutzt den Namen)
        if ( empty( $in['event'] ) && ! empty( $in['event_id'] ) ) {
            $ev = $db->rawQueryOne( "SELECT event_calendar_title FROM pacim_events WHERE event_calendar_id = ?", array( (int) $in['event_id'] ) );
            if ( $ev ) $in['event'] = $ev['event_calendar_title'];
        }
        // Kundendaten/Anschrift aufbereiten.
        if ( ! isset( $in['customer'] ) || ! is_array( $in['customer'] ) ) $in['customer'] = array();
        // G6: Adresse wird bei create zusätzlich als Top-Level-`address`-Objekt gesendet -> in customer übernehmen.
        if ( isset( $in['address'] ) && is_array( $in['address'] ) ) {
            foreach ( array( 'street', 'zip', 'zipcode', 'city', 'country', 'company' ) as $k )
                if ( isset( $in['address'][ $k ] ) && ! isset( $in['customer'][ $k ] ) ) $in['customer'][ $k ] = $in['address'][ $k ];
        }
        // G9: Top-Level `gender` (3 = Rechnung auf Firma, 0 = Privat) ebenfalls akzeptieren.
        if ( isset( $in['gender'] ) && ! isset( $in['customer']['gender'] ) ) $in['customer']['gender'] = (int) $in['gender'];
        // Alias (WP): zip -> zipcode (Postleitzahl)
        if ( isset( $in['customer']['zip'] ) && ! isset( $in['customer']['zipcode'] ) ) $in['customer']['zipcode'] = $in['customer']['zip'];
        // G9: Zusatzfahrzeug ohne Anschrift im Request -> Rechnungsanschrift der Eltern-Buchung übernehmen.
        if ( $parentId > 0 ) {
            $hasAddr = trim( (string) ( $in['customer']['street'] ?? '' ) ) !== '' || trim( (string) ( $in['customer']['zipcode'] ?? '' ) ) !== '' || trim( (string) ( $in['customer']['city'] ?? '' ) ) !== '';
            if ( ! $hasAddr ) {
                $pc = $db->rawQueryOne( "SELECT c.customer_street, c.customer_zipcode, c.customer_city, c.customer_country FROM pacim_booking b LEFT JOIN pacim_customer c ON c.customer_id = b.booking_customer WHERE b.booking_id = ?", array( $parentId ) );
                if ( $pc ) {
                    $in['customer']['street']  = (string) $pc['customer_street'];
                    $in['customer']['zipcode'] = (string) $pc['customer_zipcode'];
                    $in['customer']['city']    = (string) $pc['customer_city'];
                    $in['customer']['country'] = (string) $pc['customer_country'];
                }
            }
        }

        $in['products'] = $products;
        if ( $BROKER > 0 ) $in['brokerId'] = $BROKER; // Partner-Token: dem eigenen Partner zuordnen
        // Hinweis (booking_hint) tolerant entgegennehmen: top-level `hint` bevorzugt; Fallback `note`
        // (Alias) oder verschachtelt in customer.hint (häufiger Übergabe-Fehler).
        if ( trim( (string) ( $in['hint'] ?? '' ) ) === '' ) {
            if ( isset( $in['note'] ) && trim( (string) $in['note'] ) !== '' ) $in['hint'] = (string) $in['note'];
            elseif ( isset( $in['customer']['hint'] ) && trim( (string) $in['customer']['hint'] ) !== '' ) $in['hint'] = (string) $in['customer']['hint'];
        }
        // Quelle (Referenz): explizit gesendete `source` hat Vorrang. Ohne Angabe ist die Standardquelle
        // für Website-Buchungen (kein Partner-Token) „Parken-am-Schiff.de" – nicht der Partner-Default.
        if ( $BROKER <= 0 && trim( (string) ( $in['source'] ?? '' ) ) === '' ) $in['source'] = 'Parken-am-Schiff.de';
        // Bestätigungsmail erst NACH Übernahme der Shop-Buchungsnummer auslösen (sonst enthält
        // [booking_no] noch die vorläufige CRM-Nummer). Wunsch des Aufrufers merken, intern aus.
        $notifyWanted = ! isset( $in['notify'] ) || filter_var( $in['notify'], FILTER_VALIDATE_BOOLEAN );
        $in['notify'] = false;

        $res = bookingservice::create( $in );
        if ( empty( $res['ok'] ) ) api_err( 422, $res['error'] ?? 'Buchung fehlgeschlagen.' );

        // Nachträge: Buchungsnummer/-code, Fahrzeugtyp, Check-in-Zeit, edit_hash.
        $editHashIn = api_clean_edit_hash( $in['edit_hash'] ?? '' ); // ggf. vom Aufrufer (WP) erzeugtes Capability-Token
        $db->rawQuery( "ALTER TABLE pacim_booking ADD COLUMN IF NOT EXISTS booking_edit_hash VARCHAR(40) NULL" );
        $upd = array();
        if ( $bno !== '' ) {
            // Aufrufer hat eine eigene Nummer mitgegeben (z. B. WordPress-Shop) -> übernehmen (Verhalten unverändert).
            $upd['booking_no'] = $bno;
        } elseif ( function_exists( 'pacim_unique_booking_code' ) ) {
            // KEINE Nummer übergeben (z. B. Website-Buchungsmaske) -> systemweit eindeutigen 6-stelligen
            // Buchungscode erzeugen UND ein frisches edit_hash-Token vergeben. Import/andere Wege bleiben
            // unberührt (die laufen nicht über diesen API-Pfad bzw. senden eine eigene Nummer).
            $gen = pacim_unique_booking_code( $db, 6 );
            if ( $gen ) {
                $upd['booking_no'] = $gen;
                $bno = $gen;
                if ( $editHashIn === '' ) $editHashIn = bin2hex( random_bytes( 16 ) ); // 32 Hex Capability-Token
            }
        }
        if ( $editHashIn !== '' ) $upd['booking_edit_hash'] = $editHashIn; // muss gespeichert werden (file/update/cancel/…)
        if ( isset( $in['car'] ) && $in['car'] !== '' ) $upd['booking_car'] = strtolower( trim( (string) $in['car'] ) );
        if ( isset( $in['checkin'] ) ) { $ci = trim( (string) $in['checkin'] ); if ( preg_match( '/^([01]\d|2[0-3]):([0-5]\d)$/', $ci ) ) $upd['booking_cruise_checkin'] = $ci . ':00'; }
        if ( count( $upd ) ) { $db->where( 'booking_id', (int) $res['booking_id'] ); $db->update( 'pacim_booking', $upd ); }

        // Bestätigungsmail jetzt mit FINALEM Datenstand auslösen ([booking_no] = echte DB-Buchungsnummer).
        if ( $notifyWanted && class_exists( 'mailrules' ) ) {
            $cRow = $db->rawQueryOne( "SELECT booking_customer FROM pacim_booking WHERE booking_id = ?", array( (int) $res['booking_id'] ) );
            mailrules::evaluateEvent( 'booking_created', array(
                'invoice_id'  => (int) $res['invoice_id'],
                'booking_id'  => (int) $res['booking_id'],
                'customer_id' => $cRow ? (int) $cRow['booking_customer'] : 0,
                'to'          => trim( (string) ( $in['customer']['email'] ?? '' ) ),
            ), 'booking_confirmation' );
        }
        // Pushover-Benachrichtigung (interne Mitarbeiter) – unabhängig von der Kundenmail.
        if ( class_exists( 'pushnotify' ) ) pushnotify::booking( $db, (int) $res['invoice_id'], (int) $res['booking_id'] );

        api_log_set( 'booking', (int) $res['booking_id'] );
        api_log_set( 'invoice', (int) $res['invoice_id'] );
        api_log_set( 'target', 'Buchung angelegt (' . ( $bno !== '' ? $bno : $res['booking_no'] ) . ')' );
        // edit_hash der NEUEN Buchung (gespeichertes WP-Token, sonst berechnet) – für den Doc-Download.
        $finalNo  = ( $bno !== '' ? $bno : $res['booking_no'] );
        $editHash = api_booking_edit_hash( array( 'booking_id' => (int) $res['booking_id'], 'booking_no' => $finalNo, 'booking_edit_hash' => $editHashIn ) );
        $fileBase = SITE_URL . '/api.php?resource=booking&action=file&invoice_id=' . (int) $res['invoice_id'] . '&edit_hash=' . $editHash;
        api_ok( array(
            'invoice_id' => $res['invoice_id'], 'booking_id' => $res['booking_id'], 'booking_no' => $finalNo, 'edit_hash' => $editHash,
            'hint' => (string) ( $in['hint'] ?? '' ), // übernommener Hinweis (zur Kontrolle der Übergabe)
            // Fertige Dokument-URLs (mit Capability-Token). Aufruf weiterhin mit Bearer-Key (read_booking).
            'files' => array(
                'booking_confirmation' => $fileBase . '&type=booking&download=1',
                'invoice'              => $fileBase . '&type=invoice&download=1',
            ),
        ) );
    }

    // --- Buchung bearbeiten (whitelisted Felder) ---
    if ( $action === 'update' ) {
        need( 'edit_booking' );
        if ( ! $isPost ) api_err( 405, 'POST erforderlich.' );
        $bid = (int) api_param( 'booking_id', 0 );
        $bk = $bid > 0 ? $db->rawQueryOne( "SELECT * FROM pacim_booking WHERE booking_id = ?", array( $bid ) ) : null;
        if ( ! $bk ) api_err( 404, 'Buchung nicht gefunden.' );
        api_check_edit_hash( $bk ); // Capability-Token (falls mitgesendet)
        // Partner-Token darf NUR eigene Buchungen bearbeiten.
        if ( ! broker_owns_invoice( (int) $bk['booking_invoice'] ) ) api_err( 403, 'Kein Zugriff auf diese Buchung.' );

        api_log_set( 'booking', $bid );
        api_log_set( 'invoice', (int) $bk['booking_invoice'] );
        api_log_set( 'target', 'Buchung bearbeitet (' . $bk['booking_no'] . ')' );
        $logBefore = apilog::snapshot( $db, $bid ); // Vorher-Zustand für Diff + Rückgängig

        $b = api_body();
        $invId = (int) $bk['booking_invoice'];

        // G6: WP sendet die Adresse zusätzlich verschachtelt als `address` -> in customer übernehmen.
        if ( isset( $b['address'] ) && is_array( $b['address'] ) ) {
            if ( ! isset( $b['customer'] ) || ! is_array( $b['customer'] ) ) $b['customer'] = array();
            foreach ( array( 'street', 'zip', 'zipcode', 'city', 'country', 'company' ) as $k )
                if ( isset( $b['address'][ $k ] ) && ! isset( $b['customer'][ $k ] ) ) $b['customer'][ $k ] = $b['address'][ $k ];
        }
        // G9: Top-Level `gender` (3 = Rechnung auf Firma, 0 = Privat) ebenfalls akzeptieren.
        if ( isset( $b['gender'] ) ) {
            if ( ! isset( $b['customer'] ) || ! is_array( $b['customer'] ) ) $b['customer'] = array();
            if ( ! isset( $b['customer']['gender'] ) ) $b['customer']['gender'] = (int) $b['gender'];
        }

        /* ---------- Validierung VOR allen Schreibvorgängen ---------- */
        $ci = null;
        if ( isset( $b['checkin'] ) ) {
            $ci = trim( (string) $b['checkin'] );
            if ( ! preg_match( '/^([01]\d|2[0-3]):([0-5]\d)$/', $ci ) ) api_err( 422, 'invalid_checkin' );
        }
        if ( isset( $b['car'] ) && ! api_car_valid( $b['car'] ) ) api_err( 422, 'invalid_car' );
        if ( isset( $b['type'] ) && ! in_array( (int) $b['type'], array( 1, 2 ), true ) ) api_err( 422, 'invalid_type' );

        // Effektiver Stellplatztyp + Zeitraum (Basis für Preisberechnung)
        $effType  = isset( $b['type'] ) ? ( (int) $b['type'] === 2 ? 2 : 1 ) : (int) $bk['booking_type'];
        $effBegin = ( isset( $b['begin'] ) && preg_match( '/^\d{4}-\d{2}-\d{2}$/', $b['begin'] ) ) ? $b['begin'] : $bk['booking_begin'];
        $effEnd   = ( isset( $b['end'] ) && preg_match( '/^\d{4}-\d{2}-\d{2}$/', $b['end'] ) ) ? $b['end'] : $bk['booking_end'];
        $year     = (int) date( 'Y', strtotime( substr( (string) $effBegin, 0, 10 ) ) );

        // Zusatzprodukte: Soll-Liste (extras = vollständige Liste; valet/car separat steuerbar)
        $extrasGiven   = array_key_exists( 'extras', $b );
        $touchProducts = isset( $b['type'] ) || isset( $b['valet'] ) || isset( $b['car'] ) || $extrasGiven;
        $curAddons = array();
        foreach ( (array) $db->rawQuery( "SELECT DISTINCT product_id FROM pacim_invoice_pos WHERE invoice_id=? AND booking_id=? AND product_id>2", array( $invId, $bid ) ) as $r ) $curAddons[] = (int) $r['product_id'];
        if ( $extrasGiven ) {
            $desired = array();
            foreach ( (array) $b['extras'] as $pid ) { $pid = (int) $pid; if ( $pid > 2 ) $desired[] = $pid; }
            // VALET (8) wird separat über `valet` gesteuert -> aktuellen Stand erhalten, falls kein valet-Param.
            if ( ! isset( $b['valet'] ) && in_array( 8, $curAddons, true ) ) $desired[] = 8;
        } else {
            $desired = $curAddons;
        }
        if ( isset( $b['valet'] ) ) { $desired = array_values( array_filter( $desired, function ( $p ) { return (int) $p !== 8; } ) ); if ( filter_var( $b['valet'], FILTER_VALIDATE_BOOLEAN ) ) $desired[] = 8; }
        if ( isset( $b['car'] ) )   { $desired = array_values( array_filter( $desired, function ( $p ) { return (int) $p !== 3 && (int) $p !== 9; } ) ); $cp = api_car_product( strtolower( trim( (string) $b['car'] ) ) ); if ( $cp ) $desired[] = $cp; }
        $desired = array_values( array_unique( array_map( 'intval', $desired ) ) );

        // Kombi-Prüfung: Wohnmobil(3)/Gespann(9) nicht mit Halle (Typ 1) und nicht mit VALET (8).
        $hasRV    = in_array( 3, $desired, true ) || in_array( 9, $desired, true );
        $hasValet = in_array( 8, $desired, true );
        if ( $hasRV && ( $effType === 1 || $hasValet ) ) api_err( 422, 'invalid_combination' );

        /* ---------- Buchungsfelder ---------- */
        $bookFields = array();
        if ( isset( $b['begin'] ) && preg_match( '/^\d{4}-\d{2}-\d{2}$/', $b['begin'] ) ) $bookFields['booking_begin'] = $b['begin'];
        if ( isset( $b['end'] ) && preg_match( '/^\d{4}-\d{2}-\d{2}$/', $b['end'] ) ) $bookFields['booking_end'] = $b['end'];
        if ( isset( $b['plate'] ) )  $bookFields['booking_serial'] = strtoupper( trim( (string) $b['plate'] ) );
        if ( isset( $b['event'] ) )  $bookFields['booking_event'] = trim( (string) $b['event'] );
        if ( isset( $b['event_id'] ) ) $bookFields['booking_event_id'] = (int) $b['event_id'] ?: null;
        if ( isset( $b['guests'] ) ) $bookFields['booking_guests'] = (int) $b['guests'];
        if ( isset( $b['hint'] ) )   $bookFields['booking_hint'] = trim( (string) $b['hint'] ); // Kunden-/Partner-Hinweis ändern
        if ( isset( $b['type'] ) )   $bookFields['booking_type'] = $effType;
        if ( $ci !== null )          $bookFields['booking_cruise_checkin'] = $ci . ':00';
        // booking_valet immer am tatsächlichen VALET-Stand (Produkt 8) ausrichten – egal ob
        // VALET über `valet` oder über `extras` gesteuert wurde (dazu = 1, abgewählt = 0).
        if ( $touchProducts )        $bookFields['booking_valet'] = $hasValet ? 1 : 0;
        if ( isset( $b['car'] ) )    $bookFields['booking_car'] = strtolower( trim( (string) $b['car'] ) );
        elseif ( $extrasGiven )      $bookFields['booking_car'] = in_array( 3, $desired, true ) ? 'camper' : ( in_array( 9, $desired, true ) ? 'trailercar' : 'car' );
        if ( count( $bookFields ) ) { $db->where( 'booking_id', $bid ); $db->update( 'pacim_booking', $bookFields ); }

        /* ---------- Kundendaten (ohne firstname/lastname) ---------- */
        if ( isset( $b['customer'] ) && is_array( $b['customer'] ) ) {
            // Alias (WP): zip -> zipcode (Postleitzahl), wie bei create.
            if ( isset( $b['customer']['zip'] ) && ! isset( $b['customer']['zipcode'] ) ) $b['customer']['zipcode'] = $b['customer']['zip'];
            // Whitelist OHNE firstname/lastname (Name bleibt unveränderlich).
            $map = array( 'company'=>'customer_name','street'=>'customer_street','zipcode'=>'customer_zipcode','city'=>'customer_city','country'=>'customer_country','phone'=>'customer_phone','email'=>'customer_email','gender'=>'customer_gender' );
            $cf = array();
            foreach ( $map as $k => $col ) if ( isset( $b['customer'][$k] ) ) $cf[$col] = ( $k === 'gender' ) ? (int) $b['customer'][$k] : trim( (string) $b['customer'][$k] );
            if ( count( $cf ) ) { $db->where( 'customer_id', (int) $bk['booking_customer'] ); $db->update( 'pacim_customer', $cf ); }
        }

        /* ---------- Stellplatz/Produkte neu aufbauen + Preis serverseitig berechnen ---------- */
        $repriced = $touchProducts || isset( $b['begin'] ) || isset( $b['end'] );
        if ( $repriced ) {
            // Stellplatz-Position auf effektiven Typ + Preis (nach Dauer) setzen
            api_set_parking( $db, $invId, $bid, $effType, $effBegin, $effEnd );
            // Zusatzprodukte gemäß Soll-Liste abgleichen (idempotent): entfernen + hinzufügen
            foreach ( array_diff( $curAddons, $desired ) as $pid ) api_set_addon( $invId, $bid, (int) $pid, false, $year );
            foreach ( $desired as $pid ) api_set_addon( $invId, $bid, (int) $pid, true, $year );
        }

        /* ---------- Zahlungsstatus / Preis ---------- */
        $inf = array();
        if ( isset( $b['paid'] ) ) { $inf['invoice_payment'] = $b['paid'] ? 1 : 0; $inf['invoice_paydate'] = $b['paid'] ? date( 'Y-m-d H:i:s' ) : null; }
        if ( isset( $b['price'] ) && is_numeric( $b['price'] ) ) {
            $inf['invoice_price'] = round( (float) $b['price'], 2 ); // manueller Brutto-Override
        } elseif ( $repriced ) {
            // Kein manueller Preis -> Rechnungsbetrag (Netto-Summe aller Positionen) neu berechnen.
            $netSum = (float) ( $db->rawQueryOne( "SELECT COALESCE(SUM(invoice_pos_price*invoice_pos_quantity),0) s FROM pacim_invoice_pos WHERE invoice_id=?", array( $invId ) )['s'] ?? 0 );
            $inf['invoice_price'] = round( $netSum, 2 );
        }
        if ( count( $inf ) ) { $db->where( 'invoice_id', $invId ); $db->update( 'pacim_invoice', $inf ); }

        api_log_changes( apilog::diff( $logBefore, apilog::snapshot( $db, $bid ) ) );
        if ( class_exists( 'pushnotify' ) ) pushnotify::bookingUpdate( $db, (int) $bk['booking_invoice'], $bid );
        api_ok( array( 'booking_id' => $bid, 'invoice_price' => array_key_exists( 'invoice_price', $inf ) ? $inf['invoice_price'] : null ) );
    }

    // --- Buchung stornieren ---
    if ( $action === 'cancel' ) {
        need( 'edit_booking' );
        if ( ! $isPost ) api_err( 405, 'POST erforderlich.' );
        $bid = (int) api_param( 'booking_id', 0 );
        $bk  = $bid > 0 ? $db->rawQueryOne( "SELECT * FROM pacim_booking WHERE booking_id = ?", array( $bid ) ) : null;
        if ( ! $bk ) api_err( 404, 'not_found' );
        api_check_edit_hash( $bk ); // Capability-Token (falls mitgesendet)
        if ( ! broker_owns_invoice( (int) $bk['booking_invoice'] ) ) api_err( 403, 'Kein Zugriff auf diese Buchung.' );
        $iid = (int) $bk['booking_invoice'];
        $inv = $db->rawQueryOne( "SELECT invoice_status, invoice_customer FROM pacim_invoice WHERE invoice_id = ?", array( $iid ) );
        if ( ! $inv ) api_err( 404, 'not_found' );
        if ( (int) $inv['invoice_status'] === 3 ) api_err( 409, 'already_cancelled' );
        api_log_set( 'booking', $bid );
        api_log_set( 'invoice', $iid );
        api_log_set( 'target', 'Buchung storniert (' . $bk['booking_no'] . ')' );
        $logBefore = apilog::snapshot( $db, $bid );
        $now = date( 'Y-m-d H:i:s' );
        $db->where( 'invoice_id', $iid );
        $db->update( 'pacim_invoice', array( 'invoice_status' => 3 ) );
        api_log_changes( apilog::diff( $logBefore, apilog::snapshot( $db, $bid ) ) );
        // Storno-Mail automatisch erzeugen + in die Queue legen (Cron versendet im Hintergrund, an den
        // Regeln vorbei). Sicherheits-Logik gegen versehentliche Mails an „alte" Stornos:
        //  - dieser Block wird NUR beim frischen Storno-Übergang erreicht (bereits stornierte Buchungen
        //    wurden oben mit already_cancelled (409) abgewiesen),
        //  - enqueueStorno() dedupliziert je Buchung (sendet nie zweimal),
        //  - vergangene Anreisen erhalten keine Storno-Mail mehr.
        // notify=false ist ein expliziter Opt-out (z. B. für stille Massen-/Migrationsstornos).
        $wantMail = filter_var( api_param( 'notify', true ), FILTER_VALIDATE_BOOLEAN );
        if ( $wantMail && class_exists( 'mailqueue' ) && ! api_booking_is_past( $bk['booking_begin'] ) ) {
            mailqueue::enqueueStorno( $db, $iid, $bid, (int) $inv['invoice_customer'] );
        }
        api_ok( array( 'booking_id' => $bid, 'cancelled_at' => $now ) );
    }

    // --- Individuellen Reisezeitraum anfragen (unverbindlich; ändert die Buchung NICHT) ---
    if ( $action === 'request-period' ) {
        need( 'edit_booking' );
        if ( ! $isPost ) api_err( 405, 'POST erforderlich.' );
        $bid = (int) api_param( 'booking_id', 0 );
        $bk  = $bid > 0 ? $db->rawQueryOne( "SELECT booking_id, booking_invoice, booking_no, booking_edit_hash FROM pacim_booking WHERE booking_id = ?", array( $bid ) ) : null;
        if ( ! $bk ) api_err( 404, 'not_found' );
        api_check_edit_hash( $bk ); // Capability-Token (falls mitgesendet)
        if ( ! broker_owns_invoice( (int) $bk['booking_invoice'] ) ) api_err( 403, 'Kein Zugriff auf diese Buchung.' );
        $begin = substr( (string) api_param( 'begin', '' ), 0, 10 );
        $end   = substr( (string) api_param( 'end', '' ), 0, 10 );
        if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $begin ) || ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $end ) || strtotime( $end ) <= strtotime( $begin ) ) api_err( 422, 'invalid_period' );
        // Optionale Wunsch-Anreisezeit (HH:MM)
        $checkin = trim( (string) api_param( 'checkin', '' ) );
        if ( $checkin !== '' && ! preg_match( '/^([01]\d|2[0-3]):([0-5]\d)$/', $checkin ) ) api_err( 422, 'invalid_checkin' );
        $db->rawQuery( "CREATE TABLE IF NOT EXISTS pacim_booking_requests (
            request_id INT AUTO_INCREMENT PRIMARY KEY,
            request_booking INT NOT NULL,
            request_invoice INT NOT NULL DEFAULT 0,
            request_begin DATE NULL,
            request_end DATE NULL,
            request_note TEXT NULL,
            request_status VARCHAR(20) NOT NULL DEFAULT 'pending',
            request_source VARCHAR(20) NOT NULL DEFAULT 'api',
            request_created DATETIME NULL,
            KEY request_booking (request_booking),
            KEY request_status (request_status)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" );
        $db->rawQuery( "ALTER TABLE pacim_booking_requests ADD COLUMN IF NOT EXISTS request_checkin VARCHAR(5) NULL" );
        $db->rawQuery( "ALTER TABLE pacim_booking_requests ADD COLUMN IF NOT EXISTS request_price DECIMAL(10,2) NULL" );
        $note = trim( (string) api_param( 'note', '' ) );
        $iid  = (int) $bk['booking_invoice'];
        // Neuen Preis für den Wunschzeitraum direkt bei Eingang im CRM berechnen (Brutto).
        $pr = bookingservice::invoicePeriodPrice( $db, $iid, $begin, $end );
        $reqGross = $pr ? (float) $pr['gross'] : null;
        $rid  = $db->insert( 'pacim_booking_requests', array(
            'request_booking' => $bid,
            'request_invoice' => $iid,
            'request_type'    => 'period',
            'request_begin'   => $begin,
            'request_end'     => $end,
            'request_checkin' => ( $checkin !== '' ? $checkin : null ),
            'request_price'   => $reqGross,
            'request_note'    => $note,
            'request_status'  => 'pending',
            'request_source'  => 'api',
            'request_created' => date( 'Y-m-d H:i:s' ),
        ) );
        if ( ! $rid ) api_err( 500, 'Anfrage konnte nicht gespeichert werden.' );
        api_log_set( 'booking', $bid );
        api_log_set( 'invoice', $iid );
        api_log_set( 'target', 'Wunschzeitraum angefragt (' . $begin . ' – ' . $end . ( $checkin !== '' ? ', ' . $checkin : '' ) . ')' );
        // Mail-Regel: „Anfrage eingegangen (Terminwunsch)" – ohne booking_id, damit die
        // booking-basierte alreadySent-Dedupe mehrere Anfragen nicht blockt.
        if ( class_exists( 'mailrules' ) ) {
            $cust = $iid > 0 ? $db->rawQueryOne( "SELECT invoice_customer FROM pacim_invoice WHERE invoice_id = ?", array( $iid ) ) : null;
            mailrules::evaluateEvent( 'request_received_period', array(
                'invoice_id'  => $iid,
                'customer_id' => $cust ? (int) $cust['invoice_customer'] : 0,
                'ctx'         => array(
                    'request_type'    => 'period',
                    'request_begin'   => $begin,
                    'request_end'     => $end,
                    'request_checkin' => $checkin,
                    'request_note'    => $note,
                    'request_status'  => 'pending',
                    'request_price'   => $reqGross !== null ? number_format( $reqGross, 2, ',', '.' ) . ' €' : '',
                ),
            ), 'request_received_period' );
        }
        if ( class_exists( 'pushnotify' ) ) pushnotify::period( $db, (int) $rid );
        api_ok( array( 'request_id' => (int) $rid, 'status' => 'pending', 'checkin' => ( $checkin !== '' ? $checkin : null ), 'price' => $reqGross ) );
    }

    // --- Zeitraum-Anfragen einer Buchung lesen (Gast-Ansicht: Status/Entscheidung) ---
    if ( $action === 'period-requests' ) {
        need( 'read_booking' );
        $bid = (int) api_param( 'booking_id', 0 );
        $bk  = $bid > 0 ? $db->rawQueryOne( "SELECT booking_id, booking_invoice, booking_no, booking_edit_hash FROM pacim_booking WHERE booking_id = ?", array( $bid ) ) : null;
        if ( ! $bk ) api_err( 404, 'not_found' );
        api_check_edit_hash( $bk ); // Capability-Token (falls mitgesendet)
        if ( ! broker_owns_invoice( (int) $bk['booking_invoice'] ) ) api_err( 404, 'not_found' );
        api_log_set( 'booking', $bid ); api_log_set( 'invoice', (int) $bk['booking_invoice'] );
        api_log_set( 'target', 'Zeitraum-Anfragen gelesen' );
        if ( ! $db->rawQueryOne( "SHOW TABLES LIKE 'pacim_booking_requests'" ) ) api_ok( array( 'requests' => array() ) );
        // Spalten idempotent sicherstellen
        foreach ( array(
            "ADD COLUMN IF NOT EXISTS request_checkin VARCHAR(5) NULL",
            "ADD COLUMN IF NOT EXISTS request_price DECIMAL(10,2) NULL",
            "ADD COLUMN IF NOT EXISTS request_decision_note TEXT NULL",
            "ADD COLUMN IF NOT EXISTS request_staff VARCHAR(190) NULL",
            "ADD COLUMN IF NOT EXISTS request_decided_at DATETIME NULL",
        ) as $alter ) $db->rawQuery( "ALTER TABLE pacim_booking_requests $alter" );
        $rows = $db->rawQuery( "SELECT request_id, request_begin, request_end, request_checkin, request_price, request_note, request_status, request_decision_note, request_staff, request_decided_at, request_created FROM pacim_booking_requests WHERE request_booking = ? AND request_type = 'period' ORDER BY request_created DESC, request_id DESC", array( $bid ) );
        $out = array();
        foreach ( (array) $rows as $r ) {
            $out[] = array(
                'id'            => (int) $r['request_id'],
                'begin'         => (string) $r['request_begin'],
                'end'           => (string) $r['request_end'],
                'checkin'       => ( $r['request_checkin'] !== null && $r['request_checkin'] !== '' ) ? (string) $r['request_checkin'] : null,
                'price'         => ( $r['request_price'] !== null ) ? (float) $r['request_price'] : null,
                'note'          => (string) $r['request_note'],
                'status'        => (string) $r['request_status'],
                'decision_note' => (string) ( $r['request_decision_note'] ?? '' ),
                'staff_name'    => (string) ( $r['request_staff'] ?? '' ),
                'decided_at'    => (string) ( $r['request_decided_at'] ?? '' ),
                'created_at'    => (string) ( $r['request_created'] ?? '' ),
            );
        }
        api_ok( array( 'requests' => $out ) );
    }

    api_err( 404, 'Unbekannte booking-Aktion.' );
}

/* ============================================================ EVENT (Schiffe & Termine) */
if ( $resource === 'event' ) {

    // --- Schiffe (eindeutige Namen) ---
    if ( $action === 'ships' ) {
        need( 'read_event' );
        $rows = $db->rawQuery( "SELECT event_calendar_title AS ship, event_calendar_cruise_id AS cruise_id, COUNT(*) AS dates
                                FROM pacim_events WHERE event_calendar_title <> '' AND event_calendar_active = 1 GROUP BY event_calendar_title, event_calendar_cruise_id ORDER BY event_calendar_title" );
        api_ok( array( 'ships' => $rows ) );
    }

    // --- Kommende Termine: ohne ship_id alle Schiffe -> kommende Termine; mit ship_id: coming + letzte 10 (done) ---
    if ( $action === 'upcoming' ) {
        need( 'read_event' );
        $sel = "e.event_calendar_id AS id, e.event_calendar_title AS ship, e.event_calendar_cruise_id AS cruise_id,
                o.organizer_name AS company, e.event_calendar_begin AS begin, e.event_calendar_end AS end,
                e.event_calendar_indoor AS indoor, e.event_calendar_outdoor AS outdoor, e.event_calendar_valet AS valet,
                IF(e.event_calendar_description_public=1, e.event_calendar_description, NULL) AS description";
        $from = "FROM pacim_events e LEFT JOIN pacim_organizers o ON o.organizer_id = e.event_calendar_organizer";
        $ship = trim( (string) api_param( 'ship_id', api_param( 'ship', '' ) ) );
        // Reederei/Company eines Schiffs aus dessen Terminen ableiten (häufigste Nennung)
        $companyOf = function ( $rows ) {
            $c = array();
            foreach ( (array) $rows as $r ) { $n = trim( (string) ( $r['company'] ?? '' ) ); if ( $n !== '' ) $c[ $n ] = ( isset( $c[ $n ] ) ? $c[ $n ] + 1 : 1 ); }
            if ( ! count( $c ) ) return null;
            arsort( $c );
            return key( $c );
        };

        if ( $ship !== '' ) {
            $coming = $db->rawQuery( "SELECT $sel $from WHERE e.event_calendar_active = 1 AND e.event_calendar_title = ? AND e.event_calendar_begin >= NOW() ORDER BY e.event_calendar_begin ASC", array( $ship ) );
            $done   = $db->rawQuery( "SELECT $sel $from WHERE e.event_calendar_active = 1 AND e.event_calendar_title = ? AND e.event_calendar_begin <  NOW() ORDER BY e.event_calendar_begin DESC LIMIT 10", array( $ship ) );
            $data = array( 'ship' => $ship, 'company' => $companyOf( array_merge( (array) $coming, (array) $done ) ) );
            if ( count( (array) $coming ) ) $data['coming'] = $coming;   // nur wenn vorhanden
            $data['done'] = $done ? $done : array();
            api_ok( $data );
        }

        // ohne ship_id: alle aktiven Schiffe -> Company + nur kommende Termine
        $rows = $db->rawQuery( "SELECT $sel $from WHERE e.event_calendar_active = 1 AND e.event_calendar_begin >= NOW() AND e.event_calendar_title <> '' ORDER BY e.event_calendar_title ASC, e.event_calendar_begin ASC" );
        $grouped = array();
        foreach ( (array) $rows as $r ) { $grouped[ $r['ship'] ][] = $r; }
        $ships = array();
        foreach ( $grouped as $name => $evts ) {
            $ships[ $name ] = array( 'company' => $companyOf( $evts ), 'events' => $evts );
        }
        api_ok( array( 'ships' => $ships ) );
    }

    // --- Termine (optional je Schiff / Zeitraum) ---
    if ( $action === 'list' ) {
        need( 'read_event' );
        $where = array(); $params = array();
        $ship = trim( (string) api_param( 'ship', '' ) );
        if ( $ship !== '' ) { $where[] = "e.event_calendar_title = ?"; $params[] = $ship; }
        $from = (string) api_param( 'from', '' );
        if ( preg_match( '/^\d{4}-\d{2}-\d{2}$/', $from ) ) { $where[] = "DATE(e.event_calendar_begin) >= ?"; $params[] = $from; }
        $to = (string) api_param( 'to', '' );
        if ( preg_match( '/^\d{4}-\d{2}-\d{2}$/', $to ) ) { $where[] = "DATE(e.event_calendar_begin) <= ?"; $params[] = $to; }
        $where[] = "e.event_calendar_active = 1"; // deaktivierte Termine werden über die API NICHT ausgeliefert
        $sql = "SELECT e.event_calendar_id AS id, e.event_calendar_title AS ship, e.event_calendar_cruise_id AS cruise_id,
                       o.organizer_name AS company, e.event_calendar_begin AS begin, e.event_calendar_end AS end,
                       e.event_calendar_indoor AS indoor, e.event_calendar_outdoor AS outdoor, e.event_calendar_valet AS valet,
                       e.event_calendar_active AS active,
                       IF(e.event_calendar_description_public=1, e.event_calendar_description, NULL) AS description
                  FROM pacim_events e LEFT JOIN pacim_organizers o ON o.organizer_id = e.event_calendar_organizer";
        if ( count( $where ) ) $sql .= " WHERE " . implode( ' AND ', $where );
        $sql .= " ORDER BY e.event_calendar_begin ASC LIMIT 500";
        api_ok( array( 'events' => $db->rawQuery( $sql, $params ) ) );
    }

    if ( $action === 'get' ) {
        need( 'read_event' );
        $id = (int) api_param( 'id', 0 );
        // Deaktivierte Termine werden über die API NICHT ausgeliefert.
        $e = $db->rawQueryOne( "SELECT * FROM pacim_events WHERE event_calendar_id = ? AND event_calendar_active = 1", array( $id ) );
        if ( ! $e ) api_err( 404, 'Termin nicht gefunden.' );
        // Beschreibung nur ausliefern, wenn als öffentlich markiert
        if ( (int) ( $e['event_calendar_description_public'] ?? 0 ) !== 1 ) $e['event_calendar_description'] = null;
        api_ok( array( 'event' => $e ) );
    }

    // --- Termin anlegen/bearbeiten ---
    if ( $action === 'save' ) {
        need( 'edit_event' );
        if ( ! $isPost ) api_err( 405, 'POST erforderlich.' );
        $b = api_body();
        $fields = array();
        if ( isset( $b['ship'] ) )      $fields['event_calendar_title'] = trim( (string) $b['ship'] );
        if ( isset( $b['cruise_id'] ) ) $fields['event_calendar_cruise_id'] = trim( (string) $b['cruise_id'] );
        if ( isset( $b['begin'] ) )     $fields['event_calendar_begin'] = (string) $b['begin'];
        if ( isset( $b['end'] ) )       $fields['event_calendar_end'] = (string) $b['end'];
        if ( isset( $b['indoor'] ) )    $fields['event_calendar_indoor'] = (int) $b['indoor'];
        if ( isset( $b['outdoor'] ) )   $fields['event_calendar_outdoor'] = (int) $b['outdoor'];
        if ( isset( $b['valet'] ) )     $fields['event_calendar_valet'] = (int) $b['valet'];
        if ( isset( $b['organizer_id'] ) ) $fields['event_calendar_organizer'] = (int) $b['organizer_id'];
        if ( isset( $b['description'] ) ) $fields['event_calendar_description'] = (string) $b['description']; // HTML erlaubt
        if ( isset( $b['description_public'] ) ) $fields['event_calendar_description_public'] = $b['description_public'] ? 1 : 0;
        if ( isset( $b['active'] ) )     $fields['event_calendar_active'] = $b['active'] ? 1 : 0;
        $id = (int) api_param( 'id', 0 );
        if ( $id > 0 ) {
            if ( ! count( $fields ) ) api_err( 400, 'Keine Felder zum Aktualisieren.' );
            $db->where( 'event_calendar_id', $id ); $db->update( 'pacim_events', $fields );
            api_ok( array( 'id' => $id ) );
        }
        if ( empty( $fields['event_calendar_title'] ) || empty( $fields['event_calendar_begin'] ) ) api_err( 400, 'ship und begin erforderlich.' );
        if ( ! isset( $fields['event_calendar_active'] ) ) $fields['event_calendar_active'] = 1;
        $fields['event_calendar_created'] = date( 'Y-m-d H:i:s' );
        $newId = $db->insert( 'pacim_events', $fields );
        if ( ! $newId ) api_err( 422, 'Termin konnte nicht angelegt werden.' );
        api_ok( array( 'id' => (int) $newId ) );
    }

    api_err( 404, 'Unbekannte event-Aktion.' );
}

/* ============================================================ PRODUCT (Produkte & Preise) */
if ( $resource === 'product' ) {

    // --- Produktliste ---
    if ( $action === 'list' ) {
        need( 'read_product' );
        // Nur sichtbare Produkte sind über die API abrufbar.
        $rows = $db->rawQuery( "SELECT product_id, product_title, product_price, product_tax FROM pacim_products WHERE product_visible = 1 ORDER BY product_id" );
        api_ok( array( 'products' => $rows ) );
    }

    // --- Preis berechnen (zu Buchungsdaten / von-bis-Datum) ---
    if ( $action === 'price' ) {
        need( 'read_product' );
        $begin = substr( (string) api_param( 'begin', '' ), 0, 10 );
        $end   = substr( (string) api_param( 'end', '' ), 0, 10 );
        if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $begin ) || ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $end ) ) api_err( 400, 'begin und end (YYYY-MM-DD) erforderlich.' );
        $type = ( (int) api_param( 'type', 1 ) === 2 ) ? 2 : 1;
        // Unsichtbares Parkplatz-Produkt ist über die API nicht buchbar/abrufbar.
        $pv = $db->rawQueryOne( "SELECT product_visible FROM pacim_products WHERE product_id = ?", array( $type ) );
        if ( $pv && (int) $pv['product_visible'] !== 1 ) api_err( 403, 'Dieses Parkplatz-Produkt ist nicht verfügbar.' );
        $products = api_param( 'products', array() );
        if ( ! is_array( $products ) ) $products = array_filter( array_map( 'intval', preg_split( '/[\s,;]+/', (string) $products ) ) );
        $products = array_values( array_filter( array_map( 'intval', $products ) ) );
        // Unsichtbare Zusatzprodukte herausfiltern.
        if ( count( $products ) ) {
            $vis = array();
            foreach ( $db->rawQuery( "SELECT product_id FROM pacim_products WHERE product_visible = 1 AND product_id IN (" . implode( ',', $products ) . ")" ) as $r ) $vis[] = (int) $r['product_id'];
            $products = array_values( array_intersect( $products, $vis ) );
        }
        $q = bookingservice::quote( array( 'type' => $type, 'begin' => $begin, 'end' => $end, 'products' => $products, 'brokerId' => (int) api_param( 'broker_id', 0 ) ) );
        api_ok( array( 'quote' => $q ) );
    }

    // --- Produkt/Preis bearbeiten ---
    if ( $action === 'save' ) {
        need( 'edit_product' );
        if ( ! $isPost ) api_err( 405, 'POST erforderlich.' );
        $pid = (int) api_param( 'product_id', 0 );
        if ( $pid <= 0 || ! $db->rawQueryOne( "SELECT product_id FROM pacim_products WHERE product_id = ?", array( $pid ) ) ) api_err( 404, 'Produkt nicht gefunden.' );
        $b = api_body();
        $fields = array();
        if ( isset( $b['title'] ) ) $fields['product_title'] = trim( (string) $b['title'] );
        if ( isset( $b['price'] ) && is_numeric( $b['price'] ) ) $fields['product_price'] = round( (float) $b['price'], 6 );
        if ( isset( $b['tax'] ) ) $fields['product_tax'] = (int) $b['tax'];
        if ( count( $fields ) ) { $db->where( 'product_id', $pid ); $db->update( 'pacim_products', $fields ); }
        // Jahres-Preis (optional): year + price
        if ( isset( $b['year'] ) && (int) $b['year'] > 0 && isset( $b['price'] ) && is_numeric( $b['price'] ) ) {
            $yr = (int) $b['year']; $pr = round( (float) $b['price'], 6 );
            $ex = $db->rawQueryOne( "SELECT price_id FROM pacim_product_prices WHERE product_id = ? AND year = ?", array( $pid, $yr ) );
            if ( $ex ) { $db->where( 'price_id', (int) $ex['price_id'] ); $db->update( 'pacim_product_prices', array( 'price' => $pr ) ); }
            else $db->insert( 'pacim_product_prices', array( 'product_id' => $pid, 'year' => $yr, 'price' => $pr ) );
        }
        api_ok( array( 'product_id' => $pid ) );
    }

    // --- Parkplatz-Preistabelle (Staffelung) wie auf der Preise-Seite ---
    if ( $action === 'parking' || $action === 'tariffs' ) {
        need( 'read_product' );
        $year = (int) api_param( 'year', date( 'Y' ) );
        $years = array();
        foreach ( $db->rawQuery( "SELECT DISTINCT year y FROM pacim_parking_prices ORDER BY y DESC" ) as $r ) $years[] = (int) $r['y'];
        if ( count( $years ) && ! in_array( $year, $years, true ) ) $year = $years[0]; // Fallback: neuestes Jahr
        $out = array();
        foreach ( $db->rawQuery( "SELECT product_id, product_title, product_tax, product_visible FROM pacim_products WHERE product_id IN (1,2) ORDER BY product_id" ) as $p ) {
            if ( (int) $p['product_visible'] !== 1 ) continue; // unsichtbare Produkte nicht ausliefern
            $tax = (float) $p['product_tax'];
            $tiers = array();
            foreach ( $db->rawQuery( "SELECT days_range, price FROM pacim_parking_prices WHERE product_id = ? AND year = ? ORDER BY price_id", array( (int) $p['product_id'], $year ) ) as $t ) {
                $range = trim( (string) $t['days_range'] );
                $open  = ( substr( $range, -1 ) === '+' );
                if ( $open )                               { $from = (int) rtrim( $range, '+' ); $to = null; $label = 'ab ' . $from . ' Tage (pro Tag)'; }
                elseif ( strpos( $range, '-' ) !== false ) { list( $from, $to ) = array_map( 'intval', explode( '-', $range ) ); $label = $from . '–' . $to . ' Tage'; }
                else                                       { $from = (int) $range; $to = $from; $label = $from . ' Tage'; }
                $net = (float) $t['price'];
                $tiers[] = array(
                    'from' => $from, 'to' => $to, 'per_day' => $open, 'label' => $label,
                    'price_net' => round( $net, 2 ), 'price_gross' => round( $net * ( 1 + $tax / 100 ), 2 ),
                );
            }
            $out[] = array(
                'product_id' => (int) $p['product_id'], 'type' => ( (int) $p['product_id'] === 2 ? 'outdoor' : 'indoor' ),
                'title' => $p['product_title'], 'tax' => $tax, 'tiers' => $tiers,
            );
        }
        api_ok( array( 'year' => $year, 'parking' => $out ) );
    }

    api_err( 404, 'Unbekannte product-Aktion.' );
}

/* ============================================================ ARRIVAL-INFO (Anreise-Infos) */
if ( in_array( $resource, array( 'arrival-info', 'arrival_info', 'arrival-infos', 'arrival_infos', 'arrivalinfo' ), true ) ) {
    need( 'read_event' );
    $db->rawQuery( "SET NAMES utf8mb4" ); // Inhalt kann 4-Byte-Zeichen (Emojis) enthalten
    $labels = array( 'all' => 'Alle Parkplatztypen', 'indoor' => 'Hallenparkplatz', 'outdoor' => 'Außenparkplatz', 'valet' => 'Valet-Service' );
    try {
        $shipId  = (int) api_param( 'ship_id', 0 );
        $eventId = (int) api_param( 'event_id', 0 );
        // event_id -> Schiff-Stamm-ID (pacim_events.event_calendar_info_id) auflösen
        if ( $shipId <= 0 && $eventId > 0 ) {
            $e = $db->rawQueryOne( "SELECT event_calendar_info_id FROM pacim_events WHERE event_calendar_id = ?", array( $eventId ) );
            if ( $e ) $shipId = (int) $e['event_calendar_info_id'];
        }
        $parking = strtolower( trim( (string) api_param( 'parking_type', '' ) ) );
        if ( ! array_key_exists( $parking, $labels ) ) $parking = '';
        $checkin = trim( (string) api_param( 'checkin_time', '' ) );
        $ct = preg_match( '/^([01]?\d|2[0-3]):([0-5]\d)$/', $checkin ) ? ( ( strlen( $checkin ) === 4 ? '0' . $checkin : $checkin ) . ':00' ) : null;

        $where = array( 'arrival_info_active = 1' ); $params = array();
        if ( $shipId > 0 ) { $where[] = '(arrival_info_all_ships = 1 OR FIND_IN_SET(?, arrival_info_ship_ids))'; $params[] = $shipId; }
        if ( $parking !== '' && $parking !== 'all' ) { $where[] = "(arrival_info_parking_type = 'all' OR arrival_info_parking_type = ?)"; $params[] = $parking; }
        if ( $ct !== null ) { $where[] = '(arrival_info_no_time_limit = 1 OR (arrival_info_time_from <= ? AND arrival_info_time_to >= ?))'; $params[] = $ct; $params[] = $ct; }
        $w = implode( ' AND ', $where );
        $rows = $db->rawQuery( "SELECT * FROM pacim_arrival_info WHERE $w ORDER BY arrival_info_priority DESC, arrival_info_id DESC", $params );

        $items = array();
        foreach ( (array) $rows as $r ) {
            $all = ( (int) $r['arrival_info_all_ships'] === 1 );
            $shipNames = array(); $shipIds = array();
            if ( ! $all ) {
                $ids = array_values( array_filter( array_map( 'intval', preg_split( '/[\s,;]+/', (string) $r['arrival_info_ship_ids'] ) ) ) );
                if ( count( $ids ) ) foreach ( $db->rawQuery( "SELECT event_id AS id, event_title AS title FROM pacim_events_info WHERE event_id IN (" . implode( ',', $ids ) . ") ORDER BY event_title" ) as $s ) { $shipIds[] = (int) $s['id']; $shipNames[] = $s['title']; }
            }
            $type = array_key_exists( $r['arrival_info_parking_type'], $labels ) ? $r['arrival_info_parking_type'] : 'all';
            $noLimit = ( (int) $r['arrival_info_no_time_limit'] === 1 );
            $items[] = array(
                'id'                 => (int) $r['arrival_info_id'],
                'title'              => (string) $r['arrival_info_title'],
                'content'            => (string) $r['arrival_info_content'],
                'ships'              => $shipNames,
                'ship_ids'           => $shipIds,
                'all_ships'          => $all,
                'parking_type'       => $type,
                'parking_type_label' => $labels[ $type ],
                'time_from'          => $noLimit ? null : substr( (string) $r['arrival_info_time_from'], 0, 5 ),
                'time_to'            => $noLimit ? null : substr( (string) $r['arrival_info_time_to'], 0, 5 ),
                'no_time_limit'      => $noLimit,
                'priority'           => (int) $r['arrival_info_priority'],
                'active'             => true,
            );
        }
        api_out( 200, array( 'success' => true, 'count' => count( $items ), 'items' => $items ) );
    } catch ( Exception $e ) {
        api_out( 200, array( 'success' => false, 'message' => 'Die Anreise-Infos konnten nicht geladen werden.', 'items' => array() ) );
    }
}

/* ============================================================ CONTACT (Kontaktformular der Website) */
if ( $resource === 'contact' ) {

    // --- Kontaktanfrage als Lead/Vorgang im CRM anlegen (Backoffice: /requests/) ---
    if ( $action === 'create' ) {
        need( 'edit_booking' ); // Website-Key (gleiche Berechtigung wie booking/create)
        if ( ! $isPost ) api_err( 405, 'POST erforderlich.' );
        $db->rawQuery( "SET NAMES utf8mb4" ); // Nachricht kann 4-Byte-Zeichen (Emojis) enthalten
        $in = api_body();

        $first = trim( (string) ( $in['firstname'] ?? '' ) );
        $last  = trim( (string) ( $in['lastname'] ?? '' ) );
        $email = trim( (string) ( $in['email'] ?? '' ) );
        $msg   = trim( (string) ( $in['message'] ?? '' ) );
        if ( $first === '' || $last === '' || $email === '' || $msg === '' ) api_err( 422, 'missing_fields' );
        if ( ! filter_var( $email, FILTER_VALIDATE_EMAIL ) ) api_err( 422, 'invalid_email' );

        $ip      = substr( trim( (string) ( $in['ip_address'] ?? ( $_SERVER['REMOTE_ADDR'] ?? '' ) ) ), 0, 45 );
        $source  = trim( (string) ( $in['source'] ?? '' ) ); if ( $source === '' ) $source = 'website_contact_form';
        $rcv     = trim( (string) ( $in['received_at'] ?? '' ) );
        $created = preg_match( '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $rcv ) ? $rcv : date( 'Y-m-d H:i:s' );
        $name    = trim( $first . ' ' . $last );

        // Tabelle + benötigte Spalten sicherstellen (geteilt mit Wunschzeitraum-Anfragen).
        $db->rawQuery( "CREATE TABLE IF NOT EXISTS pacim_booking_requests (
            request_id INT AUTO_INCREMENT PRIMARY KEY,
            request_booking INT NOT NULL DEFAULT 0,
            request_invoice INT NOT NULL DEFAULT 0,
            request_begin DATE NULL, request_end DATE NULL,
            request_note TEXT NULL,
            request_status VARCHAR(20) NOT NULL DEFAULT 'pending',
            request_source VARCHAR(20) NOT NULL DEFAULT 'api',
            request_created DATETIME NULL,
            KEY request_status (request_status)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" );
        foreach ( array(
            "ADD COLUMN IF NOT EXISTS request_type VARCHAR(20) NOT NULL DEFAULT 'period'",
            "ADD COLUMN IF NOT EXISTS request_name VARCHAR(190) NULL",
            "ADD COLUMN IF NOT EXISTS request_email VARCHAR(190) NULL",
            "ADD COLUMN IF NOT EXISTS request_subject VARCHAR(190) NULL",
            "ADD COLUMN IF NOT EXISTS request_ip VARCHAR(45) NULL",
            "ADD COLUMN IF NOT EXISTS request_updated DATETIME NULL",
            "ADD KEY IF NOT EXISTS request_type (request_type)",
        ) as $alter ) @$db->rawQuery( "ALTER TABLE pacim_booking_requests $alter" );

        // Robust gegen Doppelübermittlung: gleiche E-Mail + Nachricht in den letzten 10 Min.
        $dup = $db->rawQueryOne(
            "SELECT request_id FROM pacim_booking_requests WHERE request_type='contact' AND request_email = ? AND request_note = ? AND request_created >= (NOW() - INTERVAL 10 MINUTE) ORDER BY request_id DESC LIMIT 1",
            array( $email, $msg )
        );
        if ( $dup ) {
            api_log_set( 'target', 'Kontaktanfrage (Duplikat, ' . $email . ')' );
            api_ok( array( 'id' => (int) $dup['request_id'], 'duplicate' => true ) );
        }

        $rid = $db->insert( 'pacim_booking_requests', array(
            'request_type'    => 'contact',
            'request_booking' => 0,
            'request_invoice' => 0,
            'request_note'    => $msg,
            'request_name'    => $name,
            'request_email'   => $email,
            'request_ip'      => $ip,
            'request_status'  => 'pending',
            'request_source'  => $source,
            'request_created' => $created,
        ) );
        if ( ! $rid ) api_err( 500, 'Anfrage konnte nicht gespeichert werden.' );
        api_log_set( 'target', 'Kontaktanfrage (' . $name . ' · ' . $email . ')' );
        // Mail-Regel: „Anfrage eingegangen (Kontaktformular)". Empfänger/Anrede aus den
        // Kontaktdaten (keine Buchung/Rechnung) – ohne Regel ein No-Op.
        if ( class_exists( 'mailrules' ) ) {
            $np = preg_split( '/\s+/', $name, 2 );
            mailrules::evaluateEvent( 'request_received_contact', array(
                'invoice_id' => 0, 'customer_id' => 0, 'to' => $email,
                'ctx' => array(
                    'request_type' => 'contact', 'request_note' => $msg, 'request_message' => $msg, 'request_status' => 'pending',
                    'request_name' => $name, 'request_email' => $email, 'request_source' => $source, 'request_ip' => $ip, 'request_created' => $created,
                    'customer_email' => $email, 'customer_firstname' => $np[0] ?? '', 'customer_lastname' => $np[1] ?? '',
                    'name' => $name, 'firstname' => $np[0] ?? '', 'lastname' => $np[1] ?? '', 'email' => $email,
                    'salutation' => 'Guten Tag' . ( $name !== '' ? ' ' . $name : '' ),
                ),
            ), 'request_received_contact' );
        }
        if ( class_exists( 'pushnotify' ) ) pushnotify::contact( $db, (int) $rid );
        api_ok( array( 'id' => (int) $rid ) );
    }

    api_err( 404, 'Unbekannte contact-Aktion.' );
}

api_err( 404, 'Unbekannte Ressource. Erlaubt: booking, event, product, arrival-info, contact.' );

Youez - 2016 - github.com/yon3zu
LinuXploit