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
This commit is contained in:
Thomas Bartelt
2026-09-27 10:39:23 +02:00
parent d5a3727884
commit b8111a1e7f
6 changed files with 841 additions and 5 deletions
+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]];
}
}
+547
View File
@@ -0,0 +1,547 @@
<?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_...)',
];
}
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
/**
* Mollie schickt application/x-www-form-urlencoded mit genau einem Feld: id=tr_...
* Keine Signatur -- die Wahrheit kommt per GET von Mollie. Antwort 200 = angenommen,
* alles andere wird bis zu 10x in 26 h wiederholt.
*/
public function onWebhook(array $headers, string $rawBody): int
{
$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;
}
// -------------------------------------------------------------- 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;
}
}