HEX
Server: LiteSpeed
System: Linux houston.panomity.com 6.8.0-100-generic #100-Ubuntu SMP PREEMPT_DYNAMIC Tue Jan 13 16:40:06 UTC 2026 x86_64
User: nudepix (1011)
PHP: 7.4.33
Disabled: pcntl_alarm,pcntl_fork,pcntl_waitpid,pcntl_wait,pcntl_wifexited,pcntl_wifstopped,pcntl_wifsignaled,pcntl_wifcontinued,pcntl_wexitstatus,pcntl_wtermsig,pcntl_wstopsig,pcntl_signal,pcntl_signal_get_handler,pcntl_signal_dispatch,pcntl_get_last_error,pcntl_strerror,pcntl_sigprocmask,pcntl_sigwaitinfo,pcntl_sigtimedwait,pcntl_exec,pcntl_getpriority,pcntl_setpriority,pcntl_async_signals,pcntl_unshare,
Upload Files
File: //tmp/codex_a_log.txt
Reading additional input from stdin...
OpenAI Codex v0.144.1
--------
workdir: /tmp/kiwz-hostbill-staging
model: gpt-5.5
provider: openai
approval: never
sandbox: workspace-write [workdir, /tmp, $TMPDIR]
reasoning effort: high
reasoning summaries: none
session id: 019f4ffb-6ad6-7ba1-8a83-7a373d54710c
--------
user
# Auftrag: HostBill-Setup-Skript für kiwerkzeuge.de schreiben (KEIN DB-Zugriff durch dich!)

Arbeitsverzeichnis: `/tmp/kiwz-hostbill-staging`. Du schreibst hier PHP-/Textdateien. Du hast KEINEN
Netzwerk- und KEINEN Datenbankzugriff — das Skript wird später von einem Menschen geprüft und ausgeführt.

## Kontext
HostBill (Version 2025-12-29, ionCube) unter `/home/support.panomity.com/public_html`, DB-Konfiguration
in `includes/config.php` (definiert `$db_host`, `$db_name`, `$db_user`, `$db_pass`, `$db_port`).
Live-Portal: https://support.panomity.com/ (Bestellungen unter `/cart/`).
Relevante Tabellen (Multibrand-Modul ist installiert): `hb_categories` (Orderpages), `hb_products`,
`hb_brands`, `hb_brand_config` u.a. Die exakten Spalten kennst du NICHT — das Skript muss sie zur
Laufzeit per `SHOW COLUMNS` ermitteln und sich an einer existierenden, sichtbaren Beispielzeile orientieren.

kiwerkzeuge.de (deutschsprachige KI-Tool-Suite: Bildgenerator, Musik, Video, Tiefenkarte) soll monetarisiert
werden: Kunden kaufen ein Monats-Abo, erhalten einen Priority-Code für bevorzugte GPU-Warteschlange.

## Deliverable 1: `kiwz_hostbill_setup.php`
Ein eigenständiges CLI-Skript (php-cli), das:
1. `includes/config.php` von HostBill lädt und per PDO verbindet.
2. **Dry-Run als Default**: ohne Argument nur anzeigen, was es tun würde (inkl. ermitteltes Schema,
   gewählte Vorlagezeile); erst mit `--apply` wirklich schreiben.
3. Vor Schreibzugriffen ein Backup der betroffenen Tabellen erstellt (`CREATE TABLE ... LIKE` +
   `INSERT INTO ... SELECT` in Backup-Tabellen mit Zeitstempel-Suffix, z. B. `hb_products_bak_1720680000` —
   kein mysqldump-Aufruf, damit es ohne Shell-Rechte läuft).
4. In einer TRANSAKTION (soweit Engine es erlaubt):
   a. Orderpage/Kategorie **„KI Werkzeuge"** in `hb_categories` anlegt (sichtbar). Spaltenbelegung nach
      Vorbild einer existierenden sichtbaren Kategorie (Template-Zeile kopieren, Name/Slug/Beschreibung ersetzen,
      AUTO_INCREMENT-Spalte weglassen). Idempotent: existiert eine Kategorie dieses Namens bereits, verwenden.
   b. Drei Produkte in `hb_products` anlegt (Template: existierende sichtbare Produktzeile; Kategorie-ID = neue
      Kategorie; AUTO_INCREMENT weglassen; Name/Beschreibung/Preise ersetzen; monatlich wiederkehrend, EUR):
      | Name | Preis/Monat | Kurzbeschreibung (deutsch) |
      | KI Starter | 4.90 | Priority-Rendering für Bildgenerator & Tiefenkarte, E-Mail-Support |
      | KI Pro | 14.90 | Priority für alle Werkzeuge inkl. Musik & Video, LoRA-Training bevorzugt |
      | KI Business | 39.90 | Höchste Priorität, alle Werkzeuge, Support mit 24h-Reaktionszeit |
      Preisspalten: erkenne typische HostBill-Spalten (`m` = monthly u.ä.) aus dem Schema; setze NUR die
      monatliche Zahlweise, andere Perioden deaktiviert (Wert -1 bzw. wie in HostBill üblich, an Templatezeile
      orientieren und im Dry-Run ausgeben). Falls `paytype`/`p_type`-artige Spalte existiert: „Recurring".
      Idempotent: existierendes Produkt gleichen Namens in der Kategorie nicht doppelt anlegen.
   c. Nichts UPDATEn/DELETEn an bestehenden Zeilen. Ausschließlich INSERTs (+ ggf. minimale
      Verknüpfungszeilen, falls das Schema z. B. eine Produkt-Orderpage-Relation erzwingt — im Dry-Run zeigen).
5. Am Ende ausgibt: neue Kategorie-ID, die drei Produkt-IDs, Bestell-URLs in beiden gängigen HostBill-Formaten
   (`https://support.panomity.com/?cmd=cart&action=add&id=<PID>` und `/cart/<kategorie-slug>` falls Slug existiert)
   und einen Hinweis, welche manuellen Admin-Schritte bleiben (Zahlungsmodul-Zuordnung, Steuersatz,
   Willkommens-E-Mail mit Prio-Code je Produkt).
6. Sicherheit: keine Secrets ausgeben; bei unerwartetem Schema (fehlende Tabelle/Spalten, keine Templatezeile)
   sauber abbrechen mit klarer Meldung; `--rollback`-Hinweis ausgeben (Backup-Tabellennamen).

## Deliverable 2: `kiwz_prio_codes.txt`
30 zufällige Codes im Format `KIWZ-XXXX-XXXX` (Zeichenvorrat A–Z ohne O/I, Ziffern 2–9), eine pro Zeile.
Generiere sie JETZT selbst (nicht zur Laufzeit des Skripts).

## Deliverable 3: `kiwz_gpuq_config_snippet.php`
Ein Snippet mit fertigem `'priority_codes' => [...]`-Array: die ersten 10 Codes aus Deliverable 2 → 'high'
(als Beispiel-Batch für die ersten Kunden), mit Kommentar wie man weitere einpflegt.

## Deliverable 4: `ANLEITUNG.md`
Kurz: (a) wie man das Skript ausführt (Dry-Run, dann --apply), (b) die 3 alternativen Klick-Schritte im
HostBill-Admin (padm → Settings → Products & Services → neue Orderpage + 3 Produkte) falls man es lieber
manuell macht, (c) wie die Willkommens-E-Mail je Produkt einen Prio-Code ausliefern soll, (d) Multibrand-Hinweis:
was nötig wäre, um kiwerkzeuge.de als eigene Brand mit eigenem Portal zu betreiben (nur dokumentieren).

## Harte Grenzen
Kein DB-Connect, kein Netzwerk, keine Dateien außerhalb des Arbeitsverzeichnisses lesen außer
(read-only, falls nötig): NICHTS aus /home/support.panomity.com — du kennst die nötigen Infos aus diesem Brief.
`php -l` auf das Skript am Ende.
codex
Ich erstelle die vier Dateien im Arbeitsverzeichnis und halte das PHP-Skript bewusst schema-adaptiv, weil die HostBill-Spalten erst beim späteren Lauf bekannt sind. Danach prüfe ich die PHP-Syntax lokal mit `php -l`.
exec
/bin/bash -lc 'pwd && rg --files -n . | head -100' in /tmp/kiwz-hostbill-staging
 succeeded in 0ms:
/tmp/kiwz-hostbill-staging

codex
Ich habe das Skript-Design festgelegt: es verbindet sich erst beim späteren Aufruf über HostBills `includes/config.php`, liest die Tabellenstruktur per `SHOW COLUMNS`, kopiert nur erkannte Template-Zeilen und macht ohne `--apply` keine Schreiboperation. Jetzt lege ich die Dateien an.
apply patch
patch: completed
/tmp/kiwz-hostbill-staging/ANLEITUNG.md
/tmp/kiwz-hostbill-staging/kiwz_gpuq_config_snippet.php
/tmp/kiwz-hostbill-staging/kiwz_hostbill_setup.php
/tmp/kiwz-hostbill-staging/kiwz_prio_codes.txt
diff --git a/ANLEITUNG.md b/ANLEITUNG.md
new file mode 100644
index 0000000000000000000000000000000000000000..855de0955bb7085d75847133170994b159e380e3
--- /dev/null
+++ b/ANLEITUNG.md
@@ -0,0 +1,51 @@
+# HostBill-Setup für kiwerkzeuge.de
+
+## Skript ausführen
+
+Das Skript ist standardmäßig ein Dry-Run und schreibt dabei nichts in die Datenbank:
+
+```bash
+php kiwz_hostbill_setup.php
+```
+
+Wenn Schema, Template-Zeilen, Backups und geplante Inserts plausibel aussehen, ausführen mit:
+
+```bash
+php kiwz_hostbill_setup.php --apply
+```
+
+Optional kann ein abweichender HostBill-Pfad oder eine konkrete Config-Datei angegeben werden:
+
+```bash
+php kiwz_hostbill_setup.php --hostbill-root=/home/support.panomity.com/public_html
+php kiwz_hostbill_setup.php --config=/home/support.panomity.com/public_html/includes/config.php
+```
+
+Vor den Inserts erstellt das Skript Backup-Tabellen per `CREATE TABLE ... LIKE` und `INSERT INTO ... SELECT`, z. B. `hb_products_bak_<timestamp>`. Das Skript löscht oder aktualisiert keine bestehenden Zeilen.
+
+## Manuelle Alternative im HostBill-Admin
+
+Falls die Einrichtung lieber per Klick gemacht wird:
+
+1. `padm` öffnen und zu `Settings -> Products & Services` gehen.
+2. Neue Orderpage/Kategorie `KI Werkzeuge` anlegen, sichtbar schalten und als Slug z. B. `ki-werkzeuge` setzen.
+3. Drei monatlich wiederkehrende EUR-Produkte in dieser Orderpage anlegen:
+   - `KI Starter`, 4.90 EUR/Monat, `Priority-Rendering für Bildgenerator & Tiefenkarte, E-Mail-Support`
+   - `KI Pro`, 14.90 EUR/Monat, `Priority für alle Werkzeuge inkl. Musik & Video, LoRA-Training bevorzugt`
+   - `KI Business`, 39.90 EUR/Monat, `Höchste Priorität, alle Werkzeuge, Support mit 24h-Reaktionszeit`
+
+Danach Zahlungsmodul-Zuordnung, Steuersatz und Sichtbarkeit im Warenkorb prüfen.
+
+## Willkommens-E-Mail mit Priority-Code
+
+Für jedes Produkt sollte eine Willkommens-E-Mail bzw. Produktaktivierungs-Mail eingerichtet werden. Der Inhalt sollte dem Kunden genau einen freien Code aus `kiwz_prio_codes.txt` mitteilen, z. B.:
+
+```text
+Ihr Priority-Code für kiwerkzeuge.de lautet: KIWZ-XXXX-XXXX
+```
+
+Wichtig: Codes müssen nach Ausgabe als vergeben markiert werden, damit derselbe Code nicht mehrfach an Kunden geht. Für den ersten Batch kann das Snippet `kiwz_gpuq_config_snippet.php` genutzt werden; weitere Codes werden dort im Array `'priority_codes' => [...]` ergänzt.
+
+## Multibrand-Hinweis
+
+Das Multibrand-Modul ist installiert. Für eine eigene Brand `kiwerkzeuge.de` wären zusätzlich zur Produktanlage diese Schritte nötig: Brand in `hb_brands` bzw. über den HostBill-Admin anlegen, Domain/Portal-URL und Branding in `hb_brand_config` konfigurieren, Template/Logo/E-Mail-Absender setzen, Zahlungs- und Steuerregeln der Brand zuordnen und die neue Orderpage/Produkte dieser Brand sichtbar zuweisen. Diese Anleitung dokumentiert das nur; das Setup-Skript legt keine neue Brand an.
diff --git a/kiwz_gpuq_config_snippet.php b/kiwz_gpuq_config_snippet.php
new file mode 100644
index 0000000000000000000000000000000000000000..c089e9c811a945dc3d3da6515b6c7d714f4dfd66
--- /dev/null
+++ b/kiwz_gpuq_config_snippet.php
@@ -0,0 +1,19 @@
+<?php
+
+// Beispiel-Batch für die ersten Kunden. Weitere Codes aus kiwz_prio_codes.txt
+// nach gleichem Muster ergänzen, z. B. 'KIWZ-XXXX-XXXX' => 'high'.
+$gpuqConfig = [
+    'priority_codes' => [
+        'KIWZ-X7QA-H4NZ' => 'high',
+        'KIWZ-BM6T-9VDK' => 'high',
+        'KIWZ-3LPR-W8CY' => 'high',
+        'KIWZ-F2ZH-QN5A' => 'high',
+        'KIWZ-7GVM-KX4T' => 'high',
+        'KIWZ-N9QS-2JWR' => 'high',
+        'KIWZ-H8DC-V6PY' => 'high',
+        'KIWZ-4TFK-ZM7B' => 'high',
+        'KIWZ-Q2WX-5ANR' => 'high',
+        'KIWZ-Y6PJ-C9LT' => 'high',
+    ],
+];
+
diff --git a/kiwz_hostbill_setup.php b/kiwz_hostbill_setup.php
new file mode 100644
index 0000000000000000000000000000000000000000..31546e4cb6cf26c6f4a2687cb03d66edc346a12d
--- /dev/null
+++ b/kiwz_hostbill_setup.php
@@ -0,0 +1,768 @@
+#!/usr/bin/env php
+<?php
+declare(strict_types=1);
+
+/**
+ * HostBill setup helper for kiwerkzeuge.de.
+ *
+ * Default mode is dry-run. The script intentionally discovers the HostBill
+ * schema at runtime and copies visible template rows instead of assuming an
+ * exact HostBill table layout.
+ */
+
+const HOSTBILL_DEFAULT_ROOT = '/home/support.panomity.com/public_html';
+const PORTAL_BASE_URL = 'https://support.panomity.com';
+const CATEGORY_NAME = 'KI Werkzeuge';
+const CATEGORY_SLUG = 'ki-werkzeuge';
+const CATEGORY_DESCRIPTION = 'Deutschsprachige KI-Tool-Suite mit Bildgenerator, Musik, Video und Tiefenkarte.';
+
+$PRODUCTS = [
+    [
+        'name' => 'KI Starter',
+        'price' => '4.90',
+        'description' => 'Priority-Rendering für Bildgenerator & Tiefenkarte, E-Mail-Support',
+    ],
+    [
+        'name' => 'KI Pro',
+        'price' => '14.90',
+        'description' => 'Priority für alle Werkzeuge inkl. Musik & Video, LoRA-Training bevorzugt',
+    ],
+    [
+        'name' => 'KI Business',
+        'price' => '39.90',
+        'description' => 'Höchste Priorität, alle Werkzeuge, Support mit 24h-Reaktionszeit',
+    ],
+];
+
+main($PRODUCTS);
+
+function main(array $products): void
+{
+    if (PHP_SAPI !== 'cli') {
+        fwrite(STDERR, "Dieses Skript muss mit php-cli ausgeführt werden.\n");
+        exit(1);
+    }
+
+    $opts = getopt('', ['apply', 'help', 'hostbill-root:', 'config:']);
+    if (isset($opts['help'])) {
+        printHelp();
+        return;
+    }
+
+    $dryRun = !isset($opts['apply']);
+    $hostbillRoot = rtrim((string)($opts['hostbill-root'] ?? HOSTBILL_DEFAULT_ROOT), '/');
+    $configFile = (string)($opts['config'] ?? ($hostbillRoot . '/includes/config.php'));
+
+    out('HostBill kiwerkzeuge.de Setup');
+    out('Modus: ' . ($dryRun ? 'DRY-RUN (keine Schreibzugriffe)' : 'APPLY (schreibt in die Datenbank)'));
+    out('Config: ' . $configFile);
+    out('');
+
+    $pdo = connectFromHostbillConfig($configFile);
+    $backupTables = [];
+    $transactionStarted = false;
+
+    try {
+        requireTable($pdo, 'hb_categories');
+        requireTable($pdo, 'hb_products');
+
+        $categoryCols = columns($pdo, 'hb_categories');
+        $productCols = columns($pdo, 'hb_products');
+
+        printSchema('hb_categories', $categoryCols);
+        printSchema('hb_products', $productCols);
+
+        $categoryPlan = buildCategoryPlan($pdo, $categoryCols);
+        out('Kategorie-Vorlage: ' . describeRow($categoryPlan['template'], $categoryPlan['id_col'], $categoryPlan['name_col']));
+
+        $productPlan = buildProductPlan($pdo, $productCols, $categoryPlan['id_col']);
+        out('Produkt-Vorlage: ' . describeRow($productPlan['template'], $productPlan['id_col'], $productPlan['name_col']));
+        out('Monatspreisspalte: ' . $productPlan['monthly_col']);
+        out('Deaktivierte Zahlweisen: ' . ($productPlan['disabled_period_cols'] ? implode(', ', $productPlan['disabled_period_cols']) : '(keine weiteren Periodenspalten erkannt)'));
+        out('Kategorie-Verknüpfung: ' . ($productPlan['category_col'] ? "hb_products.{$productPlan['category_col']}" : relationDescription($productPlan['relation'])));
+        out('');
+
+        $affectedTables = ['hb_categories', 'hb_products'];
+        if ($productPlan['relation']) {
+            $affectedTables[] = $productPlan['relation']['table'];
+            printSchema($productPlan['relation']['table'], columns($pdo, $productPlan['relation']['table']));
+        }
+
+        $timestamp = time();
+        foreach (array_values(array_unique($affectedTables)) as $table) {
+            $backupName = uniqueBackupName($pdo, $table, $timestamp);
+            $backupTables[$table] = $backupName;
+            out(($dryRun ? 'Würde Backup erstellen: ' : 'Erstelle Backup: ') . "{$backupName} aus {$table}");
+            if (!$dryRun) {
+                createBackup($pdo, $table, $backupName);
+            }
+        }
+        out('');
+
+        if (!$dryRun) {
+            $pdo->beginTransaction();
+            $transactionStarted = true;
+        }
+
+        $category = ensureCategory($pdo, $categoryPlan, $dryRun);
+        $categoryId = $category['id'];
+        $categorySlug = $category['slug'];
+
+        $productResults = [];
+        foreach ($products as $product) {
+            $productResults[] = ensureProduct($pdo, $productPlan, $categoryId, $product, $dryRun);
+        }
+
+        if (!$dryRun) {
+            $pdo->commit();
+            $transactionStarted = false;
+        }
+
+        out('');
+        out('Ergebnis');
+        out('Kategorie-ID: ' . displayId($categoryId));
+        foreach ($productResults as $result) {
+            out(sprintf('Produkt: %s -> ID %s (%s)', $result['name'], displayId($result['id']), $result['status']));
+        }
+        out('');
+        out('Bestell-URLs');
+        foreach ($productResults as $result) {
+            $id = displayId($result['id'], '<PID>');
+            out($result['name'] . ': ' . PORTAL_BASE_URL . '/?cmd=cart&action=add&id=' . $id);
+        }
+        if ($categorySlug !== null && $categorySlug !== '') {
+            out('Orderpage: ' . PORTAL_BASE_URL . '/cart/' . $categorySlug);
+        } else {
+            out('Orderpage: /cart/<kategorie-slug> nicht ausgebbar, weil keine Slug-Spalte erkannt wurde.');
+        }
+        out('');
+        out('Manuelle Admin-Schritte bleiben: Zahlungsmodul-Zuordnung prüfen, Steuersatz prüfen, Willkommens-E-Mail je Produkt mit Priority-Code konfigurieren.');
+        out('Rollback-Hinweis: Für einen manuellen Restore wurden diese Backup-Tabellen vorbereitet/angezeigt:');
+        foreach ($backupTables as $table => $backupName) {
+            out("  {$table} -> {$backupName}");
+        }
+        out('Dieses Skript führt kein automatisches --rollback aus, damit bestehende Daten nicht unbeabsichtigt gelöscht oder überschrieben werden.');
+    } catch (Throwable $e) {
+        if ($transactionStarted && $pdo->inTransaction()) {
+            $pdo->rollBack();
+        }
+        fwrite(STDERR, "\nABBRUCH: " . $e->getMessage() . "\n");
+        if ($backupTables) {
+            fwrite(STDERR, "Backup-Tabellen für manuelle Prüfung/Restore:\n");
+            foreach ($backupTables as $table => $backupName) {
+                fwrite(STDERR, "  {$table} -> {$backupName}\n");
+            }
+        }
+        exit(1);
+    }
+}
+
+function printHelp(): void
+{
+    out("Nutzung:");
+    out("  php kiwz_hostbill_setup.php");
+    out("  php kiwz_hostbill_setup.php --apply");
+    out("  php kiwz_hostbill_setup.php --hostbill-root=/home/support.panomity.com/public_html");
+    out("  php kiwz_hostbill_setup.php --config=/pfad/zu/includes/config.php");
+}
+
+function connectFromHostbillConfig(string $configFile): PDO
+{
+    if (!is_file($configFile) || !is_readable($configFile)) {
+        throw new RuntimeException("HostBill config.php nicht lesbar: {$configFile}");
+    }
+
+    $db_host = $db_name = $db_user = $db_pass = null;
+    $db_port = null;
+    require $configFile;
+
+    foreach (['db_host', 'db_name', 'db_user', 'db_pass'] as $var) {
+        if (!isset($$var) || $$var === '') {
+            throw new RuntimeException("HostBill config.php definiert \${$var} nicht.");
+        }
+    }
+
+    $dsn = 'mysql:host=' . $db_host . ';dbname=' . $db_name . ';charset=utf8mb4';
+    if (!empty($db_port)) {
+        $dsn .= ';port=' . (int)$db_port;
+    }
+
+    return new PDO($dsn, (string)$db_user, (string)$db_pass, [
+        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
+        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+        PDO::ATTR_EMULATE_PREPARES => false,
+    ]);
+}
+
+function requireTable(PDO $pdo, string $table): void
+{
+    $stmt = $pdo->prepare('SHOW TABLES LIKE ?');
+    $stmt->execute([$table]);
+    if (!$stmt->fetchColumn()) {
+        throw new RuntimeException("Erwartete Tabelle fehlt: {$table}");
+    }
+}
+
+function tableExists(PDO $pdo, string $table): bool
+{
+    $stmt = $pdo->prepare('SHOW TABLES LIKE ?');
+    $stmt->execute([$table]);
+    return (bool)$stmt->fetchColumn();
+}
+
+function columns(PDO $pdo, string $table): array
+{
+    $rows = $pdo->query('SHOW COLUMNS FROM ' . qi($table))->fetchAll();
+    if (!$rows) {
+        throw new RuntimeException("Keine Spalten gefunden in {$table}");
+    }
+    $out = [];
+    foreach ($rows as $row) {
+        $out[$row['Field']] = $row;
+    }
+    return $out;
+}
+
+function printSchema(string $table, array $cols): void
+{
+    out("Schema {$table}:");
+    foreach ($cols as $name => $meta) {
+        $extra = trim(($meta['Key'] ? ' key=' . $meta['Key'] : '') . ($meta['Extra'] ? ' extra=' . $meta['Extra'] : ''));
+        out('  - ' . $name . ' ' . $meta['Type'] . ($extra ? ' (' . $extra . ')' : ''));
+    }
+    out('');
+}
+
+function buildCategoryPlan(PDO $pdo, array $cols): array
+{
+    $idCol = autoIncrementColumn($cols) ?? pickColumn($cols, ['id', 'category_id', 'cat_id']);
+    $nameCol = pickColumn($cols, ['name', 'title', 'category', 'catname', 'label']);
+    $descCol = pickColumn($cols, ['description', 'descr', 'details', 'content', 'text', 'info']);
+    $slugCol = pickColumn($cols, ['slug', 'url', 'sefurl', 'sef_url', 'urlname', 'url_name']);
+
+    if (!$idCol || !$nameCol) {
+        throw new RuntimeException('hb_categories: ID- oder Name-Spalte nicht sicher erkannt.');
+    }
+
+    $template = selectTemplateRow($pdo, 'hb_categories', $cols, $idCol);
+
+    return [
+        'table' => 'hb_categories',
+        'cols' => $cols,
+        'id_col' => $idCol,
+        'name_col' => $nameCol,
+        'desc_col' => $descCol,
+        'slug_col' => $slugCol,
+        'template' => $template,
+    ];
+}
+
+function buildProductPlan(PDO $pdo, array $cols, string $categoryIdCol): array
+{
+    $idCol = autoIncrementColumn($cols) ?? pickColumn($cols, ['id', 'product_id', 'pid']);
+    $nameCol = pickColumn($cols, ['name', 'title', 'product_name', 'label']);
+    $descCol = pickColumn($cols, ['description', 'descr', 'details', 'content', 'text', 'info', 'short_description', 'shortdesc']);
+    $slugCol = pickColumn($cols, ['slug', 'url', 'sefurl', 'sef_url', 'urlname', 'url_name']);
+    $monthlyCol = pickMonthlyColumn($cols);
+    $categoryCol = pickProductCategoryColumn($cols);
+    $relation = null;
+
+    if (!$idCol || !$nameCol || !$descCol || !$monthlyCol) {
+        throw new RuntimeException('hb_products: ID-, Name-, Beschreibungs- oder Monatspreis-Spalte nicht sicher erkannt.');
+    }
+
+    if (!$categoryCol) {
+        $relation = discoverProductCategoryRelation($pdo, $idCol, $categoryIdCol);
+        if (!$relation) {
+            throw new RuntimeException('hb_products: Keine Kategorie-Spalte und keine Produkt/Kategorie-Relation sicher erkannt.');
+        }
+    }
+
+    $template = selectTemplateRow($pdo, 'hb_products', $cols, $idCol);
+
+    $periodCols = pickExistingColumns($cols, ['m', 'monthly', 'month', 'price_monthly', 'monthly_price', 'q', 'quarterly', 's', 'semiannually', 'semiannual', 'a', 'annually', 'annual', 'b', 'biennially', 'biennial', 't', 'triennially', 'triennial']);
+    $disabledPeriodCols = array_values(array_diff($periodCols, [$monthlyCol]));
+
+    return [
+        'table' => 'hb_products',
+        'cols' => $cols,
+        'id_col' => $idCol,
+        'name_col' => $nameCol,
+        'desc_col' => $descCol,
+        'slug_col' => $slugCol,
+        'monthly_col' => $monthlyCol,
+        'disabled_period_cols' => $disabledPeriodCols,
+        'category_col' => $categoryCol,
+        'relation' => $relation,
+        'template' => $template,
+    ];
+}
+
+function ensureCategory(PDO $pdo, array $plan, bool $dryRun): array
+{
+    $existing = findCategory($pdo, $plan, CATEGORY_NAME);
+    if ($existing) {
+        out('Kategorie existiert bereits, keine Änderung: ID ' . $existing[$plan['id_col']]);
+        return [
+            'id' => $existing[$plan['id_col']],
+            'slug' => $plan['slug_col'] ? (string)($existing[$plan['slug_col']] ?? CATEGORY_SLUG) : null,
+        ];
+    }
+
+    $overrides = [
+        $plan['name_col'] => CATEGORY_NAME,
+    ];
+    if ($plan['desc_col']) {
+        $overrides[$plan['desc_col']] = CATEGORY_DESCRIPTION;
+    }
+    if ($plan['slug_col']) {
+        $overrides[$plan['slug_col']] = CATEGORY_SLUG;
+    }
+    applyVisibilityOverrides($plan['cols'], $overrides);
+
+    out(($dryRun ? 'Würde Kategorie anlegen: ' : 'Lege Kategorie an: ') . CATEGORY_NAME);
+    $id = insertFromTemplate($pdo, $plan['table'], $plan['cols'], $plan['template'], $overrides, $dryRun);
+
+    return [
+        'id' => $dryRun ? null : $id,
+        'slug' => $plan['slug_col'] ? CATEGORY_SLUG : null,
+    ];
+}
+
+function ensureProduct(PDO $pdo, array $plan, $categoryId, array $product, bool $dryRun): array
+{
+    if (!$dryRun && ($categoryId === null || $categoryId === '')) {
+        throw new RuntimeException('Kategorie-ID fehlt für Produktanlage.');
+    }
+
+    $existing = $dryRun ? null : findProduct($pdo, $plan, $categoryId, $product['name']);
+    if ($existing) {
+        out('Produkt existiert bereits, keine Änderung: ' . $product['name'] . ' (ID ' . $existing[$plan['id_col']] . ')');
+        return [
+            'name' => $product['name'],
+            'id' => $existing[$plan['id_col']],
+            'status' => 'vorhanden',
+        ];
+    }
+
+    $overrides = [
+        $plan['name_col'] => $product['name'],
+        $plan['desc_col'] => $product['description'],
+        $plan['monthly_col'] => $product['price'],
+    ];
+    if ($plan['slug_col']) {
+        $overrides[$plan['slug_col']] = slugify($product['name']);
+    }
+    if ($plan['category_col']) {
+        $overrides[$plan['category_col']] = $dryRun ? '<neue Kategorie-ID>' : $categoryId;
+    }
+    foreach ($plan['disabled_period_cols'] as $col) {
+        $overrides[$col] = disabledPeriodValue($plan['template'][$col] ?? null);
+    }
+    foreach (pickExistingColumns($plan['cols'], ['paytype', 'pay_type', 'p_type', 'billingtype', 'billing_type']) as $col) {
+        $overrides[$col] = recurringValueForColumn($plan['cols'][$col]);
+    }
+    foreach (pickExistingColumns($plan['cols'], ['currency', 'curr']) as $col) {
+        if (isStringType($plan['cols'][$col]['Type'])) {
+            $overrides[$col] = 'EUR';
+        }
+    }
+    applyVisibilityOverrides($plan['cols'], $overrides);
+
+    out(($dryRun ? 'Würde Produkt anlegen: ' : 'Lege Produkt an: ') . $product['name'] . ' für ' . $product['price'] . ' EUR/Monat');
+    $id = insertFromTemplate($pdo, $plan['table'], $plan['cols'], $plan['template'], $overrides, $dryRun);
+
+    if ($plan['relation']) {
+        out(($dryRun ? 'Würde Produkt/Kategorie-Relation anlegen: ' : 'Lege Produkt/Kategorie-Relation an: ') . relationDescription($plan['relation']));
+        if (!$dryRun) {
+            ensureRelation($pdo, $plan['relation'], $id, $categoryId);
+        }
+    }
+
+    return [
+        'name' => $product['name'],
+        'id' => $dryRun ? null : $id,
+        'status' => $dryRun ? 'dry-run' : 'neu',
+    ];
+}
+
+function findCategory(PDO $pdo, array $plan, string $name): ?array
+{
+    $sql = 'SELECT * FROM ' . qi($plan['table']) . ' WHERE ' . qi($plan['name_col']) . ' = ? LIMIT 1';
+    $stmt = $pdo->prepare($sql);
+    $stmt->execute([$name]);
+    $row = $stmt->fetch();
+    return $row ?: null;
+}
+
+function findProduct(PDO $pdo, array $plan, $categoryId, string $name): ?array
+{
+    if ($plan['category_col']) {
+        $sql = 'SELECT * FROM ' . qi($plan['table']) . ' WHERE ' . qi($plan['name_col']) . ' = ? AND ' . qi($plan['category_col']) . ' = ? LIMIT 1';
+        $stmt = $pdo->prepare($sql);
+        $stmt->execute([$name, $categoryId]);
+        $row = $stmt->fetch();
+        return $row ?: null;
+    }
+
+    $rel = $plan['relation'];
+    $sql = 'SELECT p.* FROM ' . qi($plan['table']) . ' p '
+        . 'INNER JOIN ' . qi($rel['table']) . ' r ON p.' . qi($plan['id_col']) . ' = r.' . qi($rel['product_col']) . ' '
+        . 'WHERE p.' . qi($plan['name_col']) . ' = ? AND r.' . qi($rel['category_col']) . ' = ? LIMIT 1';
+    $stmt = $pdo->prepare($sql);
+    $stmt->execute([$name, $categoryId]);
+    $row = $stmt->fetch();
+    return $row ?: null;
+}
+
+function insertFromTemplate(PDO $pdo, string $table, array $cols, array $template, array $overrides, bool $dryRun)
+{
+    $data = [];
+    foreach ($cols as $name => $meta) {
+        if (!isInsertableColumn($meta)) {
+            continue;
+        }
+        $data[$name] = array_key_exists($name, $overrides) ? $overrides[$name] : ($template[$name] ?? null);
+    }
+
+    out('  Overrides:');
+    foreach ($overrides as $col => $value) {
+        out('    ' . $col . ' = ' . printableValue($value));
+    }
+
+    if ($dryRun) {
+        return null;
+    }
+
+    $fields = array_keys($data);
+    $placeholders = [];
+    $values = [];
+    foreach ($fields as $i => $field) {
+        $placeholder = ':v' . $i;
+        $placeholders[] = $placeholder;
+        $values[$placeholder] = $data[$field];
+    }
+    $sql = 'INSERT INTO ' . qi($table) . ' (' . implode(', ', array_map('qi', $fields)) . ') VALUES (' . implode(', ', $placeholders) . ')';
+    $stmt = $pdo->prepare($sql);
+    $stmt->execute($values);
+    return $pdo->lastInsertId();
+}
+
+function ensureRelation(PDO $pdo, array $rel, $productId, $categoryId): void
+{
+    $check = $pdo->prepare('SELECT 1 FROM ' . qi($rel['table']) . ' WHERE ' . qi($rel['product_col']) . ' = ? AND ' . qi($rel['category_col']) . ' = ? LIMIT 1');
+    $check->execute([$productId, $categoryId]);
+    if ($check->fetchColumn()) {
+        return;
+    }
+
+    $cols = columns($pdo, $rel['table']);
+    $template = selectAnyRow($pdo, $rel['table']);
+    $overrides = [
+        $rel['product_col'] => $productId,
+        $rel['category_col'] => $categoryId,
+    ];
+
+    if ($template) {
+        insertFromTemplate($pdo, $rel['table'], $cols, $template, $overrides, false);
+        return;
+    }
+
+    $fields = array_keys($overrides);
+    foreach ($cols as $name => $meta) {
+        if (isset($overrides[$name]) || !isInsertableColumn($meta)) {
+            continue;
+        }
+        if (strtoupper((string)$meta['Null']) === 'NO' && $meta['Default'] === null) {
+            throw new RuntimeException("Relation {$rel['table']} hat Pflichtspalte {$name}; keine Template-Zeile vorhanden.");
+        }
+    }
+    $sql = 'INSERT INTO ' . qi($rel['table']) . ' (' . implode(', ', array_map('qi', $fields)) . ') VALUES (?, ?)';
+    $stmt = $pdo->prepare($sql);
+    $stmt->execute([$productId, $categoryId]);
+}
+
+function selectTemplateRow(PDO $pdo, string $table, array $cols, string $idCol): array
+{
+    [$where, $note] = visibilityWhere($cols);
+    if ($note) {
+        out("Hinweis {$table}: {$note}");
+    }
+    $sql = 'SELECT * FROM ' . qi($table) . ' WHERE ' . $where . ' ORDER BY ' . qi($idCol) . ' ASC LIMIT 1';
+    $row = $pdo->query($sql)->fetch();
+    if (!$row) {
+        throw new RuntimeException("Keine sichtbare Template-Zeile in {$table} gefunden.");
+    }
+    return $row;
+}
+
+function selectAnyRow(PDO $pdo, string $table): ?array
+{
+    $row = $pdo->query('SELECT * FROM ' . qi($table) . ' LIMIT 1')->fetch();
+    return $row ?: null;
+}
+
+function visibilityWhere(array $cols): array
+{
+    $clauses = [];
+    foreach (['visible', 'is_visible', 'show', 'enabled', 'active', 'is_active'] as $col) {
+        if (isset($cols[$col])) {
+            $clauses[] = qi($col) . " IN (1, '1', 'yes', 'Yes', 'true', 'True', 'on', 'On')";
+        }
+    }
+    foreach (['hidden', 'is_hidden', 'disabled', 'is_disabled', 'deleted', 'archived'] as $col) {
+        if (isset($cols[$col])) {
+            $clauses[] = qi($col) . " IN (0, '0', 'no', 'No', 'false', 'False', '', 'off', 'Off')";
+        }
+    }
+    foreach (['status', 'state'] as $col) {
+        if (isset($cols[$col])) {
+            $clauses[] = qi($col) . " IN ('Active', 'active', 'Enabled', 'enabled', 'Visible', 'visible', '1', 1)";
+        }
+    }
+
+    if (!$clauses) {
+        return ['1=1', 'keine bekannte Sichtbarkeitsspalte erkannt; nutze erste vorhandene Zeile als Vorlage.'];
+    }
+    return [implode(' AND ', $clauses), null];
+}
+
+function applyVisibilityOverrides(array $cols, array &$overrides): void
+{
+    foreach (['visible', 'is_visible', 'show', 'enabled', 'active', 'is_active'] as $col) {
+        if (isset($cols[$col])) {
+            $overrides[$col] = 1;
+        }
+    }
+    foreach (['hidden', 'is_hidden', 'disabled', 'is_disabled', 'deleted', 'archived'] as $col) {
+        if (isset($cols[$col])) {
+            $overrides[$col] = 0;
+        }
+    }
+    foreach (['status', 'state'] as $col) {
+        if (isset($cols[$col])) {
+            $overrides[$col] = statusActiveValue($cols[$col]);
+        }
+    }
+}
+
+function statusActiveValue(array $meta): string
+{
+    $type = (string)$meta['Type'];
+    if (preg_match_all("/'((?:[^'\\\\]|\\\\.)*)'/", $type, $matches)) {
+        foreach ($matches[1] as $value) {
+            if (in_array(strtolower($value), ['active', 'enabled', 'visible'], true)) {
+                return $value;
+            }
+        }
+        if (in_array('1', $matches[1], true)) {
+            return '1';
+        }
+    }
+    return 'Active';
+}
+
+function recurringValueForColumn(array $meta): string
+{
+    $type = (string)$meta['Type'];
+    if (preg_match_all("/'((?:[^'\\\\]|\\\\.)*)'/", $type, $matches)) {
+        foreach ($matches[1] as $value) {
+            if (stripos($value, 'recurr') !== false) {
+                return $value;
+            }
+        }
+    }
+    return 'Recurring';
+}
+
+function discoverProductCategoryRelation(PDO $pdo, string $productIdCol, string $categoryIdCol): ?array
+{
+    $tables = $pdo->query('SHOW TABLES')->fetchAll(PDO::FETCH_COLUMN);
+    foreach ($tables as $table) {
+        $lower = strtolower((string)$table);
+        if (!preg_match('/(product.*categor|categor.*product|product.*orderpage|orderpage.*product)/', $lower)) {
+            continue;
+        }
+        $cols = columns($pdo, (string)$table);
+        $productCol = pickColumn($cols, ['product_id', 'productid', 'prod_id', 'pid', $productIdCol]);
+        $categoryCol = pickColumn($cols, ['category_id', 'cat_id', 'categoryid', 'cid', 'orderpage_id', $categoryIdCol]);
+        if ($productCol && $categoryCol && $productCol !== $categoryCol) {
+            return [
+                'table' => (string)$table,
+                'product_col' => $productCol,
+                'category_col' => $categoryCol,
+            ];
+        }
+    }
+    return null;
+}
+
+function relationDescription(?array $relation): string
+{
+    if (!$relation) {
+        return '(keine Relation)';
+    }
+    return $relation['table'] . '.' . $relation['product_col'] . ' -> Produkt, ' . $relation['table'] . '.' . $relation['category_col'] . ' -> Kategorie';
+}
+
+function createBackup(PDO $pdo, string $source, string $backup): void
+{
+    $pdo->exec('CREATE TABLE ' . qi($backup) . ' LIKE ' . qi($source));
+    $pdo->exec('INSERT INTO ' . qi($backup) . ' SELECT * FROM ' . qi($source));
+}
+
+function uniqueBackupName(PDO $pdo, string $table, int $timestamp): string
+{
+    $base = $table . '_bak_' . $timestamp;
+    $candidate = $base;
+    $i = 1;
+    while (tableExists($pdo, $candidate)) {
+        $candidate = $base . '_' . $i;
+        $i++;
+    }
+    return $candidate;
+}
+
+function autoIncrementColumn(array $cols): ?string
+{
+    foreach ($cols as $name => $meta) {
+        if (stripos((string)$meta['Extra'], 'auto_increment') !== false) {
+            return $name;
+        }
+    }
+    return null;
+}
+
+function isInsertableColumn(array $meta): bool
+{
+    $extra = strtolower((string)$meta['Extra']);
+    return strpos($extra, 'auto_increment') === false
+        && strpos($extra, 'generated') === false
+        && strpos($extra, 'virtual') === false
+        && strpos($extra, 'stored') === false;
+}
+
+function pickColumn(array $cols, array $candidates): ?string
+{
+    $map = [];
+    foreach (array_keys($cols) as $name) {
+        $map[strtolower($name)] = $name;
+    }
+    foreach ($candidates as $candidate) {
+        $key = strtolower($candidate);
+        if (isset($map[$key])) {
+            return $map[$key];
+        }
+    }
+    return null;
+}
+
+function pickExistingColumns(array $cols, array $candidates): array
+{
+    $found = [];
+    foreach ($candidates as $candidate) {
+        $col = pickColumn($cols, [$candidate]);
+        if ($col && !in_array($col, $found, true)) {
+            $found[] = $col;
+        }
+    }
+    return $found;
+}
+
+function pickMonthlyColumn(array $cols): ?string
+{
+    foreach (['m', 'monthly', 'month', 'price_monthly', 'monthly_price', 'recurring_monthly'] as $candidate) {
+        $col = pickColumn($cols, [$candidate]);
+        if ($col && isNumericType($cols[$col]['Type'])) {
+            return $col;
+        }
+    }
+    foreach ($cols as $name => $meta) {
+        if (stripos($name, 'month') !== false && isNumericType($meta['Type'])) {
+            return $name;
+        }
+    }
+    return null;
+}
+
+function pickProductCategoryColumn(array $cols): ?string
+{
+    foreach (['category_id', 'cat_id', 'categoryid', 'orderpage_id', 'cid', 'group_id'] as $candidate) {
+        $col = pickColumn($cols, [$candidate]);
+        if ($col && isNumericType($cols[$col]['Type'])) {
+            return $col;
+        }
+    }
+    $col = pickColumn($cols, ['category']);
+    if ($col && isNumericType($cols[$col]['Type'])) {
+        return $col;
+    }
+    return null;
+}
+
+function isNumericType(string $type): bool
+{
+    return (bool)preg_match('/\b(tinyint|smallint|mediumint|int|bigint|decimal|numeric|float|double|real)\b/i', $type);
+}
+
+function isStringType(string $type): bool
+{
+    return (bool)preg_match('/\b(char|varchar|text|enum|set)\b/i', $type);
+}
+
+function disabledPeriodValue($templateValue)
+{
+    if (is_numeric($templateValue) && (float)$templateValue < 0) {
+        return $templateValue;
+    }
+    return '-1';
+}
+
+function describeRow(array $row, string $idCol, ?string $nameCol): string
+{
+    $parts = ['ID ' . ($row[$idCol] ?? '?')];
+    if ($nameCol && isset($row[$nameCol])) {
+        $parts[] = $nameCol . '=' . printableValue($row[$nameCol]);
+    }
+    return implode(', ', $parts);
+}
+
+function printableValue($value): string
+{
+    if ($value === null) {
+        return 'NULL';
+    }
+    $str = (string)$value;
+    if (strlen($str) > 90) {
+        $str = substr($str, 0, 87) . '...';
+    }
+    return "'" . $str . "'";
+}
+
+function displayId($id, string $fallback = '<wird von AUTO_INCREMENT vergeben>'): string
+{
+    return ($id === null || $id === '') ? $fallback : (string)$id;
+}
+
+function slugify(string $value): string
+{
+    $value = str_replace(['Ä', 'Ö', 'Ü', 'ä', 'ö', 'ü', 'ß'], ['Ae', 'Oe', 'Ue', 'ae', 'oe', 'ue', 'ss'], $value);
+    $value = strtolower($value);
+    $value = preg_replace('/[^a-z0-9]+/', '-', $value) ?? $value;
+    $value = trim($value, '-');
+    return $value !== '' ? $value : 'kiwz-produkt';
+}
+
+function qi(string $identifier): string
+{
+    if (!preg_match('/^[A-Za-z0-9_]+$/', $identifier)) {
+        throw new RuntimeException("Unsicherer SQL-Identifier: {$identifier}");
+    }
+    return '`' . $identifier . '`';
+}
+
+function out(string $line): void
+{
+    echo $line . PHP_EOL;
+}
+
diff --git a/kiwz_prio_codes.txt b/kiwz_prio_codes.txt
new file mode 100644
index 0000000000000000000000000000000000000000..4acd9c408d2fbad263e01ddab541742f0709b92b
--- /dev/null
+++ b/kiwz_prio_codes.txt
@@ -0,0 +1,30 @@
+KIWZ-X7QA-H4NZ
+KIWZ-BM6T-9VDK
+KIWZ-3LPR-W8CY
+KIWZ-F2ZH-QN5A
+KIWZ-7GVM-KX4T
+KIWZ-N9QS-2JWR
+KIWZ-H8DC-V6PY
+KIWZ-4TFK-ZM7B
+KIWZ-Q2WX-5ANR
+KIWZ-Y6PJ-C9LT
+KIWZ-8KDA-RV3M
+KIWZ-Z5HN-WQ7F
+KIWZ-2VTC-JK9P
+KIWZ-M4RY-8XGB
+KIWZ-6QNL-FZ2D
+KIWZ-CP7W-3HKA
+KIWZ-J9XB-T5VQ
+KIWZ-W2MG-6RZN
+KIWZ-5DFY-P8KC
+KIWZ-A7VT-NQ4H
+KIWZ-R3KZ-Y6PM
+KIWZ-L8QC-2WJF
+KIWZ-9NBH-X5TA
+KIWZ-V4PR-7ZDG
+KIWZ-T6JW-M2QC
+KIWZ-G5KA-9VYR
+KIWZ-2XND-H8FP
+KIWZ-P7ZM-C4WT
+KIWZ-Q9RY-6BKL
+KIWZ-F3VC-J5XN

diff --git a/ANLEITUNG.md b/ANLEITUNG.md
new file mode 100644
index 0000000000000000000000000000000000000000..855de0955bb7085d75847133170994b159e380e3
--- /dev/null
+++ b/ANLEITUNG.md
@@ -0,0 +1,51 @@
+# HostBill-Setup für kiwerkzeuge.de
+
+## Skript ausführen
+
+Das Skript ist standardmäßig ein Dry-Run und schreibt dabei nichts in die Datenbank:
+
+```bash
+php kiwz_hostbill_setup.php
+```
+
+Wenn Schema, Template-Zeilen, Backups und geplante Inserts plausibel aussehen, ausführen mit:
+
+```bash
+php kiwz_hostbill_setup.php --apply
+```
+
+Optional kann ein abweichender HostBill-Pfad oder eine konkrete Config-Datei angegeben werden:
+
+```bash
+php kiwz_hostbill_setup.php --hostbill-root=/home/support.panomity.com/public_html
+php kiwz_hostbill_setup.php --config=/home/support.panomity.com/public_html/includes/config.php
+```
+
+Vor den Inserts erstellt das Skript Backup-Tabellen per `CREATE TABLE ... LIKE` und `INSERT INTO ... SELECT`, z. B. `hb_products_bak_<timestamp>`. Das Skript löscht oder aktualisiert keine bestehenden Zeilen.
+
+## Manuelle Alternative im HostBill-Admin
+
+Falls die Einrichtung lieber per Klick gemacht wird:
+
+1. `padm` öffnen und zu `Settings -> Products & Services` gehen.
+2. Neue Orderpage/Kategorie `KI Werkzeuge` anlegen, sichtbar schalten und als Slug z. B. `ki-werkzeuge` setzen.
+3. Drei monatlich wiederkehrende EUR-Produkte in dieser Orderpage anlegen:
+   - `KI Starter`, 4.90 EUR/Monat, `Priority-Rendering für Bildgenerator & Tiefenkarte, E-Mail-Support`
+   - `KI Pro`, 14.90 EUR/Monat, `Priority für alle Werkzeuge inkl. Musik & Video, LoRA-Training bevorzugt`
+   - `KI Business`, 39.90 EUR/Monat, `Höchste Priorität, alle Werkzeuge, Support mit 24h-Reaktionszeit`
+
+Danach Zahlungsmodul-Zuordnung, Steuersatz und Sichtbarkeit im Warenkorb prüfen.
+
+## Willkommens-E-Mail mit Priority-Code
+
+Für jedes Produkt sollte eine Willkommens-E-Mail bzw. Produktaktivierungs-Mail eingerichtet werden. Der Inhalt sollte dem Kunden genau einen freien Code aus `kiwz_prio_codes.txt` mitteilen, z. B.:
+
+```text
+Ihr Priority-Code für kiwerkzeuge.de lautet: KIWZ-XXXX-XXXX
+```
+
+Wichtig: Codes müssen nach Ausgabe als vergeben markiert werden, damit derselbe Code nicht mehrfach an Kunden geht. Für den ersten Batch kann das Snippet `kiwz_gpuq_config_snippet.php` genutzt werden; weitere Codes werden dort im Array `'priority_codes' => [...]` ergänzt.
+
+## Multibrand-Hinweis
+
+Das Multibrand-Modul ist installiert. Für eine eigene Brand `kiwerkzeuge.de` wären zusätzlich zur Produktanlage diese Schritte nötig: Brand in `hb_brands` bzw. über den HostBill-Admin anlegen, Domain/Portal-URL und Branding in `hb_brand_config` konfigurieren, Template/Logo/E-Mail-Absender setzen, Zahlungs- und Steuerregeln der Brand zuordnen und die neue Orderpage/Produkte dieser Brand sichtbar zuweisen. Diese Anleitung dokumentiert das nur; das Setup-Skript legt keine neue Brand an.
diff --git a/kiwz_gpuq_config_snippet.php b/kiwz_gpuq_config_snippet.php
new file mode 100644
index 0000000000000000000000000000000000000000..c089e9c811a945dc3d3da6515b6c7d714f4dfd66
--- /dev/null
+++ b/kiwz_gpuq_config_snippet.php
@@ -0,0 +1,19 @@
+<?php
+
+// Beispiel-Batch für die ersten Kunden. Weitere Codes aus kiwz_prio_codes.txt
+// nach gleichem Muster ergänzen, z. B. 'KIWZ-XXXX-XXXX' => 'high'.
+$gpuqConfig = [
+    'priority_codes' => [
+        'KIWZ-X7QA-H4NZ' => 'high',
+        'KIWZ-BM6T-9VDK' => 'high',
+        'KIWZ-3LPR-W8CY' => 'high',
+        'KIWZ-F2ZH-QN5A' => 'high',
+        'KIWZ-7GVM-KX4T' => 'high',
+        'KIWZ-N9QS-2JWR' => 'high',
+        'KIWZ-H8DC-V6PY' => 'high',
+        'KIWZ-4TFK-ZM7B' => 'high',
+        'KIWZ-Q2WX-5ANR' => 'high',
+        'KIWZ-Y6PJ-C9LT' => 'high',
+    ],
+];
+
diff --git a/kiwz_hostbill_setup.php b/kiwz_hostbill_setup.php
new file mode 100644
index 0000000000000000000000000000000000000000..31546e4cb6cf26c6f4a2687cb03d66edc346a12d
--- /dev/null
+++ b/kiwz_hostbill_setup.php
@@ -0,0 +1,768 @@
+#!/usr/bin/env php
+<?php
+declare(strict_types=1);
+
+/**
+ * HostBill setup helper for kiwerkzeuge.de.
+ *
+ * Default mode is dry-run. The script intentionally discovers the HostBill
+ * schema at runtime and copies visible template rows instead of assuming an
+ * exact HostBill table layout.
+ */
+
+const HOSTBILL_DEFAULT_ROOT = '/home/support.panomity.com/public_html';
+const PORTAL_BASE_URL = 'https://support.panomity.com';
+const CATEGORY_NAME = 'KI Werkzeuge';
+const CATEGORY_SLUG = 'ki-werkzeuge';
+const CATEGORY_DESCRIPTION = 'Deutschsprachige KI-Tool-Suite mit Bildgenerator, Musik, Video und Tiefenkarte.';
+
+$PRODUCTS = [
+    [
+        'name' => 'KI Starter',
+        'price' => '4.90',
+        'description' => 'Priority-Rendering für Bildgenerator & Tiefenkarte, E-Mail-Support',
+    ],
+    [
+        'name' => 'KI Pro',
+        'price' => '14.90',
+        'description' => 'Priority für alle Werkzeuge inkl. Musik & Video, LoRA-Training bevorzugt',
+    ],
+    [
+        'name' => 'KI Business',
+        'price' => '39.90',
+        'description' => 'Höchste Priorität, alle Werkzeuge, Support mit 24h-Reaktionszeit',
+    ],
+];
+
+main($PRODUCTS);
+
+function main(array $products): void
+{
+    if (PHP_SAPI !== 'cli') {
+        fwrite(STDERR, "Dieses Skript muss mit php-cli ausgeführt werden.\n");
+        exit(1);
+    }
+
+    $opts = getopt('', ['apply', 'help', 'hostbill-root:', 'config:']);
+    if (isset($opts['help'])) {
+        printHelp();
+        return;
+    }
+
+    $dryRun = !isset($opts['apply']);
+    $hostbillRoot = rtrim((string)($opts['hostbill-root'] ?? HOSTBILL_DEFAULT_ROOT), '/');
+    $configFile = (string)($opts['config'] ?? ($hostbillRoot . '/includes/config.php'));
+
+    out('HostBill kiwerkzeuge.de Setup');
+    out('Modus: ' . ($dryRun ? 'DRY-RUN (keine Schreibzugriffe)' : 'APPLY (schreibt in die Datenbank)'));
+    out('Config: ' . $configFile);
+    out('');
+
+    $pdo = connectFromHostbillConfig($configFile);
+    $backupTables = [];
+    $transactionStarted = false;
+
+    try {
+        requireTable($pdo, 'hb_categories');
+        requireTable($pdo, 'hb_products');
+
+        $categoryCols = columns($pdo, 'hb_categories');
+        $productCols = columns($pdo, 'hb_products');
+
+        printSchema('hb_categories', $categoryCols);
+        printSchema('hb_products', $productCols);
+
+        $categoryPlan = buildCategoryPlan($pdo, $categoryCols);
+        out('Kategorie-Vorlage: ' . describeRow($categoryPlan['template'], $categoryPlan['id_col'], $categoryPlan['name_col']));
+
+        $productPlan = buildProductPlan($pdo, $productCols, $categoryPlan['id_col']);
+        out('Produkt-Vorlage: ' . describeRow($productPlan['template'], $productPlan['id_col'], $productPlan['name_col']));
+        out('Monatspreisspalte: ' . $productPlan['monthly_col']);
+        out('Deaktivierte Zahlweisen: ' . ($productPlan['disabled_period_cols'] ? implode(', ', $productPlan['disabled_period_cols']) : '(keine weiteren Periodenspalten erkannt)'));
+        out('Kategorie-Verknüpfung: ' . ($productPlan['category_col'] ? "hb_products.{$productPlan['category_col']}" : relationDescription($productPlan['relation'])));
+        out('');
+
+        $affectedTables = ['hb_categories', 'hb_products'];
+        if ($productPlan['relation']) {
+            $affectedTables[] = $productPlan['relation']['table'];
+            printSchema($productPlan['relation']['table'], columns($pdo, $productPlan['relation']['table']));
+        }
+
+        $timestamp = time();
+        foreach (array_values(array_unique($affectedTables)) as $table) {
+            $backupName = uniqueBackupName($pdo, $table, $timestamp);
+            $backupTables[$table] = $backupName;
+            out(($dryRun ? 'Würde Backup erstellen: ' : 'Erstelle Backup: ') . "{$backupName} aus {$table}");
+            if (!$dryRun) {
+                createBackup($pdo, $table, $backupName);
+            }
+        }
+        out('');
+
+        if (!$dryRun) {
+            $pdo->beginTransaction();
+            $transactionStarted = true;
+        }
+
+        $category = ensureCategory($pdo, $categoryPlan, $dryRun);
+        $categoryId = $category['id'];
+        $categorySlug = $category['slug'];
+
+        $productResults = [];
+        foreach ($products as $product) {
+            $productResults[] = ensureProduct($pdo, $productPlan, $categoryId, $product, $dryRun);
+        }
+
+        if (!$dryRun) {
+            $pdo->commit();
+            $transactionStarted = false;
+        }
+
+        out('');
+        out('Ergebnis');
+        out('Kategorie-ID: ' . displayId($categoryId));
+        foreach ($productResults as $result) {
+            out(sprintf('Produkt: %s -> ID %s (%s)', $result['name'], displayId($result['id']), $result['status']));
+        }
+        out('');
+        out('Bestell-URLs');
+        foreach ($productResults as $result) {
+            $id = displayId($result['id'], '<PID>');
+            out($result['name'] . ': ' . PORTAL_BASE_URL . '/?cmd=cart&action=add&id=' . $id);
+        }
+        if ($categorySlug !== null && $categorySlug !== '') {
+            out('Orderpage: ' . PORTAL_BASE_URL . '/cart/' . $categorySlug);
+        } else {
+            out('Orderpage: /cart/<kategorie-slug> nicht ausgebbar, weil keine Slug-Spalte erkannt wurde.');
+        }
+        out('');
+        out('Manuelle Admin-Schritte bleiben: Zahlungsmodul-Zuordnung prüfen, Steuersatz prüfen, Willkommens-E-Mail je Produkt mit Priority-Code konfigurieren.');
+        out('Rollback-Hinweis: Für einen manuellen Restore wurden diese Backup-Tabellen vorbereitet/angezeigt:');
+        foreach ($backupTables as $table => $backupName) {
+            out("  {$table} -> {$backupName}");
+        }
+        out('Dieses Skript führt kein automatisches --rollback aus, damit bestehende Daten nicht unbeabsichtigt gelöscht oder überschrieben werden.');
+    } catch (Throwable $e) {
+        if ($transactionStarted && $pdo->inTransaction()) {
+            $pdo->rollBack();
+        }
+        fwrite(STDERR, "\nABBRUCH: " . $e->getMessage() . "\n");
+        if ($backupTables) {
+            fwrite(STDERR, "Backup-Tabellen für manuelle Prüfung/Restore:\n");
+            foreach ($backupTables as $table => $backupName) {
+                fwrite(STDERR, "  {$table} -> {$backupName}\n");
+            }
+        }
+        exit(1);
+    }
+}
+
+function printHelp(): void
+{
+    out("Nutzung:");
+    out("  php kiwz_hostbill_setup.php");
+    out("  php kiwz_hostbill_setup.php --apply");
+    out("  php kiwz_hostbill_setup.php --hostbill-root=/home/support.panomity.com/public_html");
+    out("  php kiwz_hostbill_setup.php --config=/pfad/zu/includes/config.php");
+}
+
+function connectFromHostbillConfig(string $configFile): PDO
+{
+    if (!is_file($configFile) || !is_readable($configFile)) {
+        throw new RuntimeException("HostBill config.php nicht lesbar: {$configFile}");
+    }
+
+    $db_host = $db_name = $db_user = $db_pass = null;
+    $db_port = null;
+    require $configFile;
+
+    foreach (['db_host', 'db_name', 'db_user', 'db_pass'] as $var) {
+        if (!isset($$var) || $$var === '') {
+            throw new RuntimeException("HostBill config.php definiert \${$var} nicht.");
+        }
+    }
+
+    $dsn = 'mysql:host=' . $db_host . ';dbname=' . $db_name . ';charset=utf8mb4';
+    if (!empty($db_port)) {
+        $dsn .= ';port=' . (int)$db_port;
+    }
+
+    return new PDO($dsn, (string)$db_user, (string)$db_pass, [
+        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
+        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+        PDO::ATTR_EMULATE_PREPARES => false,
+    ]);
+}
+
+function requireTable(PDO $pdo, string $table): void
+{
+    $stmt = $pdo->prepare('SHOW TABLES LIKE ?');
+    $stmt->execute([$table]);
+    if (!$stmt->fetchColumn()) {
+        throw new RuntimeException("Erwartete Tabelle fehlt: {$table}");
+    }
+}
+
+function tableExists(PDO $pdo, string $table): bool
+{
+    $stmt = $pdo->prepare('SHOW TABLES LIKE ?');
+    $stmt->execute([$table]);
+    return (bool)$stmt->fetchColumn();
+}
+
+function columns(PDO $pdo, string $table): array
+{
+    $rows = $pdo->query('SHOW COLUMNS FROM ' . qi($table))->fetchAll();
+    if (!$rows) {
+        throw new RuntimeException("Keine Spalten gefunden in {$table}");
+    }
+    $out = [];
+    foreach ($rows as $row) {
+        $out[$row['Field']] = $row;
+    }
+    return $out;
+}
+
+function printSchema(string $table, array $cols): void
+{
+    out("Schema {$table}:");
+    foreach ($cols as $name => $meta) {
+        $extra = trim(($meta['Key'] ? ' key=' . $meta['Key'] : '') . ($meta['Extra'] ? ' extra=' . $meta['Extra'] : ''));
+        out('  - ' . $name . ' ' . $meta['Type'] . ($extra ? ' (' . $extra . ')' : ''));
+    }
+    out('');
+}
+
+function buildCategoryPlan(PDO $pdo, array $cols): array
+{
+    $idCol = autoIncrementColumn($cols) ?? pickColumn($cols, ['id', 'category_id', 'cat_id']);
+    $nameCol = pickColumn($cols, ['name', 'title', 'category', 'catname', 'label']);
+    $descCol = pickColumn($cols, ['description', 'descr', 'details', 'content', 'text', 'info']);
+    $slugCol = pickColumn($cols, ['slug', 'url', 'sefurl', 'sef_url', 'urlname', 'url_name']);
+
+    if (!$idCol || !$nameCol) {
+        throw new RuntimeException('hb_categories: ID- oder Name-Spalte nicht sicher erkannt.');
+    }
+
+    $template = selectTemplateRow($pdo, 'hb_categories', $cols, $idCol);
+
+    return [
+        'table' => 'hb_categories',
+        'cols' => $cols,
+        'id_col' => $idCol,
+        'name_col' => $nameCol,
+        'desc_col' => $descCol,
+        'slug_col' => $slugCol,
+        'template' => $template,
+    ];
+}
+
+function buildProductPlan(PDO $pdo, array $cols, string $categoryIdCol): array
+{
+    $idCol = autoIncrementColumn($cols) ?? pickColumn($cols, ['id', 'product_id', 'pid']);
+    $nameCol = pickColumn($cols, ['name', 'title', 'product_name', 'label']);
+    $descCol = pickColumn($cols, ['description', 'descr', 'details', 'content', 'text', 'info', 'short_description', 'shortdesc']);
+    $slugCol = pickColumn($cols, ['slug', 'url', 'sefurl', 'sef_url', 'urlname', 'url_name']);
+    $monthlyCol = pickMonthlyColumn($cols);
+    $categoryCol = pickProductCategoryColumn($cols);
+    $relation = null;
+
+    if (!$idCol || !$nameCol || !$descCol || !$monthlyCol) {
+        throw new RuntimeException('hb_products: ID-, Name-, Beschreibungs- oder Monatspreis-Spalte nicht sicher erkannt.');
+    }
+
+    if (!$categoryCol) {
+        $relation = discoverProductCategoryRelation($pdo, $idCol, $categoryIdCol);
+        if (!$relation) {
+            throw new RuntimeException('hb_products: Keine Kategorie-Spalte und keine Produkt/Kategorie-Relation sicher erkannt.');
+        }
+    }
+
+    $template = selectTemplateRow($pdo, 'hb_products', $cols, $idCol);
+
+    $periodCols = pickExistingColumns($cols, ['m', 'monthly', 'month', 'price_monthly', 'monthly_price', 'q', 'quarterly', 's', 'semiannually', 'semiannual', 'a', 'annually', 'annual', 'b', 'biennially', 'biennial', 't', 'triennially', 'triennial']);
+    $disabledPeriodCols = array_values(array_diff($periodCols, [$monthlyCol]));
+
+    return [
+        'table' => 'hb_products',
+        'cols' => $cols,
+        'id_col' => $idCol,
+        'name_col' => $nameCol,
+        'desc_col' => $descCol,
+        'slug_col' => $slugCol,
+        'monthly_col' => $monthlyCol,
+        'disabled_period_cols' => $disabledPeriodCols,
+        'category_col' => $categoryCol,
+        'relation' => $relation,
+        'template' => $template,
+    ];
+}
+
+function ensureCategory(PDO $pdo, array $plan, bool $dryRun): array
+{
+    $existing = findCategory($pdo, $plan, CATEGORY_NAME);
+    if ($existing) {
+        out('Kategorie existiert bereits, keine Änderung: ID ' . $existing[$plan['id_col']]);
+        return [
+            'id' => $existing[$plan['id_col']],
+            'slug' => $plan['slug_col'] ? (string)($existing[$plan['slug_col']] ?? CATEGORY_SLUG) : null,
+        ];
+    }
+
+    $overrides = [
+        $plan['name_col'] => CATEGORY_NAME,
+    ];
+    if ($plan['desc_col']) {
+        $overrides[$plan['desc_col']] = CATEGORY_DESCRIPTION;
+    }
+    if ($plan['slug_col']) {
+        $overrides[$plan['slug_col']] = CATEGORY_SLUG;
+    }
+    applyVisibilityOverrides($plan['cols'], $overrides);
+
+    out(($dryRun ? 'Würde Kategorie anlegen: ' : 'Lege Kategorie an: ') . CATEGORY_NAME);
+    $id = insertFromTemplate($pdo, $plan['table'], $plan['cols'], $plan['template'], $overrides, $dryRun);
+
+    return [
+        'id' => $dryRun ? null : $id,
+        'slug' => $plan['slug_col'] ? CATEGORY_SLUG : null,
+    ];
+}
+
+function ensureProduct(PDO $pdo, array $plan, $categoryId, array $product, bool $dryRun): array
+{
+    if (!$dryRun && ($categoryId === null || $categoryId === '')) {
+        throw new RuntimeException('Kategorie-ID fehlt für Produktanlage.');
+    }
+
+    $existing = $dryRun ? null : findProduct($pdo, $plan, $categoryId, $product['name']);
+    if ($existing) {
+        out('Produkt existiert bereits, keine Änderung: ' . $product['name'] . ' (ID ' . $existing[$plan['id_col']] . ')');
+        return [
+            'name' => $product['name'],
+            'id' => $existing[$plan['id_col']],
+            'status' => 'vorhanden',
+        ];
+    }
+
+    $overrides = [
+        $plan['name_col'] => $product['name'],
+        $plan['desc_col'] => $product['description'],
+        $plan['monthly_col'] => $product['price'],
+    ];
+    if ($plan['slug_col']) {
+        $overrides[$plan['slug_col']] = slugify($product['name']);
+    }
+    if ($plan['category_col']) {
+        $overrides[$plan['category_col']] = $dryRun ? '<neue Kategorie-ID>' : $categoryId;
+    }
+    foreach ($plan['disabled_period_cols'] as $col) {
+        $overrides[$col] = disabledPeriodValue($plan['template'][$col] ?? null);
+    }
+    foreach (pickExistingColumns($plan['cols'], ['paytype', 'pay_type', 'p_type', 'billingtype', 'billing_type']) as $col) {
+        $overrides[$col] = recurringValueForColumn($plan['cols'][$col]);
+    }
+    foreach (pickExistingColumns($plan['cols'], ['currency', 'curr']) as $col) {
+        if (isStringType($plan['cols'][$col]['Type'])) {
+            $overrides[$col] = 'EUR';
+        }
+    }
+    applyVisibilityOverrides($plan['cols'], $overrides);
+
+    out(($dryRun ? 'Würde Produkt anlegen: ' : 'Lege Produkt an: ') . $product['name'] . ' für ' . $product['price'] . ' EUR/Monat');
+    $id = insertFromTemplate($pdo, $plan['table'], $plan['cols'], $plan['template'], $overrides, $dryRun);
+
+    if ($plan['relation']) {
+        out(($dryRun ? 'Würde Produkt/Kategorie-Relation anlegen: ' : 'Lege Produkt/Kategorie-Relation an: ') . relationDescription($plan['relation']));
+        if (!$dryRun) {
+            ensureRelation($pdo, $plan['relation'], $id, $categoryId);
+        }
+    }
+
+    return [
+        'name' => $product['name'],
+        'id' => $dryRun ? null : $id,
+        'status' => $dryRun ? 'dry-run' : 'neu',
+    ];
+}
+
+function findCategory(PDO $pdo, array $plan, string $name): ?array
+{
+    $sql = 'SELECT * FROM ' . qi($plan['table']) . ' WHERE ' . qi($plan['name_col']) . ' = ? LIMIT 1';
+    $stmt = $pdo->prepare($sql);
+    $stmt->execute([$name]);
+    $row = $stmt->fetch();
+    return $row ?: null;
+}
+
+function findProduct(PDO $pdo, array $plan, $categoryId, string $name): ?array
+{
+    if ($plan['category_col']) {
+        $sql = 'SELECT * FROM ' . qi($plan['table']) . ' WHERE ' . qi($plan['name_col']) . ' = ? AND ' . qi($plan['category_col']) . ' = ? LIMIT 1';
+        $stmt = $pdo->prepare($sql);
+        $stmt->execute([$name, $categoryId]);
+        $row = $stmt->fetch();
+        return $row ?: null;
+    }
+
+    $rel = $plan['relation'];
+    $sql = 'SELECT p.* FROM ' . qi($plan['table']) . ' p '
+        . 'INNER JOIN ' . qi($rel['table']) . ' r ON p.' . qi($plan['id_col']) . ' = r.' . qi($rel['product_col']) . ' '
+        . 'WHERE p.' . qi($plan['name_col']) . ' = ? AND r.' . qi($rel['category_col']) . ' = ? LIMIT 1';
+    $stmt = $pdo->prepare($sql);
+    $stmt->execute([$name, $categoryId]);
+    $row = $stmt->fetch();
+    return $row ?: null;
+}
+
+function insertFromTemplate(PDO $pdo, string $table, array $cols, array $template, array $overrides, bool $dryRun)
+{
+    $data = [];
+    foreach ($cols as $name => $meta) {
+        if (!isInsertableColumn($meta)) {
+            continue;
+        }
+        $data[$name] = array_key_exists($name, $overrides) ? $overrides[$name] : ($template[$name] ?? null);
+    }
+
+    out('  Overrides:');
+    foreach ($overrides as $col => $value) {
+        out('    ' . $col . ' = ' . printableValue($value));
+    }
+
+    if ($dryRun) {
+        return null;
+    }
+
+    $fields = array_keys($data);
+    $placeholders = [];
+    $values = [];
+    foreach ($fields as $i => $field) {
+        $placeholder = ':v' . $i;
+        $placeholders[] = $placeholder;
+        $values[$placeholder] = $data[$field];
+    }
+    $sql = 'INSERT INTO ' . qi($table) . ' (' . implode(', ', array_map('qi', $fields)) . ') VALUES (' . implode(', ', $placeholders) . ')';
+    $stmt = $pdo->prepare($sql);
+    $stmt->execute($values);
+    return $pdo->lastInsertId();
+}
+
+function ensureRelation(PDO $pdo, array $rel, $productId, $categoryId): void
+{
+    $check = $pdo->prepare('SELECT 1 FROM ' . qi($rel['table']) . ' WHERE ' . qi($rel['product_col']) . ' = ? AND ' . qi($rel['category_col']) . ' = ? LIMIT 1');
+    $check->execute([$productId, $categoryId]);
+    if ($check->fetchColumn()) {
+        return;
+    }
+
+    $cols = columns($pdo, $rel['table']);
+    $template = selectAnyRow($pdo, $rel['table']);
+    $overrides = [
+        $rel['product_col'] => $productId,
+        $rel['category_col'] => $categoryId,
+    ];
+
+    if ($template) {
+        insertFromTemplate($pdo, $rel['table'], $cols, $template, $overrides, false);
+        return;
+    }
+
+    $fields = array_keys($overrides);
+    foreach ($cols as $name => $meta) {
+        if (isset($overrides[$name]) || !isInsertableColumn($meta)) {
+            continue;
+        }
+        if (strtoupper((string)$meta['Null']) === 'NO' && $meta['Default'] === null) {
+            throw new RuntimeException("Relation {$rel['table']} hat Pflichtspalte {$name}; keine Template-Zeile vorhanden.");
+        }
+    }
+    $sql = 'INSERT INTO ' . qi($rel['table']) . ' (' . implode(', ', array_map('qi', $fields)) . ') VALUES (?, ?)';
+    $stmt = $pdo->prepare($sql);
+    $stmt->execute([$productId, $categoryId]);
+}
+
+function selectTemplateRow(PDO $pdo, string $table, array $cols, string $idCol): array
+{
+    [$where, $note] = visibilityWhere($cols);
+    if ($note) {
+        out("Hinweis {$table}: {$note}");
+    }
+    $sql = 'SELECT * FROM ' . qi($table) . ' WHERE ' . $where . ' ORDER BY ' . qi($idCol) . ' ASC LIMIT 1';
+    $row = $pdo->query($sql)->fetch();
+    if (!$row) {
+        throw new RuntimeException("Keine sichtbare Template-Zeile in {$table} gefunden.");
+    }
+    return $row;
+}
+
+function selectAnyRow(PDO $pdo, string $table): ?array
+{
+    $row = $pdo->query('SELECT * FROM ' . qi($table) . ' LIMIT 1')->fetch();
+    return $row ?: null;
+}
+
+function visibilityWhere(array $cols): array
+{
+    $clauses = [];
+    foreach (['visible', 'is_visible', 'show', 'enabled', 'active', 'is_active'] as $col) {
+        if (isset($cols[$col])) {
+            $clauses[] = qi($col) . " IN (1, '1', 'yes', 'Yes', 'true', 'True', 'on', 'On')";
+        }
+    }
+    foreach (['hidden', 'is_hidden', 'disabled', 'is_disabled', 'deleted', 'archived'] as $col) {
+        if (isset($cols[$col])) {
+            $clauses[] = qi($col) . " IN (0, '0', 'no', 'No', 'false', 'False', '', 'off', 'Off')";
+        }
+    }
+    foreach (['status', 'state'] as $col) {
+        if (isset($cols[$col])) {
+            $clauses[] = qi($col) . " IN ('Active', 'active', 'Enabled', 'enabled', 'Visible', 'visible', '1', 1)";
+        }
+    }
+
+    if (!$clauses) {
+        return ['1=1', 'keine bekannte Sichtbarkeitsspalte erkannt; nutze erste vorhandene Zeile als Vorlage.'];
+    }
+    return [implode(' AND ', $clauses), null];
+}
+
+function applyVisibilityOverrides(array $cols, array &$overrides): void
+{
+    foreach (['visible', 'is_visible', 'show', 'enabled', 'active', 'is_active'] as $col) {
+        if (isset($cols[$col])) {
+            $overrides[$col] = 1;
+        }
+    }
+    foreach (['hidden', 'is_hidden', 'disabled', 'is_disabled', 'deleted', 'archived'] as $col) {
+        if (isset($cols[$col])) {
+            $overrides[$col] = 0;
+        }
+    }
+    foreach (['status', 'state'] as $col) {
+        if (isset($cols[$col])) {
+            $overrides[$col] = statusActiveValue($cols[$col]);
+        }
+    }
+}
+
+function statusActiveValue(array $meta): string
+{
+    $type = (string)$meta['Type'];
+    if (preg_match_all("/'((?:[^'\\\\]|\\\\.)*)'/", $type, $matches)) {
+        foreach ($matches[1] as $value) {
+            if (in_array(strtolower($value), ['active', 'enabled', 'visible'], true)) {
+                return $value;
+            }
+        }
+        if (in_array('1', $matches[1], true)) {
+            return '1';
+        }
+    }
+    return 'Active';
+}
+
+function recurringValueForColumn(array $meta): string
+{
+    $type = (string)$meta['Type'];
+    if (preg_match_all("/'((?:[^'\\\\]|\\\\.)*)'/", $type, $matches)) {
+        foreach ($matches[1] as $value) {
+            if (stripos($value, 'recurr') !== false) {
+                return $value;
+            }
+        }
+    }
+    return 'Recurring';
+}
+
+function discoverProductCategoryRelation(PDO $pdo, string $productIdCol, string $categoryIdCol): ?array
+{
+    $tables = $pdo->query('SHOW TABLES')->fetchAll(PDO::FETCH_COLUMN);
+    foreach ($tables as $table) {
+        $lower = strtolower((string)$table);
+        if (!preg_match('/(product.*categor|categor.*product|product.*orderpage|orderpage.*product)/', $lower)) {
+            continue;
+        }
+        $cols = columns($pdo, (string)$table);
+        $productCol = pickColumn($cols, ['product_id', 'productid', 'prod_id', 'pid', $productIdCol]);
+        $categoryCol = pickColumn($cols, ['category_id', 'cat_id', 'categoryid', 'cid', 'orderpage_id', $categoryIdCol]);
+        if ($productCol && $categoryCol && $productCol !== $categoryCol) {
+            return [
+                'table' => (string)$table,
+                'product_col' => $productCol,
+                'category_col' => $categoryCol,
+            ];
+        }
+    }
+    return null;
+}
+
+function relationDescription(?array $relation): string
+{
+    if (!$relation) {
+        return '(keine Relation)';
+    }
+    return $relation['table'] . '.' . $relation['product_col'] . ' -> Produkt, ' . $relation['table'] . '.' . $relation['category_col'] . ' -> Kategorie';
+}
+
+function createBackup(PDO $pdo, string $source, string $backup): void
+{
+    $pdo->exec('CREATE TABLE ' . qi($backup) . ' LIKE ' . qi($source));
+    $pdo->exec('INSERT INTO ' . qi($backup) . ' SELECT * FROM ' . qi($source));
+}
+
+function uniqueBackupName(PDO $pdo, string $table, int $timestamp): string
+{
+    $base = $table . '_bak_' . $timestamp;
+    $candidate = $base;
+    $i = 1;
+    while (tableExists($pdo, $candidate)) {
+        $candidate = $base . '_' . $i;
+        $i++;
+    }
+    return $candidate;
+}
+
+function autoIncrementColumn(array $cols): ?string
+{
+    foreach ($cols as $name => $meta) {
+        if (stripos((string)$meta['Extra'], 'auto_increment') !== false) {
+            return $name;
+        }
+    }
+    return null;
+}
+
+function isInsertableColumn(array $meta): bool
+{
+    $extra = strtolower((string)$meta['Extra']);
+    return strpos($extra, 'auto_increment') === false
+        && strpos($extra, 'generated') === false
+        && strpos($extra, 'virtual') === false
+        && strpos($extra, 'stored') === false;
+}
+
+function pickColumn(array $cols, array $candidates): ?string
+{
+    $map = [];
+    foreach (array_keys($cols) as $name) {
+        $map[strtolower($name)] = $name;
+    }
+    foreach ($candidates as $candidate) {
+        $key = strtolower($candidate);
+        if (isset($map[$key])) {
+            return $map[$key];
+        }
+    }
+    return null;
+}
+
+function pickExistingColumns(array $cols, array $candidates): array
+{
+    $found = [];
+    foreach ($candidates as $candidate) {
+        $col = pickColumn($cols, [$candidate]);
+        if ($col && !in_array($col, $found, true)) {
+            $found[] = $col;
+        }
+    }
+    return $found;
+}
+
+function pickMonthlyColumn(array $cols): ?string
+{
+    foreach (['m', 'monthly', 'month', 'price_monthly', 'monthly_price', 'recurring_monthly'] as $candidate) {
+        $col = pickColumn($cols, [$candidate]);
+        if ($col && isNumericType($cols[$col]['Type'])) {
+            return $col;
+        }
+    }
+    foreach ($cols as $name => $meta) {
+        if (stripos($name, 'month') !== false && isNumericType($meta['Type'])) {
+            return $name;
+        }
+    }
+    return null;
+}
+
+function pickProductCategoryColumn(array $cols): ?string
+{
+    foreach (['category_id', 'cat_id', 'categoryid', 'orderpage_id', 'cid', 'group_id'] as $candidate) {
+        $col = pickColumn($cols, [$candidate]);
+        if ($col && isNumericType($cols[$col]['Type'])) {
+            return $col;
+        }
+    }
+    $col = pickColumn($cols, ['category']);
+    if ($col && isNumericType($cols[$col]['Type'])) {
+        return $col;
+    }
+    return null;
+}
+
+function isNumericType(string $type): bool
+{
+    return (bool)preg_match('/\b(tinyint|smallint|mediumint|int|bigint|decimal|numeric|float|double|real)\b/i', $type);
+}
+
+function isStringType(string $type): bool
+{
+    return (bool)preg_match('/\b(char|varchar|text|enum|set)\b/i', $type);
+}
+
+function disabledPeriodValue($templateValue)
+{
+    if (is_numeric($templateValue) && (float)$templateValue < 0) {
+        return $templateValue;
+    }
+    return '-1';
+}
+
+function describeRow(array $row, string $idCol, ?string $nameCol): string
+{
+    $parts = ['ID ' . ($row[$idCol] ?? '?')];
+    if ($nameCol && isset($row[$nameCol])) {
+        $parts[] = $nameCol . '=' . printableValue($row[$nameCol]);
+    }
+    return implode(', ', $parts);
+}
+
+function printableValue($value): string
+{
+    if ($value === null) {
+        return 'NULL';
+    }
+    $str = (string)$value;
+    if (strlen($str) > 90) {
+        $str = substr($str, 0, 87) . '...';
+    }
+    return "'" . $str . "'";
+}
+
+function displayId($id, string $fallback = '<wird von AUTO_INCREMENT vergeben>'): string
+{
+    return ($id === null || $id === '') ? $fallback : (string)$id;
+}
+
+function slugify(string $value): string
+{
+    $value = str_replace(['Ä', 'Ö', 'Ü', 'ä', 'ö', 'ü', 'ß'], ['Ae', 'Oe', 'Ue', 'ae', 'oe', 'ue', 'ss'], $value);
+    $value = strtolower($value);
+    $value = preg_replace('/[^a-z0-9]+/', '-', $value) ?? $value;
+    $value = trim($value, '-');
+    return $value !== '' ? $value : 'kiwz-produkt';
+}
+
+function qi(string $identifier): string
+{
+    if (!preg_match('/^[A-Za-z0-9_]+$/', $identifier)) {
+        throw new RuntimeException("Unsicherer SQL-Identifier: {$identifier}");
+    }
+    return '`' . $identifier . '`';
+}
+
+function out(string $line): void
+{
+    echo $line . PHP_EOL;
+}
+
diff --git a/kiwz_prio_codes.txt b/kiwz_prio_codes.txt
new file mode 100644
index 0000000000000000000000000000000000000000..4acd9c408d2fbad263e01ddab541742f0709b92b
--- /dev/null
+++ b/kiwz_prio_codes.txt
@@ -0,0 +1,30 @@
+KIWZ-X7QA-H4NZ
+KIWZ-BM6T-9VDK
+KIWZ-3LPR-W8CY
+KIWZ-F2ZH-QN5A
+KIWZ-7GVM-KX4T
+KIWZ-N9QS-2JWR
+KIWZ-H8DC-V6PY
+KIWZ-4TFK-ZM7B
+KIWZ-Q2WX-5ANR
+KIWZ-Y6PJ-C9LT
+KIWZ-8KDA-RV3M
+KIWZ-Z5HN-WQ7F
+KIWZ-2VTC-JK9P
+KIWZ-M4RY-8XGB
+KIWZ-6QNL-FZ2D
+KIWZ-CP7W-3HKA
+KIWZ-J9XB-T5VQ
+KIWZ-W2MG-6RZN
+KIWZ-5DFY-P8KC
+KIWZ-A7VT-NQ4H
+KIWZ-R3KZ-Y6PM
+KIWZ-L8QC-2WJF
+KIWZ-9NBH-X5TA
+KIWZ-V4PR-7ZDG
+KIWZ-T6JW-M2QC
+KIWZ-G5KA-9VYR
+KIWZ-2XND-H8FP
+KIWZ-P7ZM-C4WT
+KIWZ-Q9RY-6BKL
+KIWZ-F3VC-J5XN

codex
Die Dateien sind angelegt. Ich prüfe jetzt die PHP-Syntax und validiere kurz die Code-Liste gegen Format, Anzahl und Zeichenvorrat.
exec
/bin/bash -lc 'php -l kiwz_hostbill_setup.php' in /tmp/kiwz-hostbill-staging
 succeeded in 0ms:
No syntax errors detected in kiwz_hostbill_setup.php

exec
/bin/bash -lc 'php -l kiwz_gpuq_config_snippet.php' in /tmp/kiwz-hostbill-staging
 succeeded in 0ms:
No syntax errors detected in kiwz_gpuq_config_snippet.php

exec
/bin/bash -lc "awk 'BEGIN{ok=1} "'!/''^KIWZ-[ABCDEFGHJKLMNPQRSTUVWXYZ2-9]{4}-[ABCDEFGHJKLMNPQRSTUVWXYZ2-9]{4}$/ {print "bad", NR, $0; ok=0} END{print NR " codes"; exit(ok?0:1)}'"' kiwz_prio_codes.txt" in /tmp/kiwz-hostbill-staging
 succeeded in 0ms:
30 codes

diff --git a/ANLEITUNG.md b/ANLEITUNG.md
new file mode 100644
index 0000000000000000000000000000000000000000..855de0955bb7085d75847133170994b159e380e3
--- /dev/null
+++ b/ANLEITUNG.md
@@ -0,0 +1,51 @@
+# HostBill-Setup für kiwerkzeuge.de
+
+## Skript ausführen
+
+Das Skript ist standardmäßig ein Dry-Run und schreibt dabei nichts in die Datenbank:
+
+```bash
+php kiwz_hostbill_setup.php
+```
+
+Wenn Schema, Template-Zeilen, Backups und geplante Inserts plausibel aussehen, ausführen mit:
+
+```bash
+php kiwz_hostbill_setup.php --apply
+```
+
+Optional kann ein abweichender HostBill-Pfad oder eine konkrete Config-Datei angegeben werden:
+
+```bash
+php kiwz_hostbill_setup.php --hostbill-root=/home/support.panomity.com/public_html
+php kiwz_hostbill_setup.php --config=/home/support.panomity.com/public_html/includes/config.php
+```
+
+Vor den Inserts erstellt das Skript Backup-Tabellen per `CREATE TABLE ... LIKE` und `INSERT INTO ... SELECT`, z. B. `hb_products_bak_<timestamp>`. Das Skript löscht oder aktualisiert keine bestehenden Zeilen.
+
+## Manuelle Alternative im HostBill-Admin
+
+Falls die Einrichtung lieber per Klick gemacht wird:
+
+1. `padm` öffnen und zu `Settings -> Products & Services` gehen.
+2. Neue Orderpage/Kategorie `KI Werkzeuge` anlegen, sichtbar schalten und als Slug z. B. `ki-werkzeuge` setzen.
+3. Drei monatlich wiederkehrende EUR-Produkte in dieser Orderpage anlegen:
+   - `KI Starter`, 4.90 EUR/Monat, `Priority-Rendering für Bildgenerator & Tiefenkarte, E-Mail-Support`
+   - `KI Pro`, 14.90 EUR/Monat, `Priority für alle Werkzeuge inkl. Musik & Video, LoRA-Training bevorzugt`
+   - `KI Business`, 39.90 EUR/Monat, `Höchste Priorität, alle Werkzeuge, Support mit 24h-Reaktionszeit`
+
+Danach Zahlungsmodul-Zuordnung, Steuersatz und Sichtbarkeit im Warenkorb prüfen.
+
+## Willkommens-E-Mail mit Priority-Code
+
+Für jedes Produkt sollte eine Willkommens-E-Mail bzw. Produktaktivierungs-Mail eingerichtet werden. Der Inhalt sollte dem Kunden genau einen freien Code aus `kiwz_prio_codes.txt` mitteilen, z. B.:
+
+```text
+Ihr Priority-Code für kiwerkzeuge.de lautet: KIWZ-XXXX-XXXX
+```
+
+Wichtig: Codes müssen nach Ausgabe als vergeben markiert werden, damit derselbe Code nicht mehrfach an Kunden geht. Für den ersten Batch kann das Snippet `kiwz_gpuq_config_snippet.php` genutzt werden; weitere Codes werden dort im Array `'priority_codes' => [...]` ergänzt.
+
+## Multibrand-Hinweis
+
+Das Multibrand-Modul ist installiert. Für eine eigene Brand `kiwerkzeuge.de` wären zusätzlich zur Produktanlage diese Schritte nötig: Brand in `hb_brands` bzw. über den HostBill-Admin anlegen, Domain/Portal-URL und Branding in `hb_brand_config` konfigurieren, Template/Logo/E-Mail-Absender setzen, Zahlungs- und Steuerregeln der Brand zuordnen und die neue Orderpage/Produkte dieser Brand sichtbar zuweisen. Diese Anleitung dokumentiert das nur; das Setup-Skript legt keine neue Brand an.
diff --git a/kiwz_gpuq_config_snippet.php b/kiwz_gpuq_config_snippet.php
new file mode 100644
index 0000000000000000000000000000000000000000..c089e9c811a945dc3d3da6515b6c7d714f4dfd66
--- /dev/null
+++ b/kiwz_gpuq_config_snippet.php
@@ -0,0 +1,19 @@
+<?php
+
+// Beispiel-Batch für die ersten Kunden. Weitere Codes aus kiwz_prio_codes.txt
+// nach gleichem Muster ergänzen, z. B. 'KIWZ-XXXX-XXXX' => 'high'.
+$gpuqConfig = [
+    'priority_codes' => [
+        'KIWZ-X7QA-H4NZ' => 'high',
+        'KIWZ-BM6T-9VDK' => 'high',
+        'KIWZ-3LPR-W8CY' => 'high',
+        'KIWZ-F2ZH-QN5A' => 'high',
+        'KIWZ-7GVM-KX4T' => 'high',
+        'KIWZ-N9QS-2JWR' => 'high',
+        'KIWZ-H8DC-V6PY' => 'high',
+        'KIWZ-4TFK-ZM7B' => 'high',
+        'KIWZ-Q2WX-5ANR' => 'high',
+        'KIWZ-Y6PJ-C9LT' => 'high',
+    ],
+];
+
diff --git a/kiwz_hostbill_setup.php b/kiwz_hostbill_setup.php
new file mode 100644
index 0000000000000000000000000000000000000000..31546e4cb6cf26c6f4a2687cb03d66edc346a12d
--- /dev/null
+++ b/kiwz_hostbill_setup.php
@@ -0,0 +1,768 @@
+#!/usr/bin/env php
+<?php
+declare(strict_types=1);
+
+/**
+ * HostBill setup helper for kiwerkzeuge.de.
+ *
+ * Default mode is dry-run. The script intentionally discovers the HostBill
+ * schema at runtime and copies visible template rows instead of assuming an
+ * exact HostBill table layout.
+ */
+
+const HOSTBILL_DEFAULT_ROOT = '/home/support.panomity.com/public_html';
+const PORTAL_BASE_URL = 'https://support.panomity.com';
+const CATEGORY_NAME = 'KI Werkzeuge';
+const CATEGORY_SLUG = 'ki-werkzeuge';
+const CATEGORY_DESCRIPTION = 'Deutschsprachige KI-Tool-Suite mit Bildgenerator, Musik, Video und Tiefenkarte.';
+
+$PRODUCTS = [
+    [
+        'name' => 'KI Starter',
+        'price' => '4.90',
+        'description' => 'Priority-Rendering für Bildgenerator & Tiefenkarte, E-Mail-Support',
+    ],
+    [
+        'name' => 'KI Pro',
+        'price' => '14.90',
+        'description' => 'Priority für alle Werkzeuge inkl. Musik & Video, LoRA-Training bevorzugt',
+    ],
+    [
+        'name' => 'KI Business',
+        'price' => '39.90',
+        'description' => 'Höchste Priorität, alle Werkzeuge, Support mit 24h-Reaktionszeit',
+    ],
+];
+
+main($PRODUCTS);
+
+function main(array $products): void
+{
+    if (PHP_SAPI !== 'cli') {
+        fwrite(STDERR, "Dieses Skript muss mit php-cli ausgeführt werden.\n");
+        exit(1);
+    }
+
+    $opts = getopt('', ['apply', 'help', 'hostbill-root:', 'config:']);
+    if (isset($opts['help'])) {
+        printHelp();
+        return;
+    }
+
+    $dryRun = !isset($opts['apply']);
+    $hostbillRoot = rtrim((string)($opts['hostbill-root'] ?? HOSTBILL_DEFAULT_ROOT), '/');
+    $configFile = (string)($opts['config'] ?? ($hostbillRoot . '/includes/config.php'));
+
+    out('HostBill kiwerkzeuge.de Setup');
+    out('Modus: ' . ($dryRun ? 'DRY-RUN (keine Schreibzugriffe)' : 'APPLY (schreibt in die Datenbank)'));
+    out('Config: ' . $configFile);
+    out('');
+
+    $pdo = connectFromHostbillConfig($configFile);
+    $backupTables = [];
+    $transactionStarted = false;
+
+    try {
+        requireTable($pdo, 'hb_categories');
+        requireTable($pdo, 'hb_products');
+
+        $categoryCols = columns($pdo, 'hb_categories');
+        $productCols = columns($pdo, 'hb_products');
+
+        printSchema('hb_categories', $categoryCols);
+        printSchema('hb_products', $productCols);
+
+        $categoryPlan = buildCategoryPlan($pdo, $categoryCols);
+        out('Kategorie-Vorlage: ' . describeRow($categoryPlan['template'], $categoryPlan['id_col'], $categoryPlan['name_col']));
+
+        $productPlan = buildProductPlan($pdo, $productCols, $categoryPlan['id_col']);
+        out('Produkt-Vorlage: ' . describeRow($productPlan['template'], $productPlan['id_col'], $productPlan['name_col']));
+        out('Monatspreisspalte: ' . $productPlan['monthly_col']);
+        out('Deaktivierte Zahlweisen: ' . ($productPlan['disabled_period_cols'] ? implode(', ', $productPlan['disabled_period_cols']) : '(keine weiteren Periodenspalten erkannt)'));
+        out('Kategorie-Verknüpfung: ' . ($productPlan['category_col'] ? "hb_products.{$productPlan['category_col']}" : relationDescription($productPlan['relation'])));
+        out('');
+
+        $affectedTables = ['hb_categories', 'hb_products'];
+        if ($productPlan['relation']) {
+            $affectedTables[] = $productPlan['relation']['table'];
+            printSchema($productPlan['relation']['table'], columns($pdo, $productPlan['relation']['table']));
+        }
+
+        $timestamp = time();
+        foreach (array_values(array_unique($affectedTables)) as $table) {
+            $backupName = uniqueBackupName($pdo, $table, $timestamp);
+            $backupTables[$table] = $backupName;
+            out(($dryRun ? 'Würde Backup erstellen: ' : 'Erstelle Backup: ') . "{$backupName} aus {$table}");
+            if (!$dryRun) {
+                createBackup($pdo, $table, $backupName);
+            }
+        }
+        out('');
+
+        if (!$dryRun) {
+            $pdo->beginTransaction();
+            $transactionStarted = true;
+        }
+
+        $category = ensureCategory($pdo, $categoryPlan, $dryRun);
+        $categoryId = $category['id'];
+        $categorySlug = $category['slug'];
+
+        $productResults = [];
+        foreach ($products as $product) {
+            $productResults[] = ensureProduct($pdo, $productPlan, $categoryId, $product, $dryRun);
+        }
+
+        if (!$dryRun) {
+            $pdo->commit();
+            $transactionStarted = false;
+        }
+
+        out('');
+        out('Ergebnis');
+        out('Kategorie-ID: ' . displayId($categoryId));
+        foreach ($productResults as $result) {
+            out(sprintf('Produkt: %s -> ID %s (%s)', $result['name'], displayId($result['id']), $result['status']));
+        }
+        out('');
+        out('Bestell-URLs');
+        foreach ($productResults as $result) {
+            $id = displayId($result['id'], '<PID>');
+            out($result['name'] . ': ' . PORTAL_BASE_URL . '/?cmd=cart&action=add&id=' . $id);
+        }
+        if ($categorySlug !== null && $categorySlug !== '') {
+            out('Orderpage: ' . PORTAL_BASE_URL . '/cart/' . $categorySlug);
+        } else {
+            out('Orderpage: /cart/<kategorie-slug> nicht ausgebbar, weil keine Slug-Spalte erkannt wurde.');
+        }
+        out('');
+        out('Manuelle Admin-Schritte bleiben: Zahlungsmodul-Zuordnung prüfen, Steuersatz prüfen, Willkommens-E-Mail je Produkt mit Priority-Code konfigurieren.');
+        out('Rollback-Hinweis: Für einen manuellen Restore wurden diese Backup-Tabellen vorbereitet/angezeigt:');
+        foreach ($backupTables as $table => $backupName) {
+            out("  {$table} -> {$backupName}");
+        }
+        out('Dieses Skript führt kein automatisches --rollback aus, damit bestehende Daten nicht unbeabsichtigt gelöscht oder überschrieben werden.');
+    } catch (Throwable $e) {
+        if ($transactionStarted && $pdo->inTransaction()) {
+            $pdo->rollBack();
+        }
+        fwrite(STDERR, "\nABBRUCH: " . $e->getMessage() . "\n");
+        if ($backupTables) {
+            fwrite(STDERR, "Backup-Tabellen für manuelle Prüfung/Restore:\n");
+            foreach ($backupTables as $table => $backupName) {
+                fwrite(STDERR, "  {$table} -> {$backupName}\n");
+            }
+        }
+        exit(1);
+    }
+}
+
+function printHelp(): void
+{
+    out("Nutzung:");
+    out("  php kiwz_hostbill_setup.php");
+    out("  php kiwz_hostbill_setup.php --apply");
+    out("  php kiwz_hostbill_setup.php --hostbill-root=/home/support.panomity.com/public_html");
+    out("  php kiwz_hostbill_setup.php --config=/pfad/zu/includes/config.php");
+}
+
+function connectFromHostbillConfig(string $configFile): PDO
+{
+    if (!is_file($configFile) || !is_readable($configFile)) {
+        throw new RuntimeException("HostBill config.php nicht lesbar: {$configFile}");
+    }
+
+    $db_host = $db_name = $db_user = $db_pass = null;
+    $db_port = null;
+    require $configFile;
+
+    foreach (['db_host', 'db_name', 'db_user', 'db_pass'] as $var) {
+        if (!isset($$var) || $$var === '') {
+            throw new RuntimeException("HostBill config.php definiert \${$var} nicht.");
+        }
+    }
+
+    $dsn = 'mysql:host=' . $db_host . ';dbname=' . $db_name . ';charset=utf8mb4';
+    if (!empty($db_port)) {
+        $dsn .= ';port=' . (int)$db_port;
+    }
+
+    return new PDO($dsn, (string)$db_user, (string)$db_pass, [
+        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
+        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+        PDO::ATTR_EMULATE_PREPARES => false,
+    ]);
+}
+
+function requireTable(PDO $pdo, string $table): void
+{
+    $stmt = $pdo->prepare('SHOW TABLES LIKE ?');
+    $stmt->execute([$table]);
+    if (!$stmt->fetchColumn()) {
+        throw new RuntimeException("Erwartete Tabelle fehlt: {$table}");
+    }
+}
+
+function tableExists(PDO $pdo, string $table): bool
+{
+    $stmt = $pdo->prepare('SHOW TABLES LIKE ?');
+    $stmt->execute([$table]);
+    return (bool)$stmt->fetchColumn();
+}
+
+function columns(PDO $pdo, string $table): array
+{
+    $rows = $pdo->query('SHOW COLUMNS FROM ' . qi($table))->fetchAll();
+    if (!$rows) {
+        throw new RuntimeException("Keine Spalten gefunden in {$table}");
+    }
+    $out = [];
+    foreach ($rows as $row) {
+        $out[$row['Field']] = $row;
+    }
+    return $out;
+}
+
+function printSchema(string $table, array $cols): void
+{
+    out("Schema {$table}:");
+    foreach ($cols as $name => $meta) {
+        $extra = trim(($meta['Key'] ? ' key=' . $meta['Key'] : '') . ($meta['Extra'] ? ' extra=' . $meta['Extra'] : ''));
+        out('  - ' . $name . ' ' . $meta['Type'] . ($extra ? ' (' . $extra . ')' : ''));
+    }
+    out('');
+}
+
+function buildCategoryPlan(PDO $pdo, array $cols): array
+{
+    $idCol = autoIncrementColumn($cols) ?? pickColumn($cols, ['id', 'category_id', 'cat_id']);
+    $nameCol = pickColumn($cols, ['name', 'title', 'category', 'catname', 'label']);
+    $descCol = pickColumn($cols, ['description', 'descr', 'details', 'content', 'text', 'info']);
+    $slugCol = pickColumn($cols, ['slug', 'url', 'sefurl', 'sef_url', 'urlname', 'url_name']);
+
+    if (!$idCol || !$nameCol) {
+        throw new RuntimeException('hb_categories: ID- oder Name-Spalte nicht sicher erkannt.');
+    }
+
+    $template = selectTemplateRow($pdo, 'hb_categories', $cols, $idCol);
+
+    return [
+        'table' => 'hb_categories',
+        'cols' => $cols,
+        'id_col' => $idCol,
+        'name_col' => $nameCol,
+        'desc_col' => $descCol,
+        'slug_col' => $slugCol,
+        'template' => $template,
+    ];
+}
+
+function buildProductPlan(PDO $pdo, array $cols, string $categoryIdCol): array
+{
+    $idCol = autoIncrementColumn($cols) ?? pickColumn($cols, ['id', 'product_id', 'pid']);
+    $nameCol = pickColumn($cols, ['name', 'title', 'product_name', 'label']);
+    $descCol = pickColumn($cols, ['description', 'descr', 'details', 'content', 'text', 'info', 'short_description', 'shortdesc']);
+    $slugCol = pickColumn($cols, ['slug', 'url', 'sefurl', 'sef_url', 'urlname', 'url_name']);
+    $monthlyCol = pickMonthlyColumn($cols);
+    $categoryCol = pickProductCategoryColumn($cols);
+    $relation = null;
+
+    if (!$idCol || !$nameCol || !$descCol || !$monthlyCol) {
+        throw new RuntimeException('hb_products: ID-, Name-, Beschreibungs- oder Monatspreis-Spalte nicht sicher erkannt.');
+    }
+
+    if (!$categoryCol) {
+        $relation = discoverProductCategoryRelation($pdo, $idCol, $categoryIdCol);
+        if (!$relation) {
+            throw new RuntimeException('hb_products: Keine Kategorie-Spalte und keine Produkt/Kategorie-Relation sicher erkannt.');
+        }
+    }
+
+    $template = selectTemplateRow($pdo, 'hb_products', $cols, $idCol);
+
+    $periodCols = pickExistingColumns($cols, ['m', 'monthly', 'month', 'price_monthly', 'monthly_price', 'q', 'quarterly', 's', 'semiannually', 'semiannual', 'a', 'annually', 'annual', 'b', 'biennially', 'biennial', 't', 'triennially', 'triennial']);
+    $disabledPeriodCols = array_values(array_diff($periodCols, [$monthlyCol]));
+
+    return [
+        'table' => 'hb_products',
+        'cols' => $cols,
+        'id_col' => $idCol,
+        'name_col' => $nameCol,
+        'desc_col' => $descCol,
+        'slug_col' => $slugCol,
+        'monthly_col' => $monthlyCol,
+        'disabled_period_cols' => $disabledPeriodCols,
+        'category_col' => $categoryCol,
+        'relation' => $relation,
+        'template' => $template,
+    ];
+}
+
+function ensureCategory(PDO $pdo, array $plan, bool $dryRun): array
+{
+    $existing = findCategory($pdo, $plan, CATEGORY_NAME);
+    if ($existing) {
+        out('Kategorie existiert bereits, keine Änderung: ID ' . $existing[$plan['id_col']]);
+        return [
+            'id' => $existing[$plan['id_col']],
+            'slug' => $plan['slug_col'] ? (string)($existing[$plan['slug_col']] ?? CATEGORY_SLUG) : null,
+        ];
+    }
+
+    $overrides = [
+        $plan['name_col'] => CATEGORY_NAME,
+    ];
+    if ($plan['desc_col']) {
+        $overrides[$plan['desc_col']] = CATEGORY_DESCRIPTION;
+    }
+    if ($plan['slug_col']) {
+        $overrides[$plan['slug_col']] = CATEGORY_SLUG;
+    }
+    applyVisibilityOverrides($plan['cols'], $overrides);
+
+    out(($dryRun ? 'Würde Kategorie anlegen: ' : 'Lege Kategorie an: ') . CATEGORY_NAME);
+    $id = insertFromTemplate($pdo, $plan['table'], $plan['cols'], $plan['template'], $overrides, $dryRun);
+
+    return [
+        'id' => $dryRun ? null : $id,
+        'slug' => $plan['slug_col'] ? CATEGORY_SLUG : null,
+    ];
+}
+
+function ensureProduct(PDO $pdo, array $plan, $categoryId, array $product, bool $dryRun): array
+{
+    if (!$dryRun && ($categoryId === null || $categoryId === '')) {
+        throw new RuntimeException('Kategorie-ID fehlt für Produktanlage.');
+    }
+
+    $existing = $dryRun ? null : findProduct($pdo, $plan, $categoryId, $product['name']);
+    if ($existing) {
+        out('Produkt existiert bereits, keine Änderung: ' . $product['name'] . ' (ID ' . $existing[$plan['id_col']] . ')');
+        return [
+            'name' => $product['name'],
+            'id' => $existing[$plan['id_col']],
+            'status' => 'vorhanden',
+        ];
+    }
+
+    $overrides = [
+        $plan['name_col'] => $product['name'],
+        $plan['desc_col'] => $product['description'],
+        $plan['monthly_col'] => $product['price'],
+    ];
+    if ($plan['slug_col']) {
+        $overrides[$plan['slug_col']] = slugify($product['name']);
+    }
+    if ($plan['category_col']) {
+        $overrides[$plan['category_col']] = $dryRun ? '<neue Kategorie-ID>' : $categoryId;
+    }
+    foreach ($plan['disabled_period_cols'] as $col) {
+        $overrides[$col] = disabledPeriodValue($plan['template'][$col] ?? null);
+    }
+    foreach (pickExistingColumns($plan['cols'], ['paytype', 'pay_type', 'p_type', 'billingtype', 'billing_type']) as $col) {
+        $overrides[$col] = recurringValueForColumn($plan['cols'][$col]);
+    }
+    foreach (pickExistingColumns($plan['cols'], ['currency', 'curr']) as $col) {
+        if (isStringType($plan['cols'][$col]['Type'])) {
+            $overrides[$col] = 'EUR';
+        }
+    }
+    applyVisibilityOverrides($plan['cols'], $overrides);
+
+    out(($dryRun ? 'Würde Produkt anlegen: ' : 'Lege Produkt an: ') . $product['name'] . ' für ' . $product['price'] . ' EUR/Monat');
+    $id = insertFromTemplate($pdo, $plan['table'], $plan['cols'], $plan['template'], $overrides, $dryRun);
+
+    if ($plan['relation']) {
+        out(($dryRun ? 'Würde Produkt/Kategorie-Relation anlegen: ' : 'Lege Produkt/Kategorie-Relation an: ') . relationDescription($plan['relation']));
+        if (!$dryRun) {
+            ensureRelation($pdo, $plan['relation'], $id, $categoryId);
+        }
+    }
+
+    return [
+        'name' => $product['name'],
+        'id' => $dryRun ? null : $id,
+        'status' => $dryRun ? 'dry-run' : 'neu',
+    ];
+}
+
+function findCategory(PDO $pdo, array $plan, string $name): ?array
+{
+    $sql = 'SELECT * FROM ' . qi($plan['table']) . ' WHERE ' . qi($plan['name_col']) . ' = ? LIMIT 1';
+    $stmt = $pdo->prepare($sql);
+    $stmt->execute([$name]);
+    $row = $stmt->fetch();
+    return $row ?: null;
+}
+
+function findProduct(PDO $pdo, array $plan, $categoryId, string $name): ?array
+{
+    if ($plan['category_col']) {
+        $sql = 'SELECT * FROM ' . qi($plan['table']) . ' WHERE ' . qi($plan['name_col']) . ' = ? AND ' . qi($plan['category_col']) . ' = ? LIMIT 1';
+        $stmt = $pdo->prepare($sql);
+        $stmt->execute([$name, $categoryId]);
+        $row = $stmt->fetch();
+        return $row ?: null;
+    }
+
+    $rel = $plan['relation'];
+    $sql = 'SELECT p.* FROM ' . qi($plan['table']) . ' p '
+        . 'INNER JOIN ' . qi($rel['table']) . ' r ON p.' . qi($plan['id_col']) . ' = r.' . qi($rel['product_col']) . ' '
+        . 'WHERE p.' . qi($plan['name_col']) . ' = ? AND r.' . qi($rel['category_col']) . ' = ? LIMIT 1';
+    $stmt = $pdo->prepare($sql);
+    $stmt->execute([$name, $categoryId]);
+    $row = $stmt->fetch();
+    return $row ?: null;
+}
+
+function insertFromTemplate(PDO $pdo, string $table, array $cols, array $template, array $overrides, bool $dryRun)
+{
+    $data = [];
+    foreach ($cols as $name => $meta) {
+        if (!isInsertableColumn($meta)) {
+            continue;
+        }
+        $data[$name] = array_key_exists($name, $overrides) ? $overrides[$name] : ($template[$name] ?? null);
+    }
+
+    out('  Overrides:');
+    foreach ($overrides as $col => $value) {
+        out('    ' . $col . ' = ' . printableValue($value));
+    }
+
+    if ($dryRun) {
+        return null;
+    }
+
+    $fields = array_keys($data);
+    $placeholders = [];
+    $values = [];
+    foreach ($fields as $i => $field) {
+        $placeholder = ':v' . $i;
+        $placeholders[] = $placeholder;
+        $values[$placeholder] = $data[$field];
+    }
+    $sql = 'INSERT INTO ' . qi($table) . ' (' . implode(', ', array_map('qi', $fields)) . ') VALUES (' . implode(', ', $placeholders) . ')';
+    $stmt = $pdo->prepare($sql);
+    $stmt->execute($values);
+    return $pdo->lastInsertId();
+}
+
+function ensureRelation(PDO $pdo, array $rel, $productId, $categoryId): void
+{
+    $check = $pdo->prepare('SELECT 1 FROM ' . qi($rel['table']) . ' WHERE ' . qi($rel['product_col']) . ' = ? AND ' . qi($rel['category_col']) . ' = ? LIMIT 1');
+    $check->execute([$productId, $categoryId]);
+    if ($check->fetchColumn()) {
+        return;
+    }
+
+    $cols = columns($pdo, $rel['table']);
+    $template = selectAnyRow($pdo, $rel['table']);
+    $overrides = [
+        $rel['product_col'] => $productId,
+        $rel['category_col'] => $categoryId,
+    ];
+
+    if ($template) {
+        insertFromTemplate($pdo, $rel['table'], $cols, $template, $overrides, false);
+        return;
+    }
+
+    $fields = array_keys($overrides);
+    foreach ($cols as $name => $meta) {
+        if (isset($overrides[$name]) || !isInsertableColumn($meta)) {
+            continue;
+        }
+        if (strtoupper((string)$meta['Null']) === 'NO' && $meta['Default'] === null) {
+            throw new RuntimeException("Relation {$rel['table']} hat Pflichtspalte {$name}; keine Template-Zeile vorhanden.");
+        }
+    }
+    $sql = 'INSERT INTO ' . qi($rel['table']) . ' (' . implode(', ', array_map('qi', $fields)) . ') VALUES (?, ?)';
+    $stmt = $pdo->prepare($sql);
+    $stmt->execute([$productId, $categoryId]);
+}
+
+function selectTemplateRow(PDO $pdo, string $table, array $cols, string $idCol): array
+{
+    [$where, $note] = visibilityWhere($cols);
+    if ($note) {
+        out("Hinweis {$table}: {$note}");
+    }
+    $sql = 'SELECT * FROM ' . qi($table) . ' WHERE ' . $where . ' ORDER BY ' . qi($idCol) . ' ASC LIMIT 1';
+    $row = $pdo->query($sql)->fetch();
+    if (!$row) {
+        throw new RuntimeException("Keine sichtbare Template-Zeile in {$table} gefunden.");
+    }
+    return $row;
+}
+
+function selectAnyRow(PDO $pdo, string $table): ?array
+{
+    $row = $pdo->query('SELECT * FROM ' . qi($table) . ' LIMIT 1')->fetch();
+    return $row ?: null;
+}
+
+function visibilityWhere(array $cols): array
+{
+    $clauses = [];
+    foreach (['visible', 'is_visible', 'show', 'enabled', 'active', 'is_active'] as $col) {
+        if (isset($cols[$col])) {
+            $clauses[] = qi($col) . " IN (1, '1', 'yes', 'Yes', 'true', 'True', 'on', 'On')";
+        }
+    }
+    foreach (['hidden', 'is_hidden', 'disabled', 'is_disabled', 'deleted', 'archived'] as $col) {
+        if (isset($cols[$col])) {
+            $clauses[] = qi($col) . " IN (0, '0', 'no', 'No', 'false', 'False', '', 'off', 'Off')";
+        }
+    }
+    foreach (['status', 'state'] as $col) {
+        if (isset($cols[$col])) {
+            $clauses[] = qi($col) . " IN ('Active', 'active', 'Enabled', 'enabled', 'Visible', 'visible', '1', 1)";
+        }
+    }
+
+    if (!$clauses) {
+        return ['1=1', 'keine bekannte Sichtbarkeitsspalte erkannt; nutze erste vorhandene Zeile als Vorlage.'];
+    }
+    return [implode(' AND ', $clauses), null];
+}
+
+function applyVisibilityOverrides(array $cols, array &$overrides): void
+{
+    foreach (['visible', 'is_visible', 'show', 'enabled', 'active', 'is_active'] as $col) {
+        if (isset($cols[$col])) {
+            $overrides[$col] = 1;
+        }
+    }
+    foreach (['hidden', 'is_hidden', 'disabled', 'is_disabled', 'deleted', 'archived'] as $col) {
+        if (isset($cols[$col])) {
+            $overrides[$col] = 0;
+        }
+    }
+    foreach (['status', 'state'] as $col) {
+        if (isset($cols[$col])) {
+            $overrides[$col] = statusActiveValue($cols[$col]);
+        }
+    }
+}
+
+function statusActiveValue(array $meta): string
+{
+    $type = (string)$meta['Type'];
+    if (preg_match_all("/'((?:[^'\\\\]|\\\\.)*)'/", $type, $matches)) {
+        foreach ($matches[1] as $value) {
+            if (in_array(strtolower($value), ['active', 'enabled', 'visible'], true)) {
+                return $value;
+            }
+        }
+        if (in_array('1', $matches[1], true)) {
+            return '1';
+        }
+    }
+    return 'Active';
+}
+
+function recurringValueForColumn(array $meta): string
+{
+    $type = (string)$meta['Type'];
+    if (preg_match_all("/'((?:[^'\\\\]|\\\\.)*)'/", $type, $matches)) {
+        foreach ($matches[1] as $value) {
+            if (stripos($value, 'recurr') !== false) {
+                return $value;
+            }
+        }
+    }
+    return 'Recurring';
+}
+
+function discoverProductCategoryRelation(PDO $pdo, string $productIdCol, string $categoryIdCol): ?array
+{
+    $tables = $pdo->query('SHOW TABLES')->fetchAll(PDO::FETCH_COLUMN);
+    foreach ($tables as $table) {
+        $lower = strtolower((string)$table);
+        if (!preg_match('/(product.*categor|categor.*product|product.*orderpage|orderpage.*product)/', $lower)) {
+            continue;
+        }
+        $cols = columns($pdo, (string)$table);
+        $productCol = pickColumn($cols, ['product_id', 'productid', 'prod_id', 'pid', $productIdCol]);
+        $categoryCol = pickColumn($cols, ['category_id', 'cat_id', 'categoryid', 'cid', 'orderpage_id', $categoryIdCol]);
+        if ($productCol && $categoryCol && $productCol !== $categoryCol) {
+            return [
+                'table' => (string)$table,
+                'product_col' => $productCol,
+                'category_col' => $categoryCol,
+            ];
+        }
+    }
+    return null;
+}
+
+function relationDescription(?array $relation): string
+{
+    if (!$relation) {
+        return '(keine Relation)';
+    }
+    return $relation['table'] . '.' . $relation['product_col'] . ' -> Produkt, ' . $relation['table'] . '.' . $relation['category_col'] . ' -> Kategorie';
+}
+
+function createBackup(PDO $pdo, string $source, string $backup): void
+{
+    $pdo->exec('CREATE TABLE ' . qi($backup) . ' LIKE ' . qi($source));
+    $pdo->exec('INSERT INTO ' . qi($backup) . ' SELECT * FROM ' . qi($source));
+}
+
+function uniqueBackupName(PDO $pdo, string $table, int $timestamp): string
+{
+    $base = $table . '_bak_' . $timestamp;
+    $candidate = $base;
+    $i = 1;
+    while (tableExists($pdo, $candidate)) {
+        $candidate = $base . '_' . $i;
+        $i++;
+    }
+    return $candidate;
+}
+
+function autoIncrementColumn(array $cols): ?string
+{
+    foreach ($cols as $name => $meta) {
+        if (stripos((string)$meta['Extra'], 'auto_increment') !== false) {
+            return $name;
+        }
+    }
+    return null;
+}
+
+function isInsertableColumn(array $meta): bool
+{
+    $extra = strtolower((string)$meta['Extra']);
+    return strpos($extra, 'auto_increment') === false
+        && strpos($extra, 'generated') === false
+        && strpos($extra, 'virtual') === false
+        && strpos($extra, 'stored') === false;
+}
+
+function pickColumn(array $cols, array $candidates): ?string
+{
+    $map = [];
+    foreach (array_keys($cols) as $name) {
+        $map[strtolower($name)] = $name;
+    }
+    foreach ($candidates as $candidate) {
+        $key = strtolower($candidate);
+        if (isset($map[$key])) {
+            return $map[$key];
+        }
+    }
+    return null;
+}
+
+function pickExistingColumns(array $cols, array $candidates): array
+{
+    $found = [];
+    foreach ($candidates as $candidate) {
+        $col = pickColumn($cols, [$candidate]);
+        if ($col && !in_array($col, $found, true)) {
+            $found[] = $col;
+        }
+    }
+    return $found;
+}
+
+function pickMonthlyColumn(array $cols): ?string
+{
+    foreach (['m', 'monthly', 'month', 'price_monthly', 'monthly_price', 'recurring_monthly'] as $candidate) {
+        $col = pickColumn($cols, [$candidate]);
+        if ($col && isNumericType($cols[$col]['Type'])) {
+            return $col;
+        }
+    }
+    foreach ($cols as $name => $meta) {
+        if (stripos($name, 'month') !== false && isNumericType($meta['Type'])) {
+            return $name;
+        }
+    }
+    return null;
+}
+
+function pickProductCategoryColumn(array $cols): ?string
+{
+    foreach (['category_id', 'cat_id', 'categoryid', 'orderpage_id', 'cid', 'group_id'] as $candidate) {
+        $col = pickColumn($cols, [$candidate]);
+        if ($col && isNumericType($cols[$col]['Type'])) {
+            return $col;
+        }
+    }
+    $col = pickColumn($cols, ['category']);
+    if ($col && isNumericType($cols[$col]['Type'])) {
+        return $col;
+    }
+    return null;
+}
+
+function isNumericType(string $type): bool
+{
+    return (bool)preg_match('/\b(tinyint|smallint|mediumint|int|bigint|decimal|numeric|float|double|real)\b/i', $type);
+}
+
+function isStringType(string $type): bool
+{
+    return (bool)preg_match('/\b(char|varchar|text|enum|set)\b/i', $type);
+}
+
+function disabledPeriodValue($templateValue)
+{
+    if (is_numeric($templateValue) && (float)$templateValue < 0) {
+        return $templateValue;
+    }
+    return '-1';
+}
+
+function describeRow(array $row, string $idCol, ?string $nameCol): string
+{
+    $parts = ['ID ' . ($row[$idCol] ?? '?')];
+    if ($nameCol && isset($row[$nameCol])) {
+        $parts[] = $nameCol . '=' . printableValue($row[$nameCol]);
+    }
+    return implode(', ', $parts);
+}
+
+function printableValue($value): string
+{
+    if ($value === null) {
+        return 'NULL';
+    }
+    $str = (string)$value;
+    if (strlen($str) > 90) {
+        $str = substr($str, 0, 87) . '...';
+    }
+    return "'" . $str . "'";
+}
+
+function displayId($id, string $fallback = '<wird von AUTO_INCREMENT vergeben>'): string
+{
+    return ($id === null || $id === '') ? $fallback : (string)$id;
+}
+
+function slugify(string $value): string
+{
+    $value = str_replace(['Ä', 'Ö', 'Ü', 'ä', 'ö', 'ü', 'ß'], ['Ae', 'Oe', 'Ue', 'ae', 'oe', 'ue', 'ss'], $value);
+    $value = strtolower($value);
+    $value = preg_replace('/[^a-z0-9]+/', '-', $value) ?? $value;
+    $value = trim($value, '-');
+    return $value !== '' ? $value : 'kiwz-produkt';
+}
+
+function qi(string $identifier): string
+{
+    if (!preg_match('/^[A-Za-z0-9_]+$/', $identifier)) {
+        throw new RuntimeException("Unsicherer SQL-Identifier: {$identifier}");
+    }
+    return '`' . $identifier . '`';
+}
+
+function out(string $line): void
+{
+    echo $line . PHP_EOL;
+}
+
diff --git a/kiwz_prio_codes.txt b/kiwz_prio_codes.txt
new file mode 100644
index 0000000000000000000000000000000000000000..4acd9c408d2fbad263e01ddab541742f0709b92b
--- /dev/null
+++ b/kiwz_prio_codes.txt
@@ -0,0 +1,30 @@
+KIWZ-X7QA-H4NZ
+KIWZ-BM6T-9VDK
+KIWZ-3LPR-W8CY
+KIWZ-F2ZH-QN5A
+KIWZ-7GVM-KX4T
+KIWZ-N9QS-2JWR
+KIWZ-H8DC-V6PY
+KIWZ-4TFK-ZM7B
+KIWZ-Q2WX-5ANR
+KIWZ-Y6PJ-C9LT
+KIWZ-8KDA-RV3M
+KIWZ-Z5HN-WQ7F
+KIWZ-2VTC-JK9P
+KIWZ-M4RY-8XGB
+KIWZ-6QNL-FZ2D
+KIWZ-CP7W-3HKA
+KIWZ-J9XB-T5VQ
+KIWZ-W2MG-6RZN
+KIWZ-5DFY-P8KC
+KIWZ-A7VT-NQ4H
+KIWZ-R3KZ-Y6PM
+KIWZ-L8QC-2WJF
+KIWZ-9NBH-X5TA
+KIWZ-V4PR-7ZDG
+KIWZ-T6JW-M2QC
+KIWZ-G5KA-9VYR
+KIWZ-2XND-H8FP
+KIWZ-P7ZM-C4WT
+KIWZ-Q9RY-6BKL
+KIWZ-F3VC-J5XN

codex
Bei der Prüfung ist aufgefallen, dass der Dry-Run bestehende Produkte nicht als vorhanden erkennen würde, falls die Kategorie bereits existiert. Ich passe das an, damit der Dry-Run die Idempotenz besser sichtbar macht.
apply patch
patch: completed
/tmp/kiwz-hostbill-staging/kiwz_hostbill_setup.php
diff --git a/ANLEITUNG.md b/ANLEITUNG.md
new file mode 100644
index 0000000000000000000000000000000000000000..855de0955bb7085d75847133170994b159e380e3
--- /dev/null
+++ b/ANLEITUNG.md
@@ -0,0 +1,51 @@
+# HostBill-Setup für kiwerkzeuge.de
+
+## Skript ausführen
+
+Das Skript ist standardmäßig ein Dry-Run und schreibt dabei nichts in die Datenbank:
+
+```bash
+php kiwz_hostbill_setup.php
+```
+
+Wenn Schema, Template-Zeilen, Backups und geplante Inserts plausibel aussehen, ausführen mit:
+
+```bash
+php kiwz_hostbill_setup.php --apply
+```
+
+Optional kann ein abweichender HostBill-Pfad oder eine konkrete Config-Datei angegeben werden:
+
+```bash
+php kiwz_hostbill_setup.php --hostbill-root=/home/support.panomity.com/public_html
+php kiwz_hostbill_setup.php --config=/home/support.panomity.com/public_html/includes/config.php
+```
+
+Vor den Inserts erstellt das Skript Backup-Tabellen per `CREATE TABLE ... LIKE` und `INSERT INTO ... SELECT`, z. B. `hb_products_bak_<timestamp>`. Das Skript löscht oder aktualisiert keine bestehenden Zeilen.
+
+## Manuelle Alternative im HostBill-Admin
+
+Falls die Einrichtung lieber per Klick gemacht wird:
+
+1. `padm` öffnen und zu `Settings -> Products & Services` gehen.
+2. Neue Orderpage/Kategorie `KI Werkzeuge` anlegen, sichtbar schalten und als Slug z. B. `ki-werkzeuge` setzen.
+3. Drei monatlich wiederkehrende EUR-Produkte in dieser Orderpage anlegen:
+   - `KI Starter`, 4.90 EUR/Monat, `Priority-Rendering für Bildgenerator & Tiefenkarte, E-Mail-Support`
+   - `KI Pro`, 14.90 EUR/Monat, `Priority für alle Werkzeuge inkl. Musik & Video, LoRA-Training bevorzugt`
+   - `KI Business`, 39.90 EUR/Monat, `Höchste Priorität, alle Werkzeuge, Support mit 24h-Reaktionszeit`
+
+Danach Zahlungsmodul-Zuordnung, Steuersatz und Sichtbarkeit im Warenkorb prüfen.
+
+## Willkommens-E-Mail mit Priority-Code
+
+Für jedes Produkt sollte eine Willkommens-E-Mail bzw. Produktaktivierungs-Mail eingerichtet werden. Der Inhalt sollte dem Kunden genau einen freien Code aus `kiwz_prio_codes.txt` mitteilen, z. B.:
+
+```text
+Ihr Priority-Code für kiwerkzeuge.de lautet: KIWZ-XXXX-XXXX
+```
+
+Wichtig: Codes müssen nach Ausgabe als vergeben markiert werden, damit derselbe Code nicht mehrfach an Kunden geht. Für den ersten Batch kann das Snippet `kiwz_gpuq_config_snippet.php` genutzt werden; weitere Codes werden dort im Array `'priority_codes' => [...]` ergänzt.
+
+## Multibrand-Hinweis
+
+Das Multibrand-Modul ist installiert. Für eine eigene Brand `kiwerkzeuge.de` wären zusätzlich zur Produktanlage diese Schritte nötig: Brand in `hb_brands` bzw. über den HostBill-Admin anlegen, Domain/Portal-URL und Branding in `hb_brand_config` konfigurieren, Template/Logo/E-Mail-Absender setzen, Zahlungs- und Steuerregeln der Brand zuordnen und die neue Orderpage/Produkte dieser Brand sichtbar zuweisen. Diese Anleitung dokumentiert das nur; das Setup-Skript legt keine neue Brand an.
diff --git a/kiwz_gpuq_config_snippet.php b/kiwz_gpuq_config_snippet.php
new file mode 100644
index 0000000000000000000000000000000000000000..c089e9c811a945dc3d3da6515b6c7d714f4dfd66
--- /dev/null
+++ b/kiwz_gpuq_config_snippet.php
@@ -0,0 +1,19 @@
+<?php
+
+// Beispiel-Batch für die ersten Kunden. Weitere Codes aus kiwz_prio_codes.txt
+// nach gleichem Muster ergänzen, z. B. 'KIWZ-XXXX-XXXX' => 'high'.
+$gpuqConfig = [
+    'priority_codes' => [
+        'KIWZ-X7QA-H4NZ' => 'high',
+        'KIWZ-BM6T-9VDK' => 'high',
+        'KIWZ-3LPR-W8CY' => 'high',
+        'KIWZ-F2ZH-QN5A' => 'high',
+        'KIWZ-7GVM-KX4T' => 'high',
+        'KIWZ-N9QS-2JWR' => 'high',
+        'KIWZ-H8DC-V6PY' => 'high',
+        'KIWZ-4TFK-ZM7B' => 'high',
+        'KIWZ-Q2WX-5ANR' => 'high',
+        'KIWZ-Y6PJ-C9LT' => 'high',
+    ],
+];
+
diff --git a/kiwz_hostbill_setup.php b/kiwz_hostbill_setup.php
new file mode 100644
index 0000000000000000000000000000000000000000..0d3d1b036cc1df8f965ac381c8f328b8344051ee
--- /dev/null
+++ b/kiwz_hostbill_setup.php
@@ -0,0 +1,767 @@
+#!/usr/bin/env php
+<?php
+declare(strict_types=1);
+
+/**
+ * HostBill setup helper for kiwerkzeuge.de.
+ *
+ * Default mode is dry-run. The script intentionally discovers the HostBill
+ * schema at runtime and copies visible template rows instead of assuming an
+ * exact HostBill table layout.
+ */
+
+const HOSTBILL_DEFAULT_ROOT = '/home/support.panomity.com/public_html';
+const PORTAL_BASE_URL = 'https://support.panomity.com';
+const CATEGORY_NAME = 'KI Werkzeuge';
+const CATEGORY_SLUG = 'ki-werkzeuge';
+const CATEGORY_DESCRIPTION = 'Deutschsprachige KI-Tool-Suite mit Bildgenerator, Musik, Video und Tiefenkarte.';
+
+$PRODUCTS = [
+    [
+        'name' => 'KI Starter',
+        'price' => '4.90',
+        'description' => 'Priority-Rendering für Bildgenerator & Tiefenkarte, E-Mail-Support',
+    ],
+    [
+        'name' => 'KI Pro',
+        'price' => '14.90',
+        'description' => 'Priority für alle Werkzeuge inkl. Musik & Video, LoRA-Training bevorzugt',
+    ],
+    [
+        'name' => 'KI Business',
+        'price' => '39.90',
+        'description' => 'Höchste Priorität, alle Werkzeuge, Support mit 24h-Reaktionszeit',
+    ],
+];
+
+main($PRODUCTS);
+
+function main(array $products): void
+{
+    if (PHP_SAPI !== 'cli') {
+        fwrite(STDERR, "Dieses Skript muss mit php-cli ausgeführt werden.\n");
+        exit(1);
+    }
+
+    $opts = getopt('', ['apply', 'help', 'hostbill-root:', 'config:']);
+    if (isset($opts['help'])) {
+        printHelp();
+        return;
+    }
+
+    $dryRun = !isset($opts['apply']);
+    $hostbillRoot = rtrim((string)($opts['hostbill-root'] ?? HOSTBILL_DEFAULT_ROOT), '/');
+    $configFile = (string)($opts['config'] ?? ($hostbillRoot . '/includes/config.php'));
+
+    out('HostBill kiwerkzeuge.de Setup');
+    out('Modus: ' . ($dryRun ? 'DRY-RUN (keine Schreibzugriffe)' : 'APPLY (schreibt in die Datenbank)'));
+    out('Config: ' . $configFile);
+    out('');
+
+    $pdo = connectFromHostbillConfig($configFile);
+    $backupTables = [];
+    $transactionStarted = false;
+
+    try {
+        requireTable($pdo, 'hb_categories');
+        requireTable($pdo, 'hb_products');
+
+        $categoryCols = columns($pdo, 'hb_categories');
+        $productCols = columns($pdo, 'hb_products');
+
+        printSchema('hb_categories', $categoryCols);
+        printSchema('hb_products', $productCols);
+
+        $categoryPlan = buildCategoryPlan($pdo, $categoryCols);
+        out('Kategorie-Vorlage: ' . describeRow($categoryPlan['template'], $categoryPlan['id_col'], $categoryPlan['name_col']));
+
+        $productPlan = buildProductPlan($pdo, $productCols, $categoryPlan['id_col']);
+        out('Produkt-Vorlage: ' . describeRow($productPlan['template'], $productPlan['id_col'], $productPlan['name_col']));
+        out('Monatspreisspalte: ' . $productPlan['monthly_col']);
+        out('Deaktivierte Zahlweisen: ' . ($productPlan['disabled_period_cols'] ? implode(', ', $productPlan['disabled_period_cols']) : '(keine weiteren Periodenspalten erkannt)'));
+        out('Kategorie-Verknüpfung: ' . ($productPlan['category_col'] ? "hb_products.{$productPlan['category_col']}" : relationDescription($productPlan['relation'])));
+        out('');
+
+        $affectedTables = ['hb_categories', 'hb_products'];
+        if ($productPlan['relation']) {
+            $affectedTables[] = $productPlan['relation']['table'];
+            printSchema($productPlan['relation']['table'], columns($pdo, $productPlan['relation']['table']));
+        }
+
+        $timestamp = time();
+        foreach (array_values(array_unique($affectedTables)) as $table) {
+            $backupName = uniqueBackupName($pdo, $table, $timestamp);
+            $backupTables[$table] = $backupName;
+            out(($dryRun ? 'Würde Backup erstellen: ' : 'Erstelle Backup: ') . "{$backupName} aus {$table}");
+            if (!$dryRun) {
+                createBackup($pdo, $table, $backupName);
+            }
+        }
+        out('');
+
+        if (!$dryRun) {
+            $pdo->beginTransaction();
+            $transactionStarted = true;
+        }
+
+        $category = ensureCategory($pdo, $categoryPlan, $dryRun);
+        $categoryId = $category['id'];
+        $categorySlug = $category['slug'];
+
+        $productResults = [];
+        foreach ($products as $product) {
+            $productResults[] = ensureProduct($pdo, $productPlan, $categoryId, $product, $dryRun);
+        }
+
+        if (!$dryRun) {
+            $pdo->commit();
+            $transactionStarted = false;
+        }
+
+        out('');
+        out('Ergebnis');
+        out('Kategorie-ID: ' . displayId($categoryId));
+        foreach ($productResults as $result) {
+            out(sprintf('Produkt: %s -> ID %s (%s)', $result['name'], displayId($result['id']), $result['status']));
+        }
+        out('');
+        out('Bestell-URLs');
+        foreach ($productResults as $result) {
+            $id = displayId($result['id'], '<PID>');
+            out($result['name'] . ': ' . PORTAL_BASE_URL . '/?cmd=cart&action=add&id=' . $id);
+        }
+        if ($categorySlug !== null && $categorySlug !== '') {
+            out('Orderpage: ' . PORTAL_BASE_URL . '/cart/' . $categorySlug);
+        } else {
+            out('Orderpage: /cart/<kategorie-slug> nicht ausgebbar, weil keine Slug-Spalte erkannt wurde.');
+        }
+        out('');
+        out('Manuelle Admin-Schritte bleiben: Zahlungsmodul-Zuordnung prüfen, Steuersatz prüfen, Willkommens-E-Mail je Produkt mit Priority-Code konfigurieren.');
+        out('Rollback-Hinweis: Für einen manuellen Restore wurden diese Backup-Tabellen vorbereitet/angezeigt:');
+        foreach ($backupTables as $table => $backupName) {
+            out("  {$table} -> {$backupName}");
+        }
+        out('Dieses Skript führt kein automatisches --rollback aus, damit bestehende Daten nicht unbeabsichtigt gelöscht oder überschrieben werden.');
+    } catch (Throwable $e) {
+        if ($transactionStarted && $pdo->inTransaction()) {
+            $pdo->rollBack();
+        }
+        fwrite(STDERR, "\nABBRUCH: " . $e->getMessage() . "\n");
+        if ($backupTables) {
+            fwrite(STDERR, "Backup-Tabellen für manuelle Prüfung/Restore:\n");
+            foreach ($backupTables as $table => $backupName) {
+                fwrite(STDERR, "  {$table} -> {$backupName}\n");
+            }
+        }
+        exit(1);
+    }
+}
+
+function printHelp(): void
+{
+    out("Nutzung:");
+    out("  php kiwz_hostbill_setup.php");
+    out("  php kiwz_hostbill_setup.php --apply");
+    out("  php kiwz_hostbill_setup.php --hostbill-root=/home/support.panomity.com/public_html");
+    out("  php kiwz_hostbill_setup.php --config=/pfad/zu/includes/config.php");
+}
+
+function connectFromHostbillConfig(string $configFile): PDO
+{
+    if (!is_file($configFile) || !is_readable($configFile)) {
+        throw new RuntimeException("HostBill config.php nicht lesbar: {$configFile}");
+    }
+
+    $db_host = $db_name = $db_user = $db_pass = null;
+    $db_port = null;
+    require $configFile;
+
+    foreach (['db_host', 'db_name', 'db_user', 'db_pass'] as $var) {
+        if (!isset($$var) || $$var === '') {
+            throw new RuntimeException("HostBill config.php definiert \${$var} nicht.");
+        }
+    }
+
+    $dsn = 'mysql:host=' . $db_host . ';dbname=' . $db_name . ';charset=utf8mb4';
+    if (!empty($db_port)) {
+        $dsn .= ';port=' . (int)$db_port;
+    }
+
+    return new PDO($dsn, (string)$db_user, (string)$db_pass, [
+        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
+        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+        PDO::ATTR_EMULATE_PREPARES => false,
+    ]);
+}
+
+function requireTable(PDO $pdo, string $table): void
+{
+    $stmt = $pdo->prepare('SHOW TABLES LIKE ?');
+    $stmt->execute([$table]);
+    if (!$stmt->fetchColumn()) {
+        throw new RuntimeException("Erwartete Tabelle fehlt: {$table}");
+    }
+}
+
+function tableExists(PDO $pdo, string $table): bool
+{
+    $stmt = $pdo->prepare('SHOW TABLES LIKE ?');
+    $stmt->execute([$table]);
+    return (bool)$stmt->fetchColumn();
+}
+
+function columns(PDO $pdo, string $table): array
+{
+    $rows = $pdo->query('SHOW COLUMNS FROM ' . qi($table))->fetchAll();
+    if (!$rows) {
+        throw new RuntimeException("Keine Spalten gefunden in {$table}");
+    }
+    $out = [];
+    foreach ($rows as $row) {
+        $out[$row['Field']] = $row;
+    }
+    return $out;
+}
+
+function printSchema(string $table, array $cols): void
+{
+    out("Schema {$table}:");
+    foreach ($cols as $name => $meta) {
+        $extra = trim(($meta['Key'] ? ' key=' . $meta['Key'] : '') . ($meta['Extra'] ? ' extra=' . $meta['Extra'] : ''));
+        out('  - ' . $name . ' ' . $meta['Type'] . ($extra ? ' (' . $extra . ')' : ''));
+    }
+    out('');
+}
+
+function buildCategoryPlan(PDO $pdo, array $cols): array
+{
+    $idCol = autoIncrementColumn($cols) ?? pickColumn($cols, ['id', 'category_id', 'cat_id']);
+    $nameCol = pickColumn($cols, ['name', 'title', 'category', 'catname', 'label']);
+    $descCol = pickColumn($cols, ['description', 'descr', 'details', 'content', 'text', 'info']);
+    $slugCol = pickColumn($cols, ['slug', 'url', 'sefurl', 'sef_url', 'urlname', 'url_name']);
+
+    if (!$idCol || !$nameCol) {
+        throw new RuntimeException('hb_categories: ID- oder Name-Spalte nicht sicher erkannt.');
+    }
+
+    $template = selectTemplateRow($pdo, 'hb_categories', $cols, $idCol);
+
+    return [
+        'table' => 'hb_categories',
+        'cols' => $cols,
+        'id_col' => $idCol,
+        'name_col' => $nameCol,
+        'desc_col' => $descCol,
+        'slug_col' => $slugCol,
+        'template' => $template,
+    ];
+}
+
+function buildProductPlan(PDO $pdo, array $cols, string $categoryIdCol): array
+{
+    $idCol = autoIncrementColumn($cols) ?? pickColumn($cols, ['id', 'product_id', 'pid']);
+    $nameCol = pickColumn($cols, ['name', 'title', 'product_name', 'label']);
+    $descCol = pickColumn($cols, ['description', 'descr', 'details', 'content', 'text', 'info', 'short_description', 'shortdesc']);
+    $slugCol = pickColumn($cols, ['slug', 'url', 'sefurl', 'sef_url', 'urlname', 'url_name']);
+    $monthlyCol = pickMonthlyColumn($cols);
+    $categoryCol = pickProductCategoryColumn($cols);
+    $relation = null;
+
+    if (!$idCol || !$nameCol || !$descCol || !$monthlyCol) {
+        throw new RuntimeException('hb_products: ID-, Name-, Beschreibungs- oder Monatspreis-Spalte nicht sicher erkannt.');
+    }
+
+    if (!$categoryCol) {
+        $relation = discoverProductCategoryRelation($pdo, $idCol, $categoryIdCol);
+        if (!$relation) {
+            throw new RuntimeException('hb_products: Keine Kategorie-Spalte und keine Produkt/Kategorie-Relation sicher erkannt.');
+        }
+    }
+
+    $template = selectTemplateRow($pdo, 'hb_products', $cols, $idCol);
+
+    $periodCols = pickExistingColumns($cols, ['m', 'monthly', 'month', 'price_monthly', 'monthly_price', 'q', 'quarterly', 's', 'semiannually', 'semiannual', 'a', 'annually', 'annual', 'b', 'biennially', 'biennial', 't', 'triennially', 'triennial']);
+    $disabledPeriodCols = array_values(array_diff($periodCols, [$monthlyCol]));
+
+    return [
+        'table' => 'hb_products',
+        'cols' => $cols,
+        'id_col' => $idCol,
+        'name_col' => $nameCol,
+        'desc_col' => $descCol,
+        'slug_col' => $slugCol,
+        'monthly_col' => $monthlyCol,
+        'disabled_period_cols' => $disabledPeriodCols,
+        'category_col' => $categoryCol,
+        'relation' => $relation,
+        'template' => $template,
+    ];
+}
+
+function ensureCategory(PDO $pdo, array $plan, bool $dryRun): array
+{
+    $existing = findCategory($pdo, $plan, CATEGORY_NAME);
+    if ($existing) {
+        out('Kategorie existiert bereits, keine Änderung: ID ' . $existing[$plan['id_col']]);
+        return [
+            'id' => $existing[$plan['id_col']],
+            'slug' => $plan['slug_col'] ? (string)($existing[$plan['slug_col']] ?? CATEGORY_SLUG) : null,
+        ];
+    }
+
+    $overrides = [
+        $plan['name_col'] => CATEGORY_NAME,
+    ];
+    if ($plan['desc_col']) {
+        $overrides[$plan['desc_col']] = CATEGORY_DESCRIPTION;
+    }
+    if ($plan['slug_col']) {
+        $overrides[$plan['slug_col']] = CATEGORY_SLUG;
+    }
+    applyVisibilityOverrides($plan['cols'], $overrides);
+
+    out(($dryRun ? 'Würde Kategorie anlegen: ' : 'Lege Kategorie an: ') . CATEGORY_NAME);
+    $id = insertFromTemplate($pdo, $plan['table'], $plan['cols'], $plan['template'], $overrides, $dryRun);
+
+    return [
+        'id' => $dryRun ? null : $id,
+        'slug' => $plan['slug_col'] ? CATEGORY_SLUG : null,
+    ];
+}
+
+function ensureProduct(PDO $pdo, array $plan, $categoryId, array $product, bool $dryRun): array
+{
+    if (!$dryRun && ($categoryId === null || $categoryId === '')) {
+        throw new RuntimeException('Kategorie-ID fehlt für Produktanlage.');
+    }
+
+    $existing = $categoryId === null ? null : findProduct($pdo, $plan, $categoryId, $product['name']);
+    if ($existing) {
+        out('Produkt existiert bereits, keine Änderung: ' . $product['name'] . ' (ID ' . $existing[$plan['id_col']] . ')');
+        return [
+            'name' => $product['name'],
+            'id' => $existing[$plan['id_col']],
+            'status' => 'vorhanden',
+        ];
+    }
+
+    $overrides = [
+        $plan['name_col'] => $product['name'],
+        $plan['desc_col'] => $product['description'],
+        $plan['monthly_col'] => $product['price'],
+    ];
+    if ($plan['slug_col']) {
+        $overrides[$plan['slug_col']] = slugify($product['name']);
+    }
+    if ($plan['category_col']) {
+        $overrides[$plan['category_col']] = $dryRun ? '<neue Kategorie-ID>' : $categoryId;
+    }
+    foreach ($plan['disabled_period_cols'] as $col) {
+        $overrides[$col] = disabledPeriodValue($plan['template'][$col] ?? null);
+    }
+    foreach (pickExistingColumns($plan['cols'], ['paytype', 'pay_type', 'p_type', 'billingtype', 'billing_type']) as $col) {
+        $overrides[$col] = recurringValueForColumn($plan['cols'][$col]);
+    }
+    foreach (pickExistingColumns($plan['cols'], ['currency', 'curr']) as $col) {
+        if (isStringType($plan['cols'][$col]['Type'])) {
+            $overrides[$col] = 'EUR';
+        }
+    }
+    applyVisibilityOverrides($plan['cols'], $overrides);
+
+    out(($dryRun ? 'Würde Produkt anlegen: ' : 'Lege Produkt an: ') . $product['name'] . ' für ' . $product['price'] . ' EUR/Monat');
+    $id = insertFromTemplate($pdo, $plan['table'], $plan['cols'], $plan['template'], $overrides, $dryRun);
+
+    if ($plan['relation']) {
+        out(($dryRun ? 'Würde Produkt/Kategorie-Relation anlegen: ' : 'Lege Produkt/Kategorie-Relation an: ') . relationDescription($plan['relation']));
+        if (!$dryRun) {
+            ensureRelation($pdo, $plan['relation'], $id, $categoryId);
+        }
+    }
+
+    return [
+        'name' => $product['name'],
+        'id' => $dryRun ? null : $id,
+        'status' => $dryRun ? 'dry-run' : 'neu',
+    ];
+}
+
+function findCategory(PDO $pdo, array $plan, string $name): ?array
+{
+    $sql = 'SELECT * FROM ' . qi($plan['table']) . ' WHERE ' . qi($plan['name_col']) . ' = ? LIMIT 1';
+    $stmt = $pdo->prepare($sql);
+    $stmt->execute([$name]);
+    $row = $stmt->fetch();
+    return $row ?: null;
+}
+
+function findProduct(PDO $pdo, array $plan, $categoryId, string $name): ?array
+{
+    if ($plan['category_col']) {
+        $sql = 'SELECT * FROM ' . qi($plan['table']) . ' WHERE ' . qi($plan['name_col']) . ' = ? AND ' . qi($plan['category_col']) . ' = ? LIMIT 1';
+        $stmt = $pdo->prepare($sql);
+        $stmt->execute([$name, $categoryId]);
+        $row = $stmt->fetch();
+        return $row ?: null;
+    }
+
+    $rel = $plan['relation'];
+    $sql = 'SELECT p.* FROM ' . qi($plan['table']) . ' p '
+        . 'INNER JOIN ' . qi($rel['table']) . ' r ON p.' . qi($plan['id_col']) . ' = r.' . qi($rel['product_col']) . ' '
+        . 'WHERE p.' . qi($plan['name_col']) . ' = ? AND r.' . qi($rel['category_col']) . ' = ? LIMIT 1';
+    $stmt = $pdo->prepare($sql);
+    $stmt->execute([$name, $categoryId]);
+    $row = $stmt->fetch();
+    return $row ?: null;
+}
+
+function insertFromTemplate(PDO $pdo, string $table, array $cols, array $template, array $overrides, bool $dryRun)
+{
+    $data = [];
+    foreach ($cols as $name => $meta) {
+        if (!isInsertableColumn($meta)) {
+            continue;
+        }
+        $data[$name] = array_key_exists($name, $overrides) ? $overrides[$name] : ($template[$name] ?? null);
+    }
+
+    out('  Overrides:');
+    foreach ($overrides as $col => $value) {
+        out('    ' . $col . ' = ' . printableValue($value));
+    }
+
+    if ($dryRun) {
+        return null;
+    }
+
+    $fields = array_keys($data);
+    $placeholders = [];
+    $values = [];
+    foreach ($fields as $i => $field) {
+        $placeholder = ':v' . $i;
+        $placeholders[] = $placeholder;
+        $values[$placeholder] = $data[$field];
+    }
+    $sql = 'INSERT INTO ' . qi($table) . ' (' . implode(', ', array_map('qi', $fields)) . ') VALUES (' . implode(', ', $placeholders) . ')';
+    $stmt = $pdo->prepare($sql);
+    $stmt->execute($values);
+    return $pdo->lastInsertId();
+}
+
+function ensureRelation(PDO $pdo, array $rel, $productId, $categoryId): void
+{
+    $check = $pdo->prepare('SELECT 1 FROM ' . qi($rel['table']) . ' WHERE ' . qi($rel['product_col']) . ' = ? AND ' . qi($rel['category_col']) . ' = ? LIMIT 1');
+    $check->execute([$productId, $categoryId]);
+    if ($check->fetchColumn()) {
+        return;
+    }
+
+    $cols = columns($pdo, $rel['table']);
+    $template = selectAnyRow($pdo, $rel['table']);
+    $overrides = [
+        $rel['product_col'] => $productId,
+        $rel['category_col'] => $categoryId,
+    ];
+
+    if ($template) {
+        insertFromTemplate($pdo, $rel['table'], $cols, $template, $overrides, false);
+        return;
+    }
+
+    $fields = array_keys($overrides);
+    foreach ($cols as $name => $meta) {
+        if (isset($overrides[$name]) || !isInsertableColumn($meta)) {
+            continue;
+        }
+        if (strtoupper((string)$meta['Null']) === 'NO' && $meta['Default'] === null) {
+            throw new RuntimeException("Relation {$rel['table']} hat Pflichtspalte {$name}; keine Template-Zeile vorhanden.");
+        }
+    }
+    $sql = 'INSERT INTO ' . qi($rel['table']) . ' (' . implode(', ', array_map('qi', $fields)) . ') VALUES (?, ?)';
+    $stmt = $pdo->prepare($sql);
+    $stmt->execute([$productId, $categoryId]);
+}
+
+function selectTemplateRow(PDO $pdo, string $table, array $cols, string $idCol): array
+{
+    [$where, $note] = visibilityWhere($cols);
+    if ($note) {
+        out("Hinweis {$table}: {$note}");
+    }
+    $sql = 'SELECT * FROM ' . qi($table) . ' WHERE ' . $where . ' ORDER BY ' . qi($idCol) . ' ASC LIMIT 1';
+    $row = $pdo->query($sql)->fetch();
+    if (!$row) {
+        throw new RuntimeException("Keine sichtbare Template-Zeile in {$table} gefunden.");
+    }
+    return $row;
+}
+
+function selectAnyRow(PDO $pdo, string $table): ?array
+{
+    $row = $pdo->query('SELECT * FROM ' . qi($table) . ' LIMIT 1')->fetch();
+    return $row ?: null;
+}
+
+function visibilityWhere(array $cols): array
+{
+    $clauses = [];
+    foreach (['visible', 'is_visible', 'show', 'enabled', 'active', 'is_active'] as $col) {
+        if (isset($cols[$col])) {
+            $clauses[] = qi($col) . " IN (1, '1', 'yes', 'Yes', 'true', 'True', 'on', 'On')";
+        }
+    }
+    foreach (['hidden', 'is_hidden', 'disabled', 'is_disabled', 'deleted', 'archived'] as $col) {
+        if (isset($cols[$col])) {
+            $clauses[] = qi($col) . " IN (0, '0', 'no', 'No', 'false', 'False', '', 'off', 'Off')";
+        }
+    }
+    foreach (['status', 'state'] as $col) {
+        if (isset($cols[$col])) {
+            $clauses[] = qi($col) . " IN ('Active', 'active', 'Enabled', 'enabled', 'Visible', 'visible', '1', 1)";
+        }
+    }
+
+    if (!$clauses) {
+        return ['1=1', 'keine bekannte Sichtbarkeitsspalte erkannt; nutze erste vorhandene Zeile als Vorlage.'];
+    }
+    return [implode(' AND ', $clauses), null];
+}
+
+function applyVisibilityOverrides(array $cols, array &$overrides): void
+{
+    foreach (['visible', 'is_visible', 'show', 'enabled', 'active', 'is_active'] as $col) {
+        if (isset($cols[$col])) {
+            $overrides[$col] = 1;
+        }
+    }
+    foreach (['hidden', 'is_hidden', 'disabled', 'is_disabled', 'deleted', 'archived'] as $col) {
+        if (isset($cols[$col])) {
+            $overrides[$col] = 0;
+        }
+    }
+    foreach (['status', 'state'] as $col) {
+        if (isset($cols[$col])) {
+            $overrides[$col] = statusActiveValue($cols[$col]);
+        }
+    }
+}
+
+function statusActiveValue(array $meta): string
+{
+    $type = (string)$meta['Type'];
+    if (preg_match_all("/'((?:[^'\\\\]|\\\\.)*)'/", $type, $matches)) {
+        foreach ($matches[1] as $value) {
+            if (in_array(strtolower($value), ['active', 'enabled', 'visible'], true)) {
+                return $value;
+            }
+        }
+        if (in_array('1', $matches[1], true)) {
+            return '1';
+        }
+    }
+    return 'Active';
+}
+
+function recurringValueForColumn(array $meta): string
+{
+    $type = (string)$meta['Type'];
+    if (preg_match_all("/'((?:[^'\\\\]|\\\\.)*)'/", $type, $matches)) {
+        foreach ($matches[1] as $value) {
+            if (stripos($value, 'recurr') !== false) {
+                return $value;
+            }
+        }
+    }
+    return 'Recurring';
+}
+
+function discoverProductCategoryRelation(PDO $pdo, string $productIdCol, string $categoryIdCol): ?array
+{
+    $tables = $pdo->query('SHOW TABLES')->fetchAll(PDO::FETCH_COLUMN);
+    foreach ($tables as $table) {
+        $lower = strtolower((string)$table);
+        if (!preg_match('/(product.*categor|categor.*product|product.*orderpage|orderpage.*product)/', $lower)) {
+            continue;
+        }
+        $cols = columns($pdo, (string)$table);
+        $productCol = pickColumn($cols, ['product_id', 'productid', 'prod_id', 'pid', $productIdCol]);
+        $categoryCol = pickColumn($cols, ['category_id', 'cat_id', 'categoryid', 'cid', 'orderpage_id', $categoryIdCol]);
+        if ($productCol && $categoryCol && $productCol !== $categoryCol) {
+            return [
+                'table' => (string)$table,
+                'product_col' => $productCol,
+                'category_col' => $categoryCol,
+            ];
+        }
+    }
+    return null;
+}
+
+function relationDescription(?array $relation): string
+{
+    if (!$relation) {
+        return '(keine Relation)';
+    }
+    return $relation['table'] . '.' . $relation['product_col'] . ' -> Produkt, ' . $relation['table'] . '.' . $relation['category_col'] . ' -> Kategorie';
+}
+
+function createBackup(PDO $pdo, string $source, string $backup): void
+{
+    $pdo->exec('CREATE TABLE ' . qi($backup) . ' LIKE ' . qi($source));
+    $pdo->exec('INSERT INTO ' . qi($backup) . ' SELECT * FROM ' . qi($source));
+}
+
+function uniqueBackupName(PDO $pdo, string $table, int $timestamp): string
+{
+    $base = $table . '_bak_' . $timestamp;
+    $candidate = $base;
+    $i = 1;
+    while (tableExists($pdo, $candidate)) {
+        $candidate = $base . '_' . $i;
+        $i++;
+    }
+    return $candidate;
+}
+
+function autoIncrementColumn(array $cols): ?string
+{
+    foreach ($cols as $name => $meta) {
+        if (stripos((string)$meta['Extra'], 'auto_increment') !== false) {
+            return $name;
+        }
+    }
+    return null;
+}
+
+function isInsertableColumn(array $meta): bool
+{
+    $extra = strtolower((string)$meta['Extra']);
+    return strpos($extra, 'auto_increment') === false
+        && strpos($extra, 'generated') === false
+        && strpos($extra, 'virtual') === false
+        && strpos($extra, 'stored') === false;
+}
+
+function pickColumn(array $cols, array $candidates): ?string
+{
+    $map = [];
+    foreach (array_keys($cols) as $name) {
+        $map[strtolower($name)] = $name;
+    }
+    foreach ($candidates as $candidate) {
+        $key = strtolower($candidate);
+        if (isset($map[$key])) {
+            return $map[$key];
+        }
+    }
+    return null;
+}
+
+function pickExistingColumns(array $cols, array $candidates): array
+{
+    $found = [];
+    foreach ($candidates as $candidate) {
+        $col = pickColumn($cols, [$candidate]);
+        if ($col && !in_array($col, $found, true)) {
+            $found[] = $col;
+        }
+    }
+    return $found;
+}
+
+function pickMonthlyColumn(array $cols): ?string
+{
+    foreach (['m', 'monthly', 'month', 'price_monthly', 'monthly_price', 'recurring_monthly'] as $candidate) {
+        $col = pickColumn($cols, [$candidate]);
+        if ($col && isNumericType($cols[$col]['Type'])) {
+            return $col;
+        }
+    }
+    foreach ($cols as $name => $meta) {
+        if (stripos($name, 'month') !== false && isNumericType($meta['Type'])) {
+            return $name;
+        }
+    }
+    return null;
+}
+
+function pickProductCategoryColumn(array $cols): ?string
+{
+    foreach (['category_id', 'cat_id', 'categoryid', 'orderpage_id', 'cid', 'group_id'] as $candidate) {
+        $col = pickColumn($cols, [$candidate]);
+        if ($col && isNumericType($cols[$col]['Type'])) {
+            return $col;
+        }
+    }
+    $col = pickColumn($cols, ['category']);
+    if ($col && isNumericType($cols[$col]['Type'])) {
+        return $col;
+    }
+    return null;
+}
+
+function isNumericType(string $type): bool
+{
+    return (bool)preg_match('/\b(tinyint|smallint|mediumint|int|bigint|decimal|numeric|float|double|real)\b/i', $type);
+}
+
+function isStringType(string $type): bool
+{
+    return (bool)preg_match('/\b(char|varchar|text|enum|set)\b/i', $type);
+}
+
+function disabledPeriodValue($templateValue)
+{
+    if (is_numeric($templateValue) && (float)$templateValue < 0) {
+        return $templateValue;
+    }
+    return '-1';
+}
+
+function describeRow(array $row, string $idCol, ?string $nameCol): string
+{
+    $parts = ['ID ' . ($row[$idCol] ?? '?')];
+    if ($nameCol && isset($row[$nameCol])) {
+        $parts[] = $nameCol . '=' . printableValue($row[$nameCol]);
+    }
+    return implode(', ', $parts);
+}
+
+function printableValue($value): string
+{
+    if ($value === null) {
+        return 'NULL';
+    }
+    $str = (string)$value;
+    if (strlen($str) > 90) {
+        $str = substr($str, 0, 87) . '...';
+    }
+    return "'" . $str . "'";
+}
+
+function displayId($id, string $fallback = '<wird von AUTO_INCREMENT vergeben>'): string
+{
+    return ($id === null || $id === '') ? $fallback : (string)$id;
+}
+
+function slugify(string $value): string
+{
+    $value = str_replace(['Ä', 'Ö', 'Ü', 'ä', 'ö', 'ü', 'ß'], ['Ae', 'Oe', 'Ue', 'ae', 'oe', 'ue', 'ss'], $value);
+    $value = strtolower($value);
+    $value = preg_replace('/[^a-z0-9]+/', '-', $value) ?? $value;
+    $value = trim($value, '-');
+    return $value !== '' ? $value : 'kiwz-produkt';
+}
+
+function qi(string $identifier): string
+{
+    if (!preg_match('/^[A-Za-z0-9_]+$/', $identifier)) {
+        throw new RuntimeException("Unsicherer SQL-Identifier: {$identifier}");
+    }
+    return '`' . $identifier . '`';
+}
+
+function out(string $line): void
+{
+    echo $line . PHP_EOL;
+}
diff --git a/kiwz_prio_codes.txt b/kiwz_prio_codes.txt
new file mode 100644
index 0000000000000000000000000000000000000000..4acd9c408d2fbad263e01ddab541742f0709b92b
--- /dev/null
+++ b/kiwz_prio_codes.txt
@@ -0,0 +1,30 @@
+KIWZ-X7QA-H4NZ
+KIWZ-BM6T-9VDK
+KIWZ-3LPR-W8CY
+KIWZ-F2ZH-QN5A
+KIWZ-7GVM-KX4T
+KIWZ-N9QS-2JWR
+KIWZ-H8DC-V6PY
+KIWZ-4TFK-ZM7B
+KIWZ-Q2WX-5ANR
+KIWZ-Y6PJ-C9LT
+KIWZ-8KDA-RV3M
+KIWZ-Z5HN-WQ7F
+KIWZ-2VTC-JK9P
+KIWZ-M4RY-8XGB
+KIWZ-6QNL-FZ2D
+KIWZ-CP7W-3HKA
+KIWZ-J9XB-T5VQ
+KIWZ-W2MG-6RZN
+KIWZ-5DFY-P8KC
+KIWZ-A7VT-NQ4H
+KIWZ-R3KZ-Y6PM
+KIWZ-L8QC-2WJF
+KIWZ-9NBH-X5TA
+KIWZ-V4PR-7ZDG
+KIWZ-T6JW-M2QC
+KIWZ-G5KA-9VYR
+KIWZ-2XND-H8FP
+KIWZ-P7ZM-C4WT
+KIWZ-Q9RY-6BKL
+KIWZ-F3VC-J5XN

diff --git a/ANLEITUNG.md b/ANLEITUNG.md
new file mode 100644
index 0000000000000000000000000000000000000000..855de0955bb7085d75847133170994b159e380e3
--- /dev/null
+++ b/ANLEITUNG.md
@@ -0,0 +1,51 @@
+# HostBill-Setup für kiwerkzeuge.de
+
+## Skript ausführen
+
+Das Skript ist standardmäßig ein Dry-Run und schreibt dabei nichts in die Datenbank:
+
+```bash
+php kiwz_hostbill_setup.php
+```
+
+Wenn Schema, Template-Zeilen, Backups und geplante Inserts plausibel aussehen, ausführen mit:
+
+```bash
+php kiwz_hostbill_setup.php --apply
+```
+
+Optional kann ein abweichender HostBill-Pfad oder eine konkrete Config-Datei angegeben werden:
+
+```bash
+php kiwz_hostbill_setup.php --hostbill-root=/home/support.panomity.com/public_html
+php kiwz_hostbill_setup.php --config=/home/support.panomity.com/public_html/includes/config.php
+```
+
+Vor den Inserts erstellt das Skript Backup-Tabellen per `CREATE TABLE ... LIKE` und `INSERT INTO ... SELECT`, z. B. `hb_products_bak_<timestamp>`. Das Skript löscht oder aktualisiert keine bestehenden Zeilen.
+
+## Manuelle Alternative im HostBill-Admin
+
+Falls die Einrichtung lieber per Klick gemacht wird:
+
+1. `padm` öffnen und zu `Settings -> Products & Services` gehen.
+2. Neue Orderpage/Kategorie `KI Werkzeuge` anlegen, sichtbar schalten und als Slug z. B. `ki-werkzeuge` setzen.
+3. Drei monatlich wiederkehrende EUR-Produkte in dieser Orderpage anlegen:
+   - `KI Starter`, 4.90 EUR/Monat, `Priority-Rendering für Bildgenerator & Tiefenkarte, E-Mail-Support`
+   - `KI Pro`, 14.90 EUR/Monat, `Priority für alle Werkzeuge inkl. Musik & Video, LoRA-Training bevorzugt`
+   - `KI Business`, 39.90 EUR/Monat, `Höchste Priorität, alle Werkzeuge, Support mit 24h-Reaktionszeit`
+
+Danach Zahlungsmodul-Zuordnung, Steuersatz und Sichtbarkeit im Warenkorb prüfen.
+
+## Willkommens-E-Mail mit Priority-Code
+
+Für jedes Produkt sollte eine Willkommens-E-Mail bzw. Produktaktivierungs-Mail eingerichtet werden. Der Inhalt sollte dem Kunden genau einen freien Code aus `kiwz_prio_codes.txt` mitteilen, z. B.:
+
+```text
+Ihr Priority-Code für kiwerkzeuge.de lautet: KIWZ-XXXX-XXXX
+```
+
+Wichtig: Codes müssen nach Ausgabe als vergeben markiert werden, damit derselbe Code nicht mehrfach an Kunden geht. Für den ersten Batch kann das Snippet `kiwz_gpuq_config_snippet.php` genutzt werden; weitere Codes werden dort im Array `'priority_codes' => [...]` ergänzt.
+
+## Multibrand-Hinweis
+
+Das Multibrand-Modul ist installiert. Für eine eigene Brand `kiwerkzeuge.de` wären zusätzlich zur Produktanlage diese Schritte nötig: Brand in `hb_brands` bzw. über den HostBill-Admin anlegen, Domain/Portal-URL und Branding in `hb_brand_config` konfigurieren, Template/Logo/E-Mail-Absender setzen, Zahlungs- und Steuerregeln der Brand zuordnen und die neue Orderpage/Produkte dieser Brand sichtbar zuweisen. Diese Anleitung dokumentiert das nur; das Setup-Skript legt keine neue Brand an.
diff --git a/kiwz_gpuq_config_snippet.php b/kiwz_gpuq_config_snippet.php
new file mode 100644
index 0000000000000000000000000000000000000000..c089e9c811a945dc3d3da6515b6c7d714f4dfd66
--- /dev/null
+++ b/kiwz_gpuq_config_snippet.php
@@ -0,0 +1,19 @@
+<?php
+
+// Beispiel-Batch für die ersten Kunden. Weitere Codes aus kiwz_prio_codes.txt
+// nach gleichem Muster ergänzen, z. B. 'KIWZ-XXXX-XXXX' => 'high'.
+$gpuqConfig = [
+    'priority_codes' => [
+        'KIWZ-X7QA-H4NZ' => 'high',
+        'KIWZ-BM6T-9VDK' => 'high',
+        'KIWZ-3LPR-W8CY' => 'high',
+        'KIWZ-F2ZH-QN5A' => 'high',
+        'KIWZ-7GVM-KX4T' => 'high',
+        'KIWZ-N9QS-2JWR' => 'high',
+        'KIWZ-H8DC-V6PY' => 'high',
+        'KIWZ-4TFK-ZM7B' => 'high',
+        'KIWZ-Q2WX-5ANR' => 'high',
+        'KIWZ-Y6PJ-C9LT' => 'high',
+    ],
+];
+
diff --git a/kiwz_hostbill_setup.php b/kiwz_hostbill_setup.php
new file mode 100644
index 0000000000000000000000000000000000000000..0d3d1b036cc1df8f965ac381c8f328b8344051ee
--- /dev/null
+++ b/kiwz_hostbill_setup.php
@@ -0,0 +1,767 @@
+#!/usr/bin/env php
+<?php
+declare(strict_types=1);
+
+/**
+ * HostBill setup helper for kiwerkzeuge.de.
+ *
+ * Default mode is dry-run. The script intentionally discovers the HostBill
+ * schema at runtime and copies visible template rows instead of assuming an
+ * exact HostBill table layout.
+ */
+
+const HOSTBILL_DEFAULT_ROOT = '/home/support.panomity.com/public_html';
+const PORTAL_BASE_URL = 'https://support.panomity.com';
+const CATEGORY_NAME = 'KI Werkzeuge';
+const CATEGORY_SLUG = 'ki-werkzeuge';
+const CATEGORY_DESCRIPTION = 'Deutschsprachige KI-Tool-Suite mit Bildgenerator, Musik, Video und Tiefenkarte.';
+
+$PRODUCTS = [
+    [
+        'name' => 'KI Starter',
+        'price' => '4.90',
+        'description' => 'Priority-Rendering für Bildgenerator & Tiefenkarte, E-Mail-Support',
+    ],
+    [
+        'name' => 'KI Pro',
+        'price' => '14.90',
+        'description' => 'Priority für alle Werkzeuge inkl. Musik & Video, LoRA-Training bevorzugt',
+    ],
+    [
+        'name' => 'KI Business',
+        'price' => '39.90',
+        'description' => 'Höchste Priorität, alle Werkzeuge, Support mit 24h-Reaktionszeit',
+    ],
+];
+
+main($PRODUCTS);
+
+function main(array $products): void
+{
+    if (PHP_SAPI !== 'cli') {
+        fwrite(STDERR, "Dieses Skript muss mit php-cli ausgeführt werden.\n");
+        exit(1);
+    }
+
+    $opts = getopt('', ['apply', 'help', 'hostbill-root:', 'config:']);
+    if (isset($opts['help'])) {
+        printHelp();
+        return;
+    }
+
+    $dryRun = !isset($opts['apply']);
+    $hostbillRoot = rtrim((string)($opts['hostbill-root'] ?? HOSTBILL_DEFAULT_ROOT), '/');
+    $configFile = (string)($opts['config'] ?? ($hostbillRoot . '/includes/config.php'));
+
+    out('HostBill kiwerkzeuge.de Setup');
+    out('Modus: ' . ($dryRun ? 'DRY-RUN (keine Schreibzugriffe)' : 'APPLY (schreibt in die Datenbank)'));
+    out('Config: ' . $configFile);
+    out('');
+
+    $pdo = connectFromHostbillConfig($configFile);
+    $backupTables = [];
+    $transactionStarted = false;
+
+    try {
+        requireTable($pdo, 'hb_categories');
+        requireTable($pdo, 'hb_products');
+
+        $categoryCols = columns($pdo, 'hb_categories');
+        $productCols = columns($pdo, 'hb_products');
+
+        printSchema('hb_categories', $categoryCols);
+        printSchema('hb_products', $productCols);
+
+        $categoryPlan = buildCategoryPlan($pdo, $categoryCols);
+        out('Kategorie-Vorlage: ' . describeRow($categoryPlan['template'], $categoryPlan['id_col'], $categoryPlan['name_col']));
+
+        $productPlan = buildProductPlan($pdo, $productCols, $categoryPlan['id_col']);
+        out('Produkt-Vorlage: ' . describeRow($productPlan['template'], $productPlan['id_col'], $productPlan['name_col']));
+        out('Monatspreisspalte: ' . $productPlan['monthly_col']);
+        out('Deaktivierte Zahlweisen: ' . ($productPlan['disabled_period_cols'] ? implode(', ', $productPlan['disabled_period_cols']) : '(keine weiteren Periodenspalten erkannt)'));
+        out('Kategorie-Verknüpfung: ' . ($productPlan['category_col'] ? "hb_products.{$productPlan['category_col']}" : relationDescription($productPlan['relation'])));
+        out('');
+
+        $affectedTables = ['hb_categories', 'hb_products'];
+        if ($productPlan['relation']) {
+            $affectedTables[] = $productPlan['relation']['table'];
+            printSchema($productPlan['relation']['table'], columns($pdo, $productPlan['relation']['table']));
+        }
+
+        $timestamp = time();
+        foreach (array_values(array_unique($affectedTables)) as $table) {
+            $backupName = uniqueBackupName($pdo, $table, $timestamp);
+            $backupTables[$table] = $backupName;
+            out(($dryRun ? 'Würde Backup erstellen: ' : 'Erstelle Backup: ') . "{$backupName} aus {$table}");
+            if (!$dryRun) {
+                createBackup($pdo, $table, $backupName);
+            }
+        }
+        out('');
+
+        if (!$dryRun) {
+            $pdo->beginTransaction();
+            $transactionStarted = true;
+        }
+
+        $category = ensureCategory($pdo, $categoryPlan, $dryRun);
+        $categoryId = $category['id'];
+        $categorySlug = $category['slug'];
+
+        $productResults = [];
+        foreach ($products as $product) {
+            $productResults[] = ensureProduct($pdo, $productPlan, $categoryId, $product, $dryRun);
+        }
+
+        if (!$dryRun) {
+            $pdo->commit();
+            $transactionStarted = false;
+        }
+
+        out('');
+        out('Ergebnis');
+        out('Kategorie-ID: ' . displayId($categoryId));
+        foreach ($productResults as $result) {
+            out(sprintf('Produkt: %s -> ID %s (%s)', $result['name'], displayId($result['id']), $result['status']));
+        }
+        out('');
+        out('Bestell-URLs');
+        foreach ($productResults as $result) {
+            $id = displayId($result['id'], '<PID>');
+            out($result['name'] . ': ' . PORTAL_BASE_URL . '/?cmd=cart&action=add&id=' . $id);
+        }
+        if ($categorySlug !== null && $categorySlug !== '') {
+            out('Orderpage: ' . PORTAL_BASE_URL . '/cart/' . $categorySlug);
+        } else {
+            out('Orderpage: /cart/<kategorie-slug> nicht ausgebbar, weil keine Slug-Spalte erkannt wurde.');
+        }
+        out('');
+        out('Manuelle Admin-Schritte bleiben: Zahlungsmodul-Zuordnung prüfen, Steuersatz prüfen, Willkommens-E-Mail je Produkt mit Priority-Code konfigurieren.');
+        out('Rollback-Hinweis: Für einen manuellen Restore wurden diese Backup-Tabellen vorbereitet/angezeigt:');
+        foreach ($backupTables as $table => $backupName) {
+            out("  {$table} -> {$backupName}");
+        }
+        out('Dieses Skript führt kein automatisches --rollback aus, damit bestehende Daten nicht unbeabsichtigt gelöscht oder überschrieben werden.');
+    } catch (Throwable $e) {
+        if ($transactionStarted && $pdo->inTransaction()) {
+            $pdo->rollBack();
+        }
+        fwrite(STDERR, "\nABBRUCH: " . $e->getMessage() . "\n");
+        if ($backupTables) {
+            fwrite(STDERR, "Backup-Tabellen für manuelle Prüfung/Restore:\n");
+            foreach ($backupTables as $table => $backupName) {
+                fwrite(STDERR, "  {$table} -> {$backupName}\n");
+            }
+        }
+        exit(1);
+    }
+}
+
+function printHelp(): void
+{
+    out("Nutzung:");
+    out("  php kiwz_hostbill_setup.php");
+    out("  php kiwz_hostbill_setup.php --apply");
+    out("  php kiwz_hostbill_setup.php --hostbill-root=/home/support.panomity.com/public_html");
+    out("  php kiwz_hostbill_setup.php --config=/pfad/zu/includes/config.php");
+}
+
+function connectFromHostbillConfig(string $configFile): PDO
+{
+    if (!is_file($configFile) || !is_readable($configFile)) {
+        throw new RuntimeException("HostBill config.php nicht lesbar: {$configFile}");
+    }
+
+    $db_host = $db_name = $db_user = $db_pass = null;
+    $db_port = null;
+    require $configFile;
+
+    foreach (['db_host', 'db_name', 'db_user', 'db_pass'] as $var) {
+        if (!isset($$var) || $$var === '') {
+            throw new RuntimeException("HostBill config.php definiert \${$var} nicht.");
+        }
+    }
+
+    $dsn = 'mysql:host=' . $db_host . ';dbname=' . $db_name . ';charset=utf8mb4';
+    if (!empty($db_port)) {
+        $dsn .= ';port=' . (int)$db_port;
+    }
+
+    return new PDO($dsn, (string)$db_user, (string)$db_pass, [
+        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
+        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+        PDO::ATTR_EMULATE_PREPARES => false,
+    ]);
+}
+
+function requireTable(PDO $pdo, string $table): void
+{
+    $stmt = $pdo->prepare('SHOW TABLES LIKE ?');
+    $stmt->execute([$table]);
+    if (!$stmt->fetchColumn()) {
+        throw new RuntimeException("Erwartete Tabelle fehlt: {$table}");
+    }
+}
+
+function tableExists(PDO $pdo, string $table): bool
+{
+    $stmt = $pdo->prepare('SHOW TABLES LIKE ?');
+    $stmt->execute([$table]);
+    return (bool)$stmt->fetchColumn();
+}
+
+function columns(PDO $pdo, string $table): array
+{
+    $rows = $pdo->query('SHOW COLUMNS FROM ' . qi($table))->fetchAll();
+    if (!$rows) {
+        throw new RuntimeException("Keine Spalten gefunden in {$table}");
+    }
+    $out = [];
+    foreach ($rows as $row) {
+        $out[$row['Field']] = $row;
+    }
+    return $out;
+}
+
+function printSchema(string $table, array $cols): void
+{
+    out("Schema {$table}:");
+    foreach ($cols as $name => $meta) {
+        $extra = trim(($meta['Key'] ? ' key=' . $meta['Key'] : '') . ($meta['Extra'] ? ' extra=' . $meta['Extra'] : ''));
+        out('  - ' . $name . ' ' . $meta['Type'] . ($extra ? ' (' . $extra . ')' : ''));
+    }
+    out('');
+}
+
+function buildCategoryPlan(PDO $pdo, array $cols): array
+{
+    $idCol = autoIncrementColumn($cols) ?? pickColumn($cols, ['id', 'category_id', 'cat_id']);
+    $nameCol = pickColumn($cols, ['name', 'title', 'category', 'catname', 'label']);
+    $descCol = pickColumn($cols, ['description', 'descr', 'details', 'content', 'text', 'info']);
+    $slugCol = pickColumn($cols, ['slug', 'url', 'sefurl', 'sef_url', 'urlname', 'url_name']);
+
+    if (!$idCol || !$nameCol) {
+        throw new RuntimeException('hb_categories: ID- oder Name-Spalte nicht sicher erkannt.');
+    }
+
+    $template = selectTemplateRow($pdo, 'hb_categories', $cols, $idCol);
+
+    return [
+        'table' => 'hb_categories',
+        'cols' => $cols,
+        'id_col' => $idCol,
+        'name_col' => $nameCol,
+        'desc_col' => $descCol,
+        'slug_col' => $slugCol,
+        'template' => $template,
+    ];
+}
+
+function buildProductPlan(PDO $pdo, array $cols, string $categoryIdCol): array
+{
+    $idCol = autoIncrementColumn($cols) ?? pickColumn($cols, ['id', 'product_id', 'pid']);
+    $nameCol = pickColumn($cols, ['name', 'title', 'product_name', 'label']);
+    $descCol = pickColumn($cols, ['description', 'descr', 'details', 'content', 'text', 'info', 'short_description', 'shortdesc']);
+    $slugCol = pickColumn($cols, ['slug', 'url', 'sefurl', 'sef_url', 'urlname', 'url_name']);
+    $monthlyCol = pickMonthlyColumn($cols);
+    $categoryCol = pickProductCategoryColumn($cols);
+    $relation = null;
+
+    if (!$idCol || !$nameCol || !$descCol || !$monthlyCol) {
+        throw new RuntimeException('hb_products: ID-, Name-, Beschreibungs- oder Monatspreis-Spalte nicht sicher erkannt.');
+    }
+
+    if (!$categoryCol) {
+        $relation = discoverProductCategoryRelation($pdo, $idCol, $categoryIdCol);
+        if (!$relation) {
+            throw new RuntimeException('hb_products: Keine Kategorie-Spalte und keine Produkt/Kategorie-Relation sicher erkannt.');
+        }
+    }
+
+    $template = selectTemplateRow($pdo, 'hb_products', $cols, $idCol);
+
+    $periodCols = pickExistingColumns($cols, ['m', 'monthly', 'month', 'price_monthly', 'monthly_price', 'q', 'quarterly', 's', 'semiannually', 'semiannual', 'a', 'annually', 'annual', 'b', 'biennially', 'biennial', 't', 'triennially', 'triennial']);
+    $disabledPeriodCols = array_values(array_diff($periodCols, [$monthlyCol]));
+
+    return [
+        'table' => 'hb_products',
+        'cols' => $cols,
+        'id_col' => $idCol,
+        'name_col' => $nameCol,
+        'desc_col' => $descCol,
+        'slug_col' => $slugCol,
+        'monthly_col' => $monthlyCol,
+        'disabled_period_cols' => $disabledPeriodCols,
+        'category_col' => $categoryCol,
+        'relation' => $relation,
+        'template' => $template,
+    ];
+}
+
+function ensureCategory(PDO $pdo, array $plan, bool $dryRun): array
+{
+    $existing = findCategory($pdo, $plan, CATEGORY_NAME);
+    if ($existing) {
+        out('Kategorie existiert bereits, keine Änderung: ID ' . $existing[$plan['id_col']]);
+        return [
+            'id' => $existing[$plan['id_col']],
+            'slug' => $plan['slug_col'] ? (string)($existing[$plan['slug_col']] ?? CATEGORY_SLUG) : null,
+        ];
+    }
+
+    $overrides = [
+        $plan['name_col'] => CATEGORY_NAME,
+    ];
+    if ($plan['desc_col']) {
+        $overrides[$plan['desc_col']] = CATEGORY_DESCRIPTION;
+    }
+    if ($plan['slug_col']) {
+        $overrides[$plan['slug_col']] = CATEGORY_SLUG;
+    }
+    applyVisibilityOverrides($plan['cols'], $overrides);
+
+    out(($dryRun ? 'Würde Kategorie anlegen: ' : 'Lege Kategorie an: ') . CATEGORY_NAME);
+    $id = insertFromTemplate($pdo, $plan['table'], $plan['cols'], $plan['template'], $overrides, $dryRun);
+
+    return [
+        'id' => $dryRun ? null : $id,
+        'slug' => $plan['slug_col'] ? CATEGORY_SLUG : null,
+    ];
+}
+
+function ensureProduct(PDO $pdo, array $plan, $categoryId, array $product, bool $dryRun): array
+{
+    if (!$dryRun && ($categoryId === null || $categoryId === '')) {
+        throw new RuntimeException('Kategorie-ID fehlt für Produktanlage.');
+    }
+
+    $existing = $categoryId === null ? null : findProduct($pdo, $plan, $categoryId, $product['name']);
+    if ($existing) {
+        out('Produkt existiert bereits, keine Änderung: ' . $product['name'] . ' (ID ' . $existing[$plan['id_col']] . ')');
+        return [
+            'name' => $product['name'],
+            'id' => $existing[$plan['id_col']],
+            'status' => 'vorhanden',
+        ];
+    }
+
+    $overrides = [
+        $plan['name_col'] => $product['name'],
+        $plan['desc_col'] => $product['description'],
+        $plan['monthly_col'] => $product['price'],
+    ];
+    if ($plan['slug_col']) {
+        $overrides[$plan['slug_col']] = slugify($product['name']);
+    }
+    if ($plan['category_col']) {
+        $overrides[$plan['category_col']] = $dryRun ? '<neue Kategorie-ID>' : $categoryId;
+    }
+    foreach ($plan['disabled_period_cols'] as $col) {
+        $overrides[$col] = disabledPeriodValue($plan['template'][$col] ?? null);
+    }
+    foreach (pickExistingColumns($plan['cols'], ['paytype', 'pay_type', 'p_type', 'billingtype', 'billing_type']) as $col) {
+        $overrides[$col] = recurringValueForColumn($plan['cols'][$col]);
+    }
+    foreach (pickExistingColumns($plan['cols'], ['currency', 'curr']) as $col) {
+        if (isStringType($plan['cols'][$col]['Type'])) {
+            $overrides[$col] = 'EUR';
+        }
+    }
+    applyVisibilityOverrides($plan['cols'], $overrides);
+
+    out(($dryRun ? 'Würde Produkt anlegen: ' : 'Lege Produkt an: ') . $product['name'] . ' für ' . $product['price'] . ' EUR/Monat');
+    $id = insertFromTemplate($pdo, $plan['table'], $plan['cols'], $plan['template'], $overrides, $dryRun);
+
+    if ($plan['relation']) {
+        out(($dryRun ? 'Würde Produkt/Kategorie-Relation anlegen: ' : 'Lege Produkt/Kategorie-Relation an: ') . relationDescription($plan['relation']));
+        if (!$dryRun) {
+            ensureRelation($pdo, $plan['relation'], $id, $categoryId);
+        }
+    }
+
+    return [
+        'name' => $product['name'],
+        'id' => $dryRun ? null : $id,
+        'status' => $dryRun ? 'dry-run' : 'neu',
+    ];
+}
+
+function findCategory(PDO $pdo, array $plan, string $name): ?array
+{
+    $sql = 'SELECT * FROM ' . qi($plan['table']) . ' WHERE ' . qi($plan['name_col']) . ' = ? LIMIT 1';
+    $stmt = $pdo->prepare($sql);
+    $stmt->execute([$name]);
+    $row = $stmt->fetch();
+    return $row ?: null;
+}
+
+function findProduct(PDO $pdo, array $plan, $categoryId, string $name): ?array
+{
+    if ($plan['category_col']) {
+        $sql = 'SELECT * FROM ' . qi($plan['table']) . ' WHERE ' . qi($plan['name_col']) . ' = ? AND ' . qi($plan['category_col']) . ' = ? LIMIT 1';
+        $stmt = $pdo->prepare($sql);
+        $stmt->execute([$name, $categoryId]);
+        $row = $stmt->fetch();
+        return $row ?: null;
+    }
+
+    $rel = $plan['relation'];
+    $sql = 'SELECT p.* FROM ' . qi($plan['table']) . ' p '
+        . 'INNER JOIN ' . qi($rel['table']) . ' r ON p.' . qi($plan['id_col']) . ' = r.' . qi($rel['product_col']) . ' '
+        . 'WHERE p.' . qi($plan['name_col']) . ' = ? AND r.' . qi($rel['category_col']) . ' = ? LIMIT 1';
+    $stmt = $pdo->prepare($sql);
+    $stmt->execute([$name, $categoryId]);
+    $row = $stmt->fetch();
+    return $row ?: null;
+}
+
+function insertFromTemplate(PDO $pdo, string $table, array $cols, array $template, array $overrides, bool $dryRun)
+{
+    $data = [];
+    foreach ($cols as $name => $meta) {
+        if (!isInsertableColumn($meta)) {
+            continue;
+        }
+        $data[$name] = array_key_exists($name, $overrides) ? $overrides[$name] : ($template[$name] ?? null);
+    }
+
+    out('  Overrides:');
+    foreach ($overrides as $col => $value) {
+        out('    ' . $col . ' = ' . printableValue($value));
+    }
+
+    if ($dryRun) {
+        return null;
+    }
+
+    $fields = array_keys($data);
+    $placeholders = [];
+    $values = [];
+    foreach ($fields as $i => $field) {
+        $placeholder = ':v' . $i;
+        $placeholders[] = $placeholder;
+        $values[$placeholder] = $data[$field];
+    }
+    $sql = 'INSERT INTO ' . qi($table) . ' (' . implode(', ', array_map('qi', $fields)) . ') VALUES (' . implode(', ', $placeholders) . ')';
+    $stmt = $pdo->prepare($sql);
+    $stmt->execute($values);
+    return $pdo->lastInsertId();
+}
+
+function ensureRelation(PDO $pdo, array $rel, $productId, $categoryId): void
+{
+    $check = $pdo->prepare('SELECT 1 FROM ' . qi($rel['table']) . ' WHERE ' . qi($rel['product_col']) . ' = ? AND ' . qi($rel['category_col']) . ' = ? LIMIT 1');
+    $check->execute([$productId, $categoryId]);
+    if ($check->fetchColumn()) {
+        return;
+    }
+
+    $cols = columns($pdo, $rel['table']);
+    $template = selectAnyRow($pdo, $rel['table']);
+    $overrides = [
+        $rel['product_col'] => $productId,
+        $rel['category_col'] => $categoryId,
+    ];
+
+    if ($template) {
+        insertFromTemplate($pdo, $rel['table'], $cols, $template, $overrides, false);
+        return;
+    }
+
+    $fields = array_keys($overrides);
+    foreach ($cols as $name => $meta) {
+        if (isset($overrides[$name]) || !isInsertableColumn($meta)) {
+            continue;
+        }
+        if (strtoupper((string)$meta['Null']) === 'NO' && $meta['Default'] === null) {
+            throw new RuntimeException("Relation {$rel['table']} hat Pflichtspalte {$name}; keine Template-Zeile vorhanden.");
+        }
+    }
+    $sql = 'INSERT INTO ' . qi($rel['table']) . ' (' . implode(', ', array_map('qi', $fields)) . ') VALUES (?, ?)';
+    $stmt = $pdo->prepare($sql);
+    $stmt->execute([$productId, $categoryId]);
+}
+
+function selectTemplateRow(PDO $pdo, string $table, array $cols, string $idCol): array
+{
+    [$where, $note] = visibilityWhere($cols);
+    if ($note) {
+        out("Hinweis {$table}: {$note}");
+    }
+    $sql = 'SELECT * FROM ' . qi($table) . ' WHERE ' . $where . ' ORDER BY ' . qi($idCol) . ' ASC LIMIT 1';
+    $row = $pdo->query($sql)->fetch();
+    if (!$row) {
+        throw new RuntimeException("Keine sichtbare Template-Zeile in {$table} gefunden.");
+    }
+    return $row;
+}
+
+function selectAnyRow(PDO $pdo, string $table): ?array
+{
+    $row = $pdo->query('SELECT * FROM ' . qi($table) . ' LIMIT 1')->fetch();
+    return $row ?: null;
+}
+
+function visibilityWhere(array $cols): array
+{
+    $clauses = [];
+    foreach (['visible', 'is_visible', 'show', 'enabled', 'active', 'is_active'] as $col) {
+        if (isset($cols[$col])) {
+            $clauses[] = qi($col) . " IN (1, '1', 'yes', 'Yes', 'true', 'True', 'on', 'On')";
+        }
+    }
+    foreach (['hidden', 'is_hidden', 'disabled', 'is_disabled', 'deleted', 'archived'] as $col) {
+        if (isset($cols[$col])) {
+            $clauses[] = qi($col) . " IN (0, '0', 'no', 'No', 'false', 'False', '', 'off', 'Off')";
+        }
+    }
+    foreach (['status', 'state'] as $col) {
+        if (isset($cols[$col])) {
+            $clauses[] = qi($col) . " IN ('Active', 'active', 'Enabled', 'enabled', 'Visible', 'visible', '1', 1)";
+        }
+    }
+
+    if (!$clauses) {
+        return ['1=1', 'keine bekannte Sichtbarkeitsspalte erkannt; nutze erste vorhandene Zeile als Vorlage.'];
+    }
+    return [implode(' AND ', $clauses), null];
+}
+
+function applyVisibilityOverrides(array $cols, array &$overrides): void
+{
+    foreach (['visible', 'is_visible', 'show', 'enabled', 'active', 'is_active'] as $col) {
+        if (isset($cols[$col])) {
+            $overrides[$col] = 1;
+        }
+    }
+    foreach (['hidden', 'is_hidden', 'disabled', 'is_disabled', 'deleted', 'archived'] as $col) {
+        if (isset($cols[$col])) {
+            $overrides[$col] = 0;
+        }
+    }
+    foreach (['status', 'state'] as $col) {
+        if (isset($cols[$col])) {
+            $overrides[$col] = statusActiveValue($cols[$col]);
+        }
+    }
+}
+
+function statusActiveValue(array $meta): string
+{
+    $type = (string)$meta['Type'];
+    if (preg_match_all("/'((?:[^'\\\\]|\\\\.)*)'/", $type, $matches)) {
+        foreach ($matches[1] as $value) {
+            if (in_array(strtolower($value), ['active', 'enabled', 'visible'], true)) {
+                return $value;
+            }
+        }
+        if (in_array('1', $matches[1], true)) {
+            return '1';
+        }
+    }
+    return 'Active';
+}
+
+function recurringValueForColumn(array $meta): string
+{
+    $type = (string)$meta['Type'];
+    if (preg_match_all("/'((?:[^'\\\\]|\\\\.)*)'/", $type, $matches)) {
+        foreach ($matches[1] as $value) {
+            if (stripos($value, 'recurr') !== false) {
+                return $value;
+            }
+        }
+    }
+    return 'Recurring';
+}
+
+function discoverProductCategoryRelation(PDO $pdo, string $productIdCol, string $categoryIdCol): ?array
+{
+    $tables = $pdo->query('SHOW TABLES')->fetchAll(PDO::FETCH_COLUMN);
+    foreach ($tables as $table) {
+        $lower = strtolower((string)$table);
+        if (!preg_match('/(product.*categor|categor.*product|product.*orderpage|orderpage.*product)/', $lower)) {
+            continue;
+        }
+        $cols = columns($pdo, (string)$table);
+        $productCol = pickColumn($cols, ['product_id', 'productid', 'prod_id', 'pid', $productIdCol]);
+        $categoryCol = pickColumn($cols, ['category_id', 'cat_id', 'categoryid', 'cid', 'orderpage_id', $categoryIdCol]);
+        if ($productCol && $categoryCol && $productCol !== $categoryCol) {
+            return [
+                'table' => (string)$table,
+                'product_col' => $productCol,
+                'category_col' => $categoryCol,
+            ];
+        }
+    }
+    return null;
+}
+
+function relationDescription(?array $relation): string
+{
+    if (!$relation) {
+        return '(keine Relation)';
+    }
+    return $relation['table'] . '.' . $relation['product_col'] . ' -> Produkt, ' . $relation['table'] . '.' . $relation['category_col'] . ' -> Kategorie';
+}
+
+function createBackup(PDO $pdo, string $source, string $backup): void
+{
+    $pdo->exec('CREATE TABLE ' . qi($backup) . ' LIKE ' . qi($source));
+    $pdo->exec('INSERT INTO ' . qi($backup) . ' SELECT * FROM ' . qi($source));
+}
+
+function uniqueBackupName(PDO $pdo, string $table, int $timestamp): string
+{
+    $base = $table . '_bak_' . $timestamp;
+    $candidate = $base;
+    $i = 1;
+    while (tableExists($pdo, $candidate)) {
+        $candidate = $base . '_' . $i;
+        $i++;
+    }
+    return $candidate;
+}
+
+function autoIncrementColumn(array $cols): ?string
+{
+    foreach ($cols as $name => $meta) {
+        if (stripos((string)$meta['Extra'], 'auto_increment') !== false) {
+            return $name;
+        }
+    }
+    return null;
+}
+
+function isInsertableColumn(array $meta): bool
+{
+    $extra = strtolower((string)$meta['Extra']);
+    return strpos($extra, 'auto_increment') === false
+        && strpos($extra, 'generated') === false
+        && strpos($extra, 'virtual') === false
+        && strpos($extra, 'stored') === false;
+}
+
+function pickColumn(array $cols, array $candidates): ?string
+{
+    $map = [];
+    foreach (array_keys($cols) as $name) {
+        $map[strtolower($name)] = $name;
+    }
+    foreach ($candidates as $candidate) {
+        $key = strtolower($candidate);
+        if (isset($map[$key])) {
+            return $map[$key];
+        }
+    }
+    return null;
+}
+
+function pickExistingColumns(array $cols, array $candidates): array
+{
+    $found = [];
+    foreach ($candidates as $candidate) {
+        $col = pickColumn($cols, [$candidate]);
+        if ($col && !in_array($col, $found, true)) {
+            $found[] = $col;
+        }
+    }
+    return $found;
+}
+
+function pickMonthlyColumn(array $cols): ?string
+{
+    foreach (['m', 'monthly', 'month', 'price_monthly', 'monthly_price', 'recurring_monthly'] as $candidate) {
+        $col = pickColumn($cols, [$candidate]);
+        if ($col && isNumericType($cols[$col]['Type'])) {
+            return $col;
+        }
+    }
+    foreach ($cols as $name => $meta) {
+        if (stripos($name, 'month') !== false && isNumericType($meta['Type'])) {
+            return $name;
+        }
+    }
+    return null;
+}
+
+function pickProductCategoryColumn(array $cols): ?string
+{
+    foreach (['category_id', 'cat_id', 'categoryid', 'orderpage_id', 'cid', 'group_id'] as $candidate) {
+        $col = pickColumn($cols, [$candidate]);
+        if ($col && isNumericType($cols[$col]['Type'])) {
+            return $col;
+        }
+    }
+    $col = pickColumn($cols, ['category']);
+    if ($col && isNumericType($cols[$col]['Type'])) {
+        return $col;
+    }
+    return null;
+}
+
+function isNumericType(string $type): bool
+{
+    return (bool)preg_match('/\b(tinyint|smallint|mediumint|int|bigint|decimal|numeric|float|double|real)\b/i', $type);
+}
+
+function isStringType(string $type): bool
+{
+    return (bool)preg_match('/\b(char|varchar|text|enum|set)\b/i', $type);
+}
+
+function disabledPeriodValue($templateValue)
+{
+    if (is_numeric($templateValue) && (float)$templateValue < 0) {
+        return $templateValue;
+    }
+    return '-1';
+}
+
+function describeRow(array $row, string $idCol, ?string $nameCol): string
+{
+    $parts = ['ID ' . ($row[$idCol] ?? '?')];
+    if ($nameCol && isset($row[$nameCol])) {
+        $parts[] = $nameCol . '=' . printableValue($row[$nameCol]);
+    }
+    return implode(', ', $parts);
+}
+
+function printableValue($value): string
+{
+    if ($value === null) {
+        return 'NULL';
+    }
+    $str = (string)$value;
+    if (strlen($str) > 90) {
+        $str = substr($str, 0, 87) . '...';
+    }
+    return "'" . $str . "'";
+}
+
+function displayId($id, string $fallback = '<wird von AUTO_INCREMENT vergeben>'): string
+{
+    return ($id === null || $id === '') ? $fallback : (string)$id;
+}
+
+function slugify(string $value): string
+{
+    $value = str_replace(['Ä', 'Ö', 'Ü', 'ä', 'ö', 'ü', 'ß'], ['Ae', 'Oe', 'Ue', 'ae', 'oe', 'ue', 'ss'], $value);
+    $value = strtolower($value);
+    $value = preg_replace('/[^a-z0-9]+/', '-', $value) ?? $value;
+    $value = trim($value, '-');
+    return $value !== '' ? $value : 'kiwz-produkt';
+}
+
+function qi(string $identifier): string
+{
+    if (!preg_match('/^[A-Za-z0-9_]+$/', $identifier)) {
+        throw new RuntimeException("Unsicherer SQL-Identifier: {$identifier}");
+    }
+    return '`' . $identifier . '`';
+}
+
+function out(string $line): void
+{
+    echo $line . PHP_EOL;
+}
diff --git a/kiwz_prio_codes.txt b/kiwz_prio_codes.txt
new file mode 100644
index 0000000000000000000000000000000000000000..4acd9c408d2fbad263e01ddab541742f0709b92b
--- /dev/null
+++ b/kiwz_prio_codes.txt
@@ -0,0 +1,30 @@
+KIWZ-X7QA-H4NZ
+KIWZ-BM6T-9VDK
+KIWZ-3LPR-W8CY
+KIWZ-F2ZH-QN5A
+KIWZ-7GVM-KX4T
+KIWZ-N9QS-2JWR
+KIWZ-H8DC-V6PY
+KIWZ-4TFK-ZM7B
+KIWZ-Q2WX-5ANR
+KIWZ-Y6PJ-C9LT
+KIWZ-8KDA-RV3M
+KIWZ-Z5HN-WQ7F
+KIWZ-2VTC-JK9P
+KIWZ-M4RY-8XGB
+KIWZ-6QNL-FZ2D
+KIWZ-CP7W-3HKA
+KIWZ-J9XB-T5VQ
+KIWZ-W2MG-6RZN
+KIWZ-5DFY-P8KC
+KIWZ-A7VT-NQ4H
+KIWZ-R3KZ-Y6PM
+KIWZ-L8QC-2WJF
+KIWZ-9NBH-X5TA
+KIWZ-V4PR-7ZDG
+KIWZ-T6JW-M2QC
+KIWZ-G5KA-9VYR
+KIWZ-2XND-H8FP
+KIWZ-P7ZM-C4WT
+KIWZ-Q9RY-6BKL
+KIWZ-F3VC-J5XN

codex
Ich lasse die Syntaxprüfung nach der kleinen Änderung noch einmal laufen.
exec
/bin/bash -lc 'php -l kiwz_hostbill_setup.php' in /tmp/kiwz-hostbill-staging
 succeeded in 0ms:
No syntax errors detected in kiwz_hostbill_setup.php

exec
/bin/bash -lc "awk 'BEGIN{ok=1} "'!/''^KIWZ-[ABCDEFGHJKLMNPQRSTUVWXYZ2-9]{4}-[ABCDEFGHJKLMNPQRSTUVWXYZ2-9]{4}$/ {print "bad", NR, $0; ok=0} END{print NR " codes"; exit(ok?0:1)}'"' kiwz_prio_codes.txt" in /tmp/kiwz-hostbill-staging
 succeeded in 0ms:
30 codes

exec
/bin/bash -lc 'php -l kiwz_gpuq_config_snippet.php' in /tmp/kiwz-hostbill-staging
 succeeded in 0ms:
No syntax errors detected in kiwz_gpuq_config_snippet.php

diff --git a/ANLEITUNG.md b/ANLEITUNG.md
new file mode 100644
index 0000000000000000000000000000000000000000..855de0955bb7085d75847133170994b159e380e3
--- /dev/null
+++ b/ANLEITUNG.md
@@ -0,0 +1,51 @@
+# HostBill-Setup für kiwerkzeuge.de
+
+## Skript ausführen
+
+Das Skript ist standardmäßig ein Dry-Run und schreibt dabei nichts in die Datenbank:
+
+```bash
+php kiwz_hostbill_setup.php
+```
+
+Wenn Schema, Template-Zeilen, Backups und geplante Inserts plausibel aussehen, ausführen mit:
+
+```bash
+php kiwz_hostbill_setup.php --apply
+```
+
+Optional kann ein abweichender HostBill-Pfad oder eine konkrete Config-Datei angegeben werden:
+
+```bash
+php kiwz_hostbill_setup.php --hostbill-root=/home/support.panomity.com/public_html
+php kiwz_hostbill_setup.php --config=/home/support.panomity.com/public_html/includes/config.php
+```
+
+Vor den Inserts erstellt das Skript Backup-Tabellen per `CREATE TABLE ... LIKE` und `INSERT INTO ... SELECT`, z. B. `hb_products_bak_<timestamp>`. Das Skript löscht oder aktualisiert keine bestehenden Zeilen.
+
+## Manuelle Alternative im HostBill-Admin
+
+Falls die Einrichtung lieber per Klick gemacht wird:
+
+1. `padm` öffnen und zu `Settings -> Products & Services` gehen.
+2. Neue Orderpage/Kategorie `KI Werkzeuge` anlegen, sichtbar schalten und als Slug z. B. `ki-werkzeuge` setzen.
+3. Drei monatlich wiederkehrende EUR-Produkte in dieser Orderpage anlegen:
+   - `KI Starter`, 4.90 EUR/Monat, `Priority-Rendering für Bildgenerator & Tiefenkarte, E-Mail-Support`
+   - `KI Pro`, 14.90 EUR/Monat, `Priority für alle Werkzeuge inkl. Musik & Video, LoRA-Training bevorzugt`
+   - `KI Business`, 39.90 EUR/Monat, `Höchste Priorität, alle Werkzeuge, Support mit 24h-Reaktionszeit`
+
+Danach Zahlungsmodul-Zuordnung, Steuersatz und Sichtbarkeit im Warenkorb prüfen.
+
+## Willkommens-E-Mail mit Priority-Code
+
+Für jedes Produkt sollte eine Willkommens-E-Mail bzw. Produktaktivierungs-Mail eingerichtet werden. Der Inhalt sollte dem Kunden genau einen freien Code aus `kiwz_prio_codes.txt` mitteilen, z. B.:
+
+```text
+Ihr Priority-Code für kiwerkzeuge.de lautet: KIWZ-XXXX-XXXX
+```
+
+Wichtig: Codes müssen nach Ausgabe als vergeben markiert werden, damit derselbe Code nicht mehrfach an Kunden geht. Für den ersten Batch kann das Snippet `kiwz_gpuq_config_snippet.php` genutzt werden; weitere Codes werden dort im Array `'priority_codes' => [...]` ergänzt.
+
+## Multibrand-Hinweis
+
+Das Multibrand-Modul ist installiert. Für eine eigene Brand `kiwerkzeuge.de` wären zusätzlich zur Produktanlage diese Schritte nötig: Brand in `hb_brands` bzw. über den HostBill-Admin anlegen, Domain/Portal-URL und Branding in `hb_brand_config` konfigurieren, Template/Logo/E-Mail-Absender setzen, Zahlungs- und Steuerregeln der Brand zuordnen und die neue Orderpage/Produkte dieser Brand sichtbar zuweisen. Diese Anleitung dokumentiert das nur; das Setup-Skript legt keine neue Brand an.
diff --git a/kiwz_gpuq_config_snippet.php b/kiwz_gpuq_config_snippet.php
new file mode 100644
index 0000000000000000000000000000000000000000..c089e9c811a945dc3d3da6515b6c7d714f4dfd66
--- /dev/null
+++ b/kiwz_gpuq_config_snippet.php
@@ -0,0 +1,19 @@
+<?php
+
+// Beispiel-Batch für die ersten Kunden. Weitere Codes aus kiwz_prio_codes.txt
+// nach gleichem Muster ergänzen, z. B. 'KIWZ-XXXX-XXXX' => 'high'.
+$gpuqConfig = [
+    'priority_codes' => [
+        'KIWZ-X7QA-H4NZ' => 'high',
+        'KIWZ-BM6T-9VDK' => 'high',
+        'KIWZ-3LPR-W8CY' => 'high',
+        'KIWZ-F2ZH-QN5A' => 'high',
+        'KIWZ-7GVM-KX4T' => 'high',
+        'KIWZ-N9QS-2JWR' => 'high',
+        'KIWZ-H8DC-V6PY' => 'high',
+        'KIWZ-4TFK-ZM7B' => 'high',
+        'KIWZ-Q2WX-5ANR' => 'high',
+        'KIWZ-Y6PJ-C9LT' => 'high',
+    ],
+];
+
diff --git a/kiwz_hostbill_setup.php b/kiwz_hostbill_setup.php
new file mode 100644
index 0000000000000000000000000000000000000000..0d3d1b036cc1df8f965ac381c8f328b8344051ee
--- /dev/null
+++ b/kiwz_hostbill_setup.php
@@ -0,0 +1,767 @@
+#!/usr/bin/env php
+<?php
+declare(strict_types=1);
+
+/**
+ * HostBill setup helper for kiwerkzeuge.de.
+ *
+ * Default mode is dry-run. The script intentionally discovers the HostBill
+ * schema at runtime and copies visible template rows instead of assuming an
+ * exact HostBill table layout.
+ */
+
+const HOSTBILL_DEFAULT_ROOT = '/home/support.panomity.com/public_html';
+const PORTAL_BASE_URL = 'https://support.panomity.com';
+const CATEGORY_NAME = 'KI Werkzeuge';
+const CATEGORY_SLUG = 'ki-werkzeuge';
+const CATEGORY_DESCRIPTION = 'Deutschsprachige KI-Tool-Suite mit Bildgenerator, Musik, Video und Tiefenkarte.';
+
+$PRODUCTS = [
+    [
+        'name' => 'KI Starter',
+        'price' => '4.90',
+        'description' => 'Priority-Rendering für Bildgenerator & Tiefenkarte, E-Mail-Support',
+    ],
+    [
+        'name' => 'KI Pro',
+        'price' => '14.90',
+        'description' => 'Priority für alle Werkzeuge inkl. Musik & Video, LoRA-Training bevorzugt',
+    ],
+    [
+        'name' => 'KI Business',
+        'price' => '39.90',
+        'description' => 'Höchste Priorität, alle Werkzeuge, Support mit 24h-Reaktionszeit',
+    ],
+];
+
+main($PRODUCTS);
+
+function main(array $products): void
+{
+    if (PHP_SAPI !== 'cli') {
+        fwrite(STDERR, "Dieses Skript muss mit php-cli ausgeführt werden.\n");
+        exit(1);
+    }
+
+    $opts = getopt('', ['apply', 'help', 'hostbill-root:', 'config:']);
+    if (isset($opts['help'])) {
+        printHelp();
+        return;
+    }
+
+    $dryRun = !isset($opts['apply']);
+    $hostbillRoot = rtrim((string)($opts['hostbill-root'] ?? HOSTBILL_DEFAULT_ROOT), '/');
+    $configFile = (string)($opts['config'] ?? ($hostbillRoot . '/includes/config.php'));
+
+    out('HostBill kiwerkzeuge.de Setup');
+    out('Modus: ' . ($dryRun ? 'DRY-RUN (keine Schreibzugriffe)' : 'APPLY (schreibt in die Datenbank)'));
+    out('Config: ' . $configFile);
+    out('');
+
+    $pdo = connectFromHostbillConfig($configFile);
+    $backupTables = [];
+    $transactionStarted = false;
+
+    try {
+        requireTable($pdo, 'hb_categories');
+        requireTable($pdo, 'hb_products');
+
+        $categoryCols = columns($pdo, 'hb_categories');
+        $productCols = columns($pdo, 'hb_products');
+
+        printSchema('hb_categories', $categoryCols);
+        printSchema('hb_products', $productCols);
+
+        $categoryPlan = buildCategoryPlan($pdo, $categoryCols);
+        out('Kategorie-Vorlage: ' . describeRow($categoryPlan['template'], $categoryPlan['id_col'], $categoryPlan['name_col']));
+
+        $productPlan = buildProductPlan($pdo, $productCols, $categoryPlan['id_col']);
+        out('Produkt-Vorlage: ' . describeRow($productPlan['template'], $productPlan['id_col'], $productPlan['name_col']));
+        out('Monatspreisspalte: ' . $productPlan['monthly_col']);
+        out('Deaktivierte Zahlweisen: ' . ($productPlan['disabled_period_cols'] ? implode(', ', $productPlan['disabled_period_cols']) : '(keine weiteren Periodenspalten erkannt)'));
+        out('Kategorie-Verknüpfung: ' . ($productPlan['category_col'] ? "hb_products.{$productPlan['category_col']}" : relationDescription($productPlan['relation'])));
+        out('');
+
+        $affectedTables = ['hb_categories', 'hb_products'];
+        if ($productPlan['relation']) {
+            $affectedTables[] = $productPlan['relation']['table'];
+            printSchema($productPlan['relation']['table'], columns($pdo, $productPlan['relation']['table']));
+        }
+
+        $timestamp = time();
+        foreach (array_values(array_unique($affectedTables)) as $table) {
+            $backupName = uniqueBackupName($pdo, $table, $timestamp);
+            $backupTables[$table] = $backupName;
+            out(($dryRun ? 'Würde Backup erstellen: ' : 'Erstelle Backup: ') . "{$backupName} aus {$table}");
+            if (!$dryRun) {
+                createBackup($pdo, $table, $backupName);
+            }
+        }
+        out('');
+
+        if (!$dryRun) {
+            $pdo->beginTransaction();
+            $transactionStarted = true;
+        }
+
+        $category = ensureCategory($pdo, $categoryPlan, $dryRun);
+        $categoryId = $category['id'];
+        $categorySlug = $category['slug'];
+
+        $productResults = [];
+        foreach ($products as $product) {
+            $productResults[] = ensureProduct($pdo, $productPlan, $categoryId, $product, $dryRun);
+        }
+
+        if (!$dryRun) {
+            $pdo->commit();
+            $transactionStarted = false;
+        }
+
+        out('');
+        out('Ergebnis');
+        out('Kategorie-ID: ' . displayId($categoryId));
+        foreach ($productResults as $result) {
+            out(sprintf('Produkt: %s -> ID %s (%s)', $result['name'], displayId($result['id']), $result['status']));
+        }
+        out('');
+        out('Bestell-URLs');
+        foreach ($productResults as $result) {
+            $id = displayId($result['id'], '<PID>');
+            out($result['name'] . ': ' . PORTAL_BASE_URL . '/?cmd=cart&action=add&id=' . $id);
+        }
+        if ($categorySlug !== null && $categorySlug !== '') {
+            out('Orderpage: ' . PORTAL_BASE_URL . '/cart/' . $categorySlug);
+        } else {
+            out('Orderpage: /cart/<kategorie-slug> nicht ausgebbar, weil keine Slug-Spalte erkannt wurde.');
+        }
+        out('');
+        out('Manuelle Admin-Schritte bleiben: Zahlungsmodul-Zuordnung prüfen, Steuersatz prüfen, Willkommens-E-Mail je Produkt mit Priority-Code konfigurieren.');
+        out('Rollback-Hinweis: Für einen manuellen Restore wurden diese Backup-Tabellen vorbereitet/angezeigt:');
+        foreach ($backupTables as $table => $backupName) {
+            out("  {$table} -> {$backupName}");
+        }
+        out('Dieses Skript führt kein automatisches --rollback aus, damit bestehende Daten nicht unbeabsichtigt gelöscht oder überschrieben werden.');
+    } catch (Throwable $e) {
+        if ($transactionStarted && $pdo->inTransaction()) {
+            $pdo->rollBack();
+        }
+        fwrite(STDERR, "\nABBRUCH: " . $e->getMessage() . "\n");
+        if ($backupTables) {
+            fwrite(STDERR, "Backup-Tabellen für manuelle Prüfung/Restore:\n");
+            foreach ($backupTables as $table => $backupName) {
+                fwrite(STDERR, "  {$table} -> {$backupName}\n");
+            }
+        }
+        exit(1);
+    }
+}
+
+function printHelp(): void
+{
+    out("Nutzung:");
+    out("  php kiwz_hostbill_setup.php");
+    out("  php kiwz_hostbill_setup.php --apply");
+    out("  php kiwz_hostbill_setup.php --hostbill-root=/home/support.panomity.com/public_html");
+    out("  php kiwz_hostbill_setup.php --config=/pfad/zu/includes/config.php");
+}
+
+function connectFromHostbillConfig(string $configFile): PDO
+{
+    if (!is_file($configFile) || !is_readable($configFile)) {
+        throw new RuntimeException("HostBill config.php nicht lesbar: {$configFile}");
+    }
+
+    $db_host = $db_name = $db_user = $db_pass = null;
+    $db_port = null;
+    require $configFile;
+
+    foreach (['db_host', 'db_name', 'db_user', 'db_pass'] as $var) {
+        if (!isset($$var) || $$var === '') {
+            throw new RuntimeException("HostBill config.php definiert \${$var} nicht.");
+        }
+    }
+
+    $dsn = 'mysql:host=' . $db_host . ';dbname=' . $db_name . ';charset=utf8mb4';
+    if (!empty($db_port)) {
+        $dsn .= ';port=' . (int)$db_port;
+    }
+
+    return new PDO($dsn, (string)$db_user, (string)$db_pass, [
+        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
+        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+        PDO::ATTR_EMULATE_PREPARES => false,
+    ]);
+}
+
+function requireTable(PDO $pdo, string $table): void
+{
+    $stmt = $pdo->prepare('SHOW TABLES LIKE ?');
+    $stmt->execute([$table]);
+    if (!$stmt->fetchColumn()) {
+        throw new RuntimeException("Erwartete Tabelle fehlt: {$table}");
+    }
+}
+
+function tableExists(PDO $pdo, string $table): bool
+{
+    $stmt = $pdo->prepare('SHOW TABLES LIKE ?');
+    $stmt->execute([$table]);
+    return (bool)$stmt->fetchColumn();
+}
+
+function columns(PDO $pdo, string $table): array
+{
+    $rows = $pdo->query('SHOW COLUMNS FROM ' . qi($table))->fetchAll();
+    if (!$rows) {
+        throw new RuntimeException("Keine Spalten gefunden in {$table}");
+    }
+    $out = [];
+    foreach ($rows as $row) {
+        $out[$row['Field']] = $row;
+    }
+    return $out;
+}
+
+function printSchema(string $table, array $cols): void
+{
+    out("Schema {$table}:");
+    foreach ($cols as $name => $meta) {
+        $extra = trim(($meta['Key'] ? ' key=' . $meta['Key'] : '') . ($meta['Extra'] ? ' extra=' . $meta['Extra'] : ''));
+        out('  - ' . $name . ' ' . $meta['Type'] . ($extra ? ' (' . $extra . ')' : ''));
+    }
+    out('');
+}
+
+function buildCategoryPlan(PDO $pdo, array $cols): array
+{
+    $idCol = autoIncrementColumn($cols) ?? pickColumn($cols, ['id', 'category_id', 'cat_id']);
+    $nameCol = pickColumn($cols, ['name', 'title', 'category', 'catname', 'label']);
+    $descCol = pickColumn($cols, ['description', 'descr', 'details', 'content', 'text', 'info']);
+    $slugCol = pickColumn($cols, ['slug', 'url', 'sefurl', 'sef_url', 'urlname', 'url_name']);
+
+    if (!$idCol || !$nameCol) {
+        throw new RuntimeException('hb_categories: ID- oder Name-Spalte nicht sicher erkannt.');
+    }
+
+    $template = selectTemplateRow($pdo, 'hb_categories', $cols, $idCol);
+
+    return [
+        'table' => 'hb_categories',
+        'cols' => $cols,
+        'id_col' => $idCol,
+        'name_col' => $nameCol,
+        'desc_col' => $descCol,
+        'slug_col' => $slugCol,
+        'template' => $template,
+    ];
+}
+
+function buildProductPlan(PDO $pdo, array $cols, string $categoryIdCol): array
+{
+    $idCol = autoIncrementColumn($cols) ?? pickColumn($cols, ['id', 'product_id', 'pid']);
+    $nameCol = pickColumn($cols, ['name', 'title', 'product_name', 'label']);
+    $descCol = pickColumn($cols, ['description', 'descr', 'details', 'content', 'text', 'info', 'short_description', 'shortdesc']);
+    $slugCol = pickColumn($cols, ['slug', 'url', 'sefurl', 'sef_url', 'urlname', 'url_name']);
+    $monthlyCol = pickMonthlyColumn($cols);
+    $categoryCol = pickProductCategoryColumn($cols);
+    $relation = null;
+
+    if (!$idCol || !$nameCol || !$descCol || !$monthlyCol) {
+        throw new RuntimeException('hb_products: ID-, Name-, Beschreibungs- oder Monatspreis-Spalte nicht sicher erkannt.');
+    }
+
+    if (!$categoryCol) {
+        $relation = discoverProductCategoryRelation($pdo, $idCol, $categoryIdCol);
+        if (!$relation) {
+            throw new RuntimeException('hb_products: Keine Kategorie-Spalte und keine Produkt/Kategorie-Relation sicher erkannt.');
+        }
+    }
+
+    $template = selectTemplateRow($pdo, 'hb_products', $cols, $idCol);
+
+    $periodCols = pickExistingColumns($cols, ['m', 'monthly', 'month', 'price_monthly', 'monthly_price', 'q', 'quarterly', 's', 'semiannually', 'semiannual', 'a', 'annually', 'annual', 'b', 'biennially', 'biennial', 't', 'triennially', 'triennial']);
+    $disabledPeriodCols = array_values(array_diff($periodCols, [$monthlyCol]));
+
+    return [
+        'table' => 'hb_products',
+        'cols' => $cols,
+        'id_col' => $idCol,
+        'name_col' => $nameCol,
+        'desc_col' => $descCol,
+        'slug_col' => $slugCol,
+        'monthly_col' => $monthlyCol,
+        'disabled_period_cols' => $disabledPeriodCols,
+        'category_col' => $categoryCol,
+        'relation' => $relation,
+        'template' => $template,
+    ];
+}
+
+function ensureCategory(PDO $pdo, array $plan, bool $dryRun): array
+{
+    $existing = findCategory($pdo, $plan, CATEGORY_NAME);
+    if ($existing) {
+        out('Kategorie existiert bereits, keine Änderung: ID ' . $existing[$plan['id_col']]);
+        return [
+            'id' => $existing[$plan['id_col']],
+            'slug' => $plan['slug_col'] ? (string)($existing[$plan['slug_col']] ?? CATEGORY_SLUG) : null,
+        ];
+    }
+
+    $overrides = [
+        $plan['name_col'] => CATEGORY_NAME,
+    ];
+    if ($plan['desc_col']) {
+        $overrides[$plan['desc_col']] = CATEGORY_DESCRIPTION;
+    }
+    if ($plan['slug_col']) {
+        $overrides[$plan['slug_col']] = CATEGORY_SLUG;
+    }
+    applyVisibilityOverrides($plan['cols'], $overrides);
+
+    out(($dryRun ? 'Würde Kategorie anlegen: ' : 'Lege Kategorie an: ') . CATEGORY_NAME);
+    $id = insertFromTemplate($pdo, $plan['table'], $plan['cols'], $plan['template'], $overrides, $dryRun);
+
+    return [
+        'id' => $dryRun ? null : $id,
+        'slug' => $plan['slug_col'] ? CATEGORY_SLUG : null,
+    ];
+}
+
+function ensureProduct(PDO $pdo, array $plan, $categoryId, array $product, bool $dryRun): array
+{
+    if (!$dryRun && ($categoryId === null || $categoryId === '')) {
+        throw new RuntimeException('Kategorie-ID fehlt für Produktanlage.');
+    }
+
+    $existing = $categoryId === null ? null : findProduct($pdo, $plan, $categoryId, $product['name']);
+    if ($existing) {
+        out('Produkt existiert bereits, keine Änderung: ' . $product['name'] . ' (ID ' . $existing[$plan['id_col']] . ')');
+        return [
+            'name' => $product['name'],
+            'id' => $existing[$plan['id_col']],
+            'status' => 'vorhanden',
+        ];
+    }
+
+    $overrides = [
+        $plan['name_col'] => $product['name'],
+        $plan['desc_col'] => $product['description'],
+        $plan['monthly_col'] => $product['price'],
+    ];
+    if ($plan['slug_col']) {
+        $overrides[$plan['slug_col']] = slugify($product['name']);
+    }
+    if ($plan['category_col']) {
+        $overrides[$plan['category_col']] = $dryRun ? '<neue Kategorie-ID>' : $categoryId;
+    }
+    foreach ($plan['disabled_period_cols'] as $col) {
+        $overrides[$col] = disabledPeriodValue($plan['template'][$col] ?? null);
+    }
+    foreach (pickExistingColumns($plan['cols'], ['paytype', 'pay_type', 'p_type', 'billingtype', 'billing_type']) as $col) {
+        $overrides[$col] = recurringValueForColumn($plan['cols'][$col]);
+    }
+    foreach (pickExistingColumns($plan['cols'], ['currency', 'curr']) as $col) {
+        if (isStringType($plan['cols'][$col]['Type'])) {
+            $overrides[$col] = 'EUR';
+        }
+    }
+    applyVisibilityOverrides($plan['cols'], $overrides);
+
+    out(($dryRun ? 'Würde Produkt anlegen: ' : 'Lege Produkt an: ') . $product['name'] . ' für ' . $product['price'] . ' EUR/Monat');
+    $id = insertFromTemplate($pdo, $plan['table'], $plan['cols'], $plan['template'], $overrides, $dryRun);
+
+    if ($plan['relation']) {
+        out(($dryRun ? 'Würde Produkt/Kategorie-Relation anlegen: ' : 'Lege Produkt/Kategorie-Relation an: ') . relationDescription($plan['relation']));
+        if (!$dryRun) {
+            ensureRelation($pdo, $plan['relation'], $id, $categoryId);
+        }
+    }
+
+    return [
+        'name' => $product['name'],
+        'id' => $dryRun ? null : $id,
+        'status' => $dryRun ? 'dry-run' : 'neu',
+    ];
+}
+
+function findCategory(PDO $pdo, array $plan, string $name): ?array
+{
+    $sql = 'SELECT * FROM ' . qi($plan['table']) . ' WHERE ' . qi($plan['name_col']) . ' = ? LIMIT 1';
+    $stmt = $pdo->prepare($sql);
+    $stmt->execute([$name]);
+    $row = $stmt->fetch();
+    return $row ?: null;
+}
+
+function findProduct(PDO $pdo, array $plan, $categoryId, string $name): ?array
+{
+    if ($plan['category_col']) {
+        $sql = 'SELECT * FROM ' . qi($plan['table']) . ' WHERE ' . qi($plan['name_col']) . ' = ? AND ' . qi($plan['category_col']) . ' = ? LIMIT 1';
+        $stmt = $pdo->prepare($sql);
+        $stmt->execute([$name, $categoryId]);
+        $row = $stmt->fetch();
+        return $row ?: null;
+    }
+
+    $rel = $plan['relation'];
+    $sql = 'SELECT p.* FROM ' . qi($plan['table']) . ' p '
+        . 'INNER JOIN ' . qi($rel['table']) . ' r ON p.' . qi($plan['id_col']) . ' = r.' . qi($rel['product_col']) . ' '
+        . 'WHERE p.' . qi($plan['name_col']) . ' = ? AND r.' . qi($rel['category_col']) . ' = ? LIMIT 1';
+    $stmt = $pdo->prepare($sql);
+    $stmt->execute([$name, $categoryId]);
+    $row = $stmt->fetch();
+    return $row ?: null;
+}
+
+function insertFromTemplate(PDO $pdo, string $table, array $cols, array $template, array $overrides, bool $dryRun)
+{
+    $data = [];
+    foreach ($cols as $name => $meta) {
+        if (!isInsertableColumn($meta)) {
+            continue;
+        }
+        $data[$name] = array_key_exists($name, $overrides) ? $overrides[$name] : ($template[$name] ?? null);
+    }
+
+    out('  Overrides:');
+    foreach ($overrides as $col => $value) {
+        out('    ' . $col . ' = ' . printableValue($value));
+    }
+
+    if ($dryRun) {
+        return null;
+    }
+
+    $fields = array_keys($data);
+    $placeholders = [];
+    $values = [];
+    foreach ($fields as $i => $field) {
+        $placeholder = ':v' . $i;
+        $placeholders[] = $placeholder;
+        $values[$placeholder] = $data[$field];
+    }
+    $sql = 'INSERT INTO ' . qi($table) . ' (' . implode(', ', array_map('qi', $fields)) . ') VALUES (' . implode(', ', $placeholders) . ')';
+    $stmt = $pdo->prepare($sql);
+    $stmt->execute($values);
+    return $pdo->lastInsertId();
+}
+
+function ensureRelation(PDO $pdo, array $rel, $productId, $categoryId): void
+{
+    $check = $pdo->prepare('SELECT 1 FROM ' . qi($rel['table']) . ' WHERE ' . qi($rel['product_col']) . ' = ? AND ' . qi($rel['category_col']) . ' = ? LIMIT 1');
+    $check->execute([$productId, $categoryId]);
+    if ($check->fetchColumn()) {
+        return;
+    }
+
+    $cols = columns($pdo, $rel['table']);
+    $template = selectAnyRow($pdo, $rel['table']);
+    $overrides = [
+        $rel['product_col'] => $productId,
+        $rel['category_col'] => $categoryId,
+    ];
+
+    if ($template) {
+        insertFromTemplate($pdo, $rel['table'], $cols, $template, $overrides, false);
+        return;
+    }
+
+    $fields = array_keys($overrides);
+    foreach ($cols as $name => $meta) {
+        if (isset($overrides[$name]) || !isInsertableColumn($meta)) {
+            continue;
+        }
+        if (strtoupper((string)$meta['Null']) === 'NO' && $meta['Default'] === null) {
+            throw new RuntimeException("Relation {$rel['table']} hat Pflichtspalte {$name}; keine Template-Zeile vorhanden.");
+        }
+    }
+    $sql = 'INSERT INTO ' . qi($rel['table']) . ' (' . implode(', ', array_map('qi', $fields)) . ') VALUES (?, ?)';
+    $stmt = $pdo->prepare($sql);
+    $stmt->execute([$productId, $categoryId]);
+}
+
+function selectTemplateRow(PDO $pdo, string $table, array $cols, string $idCol): array
+{
+    [$where, $note] = visibilityWhere($cols);
+    if ($note) {
+        out("Hinweis {$table}: {$note}");
+    }
+    $sql = 'SELECT * FROM ' . qi($table) . ' WHERE ' . $where . ' ORDER BY ' . qi($idCol) . ' ASC LIMIT 1';
+    $row = $pdo->query($sql)->fetch();
+    if (!$row) {
+        throw new RuntimeException("Keine sichtbare Template-Zeile in {$table} gefunden.");
+    }
+    return $row;
+}
+
+function selectAnyRow(PDO $pdo, string $table): ?array
+{
+    $row = $pdo->query('SELECT * FROM ' . qi($table) . ' LIMIT 1')->fetch();
+    return $row ?: null;
+}
+
+function visibilityWhere(array $cols): array
+{
+    $clauses = [];
+    foreach (['visible', 'is_visible', 'show', 'enabled', 'active', 'is_active'] as $col) {
+        if (isset($cols[$col])) {
+            $clauses[] = qi($col) . " IN (1, '1', 'yes', 'Yes', 'true', 'True', 'on', 'On')";
+        }
+    }
+    foreach (['hidden', 'is_hidden', 'disabled', 'is_disabled', 'deleted', 'archived'] as $col) {
+        if (isset($cols[$col])) {
+            $clauses[] = qi($col) . " IN (0, '0', 'no', 'No', 'false', 'False', '', 'off', 'Off')";
+        }
+    }
+    foreach (['status', 'state'] as $col) {
+        if (isset($cols[$col])) {
+            $clauses[] = qi($col) . " IN ('Active', 'active', 'Enabled', 'enabled', 'Visible', 'visible', '1', 1)";
+        }
+    }
+
+    if (!$clauses) {
+        return ['1=1', 'keine bekannte Sichtbarkeitsspalte erkannt; nutze erste vorhandene Zeile als Vorlage.'];
+    }
+    return [implode(' AND ', $clauses), null];
+}
+
+function applyVisibilityOverrides(array $cols, array &$overrides): void
+{
+    foreach (['visible', 'is_visible', 'show', 'enabled', 'active', 'is_active'] as $col) {
+        if (isset($cols[$col])) {
+            $overrides[$col] = 1;
+        }
+    }
+    foreach (['hidden', 'is_hidden', 'disabled', 'is_disabled', 'deleted', 'archived'] as $col) {
+        if (isset($cols[$col])) {
+            $overrides[$col] = 0;
+        }
+    }
+    foreach (['status', 'state'] as $col) {
+        if (isset($cols[$col])) {
+            $overrides[$col] = statusActiveValue($cols[$col]);
+        }
+    }
+}
+
+function statusActiveValue(array $meta): string
+{
+    $type = (string)$meta['Type'];
+    if (preg_match_all("/'((?:[^'\\\\]|\\\\.)*)'/", $type, $matches)) {
+        foreach ($matches[1] as $value) {
+            if (in_array(strtolower($value), ['active', 'enabled', 'visible'], true)) {
+                return $value;
+            }
+        }
+        if (in_array('1', $matches[1], true)) {
+            return '1';
+        }
+    }
+    return 'Active';
+}
+
+function recurringValueForColumn(array $meta): string
+{
+    $type = (string)$meta['Type'];
+    if (preg_match_all("/'((?:[^'\\\\]|\\\\.)*)'/", $type, $matches)) {
+        foreach ($matches[1] as $value) {
+            if (stripos($value, 'recurr') !== false) {
+                return $value;
+            }
+        }
+    }
+    return 'Recurring';
+}
+
+function discoverProductCategoryRelation(PDO $pdo, string $productIdCol, string $categoryIdCol): ?array
+{
+    $tables = $pdo->query('SHOW TABLES')->fetchAll(PDO::FETCH_COLUMN);
+    foreach ($tables as $table) {
+        $lower = strtolower((string)$table);
+        if (!preg_match('/(product.*categor|categor.*product|product.*orderpage|orderpage.*product)/', $lower)) {
+            continue;
+        }
+        $cols = columns($pdo, (string)$table);
+        $productCol = pickColumn($cols, ['product_id', 'productid', 'prod_id', 'pid', $productIdCol]);
+        $categoryCol = pickColumn($cols, ['category_id', 'cat_id', 'categoryid', 'cid', 'orderpage_id', $categoryIdCol]);
+        if ($productCol && $categoryCol && $productCol !== $categoryCol) {
+            return [
+                'table' => (string)$table,
+                'product_col' => $productCol,
+                'category_col' => $categoryCol,
+            ];
+        }
+    }
+    return null;
+}
+
+function relationDescription(?array $relation): string
+{
+    if (!$relation) {
+        return '(keine Relation)';
+    }
+    return $relation['table'] . '.' . $relation['product_col'] . ' -> Produkt, ' . $relation['table'] . '.' . $relation['category_col'] . ' -> Kategorie';
+}
+
+function createBackup(PDO $pdo, string $source, string $backup): void
+{
+    $pdo->exec('CREATE TABLE ' . qi($backup) . ' LIKE ' . qi($source));
+    $pdo->exec('INSERT INTO ' . qi($backup) . ' SELECT * FROM ' . qi($source));
+}
+
+function uniqueBackupName(PDO $pdo, string $table, int $timestamp): string
+{
+    $base = $table . '_bak_' . $timestamp;
+    $candidate = $base;
+    $i = 1;
+    while (tableExists($pdo, $candidate)) {
+        $candidate = $base . '_' . $i;
+        $i++;
+    }
+    return $candidate;
+}
+
+function autoIncrementColumn(array $cols): ?string
+{
+    foreach ($cols as $name => $meta) {
+        if (stripos((string)$meta['Extra'], 'auto_increment') !== false) {
+            return $name;
+        }
+    }
+    return null;
+}
+
+function isInsertableColumn(array $meta): bool
+{
+    $extra = strtolower((string)$meta['Extra']);
+    return strpos($extra, 'auto_increment') === false
+        && strpos($extra, 'generated') === false
+        && strpos($extra, 'virtual') === false
+        && strpos($extra, 'stored') === false;
+}
+
+function pickColumn(array $cols, array $candidates): ?string
+{
+    $map = [];
+    foreach (array_keys($cols) as $name) {
+        $map[strtolower($name)] = $name;
+    }
+    foreach ($candidates as $candidate) {
+        $key = strtolower($candidate);
+        if (isset($map[$key])) {
+            return $map[$key];
+        }
+    }
+    return null;
+}
+
+function pickExistingColumns(array $cols, array $candidates): array
+{
+    $found = [];
+    foreach ($candidates as $candidate) {
+        $col = pickColumn($cols, [$candidate]);
+        if ($col && !in_array($col, $found, true)) {
+            $found[] = $col;
+        }
+    }
+    return $found;
+}
+
+function pickMonthlyColumn(array $cols): ?string
+{
+    foreach (['m', 'monthly', 'month', 'price_monthly', 'monthly_price', 'recurring_monthly'] as $candidate) {
+        $col = pickColumn($cols, [$candidate]);
+        if ($col && isNumericType($cols[$col]['Type'])) {
+            return $col;
+        }
+    }
+    foreach ($cols as $name => $meta) {
+        if (stripos($name, 'month') !== false && isNumericType($meta['Type'])) {
+            return $name;
+        }
+    }
+    return null;
+}
+
+function pickProductCategoryColumn(array $cols): ?string
+{
+    foreach (['category_id', 'cat_id', 'categoryid', 'orderpage_id', 'cid', 'group_id'] as $candidate) {
+        $col = pickColumn($cols, [$candidate]);
+        if ($col && isNumericType($cols[$col]['Type'])) {
+            return $col;
+        }
+    }
+    $col = pickColumn($cols, ['category']);
+    if ($col && isNumericType($cols[$col]['Type'])) {
+        return $col;
+    }
+    return null;
+}
+
+function isNumericType(string $type): bool
+{
+    return (bool)preg_match('/\b(tinyint|smallint|mediumint|int|bigint|decimal|numeric|float|double|real)\b/i', $type);
+}
+
+function isStringType(string $type): bool
+{
+    return (bool)preg_match('/\b(char|varchar|text|enum|set)\b/i', $type);
+}
+
+function disabledPeriodValue($templateValue)
+{
+    if (is_numeric($templateValue) && (float)$templateValue < 0) {
+        return $templateValue;
+    }
+    return '-1';
+}
+
+function describeRow(array $row, string $idCol, ?string $nameCol): string
+{
+    $parts = ['ID ' . ($row[$idCol] ?? '?')];
+    if ($nameCol && isset($row[$nameCol])) {
+        $parts[] = $nameCol . '=' . printableValue($row[$nameCol]);
+    }
+    return implode(', ', $parts);
+}
+
+function printableValue($value): string
+{
+    if ($value === null) {
+        return 'NULL';
+    }
+    $str = (string)$value;
+    if (strlen($str) > 90) {
+        $str = substr($str, 0, 87) . '...';
+    }
+    return "'" . $str . "'";
+}
+
+function displayId($id, string $fallback = '<wird von AUTO_INCREMENT vergeben>'): string
+{
+    return ($id === null || $id === '') ? $fallback : (string)$id;
+}
+
+function slugify(string $value): string
+{
+    $value = str_replace(['Ä', 'Ö', 'Ü', 'ä', 'ö', 'ü', 'ß'], ['Ae', 'Oe', 'Ue', 'ae', 'oe', 'ue', 'ss'], $value);
+    $value = strtolower($value);
+    $value = preg_replace('/[^a-z0-9]+/', '-', $value) ?? $value;
+    $value = trim($value, '-');
+    return $value !== '' ? $value : 'kiwz-produkt';
+}
+
+function qi(string $identifier): string
+{
+    if (!preg_match('/^[A-Za-z0-9_]+$/', $identifier)) {
+        throw new RuntimeException("Unsicherer SQL-Identifier: {$identifier}");
+    }
+    return '`' . $identifier . '`';
+}
+
+function out(string $line): void
+{
+    echo $line . PHP_EOL;
+}
diff --git a/kiwz_prio_codes.txt b/kiwz_prio_codes.txt
new file mode 100644
index 0000000000000000000000000000000000000000..4acd9c408d2fbad263e01ddab541742f0709b92b
--- /dev/null
+++ b/kiwz_prio_codes.txt
@@ -0,0 +1,30 @@
+KIWZ-X7QA-H4NZ
+KIWZ-BM6T-9VDK
+KIWZ-3LPR-W8CY
+KIWZ-F2ZH-QN5A
+KIWZ-7GVM-KX4T
+KIWZ-N9QS-2JWR
+KIWZ-H8DC-V6PY
+KIWZ-4TFK-ZM7B
+KIWZ-Q2WX-5ANR
+KIWZ-Y6PJ-C9LT
+KIWZ-8KDA-RV3M
+KIWZ-Z5HN-WQ7F
+KIWZ-2VTC-JK9P
+KIWZ-M4RY-8XGB
+KIWZ-6QNL-FZ2D
+KIWZ-CP7W-3HKA
+KIWZ-J9XB-T5VQ
+KIWZ-W2MG-6RZN
+KIWZ-5DFY-P8KC
+KIWZ-A7VT-NQ4H
+KIWZ-R3KZ-Y6PM
+KIWZ-L8QC-2WJF
+KIWZ-9NBH-X5TA
+KIWZ-V4PR-7ZDG
+KIWZ-T6JW-M2QC
+KIWZ-G5KA-9VYR
+KIWZ-2XND-H8FP
+KIWZ-P7ZM-C4WT
+KIWZ-Q9RY-6BKL
+KIWZ-F3VC-J5XN

codex
Die Syntaxprüfung ist sauber und die 30 Codes passen zum geforderten Zeichensatz. Ich setze das Hauptskript noch ausführbar, damit es direkt als CLI-Skript nutzbar ist.
exec
/bin/bash -lc 'chmod +x kiwz_hostbill_setup.php && ls -l kiwz_hostbill_setup.php kiwz_prio_codes.txt kiwz_gpuq_config_snippet.php ANLEITUNG.md' in /tmp/kiwz-hostbill-staging
 succeeded in 0ms:
-rw-r--r-- 1 root root  2694 Jul 11 07:05 ANLEITUNG.md
-rw-r--r-- 1 root root   569 Jul 11 07:05 kiwz_gpuq_config_snippet.php
-rwxr-xr-x 1 root root 26942 Jul 11 07:06 kiwz_hostbill_setup.php
-rw-r--r-- 1 root root   450 Jul 11 07:05 kiwz_prio_codes.txt

diff --git a/ANLEITUNG.md b/ANLEITUNG.md
new file mode 100644
index 0000000000000000000000000000000000000000..855de0955bb7085d75847133170994b159e380e3
--- /dev/null
+++ b/ANLEITUNG.md
@@ -0,0 +1,51 @@
+# HostBill-Setup für kiwerkzeuge.de
+
+## Skript ausführen
+
+Das Skript ist standardmäßig ein Dry-Run und schreibt dabei nichts in die Datenbank:
+
+```bash
+php kiwz_hostbill_setup.php
+```
+
+Wenn Schema, Template-Zeilen, Backups und geplante Inserts plausibel aussehen, ausführen mit:
+
+```bash
+php kiwz_hostbill_setup.php --apply
+```
+
+Optional kann ein abweichender HostBill-Pfad oder eine konkrete Config-Datei angegeben werden:
+
+```bash
+php kiwz_hostbill_setup.php --hostbill-root=/home/support.panomity.com/public_html
+php kiwz_hostbill_setup.php --config=/home/support.panomity.com/public_html/includes/config.php
+```
+
+Vor den Inserts erstellt das Skript Backup-Tabellen per `CREATE TABLE ... LIKE` und `INSERT INTO ... SELECT`, z. B. `hb_products_bak_<timestamp>`. Das Skript löscht oder aktualisiert keine bestehenden Zeilen.
+
+## Manuelle Alternative im HostBill-Admin
+
+Falls die Einrichtung lieber per Klick gemacht wird:
+
+1. `padm` öffnen und zu `Settings -> Products & Services` gehen.
+2. Neue Orderpage/Kategorie `KI Werkzeuge` anlegen, sichtbar schalten und als Slug z. B. `ki-werkzeuge` setzen.
+3. Drei monatlich wiederkehrende EUR-Produkte in dieser Orderpage anlegen:
+   - `KI Starter`, 4.90 EUR/Monat, `Priority-Rendering für Bildgenerator & Tiefenkarte, E-Mail-Support`
+   - `KI Pro`, 14.90 EUR/Monat, `Priority für alle Werkzeuge inkl. Musik & Video, LoRA-Training bevorzugt`
+   - `KI Business`, 39.90 EUR/Monat, `Höchste Priorität, alle Werkzeuge, Support mit 24h-Reaktionszeit`
+
+Danach Zahlungsmodul-Zuordnung, Steuersatz und Sichtbarkeit im Warenkorb prüfen.
+
+## Willkommens-E-Mail mit Priority-Code
+
+Für jedes Produkt sollte eine Willkommens-E-Mail bzw. Produktaktivierungs-Mail eingerichtet werden. Der Inhalt sollte dem Kunden genau einen freien Code aus `kiwz_prio_codes.txt` mitteilen, z. B.:
+
+```text
+Ihr Priority-Code für kiwerkzeuge.de lautet: KIWZ-XXXX-XXXX
+```
+
+Wichtig: Codes müssen nach Ausgabe als vergeben markiert werden, damit derselbe Code nicht mehrfach an Kunden geht. Für den ersten Batch kann das Snippet `kiwz_gpuq_config_snippet.php` genutzt werden; weitere Codes werden dort im Array `'priority_codes' => [...]` ergänzt.
+
+## Multibrand-Hinweis
+
+Das Multibrand-Modul ist installiert. Für eine eigene Brand `kiwerkzeuge.de` wären zusätzlich zur Produktanlage diese Schritte nötig: Brand in `hb_brands` bzw. über den HostBill-Admin anlegen, Domain/Portal-URL und Branding in `hb_brand_config` konfigurieren, Template/Logo/E-Mail-Absender setzen, Zahlungs- und Steuerregeln der Brand zuordnen und die neue Orderpage/Produkte dieser Brand sichtbar zuweisen. Diese Anleitung dokumentiert das nur; das Setup-Skript legt keine neue Brand an.
diff --git a/kiwz_gpuq_config_snippet.php b/kiwz_gpuq_config_snippet.php
new file mode 100644
index 0000000000000000000000000000000000000000..c089e9c811a945dc3d3da6515b6c7d714f4dfd66
--- /dev/null
+++ b/kiwz_gpuq_config_snippet.php
@@ -0,0 +1,19 @@
+<?php
+
+// Beispiel-Batch für die ersten Kunden. Weitere Codes aus kiwz_prio_codes.txt
+// nach gleichem Muster ergänzen, z. B. 'KIWZ-XXXX-XXXX' => 'high'.
+$gpuqConfig = [
+    'priority_codes' => [
+        'KIWZ-X7QA-H4NZ' => 'high',
+        'KIWZ-BM6T-9VDK' => 'high',
+        'KIWZ-3LPR-W8CY' => 'high',
+        'KIWZ-F2ZH-QN5A' => 'high',
+        'KIWZ-7GVM-KX4T' => 'high',
+        'KIWZ-N9QS-2JWR' => 'high',
+        'KIWZ-H8DC-V6PY' => 'high',
+        'KIWZ-4TFK-ZM7B' => 'high',
+        'KIWZ-Q2WX-5ANR' => 'high',
+        'KIWZ-Y6PJ-C9LT' => 'high',
+    ],
+];
+
diff --git a/kiwz_hostbill_setup.php b/kiwz_hostbill_setup.php
new file mode 100644
index 0000000000000000000000000000000000000000..0d3d1b036cc1df8f965ac381c8f328b8344051ee
--- /dev/null
+++ b/kiwz_hostbill_setup.php
@@ -0,0 +1,767 @@
+#!/usr/bin/env php
+<?php
+declare(strict_types=1);
+
+/**
+ * HostBill setup helper for kiwerkzeuge.de.
+ *
+ * Default mode is dry-run. The script intentionally discovers the HostBill
+ * schema at runtime and copies visible template rows instead of assuming an
+ * exact HostBill table layout.
+ */
+
+const HOSTBILL_DEFAULT_ROOT = '/home/support.panomity.com/public_html';
+const PORTAL_BASE_URL = 'https://support.panomity.com';
+const CATEGORY_NAME = 'KI Werkzeuge';
+const CATEGORY_SLUG = 'ki-werkzeuge';
+const CATEGORY_DESCRIPTION = 'Deutschsprachige KI-Tool-Suite mit Bildgenerator, Musik, Video und Tiefenkarte.';
+
+$PRODUCTS = [
+    [
+        'name' => 'KI Starter',
+        'price' => '4.90',
+        'description' => 'Priority-Rendering für Bildgenerator & Tiefenkarte, E-Mail-Support',
+    ],
+    [
+        'name' => 'KI Pro',
+        'price' => '14.90',
+        'description' => 'Priority für alle Werkzeuge inkl. Musik & Video, LoRA-Training bevorzugt',
+    ],
+    [
+        'name' => 'KI Business',
+        'price' => '39.90',
+        'description' => 'Höchste Priorität, alle Werkzeuge, Support mit 24h-Reaktionszeit',
+    ],
+];
+
+main($PRODUCTS);
+
+function main(array $products): void
+{
+    if (PHP_SAPI !== 'cli') {
+        fwrite(STDERR, "Dieses Skript muss mit php-cli ausgeführt werden.\n");
+        exit(1);
+    }
+
+    $opts = getopt('', ['apply', 'help', 'hostbill-root:', 'config:']);
+    if (isset($opts['help'])) {
+        printHelp();
+        return;
+    }
+
+    $dryRun = !isset($opts['apply']);
+    $hostbillRoot = rtrim((string)($opts['hostbill-root'] ?? HOSTBILL_DEFAULT_ROOT), '/');
+    $configFile = (string)($opts['config'] ?? ($hostbillRoot . '/includes/config.php'));
+
+    out('HostBill kiwerkzeuge.de Setup');
+    out('Modus: ' . ($dryRun ? 'DRY-RUN (keine Schreibzugriffe)' : 'APPLY (schreibt in die Datenbank)'));
+    out('Config: ' . $configFile);
+    out('');
+
+    $pdo = connectFromHostbillConfig($configFile);
+    $backupTables = [];
+    $transactionStarted = false;
+
+    try {
+        requireTable($pdo, 'hb_categories');
+        requireTable($pdo, 'hb_products');
+
+        $categoryCols = columns($pdo, 'hb_categories');
+        $productCols = columns($pdo, 'hb_products');
+
+        printSchema('hb_categories', $categoryCols);
+        printSchema('hb_products', $productCols);
+
+        $categoryPlan = buildCategoryPlan($pdo, $categoryCols);
+        out('Kategorie-Vorlage: ' . describeRow($categoryPlan['template'], $categoryPlan['id_col'], $categoryPlan['name_col']));
+
+        $productPlan = buildProductPlan($pdo, $productCols, $categoryPlan['id_col']);
+        out('Produkt-Vorlage: ' . describeRow($productPlan['template'], $productPlan['id_col'], $productPlan['name_col']));
+        out('Monatspreisspalte: ' . $productPlan['monthly_col']);
+        out('Deaktivierte Zahlweisen: ' . ($productPlan['disabled_period_cols'] ? implode(', ', $productPlan['disabled_period_cols']) : '(keine weiteren Periodenspalten erkannt)'));
+        out('Kategorie-Verknüpfung: ' . ($productPlan['category_col'] ? "hb_products.{$productPlan['category_col']}" : relationDescription($productPlan['relation'])));
+        out('');
+
+        $affectedTables = ['hb_categories', 'hb_products'];
+        if ($productPlan['relation']) {
+            $affectedTables[] = $productPlan['relation']['table'];
+            printSchema($productPlan['relation']['table'], columns($pdo, $productPlan['relation']['table']));
+        }
+
+        $timestamp = time();
+        foreach (array_values(array_unique($affectedTables)) as $table) {
+            $backupName = uniqueBackupName($pdo, $table, $timestamp);
+            $backupTables[$table] = $backupName;
+            out(($dryRun ? 'Würde Backup erstellen: ' : 'Erstelle Backup: ') . "{$backupName} aus {$table}");
+            if (!$dryRun) {
+                createBackup($pdo, $table, $backupName);
+            }
+        }
+        out('');
+
+        if (!$dryRun) {
+            $pdo->beginTransaction();
+            $transactionStarted = true;
+        }
+
+        $category = ensureCategory($pdo, $categoryPlan, $dryRun);
+        $categoryId = $category['id'];
+        $categorySlug = $category['slug'];
+
+        $productResults = [];
+        foreach ($products as $product) {
+            $productResults[] = ensureProduct($pdo, $productPlan, $categoryId, $product, $dryRun);
+        }
+
+        if (!$dryRun) {
+            $pdo->commit();
+            $transactionStarted = false;
+        }
+
+        out('');
+        out('Ergebnis');
+        out('Kategorie-ID: ' . displayId($categoryId));
+        foreach ($productResults as $result) {
+            out(sprintf('Produkt: %s -> ID %s (%s)', $result['name'], displayId($result['id']), $result['status']));
+        }
+        out('');
+        out('Bestell-URLs');
+        foreach ($productResults as $result) {
+            $id = displayId($result['id'], '<PID>');
+            out($result['name'] . ': ' . PORTAL_BASE_URL . '/?cmd=cart&action=add&id=' . $id);
+        }
+        if ($categorySlug !== null && $categorySlug !== '') {
+            out('Orderpage: ' . PORTAL_BASE_URL . '/cart/' . $categorySlug);
+        } else {
+            out('Orderpage: /cart/<kategorie-slug> nicht ausgebbar, weil keine Slug-Spalte erkannt wurde.');
+        }
+        out('');
+        out('Manuelle Admin-Schritte bleiben: Zahlungsmodul-Zuordnung prüfen, Steuersatz prüfen, Willkommens-E-Mail je Produkt mit Priority-Code konfigurieren.');
+        out('Rollback-Hinweis: Für einen manuellen Restore wurden diese Backup-Tabellen vorbereitet/angezeigt:');
+        foreach ($backupTables as $table => $backupName) {
+            out("  {$table} -> {$backupName}");
+        }
+        out('Dieses Skript führt kein automatisches --rollback aus, damit bestehende Daten nicht unbeabsichtigt gelöscht oder überschrieben werden.');
+    } catch (Throwable $e) {
+        if ($transactionStarted && $pdo->inTransaction()) {
+            $pdo->rollBack();
+        }
+        fwrite(STDERR, "\nABBRUCH: " . $e->getMessage() . "\n");
+        if ($backupTables) {
+            fwrite(STDERR, "Backup-Tabellen für manuelle Prüfung/Restore:\n");
+            foreach ($backupTables as $table => $backupName) {
+                fwrite(STDERR, "  {$table} -> {$backupName}\n");
+            }
+        }
+        exit(1);
+    }
+}
+
+function printHelp(): void
+{
+    out("Nutzung:");
+    out("  php kiwz_hostbill_setup.php");
+    out("  php kiwz_hostbill_setup.php --apply");
+    out("  php kiwz_hostbill_setup.php --hostbill-root=/home/support.panomity.com/public_html");
+    out("  php kiwz_hostbill_setup.php --config=/pfad/zu/includes/config.php");
+}
+
+function connectFromHostbillConfig(string $configFile): PDO
+{
+    if (!is_file($configFile) || !is_readable($configFile)) {
+        throw new RuntimeException("HostBill config.php nicht lesbar: {$configFile}");
+    }
+
+    $db_host = $db_name = $db_user = $db_pass = null;
+    $db_port = null;
+    require $configFile;
+
+    foreach (['db_host', 'db_name', 'db_user', 'db_pass'] as $var) {
+        if (!isset($$var) || $$var === '') {
+            throw new RuntimeException("HostBill config.php definiert \${$var} nicht.");
+        }
+    }
+
+    $dsn = 'mysql:host=' . $db_host . ';dbname=' . $db_name . ';charset=utf8mb4';
+    if (!empty($db_port)) {
+        $dsn .= ';port=' . (int)$db_port;
+    }
+
+    return new PDO($dsn, (string)$db_user, (string)$db_pass, [
+        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
+        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+        PDO::ATTR_EMULATE_PREPARES => false,
+    ]);
+}
+
+function requireTable(PDO $pdo, string $table): void
+{
+    $stmt = $pdo->prepare('SHOW TABLES LIKE ?');
+    $stmt->execute([$table]);
+    if (!$stmt->fetchColumn()) {
+        throw new RuntimeException("Erwartete Tabelle fehlt: {$table}");
+    }
+}
+
+function tableExists(PDO $pdo, string $table): bool
+{
+    $stmt = $pdo->prepare('SHOW TABLES LIKE ?');
+    $stmt->execute([$table]);
+    return (bool)$stmt->fetchColumn();
+}
+
+function columns(PDO $pdo, string $table): array
+{
+    $rows = $pdo->query('SHOW COLUMNS FROM ' . qi($table))->fetchAll();
+    if (!$rows) {
+        throw new RuntimeException("Keine Spalten gefunden in {$table}");
+    }
+    $out = [];
+    foreach ($rows as $row) {
+        $out[$row['Field']] = $row;
+    }
+    return $out;
+}
+
+function printSchema(string $table, array $cols): void
+{
+    out("Schema {$table}:");
+    foreach ($cols as $name => $meta) {
+        $extra = trim(($meta['Key'] ? ' key=' . $meta['Key'] : '') . ($meta['Extra'] ? ' extra=' . $meta['Extra'] : ''));
+        out('  - ' . $name . ' ' . $meta['Type'] . ($extra ? ' (' . $extra . ')' : ''));
+    }
+    out('');
+}
+
+function buildCategoryPlan(PDO $pdo, array $cols): array
+{
+    $idCol = autoIncrementColumn($cols) ?? pickColumn($cols, ['id', 'category_id', 'cat_id']);
+    $nameCol = pickColumn($cols, ['name', 'title', 'category', 'catname', 'label']);
+    $descCol = pickColumn($cols, ['description', 'descr', 'details', 'content', 'text', 'info']);
+    $slugCol = pickColumn($cols, ['slug', 'url', 'sefurl', 'sef_url', 'urlname', 'url_name']);
+
+    if (!$idCol || !$nameCol) {
+        throw new RuntimeException('hb_categories: ID- oder Name-Spalte nicht sicher erkannt.');
+    }
+
+    $template = selectTemplateRow($pdo, 'hb_categories', $cols, $idCol);
+
+    return [
+        'table' => 'hb_categories',
+        'cols' => $cols,
+        'id_col' => $idCol,
+        'name_col' => $nameCol,
+        'desc_col' => $descCol,
+        'slug_col' => $slugCol,
+        'template' => $template,
+    ];
+}
+
+function buildProductPlan(PDO $pdo, array $cols, string $categoryIdCol): array
+{
+    $idCol = autoIncrementColumn($cols) ?? pickColumn($cols, ['id', 'product_id', 'pid']);
+    $nameCol = pickColumn($cols, ['name', 'title', 'product_name', 'label']);
+    $descCol = pickColumn($cols, ['description', 'descr', 'details', 'content', 'text', 'info', 'short_description', 'shortdesc']);
+    $slugCol = pickColumn($cols, ['slug', 'url', 'sefurl', 'sef_url', 'urlname', 'url_name']);
+    $monthlyCol = pickMonthlyColumn($cols);
+    $categoryCol = pickProductCategoryColumn($cols);
+    $relation = null;
+
+    if (!$idCol || !$nameCol || !$descCol || !$monthlyCol) {
+        throw new RuntimeException('hb_products: ID-, Name-, Beschreibungs- oder Monatspreis-Spalte nicht sicher erkannt.');
+    }
+
+    if (!$categoryCol) {
+        $relation = discoverProductCategoryRelation($pdo, $idCol, $categoryIdCol);
+        if (!$relation) {
+            throw new RuntimeException('hb_products: Keine Kategorie-Spalte und keine Produkt/Kategorie-Relation sicher erkannt.');
+        }
+    }
+
+    $template = selectTemplateRow($pdo, 'hb_products', $cols, $idCol);
+
+    $periodCols = pickExistingColumns($cols, ['m', 'monthly', 'month', 'price_monthly', 'monthly_price', 'q', 'quarterly', 's', 'semiannually', 'semiannual', 'a', 'annually', 'annual', 'b', 'biennially', 'biennial', 't', 'triennially', 'triennial']);
+    $disabledPeriodCols = array_values(array_diff($periodCols, [$monthlyCol]));
+
+    return [
+        'table' => 'hb_products',
+        'cols' => $cols,
+        'id_col' => $idCol,
+        'name_col' => $nameCol,
+        'desc_col' => $descCol,
+        'slug_col' => $slugCol,
+        'monthly_col' => $monthlyCol,
+        'disabled_period_cols' => $disabledPeriodCols,
+        'category_col' => $categoryCol,
+        'relation' => $relation,
+        'template' => $template,
+    ];
+}
+
+function ensureCategory(PDO $pdo, array $plan, bool $dryRun): array
+{
+    $existing = findCategory($pdo, $plan, CATEGORY_NAME);
+    if ($existing) {
+        out('Kategorie existiert bereits, keine Änderung: ID ' . $existing[$plan['id_col']]);
+        return [
+            'id' => $existing[$plan['id_col']],
+            'slug' => $plan['slug_col'] ? (string)($existing[$plan['slug_col']] ?? CATEGORY_SLUG) : null,
+        ];
+    }
+
+    $overrides = [
+        $plan['name_col'] => CATEGORY_NAME,
+    ];
+    if ($plan['desc_col']) {
+        $overrides[$plan['desc_col']] = CATEGORY_DESCRIPTION;
+    }
+    if ($plan['slug_col']) {
+        $overrides[$plan['slug_col']] = CATEGORY_SLUG;
+    }
+    applyVisibilityOverrides($plan['cols'], $overrides);
+
+    out(($dryRun ? 'Würde Kategorie anlegen: ' : 'Lege Kategorie an: ') . CATEGORY_NAME);
+    $id = insertFromTemplate($pdo, $plan['table'], $plan['cols'], $plan['template'], $overrides, $dryRun);
+
+    return [
+        'id' => $dryRun ? null : $id,
+        'slug' => $plan['slug_col'] ? CATEGORY_SLUG : null,
+    ];
+}
+
+function ensureProduct(PDO $pdo, array $plan, $categoryId, array $product, bool $dryRun): array
+{
+    if (!$dryRun && ($categoryId === null || $categoryId === '')) {
+        throw new RuntimeException('Kategorie-ID fehlt für Produktanlage.');
+    }
+
+    $existing = $categoryId === null ? null : findProduct($pdo, $plan, $categoryId, $product['name']);
+    if ($existing) {
+        out('Produkt existiert bereits, keine Änderung: ' . $product['name'] . ' (ID ' . $existing[$plan['id_col']] . ')');
+        return [
+            'name' => $product['name'],
+            'id' => $existing[$plan['id_col']],
+            'status' => 'vorhanden',
+        ];
+    }
+
+    $overrides = [
+        $plan['name_col'] => $product['name'],
+        $plan['desc_col'] => $product['description'],
+        $plan['monthly_col'] => $product['price'],
+    ];
+    if ($plan['slug_col']) {
+        $overrides[$plan['slug_col']] = slugify($product['name']);
+    }
+    if ($plan['category_col']) {
+        $overrides[$plan['category_col']] = $dryRun ? '<neue Kategorie-ID>' : $categoryId;
+    }
+    foreach ($plan['disabled_period_cols'] as $col) {
+        $overrides[$col] = disabledPeriodValue($plan['template'][$col] ?? null);
+    }
+    foreach (pickExistingColumns($plan['cols'], ['paytype', 'pay_type', 'p_type', 'billingtype', 'billing_type']) as $col) {
+        $overrides[$col] = recurringValueForColumn($plan['cols'][$col]);
+    }
+    foreach (pickExistingColumns($plan['cols'], ['currency', 'curr']) as $col) {
+        if (isStringType($plan['cols'][$col]['Type'])) {
+            $overrides[$col] = 'EUR';
+        }
+    }
+    applyVisibilityOverrides($plan['cols'], $overrides);
+
+    out(($dryRun ? 'Würde Produkt anlegen: ' : 'Lege Produkt an: ') . $product['name'] . ' für ' . $product['price'] . ' EUR/Monat');
+    $id = insertFromTemplate($pdo, $plan['table'], $plan['cols'], $plan['template'], $overrides, $dryRun);
+
+    if ($plan['relation']) {
+        out(($dryRun ? 'Würde Produkt/Kategorie-Relation anlegen: ' : 'Lege Produkt/Kategorie-Relation an: ') . relationDescription($plan['relation']));
+        if (!$dryRun) {
+            ensureRelation($pdo, $plan['relation'], $id, $categoryId);
+        }
+    }
+
+    return [
+        'name' => $product['name'],
+        'id' => $dryRun ? null : $id,
+        'status' => $dryRun ? 'dry-run' : 'neu',
+    ];
+}
+
+function findCategory(PDO $pdo, array $plan, string $name): ?array
+{
+    $sql = 'SELECT * FROM ' . qi($plan['table']) . ' WHERE ' . qi($plan['name_col']) . ' = ? LIMIT 1';
+    $stmt = $pdo->prepare($sql);
+    $stmt->execute([$name]);
+    $row = $stmt->fetch();
+    return $row ?: null;
+}
+
+function findProduct(PDO $pdo, array $plan, $categoryId, string $name): ?array
+{
+    if ($plan['category_col']) {
+        $sql = 'SELECT * FROM ' . qi($plan['table']) . ' WHERE ' . qi($plan['name_col']) . ' = ? AND ' . qi($plan['category_col']) . ' = ? LIMIT 1';
+        $stmt = $pdo->prepare($sql);
+        $stmt->execute([$name, $categoryId]);
+        $row = $stmt->fetch();
+        return $row ?: null;
+    }
+
+    $rel = $plan['relation'];
+    $sql = 'SELECT p.* FROM ' . qi($plan['table']) . ' p '
+        . 'INNER JOIN ' . qi($rel['table']) . ' r ON p.' . qi($plan['id_col']) . ' = r.' . qi($rel['product_col']) . ' '
+        . 'WHERE p.' . qi($plan['name_col']) . ' = ? AND r.' . qi($rel['category_col']) . ' = ? LIMIT 1';
+    $stmt = $pdo->prepare($sql);
+    $stmt->execute([$name, $categoryId]);
+    $row = $stmt->fetch();
+    return $row ?: null;
+}
+
+function insertFromTemplate(PDO $pdo, string $table, array $cols, array $template, array $overrides, bool $dryRun)
+{
+    $data = [];
+    foreach ($cols as $name => $meta) {
+        if (!isInsertableColumn($meta)) {
+            continue;
+        }
+        $data[$name] = array_key_exists($name, $overrides) ? $overrides[$name] : ($template[$name] ?? null);
+    }
+
+    out('  Overrides:');
+    foreach ($overrides as $col => $value) {
+        out('    ' . $col . ' = ' . printableValue($value));
+    }
+
+    if ($dryRun) {
+        return null;
+    }
+
+    $fields = array_keys($data);
+    $placeholders = [];
+    $values = [];
+    foreach ($fields as $i => $field) {
+        $placeholder = ':v' . $i;
+        $placeholders[] = $placeholder;
+        $values[$placeholder] = $data[$field];
+    }
+    $sql = 'INSERT INTO ' . qi($table) . ' (' . implode(', ', array_map('qi', $fields)) . ') VALUES (' . implode(', ', $placeholders) . ')';
+    $stmt = $pdo->prepare($sql);
+    $stmt->execute($values);
+    return $pdo->lastInsertId();
+}
+
+function ensureRelation(PDO $pdo, array $rel, $productId, $categoryId): void
+{
+    $check = $pdo->prepare('SELECT 1 FROM ' . qi($rel['table']) . ' WHERE ' . qi($rel['product_col']) . ' = ? AND ' . qi($rel['category_col']) . ' = ? LIMIT 1');
+    $check->execute([$productId, $categoryId]);
+    if ($check->fetchColumn()) {
+        return;
+    }
+
+    $cols = columns($pdo, $rel['table']);
+    $template = selectAnyRow($pdo, $rel['table']);
+    $overrides = [
+        $rel['product_col'] => $productId,
+        $rel['category_col'] => $categoryId,
+    ];
+
+    if ($template) {
+        insertFromTemplate($pdo, $rel['table'], $cols, $template, $overrides, false);
+        return;
+    }
+
+    $fields = array_keys($overrides);
+    foreach ($cols as $name => $meta) {
+        if (isset($overrides[$name]) || !isInsertableColumn($meta)) {
+            continue;
+        }
+        if (strtoupper((string)$meta['Null']) === 'NO' && $meta['Default'] === null) {
+            throw new RuntimeException("Relation {$rel['table']} hat Pflichtspalte {$name}; keine Template-Zeile vorhanden.");
+        }
+    }
+    $sql = 'INSERT INTO ' . qi($rel['table']) . ' (' . implode(', ', array_map('qi', $fields)) . ') VALUES (?, ?)';
+    $stmt = $pdo->prepare($sql);
+    $stmt->execute([$productId, $categoryId]);
+}
+
+function selectTemplateRow(PDO $pdo, string $table, array $cols, string $idCol): array
+{
+    [$where, $note] = visibilityWhere($cols);
+    if ($note) {
+        out("Hinweis {$table}: {$note}");
+    }
+    $sql = 'SELECT * FROM ' . qi($table) . ' WHERE ' . $where . ' ORDER BY ' . qi($idCol) . ' ASC LIMIT 1';
+    $row = $pdo->query($sql)->fetch();
+    if (!$row) {
+        throw new RuntimeException("Keine sichtbare Template-Zeile in {$table} gefunden.");
+    }
+    return $row;
+}
+
+function selectAnyRow(PDO $pdo, string $table): ?array
+{
+    $row = $pdo->query('SELECT * FROM ' . qi($table) . ' LIMIT 1')->fetch();
+    return $row ?: null;
+}
+
+function visibilityWhere(array $cols): array
+{
+    $clauses = [];
+    foreach (['visible', 'is_visible', 'show', 'enabled', 'active', 'is_active'] as $col) {
+        if (isset($cols[$col])) {
+            $clauses[] = qi($col) . " IN (1, '1', 'yes', 'Yes', 'true', 'True', 'on', 'On')";
+        }
+    }
+    foreach (['hidden', 'is_hidden', 'disabled', 'is_disabled', 'deleted', 'archived'] as $col) {
+        if (isset($cols[$col])) {
+            $clauses[] = qi($col) . " IN (0, '0', 'no', 'No', 'false', 'False', '', 'off', 'Off')";
+        }
+    }
+    foreach (['status', 'state'] as $col) {
+        if (isset($cols[$col])) {
+            $clauses[] = qi($col) . " IN ('Active', 'active', 'Enabled', 'enabled', 'Visible', 'visible', '1', 1)";
+        }
+    }
+
+    if (!$clauses) {
+        return ['1=1', 'keine bekannte Sichtbarkeitsspalte erkannt; nutze erste vorhandene Zeile als Vorlage.'];
+    }
+    return [implode(' AND ', $clauses), null];
+}
+
+function applyVisibilityOverrides(array $cols, array &$overrides): void
+{
+    foreach (['visible', 'is_visible', 'show', 'enabled', 'active', 'is_active'] as $col) {
+        if (isset($cols[$col])) {
+            $overrides[$col] = 1;
+        }
+    }
+    foreach (['hidden', 'is_hidden', 'disabled', 'is_disabled', 'deleted', 'archived'] as $col) {
+        if (isset($cols[$col])) {
+            $overrides[$col] = 0;
+        }
+    }
+    foreach (['status', 'state'] as $col) {
+        if (isset($cols[$col])) {
+            $overrides[$col] = statusActiveValue($cols[$col]);
+        }
+    }
+}
+
+function statusActiveValue(array $meta): string
+{
+    $type = (string)$meta['Type'];
+    if (preg_match_all("/'((?:[^'\\\\]|\\\\.)*)'/", $type, $matches)) {
+        foreach ($matches[1] as $value) {
+            if (in_array(strtolower($value), ['active', 'enabled', 'visible'], true)) {
+                return $value;
+            }
+        }
+        if (in_array('1', $matches[1], true)) {
+            return '1';
+        }
+    }
+    return 'Active';
+}
+
+function recurringValueForColumn(array $meta): string
+{
+    $type = (string)$meta['Type'];
+    if (preg_match_all("/'((?:[^'\\\\]|\\\\.)*)'/", $type, $matches)) {
+        foreach ($matches[1] as $value) {
+            if (stripos($value, 'recurr') !== false) {
+                return $value;
+            }
+        }
+    }
+    return 'Recurring';
+}
+
+function discoverProductCategoryRelation(PDO $pdo, string $productIdCol, string $categoryIdCol): ?array
+{
+    $tables = $pdo->query('SHOW TABLES')->fetchAll(PDO::FETCH_COLUMN);
+    foreach ($tables as $table) {
+        $lower = strtolower((string)$table);
+        if (!preg_match('/(product.*categor|categor.*product|product.*orderpage|orderpage.*product)/', $lower)) {
+            continue;
+        }
+        $cols = columns($pdo, (string)$table);
+        $productCol = pickColumn($cols, ['product_id', 'productid', 'prod_id', 'pid', $productIdCol]);
+        $categoryCol = pickColumn($cols, ['category_id', 'cat_id', 'categoryid', 'cid', 'orderpage_id', $categoryIdCol]);
+        if ($productCol && $categoryCol && $productCol !== $categoryCol) {
+            return [
+                'table' => (string)$table,
+                'product_col' => $productCol,
+                'category_col' => $categoryCol,
+            ];
+        }
+    }
+    return null;
+}
+
+function relationDescription(?array $relation): string
+{
+    if (!$relation) {
+        return '(keine Relation)';
+    }
+    return $relation['table'] . '.' . $relation['product_col'] . ' -> Produkt, ' . $relation['table'] . '.' . $relation['category_col'] . ' -> Kategorie';
+}
+
+function createBackup(PDO $pdo, string $source, string $backup): void
+{
+    $pdo->exec('CREATE TABLE ' . qi($backup) . ' LIKE ' . qi($source));
+    $pdo->exec('INSERT INTO ' . qi($backup) . ' SELECT * FROM ' . qi($source));
+}
+
+function uniqueBackupName(PDO $pdo, string $table, int $timestamp): string
+{
+    $base = $table . '_bak_' . $timestamp;
+    $candidate = $base;
+    $i = 1;
+    while (tableExists($pdo, $candidate)) {
+        $candidate = $base . '_' . $i;
+        $i++;
+    }
+    return $candidate;
+}
+
+function autoIncrementColumn(array $cols): ?string
+{
+    foreach ($cols as $name => $meta) {
+        if (stripos((string)$meta['Extra'], 'auto_increment') !== false) {
+            return $name;
+        }
+    }
+    return null;
+}
+
+function isInsertableColumn(array $meta): bool
+{
+    $extra = strtolower((string)$meta['Extra']);
+    return strpos($extra, 'auto_increment') === false
+        && strpos($extra, 'generated') === false
+        && strpos($extra, 'virtual') === false
+        && strpos($extra, 'stored') === false;
+}
+
+function pickColumn(array $cols, array $candidates): ?string
+{
+    $map = [];
+    foreach (array_keys($cols) as $name) {
+        $map[strtolower($name)] = $name;
+    }
+    foreach ($candidates as $candidate) {
+        $key = strtolower($candidate);
+        if (isset($map[$key])) {
+            return $map[$key];
+        }
+    }
+    return null;
+}
+
+function pickExistingColumns(array $cols, array $candidates): array
+{
+    $found = [];
+    foreach ($candidates as $candidate) {
+        $col = pickColumn($cols, [$candidate]);
+        if ($col && !in_array($col, $found, true)) {
+            $found[] = $col;
+        }
+    }
+    return $found;
+}
+
+function pickMonthlyColumn(array $cols): ?string
+{
+    foreach (['m', 'monthly', 'month', 'price_monthly', 'monthly_price', 'recurring_monthly'] as $candidate) {
+        $col = pickColumn($cols, [$candidate]);
+        if ($col && isNumericType($cols[$col]['Type'])) {
+            return $col;
+        }
+    }
+    foreach ($cols as $name => $meta) {
+        if (stripos($name, 'month') !== false && isNumericType($meta['Type'])) {
+            return $name;
+        }
+    }
+    return null;
+}
+
+function pickProductCategoryColumn(array $cols): ?string
+{
+    foreach (['category_id', 'cat_id', 'categoryid', 'orderpage_id', 'cid', 'group_id'] as $candidate) {
+        $col = pickColumn($cols, [$candidate]);
+        if ($col && isNumericType($cols[$col]['Type'])) {
+            return $col;
+        }
+    }
+    $col = pickColumn($cols, ['category']);
+    if ($col && isNumericType($cols[$col]['Type'])) {
+        return $col;
+    }
+    return null;
+}
+
+function isNumericType(string $type): bool
+{
+    return (bool)preg_match('/\b(tinyint|smallint|mediumint|int|bigint|decimal|numeric|float|double|real)\b/i', $type);
+}
+
+function isStringType(string $type): bool
+{
+    return (bool)preg_match('/\b(char|varchar|text|enum|set)\b/i', $type);
+}
+
+function disabledPeriodValue($templateValue)
+{
+    if (is_numeric($templateValue) && (float)$templateValue < 0) {
+        return $templateValue;
+    }
+    return '-1';
+}
+
+function describeRow(array $row, string $idCol, ?string $nameCol): string
+{
+    $parts = ['ID ' . ($row[$idCol] ?? '?')];
+    if ($nameCol && isset($row[$nameCol])) {
+        $parts[] = $nameCol . '=' . printableValue($row[$nameCol]);
+    }
+    return implode(', ', $parts);
+}
+
+function printableValue($value): string
+{
+    if ($value === null) {
+        return 'NULL';
+    }
+    $str = (string)$value;
+    if (strlen($str) > 90) {
+        $str = substr($str, 0, 87) . '...';
+    }
+    return "'" . $str . "'";
+}
+
+function displayId($id, string $fallback = '<wird von AUTO_INCREMENT vergeben>'): string
+{
+    return ($id === null || $id === '') ? $fallback : (string)$id;
+}
+
+function slugify(string $value): string
+{
+    $value = str_replace(['Ä', 'Ö', 'Ü', 'ä', 'ö', 'ü', 'ß'], ['Ae', 'Oe', 'Ue', 'ae', 'oe', 'ue', 'ss'], $value);
+    $value = strtolower($value);
+    $value = preg_replace('/[^a-z0-9]+/', '-', $value) ?? $value;
+    $value = trim($value, '-');
+    return $value !== '' ? $value : 'kiwz-produkt';
+}
+
+function qi(string $identifier): string
+{
+    if (!preg_match('/^[A-Za-z0-9_]+$/', $identifier)) {
+        throw new RuntimeException("Unsicherer SQL-Identifier: {$identifier}");
+    }
+    return '`' . $identifier . '`';
+}
+
+function out(string $line): void
+{
+    echo $line . PHP_EOL;
+}
diff --git a/kiwz_prio_codes.txt b/kiwz_prio_codes.txt
new file mode 100644
index 0000000000000000000000000000000000000000..4acd9c408d2fbad263e01ddab541742f0709b92b
--- /dev/null
+++ b/kiwz_prio_codes.txt
@@ -0,0 +1,30 @@
+KIWZ-X7QA-H4NZ
+KIWZ-BM6T-9VDK
+KIWZ-3LPR-W8CY
+KIWZ-F2ZH-QN5A
+KIWZ-7GVM-KX4T
+KIWZ-N9QS-2JWR
+KIWZ-H8DC-V6PY
+KIWZ-4TFK-ZM7B
+KIWZ-Q2WX-5ANR
+KIWZ-Y6PJ-C9LT
+KIWZ-8KDA-RV3M
+KIWZ-Z5HN-WQ7F
+KIWZ-2VTC-JK9P
+KIWZ-M4RY-8XGB
+KIWZ-6QNL-FZ2D
+KIWZ-CP7W-3HKA
+KIWZ-J9XB-T5VQ
+KIWZ-W2MG-6RZN
+KIWZ-5DFY-P8KC
+KIWZ-A7VT-NQ4H
+KIWZ-R3KZ-Y6PM
+KIWZ-L8QC-2WJF
+KIWZ-9NBH-X5TA
+KIWZ-V4PR-7ZDG
+KIWZ-T6JW-M2QC
+KIWZ-G5KA-9VYR
+KIWZ-2XND-H8FP
+KIWZ-P7ZM-C4WT
+KIWZ-Q9RY-6BKL
+KIWZ-F3VC-J5XN

codex
Erledigt. Die vier Deliverables liegen im Arbeitsverzeichnis:

- [kiwz_hostbill_setup.php](/tmp/kiwz-hostbill-staging/kiwz_hostbill_setup.php)
- [kiwz_prio_codes.txt](/tmp/kiwz-hostbill-staging/kiwz_prio_codes.txt)
- [kiwz_gpuq_config_snippet.php](/tmp/kiwz-hostbill-staging/kiwz_gpuq_config_snippet.php)
- [ANLEITUNG.md](/tmp/kiwz-hostbill-staging/ANLEITUNG.md)

Geprüft:
- `php -l kiwz_hostbill_setup.php`: keine Syntaxfehler
- `php -l kiwz_gpuq_config_snippet.php`: keine Syntaxfehler
- `kiwz_prio_codes.txt`: 30 Codes, Format und Zeichenvorrat valide

Ich habe keinen DB-Connect, keinen Netzwerkzugriff und keinen Zugriff auf `/home/support.panomity.com` ausgeführt. Das Setup-Skript ist ausführbar gesetzt und bleibt standardmäßig im Dry-Run, bis `--apply` angegeben wird.
diff --git a/ANLEITUNG.md b/ANLEITUNG.md
new file mode 100644
index 0000000000000000000000000000000000000000..855de0955bb7085d75847133170994b159e380e3
--- /dev/null
+++ b/ANLEITUNG.md
@@ -0,0 +1,51 @@
+# HostBill-Setup für kiwerkzeuge.de
+
+## Skript ausführen
+
+Das Skript ist standardmäßig ein Dry-Run und schreibt dabei nichts in die Datenbank:
+
+```bash
+php kiwz_hostbill_setup.php
+```
+
+Wenn Schema, Template-Zeilen, Backups und geplante Inserts plausibel aussehen, ausführen mit:
+
+```bash
+php kiwz_hostbill_setup.php --apply
+```
+
+Optional kann ein abweichender HostBill-Pfad oder eine konkrete Config-Datei angegeben werden:
+
+```bash
+php kiwz_hostbill_setup.php --hostbill-root=/home/support.panomity.com/public_html
+php kiwz_hostbill_setup.php --config=/home/support.panomity.com/public_html/includes/config.php
+```
+
+Vor den Inserts erstellt das Skript Backup-Tabellen per `CREATE TABLE ... LIKE` und `INSERT INTO ... SELECT`, z. B. `hb_products_bak_<timestamp>`. Das Skript löscht oder aktualisiert keine bestehenden Zeilen.
+
+## Manuelle Alternative im HostBill-Admin
+
+Falls die Einrichtung lieber per Klick gemacht wird:
+
+1. `padm` öffnen und zu `Settings -> Products & Services` gehen.
+2. Neue Orderpage/Kategorie `KI Werkzeuge` anlegen, sichtbar schalten und als Slug z. B. `ki-werkzeuge` setzen.
+3. Drei monatlich wiederkehrende EUR-Produkte in dieser Orderpage anlegen:
+   - `KI Starter`, 4.90 EUR/Monat, `Priority-Rendering für Bildgenerator & Tiefenkarte, E-Mail-Support`
+   - `KI Pro`, 14.90 EUR/Monat, `Priority für alle Werkzeuge inkl. Musik & Video, LoRA-Training bevorzugt`
+   - `KI Business`, 39.90 EUR/Monat, `Höchste Priorität, alle Werkzeuge, Support mit 24h-Reaktionszeit`
+
+Danach Zahlungsmodul-Zuordnung, Steuersatz und Sichtbarkeit im Warenkorb prüfen.
+
+## Willkommens-E-Mail mit Priority-Code
+
+Für jedes Produkt sollte eine Willkommens-E-Mail bzw. Produktaktivierungs-Mail eingerichtet werden. Der Inhalt sollte dem Kunden genau einen freien Code aus `kiwz_prio_codes.txt` mitteilen, z. B.:
+
+```text
+Ihr Priority-Code für kiwerkzeuge.de lautet: KIWZ-XXXX-XXXX
+```
+
+Wichtig: Codes müssen nach Ausgabe als vergeben markiert werden, damit derselbe Code nicht mehrfach an Kunden geht. Für den ersten Batch kann das Snippet `kiwz_gpuq_config_snippet.php` genutzt werden; weitere Codes werden dort im Array `'priority_codes' => [...]` ergänzt.
+
+## Multibrand-Hinweis
+
+Das Multibrand-Modul ist installiert. Für eine eigene Brand `kiwerkzeuge.de` wären zusätzlich zur Produktanlage diese Schritte nötig: Brand in `hb_brands` bzw. über den HostBill-Admin anlegen, Domain/Portal-URL und Branding in `hb_brand_config` konfigurieren, Template/Logo/E-Mail-Absender setzen, Zahlungs- und Steuerregeln der Brand zuordnen und die neue Orderpage/Produkte dieser Brand sichtbar zuweisen. Diese Anleitung dokumentiert das nur; das Setup-Skript legt keine neue Brand an.
diff --git a/kiwz_gpuq_config_snippet.php b/kiwz_gpuq_config_snippet.php
new file mode 100644
index 0000000000000000000000000000000000000000..c089e9c811a945dc3d3da6515b6c7d714f4dfd66
--- /dev/null
+++ b/kiwz_gpuq_config_snippet.php
@@ -0,0 +1,19 @@
+<?php
+
+// Beispiel-Batch für die ersten Kunden. Weitere Codes aus kiwz_prio_codes.txt
+// nach gleichem Muster ergänzen, z. B. 'KIWZ-XXXX-XXXX' => 'high'.
+$gpuqConfig = [
+    'priority_codes' => [
+        'KIWZ-X7QA-H4NZ' => 'high',
+        'KIWZ-BM6T-9VDK' => 'high',
+        'KIWZ-3LPR-W8CY' => 'high',
+        'KIWZ-F2ZH-QN5A' => 'high',
+        'KIWZ-7GVM-KX4T' => 'high',
+        'KIWZ-N9QS-2JWR' => 'high',
+        'KIWZ-H8DC-V6PY' => 'high',
+        'KIWZ-4TFK-ZM7B' => 'high',
+        'KIWZ-Q2WX-5ANR' => 'high',
+        'KIWZ-Y6PJ-C9LT' => 'high',
+    ],
+];
+
diff --git a/kiwz_hostbill_setup.php b/kiwz_hostbill_setup.php
new file mode 100644
index 0000000000000000000000000000000000000000..0d3d1b036cc1df8f965ac381c8f328b8344051ee
--- /dev/null
+++ b/kiwz_hostbill_setup.php
@@ -0,0 +1,767 @@
+#!/usr/bin/env php
+<?php
+declare(strict_types=1);
+
+/**
+ * HostBill setup helper for kiwerkzeuge.de.
+ *
+ * Default mode is dry-run. The script intentionally discovers the HostBill
+ * schema at runtime and copies visible template rows instead of assuming an
+ * exact HostBill table layout.
+ */
+
+const HOSTBILL_DEFAULT_ROOT = '/home/support.panomity.com/public_html';
+const PORTAL_BASE_URL = 'https://support.panomity.com';
+const CATEGORY_NAME = 'KI Werkzeuge';
+const CATEGORY_SLUG = 'ki-werkzeuge';
+const CATEGORY_DESCRIPTION = 'Deutschsprachige KI-Tool-Suite mit Bildgenerator, Musik, Video und Tiefenkarte.';
+
+$PRODUCTS = [
+    [
+        'name' => 'KI Starter',
+        'price' => '4.90',
+        'description' => 'Priority-Rendering für Bildgenerator & Tiefenkarte, E-Mail-Support',
+    ],
+    [
+        'name' => 'KI Pro',
+        'price' => '14.90',
+        'description' => 'Priority für alle Werkzeuge inkl. Musik & Video, LoRA-Training bevorzugt',
+    ],
+    [
+        'name' => 'KI Business',
+        'price' => '39.90',
+        'description' => 'Höchste Priorität, alle Werkzeuge, Support mit 24h-Reaktionszeit',
+    ],
+];
+
+main($PRODUCTS);
+
+function main(array $products): void
+{
+    if (PHP_SAPI !== 'cli') {
+        fwrite(STDERR, "Dieses Skript muss mit php-cli ausgeführt werden.\n");
+        exit(1);
+    }
+
+    $opts = getopt('', ['apply', 'help', 'hostbill-root:', 'config:']);
+    if (isset($opts['help'])) {
+        printHelp();
+        return;
+    }
+
+    $dryRun = !isset($opts['apply']);
+    $hostbillRoot = rtrim((string)($opts['hostbill-root'] ?? HOSTBILL_DEFAULT_ROOT), '/');
+    $configFile = (string)($opts['config'] ?? ($hostbillRoot . '/includes/config.php'));
+
+    out('HostBill kiwerkzeuge.de Setup');
+    out('Modus: ' . ($dryRun ? 'DRY-RUN (keine Schreibzugriffe)' : 'APPLY (schreibt in die Datenbank)'));
+    out('Config: ' . $configFile);
+    out('');
+
+    $pdo = connectFromHostbillConfig($configFile);
+    $backupTables = [];
+    $transactionStarted = false;
+
+    try {
+        requireTable($pdo, 'hb_categories');
+        requireTable($pdo, 'hb_products');
+
+        $categoryCols = columns($pdo, 'hb_categories');
+        $productCols = columns($pdo, 'hb_products');
+
+        printSchema('hb_categories', $categoryCols);
+        printSchema('hb_products', $productCols);
+
+        $categoryPlan = buildCategoryPlan($pdo, $categoryCols);
+        out('Kategorie-Vorlage: ' . describeRow($categoryPlan['template'], $categoryPlan['id_col'], $categoryPlan['name_col']));
+
+        $productPlan = buildProductPlan($pdo, $productCols, $categoryPlan['id_col']);
+        out('Produkt-Vorlage: ' . describeRow($productPlan['template'], $productPlan['id_col'], $productPlan['name_col']));
+        out('Monatspreisspalte: ' . $productPlan['monthly_col']);
+        out('Deaktivierte Zahlweisen: ' . ($productPlan['disabled_period_cols'] ? implode(', ', $productPlan['disabled_period_cols']) : '(keine weiteren Periodenspalten erkannt)'));
+        out('Kategorie-Verknüpfung: ' . ($productPlan['category_col'] ? "hb_products.{$productPlan['category_col']}" : relationDescription($productPlan['relation'])));
+        out('');
+
+        $affectedTables = ['hb_categories', 'hb_products'];
+        if ($productPlan['relation']) {
+            $affectedTables[] = $productPlan['relation']['table'];
+            printSchema($productPlan['relation']['table'], columns($pdo, $productPlan['relation']['table']));
+        }
+
+        $timestamp = time();
+        foreach (array_values(array_unique($affectedTables)) as $table) {
+            $backupName = uniqueBackupName($pdo, $table, $timestamp);
+            $backupTables[$table] = $backupName;
+            out(($dryRun ? 'Würde Backup erstellen: ' : 'Erstelle Backup: ') . "{$backupName} aus {$table}");
+            if (!$dryRun) {
+                createBackup($pdo, $table, $backupName);
+            }
+        }
+        out('');
+
+        if (!$dryRun) {
+            $pdo->beginTransaction();
+            $transactionStarted = true;
+        }
+
+        $category = ensureCategory($pdo, $categoryPlan, $dryRun);
+        $categoryId = $category['id'];
+        $categorySlug = $category['slug'];
+
+        $productResults = [];
+        foreach ($products as $product) {
+            $productResults[] = ensureProduct($pdo, $productPlan, $categoryId, $product, $dryRun);
+        }
+
+        if (!$dryRun) {
+            $pdo->commit();
+            $transactionStarted = false;
+        }
+
+        out('');
+        out('Ergebnis');
+        out('Kategorie-ID: ' . displayId($categoryId));
+        foreach ($productResults as $result) {
+            out(sprintf('Produkt: %s -> ID %s (%s)', $result['name'], displayId($result['id']), $result['status']));
+        }
+        out('');
+        out('Bestell-URLs');
+        foreach ($productResults as $result) {
+            $id = displayId($result['id'], '<PID>');
+            out($result['name'] . ': ' . PORTAL_BASE_URL . '/?cmd=cart&action=add&id=' . $id);
+        }
+        if ($categorySlug !== null && $categorySlug !== '') {
+            out('Orderpage: ' . PORTAL_BASE_URL . '/cart/' . $categorySlug);
+        } else {
+            out('Orderpage: /cart/<kategorie-slug> nicht ausgebbar, weil keine Slug-Spalte erkannt wurde.');
+        }
+        out('');
+        out('Manuelle Admin-Schritte bleiben: Zahlungsmodul-Zuordnung prüfen, Steuersatz prüfen, Willkommens-E-Mail je Produkt mit Priority-Code konfigurieren.');
+        out('Rollback-Hinweis: Für einen manuellen Restore wurden diese Backup-Tabellen vorbereitet/angezeigt:');
+        foreach ($backupTables as $table => $backupName) {
+            out("  {$table} -> {$backupName}");
+        }
+        out('Dieses Skript führt kein automatisches --rollback aus, damit bestehende Daten nicht unbeabsichtigt gelöscht oder überschrieben werden.');
+    } catch (Throwable $e) {
+        if ($transactionStarted && $pdo->inTransaction()) {
+            $pdo->rollBack();
+        }
+        fwrite(STDERR, "\nABBRUCH: " . $e->getMessage() . "\n");
+        if ($backupTables) {
+            fwrite(STDERR, "Backup-Tabellen für manuelle Prüfung/Restore:\n");
+            foreach ($backupTables as $table => $backupName) {
+                fwrite(STDERR, "  {$table} -> {$backupName}\n");
+            }
+        }
+        exit(1);
+    }
+}
+
+function printHelp(): void
+{
+    out("Nutzung:");
+    out("  php kiwz_hostbill_setup.php");
+    out("  php kiwz_hostbill_setup.php --apply");
+    out("  php kiwz_hostbill_setup.php --hostbill-root=/home/support.panomity.com/public_html");
+    out("  php kiwz_hostbill_setup.php --config=/pfad/zu/includes/config.php");
+}
+
+function connectFromHostbillConfig(string $configFile): PDO
+{
+    if (!is_file($configFile) || !is_readable($configFile)) {
+        throw new RuntimeException("HostBill config.php nicht lesbar: {$configFile}");
+    }
+
+    $db_host = $db_name = $db_user = $db_pass = null;
+    $db_port = null;
+    require $configFile;
+
+    foreach (['db_host', 'db_name', 'db_user', 'db_pass'] as $var) {
+        if (!isset($$var) || $$var === '') {
+            throw new RuntimeException("HostBill config.php definiert \${$var} nicht.");
+        }
+    }
+
+    $dsn = 'mysql:host=' . $db_host . ';dbname=' . $db_name . ';charset=utf8mb4';
+    if (!empty($db_port)) {
+        $dsn .= ';port=' . (int)$db_port;
+    }
+
+    return new PDO($dsn, (string)$db_user, (string)$db_pass, [
+        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
+        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+        PDO::ATTR_EMULATE_PREPARES => false,
+    ]);
+}
+
+function requireTable(PDO $pdo, string $table): void
+{
+    $stmt = $pdo->prepare('SHOW TABLES LIKE ?');
+    $stmt->execute([$table]);
+    if (!$stmt->fetchColumn()) {
+        throw new RuntimeException("Erwartete Tabelle fehlt: {$table}");
+    }
+}
+
+function tableExists(PDO $pdo, string $table): bool
+{
+    $stmt = $pdo->prepare('SHOW TABLES LIKE ?');
+    $stmt->execute([$table]);
+    return (bool)$stmt->fetchColumn();
+}
+
+function columns(PDO $pdo, string $table): array
+{
+    $rows = $pdo->query('SHOW COLUMNS FROM ' . qi($table))->fetchAll();
+    if (!$rows) {
+        throw new RuntimeException("Keine Spalten gefunden in {$table}");
+    }
+    $out = [];
+    foreach ($rows as $row) {
+        $out[$row['Field']] = $row;
+    }
+    return $out;
+}
+
+function printSchema(string $table, array $cols): void
+{
+    out("Schema {$table}:");
+    foreach ($cols as $name => $meta) {
+        $extra = trim(($meta['Key'] ? ' key=' . $meta['Key'] : '') . ($meta['Extra'] ? ' extra=' . $meta['Extra'] : ''));
+        out('  - ' . $name . ' ' . $meta['Type'] . ($extra ? ' (' . $extra . ')' : ''));
+    }
+    out('');
+}
+
+function buildCategoryPlan(PDO $pdo, array $cols): array
+{
+    $idCol = autoIncrementColumn($cols) ?? pickColumn($cols, ['id', 'category_id', 'cat_id']);
+    $nameCol = pickColumn($cols, ['name', 'title', 'category', 'catname', 'label']);
+    $descCol = pickColumn($cols, ['description', 'descr', 'details', 'content', 'text', 'info']);
+    $slugCol = pickColumn($cols, ['slug', 'url', 'sefurl', 'sef_url', 'urlname', 'url_name']);
+
+    if (!$idCol || !$nameCol) {
+        throw new RuntimeException('hb_categories: ID- oder Name-Spalte nicht sicher erkannt.');
+    }
+
+    $template = selectTemplateRow($pdo, 'hb_categories', $cols, $idCol);
+
+    return [
+        'table' => 'hb_categories',
+        'cols' => $cols,
+        'id_col' => $idCol,
+        'name_col' => $nameCol,
+        'desc_col' => $descCol,
+        'slug_col' => $slugCol,
+        'template' => $template,
+    ];
+}
+
+function buildProductPlan(PDO $pdo, array $cols, string $categoryIdCol): array
+{
+    $idCol = autoIncrementColumn($cols) ?? pickColumn($cols, ['id', 'product_id', 'pid']);
+    $nameCol = pickColumn($cols, ['name', 'title', 'product_name', 'label']);
+    $descCol = pickColumn($cols, ['description', 'descr', 'details', 'content', 'text', 'info', 'short_description', 'shortdesc']);
+    $slugCol = pickColumn($cols, ['slug', 'url', 'sefurl', 'sef_url', 'urlname', 'url_name']);
+    $monthlyCol = pickMonthlyColumn($cols);
+    $categoryCol = pickProductCategoryColumn($cols);
+    $relation = null;
+
+    if (!$idCol || !$nameCol || !$descCol || !$monthlyCol) {
+        throw new RuntimeException('hb_products: ID-, Name-, Beschreibungs- oder Monatspreis-Spalte nicht sicher erkannt.');
+    }
+
+    if (!$categoryCol) {
+        $relation = discoverProductCategoryRelation($pdo, $idCol, $categoryIdCol);
+        if (!$relation) {
+            throw new RuntimeException('hb_products: Keine Kategorie-Spalte und keine Produkt/Kategorie-Relation sicher erkannt.');
+        }
+    }
+
+    $template = selectTemplateRow($pdo, 'hb_products', $cols, $idCol);
+
+    $periodCols = pickExistingColumns($cols, ['m', 'monthly', 'month', 'price_monthly', 'monthly_price', 'q', 'quarterly', 's', 'semiannually', 'semiannual', 'a', 'annually', 'annual', 'b', 'biennially', 'biennial', 't', 'triennially', 'triennial']);
+    $disabledPeriodCols = array_values(array_diff($periodCols, [$monthlyCol]));
+
+    return [
+        'table' => 'hb_products',
+        'cols' => $cols,
+        'id_col' => $idCol,
+        'name_col' => $nameCol,
+        'desc_col' => $descCol,
+        'slug_col' => $slugCol,
+        'monthly_col' => $monthlyCol,
+        'disabled_period_cols' => $disabledPeriodCols,
+        'category_col' => $categoryCol,
+        'relation' => $relation,
+        'template' => $template,
+    ];
+}
+
+function ensureCategory(PDO $pdo, array $plan, bool $dryRun): array
+{
+    $existing = findCategory($pdo, $plan, CATEGORY_NAME);
+    if ($existing) {
+        out('Kategorie existiert bereits, keine Änderung: ID ' . $existing[$plan['id_col']]);
+        return [
+            'id' => $existing[$plan['id_col']],
+            'slug' => $plan['slug_col'] ? (string)($existing[$plan['slug_col']] ?? CATEGORY_SLUG) : null,
+        ];
+    }
+
+    $overrides = [
+        $plan['name_col'] => CATEGORY_NAME,
+    ];
+    if ($plan['desc_col']) {
+        $overrides[$plan['desc_col']] = CATEGORY_DESCRIPTION;
+    }
+    if ($plan['slug_col']) {
+        $overrides[$plan['slug_col']] = CATEGORY_SLUG;
+    }
+    applyVisibilityOverrides($plan['cols'], $overrides);
+
+    out(($dryRun ? 'Würde Kategorie anlegen: ' : 'Lege Kategorie an: ') . CATEGORY_NAME);
+    $id = insertFromTemplate($pdo, $plan['table'], $plan['cols'], $plan['template'], $overrides, $dryRun);
+
+    return [
+        'id' => $dryRun ? null : $id,
+        'slug' => $plan['slug_col'] ? CATEGORY_SLUG : null,
+    ];
+}
+
+function ensureProduct(PDO $pdo, array $plan, $categoryId, array $product, bool $dryRun): array
+{
+    if (!$dryRun && ($categoryId === null || $categoryId === '')) {
+        throw new RuntimeException('Kategorie-ID fehlt für Produktanlage.');
+    }
+
+    $existing = $categoryId === null ? null : findProduct($pdo, $plan, $categoryId, $product['name']);
+    if ($existing) {
+        out('Produkt existiert bereits, keine Änderung: ' . $product['name'] . ' (ID ' . $existing[$plan['id_col']] . ')');
+        return [
+            'name' => $product['name'],
+            'id' => $existing[$plan['id_col']],
+            'status' => 'vorhanden',
+        ];
+    }
+
+    $overrides = [
+        $plan['name_col'] => $product['name'],
+        $plan['desc_col'] => $product['description'],
+        $plan['monthly_col'] => $product['price'],
+    ];
+    if ($plan['slug_col']) {
+        $overrides[$plan['slug_col']] = slugify($product['name']);
+    }
+    if ($plan['category_col']) {
+        $overrides[$plan['category_col']] = $dryRun ? '<neue Kategorie-ID>' : $categoryId;
+    }
+    foreach ($plan['disabled_period_cols'] as $col) {
+        $overrides[$col] = disabledPeriodValue($plan['template'][$col] ?? null);
+    }
+    foreach (pickExistingColumns($plan['cols'], ['paytype', 'pay_type', 'p_type', 'billingtype', 'billing_type']) as $col) {
+        $overrides[$col] = recurringValueForColumn($plan['cols'][$col]);
+    }
+    foreach (pickExistingColumns($plan['cols'], ['currency', 'curr']) as $col) {
+        if (isStringType($plan['cols'][$col]['Type'])) {
+            $overrides[$col] = 'EUR';
+        }
+    }
+    applyVisibilityOverrides($plan['cols'], $overrides);
+
+    out(($dryRun ? 'Würde Produkt anlegen: ' : 'Lege Produkt an: ') . $product['name'] . ' für ' . $product['price'] . ' EUR/Monat');
+    $id = insertFromTemplate($pdo, $plan['table'], $plan['cols'], $plan['template'], $overrides, $dryRun);
+
+    if ($plan['relation']) {
+        out(($dryRun ? 'Würde Produkt/Kategorie-Relation anlegen: ' : 'Lege Produkt/Kategorie-Relation an: ') . relationDescription($plan['relation']));
+        if (!$dryRun) {
+            ensureRelation($pdo, $plan['relation'], $id, $categoryId);
+        }
+    }
+
+    return [
+        'name' => $product['name'],
+        'id' => $dryRun ? null : $id,
+        'status' => $dryRun ? 'dry-run' : 'neu',
+    ];
+}
+
+function findCategory(PDO $pdo, array $plan, string $name): ?array
+{
+    $sql = 'SELECT * FROM ' . qi($plan['table']) . ' WHERE ' . qi($plan['name_col']) . ' = ? LIMIT 1';
+    $stmt = $pdo->prepare($sql);
+    $stmt->execute([$name]);
+    $row = $stmt->fetch();
+    return $row ?: null;
+}
+
+function findProduct(PDO $pdo, array $plan, $categoryId, string $name): ?array
+{
+    if ($plan['category_col']) {
+        $sql = 'SELECT * FROM ' . qi($plan['table']) . ' WHERE ' . qi($plan['name_col']) . ' = ? AND ' . qi($plan['category_col']) . ' = ? LIMIT 1';
+        $stmt = $pdo->prepare($sql);
+        $stmt->execute([$name, $categoryId]);
+        $row = $stmt->fetch();
+        return $row ?: null;
+    }
+
+    $rel = $plan['relation'];
+    $sql = 'SELECT p.* FROM ' . qi($plan['table']) . ' p '
+        . 'INNER JOIN ' . qi($rel['table']) . ' r ON p.' . qi($plan['id_col']) . ' = r.' . qi($rel['product_col']) . ' '
+        . 'WHERE p.' . qi($plan['name_col']) . ' = ? AND r.' . qi($rel['category_col']) . ' = ? LIMIT 1';
+    $stmt = $pdo->prepare($sql);
+    $stmt->execute([$name, $categoryId]);
+    $row = $stmt->fetch();
+    return $row ?: null;
+}
+
+function insertFromTemplate(PDO $pdo, string $table, array $cols, array $template, array $overrides, bool $dryRun)
+{
+    $data = [];
+    foreach ($cols as $name => $meta) {
+        if (!isInsertableColumn($meta)) {
+            continue;
+        }
+        $data[$name] = array_key_exists($name, $overrides) ? $overrides[$name] : ($template[$name] ?? null);
+    }
+
+    out('  Overrides:');
+    foreach ($overrides as $col => $value) {
+        out('    ' . $col . ' = ' . printableValue($value));
+    }
+
+    if ($dryRun) {
+        return null;
+    }
+
+    $fields = array_keys($data);
+    $placeholders = [];
+    $values = [];
+    foreach ($fields as $i => $field) {
+        $placeholder = ':v' . $i;
+        $placeholders[] = $placeholder;
+        $values[$placeholder] = $data[$field];
+    }
+    $sql = 'INSERT INTO ' . qi($table) . ' (' . implode(', ', array_map('qi', $fields)) . ') VALUES (' . implode(', ', $placeholders) . ')';
+    $stmt = $pdo->prepare($sql);
+    $stmt->execute($values);
+    return $pdo->lastInsertId();
+}
+
+function ensureRelation(PDO $pdo, array $rel, $productId, $categoryId): void
+{
+    $check = $pdo->prepare('SELECT 1 FROM ' . qi($rel['table']) . ' WHERE ' . qi($rel['product_col']) . ' = ? AND ' . qi($rel['category_col']) . ' = ? LIMIT 1');
+    $check->execute([$productId, $categoryId]);
+    if ($check->fetchColumn()) {
+        return;
+    }
+
+    $cols = columns($pdo, $rel['table']);
+    $template = selectAnyRow($pdo, $rel['table']);
+    $overrides = [
+        $rel['product_col'] => $productId,
+        $rel['category_col'] => $categoryId,
+    ];
+
+    if ($template) {
+        insertFromTemplate($pdo, $rel['table'], $cols, $template, $overrides, false);
+        return;
+    }
+
+    $fields = array_keys($overrides);
+    foreach ($cols as $name => $meta) {
+        if (isset($overrides[$name]) || !isInsertableColumn($meta)) {
+            continue;
+        }
+        if (strtoupper((string)$meta['Null']) === 'NO' && $meta['Default'] === null) {
+            throw new RuntimeException("Relation {$rel['table']} hat Pflichtspalte {$name}; keine Template-Zeile vorhanden.");
+        }
+    }
+    $sql = 'INSERT INTO ' . qi($rel['table']) . ' (' . implode(', ', array_map('qi', $fields)) . ') VALUES (?, ?)';
+    $stmt = $pdo->prepare($sql);
+    $stmt->execute([$productId, $categoryId]);
+}
+
+function selectTemplateRow(PDO $pdo, string $table, array $cols, string $idCol): array
+{
+    [$where, $note] = visibilityWhere($cols);
+    if ($note) {
+        out("Hinweis {$table}: {$note}");
+    }
+    $sql = 'SELECT * FROM ' . qi($table) . ' WHERE ' . $where . ' ORDER BY ' . qi($idCol) . ' ASC LIMIT 1';
+    $row = $pdo->query($sql)->fetch();
+    if (!$row) {
+        throw new RuntimeException("Keine sichtbare Template-Zeile in {$table} gefunden.");
+    }
+    return $row;
+}
+
+function selectAnyRow(PDO $pdo, string $table): ?array
+{
+    $row = $pdo->query('SELECT * FROM ' . qi($table) . ' LIMIT 1')->fetch();
+    return $row ?: null;
+}
+
+function visibilityWhere(array $cols): array
+{
+    $clauses = [];
+    foreach (['visible', 'is_visible', 'show', 'enabled', 'active', 'is_active'] as $col) {
+        if (isset($cols[$col])) {
+            $clauses[] = qi($col) . " IN (1, '1', 'yes', 'Yes', 'true', 'True', 'on', 'On')";
+        }
+    }
+    foreach (['hidden', 'is_hidden', 'disabled', 'is_disabled', 'deleted', 'archived'] as $col) {
+        if (isset($cols[$col])) {
+            $clauses[] = qi($col) . " IN (0, '0', 'no', 'No', 'false', 'False', '', 'off', 'Off')";
+        }
+    }
+    foreach (['status', 'state'] as $col) {
+        if (isset($cols[$col])) {
+            $clauses[] = qi($col) . " IN ('Active', 'active', 'Enabled', 'enabled', 'Visible', 'visible', '1', 1)";
+        }
+    }
+
+    if (!$clauses) {
+        return ['1=1', 'keine bekannte Sichtbarkeitsspalte erkannt; nutze erste vorhandene Zeile als Vorlage.'];
+    }
+    return [implode(' AND ', $clauses), null];
+}
+
+function applyVisibilityOverrides(array $cols, array &$overrides): void
+{
+    foreach (['visible', 'is_visible', 'show', 'enabled', 'active', 'is_active'] as $col) {
+        if (isset($cols[$col])) {
+            $overrides[$col] = 1;
+        }
+    }
+    foreach (['hidden', 'is_hidden', 'disabled', 'is_disabled', 'deleted', 'archived'] as $col) {
+        if (isset($cols[$col])) {
+            $overrides[$col] = 0;
+        }
+    }
+    foreach (['status', 'state'] as $col) {
+        if (isset($cols[$col])) {
+            $overrides[$col] = statusActiveValue($cols[$col]);
+        }
+    }
+}
+
+function statusActiveValue(array $meta): string
+{
+    $type = (string)$meta['Type'];
+    if (preg_match_all("/'((?:[^'\\\\]|\\\\.)*)'/", $type, $matches)) {
+        foreach ($matches[1] as $value) {
+            if (in_array(strtolower($value), ['active', 'enabled', 'visible'], true)) {
+                return $value;
+            }
+        }
+        if (in_array('1', $matches[1], true)) {
+            return '1';
+        }
+    }
+    return 'Active';
+}
+
+function recurringValueForColumn(array $meta): string
+{
+    $type = (string)$meta['Type'];
+    if (preg_match_all("/'((?:[^'\\\\]|\\\\.)*)'/", $type, $matches)) {
+        foreach ($matches[1] as $value) {
+            if (stripos($value, 'recurr') !== false) {
+                return $value;
+            }
+        }
+    }
+    return 'Recurring';
+}
+
+function discoverProductCategoryRelation(PDO $pdo, string $productIdCol, string $categoryIdCol): ?array
+{
+    $tables = $pdo->query('SHOW TABLES')->fetchAll(PDO::FETCH_COLUMN);
+    foreach ($tables as $table) {
+        $lower = strtolower((string)$table);
+        if (!preg_match('/(product.*categor|categor.*product|product.*orderpage|orderpage.*product)/', $lower)) {
+            continue;
+        }
+        $cols = columns($pdo, (string)$table);
+        $productCol = pickColumn($cols, ['product_id', 'productid', 'prod_id', 'pid', $productIdCol]);
+        $categoryCol = pickColumn($cols, ['category_id', 'cat_id', 'categoryid', 'cid', 'orderpage_id', $categoryIdCol]);
+        if ($productCol && $categoryCol && $productCol !== $categoryCol) {
+            return [
+                'table' => (string)$table,
+                'product_col' => $productCol,
+                'category_col' => $categoryCol,
+            ];
+        }
+    }
+    return null;
+}
+
+function relationDescription(?array $relation): string
+{
+    if (!$relation) {
+        return '(keine Relation)';
+    }
+    return $relation['table'] . '.' . $relation['product_col'] . ' -> Produkt, ' . $relation['table'] . '.' . $relation['category_col'] . ' -> Kategorie';
+}
+
+function createBackup(PDO $pdo, string $source, string $backup): void
+{
+    $pdo->exec('CREATE TABLE ' . qi($backup) . ' LIKE ' . qi($source));
+    $pdo->exec('INSERT INTO ' . qi($backup) . ' SELECT * FROM ' . qi($source));
+}
+
+function uniqueBackupName(PDO $pdo, string $table, int $timestamp): string
+{
+    $base = $table . '_bak_' . $timestamp;
+    $candidate = $base;
+    $i = 1;
+    while (tableExists($pdo, $candidate)) {
+        $candidate = $base . '_' . $i;
+        $i++;
+    }
+    return $candidate;
+}
+
+function autoIncrementColumn(array $cols): ?string
+{
+    foreach ($cols as $name => $meta) {
+        if (stripos((string)$meta['Extra'], 'auto_increment') !== false) {
+            return $name;
+        }
+    }
+    return null;
+}
+
+function isInsertableColumn(array $meta): bool
+{
+    $extra = strtolower((string)$meta['Extra']);
+    return strpos($extra, 'auto_increment') === false
+        && strpos($extra, 'generated') === false
+        && strpos($extra, 'virtual') === false
+        && strpos($extra, 'stored') === false;
+}
+
+function pickColumn(array $cols, array $candidates): ?string
+{
+    $map = [];
+    foreach (array_keys($cols) as $name) {
+        $map[strtolower($name)] = $name;
+    }
+    foreach ($candidates as $candidate) {
+        $key = strtolower($candidate);
+        if (isset($map[$key])) {
+            return $map[$key];
+        }
+    }
+    return null;
+}
+
+function pickExistingColumns(array $cols, array $candidates): array
+{
+    $found = [];
+    foreach ($candidates as $candidate) {
+        $col = pickColumn($cols, [$candidate]);
+        if ($col && !in_array($col, $found, true)) {
+            $found[] = $col;
+        }
+    }
+    return $found;
+}
+
+function pickMonthlyColumn(array $cols): ?string
+{
+    foreach (['m', 'monthly', 'month', 'price_monthly', 'monthly_price', 'recurring_monthly'] as $candidate) {
+        $col = pickColumn($cols, [$candidate]);
+        if ($col && isNumericType($cols[$col]['Type'])) {
+            return $col;
+        }
+    }
+    foreach ($cols as $name => $meta) {
+        if (stripos($name, 'month') !== false && isNumericType($meta['Type'])) {
+            return $name;
+        }
+    }
+    return null;
+}
+
+function pickProductCategoryColumn(array $cols): ?string
+{
+    foreach (['category_id', 'cat_id', 'categoryid', 'orderpage_id', 'cid', 'group_id'] as $candidate) {
+        $col = pickColumn($cols, [$candidate]);
+        if ($col && isNumericType($cols[$col]['Type'])) {
+            return $col;
+        }
+    }
+    $col = pickColumn($cols, ['category']);
+    if ($col && isNumericType($cols[$col]['Type'])) {
+        return $col;
+    }
+    return null;
+}
+
+function isNumericType(string $type): bool
+{
+    return (bool)preg_match('/\b(tinyint|smallint|mediumint|int|bigint|decimal|numeric|float|double|real)\b/i', $type);
+}
+
+function isStringType(string $type): bool
+{
+    return (bool)preg_match('/\b(char|varchar|text|enum|set)\b/i', $type);
+}
+
+function disabledPeriodValue($templateValue)
+{
+    if (is_numeric($templateValue) && (float)$templateValue < 0) {
+        return $templateValue;
+    }
+    return '-1';
+}
+
+function describeRow(array $row, string $idCol, ?string $nameCol): string
+{
+    $parts = ['ID ' . ($row[$idCol] ?? '?')];
+    if ($nameCol && isset($row[$nameCol])) {
+        $parts[] = $nameCol . '=' . printableValue($row[$nameCol]);
+    }
+    return implode(', ', $parts);
+}
+
+function printableValue($value): string
+{
+    if ($value === null) {
+        return 'NULL';
+    }
+    $str = (string)$value;
+    if (strlen($str) > 90) {
+        $str = substr($str, 0, 87) . '...';
+    }
+    return "'" . $str . "'";
+}
+
+function displayId($id, string $fallback = '<wird von AUTO_INCREMENT vergeben>'): string
+{
+    return ($id === null || $id === '') ? $fallback : (string)$id;
+}
+
+function slugify(string $value): string
+{
+    $value = str_replace(['Ä', 'Ö', 'Ü', 'ä', 'ö', 'ü', 'ß'], ['Ae', 'Oe', 'Ue', 'ae', 'oe', 'ue', 'ss'], $value);
+    $value = strtolower($value);
+    $value = preg_replace('/[^a-z0-9]+/', '-', $value) ?? $value;
+    $value = trim($value, '-');
+    return $value !== '' ? $value : 'kiwz-produkt';
+}
+
+function qi(string $identifier): string
+{
+    if (!preg_match('/^[A-Za-z0-9_]+$/', $identifier)) {
+        throw new RuntimeException("Unsicherer SQL-Identifier: {$identifier}");
+    }
+    return '`' . $identifier . '`';
+}
+
+function out(string $line): void
+{
+    echo $line . PHP_EOL;
+}
diff --git a/kiwz_prio_codes.txt b/kiwz_prio_codes.txt
new file mode 100644
index 0000000000000000000000000000000000000000..4acd9c408d2fbad263e01ddab541742f0709b92b
--- /dev/null
+++ b/kiwz_prio_codes.txt
@@ -0,0 +1,30 @@
+KIWZ-X7QA-H4NZ
+KIWZ-BM6T-9VDK
+KIWZ-3LPR-W8CY
+KIWZ-F2ZH-QN5A
+KIWZ-7GVM-KX4T
+KIWZ-N9QS-2JWR
+KIWZ-H8DC-V6PY
+KIWZ-4TFK-ZM7B
+KIWZ-Q2WX-5ANR
+KIWZ-Y6PJ-C9LT
+KIWZ-8KDA-RV3M
+KIWZ-Z5HN-WQ7F
+KIWZ-2VTC-JK9P
+KIWZ-M4RY-8XGB
+KIWZ-6QNL-FZ2D
+KIWZ-CP7W-3HKA
+KIWZ-J9XB-T5VQ
+KIWZ-W2MG-6RZN
+KIWZ-5DFY-P8KC
+KIWZ-A7VT-NQ4H
+KIWZ-R3KZ-Y6PM
+KIWZ-L8QC-2WJF
+KIWZ-9NBH-X5TA
+KIWZ-V4PR-7ZDG
+KIWZ-T6JW-M2QC
+KIWZ-G5KA-9VYR
+KIWZ-2XND-H8FP
+KIWZ-P7ZM-C4WT
+KIWZ-Q9RY-6BKL
+KIWZ-F3VC-J5XN

tokens used
45,117
Erledigt. Die vier Deliverables liegen im Arbeitsverzeichnis:

- [kiwz_hostbill_setup.php](/tmp/kiwz-hostbill-staging/kiwz_hostbill_setup.php)
- [kiwz_prio_codes.txt](/tmp/kiwz-hostbill-staging/kiwz_prio_codes.txt)
- [kiwz_gpuq_config_snippet.php](/tmp/kiwz-hostbill-staging/kiwz_gpuq_config_snippet.php)
- [ANLEITUNG.md](/tmp/kiwz-hostbill-staging/ANLEITUNG.md)

Geprüft:
- `php -l kiwz_hostbill_setup.php`: keine Syntaxfehler
- `php -l kiwz_gpuq_config_snippet.php`: keine Syntaxfehler
- `kiwz_prio_codes.txt`: 30 Codes, Format und Zeichenvorrat valide

Ich habe keinen DB-Connect, keinen Netzwerkzugriff und keinen Zugriff auf `/home/support.panomity.com` ausgeführt. Das Setup-Skript ist ausführbar gesetzt und bleibt standardmäßig im Dry-Run, bis `--apply` angegeben wird.