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.plansTitle}

{ui.plansSubtitle}

+ {renewalMode && renewalRequested && ( +
+

{ui.trialExpiredNotice}

+ {ui.trialUnavailable} +
+ )} + {renewalMode && !accountLoading && !accountError && !canActivate && ( +
+ {ui.planAdminRequired} +
+ )} {plansLoading && (

{ui.plansLoading}

)} @@ -95,6 +179,25 @@ export const PlanSelection = () => { )} + {accountError && ( +
+ {accountError} + +
+ )} + + {activationError && ( +
+ {activationError} +
+ )} + {!plansLoading && !plansError && plans.length === 0 && (
{ui.noPlans} @@ -134,7 +237,7 @@ export const PlanSelection = () => {
{plan.description} - {plan.trial_days + {!renewalMode && plan.trial_days ? `${ui.trialDays.replace("{count}", plan.trial_days)} · ${maxLeadsText}` : maxLeadsText}
@@ -142,7 +245,7 @@ export const PlanSelection = () => { ); })}
-
+ {!renewalMode &&
{ui.chooseStart} -
+
} diff --git a/src/front/routes.jsx b/src/front/routes.jsx index 6e0db5d..3885085 100644 --- a/src/front/routes.jsx +++ b/src/front/routes.jsx @@ -82,6 +82,10 @@ const ProtectedRoute = ({ children }) => { const data = await response.json(); if (!response.ok) { + if (data.code === "subscription_required") { + setStatus("subscription_required"); + return; + } throw new Error(data.message || ui.subscriptionVerifyError); } @@ -112,6 +116,10 @@ const ProtectedRoute = ({ children }) => { return

{ui.checkingSubscription}

; } + if (status === "subscription_required") { + return ; + } + if (status === "blocked") { return (
diff --git a/src/front/services/subscriptionService.mjs b/src/front/services/subscriptionService.mjs new file mode 100644 index 0000000..013bf74 --- /dev/null +++ b/src/front/services/subscriptionService.mjs @@ -0,0 +1,26 @@ +const API_URL = (import.meta.env?.VITE_BACKEND_URL || "").replace(/\/$/, ""); + +export const subscriptionService = { + activate: async ({ token, companyId, planId, signal }) => { + const response = await fetch(`${API_URL}/api/subscriptions/activate`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + "X-Company-ID": String(companyId), + }, + body: JSON.stringify({ + plan_id: planId, + registration_mode: "mock_payment", + }), + signal, + }); + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || "Unable to activate the plan."); + } + + return data; + }, +}; diff --git a/tests/frontend/subscription-expiry.test.mjs b/tests/frontend/subscription-expiry.test.mjs new file mode 100644 index 0000000..161d410 --- /dev/null +++ b/tests/frontend/subscription-expiry.test.mjs @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { subscriptionService } from "../../src/front/services/subscriptionService.mjs"; + +test("plan activation keeps the existing company and never requests a trial", async () => { + const previousFetch = globalThis.fetch; + let request; + globalThis.fetch = async (url, options) => { + request = { url, options }; + return { + ok: true, + json: async () => ({ status: "active" }), + }; + }; + + try { + await subscriptionService.activate({ + token: "token-123", + companyId: 7, + planId: 3, + }); + } finally { + globalThis.fetch = previousFetch; + } + + assert.equal(request.url, "/api/subscriptions/activate"); + assert.equal(request.options.method, "POST"); + assert.equal(request.options.headers["X-Company-ID"], "7"); + assert.deepEqual(JSON.parse(request.options.body), { + plan_id: 3, + registration_mode: "mock_payment", + }); +}); + +test("expired protected routes redirect to plan selection", () => { + const routes = readFileSync( + new URL("../../src/front/routes.jsx", import.meta.url), + "utf8" + ); + const plans = readFileSync( + new URL("../../src/front/pages/PlanSelection.jsx", import.meta.url), + "utf8" + ); + + assert.match(routes, /data\.code === "subscription_required"/); + assert.match(routes, /select-plan\?reason=expired/); + assert.match(plans, /!renewalMode &&
/); + assert.match(plans, /subscriptionService\.activate/); +}); diff --git a/tests/test_onboarding.py b/tests/test_onboarding.py index 2bb91f7..03c3201 100644 --- a/tests/test_onboarding.py +++ b/tests/test_onboarding.py @@ -54,6 +54,75 @@ def test_trial_and_expiry_with_existing_session(self): self.assertEqual(response.status_code, 403) self.assertEqual(response.json['code'], 'subscription_required') + def test_expired_trial_can_activate_a_plan_without_another_trial(self): + self.assertEqual(self.register().status_code, 201) + member = db.session.scalar(select(CompanyMembership)) + subscription = db.session.scalar(select(Subscription)) + subscription_id = subscription.id + original_trial_started_at = subscription.trial_started_at + original_trial_ends_at = utc_now() - timedelta(seconds=1) + subscription.trial_ends_at = original_trial_ends_at + professional = Plan( + code='professional', name='Professional', price_eur=79.99 + ) + db.session.add(professional) + db.session.commit() + token = self.client.post('/api/login', json=self.data).json['token'] + headers = { + 'Authorization': 'Bearer ' + token, + 'X-Company-ID': str(member.company_id), + } + + response = self.client.post( + '/api/subscriptions/activate', + headers=headers, + json={'plan_id': professional.id, 'registration_mode': 'mock_payment'}, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json['status'], 'active') + self.assertEqual(response.json['plan_id'], professional.id) + self.assertEqual( + db.session.scalar(select(func.count()).select_from(Subscription)), 1 + ) + db.session.expire_all() + activated = db.session.get(Subscription, subscription_id) + self.assertEqual(activated.status, SubscriptionStatus.ACTIVE) + self.assertEqual(activated.plan_id, professional.id) + self.assertEqual(activated.trial_started_at, original_trial_started_at) + self.assertEqual( + activated.trial_ends_at.replace(tzinfo=None), + original_trial_ends_at.replace(tzinfo=None), + ) + self.assertTrue(activated.external_subscription_id.startswith('mock_')) + self.assertTrue(subscription_allows_access(activated)) + self.assertEqual( + self.client.get('/api/auth/context', headers=headers).status_code, + 200, + ) + + def test_existing_company_cannot_request_a_second_trial(self): + self.assertEqual(self.register().status_code, 201) + member = db.session.scalar(select(CompanyMembership)) + subscription = db.session.scalar(select(Subscription)) + original_trial_ends_at = subscription.trial_ends_at + token = self.client.post('/api/login', json=self.data).json['token'] + + response = self.client.post( + '/api/subscriptions/activate', + headers={ + 'Authorization': 'Bearer ' + token, + 'X-Company-ID': str(member.company_id), + }, + json={'plan_id': self.plan.id, 'registration_mode': 'trial'}, + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json['code'], 'trial_already_used') + db.session.refresh(subscription) + self.assertEqual(subscription.status, SubscriptionStatus.TRIALING) + self.assertEqual(subscription.trial_ends_at, original_trial_ends_at) + def test_mock_payment_and_expiry(self): self.assertEqual(self.register(registration_mode='mock_payment').status_code, 201) sub = db.session.scalar(select(Subscription))