Add PayPal checkout provider
This commit is contained in:
@@ -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 === '') {
|
||||
|
||||
@@ -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}]");
|
||||
}
|
||||
|
||||
|
||||
23
app/Services/PayPal/Exceptions/PayPalException.php
Normal file
23
app/Services/PayPal/Exceptions/PayPalException.php
Normal 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;
|
||||
}
|
||||
}
|
||||
145
app/Services/PayPal/PayPalClient.php
Normal file
145
app/Services/PayPal/PayPalClient.php
Normal 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, '/');
|
||||
}
|
||||
}
|
||||
161
app/Services/PayPal/PayPalOrderService.php
Normal file
161
app/Services/PayPal/PayPalOrderService.php
Normal 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, '.', '');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user