- Removido README.md padrão do Laravel (backend) - Removidos scripts de deploy (não mais necessários) - Atualizado copilot-instructions.md para novo fluxo - Adicionada documentação de auditoria do servidor - Sincronizado código de produção com repositório Novo workflow: - Trabalhamos diretamente em /root/webmoney (symlink para /var/www/webmoney) - Mudanças PHP são instantâneas - Mudanças React requerem 'npm run build' - Commit após validação funcional
496 lines
16 KiB
PHP
Executable File
496 lines
16 KiB
PHP
Executable File
<?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;
|
|
}
|
|
}
|