Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
20.37% covered (danger)
20.37%
11 / 54
37.50% covered (danger)
37.50%
3 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
PaypalDriver
20.37% covered (danger)
20.37%
11 / 54
37.50% covered (danger)
37.50%
3 / 8
221.97
0.00% covered (danger)
0.00%
0 / 1
 key
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 label
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 configSchema
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 charge
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
56
 verify
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
30
 isImplemented
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 accessToken
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 endpoint
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace App\Services\Payment\Drivers;
4
5use App\Models\Order;
6use App\Services\Payment\PaymentResult;
7use Illuminate\Http\Request;
8use Illuminate\Support\Facades\Http;
9
10/**
11 * PayPal Orders v2 API (https://developer.paypal.com/docs/api/orders/v2/) —
12 * plain REST + OAuth2 client-credentials, no SDK needed. Same currency
13 * caveat as StripeDriver: PayPal doesn't settle in BDT, so a settlement
14 * currency must be set and the order total is sent unconverted.
15 */
16class PaypalDriver extends AbstractDriver
17{
18    public function key(): string
19    {
20        return 'paypal';
21    }
22
23    public function label(): string
24    {
25        return 'PayPal (international)';
26    }
27
28    public function configSchema(): array
29    {
30        return [
31            'client_id' => ['type' => 'text', 'label' => 'Client ID', 'required' => true],
32            'client_secret' => ['type' => 'password', 'label' => 'Client Secret', 'required' => true],
33            'currency' => [
34                'type' => 'text', 'label' => 'Settlement currency (e.g. USD)', 'required' => true,
35                'help' => 'PayPal cannot settle in BDT. This app does not convert currency — the order total is sent to PayPal as-is in whatever currency you set here.',
36            ],
37            'sandbox' => ['type' => 'toggle', 'label' => 'Sandbox mode', 'default' => true],
38        ];
39    }
40
41    public function charge(Order $order, array $config): PaymentResult
42    {
43        if (empty($config['client_id']) || empty($config['client_secret']) || empty($config['currency'])) {
44            return PaymentResult::failed('PayPal is not configured — add the Client ID, Secret, and settlement currency in Settings → Payment Gateways.');
45        }
46
47        $token = $this->accessToken($config);
48
49        if (! $token) {
50            return PaymentResult::failed('Could not authenticate with PayPal — check the Client ID/Secret.');
51        }
52
53        $response = Http::withToken($token)->post($this->endpoint($config, '/v2/checkout/orders'), [
54            'intent' => 'CAPTURE',
55            'purchase_units' => [[
56                'reference_id' => (string) $order->bar_code,
57                'amount' => [
58                    'currency_code' => strtoupper($config['currency']),
59                    'value' => number_format((float) $order->due, 2, '.', ''),
60                ],
61            ]],
62            'application_context' => [
63                'return_url' => route('payment.callback', ['gateway' => 'paypal', 'result' => 'success', 'order' => $order->bar_code]),
64                'cancel_url' => route('payment.callback', ['gateway' => 'paypal', 'result' => 'cancel', 'order' => $order->bar_code]),
65            ],
66        ]);
67
68        $data = $response->json() ?? [];
69        $approveUrl = collect($data['links'] ?? [])->firstWhere('rel', 'approve')['href'] ?? null;
70
71        if (! $response->successful() || ! $approveUrl) {
72            return PaymentResult::failed($data['message'] ?? 'Could not create the PayPal order.', $data);
73        }
74
75        return PaymentResult::redirect($approveUrl, $data['id'] ?? null);
76    }
77
78    public function verify(Request $request, array $config): PaymentResult
79    {
80        $paypalOrderId = $request->query('token'); // PayPal returns its order id as ?token=
81
82        if (! $paypalOrderId) {
83            return PaymentResult::failed('Missing PayPal order id.');
84        }
85
86        $token = $this->accessToken($config);
87
88        if (! $token) {
89            return PaymentResult::failed('Could not authenticate with PayPal to capture the order.');
90        }
91
92        $response = Http::withToken($token)->post($this->endpoint($config, "/v2/checkout/orders/{$paypalOrderId}/capture"));
93        $data = $response->json() ?? [];
94
95        if ($response->successful() && ($data['status'] ?? null) === 'COMPLETED') {
96            $captureId = $data['purchase_units'][0]['payments']['captures'][0]['id'] ?? $paypalOrderId;
97
98            return PaymentResult::paid($captureId, $data);
99        }
100
101        return PaymentResult::failed($data['message'] ?? 'PayPal could not complete the capture.', $data);
102    }
103
104    public function isImplemented(): bool
105    {
106        return true;
107    }
108
109    private function accessToken(array $config): ?string
110    {
111        $response = Http::asForm()
112            ->withBasicAuth($config['client_id'] ?? '', $config['client_secret'] ?? '')
113            ->post($this->endpoint($config, '/v1/oauth2/token'), ['grant_type' => 'client_credentials']);
114
115        return $response->successful() ? $response->json('access_token') : null;
116    }
117
118    private function endpoint(array $config, string $path): string
119    {
120        $host = (bool) ($config['sandbox'] ?? true) ? 'api-m.sandbox.paypal.com' : 'api-m.paypal.com';
121
122        return "https://{$host}{$path}";
123    }
124}