diff --git a/src/api/routes.py b/src/api/routes.py index cd7f98e..a88ecfd 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -7,6 +7,7 @@ from uuid import uuid4 from flask import Blueprint, g, jsonify, request +from flask_jwt_extended import get_jwt_identity, jwt_required from sqlalchemy import String, cast, delete, func, or_, select, update from sqlalchemy.exc import IntegrityError @@ -1947,6 +1948,91 @@ def get_plans(): ]), 200 +@api.route("/subscriptions/activate", methods=["POST"]) +@jwt_required() +def activate_subscription(): + """Activate a paid plan for an existing company after trial or plan expiry.""" + data = request.get_json(silent=True) + + if not isinstance(data, dict): + return jsonify(message="Request body must be a JSON object."), 400 + + if data.get("registration_mode") == "trial": + return jsonify( + message="The free trial has already been used for this company.", + code="trial_already_used", + ), 400 + + plan_id = data.get("plan_id") + + if type(plan_id) is not int or plan_id <= 0: + return jsonify(message="Select a valid plan."), 400 + + raw_company_id = request.headers.get("X-Company-ID", "") + + if ( + not raw_company_id.isascii() + or not raw_company_id.isdecimal() + or len(raw_company_id) > 10 + ): + return jsonify(message="Select a valid company."), 400 + + membership = db.session.scalar( + select(CompanyMembership).join(Company).where( + CompanyMembership.company_id == int(raw_company_id), + CompanyMembership.user_id == int(get_jwt_identity()), + CompanyMembership.is_active.is_(True), + Company.is_active.is_(True), + ) + ) + + if membership is None: + return jsonify(message="You do not have access to this company."), 403 + + if membership.role not in (MembershipRole.OWNER, MembershipRole.ADMIN): + return jsonify( + message="Only an owner or administrator can activate a plan." + ), 403 + + plan = db.session.scalar( + select(Plan).where( + Plan.id == plan_id, + Plan.is_active.is_(True), + ) + ) + + if plan is None: + return jsonify(message="Selected plan is unavailable."), 400 + + subscription = db.session.scalar( + select(Subscription) + .where(Subscription.company_id == membership.company_id) + .order_by(Subscription.created_at.desc(), Subscription.id.desc()) + .limit(1) + ) + + if subscription is None: + subscription = Subscription(company_id=membership.company_id) + db.session.add(subscription) + + now = utc_now() + subscription.plan = plan + subscription.status = SubscriptionStatus.ACTIVE + subscription.current_period_started_at = now + subscription.current_period_ends_at = now + timedelta(days=30) + subscription.cancelled_at = None + subscription.external_subscription_id = f"mock_{uuid4().hex}" + db.session.commit() + + return jsonify( + message="Plan activated successfully.", + company_id=membership.company_id, + plan_id=plan.id, + status=subscription.status.value, + current_period_ends_at=subscription.current_period_ends_at.isoformat(), + ), 200 + + @api.route("/register", methods=["POST"]) @limited("register") def register(): diff --git a/src/front/i18n/messages.mjs b/src/front/i18n/messages.mjs index 13ef4bc..19c86ea 100644 --- a/src/front/i18n/messages.mjs +++ b/src/front/i18n/messages.mjs @@ -90,6 +90,12 @@ export const interfaceMessages = { mockPayment: message("Simulate immediate subscription", "Simular suscripción inmediata", "Simular assinatura imediata"), noRealCharge: message("Simulate payment — no real charge", "Simular pago — sin cargo real", "Simular pagamento — sem cobrança real"), continueRegistration: message("Continue to registration", "Continuar al registro", "Continuar para o cadastro"), + trialExpiredNotice: message("Your free trial has ended. Choose a plan to continue using your existing company and data.", "Tu prueba gratuita ha terminado. Elige un plan para seguir usando tu empresa y tus datos actuales.", "Seu período de teste terminou. Escolha um plano para continuar usando sua empresa e seus dados atuais."), + trialUnavailable: message("A second free trial is not available for this company.", "Esta empresa no puede solicitar otra prueba gratuita.", "Esta empresa não pode solicitar outro período de teste gratuito."), + activatePlan: message("Activate plan", "Activar plan", "Ativar plano"), + activatingPlan: message("Activating plan...", "Activando plan...", "Ativando plano..."), + planActivationError: message("Unable to activate the plan.", "No se pudo activar el plan.", "Não foi possível ativar o plano."), + planAdminRequired: message("Ask a company owner or administrator to activate a plan.", "Solicita al propietario o a un administrador de la empresa que active un plan.", "Peça ao proprietário ou a um administrador da empresa para ativar um plano."), continue: message("Continue", "Continuar", "Continuar"), invitationTitle: message("Join your team", "Únete a tu equipo", "Entre para sua equipe"), diff --git a/src/front/pages/PlanSelection.jsx b/src/front/pages/PlanSelection.jsx index bd2e86e..be2190b 100644 --- a/src/front/pages/PlanSelection.jsx +++ b/src/front/pages/PlanSelection.jsx @@ -1,7 +1,8 @@ import { useEffect, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { useNavigate, useSearchParams } from "react-router-dom"; import { AuthLayout } from "../components/AuthLayout"; import { useLanguage } from "../context/LanguageContext"; +import { subscriptionService } from "../services/subscriptionService.mjs"; export const PlanSelection = () => { @@ -12,7 +13,60 @@ export const PlanSelection = () => { const [plansLoading, setPlansLoading] = useState(true); const [plansError, setPlansError] = useState(""); + const [accountError, setAccountError] = useState(""); + const [activationError, setActivationError] = useState(""); + const [companyId, setCompanyId] = useState(null); + const [companyRole, setCompanyRole] = useState(null); + const [accountLoading, setAccountLoading] = useState(true); + const [activating, setActivating] = useState(false); const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const token = localStorage.getItem("access_token"); + const renewalRequested = searchParams.get("reason") === "expired"; + const renewalMode = Boolean(token); + const canActivate = ["owner", "admin"].includes(companyRole); + + useEffect(() => { + if (!token) { + setAccountLoading(false); + if (renewalRequested) navigate("/login", { replace: true }); + return undefined; + } + + const controller = new AbortController(); + const apiUrl = (import.meta.env.VITE_BACKEND_URL || "").replace(/\/$/, ""); + + fetch(`${apiUrl}/api/me`, { + headers: { Authorization: `Bearer ${token}` }, + signal: controller.signal, + }) + .then(async (response) => { + if (response.status === 401) { + localStorage.removeItem("access_token"); + navigate("/login", { replace: true }); + return null; + } + if (!response.ok) throw new Error(ui.accountLoadError); + return response.json(); + }) + .then((account) => { + if (!account || controller.signal.aborted) return; + const company = account.companies?.[0]; + if (!company) throw new Error(ui.noCompany); + setCompanyId(company.id); + setCompanyRole(company.role); + }) + .catch((error) => { + if (!controller.signal.aborted) { + setAccountError(error.message || ui.accountLoadError); + } + }) + .finally(() => { + if (!controller.signal.aborted) setAccountLoading(false); + }); + + return () => controller.abort(); + }, [navigate, renewalRequested, token, ui.accountLoadError, ui.noCompany]); useEffect(() => { const controller = new AbortController(); @@ -57,10 +111,29 @@ export const PlanSelection = () => { return () => controller.abort(); }, [ui.invalidPlans, ui.plansError]); - const handleSelectPlan = (e) => { + const handleSelectPlan = async (e) => { e.preventDefault(); - if (plansLoading || plansError || selectedPlanId === null) { + if (plansLoading || accountLoading || plansError || accountError || selectedPlanId === null) { + return; + } + + if (renewalMode) { + if (!canActivate) return; + setActivating(true); + setActivationError(""); + try { + await subscriptionService.activate({ + token, + companyId, + planId: selectedPlanId, + }); + navigate("/dashboard", { replace: true }); + } catch (error) { + setActivationError(error.message || ui.planActivationError); + } finally { + setActivating(false); + } return; } @@ -78,6 +151,17 @@ export const PlanSelection = () => {
{ui.plansSubtitle}
+ {renewalMode && renewalRequested && ( +{ui.trialExpiredNotice}
+ {ui.trialUnavailable} +{ui.plansLoading}
)} @@ -95,6 +179,25 @@ export const PlanSelection = () => { )} + {accountError && ( +{ui.checkingSubscription}
; } + if (status === "subscription_required") { + return