Compare commits

...
51 Commits
Author SHA1 Message Date
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
354 changed files with 38952 additions and 2841 deletions
+5
View File
@@ -3,6 +3,11 @@ 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 ===
+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";
+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,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;
}
}
@@ -1,145 +0,0 @@
<?php
declare(strict_types=1);
/**
* Zahlungsart "PayPal Checkout (Orders v2 / REST)".
*
* Neues, eigenstaendiges Modul – laeuft parallel zum Legacy-NVP-Modul
* (PaypalPaymentMethod) und teilt sich mit ihm KEINEN Code.
*
* Aktivierung ueber einen eigenen Eintrag in `payment_methods` mit
* module = 'paypalrest'. Der Cashdesk instanziiert dann automatisch diese
* Klasse (ucfirst('paypalrest').'PaymentMethod' => PaypalrestPaymentMethod).
*
* Flow: after_order() legt die PayPal-Order an und leitet den Kunden zur
* Approve-URL weiter. Der Rueckweg laeuft NICHT ueber den Cashdesk (der ist auf
* das Legacy-Modul verdrahtet), sondern ueber /core/paypal_v2/return.php.
*/
require_once __DIR__ . '/../paypal_v2/Config.php';
require_once __DIR__ . '/../paypal_v2/PayPalClient.php';
require_once __DIR__ . '/../paypal_v2/Orders.php';
require_once __DIR__ . '/../paypal_v2/Gateway.php';
class PaypalrestPaymentMethod
{
private $base_object;
private $layout_object;
private $customer_data;
private $shipping_address;
private $billing_address;
private $shoppingcart;
private $payment_method;
private $shipping_method;
private $gift_certificate;
private $order_id;
public function __construct($base_object, $layout_object)
{
$this->base_object = $base_object;
$this->layout_object = $layout_object;
$this->customer_data = false;
$this->shipping_address = false;
$this->billing_address = false;
$this->shoppingcart = false;
$this->payment_method = false;
$this->shipping_method = false;
$this->gift_certificate = false;
$this->order_id = false;
}
public function set_data($customer, $shipping, $billing, $shoppingcart, $delivery, $payment, $order_id = false)
{
$this->customer_data = $customer;
$this->shipping_address = $shipping;
$this->billing_address = $billing;
$this->shoppingcart = $shoppingcart;
$this->payment_method = $delivery;
$this->shipping_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;
}
/** Vom Cashdesk vor dem Speichern aufgerufen. Kein Fehler => leere Rueckgabe. */
public function before_order($argument = null)
{
return '';
}
public function during_order($argument = null)
{
return '';
}
/**
* Nach dem Speichern des Auftrags: PayPal-Order anlegen und weiterleiten.
*
* @param float|false $order_total Bruttosumme des Auftrags
* @return string HTML-Fallback (nur, wenn keine Weiterleitung moeglich war)
*/
public function after_order($order_total = false)
{
$charged = (float) $order_total;
// Gutschein wie im Legacy-Modul vom zu zahlenden Betrag abziehen
if ($this->gift_certificate && isset($this->gift_certificate->with_vat)) {
$charged -= (float) $this->gift_certificate->with_vat;
}
try {
$gateway = new \PaypalV2\Gateway($this->base_object);
$order_number = $this->resolve_order_number((int) $this->order_id);
$approve_url = $gateway->createOrderAndGetApprovalUrl((int) $this->order_id, $charged, $order_number);
} catch (\Throwable $e) {
\error_log('PayPal v2 after_order: ' . $e->getMessage());
return $this->render_error('Beim Initialisieren von PayPal ist ein Fehler aufgetreten.');
}
// Bevorzugt harte Weiterleitung (an dieser Stelle sind noch keine Header gesendet).
if (!\headers_sent()) {
\header('Location: ' . $approve_url);
exit;
}
// Fallback: Button/Meta-Refresh im Template
$this->layout_object->assign('call', 'payment_open');
$this->layout_object->assign('paypal_link', $approve_url);
return $this->layout_object->_fetch('content_cash_desk/paypalrest.tpl');
}
/** Wird vom Cashdesk fuer dieses Modul NICHT aufgerufen (Return laeuft ueber return.php). */
public function change_order()
{
return '';
}
// ---------------------------------------------------------------- intern
private function resolve_order_number(int $order_id): string
{
$db = $this->base_object->db;
$res = $db->query('SELECT order_number FROM orders WHERE id=' . $order_id . ' LIMIT 1');
if ($res && $res->num_rows > 0) {
return (string) $res->fetch_object()->order_number;
}
return (string) $order_id;
}
private function render_error(string $message): string
{
$this->layout_object->assign('call', 'payment_error');
$this->layout_object->assign('message', $message);
return $this->layout_object->_fetch('content_cash_desk/paypalrest.tpl');
}
}
+521
View File
@@ -0,0 +1,521 @@
<?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;
}
$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;
}
}
+32
View File
@@ -0,0 +1,32 @@
<?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);
+106
View File
@@ -0,0 +1,106 @@
<?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 FROM payment_methods WHERE module='" . $base_object->db->real_escape_string($m) . "'");
$pm = $res ? $res->fetch_object() : null;
echo "\n[$m] " . $p->label() . ($p instanceof \Payments\Providers\PaypalV2\Provider ? ' Modus ' . $p->mode() : '') . "\n";
echo ' Zahlart: ' . ($pm ? "id {$pm->id} '{$pm->name}' " . ($pm->active ? 'aktiv' : 'inaktiv') : '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;
+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);
@@ -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;
}
}
-14
View File
@@ -1,14 +0,0 @@
# Nur die beiden Endpunkte duerfen direkt aufgerufen werden.
# Klassen/Bootstrap werden ausschliesslich per PHP-include genutzt.
<FilesMatch "\.php$">
Require all denied
</FilesMatch>
<FilesMatch "^(return|webhook)\.php$">
Require all granted
</FilesMatch>
# Token-Cache o.ae. niemals ausliefern
<FilesMatch "^\.">
Require all denied
</FilesMatch>
-99
View File
@@ -1,99 +0,0 @@
<?php
declare(strict_types=1);
namespace PaypalV2;
/**
* Laedt die geschuetzte PayPal-v2-Konfiguration von ausserhalb des Webroots.
*/
final class Config
{
/** Kandidaten-Pfade fuer die Config (erster lesbarer gewinnt). */
private const PATHS = [
'/var/www/vhosts/intelectra.de/import/paypal_v2/config.php',
];
private static ?array $data = null;
private static ?string $resolvedPath = null;
public static function load(): array
{
if (self::$data !== null) {
return self::$data;
}
foreach (self::PATHS as $path) {
if (is_file($path) && is_readable($path)) {
$cfg = include $path;
if (!is_array($cfg)) {
throw new \RuntimeException('PayPal v2: Config liefert kein Array: ' . $path);
}
self::$data = $cfg;
self::$resolvedPath = $path;
return self::$data;
}
}
throw new \RuntimeException('PayPal v2: Keine lesbare Config-Datei gefunden.');
}
public static function configPath(): string
{
self::load();
return (string) self::$resolvedPath;
}
/** Verzeichnis der Config – dient auch als Ablage fuer den Token-Cache. */
public static function dir(): string
{
return \dirname(self::configPath());
}
public static function mode(): string
{
$cfg = self::load();
return (($cfg['mode'] ?? 'sandbox') === 'live') ? 'live' : 'sandbox';
}
/** @return array{client_id:string,secret:string,webhook_id:string} */
public static function creds(): array
{
$cfg = self::load();
$mode = self::mode();
$c = $cfg[$mode] ?? [];
$clientId = (string) ($c['client_id'] ?? '');
$secret = (string) ($c['secret'] ?? '');
if ($clientId === '' || $secret === '' || \str_starts_with($clientId, 'HIER_')) {
throw new \RuntimeException('PayPal v2: Zugangsdaten fuer Modus "' . $mode . '" fehlen oder sind Platzhalter.');
}
return [
'client_id' => $clientId,
'secret' => $secret,
'webhook_id' => (string) ($c['webhook_id'] ?? ''),
];
}
public static function apiBase(): string
{
return self::mode() === 'live'
? 'https://api-m.paypal.com'
: 'https://api-m.sandbox.paypal.com';
}
public static function webhookId(): string
{
$cfg = self::load();
$mode = self::mode();
return (string) ($cfg[$mode]['webhook_id'] ?? '');
}
/** @return mixed */
public static function get(string $key, $default = null)
{
$cfg = self::load();
return $cfg[$key] ?? $default;
}
}
-250
View File
@@ -1,250 +0,0 @@
<?php
declare(strict_types=1);
namespace PaypalV2;
/**
* Bindeglied zwischen PayPal-Orders-v2 und dem Shop-Datenmodell.
*
* Zentralisiert:
* - Order-Anlage + Ablage der PayPal-Order-ID (in orders.paypal_token wiederverwendet)
* - Capture inkl. Betrags-/Waehrungsabgleich (Manipulationsschutz)
* - idempotentes Setzen des Zahlstatus (F5, Doppel-Return, Webhook-Race)
*
* Erwartet das Shop-"base"-Objekt ($base->db = mysqli).
*/
final class Gateway
{
/** @var object Shop-base-Objekt mit ->db (mysqli) */
private object $base;
public function __construct(object $base)
{
$this->base = $base;
}
/**
* Legt eine PayPal-Order an, speichert die Order-ID am Shop-Auftrag und
* gibt die Approve-URL zurueck, zu der der Kunde weitergeleitet wird.
*/
public function createOrderAndGetApprovalUrl(int $shopOrderId, float $charged, string $orderNumber): string
{
$shopBase = \rtrim((string) Config::get('shop_base_url', 'https://' . ($_SERVER['SERVER_NAME'] ?? '')), '/');
$returnUrl = $shopBase . '/core/paypal_v2/return.php';
$cancelUrl = $shopBase . '/core/paypal_v2/return.php?cancel=1';
$orders = new Orders();
$res = $orders->create($charged, $orderNumber, (string) $shopOrderId, $returnUrl, $cancelUrl);
// PayPal-Order-ID am Auftrag ablegen (vorhandene Spalte wiederverwendet)
$order = new \Order($this->base);
$order->set_order_paypal_token($res['id'], $shopOrderId);
// Exakt berechneten Betrag fuer den spaeteren Abgleich sichern (best effort;
// funktioniert nur, wenn die Spalte paypal_v2_expected_amount existiert).
$this->storeExpectedAmount($shopOrderId, $charged);
return $res['approve_url'];
}
/**
* Schliesst die Zahlung anhand der PayPal-Order-ID ab (Return-Flow).
*
* @return array{ok:bool,order_number:string,order_id:int,message:string}
*/
public function finalizeByPaypalOrderId(string $paypalOrderId): array
{
$shopOrder = $this->findOrderByToken($paypalOrderId);
if ($shopOrder === null) {
return $this->result(false, '', 0, 'Bestellung zu dieser Zahlung nicht gefunden.');
}
$orderId = (int) $shopOrder->id;
// Bereits bezahlt? -> idempotent OK
if ((string) $shopOrder->payment_status === '2') {
return $this->result(true, (string) $shopOrder->order_number, $orderId, 'bereits bezahlt');
}
$cap = (new Orders())->capture($paypalOrderId);
if ($cap['capture_status'] !== 'COMPLETED') {
return $this->result(
false,
(string) $shopOrder->order_number,
$orderId,
'Zahlung nicht abgeschlossen (' . ($cap['capture_status'] ?: $cap['order_status']) . ').'
);
}
if (!$this->amountMatches($shopOrder, $cap['amount'], $cap['currency'], $orderId, $paypalOrderId)) {
return $this->result(false, (string) $shopOrder->order_number, $orderId, 'Betrag stimmt nicht ueberein.');
}
$this->markPaid($orderId, $cap);
return $this->result(true, (string) $shopOrder->order_number, $orderId, 'ok');
}
/**
* Bestaetigt eine Zahlung aus einem verifizierten Webhook-Event
* (PAYMENT.CAPTURE.COMPLETED). Idempotent.
*
* @param array<mixed> $resource Das "resource"-Objekt des Webhook-Events (die Capture)
*/
public function confirmFromWebhookCapture(array $resource): bool
{
$shopOrderId = (int) ($resource['custom_id'] ?? 0);
if ($shopOrderId <= 0) {
return false;
}
$shopOrder = $this->findOrderById($shopOrderId);
if ($shopOrder === null) {
return false;
}
if ((string) $shopOrder->payment_status === '2') {
return true; // schon bezahlt
}
if (($resource['status'] ?? '') !== 'COMPLETED') {
return false;
}
$amount = (string) ($resource['amount']['value'] ?? '');
$currency = (string) ($resource['amount']['currency_code'] ?? '');
if (!$this->amountMatches($shopOrder, $amount, $currency, $shopOrderId, 'webhook')) {
return false;
}
$this->markPaid($shopOrderId, [
'capture_id' => (string) ($resource['id'] ?? ''),
'payer_email' => '',
'payer_id' => '',
]);
return true;
}
// ----------------------------------------------------------------- intern
/** @param array{capture_id?:string,payer_email?:string,payer_id?:string} $cap */
private function markPaid(int $orderId, array $cap): void
{
$db = $this->base->db;
$order = new \Order($this->base);
// payment_status = 2 (bezahlt) – identisch zur Legacy-Semantik
$order->set_billing_status('2', $orderId);
$order->set_payment_date(\date('Y-m-d'), $orderId);
$capId = $db->real_escape_string((string) ($cap['capture_id'] ?? ''));
$email = $db->real_escape_string((string) ($cap['payer_email'] ?? ''));
$payer = $db->real_escape_string((string) ($cap['payer_id'] ?? ''));
$sql = "UPDATE orders SET paypal_transaction_id='$capId'";
if ($email !== '') {
$sql .= ", paypal_email='$email'";
}
if ($payer !== '') {
$sql .= ", paypal_payerid='$payer'";
}
$sql .= ' WHERE id=' . $orderId;
$db->query($sql);
}
/**
* Prueft, ob der gezahlte Betrag zum erwarteten Betrag des Auftrags passt.
* Erwartet wird primaer paypal_v2_expected_amount, ersatzweise orders.order_total.
*/
private function amountMatches(object $shopOrder, string $paidAmount, string $paidCurrency, int $orderId, string $ref): bool
{
$expectedRaw = $shopOrder->paypal_v2_expected_amount ?? null;
if ($expectedRaw === null || $expectedRaw === '') {
$expectedRaw = $shopOrder->order_total;
}
$expected = \number_format((float) $expectedRaw, 2, '.', '');
$paid = \number_format((float) $paidAmount, 2, '.', '');
$currency = (string) Config::get('currency', 'EUR');
$ok = ($paid === $expected) && (\strtoupper($paidCurrency) === \strtoupper($currency));
if (!$ok) {
\error_log(
"PayPal v2 BETRAGS-MISMATCH (order $orderId, ref $ref): "
. "erwartet $expected $currency, erhalten $paid $paidCurrency"
);
}
return $ok;
}
private function findOrderByToken(string $paypalOrderId): ?object
{
$db = $this->base->db;
$token = $db->real_escape_string($paypalOrderId);
$res = $db->query(
"SELECT id, order_number, order_total, payment_status, "
. $this->expectedAmountSelect()
. " FROM orders WHERE paypal_token = '$token' LIMIT 1"
);
return ($res && $res->num_rows > 0) ? $res->fetch_object() : null;
}
private function findOrderById(int $orderId): ?object
{
$db = $this->base->db;
$res = $db->query(
"SELECT id, order_number, order_total, payment_status, "
. $this->expectedAmountSelect()
. " FROM orders WHERE id = $orderId LIMIT 1"
);
return ($res && $res->num_rows > 0) ? $res->fetch_object() : null;
}
/**
* Liefert das SELECT-Fragment fuer den erwarteten Betrag. Existiert die
* optionale Spalte nicht, wird NULL selektiert (Fallback auf order_total).
*/
private function expectedAmountSelect(): string
{
return $this->columnExists('paypal_v2_expected_amount')
? 'paypal_v2_expected_amount'
: 'NULL AS paypal_v2_expected_amount';
}
private function storeExpectedAmount(int $orderId, float $charged): void
{
if (!$this->columnExists('paypal_v2_expected_amount')) {
return;
}
$db = $this->base->db;
$val = \number_format($charged, 2, '.', '');
$db->query("UPDATE orders SET paypal_v2_expected_amount='$val' WHERE id=$orderId");
}
/** @var array<string,bool> */
private static array $colCache = [];
private function columnExists(string $column): bool
{
if (isset(self::$colCache[$column])) {
return self::$colCache[$column];
}
$db = $this->base->db;
$col = $db->real_escape_string($column);
$res = $db->query("SHOW COLUMNS FROM orders LIKE '$col'");
return self::$colCache[$column] = ($res && $res->num_rows > 0);
}
/**
* @return array{ok:bool,order_number:string,order_id:int,message:string}
*/
private function result(bool $ok, string $orderNumber, int $orderId, string $message): array
{
return ['ok' => $ok, 'order_number' => $orderNumber, 'order_id' => $orderId, 'message' => $message];
}
}
-152
View File
@@ -1,152 +0,0 @@
<?php
declare(strict_types=1);
namespace PaypalV2;
/**
* Kapselt die Orders-v2-Endpunkte: create / get / capture.
*/
final class Orders
{
private PayPalClient $client;
public function __construct(?PayPalClient $client = null)
{
$this->client = $client ?? new PayPalClient();
}
/**
* Erstellt eine PayPal-Order (intent=CAPTURE) und liefert Order-ID + Approve-URL.
*
* @return array{id:string,approve_url:string}
*/
public function create(
float $amount,
string $orderNumber,
string $shopOrderId,
string $returnUrl,
string $cancelUrl
): array {
$value = \number_format($amount, 2, '.', '');
$currency = (string) Config::get('currency', 'EUR');
$payload = [
'intent' => 'CAPTURE',
'purchase_units' => [[
'amount' => [
'currency_code' => $currency,
'value' => $value,
],
'custom_id' => $shopOrderId,
'invoice_id' => $orderNumber . '-' . $shopOrderId,
]],
'application_context' => [
'brand_name' => (string) Config::get('brand_name', 'Shop'),
'locale' => 'de-DE',
'landing_page' => 'NO_PREFERENCE',
'shipping_preference' => 'NO_SHIPPING',
'user_action' => 'PAY_NOW',
'return_url' => $returnUrl,
'cancel_url' => $cancelUrl,
],
];
// Idempotenz: gleicher Request-Id => PayPal legt keine Doppel-Order an.
$requestId = 'create-' . $shopOrderId . '-' . \substr(\hash('sha256', $orderNumber . '|' . $value), 0, 16);
[$status, $body] = $this->client->request('POST', '/v2/checkout/orders', $payload, [
'PayPal-Request-Id: ' . $requestId,
]);
if ($status !== 201 || empty($body['id'])) {
throw new \RuntimeException('PayPal v2: Order-Anlage fehlgeschlagen (HTTP ' . $status . '): ' . \json_encode($body));
}
$approve = '';
foreach (($body['links'] ?? []) as $link) {
$rel = $link['rel'] ?? '';
if ($rel === 'approve' || $rel === 'payer-action') {
$approve = (string) ($link['href'] ?? '');
break;
}
}
if ($approve === '') {
throw new \RuntimeException('PayPal v2: Kein Approve-Link in der Antwort.');
}
return ['id' => (string) $body['id'], 'approve_url' => $approve];
}
/** @return array<mixed> */
public function get(string $paypalOrderId): array
{
[$status, $body] = $this->client->request('GET', '/v2/checkout/orders/' . \rawurlencode($paypalOrderId));
if ($status !== 200) {
throw new \RuntimeException('PayPal v2: Order-Abruf fehlgeschlagen (HTTP ' . $status . ').');
}
return $body;
}
/**
* Captured eine vom Kunden freigegebene Order. Idempotent:
* Ist bereits gecaptured (Doppel-Return / Webhook-Race), wird der bestehende
* Zustand aus der Order gelesen statt einen Fehler zu werfen.
*
* @return array{order_status:string,capture_id:string,capture_status:string,amount:string,currency:string,payer_email:string,payer_id:string,raw:array}
*/
public function capture(string $paypalOrderId): array
{
[$status, $body] = $this->client->request(
'POST',
'/v2/checkout/orders/' . \rawurlencode($paypalOrderId) . '/capture',
null,
[
'PayPal-Request-Id: capture-' . $paypalOrderId,
'Prefer: return=representation',
]
);
if ($status === 201 || $status === 200) {
return $this->normalize($body);
}
if ($status === 422 && $this->hasIssue($body, 'ORDER_ALREADY_CAPTURED')) {
return $this->normalize($this->get($paypalOrderId));
}
throw new \RuntimeException('PayPal v2: Capture fehlgeschlagen (HTTP ' . $status . '): ' . \json_encode($body));
}
/**
* Vereinheitlicht Capture- bzw. Order-Antwort auf die Felder, die der Shop braucht.
*
* @param array<mixed> $body
* @return array{order_status:string,capture_id:string,capture_status:string,amount:string,currency:string,payer_email:string,payer_id:string,raw:array}
*/
public 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'] ?? ''),
'raw' => $body,
];
}
/** @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;
}
}
-133
View File
@@ -1,133 +0,0 @@
<?php
declare(strict_types=1);
namespace PaypalV2;
/**
* Duenner HTTP-Client fuer die PayPal-REST-API (Orders v2).
*
* Sicherheit:
* - TLS-Verifikation ist ERZWUNGEN (kein CURLOPT_SSL_VERIFYPEER=false wie im Legacy-NVP).
* - Access-Token wird ausserhalb des Webroots zwischengespeichert (chmod 600).
*/
final class PayPalClient
{
private string $base;
public function __construct()
{
$this->base = Config::apiBase();
}
/** OAuth2 client_credentials Access-Token, gecacht bis kurz vor Ablauf. */
public function accessToken(): string
{
$cacheFile = self::tokenCacheFile();
if (\is_file($cacheFile)) {
$c = \json_decode((string) @\file_get_contents($cacheFile), true);
if (
\is_array($c)
&& ($c['mode'] ?? '') === Config::mode()
&& isset($c['token'], $c['expires'])
&& (int) $c['expires'] > \time() + 60
) {
return (string) $c['token'];
}
}
$creds = Config::creds();
[$status, $body] = $this->raw(
'POST',
'/v1/oauth2/token',
'grant_type=client_credentials',
['Content-Type: application/x-www-form-urlencoded'],
$creds['client_id'] . ':' . $creds['secret']
);
$data = \json_decode($body, true);
if ($status !== 200 || !\is_array($data) || empty($data['access_token'])) {
throw new \RuntimeException('PayPal v2: OAuth2 fehlgeschlagen (HTTP ' . $status . ').');
}
@\file_put_contents($cacheFile, \json_encode([
'token' => (string) $data['access_token'],
'expires' => \time() + (int) ($data['expires_in'] ?? 300),
'mode' => Config::mode(),
]), LOCK_EX);
@\chmod($cacheFile, 0600);
return (string) $data['access_token'];
}
/**
* Authentifizierter JSON-Request.
*
* @param array<string,mixed>|null $payload
* @param list<string> $extraHeaders
* @return array{0:int,1:array<mixed>} [HTTP-Status, dekodierter Body]
*/
public function request(string $method, string $path, ?array $payload = null, array $extraHeaders = []): array
{
$headers = \array_merge(
[
'Content-Type: application/json',
'Authorization: Bearer ' . $this->accessToken(),
],
$extraHeaders
);
$bodyStr = $payload === null ? null : (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]];
}
/**
* Roher cURL-Aufruf.
*
* @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->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 ($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 v2: HTTP-Transportfehler: ' . $err);
}
$status = (int) \curl_getinfo($ch, CURLINFO_HTTP_CODE);
\curl_close($ch);
return [$status, (string) $resp];
}
private static function tokenCacheFile(): string
{
return Config::dir() . '/.token_cache.json';
}
}
-53
View File
@@ -1,53 +0,0 @@
<?php
declare(strict_types=1);
namespace PaypalV2;
/**
* Verifiziert PayPal-Webhook-Signaturen serverseitig gegen die PayPal-API.
* Nur so ist ein eingehendes Event vertrauenswuerdig.
*/
final class Webhook
{
/**
* @param array<string,string> $headers Request-Header (Schluessel lowercase)
* @param string $rawBody Roher Request-Body (unveraendert!)
*/
public function verify(array $headers, string $rawBody): bool
{
$webhookId = Config::webhookId();
if ($webhookId === '') {
\error_log('PayPal v2 Webhook: keine webhook_id konfiguriert – Event abgelehnt.');
return false;
}
$event = \json_decode($rawBody, true);
if (!\is_array($event)) {
return false;
}
$payload = [
'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,
'webhook_event' => $event,
];
foreach (['transmission_id', 'transmission_time', 'cert_url', 'auth_algo', 'transmission_sig'] as $k) {
if ($payload[$k] === '') {
return false;
}
}
[$status, $body] = (new PayPalClient())->request(
'POST',
'/v1/notifications/verify-webhook-signature',
$payload
);
return $status === 200 && (($body['verification_status'] ?? '') === 'SUCCESS');
}
}
-35
View File
@@ -1,35 +0,0 @@
<?php
/**
* Standalone-Bootstrap fuer die PayPal-v2-Endpunkte (return.php, webhook.php).
*
* Bindet den Shop-Kern (DB + Config) ein, OHNE den normalen index.php-Router
* und ohne das Legacy-PayPal-Modul zu beruehren.
*/
declare(strict_types=1);
// Fehler nicht an den Browser ausgeben (Produktivbetrieb)
\ini_set('display_errors', '0');
\error_reporting(E_ALL);
$docroot = $_SERVER['DOCUMENT_ROOT'] ?? '';
if ($docroot === '' || !\is_dir($docroot . '/core')) {
// Leeres/falsches DOCUMENT_ROOT (z. B. CLI) -> aus dem Dateipfad ableiten
$docroot = \dirname(__DIR__, 2);
}
// base.class.php nutzt relative Includes (./config.inc.php etc.) -> ins Docroot wechseln
\chdir($docroot);
require_once $docroot . '/core/base.class.php';
require_once $docroot . '/core/registry.class.php';
require_once $docroot . '/core/order.class.php';
require_once __DIR__ . '/Config.php';
require_once __DIR__ . '/PayPalClient.php';
require_once __DIR__ . '/Orders.php';
require_once __DIR__ . '/Webhook.php';
require_once __DIR__ . '/Gateway.php';
/** @var object $base_object Shop-base mit ->db (mysqli) und ->config */
$base_object = new base();
Registry::set('base', $base_object);
-74
View File
@@ -1,74 +0,0 @@
-- ============================================================================
-- PayPal Orders v2 (REST) – Aktivierung
-- Neues Modul, parallel zum Legacy-NVP-PayPal (payment_methods.id = 4).
--
-- In Schritten ausfuehren – nichts wird an der Legacy-Zahlart geaendert,
-- bis DU es in SCHRITT 4 bewusst tust.
-- VORHER: DB-Dump ziehen.
-- ============================================================================
-- ----------------------------------------------------------------------------
-- SCHRITT 1 – Schema: Spalte fuer den Betragsabgleich (Manipulationsschutz).
-- Additiv, nullable, keine Datenaenderung an bestehenden Auftraegen.
-- ----------------------------------------------------------------------------
ALTER TABLE `orders`
ADD COLUMN `paypal_v2_expected_amount` DECIMAL(10,2) NULL DEFAULT NULL;
-- ----------------------------------------------------------------------------
-- SCHRITT 2 – Zahlart anlegen (klont die Legacy-PayPal-Zeile id=4).
-- helperjoinkey='pp' => identischer PayPal-Aufschlag (shipping_area.add_pp).
-- active=1, ABER noch KEINER Kundengruppe zugeordnet => im Shop unsichtbar.
-- ----------------------------------------------------------------------------
INSERT INTO `payment_methods`
(`name`, `module`, `argument`, `active`, `rang`,
`invoice_text`, `order_text`, `order_email_text`,
`helperjoinkey`, `configuration_group_id`)
VALUES
('PayPal', 'paypalrest', '', 1, 2,
'Bezahlung erfolgte per PayPal',
'Sie werden zur Bezahlung Ihrer Bestellung zu PayPal weitergeleitet.',
'Der zu zahlende Betrag wird per PayPal ueberwiesen.',
'pp', 'PayPalRest');
-- Neue ID merken (fuer die folgenden Schritte):
-- SELECT id FROM payment_methods WHERE module='paypalrest';
-- ----------------------------------------------------------------------------
-- SCHRITT 3 – TESTEN (Sandbox): Zahlart NUR fuer eine Testgruppe sichtbar.
-- <NEUE_ID> ersetzen. <TEST_GRUPPE> = Kundengruppe eines Test-Accounts.
-- (Kundengruppen der Legacy-Methode: 1, 101, 103 aktiv; 100 inaktiv.)
-- ----------------------------------------------------------------------------
-- INSERT INTO `customer_group_payment_method_configuration`
-- (`group_id`, `payment_method_id`, `shipping_area_id`, `active`)
-- VALUES
-- (<TEST_GRUPPE>, <NEUE_ID>, 1, 1);
-- ----------------------------------------------------------------------------
-- SCHRITT 4 – GO-LIVE (erst nach erfolgreichem Sandbox-Test,
-- config.php auf mode='live' + Live-Credentials!):
-- a) Neue Methode fuer die echten Gruppen freischalten (wie Legacy):
-- INSERT INTO `customer_group_payment_method_configuration`
-- (`group_id`, `payment_method_id`, `shipping_area_id`, `active`)
-- VALUES
-- (1, <NEUE_ID>, 1, 1),
-- (101, <NEUE_ID>, 1, 1),
-- (103, <NEUE_ID>, 1, 1);
--
-- b) Alte NVP-PayPal-Zahlart (id=4) deaktivieren, damit sie NICHT mehr
-- auswaehlbar ist (Bestellungen/Historie bleiben unberuehrt):
-- UPDATE `payment_methods` SET `active` = 0 WHERE `id` = 4;
-- Alternativ nur die Gruppen-Sichtbarkeit abschalten:
-- UPDATE `customer_group_payment_method_configuration`
-- SET `active` = 0 WHERE `payment_method_id` = 4;
-- ----------------------------------------------------------------------------
-- ROLLBACK (falls noetig): neue Methode wieder verstecken
-- ----------------------------------------------------------------------------
-- DELETE FROM `customer_group_payment_method_configuration`
-- WHERE `payment_method_id` = (SELECT id FROM payment_methods WHERE module='paypalrest');
-- UPDATE `payment_methods` SET `active` = 0 WHERE `module` = 'paypalrest';
-49
View File
@@ -1,49 +0,0 @@
<?php
/**
* Return-Endpunkt fuer den PayPal-v2-Redirect-Flow.
*
* Ablauf:
* - Erfolg: PayPal leitet mit ?token=<PayPal-Order-ID>&PayerID=... hierher zurueck.
* -> Capture + Betragsabgleich + Zahlstatus setzen -> Weiterleitung auf die
* bestehende Shop-Erfolgsseite /Kasse/?success=1&order=<Nr>.
* - Abbruch: PayPal leitet mit ?cancel=1 hierher -> zurueck zur Kasse.
*/
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
use PaypalV2\Gateway;
$shopBase = \rtrim((string) \PaypalV2\Config::get('shop_base_url', 'https://' . ($_SERVER['SERVER_NAME'] ?? '')), '/');
/** Weiterleitung + sauberes Ende. */
$redirect = static function (string $url): never {
\header('Location: ' . $url);
exit;
};
// --- Abbruch durch den Kunden -------------------------------------------------
if (isset($_GET['cancel'])) {
$redirect($shopBase . '/Kasse/?paypal_cancel=1');
}
$paypalOrderId = isset($_GET['token']) ? (string) $_GET['token'] : '';
if ($paypalOrderId === '') {
$redirect($shopBase . '/Kasse/?paypal_error=1');
}
try {
$gateway = new Gateway($base_object);
$result = $gateway->finalizeByPaypalOrderId($paypalOrderId);
} catch (\Throwable $e) {
\error_log('PayPal v2 return.php: ' . $e->getMessage());
$redirect($shopBase . '/Kasse/?paypal_error=1');
}
if ($result['ok']) {
// Bestehende Erfolgsseite des Shops wiederverwenden (completed.tpl)
$redirect($shopBase . '/Kasse/?success=1&order=' . \rawurlencode($result['order_number']));
}
\error_log('PayPal v2 return.php: Abschluss fehlgeschlagen (order ' . $result['order_id'] . '): ' . $result['message']);
$redirect($shopBase . '/Kasse/?paypal_error=1');
-62
View File
@@ -1,62 +0,0 @@
<?php
/**
* Webhook-Endpunkt fuer PayPal-v2.
*
* Dient als verlaessliche Quelle des Zahlstatus (server-zu-server), unabhaengig
* davon, ob der Kunde nach der Zahlung tatsaechlich zum Shop zurueckkehrt.
*
* PayPal-Dashboard: Webhook auf diese URL anlegen, mindestens Event
* PAYMENT.CAPTURE.COMPLETED
* abonnieren und die entstehende Webhook-ID in die Config eintragen.
*/
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
use PaypalV2\Webhook;
use PaypalV2\Gateway;
$rawBody = (string) \file_get_contents('php://input');
// Header einsammeln (lowercase Keys)
$headers = [];
foreach ($_SERVER as $key => $value) {
if (\strncmp($key, 'HTTP_', 5) === 0) {
$name = \strtolower(\str_replace('_', '-', \substr($key, 5)));
$headers[$name] = (string) $value;
}
}
// Signatur verifizieren – sonst sofort ablehnen
try {
if (!(new Webhook())->verify($headers, $rawBody)) {
\http_response_code(400);
echo 'invalid signature';
exit;
}
} catch (\Throwable $e) {
\error_log('PayPal v2 webhook verify: ' . $e->getMessage());
\http_response_code(500);
exit;
}
$event = \json_decode($rawBody, true);
$type = \is_array($event) ? (string) ($event['event_type'] ?? '') : '';
try {
if ($type === 'PAYMENT.CAPTURE.COMPLETED') {
$resource = $event['resource'] ?? [];
if (\is_array($resource)) {
(new Gateway($base_object))->confirmFromWebhookCapture($resource);
}
}
// Weitere Event-Typen (Refund/Denied) koennen hier ergaenzt werden.
} catch (\Throwable $e) {
\error_log('PayPal v2 webhook handle (' . $type . '): ' . $e->getMessage());
\http_response_code(500);
exit;
}
// PayPal erwartet 2xx, sonst wird das Event erneut zugestellt.
\http_response_code(200);
echo 'ok';
+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);
+6 -3
View File
@@ -31,9 +31,12 @@ 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);
+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 -1144
View File
File diff suppressed because it is too large Load Diff
+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() {
+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';
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

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 792 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 612 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 722 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

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