251 lines
8.8 KiB
PHP
251 lines
8.8 KiB
PHP
<?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];
|
||
}
|
||
}
|