Compare commits

...
Author SHA1 Message Date
Thomas Bartelt 4ea1daec9a fix(cms/zahlarten): Platzhalter [#customer_freetextfield3] im Bestelltext ersetzen wie in der Kasse 2026-09-27 22:48:11 +02:00
Thomas Bartelt 91d90b6714 feat(cms): Platzhalter [#zahlarten] in Textboxen -- Liste der aktiven Zahlarten der Kundengruppe aus der Kassen-Logik (Seite Bezahlarten nicht mehr von Hand); Mollie-Aktivschalter in shipping_area auf gruppenspezifisch (2), sonst saehen Haendler Mollie 2026-09-27 22:43:55 +02:00
Thomas Bartelt 21977b8f41 feat(payments/mollie): Zahlart 'Online-Banking' (Pay by Bank, paybybank); Google Pay inaktiv (nur Direkt-Integration) 2026-09-27 22:19:56 +02:00
Thomas Bartelt 5f3bbc2c45 feat(payments/mollie): Next-Gen-Webhooks (JSON, HMAC-SHA256 X-Mollie-Signature) neben klassischem id=tr_; fix: Bestellbestaetigung aus Webhook-/CLI-Kontext (Layout-Objekt im Bootstrap); markCancelled idempotent 2026-09-27 21:51:11 +02:00
Thomas Bartelt b8111a1e7f feat(payments): Mollie-Provider -- getrennte Zahlarten je Methode (Klarna, Kreditkarte, Apple Pay, Google Pay)
- core/payments/providers/mollie: Client (Bearer API-Key, Idempotency-Key), Provider nach dem
  PayPal-v2-Vertrag: start (POST /v2/payments mit method aus payment_methods.argument),
  onReturn/onCancel (Status per GET, Rueckkehr-URL traegt o=<orders.id>), onWebhook
  (id=tr_..., Wahrheit per GET), reconcile (paid/canceled/expired/failed, >24h abbrechen)
- Klarna: lines aus order_item (brutto je Stueck), Versand, Zahlartgebuehr, Gutschein, Rabatt,
  Rundungsausgleich; billing/shippingAddress mit E-Mail und Telefon (E.164)
- core/paymentmethods/molliepaymentmethod.class.php: Kassen-Adapter ohne Express
- install_mollie.sql: 16 Spalten in shipping_area (4 helperjoinkeys), Einstellungen Gruppe
  mollie, 4 Zahlarten (module mollie, argument = Methode), Freischaltung/Leichen als Kommentar
- Kasse neu: Mollie-Logos je Methode, Apple Pay nur auf faehigen Geraeten
- cli.php status: mehrere Zahlarten je Anbieter, Modus generisch
2026-09-27 10:39:23 +02:00
Thomas Bartelt d5a3727884 fix(artikel): attribute_7 nur als Ziffernfolge in die SQL (Leerzeichen => HTTP 500); zieht Live-Haertung vom 25.08. (Grundpreis-Teiler) nach 2026-09-27 10:16:49 +02:00
Thomas Bartelt 5844b24db4 style(warenkorb-neu): Leer-Ansicht rechtsbuendig, Knopf raus aus der Klick-Falle des fixierten Kopfes 2026-09-27 10:08:31 +02:00
Thomas Bartelt d0b79a2820 fix(warenkorb-neu, kasse-neu): Scroll nicht wiederherstellen, wenn der Korb leer wurde (Knopf lag unter dem fixierten Kopf); sticky Kaesten unter den 152px-Kopf (top 168px) 2026-09-27 10:02:47 +02:00
Thomas Bartelt 4ef81ceec3 fix(warenkorb-neu): 'Weiter einkaufen' ueber das Formular (cart_goto=weiter), Auto-Speichern mit Wartezeit statt Wettlauf gegen den Klick, Doppel-Submit-Riegel; Anfuehrungszeichen 2026-09-27 09:55:16 +02:00
Thomas Bartelt 4c1ecb82a7 fix(kasse): Gast-/Express-Bestellung ohne Anrede verlor die Adresse -- CustomerAddress::validate() verlangt honorific; leer => Firma/Herr, Fehlschlag jetzt im Log 2026-09-27 00:36:03 +02:00
Thomas Bartelt 30c81cae9c fix(kasse-neu): schliessende Klammer stand hinter dem Kommentar -- Syntaxfehler, Bestellen-Knopf ohne Handler 2026-09-26 23:51:42 +02:00
Thomas Bartelt def156e1c2 feat(kasse, warenkorb): neue Ansicht ist Standard, Rueckfall ?ansicht=alt per Session
- Weiche in website_shoppingcart.php / website_cashdesk.php gedreht: content_*_neu.tpl
  ist Standard, ?ansicht=alt schaltet die Sitzung auf die alten Templates, ?ansicht=neu zurueck
- Parameter ansicht=neu aus Form-Actions und Links der neuen Templates entfernt
- Erfolgsseite completed.tpl und payment_result.tpl (theme2) im Stil der neuen Kasse
  (Schrittleiste 3 Fertig, Karte, Bestellnummer, Text aus cash_desk_order_completed)
- Erfolgsseite ohne Navigation (site_type full_width) wie Warenkorb/Kasse
- sticky Bestellkasten unter dem klebenden Theme-Kopf (top 105px statt 16px)
- fix: Korbsumme 0 teilte in manage_gift_certificate() durch null (HTTP 500)
2026-09-26 23:43:41 +02:00
Tom Bartelt 6cea66126a fix(kasse): HTTP 500 in der Gast-Kasse bei leerem Land
- Country::getShippingAreaById: Land/Gruppe als int, leeres Land -> false
  statt 'WHERE id = ' (SQL-Fatal, leere Seite, 16x seit 03.09.2026)
- manage_guest_order_address: Lieferadresse = Rechnungsadresse ohne Land
  -> Standardland fuer die Versandgebiet-Suche
2026-09-22 21:21:38 +02:00
Tom Bartelt 40b4774f0d fix(kundencenter): SQL-Fatal beim Adresse speichern (leerer parent_id)
- main_widget_menu_2: !empty statt isset, sonst 'parent_id in(10636,)'
- add_address: exit nach dem Location-Header
- get_this_url: leere Parameter nicht anhaengen
2026-09-22 20:45:53 +02:00
Tom Bartelt 85bc1d8c9a feat(payments): Block Zahlungsvorgang im Bestell-Editor, Storno-Felder bei automatischem Storno
- Bestell-Editor rechts: Zahlungsvorgang (Status, Grund, Transaktions-ID,
  Referenz, Betraege, Zeiten, Verlauf aus PG payment_log), anbieterneutral
  ueber payment_transactions (Payments\Overview, Log::forOrder)
- markCancelled() fuellt Stornodatum, Stornostatus, Stornogrund und
  Storno-Nachricht der Bestellung (nur leere Felder)
- neuer Stornogrund 'Zahlung beim Anbieter nicht abgeschlossen' (install.sql)
- reconcile: 404-Grund lesbar mit Alter des Vorgangs
2026-09-22 09:30:52 +02:00
Thomas Bartelt fe868641d2 style(warenkorb-neu, kasse-neu): offizielle Web-Fassung des Gewaehrleistungs-Labels, 'Gross anzeigen' oeffnet das PDF 2026-09-18 21:07:26 +02:00
Thomas Bartelt 0c04dc9acd style(kasse-neu): Label-Zeile mit 'Gross anzeigen' und EU-Link wie im Warenkorb 2026-09-18 21:03:30 +02:00
Thomas Bartelt f642ba7f06 style(warenkorb-neu): unter dem Label rechts Link zu europa.eu/youreurope/garantien 2026-09-18 21:03:15 +02:00
Thomas Bartelt 8f2b69827e style(warenkorb-neu): Knopf 'Mengen aktualisieren' raus, getippte Menge speichert bei Feldwechsel 2026-09-18 21:02:02 +02:00
Thomas Bartelt 985e7b2e57 style(warenkorb-neu): ab 900px Spaltenbreite Stepper und Summe inline in der Zeile 2026-09-18 21:01:10 +02:00
Thomas Bartelt 4c9c2c6a29 style(warenkorb-neu): Positionen und Anmerkung als weisse Karten mit Rand und runden Ecken 2026-09-18 20:59:54 +02:00
Thomas Bartelt 65ced5261f fix(kasse-neu): Fehlerbox ohne .prop('hidden') (jQuery 1.6) 2026-09-18 20:23:09 +02:00
Thomas Bartelt 57a101baa8 fix(kasse-neu): jQuery-1.6-kompatibel (.submit statt .on, kein .addBack) -- .on() brach die Init ab, Lieferadresse klappte nicht mehr auf 2026-09-18 20:22:54 +02:00
Thomas Bartelt b396c674c9 style(kasse-neu): Zurueck-Link nach oben in die Schrittleiste 2026-09-18 20:20:20 +02:00
Thomas Bartelt 215252f9ef style(kasse-neu, warenkorb-neu): 150px Luft am Ende vor dem Footer-Balken 2026-09-18 20:18:57 +02:00
Thomas Bartelt 6361249fd0 feat(warenkorb-neu, kasse-neu): ohne Navigation (site_type full_width), breiterer Kasten ab 900px Spalte 2026-09-18 20:17:19 +02:00
Thomas Bartelt fa836e7a10 feat(kasse-neu): Rechtliches mit Link zur jeweiligen Seite in neuem Fenster (B2B-Fassungen fuer Haendler) 2026-09-18 20:15:47 +02:00
Thomas Bartelt 15270289f5 style(kasse-neu): Gewaehrleistungs-Label ueber den Bestellkasten 2026-09-18 20:14:15 +02:00
Thomas Bartelt 33d859f454 fix(kasse-neu): Scrollposition ueber Reloads (Zahlart/Versand/Land/Gutschein) retten 2026-09-18 20:11:29 +02:00
Thomas Bartelt 49ac0f7e35 feat(kasse): Entwurf neue Kassenansicht content_cash_desk_neu.tpl (Schritte links, Bestellkasten rechts, Feldmarkierung statt Alert), Weiche ?ansicht=neu
Feldnamen, IDs und content_cash_desk.js unveraendert; nur Markup, CSS und Absende-Pruefung neu.
2026-09-18 20:09:05 +02:00
Thomas Bartelt 5c80931569 style(warenkorb-neu): Schnell hinzufuegen ueber den Warenkorb 2026-09-18 20:02:09 +02:00
Thomas Bartelt 94b924fa46 fix(warenkorb-neu): Scrollposition ueber den Submit retten, Post-Redirect-Get nach Mengenaenderung 2026-09-18 19:59:04 +02:00
Thomas Bartelt 7430556460 style(warenkorb-neu): Gewaehrleistungs-Label in voller Hoehe (width 100%, height auto) 2026-09-18 19:57:16 +02:00
Thomas Bartelt 0521b4399f feat(warenkorb-neu): Zeile zweistufig (Bild+Name / Stepper+Summe) fuer die 722px-Spalte, Gewaehrleistungs-Label ueber dem Summenkasten, Stapeln per Container-Query
Bild label-gewaehrleistung.webp liegt in media/images (gitignored), auf Dev und Live kopiert.
2026-09-18 19:55:37 +02:00
Thomas Bartelt ae7e400645 feat(warenkorb): Entwurf neue Ansicht content_shoppingcart_neu.tpl (Zeilen statt Tabelle, Stepper, Summenkasten), Weiche ?ansicht=neu; Alt-Text am Thumbnail entfernt 2026-09-18 19:44:08 +02:00
Thomas Bartelt dc57c725f6 feat(warenkorb): ein Formular fuer Mengen, Anmerkung und Weiterleitung; Anmerkung in der Kasse editierbar
Vorher: Anmerkung in eigenem Formular mit 'Text speichern', 'Zur Kasse' als reiner Link
(ungespeicherte Mengen/Anmerkung gingen verloren), in der Kasse readonly mit dem Hinweis
'kann nur im Warenkorb bearbeitet werden' -- ohne technischen Grund, die Kasse postete das
Feld ohnehin mit. Jetzt: cart_goto ('kasse' | 'express:<modul>') im Warenkorb-Modul,
Kasse uebernimmt POST customer_info in die Session, Texte auf Deutsch geglaettet.
2026-09-18 19:38:02 +02:00
Thomas Bartelt dec101f0cb fix(kasse): Express-Hinweis nennt den Anzeigenamen des Anbieters statt 'Paypal v2' 2026-09-18 07:53:29 +02:00
Thomas Bartelt f1aef6e575 fix(paypal_v2): Anzeigename 'PayPal' statt 'PayPal (v2)' -- stand in Kundenmeldungen 2026-09-18 07:53:18 +02:00
Thomas Bartelt 56f9a425c9 fix(registrierung): Anrede mit Leerfeld statt stiller Vorbelegung 'Firma', USt-ID bei Firma keine Pflicht mehr 2026-09-18 07:48:38 +02:00
Thomas Bartelt 6f0036b819 fix(kasse): Anrede beim Express-Kauf wirklich keine Pflicht -- Inline-JS-Pruefung und Label
Die Aenderung vom 14.09. nahm nur die CSS-Klasse, die Absende-Pruefung in
content_cash_desk.tpl verlangte die Anrede weiter (Alert 'vollstaendige Rechnungsadresse').
Im Express-Weg sind stattdessen Vor- und Nachname Pflicht.
2026-09-18 07:45:26 +02:00
Thomas Bartelt f65901060f feat(payments): Express-Start mit Versand und Zahlartaufschlag, PATCH nur bei Abweichung
PayPal Live verlangt nach jeder Betragsaenderung eine zweite Freigabe (422
PAYER_ACTION_REQUIRED). Die Kasse rechnet den Endbetrag jetzt beim Express-Start vor
(erste aktive Versandart des Gebiets, Gast = Gebiet 1, eingeloggt = Land/Gruppe), der
Provider vergleicht vor dem PATCH mit dem Stand bei PayPal und laesst ihn bei Gleichstand
aus. Webhooks ordnen ohne custom_id ueber die PayPal-Order-ID zu.
2026-09-18 07:40:27 +02:00
Thomas Bartelt c8fc7fba2f fix(payments): Express-Rueckkehr nach zweiter PayPal-Freigabe nimmt den normalen Rueckweg mit Capture
PayPal Live antwortet auf den Capture nach dem Betrags-PATCH mit 422 PAYER_ACTION_REQUIRED
(Sandbox nicht). Der Kunde gibt erneut frei und kehrt ueber ?payment_express= zurueck; dort
scheiterte onExpressReturn an custom_id != 'express' und zeigte eine Fehlerseite, waehrend
die Bestellung offen blieb (Live-Bestellung 312143).
2026-09-18 07:30:17 +02:00
Thomas Bartelt b8bc8aa8bf feat(backend): Zahlarten-Liste zeigt ID und Modul 2026-09-18 07:02:48 +02:00
Thomas Bartelt 7e28a5275c style(warenkorb): Express-Button mit eigener Klasse .ppbutton (PayPal-Optik, nicht .button) 2026-09-13 21:53:58 +02:00
Thomas Bartelt 5e6fcb0c3d feat(payments): Express-Adressen nur aus erlaubten Laendern (Setting express_countries, Standard DE) 2026-09-13 21:49:09 +02:00
Thomas Bartelt da54028a6e feat(payments): Anrede beim Express-Kauf keine Pflicht (Anbieter liefert keine) 2026-09-13 21:48:09 +02:00
Thomas Bartelt f33533b411 feat(payments): Express-Kauf aus dem Warenkorb (PayPal v2): Start ohne Bestellung, Adresse von PayPal, Kasse vorbelegt, Capture ohne zweiten Besuch 2026-09-13 21:44:46 +02:00
Thomas Bartelt e4b3f5a1ea chore(payments): Webhook-Rohmitschnitt nur bei logs/webhook_debug 2026-09-13 21:35:26 +02:00
Thomas Bartelt 67af646f2e fix(backend): Secret-Felder ohne type=password -- Browser-Autofill ueberschrieb die Webhook-ID 2026-09-13 21:34:07 +02:00
Thomas Bartelt 4ce09ac1b6 fix(paypal_v2): Webhook-Signatur lokal pruefen (Zertifikat, CRC32, SHA256withRSA); API-Verify nur als Rueckfall 2026-09-13 21:32:17 +02:00
Thomas Bartelt 4248f8c148 fix(paypal_v2): Webhook-Signatur mit rohem Body pruefen, Ablehnungsgrund protokollieren 2026-09-13 21:19:36 +02:00
Thomas Bartelt 83cf8b9808 chore(dev): display_errors aus -- Warnungen im Formular-HTML loesten WAF-403 in der Kasse aus 2026-09-13 21:00:58 +02:00
Thomas Bartelt 2698d518dc fix(payments): Registry laedt Kernklassen sofort (Fatal im Zahlart-Editor beim Speichern) 2026-09-13 20:33:44 +02:00
Thomas Bartelt 37017b25dd sync(dev): fehlende Admin-Theme-Assets vom Live-Stand ergaenzt (nichts ueberschrieben) 2026-09-13 19:58:55 +02:00
Thomas Bartelt 726f897c3d chore(dev): payment_webhook.php ohne Basic-Auth erreichbar 2026-09-13 19:52:45 +02:00
Thomas Bartelt 397fb5944a feat(payments): Anbieter-Gerüst core/payments mit PayPal Orders v2
- ProviderInterface/AbstractProvider: Vorgang mit Uhrzeit+Session (payment_transactions),
  Mail und Warenkorb erst nach Zahlung, idempotentes markPaid, Betragsabgleich,
  shop_event + PG payment_log
- Secrets verschluesselt (libsodium) in MySQL, Schluessel ausserhalb Docroot
- Cashdesk: ?payment_return/?payment_cancel, Pending-Riegel, zahlung_erst_spaeter()
- Backend Zahlart-Editor: Einstellungen speichern (war ohne Empfaenger), Secret-Felder
- payment_webhook.php (Standalone), cli.php (init-key, secret, reconcile, status)
- PayPal-v2-Blaupause vom Juli entfernt
2026-09-13 19:51:52 +02:00
Thomas Bartelt af81e8f63a sync: Zahlungsstrecke, PG-Events, Fatal-Log und PayPal-v2-Blaupause vom Live-Stand 2026-09-13
Deploy to Dev / deploy (push) Successful in 1s
2026-09-13 19:20:24 +02:00
Thomas Bartelt 20d55f77b8 wip: Mobile-Theme-Testphase und PHP-8.2-Guards (Stand Juni 2026) 2026-09-13 19:10:56 +02:00
1801 changed files with 56568 additions and 2067 deletions
+12
View File
@@ -1,3 +1,15 @@
# === DEV-Server: HTTP Basic Auth (kein Kunde, kein Bot) ===
AuthType Basic
AuthName "Dev-Server intelectra"
AuthUserFile /etc/apache2/htpasswd_dev_intelectra
Require valid-user
# 13.09.2026: Webhook-Endpunkt der Zahlungsanbieter muss ohne Basic-Auth erreichbar sein
# (PayPal stellt sonst nicht zu). Der Endpunkt prueft die Signatur selbst.
<Files "payment_webhook.php">
Require all granted
</Files>
# === ENDE Auth-Block ===
RewriteEngine On
+20 -5
View File
@@ -176,12 +176,27 @@ class CartSessionManager {
public function updateCartStatus($session_key, $status) {
$session_key_clean = $this->db->real_escape_string($session_key);
$status_clean = $this->db->real_escape_string($status);
// 25.08.2026: 'completed' ist ein Endzustand -- Geld ist eingezogen, daran aendert
// ein spaeterer Request nichts mehr. Vorher konnte jeder Reload der Rueckkehr-URL
// eine bezahlte Bestellung auf 'abandoned' zuruecksetzen (belegt am 24.08. bei
// Order 111902 und beim Kunden EC-6PP77504DT5715642). Die Statistik zaehlte solche
// Faelle als Abbruch, obwohl bezahlt wurde.
//
// Der Riegel sitzt hier unten in der WHERE-Klausel und nicht im aufrufenden Code,
// damit er auch fuer kuenftige Aufrufer gilt -- und weil MySQL das atomar
// entscheidet: zwei gleichzeitig zurueckkehrende Requests koennen sich nicht mehr
// gegenseitig ueberholen.
$schutz = '';
if ($status_clean !== 'completed') {
$schutz = " AND status <> 'completed'";
}
$query = "
UPDATE cart_sessions
SET status = '{$status_clean}',
updated_at = CURRENT_TIMESTAMP
WHERE session_key = '{$session_key_clean}'
UPDATE cart_sessions
SET status = '{$status_clean}',
updated_at = CURRENT_TIMESTAMP
WHERE session_key = '{$session_key_clean}'{$schutz}
";
$result = $this->db->query($query);
+32
View File
@@ -95,6 +95,38 @@ class Config extends Main {
}
}
// === Mobile-Theme-Switch (Test-Phase) ===
// Priorität:
// 1. URL-Parameter ?theme=X (setzt Cookie für 30 Tage)
// 2. Cookie THEME_OVERRIDE
// 3. User-Agent: iOS/Android Mobile → automatisch Mobile-Theme
// Whitelist verhindert beliebige Theme-Werte (Sicherheit).
$theme_whitelist = ['theme2', 'mobile'];
$is_https = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on')
|| (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https');
if (isset($_GET['theme']) && in_array($_GET['theme'], $theme_whitelist, true)) {
// iOS Safari/Chrome braucht modernes Cookie-Format mit SameSite + Secure
setcookie('THEME_OVERRIDE', $_GET['theme'], [
'expires' => time() + 30 * 86400,
'path' => '/',
'secure' => $is_https,
'httponly' => false,
'samesite' => 'Lax',
]);
$this->shopConfiguration['THEME'] = $_GET['theme'];
} elseif (isset($_COOKIE['THEME_OVERRIDE']) && in_array($_COOKIE['THEME_OVERRIDE'], $theme_whitelist, true)) {
$this->shopConfiguration['THEME'] = $_COOKIE['THEME_OVERRIDE'];
} else {
// Automatik: iOS oder Android-Mobile? → Mobile-Theme
$ua = isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : '';
if (preg_match('/iPhone|iPod|iPad|Android.*Mobile|webOS|BlackBerry|IEMobile|Opera Mini/i', $ua)) {
$this->shopConfiguration['THEME'] = 'mobile';
}
}
// === Ende Theme-Switch ===
foreach ($text_values as $obj) {
$this->shopConfiguration[$obj->name] = str_replace('{$THEME_DIR}', STANDARD_DIR.'web/'.SHOP_SYSTEM.'/themes/'.$this->shopConfiguration['THEME'], $obj->setting);
}
+8
View File
@@ -261,6 +261,14 @@ class Country extends Main {
public static function getShippingAreaById($countryId, $customer_group_id = false) {
$db = Registry::get('base')->db;
// 22.09.2026: leeres Land (Gast-Kasse per Enter abgeschickt, Land nicht gewaehlt) lief in
// "WHERE id = " und damit in einen SQL-Fatal -> HTTP 500 mit leerer Seite in der Kasse.
$countryId = (int) $countryId;
$customer_group_id = (int) $customer_group_id;
if ($countryId <= 0) {
return false;
}
$sql = "SELECT shipping_area_id
FROM countries
WHERE id = $countryId";
+2 -1
View File
@@ -404,7 +404,8 @@ class Cs_ticket extends Main {
// get agb's ...
$site_content_object = new SiteContent($this->base_object);
$var_data['[#datenschutz]'] = $site_content_object->get_textbox_data_by_id(55);
// Datenschutz: aktuelle Version aus struct 10737 (DSGVO-Update). Alte struct 55 (id 50) ist veraltet.
$var_data['[#datenschutz]'] = $site_content_object->get_textbox_data_by_id(10737);
$var_data['[#agb]'] = $site_content_object->get_textbox_data_by_id(56);
$var_data['[#agb_b2b]'] = $site_content_object->get_textbox_data_by_id(10632);
$var_data['[#widerruf]'] = $site_content_object->get_textbox_data_by_id(60);
+142
View File
@@ -0,0 +1,142 @@
<?php
/**
* Absturzprotokoll (25.08.2026).
*
* Der Shop schaltet in index.php bewusst alles ab:
* ini_set('display_errors', 0); error_reporting(0);
* Das ist fuer die Anzeige richtig -- nur wurde damit auch das Protokollieren still
* gelegt. Ueber 130 HTTP-500 in zehn Tagen standen nirgends: nicht in error_log.txt,
* nicht im Apache-Log. Gefunden wurden sie erst, als Google sich beschwerte.
*
* DIESE DATEI AENDERT NICHTS AM VERHALTEN DES SHOPS.
* register_shutdown_function() und error_get_last() arbeiten unabhaengig von
* error_reporting -- kein ini_set, kein eigener Exception-Handler (der wuerde aus einem
* sauberen HTTP 500 eine halb gerenderte 200er-Seite machen). Ungefangene Exceptions
* erzeugen ihrerseits einen E_ERROR und landen deshalb ebenfalls hier.
*
* Geschrieben wird in eine EIGENE Datei, nicht in error_log.txt -- dort gehen zwischen
* ein paar tausend Bot-Meldungen pro Tag genau die Zeilen unter, um die es geht.
*
* Auswertung:
* tail -n 40 logs/fatal.log
* grep -c FATAL logs/fatal.log
* awk -F' | ' '{print $4}' logs/fatal.log | sort | uniq -c | sort -rn # haeufigste Stellen
*/
if (!function_exists('intelectra_fatal_log_init')) {
function intelectra_fatal_log_init() {
register_shutdown_function('intelectra_fatal_log_shutdown');
}
function intelectra_fatal_log_shutdown() {
$fehler = error_get_last();
if (!is_array($fehler)) {
return;
}
// Nur echte Abstuerze. Warnungen und Notices interessieren hier nicht -- der
// letzte error_get_last() waere sonst bei fast jedem Request gefuellt.
$fatal = array(E_ERROR, E_PARSE, E_CORE_ERROR, E_CORE_WARNING,
E_COMPILE_ERROR, E_COMPILE_WARNING, E_USER_ERROR);
if (!in_array($fehler['type'], $fatal, true)) {
return;
}
try {
$datei = __DIR__ . '/../logs/fatal.log';
// Gleiche Stelle nicht im Sekundentakt mitschreiben: eine Bot-Welle auf eine
// kaputte Seite wuerde das Log sonst in Minuten unbrauchbar machen.
if (intelectra_fatal_log_schon_gesehen($fehler)) {
return;
}
// Bei 5 MB einmal wegrotieren. Mehr als zwei Generationen braucht es nicht --
// wer hier nachsieht, sucht den Fehler von heute.
if (@filesize($datei) > 5242880) {
@rename($datei, $datei . '.1');
}
$anfrage = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '-';
$methode = isset($_SERVER['REQUEST_METHOD']) ? $_SERVER['REQUEST_METHOD'] : '-';
$referer = isset($_SERVER['HTTP_REFERER']) ? $_SERVER['HTTP_REFERER'] : '-';
$ip = isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '-';
$agent = isset($_SERVER['HTTP_USER_AGENT']) ? substr($_SERVER['HTTP_USER_AGENT'], 0, 120) : '-';
// Die Meldung einer ungefangenen Exception bringt den Stack-Trace mit. Der ist
// das Wertvollste daran, aber mehrzeilig -- fuer eine Zeile pro Fehler wird er
// auf die ersten Ebenen eingedampft.
$meldung = preg_replace('/\s+/', ' ', trim($fehler['message']));
if (strlen($meldung) > 600) {
$meldung = substr($meldung, 0, 600) . ' …';
}
$zeile = sprintf(
"[%s] FATAL | %s | %s:%d | %s %s | ref=%s | ip=%s | ua=%s\n",
date('Y-m-d H:i:s'),
$meldung,
$fehler['file'],
$fehler['line'],
$methode,
$anfrage,
$referer,
$ip,
$agent
);
@file_put_contents($datei, $zeile, FILE_APPEND | LOCK_EX);
} catch (Throwable $e) {
// Ein Protokoll, das selbst Fehler wirft, ist schlimmer als keins.
}
}
/**
* Wurde genau dieser Fehler gerade eben schon protokolliert?
*
* Schluessel ist Datei + Zeile + Meldung, Sperrfrist 60 Sekunden. Die Merkliste liegt
* neben dem Log und wird nur im Fehlerfall angefasst -- im Normalbetrieb kostet diese
* Datei nichts.
*/
function intelectra_fatal_log_schon_gesehen($fehler) {
$merkdatei = __DIR__ . '/../logs/.fatal_seen.json';
$schluessel = md5($fehler['file'] . ':' . $fehler['line'] . ':' . $fehler['message']);
$jetzt = time();
$gesehen = array();
if (is_readable($merkdatei)) {
$roh = @file_get_contents($merkdatei);
if ($roh) {
$dekodiert = @json_decode($roh, true);
if (is_array($dekodiert)) {
$gesehen = $dekodiert;
}
}
}
if (isset($gesehen[$schluessel]) && ($jetzt - (int)$gesehen[$schluessel]) < 60) {
return true;
}
// Abgelaufene Eintraege raus, damit die Datei nicht waechst.
foreach ($gesehen as $k => $zeitpunkt) {
if (($jetzt - (int)$zeitpunkt) > 3600) {
unset($gesehen[$k]);
}
}
$gesehen[$schluessel] = $jetzt;
if (count($gesehen) > 200) {
$gesehen = array_slice($gesehen, -200, 200, true);
}
@file_put_contents($merkdatei, json_encode($gesehen), LOCK_EX);
return false;
}
}
/* EOF */
+3 -3
View File
@@ -4670,7 +4670,7 @@ private function generateImageFileName($warning) {
$obj->tax_rate = $this->tax_rates[$obj->tax_id]->steuersatz;
$obj->manufacturer_name = '';
if (isset($this->manufacturers[$obj->manufacturer_id])) {
$obj->manufacturer_name = $this->manufacturers[$obj->manufacturer_id]->name;
$obj->manufacturer_name = isset($this->manufacturers[$obj->manufacturer_id]->name) ? $this->manufacturers[$obj->manufacturer_id]->name : '';
}
$obj->structure_name = '';
if (isset($this->structure[$obj->structure_id])) {
@@ -4837,7 +4837,7 @@ private function generateImageFileName($warning) {
$obj->tax = $this->all_tax[$obj->tax_id]->steuersatz;
$obj->price_tax_text = str_replace('[tax]', number_format($obj->tax, 2, ',', '.'), isset($this->base_object->customer_group->price_tax_text) ? $this->base_object->customer_group->price_tax_text : 'inkl. [tax]% MwSt.');
$obj->manufacturer_text = $this->manufacturers[$obj->manufacturer_id]->name;
$obj->manufacturer_text = isset($this->manufacturers[$obj->manufacturer_id]->name) ? $this->manufacturers[$obj->manufacturer_id]->name : '';
$obj->manufacturer_data = isset($this->manufacturers[$obj->manufacturer_id]) ? $this->manufacturers[$obj->manufacturer_id] : null;
if ($show_taxes) {
@@ -5401,7 +5401,7 @@ private function generateImageFileName($warning) {
$obj->tax = $this->all_tax[$obj->tax_id]->steuersatz;
$obj->price_tax_text = str_replace('[tax]', number_format($obj->tax, 2, ',', '.'), isset($this->base_object->customer_group->price_tax_text) ? $this->base_object->customer_group->price_tax_text : 'inkl. [tax]% MwSt.');
$obj->manufacturer_text = $this->manufacturers[$obj->manufacturer_id]->name;
$obj->manufacturer_text = isset($this->manufacturers[$obj->manufacturer_id]->name) ? $this->manufacturers[$obj->manufacturer_id]->name : '';
$obj->manufacturer_data = isset($this->manufacturers[$obj->manufacturer_id]) ? $this->manufacturers[$obj->manufacturer_id] : null;
if ($show_taxes) {
+15
View File
@@ -58,6 +58,15 @@ class layout extends Smarty {
header('content-type: text/html; charset=utf-8');
$this->__construct();
// Smarty 4.5+ verlangt explizite Plugin-Registrierung für PHP-Funktionen als Modifier.
// Legacy-Templates nutzen u.a. |stristr — hier zentral registrieren statt überall ersetzen.
foreach (['stristr', 'strstr', 'str_replace', 'preg_replace', 'urlencode', 'urldecode', 'trim'] as $fn) {
if (function_exists($fn)) {
$this->registerPlugin('modifier', $fn, $fn);
}
}
$this->compile_id = SHOP_SYSTEM.'_'.$base_object->config->shopConfiguration['THEME'];
$this->compile_dir = ROOT_DIR.'/tmp/smarty_compile';
$this->cache_dir = ROOT_DIR.'/tmp/smarty_cache';
@@ -65,6 +74,12 @@ class layout extends Smarty {
$tpl_paths = array();
$tpl_paths[] = ROOT_DIR.'/web/'.SHOP_SYSTEM.'/themes/'.$base_object->config->shopConfiguration['THEME'].'/templates';
// Mobile-Theme: theme2 als Fallback vor dem Parent — Mobile überschreibt nur
// die Schlüssel-Templates, alles andere kommt aus theme2 bzw. easyshop_advanced.
if ($base_object->config->shopConfiguration['THEME'] === 'mobile') {
$tpl_paths[] = ROOT_DIR.'/web/'.SHOP_SYSTEM.'/themes/theme2/templates';
}
if ($theme->parent_theme) {
$tpl_paths[] = ROOT_DIR.'/themes/'.$theme->parent_theme.'/templates';
}
+1 -1
View File
@@ -381,7 +381,7 @@ class Manufacturer extends Main {
if ($result->num_rows > 0) {
while ($obj = $result->fetch_object()) {
$return[$obj->manufacturer_id] = $manufacturers[$obj->manufacturer_id];
$return[$obj->manufacturer_id] = isset($manufacturers[$obj->manufacturer_id]) ? $manufacturers[$obj->manufacturer_id] : null;
}
asort($return);
return $return;
+10
View File
@@ -15,11 +15,21 @@ class Payment_method extends Main {
'title' => 'Zahlungsarten',
'db_table' => 'payment_methods',
'list_fields' => array(
array(
'db_field' => 'id',
'name' => 'ID',
'sortable' => 1
),
array(
'db_field' => 'name',
'name' => 'Zahlungsart',
'sortable' => 1
),
array(
'db_field' => 'module',
'name' => 'Modul',
'sortable' => 1
),
array(
'db_field' => 'active',
'name' => 'Status',
@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
/**
* Zahlart-Adapter fuer den Cashdesk: payment_methods.module = 'mollie'.
*
* Gilt fuer ALLE Mollie-Zahlarten (Klarna, Kreditkarte, Apple Pay, Google Pay ...): welche
* Methode gemeint ist, steht in payment_methods.argument der gewaehlten Zahlart; der
* Provider liest sie ueber die Bestellung. Kopie des PayPal-v2-Adapters ohne Express.
*/
require_once __DIR__ . '/../payments/Registry.php';
class MolliePaymentMethod
{
private const MODULE = 'mollie';
private $base_object;
private $layout_object;
private $customer_data;
private $payment_method;
private $gift_certificate = false;
private $order_id = false;
public function __construct($base_object, $layout_object)
{
$this->base_object = $base_object;
$this->layout_object = $layout_object;
}
public function set_data($customer, $shipping, $billing, $shoppingcart, $delivery, $payment, $order_id = false)
{
$this->customer_data = $customer;
$this->payment_method = $payment;
$this->order_id = $order_id;
}
public function set_gift_certificate($gift_certificate)
{
$this->gift_certificate = $gift_certificate;
}
public function set_order_id($order_id)
{
$this->order_id = $order_id;
}
/** Vor dem Speichern: Anbieter einsatzbereit und Methode gesetzt? Sonst Fehlertext fuer die Kasse. */
public function before_order($argument = null)
{
$provider = \Payments\Registry::get(self::MODULE, $this->base_object);
if (!$provider) {
return 'Diese Zahlart ist derzeit nicht verfuegbar. Bitte waehlen Sie eine andere Zahlart.';
}
$method = \strtolower(\trim((string) $argument));
if ($method === '' || !\preg_match('/^[a-z0-9]{2,30}$/', $method)) {
\error_log('Mollie: Zahlart ohne Methode im Feld argument (payment_methods.id ' . ($this->payment_method->id ?? '?') . ')');
return 'Diese Zahlart ist nicht richtig eingerichtet. Bitte waehlen Sie eine andere Zahlart.';
}
return '';
}
public function during_order($argument = null)
{
return '';
}
/**
* Nach dem Speichern: Zahlung bei Mollie anlegen, Kunde per 302 zur Checkout-Seite.
*/
public function after_order($order_total = false)
{
$amount = (float) $order_total;
if ($this->gift_certificate && isset($this->gift_certificate->with_vat)) {
$amount -= (float) $this->gift_certificate->with_vat;
}
$amount = \round($amount, 2);
$label = $this->label();
try {
$provider = \Payments\Registry::get(self::MODULE, $this->base_object);
if (!$provider) {
throw new \RuntimeException('Provider mollie nicht installiert');
}
$url = $provider->start((int) $this->order_id, $amount);
} catch (\Throwable $e) {
\error_log('Mollie after_order (Order ' . $this->order_id . '): ' . $e->getMessage());
if (\function_exists('shop_event')) {
\shop_event('failed', [
'order_id' => $this->order_id, 'betrag' => $amount,
'customer_id' => $this->customer_data->id ?? null,
'detail' => 'mollie: start fehlgeschlagen -- ' . $e->getMessage(),
]);
}
$this->layout_object->assign('call', 'payment_error');
$this->layout_object->assign('provider_label', $label);
$this->layout_object->assign('message', 'Die Zahlung ueber ' . \htmlspecialchars($label) . ' konnte nicht gestartet werden. Ihr Warenkorb ist noch da &ndash; bitte versuchen Sie es erneut oder waehlen Sie eine andere Zahlart.');
return $this->layout_object->_fetch('content_cash_desk/payment_result.tpl');
}
if (!\headers_sent()) {
\header('Location: ' . $url, true, 302);
exit;
}
$this->layout_object->assign('call', 'payment_open');
$this->layout_object->assign('provider_label', $label);
$this->layout_object->assign('payment_link', $url);
return $this->layout_object->_fetch('content_cash_desk/payment_result.tpl');
}
/** Rueckkehr laeuft ueber website_cashdesk::run() (?payment_return=mollie), nicht hier. */
public function change_order()
{
return '';
}
/** Anzeigename der gewaehlten Zahlart (z.B. "Klarna"), Rueckfall "Mollie". */
private function label(): string
{
$name = \trim((string) ($this->payment_method->name ?? ''));
return $name !== '' ? $name : 'Mollie';
}
}
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
/**
* Zahlart-Adapter fuer den Cashdesk: payment_methods.module = 'paypal_v2'.
*
* Duenn mit Absicht. Der Cashdesk erwartet diese Klasse (ucfirst(module).'PaymentMethod')
* mit set_data/before_order/during_order/after_order; alles Fachliche liegt im Provider
* unter core/payments/providers/paypal_v2/. Ein weiterer Anbieter bekommt eine Kopie
* dieser Datei mit anderem Modulnamen -- mehr nicht.
*/
require_once __DIR__ . '/../payments/Registry.php';
class Paypal_v2PaymentMethod
{
private const MODULE = 'paypal_v2';
private $base_object;
private $layout_object;
private $customer_data;
private $gift_certificate = false;
private $order_id = false;
public function __construct($base_object, $layout_object)
{
$this->base_object = $base_object;
$this->layout_object = $layout_object;
}
public function set_data($customer, $shipping, $billing, $shoppingcart, $delivery, $payment, $order_id = false)
{
$this->customer_data = $customer;
$this->order_id = $order_id;
}
public function set_gift_certificate($gift_certificate)
{
$this->gift_certificate = $gift_certificate;
}
public function set_order_id($order_id)
{
$this->order_id = $order_id;
}
/** Vor dem Speichern: Anbieter einsatzbereit? Sonst Fehlertext fuer die Kasse. */
public function before_order($argument = null)
{
$provider = \Payments\Registry::get(self::MODULE, $this->base_object);
if (!$provider) {
return 'PayPal ist derzeit nicht verfuegbar. Bitte waehlen Sie eine andere Zahlart.';
}
return '';
}
public function during_order($argument = null)
{
return '';
}
/**
* Nach dem Speichern: Zahlung anlegen, Kunde per 302 zum Anbieter.
* Der POST bekommt damit einen Redirect als Antwort -- die Zurueck-Taste landet auf
* dem Kassenformular, nicht auf einem erneut abschickbaren POST.
*/
public function after_order($order_total = false)
{
$amount = (float) $order_total;
if ($this->gift_certificate && isset($this->gift_certificate->with_vat)) {
$amount -= (float) $this->gift_certificate->with_vat;
}
$amount = \round($amount, 2);
try {
$provider = \Payments\Registry::get(self::MODULE, $this->base_object);
if (!$provider) {
throw new \RuntimeException('Provider paypal_v2 nicht installiert');
}
// Express aus dem Warenkorb: der Kunde war schon bei PayPal, Adresse liegt vor.
// Betrag nachziehen und einziehen -- ohne zweiten PayPal-Besuch.
$express = $_SESSION['easyshop']['payment_express'] ?? null;
if (\is_array($express) && ($express['module'] ?? '') === self::MODULE && !empty($express['reference'])) {
$url = $provider->finishExpress((int) $this->order_id, $amount, (string) $express['reference']);
unset($_SESSION['easyshop']['payment_express']);
if ($url === '') {
\header('Location: /Kasse/?success=1&order=' . \rawurlencode($this->resolve_order_number((int) $this->order_id)));
exit;
}
} else {
$url = $provider->start((int) $this->order_id, $amount);
}
} catch (\Throwable $e) {
\error_log('PayPal v2 after_order (Order ' . $this->order_id . '): ' . $e->getMessage());
if (\function_exists('shop_event')) {
\shop_event('failed', [
'order_id' => $this->order_id, 'betrag' => $amount,
'customer_id' => $this->customer_data->id ?? null,
'detail' => 'paypal_v2: start fehlgeschlagen -- ' . $e->getMessage(),
]);
}
$this->layout_object->assign('call', 'payment_error');
$this->layout_object->assign('provider_label', 'PayPal');
$this->layout_object->assign('message', 'PayPal konnte nicht gestartet werden. Ihr Warenkorb ist noch da &ndash; bitte versuchen Sie es erneut oder waehlen Sie eine andere Zahlart.');
return $this->layout_object->_fetch('content_cash_desk/payment_result.tpl');
}
if (!\headers_sent()) {
\header('Location: ' . $url, true, 302);
exit;
}
// Es wurde schon Output gesendet: Weiterleitungsseite mit Link + Meta-Refresh.
$this->layout_object->assign('call', 'payment_open');
$this->layout_object->assign('provider_label', 'PayPal');
$this->layout_object->assign('payment_link', $url);
return $this->layout_object->_fetch('content_cash_desk/payment_result.tpl');
}
/** Rueckkehr laeuft ueber website_cashdesk::run() (?payment_return=paypal_v2), nicht hier. */
public function change_order()
{
return '';
}
private function resolve_order_number(int $orderId): string
{
$res = $this->base_object->db->query('SELECT order_number FROM orders WHERE id=' . $orderId . ' LIMIT 1');
$row = $res ? $res->fetch_object() : null;
return $row ? (string) $row->order_number : (string) $orderId;
}
}
@@ -15,6 +15,10 @@ include_once('core/paypal/nvp.class.php');
include_once('core/paypal/expresscheckout.class.php');
include_once('core/cart_session_manager.class.php');
// 25.08.2026: Ereignisprotokoll in PG -- liefert die Uhrzeiten, die `orders` nicht hat.
// Faellt still aus, wenn Postgres nicht erreichbar ist (siehe shop_events.php).
include_once($_SERVER['DOCUMENT_ROOT'].'/core/postgres/shop_events.php');
class PaypalPaymentMethod {
@@ -54,8 +58,13 @@ class PaypalPaymentMethod {
$this->gift_certificate = false;
$this->order_id = false;
$this->return_url = 'http://'.$_SERVER['SERVER_NAME'].'/Kasse/';
$this->cancel_url = $this->return_url.'&cancel=true';
// 24.08.2026: https statt http -- die alte URL schickte jeden PayPal-Rueckkehrer
// erst durch eine 301-Umleitung, mit dem Token im Klartext.
// Und "?cancel=true" statt "&cancel=true": ohne "?" war der Query-String kaputt,
// $_GET['cancel'] wurde nie gesetzt und der Cancel-Zweig in change_order() lief
// ins Leere -- wer bei PayPal abgebrochen hat, landete auf einer toten URL.
$this->return_url = 'https://'.$_SERVER['SERVER_NAME'].'/Kasse/';
$this->cancel_url = $this->return_url.'?cancel=true';
} // end __construct
@@ -131,6 +140,41 @@ class PaypalPaymentMethod {
// Continue anyway - fallback to original session handling
}
// 24.08.2026 -- Post/Redirect/Get.
// Bis hierher kam der Kunde per POST auf /Kasse/. Wuerde jetzt direkt das
// Template mit dem PayPal-Link folgen, bliebe dieser POST im Browser stehen:
// F5 oder "Zurueck" schicken ihn erneut ab und legen eine zweite Bestellung an.
// Genau das war der Weg in "ORDER PREVENTED: Empty cart" (der Warenkorb war zu
// dem Zeitpunkt schon geloescht). Deshalb 302 auf eine reine GET-Seite --
// ein Reload dort laedt nur noch den Link neu.
//
// Der pending-Eintrag ist der zweite Riegel: kommt trotzdem noch einmal ein
// ready2fly-POST mit demselben Warenkorb, greift website_cashdesk.php darauf
// zurueck, statt eine weitere Order anzulegen.
$_SESSION['easyshop']['paypal_pending'] = array(
'token' => $token,
'order_id' => $this->order_id,
'total' => round((float)$order_total, 2),
'time' => time()
);
if (function_exists('shop_event')) {
shop_event('payment_started', array(
'order_id' => $this->order_id,
'payment_method' => 4,
'betrag' => $order_total,
'paypal_token' => $token,
'customer_id' => isset($this->customer_data->id) ? $this->customer_data->id : null
));
}
if (!headers_sent()) {
header('Location: /Kasse/?pp_open='.urlencode($token));
exit;
}
// Fallback: wurde wider Erwarten schon Output gesendet, bleibt es beim
// bisherigen Verhalten -- Template mit Link, ohne PRG.
$call = 'payment_open';
} else {
$message = 'Beim Initialisieren von PayPal ist ein Fehler aufgetreten.';
@@ -163,12 +207,57 @@ class PaypalPaymentMethod {
$order = new Order($this->base_object);
// 24.08.2026 -- Riegel VOR allen drei Zweigen: ist diese Bestellung schon bezahlt?
//
// PayPal-Tokens sind Einwegtickets. Ruft jemand die Rueckkehr-Adresse ein zweites
// Mal auf -- Zurueck-Taste, Reload, zweiter Tab -- scheitert jede weitere Nachfrage
// bei PayPal zwangslaeufig. Der Code stempelte die Bestellung daraufhin auf
// "abandoned" und zeigte eine Fehlerseite. Einem Kunden, der laengst bezahlt hat.
//
// Belegt am Testkauf 24.08.2026 (Order 111902, Token EC-2G607902TV9783051):
// 23:26:45 Updated ... to completed <- Geld eingezogen
// 23:26:56 Updated ... to abandoned <- Reload der Rueckkehr-Adresse
// 23:27:01 Updated ... to abandoned
// Dasselbe Muster am selben Tag bei einem echten Kunden (EC-6PP77504DT5715642).
// Ein Teil der vermeintlichen "Abbrueche" in der Statistik sind also bezahlte
// Bestellungen mit Fehlerseite.
//
// Deckt auch den cancel-Zweig ab: eine bezahlte Bestellung darf nicht nachtraeglich
// auf "storniert" gesetzt werden, nur weil jemand auf einem alten Tab abbricht.
$bezahlt_token = isset($_GET['token']) ? $_GET['token'] : '';
if ($bezahlt_token) {
$bezahlte_order = $order->get_order_by_paypal_token($bezahlt_token);
if (is_object($bezahlte_order) && !empty($bezahlte_order->paypal_transaction_id)) {
error_log("PayPal Fix: Rueckkehr auf bereits bezahlte Order "
. $bezahlte_order->id . " (Token {$bezahlt_token}) -- zeige Bestaetigung, "
. "kein erneuter PayPal-Aufruf");
// Status geraderuecken, falls ihn ein frueherer Fehlversuch verbogen hat.
$this->cart_session_manager->updateCartStatus($bezahlt_token, 'completed');
unset($_SESSION['easyshop']['paypal_pending']);
$this->layout_object->assign('order_number', $bezahlte_order->order_number);
$this->layout_object->assign('customer_number', $bezahlte_order->customer_number);
$this->layout_object->assign('call', 'payment_confirmed');
return $this->layout_object->_fetch('content_cash_desk/paypal.tpl');
}
}
if (isset($_GET['confirm'])) {
$token = $_GET['token'];
$payer_id = $_GET['PayerId'];
$order_data = $order->get_order_by_paypal_token($token);
// 24.08.2026: Session-ID aus dem Moment VOR dem PayPal-Sprung. Der Warenkorb
// liegt in shopping_cart_items an der session_id -- kommt der Kunde mit einer
// neuen Session zurueck (iOS/Safari verliert das Cookie gern), zeigt
// session_id() ins Leere und der alte Warenkorb bliebe stehen.
$orig_session_id = '';
// PAYPAL FIX: Restore cart session data after PayPal return
$cart_session = $this->cart_session_manager->retrieveCartSession($token);
if ($cart_session && $cart_session['cart_data']) {
@@ -184,7 +273,8 @@ class PaypalPaymentMethod {
$this->shipping_method = $cart_data['shipping_method'] ?? $this->shipping_method;
$this->gift_certificate = $cart_data['gift_certificate'] ?? $this->gift_certificate;
$this->order_id = $cart_data['order_id'] ?? $this->order_id;
$orig_session_id = $cart_data['php_session_id'] ?? '';
// Debug log
error_log("PayPal Fix: Restored order_id: {$this->order_id}, cart items: " . count($this->shoppingcart ?? []));
} else {
@@ -209,25 +299,84 @@ class PaypalPaymentMethod {
// PAYPAL FIX: Mark cart session as completed
$this->cart_session_manager->updateCartStatus($token, 'completed');
// 24.08.2026: JETZT erst den Warenkorb leeren -- Geld ist eingezogen.
// Vorher passierte das in website_cashdesk::after_saving_order(), also
// bevor PayPal ueberhaupt gefragt war. Wer dort abbrach oder neu lud,
// stand ohne Warenkorb da und musste alles neu zusammensuchen; genau
// daher stammen die Bestellpaare mit identischem Betrag.
$this->clear_cart_after_payment($orig_session_id);
unset($_SESSION['easyshop']['paypal_pending']);
// 24.08.2026: Bestellbestaetigung JETZT ausloesen, nicht schon bei der
// Order-Anlage. Vorher ging sie in website_cashdesk::save_order() raus --
// also bevor der Kunde ueberhaupt bei PayPal war. Beim Testkauf lagen
// zwischen Mail (23:25:09) und eingezogener Zahlung (23:26:45) anderthalb
// Minuten, und wer nie bezahlt hat, bekam trotzdem eine Bestaetigung.
$this->bestellbestaetigung_senden($order_data);
if (function_exists('shop_event')) {
shop_event('paid', array(
'order_id' => $order_data->id,
'order_number' => $order_data->order_number,
'payment_method' => 4,
'betrag' => $order->get_order_total_by_id($order_data->id),
'paypal_token' => $token,
'transaction_id' => isset($paypal_raw_data['PAYMENTINFO_0_TRANSACTIONID'])
? $paypal_raw_data['PAYMENTINFO_0_TRANSACTIONID'] : null,
'customer_id' => $order_data->customer_id
));
}
$call = 'payment_confirmed';
} else {
$this->layout_object->assign('message', 'Beim Bezahlabschluss ist ein Fehler aufgetreten. ');
// PAYPAL FIX: Mark cart session as abandoned
$this->cart_session_manager->updateCartStatus($token, 'abandoned');
if (function_exists('shop_event')) {
shop_event('failed', array(
'order_id' => isset($order_data->id) ? $order_data->id : null,
'order_number' => isset($order_data->order_number) ? $order_data->order_number : null,
'payment_method' => 4,
'paypal_token' => $token,
'detail' => 'DoExpressCheckoutPayment abgelehnt'
));
}
$call = 'payment_error';
}
} else if (isset($_GET['cancel'])) {
$token = $_GET['token'] ?? '';
// PAYPAL FIX: Mark cart session as abandoned on cancel
if ($token) {
$this->cart_session_manager->updateCartStatus($token, 'abandoned');
// 24.08.2026: $order_data war in diesem Zweig nie gesetzt -- der Aufruf
// unten lief auf ein undefiniertes Objekt und setzte im Zweifel Order 0
// auf "storniert". Die Order gehoert ueber den Token geholt.
$order_data = $order->get_order_by_paypal_token($token);
if (isset($order_data->id) && (int)$order_data->id > 0) {
$order->set_order_status('6', $order_data->id);
}
}
// Der Warenkorb bleibt jetzt stehen (er wird erst nach erfolgreicher Zahlung
// geleert) -- der Kunde kann direkt eine andere Zahlart waehlen, statt alles
// neu zusammenzusuchen.
unset($_SESSION['easyshop']['paypal_pending']);
if (function_exists('shop_event')) {
shop_event('cancelled', array(
'order_id' => isset($order_data->id) ? $order_data->id : null,
'payment_method' => 4,
'paypal_token' => $token,
'detail' => 'Kunde hat bei PayPal abgebrochen'
));
}
$call = 'payment_cancelled';
$order->set_order_status('6', $order_data->id ?? 0);
} else if (isset($_GET['token'])) {
$token = $_GET['token'];
$payer_id = $_GET['PayerID'];
@@ -267,6 +416,79 @@ class PaypalPaymentMethod {
} // end change_order
/**
* Loest die Bestellbestaetigung aus -- erst nach eingezogener Zahlung (24.08.2026).
*
* Der Versand haengt am Ticket-Event 3 (Cs_ticket::event), das bisher in
* website_cashdesk::save_order() direkt nach der Order-Anlage lief. Bei PayPal war das
* zu frueh: die Bestellung existiert dort schon, bevor der Kunde zu PayPal geschickt
* wird. Jeder Abbrecher bekam eine Bestellbestaetigung fuer etwas, das er nie bezahlt
* hat. Bei den zuletzt gemessenen Zahlen sind das rund ein Dutzend falsche
* Bestaetigungen pro Woche.
*
* Fuer alle anderen Zahlarten bleibt es, wo es war -- dort ist die Bestellung mit dem
* Abschicken tatsaechlich verbindlich.
*
* Wirft der Versand einen Fehler, wird er protokolliert und geschluckt: die Zahlung ist
* an dieser Stelle bereits eingezogen, eine Ausnahme wuerde dem Kunden eine Fehlerseite
* fuer eine erfolgreiche Bestellung zeigen.
*
* @param object $order_data Datensatz aus Order::get_order_by_paypal_token()
*/
private function bestellbestaetigung_senden($order_data) {
if (!is_object($order_data) || empty($order_data->id)) {
error_log('PayPal Fix: Bestellbestaetigung uebersprungen -- keine Order-Daten');
return;
}
try {
include_once './core/cs_ticket.class.php';
$ticket_object = new Cs_ticket($this->base_object);
$ticket_object->event(3, $order_data->customer_id, $order_data->id);
error_log('PayPal Fix: Bestellbestaetigung ausgeloest fuer Order '.$order_data->id);
} catch (Throwable $e) {
error_log('PayPal Fix: Bestellbestaetigung fuer Order '.$order_data->id
. ' fehlgeschlagen: '.$e->getMessage());
}
} // end bestellbestaetigung_senden
/**
* Leert den Warenkorb -- erst nach eingezogener Zahlung (24.08.2026).
*
* Zwei Session-IDs, weil der Kunde mit einer anderen zurueckkommen kann als er
* gegangen ist (iOS/Safari verliert das Session-Cookie beim Sprung ueber PayPal
* gern): die aktuelle und die, die beim Weggang in cart_sessions mitgeschrieben
* wurde. Was trotzdem liegenbleibt, faengt der DATEDIFF > 2 in
* Shoppingcart::clear() ein.
*
* @param string $orig_session_id session_id() aus dem Moment vor dem PayPal-Sprung
*/
private function clear_cart_after_payment($orig_session_id = '') {
$db = $this->base_object->db;
$ids = array();
$current = session_id();
if ($current) {
$ids[$current] = true;
}
if ($orig_session_id) {
$ids[$orig_session_id] = true;
}
foreach (array_keys($ids) as $sid) {
$db->query("
DELETE FROM
shopping_cart_items
WHERE
session_id = '".$db->real_escape_string($sid)."'
");
}
} // end clear_cart_after_payment
} // end PaypalPaymentMethod
/* EOF */
+526
View File
@@ -0,0 +1,526 @@
<?php
declare(strict_types=1);
namespace Payments;
/**
* Gemeinsamer Unterbau aller Anbieter mit Weiterleitung.
*
* Hier liegt alles, was 2026 am v1-PayPal muehsam nachgeruestet wurde und fuer
* jeden weiteren Anbieter genauso gelten muss:
* - Vorgang in payment_transactions mit echter Uhrzeit, Session und Sollbetrag
* - Bestellbestaetigung ERST nach eingezogener Zahlung
* - Warenkorb ERST nach eingezogener Zahlung leeren (ueber die Session von damals)
* - idempotentes "bezahlt": zweite Rueckkehr, Webhook-Race, Abgleicher -- egal wer
* zuerst kommt, es passiert genau einmal
* - Betragsabgleich gegen den Sollbetrag (Manipulationsschutz)
* - Ereignisse fuer den Funnel (shop_event) und Rohprotokoll (Payments\Log)
*
* Einstellungen kommen aus der Konfigurationsgruppe = module() (Tabelle configuration,
* pflegbar im Zahlart-Editor), Zugangsdaten aus Payments\Secrets.
*/
abstract class AbstractProvider implements ProviderInterface
{
protected object $base;
protected \mysqli $db;
/** @var array<string,string>|null */
private ?array $settings = null;
public function __construct(object $base)
{
$this->base = $base;
$this->db = $base->db;
}
public function paysAfterRedirect(): bool
{
return true;
}
// ------------------------------------------------------------ Einstellungen
/** Wert aus der Konfigurationsgruppe des Anbieters (configuration.group_id = module()). */
protected function setting(string $name, string $default = ''): string
{
if ($this->settings === null) {
$this->settings = [];
$g = $this->db->real_escape_string($this->module());
$res = $this->db->query("SELECT name, setting FROM configuration WHERE group_id='$g'");
while ($res && ($row = $res->fetch_assoc())) {
$this->settings[(string) $row['name']] = (string) $row['setting'];
}
}
$full = $this->module() . '_' . $name;
$v = $this->settings[$full] ?? $this->settings[$name] ?? '';
return $v !== '' ? $v : $default;
}
protected function secret(string $name): string
{
$v = Secrets::get($this->db, $this->module(), $name);
if ($v === null || $v === '') {
throw new \RuntimeException($this->label() . ': Zugangsdatum "' . $name . '" ist nicht hinterlegt (Backend > Zahlungsarten).');
}
return $v;
}
protected function shopBaseUrl(): string
{
if (\defined('MAIN_URL') && MAIN_URL !== '') {
return \rtrim((string) MAIN_URL, '/');
}
return 'https://' . ($_SERVER['SERVER_NAME'] ?? 'localhost');
}
/** Rueckkehr-URLs laufen durch den Cashdesk (Session vorhanden). */
protected function returnUrl(): string
{
return $this->shopBaseUrl() . '/Kasse/?payment_return=' . $this->module();
}
protected function cancelUrl(): string
{
return $this->shopBaseUrl() . '/Kasse/?payment_cancel=' . $this->module();
}
/** Webhook laeuft OHNE Session ueber den Standalone-Endpunkt. */
protected function webhookUrl(): string
{
return $this->shopBaseUrl() . '/payment_webhook.php?p=' . $this->module();
}
// ---------------------------------------------------------------- Vorgang
/**
* Legt den Vorgang an: Referenz beim Anbieter, Sollbetrag, Session, Approve-URL.
* Schreibt zusaetzlich orders.paypal_token (Backend zeigt das Feld bereits an).
*/
protected function openTransaction(int $orderId, string $reference, float $amount, string $approveUrl, ?array $raw = null): void
{
$order = $this->orderById($orderId);
$stmt = $this->db->prepare(
'INSERT INTO payment_transactions
(provider, order_id, order_number, reference, expected_amount, currency, status,
approve_url, session_id, customer_id, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,NOW(),NOW())
ON DUPLICATE KEY UPDATE reference=VALUES(reference), expected_amount=VALUES(expected_amount),
status=VALUES(status), approve_url=VALUES(approve_url), session_id=VALUES(session_id), updated_at=NOW()'
);
if (!$stmt) {
throw new \RuntimeException('payment_transactions: ' . $this->db->error);
}
$module = $this->module();
$number = (string) ($order->order_number ?? '');
$currency = $this->currency();
$status = 'open';
$sid = \function_exists('session_id') ? (string) \session_id() : '';
$cid = (int) ($order->customer_id ?? 0);
$stmt->bind_param('sissdssssi', $module, $orderId, $number, $reference, $amount, $currency, $status, $approveUrl, $sid, $cid);
$stmt->execute();
$stmt->close();
$ref = $this->db->real_escape_string($reference);
$this->db->query("UPDATE orders SET paypal_token='$ref' WHERE id=$orderId");
$this->pendingSet($orderId, $reference, $amount, $approveUrl);
$this->event('payment_started', $orderId, $number, $amount, $reference, null, $cid);
Log::write($module, 'start', $orderId, $number, $reference, 'open', null, $raw);
}
/**
* Gibt es zu dieser Anbieter-Referenz schon einen Vorgang (mit Bestellung)?
* Braucht die Kasse bei der Express-Rueckkehr: verlangt PayPal nach dem Betrags-PATCH
* eine zweite Freigabe, kommt der Kunde ueber die Express-URL zurueck, obwohl die
* Bestellung laengst existiert -- dann ist es eine normale Rueckkehr mit Capture.
*/
public function knownReference(string $reference): bool
{
return $reference !== '' && $this->transactionByReference($reference) !== null;
}
protected function transactionByReference(string $reference): ?object
{
$stmt = $this->db->prepare('SELECT * FROM payment_transactions WHERE provider=? AND reference=? LIMIT 1');
if (!$stmt) {
return null;
}
$m = $this->module();
$stmt->bind_param('ss', $m, $reference);
$stmt->execute();
$row = $stmt->get_result()->fetch_object();
$stmt->close();
return $row ?: null;
}
protected function transactionByOrder(int $orderId): ?object
{
$stmt = $this->db->prepare('SELECT * FROM payment_transactions WHERE provider=? AND order_id=? ORDER BY id DESC LIMIT 1');
if (!$stmt) {
return null;
}
$m = $this->module();
$stmt->bind_param('si', $m, $orderId);
$stmt->execute();
$row = $stmt->get_result()->fetch_object();
$stmt->close();
return $row ?: null;
}
/** Offene Vorgaenge fuer den Abgleicher. @return list<object> */
protected function openTransactions(int $olderThanMinutes, int $maxAgeHours): array
{
$stmt = $this->db->prepare(
'SELECT * FROM payment_transactions
WHERE provider=? AND status IN ("open","pending")
AND created_at < DATE_SUB(NOW(), INTERVAL ? MINUTE)
AND created_at > DATE_SUB(NOW(), INTERVAL ? HOUR)
ORDER BY id'
);
if (!$stmt) {
return [];
}
$m = $this->module();
$stmt->bind_param('sii', $m, $olderThanMinutes, $maxAgeHours);
$stmt->execute();
$rows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
$stmt->close();
return \array_map(static fn($r) => (object) $r, $rows);
}
protected function setTransactionStatus(string $reference, string $status, ?string $detail = null): void
{
$stmt = $this->db->prepare('UPDATE payment_transactions SET status=?, detail=?, updated_at=NOW() WHERE provider=? AND reference=?');
if ($stmt) {
$m = $this->module();
$stmt->bind_param('ssss', $status, $detail, $m, $reference);
$stmt->execute();
$stmt->close();
}
}
// ------------------------------------------------------------- bezahlt
/**
* Setzt die Bestellung genau einmal auf bezahlt, gleicht vorher den Betrag ab.
* Rueckgabe true, wenn die Bestellung jetzt (oder schon vorher) bezahlt ist.
*
* Atomar ueber payment_transactions.status: nur wer den Wechsel open/pending -> paid
* schafft, loest Mail und Warenkorb aus. Zweite Rueckkehr, Webhook und Abgleicher
* laufen dann ins Leere statt in Doppelmails.
*/
protected function markPaid(string $reference, string $paidAmount, string $paidCurrency, string $captureId, string $payerEmail = '', string $payerId = '', string $source = 'return'): bool
{
$tx = $this->transactionByReference($reference);
if (!$tx) {
\error_log($this->label() . ': markPaid ohne Vorgang (' . $reference . ')');
return false;
}
$orderId = (int) $tx->order_id;
if ($tx->status === 'paid') {
return true;
}
if (!$this->amountMatches((string) $tx->expected_amount, $paidAmount, (string) $tx->currency, $paidCurrency)) {
$msg = "Betrag weicht ab: erwartet {$tx->expected_amount} {$tx->currency}, gezahlt $paidAmount $paidCurrency";
\error_log($this->label() . " BETRAGS-MISMATCH Order $orderId ($reference): $msg");
$this->setTransactionStatus($reference, 'mismatch', $msg);
$this->event('failed', $orderId, (string) $tx->order_number, (float) $paidAmount, $reference, $captureId, (int) $tx->customer_id, $msg);
Log::write($this->module(), 'mismatch', $orderId, (string) $tx->order_number, $reference, 'mismatch', null, null, $msg);
return false;
}
// Der eine Schreiber gewinnt.
$stmt = $this->db->prepare(
'UPDATE payment_transactions SET status="paid", capture_id=?, paid_amount=?, paid_at=NOW(), updated_at=NOW(), detail=?
WHERE provider=? AND reference=? AND status IN ("open","pending")'
);
if (!$stmt) {
return false;
}
$m = $this->module();
$amt = (float) $paidAmount;
$stmt->bind_param('sdsss', $captureId, $amt, $source, $m, $reference);
$stmt->execute();
$won = $stmt->affected_rows === 1;
$stmt->close();
if (!$won) {
return $this->transactionByReference($reference)?->status === 'paid';
}
$cap = $this->db->real_escape_string($captureId);
$eml = $this->db->real_escape_string($payerEmail);
$pid = $this->db->real_escape_string($payerId);
$this->db->query(
"UPDATE orders SET payment_status=2, payment_date=CURDATE(), paypal_transaction_id='$cap'"
. ($payerEmail !== '' ? ", paypal_email='$eml'" : '')
. ($payerId !== '' ? ", paypal_payerid='$pid'" : '')
. " WHERE id=$orderId"
);
$this->clearCart((string) $tx->session_id);
$this->sendOrderConfirmation($orderId, (int) $tx->customer_id);
$this->pendingClear();
$this->event('paid', $orderId, (string) $tx->order_number, (float) $paidAmount, $reference, $captureId, (int) $tx->customer_id, 'via ' . $source);
return true;
}
/** Kunde hat abgebrochen oder der Anbieter hat den Vorgang verworfen. */
protected function markCancelled(string $reference, string $why, string $source = 'cancel'): void
{
$tx = $this->transactionByReference($reference);
if (!$tx || $tx->status === 'paid') {
return;
}
// 27.09.2026: idempotent -- Rueckkehr UND Webhook melden denselben Abbruch, der
// Stornotext wurde sonst zweimal an die Bestellung gehaengt (Mollie-Test 290617).
if ($tx->status === 'cancelled') {
return;
}
$this->setTransactionStatus($reference, 'cancelled', $why);
$orderId = (int) $tx->order_id;
// 6 = storniert (wie v1-PayPal im Cancel-Zweig).
// 22.09.2026 -- dazu die Storno-Felder der Bestellung fuellen, damit im Bestell-Editor
// steht, WARUM: bisher stand da nur "Storniert", der Grund lag allein in
// payment_transactions.detail. Vorhandene Eintraege werden nicht ueberschrieben.
$msg = $this->db->real_escape_string(
$this->label() . ': ' . $why . '. Zahlung nicht abgeschlossen, kein Geld geflossen. '
. 'Automatisch storniert (' . $source . ', ' . \date('d.m.Y H:i') . ').'
);
$reason = $this->cancellationReasonId();
$this->db->query(
"UPDATE orders SET order_status=6,
cancellation_date = COALESCE(cancellation_date, CURDATE()),
cancellation_status = IF(cancellation_status IS NULL OR cancellation_status=0, 2, cancellation_status),
cancellation_reason_id = " . ($reason > 0 ? "COALESCE(NULLIF(cancellation_reason_id,0), $reason)" : 'cancellation_reason_id') . ",
cancellation_info = IF(cancellation_info IS NULL OR cancellation_info='', '$msg', CONCAT(cancellation_info, '\n', '$msg'))
WHERE id=$orderId AND payment_status<>2"
);
$this->pendingClear();
$this->event('cancelled', $orderId, (string) $tx->order_number, (float) $tx->expected_amount, $reference, null, (int) $tx->customer_id, $why . ' (' . $source . ')');
Log::write($this->module(), 'cancel', $orderId, (string) $tx->order_number, $reference, 'cancelled', null, null, $why);
}
/** Stornogrund "Zahlung beim Anbieter nicht abgeschlossen" (install.sql), 0 wenn nicht angelegt. */
private function cancellationReasonId(): int
{
$res = $this->db->query("SELECT id FROM cancellation_reasons WHERE name='Zahlung beim Anbieter nicht abgeschlossen' LIMIT 1");
$r = $res ? $res->fetch_object() : null;
return $r ? (int) $r->id : 0;
}
protected function amountMatches(string $expected, string $paid, string $expCur, string $paidCur): bool
{
return \number_format((float) $expected, 2, '.', '') === \number_format((float) $paid, 2, '.', '')
&& \strtoupper($expCur) === \strtoupper($paidCur);
}
protected function currency(): string
{
return \strtoupper($this->setting('currency', 'EUR'));
}
// --------------------------------------------------------- Shop-Anbindung
protected function orderById(int $orderId): object
{
$res = $this->db->query("SELECT * FROM orders WHERE id=$orderId LIMIT 1");
$row = $res ? $res->fetch_object() : null;
if (!$row) {
throw new \RuntimeException('Bestellung ' . $orderId . ' nicht gefunden.');
}
return $row;
}
/** Lieferadresse der Bestellung fuer den Anbieter (Kaeufer-/Verkaeuferschutz). */
protected function shippingAddress(object $order): array
{
$iso = 'DE';
$cid = (int) ($order->shipping_country ?? 0);
if ($cid > 0) {
$res = $this->db->query("SELECT iso_code_2 FROM countries WHERE id=$cid");
$r = $res ? $res->fetch_object() : null;
if ($r && \strlen((string) $r->iso_code_2) === 2) {
$iso = \strtoupper((string) $r->iso_code_2);
}
}
return [
'name' => \trim(((string) ($order->shipping_firstname ?? '')) . ' ' . ((string) ($order->shipping_surname ?? ''))),
'company' => (string) ($order->shipping_company ?? ''),
'street' => \trim(((string) ($order->shipping_street ?? '')) . ' ' . ((string) ($order->shipping_house_number ?? ''))),
'zip' => (string) ($order->shipping_zip_code ?? ''),
'city' => (string) ($order->shipping_city ?? ''),
'country' => $iso,
'email' => (string) ($order->customer_email ?? ''),
];
}
/**
* Warenkorb leeren -- ueber die Session von damals UND die aktuelle. Der Kunde kann
* mit einer neuen Session zurueckkommen (Safari/iOS), der Webhook hat gar keine.
*/
protected function clearCart(string $originalSessionId): void
{
$ids = [];
if ($originalSessionId !== '') {
$ids[$originalSessionId] = true;
}
if (\function_exists('session_id') && \session_id() !== '') {
$ids[\session_id()] = true;
}
foreach (\array_keys($ids) as $sid) {
$s = $this->db->real_escape_string((string) $sid);
$this->db->query("DELETE FROM shopping_cart_items WHERE session_id='$s'");
}
}
/**
* Bestellbestaetigung ausloesen (Ticket-Event 3). Fehler werden protokolliert und
* geschluckt: das Geld ist eingezogen, eine Ausnahme wuerde dem Kunden eine
* Fehlerseite fuer eine erfolgreiche Bestellung zeigen.
*/
protected function sendOrderConfirmation(int $orderId, int $customerId): void
{
try {
if (!\class_exists('Cs_ticket')) {
$docroot = \defined('ROOT_DIR') ? \rtrim((string) ROOT_DIR, '/') : \dirname(__DIR__, 2);
require_once $docroot . '/core/cs_ticket.class.php';
}
$ticket = new \Cs_ticket($this->base);
$ticket->event(3, $customerId, $orderId);
Log::write($this->module(), 'mail', $orderId, null, null, 'sent');
} catch (\Throwable $e) {
\error_log($this->label() . ': Bestellbestaetigung fuer Order ' . $orderId . ' fehlgeschlagen: ' . $e->getMessage());
Log::write($this->module(), 'mail', $orderId, null, null, 'failed', null, null, $e->getMessage());
}
}
// ------------------------------------------------------ Session-Riegel
/** Offener Vorgang in der Session: Riegel gegen zweite Bestellung per Zurueck-Taste. */
protected function pendingSet(int $orderId, string $reference, float $amount, string $approveUrl): void
{
if (\session_status() !== PHP_SESSION_ACTIVE) {
return;
}
$_SESSION['easyshop']['payment_pending'] = [
'module' => $this->module(),
'reference' => $reference,
'order_id' => $orderId,
'total' => \round($amount, 2),
'approve_url' => $approveUrl,
'time' => \time(),
];
}
protected function pendingClear(): void
{
if (\session_status() === PHP_SESSION_ACTIVE) {
unset($_SESSION['easyshop']['payment_pending']);
}
}
/** Ist die Bestellung des offenen Vorgangs bezahlt? (fuer den Cashdesk-Riegel) */
public function isPaid(int $orderId): bool
{
$res = $this->db->query("SELECT payment_status FROM orders WHERE id=$orderId");
$r = $res ? $res->fetch_object() : null;
return $r && (string) $r->payment_status === '2';
}
// --------------------------------------------------------------- Events
protected function event(string $name, ?int $orderId, ?string $orderNumber, ?float $amount, ?string $reference, ?string $transactionId, ?int $customerId, ?string $detail = null): void
{
if (!\function_exists('shop_event')) {
return;
}
\shop_event($name, [
'order_id' => $orderId,
'order_number' => $orderNumber,
'payment_method' => $this->paymentMethodId(),
'betrag' => $amount,
'paypal_token' => $reference,
'transaction_id' => $transactionId,
'customer_id' => $customerId,
'detail' => \trim($this->module() . ': ' . (string) $detail),
]);
}
private ?int $pmId = null;
protected function paymentMethodId(): ?int
{
if ($this->pmId === null) {
$m = $this->db->real_escape_string($this->module());
$res = $this->db->query("SELECT id FROM payment_methods WHERE module='$m' LIMIT 1");
$r = $res ? $res->fetch_object() : null;
$this->pmId = $r ? (int) $r->id : 0;
}
return $this->pmId ?: null;
}
public function secretFields(): array
{
return [];
}
// ------------------------------------------------------- Express-Defaults
public function supportsExpress(): bool
{
return false;
}
public function startExpress(float $amount): string
{
throw new \RuntimeException($this->label() . ': kein Express-Weg');
}
public function onExpressReturn(array $query): ?array
{
return null;
}
public function finishExpress(int $orderId, float $amount, string $reference): string
{
throw new \RuntimeException($this->label() . ': kein Express-Weg');
}
/** Express-Rueckkehr laeuft ueber den Cashdesk, ohne Bestellung. */
protected function expressReturnUrl(): string
{
return $this->shopBaseUrl() . '/Kasse/?payment_express=' . $this->module();
}
protected function expressCancelUrl(): string
{
return $this->shopBaseUrl() . '/Warenkorb/?payment_express_cancel=' . $this->module();
}
/** Laender, aus denen Express-Adressen angenommen werden (ISO-2, kommagetrennt). Standard: nur DE. */
public function expressCountries(): string
{
return $this->setting('express_countries', 'DE');
}
/** countries.id zu einem ISO-3166-Alpha-2-Code, 0 wenn das Land im Shop nicht lieferbar ist. */
public function countryIdByIso(string $iso): int
{
$iso = $this->db->real_escape_string(\strtoupper($iso));
$res = $this->db->query("SELECT id FROM countries WHERE iso_code_2='$iso' ORDER BY rang DESC, id LIMIT 1");
$r = $res ? $res->fetch_object() : null;
return $r ? (int) $r->id : 0;
}
/** "Neuer Weg 5a" -> ["Neuer Weg", "5a"]; ohne erkennbare Nummer bleibt alles Strasse. */
public static function splitStreet(string $line): array
{
$line = \trim($line);
if (\preg_match('/^(.*?)[\s,]+(\d+[\s\-\/]?[a-zA-Z0-9\-\/]*)$/u', $line, $m)) {
return [\trim($m[1]), \trim($m[2])];
}
return [$line, ''];
}
}
+131
View File
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace Payments;
/**
* Protokoll aller Anbieter-Antworten in PostgreSQL (krempl_data.payment_log).
*
* Warum PG: MySQL `orders` hat keine Uhrzeit und keinen Platz fuer JSON. Hier liegt
* jede Anfrage/Antwort mit Zeitstempel -- die Beweiskette bei Rueckfragen von
* Kunden oder Anbietern.
*
* Faellt STILL aus (wie shop_event): ein Protokoll darf den Checkout nie gefaehrden.
* Verbindung kommt aus core/postgres/config.ini, Sektion [postgresql].
*/
final class Log
{
/** @var resource|\PgSql\Connection|null|false */
private static $conn = null;
private static bool $dead = false;
/**
* @param array<mixed>|null $payload wird als JSON abgelegt (Secrets vorher entfernen!)
*/
public static function write(
string $provider,
string $action,
?int $orderId = null,
?string $orderNumber = null,
?string $reference = null,
?string $status = null,
?int $httpStatus = null,
?array $payload = null,
?string $detail = null
): bool {
$c = self::conn();
if (!$c) {
return false;
}
$json = $payload === null ? null : \json_encode(self::scrub($payload), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$ok = @\pg_query_params(
$c,
'INSERT INTO payment_log
(provider, action, order_id, order_number, reference, status, http_status, payload, detail, host)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)',
[
$provider, $action, $orderId, $orderNumber, $reference, $status, $httpStatus,
$json, $detail, (string) ($_SERVER['SERVER_NAME'] ?? \gethostname()),
]
);
if (!$ok) {
self::$dead = true;
\error_log('payment_log: INSERT fehlgeschlagen -- ' . @\pg_last_error($c));
return false;
}
return true;
}
/**
* Verlauf einer Bestellung fuer den Bestell-Editor, aelteste zuerst.
* Payload bleibt draussen (Roh-JSON gehoert nicht in die Maske).
*
* @return list<array{zeit:string,action:string,status:?string,http_status:?string,detail:?string,reference:?string}>
*/
public static function forOrder(int $orderId, int $limit = 40): array
{
$c = self::conn();
if (!$c || $orderId <= 0) {
return [];
}
$host = (string) ($_SERVER['SERVER_NAME'] ?? \gethostname());
$res = @\pg_query_params(
$c,
"SELECT to_char(zeitpunkt, 'DD.MM.YYYY HH24:MI:SS') AS zeit, action, status, http_status, detail, reference
FROM payment_log
WHERE order_id = $1 AND host = $2
ORDER BY id
LIMIT $3",
[$orderId, $host, $limit]
);
if (!$res) {
return [];
}
return \pg_fetch_all($res) ?: [];
}
/** @return resource|\PgSql\Connection|false */
private static function conn()
{
if (self::$dead) {
return false;
}
if (self::$conn !== null) {
return self::$conn;
}
if (!\function_exists('pg_connect')) {
self::$dead = true;
return false;
}
$ini = @\parse_ini_file(\dirname(__DIR__) . '/postgres/config.ini', true);
if (!$ini || empty($ini['postgresql'])) {
self::$dead = true;
return false;
}
$p = $ini['postgresql'];
$dsn = \sprintf(
'host=%s port=%s dbname=%s user=%s password=%s connect_timeout=2',
$p['host'] ?? 'localhost', $p['port'] ?? '5432', $p['database'] ?? '', $p['user'] ?? '', $p['password'] ?? ''
);
$c = @\pg_connect($dsn);
if (!$c) {
self::$dead = true;
\error_log('payment_log: keine PG-Verbindung -- Protokoll uebersprungen');
return false;
}
return self::$conn = $c;
}
/** Entfernt Schluessel, die nach Zugangsdaten aussehen, rekursiv. */
private static function scrub(array $data): array
{
foreach ($data as $k => $v) {
if (\is_string($k) && \preg_match('/secret|password|token|authorization|client_id/i', $k)) {
$data[$k] = '***';
} elseif (\is_array($v)) {
$data[$k] = self::scrub($v);
}
}
return $data;
}
}
+121
View File
@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace Payments;
/**
* Lesesicht auf einen Zahlungsvorgang fuers Backend (Bestell-Editor, Block "Zahlungsvorgang").
*
* Anbieterneutral: liest payment_transactions (MySQL) und den Verlauf aus payment_log (PG).
* Kein Provider-Aufruf, keine API -- nur Anzeige. Faellt still auf null zurueck, wenn die
* Bestellung keinen Vorgang hat (alte Zahlarten, Vorkasse, Rechnung ...).
*/
final class Overview
{
private const STATUS = [
'open' => 'offen -- Kunde ist beim Anbieter',
'pending' => 'in Pruefung beim Anbieter',
'paid' => 'bezahlt',
'cancelled' => 'abgebrochen / storniert',
'mismatch' => 'BETRAG WEICHT AB -- pruefen!',
];
private const SOURCE = [
'return' => 'Rueckkehr des Kunden vom Anbieter',
'express' => 'Express-Kauf aus dem Warenkorb',
'webhook' => 'Meldung des Anbieters (Webhook)',
'reconcile' => 'Abgleicher (Cron)',
];
private const ACTION = [
'create' => 'Vorgang beim Anbieter angelegt',
'start' => 'Kunde zum Anbieter weitergeleitet',
'capture' => 'Einzug (Capture)',
'webhook' => 'Meldung vom Anbieter',
'mail' => 'Bestellbestaetigung an Kunden',
'cancel' => 'Storno',
'mismatch' => 'Betragsabweichung',
'express_create' => 'Express-Kauf gestartet',
'express_get' => 'Express: Freigabe abgeholt',
'express_patch' => 'Express: Betrag angepasst',
'express_reapprove' => 'Express: zweite Freigabe noetig',
];
/**
* @return array<string,mixed>|null Felder fuers Template oder null ohne Vorgang
*/
public static function forOrder(\mysqli $db, int $orderId, ?object $base = null): ?array
{
if ($orderId <= 0) {
return null;
}
$stmt = $db->prepare('SELECT * FROM payment_transactions WHERE order_id=? ORDER BY id DESC LIMIT 1');
if (!$stmt) {
return null;
}
$stmt->bind_param('i', $orderId);
$stmt->execute();
$tx = $stmt->get_result()->fetch_assoc();
$stmt->close();
if (!$tx) {
return null;
}
$module = (string) $tx['provider'];
$label = $module;
if ($base !== null && Registry::has($module)) {
$p = Registry::get($module, $base);
if ($p) {
$label = $p->label();
}
}
$status = (string) $tx['status'];
$detail = (string) ($tx['detail'] ?? '');
if ($status === 'paid') {
// markPaid() legt in detail nur die Quelle ab (return/express/webhook/reconcile).
$detail = 'bezahlt, bestaetigt ueber: ' . (self::SOURCE[$detail] ?? $detail);
}
return [
'provider' => $module,
'provider_label' => $label,
'status' => $status,
'status_label' => self::STATUS[$status] ?? $status,
'reference' => (string) $tx['reference'],
'capture_id' => (string) ($tx['capture_id'] ?? ''),
'expected_amount' => \number_format((float) $tx['expected_amount'], 2, ',', '.') . ' ' . $tx['currency'],
'paid_amount' => $tx['paid_amount'] !== null ? \number_format((float) $tx['paid_amount'], 2, ',', '.') . ' ' . $tx['currency'] : '',
'created_at' => self::de((string) $tx['created_at']),
'paid_at' => self::de((string) ($tx['paid_at'] ?? '')),
'updated_at' => self::de((string) $tx['updated_at']),
'detail' => $detail,
'history' => self::history($orderId),
];
}
/** @return list<array{zeit:string,aktion:string,status:string,http:string,detail:string}> */
private static function history(int $orderId): array
{
$out = [];
foreach (Log::forOrder($orderId, 40) as $row) {
$a = (string) $row['action'];
$out[] = [
'zeit' => (string) $row['zeit'],
'aktion' => self::ACTION[$a] ?? $a,
'status' => (string) ($row['status'] ?? ''),
'http' => $row['http_status'] !== null && $row['http_status'] !== '' ? 'HTTP ' . $row['http_status'] : '',
'detail' => (string) ($row['detail'] ?? ''),
];
}
return $out;
}
private static function de(string $dt): string
{
if ($dt === '' || \str_starts_with($dt, '0000')) {
return '';
}
$t = \strtotime($dt);
return $t ? \date('d.m.Y H:i:s', $t) : $dt;
}
}
+102
View File
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace Payments;
/**
* Vertrag fuer Zahlungsanbieter mit Weiterleitung (PayPal v2, spaeter Amazon Pay,
* Mollie, Klarna Payments ...).
*
* Lebenszyklus einer Zahlung:
* 1. Cashdesk legt die Shop-Bestellung an (Zahlstatus offen).
* 2. start() -> Zahlung beim Anbieter anlegen, Kunde per 302 weiterleiten.
* 3. onReturn() -> Kunde kommt zurueck: Zahlung abschliessen, Bestellung bezahlt setzen.
* onCancel() -> Kunde hat abgebrochen: Bestellung stornieren, Warenkorb bleibt.
* 4. onWebhook() -> Anbieter meldet serverseitig (unabhaengig vom Kunden).
* 5. reconcile() -> Cron: haengende Vorgaenge beim Anbieter nachfragen.
*
* Alles, was fuer alle Anbieter gleich ist (Mail erst nach Zahlung, Warenkorb erst
* nach Zahlung, Ereignisprotokoll, Betragsabgleich), lebt in AbstractProvider.
*/
interface ProviderInterface
{
/** Technischer Name = payment_methods.module = Konfigurationsgruppe, z.B. 'paypal_v2'. */
public function module(): string;
/** Anzeigename fuers Backend und Protokoll. */
public function label(): string;
/** true: die Bestellung ist erst nach Rueckkehr vom Anbieter bezahlt (Mail/Warenkorb warten). */
public function paysAfterRedirect(): bool;
/**
* Zahlung anlegen und Weiterleitungs-URL liefern.
*
* @param int $orderId Shop-Bestellung (orders.id)
* @param float $amount zu zahlender Bruttobetrag (nach Gutschein)
* @return string URL, zu der der Kunde geschickt wird
* @throws \RuntimeException wenn der Anbieter nicht erreichbar ist oder ablehnt
*/
public function start(int $orderId, float $amount): string;
/**
* Rueckkehr des Kunden vom Anbieter.
* @param array<string,mixed> $query $_GET der Rueckkehr-URL
*/
public function onReturn(array $query): Result;
/** @param array<string,mixed> $query */
public function onCancel(array $query): Result;
/**
* Server-zu-Server-Nachricht des Anbieters.
* @param array<string,string> $headers Request-Header, Schluessel kleingeschrieben
* @return int HTTP-Status, den der Endpunkt zurueckgeben soll (2xx = angenommen)
*/
public function onWebhook(array $headers, string $rawBody): int;
/**
* Haengende Vorgaenge nachziehen (Cron). Liefert eine kurze Zusammenfassung je Vorgang.
* @return list<string>
*/
public function reconcile(bool $dryRun = true): array;
// ------------------------------------------------------------ Express
// "Direkt bezahlen" aus dem Warenkorb: der Kunde geht OHNE Adresse zum Anbieter,
// der liefert Adresse + E-Mail, die Kasse ist damit vorbelegt, ein Klick auf
// "zahlungspflichtig bestellen" zieht das Geld ein -- kein zweiter Besuch beim Anbieter.
/** Bietet der Anbieter den Express-Weg an (und ist er eingeschaltet)? */
public function supportsExpress(): bool;
/**
* Express-Vorgang fuer den aktuellen Warenkorb anlegen (noch keine Bestellung!).
* @param float $amount Warenwert brutto ohne Versand (Land noch unbekannt)
* @return string URL zum Anbieter
*/
public function startExpress(float $amount): string;
/**
* Rueckkehr vom Anbieter im Express-Weg: Adresse und E-Mail des Kunden abholen.
* @param array<string,mixed> $query $_GET der Rueckkehr-URL
* @return array<string,mixed>|null Kundendaten fuer die Kasse (Schluessel: reference,
* email, firstname, surname, company, street, house_number, zip_code, city,
* country_iso, approve_url) oder null, wenn der Vorgang nicht zugeordnet werden kann
*/
public function onExpressReturn(array $query): ?array;
/**
* Express abschliessen, nachdem die Bestellung angelegt ist: Betrag beim Anbieter auf
* den Endbetrag (inkl. Versand) setzen und einziehen.
* @return string '' = bezahlt; sonst eine URL, zu der der Kunde noch einmal muss
* (Anbieter verlangt erneute Freigabe, z.B. nach Betragsaenderung)
*/
public function finishExpress(int $orderId, float $amount, string $reference): string;
/**
* Felder fuer den Zahlart-Editor im Backend, die NICHT in `configuration` liegen,
* sondern verschluesselt (Secrets). name => Beschriftung.
* @return array<string,string>
*/
public function secretFields(): array;
}
+87
View File
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace Payments;
/**
* Findet den Anbieter zu einem payment_methods.module.
*
* Konvention: core/payments/providers/<module>/Provider.php mit Klasse
* Payments\Providers\<Module in StudlyCase>\Provider
* Beispiel: module 'paypal_v2' -> Payments\Providers\PaypalV2\Provider
*
* Neuer Anbieter = neues Verzeichnis, sonst nichts.
*/
final class Registry
{
/** @var array<string,ProviderInterface|null> */
private static array $cache = [];
public static function has(string $module): bool
{
$module = self::clean($module);
return $module !== '' && \is_file(self::dir($module) . '/Provider.php');
}
public static function get(string $module, object $base): ?ProviderInterface
{
$module = self::clean($module);
if ($module === '') {
return null;
}
if (\array_key_exists($module, self::$cache)) {
return self::$cache[$module];
}
$file = self::dir($module) . '/Provider.php';
if (!\is_file($file)) {
return self::$cache[$module] = null;
}
self::requireCore();
require_once $file;
$class = 'Payments\\Providers\\' . self::studly($module) . '\\Provider';
if (!\class_exists($class)) {
\error_log('Payments: Provider-Klasse fehlt: ' . $class);
return self::$cache[$module] = null;
}
$p = new $class($base);
return self::$cache[$module] = ($p instanceof ProviderInterface ? $p : null);
}
/** Alle installierten Anbieter (fuer Backend-Liste und Abgleicher). @return list<string> */
public static function all(): array
{
$out = [];
foreach (\glob(__DIR__ . '/providers/*/Provider.php') ?: [] as $f) {
$out[] = \basename(\dirname($f));
}
\sort($out);
return $out;
}
public static function requireCore(): void
{
foreach (['ProviderInterface', 'Result', 'Secrets', 'Log', 'Overview', 'AbstractProvider'] as $c) {
require_once __DIR__ . '/' . $c . '.php';
}
}
private static function dir(string $module): string
{
return __DIR__ . '/providers/' . $module;
}
private static function clean(string $module): string
{
return \preg_match('/^[a-z0-9_]+$/', $module) ? $module : '';
}
private static function studly(string $module): string
{
return \str_replace('_', '', \ucwords($module, '_'));
}
}
// Wer die Registry laedt, bekommt die Kernklassen gleich mit (Secrets, Log, Result ...),
// nicht erst beim ersten Provider. Sonst faellt z.B. der Zahlart-Editor um, der nur
// Secrets braucht.
Registry::requireCore();
+43
View File
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Payments;
/**
* Ergebnis von onReturn()/onCancel(): sagt dem Cashdesk, was er dem Kunden zeigt.
*/
final class Result
{
public const PAID = 'paid'; // bezahlt -> Erfolgsseite
public const CANCELLED = 'cancelled'; // Kunde hat abgebrochen -> Kasse mit Hinweis
public const PENDING = 'pending'; // Anbieter noch nicht fertig -> Hinweis, Abgleicher zieht nach
public const ERROR = 'error'; // Fehler -> Hinweis, Warenkorb bleibt
public function __construct(
public readonly string $status,
public readonly int $orderId = 0,
public readonly string $orderNumber = '',
public readonly string $message = ''
) {
}
public static function paid(int $orderId, string $orderNumber): self
{
return new self(self::PAID, $orderId, $orderNumber);
}
public static function cancelled(int $orderId = 0, string $orderNumber = ''): self
{
return new self(self::CANCELLED, $orderId, $orderNumber);
}
public static function pending(int $orderId, string $orderNumber, string $message): self
{
return new self(self::PENDING, $orderId, $orderNumber, $message);
}
public static function error(string $message, int $orderId = 0, string $orderNumber = ''): self
{
return new self(self::ERROR, $orderId, $orderNumber, $message);
}
}
+158
View File
@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace Payments;
/**
* Verschluesselte Zugangsdaten der Zahlungsanbieter.
*
* Werte liegen in MySQL `payment_provider_secrets` (libsodium secretbox), der
* Schluessel liegt als Datei AUSSERHALB des Docroots:
* <vhost>/payments/secrets.key (chmod 600, nur der Site-User)
* Ein DB-Dump ohne die Datei ist damit wertlos, und die Datei ohne DB auch.
*
* Im Backend werden Secrets nie angezeigt, nur "hinterlegt seit ..." und ein Feld
* zum Ueberschreiben.
*/
final class Secrets
{
private static ?string $key = null;
public static function keyPath(): string
{
$docroot = $_SERVER['DOCUMENT_ROOT'] ?? '';
if ($docroot === '' || !\is_dir($docroot)) {
$docroot = \dirname(__DIR__, 2);
}
return \dirname(\rtrim($docroot, '/')) . '/payments/secrets.key';
}
/** Legt den Schluessel an, falls er fehlt (nur per CLI/Installation gedacht). */
public static function ensureKey(): string
{
$path = self::keyPath();
if (\is_file($path)) {
return $path;
}
$dir = \dirname($path);
if (!\is_dir($dir) && !@\mkdir($dir, 0700, true)) {
throw new \RuntimeException('Secrets: Verzeichnis nicht anlegbar: ' . $dir);
}
$key = \sodium_crypto_secretbox_keygen();
if (@\file_put_contents($path, \sodium_bin2hex($key), LOCK_EX) === false) {
throw new \RuntimeException('Secrets: Schluessel nicht schreibbar: ' . $path);
}
@\chmod($path, 0600);
return $path;
}
private static function key(): string
{
if (self::$key !== null) {
return self::$key;
}
$path = self::keyPath();
if (!\is_readable($path)) {
throw new \RuntimeException('Secrets: Schluesseldatei fehlt oder nicht lesbar: ' . $path);
}
$hex = \trim((string) \file_get_contents($path));
$key = \sodium_hex2bin($hex);
if (\strlen($key) !== SODIUM_CRYPTO_SECRETBOX_KEYBYTES) {
throw new \RuntimeException('Secrets: Schluesseldatei hat falsche Laenge.');
}
return self::$key = $key;
}
public static function keyAvailable(): bool
{
return \is_readable(self::keyPath());
}
public static function encrypt(string $plain): string
{
$nonce = \random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$box = \sodium_crypto_secretbox($plain, $nonce, self::key());
return 'v1:' . \sodium_bin2base64($nonce . $box, SODIUM_BASE64_VARIANT_ORIGINAL);
}
public static function decrypt(string $stored): string
{
if (\strncmp($stored, 'v1:', 3) !== 0) {
throw new \RuntimeException('Secrets: unbekanntes Format.');
}
$raw = \sodium_base642bin(\substr($stored, 3), SODIUM_BASE64_VARIANT_ORIGINAL);
$nonce = \substr($raw, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$box = \substr($raw, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$plain = \sodium_crypto_secretbox_open($box, $nonce, self::key());
if ($plain === false) {
throw new \RuntimeException('Secrets: Entschluesselung fehlgeschlagen (falscher Schluessel?).');
}
return $plain;
}
// ------------------------------------------------------------------ Ablage
/** @return string|null Klartext oder null, wenn nicht hinterlegt */
public static function get(\mysqli $db, string $provider, string $name): ?string
{
$stmt = $db->prepare('SELECT value_enc FROM payment_provider_secrets WHERE provider=? AND name=?');
if (!$stmt) {
return null;
}
$stmt->bind_param('ss', $provider, $name);
$stmt->execute();
$row = $stmt->get_result()->fetch_assoc();
$stmt->close();
if (!$row || $row['value_enc'] === '') {
return null;
}
return self::decrypt((string) $row['value_enc']);
}
public static function set(\mysqli $db, string $provider, string $name, string $plain): void
{
$enc = self::encrypt($plain);
$stmt = $db->prepare(
'INSERT INTO payment_provider_secrets (provider, name, value_enc, updated_at)
VALUES (?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE value_enc = VALUES(value_enc), updated_at = NOW()'
);
if (!$stmt) {
throw new \RuntimeException('Secrets: ' . $db->error);
}
$stmt->bind_param('sss', $provider, $name, $enc);
$stmt->execute();
$stmt->close();
}
public static function delete(\mysqli $db, string $provider, string $name): void
{
$stmt = $db->prepare('DELETE FROM payment_provider_secrets WHERE provider=? AND name=?');
if ($stmt) {
$stmt->bind_param('ss', $provider, $name);
$stmt->execute();
$stmt->close();
}
}
/**
* Fuers Backend: welche Secrets liegen vor und seit wann (ohne Klartext).
* @return array<string,string> name => updated_at
*/
public static function status(\mysqli $db, string $provider): array
{
$out = [];
$stmt = $db->prepare('SELECT name, updated_at FROM payment_provider_secrets WHERE provider=? AND value_enc<>""');
if (!$stmt) {
return $out;
}
$stmt->bind_param('s', $provider);
$stmt->execute();
$res = $stmt->get_result();
while ($row = $res->fetch_assoc()) {
$out[(string) $row['name']] = (string) $row['updated_at'];
}
$stmt->close();
return $out;
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
/**
* Standalone-Bootstrap fuer Endpunkte OHNE Shop-Router und OHNE Session:
* payment_webhook.php (Docroot) und core/payments/cli.php.
*
* Bindet den Shop-Kern (DB + Config, Konstanten ROOT_DIR/MAIN_URL) ein und stellt
* $base_object bereit. Session wird bewusst nicht gestartet.
*/
declare(strict_types=1);
\ini_set('display_errors', '0');
\error_reporting(E_ALL);
$docroot = $_SERVER['DOCUMENT_ROOT'] ?? '';
if ($docroot === '' || !\is_dir($docroot . '/core')) {
$docroot = \dirname(__DIR__, 2);
$_SERVER['DOCUMENT_ROOT'] = $docroot;
}
\chdir($docroot);
// base.class.php nutzt relative Includes und liest config.inc.php
require_once $docroot . '/core/base.class.php';
require_once $docroot . '/core/registry.class.php';
require_once $docroot . '/core/order.class.php';
if (\is_file($docroot . '/core/postgres/shop_events.php')) {
require_once $docroot . '/core/postgres/shop_events.php';
}
require_once __DIR__ . '/Registry.php';
\Payments\Registry::requireCore();
$base_object = new base();
Registry::set('base', $base_object);
// 27.09.2026 -- Layout-Objekt (Smarty) auch hier: die Bestellbestaetigung rendert die
// Positionen ueber DeliveryItemsHTML, das Registry::get('layout_object') holt. Ohne das
// scheiterte die Mail, wenn der WEBHOOK (nicht die Rueckkehr) die Zahlung abschloss
// ("Call to a member function assign() on null", Mollie-Test 290616). Der Kunde
// bekam dann keine Bestaetigung. start() braucht SERVER_NAME (CLI: aus MAIN_URL).
if (empty($_SERVER['SERVER_NAME']) && \defined('MAIN_URL')) {
$_SERVER['SERVER_NAME'] = (string) \parse_url((string) MAIN_URL, PHP_URL_HOST);
}
$_SERVER['REQUEST_URI'] = $_SERVER['REQUEST_URI'] ?? '/'; // layout.class.php liest sie beim Start
try {
require_once $docroot . '/libs/smarty/libs/Smarty.class.php';
require_once $docroot . '/core/layout.class.php';
$layout_object = new layout();
$layout_object->start($base_object);
Registry::set('layout_object', $layout_object);
} catch (\Throwable $e) {
\error_log('payments bootstrap: Layout nicht initialisiert -- ' . $e->getMessage());
}
+110
View File
@@ -0,0 +1,110 @@
<?php
/**
* Kommandozeile fuer die Zahlungsanbieter. Immer mit dem PHP des Shops aufrufen:
*
* /opt/plesk/php/8.2/bin/php core/payments/cli.php reconcile [modul] [--apply]
* Haengende Vorgaenge nachziehen. Ohne --apply nur Bericht.
* Cron-Vorschlag (alle 15 Minuten): ... cli.php reconcile --apply >> logs/payments_reconcile.log
*
* ... cli.php init-key
* Schluesseldatei fuer die Secrets anlegen (einmalig je Server).
*
* ... cli.php secret <modul> <name> [wert]
* Secret setzen (wert leer = von STDIN lesen, damit es nicht in der Shell-History landet).
*
* ... cli.php status
* Installierte Anbieter, Modus, hinterlegte Secrets, offene Vorgaenge.
*/
declare(strict_types=1);
if (PHP_SAPI !== 'cli') {
\http_response_code(403);
exit('cli only');
}
require __DIR__ . '/bootstrap.php';
use Payments\Registry as PR;
use Payments\Secrets;
$args = $argv;
\array_shift($args);
$cmd = $args[0] ?? 'status';
switch ($cmd) {
case 'init-key':
$path = Secrets::ensureKey();
echo "Schluessel: $path\n";
break;
case 'secret':
[$module, $name] = [$args[1] ?? '', $args[2] ?? ''];
if ($module === '' || $name === '') {
exit("usage: secret <modul> <name> [wert]\n");
}
$value = $args[3] ?? '';
if ($value === '') {
echo "Wert (Eingabe unsichtbar, Enter zum Beenden): ";
\system('stty -echo');
$value = \trim((string) \fgets(STDIN));
\system('stty echo');
echo "\n";
}
Secrets::set($base_object->db, $module, $name, $value);
echo "gespeichert: $module/$name (" . \strlen($value) . " Zeichen)\n";
break;
case 'reconcile':
$apply = \in_array('--apply', $args, true);
$modules = \array_values(\array_filter(\array_slice($args, 1), static fn($a) => $a !== '--apply'));
if (!$modules) {
$modules = PR::all();
}
foreach ($modules as $m) {
$p = PR::get($m, $base_object);
if (!$p) {
echo "[$m] nicht installiert\n";
continue;
}
try {
$lines = $p->reconcile(!$apply);
} catch (\Throwable $e) {
echo '[' . \date('Y-m-d H:i:s') . "] [$m] FEHLER " . $e->getMessage() . "\n";
continue;
}
foreach ($lines as $l) {
echo '[' . \date('Y-m-d H:i:s') . "] [$m] $l\n";
}
if (!$lines) {
echo '[' . \date('Y-m-d H:i:s') . "] [$m] nichts offen\n";
}
}
break;
case 'status':
default:
echo 'Schluesseldatei: ' . Secrets::keyPath() . ' -> ' . (Secrets::keyAvailable() ? 'vorhanden' : 'FEHLT') . "\n";
foreach (PR::all() as $m) {
$p = PR::get($m, $base_object);
if (!$p) {
continue;
}
$secrets = Secrets::keyAvailable() ? Secrets::status($base_object->db, $m) : [];
$res = $base_object->db->query("SELECT id, name, active, argument FROM payment_methods WHERE module='" . $base_object->db->real_escape_string($m) . "'");
// 27.09.2026: ein Anbieter kann mehrere Zahlarten tragen (Mollie: eine Zeile je Methode)
$zahlarten = [];
while ($res && ($pm = $res->fetch_object())) {
$zahlarten[] = "id {$pm->id} '{$pm->name}'" . ($pm->argument !== '' ? " [{$pm->argument}]" : '') . ' ' . ($pm->active ? 'aktiv' : 'inaktiv');
}
echo "\n[$m] " . $p->label() . (\method_exists($p, 'mode') ? ' Modus ' . $p->mode() : '') . "\n";
echo ' Zahlart: ' . ($zahlarten ? \implode(', ', $zahlarten) : 'NICHT angelegt') . "\n";
foreach ($p->secretFields() as $name => $label) {
echo " $label: " . (isset($secrets[$name]) ? 'hinterlegt (' . $secrets[$name] . ')' : '-') . "\n";
}
$res = $base_object->db->query("SELECT status, COUNT(*) c FROM payment_transactions WHERE provider='" . $base_object->db->real_escape_string($m) . "' GROUP BY status");
while ($res && ($r = $res->fetch_object())) {
echo " Vorgaenge {$r->status}: {$r->c}\n";
}
}
break;
}
+74
View File
@@ -0,0 +1,74 @@
-- ============================================================================
-- core/payments -- Installation (MySQL, Shop-DB)
-- Additiv: bestehende Zahlarten und Bestellungen werden nicht angefasst.
-- Reihenfolge: 1) Tabellen 2) Zahlart + Einstellungen 3) Kundengruppen (Test)
-- ============================================================================
-- 1a) Vorgaenge je Anbieter -- mit echter Uhrzeit, Session (Warenkorb) und Sollbetrag.
CREATE TABLE IF NOT EXISTS `payment_transactions` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`provider` VARCHAR(40) NOT NULL,
`order_id` BIGINT UNSIGNED NOT NULL,
`order_number` VARCHAR(50) NULL,
`reference` VARCHAR(191) NOT NULL COMMENT 'ID beim Anbieter (PayPal Order-ID)',
`expected_amount` DECIMAL(13,2) NOT NULL,
`currency` CHAR(3) NOT NULL DEFAULT 'EUR',
`status` ENUM('open','pending','paid','cancelled','mismatch') NOT NULL DEFAULT 'open',
`approve_url` TEXT NULL,
`session_id` VARCHAR(191) NULL,
`customer_id` INT UNSIGNED NULL,
`capture_id` VARCHAR(191) NULL,
`paid_amount` DECIMAL(13,2) NULL,
`paid_at` DATETIME NULL,
`detail` VARCHAR(500) NULL,
`created_at` DATETIME NOT NULL,
`updated_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_provider_reference` (`provider`,`reference`),
KEY `idx_order` (`order_id`),
KEY `idx_status_created` (`provider`,`status`,`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 1b) Verschluesselte Zugangsdaten (Schluessel liegt ausserhalb des Docroots).
CREATE TABLE IF NOT EXISTS `payment_provider_secrets` (
`provider` VARCHAR(40) NOT NULL,
`name` VARCHAR(60) NOT NULL,
`value_enc` TEXT NOT NULL,
`updated_at` DATETIME NOT NULL,
PRIMARY KEY (`provider`,`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 2a) Einstellungen der Zahlart (erscheinen im Zahlart-Editor unter "Einstellungen").
INSERT IGNORE INTO `configuration` (`name`, `text`, `group_id`, `setting`, `type`, `description`) VALUES
('paypal_v2_mode', '', 'paypal_v2', 'sandbox', 1, 'sandbox oder live'),
('paypal_v2_brand_name', '', 'paypal_v2', 'intelectra', 1, 'Anzeigename auf der PayPal-Seite'),
('paypal_v2_send_address', '', 'paypal_v2', '1', 1, '1 = Lieferadresse an PayPal uebergeben (Verkaeuferschutz), 0 = nicht'),
('paypal_v2_currency', '', 'paypal_v2', 'EUR', 1, 'Waehrung (ISO 4217)'),
('paypal_v2_express', '', 'paypal_v2', '1', 1, '1 = Button "Direkt mit PayPal bezahlen" im Warenkorb, 0 = aus'),
('paypal_v2_express_countries', '', 'paypal_v2', 'DE', 1, 'Laender (ISO-2, kommagetrennt), deren PayPal-Adresse im Express-Kauf angenommen wird');
-- 2a2) Stornogrund fuer automatische Stornos (Kunde hat beim Anbieter nicht bezahlt).
-- type 0 = Kunden Storno. markCancelled() sucht ihn ueber den Namen.
INSERT INTO `cancellation_reasons` (`name`, `type`, `active`)
SELECT 'Zahlung beim Anbieter nicht abgeschlossen', 0, 1
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `cancellation_reasons` WHERE `name` = 'Zahlung beim Anbieter nicht abgeschlossen');
-- 2b) Zahlart anlegen. active=1, aber noch KEINER Kundengruppe zugeordnet => im Shop unsichtbar.
-- helperjoinkey 'pp' = derselbe PayPal-Aufschlag wie die alte Zahlart (shipping_area.add_pp).
INSERT INTO `payment_methods`
(`name`, `module`, `argument`, `active`, `rang`, `invoice_text`, `order_text`, `order_email_text`, `helperjoinkey`, `configuration_group_id`)
SELECT 'PayPal', 'paypal_v2', '', 1, 2,
'Bezahlung erfolgte per PayPal',
'Sie werden zur Bezahlung Ihrer Bestellung zu PayPal weitergeleitet.',
'Der zu zahlende Betrag wurde per PayPal beglichen.',
'pp', 'paypal_v2'
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `payment_methods` WHERE `module` = 'paypal_v2');
-- 3) TEST: Zahlart nur fuer eine Kundengruppe sichtbar machen (Gruppen: 1 Endverbraucher,
-- 100 Besucher, 101 Haendler, 103 Gast). Beispiel Endverbraucher:
-- INSERT INTO `customer_group_payment_method_configuration` (`group_id`, `payment_method_id`, `shipping_area_id`, `active`)
-- SELECT 1, id, 1, 1 FROM payment_methods WHERE module='paypal_v2';
-- GO-LIVE (spaeter): alle Gruppen wie bei id=4 freischalten und die alte Zahlart verstecken:
-- UPDATE `customer_group_payment_method_configuration` SET active=0 WHERE payment_method_id=4;
-- UPDATE `payment_methods` SET active=0 WHERE id=4;
+108
View File
@@ -0,0 +1,108 @@
-- ============================================================================
-- core/payments -- Mollie (MySQL, Shop-DB). Additiv, mehrfach ausfuehrbar.
-- Voraussetzung: install.sql (payment_transactions, payment_provider_secrets) ist drin.
--
-- Getrennte Gateways je Zahlungsart (Tom, 27.09.2026): je Mollie-Methode EINE Zeile in
-- payment_methods mit module='mollie' und argument=<Methode>. Mollie zeigt dann nur
-- diese Methode, keine Auswahl bei Mollie, kein abweichendes Warenkorb-Verhalten.
--
-- Reihenfolge: 1) Versandgebiet-Spalten 2) Einstellungen 3) Zahlarten 4) Freischaltung (manuell)
-- ============================================================================
-- 1) Die Kasse haengt jede Zahlart ueber helperjoinkey an vier Spalten in shipping_area
-- (active_/add_/account_type_/free_payment_<key>). Neue Keys = neue Spalten.
-- Aufschlaege sind bei Karten/Klarna/Wallets ohnehin nicht zulaessig -> add=0, aktiv=1.
ALTER TABLE `shipping_area`
ADD COLUMN IF NOT EXISTS `active_mollie_klarna` TINYINT(2) NOT NULL DEFAULT 2,
ADD COLUMN IF NOT EXISTS `add_mollie_klarna` DECIMAL(9,2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS `account_type_mollie_klarna` TINYINT(4) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS `free_payment_mollie_klarna` DECIMAL(6,2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS `active_mollie_creditcard` TINYINT(2) NOT NULL DEFAULT 2,
ADD COLUMN IF NOT EXISTS `add_mollie_creditcard` DECIMAL(9,2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS `account_type_mollie_creditcard` TINYINT(4) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS `free_payment_mollie_creditcard` DECIMAL(6,2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS `active_mollie_applepay` TINYINT(2) NOT NULL DEFAULT 2,
ADD COLUMN IF NOT EXISTS `add_mollie_applepay` DECIMAL(9,2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS `account_type_mollie_applepay` TINYINT(4) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS `free_payment_mollie_applepay` DECIMAL(6,2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS `active_mollie_googlepay` TINYINT(2) NOT NULL DEFAULT 2,
ADD COLUMN IF NOT EXISTS `add_mollie_googlepay` DECIMAL(9,2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS `account_type_mollie_googlepay` TINYINT(4) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS `free_payment_mollie_googlepay` DECIMAL(6,2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS `active_mollie_paybybank` TINYINT(2) NOT NULL DEFAULT 2,
ADD COLUMN IF NOT EXISTS `add_mollie_paybybank` DECIMAL(9,2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS `account_type_mollie_paybybank` TINYINT(4) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS `free_payment_mollie_paybybank` DECIMAL(6,2) NOT NULL DEFAULT 0;
-- 2) Einstellungen (Zahlart-Editor, Reiter "Einstellungen", Gruppe mollie). API-Keys sind
-- Secrets (verschluesselt), nicht hier: test_api_key, live_api_key.
INSERT IGNORE INTO `configuration` (`name`, `text`, `group_id`, `setting`, `type`, `description`) VALUES
('mollie_mode', '', 'mollie', 'test', 1, 'test oder live (waehlt den API-Key)'),
('mollie_locale', '', 'mollie', 'de_DE', 1, 'Sprache der Mollie-Seite (xx_XX)'),
('mollie_currency', '', 'mollie', 'EUR', 1, 'Waehrung (ISO 4217)'),
('mollie_description', '', 'mollie', 'Bestellung [#order_number]', 1, 'Verwendungszweck auf dem Kontoauszug des Kunden');
-- 3) Zahlarten: eine Zeile je Methode. active=1, aber noch KEINER Kundengruppe zugeordnet
-- => im Shop unsichtbar, bis Schritt 4 laeuft. argument = Mollie-Methode (Pflicht).
INSERT INTO `payment_methods`
(`name`, `module`, `argument`, `active`, `rang`, `invoice_text`, `order_text`, `order_email_text`, `helperjoinkey`, `configuration_group_id`)
SELECT 'Klarna', 'mollie', 'klarna', 1, 3,
'Bezahlung erfolgte per Klarna',
'Sie werden zur Bezahlung Ihrer Bestellung zu Klarna weitergeleitet (Rechnung, Ratenkauf oder Sofort bezahlen).',
'Der zu zahlende Betrag wird ueber Klarna abgewickelt.',
'mollie_klarna', 'mollie'
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `payment_methods` WHERE `module`='mollie' AND `argument`='klarna');
INSERT INTO `payment_methods`
(`name`, `module`, `argument`, `active`, `rang`, `invoice_text`, `order_text`, `order_email_text`, `helperjoinkey`, `configuration_group_id`)
SELECT 'Kreditkarte', 'mollie', 'creditcard', 1, 4,
'Bezahlung erfolgte per Kreditkarte',
'Sie werden zur sicheren Eingabe Ihrer Kartendaten weitergeleitet (Visa, Mastercard, American Express).',
'Der zu zahlende Betrag wurde per Kreditkarte beglichen.',
'mollie_creditcard', 'mollie'
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `payment_methods` WHERE `module`='mollie' AND `argument`='creditcard');
INSERT INTO `payment_methods`
(`name`, `module`, `argument`, `active`, `rang`, `invoice_text`, `order_text`, `order_email_text`, `helperjoinkey`, `configuration_group_id`)
SELECT 'Apple Pay', 'mollie', 'applepay', 1, 5,
'Bezahlung erfolgte per Apple Pay',
'Sie werden zur Bezahlung mit Apple Pay weitergeleitet.',
'Der zu zahlende Betrag wurde per Apple Pay beglichen.',
'mollie_applepay', 'mollie'
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `payment_methods` WHERE `module`='mollie' AND `argument`='applepay');
INSERT INTO `payment_methods`
(`name`, `module`, `argument`, `active`, `rang`, `invoice_text`, `order_text`, `order_email_text`, `helperjoinkey`, `configuration_group_id`)
SELECT 'Google Pay', 'mollie', 'googlepay', 1, 6,
'Bezahlung erfolgte per Google Pay',
'Sie werden zur Bezahlung mit Google Pay weitergeleitet.',
'Der zu zahlende Betrag wurde per Google Pay beglichen.',
'mollie_googlepay', 'mollie'
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `payment_methods` WHERE `module`='mollie' AND `argument`='googlepay');
-- Google Pay: Mollie lehnt method=googlepay im Redirect-Verfahren ab (HTTP 500, 27.09.2026) --
-- gibt es nur als Direkt-Integration mit Token. Zeile bleibt angelegt, aber inaktiv.
UPDATE `payment_methods` SET active=0 WHERE module='mollie' AND argument='googlepay';
-- "Online-Banking" = Mollie Pay by Bank (Open Banking, Nachfolger von Sofortueberweisung).
-- NICHT "Sofortueberweisung" nennen: eingetragene Marke der Sofort GmbH (Klarna).
-- Status nach Freigabe erst pending, paid kommt per Webhook (Echtzeit: Minuten, sonst 2-3 Werktage).
INSERT INTO `payment_methods`
(`name`, `module`, `argument`, `active`, `rang`, `invoice_text`, `order_text`, `order_email_text`, `helperjoinkey`, `configuration_group_id`)
SELECT 'Online-Banking', 'mollie', 'paybybank', 1, 7,
'Bezahlung erfolgte per Online-Banking (Pay by Bank)',
'Sie zahlen direkt von Ihrem Bankkonto: Bank waehlen, im Online-Banking freigeben, fertig.',
'Der zu zahlende Betrag wurde per Online-Banking ueberwiesen.',
'mollie_paybybank', 'mollie'
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `payment_methods` WHERE `module`='mollie' AND `argument`='paybybank');
-- 4) FREISCHALTUNG (manuell, nach Test): Kundengruppen 1 Endverbraucher, 103 Gast
-- (Haendler 101 bewusst nicht -- die zahlen kundenspezifisch), Versandgebiet 1.
-- INSERT INTO `customer_group_payment_method_configuration` (`group_id`, `payment_method_id`, `shipping_area_id`, `active`)
-- SELECT g.id, pm.id, 1, 1 FROM payment_methods pm JOIN customer_groups g ON g.id IN (1,103)
-- WHERE pm.module='mollie'
-- AND NOT EXISTS (SELECT 1 FROM customer_group_payment_method_configuration c WHERE c.group_id=g.id AND c.payment_method_id=pm.id AND c.shipping_area_id=1);
-- 5) LEICHEN (nach Go-Live): alte Klarna-/paymorrow-/BILLPAY-/computop-Zahlarten verstecken.
-- Nicht loeschen -- Bestellungen von frueher zeigen noch auf die ids.
-- UPDATE `payment_methods` SET active=0 WHERE id IN (8,9,10,11,12,13);
+20
View File
@@ -0,0 +1,20 @@
-- core/payments -- Rohprotokoll in PostgreSQL (krempl_data)
-- Jede Anfrage/Antwort eines Anbieters mit Zeitstempel. Die Beweiskette.
CREATE TABLE IF NOT EXISTS payment_log (
id BIGSERIAL PRIMARY KEY,
provider TEXT NOT NULL,
action TEXT NOT NULL, -- start, create, capture, get, webhook, mail, cancel, mismatch
order_id INTEGER,
order_number TEXT,
reference TEXT, -- ID beim Anbieter
status TEXT,
http_status INTEGER,
payload JSONB,
detail TEXT,
host TEXT, -- dev.intelectra.de / intelectra-shop.de
zeitpunkt TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_payment_log_order ON payment_log (order_id);
CREATE INDEX IF NOT EXISTS idx_payment_log_reference ON payment_log (reference);
CREATE INDEX IF NOT EXISTS idx_payment_log_zeit ON payment_log (zeitpunkt);
CREATE INDEX IF NOT EXISTS idx_payment_log_provider ON payment_log (provider, action, zeitpunkt);
+60
View File
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Payments\Providers\Mollie;
/**
* Duenner HTTP-Client fuer die Mollie-API v2 (https://api.mollie.com/v2/...).
*
* Auth ist ein einzelner API-Key als Bearer-Token (test_... oder live_...), kein OAuth.
* TLS-Pruefung erzwungen. POSTs bekommen einen Idempotency-Key, damit ein Retry
* innerhalb einer Stunde dieselbe Zahlung liefert statt einer zweiten.
*/
final class Client
{
private const BASE = 'https://api.mollie.com';
public function __construct(private readonly string $apiKey)
{
}
/**
* @param array<string,mixed>|null $payload
* @param list<string> $extraHeaders
* @return array{0:int,1:array<mixed>}
*/
public function request(string $method, string $path, ?array $payload = null, array $extraHeaders = []): array
{
$headers = \array_merge([
'Authorization: Bearer ' . $this->apiKey,
'Content-Type: application/json',
'Accept: application/json',
'User-Agent: intelectra-shop/1.0 core-payments-mollie',
], $extraHeaders);
$ch = \curl_init(self::BASE . $path);
\curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_TIMEOUT => 30,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_HTTPHEADER => $headers,
]);
if ($payload !== null) {
\curl_setopt($ch, CURLOPT_POSTFIELDS, (string) \json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
$resp = \curl_exec($ch);
if ($resp === false) {
$err = \curl_error($ch);
\curl_close($ch);
throw new \RuntimeException('Mollie HTTP-Transportfehler: ' . $err);
}
$status = (int) \curl_getinfo($ch, CURLINFO_HTTP_CODE);
\curl_close($ch);
$decoded = $resp === '' ? [] : \json_decode((string) $resp, true);
return [$status, \is_array($decoded) ? $decoded : ['raw' => (string) $resp]];
}
}
+609
View File
@@ -0,0 +1,609 @@
<?php
declare(strict_types=1);
namespace Payments\Providers\Mollie;
use Payments\AbstractProvider;
use Payments\Log;
use Payments\Result;
require_once __DIR__ . '/Client.php';
/**
* Mollie, Payments API v2 -- EIN Provider, mehrere Zahlarten.
*
* Toms Vorgabe (27.09.2026): getrennte Gateways je Zahlungsart, nicht "Mollie waehlt".
* Darum steht in payment_methods je Zahlart EINE Zeile mit module='mollie' und
* argument=<Mollie-Methode> (klarna, creditcard, applepay, googlepay, ...). Beim Anlegen
* der Zahlung wird `method` fest gesetzt, die Mollie-Seite zeigt nur diese Methode.
*
* Ablauf: start() legt die Zahlung an (POST /v2/payments) und leitet zur Checkout-URL.
* Die Rueckkehr-URL traegt unsere Bestellnummer (o=<orders.id>), weil Mollie beim
* Redirect nichts anhaengt. onReturn()/onCancel()/onWebhook()/reconcile() holen den
* Zahlungsstatus IMMER per GET bei Mollie -- der Webhook selbst traegt nur die ID und
* ist nicht signiert; die Abfrage ist die Verifikation.
*
* Status bei Mollie: open, pending, authorized (nicht final) / paid, canceled, expired,
* failed (final). Klarna verlangt lines + billingAddress; Betrag = Summe der lines.
*
* Einstellungen (configuration, Gruppe mollie): mode (test|live), locale, currency, description
* Secrets (payment_provider_secrets): test_api_key, live_api_key
*/
final class Provider extends AbstractProvider
{
private ?Client $client = null;
/** Anzeigenamen je Mollie-Methode (fuer Protokoll und Hinweistexte). */
private const LABELS = [
'klarna' => 'Klarna',
'creditcard' => 'Kreditkarte',
'applepay' => 'Apple Pay',
'googlepay' => 'Google Pay',
'paypal' => 'PayPal',
'banktransfer' => 'Ueberweisung',
'ideal' => 'iDEAL',
'bancontact' => 'Bancontact',
'eps' => 'EPS',
'twint' => 'TWINT',
'trustly' => 'Trustly',
'paybybank' => 'Pay by Bank',
'riverty' => 'Riverty',
'in3' => 'in3',
'billie' => 'Billie',
];
/** Methoden, fuer die Mollie Positionen (lines) und volle Adressen verlangt. */
private const NEEDS_LINES = ['klarna', 'billie', 'in3', 'riverty', 'billink', 'voucher'];
public function module(): string
{
return 'mollie';
}
public function label(): string
{
return 'Mollie';
}
public function secretFields(): array
{
return [
'test_api_key' => 'Test-API-Key (test_...)',
'live_api_key' => 'Live-API-Key (live_...)',
'webhook_secret' => 'Webhook-Signing-Secret (nur fuer Next-Gen-Webhooks aus Dashboard > Entwickler > Webhooks; leer = klassische Webhooks)',
];
}
public function mode(): string
{
return $this->setting('mode', 'test') === 'live' ? 'live' : 'test';
}
private function client(): Client
{
if ($this->client === null) {
$this->client = new Client($this->secret($this->mode() . '_api_key'));
}
return $this->client;
}
public static function methodLabel(string $method): string
{
return self::LABELS[$method] ?? \ucfirst($method);
}
/** Mollie-Methode der Zahlart, die an der Bestellung haengt (payment_methods.argument). */
public function methodForOrder(object $order): string
{
$pmId = (int) ($order->payment_method ?? 0);
$res = $this->db->query("SELECT module, argument FROM payment_methods WHERE id=$pmId LIMIT 1");
$row = $res ? $res->fetch_object() : null;
if (!$row || (string) $row->module !== $this->module()) {
throw new \RuntimeException('Mollie: Zahlart ' . $pmId . ' der Bestellung ist keine Mollie-Zahlart.');
}
$method = \strtolower(\trim((string) $row->argument));
if ($method === '' || !\preg_match('/^[a-z0-9]{2,30}$/', $method)) {
throw new \RuntimeException('Mollie: Zahlart ' . $pmId . ' hat keine Mollie-Methode im Feld "argument" (z.B. klarna, creditcard, applepay).');
}
return $method;
}
// ------------------------------------------------------------------ start
public function start(int $orderId, float $amount): string
{
$order = $this->orderById($orderId);
$number = (string) $order->order_number;
$method = $this->methodForOrder($order);
$payload = $this->buildPayload($order, $amount, $method);
// Deterministischer Idempotency-Key je (Bestellung, Betrag): ein zweiter Klick oder
// Retry innerhalb einer Stunde bekommt dieselbe Zahlung zurueck.
$idem = 'shop-' . $orderId . '-' . \substr(\hash('sha256', $number . '|' . $payload['amount']['value'] . '|' . $method), 0, 24);
[$status, $body] = $this->client()->request('POST', '/v2/payments', $payload, ['Idempotency-Key: ' . $idem]);
Log::write($this->module(), 'create', $orderId, $number, (string) ($body['id'] ?? ''), (string) ($body['status'] ?? ''), $status,
['request' => $payload, 'response' => $body]);
if ($status !== 201 || empty($body['id'])) {
throw new \RuntimeException('Mollie: Zahlung konnte nicht angelegt werden (HTTP ' . $status . '): '
. (string) ($body['detail'] ?? \json_encode($body)) . (isset($body['field']) ? ' [Feld ' . $body['field'] . ']' : ''));
}
$checkout = (string) ($body['_links']['checkout']['href'] ?? '');
if ($checkout === '') {
throw new \RuntimeException('Mollie: keine Checkout-URL in der Antwort.');
}
$this->openTransaction($orderId, (string) $body['id'], $amount, $checkout);
$this->setTransactionStatus((string) $body['id'], 'open', 'method ' . $method);
return $checkout;
}
/**
* Anfrage an Mollie zusammenbauen. Oeffentlich, damit sie sich ohne API-Key auf der
* Kommandozeile pruefen laesst (Betrag = Summe der lines usw.).
* @return array<string,mixed>
*/
public function buildPayload(object $order, float $amount, string $method): array
{
$orderId = (int) $order->id;
$number = (string) $order->order_number;
$value = \number_format($amount, 2, '.', '');
$description = \str_replace('[#order_number]', $number, $this->setting('description', 'Bestellung [#order_number]'));
$payload = [
'amount' => ['currency' => $this->currency(), 'value' => $value],
'description' => \mb_substr($description, 0, 255),
'redirectUrl' => $this->returnUrl() . '&o=' . $orderId,
'cancelUrl' => $this->cancelUrl() . '&o=' . $orderId,
'webhookUrl' => $this->webhookUrl(),
'method' => $method,
'locale' => $this->setting('locale', 'de_DE'),
'metadata' => ['order_id' => $orderId, 'order_number' => $number, 'shop' => $this->shopBaseUrl()],
];
$billing = $this->address($order, 'billing');
if ($billing !== null) {
$payload['billingAddress'] = $billing;
}
$shipping = $this->address($order, 'shipping');
if ($shipping !== null) {
$payload['shippingAddress'] = $shipping;
}
if (\in_array($method, self::NEEDS_LINES, true)) {
$payload['lines'] = $this->lines($order, $amount);
}
return $payload;
}
// ----------------------------------------------------------------- return
public function onReturn(array $query): Result
{
return $this->afterRedirect($query, false);
}
public function onCancel(array $query): Result
{
return $this->afterRedirect($query, true);
}
/**
* Rueckkehr des Kunden (Erfolg oder Abbruch-URL): Vorgang ueber unsere Bestellnummer
* finden, Status bei Mollie holen, danach handeln. Der Abbruch-Link ist keine
* Wahrheit -- auch dort wird nachgefragt.
*/
private function afterRedirect(array $query, bool $viaCancelUrl): Result
{
$orderId = (int) ($query['o'] ?? 0);
$tx = $orderId > 0 ? $this->transactionByOrder($orderId) : null;
if (!$tx) {
$p = $_SESSION['easyshop']['payment_pending'] ?? null;
if (\is_array($p) && ($p['module'] ?? '') === $this->module() && !empty($p['reference'])) {
$tx = $this->transactionByReference((string) $p['reference']);
}
}
if (!$tx) {
$this->pendingClear();
return $viaCancelUrl
? Result::cancelled()
: Result::error('Der Zahlungsvorgang konnte nicht zugeordnet werden. Ihr Warenkorb ist noch da.');
}
if ($tx->status === 'paid') {
$this->pendingClear();
return Result::paid((int) $tx->order_id, (string) $tx->order_number);
}
try {
$payment = $this->fetch((string) $tx->reference, (int) $tx->order_id, (string) $tx->order_number);
} catch (\Throwable $e) {
\error_log('Mollie return: ' . $e->getMessage());
return Result::error('Die Rueckmeldung des Zahlungsanbieters ist gerade nicht erreichbar. Ihr Warenkorb ist noch da &ndash; bitte versuchen Sie es in einer Minute erneut.',
(int) $tx->order_id, (string) $tx->order_number);
}
return $this->apply($tx, $payment, $viaCancelUrl ? 'cancel' : 'return');
}
/**
* Zahlungsstatus von Mollie auf den Vorgang anwenden -- eine Stelle fuer Rueckkehr,
* Webhook und Abgleicher.
* @param array<mixed> $payment
*/
private function apply(object $tx, array $payment, string $source): Result
{
$ref = (string) $tx->reference;
$orderId = (int) $tx->order_id;
$number = (string) $tx->order_number;
$status = (string) ($payment['status'] ?? '');
$method = (string) ($payment['method'] ?? '');
$label = $method !== '' ? self::methodLabel($method) : 'Mollie';
switch ($status) {
case 'paid':
$ok = $this->markPaid($ref, (string) ($payment['amount']['value'] ?? ''), (string) ($payment['amount']['currency'] ?? ''),
$ref, $this->payerEmail($payment), (string) ($payment['details']['consumerAccount'] ?? ''), $source . ' ' . $method);
if ($ok) {
return Result::paid($orderId, $number);
}
return Result::error('Die Zahlung konnte nicht zugeordnet werden. Bitte kontaktieren Sie uns unter Angabe der Bestellnummer ' . $number . '.', $orderId, $number);
case 'authorized':
case 'pending':
$this->setTransactionStatus($ref, 'pending', $label . ': ' . $status . ' (' . $source . ')');
return Result::pending($orderId, $number, $label . ' hat die Zahlung angenommen und prueft sie noch. Sie erhalten die Bestellbestaetigung, sobald die Zahlung bestaetigt ist.');
case 'canceled':
$this->markCancelled($ref, 'Kunde hat bei ' . $label . ' abgebrochen', $source);
return Result::cancelled($orderId, $number);
case 'expired':
$this->markCancelled($ref, $label . ': Zahlung abgelaufen (expired)', $source);
return Result::cancelled($orderId, $number);
case 'failed':
$this->markCancelled($ref, $label . ': Zahlung fehlgeschlagen (failed)', $source);
return Result::error('Die Zahlung ueber ' . $label . ' wurde nicht angenommen. Ihr Warenkorb ist noch da &ndash; bitte waehlen Sie eine andere Zahlart.', $orderId, $number);
case 'open':
default:
// Kunde ist ohne Abschluss zurueck (Fenster zu, Zurueck-Taste). Der Vorgang
// bleibt offen: Mollie laesst ihn verfallen, der Abgleicher raeumt auf.
// Der Session-Riegel schickt einen zweiten "Bestellen"-Klick auf dieselbe Checkout-URL.
$this->setTransactionStatus($ref, 'open', $label . ': ' . ($status ?: 'unbekannt') . ' bei Rueckkehr (' . $source . ')');
return Result::error('Die Zahlung ueber ' . $label . ' wurde nicht abgeschlossen. Ihr Warenkorb ist noch da &ndash; Sie koennen es erneut versuchen oder eine andere Zahlart waehlen.', $orderId, $number);
}
}
// ---------------------------------------------------------------- webhook
/**
* Zwei Webhook-Formen, ein Endpunkt (27.09.2026, docs.mollie.com/reference/webhooks-new):
*
* klassisch application/x-www-form-urlencoded, genau ein Feld id=tr_... , unsigniert.
* Haengt an jeder Zahlung (webhookUrl). Mollie empfiehlt diese Form fuer
* Zahlungsstatus weiterhin.
* next-gen JSON {resource:"event", type:"payment.paid", entityId:"tr_..."}, signiert
* mit HMAC-SHA256 ueber den rohen Body im Header X-Mollie-Signature
* ("sha256=<hex>"), Schluessel = Signing-Secret der Subscription (Dashboard >
* Entwickler > Webhooks). Ist ein Secret hinterlegt, MUSS die Signatur passen;
* ohne Secret werden JSON-Events abgelehnt (kein blindes Vertrauen).
*
* In beiden Faellen ist die Wahrheit der GET auf die Zahlung -- der Webhook ist nur der
* Anstoss. Antwort 200 = angenommen, alles andere wiederholt Mollie bis zu 10x in 26 h.
*/
public function onWebhook(array $headers, string $rawBody): int
{
$id = '';
$body = \ltrim($rawBody);
if ($body !== '' && $body[0] === '{') {
$event = \json_decode($body, true);
if (!\is_array($event) || ($event['resource'] ?? '') !== 'event') {
Log::write($this->module(), 'webhook', null, null, null, 'rejected', null, ['body' => \mb_substr($rawBody, 0, 500)], 'JSON ohne resource=event');
return 400;
}
$check = $this->verifySignature($headers, $rawBody);
if ($check !== 'ok') {
Log::write($this->module(), 'webhook', null, null, (string) ($event['entityId'] ?? ''), 'rejected', null,
['type' => $event['type'] ?? '', 'header' => $headers['x-mollie-signature'] ?? ''], 'Signatur: ' . $check);
return 400;
}
$type = (string) ($event['type'] ?? '');
$id = (string) ($event['entityId'] ?? '');
if (!\str_starts_with($type, 'payment.')) {
// andere Ereignisse (Auszahlungen, Rechnungen ...) nur protokollieren
Log::write($this->module(), 'webhook', null, null, $id, 'ignored', null, ['type' => $type], 'kein Zahlungsereignis');
return 200;
}
} else {
$params = [];
\parse_str($rawBody, $params);
$id = (string) ($params['id'] ?? ($_POST['id'] ?? ''));
}
if (!\preg_match('/^tr_[A-Za-z0-9]{6,40}$/', $id)) {
Log::write($this->module(), 'webhook', null, null, $id, 'rejected', null, ['body' => \mb_substr($rawBody, 0, 500)], 'keine Zahlungs-ID');
return 400;
}
$tx = $this->transactionByReference($id);
if (!$tx) {
// Unbekannt (z.B. Test aus dem Mollie-Dashboard): protokollieren, 200 damit Mollie nicht 26 h lang wiederholt.
Log::write($this->module(), 'webhook', null, null, $id, 'unknown', null, null, 'kein Vorgang zu dieser ID');
return 200;
}
try {
$payment = $this->fetch($id, (int) $tx->order_id, (string) $tx->order_number, 'webhook');
} catch (\Throwable $e) {
\error_log('Mollie webhook ' . $id . ': ' . $e->getMessage());
return 500; // Mollie versucht es spaeter noch einmal
}
if ($tx->status === 'paid') {
return 200;
}
$this->apply($tx, $payment, 'webhook');
return 200;
}
/**
* HMAC-SHA256 ueber den unveraenderten Body mit dem Signing-Secret. Bei Secret-Rotation
* schickt Mollie 24 h lang zwei Signaturen (kommagetrennt) -- eine muss passen.
* @param array<string,string> $headers
*/
private function verifySignature(array $headers, string $rawBody): string
{
$secret = \Payments\Secrets::get($this->db, $this->module(), 'webhook_secret') ?? '';
if ($secret === '') {
return 'kein webhook_secret hinterlegt';
}
$header = (string) ($headers['x-mollie-signature'] ?? '');
if ($header === '') {
return 'Header X-Mollie-Signature fehlt';
}
$expected = \hash_hmac('sha256', $rawBody, $secret);
foreach (\explode(',', $header) as $sig) {
$sig = \strtolower(\trim($sig));
if (\str_starts_with($sig, 'sha256=')) {
$sig = \substr($sig, 7);
}
if ($sig !== '' && \hash_equals($expected, $sig)) {
return 'ok';
}
}
return 'Signatur passt nicht';
}
// -------------------------------------------------------------- reconcile
public function reconcile(bool $dryRun = true): array
{
$out = [];
// Nach 10 Minuten nachsehen, nach 72 Stunden aufgeben.
foreach ($this->openTransactions(10, 72) as $tx) {
$ref = (string) $tx->reference;
try {
$payment = $this->fetch($ref, (int) $tx->order_id, (string) $tx->order_number, 'reconcile');
} catch (\Throwable $e) {
$out[] = "$ref (Order {$tx->order_id}): API-Fehler " . $e->getMessage();
continue;
}
$status = (string) ($payment['status'] ?? '');
$age = (\time() - \strtotime((string) $tx->created_at)) / 3600;
$tag = $dryRun ? ' [dry-run]' : '';
if ($status === 'paid') {
$out[] = "$ref (Order {$tx->order_id}): bei Mollie bezahlt, im Shop offen -> bezahlt setzen$tag";
} elseif (\in_array($status, ['canceled', 'expired', 'failed'], true)) {
$out[] = "$ref (Order {$tx->order_id}): Mollie $status -> storniert$tag";
} elseif ($age >= 24) {
// Nach 24 h ohne Abschluss: bei Mollie abbrechen (wenn noch moeglich) und stornieren.
$out[] = "$ref (Order {$tx->order_id}): seit " . \round($age) . " h $status -> abbrechen + storniert$tag";
if (!$dryRun) {
if (!empty($payment['isCancelable'])) {
[$s, $b] = $this->client()->request('DELETE', '/v2/payments/' . \rawurlencode($ref));
Log::write($this->module(), 'cancel_api', (int) $tx->order_id, (string) $tx->order_number, $ref, (string) ($b['status'] ?? ''), $s, $b);
}
$this->markCancelled($ref, 'Mollie ' . $status . ' nach ' . \round($age) . ' h ohne Abschluss', 'reconcile');
}
continue;
} else {
$out[] = "$ref (Order {$tx->order_id}): $status, " . \round($age, 1) . " h alt -> warten";
continue;
}
if (!$dryRun) {
$this->apply($tx, $payment, 'reconcile');
}
}
return $out;
}
// ----------------------------------------------------------------- intern
/** GET /v2/payments/{id} mit Protokoll. @return array<mixed> */
private function fetch(string $id, int $orderId, string $number, string $action = 'get'): array
{
[$status, $body] = $this->client()->request('GET', '/v2/payments/' . \rawurlencode($id));
Log::write($this->module(), $action, $orderId, $number, $id, (string) ($body['status'] ?? ''), $status, $body);
if ($status !== 200 || !isset($body['status'])) {
throw new \RuntimeException('Mollie GET payment ' . $id . ' fehlgeschlagen (HTTP ' . $status . '): ' . (string) ($body['detail'] ?? \json_encode($body)));
}
return $body;
}
/** @param array<mixed> $payment */
private function payerEmail(array $payment): string
{
$d = $payment['details'] ?? [];
foreach (['consumerAccount', 'payerEmail', 'email'] as $k) {
$v = (string) ($d[$k] ?? '');
if ($v !== '' && \str_contains($v, '@')) {
return $v;
}
}
return (string) ($payment['billingAddress']['email'] ?? '');
}
/**
* Adresse der Bestellung im Mollie-Format. null, wenn keine Strasse da ist (dann lieber
* gar keine Adresse als eine, die Mollie ablehnt).
* @return array<string,string>|null
*/
private function address(object $order, string $kind): ?array
{
$p = static fn(string $f) => \trim((string) ($order->{$kind . '_' . $f} ?? ''));
$street = \trim($p('street') . ' ' . $p('house_number'));
if ($street === '' || $p('zip_code') === '' || $p('city') === '') {
return null;
}
$iso = 'DE';
$cid = (int) ($order->{$kind . '_country'} ?? 0);
if ($cid > 0) {
$res = $this->db->query("SELECT iso_code_2 FROM countries WHERE id=$cid");
$r = $res ? $res->fetch_object() : null;
if ($r && \strlen((string) $r->iso_code_2) === 2) {
$iso = \strtoupper((string) $r->iso_code_2);
}
}
$first = $p('firstname');
$last = $p('surname');
$company = $p('company');
// Klarna verlangt Vor- und Nachname mit je >= 2 Zeichen; Firmenkunden ohne Person: Firma als Name.
if (\mb_strlen($first) < 2 && $company !== '') {
$first = $company;
}
if (\mb_strlen($last) < 2) {
$last = $company !== '' ? $company : $first;
}
$a = [
'givenName' => \mb_substr($first, 0, 100),
'familyName' => \mb_substr($last, 0, 100),
'streetAndNumber' => \mb_substr($street, 0, 100),
'postalCode' => $p('zip_code'),
'city' => $p('city'),
'country' => $iso,
];
if ($company !== '') {
$a['organizationName'] = \mb_substr($company, 0, 100);
}
$email = $this->customerEmail($order);
if ($email !== '') {
$a['email'] = $email;
}
$phone = $this->phoneE164((string) ($order->customer_phone ?? ''), $iso);
if ($phone !== '') {
$a['phone'] = $phone;
}
return $a;
}
private function customerEmail(object $order): string
{
$e = \trim((string) ($order->customer_email ?? ''));
if ($e === '') {
$cid = (int) ($order->customer_id ?? 0);
$res = $cid > 0 ? $this->db->query("SELECT email FROM customers WHERE id=$cid") : null;
$r = $res ? $res->fetch_object() : null;
$e = $r ? \trim((string) $r->email) : '';
}
return \filter_var($e, FILTER_VALIDATE_EMAIL) ? $e : '';
}
/** Telefon nach E.164 (nur Ziffern, Landesvorwahl); unbrauchbare Eingaben fallen weg. */
private function phoneE164(string $raw, string $iso): string
{
$d = \preg_replace('/[^0-9+]/', '', $raw) ?? '';
if ($d === '') {
return '';
}
if (\str_starts_with($d, '00')) {
$d = '+' . \substr($d, 2);
} elseif (\str_starts_with($d, '0') && $iso === 'DE') {
$d = '+49' . \substr($d, 1);
} elseif (!\str_starts_with($d, '+')) {
return '';
}
return \preg_match('/^\+[1-9][0-9]{6,14}$/', $d) ? $d : '';
}
/**
* Positionen fuer Klarna & Co.: Artikel (brutto je Stueck aus Netto + Steuersatz),
* Versand, Zahlartaufschlag, Gutschein, ggf. Rundungsausgleich -- die Summe der
* totalAmount MUSS dem Zahlbetrag entsprechen, sonst lehnt Mollie ab.
* @return list<array<string,mixed>>
*/
public function lines(object $order, float $amount): array
{
$cur = $this->currency();
$money = static fn(float $v) => ['currency' => $cur, 'value' => \number_format($v, 2, '.', '')];
$lines = [];
$sum = 0.0;
$maxRate = 0.0;
$orderId = (int) $order->id;
$res = $this->db->query("SELECT item_id, item_number, item_name, amount, price, tax_rate FROM order_item WHERE order_id=$orderId AND (parent_item_id IS NULL OR parent_item_id=0) ORDER BY id");
while ($res && ($it = $res->fetch_object())) {
$qty = \max(1, (int) $it->amount);
$rate = (float) $it->tax_rate;
$maxRate = \max($maxRate, $rate);
$unit = \round((float) $it->price * (1 + $rate / 100), 2);
$total = \round($unit * $qty, 2);
$lines[] = [
'type' => 'physical',
'description' => \mb_substr(\trim((string) $it->item_name) !== '' ? (string) $it->item_name : ('Artikel ' . $it->item_number), 0, 255),
'sku' => \mb_substr((string) $it->item_number, 0, 64),
'quantity' => $qty,
'quantityUnit'=> 'pcs',
'unitPrice' => $money($unit),
'totalAmount' => $money($total),
'vatRate' => \number_format($rate, 2, '.', ''),
'vatAmount' => $money(\round($total * $rate / (100 + $rate), 2)),
];
$sum += $total;
}
if (!$lines) {
throw new \RuntimeException('Mollie: Bestellung ' . $orderId . ' hat keine Positionen.');
}
$rate = $maxRate > 0 ? $maxRate : 19.0;
$ship = \round((float) ($order->shipping_charges ?? 0), 2);
if ($ship > 0) {
$lines[] = ['type' => 'shipping_fee', 'description' => 'Versand', 'quantity' => 1,
'unitPrice' => $money($ship), 'totalAmount' => $money($ship),
'vatRate' => \number_format($rate, 2, '.', ''), 'vatAmount' => $money(\round($ship * $rate / (100 + $rate), 2))];
$sum += $ship;
}
$fee = \round((float) ($order->payment_method_charges ?? 0), 2);
if ($fee > 0) {
$lines[] = ['type' => 'surcharge', 'description' => 'Zahlartgebuehr', 'quantity' => 1,
'unitPrice' => $money($fee), 'totalAmount' => $money($fee),
'vatRate' => \number_format($rate, 2, '.', ''), 'vatAmount' => $money(\round($fee * $rate / (100 + $rate), 2))];
$sum += $fee;
} elseif ($fee < 0) {
$lines[] = ['type' => 'discount', 'description' => 'Zahlartrabatt', 'quantity' => 1,
'unitPrice' => $money($fee), 'totalAmount' => $money($fee),
'vatRate' => \number_format($rate, 2, '.', ''), 'vatAmount' => $money(\round($fee * $rate / (100 + $rate), 2))];
$sum += $fee;
}
$gift = \round((float) ($order->gift_certificate_total ?? 0), 2);
if ($gift > 0) {
$lines[] = ['type' => 'gift_card', 'description' => 'Gutschein', 'quantity' => 1,
'unitPrice' => $money(-$gift), 'totalAmount' => $money(-$gift), 'vatRate' => '0.00', 'vatAmount' => $money(0)];
$sum -= $gift;
}
$disc = \round((float) ($order->discount_total ?? 0), 2) + \round((float) ($order->rebate_total ?? 0), 2);
if ($disc > 0) {
$lines[] = ['type' => 'discount', 'description' => 'Rabatt', 'quantity' => 1,
'unitPrice' => $money(-$disc), 'totalAmount' => $money(-$disc),
'vatRate' => \number_format($rate, 2, '.', ''), 'vatAmount' => $money(\round(-$disc * $rate / (100 + $rate), 2))];
$sum -= $disc;
}
// Rundung: Rechenwege des Shops (Netto-Summen, dann Steuer) und der Zeilen (brutto je Stueck)
// koennen um Cents auseinanderliegen. Ausgleich als eigene Zeile ohne Steuer.
$diff = \round($amount - $sum, 2);
if (\abs($diff) >= 0.01) {
$lines[] = ['type' => $diff > 0 ? 'surcharge' : 'discount', 'description' => 'Rundungsausgleich', 'quantity' => 1,
'unitPrice' => $money($diff), 'totalAmount' => $money($diff), 'vatRate' => '0.00', 'vatAmount' => $money(0)];
}
return $lines;
}
}
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace Payments\Providers\PaypalV2;
/**
* Duenner HTTP-Client fuer die PayPal-REST-API (Orders v2).
* TLS-Pruefung erzwungen. Access-Token wird pro Modus im Cache-Verzeichnis neben dem
* Secrets-Schluessel abgelegt (ausserhalb des Docroots).
*/
final class Client
{
public function __construct(
private readonly string $apiBase,
private readonly string $clientId,
private readonly string $secret,
private readonly string $cacheFile
) {
}
public function accessToken(): string
{
if (\is_file($this->cacheFile)) {
$c = \json_decode((string) @\file_get_contents($this->cacheFile), true);
if (\is_array($c) && ($c['base'] ?? '') === $this->apiBase && ($c['cid'] ?? '') === \substr($this->clientId, 0, 8)
&& isset($c['token'], $c['expires']) && (int) $c['expires'] > \time() + 60) {
return (string) $c['token'];
}
}
[$status, $body] = $this->raw('POST', '/v1/oauth2/token', 'grant_type=client_credentials',
['Content-Type: application/x-www-form-urlencoded'], $this->clientId . ':' . $this->secret);
$data = \json_decode($body, true);
if ($status !== 200 || !\is_array($data) || empty($data['access_token'])) {
throw new \RuntimeException('PayPal OAuth2 fehlgeschlagen (HTTP ' . $status . ')');
}
@\file_put_contents($this->cacheFile, \json_encode([
'token' => $data['access_token'], 'expires' => \time() + (int) ($data['expires_in'] ?? 300),
'base' => $this->apiBase, 'cid' => \substr($this->clientId, 0, 8),
]), LOCK_EX);
@\chmod($this->cacheFile, 0600);
return (string) $data['access_token'];
}
/**
* @param array<string,mixed>|null $payload
* @param list<string> $extraHeaders
* @return array{0:int,1:array<mixed>}
*/
public function request(string $method, string $path, array|string|null $payload = null, array $extraHeaders = []): array
{
$headers = \array_merge(['Content-Type: application/json', 'Authorization: Bearer ' . $this->accessToken()], $extraHeaders);
// string = fertiges JSON (z.B. mit dem rohen Webhook-Body eingebettet), wird unveraendert gesendet
$bodyStr = $payload === null ? null : (\is_string($payload) ? $payload : (string) \json_encode($payload, JSON_UNESCAPED_UNICODE));
[$status, $body] = $this->raw($method, $path, $bodyStr, $headers);
$decoded = $body === '' ? [] : \json_decode($body, true);
return [$status, \is_array($decoded) ? $decoded : ['raw' => $body]];
}
/** @param list<string> $headers @return array{0:int,1:string} */
private function raw(string $method, string $path, ?string $body, array $headers, ?string $basicAuth = null): array
{
$ch = \curl_init($this->apiBase . $path);
\curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method, CURLOPT_RETURNTRANSFER => true,
CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_TIMEOUT => 30, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_HTTPHEADER => $headers,
]);
if ($basicAuth !== null) {
\curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
\curl_setopt($ch, CURLOPT_USERPWD, $basicAuth);
}
if ($body !== null) {
\curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$resp = \curl_exec($ch);
if ($resp === false) {
$err = \curl_error($ch);
\curl_close($ch);
throw new \RuntimeException('PayPal HTTP-Transportfehler: ' . $err);
}
$status = (int) \curl_getinfo($ch, CURLINFO_HTTP_CODE);
\curl_close($ch);
return [$status, (string) $resp];
}
}
@@ -0,0 +1,637 @@
<?php
declare(strict_types=1);
namespace Payments\Providers\PaypalV2;
use Payments\AbstractProvider;
use Payments\Log;
use Payments\Result;
use Payments\Secrets;
require_once __DIR__ . '/Client.php';
/**
* PayPal Checkout, Orders API v2 (REST). Ersetzt das NVP-Express-Checkout von 2010.
*
* Ablauf: start() legt eine PayPal-Order (intent CAPTURE) an und leitet zur
* Approve-URL. onReturn() captured und setzt bezahlt. onWebhook() bestaetigt
* PAYMENT.CAPTURE.COMPLETED serverseitig. reconcile() zieht Vorgaenge nach, deren
* Rueckkehr verloren ging (APPROVED aber nie gecaptured).
*
* Einstellungen (configuration, Gruppe paypal_v2): mode, brand_name, send_address, currency
* Secrets (payment_provider_secrets): sandbox_client_id, sandbox_secret,
* live_client_id, live_secret, webhook_id
*/
final class Provider extends AbstractProvider
{
private ?Client $client = null;
public function module(): string
{
return 'paypal_v2';
}
public function label(): string
{
return 'PayPal';
}
public function secretFields(): array
{
return [
'sandbox_client_id' => 'Sandbox Client-ID',
'sandbox_secret' => 'Sandbox Secret',
'live_client_id' => 'Live Client-ID',
'live_secret' => 'Live Secret',
'webhook_id' => 'Webhook-ID (aus dem PayPal-Dashboard, fuer den aktiven Modus)',
];
}
public function mode(): string
{
return $this->setting('mode', 'sandbox') === 'live' ? 'live' : 'sandbox';
}
private function apiBase(): string
{
return $this->mode() === 'live' ? 'https://api-m.paypal.com' : 'https://api-m.sandbox.paypal.com';
}
private function client(): Client
{
if ($this->client === null) {
$m = $this->mode();
$this->client = new Client(
$this->apiBase(),
$this->secret($m . '_client_id'),
$this->secret($m . '_secret'),
\dirname(Secrets::keyPath()) . '/paypal_v2_' . $m . '.token'
);
}
return $this->client;
}
// ------------------------------------------------------------------ start
public function start(int $orderId, float $amount): string
{
$order = $this->orderById($orderId);
$number = (string) $order->order_number;
$value = \number_format($amount, 2, '.', '');
$unit = [
'reference_id' => (string) $orderId,
'custom_id' => (string) $orderId,
'invoice_id' => $number . '-' . $orderId,
'description' => 'Bestellung ' . $number,
'amount' => ['currency_code' => $this->currency(), 'value' => $value],
];
$context = [
'brand_name' => $this->setting('brand_name', 'intelectra'),
'locale' => 'de-DE',
'landing_page' => 'NO_PREFERENCE',
'user_action' => 'PAY_NOW',
'return_url' => $this->returnUrl(),
'cancel_url' => $this->cancelUrl(),
'shipping_preference' => 'NO_SHIPPING',
];
// Lieferadresse mitgeben: Voraussetzung fuer PayPal-Verkaeuferschutz bei Warenversand.
if ($this->setting('send_address', '1') === '1') {
$a = $this->shippingAddress($order);
if ($a['street'] !== '' && $a['zip'] !== '' && $a['city'] !== '') {
$context['shipping_preference'] = 'SET_PROVIDED_ADDRESS';
$unit['shipping'] = [
'name' => ['full_name' => $a['name'] !== '' ? $a['name'] : $a['company']],
'address' => [
'address_line_1' => $a['street'],
'address_line_2' => $a['company'],
'postal_code' => $a['zip'],
'admin_area_2' => $a['city'],
'country_code' => $a['country'],
],
];
}
}
$payload = ['intent' => 'CAPTURE', 'purchase_units' => [$unit], 'application_context' => $context];
$requestId = 'create-' . $orderId . '-' . \substr(\hash('sha256', $number . '|' . $value), 0, 16);
[$status, $body] = $this->client()->request('POST', '/v2/checkout/orders', $payload, ['PayPal-Request-Id: ' . $requestId]);
Log::write($this->module(), 'create', $orderId, $number, (string) ($body['id'] ?? ''), (string) ($body['status'] ?? ''), $status, $body);
if ($status !== 201 || empty($body['id'])) {
throw new \RuntimeException('PayPal Order-Anlage fehlgeschlagen (HTTP ' . $status . '): ' . \json_encode($body['details'] ?? $body));
}
$approve = '';
foreach (($body['links'] ?? []) as $link) {
if (\in_array($link['rel'] ?? '', ['approve', 'payer-action'], true)) {
$approve = (string) $link['href'];
break;
}
}
if ($approve === '') {
throw new \RuntimeException('PayPal: kein Approve-Link in der Antwort.');
}
$this->openTransaction($orderId, (string) $body['id'], $amount, $approve);
return $approve;
}
// ----------------------------------------------------------------- return
public function onReturn(array $query): Result
{
$ref = (string) ($query['token'] ?? '');
if ($ref === '') {
return Result::error('Der PayPal-Vorgang konnte nicht zugeordnet werden. Ihr Warenkorb ist noch da.');
}
$tx = $this->transactionByReference($ref);
if (!$tx) {
return Result::error('Der PayPal-Vorgang ist unbekannt. Ihr Warenkorb ist noch da.');
}
$orderId = (int) $tx->order_id;
$number = (string) $tx->order_number;
if ($tx->status === 'paid') {
$this->pendingClear();
return Result::paid($orderId, $number);
}
try {
$cap = $this->capture($ref, $orderId, $number);
} catch (\Throwable $e) {
\error_log('PayPal v2 onReturn: ' . $e->getMessage());
$this->event('failed', $orderId, $number, null, $ref, null, (int) $tx->customer_id, $e->getMessage());
return Result::error('Beim Abschluss der PayPal-Zahlung ist ein Fehler aufgetreten. Ihr Warenkorb ist noch da, bitte versuchen Sie es erneut oder waehlen Sie eine andere Zahlart.', $orderId, $number);
}
if ($cap['capture_status'] === 'COMPLETED') {
if ($this->markPaid($ref, $cap['amount'], $cap['currency'], $cap['capture_id'], $cap['payer_email'], $cap['payer_id'], 'return')) {
return Result::paid($orderId, $number);
}
return Result::error('Die Zahlung konnte nicht zugeordnet werden. Bitte kontaktieren Sie uns unter Angabe der Bestellnummer ' . $number . '.', $orderId, $number);
}
if ($cap['capture_status'] === 'PENDING') {
$this->setTransactionStatus($ref, 'pending', 'capture PENDING: ' . ($cap['pending_reason'] ?? ''));
return Result::pending($orderId, $number, 'PayPal prueft die Zahlung noch. Sie erhalten die Bestellbestaetigung, sobald die Zahlung bestaetigt ist.');
}
$this->event('failed', $orderId, $number, null, $ref, $cap['capture_id'], (int) $tx->customer_id, 'capture ' . $cap['capture_status']);
return Result::error('PayPal hat die Zahlung nicht abgeschlossen (' . ($cap['capture_status'] ?: $cap['order_status']) . '). Ihr Warenkorb ist noch da.', $orderId, $number);
}
public function onCancel(array $query): Result
{
$ref = (string) ($query['token'] ?? '');
if ($ref === '') {
$p = $_SESSION['easyshop']['payment_pending'] ?? null;
if (\is_array($p) && ($p['module'] ?? '') === $this->module()) {
$ref = (string) $p['reference'];
}
}
if ($ref !== '') {
$tx = $this->transactionByReference($ref);
if ($tx && $tx->status === 'paid') {
return Result::paid((int) $tx->order_id, (string) $tx->order_number);
}
$this->markCancelled($ref, 'Kunde hat bei PayPal abgebrochen');
return Result::cancelled((int) ($tx->order_id ?? 0), (string) ($tx->order_number ?? ''));
}
$this->pendingClear();
return Result::cancelled();
}
// ---------------------------------------------------------------- webhook
public function onWebhook(array $headers, string $rawBody): int
{
$event = \json_decode($rawBody, true);
if (!\is_array($event)) {
return 400;
}
$type = (string) ($event['event_type'] ?? '');
$verify = $this->verifyWebhook($headers, $rawBody);
if ($verify !== 'SUCCESS') {
Log::write($this->module(), 'webhook', null, null, (string) ($event['id'] ?? ''), 'rejected', null,
['event_type' => $type, 'header_keys' => \array_keys(\array_filter($headers, static fn($k) => \str_starts_with($k, 'paypal-'), ARRAY_FILTER_USE_KEY))],
'Signatur ungueltig: ' . $verify);
return 400;
}
$resource = \is_array($event['resource'] ?? null) ? $event['resource'] : [];
$orderId = (int) ($resource['custom_id'] ?? ($resource['purchase_units'][0]['custom_id'] ?? 0));
if ($orderId <= 0) {
// 18.09.2026 -- Express ohne PATCH traegt custom_id 'express': Bestellung ueber die
// PayPal-Order-ID finden (Capture: supplementary_data, Order-Events: resource.id).
$ref = (string) ($resource['supplementary_data']['related_ids']['order_id']
?? (\str_starts_with($type, 'CHECKOUT.ORDER.') ? ($resource['id'] ?? '') : ''));
$tx = $ref !== '' ? $this->transactionByReference($ref) : null;
$orderId = $tx ? (int) $tx->order_id : 0;
}
Log::write($this->module(), 'webhook', $orderId ?: null, null, (string) ($event['id'] ?? ''), $type, null, $event);
switch ($type) {
case 'PAYMENT.CAPTURE.COMPLETED':
$tx = $orderId > 0 ? $this->transactionByOrder($orderId) : null;
if ($tx && ($resource['status'] ?? '') === 'COMPLETED') {
$this->markPaid((string) $tx->reference, (string) ($resource['amount']['value'] ?? ''),
(string) ($resource['amount']['currency_code'] ?? ''), (string) ($resource['id'] ?? ''), '', '', 'webhook');
}
break;
case 'CHECKOUT.ORDER.APPROVED':
// Kunde hat freigegeben, Capture macht die Rueckkehr oder der Abgleicher.
$tx = $orderId > 0 ? $this->transactionByOrder($orderId) : null;
if ($tx && $tx->status === 'open') {
$this->setTransactionStatus((string) $tx->reference, 'open', 'APPROVED laut Webhook');
}
break;
case 'PAYMENT.CAPTURE.DENIED':
case 'PAYMENT.CAPTURE.DECLINED':
$tx = $orderId > 0 ? $this->transactionByOrder($orderId) : null;
if ($tx) {
$this->markCancelled((string) $tx->reference, 'PayPal: ' . $type, 'webhook');
}
break;
case 'PAYMENT.CAPTURE.REFUNDED':
case 'PAYMENT.CAPTURE.REVERSED':
// Nur protokolliert; Erstattungen werden im Backend gepflegt.
break;
}
return 200;
}
/**
* Signaturpruefung gegen PayPal. Der rohe Body wird UNVERAENDERT als webhook_event
* eingebettet -- PayPal hat die Signatur ueber genau diesen Text gebildet; ein
* json_decode/encode in PHP aendert Escaping und Reihenfolge und laesst die
* Pruefung scheitern.
*
* @param array<string,string> $headers
* @return string 'SUCCESS' oder Grund der Ablehnung
*/
private function verifyWebhook(array $headers, string $rawBody): string
{
$webhookId = Secrets::get($this->db, $this->module(), 'webhook_id') ?? '';
if ($webhookId === '') {
return 'keine webhook_id hinterlegt';
}
$fields = [
'transmission_id' => $headers['paypal-transmission-id'] ?? '',
'transmission_time' => $headers['paypal-transmission-time'] ?? '',
'cert_url' => $headers['paypal-cert-url'] ?? '',
'auth_algo' => $headers['paypal-auth-algo'] ?? '',
'transmission_sig' => $headers['paypal-transmission-sig'] ?? '',
'webhook_id' => $webhookId,
];
foreach ($fields as $k => $v) {
if ($v === '') {
return 'Header fehlt: ' . $k;
}
}
// 1. Lokale Pruefung (PayPal "self-verification"): Signatur ueber
// transmission_id|transmission_time|webhook_id|crc32(body), SHA256withRSA,
// mit dem Zertifikat von cert_url (nur *.paypal.com, https).
// Am 13.09.2026 in der Sandbox belegt: lokal GUELTIG, waehrend der
// API-Endpunkt verify-webhook-signature dieselbe Zustellung mit FAILURE beantwortet.
$local = $this->verifyLocally($fields, $rawBody);
if ($local === 'SUCCESS') {
return 'SUCCESS';
}
// 2. Rueckfall: PayPal fragen (roher Body eingebettet).
$json = \json_encode($fields, JSON_UNESCAPED_SLASHES);
$json = \substr($json, 0, -1) . ',"webhook_event":' . \trim($rawBody) . '}';
[$status, $body] = $this->client()->request('POST', '/v1/notifications/verify-webhook-signature', $json);
$vs = (string) ($body['verification_status'] ?? '');
if ($status === 200 && $vs === 'SUCCESS') {
return 'SUCCESS';
}
return 'lokal: ' . $local . '; API: HTTP ' . $status . ' ' . ($vs !== '' ? $vs : \json_encode($body));
}
/** @param array<string,string> $f */
private function verifyLocally(array $f, string $rawBody): string
{
if (\strtoupper($f['auth_algo']) !== 'SHA256WITHRSA') {
return 'Algorithmus ' . $f['auth_algo'];
}
$u = \parse_url($f['cert_url']);
$host = \strtolower((string) ($u['host'] ?? ''));
if (($u['scheme'] ?? '') !== 'https' || !($host === 'paypal.com' || \str_ends_with($host, '.paypal.com'))) {
return 'cert_url nicht von paypal.com';
}
$cert = $this->certificate($f['cert_url']);
if ($cert === '') {
return 'Zertifikat nicht ladbar';
}
$pub = \openssl_pkey_get_public($cert);
if ($pub === false) {
return 'Zertifikat unlesbar';
}
$msg = $f['transmission_id'] . '|' . $f['transmission_time'] . '|' . $f['webhook_id'] . '|' . \sprintf('%u', \crc32($rawBody));
$sig = \base64_decode($f['transmission_sig'], true);
if ($sig === false) {
return 'Signatur kein Base64';
}
return \openssl_verify($msg, $sig, $pub, OPENSSL_ALGO_SHA256) === 1 ? 'SUCCESS' : 'Signatur passt nicht';
}
/** Zertifikat von PayPal, 24 h neben dem Secrets-Schluessel zwischengespeichert. */
private function certificate(string $url): string
{
$file = \dirname(Secrets::keyPath()) . '/paypal_cert_' . \md5($url) . '.pem';
if (\is_file($file) && \filemtime($file) > \time() - 86400) {
return (string) \file_get_contents($file);
}
$ch = \curl_init($url);
\curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_TIMEOUT => 10]);
$pem = (string) \curl_exec($ch);
$code = (int) \curl_getinfo($ch, CURLINFO_HTTP_CODE);
\curl_close($ch);
if ($code !== 200 || !\str_contains($pem, 'BEGIN CERTIFICATE')) {
return \is_file($file) ? (string) \file_get_contents($file) : '';
}
@\file_put_contents($file, $pem, LOCK_EX);
@\chmod($file, 0600);
return $pem;
}
// ---------------------------------------------------------------- express
public function supportsExpress(): bool
{
return $this->setting('express', '1') === '1';
}
/** Order ohne Adresse anlegen; PayPal liefert sie bei der Rueckkehr (GET_FROM_FILE). */
public function startExpress(float $amount): string
{
$value = \number_format($amount, 2, '.', '');
$payload = [
'intent' => 'CAPTURE',
'purchase_units' => [[
'reference_id' => 'default',
'custom_id' => 'express',
'description' => 'Warenkorb ' . $this->setting('brand_name', 'intelectra'),
'amount' => ['currency_code' => $this->currency(), 'value' => $value],
]],
'application_context' => [
'brand_name' => $this->setting('brand_name', 'intelectra'),
'locale' => 'de-DE',
'landing_page' => 'NO_PREFERENCE',
'user_action' => 'CONTINUE', // "Weiter" statt "Jetzt bezahlen": eingezogen wird erst in der Kasse
'shipping_preference' => 'GET_FROM_FILE',
'return_url' => $this->expressReturnUrl(),
'cancel_url' => $this->expressCancelUrl(),
],
];
[$status, $body] = $this->client()->request('POST', '/v2/checkout/orders', $payload,
['PayPal-Request-Id: express-' . \session_id() . '-' . \substr(\hash('sha256', $value . '|' . \time()), 0, 12)]);
Log::write($this->module(), 'express_create', null, null, (string) ($body['id'] ?? ''), (string) ($body['status'] ?? ''), $status, $body);
if ($status !== 201 || empty($body['id'])) {
throw new \RuntimeException('PayPal Express: Order-Anlage fehlgeschlagen (HTTP ' . $status . ')');
}
$approve = '';
foreach (($body['links'] ?? []) as $link) {
if (\in_array($link['rel'] ?? '', ['approve', 'payer-action'], true)) {
$approve = (string) $link['href'];
break;
}
}
if ($approve === '') {
throw new \RuntimeException('PayPal Express: kein Approve-Link.');
}
$this->event('payment_started', null, null, $amount, (string) $body['id'], null, null, 'express aus dem Warenkorb');
return $approve;
}
public function onExpressReturn(array $query): ?array
{
$ref = (string) ($query['token'] ?? '');
if ($ref === '') {
return null;
}
[$status, $body] = $this->client()->request('GET', '/v2/checkout/orders/' . \rawurlencode($ref));
Log::write($this->module(), 'express_get', null, null, $ref, (string) ($body['status'] ?? ''), $status, $body);
if ($status !== 200 || ($body['purchase_units'][0]['custom_id'] ?? '') !== 'express') {
return null;
}
if (($body['status'] ?? '') !== 'APPROVED') {
return null; // Kunde hat nicht freigegeben
}
$payer = $body['payer'] ?? [];
$ship = $body['purchase_units'][0]['shipping'] ?? [];
$addr = $ship['address'] ?? [];
$full = \trim((string) ($ship['name']['full_name'] ?? ''));
$first = (string) ($payer['name']['given_name'] ?? '');
$last = (string) ($payer['name']['surname'] ?? '');
if ($full !== '' && $full !== \trim($first . ' ' . $last)) {
// Lieferadresse lautet auf einen anderen Namen (z.B. Packstation, Partner)
$parts = \explode(' ', $full);
$last = \array_pop($parts);
$first = \implode(' ', $parts);
}
[$street, $number] = self::splitStreet((string) ($addr['address_line_1'] ?? ''));
$approve = '';
foreach (($body['links'] ?? []) as $link) {
if (\in_array($link['rel'] ?? '', ['approve', 'payer-action'], true)) {
$approve = (string) $link['href'];
}
}
return [
'reference' => $ref,
'email' => (string) ($payer['email_address'] ?? ''),
'firstname' => $first,
'surname' => $last,
'company' => (string) ($addr['address_line_2'] ?? ''),
'street' => $street,
'house_number' => $number,
'zip_code' => (string) ($addr['postal_code'] ?? ''),
'city' => (string) ($addr['admin_area_2'] ?? ''),
'country_iso' => (string) ($addr['country_code'] ?? ''),
'approve_url' => $approve,
'payer_id' => (string) ($payer['payer_id'] ?? ''),
];
}
/**
* Betrag auf den Endbetrag setzen (PATCH ist im Status APPROVED erlaubt), Vorgang an
* die Bestellung haengen, einziehen. Verlangt PayPal nach der Aenderung eine erneute
* Freigabe, kommt die Approve-URL zurueck und der Kunde geht noch einmal hin.
*/
public function finishExpress(int $orderId, float $amount, string $reference): string
{
$order = $this->orderById($orderId);
$number = (string) $order->order_number;
$value = \number_format($amount, 2, '.', '');
// 18.09.2026 -- erst nachsehen, was bei PayPal steht. Stimmt der Betrag schon (die Kasse
// hat Versand und Aufschlag beim Express-Start mitgegeben), wird NICHT gepatcht: jede
// Aenderung nach der Freigabe kostet auf Live eine zweite Freigabe (422
// PAYER_ACTION_REQUIRED, Sandbox winkt das durch). custom_id bleibt dann 'express',
// die Zuordnung laeuft ueber die Referenz (payment_transactions).
[$s0, $b0] = $this->client()->request('GET', '/v2/checkout/orders/' . \rawurlencode($reference));
$ppValue = (string) ($b0['purchase_units'][0]['amount']['value'] ?? '');
$ppCur = (string) ($b0['purchase_units'][0]['amount']['currency_code'] ?? '');
if ($s0 === 200 && $ppValue === $value && $ppCur === $this->currency()) {
Log::write($this->module(), 'express_patch', $orderId, $number, $reference, 'skipped', $s0, ['amount' => $value, 'detail' => 'Betrag stimmt bereits, kein PATCH']);
$b2 = $b0;
} else {
$patch = [
['op' => 'replace', 'path' => "/purchase_units/@reference_id=='default'/amount",
'value' => ['currency_code' => $this->currency(), 'value' => $value]],
['op' => 'replace', 'path' => "/purchase_units/@reference_id=='default'/custom_id", 'value' => (string) $orderId],
['op' => 'add', 'path' => "/purchase_units/@reference_id=='default'/invoice_id", 'value' => $number . '-' . $orderId],
];
[$status, $body] = $this->client()->request('PATCH', '/v2/checkout/orders/' . \rawurlencode($reference), $patch);
Log::write($this->module(), 'express_patch', $orderId, $number, $reference, (string) $status, $status, $body ?: ['amount' => $value, 'vorher' => $ppValue]);
if ($status !== 204) {
throw new \RuntimeException('PayPal Express: Betrag konnte nicht gesetzt werden (HTTP ' . $status . '): ' . \json_encode($body['details'] ?? $body));
}
// Approve-URL fuer den Fall, dass PayPal eine erneute Freigabe verlangt
[$s2, $b2] = $this->client()->request('GET', '/v2/checkout/orders/' . \rawurlencode($reference));
}
$approveUrl = '';
foreach (($b2['links'] ?? []) as $link) {
if (\in_array($link['rel'] ?? '', ['approve', 'payer-action'], true)) {
$approveUrl = (string) $link['href'];
}
}
$this->openTransaction($orderId, $reference, $amount, $approveUrl);
try {
$cap = $this->capture($reference, $orderId, $number);
} catch (\RuntimeException $e) {
if (\str_contains($e->getMessage(), 'ORDER_NOT_APPROVED') || \str_contains($e->getMessage(), 'PAYER_ACTION_REQUIRED')) {
Log::write($this->module(), 'express_reapprove', $orderId, $number, $reference, 'needs approval');
return $approveUrl !== '' ? $approveUrl : 'https://www.' . ($this->mode() === 'live' ? '' : 'sandbox.') . 'paypal.com/checkoutnow?token=' . \rawurlencode($reference);
}
throw $e;
}
if ($cap['capture_status'] === 'COMPLETED') {
if ($this->markPaid($reference, $cap['amount'], $cap['currency'], $cap['capture_id'], $cap['payer_email'], $cap['payer_id'], 'express')) {
return '';
}
throw new \RuntimeException('Zahlung eingezogen, aber Zuordnung fehlgeschlagen (Bestellung ' . $number . ')');
}
if ($cap['capture_status'] === 'PENDING') {
$this->setTransactionStatus($reference, 'pending', 'capture PENDING: ' . ($cap['pending_reason'] ?? ''));
return '';
}
throw new \RuntimeException('PayPal hat die Zahlung nicht abgeschlossen (' . ($cap['capture_status'] ?: $cap['order_status']) . ')');
}
// -------------------------------------------------------------- reconcile
public function reconcile(bool $dryRun = true): array
{
$out = [];
// Nach 10 Minuten nachschauen, nach 72 Stunden aufgeben (PayPal-Orders verfallen nach 3 Tagen).
foreach ($this->openTransactions(10, 72) as $tx) {
$ref = (string) $tx->reference;
try {
[$status, $body] = $this->client()->request('GET', '/v2/checkout/orders/' . \rawurlencode($ref));
} catch (\Throwable $e) {
$out[] = "$ref (Order {$tx->order_id}): API-Fehler " . $e->getMessage();
continue;
}
if ($status === 404) {
$out[] = "$ref (Order {$tx->order_id}): bei PayPal unbekannt -> storniert" . ($dryRun ? ' [dry-run]' : '');
if (!$dryRun) {
// Beobachtet auf Live 19.-22.09.2026: nie freigegebene Orders (CREATED) liefern
// nach ~6 h 404 -- der Kunde ist bei PayPal nicht bis zur Freigabe gekommen.
$age = \round((\time() - \strtotime((string) $tx->created_at)) / 3600, 1);
$this->markCancelled($ref, "Kunde hat den Vorgang bei PayPal nicht abgeschlossen (nach $age h bei PayPal unbekannt, 404)", 'reconcile');
}
continue;
}
$pstatus = (string) ($body['status'] ?? '');
$cap = $this->normalize($body);
if ($pstatus === 'COMPLETED' && $cap['capture_status'] === 'COMPLETED') {
$out[] = "$ref (Order {$tx->order_id}): bei PayPal bezahlt, im Shop offen -> bezahlt setzen" . ($dryRun ? ' [dry-run]' : '');
if (!$dryRun) {
$this->markPaid($ref, $cap['amount'], $cap['currency'], $cap['capture_id'], $cap['payer_email'], $cap['payer_id'], 'reconcile');
}
} elseif ($pstatus === 'APPROVED') {
$out[] = "$ref (Order {$tx->order_id}): Kunde hat freigegeben, Rueckkehr verloren -> Capture nachholen" . ($dryRun ? ' [dry-run]' : '');
if (!$dryRun) {
try {
$c = $this->capture($ref, (int) $tx->order_id, (string) $tx->order_number);
if ($c['capture_status'] === 'COMPLETED') {
$this->markPaid($ref, $c['amount'], $c['currency'], $c['capture_id'], $c['payer_email'], $c['payer_id'], 'reconcile');
}
} catch (\Throwable $e) {
$out[] = " Capture fehlgeschlagen: " . $e->getMessage();
}
}
} elseif (\in_array($pstatus, ['VOIDED', 'EXPIRED'], true)) {
$out[] = "$ref (Order {$tx->order_id}): PayPal $pstatus -> storniert" . ($dryRun ? ' [dry-run]' : '');
if (!$dryRun) {
$this->markCancelled($ref, 'PayPal: ' . $pstatus, 'reconcile');
}
} else {
// CREATED / SAVED / PAYER_ACTION_REQUIRED: Kunde ist noch unterwegs oder hat das Fenster zugemacht.
$age = (\time() - \strtotime((string) $tx->created_at)) / 3600;
if ($age >= 24) {
$out[] = "$ref (Order {$tx->order_id}): seit " . \round($age) . " h $pstatus -> storniert" . ($dryRun ? ' [dry-run]' : '');
if (!$dryRun) {
$this->markCancelled($ref, 'PayPal ' . $pstatus . ' nach ' . \round($age) . ' h', 'reconcile');
}
} else {
$out[] = "$ref (Order {$tx->order_id}): $pstatus, " . \round($age, 1) . " h alt -> warten";
}
}
}
return $out;
}
// ----------------------------------------------------------------- intern
/** @return array{order_status:string,capture_id:string,capture_status:string,amount:string,currency:string,payer_email:string,payer_id:string,pending_reason:string} */
private function capture(string $ref, int $orderId, string $number): array
{
[$status, $body] = $this->client()->request('POST', '/v2/checkout/orders/' . \rawurlencode($ref) . '/capture', null,
['PayPal-Request-Id: capture-' . $ref, 'Prefer: return=representation']);
Log::write($this->module(), 'capture', $orderId, $number, $ref, (string) ($body['status'] ?? ''), $status, $body);
if ($status === 201 || $status === 200) {
return $this->normalize($body);
}
if ($status === 422 && $this->hasIssue($body, 'ORDER_ALREADY_CAPTURED')) {
[$s2, $b2] = $this->client()->request('GET', '/v2/checkout/orders/' . \rawurlencode($ref));
Log::write($this->module(), 'get', $orderId, $number, $ref, (string) ($b2['status'] ?? ''), $s2, $b2);
return $this->normalize($b2);
}
throw new \RuntimeException('Capture fehlgeschlagen (HTTP ' . $status . '): ' . \json_encode($body['details'] ?? $body));
}
/** @param array<mixed> $body */
private function normalize(array $body): array
{
$cap = $body['purchase_units'][0]['payments']['captures'][0] ?? [];
return [
'order_status' => (string) ($body['status'] ?? ''),
'capture_id' => (string) ($cap['id'] ?? ''),
'capture_status' => (string) ($cap['status'] ?? ''),
'amount' => (string) ($cap['amount']['value'] ?? ''),
'currency' => (string) ($cap['amount']['currency_code'] ?? ''),
'payer_email' => (string) ($body['payer']['email_address'] ?? ''),
'payer_id' => (string) ($body['payer']['payer_id'] ?? ''),
'pending_reason' => (string) ($cap['status_details']['reason'] ?? ''),
];
}
/** @param array<mixed> $body */
private function hasIssue(array $body, string $issue): bool
{
foreach (($body['details'] ?? []) as $d) {
if (($d['issue'] ?? '') === $issue) {
return true;
}
}
return false;
}
}
+6 -2
View File
@@ -47,8 +47,12 @@ class ExpressCheckout extends NVP {
// ."&L_PAYMENTREQUEST_0_NAME0=Ihre+Bestellung"
// ."&L_PAYMENTREQUEST_0_QTY0=1"
// ."&L_PAYMENTREQUEST_0_AMT0=".$this->order_toal
."&RETURNURL=".$this->return_url
."&CANCELURL=".$this->cancel_url
// 24.08.2026: urlencode ergaenzt. Die NVP-Postfields sind ein
// key=value&key=value-String -- eine URL mit "?cancel=true" darin wuerde ihn
// zerlegen. Ohne das laesst sich die kaputte CANCELURL (bis heute
// ".../Kasse/&cancel=true") nicht reparieren.
."&RETURNURL=".urlencode($this->return_url)
."&CANCELURL=".urlencode($this->cancel_url)
."&LOCALECODE=".'DE'
."&PAYMENTREQUEST_0_CURRENCYCODE=".$this->currency
."&NOSHIPPING="."1"
+8
View File
@@ -10,3 +10,11 @@
Deny from all
</FilesMatch>
</IfModule>
# 06.08.2026 geprueft: Ein <FilesMatch "\.php$"> waere hier WIRKUNGSLOS. Plesk laesst nginx
# PHP-Dateien direkt an PHP-FPM weiterreichen, die .htaccess wird dafuer nicht ausgewertet.
# Unkritisch: KremplDB.php und tba_render.php definieren nur Klasse bzw. Funktionen und geben
# beim Direktaufruf 0 Bytes aus. Wer sie wirklich sperren will, braucht einen location-Block
# in /var/www/vhosts/system/<domain>/conf/vhost_nginx.conf.
# Die config.ini ist ueber die Regel oben zuverlaessig gesperrt (verifiziert: HTTP 403).
+403 -6
View File
@@ -13,6 +13,153 @@ class KremplDB {
private $mysql_conn = null;
private $config = null;
/**
* "nicht mehr lieferbar" als SQL-Regex -- muss deckungsgleich mit den PHP-Pendants
* v2_nml_info() und v2_is_nml_wg() in tba_render.php bleiben.
* Zeichenklassen statt Backslash-Escapes, damit die Muster durch PHP-String und
* MySQL-Parser unveraendert durchkommen.
*/
const NML_NAME_RE = '[(]nml[e]?[)]';
const NML_WG_RE = 'lieferbar|NMLE|Nachfolge|kein Ersatz';
/**
* Aufloesung der Alternativartikel ("Nachfolger") fuer eine Liste von Shop-Artikeln.
*
* Die Zuordnung wird im Backend unter Item-Editor > Tab "Alternativartikel" gepflegt und
* landet in `item_item_assign` (type=3, im Spaltenkommentar "real friends"), geschrieben
* von modules/import/item_save_service.py. Sie steht NICHT im Artikelnamen -- der bisherige
* Namens-Regex /Nachfolger\s+(\S*\d\S*)/ erreicht 71 aktive Artikel, die Tabelle 4.150.
*
* Das "e" in "(nmle)" bedeutet "Ersatz vorhanden": 96,8 % dieser Artikel haben eine
* Zuordnung, gegenueber 7,2 % bei "(nml)" und 1,7 % in der WG "kein Ersatz".
*
* Zeigt eine Zuordnung auf einen Artikel, der selbst nicht mehr lieferbar ist, laeuft die
* Kette weiter (145 Faelle). Rekursiv gemessen: 1.607 direkt kaufbare Ziele, 1.704 mit
* Kettenverfolgung, maximale Tiefe 3. Der `pfad`-String bricht Zyklen, $max_tiefe ist der
* Backstop. Zurueck kommen nur KAUFBARE Endpunkte -- einen nml-Artikel als "Ersatz"
* anzubieten waere schlechter als gar kein Hinweis.
*
* @param int[] $item_ids Shop-Artikel-IDs (items.id)
* @return array [item_id => ['id','number','name','price_brutto','bargain_brutto',
* 'default_image_file_name','wg_name','tiefe','weitere']]
*/
public function getAlternativesBatch(array $item_ids, $max_tiefe = 4) {
if (!$item_ids) return [];
if (!$this->mysql_conn) {
$this->connectMySQL();
}
$ids = array_values(array_unique(array_filter(array_map('intval', $item_ids))));
if (!$ids) return [];
$ids_str = implode(',', $ids);
$max_tiefe = max(1, (int)$max_tiefe);
$nml_name = self::NML_NAME_RE;
$nml_wg = self::NML_WG_RE;
// Startpunkte sind nur die nicht mehr lieferbaren Artikel: nur dort ist ein Nachfolger
// eine Antwort. Bei kaufbaren Artikeln zeigt der Shop die Alternativen ohnehin selbst
// auf der Artikelseite (item_description.tpl, Anker "realfriends").
$sql = <<<SQL
WITH RECURSIVE chain AS (
SELECT
a.item_parent_id AS start_id,
a.item_child_id AS cur,
1 AS tiefe,
a.position AS pos,
CAST(CONCAT(',', a.item_parent_id, ',', a.item_child_id, ',') AS CHAR(512)) AS pfad
FROM item_item_assign a
JOIN items p ON p.id = a.item_parent_id
LEFT JOIN structure ps ON ps.id = p.structure_id
WHERE a.type = 3
AND a.item_parent_id IN ($ids_str)
AND (p.name REGEXP '$nml_name' OR COALESCE(ps.name, '') REGEXP '$nml_wg')
UNION ALL
SELECT
ch.start_id,
a.item_child_id,
ch.tiefe + 1,
a.position,
CONCAT(ch.pfad, a.item_child_id, ',')
FROM chain ch
JOIN items c ON c.id = ch.cur AND c.active = 1
LEFT JOIN structure cs ON cs.id = c.structure_id
JOIN item_item_assign a ON a.item_parent_id = ch.cur AND a.type = 3
WHERE ch.tiefe < $max_tiefe
AND ch.pfad NOT LIKE CONCAT('%,', a.item_child_id, ',%')
AND (c.name REGEXP '$nml_name' OR COALESCE(cs.name, '') REGEXP '$nml_wg')
)
SELECT
ch.start_id,
ch.tiefe,
i.id,
i.number,
i.name,
i.default_image_file_name,
i.short_uri,
ROUND(ip.price_1 * (1 + COALESCE(t.steuersatz, 19) / 100), 2) AS price_brutto,
CASE WHEN ip.bargain_price_1 > 0
THEN ROUND(ip.bargain_price_1 * (1 + COALESCE(t.steuersatz, 19) / 100), 2)
ELSE NULL END AS bargain_brutto,
COALESCE(NULLIF(TRIM(s.name), ''), 'Ohne Warengruppe') AS wg_name
FROM chain ch
JOIN items i ON i.id = ch.cur AND i.active = 1
LEFT JOIN structure s ON s.id = i.structure_id
LEFT JOIN item_prices ip ON ip.item_id = i.id AND ip.customergroup_id = 1
LEFT JOIN tax t ON t.id = i.tax_id
WHERE i.name NOT REGEXP '$nml_name'
AND COALESCE(s.name, '') NOT REGEXP '$nml_wg'
ORDER BY ch.start_id, ch.tiefe, ch.pos, i.id
SQL;
$result = mysqli_query($this->mysql_conn, $sql);
if (!$result) {
// Ein fehlender Nachfolger-Hinweis darf die Suche nicht abbrechen.
error_log('tba-search getAlternativesBatch: ' . mysqli_error($this->mysql_conn));
return [];
}
$out = [];
while ($row = mysqli_fetch_assoc($result)) {
$start = (int)$row['start_id'];
if (!isset($out[$start])) {
// Erster Treffer je Artikel gewinnt: kleinste Kettentiefe, dann die im Backend
// gepflegte Reihenfolge (position).
unset($row['start_id']);
$row['tiefe'] = (int)$row['tiefe'];
$row['weitere'] = 0;
$out[$start] = $row;
} else {
$out[$start]['weitere']++;
}
}
return $out;
}
/**
* Haengt an jeden Artikel der Liste seine kaufbare Alternative unter dem Schluessel 'alt'.
* Ein Query fuer die ganze Liste; ohne Treffer bleibt 'alt' ungesetzt.
*/
private function attachAlternatives(array &$items) {
if (!$items) return;
$ids = [];
foreach ($items as $it) {
if (isset($it['id'])) $ids[] = (int)$it['id'];
}
$alts = $this->getAlternativesBatch($ids);
if (!$alts) return;
foreach ($items as &$it) {
$id = (int)($it['id'] ?? 0);
if (isset($alts[$id])) $it['alt'] = $alts[$id];
}
unset($it);
}
public function __construct($connect_mysql = false) {
$config_file = __DIR__ . '/config.ini';
@@ -88,7 +235,16 @@ class KremplDB {
* @param int $limit Maximum number of results (default: 20)
* @return array Array of devices with scores and ersatzteile
*/
public function searchGeraete($search_term, $limit = 20) {
/**
* @param string $search_term
* @param int $limit
* @param bool $with_parts Ersatzteile je Treffer mitladen. Default false, weil das
* pro Treffer einen eigenen Query kostet -- bei 20 Treffern also 20 zusaetzliche
* Abfragen, deren Ergebnis in der gefuehrten Ansicht nur fuer den Top-Treffer
* gebraucht wird. Die uebrigen Kacheln laden per AJAX nach und brauchen bis dahin
* nur die Anzahl (siehe getErsatzteilCountsBatch()).
*/
public function searchGeraete($search_term, $limit = 20, $with_parts = false) {
if (empty($search_term)) {
return [];
}
@@ -104,14 +260,168 @@ class KremplDB {
}
while ($row = pg_fetch_assoc($result)) {
// For each device, find matching ersatzteile
$row['ersatzteile'] = $this->getErsatzteileForGeraet($row['geraet_id']);
if ($with_parts) {
$row['ersatzteile'] = $this->getErsatzteileForGeraet($row['geraet_id']);
}
$results[] = $row;
}
return $results;
}
/**
* Anzahl zugeordneter Ersatzteile fuer mehrere Geraete in EINEM Query.
*
* Ersetzt die 20 Einzelaufrufe von getErsatzteileForGeraet(), die nur gezaehlt wurden --
* die eingeklappten Kacheln zeigen lediglich "N Ersatzteile laut Herstellerdaten
* hinterlegt" und holen die Liste selbst erst beim Aufklappen per AJAX.
*
* @param array $geraet_ids
* @return array geraet_id => Anzahl
*/
public function getErsatzteilCountsBatch(array $geraet_ids) {
$ids = array_values(array_unique(array_filter(array_map('intval', $geraet_ids))));
if (!$ids) return [];
// LATERAL + @> statt JOIN mit "= ANY()": nur @> trifft die GIN-Operatorklasse von
// idx_mapping_geraet_array, und LATERAL sorgt dafuer, dass der Index pro gid einmal
// benutzt wird statt einmal ueber die ganze Tabelle zu scannen.
// Gemessen fuer 20 Geraete: JOIN + "= ANY()" 4833 ms, korrelierte Subquery + @> 6 ms,
// LATERAL + @> 3,6 ms.
$query = "
SELECT g.gid, x.anzahl
FROM unnest($1::bigint[]) AS g(gid)
CROSS JOIN LATERAL (
SELECT COUNT(*) AS anzahl
FROM ersatzteil_mapping em
WHERE em.geraet_ids @> ARRAY[g.gid]
) x
";
$result = pg_query_params($this->pg_conn, $query, ['{' . implode(',', $ids) . '}']);
if (!$result) {
throw new Exception("Ersatzteil count query failed: " . pg_last_error($this->pg_conn));
}
$out = [];
while ($row = pg_fetch_assoc($result)) {
$out[$row['gid']] = (int)$row['anzahl'];
}
return $out;
}
/**
* Volldaten eines Geraets fuer die Bestaetigungsansicht ("Ist das dein Geraet?").
*
* search_geraete_smart() liefert nur geraet_id, nr, modell_bezeichnung, typ, marke,
* score und match_type. Fuer die gefuehrte Ansicht brauchen wir zusaetzlich typ_de,
* die Warengruppen-Texte und vor allem den Produktionszeitraum -- das Baujahr ist
* fuer Kunden der verlaesslichste Wiedererkennungsanker.
*
* Wird nur fuer den Top-Treffer aufgerufen, ist also ein einzelner PK-Lookup.
*
* @param int $geraet_id
* @return array|null Assoziatives Array oder null, wenn nicht gefunden
*/
public function getGeraetDetails($geraet_id) {
$query = "
SELECT id, nr, marke, typ, typ_de, typ_fr, modell_bezeichnung,
zusatz_nummer, produktionsstart, produktionsende,
wgtext1, wgtext2, zusatz, bezeichnung_original
FROM geraete
WHERE id = $1
LIMIT 1
";
$result = pg_query_params($this->pg_conn, $query, [$geraet_id]);
if (!$result) {
throw new Exception("Geraet detail query failed: " . pg_last_error($this->pg_conn));
}
$row = pg_fetch_assoc($result);
return $row ?: null;
}
/**
* Wie getGeraetDetails(), aber fuer mehrere IDs in einem Query.
*
* Gebraucht fuer die Varianten-Auswahl: BSH (SIEMENS/BOSCH) haengt Bauzustaende als
* "/01", "/02" ... an die Typnummer in nr. Laesst der Kunde das Suffix weg, kommen
* z.B. bei "WT46W560BY" zehn Treffer zurueck, die sich NUR im Produktionszeitraum
* unterscheiden -- den liefert search_geraete_smart() aber nicht mit.
*
* @param array $ids Geraet-IDs
* @return array geraet_id => Detailzeile
*/
public function getGeraetDetailsBatch(array $ids) {
$ids = array_values(array_unique(array_map('intval', array_filter($ids))));
if (!$ids) return [];
$query = "
SELECT id, nr, marke, typ, typ_de, modell_bezeichnung,
produktionsstart, produktionsende, wgtext1, wgtext2
FROM geraete
WHERE id = ANY($1)
";
$result = pg_query_params($this->pg_conn, $query, ['{' . implode(',', $ids) . '}']);
if (!$result) {
throw new Exception("Geraet batch query failed: " . pg_last_error($this->pg_conn));
}
$out = [];
while ($row = pg_fetch_assoc($result)) {
$out[$row['id']] = $row;
}
return $out;
}
/**
* Ordnet Krempl-Navision-IDs den Shop-Artikeln zu (MySQL), damit die Ersatzteil-Chips
* einen echten Shop-Link bekommen koennen.
*
* getErsatzteileForGeraet() liefert nur navision_id/originalnummer aus PostgreSQL --
* ohne items.id gibt es keinen Link. Die Zuordnung folgt derselben Regel wie
* getAvailableItemsForGeraet(): ab 8 Zeichen steht die Nummer in items.number,
* darunter in items.attribute_7.
*
* @param array $navision_ids
* @return array navision_id => ['id' => items.id, 'name' => ..., 'number' => ...]
*/
public function getShopItemsByNavisionIds(array $navision_ids) {
$ids = array_values(array_unique(array_filter(array_map('trim', $navision_ids), function ($v) {
return $v !== '';
})));
if (!$ids) return [];
if (!$this->mysql_conn) {
$this->connectMySQL();
}
$esc = [];
foreach ($ids as $v) {
$esc[] = "'" . mysqli_real_escape_string($this->mysql_conn, $v) . "'";
}
$in = implode(',', $esc);
$sql = "
SELECT i.id, i.number, i.name, i.attribute_7, i.short_uri,
CASE WHEN LENGTH(i.number) >= 8 THEN i.number ELSE i.attribute_7 END AS matched_navision_id
FROM items i
WHERE (
(LENGTH(i.number) >= 8 AND i.number IN ($in))
OR (LENGTH(i.number) < 8 AND i.attribute_7 IN ($in))
)
AND i.active = 1
";
$result = mysqli_query($this->mysql_conn, $sql);
if (!$result) {
throw new Exception("Shop item lookup failed: " . mysqli_error($this->mysql_conn));
}
$map = [];
while ($row = mysqli_fetch_assoc($result)) {
$key = trim((string)$row['matched_navision_id']);
if ($key !== '' && !isset($map[$key])) {
$map[$key] = ['id' => $row['id'], 'number' => $row['number'], 'name' => $row['name']];
}
}
return $map;
}
/**
* Get all ersatzteile (spare parts) for a specific device
*
@@ -128,7 +438,10 @@ class KremplDB {
e.marke
FROM ersatzteil_mapping em
INNER JOIN ersatzteile e ON em.ersatzteil_id = e.id
WHERE $1 = ANY(em.geraet_ids)
-- @> statt = ANY(geraet_ids): nur der contains-Operator trifft die
-- GIN-Operatorklasse von idx_mapping_geraet_array. Mit = ANY() macht PG einen
-- Seq Scan ueber 532 MB (gemessen 788 ms), mit @> einen Bitmap Index Scan (6 ms).
WHERE em.geraet_ids @> ARRAY[$1::bigint]
ORDER BY e.navision_id
LIMIT $2
";
@@ -284,7 +597,8 @@ class KremplDB {
SELECT e.navision_id
FROM ersatzteil_mapping em
INNER JOIN ersatzteile e ON em.ersatzteil_id = e.id
WHERE $1 = ANY(em.geraet_ids)
-- @> statt = ANY(), sonst greift der GIN-Index nicht (788 ms -> 6 ms).
WHERE em.geraet_ids @> ARRAY[$1::bigint]
AND e.navision_id IS NOT NULL
ORDER BY e.navision_id
";
@@ -319,11 +633,22 @@ class KremplDB {
i.base_price,
i.manufacturer_id,
i.structure_id,
i.default_image_file_name,
i.short_uri,
ROUND(ip.price_1 * (1 + COALESCE(t.steuersatz, 19) / 100), 2) AS price_brutto,
CASE WHEN ip.bargain_price_1 > 0
THEN ROUND(ip.bargain_price_1 * (1 + COALESCE(t.steuersatz, 19) / 100), 2)
ELSE NULL END AS bargain_brutto,
CASE
WHEN LENGTH(i.number) >= 8 THEN i.number
ELSE i.attribute_7
END AS matched_navision_id
END AS matched_navision_id,
COALESCE(NULLIF(TRIM(s.name), ''), 'Ohne Warengruppe') AS wg_name,
i.structure_id AS wg_id
FROM items i
LEFT JOIN item_prices ip ON ip.item_id = i.id AND ip.customergroup_id = 1
LEFT JOIN tax t ON t.id = i.tax_id
LEFT JOIN structure s ON s.id = i.structure_id
WHERE (
(LENGTH(i.number) >= 8 AND i.number IN ($navision_ids_str))
OR
@@ -345,6 +670,78 @@ class KremplDB {
$items[] = $row;
}
$this->attachAlternatives($items);
return $items;
}
/**
* Full-text-ähnliche Suche auf MySQL items.
* Multi-Token-AND auf name + number + attribute_7. Nur aktive Items.
* Sortiert nach inventory (verfügbare zuerst), dann name.
*
* @param string $search_term
* @param int $limit
* @return array
*/
public function searchItems($search_term, $limit = 20) {
if (empty($search_term)) return [];
if (!$this->mysql_conn) {
$this->connectMySQL();
}
$tokens = preg_split('/\s+/', trim($search_term));
$tokens = array_filter($tokens, fn($t) => strlen($t) > 0);
if (!$tokens) return [];
$where_parts = [];
foreach ($tokens as $tok) {
$tok_esc = mysqli_real_escape_string($this->mysql_conn, $tok);
$where_parts[] = "(i.name LIKE '%$tok_esc%' OR i.number LIKE '%$tok_esc%' OR i.attribute_7 LIKE '%$tok_esc%')";
}
$where = implode(' AND ', $where_parts);
$limit = (int)$limit;
$sql = "
SELECT
i.id,
i.number,
i.name,
i.attribute_7 AS navision_id_alt,
i.inventory,
i.base_price,
i.default_image_file_name,
i.short_uri,
CASE WHEN LENGTH(i.number) >= 8 THEN i.number ELSE i.attribute_7 END AS matched_navision_id,
ROUND(ip.price_1 * (1 + COALESCE(t.steuersatz, 19) / 100), 2) AS price_brutto,
CASE WHEN ip.bargain_price_1 > 0
THEN ROUND(ip.bargain_price_1 * (1 + COALESCE(t.steuersatz, 19) / 100), 2)
ELSE NULL END AS bargain_brutto,
COALESCE(NULLIF(TRIM(s.name), ''), 'Ohne Warengruppe') AS wg_name,
i.structure_id AS wg_id
FROM items i
LEFT JOIN item_prices ip ON ip.item_id = i.id AND ip.customergroup_id = 1
LEFT JOIN tax t ON t.id = i.tax_id
LEFT JOIN structure s ON s.id = i.structure_id
WHERE i.active = 1
AND $where
ORDER BY (i.inventory IS NULL) ASC, i.inventory DESC, i.name ASC
LIMIT $limit
";
$result = mysqli_query($this->mysql_conn, $sql);
if (!$result) {
throw new Exception("Item search failed: " . mysqli_error($this->mysql_conn));
}
$items = [];
while ($row = mysqli_fetch_assoc($result)) {
$items[] = $row;
}
$this->attachAlternatives($items);
return $items;
}
@@ -0,0 +1,602 @@
<?php
/**
* Krempl PostgreSQL Database Connection Class
*
* Usage:
* require_once __DIR__ . '/core/postgres/KremplDB.php';
* $db = new KremplDB();
* $results = $db->searchGeraete('AWB 921 PH');
*/
class KremplDB {
private $pg_conn = null;
private $mysql_conn = null;
private $config = null;
public function __construct($connect_mysql = false) {
$config_file = __DIR__ . '/config.ini';
if (!file_exists($config_file)) {
throw new Exception("Config file not found: $config_file");
}
$this->config = parse_ini_file($config_file, true);
if (!isset($this->config['postgresql'])) {
throw new Exception("PostgreSQL config section not found in config.ini");
}
$this->connectPostgres();
if ($connect_mysql) {
$this->connectMySQL();
}
}
private function connectPostgres() {
$pg = $this->config['postgresql'];
$conn_string = sprintf(
"host=%s port=%s dbname=%s user=%s password=%s",
$pg['host'] ?? 'localhost',
$pg['port'] ?? '5432',
$pg['database'] ?? 'krempl_data',
$pg['user'] ?? 'krempl_user',
$pg['password'] ?? ''
);
$this->pg_conn = @pg_connect($conn_string);
if (!$this->pg_conn) {
throw new Exception("PostgreSQL connection failed. Check credentials in config.ini");
}
}
private function connectMySQL() {
if (!isset($this->config['mysql'])) {
throw new Exception("MySQL config section not found in config.ini");
}
$my = $this->config['mysql'];
$this->mysql_conn = @mysqli_connect(
$my['host'] ?? 'localhost',
$my['user'] ?? '',
$my['password'] ?? '',
$my['database'] ?? ''
);
if (!$this->mysql_conn) {
throw new Exception("MySQL connection failed: " . mysqli_connect_error());
}
mysqli_set_charset($this->mysql_conn, 'utf8mb4');
}
public function getPostgresConnection() {
return $this->pg_conn;
}
public function getMySQLConnection() {
return $this->mysql_conn;
}
/**
* Search for devices using intelligent normalized search
*
* @param string $search_term The search term (e.g. "AWB 921 PH", "ZCS 2100B")
* @param int $limit Maximum number of results (default: 20)
* @return array Array of devices with scores and ersatzteile
*/
/**
* @param string $search_term
* @param int $limit
* @param bool $with_parts Ersatzteile je Treffer mitladen. Default false, weil das
* pro Treffer einen eigenen Query kostet -- bei 20 Treffern also 20 zusaetzliche
* Abfragen, deren Ergebnis in der gefuehrten Ansicht nur fuer den Top-Treffer
* gebraucht wird. Die uebrigen Kacheln laden per AJAX nach und brauchen bis dahin
* nur die Anzahl (siehe getErsatzteilCountsBatch()).
*/
public function searchGeraete($search_term, $limit = 20, $with_parts = false) {
if (empty($search_term)) {
return [];
}
$results = [];
// Search for devices using the smart search function
$query = "SELECT * FROM search_geraete_smart($1, $2)";
$result = pg_query_params($this->pg_conn, $query, [$search_term, $limit]);
if (!$result) {
throw new Exception("Search query failed: " . pg_last_error($this->pg_conn));
}
while ($row = pg_fetch_assoc($result)) {
if ($with_parts) {
$row['ersatzteile'] = $this->getErsatzteileForGeraet($row['geraet_id']);
}
$results[] = $row;
}
return $results;
}
/**
* Anzahl zugeordneter Ersatzteile fuer mehrere Geraete in EINEM Query.
*
* Ersetzt die 20 Einzelaufrufe von getErsatzteileForGeraet(), die nur gezaehlt wurden --
* die eingeklappten Kacheln zeigen lediglich "N Ersatzteile laut Herstellerdaten
* hinterlegt" und holen die Liste selbst erst beim Aufklappen per AJAX.
*
* @param array $geraet_ids
* @return array geraet_id => Anzahl
*/
public function getErsatzteilCountsBatch(array $geraet_ids) {
$ids = array_values(array_unique(array_filter(array_map('intval', $geraet_ids))));
if (!$ids) return [];
// LATERAL + @> statt JOIN mit "= ANY()": nur @> trifft die GIN-Operatorklasse von
// idx_mapping_geraet_array, und LATERAL sorgt dafuer, dass der Index pro gid einmal
// benutzt wird statt einmal ueber die ganze Tabelle zu scannen.
// Gemessen fuer 20 Geraete: JOIN + "= ANY()" 4833 ms, korrelierte Subquery + @> 6 ms,
// LATERAL + @> 3,6 ms.
$query = "
SELECT g.gid, x.anzahl
FROM unnest($1::bigint[]) AS g(gid)
CROSS JOIN LATERAL (
SELECT COUNT(*) AS anzahl
FROM ersatzteil_mapping em
WHERE em.geraet_ids @> ARRAY[g.gid]
) x
";
$result = pg_query_params($this->pg_conn, $query, ['{' . implode(',', $ids) . '}']);
if (!$result) {
throw new Exception("Ersatzteil count query failed: " . pg_last_error($this->pg_conn));
}
$out = [];
while ($row = pg_fetch_assoc($result)) {
$out[$row['gid']] = (int)$row['anzahl'];
}
return $out;
}
/**
* Volldaten eines Geraets fuer die Bestaetigungsansicht ("Ist das dein Geraet?").
*
* search_geraete_smart() liefert nur geraet_id, nr, modell_bezeichnung, typ, marke,
* score und match_type. Fuer die gefuehrte Ansicht brauchen wir zusaetzlich typ_de,
* die Warengruppen-Texte und vor allem den Produktionszeitraum -- das Baujahr ist
* fuer Kunden der verlaesslichste Wiedererkennungsanker.
*
* Wird nur fuer den Top-Treffer aufgerufen, ist also ein einzelner PK-Lookup.
*
* @param int $geraet_id
* @return array|null Assoziatives Array oder null, wenn nicht gefunden
*/
public function getGeraetDetails($geraet_id) {
$query = "
SELECT id, nr, marke, typ, typ_de, typ_fr, modell_bezeichnung,
zusatz_nummer, produktionsstart, produktionsende,
wgtext1, wgtext2, zusatz, bezeichnung_original
FROM geraete
WHERE id = $1
LIMIT 1
";
$result = pg_query_params($this->pg_conn, $query, [$geraet_id]);
if (!$result) {
throw new Exception("Geraet detail query failed: " . pg_last_error($this->pg_conn));
}
$row = pg_fetch_assoc($result);
return $row ?: null;
}
/**
* Wie getGeraetDetails(), aber fuer mehrere IDs in einem Query.
*
* Gebraucht fuer die Varianten-Auswahl: BSH (SIEMENS/BOSCH) haengt Bauzustaende als
* "/01", "/02" ... an die Typnummer in nr. Laesst der Kunde das Suffix weg, kommen
* z.B. bei "WT46W560BY" zehn Treffer zurueck, die sich NUR im Produktionszeitraum
* unterscheiden -- den liefert search_geraete_smart() aber nicht mit.
*
* @param array $ids Geraet-IDs
* @return array geraet_id => Detailzeile
*/
public function getGeraetDetailsBatch(array $ids) {
$ids = array_values(array_unique(array_map('intval', array_filter($ids))));
if (!$ids) return [];
$query = "
SELECT id, nr, marke, typ, typ_de, modell_bezeichnung,
produktionsstart, produktionsende, wgtext1, wgtext2
FROM geraete
WHERE id = ANY($1)
";
$result = pg_query_params($this->pg_conn, $query, ['{' . implode(',', $ids) . '}']);
if (!$result) {
throw new Exception("Geraet batch query failed: " . pg_last_error($this->pg_conn));
}
$out = [];
while ($row = pg_fetch_assoc($result)) {
$out[$row['id']] = $row;
}
return $out;
}
/**
* Ordnet Krempl-Navision-IDs den Shop-Artikeln zu (MySQL), damit die Ersatzteil-Chips
* einen echten Shop-Link bekommen koennen.
*
* getErsatzteileForGeraet() liefert nur navision_id/originalnummer aus PostgreSQL --
* ohne items.id gibt es keinen Link. Die Zuordnung folgt derselben Regel wie
* getAvailableItemsForGeraet(): ab 8 Zeichen steht die Nummer in items.number,
* darunter in items.attribute_7.
*
* @param array $navision_ids
* @return array navision_id => ['id' => items.id, 'name' => ..., 'number' => ...]
*/
public function getShopItemsByNavisionIds(array $navision_ids) {
$ids = array_values(array_unique(array_filter(array_map('trim', $navision_ids), function ($v) {
return $v !== '';
})));
if (!$ids) return [];
if (!$this->mysql_conn) {
$this->connectMySQL();
}
$esc = [];
foreach ($ids as $v) {
$esc[] = "'" . mysqli_real_escape_string($this->mysql_conn, $v) . "'";
}
$in = implode(',', $esc);
$sql = "
SELECT i.id, i.number, i.name, i.attribute_7,
CASE WHEN LENGTH(i.number) >= 8 THEN i.number ELSE i.attribute_7 END AS matched_navision_id
FROM items i
WHERE (
(LENGTH(i.number) >= 8 AND i.number IN ($in))
OR (LENGTH(i.number) < 8 AND i.attribute_7 IN ($in))
)
AND i.active = 1
";
$result = mysqli_query($this->mysql_conn, $sql);
if (!$result) {
throw new Exception("Shop item lookup failed: " . mysqli_error($this->mysql_conn));
}
$map = [];
while ($row = mysqli_fetch_assoc($result)) {
$key = trim((string)$row['matched_navision_id']);
if ($key !== '' && !isset($map[$key])) {
$map[$key] = ['id' => $row['id'], 'number' => $row['number'], 'name' => $row['name']];
}
}
return $map;
}
/**
* Get all ersatzteile (spare parts) for a specific device
*
* @param int $geraet_id The device ID
* @param int $limit Maximum number of results (default: 200)
* @return array Array of ersatzteile with navision_id, krempl_id, etc.
*/
public function getErsatzteileForGeraet($geraet_id, $limit = 200) {
$query = "
SELECT
e.id AS krempl_id,
e.navision_id,
e.originalnummer,
e.marke
FROM ersatzteil_mapping em
INNER JOIN ersatzteile e ON em.ersatzteil_id = e.id
-- @> statt = ANY(geraet_ids): nur der contains-Operator trifft die
-- GIN-Operatorklasse von idx_mapping_geraet_array. Mit = ANY() macht PG einen
-- Seq Scan ueber 532 MB (gemessen 788 ms), mit @> einen Bitmap Index Scan (6 ms).
WHERE em.geraet_ids @> ARRAY[$1::bigint]
ORDER BY e.navision_id
LIMIT $2
";
$result = pg_query_params($this->pg_conn, $query, [$geraet_id, $limit]);
if (!$result) {
throw new Exception("Ersatzteile query failed: " . pg_last_error($this->pg_conn));
}
$ersatzteile = [];
while ($row = pg_fetch_assoc($result)) {
$ersatzteile[] = $row;
}
return $ersatzteile;
}
/**
* Get all devices compatible with a specific ersatzteil (by navision_id)
*
* @param int $navision_id The Navision ID from the shop
* @param int $limit Maximum number of results (default: 500)
* @return array Array of compatible devices
*/
public function getGeraeteForNavisionId($navision_id, $limit = 500) {
// First find the krempl ersatzteil_id
$query1 = "SELECT id FROM ersatzteile WHERE navision_id = $1 LIMIT 1";
$result1 = pg_query_params($this->pg_conn, $query1, [$navision_id]);
if (!$result1) {
throw new Exception("Navision lookup failed: " . pg_last_error($this->pg_conn));
}
$row = pg_fetch_assoc($result1);
if (!$row) {
return []; // No krempl data for this navision_id
}
$ersatzteil_id = $row['id'];
// Get the array of device IDs
$query2 = "SELECT geraet_ids FROM ersatzteil_mapping WHERE ersatzteil_id = $1";
$result2 = pg_query_params($this->pg_conn, $query2, [$ersatzteil_id]);
if (!$result2) {
throw new Exception("Mapping lookup failed: " . pg_last_error($this->pg_conn));
}
$row = pg_fetch_assoc($result2);
if (!$row || !$row['geraet_ids']) {
return [];
}
// Parse the PostgreSQL array format
$geraet_ids_str = trim($row['geraet_ids'], '{}');
$geraet_ids = array_map('intval', explode(',', $geraet_ids_str));
// Get device details
$query3 = "
SELECT
id,
nr,
modell_bezeichnung,
typ,
typ_de,
marke,
zusatz
FROM geraete
WHERE id = ANY($1)
ORDER BY marke, modell_bezeichnung
LIMIT $2
";
$result3 = pg_query_params($this->pg_conn, $query3, ['{' . implode(',', $geraet_ids) . '}', $limit]);
if (!$result3) {
throw new Exception("Geraete lookup failed: " . pg_last_error($this->pg_conn));
}
$geraete = [];
while ($row = pg_fetch_assoc($result3)) {
$geraete[] = $row;
}
return $geraete;
}
/**
* Rebuild the search index after CSV re-import
* Call this after running import.py
*
* @return int Number of rows indexed
*/
public function rebuildSearchIndex() {
$query = "SELECT rebuild_geraete_search_index()";
$result = pg_query($this->pg_conn, $query);
if (!$result) {
throw new Exception("Rebuild index failed: " . pg_last_error($this->pg_conn));
}
$row = pg_fetch_row($result);
return (int)$row[0];
}
/**
* Get database statistics
*
* @return array Statistics about the database
*/
public function getStats() {
$stats = [];
$queries = [
'geraete_count' => "SELECT COUNT(*) FROM geraete",
'ersatzteile_count' => "SELECT COUNT(*) FROM ersatzteile",
'mapping_count' => "SELECT COUNT(*) FROM ersatzteil_mapping",
'passendwie_count' => "SELECT COUNT(*) FROM passendwie",
'search_index_count' => "SELECT COUNT(*) FROM geraete_search_index",
];
foreach ($queries as $key => $query) {
$result = pg_query($this->pg_conn, $query);
if ($result) {
$row = pg_fetch_row($result);
$stats[$key] = (int)$row[0];
}
}
return $stats;
}
/**
* Get available shop items for a device (Hybrid: PostgreSQL + MySQL)
*
* This function combines Krempl data (PostgreSQL) with shop inventory (MySQL)
* to show only the spare parts that are actually available in the shop.
*
* @param int $geraet_id The Krempl device ID
* @param int $limit Maximum number of items to return (default: 200)
* @return array Array of shop items with full details
*/
public function getAvailableItemsForGeraet($geraet_id, $limit = 200) {
// Ensure MySQL is connected
if (!$this->mysql_conn) {
$this->connectMySQL();
}
// Step 1: Get all Navision IDs from PostgreSQL for this device
$navision_ids = [];
$query = "
SELECT e.navision_id
FROM ersatzteil_mapping em
INNER JOIN ersatzteile e ON em.ersatzteil_id = e.id
-- @> statt = ANY(), sonst greift der GIN-Index nicht (788 ms -> 6 ms).
WHERE em.geraet_ids @> ARRAY[$1::bigint]
AND e.navision_id IS NOT NULL
ORDER BY e.navision_id
";
$result = pg_query_params($this->pg_conn, $query, [$geraet_id]);
if (!$result) {
throw new Exception("Failed to get navision_ids: " . pg_last_error($this->pg_conn));
}
while ($row = pg_fetch_assoc($result)) {
$navision_ids[] = (int)$row['navision_id'];
}
if (empty($navision_ids)) {
return []; // No parts found
}
// Step 2: Query MySQL for items that match these Navision IDs
// Logic: number >= 8 digits → navision_id is in `number`
// number < 8 digits → navision_id is in `attribute_7`
$navision_ids_str = implode(',', $navision_ids);
$mysql_query = "
SELECT
i.id,
i.number,
i.name,
i.attribute_7 AS navision_id_alt,
i.inventory,
i.base_price,
i.manufacturer_id,
i.structure_id,
i.default_image_file_name,
ROUND(ip.price_1 * (1 + COALESCE(t.steuersatz, 19) / 100), 2) AS price_brutto,
CASE WHEN ip.bargain_price_1 > 0
THEN ROUND(ip.bargain_price_1 * (1 + COALESCE(t.steuersatz, 19) / 100), 2)
ELSE NULL END AS bargain_brutto,
CASE
WHEN LENGTH(i.number) >= 8 THEN i.number
ELSE i.attribute_7
END AS matched_navision_id,
COALESCE(NULLIF(TRIM(s.name), ''), 'Ohne Warengruppe') AS wg_name,
i.structure_id AS wg_id
FROM items i
LEFT JOIN item_prices ip ON ip.item_id = i.id AND ip.customergroup_id = 1
LEFT JOIN tax t ON t.id = i.tax_id
LEFT JOIN structure s ON s.id = i.structure_id
WHERE (
(LENGTH(i.number) >= 8 AND i.number IN ($navision_ids_str))
OR
(LENGTH(i.number) < 8 AND i.attribute_7 IN ($navision_ids_str))
)
AND i.active = 1
ORDER BY i.name
LIMIT $limit
";
$mysql_result = mysqli_query($this->mysql_conn, $mysql_query);
if (!$mysql_result) {
throw new Exception("MySQL query failed: " . mysqli_error($this->mysql_conn));
}
$items = [];
while ($row = mysqli_fetch_assoc($mysql_result)) {
$items[] = $row;
}
return $items;
}
/**
* Full-text-ähnliche Suche auf MySQL items.
* Multi-Token-AND auf name + number + attribute_7. Nur aktive Items.
* Sortiert nach inventory (verfügbare zuerst), dann name.
*
* @param string $search_term
* @param int $limit
* @return array
*/
public function searchItems($search_term, $limit = 20) {
if (empty($search_term)) return [];
if (!$this->mysql_conn) {
$this->connectMySQL();
}
$tokens = preg_split('/\s+/', trim($search_term));
$tokens = array_filter($tokens, fn($t) => strlen($t) > 0);
if (!$tokens) return [];
$where_parts = [];
foreach ($tokens as $tok) {
$tok_esc = mysqli_real_escape_string($this->mysql_conn, $tok);
$where_parts[] = "(i.name LIKE '%$tok_esc%' OR i.number LIKE '%$tok_esc%' OR i.attribute_7 LIKE '%$tok_esc%')";
}
$where = implode(' AND ', $where_parts);
$limit = (int)$limit;
$sql = "
SELECT
i.id,
i.number,
i.name,
i.attribute_7 AS navision_id_alt,
i.inventory,
i.base_price,
i.default_image_file_name,
CASE WHEN LENGTH(i.number) >= 8 THEN i.number ELSE i.attribute_7 END AS matched_navision_id,
ROUND(ip.price_1 * (1 + COALESCE(t.steuersatz, 19) / 100), 2) AS price_brutto,
CASE WHEN ip.bargain_price_1 > 0
THEN ROUND(ip.bargain_price_1 * (1 + COALESCE(t.steuersatz, 19) / 100), 2)
ELSE NULL END AS bargain_brutto,
COALESCE(NULLIF(TRIM(s.name), ''), 'Ohne Warengruppe') AS wg_name,
i.structure_id AS wg_id
FROM items i
LEFT JOIN item_prices ip ON ip.item_id = i.id AND ip.customergroup_id = 1
LEFT JOIN tax t ON t.id = i.tax_id
LEFT JOIN structure s ON s.id = i.structure_id
WHERE i.active = 1
AND $where
ORDER BY (i.inventory IS NULL) ASC, i.inventory DESC, i.name ASC
LIMIT $limit
";
$result = mysqli_query($this->mysql_conn, $sql);
if (!$result) {
throw new Exception("Item search failed: " . mysqli_error($this->mysql_conn));
}
$items = [];
while ($row = mysqli_fetch_assoc($result)) {
$items[] = $row;
}
return $items;
}
public function __destruct() {
if ($this->pg_conn) {
pg_close($this->pg_conn);
}
if ($this->mysql_conn) {
mysqli_close($this->mysql_conn);
}
}
}
@@ -0,0 +1,30 @@
[postgresql]
host = localhost
port = 5432
database = krempl_data
user = krempl_user
password = KremplSecure2025
[mysql]
host = localhost
user = webshopadm
password = "Je=53qi2Jey53qi2"
database = webshop-sql2022
[csv_files]
geraete = /var/www/vhosts/intelectra.de/httpdocs/upload/geraete_Export.csv
artikel = /var/www/vhosts/intelectra.de/httpdocs/upload/artikel_Export.csv
mapping = /var/www/vhosts/intelectra.de/httpdocs/upload/artikelgeraet_Export.csv
passendwie = /var/www/vhosts/intelectra.de/httpdocs/upload/passendwie_Export.csv
[import_settings]
batch_size_geraete = 1000
batch_size_artikel = 1000
batch_size_passendwie = 1000
batch_size_mapping = 500
mapping_in_memory = true
progress_interval = 1000000
[logging]
log_level = INFO
log_file =
+129
View File
@@ -0,0 +1,129 @@
<?php
/**
* Ereignis-Log fuer Bestellvorgaenge (25.08.2026).
*
* Warum PostgreSQL und nicht der Shop selbst:
* Die MySQL-Tabelle `orders` hat keine einzige Spalte mit Uhrzeit -- `order_date` und alle
* anderen Datumsfelder sind Typ DATE, und `order_history.timestamp` wird bei der
* Bestellanlage nicht gefuellt. Damit laesst sich weder eine Abbruchquote sauber messen
* noch belegen, ob eine Aenderung am Bezahlweg etwas gebracht hat. Genau daran ist die
* Analyse der PayPal-Abbrueche zuerst haengengeblieben.
*
* Grundregel dieser Datei: **Der Bestellvorgang darf hier NIEMALS haengenbleiben.**
* Ist Postgres weg, langsam oder die Tabelle fehlt, wird still weitergemacht. Ein
* Messwerkzeug, das den Kaufvorgang gefaehrdet, ist sein Geld nicht wert. Deshalb
* connect_timeout, deshalb ueberall @ und Rueckgabepruefung statt Exceptions.
*
* Ereignisse:
* order_created Bestellung in MySQL angelegt (bei PayPal: noch nicht bezahlt!)
* payment_started Kunde wurde zu PayPal geschickt, Token steht
* paid Geld eingezogen (DoExpressCheckoutPayment erfolgreich)
* cancelled Kunde hat bei PayPal abgebrochen
* failed PayPal hat den Abschluss abgelehnt
* duplicate_blocked zweiter Versuch desselben Vorgangs abgefangen
* mail_sent Bestellbestaetigung ausgeloest
*
* Auswertung z. B.:
* SELECT date_trunc('day', zeitpunkt) AS tag,
* count(*) FILTER (WHERE ereignis = 'payment_started') AS gestartet,
* count(*) FILTER (WHERE ereignis = 'paid') AS bezahlt
* FROM shop_order_events GROUP BY 1 ORDER BY 1;
*/
if (!function_exists('shop_event')) {
/**
* Schreibt ein Ereignis. Schlaegt das fehl, passiert nichts weiter.
*
* @param string $ereignis siehe Liste oben
* @param array $daten order_id, order_number, payment_method, betrag,
* paypal_token, transaction_id, customer_id, detail
* @return bool true wenn geschrieben, sonst false
*/
function shop_event($ereignis, $daten = array()) {
static $verbindung = null;
static $tot = false;
// Einmal gescheitert -> fuer diesen Request nicht weiter versuchen. Sonst wartet
// jeder Aufruf erneut auf einen Timeout.
if ($tot) {
return false;
}
if (!function_exists('pg_connect')) {
$tot = true;
return false;
}
if ($verbindung === null) {
$ini_pfad = dirname(__FILE__) . '/config.ini';
if (!is_readable($ini_pfad)) {
$tot = true;
return false;
}
$ini = @parse_ini_file($ini_pfad, true);
if (!$ini || empty($ini['postgresql'])) {
$tot = true;
return false;
}
$pg = $ini['postgresql'];
// connect_timeout ist der wichtigste Parameter hier: liegt Postgres,
// wartet der Checkout hoechstens zwei Sekunden statt bis zum PHP-Timeout.
$dsn = sprintf(
'host=%s port=%s dbname=%s user=%s password=%s connect_timeout=2',
isset($pg['host']) ? $pg['host'] : 'localhost',
isset($pg['port']) ? $pg['port'] : '5432',
isset($pg['database']) ? $pg['database'] : '',
isset($pg['user']) ? $pg['user'] : '',
isset($pg['password']) ? $pg['password'] : ''
);
$verbindung = @pg_connect($dsn);
if (!$verbindung) {
$verbindung = null;
$tot = true;
error_log('shop_event: keine PG-Verbindung -- Ereignisprotokoll uebersprungen');
return false;
}
}
$werte = array(
(string)$ereignis,
isset($daten['order_id']) && $daten['order_id'] !== '' ? (int)$daten['order_id'] : null,
isset($daten['order_number']) ? (string)$daten['order_number'] : null,
isset($daten['payment_method']) && $daten['payment_method'] !== '' ? (int)$daten['payment_method'] : null,
isset($daten['betrag']) && $daten['betrag'] !== '' ? (float)$daten['betrag'] : null,
isset($daten['paypal_token']) ? (string)$daten['paypal_token'] : null,
isset($daten['transaction_id']) ? (string)$daten['transaction_id'] : null,
isset($daten['customer_id']) && $daten['customer_id'] !== '' ? (int)$daten['customer_id'] : null,
function_exists('session_id') && session_id() ? session_id() : null,
isset($daten['detail']) ? (string)$daten['detail'] : null
);
$ergebnis = @pg_query_params(
$verbindung,
'INSERT INTO shop_order_events
(ereignis, order_id, order_number, payment_method, betrag,
paypal_token, transaction_id, customer_id, session_id, detail)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)',
$werte
);
if (!$ergebnis) {
// Einmal melden, dann Ruhe -- ein volllaufendes Log hilft niemandem.
$tot = true;
error_log('shop_event: INSERT fehlgeschlagen (' . $ereignis . ') -- '
. @pg_last_error($verbindung));
return false;
}
return true;
}
}
/* EOF */
+349
View File
@@ -0,0 +1,349 @@
<?php
/**
* Render-Helfer fuer tba-search.php.
*
* Ausgelagert, damit der AJAX-Endpoint (Nachladen der Artikel beim Aufklappen einer
* Geraete-Kachel) dieselben Funktionen nutzen kann -- der laeuft VOR jeder HTML-Ausgabe
* und kaeme an Funktionen, die mitten im Template definiert sind, nicht heran.
*/
/**
* Basis-URL fuer Shop-Links und Artikelbilder.
*
* Auf dev/newmail MUSS sie absolut auf den Live-Host zeigen: dort liegen weder die
* Shop-Daten noch die ~113k Artikelbilder (~5 GB), beides existiert nur auf intelectra.de.
*
* Beim Einbau ins Live-Shop-Frontend (Smarty) hier einfach '' setzen oder vor dem
* require mit define()/Smarty-Variable ueberschreiben -- dann werden alle Links relativ,
* ohne dass an den Render-Funktionen etwas geaendert werden muss:
*
* define('TBA_SHOP_BASE', ''); // relativ, fuer den Live-Shop
* require_once '.../tba_render.php';
*/
if (!defined('TBA_SHOP_BASE')) {
// 05.08.2026 von intelectra.de auf intelectra-shop.de umgestellt: intelectra.de leitet seit
// dem per 301 dorthin um (Domain-Konsolidierung, siehe .htaccess des Live-Docroots).
// Ohne diese Aenderung liefe jeder Artikel- und Bildlink der Suche durch die Weiterleitung.
define('TBA_SHOP_BASE', 'https://intelectra-shop.de');
}
/** Shop-Basis + Pfad, ohne doppelten Slash. */
function tba_url($path) {
return rtrim(TBA_SHOP_BASE, '/') . '/' . ltrim($path, '/');
}
if (!function_exists('v2_confidence')) {
/**
* Anzeigename eines Geraets. modell_bezeichnung ist bei 490.522 von 1.675.828
* Geraeten LEER (29%) -- vor allem bei BSH (SIEMENS/BOSCH), wo die Typnummer
* stattdessen in nr steht (z.B. "WT46W560BY/01"). Ohne Fallback rendern die
* Kacheln komplett namenlos.
*/
function v2_gname($d) {
foreach (['modell_bezeichnung', 'bezeichnung_original', 'nr'] as $k) {
if (isset($d[$k]) && trim((string)$d[$k]) !== '') return trim($d[$k]);
}
return '(ohne Bezeichnung)';
}
/**
* Produktionsdatum -> Jahr. In produktionsstart/-ende stecken ZWEI Formate:
* YYYYMMDD z.B. "19790101" (AEG Lavatherm 400)
* YYMM z.B. "8810" (SIEMENS WT46W560BY/01 = Oktober 1988)
* Bei YYMM gilt: >= 70 -> 19YY, sonst 20YY.
*/
function v2_jahr4($v) {
$s = preg_replace('/\D/', '', (string)$v);
if ($s === '') return '';
if (strlen($s) >= 6) return substr($s, 0, 4); // YYYYMM(DD)
if (strlen($s) === 4) { // YYMM
$yy = (int)substr($s, 0, 2);
return (string)($yy >= 70 ? 1900 + $yy : 2000 + $yy);
}
if (strlen($s) === 2) { // nur YY
$yy = (int)$s;
return (string)($yy >= 70 ? 1900 + $yy : 2000 + $yy);
}
return '';
}
/** Monat/Jahr fuer die Variantenliste, z.B. "10/1988". Leer, wenn nicht ableitbar. */
function v2_monat($v) {
$s = preg_replace('/\D/', '', (string)$v);
$j = v2_jahr4($s);
if ($j === '') return '';
if (strlen($s) >= 6) $m = substr($s, 4, 2); // YYYYMM(DD)
elseif (strlen($s) === 4) $m = substr($s, 2, 2); // YYMM
else return $j;
$m = (int)$m;
return ($m >= 1 && $m <= 12) ? sprintf('%02d/%s', $m, $j) : $j;
}
/**
* Shop-URL zu einem Artikel -- leer, wenn kein Shop-Artikel bekannt ist.
*
* Bevorzugt die sprechende SEO-URL "/<short_uri>.html". Das Feld `items.short_uri`
* ist bei allen 16.927 aktiven Artikeln gefuellt (Format "<Name-Slug>-<Artikelnr>")
* und wird vom Shop selbst so verlinkt (item_box.tpl). Aufgeloest wird sie in der
* .htaccess ueber "RewriteRule ^([aA-zZ0-9-_,]+).(html)$ index.php?item_suri=$1".
*
* Diese Regel akzeptiert nur die Zeichen ihrer Klasse. Deshalb wird hier geprueft,
* BEVOR verlinkt wird -- eine short_uri mit abweichenden Zeichen wuerde nicht
* greifen und den Kunden auf einen 404 schicken. In dem Fall bleibt es bei der
* ID-URL, die immer funktioniert.
*
* Bewusst enger als die .htaccess: deren "aA-zZ" ist ein alter Tippfehler und deckt
* als ASCII-Bereich A(65)-z(122) auch ` [ \ ] ^ _ ab. Fuenf Artikel haben deshalb
* einen Backtick in der short_uri und funktionieren nur zufaellig; die bekommen
* hier die ID-URL. Auf den Tippfehler zu bauen waere fahrlaessig.
*/
function v2_shopurl($item_id, $short_uri = null) {
if (!$item_id) return '';
$uri = trim((string)$short_uri);
if ($uri !== '' && preg_match('/^[A-Za-z0-9_,-]+$/', $uri)) {
return tba_url($uri . '.html');
}
return tba_url('index.php?item_id=' . (int)$item_id);
}
/**
* Chip fuer ein Ersatzteil/Artikel. Gibt einen <a> aus, wenn ein Shop-Artikel
* bekannt ist, sonst ein <span> -- damit nichts ins Leere klickt.
*/
function v2_chip($label, $sub, $item_id, $buyclass = false, $short_uri = null) {
$cls = 'v2-chip' . ($buyclass ? ' v2-buy' : '');
$inner = '<b>' . htmlspecialchars($label) . '</b>'
. ($sub !== '' ? ' · ' . htmlspecialchars($sub) : '');
$url = v2_shopurl($item_id, $short_uri);
if ($url === '') {
return '<span class="' . $cls . '">' . $inner . '</span>';
}
return '<a class="' . $cls . ' v2-chip-link" target="_blank" rel="noopener" href="'
. htmlspecialchars($url) . '">' . $inner . '</a>';
}
/**
* Artikel-ZEILE statt der alten Chip-"Blase": Bild, Name, Art-Nr, Preis.
* Wird in der Bestaetigungskarte und in den Geraete-Kacheln verwendet, damit
* die Teile-Liste dieselbe Form hat wie die Artikelliste weiter unten.
* Ohne Shop-Artikel-ID wird ein div statt eines Links ausgegeben.
*/
function v2_artikel_row($it) {
$url = v2_shopurl($it['id'] ?? null, $it['short_uri'] ?? null);
$img = !empty($it['default_image_file_name'])
? tba_url('web/intelectra_shop/images/article/' . rawurlencode($it['default_image_file_name']))
: '';
$pb = isset($it['price_brutto']) ? (float)$it['price_brutto'] : 0;
$bb = !empty($it['bargain_brutto']) ? (float)$it['bargain_brutto'] : 0;
$nml = v2_nml_info((string)($it['name'] ?? ''));
// nml zusaetzlich ueber die Warengruppe erkennen: die ist verlaesslicher als
// der Namensmarker (erkennt 2.207 Faelle, die der Regex uebersieht).
$is_nml = $nml['is_nml'] || v2_is_nml_wg($it['wg_name'] ?? '');
$h = $url !== ''
? '<a class="v2-row" target="_blank" rel="noopener" href="' . htmlspecialchars($url) . '">'
: '<div class="v2-row v2-row-nolink">';
$h .= $img !== ''
? '<img class="v2-row-img" src="' . htmlspecialchars($img) . '" alt="" loading="lazy" onerror="this.style.visibility=\'hidden\'">'
: '<span class="v2-row-img v2-row-noimg">–</span>';
$h .= '<span class="v2-row-txt">'
. '<span class="v2-row-name">' . htmlspecialchars($nml['clean']) . '</span>'
. '<span class="v2-row-nr">Art-Nr ' . htmlspecialchars((string)($it['number'] ?? ''));
if ($is_nml) {
$h .= ' · <b style="color:#e65100">nicht mehr lieferbar</b>';
// Die aus dem Namen gelesene Nummer nur als Rueckfallebene: liegt eine
// gepflegte Zuordnung vor, steht der Ersatz gleich darunter als Zeile und
// die blosse Nummer waere doppelt.
if (empty($it['alt']) && !empty($nml['nachfolger'])) {
$h .= ' · Nachfolger ' . htmlspecialchars($nml['nachfolger']);
}
}
$h .= '</span></span>';
$h .= '<span class="v2-row-price">';
if ($bb > 0 && $pb > 0 && $bb < $pb) {
$h .= '<b style="color:#c62828">' . number_format($bb, 2, ',', '.') . ' €</b>'
. '<s>' . number_format($pb, 2, ',', '.') . ' €</s>';
} elseif ($pb > 0) {
$h .= '<b>' . number_format($pb, 2, ',', '.') . ' €</b>';
} elseif ($is_nml) {
// Bei nml steht der Preis in der DB auf 0 -- "Preis auf Anfrage" waere hier
// eine falsche Erwartung, der Artikel ist gar nicht bestellbar.
$h .= '<span class="v2-row-noprice">—</span>';
} else {
$h .= '<span class="v2-row-noprice">Preis auf Anfrage</span>';
}
$h .= '</span>';
$h .= ($url !== '' ? '</a>' : '</div>');
// Der Ersatzartikel bekommt eine EIGENE Zeile: ein Link darf nicht in einem
// Link stehen, und der Kunde soll ihn direkt anklicken koennen statt erst den
// ausgelaufenen Artikel zu oeffnen.
if ($is_nml && !empty($it['alt'])) {
$h = '<div class="v2-rowgrp">' . $h . v2_altrow($it['alt']) . '</div>';
}
return $h;
}
/**
* Zeile "Ersatz verfuegbar" unter einem nicht mehr lieferbaren Artikel.
* Datenquelle ist die im Backend gepflegte Zuordnung (item_item_assign type=3),
* aufgeloest von KremplDB::getAlternativesBatch(). Angezeigt werden nur kaufbare
* Ziele, deshalb ist die Zusage hier belastbar.
*/
function v2_altrow($alt) {
$url = v2_shopurl($alt['id'] ?? null, $alt['short_uri'] ?? null);
if ($url === '') return '';
$pb = isset($alt['price_brutto']) ? (float)$alt['price_brutto'] : 0;
$bb = !empty($alt['bargain_brutto']) ? (float)$alt['bargain_brutto'] : 0;
$h = '<a class="v2-altrow" target="_blank" rel="noopener" href="' . htmlspecialchars($url) . '">'
. '<span class="v2-altrow-tag">→ Ersatz</span>'
. '<span class="v2-altrow-txt">'
. '<span class="v2-altrow-name">' . htmlspecialchars((string)($alt['name'] ?? '')) . '</span>'
. '<span class="v2-altrow-nr">Art-Nr ' . htmlspecialchars((string)($alt['number'] ?? ''));
// Ueber mehr als eine Station gelaufen: der direkte Nachfolger war selbst
// ausgelaufen. Das ist eine ehrliche Angabe, keine Fussnote.
if (!empty($alt['tiefe']) && (int)$alt['tiefe'] > 1) {
$h .= ' · über ' . (int)$alt['tiefe'] . ' Nachfolger ermittelt';
}
if (!empty($alt['weitere'])) {
$h .= ' · ' . (int)$alt['weitere'] . ' weitere Alternative'
. ((int)$alt['weitere'] > 1 ? 'n' : '');
}
$h .= '</span></span><span class="v2-altrow-price">';
if ($bb > 0 && $pb > 0 && $bb < $pb) {
$h .= '<b>' . number_format($bb, 2, ',', '.') . ' €</b>'
. '<s>' . number_format($pb, 2, ',', '.') . ' €</s>';
} elseif ($pb > 0) {
$h .= '<b>' . number_format($pb, 2, ',', '.') . ' €</b>';
} else {
$h .= '<span class="v2-row-noprice">Preis auf Anfrage</span>';
}
return $h . '</span></a>';
}
/** Warengruppen-Name deutet auf "nicht mehr lieferbar" hin? */
function v2_is_nml_wg($wg) {
return (bool)preg_match('/lieferbar|NMLE|Nachfolge|kein Ersatz/i', (string)$wg);
}
/**
* nml-Status eines Shop-Artikels aus BEIDEN Quellen: Warengruppe und Namensmarker.
*
* Die Warengruppe ist der verlaesslichere Marker -- gemessen am 30.07.2026:
* ueber WG erkannt 9.848 Artikel, ueber (nml)-Regex 7.694, davon erkennt nur die
* WG 2.207 Faelle (Klartext "Nicht mehr lieferbar +++ kein Ersatz" ohne Suffix)
* und nur der Name 51. Zusammen also ~9.899 von 16.923 aktiven.
* Die nml-Warengruppen sind 9929, 10649, 10746, 10750; ihr Name "weitere Treffer:
* Nachfolge-Artikel" (10649) ist irrefuehrend -- dort liegen ausgelaufene Artikel
* (1.241 von 1.274 tragen selbst einen nml-Marker), nicht die Nachfolger.
* Ein Nachfolger laesst sich weiterhin nur aus dem NAMEN lesen, daher bleibt die
* Unterscheidung g_succ/g_dead am Namenstext haengen.
*/
function v2_nml_full($it) {
$info = v2_nml_info((string)($it['name'] ?? ''));
if (!$info['is_nml'] && v2_is_nml_wg($it['wg_name'] ?? '')) {
$info['is_nml'] = true;
}
return $info;
}
/**
* Artikel-Liste als Zeilen. Ab $threshold Treffern wird nach Warengruppe
* gruppiert -- genau wie die alte Shop-Suche, weil eine flache Liste mit 30+
* Positionen unbrauchbar ist. nml-Warengruppen ("Nicht mehr Lieferbar +++ ...",
* "NMLE-ohne WG", "weitere Treffer: Nachfolge-Artikel") wandern ans Ende.
*/
function v2_artikel_liste(array $items, $threshold = 12, $max = null) {
if (!$items) return '';
// nml-Artikel ans Ende: sie sind nicht bestellbar und sollen die Liste nicht
// anfuehren. Muss VOR dem Abschneiden passieren, sonst landen kaufbare Teile
// im versteckten Rest, waehrend nml-Positionen die sichtbaren Plaetze belegen.
$nml_flag = function ($it) {
return (v2_is_nml_wg($it['wg_name'] ?? '')
|| v2_nml_info((string)($it['name'] ?? ''))['is_nml']) ? 1 : 0;
};
usort($items, function ($a, $b) use ($nml_flag) {
return $nml_flag($a) - $nml_flag($b);
});
// In den eingeklappten Geraete-Kacheln wird begrenzt ($max), sonst rendert eine
// Suche wie "WT46W560BY" 10 Kacheln x bis 200 Artikel = mehrere Tausend Zeilen.
// Die aktive Bestaetigungskarte zeigt alles ($max = null). Der Rest wird NICHT
// verworfen, sondern aufklappbar angehaengt -- vorher war "+ N weitere" ein
// toter Text und der Kunde erfuhr nie, was er noch kaufen koennte.
$restitems = [];
if ($max !== null && count($items) > $max) {
$restitems = array_slice($items, $max);
$items = array_slice($items, 0, $max);
}
$more = '';
if ($restitems) {
$more = '<details class="v2-rowmore"><summary>+ ' . count($restitems)
. ' weitere Artikel für dieses Gerät anzeigen</summary><div class="v2-rows">';
foreach ($restitems as $it) { $more .= v2_artikel_row($it); }
$more .= '</div></details>';
}
if (count($items) <= $threshold) {
$h = '<div class="v2-rows">';
foreach ($items as $it) { $h .= v2_artikel_row($it); }
return $h . '</div>' . $more;
}
$groups = [];
foreach ($items as $it) {
$wg = trim((string)($it['wg_name'] ?? '')) ?: 'Ohne Warengruppe';
$groups[$wg][] = $it;
}
// Echte Warengruppen zuerst (die groesste oben), nml-Gruppen ans Ende.
uksort($groups, function ($a, $b) use (&$groups) {
$na = v2_is_nml_wg($a) ? 1 : 0;
$nb = v2_is_nml_wg($b) ? 1 : 0;
if ($na !== $nb) return $na - $nb;
return count($groups[$b]) <=> count($groups[$a]);
});
$h = '';
foreach ($groups as $wg => $its) {
$cls = v2_is_nml_wg($wg) ? ' v2-wg-nml' : '';
$h .= '<h4 class="v2-wg' . $cls . '">' . htmlspecialchars($wg)
. ' <span>' . count($its) . '</span></h4><div class="v2-rows">';
foreach ($its as $it) { $h .= v2_artikel_row($it); }
$h .= '</div>';
}
return $h . $more;
}
function v2_confidence($score) {
$s = (int)$score;
if ($s >= 100) return ['Exakter Treffer', '#2e7d32', '#e8f5e9', '✓'];
if ($s >= 90) return ['Sehr wahrscheinlich dein Gerät','#388e3c', '#edf7ee', '✓'];
if ($s >= 75) return ['Könnte passen', '#f9a825', '#fff8e1', '≈'];
return ['Ähnlich – bitte prüfen', '#9e9e9e', '#f5f5f5', '?'];
}
// nml/Nachfolger steckt im Artikelnamen: "(nml)"/"(nmle)" + "Nachfolger <Nr>"
function v2_nml_info($name) {
$is_nml = (bool)preg_match('/\(nml[e]?\)/i', $name);
$nachf = null;
// Nachfolger-Nummer muss mind. eine Ziffer enthalten (schließt "Nachfolger Original" aus)
if (preg_match('/Nachfolger\s+([A-Za-z0-9\-\/\.]*\d[A-Za-z0-9\-\/\.]*)/i', $name, $m)) {
$nachf = $m[1];
}
$kein_ersatz = (stripos($name, 'kein Ersatz') !== false);
// Anzeigename säubern: (nml)-Marker + "Nachfolger ... Original(teil)" raus
$clean = preg_replace('/\s*\(nml[e]?\)\s*/i', ' ', $name);
$clean = preg_replace('/\s*Nachfolger\s+[A-Za-z0-9\-\/\.]*\d[A-Za-z0-9\-\/\.]*\s*/i', ' ', $clean);
$clean = preg_replace('/\s*(Originalteil|Original)\s*$/i', '', $clean);
$clean = trim(preg_replace('/\s{2,}/', ' ', $clean));
return ['is_nml'=>$is_nml, 'nachfolger'=>$nachf, 'kein_ersatz'=>$kein_ersatz, 'clean'=>($clean ?: $name)];
}
}
@@ -0,0 +1,272 @@
<?php
/**
* Render-Helfer fuer tba-search.php.
*
* Ausgelagert, damit der AJAX-Endpoint (Nachladen der Artikel beim Aufklappen einer
* Geraete-Kachel) dieselben Funktionen nutzen kann -- der laeuft VOR jeder HTML-Ausgabe
* und kaeme an Funktionen, die mitten im Template definiert sind, nicht heran.
*/
/**
* Basis-URL fuer Shop-Links und Artikelbilder.
*
* Auf dev/newmail MUSS sie absolut auf den Live-Host zeigen: dort liegen weder die
* Shop-Daten noch die ~113k Artikelbilder (~5 GB), beides existiert nur auf intelectra.de.
*
* Beim Einbau ins Live-Shop-Frontend (Smarty) hier einfach '' setzen oder vor dem
* require mit define()/Smarty-Variable ueberschreiben -- dann werden alle Links relativ,
* ohne dass an den Render-Funktionen etwas geaendert werden muss:
*
* define('TBA_SHOP_BASE', ''); // relativ, fuer den Live-Shop
* require_once '.../tba_render.php';
*/
if (!defined('TBA_SHOP_BASE')) {
define('TBA_SHOP_BASE', 'https://intelectra.de');
}
/** Shop-Basis + Pfad, ohne doppelten Slash. */
function tba_url($path) {
return rtrim(TBA_SHOP_BASE, '/') . '/' . ltrim($path, '/');
}
if (!function_exists('v2_confidence')) {
/**
* Anzeigename eines Geraets. modell_bezeichnung ist bei 490.522 von 1.675.828
* Geraeten LEER (29%) -- vor allem bei BSH (SIEMENS/BOSCH), wo die Typnummer
* stattdessen in nr steht (z.B. "WT46W560BY/01"). Ohne Fallback rendern die
* Kacheln komplett namenlos.
*/
function v2_gname($d) {
foreach (['modell_bezeichnung', 'bezeichnung_original', 'nr'] as $k) {
if (isset($d[$k]) && trim((string)$d[$k]) !== '') return trim($d[$k]);
}
return '(ohne Bezeichnung)';
}
/**
* Produktionsdatum -> Jahr. In produktionsstart/-ende stecken ZWEI Formate:
* YYYYMMDD z.B. "19790101" (AEG Lavatherm 400)
* YYMM z.B. "8810" (SIEMENS WT46W560BY/01 = Oktober 1988)
* Bei YYMM gilt: >= 70 -> 19YY, sonst 20YY.
*/
function v2_jahr4($v) {
$s = preg_replace('/\D/', '', (string)$v);
if ($s === '') return '';
if (strlen($s) >= 6) return substr($s, 0, 4); // YYYYMM(DD)
if (strlen($s) === 4) { // YYMM
$yy = (int)substr($s, 0, 2);
return (string)($yy >= 70 ? 1900 + $yy : 2000 + $yy);
}
if (strlen($s) === 2) { // nur YY
$yy = (int)$s;
return (string)($yy >= 70 ? 1900 + $yy : 2000 + $yy);
}
return '';
}
/** Monat/Jahr fuer die Variantenliste, z.B. "10/1988". Leer, wenn nicht ableitbar. */
function v2_monat($v) {
$s = preg_replace('/\D/', '', (string)$v);
$j = v2_jahr4($s);
if ($j === '') return '';
if (strlen($s) >= 6) $m = substr($s, 4, 2); // YYYYMM(DD)
elseif (strlen($s) === 4) $m = substr($s, 2, 2); // YYMM
else return $j;
$m = (int)$m;
return ($m >= 1 && $m <= 12) ? sprintf('%02d/%s', $m, $j) : $j;
}
/** Shop-URL zu einer items.id -- leer, wenn kein Shop-Artikel bekannt ist. */
function v2_shopurl($item_id) {
return $item_id ? tba_url('index.php?item_id=' . (int)$item_id) : '';
}
/**
* Chip fuer ein Ersatzteil/Artikel. Gibt einen <a> aus, wenn ein Shop-Artikel
* bekannt ist, sonst ein <span> -- damit nichts ins Leere klickt.
*/
function v2_chip($label, $sub, $item_id, $buyclass = false) {
$cls = 'v2-chip' . ($buyclass ? ' v2-buy' : '');
$inner = '<b>' . htmlspecialchars($label) . '</b>'
. ($sub !== '' ? ' · ' . htmlspecialchars($sub) : '');
$url = v2_shopurl($item_id);
if ($url === '') {
return '<span class="' . $cls . '">' . $inner . '</span>';
}
return '<a class="' . $cls . ' v2-chip-link" target="_blank" rel="noopener" href="'
. htmlspecialchars($url) . '">' . $inner . '</a>';
}
/**
* Artikel-ZEILE statt der alten Chip-"Blase": Bild, Name, Art-Nr, Preis.
* Wird in der Bestaetigungskarte und in den Geraete-Kacheln verwendet, damit
* die Teile-Liste dieselbe Form hat wie die Artikelliste weiter unten.
* Ohne Shop-Artikel-ID wird ein div statt eines Links ausgegeben.
*/
function v2_artikel_row($it) {
$url = v2_shopurl($it['id'] ?? null);
$img = !empty($it['default_image_file_name'])
? tba_url('web/intelectra_shop/images/article/' . rawurlencode($it['default_image_file_name']))
: '';
$pb = isset($it['price_brutto']) ? (float)$it['price_brutto'] : 0;
$bb = !empty($it['bargain_brutto']) ? (float)$it['bargain_brutto'] : 0;
$nml = v2_nml_info((string)($it['name'] ?? ''));
// nml zusaetzlich ueber die Warengruppe erkennen: die ist verlaesslicher als
// der Namensmarker (erkennt 2.207 Faelle, die der Regex uebersieht).
$is_nml = $nml['is_nml'] || v2_is_nml_wg($it['wg_name'] ?? '');
$h = $url !== ''
? '<a class="v2-row" target="_blank" rel="noopener" href="' . htmlspecialchars($url) . '">'
: '<div class="v2-row v2-row-nolink">';
$h .= $img !== ''
? '<img class="v2-row-img" src="' . htmlspecialchars($img) . '" alt="" loading="lazy" onerror="this.style.visibility=\'hidden\'">'
: '<span class="v2-row-img v2-row-noimg">–</span>';
$h .= '<span class="v2-row-txt">'
. '<span class="v2-row-name">' . htmlspecialchars($nml['clean']) . '</span>'
. '<span class="v2-row-nr">Art-Nr ' . htmlspecialchars((string)($it['number'] ?? ''));
if ($is_nml) {
$h .= ' · <b style="color:#e65100">nicht mehr lieferbar</b>';
if (!empty($nml['nachfolger'])) {
$h .= ' · Nachfolger ' . htmlspecialchars($nml['nachfolger']);
}
}
$h .= '</span></span>';
$h .= '<span class="v2-row-price">';
if ($bb > 0 && $pb > 0 && $bb < $pb) {
$h .= '<b style="color:#c62828">' . number_format($bb, 2, ',', '.') . ' €</b>'
. '<s>' . number_format($pb, 2, ',', '.') . ' €</s>';
} elseif ($pb > 0) {
$h .= '<b>' . number_format($pb, 2, ',', '.') . ' €</b>';
} elseif ($is_nml) {
// Bei nml steht der Preis in der DB auf 0 -- "Preis auf Anfrage" waere hier
// eine falsche Erwartung, der Artikel ist gar nicht bestellbar.
$h .= '<span class="v2-row-noprice">—</span>';
} else {
$h .= '<span class="v2-row-noprice">Preis auf Anfrage</span>';
}
$h .= '</span>';
return $h . ($url !== '' ? '</a>' : '</div>');
}
/** Warengruppen-Name deutet auf "nicht mehr lieferbar" hin? */
function v2_is_nml_wg($wg) {
return (bool)preg_match('/lieferbar|NMLE|Nachfolge|kein Ersatz/i', (string)$wg);
}
/**
* nml-Status eines Shop-Artikels aus BEIDEN Quellen: Warengruppe und Namensmarker.
*
* Die Warengruppe ist der verlaesslichere Marker -- gemessen am 30.07.2026:
* ueber WG erkannt 9.848 Artikel, ueber (nml)-Regex 7.694, davon erkennt nur die
* WG 2.207 Faelle (Klartext "Nicht mehr lieferbar +++ kein Ersatz" ohne Suffix)
* und nur der Name 51. Zusammen also ~9.899 von 16.923 aktiven.
* Die nml-Warengruppen sind 9929, 10649, 10746, 10750; ihr Name "weitere Treffer:
* Nachfolge-Artikel" (10649) ist irrefuehrend -- dort liegen ausgelaufene Artikel
* (1.241 von 1.274 tragen selbst einen nml-Marker), nicht die Nachfolger.
* Ein Nachfolger laesst sich weiterhin nur aus dem NAMEN lesen, daher bleibt die
* Unterscheidung g_succ/g_dead am Namenstext haengen.
*/
function v2_nml_full($it) {
$info = v2_nml_info((string)($it['name'] ?? ''));
if (!$info['is_nml'] && v2_is_nml_wg($it['wg_name'] ?? '')) {
$info['is_nml'] = true;
}
return $info;
}
/**
* Artikel-Liste als Zeilen. Ab $threshold Treffern wird nach Warengruppe
* gruppiert -- genau wie die alte Shop-Suche, weil eine flache Liste mit 30+
* Positionen unbrauchbar ist. nml-Warengruppen ("Nicht mehr Lieferbar +++ ...",
* "NMLE-ohne WG", "weitere Treffer: Nachfolge-Artikel") wandern ans Ende.
*/
function v2_artikel_liste(array $items, $threshold = 12, $max = null) {
if (!$items) return '';
// nml-Artikel ans Ende: sie sind nicht bestellbar und sollen die Liste nicht
// anfuehren. Muss VOR dem Abschneiden passieren, sonst landen kaufbare Teile
// im versteckten Rest, waehrend nml-Positionen die sichtbaren Plaetze belegen.
$nml_flag = function ($it) {
return (v2_is_nml_wg($it['wg_name'] ?? '')
|| v2_nml_info((string)($it['name'] ?? ''))['is_nml']) ? 1 : 0;
};
usort($items, function ($a, $b) use ($nml_flag) {
return $nml_flag($a) - $nml_flag($b);
});
// In den eingeklappten Geraete-Kacheln wird begrenzt ($max), sonst rendert eine
// Suche wie "WT46W560BY" 10 Kacheln x bis 200 Artikel = mehrere Tausend Zeilen.
// Die aktive Bestaetigungskarte zeigt alles ($max = null). Der Rest wird NICHT
// verworfen, sondern aufklappbar angehaengt -- vorher war "+ N weitere" ein
// toter Text und der Kunde erfuhr nie, was er noch kaufen koennte.
$restitems = [];
if ($max !== null && count($items) > $max) {
$restitems = array_slice($items, $max);
$items = array_slice($items, 0, $max);
}
$more = '';
if ($restitems) {
$more = '<details class="v2-rowmore"><summary>+ ' . count($restitems)
. ' weitere Artikel für dieses Gerät anzeigen</summary><div class="v2-rows">';
foreach ($restitems as $it) { $more .= v2_artikel_row($it); }
$more .= '</div></details>';
}
if (count($items) <= $threshold) {
$h = '<div class="v2-rows">';
foreach ($items as $it) { $h .= v2_artikel_row($it); }
return $h . '</div>' . $more;
}
$groups = [];
foreach ($items as $it) {
$wg = trim((string)($it['wg_name'] ?? '')) ?: 'Ohne Warengruppe';
$groups[$wg][] = $it;
}
// Echte Warengruppen zuerst (die groesste oben), nml-Gruppen ans Ende.
uksort($groups, function ($a, $b) use (&$groups) {
$na = v2_is_nml_wg($a) ? 1 : 0;
$nb = v2_is_nml_wg($b) ? 1 : 0;
if ($na !== $nb) return $na - $nb;
return count($groups[$b]) <=> count($groups[$a]);
});
$h = '';
foreach ($groups as $wg => $its) {
$cls = v2_is_nml_wg($wg) ? ' v2-wg-nml' : '';
$h .= '<h4 class="v2-wg' . $cls . '">' . htmlspecialchars($wg)
. ' <span>' . count($its) . '</span></h4><div class="v2-rows">';
foreach ($its as $it) { $h .= v2_artikel_row($it); }
$h .= '</div>';
}
return $h . $more;
}
function v2_confidence($score) {
$s = (int)$score;
if ($s >= 100) return ['Exakter Treffer', '#2e7d32', '#e8f5e9', '✓'];
if ($s >= 90) return ['Sehr wahrscheinlich dein Gerät','#388e3c', '#edf7ee', '✓'];
if ($s >= 75) return ['Könnte passen', '#f9a825', '#fff8e1', '≈'];
return ['Ähnlich – bitte prüfen', '#9e9e9e', '#f5f5f5', '?'];
}
// nml/Nachfolger steckt im Artikelnamen: "(nml)"/"(nmle)" + "Nachfolger <Nr>"
function v2_nml_info($name) {
$is_nml = (bool)preg_match('/\(nml[e]?\)/i', $name);
$nachf = null;
// Nachfolger-Nummer muss mind. eine Ziffer enthalten (schließt "Nachfolger Original" aus)
if (preg_match('/Nachfolger\s+([A-Za-z0-9\-\/\.]*\d[A-Za-z0-9\-\/\.]*)/i', $name, $m)) {
$nachf = $m[1];
}
$kein_ersatz = (stripos($name, 'kein Ersatz') !== false);
// Anzeigename säubern: (nml)-Marker + "Nachfolger ... Original(teil)" raus
$clean = preg_replace('/\s*\(nml[e]?\)\s*/i', ' ', $name);
$clean = preg_replace('/\s*Nachfolger\s+[A-Za-z0-9\-\/\.]*\d[A-Za-z0-9\-\/\.]*\s*/i', ' ', $clean);
$clean = preg_replace('/\s*(Originalteil|Original)\s*$/i', '', $clean);
$clean = trim(preg_replace('/\s{2,}/', ' ', $clean));
return ['is_nml'=>$is_nml, 'nachfolger'=>$nachf, 'kein_ersatz'=>$kein_ersatz, 'clean'=>($clean ?: $name)];
}
}
+132 -40
View File
@@ -301,9 +301,7 @@ class Revocation {
}
$msg .= $this->mail_row('IP (anon.)', $revocation->ip_anonymized ?? '–');
$msg .= '</table>';
$msg .= '<p style="margin-top:20px;"><strong>Bitte Rückzahlung über die Warenwirtschaft '
. 'veranlassen.</strong> Die Zahlung muss über dieselbe Zahlungsart wie die ursprüngliche '
. 'Bestellung erfolgen.</p>';
$msg .= '<p style="margin-top:20px;"><strong>Bitte prüfen und E-Mail an die Kundin / den Kunden schreiben.</strong></p>';
$msg .= '<p>Admin: <a href="https://'.($_SERVER['HTTP_HOST'] ?? 'www.intelectra.de')
. '/index.php?admin_modul=admin_revocation_list">Widerrufsliste öffnen</a></p>';
@@ -324,27 +322,19 @@ class Revocation {
$order_number = $order->order_number ?? ('#'.$revocation->order_id);
$name = trim(($order->billing_firstname ?? '').' '.($order->billing_surname ?? ''));
$subject = 'Bestätigung Ihres Widerrufs – Bestellung '.$order_number;
$ts = strtotime($revocation->submitted_at);
$datum = date('d.m.Y', $ts);
$uhrzeit = date('H:i', $ts);
$msg = '<p>Sehr geehrte Kundin, sehr geehrter Kunde'.($name ? ' '.htmlspecialchars($name) : '').',</p>';
$msg .= '<p>hiermit bestätigen wir den Eingang Ihrer Widerrufserklärung zu Bestellung '
. '<strong>'.htmlspecialchars($order_number).'</strong> am '
. date('d.m.Y H:i', strtotime($revocation->submitted_at)).' Uhr.</p>';
$msg .= '<h3>Nächste Schritte</h3>';
$msg .= '<p>Bitte senden Sie die Waren unverzüglich, spätestens binnen 14 Tagen, an:</p>';
$msg .= '<p style="background:#f4f4f4;padding:12px;border-left:4px solid #c00;">'
. '<strong>'.REVOCATION_RETURN_COMPANY.'</strong><br>'
. REVOCATION_RETURN_STREET.'<br>'
. REVOCATION_RETURN_CITY.'</p>';
$msg .= '<p>'.REVOCATION_RETURN_COSTS_NOTE.'</p>';
$msg .= '<p>Die Rückzahlung erfolgt innerhalb von 14 Tagen nach Eingang Ihres Widerrufs über '
. 'dieselbe Zahlungsart, die Sie bei der ursprünglichen Bestellung verwendet haben – '
. 'es sei denn, mit Ihnen wurde ausdrücklich etwas anderes vereinbart.</p>';
$msg .= '<p>Bei Rückfragen erreichen Sie uns jederzeit unter '
. '<a href="mailto:'.REVOCATION_OWNER_EMAIL.'">'.REVOCATION_OWNER_EMAIL.'</a>.</p>';
$msg .= '<hr><p style="font-size:0.85em;color:#666;">'.REVOCATION_RETURN_COMPANY.'</p>';
$ok = $mailer->send_mail($subject, $msg, $revocation->customer_email);
$ok = $this->send_neutral_eingangsbestaetigung(
$revocation->customer_email,
$name,
$revocation->customer_email,
$order_number,
$datum,
$uhrzeit,
$revocation->reason_text ?? ''
);
if ($ok) {
$this->db->query(
@@ -487,20 +477,25 @@ class Revocation {
}
/**
* Widerruf ohne Token – Lookup anhand Bestellnr + E-Mail.
* Wenn noch kein revocation-Record existiert, wird er inline angelegt.
* Widerruf ohne Token.
* Pflichtfelder: Name + E-Mail. Button-Klick = Erklärung.
* Bestellnummer + Artikel sind OPTIONAL (Händlerbund-Linie). Wenn Order
* gefunden → DB-Tracking. Sonst → reiner Anonym-Pfad mit Owner-Mail zur
* manuellen Zuordnung. Der Widerruf wird in jedem Fall entgegengenommen.
*/
public function submit_anonymous($name, $order_number, $email, $reason_id, $reason_text, $ip, $user_agent) {
public function submit_anonymous($name, $order_number, $email, $article, $ip, $user_agent) {
if (!REVOCATION_ENABLED) {
$this->error = 'feature_disabled';
return false;
}
$name = trim((string)$name);
$order_number = trim((string)$order_number);
$email = trim(strtolower((string)$email));
$article = trim((string)$article);
if ($order_number === '') {
$this->error = 'missing_order';
if ($name === '') {
$this->error = 'missing_name';
return false;
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
@@ -508,22 +503,24 @@ class Revocation {
return false;
}
$order = $this->fetch_order_by_number_email($order_number, $email);
// Order-Lookup nur informativ. Wenn keine Order angegeben oder
// keine gefunden oder nicht widerrufsfähig → Anonym-Pfad.
$order = ($order_number !== '')
? $this->fetch_order_by_number_email($order_number, $email)
: null;
if (!$order) {
$this->error = 'order_not_found';
return false;
return $this->process_anonymous_unmatched($name, $order_number, $email, $article, $ip, $user_agent);
}
$group_id = RevocationHelper::resolve_group_id($this->db, $order->customer_id);
if (!RevocationHelper::is_b2c($group_id)) {
$this->error = 'not_eligible';
return false;
return $this->process_anonymous_unmatched($name, $order_number, $email, $article, $ip, $user_agent);
}
$expires_at = RevocationHelper::calc_expires_at($order->order_date);
if (strtotime($expires_at) < time()) {
$this->error = 'expired';
return false;
return $this->process_anonymous_unmatched($name, $order_number, $email, $article, $ip, $user_agent);
}
// Existing Eintrag wiederverwenden, sonst inline anlegen
@@ -563,11 +560,10 @@ class Revocation {
$stmt->close();
}
$reason_id = ((int)$reason_id > 0) ? (int)$reason_id : null;
$reason_text = trim((string)$reason_text);
if (mb_strlen($reason_text) > 2000) {
$reason_text = mb_substr($reason_text, 0, 2000);
}
$reason_id = null;
$reason_text = ($article !== '' && mb_strlen($article) > 2000)
? mb_substr($article, 0, 2000)
: $article;
$ip_anon = RevocationHelper::anonymize_ip($ip);
$ua = $user_agent ? mb_substr($user_agent, 0, 255) : null;
@@ -623,4 +619,100 @@ class Revocation {
if (!$res || $res->num_rows === 0) return false;
return $res->fetch_object();
}
// ============================================================
// Anonym-Pfad: ohne (passende) Bestellung
// ============================================================
/**
* Widerruf ohne Order-Match.
* Kein DB-Eintrag, dafür Owner-Mail mit Hinweis auf manuelle Zuordnung
* und Eingangsbestätigung an den Kunden.
*/
private function process_anonymous_unmatched($name, $order_number, $email, $article, $ip, $user_agent) {
$ip_anon = RevocationHelper::anonymize_ip($ip);
$ua = $user_agent ? mb_substr((string)$user_agent, 0, 255) : null;
$article = trim((string)$article);
if (mb_strlen($article) > 2000) {
$article = mb_substr($article, 0, 2000);
}
$this->send_anonymous_owner_notification($name, $order_number, $email, $article, $ip_anon, $ua);
$this->send_anonymous_customer_confirmation($name, $order_number, $email, $article);
return true;
}
private function send_anonymous_owner_notification($name, $order_number, $email, $article, $ip_anon, $ua) {
$mailer = new mail_tools($this->base_object);
$subject = 'Widerruf eingegangen (manuelle Zuordnung erforderlich)';
$msg = '<h2>Widerruf eingegangen</h2>';
$msg .= '<p style="background:#fff3cd;border-left:4px solid #ffa500;padding:10px;">';
$msg .= '<strong>Bitte manuell zuordnen.</strong> Der Widerruf wurde ohne (oder mit nicht gefundener) Bestellnummer eingereicht. ';
$msg .= 'Es wurde kein automatischer DB-Eintrag erzeugt.';
$msg .= '</p>';
$msg .= '<table cellpadding="4" cellspacing="0" border="0" style="font-family:Arial,sans-serif;">';
$msg .= $this->mail_row('Name', htmlspecialchars($name));
$msg .= $this->mail_row('E-Mail', htmlspecialchars($email));
$msg .= $this->mail_row('Bestellnummer', $order_number !== '' ? htmlspecialchars($order_number) : '(nicht angegeben)');
$msg .= $this->mail_row('Artikel/Bemerkung', $article !== '' ? nl2br(htmlspecialchars($article)) : '(nicht angegeben)');
$msg .= $this->mail_row('Datum/Uhrzeit', date('d.m.Y H:i:s'));
$msg .= $this->mail_row('IP (anon.)', $ip_anon ?? '–');
$msg .= '</table>';
$msg .= '<p style="margin-top:20px;"><strong>Bitte prüfen und E-Mail an die Kundin / den Kunden schreiben.</strong></p>';
return $mailer->send_mail($subject, $msg, REVOCATION_OWNER_EMAIL);
}
private function send_anonymous_customer_confirmation($name, $order_number, $email, $article) {
return $this->send_neutral_eingangsbestaetigung(
$email,
$name,
$email,
$order_number !== '' ? $order_number : '(nicht angegeben)',
date('d.m.Y'),
date('H:i'),
$article
);
}
/**
* Neutrale Eingangsbestätigung gemäß Händlerbund-Vorlage.
*
* WICHTIG: Diese E-Mail darf NUR den Eingang der Widerrufserklärung
* bestätigen und KEINE wirksame Bestätigung des Widerrufs selbst sein.
* Keine Rücksendeadresse, keine Frist-Hinweise, keine Werbung.
*
* @param string $to Empfänger-Mail
* @param string $consumer_name Name des Verbrauchers für die Anrede
* @param string $consumer_email E-Mail aus dem Formular (für „Inhalt")
* @param string $order_number Bestellnr aus dem Formular ('' = nicht angegeben)
* @param string $date_str Eingangsdatum (dd.mm.yyyy)
* @param string $time_str Eingangsuhrzeit (HH:MM)
* @param string $article Artikel/Bemerkung (optional)
*/
private function send_neutral_eingangsbestaetigung($to, $consumer_name, $consumer_email,
$order_number, $date_str, $time_str, $article) {
$mailer = new mail_tools($this->base_object);
$subject = 'Ihr Widerruf ist bei uns eingegangen';
$name_safe = htmlspecialchars(trim((string)$consumer_name));
$anrede = $name_safe !== '' ? 'Hallo '.$name_safe.',' : 'Hallo,';
$msg = '<p>'.$anrede.'</p>';
$msg .= '<p>wir best&auml;tigen hiermit den Eingang Ihres Widerrufs mit folgendem Inhalt:</p>';
$msg .= '<ul style="line-height:1.6;">';
$msg .= '<li><strong>Name:</strong> '.$name_safe.'</li>';
$msg .= '<li><strong>E-Mail:</strong> '.htmlspecialchars($consumer_email).'</li>';
$msg .= '<li><strong>Bestellnummer:</strong> '.htmlspecialchars($order_number).'</li>';
if (!empty($article)) {
$msg .= '<li><strong>Artikel/Bemerkung:</strong> '.nl2br(htmlspecialchars($article)).'</li>';
}
$msg .= '<li><strong>Eingang am:</strong> '.htmlspecialchars($date_str).' um '.htmlspecialchars($time_str).' Uhr</li>';
$msg .= '</ul>';
$msg .= '<p>Wir pr&uuml;fen Ihr Anliegen und melden uns zeitnah bei Ihnen.</p>';
$msg .= '<p>Viele Gr&uuml;&szlig;e<br>Ihre '.htmlspecialchars(REVOCATION_RETURN_COMPANY).'</p>';
return $mailer->send_mail($subject, $msg, $to);
}
}
+4 -2
View File
@@ -155,10 +155,12 @@ class website {
function main_widget_menu_2() {
$structure_object = new Structure($this->base_object);
$selected_ids = array();
if (isset($_GET['menu_id'])) {
// 22.09.2026: !empty statt isset -- das Kundencenter postet auf "...&parent_id=" (leer),
// isset war dann wahr und get_all_tree() baute "parent_id in(10636,)" -> SQL-Fatal.
if (!empty($_GET['menu_id'])) {
$selected_ids[] = $_GET['menu_id'];
}
if (isset($_GET['parent_id'])) {
if (!empty($_GET['parent_id'])) {
$selected_ids[] = $_GET['parent_id'];
}
$data = $structure_object->get_all_tree(5, $selected_ids, $this->base_object->customer_group->id);
+10 -4
View File
@@ -31,13 +31,19 @@ if (isset($_GET["garda"]) || isset($_SESSION["garda"])) {
header("location: https://www.intelectra-shop.de");
}*/
// DEV DEBUG TEMPORARY
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
// DEV: Fehler NICHT auf den Bildschirm. 13.09.2026: display_errors=1 schrieb PHP-Warnungen
// als HTML in die value="" der Kassenfelder, der naechste POST trug damit "<br /><b>Warning"
// in shipping[firstname] & Co. und die WAF blockte die Kasse mit 403 (Regel 941320).
// Alles landet weiter in logs/shop_error.log und logs/fatal.log.
ini_set('display_errors', 0);
ini_set('display_startup_errors', 0);
error_reporting(E_ALL);
ini_set('log_errors', 1);
ini_set('error_log', __DIR__ . '/error_log.txt');
// 13.09.2026: wie Live -- Log nach logs/ (per .htaccess gesperrt), Absturzprotokoll in logs/fatal.log
ini_set('error_log', __DIR__ . '/logs/shop_error.log');
include_once './core/fatal_log.php';
intelectra_fatal_log_init();
//setlocale(LC_ALL, 'de_DE.UTF-8@euro', 'de_DE', 'deu_deu', 'ge');
+4
View File
@@ -129,6 +129,7 @@ class KlarnaConfig implements ArrayAccess
*
* @return bool
*/
#[\ReturnTypeWillChange]
public function offsetExists($offset)
{
return isset($this->options[$offset]);
@@ -141,6 +142,7 @@ class KlarnaConfig implements ArrayAccess
*
* @return mixed
*/
#[\ReturnTypeWillChange]
public function offsetGet($offset)
{
if (!$this->offsetExists($offset)) {
@@ -157,6 +159,7 @@ class KlarnaConfig implements ArrayAccess
*
* @return void
*/
#[\ReturnTypeWillChange]
public function offsetSet($offset, $value)
{
$this->options[$offset] = $value;
@@ -169,6 +172,7 @@ class KlarnaConfig implements ArrayAccess
*
* @return void
*/
#[\ReturnTypeWillChange]
public function offsetUnset($offset)
{
unset($this->options[$offset]);
+5
View File
@@ -152,6 +152,11 @@ class admin_order_editor {
$this->layout_object->assign('order', $order_data);
$this->layout_object->assign('order_items', $order_items);
$this->layout_object->assign('footer', $order_footer);
// 22.09.2026 -- Zahlungsvorgang aus core/payments (PayPal v2, Mollie ...):
// Status, Referenz, Transaktions-ID, Grund und Verlauf. Null bei alten Zahlarten.
include_once './core/payments/Registry.php';
$this->layout_object->assign('payment_tx', \Payments\Overview::forOrder($this->base_object->db, (int)$id, $this->base_object));
} catch (Exception $e) {
$this->layout_object->assign('error_message', $e->getMessage());
}
+81
View File
@@ -41,6 +41,14 @@ class admin_payment_method_editor {
$is_submitted = false;
}
// 13.09.2026: Reiter "Einstellungen" speichern (Konfigurationsgruppe + Secrets).
// Vorher hatte dieses Formular keinen Empfaenger -- es postete ins Leere.
if (isset($_POST['payment_config_save']) && $has_id) {
$this->save_provider_config((int)$_POST['id']);
$is_submitted = false;
$_GET['id'] = (int)$_POST['id'];
}
// decide what CRUD method to call and call it in ListAndEdit
// create
$payment_method_object = new Payment_method($this->base_object);
@@ -60,6 +68,7 @@ class admin_payment_method_editor {
$config_data = $this->config->get_config_elements($payment_method_data->configuration_group_id);
$this->layout_object->assign('config_data', $config_data);
}
$this->assign_provider($payment_method_data);
}// read
elseif($has_id && !$is_submitted && $_GET['mod'] != 'delete') {
// template variable name
@@ -72,6 +81,7 @@ class admin_payment_method_editor {
$config_data = $this->config->get_config_elements($payment_method_data->configuration_group_id);
$this->layout_object->assign('config_data', $config_data);
}
$this->assign_provider($payment_method_data);
} // update
elseif($has_id && $is_submitted && $_GET['mod'] != 'delete') {
$payment_method_object->id = $_POST['id'];
@@ -92,6 +102,7 @@ class admin_payment_method_editor {
$config_data = $this->config->get_config_elements($payment_method_data->configuration_group_id);
$this->layout_object->assign('config_data', $config_data);
}
$this->assign_provider($payment_method_data);
} // delete
elseif($has_id && isset($_GET['mod']) && $_GET['mod'] == 'delete'){
$payment_method_object->id = $_GET['id'];
@@ -103,6 +114,76 @@ class admin_payment_method_editor {
return $this->layout_object->fetch('admin_payment_method_editor.tpl');
}
/**
* 13.09.2026 -- Zahlungsanbieter aus core/payments: Secret-Felder fuers Template.
* Klartext wird nie ausgeliefert, nur "hinterlegt seit".
*/
private function assign_provider($payment_method_data) {
if (!is_object($payment_method_data) || empty($payment_method_data->module)) {
return;
}
include_once './core/payments/Registry.php';
if (!\Payments\Registry::has((string)$payment_method_data->module)) {
return;
}
try {
$provider = \Payments\Registry::get((string)$payment_method_data->module, $this->base_object);
} catch (Throwable $e) {
$this->layout_object->assign('provider_error', $e->getMessage());
return;
}
if (!$provider) {
return;
}
$key_ok = \Payments\Secrets::keyAvailable();
$status = $key_ok ? \Payments\Secrets::status($this->base_object->db, $provider->module()) : array();
$fields = array();
foreach ($provider->secretFields() as $name => $label) {
$fields[] = array(
'name' => $name,
'label' => $label,
'since' => isset($status[$name]) ? $status[$name] : '',
);
}
$this->layout_object->assign('provider_label', $provider->label());
$this->layout_object->assign('provider_module', $provider->module());
$this->layout_object->assign('provider_key_ok', $key_ok ? 1 : 0);
$this->layout_object->assign('provider_key_path', \Payments\Secrets::keyPath());
$this->layout_object->assign('provider_secrets', $fields);
$this->layout_object->assign('provider_webhook_url', rtrim(MAIN_URL, '/').'/payment_webhook.php?p='.$provider->module());
}
/** Einstellungen (configuration-Gruppe) und Secrets aus dem Reiter "Einstellungen" speichern. */
private function save_provider_config($payment_method_id) {
$pm_object = new Payment_method($this->base_object);
$pm_object->id = $payment_method_id;
$pm = $pm_object->get_data();
if (!$pm) {
return;
}
if (!empty($pm->configuration_group_id) && isset($_POST['form_field']) && is_array($_POST['form_field'])) {
$clean = array();
foreach ($_POST['form_field'] as $k => $v) {
$clean[$k] = $this->base_object->db->real_escape_string((string)$v);
}
$this->config->update($pm->configuration_group_id, $clean);
}
if (!empty($pm->module) && isset($_POST['secret_field']) && is_array($_POST['secret_field'])) {
include_once './core/payments/Registry.php';
if (\Payments\Registry::has((string)$pm->module) && \Payments\Secrets::keyAvailable()) {
$provider = \Payments\Registry::get((string)$pm->module, $this->base_object);
$allowed = $provider ? $provider->secretFields() : array();
foreach ($_POST['secret_field'] as $name => $value) {
$value = trim((string)$value);
if ($value === '' || !isset($allowed[$name])) {
continue; // leer = unveraendert lassen
}
\Payments\Secrets::set($this->base_object->db, $provider->module(), (string)$name, $value);
}
}
}
}
}
+1517 -872
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -229,7 +229,8 @@ class website_customer_registration {
$site_content_object = new SiteContent($this->base_object);
$agb_data = array();
$agb_data['datenschutz'] = $site_content_object->get_textbox_data_by_id(55);
// Datenschutz: aktuelle Version aus struct 10737 (DSGVO-Update). Alte struct 55 (id 50) ist veraltet.
$agb_data['datenschutz'] = $site_content_object->get_textbox_data_by_id(10737);
$agb_data['agbs'] = $site_content_object->get_textbox_data_by_id(56);
$agb_data['widerruf'] = $site_content_object->get_textbox_data_by_id(60);
$this->layout_object->assign('agb_data', $agb_data);
+8 -1
View File
@@ -250,6 +250,8 @@ class website_customercenter {
}
header('location: ' . $_SERVER["HTTP_REFERER"]);
// 22.09.2026: nach dem Redirect nicht weiterrendern (lief sonst in die Widgets und den SQL-Fatal).
exit();
}
private function edit_base_data($customer_id) {
@@ -332,7 +334,12 @@ class website_customercenter {
$base_url .= ':' . $_SERVER['SERVER_PORT'];
}
return $base_url . '/index.php?menu_id=' . $_GET['menu_id'] . '&parent_id=' . $_GET['parent_id'];
// 22.09.2026: leere Parameter nicht anhaengen (Seite kommt per short_uri /Kundencenter/ ohne parent_id).
$url = $base_url . '/index.php?menu_id=' . (int) ($_GET['menu_id'] ?? 0);
if (!empty($_GET['parent_id'])) {
$url .= '&parent_id=' . (int) $_GET['parent_id'];
}
return $url;
}
private function get_invoice_pdf() {
+48
View File
@@ -392,6 +392,12 @@ class website_dyncontent {
$var_data = array();
$var_data['[#HIDDEN_TEXT_START]'] = '<span class="text_box_hidden_text">';
$var_data['[#HIDDEN_TEXT_END]'] = ' </span> <a class="text_box_link_show_text" href="#">mehr&gt;&gt;</a><a class="text_box_link_hide_text" href="#">&lt;&lt;weniger</a>';
// 27.09.2026 -- [#zahlarten]: Liste der aktiven Zahlarten der Kundengruppe (Tom: die
// CMS-Seite "Bezahlarten" soll nicht mehr von Hand gepflegt werden, sondern dem
// Aktiv-Schalter im Backend folgen).
if (strpos($block->content, '[#zahlarten]') !== false) {
$var_data['[#zahlarten]'] = $this->zahlarten_liste();
}
$block->content = strtr($block->content, $var_data);
@@ -401,6 +407,48 @@ class website_dyncontent {
return $this->layout_object->_fetch('content_box_textbox.tpl');
}
/**
* Zahlarten, die der Besucher in der Kasse bekaeme -- dieselbe Quelle wie die Kasse
* (Payment_method::getMethodsForGroup: payment_methods.active, shipping_area.active_<key>,
* Kundengruppen-Freischaltung). Besucher (Gruppe 100) sehen die Gast-Zahlarten, weil sie
* als Gast bestellen; Haendler ihr B2B-Versandgebiet. Bankeinzug/Rechnung nur, wenn der
* angemeldete Kunde dafuer freigeschaltet ist (wie in content_cash_desk_neu.tpl).
*/
private function zahlarten_liste() {
try {
include_once './core/payment_method.class.php';
$group = (int)$this->base_object->customer_group->id;
if ($group <= 0 || $group == 100) {
$group = 103; // Gast
}
$area = ($group == 101) ? 30 : 1; // Deutschland-B2B / Deutschland-B2C
$pm_object = new Payment_method($this->base_object);
$all = $pm_object->getMethodsForGroup($group, -1, $area);
$customer = $this->base_object->customer;
$list = array();
foreach ((array)$all as $pm) {
if (!isset($pm->activeType) || $pm->activeType <= 0) {
continue;
}
if ($pm->helperjoinkey == 'be' && empty($customer->direct_debit_active)) {
continue;
}
if ($pm->helperjoinkey == 'r' && empty($customer->rechnung_active)) {
continue;
}
$list[] = $pm;
}
$this->layout_object->assign('zahlarten', $list);
$this->layout_object->assign('zahlarten_gruppe', $group);
// Platzhalter im Bestelltext (z.B. "Kundenspezifisch" bei Haendlern), wie content_cash_desk_neu.tpl
$this->layout_object->assign('zahlarten_freitext3', isset($customer->freetextfield3) ? (string)$customer->freetextfield3 : '');
return $this->layout_object->_fetch('content_box_zahlarten.tpl');
} catch (Throwable $e) {
error_log('zahlarten_liste: '.$e->getMessage());
return '';
}
}
private function banner($structure_id, $block) {
$config = array();
eval($block->configuration);
+3 -1
View File
@@ -205,7 +205,9 @@ else if (isset($_GET['item_id']) && $_GET['item_id'] && (!isset($_GET['action'])
$layout_object->metaTagObject->overwriteVar('VonHersteller', 'von '.$hersteller);
$layout_object->metaTagObject->overwriteVar('Hersteller', $hersteller);
$layout_object->metaTagObject->overwriteVar('ArtikelAttribut6', str_replace(';',',',$content_object->item_data->attribute_6));
$structure_id = (isset($_GET['menu_id']) && $_GET['menu_id']) ? $_GET['menu_id'] : $content_object->structure_data[0]->id;
$structure_id = (isset($_GET['menu_id']) && $_GET['menu_id'])
? $_GET['menu_id']
: (isset($content_object->structure_data[0]->id) ? $content_object->structure_data[0]->id : 0);
$structure_object = new Structure($base_object);
$tree_ids = array_reverse($structure_object->get_all_tree_ids($structure_id));
$z = 0;
+1
View File
@@ -18,6 +18,7 @@ include_once './core/customer_group.class.php';
include_once './core/cs_ticket.class.php';
include_once './core/shop_klarna.class.php';
#[\AllowDynamicProperties]
class website_item {
private $base_object;
+79 -3
View File
@@ -48,7 +48,33 @@ class website_shoppingcart {
private function show_shoppingcart() {
if (isset($_POST['customer_info'])) {
$_SESSION['customer_info'] = $_POST['customer_info'];
$_SESSION['customer_info'] = trim((string)$_POST['customer_info']);
}
// 18.09.2026 -- Warenkorb ist EIN Formular: "Zur Kasse" und die Express-Knoepfe schicken
// es ab (Mengen + Anmerkung werden so mitgespeichert) und das Ziel steht in cart_goto.
if (isset($_POST['cart_goto']) && $_POST['cart_goto'] !== '') {
$goto = (string)$_POST['cart_goto'];
if ($goto === 'kasse') {
header('Location: /Kasse/');
exit;
}
// 27.09.2026: "Weiter einkaufen" geht ueber das Formular (Mengen werden mitgespeichert),
// festes Ziel Startseite -- kein offener Redirect.
if ($goto === 'weiter') {
header('Location: /');
exit;
}
if (preg_match('/^express:([a-z0-9_]{1,40})$/', $goto, $m)) {
header('Location: /Kasse/?payment_express_start='.$m[1]);
exit;
}
}
// Post-Redirect-Get fuer die neue Ansicht: nach Mengen-/Anmerkungsaenderung per GET neu
// laden, sonst fragt ein Reload "Formular erneut senden" und schickt die Mengen doppelt.
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['cart_goto']) && !$this->alte_ansicht()) {
header('Location: /Warenkorb/');
exit;
}
if (isset($_SESSION['customer_info']) && $_SESSION['customer_info']) {
@@ -56,14 +82,64 @@ class website_shoppingcart {
}
$data = $this->shoppingcart_object->get_data();
$this->layout_object->assign('shoppingcart', $data);
$this->layout_object->assign('shoppingcartVATUsage', $this->base_object->customer_group->show_tax);
// 14.09.2026: Express-Buttons der Zahlungsanbieter ("Direkt mit PayPal bezahlen").
// Angezeigt, wenn der Anbieter Express kann und seine Zahlart aktiv ist.
$this->layout_object->assign('express_providers', $this->express_providers());
return $this->layout_object->_fetch('content_shoppingcart.tpl');
// 27.09.2026 -- die neue Ansicht (content_shoppingcart_neu.tpl) ist Standard.
// Rueckfall auf das alte Template: /Warenkorb/?ansicht=alt (siehe alte_ansicht()).
if ($this->alte_ansicht()) {
return $this->layout_object->_fetch('content_shoppingcart.tpl');
}
$this->layout_object->assign('site_type', 'full_width'); // ohne Navigation, mehr Breite (Tom, 18.09.)
return $this->layout_object->_fetch('content_shoppingcart_neu.tpl');
} // end show_shoppingcart
/**
* Rueckfall auf die alte Warenkorb-/Kassenansicht (27.09.2026, Go-Live der neuen Ansicht):
* ?ansicht=alt schaltet fuer die Sitzung auf die alten Templates, ?ansicht=neu hebt das auf.
* Haelt ueber die Formular-POSTs hinweg, weil die alten Templates keinen Parameter mitschicken.
* Dieselbe Weiche steht in website_cashdesk.php.
*/
private function alte_ansicht() {
if (isset($_GET['ansicht'])) {
$_SESSION['easyshop']['ansicht_alt'] = ($_GET['ansicht'] === 'alt') ? 1 : 0;
}
return !empty($_SESSION['easyshop']['ansicht_alt']);
}
/** @return array<int,array{module:string,label:string}> */
private function express_providers() {
$out = array();
$file = $_SERVER['DOCUMENT_ROOT'].'/core/payments/Registry.php';
if (!is_file($file)) {
return $out;
}
include_once $file;
$res = $this->base_object->db->query("SELECT module FROM payment_methods WHERE active=1 AND module<>''");
while ($res && ($row = $res->fetch_object())) {
$module = (string)$row->module;
if (!\Payments\Registry::has($module)) {
continue;
}
try {
$p = \Payments\Registry::get($module, $this->base_object);
if ($p && $p->supportsExpress()) {
$out[] = array('module' => $module, 'label' => $p->label());
}
} catch (Throwable $e) {
error_log('Payments express button '.$module.': '.$e->getMessage());
}
}
return $out;
}
private function update_shoppingcart() {
foreach ($_POST['update_item'] as $item => $quantity) {
if ($quantity > 0) {
+44
View File
@@ -0,0 +1,44 @@
<?php
/**
* Webhook-Endpunkt fuer alle Zahlungsanbieter: /payment_webhook.php?p=<module>
*
* Server-zu-Server, keine Session. Der Provider prueft die Signatur selbst und
* entscheidet, was mit dem Ereignis passiert. Antwort 2xx = angenommen, sonst
* stellt der Anbieter erneut zu.
*/
declare(strict_types=1);
require __DIR__ . '/core/payments/bootstrap.php';
$module = isset($_GET['p']) ? (string) $_GET['p'] : '';
$provider = \Payments\Registry::get($module, $base_object);
if (!$provider) {
\http_response_code(404);
echo 'unknown provider';
exit;
}
$rawBody = (string) \file_get_contents('php://input');
$headers = [];
foreach ($_SERVER as $key => $value) {
if (\strncmp($key, 'HTTP_', 5) === 0) {
$headers[\strtolower(\str_replace('_', '-', \substr($key, 5)))] = (string) $value;
}
}
// Letzte Zustellung roh ablegen -- nur wenn logs/webhook_debug existiert (Fehlersuche an
// der Signaturpruefung, 13.09.2026). logs/ ist per .htaccess gesperrt.
if (\is_file(__DIR__ . '/logs/webhook_debug')) {
@\file_put_contents(__DIR__ . '/logs/webhook_last_' . \preg_replace('/[^a-z0-9_]/', '', $module) . '.json',
\json_encode(['time' => \date('c'), 'headers' => $headers, 'body' => $rawBody], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
}
try {
$status = $provider->onWebhook($headers, $rawBody);
} catch (\Throwable $e) {
\error_log('payment_webhook ' . $module . ': ' . $e->getMessage());
$status = 500;
}
\http_response_code($status);
echo $status < 300 ? 'ok' : 'rejected';
+896 -141
View File
File diff suppressed because it is too large Load Diff
+816
View File
@@ -0,0 +1,816 @@
<?php
/**
* Krempl PostgreSQL Search Test Interface
* Search for devices and show matching ersatzteile
*/
require_once __DIR__ . '/core/postgres/KremplDB.php';
$search_term = isset($_GET['q']) ? trim($_GET['q']) : '';
$results = [];
$item_results = [];
$search_time = 0;
$item_search_time = 0;
$error_message = '';
$show_shop_items = isset($_GET['shop_items']) && $_GET['shop_items'] === '1';
try {
// MySQL wird in beiden Fällen gebraucht (rechte Spalte = Artikel-Suche)
$db = new KremplDB(true);
if ($search_term !== '') {
// Links: Geräte-Suche (PostgreSQL)
$start_time = microtime(true);
$results = $db->searchGeraete($search_term, 20);
if ($show_shop_items) {
foreach ($results as &$device) {
$device['shop_items'] = $db->getAvailableItemsForGeraet($device['geraet_id'], 200);
}
unset($device);
}
$search_time = (microtime(true) - $start_time) * 1000;
// Rechts: Artikel-/Ersatzteil-Suche (MySQL)
$start_time2 = microtime(true);
$item_results = $db->searchItems($search_term, 20);
$item_search_time = (microtime(true) - $start_time2) * 1000;
}
} catch (Exception $e) {
$error_message = $e->getMessage();
}
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Krempl PostgreSQL Search Test</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: #f5f5f5;
padding: 20px;
}
.container {
max-width: 1600px;
margin: 0 auto;
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.split-results {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
margin-top: 20px;
}
.split-results > div {
min-width: 0;
}
.split-results h2 {
font-size: 16px;
color: #444;
background: #f4f4f4;
padding: 10px 14px;
border-left: 4px solid #4CAF50;
margin-bottom: 14px;
}
.split-results .right-pane h2 {
border-left-color: #1976D2;
}
.item-card {
border: 1px solid #e0e0e0;
border-radius: 4px;
margin-bottom: 8px;
padding: 10px 12px;
background: #fafafa;
font-size: 13px;
}
.item-card .item-name {
font-weight: 600;
color: #333;
margin-bottom: 4px;
}
.item-card .item-meta {
color: #666;
font-size: 12px;
}
.item-card .item-meta strong {color:#1976D2;}
.inventory-positive {color:#2e7d32;font-weight:bold;}
.inventory-zero {color:#aaa;}
.inventory-null {color:#aaa;font-style:italic;}
@media (max-width: 900px) {
.split-results { grid-template-columns: 1fr; }
}
h1 {
color: #333;
margin-bottom: 20px;
}
.search-box {
margin-bottom: 30px;
}
.search-input {
width: 100%;
padding: 12px 15px;
font-size: 16px;
border: 2px solid #ddd;
border-radius: 4px;
transition: border-color 0.3s;
}
.search-input:focus {
outline: none;
border-color: #4CAF50;
}
.search-btn {
padding: 10px 20px;
background: #4CAF50;
color: white;
border: none;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
transition: background 0.3s;
white-space: nowrap;
}
.search-btn:hover {
background: #45a049;
}
#shopItemsToggle {
cursor: pointer;
width: 18px;
height: 18px;
}
.search-info {
color: #666;
margin-bottom: 20px;
font-size: 14px;
}
.device-card {
border: 1px solid #e0e0e0;
border-radius: 4px;
margin-bottom: 20px;
background: #fafafa;
overflow: hidden;
}
.device-card.collapsed .ersatzteile-section {
display: none;
}
.device-header {
display: flex;
justify-content: space-between;
align-items: start;
padding: 15px;
margin-bottom: 0;
border-bottom: 2px solid #e0e0e0;
}
.score-badge {
cursor: pointer;
transition: all 0.2s;
user-select: none;
}
.score-badge:hover {
opacity: 0.9;
transform: scale(1.02);
}
.toggle-arrow {
display: inline-block;
margin-left: 8px;
transition: transform 0.3s;
font-size: 10px;
}
.device-card.collapsed .toggle-arrow {
transform: rotate(-90deg);
}
.device-info {
flex: 1;
}
.device-title {
font-size: 18px;
font-weight: bold;
color: #333;
margin-bottom: 5px;
}
.device-title a {
color: #333;
text-decoration: none;
transition: color 0.2s;
}
.device-title a:hover {
color: #4CAF50;
text-decoration: underline;
}
.device-details {
color: #666;
font-size: 14px;
}
.device-details span {
display: inline-block;
margin-right: 15px;
}
.score-badge {
display: inline-block;
padding: 5px 12px;
border-radius: 20px;
font-weight: bold;
font-size: 14px;
}
.score-100 { background: #4CAF50; color: white; }
.score-95 { background: #66BB6A; color: white; }
.score-90 { background: #81C784; color: white; }
.score-80 { background: #FFC107; color: white; }
.score-75 { background: #FFD54F; color: #333; }
.score-60 { background: #FFE082; color: #333; }
.score-default { background: #BDBDBD; color: white; }
.ersatzteile-section {
padding: 15px;
}
.ersatzteile-title {
font-weight: bold;
color: #333;
margin-bottom: 10px;
margin-top: 10px;
font-size: 14px;
}
.ersatzteile-table {
width: 100%;
border-collapse: collapse;
background: white;
border-radius: 4px;
overflow: hidden;
}
.ersatzteile-table th {
background: #f0f0f0;
padding: 8px;
text-align: left;
font-size: 13px;
font-weight: 600;
color: #555;
}
.ersatzteile-table td {
padding: 8px;
border-top: 1px solid #e0e0e0;
font-size: 13px;
}
.ersatzteile-table tr:hover {
background: #f9f9f9;
}
.navision-id {
font-weight: bold;
color: #1976D2;
}
.no-results {
text-align: center;
padding: 40px;
color: #999;
}
.match-type {
display: inline-block;
font-size: 11px;
padding: 2px 6px;
background: #e0e0e0;
border-radius: 3px;
color: #666;
margin-left: 8px;
}
.error-message {
background: #ffebee;
border: 1px solid #ef5350;
color: #c62828;
padding: 15px;
border-radius: 4px;
margin-bottom: 20px;
}
.error-message strong {
display: block;
margin-bottom: 5px;
}
</style>
</head>
<body>
<div class="container">
<h1>Krempl PostgreSQL Search Test</h1>
<div class="search-box">
<form method="GET" action="" id="searchForm">
<input
type="text"
name="q"
class="search-input"
placeholder="Gerätesuche (z.B. AWB 921 PH, ZCS 2100B, 2100B)"
value="<?php echo htmlspecialchars($search_term); ?>"
autofocus
/>
<div style="margin-top: 10px; display: flex; align-items: center; gap: 15px;">
<button type="submit" class="search-btn">Suchen</button>
<label style="font-size: 14px; cursor: pointer; display: flex; align-items: center; gap: 5px;">
<input type="checkbox" name="shop_items" value="1" <?php echo $show_shop_items ? 'checked' : ''; ?> id="shopItemsToggle" onchange="document.getElementById('searchForm').submit();">
<span>Nur verfügbare Shop-Artikel (MySQL)</span>
</label>
</div>
</form>
</div>
<?php if ($error_message): ?>
<div class="error-message">
<strong>Fehler bei der Datenbankverbindung:</strong>
<?php echo htmlspecialchars($error_message); ?>
</div>
<?php endif; ?>
<?php if ($search_term !== '' && !$error_message): ?>
<div class="split-results">
<!-- LINKS: Geräte-Suche (PostgreSQL) -->
<div class="left-pane">
<h2>Geräte (PostgreSQL)
— <?php echo count($results); ?> Treffer
(<?php echo number_format($search_time, 0); ?>&thinsp;ms)
</h2>
<?php if (count($results) === 0): ?>
<div class="no-results" style="padding:20px;">
Keine Geräte gefunden.
</div>
<?php endif; ?>
<?php foreach ($results as $device): ?>
<?php
$score = (int)$device['score'];
$score_class = 'score-default';
if ($score >= 100) $score_class = 'score-100';
elseif ($score >= 95) $score_class = 'score-95';
elseif ($score >= 90) $score_class = 'score-90';
elseif ($score >= 80) $score_class = 'score-80';
elseif ($score >= 75) $score_class = 'score-75';
elseif ($score >= 60) $score_class = 'score-60';
?>
<div class="device-card collapsed" id="device-<?php echo $device['geraet_id']; ?>">
<div class="device-header">
<div class="device-info">
<div class="device-title">
<a href="geraet.php?id=<?php echo $device['geraet_id']; ?>&q=<?php echo urlencode($search_term); ?><?php echo $show_shop_items ? '&shop_items=1' : ''; ?>" style="color: #333; text-decoration: none;">
<?php echo htmlspecialchars($device['modell_bezeichnung']); ?>
</a>
<span class="match-type"><?php echo htmlspecialchars($device['match_type']); ?></span>
</div>
<div class="device-details">
<span><strong>Marke:</strong> <?php echo htmlspecialchars($device['marke']); ?></span>
<span><strong>Typ:</strong> <?php echo htmlspecialchars($device['typ']); ?></span>
<span><strong>Nr:</strong> <?php echo htmlspecialchars($device['nr']); ?></span>
<span><strong>ID:</strong> <?php echo htmlspecialchars($device['geraet_id']); ?></span>
</div>
</div>
<div>
<span class="score-badge <?php echo $score_class; ?>" onclick="toggleDevice(<?php echo $device['geraet_id']; ?>)">
Score: <?php echo $score; ?>
<span class="toggle-arrow">▼</span>
</span>
</div>
</div>
<?php if ($show_shop_items && isset($device['shop_items'])): ?>
<!-- Show available shop items -->
<?php if (count($device['shop_items']) > 0): ?>
<div class="ersatzteile-section">
<div class="ersatzteile-title">
Verfügbare Shop-Artikel:
<span style="background: #4CAF50; color: white; padding: 3px 10px; border-radius: 12px; font-weight: bold; margin-left: 5px;">
<?php echo count($device['shop_items']); ?> verfügbar
</span>
</div>
<table class="ersatzteile-table">
<thead>
<tr>
<th>Artikel-Nr</th>
<th>Artikelname</th>
<th>Navision-ID</th>
<th>Lagerbestand</th>
</tr>
</thead>
<tbody>
<?php foreach ($device['shop_items'] as $item): ?>
<tr>
<td><?php echo htmlspecialchars($item['number']); ?></td>
<td><?php echo htmlspecialchars($item['name']); ?></td>
<td class="navision-id"><?php echo htmlspecialchars($item['matched_navision_id']); ?></td>
<td><?php echo (int)$item['inventory']; ?> Stück</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php else: ?>
<div class="ersatzteile-section">
<div class="ersatzteile-title" style="color: #999;">
Keine verfügbaren Shop-Artikel gefunden
</div>
</div>
<?php endif; ?>
<?php else: ?>
<!-- Show all Krempl ersatzteile -->
<?php if (count($device['ersatzteile']) > 0): ?>
<div class="ersatzteile-section">
<div class="ersatzteile-title">
Passende Ersatzteile (Krempl):
<span style="background: #2196F3; color: white; padding: 3px 10px; border-radius: 12px; font-weight: bold; margin-left: 5px;">
<?php echo count($device['ersatzteile']); ?> gesamt
</span>
</div>
<table class="ersatzteile-table">
<thead>
<tr>
<th>Navision ID</th>
<th>Krempl ID</th>
<th>Originalnummer</th>
<th>Marke</th>
</tr>
</thead>
<tbody>
<?php foreach ($device['ersatzteile'] as $ers): ?>
<tr>
<td class="navision-id"><?php echo htmlspecialchars($ers['navision_id']); ?></td>
<td><?php echo htmlspecialchars($ers['krempl_id']); ?></td>
<td><?php echo htmlspecialchars($ers['originalnummer']); ?></td>
<td><?php echo htmlspecialchars($ers['marke']); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php else: ?>
<div class="ersatzteile-section">
<div class="ersatzteile-title" style="color: #999;">
Keine Ersatzteile gefunden
</div>
</div>
<?php endif; ?>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div><!-- /left-pane -->
<!-- RECHTS: Artikel-/Ersatzteil-Suche (MySQL) -->
<div class="right-pane">
<h2>Ersatzteile / Artikel (MySQL items)
— <?php echo count($item_results); ?> Treffer
(<?php echo number_format($item_search_time, 0); ?>&thinsp;ms)
</h2>
<?php if (count($item_results) === 0): ?>
<div class="no-results" style="padding:20px;">
Keine Artikel gefunden.
</div>
<?php else: ?>
<?php foreach ($item_results as $item): ?>
<div class="item-card">
<div class="item-name"><?= htmlspecialchars($item['name']) ?></div>
<div class="item-meta">
Art-Nr: <strong><?= htmlspecialchars($item['number']) ?></strong>
· Navision: <strong><?= htmlspecialchars($item['matched_navision_id']) ?></strong>
· Lager:
<?php
$inv = $item['inventory'];
if ($inv === null) {
echo '<span class="inventory-null">k.A.</span>';
} elseif ((int)$inv > 0) {
echo '<span class="inventory-positive">' . (int)$inv . ' Stk</span>';
} else {
echo '<span class="inventory-zero">0</span>';
}
?>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div><!-- /right-pane -->
</div><!-- /split-results -->
<?php
// ==================================================================
// A/B – NEUE DARSTELLUNG (Prototyp, geführte Ergebnis-Ansicht)
// Nutzt dieselben Daten ($results / $item_results), nur menschlich.
// ==================================================================
if (!function_exists('v2_confidence')) {
function v2_confidence($score) {
$s = (int)$score;
if ($s >= 100) return ['Exakter Treffer', '#2e7d32', '#e8f5e9', '✓'];
if ($s >= 90) return ['Sehr wahrscheinlich dein Gerät','#388e3c', '#edf7ee', '✓'];
if ($s >= 75) return ['Könnte passen', '#f9a825', '#fff8e1', '≈'];
return ['Ähnlich – bitte prüfen', '#9e9e9e', '#f5f5f5', '?'];
}
// nml/Nachfolger steckt im Artikelnamen: "(nml)"/"(nmle)" + "Nachfolger <Nr>"
function v2_nml_info($name) {
$is_nml = (bool)preg_match('/\(nml[e]?\)/i', $name);
$nachf = null;
// Nachfolger-Nummer muss mind. eine Ziffer enthalten (schließt "Nachfolger Original" aus)
if (preg_match('/Nachfolger\s+([A-Za-z0-9\-\/\.]*\d[A-Za-z0-9\-\/\.]*)/i', $name, $m)) {
$nachf = $m[1];
}
$kein_ersatz = (stripos($name, 'kein Ersatz') !== false);
// Anzeigename säubern: (nml)-Marker + "Nachfolger ... Original(teil)" raus
$clean = preg_replace('/\s*\(nml[e]?\)\s*/i', ' ', $name);
$clean = preg_replace('/\s*Nachfolger\s+[A-Za-z0-9\-\/\.]*\d[A-Za-z0-9\-\/\.]*\s*/i', ' ', $clean);
$clean = preg_replace('/\s*(Originalteil|Original)\s*$/i', '', $clean);
$clean = trim(preg_replace('/\s{2,}/', ' ', $clean));
return ['is_nml'=>$is_nml, 'nachfolger'=>$nachf, 'kein_ersatz'=>$kein_ersatz, 'clean'=>($clean ?: $name)];
}
}
// Treffer in "stark" (>=75) und "schwach" (<75) teilen
$v2_strong = []; $v2_weak = [];
foreach ($results as $d) { ((int)$d['score'] >= 75) ? $v2_strong[] = $d : $v2_weak[] = $d; }
// Artikel klassifizieren: kaufbar / ausgelaufen-mit-Nachfolger / tot
$g_buy = []; $g_succ = []; $g_dead = [];
foreach ($item_results as $it) {
$it['_nml'] = v2_nml_info($it['name']);
if (!$it['_nml']['is_nml']) $g_buy[] = $it; // regulär
elseif ($it['_nml']['nachfolger']) $g_succ[] = $it; // ausgelaufen, Nachfolger da
else $g_dead[] = $it; // ausgelaufen, kein Ersatz
}
$v2_top_score = isset($results[0]) ? (int)$results[0]['score'] : 0;
// Intent-Einordnung
if ($v2_top_score >= 90) $v2_headline = 'Wir haben dein Gerät gefunden.';
elseif (count($v2_strong) > 0) $v2_headline = 'Das könnte dein Gerät sein:';
elseif (count($item_results)) $v2_headline = 'Passende Artikel gefunden:';
else $v2_headline = 'Mehrere mögliche Treffer – grenz gern ein.';
?>
<hr style="margin:40px 0 0; border:none; border-top:3px dashed #cfcfcf;">
<style>
.v2{margin-top:24px;font-size:15px}
.v2 .v2-flag{display:inline-block;background:#111;color:#fff;font-size:11px;font-weight:700;
letter-spacing:.5px;padding:3px 10px;border-radius:4px;text-transform:uppercase}
.v2 .v2-head{font-size:22px;font-weight:700;color:#222;margin:14px 0 4px}
.v2 .v2-sub{color:#777;font-size:13px;margin-bottom:22px}
.v2-dev{border:1px solid #e3e3e3;border-radius:10px;margin-bottom:16px;overflow:hidden;
box-shadow:0 1px 3px rgba(0,0,0,.05)}
.v2-dev-top{display:flex;justify-content:space-between;align-items:flex-start;gap:16px;
padding:16px 18px;background:#fff}
.v2-dev-name{font-size:18px;font-weight:700;color:#1a1a1a;line-height:1.3}
.v2-dev-meta{color:#666;font-size:13px;margin-top:5px}
.v2-dev-meta b{color:#444}
.v2-conf{white-space:nowrap;font-size:12px;font-weight:700;padding:6px 12px;border-radius:20px}
.v2-parts{padding:14px 18px;border-top:1px solid #f0f0f0;background:#fcfcfc}
.v2-parts h4{font-size:13px;color:#555;margin-bottom:9px;font-weight:600}
.v2-chip{display:inline-block;background:#fff;border:1px solid #e0e0e0;border-radius:16px;
padding:4px 11px;margin:0 5px 6px 0;font-size:12px;color:#444}
.v2-chip b{color:#1565c0}
.v2-buy{background:#e8f5e9;border-color:#c8e6c9}
.v2-more{margin:6px 0 22px}
.v2-more summary{cursor:pointer;color:#1565c0;font-size:14px;padding:8px 0}
.v2-shop{margin-top:30px}
.v2-shop h3{font-size:16px;color:#333;margin-bottom:12px;border-left:4px solid #1976D2;padding-left:10px}
.v2-item{display:flex;justify-content:space-between;align-items:center;gap:14px;
border:1px solid #eee;border-radius:8px;padding:12px 15px;margin-bottom:8px;background:#fff}
.v2-item-name{font-weight:600;color:#222}
.v2-item-meta{color:#888;font-size:12px;margin-top:3px}
.v2-stock{white-space:nowrap;font-size:13px;font-weight:700}
.v2-in{color:#2e7d32}.v2-out{color:#c62828}.v2-na{color:#aaa;font-style:italic;font-weight:400}
.v2-cart{background:#4CAF50;color:#fff;border:none;border-radius:6px;padding:8px 14px;
font-size:13px;font-weight:600;cursor:pointer;white-space:nowrap}
.v2-cart:disabled{background:#ccc;cursor:not-allowed}
.v2-hint{background:#fffdf3;border:1px dashed #e6d9a8;border-radius:8px;padding:10px 14px;
font-size:12px;color:#8a7a3a;margin-top:20px}
</style>
<div class="v2">
<span class="v2-flag">◇ Variante B – geführte Ansicht (Prototyp)</span>
<div class="v2-head"><?php echo htmlspecialchars($v2_headline); ?></div>
<div class="v2-sub">
<?php echo count($v2_strong); ?> passende Geräte
· <?php echo count($g_buy); ?> kaufbare Artikel
<?php if (count($g_succ)) echo ' · '.count($g_succ).' mit Nachfolger'; ?>
<?php if (count($g_dead)) echo ' · '.count($g_dead).' ausgelaufen'; ?>
<?php if (count($v2_weak)) echo ' · '.count($v2_weak).' weitere mögliche Geräte'; ?>
</div>
<?php foreach ($v2_strong as $i => $d):
list($clabel,$ccol,$cbg,$cicon) = v2_confidence($d['score']);
$parts = $d['ersatzteile'] ?? [];
$buy = $d['shop_items'] ?? null; // nur wenn shop_items-Toggle an
?>
<div class="v2-dev">
<div class="v2-dev-top">
<div>
<div class="v2-dev-name"><?php echo htmlspecialchars($d['marke'].' '.$d['modell_bezeichnung']); ?></div>
<div class="v2-dev-meta">
<b>Typ:</b> <?php echo htmlspecialchars($d['typ']); ?>
· <b>Nr:</b> <?php echo htmlspecialchars($d['nr']); ?>
<?php if (!empty($d['produktionsstart']) || !empty($d['produktionsende'])): ?>
· <b>Baujahr:</b> <?php echo htmlspecialchars(trim(($d['produktionsstart']??'').'–'.($d['produktionsende']??''),'–')); ?>
<?php endif; ?>
</div>
</div>
<span class="v2-conf" style="color:<?php echo $ccol; ?>;background:<?php echo $cbg; ?>;">
<?php echo $cicon.' '.htmlspecialchars($clabel); ?>
</span>
</div>
<?php if ($buy && count($buy)): ?>
<div class="v2-parts">
<h4>✓ Sofort erhältlich für dieses Gerät (<?php echo count($buy); ?>):</h4>
<?php foreach (array_slice($buy,0,8) as $b): ?>
<span class="v2-chip v2-buy"><b><?php echo htmlspecialchars($b['number']); ?></b>
· <?php echo htmlspecialchars(mb_strimwidth($b['name'],0,42,'…')); ?>
<?php if ((int)$b['inventory']>0) echo '· '.(int)$b['inventory'].' Stk'; ?></span>
<?php endforeach; ?>
</div>
<?php elseif (count($parts)): ?>
<div class="v2-parts">
<h4><?php echo count($parts); ?> passende Ersatzteile — Auswahl:</h4>
<?php foreach (array_slice($parts,0,10) as $p): ?>
<span class="v2-chip"><b><?php echo htmlspecialchars($p['originalnummer']); ?></b>
<?php if (!empty($p['marke'])) echo '· '.htmlspecialchars($p['marke']); ?></span>
<?php endforeach; ?>
<?php if (count($parts) > 10): ?>
<span class="v2-chip" style="border-style:dashed;color:#999;">+<?php echo count($parts)-10; ?> weitere</span>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
<?php if (count($v2_weak)): ?>
<details class="v2-more">
<summary>Weitere mögliche Geräte anzeigen (<?php echo count($v2_weak); ?>) – schwächere Treffer</summary>
<?php foreach ($v2_weak as $d): list($clabel,$ccol,$cbg,$cicon)=v2_confidence($d['score']); ?>
<div class="v2-dev" style="opacity:.75">
<div class="v2-dev-top">
<div>
<div class="v2-dev-name" style="font-size:15px"><?php echo htmlspecialchars($d['marke'].' '.$d['modell_bezeichnung']); ?></div>
<div class="v2-dev-meta"><b>Typ:</b> <?php echo htmlspecialchars($d['typ']); ?> · <b>Nr:</b> <?php echo htmlspecialchars($d['nr']); ?></div>
</div>
<span class="v2-conf" style="color:<?php echo $ccol; ?>;background:<?php echo $cbg; ?>;"><?php echo $cicon.' '.htmlspecialchars($clabel); ?></span>
</div>
</div>
<?php endforeach; ?>
</details>
<?php endif; ?>
<?php $keep_shop = $show_shop_items ? '&shop_items=1' : ''; ?>
<?php if (count($g_buy)): ?>
<div class="v2-shop">
<h3>🛒 Diese Artikel kannst du direkt kaufen</h3>
<?php foreach ($g_buy as $it):
$inv = $it['inventory'];
$can = ($inv !== null && (int)$inv > 0);
// Bilder liegen nur auf LIVE → absolute URL
$img = !empty($it['default_image_file_name'])
? 'https://intelectra.de/web/intelectra_shop/images/article/'.rawurlencode($it['default_image_file_name'])
: '';
$pb = isset($it['price_brutto']) ? (float)$it['price_brutto'] : 0;
$bb = !empty($it['bargain_brutto']) ? (float)$it['bargain_brutto'] : 0;
?>
<div class="v2-item">
<div style="display:flex;align-items:center;gap:14px;min-width:0">
<?php if ($img): ?>
<img src="<?php echo $img; ?>" alt="" loading="lazy"
style="width:64px;height:64px;object-fit:contain;background:#fff;border:1px solid #eee;border-radius:6px;flex:0 0 64px"
onerror="this.style.display='none'">
<?php else: ?>
<div style="width:64px;height:64px;flex:0 0 64px;background:#f5f5f5;border:1px dashed #ddd;border-radius:6px;
display:flex;align-items:center;justify-content:center;color:#bbb;font-size:11px">kein Bild</div>
<?php endif; ?>
<div style="min-width:0">
<div class="v2-item-name"><?php echo htmlspecialchars($it['_nml']['clean']); ?></div>
<div class="v2-item-meta">Art-Nr <?php echo htmlspecialchars($it['number']); ?></div>
</div>
</div>
<div style="text-align:right;flex:0 0 auto">
<div style="font-size:17px;font-weight:700;color:#1a1a1a;margin-bottom:2px">
<?php if ($bb > 0 && $pb > 0 && $bb < $pb): ?>
<span style="color:#c62828"><?php echo number_format($bb, 2, ',', '.'); ?>&nbsp;€</span>
<span style="font-size:12px;color:#999;text-decoration:line-through;font-weight:400"><?php echo number_format($pb, 2, ',', '.'); ?>&nbsp;€</span>
<?php elseif ($pb > 0): ?>
<?php echo number_format($pb, 2, ',', '.'); ?>&nbsp;€
<?php else: ?>
<span style="font-size:12px;color:#999;font-weight:400">Preis auf Anfrage</span>
<?php endif; ?>
</div>
<?php if ($pb > 0): ?><div style="font-size:10px;color:#aaa;margin-bottom:4px">inkl. MwSt.</div><?php endif; ?>
<div class="v2-stock <?php echo $inv===null?'v2-na':($can?'v2-in':'v2-out'); ?>">
<?php echo $inv===null ? 'Lager k.A.' : ($can ? '● '.(int)$inv.' ab Lager' : '○ nicht am Lager'); ?>
</div>
<button class="v2-cart" <?php echo $can?'':'disabled'; ?> style="margin-top:6px">
<?php echo $can ? 'In den Warenkorb' : 'Bestellbar'; ?>
</button>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if (count($g_succ)): ?>
<div class="v2-shop">
<h3 style="border-left-color:#fb8c00">🔄 Ausgelaufen – aber es gibt einen Nachfolger</h3>
<?php foreach ($g_succ as $it): $nf = $it['_nml']['nachfolger']; ?>
<div class="v2-item" style="background:#fff8f0;border-color:#ffe0b2">
<div>
<div class="v2-item-name" style="color:#8a6d3b;text-decoration:line-through;text-decoration-color:#d9b38c">
<?php echo htmlspecialchars($it['_nml']['clean']); ?>
</div>
<div class="v2-item-meta">Art-Nr <?php echo htmlspecialchars($it['number']); ?> · nicht mehr lieferbar</div>
</div>
<div style="text-align:right">
<div style="font-size:12px;color:#e65100;font-weight:600;margin-bottom:6px">Nachfolger: <?php echo htmlspecialchars($nf); ?></div>
<a href="?q=<?php echo urlencode($nf).$keep_shop; ?>"
class="v2-cart" style="background:#fb8c00;text-decoration:none;display:inline-block">
→ Nachfolger anzeigen
</a>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if (count($g_dead)): ?>
<details class="v2-more" style="margin-top:20px">
<summary>Nicht mehr lieferbar, kein Ersatz (<?php echo count($g_dead); ?>) – ausgeblendet</summary>
<?php foreach ($g_dead as $it): ?>
<div class="v2-item" style="opacity:.55">
<div>
<div class="v2-item-name" style="text-decoration:line-through"><?php echo htmlspecialchars($it['_nml']['clean']); ?></div>
<div class="v2-item-meta">Art-Nr <?php echo htmlspecialchars($it['number']); ?></div>
</div>
<div class="v2-stock v2-out" style="white-space:nowrap">✕ entfällt</div>
</div>
<?php endforeach; ?>
</details>
<?php endif; ?>
<?php if (!count($g_buy) && !count($g_succ) && !count($g_dead)): ?>
<!-- keine Artikel -->
<?php endif; ?>
<div class="v2-hint">
💡 <b>Prototyp-Notiz (nicht final):</b> Bild (aus <code>items.default_image_file_name</code>, geladen vom Live-Server)
+ Preis (<code>item_prices</code> Kundengruppe 1, brutto inkl. Steuersatz, Angebotspreis rot) sind jetzt drin.
<b>nml/Nachfolger ist jetzt live:</b> Der Marker <code>(nml)</code>/<code>(nmle)</code> im Artikelnamen wird erkannt —
ausgelaufene Teile <b>mit</b> „Nachfolger &lt;Nr&gt;" landen im orangen Block und verlinken per Klick auf den kaufbaren Nachfolger;
Teile <b>ohne</b> Ersatz werden ans Ende ausgeblendet. Score-Zahlen durch Vertrauens-Text ersetzt.
Tipp: oben „Nur verfügbare Shop-Artikel" aktivieren zeigt pro Gerät die kaufbaren Teile grün.
</div>
</div>
<?php else: ?>
<div class="no-results">
Gib einen Suchbegriff ein (z.B. "AWB 921 PH", "ZCS 2100B", "Kohlefilter Bauknecht")
</div>
<?php endif; ?>
</div>
<script>
function toggleDevice(deviceId) {
const card = document.getElementById('device-' + deviceId);
if (card) {
card.classList.toggle('collapsed');
}
}
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
ul.tabs {
list-style:none;
margin:0;
padding:0;
}
ul.tabs li {
display:inline-block;
border:1px solid black;
border-bottom:none;
}
ul.tabs li a {
height:30px;
line-height:30px;
text-decoration:none;
margin-left:10px;
margin-right:10px;
}
ui.tabs li a:hover {
text-decoration:underline;
}
#tabs div.tab {
display:none;
}
#tabs div.display {
display:block;
border-top:1px solid black;
}
@@ -0,0 +1,99 @@
.easyway_shop .line {
width:100%;
display: block;
}
.block {
border: 1px solid #D9D9D9;
padding:15px;
}
.full {
width: 948px;
display: inline-block;
}
.wide {
width: 537px;
display: inline-block;
}
.narrow {
width: 312px;
display: inline-block;
}
.easyway_shop label {
color: #003580;
display: inline-block;
font-weight: bold;
line-height: 1em;
margin-bottom: 2px;
}
.easyway_shop input[type="text"], .easyway_shop select {
width: 100%;
border: 1px solid #B5C0F9;
color: #009900;
display: block;
font-weight: bold;
}
.wide .width_1_out_of_5 {
display: inline-block;
height:40px;
width: 94px;
margin-left: 10px;
margin-right: 10px;
}
.wide .width_2_out_of_5 {
display: inline-block;
height:40px;
width: 206px;
margin-left: 10px;
margin-right: 10px;
}
.wide .width_3_out_of_5 {
display: inline-block;
height:40px;
width: 322px;
margin-left: 10px;
margin-right: 10px;
}
.wide .width_4_out_of_5 {
display: inline-block;
height:40px;
width: 416px;
margin-left: 10px;
margin-right: 10px;
}
.wide .width_5_out_of_5 {
display: inline-block;
height:40px;
width: 517px;
margin-left: 10px;
margin-right: 10px;
}
.last {
margin-right: 0;
}
.action_button {
display: inline-block;
height: 20px;
background: transparent;
border: 1px solid #B5C0F9;
padding: 5px;
font-weight: bold;
text-decoration: none;
text-align: center;
}
.action_button:hover {
text-decoration: underline;
}
+397
View File
@@ -0,0 +1,397 @@
.payment_method_config {
padding-left:5px;
clear:both;
}
.box {
border: 1px solid #D9D9D9;
padding: 15px;
margin-bottom:15px;
}
.regular {
width:573px;
}
#contentContainer #adminLeftFloater {
float: left;
width: 605px;
padding-right: 15px;
}
#contentContainer #adminRightFloater {
float: left;
width: 344px;
padding-left: 15px;
min-height: 866px;
background: transparent url(../images/content.rightcolumn.background.jpg) no-repeat left top;
}
div.checkbox label {
display:inline;
}
form.edit2 label {
display: block;
color: #003580;
font-weight: bold;
margin-bottom: 2px;
line-height: 1em;
width: 100%;
}
.span_form {
font-weight: normal;
float: right;
color: #A6A6A6;
text-align: right;
width: 60px;
}
form.edit2 input, form.edit2 textarea, form.edit2 select {
display: block;
box-sizing: border-box;
-moz-box-sizing: border-box;
-webkit-box-sizing: border-box;
border: #B5C0F9 1px solid;
color: #009900;
font-weight: bold;
width:100%;
}
form.edit label {
display: block;
color: #003580;
font-weight: bold;
margin-bottom: 2px;
line-height: 1em;
width: 100%;
}
form.edit label.checkbox {
display: inline;
}
/*
form.edit span {
font-weight: normal;
float: right;
color: #A6A6A6;
text-align: right;
width: 60px;
}
*/
form.edit input, form.edit textarea, form.edit select {
display: block;
box-sizing: border-box;
-moz-box-sizing: border-box;
-webkit-box-sizing: border-box;
border: #B5C0F9 1px solid;
color: #009900;
font-weight: bold;
width:100%;
}
form.edit input.required, form.edit select.required {
background: #ffd5d5;
}
forms.edit input[readonly="readonly"] {
color:#003580;
}
form.edit input[type="checkbox"] {
display: inline;
width: auto;
}
.edit_title {
position: relative;
font-family: Arial;
font-size: 14px;
font-weight: bold;
color: #990000;
margin-bottom: 5px;
margin-top: 15px;
z-index: 20;
}
.edit_title_toggler {
position: relative;
font-family: Arial;
font-size: 14px;
font-weight: bold;
color: #003594;
margin: 10px 10px;
cursor: pointer;
background: transparent url(../images/edit_form_open.png) no-repeat right;
}
.edit_title_hidden {
color: #003594;
background: transparent url(../images/edit_form_close.png) no-repeat right;
}
.formVariant2 .edit_title_toggler {
position: relative;
font-family: Arial;
font-size: 14px;
font-weight: bold;
color: #003594;
margin: 0px 10px;
cursor: pointer;
background: #F0F0EE url(../images/edit_form_open.png) no-repeat right;
padding: 10px;
border: #CCCCCC 1px solid;
}
.formVariant2 .edit_title_hidden {
color: #003594;
background: #F0F0EE url(../images/edit_form_close.png) no-repeat right;
}
.toggleable_container2 {
border: #CCCCCC 1px solid;
padding: 10px;
margin: 0px 10px;
}
.edit_element_1_3 {
float:left;
height:40px;
width: 170px;
margin-left: 10px;
margin-right: 10px;
}
.toggleable_container2 .edit_element_1_3 {
width: 153px;
}
.toggleable_container2 .edit_element_1_4 {
float:left;
height:40px;
width: 110px;
margin-left: 10px;
margin-right: 10px;
}
.edit_element_1_5 {
float:left;
height:40px;
width: 94px;
margin-left: 10px;
margin-right: 10px;
}
.edit_element_2_5 {
float:left;
height:40px;
width: 208px;
margin-left: 10px;
margin-right: 10px;
}
.edit_element_3_5 {
float:left;
height:40px;
width: 322px;
margin-left: 10px;
margin-right: 10px;
}
.edit_element_4_5 {
float:left;
height:40px;
width: 416px;
margin-left: 10px;
margin-right: 10px;
}
.edit_element_5_5 {
float:left;
height:40px;
width: 550px;
margin-left: 10px;
margin-right: 10px;
}
.edit_endline {
width:550px;
height:1px;
background:#eeeeee;
clear:left;
margin-bottom:8px;
margin-left: 10px;
}
.edit_element_1_1 {
float:left;
width:550px;
height:40px;
margin-left: 10px;
margin-right: 10px;
}
.edit_element_75 {
float:left;
width:454px;
height:40px;
margin-left: 10px;
margin-right: 10px;
}
.edit_element_150 {
width:930px;
height:40px;
margin-left: 10px;
margin-right: 10px;
}
.forms_table {
width:100%;
}
.forms_table td.position {
width:40px;
}
.forms_table td.amount {
width:40px;
}
.forms_table td.item_number {
width:80px;
}
.forms_table td.price {
width:40px;
}
.forms_table td.item_total {
width:40px;
}
.forms_table thead{
background:#7086B4;
color:white;
}
div.search_results {
position:relative;
background:white;
z-index:200;
-moz-box-sizing: border-box;
border: 1px solid #B5C0F9;
border-top: 0;
color: #009900;
display: block;
font-weight: bold;
width: 100%;
}
div.hidden {
display:none;
}
div.togglebutton {
height:16px;
line-height:16px;
margin-top:4px;
margin-bottom:4px;
}
span.togglebutton {
display: inline-block;
height:16px;
line-height:16px;
}
span.togglebutton > label.togglebutton {
padding-left: 20px;
display:inline-block;
height:16px;
line-height:16px;;
}
label.togglebutton > span {
display:none;
}
label.leftalign {
background:transparent url(../images/text_left.png) no-repeat;
}
label.centeralign {
background:transparent url(../images/text_center.png) no-repeat;
}
label.rightalign {
background:transparent url(../images/text_right.png) no-repeat;
}
.sort_arrow {
height: 15px;
width: 32px;
float: right;
background:transparent url(../images/sort.png) no-repeat 0px 0px;
}
.sort_arrow_up {
background-position: 0px -32px;
}
.sort_arrow_down {
background-position: 0px -16px;
}
select[readonly] {
background: #EEEEEE;
}
input[readonly] {
background: #EEEEEE;
}
.order_editor_button {
border: 1px solid #B5C0F9;
display: inline-block;
font-weight: bold;
height: 20px;
background: #F9F9F9;
}
#customer_search_results {
background: white;
z-index: 200;
}
div#file_list {
min-height: 194px;
}
.not_used {
background: #f5e7e7;
}
select option.level1 {
color: #009900;
}
select option.level2 {
color: #C88300;
}
select option.level3 {
color: #668800;
}
select option.level4 {
color: #DE0043;
}
select option.level5 {
color: #430066;
}
select option.level6 {
color: #001834;
}
select option.levelAssigned {
font-weight: normal;
background: #DEDEDE;
}
+582
View File
@@ -0,0 +1,582 @@
html {
font-size: 100%;
margin: 0px;
padding: 0px;
height: 100%;
}
body {
margin: 0px;
padding: 0px;
font-size: 0.7em;
line-height: 1.5em;
font-family: Tahoma, "Liberation Sans", Arial;
cursor: default;
height: 100%;
color: #1A1A1A;
text-align: center;
}
#OverlayPixl {
position: absolute;
top: 0px;
left: 0px;
height: 0px;
width: 100%;
z-index: 101;
}
input {
font-family: Arial;
}
a {
outline-width: 0px;
color: #003580;
}
a:hover {
color: #990000;
}
.float_right {
float: right;
}
.float_left {
float: left;
}
.clear {
clear: both;
}
#bodycontainer
{
position: relative;
width: 100%;
text-align: left;
}
.main_footer {
/*background: #003D92;*/
background-image: -webkit-linear-gradient(#003D92 0%, #205Db2 100%);
background-image: -moz-linear-gradient(#003D92 0%, #205Db2 100%);
background-image: -o-linear-gradient(#003D92 0%, #205Db2 100%);
background-image: linear-gradient(#003D92 0%, #205Db2 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#003D92', endColorstr='#205Db2');
height: 15px;
}
.footer_copy {
position: absolute;
right: 10px;
font-size: 10px;
font-weight: normal;
color: #ffffff;
line-height: 15px;
}
#header {
position: relative;
height: 51px;
margin: 0px 0px;
/*background: #003D92;*/
background-image: -webkit-linear-gradient(#306Dc2 0%, #003D92 100%);
background-image: -moz-linear-gradient(#306Dc2 0%, #003D92 100%);
background-image: -o-linear-gradient(#306Dc2 0%, #003D92 100%);
background-image: linear-gradient(#306Dc2 0%, #003D92 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#306Dc2', endColorstr='#003D92');
z-index: 100;
}
#loggedin_user {
position: absolute;
right: 20px;
top: 35px;
height: 16px;
color: #ffffff;
}
#header_today {
position: absolute;
right: 20px;
top: 10px;
height: 16px;
color: #ffffff;
font-weight: bold;
font-size: 14px;
}
.header_stat {
opacity:0.5;
position:absolute;
top:15px;
height:27px;
width:25px;
text-align:right;
color:#ffffff;
}
.header_stat span {
display:none;
}
.header_stat_active {
opacity:1.0;
}
.header_stat_active span {
display:inline;
position:absolute;
top:0px;
right:0px;
height:11px;
line-height:11px;
font-size:11px;
font-family:Arial;
}
.header_stat_customer {
left:590px;
background:transparent url(../images/header_user.png) bottom left no-repeat;
cursor:pointer;
}
.header_stat_email {
left:640px;
background:transparent url(../images/header_email.png) bottom left no-repeat;
cursor:pointer;
}
.header_stat_order {
left:690px;
background:transparent url(../images/header_order.png) bottom left no-repeat;
cursor:pointer;
}
#loggedin_user_link {
padding-left: 15px;
color: #ffffff;
text-decoration:none;
}
#logout_button {
color: #ffffff;
text-decoration:none;
}
#logout_button a {
color: #ffffff;
}
#logo {
position: absolute;
height: 51px;
left: 5px;
top: 13px;
}
#logo a {
color: white;
text-decoration:none;
line-height: 51px;
}
#new_action {
position: absolute;
left: 200px;
top: 18px;
}
#global_live_search {
position: absolute;
top: 18px;
left: 350px;
display: block;
width: 200px;
z-index: 101;
}
a.areaLinkItem {
position: relative;
left: -3px;
display: block;
height: 80px;
width: 120px;
border: 1px solid transparent;
background: transparent url(../images/submenu_item_default.png) no-repeat center 15px;
}
a.areaLinkItem:hover {
background-color:#eeeeee;
border: 1px solid #ff0000;
}
a.areaLinkItem span {
font-weight: normal;
text-decoration: none;
position: absolute;
top: 50px;
font-size: 11px;
width: 120px;
text-align: center;
}
#searchContainer {
position: absolute;
top: -479px;
left: -15px;/*hack*/
width: 1024px;
height: 479px;
background: transparent url(../images/search.background.png) no-repeat 0px 0px;
z-index: 1000;
}
#searchScopes {
position: absolute;
left: 15px;
top: 0px;
width: 620px;
height: 100%;
}
#searchScopeSelectors {
position: absolute;
left: 636px;
top: -1px;
width: 375px;
height: 100%;
}
#searchScopeSelectors a {
position: relative;
display: block;
width: 375px;
height: 112px;
background: transparent url(../images/search.scope.background.jpg) no-repeat 0px 0px;
text-decoration:none;
color: #000000;
font-size: 16px;
}
#searchScopeSelectors a.active {
background-position:0px -112px;
}
#searchScopeSelectors a span {
position: relative;
display: block;
left: 110px;
top: 43px;
}
#searchScopes div.searchScope {
position: absolute;
display: none;
left: 30px;
right: 30px;
top: 40px;
bottom: 20px;
}
#searchScopes div.searchScopeActive {
display: block;
}
#contentContainer {
position: relative;
z-index: 10;
}
.message {
text-align:center;
top:0;
position:absolute;
line-height:20px;
font-size:20px;
width:936px;
padding:20px;
z-index:1000;
}
/* blue 30 130 230 */
.info_message {
background:#d3d6f9;
border:2px solid #2333e1;
color:#070b35;
}
/* yellow 30 130 230 */
.warning_message {
background:#f9f7d3;
border:2px solid #e1d723;
color:#353207;
}
/* red 30 130 230 */
.error_message {
background:#f9d3d3;
border:2px solid #e02424;
color:#350707;
}
.pagination {
float:right;
margin-top:2px;
}
.pagination a{
color: black;
margin-left:2px;
padding:2px 4px;
border:1px solid #DFDFDF;
text-decoration:none;
}
.pagination a:hover {
background: #DFDFDF;
}
.pagination a.highlight {
background: #DFDFDF;
}
a > img {
border: 0;
padding: 0;
margin: 0;
}
#rightMenu {
position: absolute;
margin: 0;
padding: 0;
z-index: 1000;
background: white;
border: 1px solid black;
border-bottom: none;
list-style: none;
}
#rightMenu li {
padding-left: 10px;
padding-right: 10px;
border-bottom: 1px solid black;
}
#rightMenu a {
line-height: 2em;
color: black;
text-decoration: none;
}
#rightMenu a:hover {
text-decoration: underline;
}
#sortable li {
margin:5px;
}
.content_box {
position: relative;
background:white;
padding:5px;
}
.content_box .header {
background:#7086B4;
margin:0;
padding:0;
}
.content_box .body {
margin-top:10px;
}
.content_box .footer {
position: absolute;
top: 140px;
width: 108px;
}
#sortable_images li {
margin:5px;
width:120px;
float:left;
border: 1px solid #cccccc;
height: 210px;
}
#sortable_attributes li {
margin:2px;
width:580px;
height:60px;
border: 1px solid #cccccc;
}
.edit_icon {
border: 0px solid #B5C0F9;
}
.edit_icon_bar {
position: absolute;
left: 390px;
top: 15px;
width: 200px;
height: 30px;
text-align: right;
}
#button_article_list_1 {
float: left;
width: 100px;
height: 110px;
cursor: pointer;
background: transparent url(../images/artikelliste_1_normal.png) no-repeat;
}
#button_article_list_1.active {
background: transparent url(../images/artikelliste_1_active.png) no-repeat;
}
#button_article_list_2 {
float: left;
width: 100px;
height: 110px;
margin-left:20px;
cursor: pointer;
background: transparent url(../images/artikelliste_2_normal.png) no-repeat;
}
#button_article_list_2.active {
background: transparent url(../images/artikelliste_2_active.png) no-repeat;
}
#button_article_list_3 {
float: left;
width: 100px;
height: 110px;
margin-left:20px;
cursor: pointer;
background: transparent url(../images/artikelliste_3_normal.png) no-repeat;
}
#button_article_list_3.active {
background: transparent url(../images/artikelliste_3_active.png) no-repeat;
}
#searchResultBox {
background: white;
position: absolute;
top: 56px;
width: 978px;
height: 407px;
margin: auto;
z-index: 100;
border: 1px solid #D9D9D9;
border-bottom: 5px solid #D9D9D9;
}
.search_box {
position:absolute;
top:5px;
width:235px;
margin:0;
padding:0;
display:inline-block;
}
.search_box h3 {
text-align: center;
color: white;
width:100%;
background:#7086B4;
}
.search_box a {
text-decoration: none;
}
ul.tabs {
border: 1px solid #D9D9D9;
padding: 0;
margin: 0;
}
ul.tabs li {
display: inline-block;
padding: 0;
margin: 0;
}
ul.tabs a {
font-size: 12px;
font-weight: bold;
text-decoration: none;
padding: 5px;
}
ul.tabs a:hover {
text-decoration: underline;
}
ul#definitions {
margin: 0;
padding: 0;
color: red;
margin-top: 10px;
}
ul#definitions li {
list-style: none;
margin: 0;
padding: 0;
}
ul#definitions span {
color: black;
}
.info_block {
background:#f9f9f9;
border:1px solid #f9f9f9;
margin-bottom:10px;
}
.info_block * {
margin: 5px;
}
.money {
text-align: right;
}
.date {
text-align: right;
}
.quantity {
text-align: center;
}
#banner_image {
width:550px;
}
dl.legend {
width: 312px;
}
dl.legend dt {
color: red;
clear: both;
float: left;
width: 140px;
display: inline-block;
margin-bottom: 5px;
}
dl.legend dd {
float: left;
text-align: left;
width: 130px;
margin-bottom: 5px;
}
.opinion_bull {
position: relative;
float: left;
margin-right:7px;
width: 12px;
height: 12px;
background: transparent url(./../images/opinion_bull.png) no-repeat;
}
.opinion_bull_active {
position: relative;
float: left;
margin-right:7px;
width: 12px;
height: 12px;
background: transparent url(./../images/opinion_bull_active.png) no-repeat;
}
.multilang_element input {
background: #ccccff;
}
#centerContainer {
position: relative;
min-height: 100%;
height: auto !important;
height: 100%;
margin: 0 auto -15px;
}
#adminLeftFloater {
float: left;
width: 100%;
min-height: 400px;
}
+32
View File
@@ -0,0 +1,32 @@
form.new input[type="submit"] {
clear:both;
position:relative;
width:200px;
margin-top: 10px;
margin-left: auto;
maring-right: auto;
border: 1px solid #B5C0F9;
color: #009900;
font-weight: bold;
}
form.new div {
margin-top: 5px;
margin-bottom: 5px;
}
form.new label {
color: #003580;
display: inline-block;
font-weight: bold;
line-height: 1em;
margin-bottom: 2px;
}
form.new input[type="text"] {
width: 100%;
border: 1px solid #B5C0F9;
color: #009900;
display: block;
font-weight: bold;
}
+451
View File
@@ -0,0 +1,451 @@
.object_list_table {
width: 100%;
border-collapse: collapse;
background: #ffffff;
}
.object_list_table thead {
background: #7086B4;
color: #ffffff;
font-weight: bold;
}
.object_list_table thead tr {
border: #000000 0px solid;
}
.object_list_table thead tr th, .object_list_table>tbody>tr>td, .object_list_table tfoot tr td {
padding: 2px;
}
.object_list_table thead tr th, .object_list_table>tbody>tr>td {
border: #9B9B9B 1px dotted;
border-width: 0px 1px;
text-align: left;
}
.object_list_table thead tr th.left, .object_list_table>tbody>tr>td.left {
text-align: left;
}
.object_list_table thead tr th.right, .object_list_table>tbody>tr>td.right {
text-align: right;
}
.object_list_table thead tr th .sortables {
min-width: 32px;
height: 16px;
padding-left: 32px;
display: inline;
background: transparent url(../images/audarisTools.table.sortables.png) no-repeat 0px 0px;
}
.object_list_table thead tr th .sortables.desc {
background-position: 0px -16px;
}
.object_list_table thead tr th .sortables.asc {
background-position: 0px -32px;
}
.object_list_table thead tr th.left .sortables {
padding-left: 0px;
padding-right: 32px;
background-position: right 0px;
}
.object_list_table thead tr th.left .sortables.desc {
background-position: right -16px;
}
.object_list_table thead tr th.left .sortables.asc {
background-position: right -32px;
}
.object_list_table>tbody>tr {
border: #000000 0px solid;
border-width: 0px 0px;
border-bottom: #9B9B9B 1px dotted;
}
.object_list_table>tbody>tr.lastChild {
border-bottom-width: 0px;
}
.object_list_table>tbody>tr.evenRow {
background: #DFE4ED;
}
.object_list_table>tbody>tr.hover {
background: #FFCC8D;
cursor: pointer;
}
.object_list_table tbody tr:hover {
background: #999999;
}
.object_list_item_activated {
background: #777777;
}
.object_list_table_filter {
position: relative;
width: 100%;
background: #dddddd;
}
.object_list_table_filter_wrapper {
position: relative;
height: 0px;
background: #dddddd;
overflow: hidden;
}
.filter_element_wrapper {
float:left;
height:40px;
width: 200px;
margin-left: 10px;
margin-right: 10px;
}
.filter_set {
border: 1px solid #999999;
color: #444444;
margin:10px;
}
.object_list_table_toolbar_title {
position: relative;
width: 100%;
height: 30px;
/*background: #90a6d4;*/
background-image: -webkit-linear-gradient(#e7e5de 0%, #d2d2d2 100%);
background-image: -moz-linear-gradient(#e7e5de 0%, #d2d2d2 100%);
background-image: -o-linear-gradient(#e7e5de 0%, #d2d2d2 100%);
background-image: linear-gradient(#e7e5de 0%, #d2d2d2 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#e7e5de', endColorstr='#d2d2d2');
}
.object_list_table_toolbar_title span {
position: absolute;
left: 10px;
top: 5px;
line-height: 18px;
font-weight: normal;
font-family: Arial;
font-size: 14px;
color:#604a48;
}
.quick_filter {
position: relative;
top: 0px;
left: 0px;
height: 30px;
width: 100%;
/*background: #dddddd;*/
background-image: -webkit-linear-gradient(#c0d6f4 0%, #90a6d4 100%);
background-image: -moz-linear-gradient(#c0d6f4 0%, #90a6d4 100%);
background-image: -o-linear-gradient(#c0d6f4 0%, #90a6d4 100%);
background-image: linear-gradient(#c0d6f4 0%, #90a6d4 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#c0d6f4', endColorstr='#90a6d4');
}
.quick_filter span {
text-decoration: none;
padding-left: 20px;
line-height: 30px;
color: #555555;
cursor: pointer;
}
.quick_filter span:hover, .quick_filter span.active {
color: #000000;
}
.object_list_table_toolbar {
position: relative;
width: 100%;
height: 30px;
background: #B6B6B6;
border: 0px solid #333333;
border-bottom: 0px;
}
.object_list_table_toolbar a {
position: relative;
display: block;
float: left;
height: 20px;
top: 5px;
margin-left: 15px;
text-decoration: none;
border: 1px solid transparent;
}
.object_list_table_toolbar a:hover {
background: #8096c4;
border: 1px solid #333333;
}
.object_list_table_toolbar a span {
text-decoration: none;
color: #000000;
line-height: 20px;
font-size: 11px;
text-decoration: none;
padding-left: 2px;
}
.object_list_table_toolbar img {
position: relative;
top: 3px;
}
.object_list_table_toolbar a.inactive {
border: 1px solid transparent;
background: transparent;
cursor: default;
}
.object_list_table_toolbar a.inactive span {
color: #555555;
}
.object_list_table_toolbar .search_wrapper {
position: absolute;
right: 20px;
top: 6px;
}
.object_list_table_toolbar .search_wrapper input {
position: relative;
width: 200px;
height: 16px;
border: 1px solid #000000;
}
.object_list_table_toolbar .search_wrapper input.search_button {
position: relative;
top: 1px;
width: 50px;
height: 20px;
border: 1px solid #000000;
}
.object_list_col_header {
background: #eeeeee;
color: #444444;
font-weight: normal;
text-align: left;
}
.object_list_col_sortable {
cursor: pointer;
}
.object_list_table_footer {
position: relative;
width: 100%;
height: 30px;
/*background: #90a6d4;*/
background-image: -webkit-linear-gradient(#e7e5de 0%, #d2d2d2 100%);
background-image: -moz-linear-gradient(#e7e5de 0%, #d2d2d2 100%);
background-image: -o-linear-gradient(#e7e5de 0%, #d2d2d2 100%);
background-image: linear-gradient(#e7e5de 0%, #d2d2d2 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#e7e5de', endColorstr='#d2d2d2');
border: 0px solid #333333;
border-top: 0px;
}
.object_list_table_footer .items_per_page_wrapper {
position: absolute;
top: 5px;
left: 20px;
color:#000000;
}
.object_list_table_footer .pagination_wrapper {
position: absolute;
top: 5px;
right: 20px;
}
.object_list_table_footer .object_list_stats {
position: absolute;
top: 7px;
left: 300px;
color:#000000;
}
.object_list_table_action {
position: relative;
width: 100%;
background: #dddddd;
}
.object_list_table_action_wrapper {
position: relative;
height: 0px;
background: #dddddd;
overflow: hidden;
}
.action_element_wrapper {
float:left;
height:40px;
width: 200px;
margin-left: 10px;
margin-right: 10px;
}
.action_set {
border: 1px solid #999999;
color: #444444;
margin:10px;
}
.list_action_button {
border: 1px solid #999999;
color: #444444;
height: 37px;
width: 250px;
cursor: default;
}
.list_action_button_active {
border: 1px solid #000000;
color: #000000;
cursor: pointer;
}
/* order list css */
.order_list_item_price {
display: block;
width: 95%;
text-align: right;
}
.order_list_item_inventar_0 {
display: block;
width: 100%;
height: 100%;
}
.order_list_item_inventar_1 {
display: block;
width: 100%;
height: 100%;
background: #00ff00;
}
.orders_order_status_Neu {
font-weight:bold;
}
.orders_order_status_Storno {
font-weight:bold;
color: #ff0000;
}
/* ticket list css */
.tickets_state_id_Neu {
font-weight:bold;
}
/* object edit */
.object_edit_table_toolbar {
position: relative;
width: 100%;
height: 30px;
background: #b6b6b6;
border: 0px solid #333333;
border-bottom: 0px;
}
.object_edit_table_toolbar a {
position: relative;
display: block;
float: left;
height: 20px;
top: 5px;
margin-left: 15px;
text-decoration: none;
border: 1px solid transparent;
}
.object_edit_table_toolbar a:hover {
background: #8096c4;
border: 1px solid #333333;
}
.object_edit_table_toolbar a span {
text-decoration: none;
color: #000000;
line-height: 20px;
font-size: 11px;
text-decoration: none;
padding-left: 2px;
}
.object_edit_table_toolbar img {
position: relative;
top: 3px;
}
.object_edit_table_toolbar a.inactive {
border: 1px solid transparent;
background: transparent;
cursor: default;
}
.object_edit_table_toolbar a.inactive span {
color: #555555;
}
.object_edit_table_toolbar .language_wrapper {
position: absolute;
right: 20px;
top: 5px;
line-height: 20px;
}
.object_edit_table_toolbar .language_wrapper select {
position: relative;
width: 150px;
height: 20px;
}
.object_list_table_view {
position: relative;
width: 100%;
background: #dddddd;
}
.object_list_table_view_wrapper {
position: relative;
height: 0px;
background: #dddddd;
overflow: hidden;
}
.filter_element_view {
float:left;
height:40px;
width: 200px;
margin-left: 10px;
margin-right: 10px;
}
.view_set {
border: 1px solid #999999;
color: #444444;
margin:10px;
}
.pagination {
float:right;
margin-top:2px;
color:#000000;
}
.pagination a{
color: black;
margin-left:2px;
padding:2px 4px;
border:1px solid #000000;
text-decoration:none;
}
.pagination a:hover {
background: #000000;
}
.pagination a.highlight {
background: #000000;
}
.pagination a {
color: #000000;
}
+103
View File
@@ -0,0 +1,103 @@
table.order {
width: 100%;
}
table.order .buttons {
width: 62px;
padding-right: 5px;
}
table.order .pos {
width: 36px;
padding-right: 5px;
}
table.order .quantity {
width: 36px;
padding-right: 5px;
}
table.order .item {
font-weight: bold;
padding-right: 5px;
}
table.order .price {
width: 70px;
padding-right: 5px;
}
table.order .price > input {
width: 54px;
display: inline-block;
}
table.order .tax_rate {
width: 45px;
padding-right: 5px;
}
.total {
text-align: right;
}
table.order .total {
width: 75px;
}
table.order .total span {
display: inline-block;
}
span.total {
float: right;
}
td.inventory label, td.inventory {
float: right;
}
td.inventory span {
display: inline-block;
width: 100%;
text-align: center;
}
table.order .item div {
font-size: 0.8em;
font-weight: normal;
color: #727272;
}
.button {
cursor: pointer;
}
.drop_box {
display: none;
}
.drop_box.open {
display: table-row;
}
.drop_box_button {
display: inline-block;
width: 16px;
height: 16px;
}
.drop_box_button.down {
background: url(../images/edit_form_close.png) no-repeat;
}
.drop_box_button.up {
background: url(../images/edit_form_open.png) no-repeat;
}
.delete_button {
display: inline-block;
width: 16px;
height: 16px;
background: url(../images/delete.png) no-repeat;
}
+87
View File
@@ -0,0 +1,87 @@
.rsTabs {
position: relative;
}
.rsTabs_Header {
height: 30px;
background: transparent url(../images/rsTabs.head.shadow.jpg) no-repeat right bottom;
}
.rsTabs_Body {
position: relative;
border: #d9d9d9 1px solid;
z-index: 2;
padding: 15px;
background: #ffffff;
/*background: transparent url(images/rsTabs.body.background.png) repeat-x left top;*/
}
.rsTabs_BodyElement {
display: none;
}
.rsTabs_BodyElementActive {
display: block;
}
.rsTabs_HeaderTab {
position: relative;
height: 30px;
border: #d9d9d9 1px solid;
border-bottom-width: 0px;
bottom: 0px;
text-align: center;
padding: 0px 15px;
background: #EEEEEE;
cursor: pointer;
z-index: 1;
color: #003580;
}
.rsTabs_HeaderTab:hover {
background: #CCCCCC;
}
.rsTabs_HeaderTabActive, .rsTabs_HeaderTabActive:hover {
background: #ffffff;
cursor: default;
z-index: 3;
}
.rsTabs_HeaderTab span {
position: relative;
top: 9px;
font-weight: bold;
font-size: 12px;
}
.rsTabs_HeaderTab .error {
position: absolute;
top: -25px;
width: 80px;
height: 31px;
background: transparent url(../images/rsTabs.head.error.png) no-repeat 0px 0px;
color: #ffffff;
font-weight: bold;
padding-top: 1px;
}
.rsToolsToolTip {
position: absolute;
width: 287px;
z-index: 9999;
}
.rsToolsToolTipHeader {
height: 25px;
background: transparent url(../images/rsTools.Tooltip.sprites.png) no-repeat 0px 0px;
}
.rsToolsToolTipContent {
background: transparent url(../images/rsTools.Tooltip.sprites.png) repeat-y -574px 0px;
color: #ffffff;
text-align: left;
font-weight: bold;
padding: 0px 15px;
}
.rsToolsToolTipFooter {
height: 25px;
background: transparent url(../images/rsTools.Tooltip.sprites.png) no-repeat -287px 0px;
}
.rsTabs_HeaderTab a {
text-decoration: none;
}
.rsTabs_HeaderTab a:hover {
color: #003580;
}
+53
View File
@@ -0,0 +1,53 @@
.search_text_box {
width: 200px;
}
.checkbox_container {
width: 350px;
display: inline-block;
}
div.search_list {
position:relative;
width: 200px;
height: 250px;
display: inline-block;
border: 1px solid #7086B4;
}
div.search_list label {
background: #7086B4;
color: white;
width: 100%;
display: inline-block;
line-height: 20px;
text-align: center;
position: absolute;
top: 0;
}
div.search_list ul {
list-style-type: none;
}
/*div.search_list ul.nested ul {
display:none;
}*/
div.search_list > ul {
position: absolute;
top:20px;
bottom:20px;
margin:0px;
padding:0px;
overflow:auto;
width:200px;
}
div.search_list > ul > li {
margin-left:5px;
}
div.search_list input {
border-left: none;
border-right: none;
border-bottom: none;
border-top:1px solid #7086B4;
width: 200px;
height:20px;
margin: 0 auto;
position: absolute;
bottom: 0;
}
+45
View File
@@ -0,0 +1,45 @@
.statistics {
width: 100%;
font-size: 12px;
}
.statistics thead{
background: #eeeeee;
}
.statistics th {
font-size: 1.2em;
padding: 5px;
}
.statistics tr.top td {
padding-top: 5px;
}
.statistics tr.bottom {
border-bottom: 1px solid black;
}
.statistics tr.bottom td {
padding-bottom: 5px;
}
.selection {
display:inline-block;
width:150px;
margin-right:10px;
margin-bottom:10px;
}
.selection select {
width:100%;
}
.year {
float: right;
padding-right: 10px;
}
.money {
text-align: right;
}
+133
View File
@@ -0,0 +1,133 @@
/* audaris tools table */
.audarisToolsTable {
width: 100%;
border-collapse: collapse;
}
.audarisToolsTable thead {
background: #7086B4;
color: #ffffff;
font-weight: bold;
}
.audarisToolsTable thead tr {
border: #000000 1px solid;
}
.audarisToolsTable thead tr th, .audarisToolsTable>tbody>tr>td, .audarisToolsTable tfoot tr td {
padding: 5px;
}
.audarisToolsTable thead tr th, .audarisToolsTable>tbody>tr>td {
border: #9B9B9B 1px dotted;
border-width: 0px 1px;
text-align: center;
}
.audarisToolsTable thead tr th.left, .audarisToolsTable>tbody>tr>td.left {
text-align: left;
}
.audarisToolsTable thead tr th.right, .audarisToolsTable>tbody>tr>td.right {
text-align: right;
}
.audarisToolsTable thead tr th .sortables {
min-width: 32px;
height: 16px;
padding-left: 32px;
display: inline;
background: transparent url(../images/audarisTools.table.sortables.png) no-repeat 0px 0px;
}
.audarisToolsTable thead tr th .sortables.desc {
background-position: 0px -16px;
}
.audarisToolsTable thead tr th .sortables.asc {
background-position: 0px -32px;
}
.audarisToolsTable thead tr th.left .sortables {
padding-left: 0px;
padding-right: 32px;
background-position: right 0px;
}
.audarisToolsTable thead tr th.left .sortables.desc {
background-position: right -16px;
}
.audarisToolsTable thead tr th.left .sortables.asc {
background-position: right -32px;
}
.audarisToolsTable>tbody>tr {
border: #000000 1px solid;
border-width: 0px 1px;
}
.audarisToolsTable>tbody>tr.lastChild {
border-bottom-width: 1px;
}
.audarisToolsTable>tbody>tr.evenRow {
background: #DFE4ED;
}
.audarisToolsTable>tbody>tr.hover {
background: #FFCC8D;
cursor: pointer;
}
.audarisToolsTable>tbody>tr>td.boolean_true {
background: transparent url(../images/audarisTools.table.boolean_true.png) no-repeat center center;
}
.audarisToolsTable>tbody>tr>td.boolean_false {
background: transparent url(../images/audarisTools.table.boolean_false.png) no-repeat center center;
}
.audarisToolsTable>tbody>tr.active {
background: #FFCC8D;
border: #7086B4 2px solid;
border-bottom-width: 0px;
}
.audarisToolsTable>tbody>tr.rowDetails {
background: #E1DAC9;
border: #7086B4 2px solid;
border-top-width: 0px;
}
.audarisToolsTable>tbody>tr.rowDetails>td {
text-align: left;
padding: 0px;
}
.audarisToolsTable tbody {
border-bottom: 1px solid black;
}
.audarisToolsTable tfoot tr td .left {
float: left;
}
.audarisToolsTable tfoot tr td .right {
float: right;
}
.audarisToolsTable .red {
}
.audarisToolsTable .orange {
}
.audarisToolsTable .green {
}
.audarisToolsTable tr.disabled {
background:#f2f2f2;
}
.action_icon {
margin-right:5px;
}
.audarisToolsTable tr.locked {
color:red;
}
.audarisToolsTable th a {
color: white;
text-decoration: none;
}
.audarisToolsTable th a:hover {
text-decoration: underline;
}
.audarisToolsTable tbody tr:hover {
background: #f9f9f9;
}
/* template specific talbes */
#order_editor_history_talbe {
width: 400px;
}
+80
View File
@@ -0,0 +1,80 @@
/* widget containers */
.widget_container {
list-style:none;
margin:0;
padding:0;
margin-bottom:80px;
min-height:60px;
}
.widget_container:hover {
background: #eee;
}
#big_widget_container {
width: 650px;
display: inline-block;
float: left;
}
#little_widget_container {
width: 320px;
display: inline-block;
float: right;
}
/* little widget */
div.little_widget {
width:318px;
border:1px solid #DFDFDF;
margin-bottom:10px;
background:white;
}
div.little_widget_title_bar {
background:#7086B4;
color:white;
width:318px;
position:relative;
height:27px;
line-height:27px;
width:100%;
}
div.little_widget_title_bar span{
margin-left:10px;
}
div.little_widget_content {
width:298px;
margin:10px;
}
div.little_widget_content table {
width:100%;
}
div.little_widget_content table thead {
background:#DFDFDF;
}
/* big widget */
div.big_widget {
width:648px;
border:1px solid #DFDFDF;
margin-bottom:10px;
background:white;
}
div.big_widget_title_bar {
background:#7086B4;
color:white;
width:648px;
position:relative;
height:27px;
line-height:27px;
width:100%;
}
div.big_widget_title_bar span{
margin-left:10px;
}
div.big_widget_content {
width:628px;
margin:10px;
}
div.big_widget_content table {
width:100%;
}
div.big_widget_content table thead {
background:#DFDFDF;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 129 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 731 B

@@ -0,0 +1,92 @@
$(document).ready(function() {
$('#adminLeftFloater').rsTabs();
var id = $('input[name="form_field[id]"]').val();
$('#header_upload').uploadify({
'uploader' : '../../../../libs/uploadify/uploadify.swf',
'script' : './index.php?admin_modul=admin_manufacturer_editor',
'cancelImg' : '../../../../libs/uploadify/cancel.png',
'folder' : '../../../../libs/uploads',
'scriptData' : {
'action' : 'upload',
'id' : id
},
'fileDesc' : 'Web Image Files',
'buttonText': 'Logo',
'auto' : true,
'multi' : false,
'onComplete': function(event, ID, fileObj, response, data) {
try {
var mydata = $.parseJSON(response);
if (mydata.status == 'success') {
window.location.href = './index.php?admin_modul=admin_manufacturer_editor&id=' + id;
} else {
$().easywayShopMessageDialog("ERROR: can't save", {}, function(){
$().easywayShopMessageDialog(mydata.data, {}, function(){});
});
}
} catch(e) {
$().easywayShopMessageDialog("ERROR: can't upload", {}, function(){});
}
}
});
$('#delete_logo').click(function(e) {
e.preventDefault();
var url = $(this).attr('href');
var jqxhr = $.ajax({
'url': url
});
jqxhr.success(function(data) {
try {
data = $.parseJSON(data);
if (data.status == 'success') {
window.location.reload();
} else {
$().easywayShopMessageDialog("ERROR: no save'", {}, function(){});
}
} catch(e) {
$().easywayShopMessageDialog("ERROR: wrong answer", {}, function(){});
}
});
jqxhr.error(function() {
$().easywayShopMessageDialog("ERROR", {}, function(){});
});
});
$('form').submit(function(e) {
e.preventDefault();
var url = $(this).attr('action');
var data = $(this).serialize();
var jqxhr = $.ajax({
url: url,
data: data,
type: 'POST'
});
jqxhr.success(function(data) {
try {
data = $.parseJSON(data);
if (data.status == 'success') {
window.location.href = './index.php?admin_modul=admin_manufacturer_editor&id=' + data.id;
} else {
$().easywayShopMessageDialog("ERROR: no save'", {}, function(){});
}
} catch(e) {
$().easywayShopMessageDialog("ERROR: wrong answer", {}, function(){});
}
});
jqxhr.error(function() {
$().easywayShopMessageDialog("ERROR: no answer", {}, function(){});
});
});
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 899 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 612 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 435 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 513 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 632 B

Some files were not shown because too many files have changed in this diff Show More