- Add UserManagementController@store for creating users - Add POST /api/admin/users endpoint - Support user types: Free, Pro, Admin - Auto-create 100-year subscription for Pro/Admin users - Add user creation modal to Users.jsx - Complete SaaS limit testing: - Free user limits: 1 account, 10 categories, 3 budgets, 100 tx - Middleware blocks correctly at limits - Error messages are user-friendly - Usage stats API working correctly - Update SAAS_STATUS.md with test results - Bump version to 1.51.0
370 lines
12 KiB
PHP
370 lines
12 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;
|
|
}
|
|
|
|
Log::error('PayPal cancel subscription failed', ['response' => $response->json()]);
|
|
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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|