Add PayPal checkout provider

This commit is contained in:
Codex Agent
2026-02-04 12:18:14 +01:00
parent 56a39d0535
commit fc5dfb272c
33 changed files with 1586 additions and 571 deletions

View File

@@ -69,7 +69,8 @@ class CheckoutAssignmentService
?? ($metadata['lemonsqueezy_order_id'] ?? $metadata['lemonsqueezy_checkout_id'] ? CheckoutSession::PROVIDER_LEMONSQUEEZY : null)
?? CheckoutSession::PROVIDER_FREE;
$totals = $this->resolveLemonSqueezyTotals($session, $options['payload'] ?? []);
$provider = $providerName ?: $session->provider;
$totals = $this->resolveProviderTotals($session, $options['payload'] ?? [], $provider);
$currency = $totals['currency'] ?? $session->currency ?? $package->currency ?? 'EUR';
$price = array_key_exists('total', $totals) ? $totals['total'] : (float) $session->amount_total;
@@ -88,7 +89,8 @@ class CheckoutAssignmentService
'payload' => $options['payload'] ?? null,
'checkout_session_id' => $session->id,
'consents' => $consents ?: null,
'lemonsqueezy_totals' => $totals !== [] ? $totals : null,
'lemonsqueezy_totals' => $provider === CheckoutSession::PROVIDER_LEMONSQUEEZY && $totals !== [] ? $totals : null,
'paypal_totals' => $provider === CheckoutSession::PROVIDER_PAYPAL && $totals !== [] ? $totals : null,
'currency' => $currency,
], static fn ($value) => $value !== null && $value !== ''),
]
@@ -219,6 +221,19 @@ class CheckoutAssignmentService
])->save();
}
/**
* @param array<string, mixed> $payload
* @return array{currency?: string, subtotal?: float, discount?: float, tax?: float, total?: float}
*/
protected function resolveProviderTotals(CheckoutSession $session, array $payload, ?string $provider): array
{
if ($provider === CheckoutSession::PROVIDER_PAYPAL) {
return $this->resolvePayPalTotals($session, $payload);
}
return $this->resolveLemonSqueezyTotals($session, $payload);
}
/**
* @param array<string, mixed> $payload
* @return array{currency?: string, subtotal?: float, discount?: float, tax?: float, total?: float}
@@ -252,6 +267,34 @@ class CheckoutAssignmentService
], static fn ($value) => $value !== null);
}
/**
* @param array<string, mixed> $payload
* @return array{currency?: string, total?: float}
*/
protected function resolvePayPalTotals(CheckoutSession $session, array $payload): array
{
$metadataTotals = $session->provider_metadata['paypal_totals'] ?? null;
if (is_array($metadataTotals) && $metadataTotals !== []) {
return $metadataTotals;
}
$amount = Arr::get($payload, 'purchase_units.0.payments.captures.0.amount')
?? Arr::get($payload, 'purchase_units.0.amount');
if (! is_array($amount)) {
return [];
}
$currency = Arr::get($amount, 'currency_code');
$total = Arr::get($amount, 'value');
return array_filter([
'currency' => is_string($currency) ? strtoupper($currency) : null,
'total' => is_numeric($total) ? (float) $total : null,
], static fn ($value) => $value !== null);
}
protected function convertMinorAmount(mixed $value): ?float
{
if ($value === null || $value === '') {

View File

@@ -74,6 +74,8 @@ class CheckoutSessionService
$session->status = CheckoutSession::STATUS_DRAFT;
$session->lemonsqueezy_checkout_id = null;
$session->lemonsqueezy_order_id = null;
$session->paypal_order_id = null;
$session->paypal_capture_id = null;
$session->provider_metadata = [];
$session->failure_reason = null;
$session->coupon()->dissociate();
@@ -117,10 +119,20 @@ class CheckoutSessionService
{
$provider = strtolower($provider);
if (! in_array($provider, [
CheckoutSession::PROVIDER_LEMONSQUEEZY,
CheckoutSession::PROVIDER_FREE,
], true)) {
$configuredProviders = config('checkout.providers', []);
if (! is_array($configuredProviders) || $configuredProviders === []) {
$configuredProviders = [
CheckoutSession::PROVIDER_LEMONSQUEEZY,
CheckoutSession::PROVIDER_PAYPAL,
CheckoutSession::PROVIDER_FREE,
];
}
if (! in_array(CheckoutSession::PROVIDER_FREE, $configuredProviders, true)) {
$configuredProviders[] = CheckoutSession::PROVIDER_FREE;
}
if (! in_array($provider, $configuredProviders, true)) {
throw new RuntimeException("Unsupported checkout provider [{$provider}]");
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Services\PayPal\Exceptions;
use RuntimeException;
class PayPalException extends RuntimeException
{
public function __construct(string $message, private readonly ?int $status = null, private readonly array $context = [])
{
parent::__construct($message, $status ?? 0);
}
public function status(): ?int
{
return $this->status;
}
public function context(): array
{
return $this->context;
}
}

View File

@@ -0,0 +1,145 @@
<?php
namespace App\Services\PayPal;
use App\Services\PayPal\Exceptions\PayPalException;
use Illuminate\Http\Client\Factory as HttpFactory;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
class PayPalClient
{
public function __construct(
private readonly HttpFactory $http,
) {}
public function get(string $endpoint, array $query = [], array $headers = []): array
{
return $this->send('GET', $endpoint, [
'query' => $query,
'headers' => $headers,
]);
}
public function post(string $endpoint, array|object $payload = [], array $headers = []): array
{
return $this->send('POST', $endpoint, [
'json' => $payload,
'headers' => $headers,
]);
}
protected function send(string $method, string $endpoint, array $options = []): array
{
$request = $this->preparedRequest();
try {
$response = $request->send(strtoupper($method), ltrim($endpoint, '/'), $options);
} catch (RequestException $exception) {
throw new PayPalException(
$exception->getMessage(),
$exception->response?->status(),
$exception->response?->json() ?? []
);
}
if ($response->failed()) {
$body = $response->json() ?? [];
$message = Arr::get($body, 'message')
?? Arr::get($body, 'details.0.description')
?? sprintf('PayPal request failed with status %s', $response->status());
throw new PayPalException($message, $response->status(), $body);
}
return $response->json() ?? [];
}
protected function preparedRequest(): PendingRequest
{
$baseUrl = $this->baseUrl();
$token = $this->accessToken();
return $this->http
->baseUrl($baseUrl)
->withHeaders([
'Accept' => 'application/json',
'Content-Type' => 'application/json',
'User-Agent' => sprintf('FotospielApp/%s PayPalClient', app()->version()),
])
->withToken($token)
->acceptJson()
->asJson();
}
protected function accessToken(): string
{
$cacheKey = 'paypal:access_token';
$cached = Cache::get($cacheKey);
if (is_string($cached) && $cached !== '') {
return $cached;
}
$tokenResponse = $this->requestAccessToken();
$accessToken = Arr::get($tokenResponse, 'access_token');
if (! is_string($accessToken) || $accessToken === '') {
throw new PayPalException('PayPal access token missing from response.', null, $tokenResponse);
}
$expiresIn = (int) Arr::get($tokenResponse, 'expires_in', 0);
if ($expiresIn > 60) {
Cache::put($cacheKey, $accessToken, now()->addSeconds($expiresIn - 60));
}
return $accessToken;
}
protected function requestAccessToken(): array
{
$clientId = config('services.paypal.client_id');
$secret = config('services.paypal.secret');
if (! $clientId || ! $secret) {
throw new PayPalException('PayPal client credentials are not configured.');
}
$response = $this->http
->baseUrl($this->baseUrl())
->withBasicAuth($clientId, $secret)
->asForm()
->acceptJson()
->post('/v1/oauth2/token', [
'grant_type' => 'client_credentials',
]);
if ($response->failed()) {
$body = $response->json() ?? [];
$message = Arr::get($body, 'error_description')
?? Arr::get($body, 'message')
?? sprintf('PayPal OAuth failed with status %s', $response->status());
throw new PayPalException($message, $response->status(), $body);
}
return $response->json() ?? [];
}
protected function baseUrl(): string
{
$sandbox = (bool) config('services.paypal.sandbox', true);
$baseUrl = $sandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com';
$baseUrl = trim($baseUrl);
if (! Str::startsWith($baseUrl, ['http://', 'https://'])) {
$baseUrl = 'https://'.ltrim($baseUrl, '/');
}
return rtrim($baseUrl, '/');
}
}

View File

@@ -0,0 +1,161 @@
<?php
namespace App\Services\PayPal;
use App\Models\CheckoutSession;
use App\Models\Package;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
class PayPalOrderService
{
public function __construct(private readonly PayPalClient $client) {}
/**
* @param array{return_url?: string|null, cancel_url?: string|null, locale?: string|null, request_id?: string|null} $options
* @return array<string, mixed>
*/
public function createOrder(CheckoutSession $session, Package $package, array $options = []): array
{
$currency = strtoupper((string) ($session->currency ?: $package->currency ?: 'EUR'));
$total = $this->formatAmount((float) $session->amount_total);
$subtotal = $this->formatAmount((float) $session->amount_subtotal);
$discount = $this->formatAmount((float) $session->amount_discount);
$amount = [
'currency_code' => $currency,
'value' => $total,
];
if ($subtotal !== null && $discount !== null) {
$amount['breakdown'] = [
'item_total' => [
'currency_code' => $currency,
'value' => $subtotal,
],
'discount' => [
'currency_code' => $currency,
'value' => $discount,
],
];
}
$purchaseUnit = [
'reference_id' => 'package-'.$package->id,
'description' => Str::limit($package->name ?? 'Package', 127, ''),
'custom_id' => $session->id,
'amount' => $amount,
];
$applicationContext = array_filter([
'brand_name' => config('app.name', 'Fotospiel'),
'landing_page' => 'NO_PREFERENCE',
'user_action' => 'PAY_NOW',
'shipping_preference' => 'NO_SHIPPING',
'locale' => $this->resolveLocale($options['locale'] ?? $session->locale),
'return_url' => $options['return_url'] ?? null,
'cancel_url' => $options['cancel_url'] ?? null,
], static fn ($value) => $value !== null && $value !== '');
$payload = [
'intent' => 'CAPTURE',
'purchase_units' => [$purchaseUnit],
'application_context' => $applicationContext,
];
$headers = [];
$requestId = $options['request_id'] ?? $session->id;
if (is_string($requestId) && $requestId !== '') {
$headers['PayPal-Request-Id'] = $requestId;
}
return $this->client->post('/v2/checkout/orders', $payload, $headers);
}
/**
* @param array{request_id?: string|null} $options
* @return array<string, mixed>
*/
public function captureOrder(string $orderId, array $options = []): array
{
$headers = [];
$requestId = $options['request_id'] ?? null;
if (is_string($requestId) && $requestId !== '') {
$headers['PayPal-Request-Id'] = $requestId;
}
return $this->client->post(sprintf('/v2/checkout/orders/%s/capture', $orderId), [], $headers);
}
public function resolveApproveUrl(array $payload): ?string
{
$links = Arr::get($payload, 'links', []);
if (! is_array($links)) {
return null;
}
foreach ($links as $link) {
if (! is_array($link)) {
continue;
}
if (($link['rel'] ?? null) === 'approve') {
return is_string($link['href'] ?? null) ? $link['href'] : null;
}
}
return null;
}
public function resolveCaptureId(array $payload): ?string
{
$captureId = Arr::get($payload, 'purchase_units.0.payments.captures.0.id');
return is_string($captureId) && $captureId !== '' ? $captureId : null;
}
/**
* @return array{currency?: string, total?: float}
*/
public function resolveTotals(array $payload): array
{
$amount = Arr::get($payload, 'purchase_units.0.payments.captures.0.amount')
?? Arr::get($payload, 'purchase_units.0.amount');
if (! is_array($amount)) {
return [];
}
$currency = Arr::get($amount, 'currency_code');
$total = Arr::get($amount, 'value');
return array_filter([
'currency' => is_string($currency) ? strtoupper($currency) : null,
'total' => is_numeric($total) ? (float) $total : null,
], static fn ($value) => $value !== null);
}
protected function resolveLocale(?string $locale): ?string
{
if (! $locale) {
return null;
}
$normalized = str_replace('_', '-', $locale);
return match (strtolower($normalized)) {
'de', 'de-de', 'de-at', 'de-ch' => 'de-DE',
'en', 'en-gb' => 'en-GB',
default => 'en-US',
};
}
protected function formatAmount(float $amount): ?string
{
if (! is_finite($amount)) {
return null;
}
return number_format($amount, 2, '.', '');
}
}