Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions src/api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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():
Expand Down
6 changes: 6 additions & 0 deletions src/front/i18n/messages.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
125 changes: 118 additions & 7 deletions src/front/pages/PlanSelection.jsx
Original file line number Diff line number Diff line change
@@ -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 = () => {
Expand All @@ -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();
Expand Down Expand Up @@ -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;
}

Expand All @@ -78,6 +151,17 @@ export const PlanSelection = () => {

<h2 className="fw-bold text-body fs-3 mb-1">{ui.plansTitle}</h2>
<p className="text-muted small mb-4">{ui.plansSubtitle}</p>
{renewalMode && renewalRequested && (
<div className="alert alert-warning" role="status">
<p className="mb-1">{ui.trialExpiredNotice}</p>
<strong>{ui.trialUnavailable}</strong>
</div>
)}
{renewalMode && !accountLoading && !accountError && !canActivate && (
<div className="alert alert-info" role="status">
{ui.planAdminRequired}
</div>
)}
{plansLoading && (
<p role="status">{ui.plansLoading}</p>
)}
Expand All @@ -95,6 +179,25 @@ export const PlanSelection = () => {
</div>
)}

{accountError && (
<div className="alert alert-danger" role="alert">
{accountError}
<button
type="button"
className="btn btn-sm btn-outline-danger ms-3"
onClick={() => window.location.reload()}
>
{ui.retry}
</button>
</div>
)}

{activationError && (
<div className="alert alert-danger" role="alert">
{activationError}
</div>
)}

{!plansLoading && !plansError && plans.length === 0 && (
<div className="alert alert-info" role="status">
{ui.noPlans}
Expand Down Expand Up @@ -134,15 +237,15 @@ export const PlanSelection = () => {
<div className="d-flex justify-content-between align-items-center mt-1">
<small className="text-muted">{plan.description}</small>
<small className="text-secondary fw-medium">
{plan.trial_days
{!renewalMode && plan.trial_days
? `${ui.trialDays.replace("{count}", plan.trial_days)} · ${maxLeadsText}`
: maxLeadsText} </small>
</div>
</div>
);
})}
</div>
<fieldset>
{!renewalMode && <fieldset>
<legend className="fs-6">{ui.chooseStart}</legend>

<label className="d-block">
Expand All @@ -168,18 +271,26 @@ export const PlanSelection = () => {
/>
{ui.noRealCharge}
</label>
</fieldset>
</fieldset>}
<button
type="submit"
disabled={
plansLoading ||
accountLoading ||
activating ||
Boolean(plansError) ||
Boolean(accountError) ||
(renewalMode && !canActivate) ||
selectedPlanId === null
}
className="w-100 py-2 btn text-white fw-semibold shadow-sm mt-3"
style={{ backgroundColor: "#9333ea", borderColor: "#9333ea" }}
>
{ui.continueRegistration}
{activating
? ui.activatingPlan
: renewalMode
? ui.activatePlan
: ui.continueRegistration}
</button>
</form>
</div>
Expand Down
8 changes: 8 additions & 0 deletions src/front/routes.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -112,6 +116,10 @@ const ProtectedRoute = ({ children }) => {
return <p role="status">{ui.checkingSubscription}</p>;
}

if (status === "subscription_required") {
return <Navigate to="/select-plan?reason=expired" replace />;
}

if (status === "blocked") {
return (
<div className="alert alert-warning" role="alert">
Expand Down
26 changes: 26 additions & 0 deletions src/front/services/subscriptionService.mjs
Original file line number Diff line number Diff line change
@@ -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;
},
};
Loading
Loading