feat(subscriptions): Add PayPal integration for SaaS (Fase 3) v1.50.0

Backend:
- PayPalService with OAuth2, subscriptions, webhooks
- SubscriptionController with status, subscribe, confirm, cancel, invoices
- Webhook handlers for PayPal events (activated, cancelled, expired, payment)
- Config for PayPal credentials

Frontend:
- Pricing.jsx: Plans page with cards, FAQ, trust badges
- Billing.jsx: Subscription management, invoices list, cancel modal
- Added routes /pricing (public) and /billing (auth)
- Navigation links in Settings menu

Translations:
- pricing.* and billing.* keys in ES, PT-BR, EN
- nav.pricing, nav.billing

Ready for PayPal Sandbox testing once credentials are configured.
This commit is contained in:
marcoitaloesp-ai 2025-12-17 10:56:54 +00:00 committed by GitHub
parent 0adb5c889f
commit 679a1bc4b2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1965 additions and 4 deletions

View File

@ -5,6 +5,71 @@ O formato segue [Keep a Changelog](https://keepachangelog.com/pt-BR/).
Este projeto adota [Versionamento Semântico](https://semver.org/pt-BR/).
## [1.50.0] - 2025-12-17
### Added
- 💳 **Integração PayPal Subscriptions (Fase 3)** - Sistema completo de assinaturas com PayPal
- **Backend**:
- `PayPalService`: Integração completa com PayPal Subscriptions API
- Autenticação OAuth2 com cache de token
- Criação de produtos e planos de billing no PayPal
- Criação e cancelamento de assinaturas
- Verificação de assinatura de webhooks
- `SubscriptionController`: Endpoints para gestão de assinaturas
- `GET /api/subscription/status`: Status da assinatura atual
- `POST /api/subscription/subscribe`: Criar nova assinatura
- `POST /api/subscription/confirm`: Confirmar após retorno PayPal
- `POST /api/subscription/cancel`: Cancelar assinatura
- `GET /api/subscription/invoices`: Listar faturas do usuário
- `POST /api/paypal/webhook`: Receber eventos do PayPal
- Webhooks suportados: BILLING.SUBSCRIPTION.ACTIVATED, CANCELLED, EXPIRED, PAYMENT.SALE.COMPLETED
- **Frontend**:
- `Pricing.jsx`: Página de planos e preços
- Cards de planos com destaque para mais popular
- Badge de economia para plano anual (17%)
- Indicador de trial de 7 dias
- FAQ com perguntas frequentes
- Badges de segurança (PayPal, cancelamento)
- `Billing.jsx`: Página de faturamento
- Card com plano atual e status
- Features e limites do plano
- Lista de faturas com download PDF
- Modal de confirmação para cancelar
- Confirmação automática após retorno PayPal
- **Traduções**: Completas em ES, PT-BR, EN
- pricing.* (title, free, month, trial, FAQ, etc.)
- billing.* (status, invoices, cancel, limits, etc.)
- nav.billing, nav.pricing
- **Navegação**: Novas opções no menu Settings
- /pricing (público) - Página de planos
- /billing (auth) - Gestão de assinatura
### Technical Details
- **PayPal API v1**: Subscriptions API com OAuth2
- **Webhook Events**: Validação HMAC para segurança
- **Invoice Generation**: Numeração automática WM-YYYY-NNNNNN
- **Trial Support**: 7 dias com status 'trialing'
- **Multi-currency**: EUR (default), USD, BRL suportados
### Roadmap SaaS
1. ✅ **Fase 1**: Perfil completo do usuário
2. ✅ **Fase 2**: Tabelas de assinaturas (plans, subscriptions, invoices)
3. ✅ **Fase 3**: Integração PayPal Subscriptions
4. ⏳ **Fase 4**: Enforcement de limites por plano
### Configuration Required
Para ativar PayPal, configurar no .env:
```
PAYPAL_CLIENT_ID=your_client_id
PAYPAL_CLIENT_SECRET=your_client_secret
PAYPAL_MODE=sandbox # ou 'live' para produção
PAYPAL_WEBHOOK_ID=your_webhook_id
```
## [1.49.0] - 2025-12-17
### Added

View File

@ -1 +1 @@
1.49.0
1.50.0

View File

@ -0,0 +1,559 @@
<?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();
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,
],
]);
}
/**
* 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
{
$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();
}
}

View File

@ -0,0 +1,364 @@
<?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
{
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;
}
}

View File

@ -35,4 +35,11 @@
],
],
'paypal' => [
'client_id' => env('PAYPAL_CLIENT_ID'),
'client_secret' => env('PAYPAL_CLIENT_SECRET'),
'mode' => env('PAYPAL_MODE', 'sandbox'), // sandbox or live
'webhook_id' => env('PAYPAL_WEBHOOK_ID'),
],
];

View File

@ -23,6 +23,7 @@
use App\Http\Controllers\Api\FinancialHealthController;
use App\Http\Controllers\Api\UserPreferenceController;
use App\Http\Controllers\Api\PlanController;
use App\Http\Controllers\Api\SubscriptionController;
// Public routes with rate limiting
Route::post('/register', [AuthController::class, 'register'])->middleware('throttle:register');
@ -32,6 +33,12 @@
Route::get('/plans', [PlanController::class, 'index']);
Route::get('/plans/{slug}', [PlanController::class, 'show']);
// PayPal config (public - needed for frontend SDK)
Route::get('/paypal/config', [SubscriptionController::class, 'paypalConfig']);
// PayPal webhook (public - called by PayPal)
Route::post('/paypal/webhook', [SubscriptionController::class, 'webhook']);
// Email testing routes (should be protected in production)
Route::post('/email/send-test', [EmailTestController::class, 'sendTest']);
Route::get('/email/anti-spam-info', [EmailTestController::class, 'getAntiSpamInfo']);
@ -46,6 +53,15 @@
return $request->user();
});
// ============================================
// Subscriptions (Assinaturas)
// ============================================
Route::get('/subscription/status', [SubscriptionController::class, 'status']);
Route::post('/subscription/subscribe', [SubscriptionController::class, 'subscribe']);
Route::post('/subscription/confirm', [SubscriptionController::class, 'confirm']);
Route::post('/subscription/cancel', [SubscriptionController::class, 'cancel']);
Route::get('/subscription/invoices', [SubscriptionController::class, 'invoices']);
// ============================================
// Contas (Accounts)
// ============================================

View File

@ -23,6 +23,8 @@ import Budgets from './pages/Budgets';
import Reports from './pages/Reports';
import Preferences from './pages/Preferences';
import Profile from './pages/Profile';
import Pricing from './pages/Pricing';
import Billing from './pages/Billing';
function App() {
return (
@ -201,6 +203,24 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/pricing"
element={
<Layout>
<Pricing />
</Layout>
}
/>
<Route
path="/billing"
element={
<ProtectedRoute>
<Layout>
<Billing />
</Layout>
</ProtectedRoute>
}
/>
<Route path="/" element={<Navigate to="/dashboard" />} />
</Routes>
<CookieConsent />

View File

@ -105,6 +105,8 @@ const Layout = ({ children }) => {
{ path: '/cost-centers', icon: 'bi-building', label: t('nav.costCenters') },
{ path: '/preferences', icon: 'bi-sliders', label: t('nav.preferences') },
{ path: '/profile', icon: 'bi-person-circle', label: t('nav.profile') },
{ path: '/billing', icon: 'bi-credit-card', label: t('nav.billing') },
{ path: '/pricing', icon: 'bi-tags-fill', label: t('nav.pricing') },
]
},
];

View File

@ -100,7 +100,9 @@
"planning": "Planning",
"financialHealth": "Financial Health",
"goals": "Goals",
"budgets": "Budgets"
"budgets": "Budgets",
"billing": "Billing",
"pricing": "Plans"
},
"dashboard": {
"title": "Dashboard",
@ -2041,5 +2043,85 @@
"passwordError": "Error changing password",
"passwordMismatch": "Passwords do not match",
"passwordTooShort": "Password must be at least 8 characters"
},
"pricing": {
"title": "Plans & Pricing",
"subtitle": "Choose the perfect plan to manage your finances",
"free": "Free",
"month": "month",
"year": "year",
"billedAnnually": "Billed annually €{{price}}",
"save": "Save {{percent}}%",
"trialDays": "{{days}}-day free trial",
"mostPopular": "Most Popular",
"currentPlan": "Current Plan",
"startFree": "Start Free",
"startTrial": "Start Free Trial",
"subscribe": "Subscribe",
"loginRequired": "Please login to subscribe",
"subscribed": "Subscription activated successfully!",
"subscribeError": "Error processing subscription",
"paymentCanceled": "Payment canceled",
"securePayment": "Secure payment",
"cancelAnytime": "Cancel anytime",
"paypalSecure": "Secure payment with PayPal",
"faq": {
"title": "Frequently Asked Questions",
"q1": "Can I change plans at any time?",
"a1": "Yes, you can upgrade or downgrade your plan at any time. The change will apply to your next billing cycle.",
"q2": "What happens to my data if I cancel?",
"a2": "Your data stays safe. You'll be able to access the free plan and export all your information.",
"q3": "Do you offer refunds?",
"a3": "We offer a 30-day money-back guarantee. If you're not satisfied, contact us for a full refund."
}
},
"billing": {
"title": "Billing",
"currentPlan": "Current Plan",
"noPlan": "No Plan",
"noActiveSubscription": "You don't have an active subscription",
"upgradePlan": "Upgrade Plan",
"changePlan": "Change Plan",
"cancelSubscription": "Cancel Subscription",
"planFeatures": "Plan Features",
"limits": "Limits",
"accounts": "Accounts",
"budgets": "Budgets",
"sharedUsers": "Shared users",
"unlimited": "Unlimited",
"month": "month",
"year": "year",
"invoices": "Invoices",
"noInvoices": "No invoices yet",
"invoiceNumber": "Invoice #",
"date": "Date",
"description": "Description",
"amount": "Amount",
"status": {
"active": "Active",
"trialing": "Trial",
"canceled": "Canceled",
"expired": "Expired",
"past_due": "Past Due"
},
"invoiceStatus": {
"paid": "Paid",
"pending": "Pending",
"failed": "Failed",
"refunded": "Refunded"
},
"trialEnds": "Trial ends on {{date}}",
"renewsOn": "Renews on {{date}}",
"endsOn": "Ends on {{date}}",
"subscriptionConfirmed": "Subscription confirmed successfully!",
"confirmError": "Error confirming subscription",
"subscriptionCanceled": "Subscription canceled",
"cancelError": "Error canceling subscription",
"cancelConfirmTitle": "Cancel subscription?",
"cancelConfirmMessage": "Are you sure you want to cancel your subscription?",
"cancelNote1": "You'll keep access until the end of the current period",
"cancelNote2": "Your data will not be deleted",
"cancelNote3": "You can reactivate your subscription at any time",
"confirmCancel": "Yes, Cancel"
}
}

View File

@ -101,7 +101,9 @@
"planning": "Planificación",
"financialHealth": "Salud Financiera",
"goals": "Metas",
"budgets": "Presupuestos"
"budgets": "Presupuestos",
"billing": "Facturación",
"pricing": "Planes"
},
"dashboard": {
"title": "Panel de Control",
@ -2029,5 +2031,85 @@
"passwordError": "Error al cambiar contraseña",
"passwordMismatch": "Las contraseñas no coinciden",
"passwordTooShort": "La contraseña debe tener al menos 8 caracteres"
},
"pricing": {
"title": "Planes y Precios",
"subtitle": "Elige el plan perfecto para gestionar tus finanzas",
"free": "Gratis",
"month": "mes",
"year": "año",
"billedAnnually": "Facturado anualmente €{{price}}",
"save": "Ahorra {{percent}}%",
"trialDays": "{{days}} días de prueba gratis",
"mostPopular": "Más Popular",
"currentPlan": "Plan Actual",
"startFree": "Comenzar Gratis",
"startTrial": "Iniciar Prueba Gratis",
"subscribe": "Suscribirse",
"loginRequired": "Inicia sesión para suscribirte",
"subscribed": "¡Suscripción activada con éxito!",
"subscribeError": "Error al procesar la suscripción",
"paymentCanceled": "Pago cancelado",
"securePayment": "Pago seguro",
"cancelAnytime": "Cancela cuando quieras",
"paypalSecure": "Pago seguro con PayPal",
"faq": {
"title": "Preguntas Frecuentes",
"q1": "¿Puedo cambiar de plan en cualquier momento?",
"a1": "Sí, puedes actualizar o degradar tu plan en cualquier momento. El cambio se aplicará en tu próximo ciclo de facturación.",
"q2": "¿Qué pasa con mis datos si cancelo?",
"a2": "Tus datos se mantendrán seguros. Podrás acceder al plan gratuito y exportar toda tu información.",
"q3": "¿Ofrecen reembolsos?",
"a3": "Ofrecemos una garantía de devolución de 30 días. Si no estás satisfecho, contáctanos para un reembolso completo."
}
},
"billing": {
"title": "Facturación",
"currentPlan": "Plan Actual",
"noPlan": "Sin Plan",
"noActiveSubscription": "No tienes una suscripción activa",
"upgradePlan": "Mejorar Plan",
"changePlan": "Cambiar Plan",
"cancelSubscription": "Cancelar Suscripción",
"planFeatures": "Características del Plan",
"limits": "Límites",
"accounts": "Cuentas",
"budgets": "Presupuestos",
"sharedUsers": "Usuarios compartidos",
"unlimited": "Ilimitado",
"month": "mes",
"year": "año",
"invoices": "Facturas",
"noInvoices": "No hay facturas aún",
"invoiceNumber": "Nº Factura",
"date": "Fecha",
"description": "Descripción",
"amount": "Importe",
"status": {
"active": "Activa",
"trialing": "En Prueba",
"canceled": "Cancelada",
"expired": "Expirada",
"past_due": "Pago Pendiente"
},
"invoiceStatus": {
"paid": "Pagada",
"pending": "Pendiente",
"failed": "Fallida",
"refunded": "Reembolsada"
},
"trialEnds": "Prueba termina el {{date}}",
"renewsOn": "Se renueva el {{date}}",
"endsOn": "Termina el {{date}}",
"subscriptionConfirmed": "¡Suscripción confirmada con éxito!",
"confirmError": "Error al confirmar la suscripción",
"subscriptionCanceled": "Suscripción cancelada",
"cancelError": "Error al cancelar la suscripción",
"cancelConfirmTitle": "¿Cancelar suscripción?",
"cancelConfirmMessage": "¿Estás seguro de que deseas cancelar tu suscripción?",
"cancelNote1": "Mantendrás acceso hasta el final del período actual",
"cancelNote2": "Tus datos no se eliminarán",
"cancelNote3": "Puedes reactivar tu suscripción en cualquier momento",
"confirmCancel": "Sí, Cancelar"
}
}

View File

@ -102,7 +102,9 @@
"planning": "Planejamento",
"financialHealth": "Saúde Financeira",
"goals": "Metas",
"budgets": "Orçamentos"
"budgets": "Orçamentos",
"billing": "Faturamento",
"pricing": "Planos"
},
"dashboard": {
"title": "Painel de Controle",
@ -2047,5 +2049,85 @@
"passwordError": "Erro ao alterar senha",
"passwordMismatch": "As senhas não coincidem",
"passwordTooShort": "A senha deve ter pelo menos 8 caracteres"
},
"pricing": {
"title": "Planos e Preços",
"subtitle": "Escolha o plano perfeito para gerenciar suas finanças",
"free": "Grátis",
"month": "mês",
"year": "ano",
"billedAnnually": "Cobrado anualmente €{{price}}",
"save": "Economize {{percent}}%",
"trialDays": "{{days}} dias de teste grátis",
"mostPopular": "Mais Popular",
"currentPlan": "Plano Atual",
"startFree": "Começar Grátis",
"startTrial": "Iniciar Teste Grátis",
"subscribe": "Assinar",
"loginRequired": "Faça login para assinar",
"subscribed": "Assinatura ativada com sucesso!",
"subscribeError": "Erro ao processar assinatura",
"paymentCanceled": "Pagamento cancelado",
"securePayment": "Pagamento seguro",
"cancelAnytime": "Cancele quando quiser",
"paypalSecure": "Pagamento seguro com PayPal",
"faq": {
"title": "Perguntas Frequentes",
"q1": "Posso mudar de plano a qualquer momento?",
"a1": "Sim, você pode fazer upgrade ou downgrade a qualquer momento. A mudança será aplicada no próximo ciclo de cobrança.",
"q2": "O que acontece com meus dados se eu cancelar?",
"a2": "Seus dados ficam seguros. Você poderá acessar o plano gratuito e exportar todas as suas informações.",
"q3": "Vocês oferecem reembolso?",
"a3": "Oferecemos garantia de devolução de 30 dias. Se não estiver satisfeito, entre em contato para reembolso completo."
}
},
"billing": {
"title": "Faturamento",
"currentPlan": "Plano Atual",
"noPlan": "Sem Plano",
"noActiveSubscription": "Você não tem uma assinatura ativa",
"upgradePlan": "Fazer Upgrade",
"changePlan": "Trocar Plano",
"cancelSubscription": "Cancelar Assinatura",
"planFeatures": "Recursos do Plano",
"limits": "Limites",
"accounts": "Contas",
"budgets": "Orçamentos",
"sharedUsers": "Usuários compartilhados",
"unlimited": "Ilimitado",
"month": "mês",
"year": "ano",
"invoices": "Faturas",
"noInvoices": "Nenhuma fatura ainda",
"invoiceNumber": "Nº Fatura",
"date": "Data",
"description": "Descrição",
"amount": "Valor",
"status": {
"active": "Ativa",
"trialing": "Em Teste",
"canceled": "Cancelada",
"expired": "Expirada",
"past_due": "Pagamento Pendente"
},
"invoiceStatus": {
"paid": "Paga",
"pending": "Pendente",
"failed": "Falhou",
"refunded": "Reembolsada"
},
"trialEnds": "Teste termina em {{date}}",
"renewsOn": "Renova em {{date}}",
"endsOn": "Termina em {{date}}",
"subscriptionConfirmed": "Assinatura confirmada com sucesso!",
"confirmError": "Erro ao confirmar assinatura",
"subscriptionCanceled": "Assinatura cancelada",
"cancelError": "Erro ao cancelar assinatura",
"cancelConfirmTitle": "Cancelar assinatura?",
"cancelConfirmMessage": "Tem certeza que deseja cancelar sua assinatura?",
"cancelNote1": "Você manterá acesso até o final do período atual",
"cancelNote2": "Seus dados não serão excluídos",
"cancelNote3": "Você pode reativar sua assinatura a qualquer momento",
"confirmCancel": "Sim, Cancelar"
}
}

View File

@ -0,0 +1,413 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useToast } from '../components/Toast';
import api from '../services/api';
export default function Billing() {
const { t } = useTranslation();
const navigate = useNavigate();
const { showToast } = useToast();
const [searchParams] = useSearchParams();
const [subscription, setSubscription] = useState(null);
const [plan, setPlan] = useState(null);
const [invoices, setInvoices] = useState([]);
const [loading, setLoading] = useState(true);
const [canceling, setCanceling] = useState(false);
const [showCancelModal, setShowCancelModal] = useState(false);
useEffect(() => {
// Handle subscription confirmation from PayPal return
const subscriptionId = searchParams.get('subscription_id');
const token = searchParams.get('token');
if (subscriptionId && token) {
confirmSubscription(subscriptionId, token);
} else {
loadData();
}
}, []);
const confirmSubscription = async (subscriptionId, token) => {
try {
setLoading(true);
const response = await api.post('/subscription/confirm', {
subscription_id: subscriptionId,
token: token,
});
if (response.data.success) {
showToast(t('billing.subscriptionConfirmed'), 'success');
// Clear URL params
navigate('/billing', { replace: true });
}
} catch (error) {
console.error('Error confirming subscription:', error);
showToast(t('billing.confirmError'), 'error');
}
loadData();
};
const loadData = async () => {
try {
setLoading(true);
// Load subscription status
const statusResponse = await api.get('/subscription/status');
if (statusResponse.data.success) {
setSubscription(statusResponse.data.data.subscription);
setPlan(statusResponse.data.data.plan);
}
// Load invoices
const invoicesResponse = await api.get('/subscription/invoices');
if (invoicesResponse.data.success) {
setInvoices(invoicesResponse.data.data.invoices);
}
} catch (error) {
console.error('Error loading billing:', error);
if (error.response?.status === 401) {
navigate('/login?redirect=/billing');
}
} finally {
setLoading(false);
}
};
const handleCancelSubscription = async () => {
try {
setCanceling(true);
const response = await api.post('/subscription/cancel');
if (response.data.success) {
showToast(t('billing.subscriptionCanceled'), 'success');
setShowCancelModal(false);
loadData();
}
} catch (error) {
console.error('Error canceling subscription:', error);
showToast(error.response?.data?.message || t('billing.cancelError'), 'error');
} finally {
setCanceling(false);
}
};
const formatDate = (dateString) => {
if (!dateString) return '-';
return new Date(dateString).toLocaleDateString();
};
const formatCurrency = (amount, currency = 'EUR') => {
return new Intl.NumberFormat('es-ES', {
style: 'currency',
currency: currency,
}).format(amount);
};
const getStatusBadge = (status) => {
const badges = {
active: 'bg-success',
trialing: 'bg-info',
canceled: 'bg-warning',
expired: 'bg-danger',
past_due: 'bg-danger',
};
return badges[status] || 'bg-secondary';
};
const getInvoiceStatusBadge = (status) => {
const badges = {
paid: 'bg-success',
pending: 'bg-warning',
failed: 'bg-danger',
refunded: 'bg-info',
};
return badges[status] || 'bg-secondary';
};
if (loading) {
return (
<div className="container py-5">
<div className="d-flex justify-content-center align-items-center" style={{ minHeight: '400px' }}>
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">{t('common.loading')}</span>
</div>
</div>
</div>
);
}
return (
<div className="container py-5">
<h1 className="mb-4">{t('billing.title')}</h1>
{/* Current Plan Card */}
<div className="card mb-4">
<div className="card-header">
<h5 className="mb-0">
<i className="bi bi-credit-card me-2"></i>
{t('billing.currentPlan')}
</h5>
</div>
<div className="card-body">
<div className="row align-items-center">
<div className="col-md-8">
<h3 className="mb-2">{plan?.name || t('billing.noPlan')}</h3>
{subscription ? (
<div className="mb-3">
<span className={`badge ${getStatusBadge(subscription.status)} me-2`}>
{t(`billing.status.${subscription.status}`)}
</span>
{subscription.status === 'trialing' && subscription.trial_ends_at && (
<small className="text-muted">
{t('billing.trialEnds', { date: formatDate(subscription.trial_ends_at) })}
</small>
)}
{subscription.status === 'active' && subscription.current_period_end && (
<small className="text-muted">
{t('billing.renewsOn', { date: formatDate(subscription.current_period_end) })}
</small>
)}
{subscription.status === 'canceled' && subscription.ends_at && (
<small className="text-muted">
{t('billing.endsOn', { date: formatDate(subscription.ends_at) })}
</small>
)}
</div>
) : (
<p className="text-muted mb-3">{t('billing.noActiveSubscription')}</p>
)}
{plan && !plan.is_free && (
<p className="mb-0">
<strong>{formatCurrency(plan.price)}</strong>
<span className="text-muted">
/{plan.billing_period === 'annual' ? t('billing.year') : t('billing.month')}
</span>
</p>
)}
</div>
<div className="col-md-4 text-md-end mt-3 mt-md-0">
{(!subscription || plan?.is_free) && (
<button
className="btn btn-primary"
onClick={() => navigate('/pricing')}
>
<i className="bi bi-arrow-up-circle me-2"></i>
{t('billing.upgradePlan')}
</button>
)}
{subscription && !plan?.is_free && subscription.status !== 'canceled' && (
<div className="d-flex flex-column gap-2">
<button
className="btn btn-outline-primary"
onClick={() => navigate('/pricing')}
>
{t('billing.changePlan')}
</button>
<button
className="btn btn-outline-danger"
onClick={() => setShowCancelModal(true)}
>
{t('billing.cancelSubscription')}
</button>
</div>
)}
</div>
</div>
</div>
</div>
{/* Plan Features */}
{plan && (
<div className="card mb-4">
<div className="card-header">
<h5 className="mb-0">
<i className="bi bi-list-check me-2"></i>
{t('billing.planFeatures')}
</h5>
</div>
<div className="card-body">
<div className="row">
<div className="col-md-6">
<ul className="list-unstyled">
{plan.features?.slice(0, Math.ceil(plan.features.length / 2)).map((feature, idx) => (
<li key={idx} className="mb-2">
<i className="bi bi-check-circle-fill text-success me-2"></i>
{feature}
</li>
))}
</ul>
</div>
<div className="col-md-6">
<ul className="list-unstyled">
{plan.features?.slice(Math.ceil(plan.features.length / 2)).map((feature, idx) => (
<li key={idx} className="mb-2">
<i className="bi bi-check-circle-fill text-success me-2"></i>
{feature}
</li>
))}
</ul>
</div>
</div>
{/* Limits */}
{plan.limits && (
<div className="mt-3 pt-3 border-top">
<h6 className="text-muted mb-3">{t('billing.limits')}</h6>
<div className="row g-3">
<div className="col-md-4">
<div className="d-flex align-items-center">
<i className="bi bi-bank fs-4 text-primary me-2"></i>
<div>
<small className="text-muted d-block">{t('billing.accounts')}</small>
<strong>{plan.limits.accounts === -1 ? t('billing.unlimited') : plan.limits.accounts}</strong>
</div>
</div>
</div>
<div className="col-md-4">
<div className="d-flex align-items-center">
<i className="bi bi-receipt fs-4 text-primary me-2"></i>
<div>
<small className="text-muted d-block">{t('billing.budgets')}</small>
<strong>{plan.limits.budgets === -1 ? t('billing.unlimited') : plan.limits.budgets}</strong>
</div>
</div>
</div>
<div className="col-md-4">
<div className="d-flex align-items-center">
<i className="bi bi-people fs-4 text-primary me-2"></i>
<div>
<small className="text-muted d-block">{t('billing.sharedUsers')}</small>
<strong>{plan.limits.shared_users === -1 ? t('billing.unlimited') : plan.limits.shared_users}</strong>
</div>
</div>
</div>
</div>
</div>
)}
</div>
</div>
)}
{/* Invoices */}
<div className="card">
<div className="card-header d-flex justify-content-between align-items-center">
<h5 className="mb-0">
<i className="bi bi-receipt me-2"></i>
{t('billing.invoices')}
</h5>
</div>
<div className="card-body">
{invoices.length === 0 ? (
<p className="text-muted text-center py-4">{t('billing.noInvoices')}</p>
) : (
<div className="table-responsive">
<table className="table table-hover">
<thead>
<tr>
<th>{t('billing.invoiceNumber')}</th>
<th>{t('billing.date')}</th>
<th>{t('billing.description')}</th>
<th className="text-end">{t('billing.amount')}</th>
<th>{t('billing.status')}</th>
<th></th>
</tr>
</thead>
<tbody>
{invoices.map((invoice) => (
<tr key={invoice.id}>
<td>
<code>{invoice.invoice_number}</code>
</td>
<td>{formatDate(invoice.invoice_date)}</td>
<td>{invoice.description || '-'}</td>
<td className="text-end">
{formatCurrency(invoice.total_amount, invoice.currency)}
</td>
<td>
<span className={`badge ${getInvoiceStatusBadge(invoice.status)}`}>
{t(`billing.invoiceStatus.${invoice.status}`)}
</span>
</td>
<td className="text-end">
{invoice.pdf_url && (
<a
href={invoice.pdf_url}
target="_blank"
rel="noopener noreferrer"
className="btn btn-sm btn-outline-secondary"
>
<i className="bi bi-download"></i>
</a>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
{/* Cancel Subscription Modal */}
{showCancelModal && (
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
<div className="modal-dialog modal-dialog-centered">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">{t('billing.cancelConfirmTitle')}</h5>
<button
type="button"
className="btn-close"
onClick={() => setShowCancelModal(false)}
></button>
</div>
<div className="modal-body">
<p>{t('billing.cancelConfirmMessage')}</p>
<ul className="text-muted">
<li>{t('billing.cancelNote1')}</li>
<li>{t('billing.cancelNote2')}</li>
<li>{t('billing.cancelNote3')}</li>
</ul>
</div>
<div className="modal-footer">
<button
type="button"
className="btn btn-secondary"
onClick={() => setShowCancelModal(false)}
>
{t('common.cancel')}
</button>
<button
type="button"
className="btn btn-danger"
onClick={handleCancelSubscription}
disabled={canceling}
>
{canceling ? (
<>
<span className="spinner-border spinner-border-sm me-2"></span>
{t('common.processing')}
</>
) : (
t('billing.confirmCancel')
)}
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,269 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useToast } from '../components/Toast';
import api from '../services/api';
export default function Pricing() {
const { t } = useTranslation();
const navigate = useNavigate();
const { showToast } = useToast();
const [searchParams] = useSearchParams();
const [plans, setPlans] = useState([]);
const [currentPlan, setCurrentPlan] = useState(null);
const [loading, setLoading] = useState(true);
const [subscribing, setSubscribing] = useState(null);
useEffect(() => {
loadData();
// Check if user came back from canceled payment
if (searchParams.get('canceled') === 'true') {
showToast(t('pricing.paymentCanceled'), 'warning');
}
}, []);
const loadData = async () => {
try {
setLoading(true);
// Load plans
const plansResponse = await api.get('/plans');
if (plansResponse.data.success) {
setPlans(plansResponse.data.data.plans);
}
// Load current subscription status
try {
const statusResponse = await api.get('/subscription/status');
if (statusResponse.data.success) {
setCurrentPlan(statusResponse.data.data.plan);
}
} catch (e) {
// User not logged in - that's ok for pricing page
}
} catch (error) {
console.error('Error loading pricing:', error);
showToast(t('common.error'), 'error');
} finally {
setLoading(false);
}
};
const handleSubscribe = async (planSlug) => {
// Check if user is logged in
const token = localStorage.getItem('token');
if (!token) {
showToast(t('pricing.loginRequired'), 'warning');
navigate('/login?redirect=/pricing');
return;
}
try {
setSubscribing(planSlug);
const response = await api.post('/subscription/subscribe', {
plan_slug: planSlug,
});
if (response.data.success) {
if (response.data.data.approve_url) {
// Redirect to PayPal
window.location.href = response.data.data.approve_url;
} else {
// Free plan - subscribed directly
showToast(t('pricing.subscribed'), 'success');
navigate('/billing');
}
}
} catch (error) {
console.error('Error subscribing:', error);
showToast(error.response?.data?.message || t('pricing.subscribeError'), 'error');
} finally {
setSubscribing(null);
}
};
const isCurrentPlan = (planSlug) => {
return currentPlan?.slug === planSlug;
};
if (loading) {
return (
<div className="container py-5">
<div className="d-flex justify-content-center align-items-center" style={{ minHeight: '400px' }}>
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">{t('common.loading')}</span>
</div>
</div>
</div>
);
}
return (
<div className="container py-5">
{/* Header */}
<div className="text-center mb-5">
<h1 className="display-4 fw-bold mb-3">{t('pricing.title')}</h1>
<p className="lead text-muted">{t('pricing.subtitle')}</p>
</div>
{/* Plans Grid */}
<div className="row justify-content-center g-4">
{plans.map((plan) => (
<div key={plan.id} className="col-lg-4 col-md-6">
<div className={`card h-100 ${plan.is_featured ? 'border-primary border-2' : ''}`}>
{plan.is_featured && (
<div className="card-header bg-primary text-white text-center py-2">
<i className="bi bi-star-fill me-2"></i>
{t('pricing.mostPopular')}
</div>
)}
<div className="card-body d-flex flex-column">
{/* Plan Name */}
<h3 className="card-title text-center mb-3">{plan.name}</h3>
{/* Price */}
<div className="text-center mb-4">
{plan.is_free ? (
<div className="display-4 fw-bold text-success">{t('pricing.free')}</div>
) : (
<>
<div className="display-4 fw-bold">
{plan.monthly_price.toFixed(2)}
<span className="fs-6 fw-normal text-muted">/{t('pricing.month')}</span>
</div>
{plan.billing_period === 'annual' && (
<div className="text-muted small">
{t('pricing.billedAnnually', { price: plan.price })}
</div>
)}
{plan.savings_percent && (
<span className="badge bg-success mt-2">
{t('pricing.save', { percent: plan.savings_percent })}
</span>
)}
</>
)}
</div>
{/* Trial Badge */}
{plan.has_trial && (
<div className="text-center mb-3">
<span className="badge bg-info">
<i className="bi bi-gift me-1"></i>
{t('pricing.trialDays', { days: plan.trial_days })}
</span>
</div>
)}
{/* Description */}
<p className="text-muted text-center mb-4">{plan.description}</p>
{/* Features */}
<ul className="list-unstyled mb-4 flex-grow-1">
{plan.features?.map((feature, idx) => (
<li key={idx} className="mb-2">
<i className="bi bi-check-circle-fill text-success me-2"></i>
{feature}
</li>
))}
</ul>
{/* CTA Button */}
<div className="mt-auto">
{isCurrentPlan(plan.slug) ? (
<button className="btn btn-outline-secondary w-100" disabled>
<i className="bi bi-check-lg me-2"></i>
{t('pricing.currentPlan')}
</button>
) : (
<button
className={`btn w-100 ${plan.is_featured ? 'btn-primary' : 'btn-outline-primary'}`}
onClick={() => handleSubscribe(plan.slug)}
disabled={subscribing !== null}
>
{subscribing === plan.slug ? (
<>
<span className="spinner-border spinner-border-sm me-2" role="status"></span>
{t('common.processing')}
</>
) : plan.is_free ? (
<>
<i className="bi bi-arrow-right me-2"></i>
{t('pricing.startFree')}
</>
) : (
<>
<i className="bi bi-credit-card me-2"></i>
{plan.has_trial ? t('pricing.startTrial') : t('pricing.subscribe')}
</>
)}
</button>
)}
</div>
</div>
</div>
</div>
))}
</div>
{/* FAQ Section */}
<div className="mt-5 pt-5">
<h3 className="text-center mb-4">{t('pricing.faq.title')}</h3>
<div className="accordion" id="pricingFaq">
<div className="accordion-item">
<h4 className="accordion-header">
<button className="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#faq1">
{t('pricing.faq.q1')}
</button>
</h4>
<div id="faq1" className="accordion-collapse collapse" data-bs-parent="#pricingFaq">
<div className="accordion-body">{t('pricing.faq.a1')}</div>
</div>
</div>
<div className="accordion-item">
<h4 className="accordion-header">
<button className="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#faq2">
{t('pricing.faq.q2')}
</button>
</h4>
<div id="faq2" className="accordion-collapse collapse" data-bs-parent="#pricingFaq">
<div className="accordion-body">{t('pricing.faq.a2')}</div>
</div>
</div>
<div className="accordion-item">
<h4 className="accordion-header">
<button className="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#faq3">
{t('pricing.faq.q3')}
</button>
</h4>
<div id="faq3" className="accordion-collapse collapse" data-bs-parent="#pricingFaq">
<div className="accordion-body">{t('pricing.faq.a3')}</div>
</div>
</div>
</div>
</div>
{/* Trust Badges */}
<div className="text-center mt-5 pt-4">
<div className="d-flex justify-content-center gap-4 flex-wrap">
<div className="text-muted">
<i className="bi bi-shield-check fs-4 text-success me-2"></i>
{t('pricing.securePayment')}
</div>
<div className="text-muted">
<i className="bi bi-arrow-repeat fs-4 text-primary me-2"></i>
{t('pricing.cancelAnytime')}
</div>
<div className="text-muted">
<i className="bi bi-paypal fs-4 text-info me-2"></i>
{t('pricing.paypalSecure')}
</div>
</div>
</div>
</div>
);
}