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/).
|
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
|
## [1.49.0] - 2025-12-17
|
||||||
|
|
||||||
### Added
|
### 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\FinancialHealthController;
|
||||||
use App\Http\Controllers\Api\UserPreferenceController;
|
use App\Http\Controllers\Api\UserPreferenceController;
|
||||||
use App\Http\Controllers\Api\PlanController;
|
use App\Http\Controllers\Api\PlanController;
|
||||||
|
use App\Http\Controllers\Api\SubscriptionController;
|
||||||
|
|
||||||
// Public routes with rate limiting
|
// Public routes with rate limiting
|
||||||
Route::post('/register', [AuthController::class, 'register'])->middleware('throttle:register');
|
Route::post('/register', [AuthController::class, 'register'])->middleware('throttle:register');
|
||||||
@ -32,6 +33,12 @@
|
|||||||
Route::get('/plans', [PlanController::class, 'index']);
|
Route::get('/plans', [PlanController::class, 'index']);
|
||||||
Route::get('/plans/{slug}', [PlanController::class, 'show']);
|
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)
|
// Email testing routes (should be protected in production)
|
||||||
Route::post('/email/send-test', [EmailTestController::class, 'sendTest']);
|
Route::post('/email/send-test', [EmailTestController::class, 'sendTest']);
|
||||||
Route::get('/email/anti-spam-info', [EmailTestController::class, 'getAntiSpamInfo']);
|
Route::get('/email/anti-spam-info', [EmailTestController::class, 'getAntiSpamInfo']);
|
||||||
@ -46,6 +53,15 @@
|
|||||||
return $request->user();
|
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)
|
// Contas (Accounts)
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
@ -23,6 +23,8 @@ import Budgets from './pages/Budgets';
|
|||||||
import Reports from './pages/Reports';
|
import Reports from './pages/Reports';
|
||||||
import Preferences from './pages/Preferences';
|
import Preferences from './pages/Preferences';
|
||||||
import Profile from './pages/Profile';
|
import Profile from './pages/Profile';
|
||||||
|
import Pricing from './pages/Pricing';
|
||||||
|
import Billing from './pages/Billing';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@ -201,6 +203,24 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/pricing"
|
||||||
|
element={
|
||||||
|
<Layout>
|
||||||
|
<Pricing />
|
||||||
|
</Layout>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/billing"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Layout>
|
||||||
|
<Billing />
|
||||||
|
</Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="/" element={<Navigate to="/dashboard" />} />
|
<Route path="/" element={<Navigate to="/dashboard" />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
<CookieConsent />
|
<CookieConsent />
|
||||||
|
|||||||
@ -105,6 +105,8 @@ const Layout = ({ children }) => {
|
|||||||
{ path: '/cost-centers', icon: 'bi-building', label: t('nav.costCenters') },
|
{ path: '/cost-centers', icon: 'bi-building', label: t('nav.costCenters') },
|
||||||
{ path: '/preferences', icon: 'bi-sliders', label: t('nav.preferences') },
|
{ path: '/preferences', icon: 'bi-sliders', label: t('nav.preferences') },
|
||||||
{ path: '/profile', icon: 'bi-person-circle', label: t('nav.profile') },
|
{ 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",
|
"planning": "Planning",
|
||||||
"financialHealth": "Financial Health",
|
"financialHealth": "Financial Health",
|
||||||
"goals": "Goals",
|
"goals": "Goals",
|
||||||
"budgets": "Budgets"
|
"budgets": "Budgets",
|
||||||
|
"billing": "Billing",
|
||||||
|
"pricing": "Plans"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
@ -2041,5 +2043,85 @@
|
|||||||
"passwordError": "Error changing password",
|
"passwordError": "Error changing password",
|
||||||
"passwordMismatch": "Passwords do not match",
|
"passwordMismatch": "Passwords do not match",
|
||||||
"passwordTooShort": "Password must be at least 8 characters"
|
"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",
|
"planning": "Planificación",
|
||||||
"financialHealth": "Salud Financiera",
|
"financialHealth": "Salud Financiera",
|
||||||
"goals": "Metas",
|
"goals": "Metas",
|
||||||
"budgets": "Presupuestos"
|
"budgets": "Presupuestos",
|
||||||
|
"billing": "Facturación",
|
||||||
|
"pricing": "Planes"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Panel de Control",
|
"title": "Panel de Control",
|
||||||
@ -2029,5 +2031,85 @@
|
|||||||
"passwordError": "Error al cambiar contraseña",
|
"passwordError": "Error al cambiar contraseña",
|
||||||
"passwordMismatch": "Las contraseñas no coinciden",
|
"passwordMismatch": "Las contraseñas no coinciden",
|
||||||
"passwordTooShort": "La contraseña debe tener al menos 8 caracteres"
|
"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",
|
"planning": "Planejamento",
|
||||||
"financialHealth": "Saúde Financeira",
|
"financialHealth": "Saúde Financeira",
|
||||||
"goals": "Metas",
|
"goals": "Metas",
|
||||||
"budgets": "Orçamentos"
|
"budgets": "Orçamentos",
|
||||||
|
"billing": "Faturamento",
|
||||||
|
"pricing": "Planos"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Painel de Controle",
|
"title": "Painel de Controle",
|
||||||
@ -2047,5 +2049,85 @@
|
|||||||
"passwordError": "Erro ao alterar senha",
|
"passwordError": "Erro ao alterar senha",
|
||||||
"passwordMismatch": "As senhas não coincidem",
|
"passwordMismatch": "As senhas não coincidem",
|
||||||
"passwordTooShort": "A senha deve ter pelo menos 8 caracteres"
|
"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