Merge pull request #64 from artslidd/develop

feat: implement trial period for checkout sessions and enforce team p…
This commit is contained in:
Arthur Belleville 2026-03-16 10:27:08 +01:00 committed by GitHub
commit 6050286732
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 50 additions and 8 deletions

View file

@ -293,6 +293,9 @@ const createCheckoutSession = (
customerId = customer.id;
}
const TRIAL_PERIOD_DAYS = 14;
const planHasTrial = plan === "solo" || plan === "team";
const session = await stripe.checkout.sessions.create({
customer: customerId,
line_items: [
@ -315,6 +318,7 @@ const createCheckoutSession = (
organization_id: String(ownerContext.organizationId),
checkout_plan: plan ?? "legacy_price",
},
...(planHasTrial && { trial_period_days: TRIAL_PERIOD_DAYS }),
},
});

View file

@ -531,6 +531,20 @@ const inviteToOrganization = factory.createHandlers(async (c) => {
return c.json({ error: "This email already belongs to another organization" }, 409);
}
const TEAM_PLAN_MEMBER_LIMIT = 3;
if (
billingState.active_subscription_plan === "team" &&
billingState.member_count >= TEAM_PLAN_MEMBER_LIMIT
) {
return c.json(
{
error: `The Teams plan is limited to ${TEAM_PLAN_MEMBER_LIMIT} members per organization. Upgrade to the Founder plan to add unlimited members.`,
},
403
);
}
if (billingState.is_trial_expired && billingState.active_subscription_plan !== "annual") {
const requiredSeatsForInvite = billingState.member_count + 1;

View file

@ -80,6 +80,10 @@ export default function SettingsPage() {
);
const canManageMembers = organizationData?.is_billing_owner ?? false;
const TEAM_PLAN_MEMBER_LIMIT = 3;
const isAtTeamMemberLimit =
organizationData?.active_subscription_plan === "team" &&
(organizationData?.organization?.member_count ?? 0) >= TEAM_PLAN_MEMBER_LIMIT;
const getDisplayName = (input: {
first_name?: string | null;
@ -419,13 +423,21 @@ export default function SettingsPage() {
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
placeholder={t("settings:teamInvite.emailPlaceholder")}
disabled={isAtTeamMemberLimit}
/>
<p className="text-xs text-muted-foreground">{t("settings:teamInvite.hint")}</p>
{isAtTeamMemberLimit ? (
<p className="text-xs text-amber-600 dark:text-amber-400">
Le plan Teams est limité à {TEAM_PLAN_MEMBER_LIMIT} membres. Passez au plan
Founder pour ajouter des membres illimités.
</p>
) : (
<p className="text-xs text-muted-foreground">{t("settings:teamInvite.hint")}</p>
)}
</div>
<div className="flex justify-end">
<Button
disabled={inviteOrganizationUserPending || !inviteEmail.trim()}
disabled={inviteOrganizationUserPending || !inviteEmail.trim() || isAtTeamMemberLimit}
onClick={() => {
inviteOrganizationUser(inviteEmail.trim());
setInviteEmail("");

View file

@ -8,12 +8,17 @@ import { Label } from "@xtablo/ui/components/label";
import { ArrowLeftIcon, MonitorIcon, MoonIcon, SparklesIcon, SunIcon } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { Link, useSearchParams } from "react-router-dom";
import { useSignUp } from "../hooks/auth";
import { DEFAULT_SIGNUP_BILLING_INTENT, type SignupBillingIntent } from "../lib/billing";
import {
DEFAULT_SIGNUP_BILLING_INTENT,
isSignupBillingIntent,
type SignupBillingIntent,
} from "../lib/billing";
export function SignUpV2Page() {
const { t } = useTranslation(["auth", "common"]);
const [searchParams] = useSearchParams();
const redirectUrl = localStorage.getItem("redirectUrl");
const { mutate: signUp, isPending } = useSignUp({
redirectUrl: redirectUrl ?? null,
@ -30,8 +35,9 @@ export function SignUpV2Page() {
business_name: "",
});
const [termsAccepted, setTermsAccepted] = useState(false);
const planParam = searchParams.get("plan");
const [billingIntent, setBillingIntent] = useState<SignupBillingIntent>(
DEFAULT_SIGNUP_BILLING_INTENT
isSignupBillingIntent(planParam) ? planParam : DEFAULT_SIGNUP_BILLING_INTENT
);
const { theme, setTheme } = useTheme();

View file

@ -8,13 +8,18 @@ import { Label } from "@xtablo/ui/components/label";
import { MonitorIcon, MoonIcon, SunIcon } from "lucide-react";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { Link, useSearchParams } from "react-router-dom";
import { twMerge } from "tailwind-merge";
import { useSignUp } from "../hooks/auth";
import { DEFAULT_SIGNUP_BILLING_INTENT, type SignupBillingIntent } from "../lib/billing";
import {
DEFAULT_SIGNUP_BILLING_INTENT,
isSignupBillingIntent,
type SignupBillingIntent,
} from "../lib/billing";
export function SignUpPage() {
const { t } = useTranslation(["auth", "common"]);
const [searchParams] = useSearchParams();
const redirectUrl = localStorage.getItem("redirectUrl");
const { mutate: signUp, isPending } = useSignUp({
redirectUrl: redirectUrl ?? null,
@ -31,8 +36,9 @@ export function SignUpPage() {
business_name: "",
});
const [termsAccepted, setTermsAccepted] = useState(false);
const planParam = searchParams.get("plan");
const [billingIntent, setBillingIntent] = useState<SignupBillingIntent>(
DEFAULT_SIGNUP_BILLING_INTENT
isSignupBillingIntent(planParam) ? planParam : DEFAULT_SIGNUP_BILLING_INTENT
);
// 3D Parallax effect