Compare commits

56 Commits
Author SHA1 Message Date
Thomas Bartelt 4ea1daec9a fix(cms/zahlarten): Platzhalter [#customer_freetextfield3] im Bestelltext ersetzen wie in der Kasse 2026-09-27 22:48:11 +02:00
Thomas Bartelt 91d90b6714 feat(cms): Platzhalter [#zahlarten] in Textboxen -- Liste der aktiven Zahlarten der Kundengruppe aus der Kassen-Logik (Seite Bezahlarten nicht mehr von Hand); Mollie-Aktivschalter in shipping_area auf gruppenspezifisch (2), sonst saehen Haendler Mollie 2026-09-27 22:43:55 +02:00
Thomas Bartelt 21977b8f41 feat(payments/mollie): Zahlart 'Online-Banking' (Pay by Bank, paybybank); Google Pay inaktiv (nur Direkt-Integration) 2026-09-27 22:19:56 +02:00
Thomas Bartelt 5f3bbc2c45 feat(payments/mollie): Next-Gen-Webhooks (JSON, HMAC-SHA256 X-Mollie-Signature) neben klassischem id=tr_; fix: Bestellbestaetigung aus Webhook-/CLI-Kontext (Layout-Objekt im Bootstrap); markCancelled idempotent 2026-09-27 21:51:11 +02:00
Thomas Bartelt b8111a1e7f feat(payments): Mollie-Provider -- getrennte Zahlarten je Methode (Klarna, Kreditkarte, Apple Pay, Google Pay)
- core/payments/providers/mollie: Client (Bearer API-Key, Idempotency-Key), Provider nach dem
  PayPal-v2-Vertrag: start (POST /v2/payments mit method aus payment_methods.argument),
  onReturn/onCancel (Status per GET, Rueckkehr-URL traegt o=<orders.id>), onWebhook
  (id=tr_..., Wahrheit per GET), reconcile (paid/canceled/expired/failed, >24h abbrechen)
- Klarna: lines aus order_item (brutto je Stueck), Versand, Zahlartgebuehr, Gutschein, Rabatt,
  Rundungsausgleich; billing/shippingAddress mit E-Mail und Telefon (E.164)
- core/paymentmethods/molliepaymentmethod.class.php: Kassen-Adapter ohne Express
- install_mollie.sql: 16 Spalten in shipping_area (4 helperjoinkeys), Einstellungen Gruppe
  mollie, 4 Zahlarten (module mollie, argument = Methode), Freischaltung/Leichen als Kommentar
- Kasse neu: Mollie-Logos je Methode, Apple Pay nur auf faehigen Geraeten
- cli.php status: mehrere Zahlarten je Anbieter, Modus generisch
2026-09-27 10:39:23 +02:00
Thomas Bartelt d5a3727884 fix(artikel): attribute_7 nur als Ziffernfolge in die SQL (Leerzeichen => HTTP 500); zieht Live-Haertung vom 25.08. (Grundpreis-Teiler) nach 2026-09-27 10:16:49 +02:00
Thomas Bartelt 5844b24db4 style(warenkorb-neu): Leer-Ansicht rechtsbuendig, Knopf raus aus der Klick-Falle des fixierten Kopfes 2026-09-27 10:08:31 +02:00
Thomas Bartelt d0b79a2820 fix(warenkorb-neu, kasse-neu): Scroll nicht wiederherstellen, wenn der Korb leer wurde (Knopf lag unter dem fixierten Kopf); sticky Kaesten unter den 152px-Kopf (top 168px) 2026-09-27 10:02:47 +02:00
Thomas Bartelt 4ef81ceec3 fix(warenkorb-neu): 'Weiter einkaufen' ueber das Formular (cart_goto=weiter), Auto-Speichern mit Wartezeit statt Wettlauf gegen den Klick, Doppel-Submit-Riegel; Anfuehrungszeichen 2026-09-27 09:55:16 +02:00
Thomas Bartelt 4c1ecb82a7 fix(kasse): Gast-/Express-Bestellung ohne Anrede verlor die Adresse -- CustomerAddress::validate() verlangt honorific; leer => Firma/Herr, Fehlschlag jetzt im Log 2026-09-27 00:36:03 +02:00
Thomas Bartelt 30c81cae9c fix(kasse-neu): schliessende Klammer stand hinter dem Kommentar -- Syntaxfehler, Bestellen-Knopf ohne Handler 2026-09-26 23:51:42 +02:00
Thomas Bartelt def156e1c2 feat(kasse, warenkorb): neue Ansicht ist Standard, Rueckfall ?ansicht=alt per Session
- Weiche in website_shoppingcart.php / website_cashdesk.php gedreht: content_*_neu.tpl
  ist Standard, ?ansicht=alt schaltet die Sitzung auf die alten Templates, ?ansicht=neu zurueck
- Parameter ansicht=neu aus Form-Actions und Links der neuen Templates entfernt
- Erfolgsseite completed.tpl und payment_result.tpl (theme2) im Stil der neuen Kasse
  (Schrittleiste 3 Fertig, Karte, Bestellnummer, Text aus cash_desk_order_completed)
- Erfolgsseite ohne Navigation (site_type full_width) wie Warenkorb/Kasse
- sticky Bestellkasten unter dem klebenden Theme-Kopf (top 105px statt 16px)
- fix: Korbsumme 0 teilte in manage_gift_certificate() durch null (HTTP 500)
2026-09-26 23:43:41 +02:00
Tom Bartelt 6cea66126a fix(kasse): HTTP 500 in der Gast-Kasse bei leerem Land
- Country::getShippingAreaById: Land/Gruppe als int, leeres Land -> false
  statt 'WHERE id = ' (SQL-Fatal, leere Seite, 16x seit 03.09.2026)
- manage_guest_order_address: Lieferadresse = Rechnungsadresse ohne Land
  -> Standardland fuer die Versandgebiet-Suche
2026-09-22 21:21:38 +02:00
Tom Bartelt 40b4774f0d fix(kundencenter): SQL-Fatal beim Adresse speichern (leerer parent_id)
- main_widget_menu_2: !empty statt isset, sonst 'parent_id in(10636,)'
- add_address: exit nach dem Location-Header
- get_this_url: leere Parameter nicht anhaengen
2026-09-22 20:45:53 +02:00
Tom Bartelt 85bc1d8c9a feat(payments): Block Zahlungsvorgang im Bestell-Editor, Storno-Felder bei automatischem Storno
- Bestell-Editor rechts: Zahlungsvorgang (Status, Grund, Transaktions-ID,
  Referenz, Betraege, Zeiten, Verlauf aus PG payment_log), anbieterneutral
  ueber payment_transactions (Payments\Overview, Log::forOrder)
- markCancelled() fuellt Stornodatum, Stornostatus, Stornogrund und
  Storno-Nachricht der Bestellung (nur leere Felder)
- neuer Stornogrund 'Zahlung beim Anbieter nicht abgeschlossen' (install.sql)
- reconcile: 404-Grund lesbar mit Alter des Vorgangs
2026-09-22 09:30:52 +02:00
Thomas Bartelt fe868641d2 style(warenkorb-neu, kasse-neu): offizielle Web-Fassung des Gewaehrleistungs-Labels, 'Gross anzeigen' oeffnet das PDF 2026-09-18 21:07:26 +02:00
Thomas Bartelt 0c04dc9acd style(kasse-neu): Label-Zeile mit 'Gross anzeigen' und EU-Link wie im Warenkorb 2026-09-18 21:03:30 +02:00
Thomas Bartelt f642ba7f06 style(warenkorb-neu): unter dem Label rechts Link zu europa.eu/youreurope/garantien 2026-09-18 21:03:15 +02:00
Thomas Bartelt 8f2b69827e style(warenkorb-neu): Knopf 'Mengen aktualisieren' raus, getippte Menge speichert bei Feldwechsel 2026-09-18 21:02:02 +02:00
Thomas Bartelt 985e7b2e57 style(warenkorb-neu): ab 900px Spaltenbreite Stepper und Summe inline in der Zeile 2026-09-18 21:01:10 +02:00
Thomas Bartelt 4c9c2c6a29 style(warenkorb-neu): Positionen und Anmerkung als weisse Karten mit Rand und runden Ecken 2026-09-18 20:59:54 +02:00
Thomas Bartelt 65ced5261f fix(kasse-neu): Fehlerbox ohne .prop('hidden') (jQuery 1.6) 2026-09-18 20:23:09 +02:00
Thomas Bartelt 57a101baa8 fix(kasse-neu): jQuery-1.6-kompatibel (.submit statt .on, kein .addBack) -- .on() brach die Init ab, Lieferadresse klappte nicht mehr auf 2026-09-18 20:22:54 +02:00
Thomas Bartelt b396c674c9 style(kasse-neu): Zurueck-Link nach oben in die Schrittleiste 2026-09-18 20:20:20 +02:00
Thomas Bartelt 215252f9ef style(kasse-neu, warenkorb-neu): 150px Luft am Ende vor dem Footer-Balken 2026-09-18 20:18:57 +02:00
Thomas Bartelt 6361249fd0 feat(warenkorb-neu, kasse-neu): ohne Navigation (site_type full_width), breiterer Kasten ab 900px Spalte 2026-09-18 20:17:19 +02:00
Thomas Bartelt fa836e7a10 feat(kasse-neu): Rechtliches mit Link zur jeweiligen Seite in neuem Fenster (B2B-Fassungen fuer Haendler) 2026-09-18 20:15:47 +02:00
Thomas Bartelt 15270289f5 style(kasse-neu): Gewaehrleistungs-Label ueber den Bestellkasten 2026-09-18 20:14:15 +02:00
Thomas Bartelt 33d859f454 fix(kasse-neu): Scrollposition ueber Reloads (Zahlart/Versand/Land/Gutschein) retten 2026-09-18 20:11:29 +02:00
Thomas Bartelt 49ac0f7e35 feat(kasse): Entwurf neue Kassenansicht content_cash_desk_neu.tpl (Schritte links, Bestellkasten rechts, Feldmarkierung statt Alert), Weiche ?ansicht=neu
Feldnamen, IDs und content_cash_desk.js unveraendert; nur Markup, CSS und Absende-Pruefung neu.
2026-09-18 20:09:05 +02:00
Thomas Bartelt 5c80931569 style(warenkorb-neu): Schnell hinzufuegen ueber den Warenkorb 2026-09-18 20:02:09 +02:00
Thomas Bartelt 94b924fa46 fix(warenkorb-neu): Scrollposition ueber den Submit retten, Post-Redirect-Get nach Mengenaenderung 2026-09-18 19:59:04 +02:00
Thomas Bartelt 7430556460 style(warenkorb-neu): Gewaehrleistungs-Label in voller Hoehe (width 100%, height auto) 2026-09-18 19:57:16 +02:00
Thomas Bartelt 0521b4399f feat(warenkorb-neu): Zeile zweistufig (Bild+Name / Stepper+Summe) fuer die 722px-Spalte, Gewaehrleistungs-Label ueber dem Summenkasten, Stapeln per Container-Query
Bild label-gewaehrleistung.webp liegt in media/images (gitignored), auf Dev und Live kopiert.
2026-09-18 19:55:37 +02:00
Thomas Bartelt ae7e400645 feat(warenkorb): Entwurf neue Ansicht content_shoppingcart_neu.tpl (Zeilen statt Tabelle, Stepper, Summenkasten), Weiche ?ansicht=neu; Alt-Text am Thumbnail entfernt 2026-09-18 19:44:08 +02:00
Thomas Bartelt dc57c725f6 feat(warenkorb): ein Formular fuer Mengen, Anmerkung und Weiterleitung; Anmerkung in der Kasse editierbar
Vorher: Anmerkung in eigenem Formular mit 'Text speichern', 'Zur Kasse' als reiner Link
(ungespeicherte Mengen/Anmerkung gingen verloren), in der Kasse readonly mit dem Hinweis
'kann nur im Warenkorb bearbeitet werden' -- ohne technischen Grund, die Kasse postete das
Feld ohnehin mit. Jetzt: cart_goto ('kasse' | 'express:<modul>') im Warenkorb-Modul,
Kasse uebernimmt POST customer_info in die Session, Texte auf Deutsch geglaettet.
2026-09-18 19:38:02 +02:00
Thomas Bartelt dec101f0cb fix(kasse): Express-Hinweis nennt den Anzeigenamen des Anbieters statt 'Paypal v2' 2026-09-18 07:53:29 +02:00
Thomas Bartelt f1aef6e575 fix(paypal_v2): Anzeigename 'PayPal' statt 'PayPal (v2)' -- stand in Kundenmeldungen 2026-09-18 07:53:18 +02:00
Thomas Bartelt 56f9a425c9 fix(registrierung): Anrede mit Leerfeld statt stiller Vorbelegung 'Firma', USt-ID bei Firma keine Pflicht mehr 2026-09-18 07:48:38 +02:00
Thomas Bartelt 6f0036b819 fix(kasse): Anrede beim Express-Kauf wirklich keine Pflicht -- Inline-JS-Pruefung und Label
Die Aenderung vom 14.09. nahm nur die CSS-Klasse, die Absende-Pruefung in
content_cash_desk.tpl verlangte die Anrede weiter (Alert 'vollstaendige Rechnungsadresse').
Im Express-Weg sind stattdessen Vor- und Nachname Pflicht.
2026-09-18 07:45:26 +02:00
Thomas Bartelt f65901060f feat(payments): Express-Start mit Versand und Zahlartaufschlag, PATCH nur bei Abweichung
PayPal Live verlangt nach jeder Betragsaenderung eine zweite Freigabe (422
PAYER_ACTION_REQUIRED). Die Kasse rechnet den Endbetrag jetzt beim Express-Start vor
(erste aktive Versandart des Gebiets, Gast = Gebiet 1, eingeloggt = Land/Gruppe), der
Provider vergleicht vor dem PATCH mit dem Stand bei PayPal und laesst ihn bei Gleichstand
aus. Webhooks ordnen ohne custom_id ueber die PayPal-Order-ID zu.
2026-09-18 07:40:27 +02:00
Thomas Bartelt c8fc7fba2f fix(payments): Express-Rueckkehr nach zweiter PayPal-Freigabe nimmt den normalen Rueckweg mit Capture
PayPal Live antwortet auf den Capture nach dem Betrags-PATCH mit 422 PAYER_ACTION_REQUIRED
(Sandbox nicht). Der Kunde gibt erneut frei und kehrt ueber ?payment_express= zurueck; dort
scheiterte onExpressReturn an custom_id != 'express' und zeigte eine Fehlerseite, waehrend
die Bestellung offen blieb (Live-Bestellung 312143).
2026-09-18 07:30:17 +02:00
Thomas Bartelt b8bc8aa8bf feat(backend): Zahlarten-Liste zeigt ID und Modul 2026-09-18 07:02:48 +02:00
Thomas Bartelt 7e28a5275c style(warenkorb): Express-Button mit eigener Klasse .ppbutton (PayPal-Optik, nicht .button) 2026-09-13 21:53:58 +02:00
Thomas Bartelt 5e6fcb0c3d feat(payments): Express-Adressen nur aus erlaubten Laendern (Setting express_countries, Standard DE) 2026-09-13 21:49:09 +02:00
Thomas Bartelt da54028a6e feat(payments): Anrede beim Express-Kauf keine Pflicht (Anbieter liefert keine) 2026-09-13 21:48:09 +02:00
Thomas Bartelt f33533b411 feat(payments): Express-Kauf aus dem Warenkorb (PayPal v2): Start ohne Bestellung, Adresse von PayPal, Kasse vorbelegt, Capture ohne zweiten Besuch 2026-09-13 21:44:46 +02:00
Thomas Bartelt e4b3f5a1ea chore(payments): Webhook-Rohmitschnitt nur bei logs/webhook_debug 2026-09-13 21:35:26 +02:00
Thomas Bartelt 67af646f2e fix(backend): Secret-Felder ohne type=password -- Browser-Autofill ueberschrieb die Webhook-ID 2026-09-13 21:34:07 +02:00
Thomas Bartelt 4ce09ac1b6 fix(paypal_v2): Webhook-Signatur lokal pruefen (Zertifikat, CRC32, SHA256withRSA); API-Verify nur als Rueckfall 2026-09-13 21:32:17 +02:00
Thomas Bartelt 4248f8c148 fix(paypal_v2): Webhook-Signatur mit rohem Body pruefen, Ablehnungsgrund protokollieren 2026-09-13 21:19:36 +02:00
Thomas Bartelt 83cf8b9808 chore(dev): display_errors aus -- Warnungen im Formular-HTML loesten WAF-403 in der Kasse aus 2026-09-13 21:00:58 +02:00
Thomas Bartelt 2698d518dc fix(payments): Registry laedt Kernklassen sofort (Fatal im Zahlart-Editor beim Speichern) 2026-09-13 20:33:44 +02:00
Thomas Bartelt 37017b25dd sync(dev): fehlende Admin-Theme-Assets vom Live-Stand ergaenzt (nichts ueberschrieben) 2026-09-13 19:58:55 +02:00
Thomas Bartelt 726f897c3d chore(dev): payment_webhook.php ohne Basic-Auth erreichbar 2026-09-13 19:52:45 +02:00
Thomas Bartelt 397fb5944a feat(payments): Anbieter-Gerüst core/payments mit PayPal Orders v2
- ProviderInterface/AbstractProvider: Vorgang mit Uhrzeit+Session (payment_transactions),
  Mail und Warenkorb erst nach Zahlung, idempotentes markPaid, Betragsabgleich,
  shop_event + PG payment_log
- Secrets verschluesselt (libsodium) in MySQL, Schluessel ausserhalb Docroot
- Cashdesk: ?payment_return/?payment_cancel, Pending-Riegel, zahlung_erst_spaeter()
- Backend Zahlart-Editor: Einstellungen speichern (war ohne Empfaenger), Secret-Felder
- payment_webhook.php (Standalone), cli.php (init-key, secret, reconcile, status)
- PayPal-v2-Blaupause vom Juli entfernt
2026-09-13 19:51:52 +02:00
360 changed files with 40005 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,122 @@
<?php
declare(strict_types=1);
/**
* Zahlart-Adapter fuer den Cashdesk: payment_methods.module = 'mollie'.
*
* Gilt fuer ALLE Mollie-Zahlarten (Klarna, Kreditkarte, Apple Pay, Google Pay ...): welche
* Methode gemeint ist, steht in payment_methods.argument der gewaehlten Zahlart; der
* Provider liest sie ueber die Bestellung. Kopie des PayPal-v2-Adapters ohne Express.
*/
require_once __DIR__ . '/../payments/Registry.php';
class MolliePaymentMethod
{
private const MODULE = 'mollie';
private $base_object;
private $layout_object;
private $customer_data;
private $payment_method;
private $gift_certificate = false;
private $order_id = false;
public function __construct($base_object, $layout_object)
{
$this->base_object = $base_object;
$this->layout_object = $layout_object;
}
public function set_data($customer, $shipping, $billing, $shoppingcart, $delivery, $payment, $order_id = false)
{
$this->customer_data = $customer;
$this->payment_method = $payment;
$this->order_id = $order_id;
}
public function set_gift_certificate($gift_certificate)
{
$this->gift_certificate = $gift_certificate;
}
public function set_order_id($order_id)
{
$this->order_id = $order_id;
}
/** Vor dem Speichern: Anbieter einsatzbereit und Methode gesetzt? Sonst Fehlertext fuer die Kasse. */
public function before_order($argument = null)
{
$provider = \Payments\Registry::get(self::MODULE, $this->base_object);
if (!$provider) {
return 'Diese Zahlart ist derzeit nicht verfuegbar. Bitte waehlen Sie eine andere Zahlart.';
}
$method = \strtolower(\trim((string) $argument));
if ($method === '' || !\preg_match('/^[a-z0-9]{2,30}$/', $method)) {
\error_log('Mollie: Zahlart ohne Methode im Feld argument (payment_methods.id ' . ($this->payment_method->id ?? '?') . ')');
return 'Diese Zahlart ist nicht richtig eingerichtet. Bitte waehlen Sie eine andere Zahlart.';
}
return '';
}
public function during_order($argument = null)
{
return '';
}
/**
* Nach dem Speichern: Zahlung bei Mollie anlegen, Kunde per 302 zur Checkout-Seite.
*/
public function after_order($order_total = false)
{
$amount = (float) $order_total;
if ($this->gift_certificate && isset($this->gift_certificate->with_vat)) {
$amount -= (float) $this->gift_certificate->with_vat;
}
$amount = \round($amount, 2);
$label = $this->label();
try {
$provider = \Payments\Registry::get(self::MODULE, $this->base_object);
if (!$provider) {
throw new \RuntimeException('Provider mollie nicht installiert');
}
$url = $provider->start((int) $this->order_id, $amount);
} catch (\Throwable $e) {
\error_log('Mollie after_order (Order ' . $this->order_id . '): ' . $e->getMessage());
if (\function_exists('shop_event')) {
\shop_event('failed', [
'order_id' => $this->order_id, 'betrag' => $amount,
'customer_id' => $this->customer_data->id ?? null,
'detail' => 'mollie: start fehlgeschlagen -- ' . $e->getMessage(),
]);
}
$this->layout_object->assign('call', 'payment_error');
$this->layout_object->assign('provider_label', $label);
$this->layout_object->assign('message', 'Die Zahlung ueber ' . \htmlspecialchars($label) . ' konnte nicht gestartet werden. Ihr Warenkorb ist noch da &ndash; bitte versuchen Sie es erneut oder waehlen Sie eine andere Zahlart.');
return $this->layout_object->_fetch('content_cash_desk/payment_result.tpl');
}
if (!\headers_sent()) {
\header('Location: ' . $url, true, 302);
exit;
}
$this->layout_object->assign('call', 'payment_open');
$this->layout_object->assign('provider_label', $label);
$this->layout_object->assign('payment_link', $url);
return $this->layout_object->_fetch('content_cash_desk/payment_result.tpl');
}
/** Rueckkehr laeuft ueber website_cashdesk::run() (?payment_return=mollie), nicht hier. */
public function change_order()
{
return '';
}
/** Anzeigename der gewaehlten Zahlart (z.B. "Klarna"), Rueckfall "Mollie". */
private function label(): string
{
$name = \trim((string) ($this->payment_method->name ?? ''));
return $name !== '' ? $name : 'Mollie';
}
}
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
/**
* Zahlart-Adapter fuer den Cashdesk: payment_methods.module = 'paypal_v2'.
*
* Duenn mit Absicht. Der Cashdesk erwartet diese Klasse (ucfirst(module).'PaymentMethod')
* mit set_data/before_order/during_order/after_order; alles Fachliche liegt im Provider
* unter core/payments/providers/paypal_v2/. Ein weiterer Anbieter bekommt eine Kopie
* dieser Datei mit anderem Modulnamen -- mehr nicht.
*/
require_once __DIR__ . '/../payments/Registry.php';
class Paypal_v2PaymentMethod
{
private const MODULE = 'paypal_v2';
private $base_object;
private $layout_object;
private $customer_data;
private $gift_certificate = false;
private $order_id = false;
public function __construct($base_object, $layout_object)
{
$this->base_object = $base_object;
$this->layout_object = $layout_object;
}
public function set_data($customer, $shipping, $billing, $shoppingcart, $delivery, $payment, $order_id = false)
{
$this->customer_data = $customer;
$this->order_id = $order_id;
}
public function set_gift_certificate($gift_certificate)
{
$this->gift_certificate = $gift_certificate;
}
public function set_order_id($order_id)
{
$this->order_id = $order_id;
}
/** Vor dem Speichern: Anbieter einsatzbereit? Sonst Fehlertext fuer die Kasse. */
public function before_order($argument = null)
{
$provider = \Payments\Registry::get(self::MODULE, $this->base_object);
if (!$provider) {
return 'PayPal ist derzeit nicht verfuegbar. Bitte waehlen Sie eine andere Zahlart.';
}
return '';
}
public function during_order($argument = null)
{
return '';
}
/**
* Nach dem Speichern: Zahlung anlegen, Kunde per 302 zum Anbieter.
* Der POST bekommt damit einen Redirect als Antwort -- die Zurueck-Taste landet auf
* dem Kassenformular, nicht auf einem erneut abschickbaren POST.
*/
public function after_order($order_total = false)
{
$amount = (float) $order_total;
if ($this->gift_certificate && isset($this->gift_certificate->with_vat)) {
$amount -= (float) $this->gift_certificate->with_vat;
}
$amount = \round($amount, 2);
try {
$provider = \Payments\Registry::get(self::MODULE, $this->base_object);
if (!$provider) {
throw new \RuntimeException('Provider paypal_v2 nicht installiert');
}
// Express aus dem Warenkorb: der Kunde war schon bei PayPal, Adresse liegt vor.
// Betrag nachziehen und einziehen -- ohne zweiten PayPal-Besuch.
$express = $_SESSION['easyshop']['payment_express'] ?? null;
if (\is_array($express) && ($express['module'] ?? '') === self::MODULE && !empty($express['reference'])) {
$url = $provider->finishExpress((int) $this->order_id, $amount, (string) $express['reference']);
unset($_SESSION['easyshop']['payment_express']);
if ($url === '') {
\header('Location: /Kasse/?success=1&order=' . \rawurlencode($this->resolve_order_number((int) $this->order_id)));
exit;
}
} else {
$url = $provider->start((int) $this->order_id, $amount);
}
} catch (\Throwable $e) {
\error_log('PayPal v2 after_order (Order ' . $this->order_id . '): ' . $e->getMessage());
if (\function_exists('shop_event')) {
\shop_event('failed', [
'order_id' => $this->order_id, 'betrag' => $amount,
'customer_id' => $this->customer_data->id ?? null,
'detail' => 'paypal_v2: start fehlgeschlagen -- ' . $e->getMessage(),
]);
}
$this->layout_object->assign('call', 'payment_error');
$this->layout_object->assign('provider_label', 'PayPal');
$this->layout_object->assign('message', 'PayPal konnte nicht gestartet werden. Ihr Warenkorb ist noch da &ndash; bitte versuchen Sie es erneut oder waehlen Sie eine andere Zahlart.');
return $this->layout_object->_fetch('content_cash_desk/payment_result.tpl');
}
if (!\headers_sent()) {
\header('Location: ' . $url, true, 302);
exit;
}
// Es wurde schon Output gesendet: Weiterleitungsseite mit Link + Meta-Refresh.
$this->layout_object->assign('call', 'payment_open');
$this->layout_object->assign('provider_label', 'PayPal');
$this->layout_object->assign('payment_link', $url);
return $this->layout_object->_fetch('content_cash_desk/payment_result.tpl');
}
/** Rueckkehr laeuft ueber website_cashdesk::run() (?payment_return=paypal_v2), nicht hier. */
public function change_order()
{
return '';
}
private function resolve_order_number(int $orderId): string
{
$res = $this->base_object->db->query('SELECT order_number FROM orders WHERE id=' . $orderId . ' LIMIT 1');
$row = $res ? $res->fetch_object() : null;
return $row ? (string) $row->order_number : (string) $orderId;
}
}
@@ -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');
}
}
+526
View File
@@ -0,0 +1,526 @@
<?php
declare(strict_types=1);
namespace Payments;
/**
* Gemeinsamer Unterbau aller Anbieter mit Weiterleitung.
*
* Hier liegt alles, was 2026 am v1-PayPal muehsam nachgeruestet wurde und fuer
* jeden weiteren Anbieter genauso gelten muss:
* - Vorgang in payment_transactions mit echter Uhrzeit, Session und Sollbetrag
* - Bestellbestaetigung ERST nach eingezogener Zahlung
* - Warenkorb ERST nach eingezogener Zahlung leeren (ueber die Session von damals)
* - idempotentes "bezahlt": zweite Rueckkehr, Webhook-Race, Abgleicher -- egal wer
* zuerst kommt, es passiert genau einmal
* - Betragsabgleich gegen den Sollbetrag (Manipulationsschutz)
* - Ereignisse fuer den Funnel (shop_event) und Rohprotokoll (Payments\Log)
*
* Einstellungen kommen aus der Konfigurationsgruppe = module() (Tabelle configuration,
* pflegbar im Zahlart-Editor), Zugangsdaten aus Payments\Secrets.
*/
abstract class AbstractProvider implements ProviderInterface
{
protected object $base;
protected \mysqli $db;
/** @var array<string,string>|null */
private ?array $settings = null;
public function __construct(object $base)
{
$this->base = $base;
$this->db = $base->db;
}
public function paysAfterRedirect(): bool
{
return true;
}
// ------------------------------------------------------------ Einstellungen
/** Wert aus der Konfigurationsgruppe des Anbieters (configuration.group_id = module()). */
protected function setting(string $name, string $default = ''): string
{
if ($this->settings === null) {
$this->settings = [];
$g = $this->db->real_escape_string($this->module());
$res = $this->db->query("SELECT name, setting FROM configuration WHERE group_id='$g'");
while ($res && ($row = $res->fetch_assoc())) {
$this->settings[(string) $row['name']] = (string) $row['setting'];
}
}
$full = $this->module() . '_' . $name;
$v = $this->settings[$full] ?? $this->settings[$name] ?? '';
return $v !== '' ? $v : $default;
}
protected function secret(string $name): string
{
$v = Secrets::get($this->db, $this->module(), $name);
if ($v === null || $v === '') {
throw new \RuntimeException($this->label() . ': Zugangsdatum "' . $name . '" ist nicht hinterlegt (Backend > Zahlungsarten).');
}
return $v;
}
protected function shopBaseUrl(): string
{
if (\defined('MAIN_URL') && MAIN_URL !== '') {
return \rtrim((string) MAIN_URL, '/');
}
return 'https://' . ($_SERVER['SERVER_NAME'] ?? 'localhost');
}
/** Rueckkehr-URLs laufen durch den Cashdesk (Session vorhanden). */
protected function returnUrl(): string
{
return $this->shopBaseUrl() . '/Kasse/?payment_return=' . $this->module();
}
protected function cancelUrl(): string
{
return $this->shopBaseUrl() . '/Kasse/?payment_cancel=' . $this->module();
}
/** Webhook laeuft OHNE Session ueber den Standalone-Endpunkt. */
protected function webhookUrl(): string
{
return $this->shopBaseUrl() . '/payment_webhook.php?p=' . $this->module();
}
// ---------------------------------------------------------------- Vorgang
/**
* Legt den Vorgang an: Referenz beim Anbieter, Sollbetrag, Session, Approve-URL.
* Schreibt zusaetzlich orders.paypal_token (Backend zeigt das Feld bereits an).
*/
protected function openTransaction(int $orderId, string $reference, float $amount, string $approveUrl, ?array $raw = null): void
{
$order = $this->orderById($orderId);
$stmt = $this->db->prepare(
'INSERT INTO payment_transactions
(provider, order_id, order_number, reference, expected_amount, currency, status,
approve_url, session_id, customer_id, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,NOW(),NOW())
ON DUPLICATE KEY UPDATE reference=VALUES(reference), expected_amount=VALUES(expected_amount),
status=VALUES(status), approve_url=VALUES(approve_url), session_id=VALUES(session_id), updated_at=NOW()'
);
if (!$stmt) {
throw new \RuntimeException('payment_transactions: ' . $this->db->error);
}
$module = $this->module();
$number = (string) ($order->order_number ?? '');
$currency = $this->currency();
$status = 'open';
$sid = \function_exists('session_id') ? (string) \session_id() : '';
$cid = (int) ($order->customer_id ?? 0);
$stmt->bind_param('sissdssssi', $module, $orderId, $number, $reference, $amount, $currency, $status, $approveUrl, $sid, $cid);
$stmt->execute();
$stmt->close();
$ref = $this->db->real_escape_string($reference);
$this->db->query("UPDATE orders SET paypal_token='$ref' WHERE id=$orderId");
$this->pendingSet($orderId, $reference, $amount, $approveUrl);
$this->event('payment_started', $orderId, $number, $amount, $reference, null, $cid);
Log::write($module, 'start', $orderId, $number, $reference, 'open', null, $raw);
}
/**
* Gibt es zu dieser Anbieter-Referenz schon einen Vorgang (mit Bestellung)?
* Braucht die Kasse bei der Express-Rueckkehr: verlangt PayPal nach dem Betrags-PATCH
* eine zweite Freigabe, kommt der Kunde ueber die Express-URL zurueck, obwohl die
* Bestellung laengst existiert -- dann ist es eine normale Rueckkehr mit Capture.
*/
public function knownReference(string $reference): bool
{
return $reference !== '' && $this->transactionByReference($reference) !== null;
}
protected function transactionByReference(string $reference): ?object
{
$stmt = $this->db->prepare('SELECT * FROM payment_transactions WHERE provider=? AND reference=? LIMIT 1');
if (!$stmt) {
return null;
}
$m = $this->module();
$stmt->bind_param('ss', $m, $reference);
$stmt->execute();
$row = $stmt->get_result()->fetch_object();
$stmt->close();
return $row ?: null;
}
protected function transactionByOrder(int $orderId): ?object
{
$stmt = $this->db->prepare('SELECT * FROM payment_transactions WHERE provider=? AND order_id=? ORDER BY id DESC LIMIT 1');
if (!$stmt) {
return null;
}
$m = $this->module();
$stmt->bind_param('si', $m, $orderId);
$stmt->execute();
$row = $stmt->get_result()->fetch_object();
$stmt->close();
return $row ?: null;
}
/** Offene Vorgaenge fuer den Abgleicher. @return list<object> */
protected function openTransactions(int $olderThanMinutes, int $maxAgeHours): array
{
$stmt = $this->db->prepare(
'SELECT * FROM payment_transactions
WHERE provider=? AND status IN ("open","pending")
AND created_at < DATE_SUB(NOW(), INTERVAL ? MINUTE)
AND created_at > DATE_SUB(NOW(), INTERVAL ? HOUR)
ORDER BY id'
);
if (!$stmt) {
return [];
}
$m = $this->module();
$stmt->bind_param('sii', $m, $olderThanMinutes, $maxAgeHours);
$stmt->execute();
$rows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
$stmt->close();
return \array_map(static fn($r) => (object) $r, $rows);
}
protected function setTransactionStatus(string $reference, string $status, ?string $detail = null): void
{
$stmt = $this->db->prepare('UPDATE payment_transactions SET status=?, detail=?, updated_at=NOW() WHERE provider=? AND reference=?');
if ($stmt) {
$m = $this->module();
$stmt->bind_param('ssss', $status, $detail, $m, $reference);
$stmt->execute();
$stmt->close();
}
}
// ------------------------------------------------------------- bezahlt
/**
* Setzt die Bestellung genau einmal auf bezahlt, gleicht vorher den Betrag ab.
* Rueckgabe true, wenn die Bestellung jetzt (oder schon vorher) bezahlt ist.
*
* Atomar ueber payment_transactions.status: nur wer den Wechsel open/pending -> paid
* schafft, loest Mail und Warenkorb aus. Zweite Rueckkehr, Webhook und Abgleicher
* laufen dann ins Leere statt in Doppelmails.
*/
protected function markPaid(string $reference, string $paidAmount, string $paidCurrency, string $captureId, string $payerEmail = '', string $payerId = '', string $source = 'return'): bool
{
$tx = $this->transactionByReference($reference);
if (!$tx) {
\error_log($this->label() . ': markPaid ohne Vorgang (' . $reference . ')');
return false;
}
$orderId = (int) $tx->order_id;
if ($tx->status === 'paid') {
return true;
}
if (!$this->amountMatches((string) $tx->expected_amount, $paidAmount, (string) $tx->currency, $paidCurrency)) {
$msg = "Betrag weicht ab: erwartet {$tx->expected_amount} {$tx->currency}, gezahlt $paidAmount $paidCurrency";
\error_log($this->label() . " BETRAGS-MISMATCH Order $orderId ($reference): $msg");
$this->setTransactionStatus($reference, 'mismatch', $msg);
$this->event('failed', $orderId, (string) $tx->order_number, (float) $paidAmount, $reference, $captureId, (int) $tx->customer_id, $msg);
Log::write($this->module(), 'mismatch', $orderId, (string) $tx->order_number, $reference, 'mismatch', null, null, $msg);
return false;
}
// Der eine Schreiber gewinnt.
$stmt = $this->db->prepare(
'UPDATE payment_transactions SET status="paid", capture_id=?, paid_amount=?, paid_at=NOW(), updated_at=NOW(), detail=?
WHERE provider=? AND reference=? AND status IN ("open","pending")'
);
if (!$stmt) {
return false;
}
$m = $this->module();
$amt = (float) $paidAmount;
$stmt->bind_param('sdsss', $captureId, $amt, $source, $m, $reference);
$stmt->execute();
$won = $stmt->affected_rows === 1;
$stmt->close();
if (!$won) {
return $this->transactionByReference($reference)?->status === 'paid';
}
$cap = $this->db->real_escape_string($captureId);
$eml = $this->db->real_escape_string($payerEmail);
$pid = $this->db->real_escape_string($payerId);
$this->db->query(
"UPDATE orders SET payment_status=2, payment_date=CURDATE(), paypal_transaction_id='$cap'"
. ($payerEmail !== '' ? ", paypal_email='$eml'" : '')
. ($payerId !== '' ? ", paypal_payerid='$pid'" : '')
. " WHERE id=$orderId"
);
$this->clearCart((string) $tx->session_id);
$this->sendOrderConfirmation($orderId, (int) $tx->customer_id);
$this->pendingClear();
$this->event('paid', $orderId, (string) $tx->order_number, (float) $paidAmount, $reference, $captureId, (int) $tx->customer_id, 'via ' . $source);
return true;
}
/** Kunde hat abgebrochen oder der Anbieter hat den Vorgang verworfen. */
protected function markCancelled(string $reference, string $why, string $source = 'cancel'): void
{
$tx = $this->transactionByReference($reference);
if (!$tx || $tx->status === 'paid') {
return;
}
// 27.09.2026: idempotent -- Rueckkehr UND Webhook melden denselben Abbruch, der
// Stornotext wurde sonst zweimal an die Bestellung gehaengt (Mollie-Test 290617).
if ($tx->status === 'cancelled') {
return;
}
$this->setTransactionStatus($reference, 'cancelled', $why);
$orderId = (int) $tx->order_id;
// 6 = storniert (wie v1-PayPal im Cancel-Zweig).
// 22.09.2026 -- dazu die Storno-Felder der Bestellung fuellen, damit im Bestell-Editor
// steht, WARUM: bisher stand da nur "Storniert", der Grund lag allein in
// payment_transactions.detail. Vorhandene Eintraege werden nicht ueberschrieben.
$msg = $this->db->real_escape_string(
$this->label() . ': ' . $why . '. Zahlung nicht abgeschlossen, kein Geld geflossen. '
. 'Automatisch storniert (' . $source . ', ' . \date('d.m.Y H:i') . ').'
);
$reason = $this->cancellationReasonId();
$this->db->query(
"UPDATE orders SET order_status=6,
cancellation_date = COALESCE(cancellation_date, CURDATE()),
cancellation_status = IF(cancellation_status IS NULL OR cancellation_status=0, 2, cancellation_status),
cancellation_reason_id = " . ($reason > 0 ? "COALESCE(NULLIF(cancellation_reason_id,0), $reason)" : 'cancellation_reason_id') . ",
cancellation_info = IF(cancellation_info IS NULL OR cancellation_info='', '$msg', CONCAT(cancellation_info, '\n', '$msg'))
WHERE id=$orderId AND payment_status<>2"
);
$this->pendingClear();
$this->event('cancelled', $orderId, (string) $tx->order_number, (float) $tx->expected_amount, $reference, null, (int) $tx->customer_id, $why . ' (' . $source . ')');
Log::write($this->module(), 'cancel', $orderId, (string) $tx->order_number, $reference, 'cancelled', null, null, $why);
}
/** Stornogrund "Zahlung beim Anbieter nicht abgeschlossen" (install.sql), 0 wenn nicht angelegt. */
private function cancellationReasonId(): int
{
$res = $this->db->query("SELECT id FROM cancellation_reasons WHERE name='Zahlung beim Anbieter nicht abgeschlossen' LIMIT 1");
$r = $res ? $res->fetch_object() : null;
return $r ? (int) $r->id : 0;
}
protected function amountMatches(string $expected, string $paid, string $expCur, string $paidCur): bool
{
return \number_format((float) $expected, 2, '.', '') === \number_format((float) $paid, 2, '.', '')
&& \strtoupper($expCur) === \strtoupper($paidCur);
}
protected function currency(): string
{
return \strtoupper($this->setting('currency', 'EUR'));
}
// --------------------------------------------------------- Shop-Anbindung
protected function orderById(int $orderId): object
{
$res = $this->db->query("SELECT * FROM orders WHERE id=$orderId LIMIT 1");
$row = $res ? $res->fetch_object() : null;
if (!$row) {
throw new \RuntimeException('Bestellung ' . $orderId . ' nicht gefunden.');
}
return $row;
}
/** Lieferadresse der Bestellung fuer den Anbieter (Kaeufer-/Verkaeuferschutz). */
protected function shippingAddress(object $order): array
{
$iso = 'DE';
$cid = (int) ($order->shipping_country ?? 0);
if ($cid > 0) {
$res = $this->db->query("SELECT iso_code_2 FROM countries WHERE id=$cid");
$r = $res ? $res->fetch_object() : null;
if ($r && \strlen((string) $r->iso_code_2) === 2) {
$iso = \strtoupper((string) $r->iso_code_2);
}
}
return [
'name' => \trim(((string) ($order->shipping_firstname ?? '')) . ' ' . ((string) ($order->shipping_surname ?? ''))),
'company' => (string) ($order->shipping_company ?? ''),
'street' => \trim(((string) ($order->shipping_street ?? '')) . ' ' . ((string) ($order->shipping_house_number ?? ''))),
'zip' => (string) ($order->shipping_zip_code ?? ''),
'city' => (string) ($order->shipping_city ?? ''),
'country' => $iso,
'email' => (string) ($order->customer_email ?? ''),
];
}
/**
* Warenkorb leeren -- ueber die Session von damals UND die aktuelle. Der Kunde kann
* mit einer neuen Session zurueckkommen (Safari/iOS), der Webhook hat gar keine.
*/
protected function clearCart(string $originalSessionId): void
{
$ids = [];
if ($originalSessionId !== '') {
$ids[$originalSessionId] = true;
}
if (\function_exists('session_id') && \session_id() !== '') {
$ids[\session_id()] = true;
}
foreach (\array_keys($ids) as $sid) {
$s = $this->db->real_escape_string((string) $sid);
$this->db->query("DELETE FROM shopping_cart_items WHERE session_id='$s'");
}
}
/**
* Bestellbestaetigung ausloesen (Ticket-Event 3). Fehler werden protokolliert und
* geschluckt: das Geld ist eingezogen, eine Ausnahme wuerde dem Kunden eine
* Fehlerseite fuer eine erfolgreiche Bestellung zeigen.
*/
protected function sendOrderConfirmation(int $orderId, int $customerId): void
{
try {
if (!\class_exists('Cs_ticket')) {
$docroot = \defined('ROOT_DIR') ? \rtrim((string) ROOT_DIR, '/') : \dirname(__DIR__, 2);
require_once $docroot . '/core/cs_ticket.class.php';
}
$ticket = new \Cs_ticket($this->base);
$ticket->event(3, $customerId, $orderId);
Log::write($this->module(), 'mail', $orderId, null, null, 'sent');
} catch (\Throwable $e) {
\error_log($this->label() . ': Bestellbestaetigung fuer Order ' . $orderId . ' fehlgeschlagen: ' . $e->getMessage());
Log::write($this->module(), 'mail', $orderId, null, null, 'failed', null, null, $e->getMessage());
}
}
// ------------------------------------------------------ Session-Riegel
/** Offener Vorgang in der Session: Riegel gegen zweite Bestellung per Zurueck-Taste. */
protected function pendingSet(int $orderId, string $reference, float $amount, string $approveUrl): void
{
if (\session_status() !== PHP_SESSION_ACTIVE) {
return;
}
$_SESSION['easyshop']['payment_pending'] = [
'module' => $this->module(),
'reference' => $reference,
'order_id' => $orderId,
'total' => \round($amount, 2),
'approve_url' => $approveUrl,
'time' => \time(),
];
}
protected function pendingClear(): void
{
if (\session_status() === PHP_SESSION_ACTIVE) {
unset($_SESSION['easyshop']['payment_pending']);
}
}
/** Ist die Bestellung des offenen Vorgangs bezahlt? (fuer den Cashdesk-Riegel) */
public function isPaid(int $orderId): bool
{
$res = $this->db->query("SELECT payment_status FROM orders WHERE id=$orderId");
$r = $res ? $res->fetch_object() : null;
return $r && (string) $r->payment_status === '2';
}
// --------------------------------------------------------------- Events
protected function event(string $name, ?int $orderId, ?string $orderNumber, ?float $amount, ?string $reference, ?string $transactionId, ?int $customerId, ?string $detail = null): void
{
if (!\function_exists('shop_event')) {
return;
}
\shop_event($name, [
'order_id' => $orderId,
'order_number' => $orderNumber,
'payment_method' => $this->paymentMethodId(),
'betrag' => $amount,
'paypal_token' => $reference,
'transaction_id' => $transactionId,
'customer_id' => $customerId,
'detail' => \trim($this->module() . ': ' . (string) $detail),
]);
}
private ?int $pmId = null;
protected function paymentMethodId(): ?int
{
if ($this->pmId === null) {
$m = $this->db->real_escape_string($this->module());
$res = $this->db->query("SELECT id FROM payment_methods WHERE module='$m' LIMIT 1");
$r = $res ? $res->fetch_object() : null;
$this->pmId = $r ? (int) $r->id : 0;
}
return $this->pmId ?: null;
}
public function secretFields(): array
{
return [];
}
// ------------------------------------------------------- Express-Defaults
public function supportsExpress(): bool
{
return false;
}
public function startExpress(float $amount): string
{
throw new \RuntimeException($this->label() . ': kein Express-Weg');
}
public function onExpressReturn(array $query): ?array
{
return null;
}
public function finishExpress(int $orderId, float $amount, string $reference): string
{
throw new \RuntimeException($this->label() . ': kein Express-Weg');
}
/** Express-Rueckkehr laeuft ueber den Cashdesk, ohne Bestellung. */
protected function expressReturnUrl(): string
{
return $this->shopBaseUrl() . '/Kasse/?payment_express=' . $this->module();
}
protected function expressCancelUrl(): string
{
return $this->shopBaseUrl() . '/Warenkorb/?payment_express_cancel=' . $this->module();
}
/** Laender, aus denen Express-Adressen angenommen werden (ISO-2, kommagetrennt). Standard: nur DE. */
public function expressCountries(): string
{
return $this->setting('express_countries', 'DE');
}
/** countries.id zu einem ISO-3166-Alpha-2-Code, 0 wenn das Land im Shop nicht lieferbar ist. */
public function countryIdByIso(string $iso): int
{
$iso = $this->db->real_escape_string(\strtoupper($iso));
$res = $this->db->query("SELECT id FROM countries WHERE iso_code_2='$iso' ORDER BY rang DESC, id LIMIT 1");
$r = $res ? $res->fetch_object() : null;
return $r ? (int) $r->id : 0;
}
/** "Neuer Weg 5a" -> ["Neuer Weg", "5a"]; ohne erkennbare Nummer bleibt alles Strasse. */
public static function splitStreet(string $line): array
{
$line = \trim($line);
if (\preg_match('/^(.*?)[\s,]+(\d+[\s\-\/]?[a-zA-Z0-9\-\/]*)$/u', $line, $m)) {
return [\trim($m[1]), \trim($m[2])];
}
return [$line, ''];
}
}
+131
View File
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace Payments;
/**
* Protokoll aller Anbieter-Antworten in PostgreSQL (krempl_data.payment_log).
*
* Warum PG: MySQL `orders` hat keine Uhrzeit und keinen Platz fuer JSON. Hier liegt
* jede Anfrage/Antwort mit Zeitstempel -- die Beweiskette bei Rueckfragen von
* Kunden oder Anbietern.
*
* Faellt STILL aus (wie shop_event): ein Protokoll darf den Checkout nie gefaehrden.
* Verbindung kommt aus core/postgres/config.ini, Sektion [postgresql].
*/
final class Log
{
/** @var resource|\PgSql\Connection|null|false */
private static $conn = null;
private static bool $dead = false;
/**
* @param array<mixed>|null $payload wird als JSON abgelegt (Secrets vorher entfernen!)
*/
public static function write(
string $provider,
string $action,
?int $orderId = null,
?string $orderNumber = null,
?string $reference = null,
?string $status = null,
?int $httpStatus = null,
?array $payload = null,
?string $detail = null
): bool {
$c = self::conn();
if (!$c) {
return false;
}
$json = $payload === null ? null : \json_encode(self::scrub($payload), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$ok = @\pg_query_params(
$c,
'INSERT INTO payment_log
(provider, action, order_id, order_number, reference, status, http_status, payload, detail, host)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)',
[
$provider, $action, $orderId, $orderNumber, $reference, $status, $httpStatus,
$json, $detail, (string) ($_SERVER['SERVER_NAME'] ?? \gethostname()),
]
);
if (!$ok) {
self::$dead = true;
\error_log('payment_log: INSERT fehlgeschlagen -- ' . @\pg_last_error($c));
return false;
}
return true;
}
/**
* Verlauf einer Bestellung fuer den Bestell-Editor, aelteste zuerst.
* Payload bleibt draussen (Roh-JSON gehoert nicht in die Maske).
*
* @return list<array{zeit:string,action:string,status:?string,http_status:?string,detail:?string,reference:?string}>
*/
public static function forOrder(int $orderId, int $limit = 40): array
{
$c = self::conn();
if (!$c || $orderId <= 0) {
return [];
}
$host = (string) ($_SERVER['SERVER_NAME'] ?? \gethostname());
$res = @\pg_query_params(
$c,
"SELECT to_char(zeitpunkt, 'DD.MM.YYYY HH24:MI:SS') AS zeit, action, status, http_status, detail, reference
FROM payment_log
WHERE order_id = $1 AND host = $2
ORDER BY id
LIMIT $3",
[$orderId, $host, $limit]
);
if (!$res) {
return [];
}
return \pg_fetch_all($res) ?: [];
}
/** @return resource|\PgSql\Connection|false */
private static function conn()
{
if (self::$dead) {
return false;
}
if (self::$conn !== null) {
return self::$conn;
}
if (!\function_exists('pg_connect')) {
self::$dead = true;
return false;
}
$ini = @\parse_ini_file(\dirname(__DIR__) . '/postgres/config.ini', true);
if (!$ini || empty($ini['postgresql'])) {
self::$dead = true;
return false;
}
$p = $ini['postgresql'];
$dsn = \sprintf(
'host=%s port=%s dbname=%s user=%s password=%s connect_timeout=2',
$p['host'] ?? 'localhost', $p['port'] ?? '5432', $p['database'] ?? '', $p['user'] ?? '', $p['password'] ?? ''
);
$c = @\pg_connect($dsn);
if (!$c) {
self::$dead = true;
\error_log('payment_log: keine PG-Verbindung -- Protokoll uebersprungen');
return false;
}
return self::$conn = $c;
}
/** Entfernt Schluessel, die nach Zugangsdaten aussehen, rekursiv. */
private static function scrub(array $data): array
{
foreach ($data as $k => $v) {
if (\is_string($k) && \preg_match('/secret|password|token|authorization|client_id/i', $k)) {
$data[$k] = '***';
} elseif (\is_array($v)) {
$data[$k] = self::scrub($v);
}
}
return $data;
}
}
+121
View File
@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace Payments;
/**
* Lesesicht auf einen Zahlungsvorgang fuers Backend (Bestell-Editor, Block "Zahlungsvorgang").
*
* Anbieterneutral: liest payment_transactions (MySQL) und den Verlauf aus payment_log (PG).
* Kein Provider-Aufruf, keine API -- nur Anzeige. Faellt still auf null zurueck, wenn die
* Bestellung keinen Vorgang hat (alte Zahlarten, Vorkasse, Rechnung ...).
*/
final class Overview
{
private const STATUS = [
'open' => 'offen -- Kunde ist beim Anbieter',
'pending' => 'in Pruefung beim Anbieter',
'paid' => 'bezahlt',
'cancelled' => 'abgebrochen / storniert',
'mismatch' => 'BETRAG WEICHT AB -- pruefen!',
];
private const SOURCE = [
'return' => 'Rueckkehr des Kunden vom Anbieter',
'express' => 'Express-Kauf aus dem Warenkorb',
'webhook' => 'Meldung des Anbieters (Webhook)',
'reconcile' => 'Abgleicher (Cron)',
];
private const ACTION = [
'create' => 'Vorgang beim Anbieter angelegt',
'start' => 'Kunde zum Anbieter weitergeleitet',
'capture' => 'Einzug (Capture)',
'webhook' => 'Meldung vom Anbieter',
'mail' => 'Bestellbestaetigung an Kunden',
'cancel' => 'Storno',
'mismatch' => 'Betragsabweichung',
'express_create' => 'Express-Kauf gestartet',
'express_get' => 'Express: Freigabe abgeholt',
'express_patch' => 'Express: Betrag angepasst',
'express_reapprove' => 'Express: zweite Freigabe noetig',
];
/**
* @return array<string,mixed>|null Felder fuers Template oder null ohne Vorgang
*/
public static function forOrder(\mysqli $db, int $orderId, ?object $base = null): ?array
{
if ($orderId <= 0) {
return null;
}
$stmt = $db->prepare('SELECT * FROM payment_transactions WHERE order_id=? ORDER BY id DESC LIMIT 1');
if (!$stmt) {
return null;
}
$stmt->bind_param('i', $orderId);
$stmt->execute();
$tx = $stmt->get_result()->fetch_assoc();
$stmt->close();
if (!$tx) {
return null;
}
$module = (string) $tx['provider'];
$label = $module;
if ($base !== null && Registry::has($module)) {
$p = Registry::get($module, $base);
if ($p) {
$label = $p->label();
}
}
$status = (string) $tx['status'];
$detail = (string) ($tx['detail'] ?? '');
if ($status === 'paid') {
// markPaid() legt in detail nur die Quelle ab (return/express/webhook/reconcile).
$detail = 'bezahlt, bestaetigt ueber: ' . (self::SOURCE[$detail] ?? $detail);
}
return [
'provider' => $module,
'provider_label' => $label,
'status' => $status,
'status_label' => self::STATUS[$status] ?? $status,
'reference' => (string) $tx['reference'],
'capture_id' => (string) ($tx['capture_id'] ?? ''),
'expected_amount' => \number_format((float) $tx['expected_amount'], 2, ',', '.') . ' ' . $tx['currency'],
'paid_amount' => $tx['paid_amount'] !== null ? \number_format((float) $tx['paid_amount'], 2, ',', '.') . ' ' . $tx['currency'] : '',
'created_at' => self::de((string) $tx['created_at']),
'paid_at' => self::de((string) ($tx['paid_at'] ?? '')),
'updated_at' => self::de((string) $tx['updated_at']),
'detail' => $detail,
'history' => self::history($orderId),
];
}
/** @return list<array{zeit:string,aktion:string,status:string,http:string,detail:string}> */
private static function history(int $orderId): array
{
$out = [];
foreach (Log::forOrder($orderId, 40) as $row) {
$a = (string) $row['action'];
$out[] = [
'zeit' => (string) $row['zeit'],
'aktion' => self::ACTION[$a] ?? $a,
'status' => (string) ($row['status'] ?? ''),
'http' => $row['http_status'] !== null && $row['http_status'] !== '' ? 'HTTP ' . $row['http_status'] : '',
'detail' => (string) ($row['detail'] ?? ''),
];
}
return $out;
}
private static function de(string $dt): string
{
if ($dt === '' || \str_starts_with($dt, '0000')) {
return '';
}
$t = \strtotime($dt);
return $t ? \date('d.m.Y H:i:s', $t) : $dt;
}
}
+102
View File
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace Payments;
/**
* Vertrag fuer Zahlungsanbieter mit Weiterleitung (PayPal v2, spaeter Amazon Pay,
* Mollie, Klarna Payments ...).
*
* Lebenszyklus einer Zahlung:
* 1. Cashdesk legt die Shop-Bestellung an (Zahlstatus offen).
* 2. start() -> Zahlung beim Anbieter anlegen, Kunde per 302 weiterleiten.
* 3. onReturn() -> Kunde kommt zurueck: Zahlung abschliessen, Bestellung bezahlt setzen.
* onCancel() -> Kunde hat abgebrochen: Bestellung stornieren, Warenkorb bleibt.
* 4. onWebhook() -> Anbieter meldet serverseitig (unabhaengig vom Kunden).
* 5. reconcile() -> Cron: haengende Vorgaenge beim Anbieter nachfragen.
*
* Alles, was fuer alle Anbieter gleich ist (Mail erst nach Zahlung, Warenkorb erst
* nach Zahlung, Ereignisprotokoll, Betragsabgleich), lebt in AbstractProvider.
*/
interface ProviderInterface
{
/** Technischer Name = payment_methods.module = Konfigurationsgruppe, z.B. 'paypal_v2'. */
public function module(): string;
/** Anzeigename fuers Backend und Protokoll. */
public function label(): string;
/** true: die Bestellung ist erst nach Rueckkehr vom Anbieter bezahlt (Mail/Warenkorb warten). */
public function paysAfterRedirect(): bool;
/**
* Zahlung anlegen und Weiterleitungs-URL liefern.
*
* @param int $orderId Shop-Bestellung (orders.id)
* @param float $amount zu zahlender Bruttobetrag (nach Gutschein)
* @return string URL, zu der der Kunde geschickt wird
* @throws \RuntimeException wenn der Anbieter nicht erreichbar ist oder ablehnt
*/
public function start(int $orderId, float $amount): string;
/**
* Rueckkehr des Kunden vom Anbieter.
* @param array<string,mixed> $query $_GET der Rueckkehr-URL
*/
public function onReturn(array $query): Result;
/** @param array<string,mixed> $query */
public function onCancel(array $query): Result;
/**
* Server-zu-Server-Nachricht des Anbieters.
* @param array<string,string> $headers Request-Header, Schluessel kleingeschrieben
* @return int HTTP-Status, den der Endpunkt zurueckgeben soll (2xx = angenommen)
*/
public function onWebhook(array $headers, string $rawBody): int;
/**
* Haengende Vorgaenge nachziehen (Cron). Liefert eine kurze Zusammenfassung je Vorgang.
* @return list<string>
*/
public function reconcile(bool $dryRun = true): array;
// ------------------------------------------------------------ Express
// "Direkt bezahlen" aus dem Warenkorb: der Kunde geht OHNE Adresse zum Anbieter,
// der liefert Adresse + E-Mail, die Kasse ist damit vorbelegt, ein Klick auf
// "zahlungspflichtig bestellen" zieht das Geld ein -- kein zweiter Besuch beim Anbieter.
/** Bietet der Anbieter den Express-Weg an (und ist er eingeschaltet)? */
public function supportsExpress(): bool;
/**
* Express-Vorgang fuer den aktuellen Warenkorb anlegen (noch keine Bestellung!).
* @param float $amount Warenwert brutto ohne Versand (Land noch unbekannt)
* @return string URL zum Anbieter
*/
public function startExpress(float $amount): string;
/**
* Rueckkehr vom Anbieter im Express-Weg: Adresse und E-Mail des Kunden abholen.
* @param array<string,mixed> $query $_GET der Rueckkehr-URL
* @return array<string,mixed>|null Kundendaten fuer die Kasse (Schluessel: reference,
* email, firstname, surname, company, street, house_number, zip_code, city,
* country_iso, approve_url) oder null, wenn der Vorgang nicht zugeordnet werden kann
*/
public function onExpressReturn(array $query): ?array;
/**
* Express abschliessen, nachdem die Bestellung angelegt ist: Betrag beim Anbieter auf
* den Endbetrag (inkl. Versand) setzen und einziehen.
* @return string '' = bezahlt; sonst eine URL, zu der der Kunde noch einmal muss
* (Anbieter verlangt erneute Freigabe, z.B. nach Betragsaenderung)
*/
public function finishExpress(int $orderId, float $amount, string $reference): string;
/**
* Felder fuer den Zahlart-Editor im Backend, die NICHT in `configuration` liegen,
* sondern verschluesselt (Secrets). name => Beschriftung.
* @return array<string,string>
*/
public function secretFields(): array;
}
+87
View File
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace Payments;
/**
* Findet den Anbieter zu einem payment_methods.module.
*
* Konvention: core/payments/providers/<module>/Provider.php mit Klasse
* Payments\Providers\<Module in StudlyCase>\Provider
* Beispiel: module 'paypal_v2' -> Payments\Providers\PaypalV2\Provider
*
* Neuer Anbieter = neues Verzeichnis, sonst nichts.
*/
final class Registry
{
/** @var array<string,ProviderInterface|null> */
private static array $cache = [];
public static function has(string $module): bool
{
$module = self::clean($module);
return $module !== '' && \is_file(self::dir($module) . '/Provider.php');
}
public static function get(string $module, object $base): ?ProviderInterface
{
$module = self::clean($module);
if ($module === '') {
return null;
}
if (\array_key_exists($module, self::$cache)) {
return self::$cache[$module];
}
$file = self::dir($module) . '/Provider.php';
if (!\is_file($file)) {
return self::$cache[$module] = null;
}
self::requireCore();
require_once $file;
$class = 'Payments\\Providers\\' . self::studly($module) . '\\Provider';
if (!\class_exists($class)) {
\error_log('Payments: Provider-Klasse fehlt: ' . $class);
return self::$cache[$module] = null;
}
$p = new $class($base);
return self::$cache[$module] = ($p instanceof ProviderInterface ? $p : null);
}
/** Alle installierten Anbieter (fuer Backend-Liste und Abgleicher). @return list<string> */
public static function all(): array
{
$out = [];
foreach (\glob(__DIR__ . '/providers/*/Provider.php') ?: [] as $f) {
$out[] = \basename(\dirname($f));
}
\sort($out);
return $out;
}
public static function requireCore(): void
{
foreach (['ProviderInterface', 'Result', 'Secrets', 'Log', 'Overview', 'AbstractProvider'] as $c) {
require_once __DIR__ . '/' . $c . '.php';
}
}
private static function dir(string $module): string
{
return __DIR__ . '/providers/' . $module;
}
private static function clean(string $module): string
{
return \preg_match('/^[a-z0-9_]+$/', $module) ? $module : '';
}
private static function studly(string $module): string
{
return \str_replace('_', '', \ucwords($module, '_'));
}
}
// Wer die Registry laedt, bekommt die Kernklassen gleich mit (Secrets, Log, Result ...),
// nicht erst beim ersten Provider. Sonst faellt z.B. der Zahlart-Editor um, der nur
// Secrets braucht.
Registry::requireCore();
+43
View File
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Payments;
/**
* Ergebnis von onReturn()/onCancel(): sagt dem Cashdesk, was er dem Kunden zeigt.
*/
final class Result
{
public const PAID = 'paid'; // bezahlt -> Erfolgsseite
public const CANCELLED = 'cancelled'; // Kunde hat abgebrochen -> Kasse mit Hinweis
public const PENDING = 'pending'; // Anbieter noch nicht fertig -> Hinweis, Abgleicher zieht nach
public const ERROR = 'error'; // Fehler -> Hinweis, Warenkorb bleibt
public function __construct(
public readonly string $status,
public readonly int $orderId = 0,
public readonly string $orderNumber = '',
public readonly string $message = ''
) {
}
public static function paid(int $orderId, string $orderNumber): self
{
return new self(self::PAID, $orderId, $orderNumber);
}
public static function cancelled(int $orderId = 0, string $orderNumber = ''): self
{
return new self(self::CANCELLED, $orderId, $orderNumber);
}
public static function pending(int $orderId, string $orderNumber, string $message): self
{
return new self(self::PENDING, $orderId, $orderNumber, $message);
}
public static function error(string $message, int $orderId = 0, string $orderNumber = ''): self
{
return new self(self::ERROR, $orderId, $orderNumber, $message);
}
}
+158
View File
@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace Payments;
/**
* Verschluesselte Zugangsdaten der Zahlungsanbieter.
*
* Werte liegen in MySQL `payment_provider_secrets` (libsodium secretbox), der
* Schluessel liegt als Datei AUSSERHALB des Docroots:
* <vhost>/payments/secrets.key (chmod 600, nur der Site-User)
* Ein DB-Dump ohne die Datei ist damit wertlos, und die Datei ohne DB auch.
*
* Im Backend werden Secrets nie angezeigt, nur "hinterlegt seit ..." und ein Feld
* zum Ueberschreiben.
*/
final class Secrets
{
private static ?string $key = null;
public static function keyPath(): string
{
$docroot = $_SERVER['DOCUMENT_ROOT'] ?? '';
if ($docroot === '' || !\is_dir($docroot)) {
$docroot = \dirname(__DIR__, 2);
}
return \dirname(\rtrim($docroot, '/')) . '/payments/secrets.key';
}
/** Legt den Schluessel an, falls er fehlt (nur per CLI/Installation gedacht). */
public static function ensureKey(): string
{
$path = self::keyPath();
if (\is_file($path)) {
return $path;
}
$dir = \dirname($path);
if (!\is_dir($dir) && !@\mkdir($dir, 0700, true)) {
throw new \RuntimeException('Secrets: Verzeichnis nicht anlegbar: ' . $dir);
}
$key = \sodium_crypto_secretbox_keygen();
if (@\file_put_contents($path, \sodium_bin2hex($key), LOCK_EX) === false) {
throw new \RuntimeException('Secrets: Schluessel nicht schreibbar: ' . $path);
}
@\chmod($path, 0600);
return $path;
}
private static function key(): string
{
if (self::$key !== null) {
return self::$key;
}
$path = self::keyPath();
if (!\is_readable($path)) {
throw new \RuntimeException('Secrets: Schluesseldatei fehlt oder nicht lesbar: ' . $path);
}
$hex = \trim((string) \file_get_contents($path));
$key = \sodium_hex2bin($hex);
if (\strlen($key) !== SODIUM_CRYPTO_SECRETBOX_KEYBYTES) {
throw new \RuntimeException('Secrets: Schluesseldatei hat falsche Laenge.');
}
return self::$key = $key;
}
public static function keyAvailable(): bool
{
return \is_readable(self::keyPath());
}
public static function encrypt(string $plain): string
{
$nonce = \random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$box = \sodium_crypto_secretbox($plain, $nonce, self::key());
return 'v1:' . \sodium_bin2base64($nonce . $box, SODIUM_BASE64_VARIANT_ORIGINAL);
}
public static function decrypt(string $stored): string
{
if (\strncmp($stored, 'v1:', 3) !== 0) {
throw new \RuntimeException('Secrets: unbekanntes Format.');
}
$raw = \sodium_base642bin(\substr($stored, 3), SODIUM_BASE64_VARIANT_ORIGINAL);
$nonce = \substr($raw, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$box = \substr($raw, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$plain = \sodium_crypto_secretbox_open($box, $nonce, self::key());
if ($plain === false) {
throw new \RuntimeException('Secrets: Entschluesselung fehlgeschlagen (falscher Schluessel?).');
}
return $plain;
}
// ------------------------------------------------------------------ Ablage
/** @return string|null Klartext oder null, wenn nicht hinterlegt */
public static function get(\mysqli $db, string $provider, string $name): ?string
{
$stmt = $db->prepare('SELECT value_enc FROM payment_provider_secrets WHERE provider=? AND name=?');
if (!$stmt) {
return null;
}
$stmt->bind_param('ss', $provider, $name);
$stmt->execute();
$row = $stmt->get_result()->fetch_assoc();
$stmt->close();
if (!$row || $row['value_enc'] === '') {
return null;
}
return self::decrypt((string) $row['value_enc']);
}
public static function set(\mysqli $db, string $provider, string $name, string $plain): void
{
$enc = self::encrypt($plain);
$stmt = $db->prepare(
'INSERT INTO payment_provider_secrets (provider, name, value_enc, updated_at)
VALUES (?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE value_enc = VALUES(value_enc), updated_at = NOW()'
);
if (!$stmt) {
throw new \RuntimeException('Secrets: ' . $db->error);
}
$stmt->bind_param('sss', $provider, $name, $enc);
$stmt->execute();
$stmt->close();
}
public static function delete(\mysqli $db, string $provider, string $name): void
{
$stmt = $db->prepare('DELETE FROM payment_provider_secrets WHERE provider=? AND name=?');
if ($stmt) {
$stmt->bind_param('ss', $provider, $name);
$stmt->execute();
$stmt->close();
}
}
/**
* Fuers Backend: welche Secrets liegen vor und seit wann (ohne Klartext).
* @return array<string,string> name => updated_at
*/
public static function status(\mysqli $db, string $provider): array
{
$out = [];
$stmt = $db->prepare('SELECT name, updated_at FROM payment_provider_secrets WHERE provider=? AND value_enc<>""');
if (!$stmt) {
return $out;
}
$stmt->bind_param('s', $provider);
$stmt->execute();
$res = $stmt->get_result();
while ($row = $res->fetch_assoc()) {
$out[(string) $row['name']] = (string) $row['updated_at'];
}
$stmt->close();
return $out;
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
/**
* Standalone-Bootstrap fuer Endpunkte OHNE Shop-Router und OHNE Session:
* payment_webhook.php (Docroot) und core/payments/cli.php.
*
* Bindet den Shop-Kern (DB + Config, Konstanten ROOT_DIR/MAIN_URL) ein und stellt
* $base_object bereit. Session wird bewusst nicht gestartet.
*/
declare(strict_types=1);
\ini_set('display_errors', '0');
\error_reporting(E_ALL);
$docroot = $_SERVER['DOCUMENT_ROOT'] ?? '';
if ($docroot === '' || !\is_dir($docroot . '/core')) {
$docroot = \dirname(__DIR__, 2);
$_SERVER['DOCUMENT_ROOT'] = $docroot;
}
\chdir($docroot);
// base.class.php nutzt relative Includes und liest config.inc.php
require_once $docroot . '/core/base.class.php';
require_once $docroot . '/core/registry.class.php';
require_once $docroot . '/core/order.class.php';
if (\is_file($docroot . '/core/postgres/shop_events.php')) {
require_once $docroot . '/core/postgres/shop_events.php';
}
require_once __DIR__ . '/Registry.php';
\Payments\Registry::requireCore();
$base_object = new base();
Registry::set('base', $base_object);
// 27.09.2026 -- Layout-Objekt (Smarty) auch hier: die Bestellbestaetigung rendert die
// Positionen ueber DeliveryItemsHTML, das Registry::get('layout_object') holt. Ohne das
// scheiterte die Mail, wenn der WEBHOOK (nicht die Rueckkehr) die Zahlung abschloss
// ("Call to a member function assign() on null", Mollie-Test 290616). Der Kunde
// bekam dann keine Bestaetigung. start() braucht SERVER_NAME (CLI: aus MAIN_URL).
if (empty($_SERVER['SERVER_NAME']) && \defined('MAIN_URL')) {
$_SERVER['SERVER_NAME'] = (string) \parse_url((string) MAIN_URL, PHP_URL_HOST);
}
$_SERVER['REQUEST_URI'] = $_SERVER['REQUEST_URI'] ?? '/'; // layout.class.php liest sie beim Start
try {
require_once $docroot . '/libs/smarty/libs/Smarty.class.php';
require_once $docroot . '/core/layout.class.php';
$layout_object = new layout();
$layout_object->start($base_object);
Registry::set('layout_object', $layout_object);
} catch (\Throwable $e) {
\error_log('payments bootstrap: Layout nicht initialisiert -- ' . $e->getMessage());
}
+110
View File
@@ -0,0 +1,110 @@
<?php
/**
* Kommandozeile fuer die Zahlungsanbieter. Immer mit dem PHP des Shops aufrufen:
*
* /opt/plesk/php/8.2/bin/php core/payments/cli.php reconcile [modul] [--apply]
* Haengende Vorgaenge nachziehen. Ohne --apply nur Bericht.
* Cron-Vorschlag (alle 15 Minuten): ... cli.php reconcile --apply >> logs/payments_reconcile.log
*
* ... cli.php init-key
* Schluesseldatei fuer die Secrets anlegen (einmalig je Server).
*
* ... cli.php secret <modul> <name> [wert]
* Secret setzen (wert leer = von STDIN lesen, damit es nicht in der Shell-History landet).
*
* ... cli.php status
* Installierte Anbieter, Modus, hinterlegte Secrets, offene Vorgaenge.
*/
declare(strict_types=1);
if (PHP_SAPI !== 'cli') {
\http_response_code(403);
exit('cli only');
}
require __DIR__ . '/bootstrap.php';
use Payments\Registry as PR;
use Payments\Secrets;
$args = $argv;
\array_shift($args);
$cmd = $args[0] ?? 'status';
switch ($cmd) {
case 'init-key':
$path = Secrets::ensureKey();
echo "Schluessel: $path\n";
break;
case 'secret':
[$module, $name] = [$args[1] ?? '', $args[2] ?? ''];
if ($module === '' || $name === '') {
exit("usage: secret <modul> <name> [wert]\n");
}
$value = $args[3] ?? '';
if ($value === '') {
echo "Wert (Eingabe unsichtbar, Enter zum Beenden): ";
\system('stty -echo');
$value = \trim((string) \fgets(STDIN));
\system('stty echo');
echo "\n";
}
Secrets::set($base_object->db, $module, $name, $value);
echo "gespeichert: $module/$name (" . \strlen($value) . " Zeichen)\n";
break;
case 'reconcile':
$apply = \in_array('--apply', $args, true);
$modules = \array_values(\array_filter(\array_slice($args, 1), static fn($a) => $a !== '--apply'));
if (!$modules) {
$modules = PR::all();
}
foreach ($modules as $m) {
$p = PR::get($m, $base_object);
if (!$p) {
echo "[$m] nicht installiert\n";
continue;
}
try {
$lines = $p->reconcile(!$apply);
} catch (\Throwable $e) {
echo '[' . \date('Y-m-d H:i:s') . "] [$m] FEHLER " . $e->getMessage() . "\n";
continue;
}
foreach ($lines as $l) {
echo '[' . \date('Y-m-d H:i:s') . "] [$m] $l\n";
}
if (!$lines) {
echo '[' . \date('Y-m-d H:i:s') . "] [$m] nichts offen\n";
}
}
break;
case 'status':
default:
echo 'Schluesseldatei: ' . Secrets::keyPath() . ' -> ' . (Secrets::keyAvailable() ? 'vorhanden' : 'FEHLT') . "\n";
foreach (PR::all() as $m) {
$p = PR::get($m, $base_object);
if (!$p) {
continue;
}
$secrets = Secrets::keyAvailable() ? Secrets::status($base_object->db, $m) : [];
$res = $base_object->db->query("SELECT id, name, active, argument FROM payment_methods WHERE module='" . $base_object->db->real_escape_string($m) . "'");
// 27.09.2026: ein Anbieter kann mehrere Zahlarten tragen (Mollie: eine Zeile je Methode)
$zahlarten = [];
while ($res && ($pm = $res->fetch_object())) {
$zahlarten[] = "id {$pm->id} '{$pm->name}'" . ($pm->argument !== '' ? " [{$pm->argument}]" : '') . ' ' . ($pm->active ? 'aktiv' : 'inaktiv');
}
echo "\n[$m] " . $p->label() . (\method_exists($p, 'mode') ? ' Modus ' . $p->mode() : '') . "\n";
echo ' Zahlart: ' . ($zahlarten ? \implode(', ', $zahlarten) : 'NICHT angelegt') . "\n";
foreach ($p->secretFields() as $name => $label) {
echo " $label: " . (isset($secrets[$name]) ? 'hinterlegt (' . $secrets[$name] . ')' : '-') . "\n";
}
$res = $base_object->db->query("SELECT status, COUNT(*) c FROM payment_transactions WHERE provider='" . $base_object->db->real_escape_string($m) . "' GROUP BY status");
while ($res && ($r = $res->fetch_object())) {
echo " Vorgaenge {$r->status}: {$r->c}\n";
}
}
break;
}
+74
View File
@@ -0,0 +1,74 @@
-- ============================================================================
-- core/payments -- Installation (MySQL, Shop-DB)
-- Additiv: bestehende Zahlarten und Bestellungen werden nicht angefasst.
-- Reihenfolge: 1) Tabellen 2) Zahlart + Einstellungen 3) Kundengruppen (Test)
-- ============================================================================
-- 1a) Vorgaenge je Anbieter -- mit echter Uhrzeit, Session (Warenkorb) und Sollbetrag.
CREATE TABLE IF NOT EXISTS `payment_transactions` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`provider` VARCHAR(40) NOT NULL,
`order_id` BIGINT UNSIGNED NOT NULL,
`order_number` VARCHAR(50) NULL,
`reference` VARCHAR(191) NOT NULL COMMENT 'ID beim Anbieter (PayPal Order-ID)',
`expected_amount` DECIMAL(13,2) NOT NULL,
`currency` CHAR(3) NOT NULL DEFAULT 'EUR',
`status` ENUM('open','pending','paid','cancelled','mismatch') NOT NULL DEFAULT 'open',
`approve_url` TEXT NULL,
`session_id` VARCHAR(191) NULL,
`customer_id` INT UNSIGNED NULL,
`capture_id` VARCHAR(191) NULL,
`paid_amount` DECIMAL(13,2) NULL,
`paid_at` DATETIME NULL,
`detail` VARCHAR(500) NULL,
`created_at` DATETIME NOT NULL,
`updated_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_provider_reference` (`provider`,`reference`),
KEY `idx_order` (`order_id`),
KEY `idx_status_created` (`provider`,`status`,`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 1b) Verschluesselte Zugangsdaten (Schluessel liegt ausserhalb des Docroots).
CREATE TABLE IF NOT EXISTS `payment_provider_secrets` (
`provider` VARCHAR(40) NOT NULL,
`name` VARCHAR(60) NOT NULL,
`value_enc` TEXT NOT NULL,
`updated_at` DATETIME NOT NULL,
PRIMARY KEY (`provider`,`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 2a) Einstellungen der Zahlart (erscheinen im Zahlart-Editor unter "Einstellungen").
INSERT IGNORE INTO `configuration` (`name`, `text`, `group_id`, `setting`, `type`, `description`) VALUES
('paypal_v2_mode', '', 'paypal_v2', 'sandbox', 1, 'sandbox oder live'),
('paypal_v2_brand_name', '', 'paypal_v2', 'intelectra', 1, 'Anzeigename auf der PayPal-Seite'),
('paypal_v2_send_address', '', 'paypal_v2', '1', 1, '1 = Lieferadresse an PayPal uebergeben (Verkaeuferschutz), 0 = nicht'),
('paypal_v2_currency', '', 'paypal_v2', 'EUR', 1, 'Waehrung (ISO 4217)'),
('paypal_v2_express', '', 'paypal_v2', '1', 1, '1 = Button "Direkt mit PayPal bezahlen" im Warenkorb, 0 = aus'),
('paypal_v2_express_countries', '', 'paypal_v2', 'DE', 1, 'Laender (ISO-2, kommagetrennt), deren PayPal-Adresse im Express-Kauf angenommen wird');
-- 2a2) Stornogrund fuer automatische Stornos (Kunde hat beim Anbieter nicht bezahlt).
-- type 0 = Kunden Storno. markCancelled() sucht ihn ueber den Namen.
INSERT INTO `cancellation_reasons` (`name`, `type`, `active`)
SELECT 'Zahlung beim Anbieter nicht abgeschlossen', 0, 1
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `cancellation_reasons` WHERE `name` = 'Zahlung beim Anbieter nicht abgeschlossen');
-- 2b) Zahlart anlegen. active=1, aber noch KEINER Kundengruppe zugeordnet => im Shop unsichtbar.
-- helperjoinkey 'pp' = derselbe PayPal-Aufschlag wie die alte Zahlart (shipping_area.add_pp).
INSERT INTO `payment_methods`
(`name`, `module`, `argument`, `active`, `rang`, `invoice_text`, `order_text`, `order_email_text`, `helperjoinkey`, `configuration_group_id`)
SELECT 'PayPal', 'paypal_v2', '', 1, 2,
'Bezahlung erfolgte per PayPal',
'Sie werden zur Bezahlung Ihrer Bestellung zu PayPal weitergeleitet.',
'Der zu zahlende Betrag wurde per PayPal beglichen.',
'pp', 'paypal_v2'
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `payment_methods` WHERE `module` = 'paypal_v2');
-- 3) TEST: Zahlart nur fuer eine Kundengruppe sichtbar machen (Gruppen: 1 Endverbraucher,
-- 100 Besucher, 101 Haendler, 103 Gast). Beispiel Endverbraucher:
-- INSERT INTO `customer_group_payment_method_configuration` (`group_id`, `payment_method_id`, `shipping_area_id`, `active`)
-- SELECT 1, id, 1, 1 FROM payment_methods WHERE module='paypal_v2';
-- GO-LIVE (spaeter): alle Gruppen wie bei id=4 freischalten und die alte Zahlart verstecken:
-- UPDATE `customer_group_payment_method_configuration` SET active=0 WHERE payment_method_id=4;
-- UPDATE `payment_methods` SET active=0 WHERE id=4;
+108
View File
@@ -0,0 +1,108 @@
-- ============================================================================
-- core/payments -- Mollie (MySQL, Shop-DB). Additiv, mehrfach ausfuehrbar.
-- Voraussetzung: install.sql (payment_transactions, payment_provider_secrets) ist drin.
--
-- Getrennte Gateways je Zahlungsart (Tom, 27.09.2026): je Mollie-Methode EINE Zeile in
-- payment_methods mit module='mollie' und argument=<Methode>. Mollie zeigt dann nur
-- diese Methode, keine Auswahl bei Mollie, kein abweichendes Warenkorb-Verhalten.
--
-- Reihenfolge: 1) Versandgebiet-Spalten 2) Einstellungen 3) Zahlarten 4) Freischaltung (manuell)
-- ============================================================================
-- 1) Die Kasse haengt jede Zahlart ueber helperjoinkey an vier Spalten in shipping_area
-- (active_/add_/account_type_/free_payment_<key>). Neue Keys = neue Spalten.
-- Aufschlaege sind bei Karten/Klarna/Wallets ohnehin nicht zulaessig -> add=0, aktiv=1.
ALTER TABLE `shipping_area`
ADD COLUMN IF NOT EXISTS `active_mollie_klarna` TINYINT(2) NOT NULL DEFAULT 2,
ADD COLUMN IF NOT EXISTS `add_mollie_klarna` DECIMAL(9,2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS `account_type_mollie_klarna` TINYINT(4) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS `free_payment_mollie_klarna` DECIMAL(6,2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS `active_mollie_creditcard` TINYINT(2) NOT NULL DEFAULT 2,
ADD COLUMN IF NOT EXISTS `add_mollie_creditcard` DECIMAL(9,2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS `account_type_mollie_creditcard` TINYINT(4) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS `free_payment_mollie_creditcard` DECIMAL(6,2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS `active_mollie_applepay` TINYINT(2) NOT NULL DEFAULT 2,
ADD COLUMN IF NOT EXISTS `add_mollie_applepay` DECIMAL(9,2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS `account_type_mollie_applepay` TINYINT(4) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS `free_payment_mollie_applepay` DECIMAL(6,2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS `active_mollie_googlepay` TINYINT(2) NOT NULL DEFAULT 2,
ADD COLUMN IF NOT EXISTS `add_mollie_googlepay` DECIMAL(9,2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS `account_type_mollie_googlepay` TINYINT(4) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS `free_payment_mollie_googlepay` DECIMAL(6,2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS `active_mollie_paybybank` TINYINT(2) NOT NULL DEFAULT 2,
ADD COLUMN IF NOT EXISTS `add_mollie_paybybank` DECIMAL(9,2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS `account_type_mollie_paybybank` TINYINT(4) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS `free_payment_mollie_paybybank` DECIMAL(6,2) NOT NULL DEFAULT 0;
-- 2) Einstellungen (Zahlart-Editor, Reiter "Einstellungen", Gruppe mollie). API-Keys sind
-- Secrets (verschluesselt), nicht hier: test_api_key, live_api_key.
INSERT IGNORE INTO `configuration` (`name`, `text`, `group_id`, `setting`, `type`, `description`) VALUES
('mollie_mode', '', 'mollie', 'test', 1, 'test oder live (waehlt den API-Key)'),
('mollie_locale', '', 'mollie', 'de_DE', 1, 'Sprache der Mollie-Seite (xx_XX)'),
('mollie_currency', '', 'mollie', 'EUR', 1, 'Waehrung (ISO 4217)'),
('mollie_description', '', 'mollie', 'Bestellung [#order_number]', 1, 'Verwendungszweck auf dem Kontoauszug des Kunden');
-- 3) Zahlarten: eine Zeile je Methode. active=1, aber noch KEINER Kundengruppe zugeordnet
-- => im Shop unsichtbar, bis Schritt 4 laeuft. argument = Mollie-Methode (Pflicht).
INSERT INTO `payment_methods`
(`name`, `module`, `argument`, `active`, `rang`, `invoice_text`, `order_text`, `order_email_text`, `helperjoinkey`, `configuration_group_id`)
SELECT 'Klarna', 'mollie', 'klarna', 1, 3,
'Bezahlung erfolgte per Klarna',
'Sie werden zur Bezahlung Ihrer Bestellung zu Klarna weitergeleitet (Rechnung, Ratenkauf oder Sofort bezahlen).',
'Der zu zahlende Betrag wird ueber Klarna abgewickelt.',
'mollie_klarna', 'mollie'
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `payment_methods` WHERE `module`='mollie' AND `argument`='klarna');
INSERT INTO `payment_methods`
(`name`, `module`, `argument`, `active`, `rang`, `invoice_text`, `order_text`, `order_email_text`, `helperjoinkey`, `configuration_group_id`)
SELECT 'Kreditkarte', 'mollie', 'creditcard', 1, 4,
'Bezahlung erfolgte per Kreditkarte',
'Sie werden zur sicheren Eingabe Ihrer Kartendaten weitergeleitet (Visa, Mastercard, American Express).',
'Der zu zahlende Betrag wurde per Kreditkarte beglichen.',
'mollie_creditcard', 'mollie'
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `payment_methods` WHERE `module`='mollie' AND `argument`='creditcard');
INSERT INTO `payment_methods`
(`name`, `module`, `argument`, `active`, `rang`, `invoice_text`, `order_text`, `order_email_text`, `helperjoinkey`, `configuration_group_id`)
SELECT 'Apple Pay', 'mollie', 'applepay', 1, 5,
'Bezahlung erfolgte per Apple Pay',
'Sie werden zur Bezahlung mit Apple Pay weitergeleitet.',
'Der zu zahlende Betrag wurde per Apple Pay beglichen.',
'mollie_applepay', 'mollie'
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `payment_methods` WHERE `module`='mollie' AND `argument`='applepay');
INSERT INTO `payment_methods`
(`name`, `module`, `argument`, `active`, `rang`, `invoice_text`, `order_text`, `order_email_text`, `helperjoinkey`, `configuration_group_id`)
SELECT 'Google Pay', 'mollie', 'googlepay', 1, 6,
'Bezahlung erfolgte per Google Pay',
'Sie werden zur Bezahlung mit Google Pay weitergeleitet.',
'Der zu zahlende Betrag wurde per Google Pay beglichen.',
'mollie_googlepay', 'mollie'
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `payment_methods` WHERE `module`='mollie' AND `argument`='googlepay');
-- Google Pay: Mollie lehnt method=googlepay im Redirect-Verfahren ab (HTTP 500, 27.09.2026) --
-- gibt es nur als Direkt-Integration mit Token. Zeile bleibt angelegt, aber inaktiv.
UPDATE `payment_methods` SET active=0 WHERE module='mollie' AND argument='googlepay';
-- "Online-Banking" = Mollie Pay by Bank (Open Banking, Nachfolger von Sofortueberweisung).
-- NICHT "Sofortueberweisung" nennen: eingetragene Marke der Sofort GmbH (Klarna).
-- Status nach Freigabe erst pending, paid kommt per Webhook (Echtzeit: Minuten, sonst 2-3 Werktage).
INSERT INTO `payment_methods`
(`name`, `module`, `argument`, `active`, `rang`, `invoice_text`, `order_text`, `order_email_text`, `helperjoinkey`, `configuration_group_id`)
SELECT 'Online-Banking', 'mollie', 'paybybank', 1, 7,
'Bezahlung erfolgte per Online-Banking (Pay by Bank)',
'Sie zahlen direkt von Ihrem Bankkonto: Bank waehlen, im Online-Banking freigeben, fertig.',
'Der zu zahlende Betrag wurde per Online-Banking ueberwiesen.',
'mollie_paybybank', 'mollie'
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `payment_methods` WHERE `module`='mollie' AND `argument`='paybybank');
-- 4) FREISCHALTUNG (manuell, nach Test): Kundengruppen 1 Endverbraucher, 103 Gast
-- (Haendler 101 bewusst nicht -- die zahlen kundenspezifisch), Versandgebiet 1.
-- INSERT INTO `customer_group_payment_method_configuration` (`group_id`, `payment_method_id`, `shipping_area_id`, `active`)
-- SELECT g.id, pm.id, 1, 1 FROM payment_methods pm JOIN customer_groups g ON g.id IN (1,103)
-- WHERE pm.module='mollie'
-- AND NOT EXISTS (SELECT 1 FROM customer_group_payment_method_configuration c WHERE c.group_id=g.id AND c.payment_method_id=pm.id AND c.shipping_area_id=1);
-- 5) LEICHEN (nach Go-Live): alte Klarna-/paymorrow-/BILLPAY-/computop-Zahlarten verstecken.
-- Nicht loeschen -- Bestellungen von frueher zeigen noch auf die ids.
-- UPDATE `payment_methods` SET active=0 WHERE id IN (8,9,10,11,12,13);
+20
View File
@@ -0,0 +1,20 @@
-- core/payments -- Rohprotokoll in PostgreSQL (krempl_data)
-- Jede Anfrage/Antwort eines Anbieters mit Zeitstempel. Die Beweiskette.
CREATE TABLE IF NOT EXISTS payment_log (
id BIGSERIAL PRIMARY KEY,
provider TEXT NOT NULL,
action TEXT NOT NULL, -- start, create, capture, get, webhook, mail, cancel, mismatch
order_id INTEGER,
order_number TEXT,
reference TEXT, -- ID beim Anbieter
status TEXT,
http_status INTEGER,
payload JSONB,
detail TEXT,
host TEXT, -- dev.intelectra.de / intelectra-shop.de
zeitpunkt TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_payment_log_order ON payment_log (order_id);
CREATE INDEX IF NOT EXISTS idx_payment_log_reference ON payment_log (reference);
CREATE INDEX IF NOT EXISTS idx_payment_log_zeit ON payment_log (zeitpunkt);
CREATE INDEX IF NOT EXISTS idx_payment_log_provider ON payment_log (provider, action, zeitpunkt);
+60
View File
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Payments\Providers\Mollie;
/**
* Duenner HTTP-Client fuer die Mollie-API v2 (https://api.mollie.com/v2/...).
*
* Auth ist ein einzelner API-Key als Bearer-Token (test_... oder live_...), kein OAuth.
* TLS-Pruefung erzwungen. POSTs bekommen einen Idempotency-Key, damit ein Retry
* innerhalb einer Stunde dieselbe Zahlung liefert statt einer zweiten.
*/
final class Client
{
private const BASE = 'https://api.mollie.com';
public function __construct(private readonly string $apiKey)
{
}
/**
* @param array<string,mixed>|null $payload
* @param list<string> $extraHeaders
* @return array{0:int,1:array<mixed>}
*/
public function request(string $method, string $path, ?array $payload = null, array $extraHeaders = []): array
{
$headers = \array_merge([
'Authorization: Bearer ' . $this->apiKey,
'Content-Type: application/json',
'Accept: application/json',
'User-Agent: intelectra-shop/1.0 core-payments-mollie',
], $extraHeaders);
$ch = \curl_init(self::BASE . $path);
\curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_TIMEOUT => 30,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_HTTPHEADER => $headers,
]);
if ($payload !== null) {
\curl_setopt($ch, CURLOPT_POSTFIELDS, (string) \json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
$resp = \curl_exec($ch);
if ($resp === false) {
$err = \curl_error($ch);
\curl_close($ch);
throw new \RuntimeException('Mollie HTTP-Transportfehler: ' . $err);
}
$status = (int) \curl_getinfo($ch, CURLINFO_HTTP_CODE);
\curl_close($ch);
$decoded = $resp === '' ? [] : \json_decode((string) $resp, true);
return [$status, \is_array($decoded) ? $decoded : ['raw' => (string) $resp]];
}
}
+609
View File
@@ -0,0 +1,609 @@
<?php
declare(strict_types=1);
namespace Payments\Providers\Mollie;
use Payments\AbstractProvider;
use Payments\Log;
use Payments\Result;
require_once __DIR__ . '/Client.php';
/**
* Mollie, Payments API v2 -- EIN Provider, mehrere Zahlarten.
*
* Toms Vorgabe (27.09.2026): getrennte Gateways je Zahlungsart, nicht "Mollie waehlt".
* Darum steht in payment_methods je Zahlart EINE Zeile mit module='mollie' und
* argument=<Mollie-Methode> (klarna, creditcard, applepay, googlepay, ...). Beim Anlegen
* der Zahlung wird `method` fest gesetzt, die Mollie-Seite zeigt nur diese Methode.
*
* Ablauf: start() legt die Zahlung an (POST /v2/payments) und leitet zur Checkout-URL.
* Die Rueckkehr-URL traegt unsere Bestellnummer (o=<orders.id>), weil Mollie beim
* Redirect nichts anhaengt. onReturn()/onCancel()/onWebhook()/reconcile() holen den
* Zahlungsstatus IMMER per GET bei Mollie -- der Webhook selbst traegt nur die ID und
* ist nicht signiert; die Abfrage ist die Verifikation.
*
* Status bei Mollie: open, pending, authorized (nicht final) / paid, canceled, expired,
* failed (final). Klarna verlangt lines + billingAddress; Betrag = Summe der lines.
*
* Einstellungen (configuration, Gruppe mollie): mode (test|live), locale, currency, description
* Secrets (payment_provider_secrets): test_api_key, live_api_key
*/
final class Provider extends AbstractProvider
{
private ?Client $client = null;
/** Anzeigenamen je Mollie-Methode (fuer Protokoll und Hinweistexte). */
private const LABELS = [
'klarna' => 'Klarna',
'creditcard' => 'Kreditkarte',
'applepay' => 'Apple Pay',
'googlepay' => 'Google Pay',
'paypal' => 'PayPal',
'banktransfer' => 'Ueberweisung',
'ideal' => 'iDEAL',
'bancontact' => 'Bancontact',
'eps' => 'EPS',
'twint' => 'TWINT',
'trustly' => 'Trustly',
'paybybank' => 'Pay by Bank',
'riverty' => 'Riverty',
'in3' => 'in3',
'billie' => 'Billie',
];
/** Methoden, fuer die Mollie Positionen (lines) und volle Adressen verlangt. */
private const NEEDS_LINES = ['klarna', 'billie', 'in3', 'riverty', 'billink', 'voucher'];
public function module(): string
{
return 'mollie';
}
public function label(): string
{
return 'Mollie';
}
public function secretFields(): array
{
return [
'test_api_key' => 'Test-API-Key (test_...)',
'live_api_key' => 'Live-API-Key (live_...)',
'webhook_secret' => 'Webhook-Signing-Secret (nur fuer Next-Gen-Webhooks aus Dashboard > Entwickler > Webhooks; leer = klassische Webhooks)',
];
}
public function mode(): string
{
return $this->setting('mode', 'test') === 'live' ? 'live' : 'test';
}
private function client(): Client
{
if ($this->client === null) {
$this->client = new Client($this->secret($this->mode() . '_api_key'));
}
return $this->client;
}
public static function methodLabel(string $method): string
{
return self::LABELS[$method] ?? \ucfirst($method);
}
/** Mollie-Methode der Zahlart, die an der Bestellung haengt (payment_methods.argument). */
public function methodForOrder(object $order): string
{
$pmId = (int) ($order->payment_method ?? 0);
$res = $this->db->query("SELECT module, argument FROM payment_methods WHERE id=$pmId LIMIT 1");
$row = $res ? $res->fetch_object() : null;
if (!$row || (string) $row->module !== $this->module()) {
throw new \RuntimeException('Mollie: Zahlart ' . $pmId . ' der Bestellung ist keine Mollie-Zahlart.');
}
$method = \strtolower(\trim((string) $row->argument));
if ($method === '' || !\preg_match('/^[a-z0-9]{2,30}$/', $method)) {
throw new \RuntimeException('Mollie: Zahlart ' . $pmId . ' hat keine Mollie-Methode im Feld "argument" (z.B. klarna, creditcard, applepay).');
}
return $method;
}
// ------------------------------------------------------------------ start
public function start(int $orderId, float $amount): string
{
$order = $this->orderById($orderId);
$number = (string) $order->order_number;
$method = $this->methodForOrder($order);
$payload = $this->buildPayload($order, $amount, $method);
// Deterministischer Idempotency-Key je (Bestellung, Betrag): ein zweiter Klick oder
// Retry innerhalb einer Stunde bekommt dieselbe Zahlung zurueck.
$idem = 'shop-' . $orderId . '-' . \substr(\hash('sha256', $number . '|' . $payload['amount']['value'] . '|' . $method), 0, 24);
[$status, $body] = $this->client()->request('POST', '/v2/payments', $payload, ['Idempotency-Key: ' . $idem]);
Log::write($this->module(), 'create', $orderId, $number, (string) ($body['id'] ?? ''), (string) ($body['status'] ?? ''), $status,
['request' => $payload, 'response' => $body]);
if ($status !== 201 || empty($body['id'])) {
throw new \RuntimeException('Mollie: Zahlung konnte nicht angelegt werden (HTTP ' . $status . '): '
. (string) ($body['detail'] ?? \json_encode($body)) . (isset($body['field']) ? ' [Feld ' . $body['field'] . ']' : ''));
}
$checkout = (string) ($body['_links']['checkout']['href'] ?? '');
if ($checkout === '') {
throw new \RuntimeException('Mollie: keine Checkout-URL in der Antwort.');
}
$this->openTransaction($orderId, (string) $body['id'], $amount, $checkout);
$this->setTransactionStatus((string) $body['id'], 'open', 'method ' . $method);
return $checkout;
}
/**
* Anfrage an Mollie zusammenbauen. Oeffentlich, damit sie sich ohne API-Key auf der
* Kommandozeile pruefen laesst (Betrag = Summe der lines usw.).
* @return array<string,mixed>
*/
public function buildPayload(object $order, float $amount, string $method): array
{
$orderId = (int) $order->id;
$number = (string) $order->order_number;
$value = \number_format($amount, 2, '.', '');
$description = \str_replace('[#order_number]', $number, $this->setting('description', 'Bestellung [#order_number]'));
$payload = [
'amount' => ['currency' => $this->currency(), 'value' => $value],
'description' => \mb_substr($description, 0, 255),
'redirectUrl' => $this->returnUrl() . '&o=' . $orderId,
'cancelUrl' => $this->cancelUrl() . '&o=' . $orderId,
'webhookUrl' => $this->webhookUrl(),
'method' => $method,
'locale' => $this->setting('locale', 'de_DE'),
'metadata' => ['order_id' => $orderId, 'order_number' => $number, 'shop' => $this->shopBaseUrl()],
];
$billing = $this->address($order, 'billing');
if ($billing !== null) {
$payload['billingAddress'] = $billing;
}
$shipping = $this->address($order, 'shipping');
if ($shipping !== null) {
$payload['shippingAddress'] = $shipping;
}
if (\in_array($method, self::NEEDS_LINES, true)) {
$payload['lines'] = $this->lines($order, $amount);
}
return $payload;
}
// ----------------------------------------------------------------- return
public function onReturn(array $query): Result
{
return $this->afterRedirect($query, false);
}
public function onCancel(array $query): Result
{
return $this->afterRedirect($query, true);
}
/**
* Rueckkehr des Kunden (Erfolg oder Abbruch-URL): Vorgang ueber unsere Bestellnummer
* finden, Status bei Mollie holen, danach handeln. Der Abbruch-Link ist keine
* Wahrheit -- auch dort wird nachgefragt.
*/
private function afterRedirect(array $query, bool $viaCancelUrl): Result
{
$orderId = (int) ($query['o'] ?? 0);
$tx = $orderId > 0 ? $this->transactionByOrder($orderId) : null;
if (!$tx) {
$p = $_SESSION['easyshop']['payment_pending'] ?? null;
if (\is_array($p) && ($p['module'] ?? '') === $this->module() && !empty($p['reference'])) {
$tx = $this->transactionByReference((string) $p['reference']);
}
}
if (!$tx) {
$this->pendingClear();
return $viaCancelUrl
? Result::cancelled()
: Result::error('Der Zahlungsvorgang konnte nicht zugeordnet werden. Ihr Warenkorb ist noch da.');
}
if ($tx->status === 'paid') {
$this->pendingClear();
return Result::paid((int) $tx->order_id, (string) $tx->order_number);
}
try {
$payment = $this->fetch((string) $tx->reference, (int) $tx->order_id, (string) $tx->order_number);
} catch (\Throwable $e) {
\error_log('Mollie return: ' . $e->getMessage());
return Result::error('Die Rueckmeldung des Zahlungsanbieters ist gerade nicht erreichbar. Ihr Warenkorb ist noch da &ndash; bitte versuchen Sie es in einer Minute erneut.',
(int) $tx->order_id, (string) $tx->order_number);
}
return $this->apply($tx, $payment, $viaCancelUrl ? 'cancel' : 'return');
}
/**
* Zahlungsstatus von Mollie auf den Vorgang anwenden -- eine Stelle fuer Rueckkehr,
* Webhook und Abgleicher.
* @param array<mixed> $payment
*/
private function apply(object $tx, array $payment, string $source): Result
{
$ref = (string) $tx->reference;
$orderId = (int) $tx->order_id;
$number = (string) $tx->order_number;
$status = (string) ($payment['status'] ?? '');
$method = (string) ($payment['method'] ?? '');
$label = $method !== '' ? self::methodLabel($method) : 'Mollie';
switch ($status) {
case 'paid':
$ok = $this->markPaid($ref, (string) ($payment['amount']['value'] ?? ''), (string) ($payment['amount']['currency'] ?? ''),
$ref, $this->payerEmail($payment), (string) ($payment['details']['consumerAccount'] ?? ''), $source . ' ' . $method);
if ($ok) {
return Result::paid($orderId, $number);
}
return Result::error('Die Zahlung konnte nicht zugeordnet werden. Bitte kontaktieren Sie uns unter Angabe der Bestellnummer ' . $number . '.', $orderId, $number);
case 'authorized':
case 'pending':
$this->setTransactionStatus($ref, 'pending', $label . ': ' . $status . ' (' . $source . ')');
return Result::pending($orderId, $number, $label . ' hat die Zahlung angenommen und prueft sie noch. Sie erhalten die Bestellbestaetigung, sobald die Zahlung bestaetigt ist.');
case 'canceled':
$this->markCancelled($ref, 'Kunde hat bei ' . $label . ' abgebrochen', $source);
return Result::cancelled($orderId, $number);
case 'expired':
$this->markCancelled($ref, $label . ': Zahlung abgelaufen (expired)', $source);
return Result::cancelled($orderId, $number);
case 'failed':
$this->markCancelled($ref, $label . ': Zahlung fehlgeschlagen (failed)', $source);
return Result::error('Die Zahlung ueber ' . $label . ' wurde nicht angenommen. Ihr Warenkorb ist noch da &ndash; bitte waehlen Sie eine andere Zahlart.', $orderId, $number);
case 'open':
default:
// Kunde ist ohne Abschluss zurueck (Fenster zu, Zurueck-Taste). Der Vorgang
// bleibt offen: Mollie laesst ihn verfallen, der Abgleicher raeumt auf.
// Der Session-Riegel schickt einen zweiten "Bestellen"-Klick auf dieselbe Checkout-URL.
$this->setTransactionStatus($ref, 'open', $label . ': ' . ($status ?: 'unbekannt') . ' bei Rueckkehr (' . $source . ')');
return Result::error('Die Zahlung ueber ' . $label . ' wurde nicht abgeschlossen. Ihr Warenkorb ist noch da &ndash; Sie koennen es erneut versuchen oder eine andere Zahlart waehlen.', $orderId, $number);
}
}
// ---------------------------------------------------------------- webhook
/**
* Zwei Webhook-Formen, ein Endpunkt (27.09.2026, docs.mollie.com/reference/webhooks-new):
*
* klassisch application/x-www-form-urlencoded, genau ein Feld id=tr_... , unsigniert.
* Haengt an jeder Zahlung (webhookUrl). Mollie empfiehlt diese Form fuer
* Zahlungsstatus weiterhin.
* next-gen JSON {resource:"event", type:"payment.paid", entityId:"tr_..."}, signiert
* mit HMAC-SHA256 ueber den rohen Body im Header X-Mollie-Signature
* ("sha256=<hex>"), Schluessel = Signing-Secret der Subscription (Dashboard >
* Entwickler > Webhooks). Ist ein Secret hinterlegt, MUSS die Signatur passen;
* ohne Secret werden JSON-Events abgelehnt (kein blindes Vertrauen).
*
* In beiden Faellen ist die Wahrheit der GET auf die Zahlung -- der Webhook ist nur der
* Anstoss. Antwort 200 = angenommen, alles andere wiederholt Mollie bis zu 10x in 26 h.
*/
public function onWebhook(array $headers, string $rawBody): int
{
$id = '';
$body = \ltrim($rawBody);
if ($body !== '' && $body[0] === '{') {
$event = \json_decode($body, true);
if (!\is_array($event) || ($event['resource'] ?? '') !== 'event') {
Log::write($this->module(), 'webhook', null, null, null, 'rejected', null, ['body' => \mb_substr($rawBody, 0, 500)], 'JSON ohne resource=event');
return 400;
}
$check = $this->verifySignature($headers, $rawBody);
if ($check !== 'ok') {
Log::write($this->module(), 'webhook', null, null, (string) ($event['entityId'] ?? ''), 'rejected', null,
['type' => $event['type'] ?? '', 'header' => $headers['x-mollie-signature'] ?? ''], 'Signatur: ' . $check);
return 400;
}
$type = (string) ($event['type'] ?? '');
$id = (string) ($event['entityId'] ?? '');
if (!\str_starts_with($type, 'payment.')) {
// andere Ereignisse (Auszahlungen, Rechnungen ...) nur protokollieren
Log::write($this->module(), 'webhook', null, null, $id, 'ignored', null, ['type' => $type], 'kein Zahlungsereignis');
return 200;
}
} else {
$params = [];
\parse_str($rawBody, $params);
$id = (string) ($params['id'] ?? ($_POST['id'] ?? ''));
}
if (!\preg_match('/^tr_[A-Za-z0-9]{6,40}$/', $id)) {
Log::write($this->module(), 'webhook', null, null, $id, 'rejected', null, ['body' => \mb_substr($rawBody, 0, 500)], 'keine Zahlungs-ID');
return 400;
}
$tx = $this->transactionByReference($id);
if (!$tx) {
// Unbekannt (z.B. Test aus dem Mollie-Dashboard): protokollieren, 200 damit Mollie nicht 26 h lang wiederholt.
Log::write($this->module(), 'webhook', null, null, $id, 'unknown', null, null, 'kein Vorgang zu dieser ID');
return 200;
}
try {
$payment = $this->fetch($id, (int) $tx->order_id, (string) $tx->order_number, 'webhook');
} catch (\Throwable $e) {
\error_log('Mollie webhook ' . $id . ': ' . $e->getMessage());
return 500; // Mollie versucht es spaeter noch einmal
}
if ($tx->status === 'paid') {
return 200;
}
$this->apply($tx, $payment, 'webhook');
return 200;
}
/**
* HMAC-SHA256 ueber den unveraenderten Body mit dem Signing-Secret. Bei Secret-Rotation
* schickt Mollie 24 h lang zwei Signaturen (kommagetrennt) -- eine muss passen.
* @param array<string,string> $headers
*/
private function verifySignature(array $headers, string $rawBody): string
{
$secret = \Payments\Secrets::get($this->db, $this->module(), 'webhook_secret') ?? '';
if ($secret === '') {
return 'kein webhook_secret hinterlegt';
}
$header = (string) ($headers['x-mollie-signature'] ?? '');
if ($header === '') {
return 'Header X-Mollie-Signature fehlt';
}
$expected = \hash_hmac('sha256', $rawBody, $secret);
foreach (\explode(',', $header) as $sig) {
$sig = \strtolower(\trim($sig));
if (\str_starts_with($sig, 'sha256=')) {
$sig = \substr($sig, 7);
}
if ($sig !== '' && \hash_equals($expected, $sig)) {
return 'ok';
}
}
return 'Signatur passt nicht';
}
// -------------------------------------------------------------- reconcile
public function reconcile(bool $dryRun = true): array
{
$out = [];
// Nach 10 Minuten nachsehen, nach 72 Stunden aufgeben.
foreach ($this->openTransactions(10, 72) as $tx) {
$ref = (string) $tx->reference;
try {
$payment = $this->fetch($ref, (int) $tx->order_id, (string) $tx->order_number, 'reconcile');
} catch (\Throwable $e) {
$out[] = "$ref (Order {$tx->order_id}): API-Fehler " . $e->getMessage();
continue;
}
$status = (string) ($payment['status'] ?? '');
$age = (\time() - \strtotime((string) $tx->created_at)) / 3600;
$tag = $dryRun ? ' [dry-run]' : '';
if ($status === 'paid') {
$out[] = "$ref (Order {$tx->order_id}): bei Mollie bezahlt, im Shop offen -> bezahlt setzen$tag";
} elseif (\in_array($status, ['canceled', 'expired', 'failed'], true)) {
$out[] = "$ref (Order {$tx->order_id}): Mollie $status -> storniert$tag";
} elseif ($age >= 24) {
// Nach 24 h ohne Abschluss: bei Mollie abbrechen (wenn noch moeglich) und stornieren.
$out[] = "$ref (Order {$tx->order_id}): seit " . \round($age) . " h $status -> abbrechen + storniert$tag";
if (!$dryRun) {
if (!empty($payment['isCancelable'])) {
[$s, $b] = $this->client()->request('DELETE', '/v2/payments/' . \rawurlencode($ref));
Log::write($this->module(), 'cancel_api', (int) $tx->order_id, (string) $tx->order_number, $ref, (string) ($b['status'] ?? ''), $s, $b);
}
$this->markCancelled($ref, 'Mollie ' . $status . ' nach ' . \round($age) . ' h ohne Abschluss', 'reconcile');
}
continue;
} else {
$out[] = "$ref (Order {$tx->order_id}): $status, " . \round($age, 1) . " h alt -> warten";
continue;
}
if (!$dryRun) {
$this->apply($tx, $payment, 'reconcile');
}
}
return $out;
}
// ----------------------------------------------------------------- intern
/** GET /v2/payments/{id} mit Protokoll. @return array<mixed> */
private function fetch(string $id, int $orderId, string $number, string $action = 'get'): array
{
[$status, $body] = $this->client()->request('GET', '/v2/payments/' . \rawurlencode($id));
Log::write($this->module(), $action, $orderId, $number, $id, (string) ($body['status'] ?? ''), $status, $body);
if ($status !== 200 || !isset($body['status'])) {
throw new \RuntimeException('Mollie GET payment ' . $id . ' fehlgeschlagen (HTTP ' . $status . '): ' . (string) ($body['detail'] ?? \json_encode($body)));
}
return $body;
}
/** @param array<mixed> $payment */
private function payerEmail(array $payment): string
{
$d = $payment['details'] ?? [];
foreach (['consumerAccount', 'payerEmail', 'email'] as $k) {
$v = (string) ($d[$k] ?? '');
if ($v !== '' && \str_contains($v, '@')) {
return $v;
}
}
return (string) ($payment['billingAddress']['email'] ?? '');
}
/**
* Adresse der Bestellung im Mollie-Format. null, wenn keine Strasse da ist (dann lieber
* gar keine Adresse als eine, die Mollie ablehnt).
* @return array<string,string>|null
*/
private function address(object $order, string $kind): ?array
{
$p = static fn(string $f) => \trim((string) ($order->{$kind . '_' . $f} ?? ''));
$street = \trim($p('street') . ' ' . $p('house_number'));
if ($street === '' || $p('zip_code') === '' || $p('city') === '') {
return null;
}
$iso = 'DE';
$cid = (int) ($order->{$kind . '_country'} ?? 0);
if ($cid > 0) {
$res = $this->db->query("SELECT iso_code_2 FROM countries WHERE id=$cid");
$r = $res ? $res->fetch_object() : null;
if ($r && \strlen((string) $r->iso_code_2) === 2) {
$iso = \strtoupper((string) $r->iso_code_2);
}
}
$first = $p('firstname');
$last = $p('surname');
$company = $p('company');
// Klarna verlangt Vor- und Nachname mit je >= 2 Zeichen; Firmenkunden ohne Person: Firma als Name.
if (\mb_strlen($first) < 2 && $company !== '') {
$first = $company;
}
if (\mb_strlen($last) < 2) {
$last = $company !== '' ? $company : $first;
}
$a = [
'givenName' => \mb_substr($first, 0, 100),
'familyName' => \mb_substr($last, 0, 100),
'streetAndNumber' => \mb_substr($street, 0, 100),
'postalCode' => $p('zip_code'),
'city' => $p('city'),
'country' => $iso,
];
if ($company !== '') {
$a['organizationName'] = \mb_substr($company, 0, 100);
}
$email = $this->customerEmail($order);
if ($email !== '') {
$a['email'] = $email;
}
$phone = $this->phoneE164((string) ($order->customer_phone ?? ''), $iso);
if ($phone !== '') {
$a['phone'] = $phone;
}
return $a;
}
private function customerEmail(object $order): string
{
$e = \trim((string) ($order->customer_email ?? ''));
if ($e === '') {
$cid = (int) ($order->customer_id ?? 0);
$res = $cid > 0 ? $this->db->query("SELECT email FROM customers WHERE id=$cid") : null;
$r = $res ? $res->fetch_object() : null;
$e = $r ? \trim((string) $r->email) : '';
}
return \filter_var($e, FILTER_VALIDATE_EMAIL) ? $e : '';
}
/** Telefon nach E.164 (nur Ziffern, Landesvorwahl); unbrauchbare Eingaben fallen weg. */
private function phoneE164(string $raw, string $iso): string
{
$d = \preg_replace('/[^0-9+]/', '', $raw) ?? '';
if ($d === '') {
return '';
}
if (\str_starts_with($d, '00')) {
$d = '+' . \substr($d, 2);
} elseif (\str_starts_with($d, '0') && $iso === 'DE') {
$d = '+49' . \substr($d, 1);
} elseif (!\str_starts_with($d, '+')) {
return '';
}
return \preg_match('/^\+[1-9][0-9]{6,14}$/', $d) ? $d : '';
}
/**
* Positionen fuer Klarna & Co.: Artikel (brutto je Stueck aus Netto + Steuersatz),
* Versand, Zahlartaufschlag, Gutschein, ggf. Rundungsausgleich -- die Summe der
* totalAmount MUSS dem Zahlbetrag entsprechen, sonst lehnt Mollie ab.
* @return list<array<string,mixed>>
*/
public function lines(object $order, float $amount): array
{
$cur = $this->currency();
$money = static fn(float $v) => ['currency' => $cur, 'value' => \number_format($v, 2, '.', '')];
$lines = [];
$sum = 0.0;
$maxRate = 0.0;
$orderId = (int) $order->id;
$res = $this->db->query("SELECT item_id, item_number, item_name, amount, price, tax_rate FROM order_item WHERE order_id=$orderId AND (parent_item_id IS NULL OR parent_item_id=0) ORDER BY id");
while ($res && ($it = $res->fetch_object())) {
$qty = \max(1, (int) $it->amount);
$rate = (float) $it->tax_rate;
$maxRate = \max($maxRate, $rate);
$unit = \round((float) $it->price * (1 + $rate / 100), 2);
$total = \round($unit * $qty, 2);
$lines[] = [
'type' => 'physical',
'description' => \mb_substr(\trim((string) $it->item_name) !== '' ? (string) $it->item_name : ('Artikel ' . $it->item_number), 0, 255),
'sku' => \mb_substr((string) $it->item_number, 0, 64),
'quantity' => $qty,
'quantityUnit'=> 'pcs',
'unitPrice' => $money($unit),
'totalAmount' => $money($total),
'vatRate' => \number_format($rate, 2, '.', ''),
'vatAmount' => $money(\round($total * $rate / (100 + $rate), 2)),
];
$sum += $total;
}
if (!$lines) {
throw new \RuntimeException('Mollie: Bestellung ' . $orderId . ' hat keine Positionen.');
}
$rate = $maxRate > 0 ? $maxRate : 19.0;
$ship = \round((float) ($order->shipping_charges ?? 0), 2);
if ($ship > 0) {
$lines[] = ['type' => 'shipping_fee', 'description' => 'Versand', 'quantity' => 1,
'unitPrice' => $money($ship), 'totalAmount' => $money($ship),
'vatRate' => \number_format($rate, 2, '.', ''), 'vatAmount' => $money(\round($ship * $rate / (100 + $rate), 2))];
$sum += $ship;
}
$fee = \round((float) ($order->payment_method_charges ?? 0), 2);
if ($fee > 0) {
$lines[] = ['type' => 'surcharge', 'description' => 'Zahlartgebuehr', 'quantity' => 1,
'unitPrice' => $money($fee), 'totalAmount' => $money($fee),
'vatRate' => \number_format($rate, 2, '.', ''), 'vatAmount' => $money(\round($fee * $rate / (100 + $rate), 2))];
$sum += $fee;
} elseif ($fee < 0) {
$lines[] = ['type' => 'discount', 'description' => 'Zahlartrabatt', 'quantity' => 1,
'unitPrice' => $money($fee), 'totalAmount' => $money($fee),
'vatRate' => \number_format($rate, 2, '.', ''), 'vatAmount' => $money(\round($fee * $rate / (100 + $rate), 2))];
$sum += $fee;
}
$gift = \round((float) ($order->gift_certificate_total ?? 0), 2);
if ($gift > 0) {
$lines[] = ['type' => 'gift_card', 'description' => 'Gutschein', 'quantity' => 1,
'unitPrice' => $money(-$gift), 'totalAmount' => $money(-$gift), 'vatRate' => '0.00', 'vatAmount' => $money(0)];
$sum -= $gift;
}
$disc = \round((float) ($order->discount_total ?? 0), 2) + \round((float) ($order->rebate_total ?? 0), 2);
if ($disc > 0) {
$lines[] = ['type' => 'discount', 'description' => 'Rabatt', 'quantity' => 1,
'unitPrice' => $money(-$disc), 'totalAmount' => $money(-$disc),
'vatRate' => \number_format($rate, 2, '.', ''), 'vatAmount' => $money(\round(-$disc * $rate / (100 + $rate), 2))];
$sum -= $disc;
}
// Rundung: Rechenwege des Shops (Netto-Summen, dann Steuer) und der Zeilen (brutto je Stueck)
// koennen um Cents auseinanderliegen. Ausgleich als eigene Zeile ohne Steuer.
$diff = \round($amount - $sum, 2);
if (\abs($diff) >= 0.01) {
$lines[] = ['type' => $diff > 0 ? 'surcharge' : 'discount', 'description' => 'Rundungsausgleich', 'quantity' => 1,
'unitPrice' => $money($diff), 'totalAmount' => $money($diff), 'vatRate' => '0.00', 'vatAmount' => $money(0)];
}
return $lines;
}
}
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace Payments\Providers\PaypalV2;
/**
* Duenner HTTP-Client fuer die PayPal-REST-API (Orders v2).
* TLS-Pruefung erzwungen. Access-Token wird pro Modus im Cache-Verzeichnis neben dem
* Secrets-Schluessel abgelegt (ausserhalb des Docroots).
*/
final class Client
{
public function __construct(
private readonly string $apiBase,
private readonly string $clientId,
private readonly string $secret,
private readonly string $cacheFile
) {
}
public function accessToken(): string
{
if (\is_file($this->cacheFile)) {
$c = \json_decode((string) @\file_get_contents($this->cacheFile), true);
if (\is_array($c) && ($c['base'] ?? '') === $this->apiBase && ($c['cid'] ?? '') === \substr($this->clientId, 0, 8)
&& isset($c['token'], $c['expires']) && (int) $c['expires'] > \time() + 60) {
return (string) $c['token'];
}
}
[$status, $body] = $this->raw('POST', '/v1/oauth2/token', 'grant_type=client_credentials',
['Content-Type: application/x-www-form-urlencoded'], $this->clientId . ':' . $this->secret);
$data = \json_decode($body, true);
if ($status !== 200 || !\is_array($data) || empty($data['access_token'])) {
throw new \RuntimeException('PayPal OAuth2 fehlgeschlagen (HTTP ' . $status . ')');
}
@\file_put_contents($this->cacheFile, \json_encode([
'token' => $data['access_token'], 'expires' => \time() + (int) ($data['expires_in'] ?? 300),
'base' => $this->apiBase, 'cid' => \substr($this->clientId, 0, 8),
]), LOCK_EX);
@\chmod($this->cacheFile, 0600);
return (string) $data['access_token'];
}
/**
* @param array<string,mixed>|null $payload
* @param list<string> $extraHeaders
* @return array{0:int,1:array<mixed>}
*/
public function request(string $method, string $path, array|string|null $payload = null, array $extraHeaders = []): array
{
$headers = \array_merge(['Content-Type: application/json', 'Authorization: Bearer ' . $this->accessToken()], $extraHeaders);
// string = fertiges JSON (z.B. mit dem rohen Webhook-Body eingebettet), wird unveraendert gesendet
$bodyStr = $payload === null ? null : (\is_string($payload) ? $payload : (string) \json_encode($payload, JSON_UNESCAPED_UNICODE));
[$status, $body] = $this->raw($method, $path, $bodyStr, $headers);
$decoded = $body === '' ? [] : \json_decode($body, true);
return [$status, \is_array($decoded) ? $decoded : ['raw' => $body]];
}
/** @param list<string> $headers @return array{0:int,1:string} */
private function raw(string $method, string $path, ?string $body, array $headers, ?string $basicAuth = null): array
{
$ch = \curl_init($this->apiBase . $path);
\curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method, CURLOPT_RETURNTRANSFER => true,
CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_TIMEOUT => 30, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_HTTPHEADER => $headers,
]);
if ($basicAuth !== null) {
\curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
\curl_setopt($ch, CURLOPT_USERPWD, $basicAuth);
}
if ($body !== null) {
\curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$resp = \curl_exec($ch);
if ($resp === false) {
$err = \curl_error($ch);
\curl_close($ch);
throw new \RuntimeException('PayPal HTTP-Transportfehler: ' . $err);
}
$status = (int) \curl_getinfo($ch, CURLINFO_HTTP_CODE);
\curl_close($ch);
return [$status, (string) $resp];
}
}
@@ -0,0 +1,637 @@
<?php
declare(strict_types=1);
namespace Payments\Providers\PaypalV2;
use Payments\AbstractProvider;
use Payments\Log;
use Payments\Result;
use Payments\Secrets;
require_once __DIR__ . '/Client.php';
/**
* PayPal Checkout, Orders API v2 (REST). Ersetzt das NVP-Express-Checkout von 2010.
*
* Ablauf: start() legt eine PayPal-Order (intent CAPTURE) an und leitet zur
* Approve-URL. onReturn() captured und setzt bezahlt. onWebhook() bestaetigt
* PAYMENT.CAPTURE.COMPLETED serverseitig. reconcile() zieht Vorgaenge nach, deren
* Rueckkehr verloren ging (APPROVED aber nie gecaptured).
*
* Einstellungen (configuration, Gruppe paypal_v2): mode, brand_name, send_address, currency
* Secrets (payment_provider_secrets): sandbox_client_id, sandbox_secret,
* live_client_id, live_secret, webhook_id
*/
final class Provider extends AbstractProvider
{
private ?Client $client = null;
public function module(): string
{
return 'paypal_v2';
}
public function label(): string
{
return 'PayPal';
}
public function secretFields(): array
{
return [
'sandbox_client_id' => 'Sandbox Client-ID',
'sandbox_secret' => 'Sandbox Secret',
'live_client_id' => 'Live Client-ID',
'live_secret' => 'Live Secret',
'webhook_id' => 'Webhook-ID (aus dem PayPal-Dashboard, fuer den aktiven Modus)',
];
}
public function mode(): string
{
return $this->setting('mode', 'sandbox') === 'live' ? 'live' : 'sandbox';
}
private function apiBase(): string
{
return $this->mode() === 'live' ? 'https://api-m.paypal.com' : 'https://api-m.sandbox.paypal.com';
}
private function client(): Client
{
if ($this->client === null) {
$m = $this->mode();
$this->client = new Client(
$this->apiBase(),
$this->secret($m . '_client_id'),
$this->secret($m . '_secret'),
\dirname(Secrets::keyPath()) . '/paypal_v2_' . $m . '.token'
);
}
return $this->client;
}
// ------------------------------------------------------------------ start
public function start(int $orderId, float $amount): string
{
$order = $this->orderById($orderId);
$number = (string) $order->order_number;
$value = \number_format($amount, 2, '.', '');
$unit = [
'reference_id' => (string) $orderId,
'custom_id' => (string) $orderId,
'invoice_id' => $number . '-' . $orderId,
'description' => 'Bestellung ' . $number,
'amount' => ['currency_code' => $this->currency(), 'value' => $value],
];
$context = [
'brand_name' => $this->setting('brand_name', 'intelectra'),
'locale' => 'de-DE',
'landing_page' => 'NO_PREFERENCE',
'user_action' => 'PAY_NOW',
'return_url' => $this->returnUrl(),
'cancel_url' => $this->cancelUrl(),
'shipping_preference' => 'NO_SHIPPING',
];
// Lieferadresse mitgeben: Voraussetzung fuer PayPal-Verkaeuferschutz bei Warenversand.
if ($this->setting('send_address', '1') === '1') {
$a = $this->shippingAddress($order);
if ($a['street'] !== '' && $a['zip'] !== '' && $a['city'] !== '') {
$context['shipping_preference'] = 'SET_PROVIDED_ADDRESS';
$unit['shipping'] = [
'name' => ['full_name' => $a['name'] !== '' ? $a['name'] : $a['company']],
'address' => [
'address_line_1' => $a['street'],
'address_line_2' => $a['company'],
'postal_code' => $a['zip'],
'admin_area_2' => $a['city'],
'country_code' => $a['country'],
],
];
}
}
$payload = ['intent' => 'CAPTURE', 'purchase_units' => [$unit], 'application_context' => $context];
$requestId = 'create-' . $orderId . '-' . \substr(\hash('sha256', $number . '|' . $value), 0, 16);
[$status, $body] = $this->client()->request('POST', '/v2/checkout/orders', $payload, ['PayPal-Request-Id: ' . $requestId]);
Log::write($this->module(), 'create', $orderId, $number, (string) ($body['id'] ?? ''), (string) ($body['status'] ?? ''), $status, $body);
if ($status !== 201 || empty($body['id'])) {
throw new \RuntimeException('PayPal Order-Anlage fehlgeschlagen (HTTP ' . $status . '): ' . \json_encode($body['details'] ?? $body));
}
$approve = '';
foreach (($body['links'] ?? []) as $link) {
if (\in_array($link['rel'] ?? '', ['approve', 'payer-action'], true)) {
$approve = (string) $link['href'];
break;
}
}
if ($approve === '') {
throw new \RuntimeException('PayPal: kein Approve-Link in der Antwort.');
}
$this->openTransaction($orderId, (string) $body['id'], $amount, $approve);
return $approve;
}
// ----------------------------------------------------------------- return
public function onReturn(array $query): Result
{
$ref = (string) ($query['token'] ?? '');
if ($ref === '') {
return Result::error('Der PayPal-Vorgang konnte nicht zugeordnet werden. Ihr Warenkorb ist noch da.');
}
$tx = $this->transactionByReference($ref);
if (!$tx) {
return Result::error('Der PayPal-Vorgang ist unbekannt. Ihr Warenkorb ist noch da.');
}
$orderId = (int) $tx->order_id;
$number = (string) $tx->order_number;
if ($tx->status === 'paid') {
$this->pendingClear();
return Result::paid($orderId, $number);
}
try {
$cap = $this->capture($ref, $orderId, $number);
} catch (\Throwable $e) {
\error_log('PayPal v2 onReturn: ' . $e->getMessage());
$this->event('failed', $orderId, $number, null, $ref, null, (int) $tx->customer_id, $e->getMessage());
return Result::error('Beim Abschluss der PayPal-Zahlung ist ein Fehler aufgetreten. Ihr Warenkorb ist noch da, bitte versuchen Sie es erneut oder waehlen Sie eine andere Zahlart.', $orderId, $number);
}
if ($cap['capture_status'] === 'COMPLETED') {
if ($this->markPaid($ref, $cap['amount'], $cap['currency'], $cap['capture_id'], $cap['payer_email'], $cap['payer_id'], 'return')) {
return Result::paid($orderId, $number);
}
return Result::error('Die Zahlung konnte nicht zugeordnet werden. Bitte kontaktieren Sie uns unter Angabe der Bestellnummer ' . $number . '.', $orderId, $number);
}
if ($cap['capture_status'] === 'PENDING') {
$this->setTransactionStatus($ref, 'pending', 'capture PENDING: ' . ($cap['pending_reason'] ?? ''));
return Result::pending($orderId, $number, 'PayPal prueft die Zahlung noch. Sie erhalten die Bestellbestaetigung, sobald die Zahlung bestaetigt ist.');
}
$this->event('failed', $orderId, $number, null, $ref, $cap['capture_id'], (int) $tx->customer_id, 'capture ' . $cap['capture_status']);
return Result::error('PayPal hat die Zahlung nicht abgeschlossen (' . ($cap['capture_status'] ?: $cap['order_status']) . '). Ihr Warenkorb ist noch da.', $orderId, $number);
}
public function onCancel(array $query): Result
{
$ref = (string) ($query['token'] ?? '');
if ($ref === '') {
$p = $_SESSION['easyshop']['payment_pending'] ?? null;
if (\is_array($p) && ($p['module'] ?? '') === $this->module()) {
$ref = (string) $p['reference'];
}
}
if ($ref !== '') {
$tx = $this->transactionByReference($ref);
if ($tx && $tx->status === 'paid') {
return Result::paid((int) $tx->order_id, (string) $tx->order_number);
}
$this->markCancelled($ref, 'Kunde hat bei PayPal abgebrochen');
return Result::cancelled((int) ($tx->order_id ?? 0), (string) ($tx->order_number ?? ''));
}
$this->pendingClear();
return Result::cancelled();
}
// ---------------------------------------------------------------- webhook
public function onWebhook(array $headers, string $rawBody): int
{
$event = \json_decode($rawBody, true);
if (!\is_array($event)) {
return 400;
}
$type = (string) ($event['event_type'] ?? '');
$verify = $this->verifyWebhook($headers, $rawBody);
if ($verify !== 'SUCCESS') {
Log::write($this->module(), 'webhook', null, null, (string) ($event['id'] ?? ''), 'rejected', null,
['event_type' => $type, 'header_keys' => \array_keys(\array_filter($headers, static fn($k) => \str_starts_with($k, 'paypal-'), ARRAY_FILTER_USE_KEY))],
'Signatur ungueltig: ' . $verify);
return 400;
}
$resource = \is_array($event['resource'] ?? null) ? $event['resource'] : [];
$orderId = (int) ($resource['custom_id'] ?? ($resource['purchase_units'][0]['custom_id'] ?? 0));
if ($orderId <= 0) {
// 18.09.2026 -- Express ohne PATCH traegt custom_id 'express': Bestellung ueber die
// PayPal-Order-ID finden (Capture: supplementary_data, Order-Events: resource.id).
$ref = (string) ($resource['supplementary_data']['related_ids']['order_id']
?? (\str_starts_with($type, 'CHECKOUT.ORDER.') ? ($resource['id'] ?? '') : ''));
$tx = $ref !== '' ? $this->transactionByReference($ref) : null;
$orderId = $tx ? (int) $tx->order_id : 0;
}
Log::write($this->module(), 'webhook', $orderId ?: null, null, (string) ($event['id'] ?? ''), $type, null, $event);
switch ($type) {
case 'PAYMENT.CAPTURE.COMPLETED':
$tx = $orderId > 0 ? $this->transactionByOrder($orderId) : null;
if ($tx && ($resource['status'] ?? '') === 'COMPLETED') {
$this->markPaid((string) $tx->reference, (string) ($resource['amount']['value'] ?? ''),
(string) ($resource['amount']['currency_code'] ?? ''), (string) ($resource['id'] ?? ''), '', '', 'webhook');
}
break;
case 'CHECKOUT.ORDER.APPROVED':
// Kunde hat freigegeben, Capture macht die Rueckkehr oder der Abgleicher.
$tx = $orderId > 0 ? $this->transactionByOrder($orderId) : null;
if ($tx && $tx->status === 'open') {
$this->setTransactionStatus((string) $tx->reference, 'open', 'APPROVED laut Webhook');
}
break;
case 'PAYMENT.CAPTURE.DENIED':
case 'PAYMENT.CAPTURE.DECLINED':
$tx = $orderId > 0 ? $this->transactionByOrder($orderId) : null;
if ($tx) {
$this->markCancelled((string) $tx->reference, 'PayPal: ' . $type, 'webhook');
}
break;
case 'PAYMENT.CAPTURE.REFUNDED':
case 'PAYMENT.CAPTURE.REVERSED':
// Nur protokolliert; Erstattungen werden im Backend gepflegt.
break;
}
return 200;
}
/**
* Signaturpruefung gegen PayPal. Der rohe Body wird UNVERAENDERT als webhook_event
* eingebettet -- PayPal hat die Signatur ueber genau diesen Text gebildet; ein
* json_decode/encode in PHP aendert Escaping und Reihenfolge und laesst die
* Pruefung scheitern.
*
* @param array<string,string> $headers
* @return string 'SUCCESS' oder Grund der Ablehnung
*/
private function verifyWebhook(array $headers, string $rawBody): string
{
$webhookId = Secrets::get($this->db, $this->module(), 'webhook_id') ?? '';
if ($webhookId === '') {
return 'keine webhook_id hinterlegt';
}
$fields = [
'transmission_id' => $headers['paypal-transmission-id'] ?? '',
'transmission_time' => $headers['paypal-transmission-time'] ?? '',
'cert_url' => $headers['paypal-cert-url'] ?? '',
'auth_algo' => $headers['paypal-auth-algo'] ?? '',
'transmission_sig' => $headers['paypal-transmission-sig'] ?? '',
'webhook_id' => $webhookId,
];
foreach ($fields as $k => $v) {
if ($v === '') {
return 'Header fehlt: ' . $k;
}
}
// 1. Lokale Pruefung (PayPal "self-verification"): Signatur ueber
// transmission_id|transmission_time|webhook_id|crc32(body), SHA256withRSA,
// mit dem Zertifikat von cert_url (nur *.paypal.com, https).
// Am 13.09.2026 in der Sandbox belegt: lokal GUELTIG, waehrend der
// API-Endpunkt verify-webhook-signature dieselbe Zustellung mit FAILURE beantwortet.
$local = $this->verifyLocally($fields, $rawBody);
if ($local === 'SUCCESS') {
return 'SUCCESS';
}
// 2. Rueckfall: PayPal fragen (roher Body eingebettet).
$json = \json_encode($fields, JSON_UNESCAPED_SLASHES);
$json = \substr($json, 0, -1) . ',"webhook_event":' . \trim($rawBody) . '}';
[$status, $body] = $this->client()->request('POST', '/v1/notifications/verify-webhook-signature', $json);
$vs = (string) ($body['verification_status'] ?? '');
if ($status === 200 && $vs === 'SUCCESS') {
return 'SUCCESS';
}
return 'lokal: ' . $local . '; API: HTTP ' . $status . ' ' . ($vs !== '' ? $vs : \json_encode($body));
}
/** @param array<string,string> $f */
private function verifyLocally(array $f, string $rawBody): string
{
if (\strtoupper($f['auth_algo']) !== 'SHA256WITHRSA') {
return 'Algorithmus ' . $f['auth_algo'];
}
$u = \parse_url($f['cert_url']);
$host = \strtolower((string) ($u['host'] ?? ''));
if (($u['scheme'] ?? '') !== 'https' || !($host === 'paypal.com' || \str_ends_with($host, '.paypal.com'))) {
return 'cert_url nicht von paypal.com';
}
$cert = $this->certificate($f['cert_url']);
if ($cert === '') {
return 'Zertifikat nicht ladbar';
}
$pub = \openssl_pkey_get_public($cert);
if ($pub === false) {
return 'Zertifikat unlesbar';
}
$msg = $f['transmission_id'] . '|' . $f['transmission_time'] . '|' . $f['webhook_id'] . '|' . \sprintf('%u', \crc32($rawBody));
$sig = \base64_decode($f['transmission_sig'], true);
if ($sig === false) {
return 'Signatur kein Base64';
}
return \openssl_verify($msg, $sig, $pub, OPENSSL_ALGO_SHA256) === 1 ? 'SUCCESS' : 'Signatur passt nicht';
}
/** Zertifikat von PayPal, 24 h neben dem Secrets-Schluessel zwischengespeichert. */
private function certificate(string $url): string
{
$file = \dirname(Secrets::keyPath()) . '/paypal_cert_' . \md5($url) . '.pem';
if (\is_file($file) && \filemtime($file) > \time() - 86400) {
return (string) \file_get_contents($file);
}
$ch = \curl_init($url);
\curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_TIMEOUT => 10]);
$pem = (string) \curl_exec($ch);
$code = (int) \curl_getinfo($ch, CURLINFO_HTTP_CODE);
\curl_close($ch);
if ($code !== 200 || !\str_contains($pem, 'BEGIN CERTIFICATE')) {
return \is_file($file) ? (string) \file_get_contents($file) : '';
}
@\file_put_contents($file, $pem, LOCK_EX);
@\chmod($file, 0600);
return $pem;
}
// ---------------------------------------------------------------- express
public function supportsExpress(): bool
{
return $this->setting('express', '1') === '1';
}
/** Order ohne Adresse anlegen; PayPal liefert sie bei der Rueckkehr (GET_FROM_FILE). */
public function startExpress(float $amount): string
{
$value = \number_format($amount, 2, '.', '');
$payload = [
'intent' => 'CAPTURE',
'purchase_units' => [[
'reference_id' => 'default',
'custom_id' => 'express',
'description' => 'Warenkorb ' . $this->setting('brand_name', 'intelectra'),
'amount' => ['currency_code' => $this->currency(), 'value' => $value],
]],
'application_context' => [
'brand_name' => $this->setting('brand_name', 'intelectra'),
'locale' => 'de-DE',
'landing_page' => 'NO_PREFERENCE',
'user_action' => 'CONTINUE', // "Weiter" statt "Jetzt bezahlen": eingezogen wird erst in der Kasse
'shipping_preference' => 'GET_FROM_FILE',
'return_url' => $this->expressReturnUrl(),
'cancel_url' => $this->expressCancelUrl(),
],
];
[$status, $body] = $this->client()->request('POST', '/v2/checkout/orders', $payload,
['PayPal-Request-Id: express-' . \session_id() . '-' . \substr(\hash('sha256', $value . '|' . \time()), 0, 12)]);
Log::write($this->module(), 'express_create', null, null, (string) ($body['id'] ?? ''), (string) ($body['status'] ?? ''), $status, $body);
if ($status !== 201 || empty($body['id'])) {
throw new \RuntimeException('PayPal Express: Order-Anlage fehlgeschlagen (HTTP ' . $status . ')');
}
$approve = '';
foreach (($body['links'] ?? []) as $link) {
if (\in_array($link['rel'] ?? '', ['approve', 'payer-action'], true)) {
$approve = (string) $link['href'];
break;
}
}
if ($approve === '') {
throw new \RuntimeException('PayPal Express: kein Approve-Link.');
}
$this->event('payment_started', null, null, $amount, (string) $body['id'], null, null, 'express aus dem Warenkorb');
return $approve;
}
public function onExpressReturn(array $query): ?array
{
$ref = (string) ($query['token'] ?? '');
if ($ref === '') {
return null;
}
[$status, $body] = $this->client()->request('GET', '/v2/checkout/orders/' . \rawurlencode($ref));
Log::write($this->module(), 'express_get', null, null, $ref, (string) ($body['status'] ?? ''), $status, $body);
if ($status !== 200 || ($body['purchase_units'][0]['custom_id'] ?? '') !== 'express') {
return null;
}
if (($body['status'] ?? '') !== 'APPROVED') {
return null; // Kunde hat nicht freigegeben
}
$payer = $body['payer'] ?? [];
$ship = $body['purchase_units'][0]['shipping'] ?? [];
$addr = $ship['address'] ?? [];
$full = \trim((string) ($ship['name']['full_name'] ?? ''));
$first = (string) ($payer['name']['given_name'] ?? '');
$last = (string) ($payer['name']['surname'] ?? '');
if ($full !== '' && $full !== \trim($first . ' ' . $last)) {
// Lieferadresse lautet auf einen anderen Namen (z.B. Packstation, Partner)
$parts = \explode(' ', $full);
$last = \array_pop($parts);
$first = \implode(' ', $parts);
}
[$street, $number] = self::splitStreet((string) ($addr['address_line_1'] ?? ''));
$approve = '';
foreach (($body['links'] ?? []) as $link) {
if (\in_array($link['rel'] ?? '', ['approve', 'payer-action'], true)) {
$approve = (string) $link['href'];
}
}
return [
'reference' => $ref,
'email' => (string) ($payer['email_address'] ?? ''),
'firstname' => $first,
'surname' => $last,
'company' => (string) ($addr['address_line_2'] ?? ''),
'street' => $street,
'house_number' => $number,
'zip_code' => (string) ($addr['postal_code'] ?? ''),
'city' => (string) ($addr['admin_area_2'] ?? ''),
'country_iso' => (string) ($addr['country_code'] ?? ''),
'approve_url' => $approve,
'payer_id' => (string) ($payer['payer_id'] ?? ''),
];
}
/**
* Betrag auf den Endbetrag setzen (PATCH ist im Status APPROVED erlaubt), Vorgang an
* die Bestellung haengen, einziehen. Verlangt PayPal nach der Aenderung eine erneute
* Freigabe, kommt die Approve-URL zurueck und der Kunde geht noch einmal hin.
*/
public function finishExpress(int $orderId, float $amount, string $reference): string
{
$order = $this->orderById($orderId);
$number = (string) $order->order_number;
$value = \number_format($amount, 2, '.', '');
// 18.09.2026 -- erst nachsehen, was bei PayPal steht. Stimmt der Betrag schon (die Kasse
// hat Versand und Aufschlag beim Express-Start mitgegeben), wird NICHT gepatcht: jede
// Aenderung nach der Freigabe kostet auf Live eine zweite Freigabe (422
// PAYER_ACTION_REQUIRED, Sandbox winkt das durch). custom_id bleibt dann 'express',
// die Zuordnung laeuft ueber die Referenz (payment_transactions).
[$s0, $b0] = $this->client()->request('GET', '/v2/checkout/orders/' . \rawurlencode($reference));
$ppValue = (string) ($b0['purchase_units'][0]['amount']['value'] ?? '');
$ppCur = (string) ($b0['purchase_units'][0]['amount']['currency_code'] ?? '');
if ($s0 === 200 && $ppValue === $value && $ppCur === $this->currency()) {
Log::write($this->module(), 'express_patch', $orderId, $number, $reference, 'skipped', $s0, ['amount' => $value, 'detail' => 'Betrag stimmt bereits, kein PATCH']);
$b2 = $b0;
} else {
$patch = [
['op' => 'replace', 'path' => "/purchase_units/@reference_id=='default'/amount",
'value' => ['currency_code' => $this->currency(), 'value' => $value]],
['op' => 'replace', 'path' => "/purchase_units/@reference_id=='default'/custom_id", 'value' => (string) $orderId],
['op' => 'add', 'path' => "/purchase_units/@reference_id=='default'/invoice_id", 'value' => $number . '-' . $orderId],
];
[$status, $body] = $this->client()->request('PATCH', '/v2/checkout/orders/' . \rawurlencode($reference), $patch);
Log::write($this->module(), 'express_patch', $orderId, $number, $reference, (string) $status, $status, $body ?: ['amount' => $value, 'vorher' => $ppValue]);
if ($status !== 204) {
throw new \RuntimeException('PayPal Express: Betrag konnte nicht gesetzt werden (HTTP ' . $status . '): ' . \json_encode($body['details'] ?? $body));
}
// Approve-URL fuer den Fall, dass PayPal eine erneute Freigabe verlangt
[$s2, $b2] = $this->client()->request('GET', '/v2/checkout/orders/' . \rawurlencode($reference));
}
$approveUrl = '';
foreach (($b2['links'] ?? []) as $link) {
if (\in_array($link['rel'] ?? '', ['approve', 'payer-action'], true)) {
$approveUrl = (string) $link['href'];
}
}
$this->openTransaction($orderId, $reference, $amount, $approveUrl);
try {
$cap = $this->capture($reference, $orderId, $number);
} catch (\RuntimeException $e) {
if (\str_contains($e->getMessage(), 'ORDER_NOT_APPROVED') || \str_contains($e->getMessage(), 'PAYER_ACTION_REQUIRED')) {
Log::write($this->module(), 'express_reapprove', $orderId, $number, $reference, 'needs approval');
return $approveUrl !== '' ? $approveUrl : 'https://www.' . ($this->mode() === 'live' ? '' : 'sandbox.') . 'paypal.com/checkoutnow?token=' . \rawurlencode($reference);
}
throw $e;
}
if ($cap['capture_status'] === 'COMPLETED') {
if ($this->markPaid($reference, $cap['amount'], $cap['currency'], $cap['capture_id'], $cap['payer_email'], $cap['payer_id'], 'express')) {
return '';
}
throw new \RuntimeException('Zahlung eingezogen, aber Zuordnung fehlgeschlagen (Bestellung ' . $number . ')');
}
if ($cap['capture_status'] === 'PENDING') {
$this->setTransactionStatus($reference, 'pending', 'capture PENDING: ' . ($cap['pending_reason'] ?? ''));
return '';
}
throw new \RuntimeException('PayPal hat die Zahlung nicht abgeschlossen (' . ($cap['capture_status'] ?: $cap['order_status']) . ')');
}
// -------------------------------------------------------------- reconcile
public function reconcile(bool $dryRun = true): array
{
$out = [];
// Nach 10 Minuten nachschauen, nach 72 Stunden aufgeben (PayPal-Orders verfallen nach 3 Tagen).
foreach ($this->openTransactions(10, 72) as $tx) {
$ref = (string) $tx->reference;
try {
[$status, $body] = $this->client()->request('GET', '/v2/checkout/orders/' . \rawurlencode($ref));
} catch (\Throwable $e) {
$out[] = "$ref (Order {$tx->order_id}): API-Fehler " . $e->getMessage();
continue;
}
if ($status === 404) {
$out[] = "$ref (Order {$tx->order_id}): bei PayPal unbekannt -> storniert" . ($dryRun ? ' [dry-run]' : '');
if (!$dryRun) {
// Beobachtet auf Live 19.-22.09.2026: nie freigegebene Orders (CREATED) liefern
// nach ~6 h 404 -- der Kunde ist bei PayPal nicht bis zur Freigabe gekommen.
$age = \round((\time() - \strtotime((string) $tx->created_at)) / 3600, 1);
$this->markCancelled($ref, "Kunde hat den Vorgang bei PayPal nicht abgeschlossen (nach $age h bei PayPal unbekannt, 404)", 'reconcile');
}
continue;
}
$pstatus = (string) ($body['status'] ?? '');
$cap = $this->normalize($body);
if ($pstatus === 'COMPLETED' && $cap['capture_status'] === 'COMPLETED') {
$out[] = "$ref (Order {$tx->order_id}): bei PayPal bezahlt, im Shop offen -> bezahlt setzen" . ($dryRun ? ' [dry-run]' : '');
if (!$dryRun) {
$this->markPaid($ref, $cap['amount'], $cap['currency'], $cap['capture_id'], $cap['payer_email'], $cap['payer_id'], 'reconcile');
}
} elseif ($pstatus === 'APPROVED') {
$out[] = "$ref (Order {$tx->order_id}): Kunde hat freigegeben, Rueckkehr verloren -> Capture nachholen" . ($dryRun ? ' [dry-run]' : '');
if (!$dryRun) {
try {
$c = $this->capture($ref, (int) $tx->order_id, (string) $tx->order_number);
if ($c['capture_status'] === 'COMPLETED') {
$this->markPaid($ref, $c['amount'], $c['currency'], $c['capture_id'], $c['payer_email'], $c['payer_id'], 'reconcile');
}
} catch (\Throwable $e) {
$out[] = " Capture fehlgeschlagen: " . $e->getMessage();
}
}
} elseif (\in_array($pstatus, ['VOIDED', 'EXPIRED'], true)) {
$out[] = "$ref (Order {$tx->order_id}): PayPal $pstatus -> storniert" . ($dryRun ? ' [dry-run]' : '');
if (!$dryRun) {
$this->markCancelled($ref, 'PayPal: ' . $pstatus, 'reconcile');
}
} else {
// CREATED / SAVED / PAYER_ACTION_REQUIRED: Kunde ist noch unterwegs oder hat das Fenster zugemacht.
$age = (\time() - \strtotime((string) $tx->created_at)) / 3600;
if ($age >= 24) {
$out[] = "$ref (Order {$tx->order_id}): seit " . \round($age) . " h $pstatus -> storniert" . ($dryRun ? ' [dry-run]' : '');
if (!$dryRun) {
$this->markCancelled($ref, 'PayPal ' . $pstatus . ' nach ' . \round($age) . ' h', 'reconcile');
}
} else {
$out[] = "$ref (Order {$tx->order_id}): $pstatus, " . \round($age, 1) . " h alt -> warten";
}
}
}
return $out;
}
// ----------------------------------------------------------------- intern
/** @return array{order_status:string,capture_id:string,capture_status:string,amount:string,currency:string,payer_email:string,payer_id:string,pending_reason:string} */
private function capture(string $ref, int $orderId, string $number): array
{
[$status, $body] = $this->client()->request('POST', '/v2/checkout/orders/' . \rawurlencode($ref) . '/capture', null,
['PayPal-Request-Id: capture-' . $ref, 'Prefer: return=representation']);
Log::write($this->module(), 'capture', $orderId, $number, $ref, (string) ($body['status'] ?? ''), $status, $body);
if ($status === 201 || $status === 200) {
return $this->normalize($body);
}
if ($status === 422 && $this->hasIssue($body, 'ORDER_ALREADY_CAPTURED')) {
[$s2, $b2] = $this->client()->request('GET', '/v2/checkout/orders/' . \rawurlencode($ref));
Log::write($this->module(), 'get', $orderId, $number, $ref, (string) ($b2['status'] ?? ''), $s2, $b2);
return $this->normalize($b2);
}
throw new \RuntimeException('Capture fehlgeschlagen (HTTP ' . $status . '): ' . \json_encode($body['details'] ?? $body));
}
/** @param array<mixed> $body */
private function normalize(array $body): array
{
$cap = $body['purchase_units'][0]['payments']['captures'][0] ?? [];
return [
'order_status' => (string) ($body['status'] ?? ''),
'capture_id' => (string) ($cap['id'] ?? ''),
'capture_status' => (string) ($cap['status'] ?? ''),
'amount' => (string) ($cap['amount']['value'] ?? ''),
'currency' => (string) ($cap['amount']['currency_code'] ?? ''),
'payer_email' => (string) ($body['payer']['email_address'] ?? ''),
'payer_id' => (string) ($body['payer']['payer_id'] ?? ''),
'pending_reason' => (string) ($cap['status_details']['reason'] ?? ''),
];
}
/** @param array<mixed> $body */
private function hasIssue(array $body, string $issue): bool
{
foreach (($body['details'] ?? []) as $d) {
if (($d['issue'] ?? '') === $issue) {
return true;
}
}
return false;
}
}
-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'];
@@ -104,6 +115,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);
}
}
}
}
}
+381 -8
View File
@@ -13,6 +13,7 @@ include_once('./core/customer.class.php');
include_once('./core/customeraddress.class.php');
include_once('./core/shoppingcart.class.php');
include_once('./core/payment_method.class.php');
include_once('./core/payments/Registry.php'); // 13.09.2026: Zahlungsanbieter mit Weiterleitung (PayPal v2, ...)
include_once('./core/deliverer.class.php'); // should be called shipping methods
include_once('./core/order.class.php');
include_once('./core/cs_ticket.class.php');
@@ -66,10 +67,34 @@ class website_cashdesk {
if (isset($_GET['order'])) {
$this->layout_object->assign('order_number', $_GET['order']);
}
// 27.09.2026: Erfolgsseite ohne Navigation, wie Warenkorb und Kasse der neuen Ansicht
if (!$this->alte_ansicht()) {
$this->layout_object->assign('site_type', 'full_width');
}
// Success-Template anzeigen - auch bei Reload!
return $this->layout_object->_fetch('content_cash_desk/completed.tpl');
}
// 13.09.2026 -- Rueckkehr von einem Zahlungsanbieter aus core/payments
// (?payment_return=<modul> bzw. ?payment_cancel=<modul>). Laeuft hier im Cashdesk,
// weil die Session gebraucht wird (Pending-Riegel, Warenkorb). Steht vor dem
// Kunden-/Login-Check: nach dem Sprung ueber den Anbieter ist kein ?guest_order=1
// mehr in der URL, ein Gast haette sonst keine customer_id.
if (isset($_GET['payment_return']) || isset($_GET['payment_cancel'])) {
return $this->payment_provider_return();
}
// 14.09.2026 -- Express aus dem Warenkorb ("Direkt mit PayPal bezahlen").
// Start: Vorgang beim Anbieter ohne Bestellung, Kunde geht hin und gibt frei.
// Rueckkehr: Adresse/E-Mail vom Anbieter in die Session, weiter in die Gast-Kasse,
// dort ist alles vorbelegt -- ein Klick auf "zahlungspflichtig bestellen" zieht ein.
if (isset($_GET['payment_express_start'])) {
return $this->payment_express_start();
}
if (isset($_GET['payment_express'])) {
return $this->payment_express_return();
}
// 24.08.2026 -- Zielseite des Post/Redirect/Get aus PaypalPaymentMethod::after_order().
// Reines GET: hier steht der Link zu PayPal, ein Reload laedt nur noch ihn neu und
// legt keine zweite Bestellung mehr an.
@@ -99,6 +124,7 @@ class website_cashdesk {
$this->base_object->customer->customer_id = $this->base_object->config->shopConfiguration['guest_order_customer_id'];
$this->layout_object->assign('guest_order', 1);
}
$this->payment_express_prefill();
$action = '';
if ($this->base_object->customer->customer_id) {
@@ -135,6 +161,19 @@ class website_cashdesk {
if (count($this->errors) == 0) {
if (isset($_GET['guest_order']) && $_GET['guest_order'] == 1) {
$this->setup_customer_data();
// 27.09.2026 -- Express ohne Anrede: die Anrede ist im Express-Kauf keine Pflicht,
// CustomerAddress::validate() verlangt sie aber. Leere Anrede liess die Adress-Anlage
// still scheitern, die Bestellung wurde OHNE Adresse gespeichert (312153, 312217).
// Leer => Firma (2) bei Firmenname, sonst Herr (0) -- wie customers.honorific es
// ohnehin speichert. Neutrale Anrede bleibt eigenes Thema.
foreach (array('billing', 'shipping') as $blk) {
if (isset($_POST[$blk]) && is_array($_POST[$blk])
&& (!isset($_POST[$blk]['honorific']) || (string)$_POST[$blk]['honorific'] === '')) {
$_POST[$blk]['honorific'] = !empty($_POST[$blk]['company']) ? 2 : 0;
}
}
// check customer already exists by email
$customer_id = 0;//$this->base_object->customer->check_email($_POST['billing']['email']);
@@ -172,6 +211,11 @@ class website_cashdesk {
'customer_id' => $customer_id
);
$res = $customer_address_object->create($billing_address);
if (!$res) {
// bisher stumm -> Bestellung ohne Adresse; jetzt wenigstens sichtbar im PHP-Log
error_log('Checkout: Gast-Rechnungsadresse nicht angelegt (Kunde '.$customer_id.'): '
. json_encode($customer_address_object->get_validation_errors()));
}
$this->billing_address['address'] = $res;
$shipping_address = array (
'honorific' => $_POST['shipping']['honorific'],
@@ -187,6 +231,10 @@ class website_cashdesk {
);
$res = $customer_address_object->create($shipping_address);
if (!$res) {
error_log('Checkout: Gast-Lieferadresse nicht angelegt (Kunde '.$customer_id.'): '
. json_encode($customer_address_object->get_validation_errors()));
}
$this->shipping_address['address'] = $res;
}
@@ -224,6 +272,16 @@ class website_cashdesk {
exit;
}
// 13.09.2026: dasselbe fuer Anbieter aus core/payments -- zurueck zur
// Approve-URL des offenen Vorgangs statt einer zweiten Bestellung.
$offen_pv = $this->payment_provider_pending();
if ($offen_pv && (int)$offen_pv['order_id'] === (int)$erledigt['order_id']) {
error_log('Checkout: Vorgang '.$erledigt['order_number']
. ' bereits bestellt, zurueck auf offenen '.$offen_pv['module'].'-Vorgang');
header('Location: '.$offen_pv['approve_url']);
exit;
}
error_log('Checkout: Vorgang bereits bestellt (Order '
. $erledigt['order_number'].') -- keine zweite Order angelegt');
@@ -265,6 +323,21 @@ class website_cashdesk {
exit;
}
// 13.09.2026: derselbe Riegel fuer Anbieter aus core/payments. Der offene
// Vorgang liegt in $_SESSION['easyshop']['payment_pending'] (gesetzt vom
// Provider beim Start) -- gleiche Zahlart, gleicher Betrag, juenger als
// eine Stunde, nicht bezahlt: zurueck zur Approve-URL.
$pending_pv = $this->payment_provider_pending();
if ($pending_pv
&& $pending_pv['module'] === $pending_module
&& abs(round((float)$this->shoppingcart_data['order_total'], 2) - (float)$pending_pv['total']) < 0.01) {
error_log('Payments: doppelter ready2fly-POST abgefangen, zurueck auf offenen '
. $pending_pv['module'].'-Vorgang '.$pending_pv['reference']);
header('Location: '.$pending_pv['approve_url']);
exit;
}
$is_saved = $this->save_order();
if ($is_saved) {
@@ -298,7 +371,11 @@ class website_cashdesk {
$this->manage_errors();
$this->manage_data_persistence();
// customer info
// customer info -- 18.09.2026: in der Kasse editierbar, ein POST (auch bei
// Fehlern im Formular) aktualisiert die Session, die Bestellung nimmt $_POST (s.u.)
if (isset($_POST['customer_info'])) {
$_SESSION['customer_info'] = trim((string)$_POST['customer_info']);
}
if (isset($_SESSION['customer_info']) && $_SESSION['customer_info']) {
$this->layout_object->assign('customer_info', $_SESSION['customer_info']);
}
@@ -311,9 +388,15 @@ class website_cashdesk {
// "Bestellen" als denselben Vorgang erkennbar (siehe save_order()).
$this->layout_object->assign('checkout_nonce', $this->checkout_nonce_holen());
return $this->layout_object->_fetch('content_cash_desk.tpl');
// 27.09.2026 -- die neue Kasse (content_cash_desk_neu.tpl) ist Standard.
// Rueckfall auf das alte Template: /Kasse/?ansicht=alt (siehe alte_ansicht()).
if ($this->alte_ansicht()) {
return $this->layout_object->_fetch('content_cash_desk.tpl');
}
$this->layout_object->assign('site_type', 'full_width'); // ohne Navigation, mehr Breite (Tom, 18.09.)
return $this->layout_object->_fetch('content_cash_desk_neu.tpl');
} else if ($_GET['token']) {
} else if (isset($_GET['token']) && $_GET['token']) {
return $this->change_order('paypal');
} else {
// login
@@ -326,6 +409,290 @@ class website_cashdesk {
// end run
/**
* Rueckfall auf die alte Kassen-/Warenkorbansicht (27.09.2026, Go-Live der neuen Ansicht):
* ?ansicht=alt schaltet fuer die Sitzung auf die alten Templates, ?ansicht=neu hebt das auf.
* Dieselbe Weiche steht in website_shoppingcart.php.
*/
private function alte_ansicht() {
if (isset($_GET['ansicht'])) {
$_SESSION['easyshop']['ansicht_alt'] = ($_GET['ansicht'] === 'alt') ? 1 : 0;
}
return !empty($_SESSION['easyshop']['ansicht_alt']);
}
// ------------------------------------------------------------ Express
/** Start aus dem Warenkorb: /Kasse/?payment_express_start=<modul> */
private function payment_express_start() {
$module = strtolower(trim((string)$_GET['payment_express_start']));
$provider = $this->payment_provider($module);
if (!$provider || !$provider->supportsExpress()) {
header('Location: /Warenkorb/');
exit;
}
$shoppingcart_object = new Shoppingcart($this->base_object);
$cart = $shoppingcart_object->get_data();
if (empty($cart['items']) || (float)$cart['price_sum_gross'] <= 0) {
header('Location: /Warenkorb/');
exit;
}
try {
$url = $provider->startExpress($this->payment_express_amount($cart, $provider));
} catch (Throwable $e) {
error_log('Payments express start '.$module.': '.$e->getMessage());
$this->layout_object->assign('call', 'payment_error');
$this->layout_object->assign('provider_label', $provider->label());
$this->layout_object->assign('message', $provider->label().' konnte nicht gestartet werden. Ihr Warenkorb ist noch da &ndash; bitte gehen Sie normal zur Kasse.');
return $this->layout_object->_fetch('content_cash_desk/payment_result.tpl');
}
header('Location: '.$url);
exit;
}
/**
* 18.09.2026 -- Endbetrag fuer den Express-Start vorab rechnen: Warenwert + Versand +
* Zahlartaufschlag, genau so, wie die Kasse ihn spaeter vorbelegt (erste aktive Versandart
* des Gebiets, Gast: Gebiet 1 wie in manage_guest_payment_method). PayPal Live verlangt
* bei einer Betragsaenderung nach der Freigabe eine zweite Freigabe (422
* PAYER_ACTION_REQUIRED); stimmt der Betrag schon, entfaellt der PATCH und damit der
* zweite PayPal-Besuch. Land: erstes erlaubtes Express-Land (Setting, Standard DE).
* Im Zweifel (Fehler, keine Versandart) bleibt es beim Warenwert -- dann greift der
* Rueckfall mit zweiter Freigabe wie bisher.
*/
private function payment_express_amount($cart, $provider) {
$amount = round((float)$cart['price_sum_gross'], 2);
try {
$guest_group = (int)$this->base_object->config->shopConfiguration['guest_order_customer_group'];
$guest_id = (int)$this->base_object->config->shopConfiguration['guest_order_customer_id'];
$cid = (int)$this->base_object->customer->customer_id;
$logged_in = $cid > 0 && $cid !== $guest_id;
$group_id = $guest_group;
$area_id = 1;
if ($logged_in) {
$customer_object = new Customer($this->base_object);
$customer_data = $customer_object->get_by_id($cid);
$group_id = (int)$customer_data->group_id;
$allowed = array_filter(array_map('trim', explode(',', strtoupper($provider->expressCountries()))));
$iso = $allowed ? reset($allowed) : 'DE';
$country_id = $provider->countryIdByIso($iso);
$area_id = $country_id > 0 ? (int)Country::getShippingAreaById($country_id, $group_id) : 0;
if ($area_id <= 0) {
return $amount;
}
}
$deliverer = new Deliverer($this->base_object);
$methods = $logged_in
? $deliverer->getAllActive($area_id, $cart['standard_shipping_items'], $cart['bulk_goods'], $cart['item_weight'], $cart['price_sum_gross'])
: $deliverer->getAllActive($area_id, $cart['standard_shipping_items'], $cart['bulk_goods'], $cart['item_weight']);
if (!is_array($methods) || !$methods) {
return $amount;
}
$first = current($methods);
$shipping = round((float)(isset($first->price_add) ? $first->price_add : 0), 2);
$payment_add = 0.0;
$payment_method_object = new Payment_method($this->base_object);
$pms = $payment_method_object->getMethodsForGroup($group_id, $cart['price_sum_gross'], $area_id);
foreach ((array)$pms as $pm) {
if (isset($pm->module) && $pm->module === $provider->module()) {
$payment_add = round((float)(isset($pm->paymentAdd) ? $pm->paymentAdd : 0), 2);
break;
}
}
return round($amount + $shipping + $payment_add, 2);
} catch (Throwable $e) {
error_log('Payments express amount: '.$e->getMessage());
return $amount;
}
}
/** Rueckkehr im Express-Weg: /Kasse/?payment_express=<modul>&token=... */
private function payment_express_return() {
$module = strtolower(trim((string)$_GET['payment_express']));
$provider = $this->payment_provider($module);
$data = null;
// 18.09.2026 -- zweite Freigabe: PayPal Live verlangt nach dem Betrags-PATCH (Versand)
// eine erneute Freigabe, der Kunde kommt danach ueber DIESE URL zurueck. Die Bestellung
// und der Vorgang existieren dann schon -> normaler Rueckweg mit Capture, nicht noch
// einmal die Kasse vorbelegen (custom_id ist da auch nicht mehr 'express').
if ($provider && $provider->knownReference((string)($_GET['token'] ?? ''))) {
$_GET['payment_return'] = $module;
return $this->payment_provider_return();
}
if ($provider && $provider->supportsExpress()) {
try {
$data = $provider->onExpressReturn($_GET);
} catch (Throwable $e) {
error_log('Payments express return '.$module.': '.$e->getMessage());
}
}
if (!$data) {
$this->layout_object->assign('call', 'payment_error');
$this->layout_object->assign('provider_label', $provider ? $provider->label() : 'Zahlung');
$this->layout_object->assign('message', 'Der Express-Vorgang konnte nicht &uuml;bernommen werden. Ihr Warenkorb ist noch da &ndash; bitte gehen Sie normal zur Kasse.');
return $this->layout_object->_fetch('content_cash_desk/payment_result.tpl');
}
// Erlaubte Laender fuer Express (Setting <modul>_express_countries, Standard DE):
// PayPal-Konten gibt es weltweit, geliefert wird aber nur, wohin der Shop liefert.
$allowed = array_filter(array_map('trim', explode(',', strtoupper($provider->expressCountries()))));
$iso = strtoupper((string)$data['country_iso']);
$country_id = in_array($iso, $allowed, true) ? $provider->countryIdByIso($iso) : 0;
if ($country_id <= 0) {
$this->layout_object->assign('call', 'payment_error');
$this->layout_object->assign('provider_label', $provider->label());
$this->layout_object->assign('message', 'In das bei '.$provider->label().' hinterlegte Land ('.htmlspecialchars((string)$data['country_iso']).') liefern wir leider nicht. Bitte gehen Sie normal zur Kasse und w&auml;hlen Sie eine Lieferadresse.');
return $this->layout_object->_fetch('content_cash_desk/payment_result.tpl');
}
$data['module'] = $module;
$data['country_id'] = $country_id;
$data['time'] = time();
$_SESSION['easyshop']['payment_express'] = $data;
// Eingeloggter Kunde: eigene Kasse mit eigenen Adressen, nur Zahlart vorbelegt.
$logged_in = $this->base_object->customer->customer_id
&& (int)$this->base_object->customer->customer_id !== (int)$this->base_object->config->shopConfiguration['guest_order_customer_id'];
header('Location: /Kasse/'.($logged_in ? '' : '?guest_order=1'));
exit;
}
/**
* Kasse mit den Express-Daten vorbelegen (Gast: Adresse + E-Mail + Zahlart,
* eingeloggt: nur Zahlart). Nur beim reinen Anzeigen -- ein POST des Kunden gewinnt.
*/
private function payment_express_prefill() {
$e = isset($_SESSION['easyshop']['payment_express']) ? $_SESSION['easyshop']['payment_express'] : null;
if (!is_array($e) || empty($e['module']) || empty($e['reference'])) {
return;
}
if ((time() - (int)$e['time']) > 3600) {
unset($_SESSION['easyshop']['payment_express']);
return;
}
$pm_id = 0;
$res = $this->base_object->db->query("SELECT id FROM payment_methods WHERE module='".$this->base_object->db->real_escape_string($e['module'])."' AND active=1 LIMIT 1");
if ($res && ($r = $res->fetch_object())) {
$pm_id = (int)$r->id;
}
$this->layout_object->assign('express_active', 1);
$express_provider = $this->payment_provider($e['module']);
$this->layout_object->assign('express_label', $express_provider ? $express_provider->label() : ucfirst(str_replace('_', ' ', $e['module'])));
if (isset($_POST['cashdesk_action']) || isset($_POST['save_address'])) {
return; // Kunde hat das Formular selbst abgeschickt
}
if ($pm_id) {
$_POST['payment_method'] = $pm_id;
}
$is_guest = isset($_GET['guest_order']) && $_GET['guest_order'] == 1;
if (!$is_guest) {
return;
}
$addr = array(
'honorific' => '',
'company' => (string)$e['company'],
'firstname' => (string)$e['firstname'],
'surname' => (string)$e['surname'],
'street' => (string)$e['street'],
'house_number' => (string)$e['house_number'],
'zip_code' => (string)$e['zip_code'],
'city' => (string)$e['city'],
'country' => (int)$e['country_id'],
);
$_POST['billing'] = $addr + array('email' => (string)$e['email'], 'address' => '', 'billing_address' => '');
$_POST['shipping'] = $addr + array('address' => -1); // -1 = wie Rechnungsadresse
$this->personal_data = $_POST;
}
/**
* 13.09.2026 -- Zahlungsanbieter aus core/payments.
*
* Bei diesen Zahlarten (und beim alten PayPal) ist die Bestellung erst nach der
* Rueckkehr vom Anbieter bezahlt: Bestellbestaetigung und Warenkorb-Leerung warten
* bis dahin. Alle anderen Zahlarten sind mit dem Abschicken verbindlich.
*/
private function zahlung_erst_spaeter($module) {
$module = strtolower((string)$module);
if ($module === 'paypal') {
return true; // Legacy-NVP-PayPal, raeumt in PaypalPaymentMethod::change_order() selbst auf
}
$provider = $this->payment_provider($module);
return $provider ? $provider->paysAfterRedirect() : false;
}
/** @return \Payments\ProviderInterface|null */
private function payment_provider($module) {
if (!class_exists('\Payments\Registry') || !\Payments\Registry::has((string)$module)) {
return null;
}
try {
return \Payments\Registry::get((string)$module, $this->base_object);
} catch (Throwable $e) {
error_log('Payments: Provider '.$module.' nicht ladbar: '.$e->getMessage());
return null;
}
}
/**
* Offener, unbezahlter Vorgang eines Anbieters in dieser Session (juenger als 1 h),
* sonst null. Grundlage der Riegel gegen Doppelbestellungen.
*/
private function payment_provider_pending() {
$p = isset($_SESSION['easyshop']['payment_pending']) ? $_SESSION['easyshop']['payment_pending'] : null;
if (!is_array($p) || empty($p['module']) || empty($p['approve_url']) || empty($p['order_id'])) {
return null;
}
if ((time() - (int)(isset($p['time']) ? $p['time'] : 0)) >= 3600) {
unset($_SESSION['easyshop']['payment_pending']);
return null;
}
$provider = $this->payment_provider($p['module']);
if (!$provider || $provider->isPaid((int)$p['order_id'])) {
unset($_SESSION['easyshop']['payment_pending']);
return null;
}
return $p;
}
/**
* Rueckkehr vom Anbieter (?payment_return=<modul> | ?payment_cancel=<modul>).
* Bezahlt -> Redirect auf die Erfolgsseite (F5-sicher). Sonst Hinweisseite, der
* Warenkorb bleibt in jedem Fall stehen.
*/
private function payment_provider_return() {
$is_cancel = isset($_GET['payment_cancel']);
$module = strtolower(trim((string)($is_cancel ? $_GET['payment_cancel'] : $_GET['payment_return'])));
$provider = $this->payment_provider($module);
if (!$provider) {
$this->layout_object->assign('call', 'payment_error');
$this->layout_object->assign('message', 'Die Zahlungsart konnte nicht zugeordnet werden. Ihr Warenkorb ist noch da.');
return $this->layout_object->_fetch('content_cash_desk/payment_result.tpl');
}
try {
$result = $is_cancel ? $provider->onCancel($_GET) : $provider->onReturn($_GET);
} catch (Throwable $e) {
error_log('Payments: '.$module.' return/cancel: '.$e->getMessage());
$result = \Payments\Result::error('Bei der Rueckmeldung des Zahlungsanbieters ist ein Fehler aufgetreten. Ihr Warenkorb ist noch da.');
}
if ($result->status === \Payments\Result::PAID) {
header('Location: /Kasse/?success=1&order='.urlencode($result->orderNumber));
exit;
}
$this->layout_object->assign('provider_label', $provider->label());
$this->layout_object->assign('order_number', $result->orderNumber);
$this->layout_object->assign('message', $result->message);
$this->layout_object->assign('call',
$result->status === \Payments\Result::CANCELLED ? 'payment_cancelled'
: ($result->status === \Payments\Result::PENDING ? 'payment_pending' : 'payment_error'));
return $this->layout_object->_fetch('content_cash_desk/payment_result.tpl');
}
private function setup_countries() {
$country_object = new Country($this->base_object);
@@ -366,6 +733,10 @@ class website_cashdesk {
if (isset($_POST['shipping']) && $_POST['shipping']['address'] == -1) {
$this->shipping_address = $_POST['billing'];
if (empty($this->shipping_address['country'])) {
// 22.09.2026: Land nicht gewaehlt -> Standardland fuer die Versandgebiet-Suche, sonst SQL-Fatal
$this->shipping_address['country'] = $this->customer_data->default_address_country_id;
}
$_POST['shipping'] = $_POST['billing'];
} else if (isset($_POST['shipping'])) {
if (!$this->shipping_address['country']) {
@@ -554,8 +925,10 @@ class website_cashdesk {
$this->has_gift_certificate = false;
}
// 27.09.2026: Korbsumme 0 (Artikel ohne Preis) teilte hier durch null -> HTTP 500 in der Kasse
$sum_gross = (float)$this->shoppingcart_data['price_sum_gross'];
foreach ($this->shoppingcart_data['tax_values'] as $key => $value) {
$this->shoppingcart_data['tax_values'][$key]['gift']['percent'] = $percent = round(($value['gross'] / $this->shoppingcart_data['price_sum_gross'] * 100), 2);
$this->shoppingcart_data['tax_values'][$key]['gift']['percent'] = $percent = $sum_gross > 0 ? round(($value['gross'] / $sum_gross * 100), 2) : 0;
$this->shoppingcart_data['tax_values'][$key]['gift']['gross'] = $new_gross = $this->shoppingcart_data['price_sum_gross'] / 100 * $percent;
$this->shoppingcart_data['tax_values'][$key]['gift']['net'] = $new_net = $new_gross / (100 + $key) * 100;
$this->shoppingcart_data['tax_values'][$key]['gift']['vat'] = $new_gross - $new_net;
@@ -842,7 +1215,7 @@ class website_cashdesk {
? strtolower((string)$this->payment_methods[$this->payment_method_id]->module)
: '';
if ($mail_modul !== 'paypal') {
if (!$this->zahlung_erst_spaeter($mail_modul)) {
// send email
$ticket_object = new Cs_ticket($this->base_object);
$ticket_object->event(3, $this->customer_data->id, $this->order_id);
@@ -874,8 +1247,8 @@ class website_cashdesk {
'betrag' => isset($this->shoppingcart_data['order_total'])
? $this->shoppingcart_data['order_total'] : null,
'customer_id' => isset($this->customer_data->id) ? $this->customer_data->id : null,
'detail' => $mail_modul === 'paypal'
? 'PayPal: noch nicht bezahlt' : 'sofort verbindlich'
'detail' => $this->zahlung_erst_spaeter($mail_modul)
? $mail_modul.': noch nicht bezahlt' : 'sofort verbindlich'
));
}
@@ -917,7 +1290,7 @@ class website_cashdesk {
? strtolower((string)$this->payment_methods[$this->payment_method_id]->module)
: '';
if ($payment_module !== 'paypal') {
if (!$this->zahlung_erst_spaeter($payment_module)) {
$shoppingcart_object = new Shoppingcart($this->base_object);
$shoppingcart_object->clear(1);
}
+8 -1
View File
@@ -250,6 +250,8 @@ class website_customercenter {
}
header('location: ' . $_SERVER["HTTP_REFERER"]);
// 22.09.2026: nach dem Redirect nicht weiterrendern (lief sonst in die Widgets und den SQL-Fatal).
exit();
}
private function edit_base_data($customer_id) {
@@ -332,7 +334,12 @@ class website_customercenter {
$base_url .= ':' . $_SERVER['SERVER_PORT'];
}
return $base_url . '/index.php?menu_id=' . $_GET['menu_id'] . '&parent_id=' . $_GET['parent_id'];
// 22.09.2026: leere Parameter nicht anhaengen (Seite kommt per short_uri /Kundencenter/ ohne parent_id).
$url = $base_url . '/index.php?menu_id=' . (int) ($_GET['menu_id'] ?? 0);
if (!empty($_GET['parent_id'])) {
$url .= '&parent_id=' . (int) $_GET['parent_id'];
}
return $url;
}
private function get_invoice_pdf() {
+48
View File
@@ -392,6 +392,12 @@ class website_dyncontent {
$var_data = array();
$var_data['[#HIDDEN_TEXT_START]'] = '<span class="text_box_hidden_text">';
$var_data['[#HIDDEN_TEXT_END]'] = ' </span> <a class="text_box_link_show_text" href="#">mehr&gt;&gt;</a><a class="text_box_link_hide_text" href="#">&lt;&lt;weniger</a>';
// 27.09.2026 -- [#zahlarten]: Liste der aktiven Zahlarten der Kundengruppe (Tom: die
// CMS-Seite "Bezahlarten" soll nicht mehr von Hand gepflegt werden, sondern dem
// Aktiv-Schalter im Backend folgen).
if (strpos($block->content, '[#zahlarten]') !== false) {
$var_data['[#zahlarten]'] = $this->zahlarten_liste();
}
$block->content = strtr($block->content, $var_data);
@@ -401,6 +407,48 @@ class website_dyncontent {
return $this->layout_object->_fetch('content_box_textbox.tpl');
}
/**
* Zahlarten, die der Besucher in der Kasse bekaeme -- dieselbe Quelle wie die Kasse
* (Payment_method::getMethodsForGroup: payment_methods.active, shipping_area.active_<key>,
* Kundengruppen-Freischaltung). Besucher (Gruppe 100) sehen die Gast-Zahlarten, weil sie
* als Gast bestellen; Haendler ihr B2B-Versandgebiet. Bankeinzug/Rechnung nur, wenn der
* angemeldete Kunde dafuer freigeschaltet ist (wie in content_cash_desk_neu.tpl).
*/
private function zahlarten_liste() {
try {
include_once './core/payment_method.class.php';
$group = (int)$this->base_object->customer_group->id;
if ($group <= 0 || $group == 100) {
$group = 103; // Gast
}
$area = ($group == 101) ? 30 : 1; // Deutschland-B2B / Deutschland-B2C
$pm_object = new Payment_method($this->base_object);
$all = $pm_object->getMethodsForGroup($group, -1, $area);
$customer = $this->base_object->customer;
$list = array();
foreach ((array)$all as $pm) {
if (!isset($pm->activeType) || $pm->activeType <= 0) {
continue;
}
if ($pm->helperjoinkey == 'be' && empty($customer->direct_debit_active)) {
continue;
}
if ($pm->helperjoinkey == 'r' && empty($customer->rechnung_active)) {
continue;
}
$list[] = $pm;
}
$this->layout_object->assign('zahlarten', $list);
$this->layout_object->assign('zahlarten_gruppe', $group);
// Platzhalter im Bestelltext (z.B. "Kundenspezifisch" bei Haendlern), wie content_cash_desk_neu.tpl
$this->layout_object->assign('zahlarten_freitext3', isset($customer->freetextfield3) ? (string)$customer->freetextfield3 : '');
return $this->layout_object->_fetch('content_box_zahlarten.tpl');
} catch (Throwable $e) {
error_log('zahlarten_liste: '.$e->getMessage());
return '';
}
}
private function banner($structure_id, $block) {
$config = array();
eval($block->configuration);
+78 -2
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']) {
@@ -60,10 +86,60 @@ class website_shoppingcart {
$this->layout_object->assign('shoppingcart', $data);
$this->layout_object->assign('shoppingcartVATUsage', $this->base_object->customer_group->show_tax);
return $this->layout_object->_fetch('content_shoppingcart.tpl');
// 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());
// 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

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