- 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
522 lines
22 KiB
PHP
522 lines
22 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace Payments;
|
|
|
|
/**
|
|
* Gemeinsamer Unterbau aller Anbieter mit Weiterleitung.
|
|
*
|
|
* Hier liegt alles, was 2026 am v1-PayPal muehsam nachgeruestet wurde und fuer
|
|
* jeden weiteren Anbieter genauso gelten muss:
|
|
* - Vorgang in payment_transactions mit echter Uhrzeit, Session und Sollbetrag
|
|
* - Bestellbestaetigung ERST nach eingezogener Zahlung
|
|
* - Warenkorb ERST nach eingezogener Zahlung leeren (ueber die Session von damals)
|
|
* - idempotentes "bezahlt": zweite Rueckkehr, Webhook-Race, Abgleicher -- egal wer
|
|
* zuerst kommt, es passiert genau einmal
|
|
* - Betragsabgleich gegen den Sollbetrag (Manipulationsschutz)
|
|
* - Ereignisse fuer den Funnel (shop_event) und Rohprotokoll (Payments\Log)
|
|
*
|
|
* Einstellungen kommen aus der Konfigurationsgruppe = module() (Tabelle configuration,
|
|
* pflegbar im Zahlart-Editor), Zugangsdaten aus Payments\Secrets.
|
|
*/
|
|
abstract class AbstractProvider implements ProviderInterface
|
|
{
|
|
protected object $base;
|
|
protected \mysqli $db;
|
|
|
|
/** @var array<string,string>|null */
|
|
private ?array $settings = null;
|
|
|
|
public function __construct(object $base)
|
|
{
|
|
$this->base = $base;
|
|
$this->db = $base->db;
|
|
}
|
|
|
|
public function paysAfterRedirect(): bool
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// ------------------------------------------------------------ Einstellungen
|
|
|
|
/** Wert aus der Konfigurationsgruppe des Anbieters (configuration.group_id = module()). */
|
|
protected function setting(string $name, string $default = ''): string
|
|
{
|
|
if ($this->settings === null) {
|
|
$this->settings = [];
|
|
$g = $this->db->real_escape_string($this->module());
|
|
$res = $this->db->query("SELECT name, setting FROM configuration WHERE group_id='$g'");
|
|
while ($res && ($row = $res->fetch_assoc())) {
|
|
$this->settings[(string) $row['name']] = (string) $row['setting'];
|
|
}
|
|
}
|
|
$full = $this->module() . '_' . $name;
|
|
$v = $this->settings[$full] ?? $this->settings[$name] ?? '';
|
|
return $v !== '' ? $v : $default;
|
|
}
|
|
|
|
protected function secret(string $name): string
|
|
{
|
|
$v = Secrets::get($this->db, $this->module(), $name);
|
|
if ($v === null || $v === '') {
|
|
throw new \RuntimeException($this->label() . ': Zugangsdatum "' . $name . '" ist nicht hinterlegt (Backend > Zahlungsarten).');
|
|
}
|
|
return $v;
|
|
}
|
|
|
|
protected function shopBaseUrl(): string
|
|
{
|
|
if (\defined('MAIN_URL') && MAIN_URL !== '') {
|
|
return \rtrim((string) MAIN_URL, '/');
|
|
}
|
|
return 'https://' . ($_SERVER['SERVER_NAME'] ?? 'localhost');
|
|
}
|
|
|
|
/** Rueckkehr-URLs laufen durch den Cashdesk (Session vorhanden). */
|
|
protected function returnUrl(): string
|
|
{
|
|
return $this->shopBaseUrl() . '/Kasse/?payment_return=' . $this->module();
|
|
}
|
|
|
|
protected function cancelUrl(): string
|
|
{
|
|
return $this->shopBaseUrl() . '/Kasse/?payment_cancel=' . $this->module();
|
|
}
|
|
|
|
/** Webhook laeuft OHNE Session ueber den Standalone-Endpunkt. */
|
|
protected function webhookUrl(): string
|
|
{
|
|
return $this->shopBaseUrl() . '/payment_webhook.php?p=' . $this->module();
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Vorgang
|
|
|
|
/**
|
|
* Legt den Vorgang an: Referenz beim Anbieter, Sollbetrag, Session, Approve-URL.
|
|
* Schreibt zusaetzlich orders.paypal_token (Backend zeigt das Feld bereits an).
|
|
*/
|
|
protected function openTransaction(int $orderId, string $reference, float $amount, string $approveUrl, ?array $raw = null): void
|
|
{
|
|
$order = $this->orderById($orderId);
|
|
$stmt = $this->db->prepare(
|
|
'INSERT INTO payment_transactions
|
|
(provider, order_id, order_number, reference, expected_amount, currency, status,
|
|
approve_url, session_id, customer_id, created_at, updated_at)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?,NOW(),NOW())
|
|
ON DUPLICATE KEY UPDATE reference=VALUES(reference), expected_amount=VALUES(expected_amount),
|
|
status=VALUES(status), approve_url=VALUES(approve_url), session_id=VALUES(session_id), updated_at=NOW()'
|
|
);
|
|
if (!$stmt) {
|
|
throw new \RuntimeException('payment_transactions: ' . $this->db->error);
|
|
}
|
|
$module = $this->module();
|
|
$number = (string) ($order->order_number ?? '');
|
|
$currency = $this->currency();
|
|
$status = 'open';
|
|
$sid = \function_exists('session_id') ? (string) \session_id() : '';
|
|
$cid = (int) ($order->customer_id ?? 0);
|
|
$stmt->bind_param('sissdssssi', $module, $orderId, $number, $reference, $amount, $currency, $status, $approveUrl, $sid, $cid);
|
|
$stmt->execute();
|
|
$stmt->close();
|
|
|
|
$ref = $this->db->real_escape_string($reference);
|
|
$this->db->query("UPDATE orders SET paypal_token='$ref' WHERE id=$orderId");
|
|
|
|
$this->pendingSet($orderId, $reference, $amount, $approveUrl);
|
|
$this->event('payment_started', $orderId, $number, $amount, $reference, null, $cid);
|
|
Log::write($module, 'start', $orderId, $number, $reference, 'open', null, $raw);
|
|
}
|
|
|
|
/**
|
|
* Gibt es zu dieser Anbieter-Referenz schon einen Vorgang (mit Bestellung)?
|
|
* Braucht die Kasse bei der Express-Rueckkehr: verlangt PayPal nach dem Betrags-PATCH
|
|
* eine zweite Freigabe, kommt der Kunde ueber die Express-URL zurueck, obwohl die
|
|
* Bestellung laengst existiert -- dann ist es eine normale Rueckkehr mit Capture.
|
|
*/
|
|
public function knownReference(string $reference): bool
|
|
{
|
|
return $reference !== '' && $this->transactionByReference($reference) !== null;
|
|
}
|
|
|
|
protected function transactionByReference(string $reference): ?object
|
|
{
|
|
$stmt = $this->db->prepare('SELECT * FROM payment_transactions WHERE provider=? AND reference=? LIMIT 1');
|
|
if (!$stmt) {
|
|
return null;
|
|
}
|
|
$m = $this->module();
|
|
$stmt->bind_param('ss', $m, $reference);
|
|
$stmt->execute();
|
|
$row = $stmt->get_result()->fetch_object();
|
|
$stmt->close();
|
|
return $row ?: null;
|
|
}
|
|
|
|
protected function transactionByOrder(int $orderId): ?object
|
|
{
|
|
$stmt = $this->db->prepare('SELECT * FROM payment_transactions WHERE provider=? AND order_id=? ORDER BY id DESC LIMIT 1');
|
|
if (!$stmt) {
|
|
return null;
|
|
}
|
|
$m = $this->module();
|
|
$stmt->bind_param('si', $m, $orderId);
|
|
$stmt->execute();
|
|
$row = $stmt->get_result()->fetch_object();
|
|
$stmt->close();
|
|
return $row ?: null;
|
|
}
|
|
|
|
/** Offene Vorgaenge fuer den Abgleicher. @return list<object> */
|
|
protected function openTransactions(int $olderThanMinutes, int $maxAgeHours): array
|
|
{
|
|
$stmt = $this->db->prepare(
|
|
'SELECT * FROM payment_transactions
|
|
WHERE provider=? AND status IN ("open","pending")
|
|
AND created_at < DATE_SUB(NOW(), INTERVAL ? MINUTE)
|
|
AND created_at > DATE_SUB(NOW(), INTERVAL ? HOUR)
|
|
ORDER BY id'
|
|
);
|
|
if (!$stmt) {
|
|
return [];
|
|
}
|
|
$m = $this->module();
|
|
$stmt->bind_param('sii', $m, $olderThanMinutes, $maxAgeHours);
|
|
$stmt->execute();
|
|
$rows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
|
|
$stmt->close();
|
|
return \array_map(static fn($r) => (object) $r, $rows);
|
|
}
|
|
|
|
protected function setTransactionStatus(string $reference, string $status, ?string $detail = null): void
|
|
{
|
|
$stmt = $this->db->prepare('UPDATE payment_transactions SET status=?, detail=?, updated_at=NOW() WHERE provider=? AND reference=?');
|
|
if ($stmt) {
|
|
$m = $this->module();
|
|
$stmt->bind_param('ssss', $status, $detail, $m, $reference);
|
|
$stmt->execute();
|
|
$stmt->close();
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------- bezahlt
|
|
|
|
/**
|
|
* Setzt die Bestellung genau einmal auf bezahlt, gleicht vorher den Betrag ab.
|
|
* Rueckgabe true, wenn die Bestellung jetzt (oder schon vorher) bezahlt ist.
|
|
*
|
|
* Atomar ueber payment_transactions.status: nur wer den Wechsel open/pending -> paid
|
|
* schafft, loest Mail und Warenkorb aus. Zweite Rueckkehr, Webhook und Abgleicher
|
|
* laufen dann ins Leere statt in Doppelmails.
|
|
*/
|
|
protected function markPaid(string $reference, string $paidAmount, string $paidCurrency, string $captureId, string $payerEmail = '', string $payerId = '', string $source = 'return'): bool
|
|
{
|
|
$tx = $this->transactionByReference($reference);
|
|
if (!$tx) {
|
|
\error_log($this->label() . ': markPaid ohne Vorgang (' . $reference . ')');
|
|
return false;
|
|
}
|
|
$orderId = (int) $tx->order_id;
|
|
|
|
if ($tx->status === 'paid') {
|
|
return true;
|
|
}
|
|
|
|
if (!$this->amountMatches((string) $tx->expected_amount, $paidAmount, (string) $tx->currency, $paidCurrency)) {
|
|
$msg = "Betrag weicht ab: erwartet {$tx->expected_amount} {$tx->currency}, gezahlt $paidAmount $paidCurrency";
|
|
\error_log($this->label() . " BETRAGS-MISMATCH Order $orderId ($reference): $msg");
|
|
$this->setTransactionStatus($reference, 'mismatch', $msg);
|
|
$this->event('failed', $orderId, (string) $tx->order_number, (float) $paidAmount, $reference, $captureId, (int) $tx->customer_id, $msg);
|
|
Log::write($this->module(), 'mismatch', $orderId, (string) $tx->order_number, $reference, 'mismatch', null, null, $msg);
|
|
return false;
|
|
}
|
|
|
|
// Der eine Schreiber gewinnt.
|
|
$stmt = $this->db->prepare(
|
|
'UPDATE payment_transactions SET status="paid", capture_id=?, paid_amount=?, paid_at=NOW(), updated_at=NOW(), detail=?
|
|
WHERE provider=? AND reference=? AND status IN ("open","pending")'
|
|
);
|
|
if (!$stmt) {
|
|
return false;
|
|
}
|
|
$m = $this->module();
|
|
$amt = (float) $paidAmount;
|
|
$stmt->bind_param('sdsss', $captureId, $amt, $source, $m, $reference);
|
|
$stmt->execute();
|
|
$won = $stmt->affected_rows === 1;
|
|
$stmt->close();
|
|
if (!$won) {
|
|
return $this->transactionByReference($reference)?->status === 'paid';
|
|
}
|
|
|
|
$cap = $this->db->real_escape_string($captureId);
|
|
$eml = $this->db->real_escape_string($payerEmail);
|
|
$pid = $this->db->real_escape_string($payerId);
|
|
$this->db->query(
|
|
"UPDATE orders SET payment_status=2, payment_date=CURDATE(), paypal_transaction_id='$cap'"
|
|
. ($payerEmail !== '' ? ", paypal_email='$eml'" : '')
|
|
. ($payerId !== '' ? ", paypal_payerid='$pid'" : '')
|
|
. " WHERE id=$orderId"
|
|
);
|
|
|
|
$this->clearCart((string) $tx->session_id);
|
|
$this->sendOrderConfirmation($orderId, (int) $tx->customer_id);
|
|
$this->pendingClear();
|
|
$this->event('paid', $orderId, (string) $tx->order_number, (float) $paidAmount, $reference, $captureId, (int) $tx->customer_id, 'via ' . $source);
|
|
return true;
|
|
}
|
|
|
|
/** Kunde hat abgebrochen oder der Anbieter hat den Vorgang verworfen. */
|
|
protected function markCancelled(string $reference, string $why, string $source = 'cancel'): void
|
|
{
|
|
$tx = $this->transactionByReference($reference);
|
|
if (!$tx || $tx->status === 'paid') {
|
|
return;
|
|
}
|
|
$this->setTransactionStatus($reference, 'cancelled', $why);
|
|
$orderId = (int) $tx->order_id;
|
|
// 6 = storniert (wie v1-PayPal im Cancel-Zweig).
|
|
// 22.09.2026 -- dazu die Storno-Felder der Bestellung fuellen, damit im Bestell-Editor
|
|
// steht, WARUM: bisher stand da nur "Storniert", der Grund lag allein in
|
|
// payment_transactions.detail. Vorhandene Eintraege werden nicht ueberschrieben.
|
|
$msg = $this->db->real_escape_string(
|
|
$this->label() . ': ' . $why . '. Zahlung nicht abgeschlossen, kein Geld geflossen. '
|
|
. 'Automatisch storniert (' . $source . ', ' . \date('d.m.Y H:i') . ').'
|
|
);
|
|
$reason = $this->cancellationReasonId();
|
|
$this->db->query(
|
|
"UPDATE orders SET order_status=6,
|
|
cancellation_date = COALESCE(cancellation_date, CURDATE()),
|
|
cancellation_status = IF(cancellation_status IS NULL OR cancellation_status=0, 2, cancellation_status),
|
|
cancellation_reason_id = " . ($reason > 0 ? "COALESCE(NULLIF(cancellation_reason_id,0), $reason)" : 'cancellation_reason_id') . ",
|
|
cancellation_info = IF(cancellation_info IS NULL OR cancellation_info='', '$msg', CONCAT(cancellation_info, '\n', '$msg'))
|
|
WHERE id=$orderId AND payment_status<>2"
|
|
);
|
|
$this->pendingClear();
|
|
$this->event('cancelled', $orderId, (string) $tx->order_number, (float) $tx->expected_amount, $reference, null, (int) $tx->customer_id, $why . ' (' . $source . ')');
|
|
Log::write($this->module(), 'cancel', $orderId, (string) $tx->order_number, $reference, 'cancelled', null, null, $why);
|
|
}
|
|
|
|
/** Stornogrund "Zahlung beim Anbieter nicht abgeschlossen" (install.sql), 0 wenn nicht angelegt. */
|
|
private function cancellationReasonId(): int
|
|
{
|
|
$res = $this->db->query("SELECT id FROM cancellation_reasons WHERE name='Zahlung beim Anbieter nicht abgeschlossen' LIMIT 1");
|
|
$r = $res ? $res->fetch_object() : null;
|
|
return $r ? (int) $r->id : 0;
|
|
}
|
|
|
|
protected function amountMatches(string $expected, string $paid, string $expCur, string $paidCur): bool
|
|
{
|
|
return \number_format((float) $expected, 2, '.', '') === \number_format((float) $paid, 2, '.', '')
|
|
&& \strtoupper($expCur) === \strtoupper($paidCur);
|
|
}
|
|
|
|
protected function currency(): string
|
|
{
|
|
return \strtoupper($this->setting('currency', 'EUR'));
|
|
}
|
|
|
|
// --------------------------------------------------------- Shop-Anbindung
|
|
|
|
protected function orderById(int $orderId): object
|
|
{
|
|
$res = $this->db->query("SELECT * FROM orders WHERE id=$orderId LIMIT 1");
|
|
$row = $res ? $res->fetch_object() : null;
|
|
if (!$row) {
|
|
throw new \RuntimeException('Bestellung ' . $orderId . ' nicht gefunden.');
|
|
}
|
|
return $row;
|
|
}
|
|
|
|
/** Lieferadresse der Bestellung fuer den Anbieter (Kaeufer-/Verkaeuferschutz). */
|
|
protected function shippingAddress(object $order): array
|
|
{
|
|
$iso = 'DE';
|
|
$cid = (int) ($order->shipping_country ?? 0);
|
|
if ($cid > 0) {
|
|
$res = $this->db->query("SELECT iso_code_2 FROM countries WHERE id=$cid");
|
|
$r = $res ? $res->fetch_object() : null;
|
|
if ($r && \strlen((string) $r->iso_code_2) === 2) {
|
|
$iso = \strtoupper((string) $r->iso_code_2);
|
|
}
|
|
}
|
|
return [
|
|
'name' => \trim(((string) ($order->shipping_firstname ?? '')) . ' ' . ((string) ($order->shipping_surname ?? ''))),
|
|
'company' => (string) ($order->shipping_company ?? ''),
|
|
'street' => \trim(((string) ($order->shipping_street ?? '')) . ' ' . ((string) ($order->shipping_house_number ?? ''))),
|
|
'zip' => (string) ($order->shipping_zip_code ?? ''),
|
|
'city' => (string) ($order->shipping_city ?? ''),
|
|
'country' => $iso,
|
|
'email' => (string) ($order->customer_email ?? ''),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Warenkorb leeren -- ueber die Session von damals UND die aktuelle. Der Kunde kann
|
|
* mit einer neuen Session zurueckkommen (Safari/iOS), der Webhook hat gar keine.
|
|
*/
|
|
protected function clearCart(string $originalSessionId): void
|
|
{
|
|
$ids = [];
|
|
if ($originalSessionId !== '') {
|
|
$ids[$originalSessionId] = true;
|
|
}
|
|
if (\function_exists('session_id') && \session_id() !== '') {
|
|
$ids[\session_id()] = true;
|
|
}
|
|
foreach (\array_keys($ids) as $sid) {
|
|
$s = $this->db->real_escape_string((string) $sid);
|
|
$this->db->query("DELETE FROM shopping_cart_items WHERE session_id='$s'");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Bestellbestaetigung ausloesen (Ticket-Event 3). Fehler werden protokolliert und
|
|
* geschluckt: das Geld ist eingezogen, eine Ausnahme wuerde dem Kunden eine
|
|
* Fehlerseite fuer eine erfolgreiche Bestellung zeigen.
|
|
*/
|
|
protected function sendOrderConfirmation(int $orderId, int $customerId): void
|
|
{
|
|
try {
|
|
if (!\class_exists('Cs_ticket')) {
|
|
$docroot = \defined('ROOT_DIR') ? \rtrim((string) ROOT_DIR, '/') : \dirname(__DIR__, 2);
|
|
require_once $docroot . '/core/cs_ticket.class.php';
|
|
}
|
|
$ticket = new \Cs_ticket($this->base);
|
|
$ticket->event(3, $customerId, $orderId);
|
|
Log::write($this->module(), 'mail', $orderId, null, null, 'sent');
|
|
} catch (\Throwable $e) {
|
|
\error_log($this->label() . ': Bestellbestaetigung fuer Order ' . $orderId . ' fehlgeschlagen: ' . $e->getMessage());
|
|
Log::write($this->module(), 'mail', $orderId, null, null, 'failed', null, null, $e->getMessage());
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------ Session-Riegel
|
|
|
|
/** Offener Vorgang in der Session: Riegel gegen zweite Bestellung per Zurueck-Taste. */
|
|
protected function pendingSet(int $orderId, string $reference, float $amount, string $approveUrl): void
|
|
{
|
|
if (\session_status() !== PHP_SESSION_ACTIVE) {
|
|
return;
|
|
}
|
|
$_SESSION['easyshop']['payment_pending'] = [
|
|
'module' => $this->module(),
|
|
'reference' => $reference,
|
|
'order_id' => $orderId,
|
|
'total' => \round($amount, 2),
|
|
'approve_url' => $approveUrl,
|
|
'time' => \time(),
|
|
];
|
|
}
|
|
|
|
protected function pendingClear(): void
|
|
{
|
|
if (\session_status() === PHP_SESSION_ACTIVE) {
|
|
unset($_SESSION['easyshop']['payment_pending']);
|
|
}
|
|
}
|
|
|
|
/** Ist die Bestellung des offenen Vorgangs bezahlt? (fuer den Cashdesk-Riegel) */
|
|
public function isPaid(int $orderId): bool
|
|
{
|
|
$res = $this->db->query("SELECT payment_status FROM orders WHERE id=$orderId");
|
|
$r = $res ? $res->fetch_object() : null;
|
|
return $r && (string) $r->payment_status === '2';
|
|
}
|
|
|
|
// --------------------------------------------------------------- Events
|
|
|
|
protected function event(string $name, ?int $orderId, ?string $orderNumber, ?float $amount, ?string $reference, ?string $transactionId, ?int $customerId, ?string $detail = null): void
|
|
{
|
|
if (!\function_exists('shop_event')) {
|
|
return;
|
|
}
|
|
\shop_event($name, [
|
|
'order_id' => $orderId,
|
|
'order_number' => $orderNumber,
|
|
'payment_method' => $this->paymentMethodId(),
|
|
'betrag' => $amount,
|
|
'paypal_token' => $reference,
|
|
'transaction_id' => $transactionId,
|
|
'customer_id' => $customerId,
|
|
'detail' => \trim($this->module() . ': ' . (string) $detail),
|
|
]);
|
|
}
|
|
|
|
private ?int $pmId = null;
|
|
|
|
protected function paymentMethodId(): ?int
|
|
{
|
|
if ($this->pmId === null) {
|
|
$m = $this->db->real_escape_string($this->module());
|
|
$res = $this->db->query("SELECT id FROM payment_methods WHERE module='$m' LIMIT 1");
|
|
$r = $res ? $res->fetch_object() : null;
|
|
$this->pmId = $r ? (int) $r->id : 0;
|
|
}
|
|
return $this->pmId ?: null;
|
|
}
|
|
|
|
public function secretFields(): array
|
|
{
|
|
return [];
|
|
}
|
|
|
|
// ------------------------------------------------------- Express-Defaults
|
|
|
|
public function supportsExpress(): bool
|
|
{
|
|
return false;
|
|
}
|
|
|
|
public function startExpress(float $amount): string
|
|
{
|
|
throw new \RuntimeException($this->label() . ': kein Express-Weg');
|
|
}
|
|
|
|
public function onExpressReturn(array $query): ?array
|
|
{
|
|
return null;
|
|
}
|
|
|
|
public function finishExpress(int $orderId, float $amount, string $reference): string
|
|
{
|
|
throw new \RuntimeException($this->label() . ': kein Express-Weg');
|
|
}
|
|
|
|
/** Express-Rueckkehr laeuft ueber den Cashdesk, ohne Bestellung. */
|
|
protected function expressReturnUrl(): string
|
|
{
|
|
return $this->shopBaseUrl() . '/Kasse/?payment_express=' . $this->module();
|
|
}
|
|
|
|
protected function expressCancelUrl(): string
|
|
{
|
|
return $this->shopBaseUrl() . '/Warenkorb/?payment_express_cancel=' . $this->module();
|
|
}
|
|
|
|
/** Laender, aus denen Express-Adressen angenommen werden (ISO-2, kommagetrennt). Standard: nur DE. */
|
|
public function expressCountries(): string
|
|
{
|
|
return $this->setting('express_countries', 'DE');
|
|
}
|
|
|
|
/** countries.id zu einem ISO-3166-Alpha-2-Code, 0 wenn das Land im Shop nicht lieferbar ist. */
|
|
public function countryIdByIso(string $iso): int
|
|
{
|
|
$iso = $this->db->real_escape_string(\strtoupper($iso));
|
|
$res = $this->db->query("SELECT id FROM countries WHERE iso_code_2='$iso' ORDER BY rang DESC, id LIMIT 1");
|
|
$r = $res ? $res->fetch_object() : null;
|
|
return $r ? (int) $r->id : 0;
|
|
}
|
|
|
|
/** "Neuer Weg 5a" -> ["Neuer Weg", "5a"]; ohne erkennbare Nummer bleibt alles Strasse. */
|
|
public static function splitStreet(string $line): array
|
|
{
|
|
$line = \trim($line);
|
|
if (\preg_match('/^(.*?)[\s,]+(\d+[\s\-\/]?[a-zA-Z0-9\-\/]*)$/u', $line, $m)) {
|
|
return [\trim($m[1]), \trim($m[2])];
|
|
}
|
|
return [$line, ''];
|
|
}
|
|
}
|