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/seaside2.pacim.de/web/app/models/

Upload File :
current_dir [ Writeable ] document_root [ Writeable ]

 

Command :


[ Back ]     

Current File : /var/www/seaside2.pacim.de/web/app/models/Invoice.php
<?php

class Invoice
{
    private PDO $pdo;

    public function __construct(Database $db)
    {
        $this->pdo = $db->getConnection();
    }

    public function all(array $hideSources = []): array
    {
        // Unbounded — wird nur noch von Sonderfällen (z. B. Exports) gebraucht.
        // Listenseite nutzt paginated().
        [$where, $params] = $this->buildListWhere($hideSources);
        $sql = "SELECT i.*,
                       b.booking_no,
                       c.first_name, c.last_name, c.company
                FROM invoices i
                INNER JOIN bookings b ON b.id = i.booking_id
                INNER JOIN customers c ON c.id = i.customer_id
                WHERE $where
                ORDER BY i.id DESC";
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute($params);
        return $stmt->fetchAll();
    }

    /**
     * Server-seitig paginiert. Sort nach i.id DESC (indexbasiert, neueste zuerst) —
     *  das alte CASE/SUBSTRING-Schema war non-sargable und hat bei großen Tabellen
     *  einen vollständigen Filesort erzwungen.
     */
    public function paginated(array $hideSources = [], int $perPage = 20, int $page = 1): array
    {
        $perPage = max(1, $perPage);
        $page    = max(1, $page);

        [$where, $params] = $this->buildListWhere($hideSources);

        $countStmt = $this->pdo->prepare(
            "SELECT COUNT(*) FROM invoices i
              INNER JOIN bookings b ON b.id = i.booking_id
              WHERE $where"
        );
        $countStmt->execute($params);
        $total = (int)$countStmt->fetchColumn();

        $pages  = max(1, (int)ceil($total / $perPage));
        $page   = max(1, min($pages, $page));
        $offset = ($page - 1) * $perPage;

        $sql = "SELECT i.id, i.invoice_no, i.invoice_date, i.status,
                       i.total_net, i.total_vat, i.total_gross,
                       i.booking_id, i.customer_id,
                       b.booking_no,
                       c.first_name, c.last_name, c.company
                  FROM invoices i
                  INNER JOIN bookings b  ON b.id = i.booking_id
                  INNER JOIN customers c ON c.id = i.customer_id
                  WHERE $where
                  ORDER BY i.id DESC
                  LIMIT $perPage OFFSET $offset";
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute($params);
        $items = $stmt->fetchAll();

        return [
            'items'   => $items,
            'total'   => $total,
            'page'    => $page,
            'pages'   => $pages,
            'perPage' => $perPage,
            'from'    => $total === 0 ? 0 : $offset + 1,
            'to'      => min($total, $offset + $perPage),
        ];
    }

    private function buildListWhere(array $hideSources): array
    {
        $where  = '1=1';
        $params = [];
        if (!empty($hideSources)) {
            $phs = [];
            foreach ($hideSources as $i => $s) {
                $key = 'hs' . $i;
                $phs[] = ':' . $key;
                $params[$key] = (string)$s;
            }
            $where .= " AND COALESCE(b.source,'') NOT IN (" . implode(',', $phs) . ")";
        }
        return [$where, $params];
    }

    public function find(int $id): ?array
    {
        $sql = "SELECT i.*,
                       b.booking_no, b.status AS booking_status, b.total_net AS b_total_net, b.total_gross AS b_total_gross,
                       b.travel_from, b.travel_to, b.arrival_time, b.license_plate, b.notes,
                       c.first_name AS c_first_name, c.last_name AS c_last_name, c.company AS c_company,
                       c.street AS c_street, c.zip AS c_zip, c.city AS c_city,
                       c.email AS c_email, c.phone AS c_phone,
                       b.event_id,
                       e.title AS event_title, e.ship_name AS event_ship, e.terminal AS event_terminal,
                       e.starts_at AS event_starts_at, e.ends_at AS event_ends_at
                FROM invoices i
                INNER JOIN bookings b ON b.id = i.booking_id
                INNER JOIN customers c ON c.id = i.customer_id
                INNER JOIN events e ON e.id = b.event_id
                WHERE i.id = :id";
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute(['id' => $id]);
        $row = $stmt->fetch();
        return $row !== false ? $row : null;
    }

    /**
     * Rechnungen, die an einem bestimmten Tag erstellt wurden (invoice_date).
     * Optional auf ein Event und/oder eine bestimmte customer.source eingeschränkt.
     *
     * Zusätzlich:
     *  - $filters['event_ids']        — array<int>:    Multi-Event-Filter (b.event_id IN (...))
     *  - $filters['sources']          — array<string>: Multi-Quelle-Filter (b.source IN (...))
     *                                                  bzw. '__none__' für Buchungen ohne Quelle.
     *  - $filters['payment_methods']  — array<string>: Multi-Zahlungsart-Filter (EXISTS payments)
     *  - $filters['exclude_sources']  — array<string>: Quellen, die nie auftauchen dürfen
     *                                                  (sowohl b.source als auch c.source).
     */
    public function byDate(string $date, ?int $eventId = null, ?string $source = null, array $filters = []): array
    {
        // Sargable Range — nutzt idx_invoices_invoice_date (kein DATE()-Wrapper).
        $sql = "SELECT i.*,
                       b.booking_no, b.event_id, b.source AS booking_source,
                       c.first_name, c.last_name, c.company, c.source,
                       e.title AS event_title
                  FROM invoices i
                  INNER JOIN bookings b  ON b.id = i.booking_id
                  INNER JOIN customers c ON c.id = i.customer_id
                  LEFT  JOIN events    e ON e.id = b.event_id
                 WHERE i.invoice_date >= :d AND i.invoice_date < :d_next";
        $params = ['d' => $date, 'd_next' => date('Y-m-d', strtotime($date . ' +1 day'))];
        if ($eventId !== null) {
            $sql .= " AND b.event_id = :e";
            $params['e'] = $eventId;
        }
        if ($source !== null) {
            $sql .= " AND COALESCE(c.source,'') = :src";
            $params['src'] = $source;
        }

        $eventIds = array_values(array_filter(array_map('intval', $filters['event_ids'] ?? []), fn ($v) => $v > 0));
        if (!empty($eventIds)) {
            $phs = [];
            foreach ($eventIds as $i => $eid) { $k = 'fe' . $i; $phs[] = ':' . $k; $params[$k] = $eid; }
            $sql .= " AND b.event_id IN (" . implode(',', $phs) . ")";
        }

        $sources = array_values(array_filter(array_map('strval', $filters['sources'] ?? []), fn ($v) => $v !== ''));
        if (!empty($sources)) {
            $phs = [];
            $wantNone = false;
            foreach ($sources as $i => $s) {
                if ($s === '__none__') { $wantNone = true; continue; }
                $k = 'fs' . $i; $phs[] = ':' . $k; $params[$k] = $s;
            }
            $parts = [];
            if (!empty($phs)) $parts[] = "COALESCE(b.source,'') IN (" . implode(',', $phs) . ")";
            if ($wantNone)    $parts[] = "COALESCE(b.source,'') = ''";
            if (!empty($parts)) $sql .= " AND (" . implode(' OR ', $parts) . ")";
        }

        $methods = array_values(array_filter(array_map('strval', $filters['payment_methods'] ?? []), fn ($v) => $v !== ''));
        if (!empty($methods)) {
            $phs = [];
            foreach ($methods as $i => $m) { $k = 'fm' . $i; $phs[] = ':' . $k; $params[$k] = $m; }
            $sql .= " AND EXISTS (SELECT 1 FROM payments p WHERE p.invoice_id = i.id"
                  . " AND p.payment_method IN (" . implode(',', $phs) . "))";
        }

        $excludeSources = array_values(array_filter(array_map('strval', $filters['exclude_sources'] ?? []), fn ($v) => $v !== ''));
        if (!empty($excludeSources)) {
            // Zwei separate Placeholder-Sätze — MariaDB lehnt sonst HY093 ab,
            // wenn derselbe Platzhalter in zwei Klauseln auftaucht.
            $phsB = $phsC = [];
            foreach ($excludeSources as $i => $s) {
                $kb = 'fxb' . $i; $kc = 'fxc' . $i;
                $phsB[] = ':' . $kb; $phsC[] = ':' . $kc;
                $params[$kb] = $s;  $params[$kc] = $s;
            }
            $sql .= " AND COALESCE(b.source,'') NOT IN (" . implode(',', $phsB) . ")";
            $sql .= " AND COALESCE(c.source,'') NOT IN (" . implode(',', $phsC) . ")";
        }

        $sql .= " ORDER BY i.id DESC";
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute($params);
        return $stmt->fetchAll();
    }

    public function byCustomer(int $customerId): array
    {
        $sql = "SELECT i.*, b.booking_no
                FROM invoices i
                INNER JOIN bookings b ON b.id = i.booking_id
                WHERE i.customer_id = :id
                ORDER BY i.invoice_date DESC, i.id DESC";
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute(['id' => $customerId]);
        return $stmt->fetchAll();
    }

    public function byEvent(int $eventId): array
    {
        $sql = "SELECT i.*, b.booking_no
                FROM invoices i
                INNER JOIN bookings b ON b.id = i.booking_id
                WHERE b.event_id = :id
                ORDER BY i.id DESC";
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute(['id' => $eventId]);
        return $stmt->fetchAll();
    }

    public function findByBooking(int $bookingId): ?array
    {
        $stmt = $this->pdo->prepare("SELECT * FROM invoices WHERE booking_id = :id ORDER BY id ASC LIMIT 1");
        $stmt->execute(['id' => $bookingId]);
        $row = $stmt->fetch();
        return $row !== false ? $row : null;
    }

    /**
     * Alle Rechnungen einer Buchung — chronologisch (älteste zuerst).
     */
    public function allByBooking(int $bookingId): array
    {
        $stmt = $this->pdo->prepare("SELECT * FROM invoices WHERE booking_id = :id ORDER BY id ASC");
        $stmt->execute(['id' => $bookingId]);
        return $stmt->fetchAll();
    }

    /**
     * Liefert die jüngste Entwurfs-Rechnung einer Buchung (status='draft').
     * AIDA-Zusatzposten landen in einem solchen Entwurf, bis die Zahlung gestellt ist.
     */
    public function findDraftForBooking(int $bookingId): ?array
    {
        $stmt = $this->pdo->prepare("SELECT * FROM invoices
                                      WHERE booking_id = :id AND status = 'draft'
                                      ORDER BY id DESC LIMIT 1");
        $stmt->execute(['id' => $bookingId]);
        $row = $stmt->fetch();
        return $row !== false ? $row : null;
    }

    /**
     * Hängt ein booking_product als invoice_item an einen Entwurf an. Existiert
     * für die Buchung noch kein Entwurf, wird einer erstellt (status='draft',
     * invoice_no=NULL, invoice_date=NULL — beides wird erst bei Zahlung gefüllt).
     * Aktualisiert die Entwurfs-Summen entsprechend.
     */
    public function addItemToDraftForBooking(int $bookingId, int $bookingProductId): int
    {
        $stmt = $this->pdo->prepare("SELECT bp.*, p.title AS product_title, p.description AS product_description
                                       FROM booking_products bp
                                       INNER JOIN products p ON p.id = bp.product_id
                                      WHERE bp.id = :id");
        $stmt->execute(['id' => $bookingProductId]);
        $bp = $stmt->fetch();
        if (!$bp) throw new RuntimeException('Buchungs-Produkt nicht gefunden.');

        $stmt = $this->pdo->prepare("SELECT customer_id FROM bookings WHERE id = :id");
        $stmt->execute(['id' => $bookingId]);
        $customerId = (int)$stmt->fetchColumn();
        if ($customerId <= 0) throw new RuntimeException('Buchung nicht gefunden.');

        $this->pdo->beginTransaction();
        try {
            $draft = $this->findDraftForBooking($bookingId);
            if (!$draft) {
                $stmt = $this->pdo->prepare("INSERT INTO invoices
                        (invoice_no, booking_id, customer_id, invoice_date, status, total_net, total_vat, total_gross)
                        VALUES (NULL, :booking_id, :customer_id, NULL, 'draft', 0, 0, 0)");
                $stmt->execute([
                    'booking_id'  => $bookingId,
                    'customer_id' => $customerId,
                ]);
                $draftId = (int)$this->pdo->lastInsertId();
            } else {
                $draftId = (int)$draft['id'];
            }

            $qty   = (int)$bp['quantity'];
            $net   = (float)$bp['price_net'];
            $vat   = (float)$bp['vat_rate'];
            $gross = (float)$bp['price_gross'];
            $totalNet   = round($qty * $net, 2);
            $totalGross = round($qty * $gross, 2);

            $itemStmt = $this->pdo->prepare("INSERT INTO invoice_items
                (invoice_id, product_id, title, description, quantity, price_net, vat_rate, price_gross, total_net, total_vat, total_gross)
                VALUES (:invoice_id, :product_id, :title, :description, :quantity, :price_net, :vat_rate, :price_gross, :total_net, :total_vat, :total_gross)");
            $itemStmt->execute([
                'invoice_id'  => $draftId,
                'product_id'  => (int)$bp['product_id'],
                'title'       => $bp['product_title'],
                'description' => $bp['product_description'],
                'quantity'    => $qty,
                'price_net'   => $net,
                'vat_rate'    => $vat,
                'price_gross' => $gross,
                'total_net'   => $totalNet,
                'total_vat'   => round($totalGross - $totalNet, 2),
                'total_gross' => $totalGross,
            ]);

            // Summen neu berechnen — nur über die Items dieser Rechnung.
            $tot = $this->pdo->prepare("SELECT
                    COALESCE(SUM(total_net), 0)   AS n,
                    COALESCE(SUM(total_vat), 0)   AS v,
                    COALESCE(SUM(total_gross), 0) AS g
                  FROM invoice_items WHERE invoice_id = :id");
            $tot->execute(['id' => $draftId]);
            $t = $tot->fetch();
            $this->pdo->prepare("UPDATE invoices SET total_net = :n, total_vat = :v, total_gross = :g WHERE id = :id")
                ->execute([
                    'n'  => $t['n'],
                    'v'  => $t['v'],
                    'g'  => $t['g'],
                    'id' => $draftId,
                ]);

            $this->pdo->commit();
            return $draftId;
        } catch (Throwable $e) {
            if ($this->pdo->inTransaction()) $this->pdo->rollBack();
            throw $e;
        }
    }

    /**
     * Promoviert bezahlte Entwurfs-Rechnungen, deren Anreisetag in der Vergangenheit
     * liegt oder heute ist und 22:00 Uhr erreicht ist, auf Status 'finalized'.
     * Eine Entwurfs-Rechnung gilt als bereit, wenn ihr eine Rechnungsnummer
     * zugewiesen wurde (passiert beim vollständigen Bezahlen).
     *
     * @return int Anzahl der promovierten Rechnungen
     */
    public function promoteDraftsAfterArrival(): int
    {
        $sql = "UPDATE invoices i
                INNER JOIN bookings b ON b.id = i.booking_id
                   SET i.status = 'finalized'
                 WHERE i.status = 'draft'
                   AND i.invoice_no IS NOT NULL AND i.invoice_no <> ''
                   AND (
                       b.travel_from_date <  CURDATE()
                    OR (b.travel_from_date = CURDATE() AND TIME(NOW()) >= '22:00:00')
                   )";
        try {
            $stmt = $this->pdo->prepare($sql);
            $stmt->execute();
            return $stmt->rowCount();
        } catch (Throwable $e) {
            return 0;
        }
    }

    public function items(int $invoiceId): array
    {
        $stmt = $this->pdo->prepare("SELECT * FROM invoice_items WHERE invoice_id = :id ORDER BY id ASC");
        $stmt->execute(['id' => $invoiceId]);
        return $stmt->fetchAll();
    }

    /**
     * Rechnungspräfix aus den Einstellungen — z.B. "RE", "INV", "R".
     * Default "RE", falls keine Setting vorhanden.
     */
    private function invoicePrefix(): string
    {
        static $cached = null;
        if ($cached !== null) return $cached;
        try {
            $stmt = $this->pdo->prepare("SELECT config_value FROM settings WHERE config_key = 'invoice_prefix' LIMIT 1");
            $stmt->execute();
            $v = (string)$stmt->fetchColumn();
            $cached = trim($v) !== '' ? trim($v) : 'RE';
        } catch (Throwable $e) {
            $cached = 'RE';
        }
        return $cached;
    }

    public function generateInvoiceNumber(): string
    {
        // 1) Recycelte Nummern bevorzugt (FIFO) — bereits vergebene Nummern,
        //    die nach Storno wieder freigegeben wurden, werden zuerst wieder ausgegeben.
        $stmt = $this->pdo->prepare("SELECT id, invoice_no FROM recycled_invoice_numbers
                                     WHERE used_at IS NULL
                                     ORDER BY id ASC LIMIT 1");
        $stmt->execute();
        $recycled = $stmt->fetch();
        if ($recycled) {
            $upd = $this->pdo->prepare("UPDATE recycled_invoice_numbers SET used_at = NOW() WHERE id = :id");
            $upd->execute(['id' => (int)$recycled['id']]);
            return $recycled['invoice_no'];
        }

        // 2) Format = "<PRÄFIX>#####". Präfix kommt aus den Einstellungen, der
        //    Zähler wird durch DB-Suche aller invoice_no, die mit dem Präfix beginnen,
        //    ermittelt: alles außer Ziffern wegparsen, Maximum suchen, +1.
        //    Beispiel: Präfix "RE" → "RE00001", "RE00002", … "RE00125".
        $prefix    = $this->invoicePrefix();
        $prefixLen = strlen($prefix);
        $maxN      = 0;

        $st = $this->pdo->prepare("SELECT invoice_no FROM invoices WHERE invoice_no LIKE :p");
        $st->execute(['p' => $prefix . '%']);
        foreach ($st->fetchAll(PDO::FETCH_COLUMN) as $no) {
            $rest   = substr((string)$no, $prefixLen);
            $digits = preg_replace('/[^0-9]/', '', $rest);
            if ($digits !== '') {
                $n = (int)$digits;
                if ($n > $maxN) $maxN = $n;
            }
        }
        // Auch den Recycling-Pool berücksichtigen, damit nichts kollidiert.
        $st = $this->pdo->prepare("SELECT invoice_no FROM recycled_invoice_numbers WHERE invoice_no LIKE :p");
        $st->execute(['p' => $prefix . '%']);
        foreach ($st->fetchAll(PDO::FETCH_COLUMN) as $no) {
            $rest   = substr((string)$no, $prefixLen);
            $digits = preg_replace('/[^0-9]/', '', $rest);
            if ($digits !== '') {
                $n = (int)$digits;
                if ($n > $maxN) $maxN = $n;
            }
        }

        $next = $maxN + 1;
        return $prefix . str_pad((string)$next, 5, '0', STR_PAD_LEFT);
    }

    /**
     * Voll-Löschung einer Rechnung inkl. invoice_items.
     */
    public function delete(int $id): bool
    {
        $this->pdo->beginTransaction();
        try {
            $stmt = $this->pdo->prepare("DELETE FROM invoice_items WHERE invoice_id = :id");
            $stmt->execute(['id' => $id]);
            $stmt = $this->pdo->prepare("DELETE FROM invoices WHERE id = :id");
            $stmt->execute(['id' => $id]);
            $this->pdo->commit();
            SearchIndexer::purge('invoice', $id);
            return true;
        } catch (Throwable $e) {
            $this->pdo->rollBack();
            throw $e;
        }
    }

    /**
     * Stellt eine Rechnungsnummer in den Recycling-Pool.
     */
    public function recycleNumber(string $invoiceNo, ?string $recycledBy = null): bool
    {
        // Aus jeder beliebigen Nummern-Form (alt: "RE-2024-0125", neu: "20240125",
        //  oder Mischformen wie "PAS-125") den Integer-Counter durch Strippen
        //  aller Nicht-Ziffern ableiten.
        $digits = preg_replace('/[^0-9]/', '', $invoiceNo);
        if ($digits === '') return false;
        $number = (int)$digits;
        // year-Spalte ist in der Tabelle NOT NULL — wir tragen das aktuelle Jahr
        //  ein, hat aber keine Bedeutung mehr für die Vergabe.
        $year = (int)date('Y');
        $stmt = $this->pdo->prepare("INSERT INTO recycled_invoice_numbers
                (invoice_no, year, number, recycled_by)
                VALUES (:no, :y, :n, :u)
                ON DUPLICATE KEY UPDATE used_at = NULL, recycled_at = NOW(), recycled_by = VALUES(recycled_by)");
        return $stmt->execute([
            'no' => $invoiceNo,
            'y'  => $year,
            'n'  => $number,
            'u'  => $recycledBy,
        ]);
    }

    /**
     * Synchronisiert eine bestehende Rechnung mit der aktuellen Buchung —
     * löscht und legt invoice_items neu an (inkl. Rabatt-Posten) und passt
     * invoice.total_* an. Liefert true, wenn eine Rechnung gefunden wurde.
     *
     * Wird nach nachträglichen Rabatt-Änderungen aufgerufen, damit
     * Buchungs-Brutto, Rechnungs-Brutto und Items konsistent bleiben.
     */
    public function rebuildForBooking(int $bookingId): bool
    {
        $invoice = $this->findByBooking($bookingId);
        if (!$invoice) return false;
        // Bereits festgeschriebene Rechnungen nicht mehr überschreiben — sonst würde
        // ein nachträglicher Posten die bereits bezahlte/abgeschlossene Rechnung
        // mutieren. Für solche Fälle wird stattdessen ein neuer Entwurf gepflegt.
        if (in_array((string)($invoice['status'] ?? ''), ['finalized', 'paid', 'cancelled'], true)) {
            return false;
        }
        $invoiceId = (int)$invoice['id'];

        $stmt = $this->pdo->prepare("SELECT * FROM bookings WHERE id = :id");
        $stmt->execute(['id' => $bookingId]);
        $booking = $stmt->fetch();
        if (!$booking) return false;

        $stmt = $this->pdo->prepare("SELECT bp.*, p.title AS product_title, p.description AS product_description
                                     FROM booking_products bp
                                     INNER JOIN products p ON p.id = bp.product_id
                                     WHERE bp.booking_id = :id
                                     ORDER BY bp.id ASC");
        $stmt->execute(['id' => $bookingId]);
        $items = $stmt->fetchAll();

        $this->pdo->beginTransaction();
        try {
            $this->pdo->prepare("DELETE FROM invoice_items WHERE invoice_id = :id")
                ->execute(['id' => $invoiceId]);

            $itemStmt = $this->pdo->prepare("INSERT INTO invoice_items
                (invoice_id, product_id, title, description, quantity, price_net, vat_rate, price_gross, total_net, total_vat, total_gross)
                VALUES (:invoice_id, :product_id, :title, :description, :quantity, :price_net, :vat_rate, :price_gross, :total_net, :total_vat, :total_gross)");

            $itemsSumNet = 0.0;
            $weightedVatNumer = 0.0;
            foreach ($items as $it) {
                $qty   = (int)$it['quantity'];
                $net   = (float)$it['price_net'];
                $vat   = (float)$it['vat_rate'];
                $gross = (float)$it['price_gross'];
                $totalNet   = round($qty * $net, 2);
                $totalGross = round($qty * $gross, 2);
                $itemsSumNet += $totalNet;
                $weightedVatNumer += $totalNet * $vat;
                $itemStmt->execute([
                    'invoice_id'  => $invoiceId,
                    'product_id'  => (int)$it['product_id'],
                    'title'       => $it['product_title'],
                    'description' => $it['product_description'],
                    'quantity'    => $qty,
                    'price_net'   => $net,
                    'vat_rate'    => $vat,
                    'price_gross' => $gross,
                    'total_net'   => $totalNet,
                    'total_vat'   => round($totalGross - $totalNet, 2),
                    'total_gross' => $totalGross,
                ]);
            }

            $discount = (float)($booking['discount_amount'] ?? 0);
            if ($discount > 0) {
                $avgVat = $itemsSumNet > 0 ? round($weightedVatNumer / $itemsSumNet, 2) : 0.0;
                $rabattNet = round($discount / (1 + $avgVat / 100), 2);
                $rabattVat = round($discount - $rabattNet, 2);
                $itemStmt->execute([
                    'invoice_id'  => $invoiceId,
                    'product_id'  => null,
                    'title'       => $booking['discount_label'] ?? 'Rabatt',
                    'description' => 'Rabatt',
                    'quantity'    => 1,
                    'price_net'   => -$rabattNet,
                    'vat_rate'    => $avgVat,
                    'price_gross' => -$discount,
                    'total_net'   => -$rabattNet,
                    'total_vat'   => -$rabattVat,
                    'total_gross' => -$discount,
                ]);
            }

            $this->pdo->prepare("UPDATE invoices SET
                    total_net = :n, total_vat = :v, total_gross = :g,
                    pdf_path = NULL, html_path = NULL
                WHERE id = :id")
                ->execute([
                    'n'  => $booking['total_net'],
                    'v'  => $booking['total_vat'],
                    'g'  => $booking['total_gross'],
                    'id' => $invoiceId,
                ]);

            $this->pdo->commit();
            return true;
        } catch (Throwable $e) {
            $this->pdo->rollBack();
            throw $e;
        }
    }

    /**
     * Erstellt eine Rechnung aus einer Buchung. Wirft Exception, wenn schon eine existiert.
     */
    public function createFromBooking(int $bookingId): int
    {
        $existing = $this->findByBooking($bookingId);
        if ($existing) {
            throw new RuntimeException('Für diese Buchung existiert bereits eine Rechnung (' . $existing['invoice_no'] . ').');
        }

        $stmt = $this->pdo->prepare("SELECT * FROM bookings WHERE id = :id");
        $stmt->execute(['id' => $bookingId]);
        $booking = $stmt->fetch();
        if (!$booking) {
            throw new RuntimeException('Buchung nicht gefunden.');
        }

        $stmt = $this->pdo->prepare("SELECT bp.*, p.title AS product_title, p.description AS product_description
                                     FROM booking_products bp
                                     INNER JOIN products p ON p.id = bp.product_id
                                     WHERE bp.booking_id = :id
                                     ORDER BY bp.id ASC");
        $stmt->execute(['id' => $bookingId]);
        $items = $stmt->fetchAll();

        $this->pdo->beginTransaction();
        try {
            $invoiceNo = $this->generateInvoiceNumber();

            // Rechnungsdatum = bookings.travel_from (Anreisedatum). Fallback heute,
            //  wenn aus irgendeinem Grund kein Reisedatum gesetzt ist.
            $tf = (string)($booking['travel_from'] ?? '');
            $invDate = ($tf !== '' && substr($tf, 0, 10) !== '0000-00-00') ? substr($tf, 0, 10) : date('Y-m-d');
            $insert = $this->pdo->prepare("INSERT INTO invoices
                (invoice_no, booking_id, customer_id, invoice_date, status, total_net, total_vat, total_gross)
                VALUES (:invoice_no, :booking_id, :customer_id, :invoice_date, :status, :total_net, :total_vat, :total_gross)");
            $insert->execute([
                'invoice_no'   => $invoiceNo,
                'booking_id'   => $bookingId,
                'customer_id'  => (int)$booking['customer_id'],
                'invoice_date' => $invDate,
                'status'       => 'issued',
                'total_net'    => $booking['total_net'],
                'total_vat'    => $booking['total_vat'],
                'total_gross'  => $booking['total_gross'],
            ]);
            $invoiceId = (int)$this->pdo->lastInsertId();

            $itemStmt = $this->pdo->prepare("INSERT INTO invoice_items
                (invoice_id, product_id, title, description, quantity, price_net, vat_rate, price_gross, total_net, total_vat, total_gross)
                VALUES (:invoice_id, :product_id, :title, :description, :quantity, :price_net, :vat_rate, :price_gross, :total_net, :total_vat, :total_gross)");

            $itemsSumNet = 0.0;
            $itemsSumGross = 0.0;
            $weightedVatNumer = 0.0;
            foreach ($items as $it) {
                $qty   = (int)$it['quantity'];
                $net   = (float)$it['price_net'];
                $vat   = (float)$it['vat_rate'];
                $gross = (float)$it['price_gross'];
                $totalNet   = round($qty * $net, 2);
                $totalGross = round($qty * $gross, 2);
                $itemsSumNet   += $totalNet;
                $itemsSumGross += $totalGross;
                $weightedVatNumer += $totalNet * $vat;
                $itemStmt->execute([
                    'invoice_id'  => $invoiceId,
                    'product_id'  => (int)$it['product_id'],
                    'title'       => $it['product_title'],
                    'description' => $it['product_description'],
                    'quantity'    => $qty,
                    'price_net'   => $net,
                    'vat_rate'    => $vat,
                    'price_gross' => $gross,
                    'total_net'   => $totalNet,
                    'total_vat'   => round($totalGross - $totalNet, 2),
                    'total_gross' => $totalGross,
                ]);
            }

            // Rabatt als negativen Posten anfügen — Items + Rabatt = invoice.total_gross.
            $discountAmount = (float)($booking['discount_amount'] ?? 0);
            if ($discountAmount > 0) {
                $discountLabel = (string)($booking['discount_label'] ?? 'Rabatt');
                $avgVat = $itemsSumNet > 0 ? round($weightedVatNumer / $itemsSumNet, 2) : 0.0;
                $rabattNet = round($discountAmount / (1 + $avgVat / 100), 2);
                $rabattVat = round($discountAmount - $rabattNet, 2);
                $itemStmt->execute([
                    'invoice_id'  => $invoiceId,
                    'product_id'  => null,
                    'title'       => $discountLabel,
                    'description' => 'Rabatt',
                    'quantity'    => 1,
                    'price_net'   => -$rabattNet,
                    'vat_rate'    => $avgVat,
                    'price_gross' => -$discountAmount,
                    'total_net'   => -$rabattNet,
                    'total_vat'   => -$rabattVat,
                    'total_gross' => -$discountAmount,
                ]);
            }

            $this->pdo->commit();
            $this->syncSearchIndex($invoiceId);
            return $invoiceId;
        } catch (Throwable $e) {
            $this->pdo->rollBack();
            throw $e;
        }
    }

    public function markPaid(int $id): bool
    {
        $stmt = $this->pdo->prepare("UPDATE invoices SET status = 'paid' WHERE id = :id");
        $ok = $stmt->execute(['id' => $id]);
        if ($ok) $this->syncSearchIndex($id);
        return $ok;
    }

    /**
     * Re-synchronisiert eine Rechnung in den Such-Index (nach create/update).
     */
    public function syncSearchIndex(int $id): void
    {
        $stmt = $this->pdo->prepare(
            "SELECT i.id, i.invoice_no, i.invoice_date, i.updated_at,
                    b.booking_no,
                    c.first_name, c.last_name, c.company
               FROM invoices i
               INNER JOIN bookings  b ON b.id = i.booking_id
               INNER JOIN customers c ON c.id = i.customer_id
              WHERE i.id = :id"
        );
        $stmt->execute(['id' => $id]);
        $row = $stmt->fetch();
        if ($row) SearchIndexer::syncInvoice($row);
    }

    /**
     * Rechnungsdatum = bookings.travel_from der verknüpften Buchung.
     * Fallback heute, falls travel_from leer/NULL ist.
     */
    public function touchInvoiceDate(int $id, ?string $date = null): bool
    {
        if ($date === null) {
            $stmt = $this->pdo->prepare(
                "SELECT b.travel_from FROM invoices i
                   INNER JOIN bookings b ON b.id = i.booking_id
                  WHERE i.id = :id LIMIT 1"
            );
            $stmt->execute(['id' => $id]);
            $tf = (string)($stmt->fetchColumn() ?: '');
            $date = ($tf !== '' && substr($tf, 0, 10) !== '0000-00-00') ? substr($tf, 0, 10) : date('Y-m-d');
        }
        $stmt = $this->pdo->prepare("UPDATE invoices SET invoice_date = :d WHERE id = :id");
        $ok = $stmt->execute(['d' => $date, 'id' => $id]);
        if ($ok) $this->syncSearchIndex($id);
        return $ok;
    }

    /**
     * Prüft, ob die Rechnung ein Entwurf ohne Nummer ist und die Zahlungen
     * total_gross decken — wenn ja, wird eine Rechnungsnummer vergeben und
     * invoice_date auf heute gesetzt. Status bleibt 'draft' (Promotion auf
     * 'finalized' passiert separat via promoteDraftsAfterArrival).
     * Liefert die vergebene Nummer oder null, wenn nichts zu tun war.
     */
    public function assignNumberIfDraftFullyPaid(int $invoiceId): ?string
    {
        $stmt = $this->pdo->prepare("SELECT id, invoice_no, status, total_gross FROM invoices WHERE id = :id");
        $stmt->execute(['id' => $invoiceId]);
        $inv = $stmt->fetch();
        if (!$inv) return null;
        if ((string)($inv['status'] ?? '') !== 'draft') return null;
        if (trim((string)($inv['invoice_no'] ?? '')) !== '') return null;
        $gross = (float)$inv['total_gross'];
        if ($gross <= 0) return null;
        // Summe aller Zahlungen, die direkt dieser Rechnung zugeordnet sind.
        $stmt = $this->pdo->prepare("SELECT COALESCE(SUM(amount), 0) FROM payments WHERE invoice_id = :id");
        $stmt->execute(['id' => $invoiceId]);
        $paid = (float)$stmt->fetchColumn();
        if ($paid + 0.005 < $gross) return null;
        $no = $this->generateInvoiceNumber();
        $stmt = $this->pdo->prepare("UPDATE invoices SET invoice_no = :n WHERE id = :id");
        $stmt->execute(['n' => $no, 'id' => $invoiceId]);
        // Rechnungsdatum aus der zugehörigen Buchung ableiten (travel_from).
        $this->touchInvoiceDate($invoiceId);
        $this->syncSearchIndex($invoiceId);
        return $no;
    }

    public function updateStatus(int $id, string $status): bool
    {
        $stmt = $this->pdo->prepare("UPDATE invoices SET status = :s WHERE id = :id");
        $ok = $stmt->execute(['s' => $status, 'id' => $id]);
        if ($ok) $this->syncSearchIndex($id);
        return $ok;
    }

    public function updateHtmlPath(int $id, string $path): bool
    {
        $stmt = $this->pdo->prepare("UPDATE invoices SET html_path = :p WHERE id = :id");
        return $stmt->execute(['p' => $path, 'id' => $id]);
    }

    public function updatePdfPath(int $id, string $path): bool
    {
        $stmt = $this->pdo->prepare("UPDATE invoices SET pdf_path = :p WHERE id = :id");
        return $stmt->execute(['p' => $path, 'id' => $id]);
    }
}

Youez - 2016 - github.com/yon3zu
LinuXploit