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.