webmoney/backend/app/Services/PayPalService.php
marcoitaloesp-ai 6292b62315
feat: complete email system redesign with corporate templates
- Redesigned all email templates with professional corporate style
- Created base layout with dark header, status cards, and footer
- Updated: subscription-cancelled, account-activation, welcome, welcome-new-user, due-payments-alert
- Removed emojis and gradients for cleaner look
- Added multi-language support (ES, PT-BR, EN)
- Fixed email delivery (sync instead of queue)
- Fixed PayPal already-cancelled subscription handling
- Cleaned orphan subscriptions from deleted users
2025-12-18 00:44:37 +00:00

496 lines
16 KiB
PHP

<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Cache;
use App\Models\Plan;
class PayPalService
{
private ?string $clientId;
private ?string $clientSecret;
private string $baseUrl;
private bool $sandbox;
public function __construct()
{
$this->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;
}
}