webmoney/backend/app/Http/Controllers/Api/SubscriptionController.php
marcoitaloesp-ai 3a336eb692
feat: Admin user management + SaaS limits tested v1.51.0
- 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
2025-12-17 15:22:01 +00:00

588 lines
20 KiB
PHP

<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Plan;
use App\Models\Subscription;
use App\Models\Invoice;
use App\Services\PayPalService;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use Carbon\Carbon;
class SubscriptionController extends Controller
{
protected PayPalService $paypal;
public function __construct(PayPalService $paypal)
{
$this->paypal = $paypal;
}
/**
* Get current subscription status
*/
public function status(Request $request): JsonResponse
{
$user = $request->user();
$subscription = $user->subscriptions()->active()->with('plan')->first();
$currentPlan = $user->currentPlan();
// Get current usage
$usage = [
'accounts' => $user->accounts()->count(),
'categories' => $user->categories()->count(),
'budgets' => $user->budgets()->count(),
'transactions' => $user->transactions()->count(),
];
// Calculate usage percentages if plan has limits
$limits = $currentPlan?->limits ?? [];
$usagePercentages = [];
foreach ($usage as $resource => $count) {
$limit = $limits[$resource] ?? null;
if ($limit !== null && $limit > 0) {
$usagePercentages[$resource] = round(($count / $limit) * 100, 1);
} else {
$usagePercentages[$resource] = null; // unlimited
}
}
return response()->json([
'success' => true,
'data' => [
'has_subscription' => $subscription !== null,
'on_trial' => $subscription?->isOnTrial() ?? false,
'trial_ends_at' => $subscription?->trial_ends_at,
'days_until_trial_ends' => $subscription?->days_until_trial_ends,
'current_period_end' => $subscription?->current_period_end,
'status' => $subscription?->status,
'status_label' => $subscription?->status_label,
'canceled_at' => $subscription?->canceled_at,
'on_grace_period' => $subscription?->onGracePeriod() ?? false,
'plan' => $currentPlan ? [
'id' => $currentPlan->id,
'slug' => $currentPlan->slug,
'name' => $currentPlan->name,
'price' => $currentPlan->price,
'formatted_price' => $currentPlan->formatted_price,
'billing_period' => $currentPlan->billing_period,
'is_free' => $currentPlan->is_free,
'features' => $currentPlan->features,
'limits' => $currentPlan->limits,
] : null,
'usage' => $usage,
'usage_percentages' => $usagePercentages,
],
]);
}
/**
* Create a subscription checkout
*/
public function subscribe(Request $request): JsonResponse
{
$request->validate([
'plan_slug' => 'required|string|exists:plans,slug',
]);
$user = $request->user();
$plan = Plan::where('slug', $request->plan_slug)->where('is_active', true)->first();
if (!$plan) {
return response()->json([
'success' => false,
'message' => 'Plan not found or inactive',
], 404);
}
// Check if plan is free
if ($plan->is_free) {
return $this->subscribeFree($user, $plan);
}
// Check if PayPal is configured
if (!$this->paypal->isConfigured()) {
return response()->json([
'success' => false,
'message' => 'Payment gateway not configured',
], 500);
}
// Check if plan has PayPal plan ID
if (!$plan->paypal_plan_id) {
return response()->json([
'success' => false,
'message' => 'Plan not configured for payments yet',
], 500);
}
// Create PayPal subscription
$frontendUrl = config('app.frontend_url', 'https://webmoney.cnxifly.com');
$returnUrl = "{$frontendUrl}/billing?success=true&plan={$plan->slug}";
$cancelUrl = "{$frontendUrl}/pricing?canceled=true";
$paypalSubscription = $this->paypal->createSubscription($plan, $returnUrl, $cancelUrl);
if (!$paypalSubscription) {
return response()->json([
'success' => false,
'message' => 'Failed to create subscription',
], 500);
}
// Find approve link
$approveUrl = collect($paypalSubscription['links'] ?? [])
->firstWhere('rel', 'approve')['href'] ?? null;
if (!$approveUrl) {
return response()->json([
'success' => false,
'message' => 'No approval URL received',
], 500);
}
// Create pending subscription in our DB
$subscription = Subscription::create([
'user_id' => $user->id,
'plan_id' => $plan->id,
'status' => Subscription::STATUS_TRIALING,
'paypal_subscription_id' => $paypalSubscription['id'],
'paypal_status' => $paypalSubscription['status'],
'paypal_data' => $paypalSubscription,
'price_paid' => $plan->price,
'currency' => $plan->currency,
]);
return response()->json([
'success' => true,
'data' => [
'subscription_id' => $subscription->id,
'paypal_subscription_id' => $paypalSubscription['id'],
'approve_url' => $approveUrl,
],
]);
}
/**
* Subscribe to free plan
*/
private function subscribeFree($user, Plan $plan): JsonResponse
{
// Cancel any existing subscriptions
$user->subscriptions()->active()->update([
'status' => Subscription::STATUS_CANCELED,
'canceled_at' => now(),
'ends_at' => now(),
'cancel_reason' => 'Downgraded to free plan',
]);
// Create free subscription
$subscription = Subscription::createForUser($user, $plan);
return response()->json([
'success' => true,
'message' => 'Subscribed to free plan',
'data' => [
'subscription_id' => $subscription->id,
'status' => $subscription->status,
],
]);
}
/**
* Confirm subscription after PayPal approval
*/
public function confirm(Request $request): JsonResponse
{
$request->validate([
'subscription_id' => 'required|string',
]);
$user = $request->user();
$subscription = Subscription::where('paypal_subscription_id', $request->subscription_id)
->where('user_id', $user->id)
->first();
if (!$subscription) {
return response()->json([
'success' => false,
'message' => 'Subscription not found',
], 404);
}
// Get subscription details from PayPal
$paypalData = $this->paypal->getSubscription($request->subscription_id);
if (!$paypalData) {
return response()->json([
'success' => false,
'message' => 'Failed to verify subscription',
], 500);
}
// Update subscription based on PayPal status
$this->updateSubscriptionFromPayPal($subscription, $paypalData);
// Cancel other active subscriptions
$user->subscriptions()
->where('id', '!=', $subscription->id)
->active()
->update([
'status' => Subscription::STATUS_CANCELED,
'canceled_at' => now(),
'ends_at' => now(),
'cancel_reason' => 'Replaced by new subscription',
]);
// Create invoice for the subscription
if ($subscription->isActive() && !$subscription->plan->is_free) {
Invoice::createForSubscription(
$subscription,
Invoice::REASON_SUBSCRIPTION_CREATE,
"{$subscription->plan->name} - Nueva suscripción"
)->markAsPaid($paypalData['id'] ?? null);
}
return response()->json([
'success' => true,
'message' => 'Subscription confirmed',
'data' => [
'status' => $subscription->status,
'status_label' => $subscription->status_label,
'plan' => $subscription->plan->name,
],
]);
}
/**
* Cancel subscription
*/
public function cancel(Request $request): JsonResponse
{
$request->validate([
'reason' => 'nullable|string|max:500',
'immediately' => 'nullable|boolean',
]);
$user = $request->user();
$subscription = $user->subscriptions()->active()->first();
if (!$subscription) {
return response()->json([
'success' => false,
'message' => 'No active subscription found',
], 404);
}
// If it's a paid plan, cancel on PayPal
if ($subscription->paypal_subscription_id && !$subscription->plan->is_free) {
$canceled = $this->paypal->cancelSubscription(
$subscription->paypal_subscription_id,
$request->reason ?? 'User requested cancellation'
);
if (!$canceled) {
return response()->json([
'success' => false,
'message' => 'Failed to cancel subscription on PayPal',
], 500);
}
}
// Cancel in our DB
$subscription->cancel(
$request->reason,
$request->boolean('immediately', false)
);
return response()->json([
'success' => true,
'message' => $request->boolean('immediately')
? 'Subscription canceled immediately'
: 'Subscription will be canceled at period end',
'data' => [
'status' => $subscription->status,
'ends_at' => $subscription->ends_at,
],
]);
}
/**
* Get invoices
*/
public function invoices(Request $request): JsonResponse
{
$user = $request->user();
$invoices = $user->invoices()
->with('subscription.plan')
->recent()
->paginate(20);
return response()->json([
'success' => true,
'data' => [
'invoices' => $invoices->map(function ($invoice) {
return [
'id' => $invoice->id,
'number' => $invoice->number,
'status' => $invoice->status,
'status_label' => $invoice->status_label,
'status_color' => $invoice->status_color,
'description' => $invoice->description,
'subtotal' => $invoice->subtotal,
'tax' => $invoice->tax,
'tax_percent' => $invoice->tax_percent,
'total' => $invoice->total,
'formatted_total' => $invoice->formatted_total,
'currency' => $invoice->currency,
'billing_reason' => $invoice->billing_reason,
'billing_reason_label' => $invoice->billing_reason_label,
'paid_at' => $invoice->paid_at,
'created_at' => $invoice->created_at,
'plan_name' => $invoice->subscription?->plan?->name,
];
}),
'pagination' => [
'current_page' => $invoices->currentPage(),
'last_page' => $invoices->lastPage(),
'per_page' => $invoices->perPage(),
'total' => $invoices->total(),
],
],
]);
}
/**
* Get PayPal client ID for frontend
*/
public function paypalConfig(): JsonResponse
{
return response()->json([
'success' => true,
'data' => [
'client_id' => $this->paypal->getClientId(),
'sandbox' => $this->paypal->isSandbox(),
],
]);
}
/**
* PayPal webhook handler
*/
public function webhook(Request $request): JsonResponse
{
// Log all incoming webhooks for debugging
Log::channel('single')->info('=== PAYPAL WEBHOOK RECEIVED ===', [
'event_type' => $request->input('event_type'),
'content' => $request->all(),
]);
$webhookId = config('services.paypal.webhook_id');
// Verify webhook signature (skip in sandbox for testing)
if (!$this->paypal->isSandbox() && $webhookId) {
$verified = $this->paypal->verifyWebhookSignature(
$request->headers->all(),
$request->getContent(),
$webhookId
);
if (!$verified) {
Log::warning('PayPal webhook signature verification failed');
return response()->json(['status' => 'signature_invalid'], 400);
}
}
$event = $request->all();
$eventType = $event['event_type'] ?? '';
$resource = $event['resource'] ?? [];
Log::info('PayPal webhook received', ['event_type' => $eventType]);
switch ($eventType) {
case 'BILLING.SUBSCRIPTION.ACTIVATED':
$this->handleSubscriptionActivated($resource);
break;
case 'BILLING.SUBSCRIPTION.CANCELLED':
$this->handleSubscriptionCancelled($resource);
break;
case 'BILLING.SUBSCRIPTION.EXPIRED':
$this->handleSubscriptionExpired($resource);
break;
case 'BILLING.SUBSCRIPTION.SUSPENDED':
$this->handleSubscriptionSuspended($resource);
break;
case 'PAYMENT.SALE.COMPLETED':
$this->handlePaymentCompleted($resource);
break;
case 'PAYMENT.SALE.DENIED':
case 'PAYMENT.SALE.REFUNDED':
$this->handlePaymentFailed($resource);
break;
default:
Log::info('Unhandled PayPal webhook event', ['event_type' => $eventType]);
}
return response()->json(['status' => 'ok']);
}
// ==================== WEBHOOK HANDLERS ====================
private function handleSubscriptionActivated(array $resource): void
{
$subscription = Subscription::where('paypal_subscription_id', $resource['id'])->first();
if (!$subscription) return;
$this->updateSubscriptionFromPayPal($subscription, $resource);
Log::info('Subscription activated', ['subscription_id' => $subscription->id]);
}
private function handleSubscriptionCancelled(array $resource): void
{
$subscription = Subscription::where('paypal_subscription_id', $resource['id'])->first();
if (!$subscription) return;
$subscription->update([
'status' => Subscription::STATUS_CANCELED,
'canceled_at' => now(),
'paypal_status' => $resource['status'] ?? 'CANCELLED',
'paypal_data' => $resource,
]);
Log::info('Subscription cancelled via webhook', ['subscription_id' => $subscription->id]);
}
private function handleSubscriptionExpired(array $resource): void
{
$subscription = Subscription::where('paypal_subscription_id', $resource['id'])->first();
if (!$subscription) return;
$subscription->markAsExpired();
$subscription->update([
'paypal_status' => $resource['status'] ?? 'EXPIRED',
'paypal_data' => $resource,
]);
Log::info('Subscription expired via webhook', ['subscription_id' => $subscription->id]);
}
private function handleSubscriptionSuspended(array $resource): void
{
$subscription = Subscription::where('paypal_subscription_id', $resource['id'])->first();
if (!$subscription) return;
$subscription->update([
'status' => Subscription::STATUS_PAST_DUE,
'paypal_status' => $resource['status'] ?? 'SUSPENDED',
'paypal_data' => $resource,
]);
Log::info('Subscription suspended via webhook', ['subscription_id' => $subscription->id]);
}
private function handlePaymentCompleted(array $resource): void
{
$billingAgreementId = $resource['billing_agreement_id'] ?? null;
if (!$billingAgreementId) return;
$subscription = Subscription::where('paypal_subscription_id', $billingAgreementId)->first();
if (!$subscription) return;
// Create invoice for recurring payment
$invoice = Invoice::createForSubscription(
$subscription,
Invoice::REASON_SUBSCRIPTION_CYCLE,
"{$subscription->plan->name} - " . now()->format('F Y')
);
$invoice->update([
'paypal_payment_id' => $resource['id'] ?? null,
'paypal_data' => $resource,
]);
$invoice->markAsPaid($resource['id'] ?? null);
// Renew subscription period
$subscription->renew();
Log::info('Payment completed, subscription renewed', [
'subscription_id' => $subscription->id,
'invoice_id' => $invoice->id,
]);
}
private function handlePaymentFailed(array $resource): void
{
$billingAgreementId = $resource['billing_agreement_id'] ?? null;
if (!$billingAgreementId) return;
$subscription = Subscription::where('paypal_subscription_id', $billingAgreementId)->first();
if (!$subscription) return;
$subscription->update([
'status' => Subscription::STATUS_PAST_DUE,
]);
Log::warning('Payment failed', ['subscription_id' => $subscription->id]);
}
// ==================== HELPERS ====================
private function updateSubscriptionFromPayPal(Subscription $subscription, array $paypalData): void
{
$status = $paypalData['status'] ?? '';
$subscription->paypal_status = $status;
$subscription->paypal_data = $paypalData;
switch ($status) {
case 'ACTIVE':
$subscription->status = Subscription::STATUS_ACTIVE;
if (isset($paypalData['billing_info']['next_billing_time'])) {
$subscription->current_period_end = Carbon::parse($paypalData['billing_info']['next_billing_time']);
}
if (isset($paypalData['billing_info']['last_payment']['time'])) {
$subscription->current_period_start = Carbon::parse($paypalData['billing_info']['last_payment']['time']);
}
break;
case 'APPROVAL_PENDING':
// Keep as trialing until approved
break;
case 'APPROVED':
// Subscription approved but not yet active
if ($subscription->plan->has_trial) {
$subscription->startTrial($subscription->plan->trial_days);
} else {
$subscription->activate();
}
break;
case 'SUSPENDED':
$subscription->status = Subscription::STATUS_PAST_DUE;
break;
case 'CANCELLED':
$subscription->status = Subscription::STATUS_CANCELED;
$subscription->canceled_at = now();
break;
case 'EXPIRED':
$subscription->status = Subscription::STATUS_EXPIRED;
break;
}
$subscription->save();
}
}