HEX
Server: LiteSpeed
System: Linux s1049.use1.mysecurecloudhost.com 4.18.0-477.27.2.lve.el8.x86_64 #1 SMP Wed Oct 11 12:32:56 UTC 2023 x86_64
User: xedaptot (3356)
PHP: 8.3.31
Disabled: NONE
Upload Files
File: /home/xedaptot/hi.naniguide.com/app/Services/Billing/PayPalPaymentService.php
<?php

namespace App\Services\Billing;

use App\Models\Customer;
use App\Models\Plan;
use App\Models\Setting;
use App\Models\Subscription;
use App\Models\WebhookEvent;
use App\Services\AffiliateCommissionService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;

class PayPalPaymentService implements PaymentProviderInterface
{
    public function createCheckoutSession(Customer $customer, Plan $plan, array $options = []): string
    {
        $clientId = $this->getConfigValue('client_id');
        $clientSecret = $this->getConfigValue('client_secret');

        if (!is_string($clientId) || trim($clientId) === '' || !is_string($clientSecret) || trim($clientSecret) === '') {
            throw new \RuntimeException('PayPal is not configured. Set PAYPAL_CLIENT_ID and PAYPAL_CLIENT_SECRET (or configure via Payment Methods).');
        }

        $currency = strtoupper((string) ($plan->currency ?: ($customer->currency ?: 'USD')));
        $amount = (float) $plan->price;
        if ($amount < 0) {
            $amount = 0;
        }

        $localSubscriptionId = (int) ($options['local_subscription_id'] ?? 0);

        $referenceId = sprintf(
            'sub_%d_%s',
            $localSubscriptionId,
            Str::lower(Str::random(12))
        );

        if ($localSubscriptionId > 0) {
            Subscription::whereKey($localSubscriptionId)->update([
                'payment_reference' => $referenceId,
            ]);
        }

        $returnUrl = $options['redirect_url'] ?? route('billing.paypal.callback');
        $cancelUrl = $options['cancel_url'] ?? route('customer.billing.index');

        $order = $this->createOrder($clientId, $clientSecret, [
            'currency' => $currency,
            'amount' => number_format($amount, 2, '.', ''),
            'description' => (string) ($plan->name ?: (config('app.name') . ' Subscription')),
            'custom_id' => (string) ($localSubscriptionId > 0 ? $localSubscriptionId : ''),
            'reference_id' => $referenceId,
            'return_url' => (string) $returnUrl,
            'cancel_url' => (string) $cancelUrl,
        ]);

        $orderId = $order['id'] ?? null;
        if (!is_string($orderId) || trim($orderId) === '') {
            throw new \RuntimeException('PayPal checkout failed: missing order id.');
        }

        if ($localSubscriptionId > 0) {
            Subscription::whereKey($localSubscriptionId)->update([
                'stripe_checkout_session_id' => $orderId,
            ]);
        }

        $approveUrl = $this->extractApproveUrl($order);
        if (!is_string($approveUrl) || trim($approveUrl) === '') {
            throw new \RuntimeException('PayPal checkout failed: missing approval link.');
        }

        return $approveUrl;
    }

    public function createCustomerPortal(Customer $customer, array $options = []): string
    {
        return (string) ($options['return_url'] ?? url('/customer/billing'));
    }

    public function cancelAtPeriodEnd(Subscription $subscription): void
    {
        return;
    }

    public function resume(Subscription $subscription): void
    {
        return;
    }

    public function handleWebhook(Request $request): void
    {
        $payload = $request->json()->all();
        if (!is_array($payload)) {
            return;
        }

        $eventType = $payload['event_type'] ?? $payload['eventType'] ?? null;
        $eventId = $payload['id'] ?? null;

        if (!is_string($eventType) || trim($eventType) === '') {
            return;
        }

        $eventId = is_string($eventId) ? trim($eventId) : null;
        if ($eventId === null || $eventId === '') {
            $eventId = (string) Str::uuid();
        }

        if (WebhookEvent::where('provider', 'paypal')->where('event_id', $eventId)->exists()) {
            return;
        }

        WebhookEvent::create([
            'provider' => 'paypal',
            'event_id' => $eventId,
            'type' => (string) $eventType,
            'payload' => $payload,
            'processed_at' => now(),
        ]);

        $eventType = (string) $eventType;
        if ($eventType !== 'PAYMENT.CAPTURE.COMPLETED') {
            return;
        }

        $orderId = data_get($payload, 'resource.supplementary_data.related_ids.order_id');
        if (!is_string($orderId) || trim($orderId) === '') {
            $orderId = data_get($payload, 'resource.id');
        }

        if (!is_string($orderId) || trim($orderId) === '') {
            return;
        }

        $subscription = Subscription::query()
            ->where('provider', 'paypal')
            ->where('stripe_checkout_session_id', $orderId)
            ->latest()
            ->first();

        if (!$subscription) {
            return;
        }

        $amount = data_get($payload, 'resource.amount.value');
        $currency = data_get($payload, 'resource.amount.currency_code');

        $amount = is_numeric($amount) ? (float) $amount : null;
        $currency = is_string($currency) ? strtoupper(trim($currency)) : null;

        $this->activateSubscriptionFromPayment($subscription, $orderId, $amount, $currency);
    }

    public function captureOrderAndSync(string $orderId): array
    {
        $clientId = $this->getConfigValue('client_id');
        $clientSecret = $this->getConfigValue('client_secret');

        if (!is_string($clientId) || trim($clientId) === '' || !is_string($clientSecret) || trim($clientSecret) === '') {
            throw new \RuntimeException('PayPal is not configured.');
        }

        return $this->captureOrder($clientId, $clientSecret, $orderId);
    }

    public function applyCapturedOrderData(array $captureData): ?Subscription
    {
        $orderId = $captureData['id'] ?? null;
        if (!is_string($orderId) || trim($orderId) === '') {
            return null;
        }

        $status = (string) ($captureData['status'] ?? '');
        $isCompleted = $status === 'COMPLETED' || $status === 'APPROVED';

        $customId = data_get($captureData, 'purchase_units.0.custom_id');
        $subscription = null;

        if (is_string($customId) && is_numeric($customId)) {
            $subscription = Subscription::query()->whereKey((int) $customId)->first();
        }

        if (!$subscription) {
            $subscription = Subscription::query()
                ->where('provider', 'paypal')
                ->where('stripe_checkout_session_id', $orderId)
                ->latest()
                ->first();
        }

        if (!$subscription) {
            return null;
        }

        if (!$isCompleted) {
            $subscription->update([
                'status' => 'pending',
                'last_payment_status' => $status !== '' ? strtolower($status) : $subscription->last_payment_status,
                'stripe_checkout_session_id' => $orderId,
            ]);

            return $subscription;
        }

        $amount = data_get($captureData, 'purchase_units.0.payments.captures.0.amount.value');
        $currency = data_get($captureData, 'purchase_units.0.payments.captures.0.amount.currency_code');

        $amount = is_numeric($amount) ? (float) $amount : null;
        $currency = is_string($currency) ? strtoupper(trim($currency)) : null;

        $this->activateSubscriptionFromPayment($subscription, $orderId, $amount, $currency);

        return $subscription;
    }

    private function activateSubscriptionFromPayment(Subscription $subscription, string $orderId, ?float $amount, ?string $currency): void
    {
        $plan = $subscription->plan;
        if (!$plan && $subscription->plan_db_id) {
            $plan = Plan::query()->with('customerGroup')->find($subscription->plan_db_id);
        }

        $billingCycle = $plan?->billing_cycle ?? $subscription->billing_cycle;
        $periodStart = now();
        $periodEnd = match ($billingCycle) {
            'yearly' => now()->addYear(),
            'lifetime' => null,
            default => now()->addMonth(),
        };

        $subscription->update([
            'provider' => 'paypal',
            'payment_gateway' => 'paypal',
            'status' => $subscription->trial_ends_at && $subscription->trial_ends_at->isFuture() ? 'trialing' : 'active',
            'period_start' => $periodStart,
            'period_end' => $periodEnd,
            'cancel_at_period_end' => false,
            'auto_renew' => $billingCycle !== 'lifetime',
            'last_payment_status' => 'succeeded',
            'stripe_checkout_session_id' => $orderId,
        ]);

        if ($plan && $plan->customerGroup) {
            $limits = [
                'emails_sent_this_month' => $plan->customerGroup?->quota,
                'subscribers_count' => $plan->customerGroup?->max_subscribers,
                'campaigns_count' => $plan->customerGroup?->max_campaigns,
            ];

            $subscription->update([
                'features' => $plan->customerGroup?->permissions,
                'limits' => array_filter($limits, fn ($v) => $v !== null),
            ]);

            $customer = $subscription->customer;
            if ($customer && $plan->customer_group_id) {
                $customer->customerGroups()->syncWithoutDetaching([(int) $plan->customer_group_id]);
            }
        }

        $eventKey = 'paypal:order:' . $orderId;

        app(AffiliateCommissionService::class)->createCommissionForSubscriptionPayment(
            $subscription,
            'paypal',
            $eventKey,
            $amount,
            $currency
        );
    }

    private function createOrder(string $clientId, string $clientSecret, array $payload): array
    {
        $token = $this->getAccessToken($clientId, $clientSecret);

        $baseUrl = $this->getBaseUrl();

        $response = Http::withToken($token)
            ->acceptJson()
            ->post($baseUrl . '/v2/checkout/orders', [
                'intent' => 'CAPTURE',
                'purchase_units' => [
                    [
                        'reference_id' => (string) ($payload['reference_id'] ?? ''),
                        'description' => (string) ($payload['description'] ?? ''),
                        'custom_id' => (string) ($payload['custom_id'] ?? ''),
                        'amount' => [
                            'currency_code' => (string) ($payload['currency'] ?? 'USD'),
                            'value' => (string) ($payload['amount'] ?? '0.00'),
                        ],
                    ],
                ],
                'application_context' => [
                    'return_url' => (string) ($payload['return_url'] ?? route('billing.paypal.callback')),
                    'cancel_url' => (string) ($payload['cancel_url'] ?? route('customer.billing.index')),
                    'brand_name' => (string) config('app.name'),
                    'shipping_preference' => 'NO_SHIPPING',
                    'user_action' => 'PAY_NOW',
                ],
            ]);

        if (!$response->ok()) {
            Log::warning('PayPal order creation failed', [
                'status' => $response->status(),
                'body' => $response->json(),
            ]);
            throw new \RuntimeException('PayPal checkout failed.');
        }

        $data = $response->json();
        return is_array($data) ? $data : [];
    }

    private function captureOrder(string $clientId, string $clientSecret, string $orderId): array
    {
        $token = $this->getAccessToken($clientId, $clientSecret);
        $baseUrl = $this->getBaseUrl();

        $response = Http::withToken($token)
            ->acceptJson()
            ->post($baseUrl . '/v2/checkout/orders/' . urlencode($orderId) . '/capture');

        if (!$response->ok()) {
            Log::warning('PayPal order capture failed', [
                'status' => $response->status(),
                'body' => $response->json(),
            ]);
            throw new \RuntimeException('Unable to capture PayPal payment.');
        }

        $data = $response->json();
        return is_array($data) ? $data : [];
    }

    private function extractApproveUrl(array $order): ?string
    {
        $links = $order['links'] ?? null;
        if (!is_array($links)) {
            return null;
        }

        foreach ($links as $link) {
            if (!is_array($link)) {
                continue;
            }

            $rel = $link['rel'] ?? null;
            $href = $link['href'] ?? null;

            if (is_string($rel) && strtolower($rel) === 'approve' && is_string($href) && trim($href) !== '') {
                return trim($href);
            }
        }

        return null;
    }

    private function getAccessToken(string $clientId, string $clientSecret): string
    {
        $baseUrl = $this->getBaseUrl();

        $response = Http::withBasicAuth($clientId, $clientSecret)
            ->asForm()
            ->acceptJson()
            ->post($baseUrl . '/v1/oauth2/token', [
                'grant_type' => 'client_credentials',
            ]);

        if (!$response->ok()) {
            Log::warning('PayPal token request failed', [
                'status' => $response->status(),
                'body' => $response->json(),
            ]);
            throw new \RuntimeException('PayPal authentication failed.');
        }

        $token = $response->json('access_token');
        if (!is_string($token) || trim($token) === '') {
            throw new \RuntimeException('PayPal authentication failed: missing access token.');
        }

        return $token;
    }

    private function getBaseUrl(): string
    {
        return $this->getMode() === 'sandbox'
            ? 'https://api-m.sandbox.paypal.com'
            : 'https://api-m.paypal.com';
    }

    private function getMode(): string
    {
        $billingProviders = Setting::get('billing_providers');
        $mode = is_array($billingProviders) ? data_get($billingProviders, 'paypal.mode') : null;

        if (!is_string($mode) || !in_array($mode, ['live', 'sandbox'], true)) {
            $mode = 'live';
        }

        return $mode;
    }

    private function getConfigValue(string $key): ?string
    {
        $billingProviders = Setting::get('billing_providers');
        $mode = $this->getMode();

        $value = null;
        if (is_array($billingProviders)) {
            $value = data_get($billingProviders, 'paypal.' . $mode . '.' . $key);
        }

        if (!is_string($value) || trim($value) === '') {
            $fallbackKey = 'paypal_' . $key;
            $value = Setting::get($fallbackKey, config('billing.paypal.' . $key));
        }

        return is_string($value) ? trim($value) : null;
    }
}