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:
parent
0adb5c889f
commit
679a1bc4b2
65
CHANGELOG.md
65
CHANGELOG.md
@ -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
|
||||
|
||||
559
backend/app/Http/Controllers/Api/SubscriptionController.php
Normal file
559
backend/app/Http/Controllers/Api/SubscriptionController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
364
backend/app/Services/PayPalService.php
Normal file
364
backend/app/Services/PayPalService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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'),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@ -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)
|
||||
// ============================================
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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') },
|
||||
]
|
||||
},
|
||||
];
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
413
frontend/src/pages/Billing.jsx
Normal file
413
frontend/src/pages/Billing.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
269
frontend/src/pages/Pricing.jsx
Normal file
269
frontend/src/pages/Pricing.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user