feat(payments): Express-Kauf aus dem Warenkorb (PayPal v2): Start ohne Bestellung, Adresse von PayPal, Kasse vorbelegt, Capture ohne zweiten Besuch
This commit is contained in:
@@ -350,6 +350,159 @@ final class Provider extends AbstractProvider
|
||||
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, '.', '');
|
||||
|
||||
$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]);
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user