clientId = config('services.paypal.client_id'); $this->clientSecret = config('services.paypal.client_secret'); $this->sandbox = config('services.paypal.mode', 'sandbox') === 'sandbox'; $this->baseUrl = $this->sandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com'; } /** * Get PayPal access token (cached for 8 hours) */ public function getAccessToken(): ?string { if (!$this->isConfigured()) { Log::warning('PayPal not configured'); return null; } return Cache::remember('paypal_access_token', 28800, function () { try { $response = Http::withBasicAuth($this->clientId, $this->clientSecret) ->asForm() ->post("{$this->baseUrl}/v1/oauth2/token", [ 'grant_type' => 'client_credentials', ]); if ($response->successful()) { return $response->json('access_token'); } Log::error('PayPal auth failed', ['response' => $response->json()]); return null; } catch (\Exception $e) { Log::error('PayPal auth exception', ['error' => $e->getMessage()]); return null; } }); } /** * Create a PayPal Product (required before creating plans) */ public function createProduct(): ?array { $token = $this->getAccessToken(); if (!$token) return null; try { $response = Http::withToken($token) ->post("{$this->baseUrl}/v1/catalogs/products", [ 'name' => 'WEBMoney Pro', 'description' => 'WEBMoney - Control de Finanzas Personales', 'type' => 'SERVICE', 'category' => 'SOFTWARE', 'home_url' => config('app.url'), ]); if ($response->successful()) { return $response->json(); } Log::error('PayPal create product failed', ['response' => $response->json()]); return null; } catch (\Exception $e) { Log::error('PayPal create product exception', ['error' => $e->getMessage()]); return null; } } /** * Create a PayPal billing plan */ public function createBillingPlan(Plan $plan, string $productId): ?array { $token = $this->getAccessToken(); if (!$token) return null; $billingCycles = []; // Trial period (if plan has trial) if ($plan->trial_days > 0) { $billingCycles[] = [ 'frequency' => [ 'interval_unit' => 'DAY', 'interval_count' => $plan->trial_days, ], 'tenure_type' => 'TRIAL', 'sequence' => 1, 'total_cycles' => 1, 'pricing_scheme' => [ 'fixed_price' => [ 'value' => '0', 'currency_code' => $plan->currency, ], ], ]; } // Regular billing cycle $billingCycles[] = [ 'frequency' => [ 'interval_unit' => $plan->billing_period === 'annual' ? 'YEAR' : 'MONTH', 'interval_count' => 1, ], 'tenure_type' => 'REGULAR', 'sequence' => $plan->trial_days > 0 ? 2 : 1, 'total_cycles' => 0, // Infinite 'pricing_scheme' => [ 'fixed_price' => [ 'value' => (string) $plan->price, 'currency_code' => $plan->currency, ], ], ]; try { $response = Http::withToken($token) ->post("{$this->baseUrl}/v1/billing/plans", [ 'product_id' => $productId, 'name' => $plan->name, 'description' => $plan->description ?? "Suscripción {$plan->name}", 'status' => 'ACTIVE', 'billing_cycles' => $billingCycles, 'payment_preferences' => [ 'auto_bill_outstanding' => true, 'setup_fee' => [ 'value' => '0', 'currency_code' => $plan->currency, ], 'setup_fee_failure_action' => 'CONTINUE', 'payment_failure_threshold' => 3, ], ]); if ($response->successful()) { $data = $response->json(); // Update plan with PayPal plan ID $plan->update(['paypal_plan_id' => $data['id']]); return $data; } Log::error('PayPal create plan failed', ['response' => $response->json()]); return null; } catch (\Exception $e) { Log::error('PayPal create plan exception', ['error' => $e->getMessage()]); return null; } } /** * Create a subscription for a user */ public function createSubscription(Plan $plan, string $returnUrl, string $cancelUrl): ?array { $token = $this->getAccessToken(); if (!$token) return null; if (!$plan->paypal_plan_id) { Log::error('Plan has no PayPal plan ID', ['plan_id' => $plan->id]); return null; } try { $response = Http::withToken($token) ->post("{$this->baseUrl}/v1/billing/subscriptions", [ 'plan_id' => $plan->paypal_plan_id, 'application_context' => [ 'brand_name' => 'WEBMoney', 'locale' => 'es-ES', 'shipping_preference' => 'NO_SHIPPING', 'user_action' => 'SUBSCRIBE_NOW', 'payment_method' => [ 'payer_selected' => 'PAYPAL', 'payee_preferred' => 'IMMEDIATE_PAYMENT_REQUIRED', ], 'return_url' => $returnUrl, 'cancel_url' => $cancelUrl, ], ]); if ($response->successful()) { return $response->json(); } Log::error('PayPal create subscription failed', ['response' => $response->json()]); return null; } catch (\Exception $e) { Log::error('PayPal create subscription exception', ['error' => $e->getMessage()]); return null; } } /** * Get subscription details */ public function getSubscription(string $subscriptionId): ?array { $token = $this->getAccessToken(); if (!$token) return null; try { $response = Http::withToken($token) ->get("{$this->baseUrl}/v1/billing/subscriptions/{$subscriptionId}"); if ($response->successful()) { return $response->json(); } Log::error('PayPal get subscription failed', ['response' => $response->json()]); return null; } catch (\Exception $e) { Log::error('PayPal get subscription exception', ['error' => $e->getMessage()]); return null; } } /** * Cancel a subscription */ public function cancelSubscription(string $subscriptionId, string $reason = 'User requested cancellation'): bool { $token = $this->getAccessToken(); if (!$token) return false; try { $response = Http::withToken($token) ->post("{$this->baseUrl}/v1/billing/subscriptions/{$subscriptionId}/cancel", [ 'reason' => $reason, ]); if ($response->status() === 204) { return true; } // Check if subscription is already cancelled - treat as success $responseData = $response->json(); if (isset($responseData['details'][0]['issue']) && $responseData['details'][0]['issue'] === 'SUBSCRIPTION_STATUS_INVALID') { Log::info('PayPal subscription already cancelled', ['subscription_id' => $subscriptionId]); return true; } Log::error('PayPal cancel subscription failed', ['response' => $responseData]); return false; } catch (\Exception $e) { Log::error('PayPal cancel subscription exception', ['error' => $e->getMessage()]); return false; } } /** * Suspend a subscription */ public function suspendSubscription(string $subscriptionId, string $reason = 'Suspended by system'): bool { $token = $this->getAccessToken(); if (!$token) return false; try { $response = Http::withToken($token) ->post("{$this->baseUrl}/v1/billing/subscriptions/{$subscriptionId}/suspend", [ 'reason' => $reason, ]); if ($response->status() === 204) { return true; } Log::error('PayPal suspend subscription failed', ['response' => $response->json()]); return false; } catch (\Exception $e) { Log::error('PayPal suspend subscription exception', ['error' => $e->getMessage()]); return false; } } /** * Verify webhook signature */ public function verifyWebhookSignature(array $headers, string $body, string $webhookId): bool { $token = $this->getAccessToken(); if (!$token) return false; try { $response = Http::withToken($token) ->post("{$this->baseUrl}/v1/notifications/verify-webhook-signature", [ 'auth_algo' => $headers['PAYPAL-AUTH-ALGO'] ?? '', 'cert_url' => $headers['PAYPAL-CERT-URL'] ?? '', 'transmission_id' => $headers['PAYPAL-TRANSMISSION-ID'] ?? '', 'transmission_sig' => $headers['PAYPAL-TRANSMISSION-SIG'] ?? '', 'transmission_time' => $headers['PAYPAL-TRANSMISSION-TIME'] ?? '', 'webhook_id' => $webhookId, 'webhook_event' => json_decode($body, true), ]); if ($response->successful()) { return $response->json('verification_status') === 'SUCCESS'; } return false; } catch (\Exception $e) { Log::error('PayPal verify webhook exception', ['error' => $e->getMessage()]); return false; } } /** * Get transactions for a subscription */ public function getSubscriptionTransactions(string $subscriptionId, string $startTime, string $endTime): ?array { $token = $this->getAccessToken(); if (!$token) return null; try { $response = Http::withToken($token) ->get("{$this->baseUrl}/v1/billing/subscriptions/{$subscriptionId}/transactions", [ 'start_time' => $startTime, 'end_time' => $endTime, ]); if ($response->successful()) { return $response->json(); } return null; } catch (\Exception $e) { Log::error('PayPal get transactions exception', ['error' => $e->getMessage()]); return null; } } /** * Refund a capture (payment) */ public function refundCapture(string $captureId, ?float $amount = null, ?string $currency = 'EUR', string $note = 'Refund within 7-day guarantee period'): ?array { $token = $this->getAccessToken(); if (!$token) return null; try { $body = [ 'note_to_payer' => $note, ]; // If amount specified, do partial refund; otherwise full refund if ($amount !== null) { $body['amount'] = [ 'value' => number_format($amount, 2, '.', ''), 'currency_code' => $currency, ]; } $response = Http::withToken($token) ->post("{$this->baseUrl}/v2/payments/captures/{$captureId}/refund", $body); if ($response->successful()) { Log::info('PayPal refund successful', ['capture_id' => $captureId, 'response' => $response->json()]); return $response->json(); } Log::error('PayPal refund failed', ['capture_id' => $captureId, 'response' => $response->json()]); return null; } catch (\Exception $e) { Log::error('PayPal refund exception', ['error' => $e->getMessage()]); return null; } } /** * Get the last transaction/capture for a subscription to refund */ public function getLastSubscriptionCapture(string $subscriptionId): ?string { $token = $this->getAccessToken(); if (!$token) return null; try { // Get transactions from last 30 days $startTime = now()->subDays(30)->toIso8601String(); $endTime = now()->toIso8601String(); $response = Http::withToken($token) ->get("{$this->baseUrl}/v1/billing/subscriptions/{$subscriptionId}/transactions", [ 'start_time' => $startTime, 'end_time' => $endTime, ]); if ($response->successful()) { $transactions = $response->json('transactions') ?? []; // Find the most recent COMPLETED transaction foreach ($transactions as $transaction) { if (($transaction['status'] ?? '') === 'COMPLETED' && !empty($transaction['id'])) { Log::info('Found capture for refund', ['subscription_id' => $subscriptionId, 'capture_id' => $transaction['id']]); return $transaction['id']; } } } Log::warning('No capture found for subscription', ['subscription_id' => $subscriptionId]); return null; } catch (\Exception $e) { Log::error('PayPal get capture exception', ['error' => $e->getMessage()]); return null; } } /** * Cancel subscription and refund if within guarantee period */ public function cancelAndRefund(string $subscriptionId, string $reason = 'Refund within 7-day guarantee'): array { $result = [ 'canceled' => false, 'refunded' => false, 'refund_id' => null, 'refund_amount' => null, 'error' => null, ]; // First, get capture ID for refund $captureId = $this->getLastSubscriptionCapture($subscriptionId); // Cancel the subscription $canceled = $this->cancelSubscription($subscriptionId, $reason); $result['canceled'] = $canceled; if (!$canceled) { $result['error'] = 'Failed to cancel subscription'; return $result; } // Process refund if we have a capture if ($captureId) { $refund = $this->refundCapture($captureId, null, 'EUR', $reason); if ($refund) { $result['refunded'] = true; $result['refund_id'] = $refund['id'] ?? null; $result['refund_amount'] = $refund['amount']['value'] ?? null; } else { $result['error'] = 'Subscription canceled but refund failed'; } } else { $result['error'] = 'Subscription canceled but no payment found to refund'; } return $result; } /** * Check if PayPal is configured */ public function isConfigured(): bool { return !empty($this->clientId) && !empty($this->clientSecret); } /** * Get client ID for frontend */ public function getClientId(): ?string { return $this->clientId; } /** * Check if in sandbox mode */ public function isSandbox(): bool { return $this->sandbox; } }