Implement solo teams founder billing
This commit is contained in:
parent
0e91257aa1
commit
d0820ebbf1
22 changed files with 1490 additions and 345 deletions
|
|
@ -9,4 +9,11 @@ R2_ACCOUNT_ID="9715fa14c5e5d1612301572cf1c6bbee"
|
|||
TASKS_SECRET="gT3BAytmNwhe1wKmvgREBlWcqK0="
|
||||
|
||||
EMAIL_USER="baptiste@xtablo.com"
|
||||
EMAIL_CLIENT_ID="904332563417-e2n7pchtgnkrkp360baaebfeig55maig.apps.googleusercontent.com"
|
||||
EMAIL_CLIENT_ID="904332563417-e2n7pchtgnkrkp360baaebfeig55maig.apps.googleusercontent.com"
|
||||
|
||||
STRIPE_SOLO_PRICE_ID=price_solo_placeholder
|
||||
STRIPE_TEAM_MONTHLY_PRICE_ID=price_team_placeholder
|
||||
STRIPE_FOUNDER_PRICE_ID=price_founder_placeholder
|
||||
|
||||
BILLING_TRIAL_DAYS=14
|
||||
BILLING_TRIAL_ROLLOUT_AT=2026-03-08T00:00:00.000Z
|
||||
|
|
|
|||
73
apps/api/src/__tests__/helpers/billing.test.ts
Normal file
73
apps/api/src/__tests__/helpers/billing.test.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getBillableMemberCount,
|
||||
getOrganizationOwner,
|
||||
getTrialWindow,
|
||||
} from "../../helpers/billing.js";
|
||||
|
||||
describe("billing helpers", () => {
|
||||
it("returns the earliest organization member as billing owner", () => {
|
||||
const owner = getOrganizationOwner([
|
||||
{
|
||||
id: "owner-user",
|
||||
created_at: "2026-01-01T10:00:00.000Z",
|
||||
is_temporary: false,
|
||||
},
|
||||
{
|
||||
id: "late-user",
|
||||
created_at: "2026-01-02T10:00:00.000Z",
|
||||
is_temporary: false,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(owner?.id).toBe("owner-user");
|
||||
});
|
||||
|
||||
it("excludes temporary users from billable seat count", () => {
|
||||
const count = getBillableMemberCount([
|
||||
{
|
||||
id: "user-1",
|
||||
created_at: "2026-01-01T10:00:00.000Z",
|
||||
is_temporary: false,
|
||||
},
|
||||
{
|
||||
id: "temp-1",
|
||||
created_at: "2026-01-02T10:00:00.000Z",
|
||||
is_temporary: true,
|
||||
},
|
||||
{
|
||||
id: "user-2",
|
||||
created_at: "2026-01-03T10:00:00.000Z",
|
||||
is_temporary: null,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
|
||||
it("uses rollout date as trial start for older organizations", () => {
|
||||
const result = getTrialWindow({
|
||||
ownerCreatedAt: new Date("2025-12-01T00:00:00.000Z"),
|
||||
now: new Date("2026-03-10T00:00:00.000Z"),
|
||||
trialDays: 14,
|
||||
rolloutAt: new Date("2026-03-08T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(result.trialStartDate.toISOString()).toBe("2026-03-08T00:00:00.000Z");
|
||||
expect(result.trialEndDate.toISOString()).toBe("2026-03-22T00:00:00.000Z");
|
||||
expect(result.isTrialExpired).toBe(false);
|
||||
});
|
||||
|
||||
it("uses owner creation date as trial start for new organizations", () => {
|
||||
const result = getTrialWindow({
|
||||
ownerCreatedAt: new Date("2026-03-12T00:00:00.000Z"),
|
||||
now: new Date("2026-03-28T00:00:00.000Z"),
|
||||
trialDays: 14,
|
||||
rolloutAt: new Date("2026-03-08T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(result.trialStartDate.toISOString()).toBe("2026-03-12T00:00:00.000Z");
|
||||
expect(result.trialEndDate.toISOString()).toBe("2026-03-26T00:00:00.000Z");
|
||||
expect(result.isTrialExpired).toBe(true);
|
||||
});
|
||||
});
|
||||
379
apps/api/src/helpers/billing.ts
Normal file
379
apps/api/src/helpers/billing.ts
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
|
||||
export type BillingPlan = "solo" | "team" | "annual";
|
||||
export type RequiredBillingPlan = "solo" | "team";
|
||||
|
||||
type BillingProfileRow = {
|
||||
id: string;
|
||||
created_at: string | null;
|
||||
is_temporary: boolean | null;
|
||||
};
|
||||
|
||||
type StripeCustomerRow = {
|
||||
id: string;
|
||||
metadata: Record<string, string | null> | null;
|
||||
};
|
||||
|
||||
type StripeSubscriptionRow = {
|
||||
id: string;
|
||||
status: string | null;
|
||||
created: number | null;
|
||||
current_period_end: number | null;
|
||||
};
|
||||
|
||||
type StripeSubscriptionItemRow = {
|
||||
subscription: string;
|
||||
price: string | null;
|
||||
quantity: number | null;
|
||||
};
|
||||
|
||||
type StripePriceRow = {
|
||||
id: string;
|
||||
lookup_key: string | null;
|
||||
metadata: Record<string, string | null> | null;
|
||||
product: string | null;
|
||||
};
|
||||
|
||||
type StripeProductRow = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
metadata: Record<string, string | null> | null;
|
||||
};
|
||||
|
||||
export type OrganizationBillingState = {
|
||||
owner_user_id: string;
|
||||
member_count: number;
|
||||
trial_starts_at: string;
|
||||
trial_ends_at: string;
|
||||
is_trial_expired: boolean;
|
||||
required_plan: RequiredBillingPlan;
|
||||
required_team_quantity: number;
|
||||
active_subscription_plan: BillingPlan | null;
|
||||
active_subscription_quantity: number;
|
||||
};
|
||||
|
||||
const ACTIVE_BILLING_STATUSES = ["active", "trialing", "past_due"];
|
||||
const DEFAULT_BILLING_TRIAL_DAYS = 14;
|
||||
|
||||
const parseTrialDays = () => {
|
||||
const parsed = Number.parseInt(process.env.BILLING_TRIAL_DAYS ?? "", 10);
|
||||
if (Number.isNaN(parsed) || parsed <= 0) {
|
||||
return DEFAULT_BILLING_TRIAL_DAYS;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const parseTrialRolloutDate = () => {
|
||||
const raw = process.env.BILLING_TRIAL_ROLLOUT_AT;
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = new Date(raw);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
};
|
||||
|
||||
export const getOrganizationOwner = (profiles: BillingProfileRow[]) => profiles[0] ?? null;
|
||||
|
||||
export const getBillableMemberCount = (profiles: BillingProfileRow[]) =>
|
||||
profiles.filter((profile) => profile.is_temporary !== true).length;
|
||||
|
||||
export const getTrialWindow = (input: {
|
||||
ownerCreatedAt: Date;
|
||||
now: Date;
|
||||
trialDays: number;
|
||||
rolloutAt: Date | null;
|
||||
}) => {
|
||||
const { ownerCreatedAt, now, trialDays, rolloutAt } = input;
|
||||
const trialStartDate = rolloutAt && rolloutAt > ownerCreatedAt ? rolloutAt : ownerCreatedAt;
|
||||
const trialEndDate = new Date(trialStartDate);
|
||||
trialEndDate.setUTCDate(trialEndDate.getUTCDate() + trialDays);
|
||||
|
||||
return {
|
||||
trialStartDate,
|
||||
trialEndDate,
|
||||
isTrialExpired: now.getTime() > trialEndDate.getTime(),
|
||||
};
|
||||
};
|
||||
|
||||
const resolveRequiredPlan = (memberCount: number): RequiredBillingPlan =>
|
||||
memberCount <= 1 ? "solo" : "team";
|
||||
|
||||
const statusWeight = (status: string | null | undefined) => {
|
||||
if (status === "active") return 3;
|
||||
if (status === "past_due") return 2;
|
||||
if (status === "trialing") return 1;
|
||||
return 0;
|
||||
};
|
||||
|
||||
const planWeight = (plan: BillingPlan | null) => {
|
||||
if (plan === "annual") return 3;
|
||||
if (plan === "team") return 2;
|
||||
if (plan === "solo") return 1;
|
||||
return 0;
|
||||
};
|
||||
|
||||
const inferBillingPlan = (planHint: string | null | undefined): BillingPlan | null => {
|
||||
const hint = (planHint ?? "").toLowerCase();
|
||||
|
||||
if (
|
||||
hint.includes("founder") ||
|
||||
hint.includes("annual") ||
|
||||
hint.includes("beta") ||
|
||||
hint.includes("infinite")
|
||||
) {
|
||||
return "annual";
|
||||
}
|
||||
|
||||
if (hint.includes("team") || hint.includes("standard")) {
|
||||
return "team";
|
||||
}
|
||||
|
||||
if (hint.includes("solo") || hint.includes("free")) {
|
||||
return "solo";
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getPlanHint = (price: StripePriceRow | undefined, product: StripeProductRow | undefined) => {
|
||||
const parts = [
|
||||
price?.metadata?.plan,
|
||||
price?.metadata?.tier,
|
||||
price?.lookup_key,
|
||||
product?.metadata?.plan,
|
||||
product?.metadata?.tier,
|
||||
product?.name,
|
||||
]
|
||||
.map((part) => part?.toLowerCase().trim())
|
||||
.filter((part): part is string => Boolean(part && part.length > 0));
|
||||
|
||||
return parts[0] ?? null;
|
||||
};
|
||||
|
||||
const getOrganizationProfiles = async (supabase: SupabaseClient, organizationId: number) => {
|
||||
const { data, error } = await supabase
|
||||
.from("profiles")
|
||||
.select("id, created_at, is_temporary")
|
||||
.eq("organization_id", organizationId)
|
||||
.order("created_at", { ascending: true });
|
||||
|
||||
if (error) {
|
||||
return { error: error.message, profiles: null };
|
||||
}
|
||||
|
||||
const profiles = (data ?? []) as BillingProfileRow[];
|
||||
if (profiles.length === 0) {
|
||||
return { error: "Organization has no members", profiles: null };
|
||||
}
|
||||
|
||||
return { error: null, profiles };
|
||||
};
|
||||
|
||||
const resolveActiveSubscription = async (
|
||||
supabase: SupabaseClient,
|
||||
ownerUserId: string
|
||||
): Promise<{ plan: BillingPlan | null; quantity: number }> => {
|
||||
const { data: customers, error: customersError } = await supabase
|
||||
.schema("stripe")
|
||||
.from("customers")
|
||||
.select("id, metadata")
|
||||
.limit(1000);
|
||||
|
||||
if (customersError) {
|
||||
throw new Error(customersError.message);
|
||||
}
|
||||
|
||||
const customer = (customers as StripeCustomerRow[] | null)?.find(
|
||||
(candidate) => candidate.metadata?.user_id === ownerUserId
|
||||
);
|
||||
|
||||
if (!customer) {
|
||||
return { plan: null, quantity: 0 };
|
||||
}
|
||||
|
||||
const { data: subscriptions, error: subscriptionsError } = await supabase
|
||||
.schema("stripe")
|
||||
.from("subscriptions")
|
||||
.select("id, status, created, current_period_end")
|
||||
.eq("customer", customer.id)
|
||||
.in("status", ACTIVE_BILLING_STATUSES);
|
||||
|
||||
if (subscriptionsError) {
|
||||
throw new Error(subscriptionsError.message);
|
||||
}
|
||||
|
||||
const normalizedSubscriptions = (subscriptions ?? []) as StripeSubscriptionRow[];
|
||||
if (normalizedSubscriptions.length === 0) {
|
||||
return { plan: null, quantity: 0 };
|
||||
}
|
||||
|
||||
const subscriptionIds = normalizedSubscriptions.map((subscription) => subscription.id);
|
||||
const { data: subscriptionItems, error: subscriptionItemsError } = await supabase
|
||||
.schema("stripe")
|
||||
.from("subscription_items")
|
||||
.select("subscription, price, quantity")
|
||||
.in("subscription", subscriptionIds);
|
||||
|
||||
if (subscriptionItemsError) {
|
||||
throw new Error(subscriptionItemsError.message);
|
||||
}
|
||||
|
||||
const normalizedItems = (subscriptionItems ?? []) as StripeSubscriptionItemRow[];
|
||||
const priceIds = Array.from(
|
||||
new Set(normalizedItems.map((item) => item.price).filter((price): price is string => !!price))
|
||||
);
|
||||
|
||||
let pricesById = new Map<string, StripePriceRow>();
|
||||
let productsById = new Map<string, StripeProductRow>();
|
||||
|
||||
if (priceIds.length > 0) {
|
||||
const { data: prices, error: pricesError } = await supabase
|
||||
.schema("stripe")
|
||||
.from("prices")
|
||||
.select("id, lookup_key, metadata, product")
|
||||
.in("id", priceIds);
|
||||
|
||||
if (pricesError) {
|
||||
throw new Error(pricesError.message);
|
||||
}
|
||||
|
||||
const normalizedPrices = (prices ?? []) as StripePriceRow[];
|
||||
pricesById = new Map(normalizedPrices.map((price) => [price.id, price]));
|
||||
|
||||
const productIds = Array.from(
|
||||
new Set(
|
||||
normalizedPrices
|
||||
.map((price) => price.product)
|
||||
.filter((product): product is string => Boolean(product))
|
||||
)
|
||||
);
|
||||
|
||||
if (productIds.length > 0) {
|
||||
const { data: products, error: productsError } = await supabase
|
||||
.schema("stripe")
|
||||
.from("products")
|
||||
.select("id, name, metadata")
|
||||
.in("id", productIds);
|
||||
|
||||
if (productsError) {
|
||||
throw new Error(productsError.message);
|
||||
}
|
||||
|
||||
const normalizedProducts = (products ?? []) as StripeProductRow[];
|
||||
productsById = new Map(normalizedProducts.map((product) => [product.id, product]));
|
||||
}
|
||||
}
|
||||
|
||||
const candidates = normalizedSubscriptions.flatMap((subscription) => {
|
||||
const relatedItems = normalizedItems.filter((item) => item.subscription === subscription.id);
|
||||
|
||||
if (relatedItems.length === 0) {
|
||||
return [
|
||||
{
|
||||
subscription,
|
||||
plan: null as BillingPlan | null,
|
||||
quantity: 1,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return relatedItems.map((item) => {
|
||||
const price = item.price ? pricesById.get(item.price) : undefined;
|
||||
const product = price?.product ? productsById.get(price.product) : undefined;
|
||||
const hint = getPlanHint(price, product);
|
||||
|
||||
return {
|
||||
subscription,
|
||||
plan: inferBillingPlan(hint),
|
||||
quantity: Math.max(1, item.quantity ?? 1),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return { plan: "team", quantity: 1 };
|
||||
}
|
||||
|
||||
candidates.sort((a, b) => {
|
||||
const aPlan = a.plan ?? "team";
|
||||
const bPlan = b.plan ?? "team";
|
||||
|
||||
const byPlanWeight = planWeight(bPlan) - planWeight(aPlan);
|
||||
if (byPlanWeight !== 0) return byPlanWeight;
|
||||
|
||||
const byStatusWeight =
|
||||
statusWeight(b.subscription.status) - statusWeight(a.subscription.status);
|
||||
if (byStatusWeight !== 0) return byStatusWeight;
|
||||
|
||||
const aPeriodEnd = a.subscription.current_period_end ?? a.subscription.created ?? 0;
|
||||
const bPeriodEnd = b.subscription.current_period_end ?? b.subscription.created ?? 0;
|
||||
return bPeriodEnd - aPeriodEnd;
|
||||
});
|
||||
|
||||
const winner = candidates[0];
|
||||
return {
|
||||
// Backward-compatible default for unknown paid subscriptions.
|
||||
plan: winner.plan ?? "team",
|
||||
quantity: winner.quantity,
|
||||
};
|
||||
};
|
||||
|
||||
export const getOrganizationBillingState = async (
|
||||
supabase: SupabaseClient,
|
||||
organizationId: number,
|
||||
nowInput: Date = new Date()
|
||||
): Promise<{ data: OrganizationBillingState | null; error: string | null }> => {
|
||||
const { profiles, error: profilesError } = await getOrganizationProfiles(
|
||||
supabase,
|
||||
organizationId
|
||||
);
|
||||
if (profilesError || !profiles) {
|
||||
return { data: null, error: profilesError ?? "Failed to load organization members" };
|
||||
}
|
||||
|
||||
const owner = getOrganizationOwner(profiles);
|
||||
if (!owner) {
|
||||
return { data: null, error: "Organization has no members" };
|
||||
}
|
||||
|
||||
const ownerCreatedAt = owner.created_at ? new Date(owner.created_at) : nowInput;
|
||||
const trialWindow = getTrialWindow({
|
||||
ownerCreatedAt,
|
||||
now: new Date(nowInput),
|
||||
trialDays: parseTrialDays(),
|
||||
rolloutAt: parseTrialRolloutDate(),
|
||||
});
|
||||
|
||||
const memberCount = getBillableMemberCount(profiles);
|
||||
const requiredPlan = resolveRequiredPlan(memberCount);
|
||||
|
||||
try {
|
||||
const activeSubscription = await resolveActiveSubscription(supabase, owner.id);
|
||||
|
||||
return {
|
||||
data: {
|
||||
owner_user_id: owner.id,
|
||||
member_count: memberCount,
|
||||
trial_starts_at: trialWindow.trialStartDate.toISOString(),
|
||||
trial_ends_at: trialWindow.trialEndDate.toISOString(),
|
||||
is_trial_expired: trialWindow.isTrialExpired,
|
||||
required_plan: requiredPlan,
|
||||
required_team_quantity: requiredPlan === "team" ? Math.max(1, memberCount) : 1,
|
||||
active_subscription_plan: activeSubscription.plan,
|
||||
active_subscription_quantity: activeSubscription.quantity,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
data: null,
|
||||
error: error instanceof Error ? error.message : "Failed to resolve billing state",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
@ -1,10 +1,162 @@
|
|||
import { Hono } from "hono";
|
||||
import { createFactory } from "hono/factory";
|
||||
import Stripe from "stripe";
|
||||
import type { AppConfig } from "../config.js";
|
||||
import { getOrganizationBillingState } from "../helpers/billing.js";
|
||||
import { MiddlewareManager } from "../middlewares/middleware.js";
|
||||
import type { AuthEnv, BaseEnv } from "../types/app.types.js";
|
||||
|
||||
const DEFAULT_INFINITE_SUBSCRIPTION_ALLOWLIST = new Set([
|
||||
"arbelleville@gmail.com",
|
||||
"baptiste.belleville74@gmail.com",
|
||||
"hugo@xtablo.com",
|
||||
]);
|
||||
|
||||
const BILLING_OWNER_ONLY_ERROR = "Only the organization billing owner can manage billing";
|
||||
|
||||
type CheckoutPlan = "solo" | "team" | "founder";
|
||||
|
||||
type StripePriceRow = {
|
||||
id: string;
|
||||
unit_amount: number | null;
|
||||
lookup_key: string | null;
|
||||
metadata: Record<string, string | null> | null;
|
||||
};
|
||||
|
||||
type CreateCheckoutSessionBody = {
|
||||
plan?: CheckoutPlan;
|
||||
priceId?: string;
|
||||
price_id?: string;
|
||||
successUrl?: string;
|
||||
success_url?: string;
|
||||
cancelUrl?: string;
|
||||
cancel_url?: string;
|
||||
};
|
||||
|
||||
const parseCsvList = (value: string | undefined): string[] =>
|
||||
(value ?? "")
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
|
||||
const getInfinitePriceAllowlist = (): Set<string> => {
|
||||
const configuredAllowlist = parseCsvList(process.env.STRIPE_INFINITE_ALLOWED_EMAILS).map(
|
||||
(email) => email.toLowerCase()
|
||||
);
|
||||
|
||||
return configuredAllowlist.length > 0
|
||||
? new Set(configuredAllowlist)
|
||||
: DEFAULT_INFINITE_SUBSCRIPTION_ALLOWLIST;
|
||||
};
|
||||
|
||||
const getInfinitePriceIds = (): Set<string> => {
|
||||
const configuredIds = [
|
||||
...parseCsvList(process.env.STRIPE_INFINITE_PRICE_IDS),
|
||||
...(process.env.STRIPE_INFINITE_PRICE_ID ? [process.env.STRIPE_INFINITE_PRICE_ID] : []),
|
||||
].map((id) => id.trim());
|
||||
|
||||
return new Set(configuredIds.filter((id) => id.length > 0));
|
||||
};
|
||||
|
||||
const isInfinitePrice = (price: StripePriceRow): boolean => {
|
||||
const configuredPriceIds = getInfinitePriceIds();
|
||||
if (configuredPriceIds.size > 0) {
|
||||
return configuredPriceIds.has(price.id);
|
||||
}
|
||||
|
||||
const lookupKey = price.lookup_key?.toLowerCase() ?? "";
|
||||
const metadataPlan = price.metadata?.plan?.toLowerCase() ?? "";
|
||||
const metadataTier = price.metadata?.tier?.toLowerCase() ?? "";
|
||||
|
||||
// Backward-compatible fallback for existing infinite setup.
|
||||
return (
|
||||
lookupKey === "infinite" ||
|
||||
lookupKey === "infinite_subscription" ||
|
||||
metadataPlan === "infinite" ||
|
||||
metadataTier === "infinite" ||
|
||||
price.unit_amount === 0
|
||||
);
|
||||
};
|
||||
|
||||
const getPlanPriceId = (plan: CheckoutPlan): string | null => {
|
||||
if (plan === "solo") {
|
||||
return process.env.STRIPE_SOLO_PRICE_ID ?? null;
|
||||
}
|
||||
|
||||
if (plan === "team") {
|
||||
return process.env.STRIPE_TEAM_MONTHLY_PRICE_ID ?? null;
|
||||
}
|
||||
|
||||
return process.env.STRIPE_FOUNDER_PRICE_ID ?? null;
|
||||
};
|
||||
|
||||
const findStripeCustomerByUserId = async (
|
||||
supabase: AuthEnv["Variables"]["supabase"],
|
||||
userId: string
|
||||
) => {
|
||||
const { data: customers, error } = await supabase
|
||||
.schema("stripe")
|
||||
.from("customers")
|
||||
.select("id, metadata")
|
||||
.limit(1000);
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
return (
|
||||
(
|
||||
customers as Array<{ id: string; metadata: Record<string, string | null> | null }> | null
|
||||
)?.find((customer) => customer.metadata?.user_id === userId) ?? null
|
||||
);
|
||||
};
|
||||
|
||||
const resolveBillingOwnerContext = async (
|
||||
supabase: AuthEnv["Variables"]["supabase"],
|
||||
userId: string
|
||||
) => {
|
||||
const { data: profile, error: profileError } = await supabase
|
||||
.from("profiles")
|
||||
.select("organization_id")
|
||||
.eq("id", userId)
|
||||
.single();
|
||||
|
||||
if (profileError || !profile?.organization_id) {
|
||||
return { error: "Failed to resolve your organization", data: null };
|
||||
}
|
||||
|
||||
const { data: billingState, error: billingError } = await getOrganizationBillingState(
|
||||
supabase,
|
||||
profile.organization_id
|
||||
);
|
||||
|
||||
if (billingError || !billingState) {
|
||||
return { error: "Failed to resolve organization billing state", data: null };
|
||||
}
|
||||
|
||||
return {
|
||||
error: null,
|
||||
data: {
|
||||
organizationId: profile.organization_id,
|
||||
billingState,
|
||||
isBillingOwner: billingState.owner_user_id === userId,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const getCheckoutPrice = async (
|
||||
supabase: AuthEnv["Variables"]["supabase"],
|
||||
priceId: string
|
||||
): Promise<StripePriceRow | null> => {
|
||||
const { data: price } = await supabase
|
||||
.schema("stripe")
|
||||
.from("prices")
|
||||
.select("id, unit_amount, lookup_key, metadata")
|
||||
.eq("id", priceId)
|
||||
.maybeSingle();
|
||||
|
||||
return (price as StripePriceRow | null) ?? null;
|
||||
};
|
||||
|
||||
const webhookFactory = createFactory<BaseEnv>();
|
||||
|
||||
/**
|
||||
|
|
@ -61,83 +213,107 @@ const createCheckoutSession = (
|
|||
const supabase = c.get("supabase");
|
||||
const stripe = c.get("stripe");
|
||||
|
||||
const body = await c.req.json();
|
||||
const { priceId, successUrl, cancelUrl } = body;
|
||||
const body = (await c.req.json()) as CreateCheckoutSessionBody;
|
||||
const plan = body.plan;
|
||||
const legacyPriceId = body.priceId ?? body.price_id;
|
||||
const successUrl = body.successUrl ?? body.success_url;
|
||||
const cancelUrl = body.cancelUrl ?? body.cancel_url;
|
||||
|
||||
if (!plan && !legacyPriceId) {
|
||||
return c.json({ error: "plan or priceId is required" }, 400);
|
||||
}
|
||||
|
||||
if (plan && !["solo", "team", "founder"].includes(plan)) {
|
||||
return c.json({ error: "Invalid plan" }, 400);
|
||||
}
|
||||
|
||||
const { data: ownerContext, error: ownerError } = await resolveBillingOwnerContext(
|
||||
supabase,
|
||||
user.id
|
||||
);
|
||||
|
||||
if (ownerError || !ownerContext) {
|
||||
return c.json({ error: ownerError ?? "Failed to resolve billing owner" }, 500);
|
||||
}
|
||||
|
||||
if (!ownerContext.isBillingOwner) {
|
||||
return c.json({ error: BILLING_OWNER_ONLY_ERROR }, 403);
|
||||
}
|
||||
|
||||
let priceId = legacyPriceId ?? null;
|
||||
let quantity = 1;
|
||||
|
||||
if (plan) {
|
||||
priceId = getPlanPriceId(plan);
|
||||
if (!priceId) {
|
||||
return c.json({ error: `Missing Stripe price configuration for plan: ${plan}` }, 500);
|
||||
}
|
||||
|
||||
if (plan === "team") {
|
||||
quantity = Math.max(1, ownerContext.billingState.member_count);
|
||||
}
|
||||
}
|
||||
|
||||
if (!priceId) {
|
||||
return c.json({ error: "priceId is required" }, 400);
|
||||
}
|
||||
|
||||
const { data: price } = await supabase
|
||||
.schema("stripe")
|
||||
.from("prices")
|
||||
.select("*")
|
||||
.eq("id", priceId)
|
||||
.maybeSingle();
|
||||
const price = await getCheckoutPrice(supabase, priceId);
|
||||
|
||||
if (!price) {
|
||||
return c.json({ error: "Price not found" }, 404);
|
||||
}
|
||||
|
||||
const allowedInfiniteUsers = [
|
||||
"arbelleville@gmail.com",
|
||||
"baptiste.belleville74@gmail.com",
|
||||
"hugo@xtablo.com",
|
||||
];
|
||||
|
||||
if (price.unit_amount === 0 && !allowedInfiniteUsers.includes(user.email!)) {
|
||||
return c.json({ error: "This price is not available" }, 400);
|
||||
const normalizedEmail = user.email?.toLowerCase() ?? "";
|
||||
const isRestrictedInfinitePrice = isInfinitePrice(price);
|
||||
const infinitePriceAllowlist = getInfinitePriceAllowlist();
|
||||
if (isRestrictedInfinitePrice && !infinitePriceAllowlist.has(normalizedEmail)) {
|
||||
return c.json({ error: "This price is not available" }, 403);
|
||||
}
|
||||
|
||||
try {
|
||||
// Get or create Stripe customer
|
||||
let customerId: string;
|
||||
const ownerUserId = ownerContext.billingState.owner_user_id;
|
||||
const ownerCustomer = await findStripeCustomerByUserId(supabase, ownerUserId);
|
||||
|
||||
// Check if customer already exists by querying stripe schema with metadata filter
|
||||
// Note: Using service role, so we filter manually by metadata
|
||||
const { data: customers } = await supabase
|
||||
.schema("stripe")
|
||||
.from("customers")
|
||||
.select("id, metadata")
|
||||
.limit(1000); // Get all customers to filter by metadata
|
||||
let customerId = ownerCustomer?.id ?? "";
|
||||
if (!customerId) {
|
||||
if (!user.email) {
|
||||
return c.json({ error: "Billing owner email is required" }, 400);
|
||||
}
|
||||
|
||||
const existingCustomer = customers?.find(
|
||||
(c: Stripe.Customer) => c.metadata?.user_id === user.id
|
||||
);
|
||||
|
||||
if (existingCustomer) {
|
||||
customerId = existingCustomer.id;
|
||||
} else {
|
||||
// Create new Stripe customer with user_id in metadata
|
||||
// stripe-sync-engine will automatically sync this to the database via webhook
|
||||
// Create new Stripe customer with user_id in metadata.
|
||||
// stripe-sync-engine syncs this customer to Supabase via webhook.
|
||||
const customer = await stripe.customers.create({
|
||||
email: user.email!,
|
||||
email: user.email,
|
||||
metadata: {
|
||||
user_id: user.id, // Stored in metadata for tracking
|
||||
user_id: ownerUserId,
|
||||
},
|
||||
});
|
||||
|
||||
customerId = customer.id;
|
||||
}
|
||||
|
||||
// Create Checkout Session
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
customer: customerId,
|
||||
line_items: [
|
||||
{
|
||||
price: priceId,
|
||||
quantity: 1,
|
||||
quantity,
|
||||
},
|
||||
],
|
||||
mode: "subscription",
|
||||
success_url: successUrl || `${config.XTABLO_URL}/settings?success=true`,
|
||||
cancel_url: cancelUrl || `${config.XTABLO_URL}/settings?canceled=true`,
|
||||
metadata: {
|
||||
user_id: user.id,
|
||||
user_id: ownerUserId,
|
||||
organization_id: String(ownerContext.organizationId),
|
||||
checkout_plan: plan ?? "legacy_price",
|
||||
},
|
||||
subscription_data: {
|
||||
metadata: {
|
||||
user_id: user.id,
|
||||
user_id: ownerUserId,
|
||||
organization_id: String(ownerContext.organizationId),
|
||||
checkout_plan: plan ?? "legacy_price",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -166,16 +342,26 @@ const createPortalSession = (
|
|||
const stripe = c.get("stripe");
|
||||
|
||||
const body = await c.req.json();
|
||||
const { returnUrl } = body;
|
||||
const returnUrl = body.returnUrl ?? body.return_url;
|
||||
|
||||
const { data: ownerContext, error: ownerError } = await resolveBillingOwnerContext(
|
||||
supabase,
|
||||
user.id
|
||||
);
|
||||
|
||||
if (ownerError || !ownerContext) {
|
||||
return c.json({ error: ownerError ?? "Failed to resolve billing owner" }, 500);
|
||||
}
|
||||
|
||||
if (!ownerContext.isBillingOwner) {
|
||||
return c.json({ error: BILLING_OWNER_ONLY_ERROR }, 403);
|
||||
}
|
||||
|
||||
try {
|
||||
// Get Stripe customer ID by filtering metadata
|
||||
const { data: customers } = await supabase
|
||||
.schema("stripe")
|
||||
.from("customers")
|
||||
.select("id, metadata");
|
||||
|
||||
const customer = customers?.find((c: Stripe.Customer) => c.metadata?.user_id === user.id);
|
||||
const customer = await findStripeCustomerByUserId(
|
||||
supabase,
|
||||
ownerContext.billingState.owner_user_id
|
||||
);
|
||||
|
||||
if (!customer) {
|
||||
return c.json({ error: "No Stripe customer found" }, 404);
|
||||
|
|
@ -210,14 +396,24 @@ const cancelSubscription = (middlewareManager: ReturnType<typeof MiddlewareManag
|
|||
const supabase = c.get("supabase");
|
||||
const stripe = c.get("stripe");
|
||||
|
||||
try {
|
||||
// Get user's Stripe customer first
|
||||
const { data: customers } = await supabase
|
||||
.schema("stripe")
|
||||
.from("customers")
|
||||
.select("id, metadata");
|
||||
const { data: ownerContext, error: ownerError } = await resolveBillingOwnerContext(
|
||||
supabase,
|
||||
user.id
|
||||
);
|
||||
|
||||
const customer = customers?.find((c: Stripe.Customer) => c.metadata?.user_id === user.id);
|
||||
if (ownerError || !ownerContext) {
|
||||
return c.json({ error: ownerError ?? "Failed to resolve billing owner" }, 500);
|
||||
}
|
||||
|
||||
if (!ownerContext.isBillingOwner) {
|
||||
return c.json({ error: BILLING_OWNER_ONLY_ERROR }, 403);
|
||||
}
|
||||
|
||||
try {
|
||||
const customer = await findStripeCustomerByUserId(
|
||||
supabase,
|
||||
ownerContext.billingState.owner_user_id
|
||||
);
|
||||
|
||||
if (!customer) {
|
||||
return c.json({ error: "Customer not found" }, 404);
|
||||
|
|
@ -265,14 +461,24 @@ const reactivateSubscription = (
|
|||
const supabase = c.get("supabase");
|
||||
const stripe = c.get("stripe");
|
||||
|
||||
try {
|
||||
// Get user's Stripe customer first
|
||||
const { data: customers } = await supabase
|
||||
.schema("stripe")
|
||||
.from("customers")
|
||||
.select("id, metadata");
|
||||
const { data: ownerContext, error: ownerError } = await resolveBillingOwnerContext(
|
||||
supabase,
|
||||
user.id
|
||||
);
|
||||
|
||||
const customer = customers?.find((c: Stripe.Customer) => c.metadata?.user_id === user.id);
|
||||
if (ownerError || !ownerContext) {
|
||||
return c.json({ error: ownerError ?? "Failed to resolve billing owner" }, 500);
|
||||
}
|
||||
|
||||
if (!ownerContext.isBillingOwner) {
|
||||
return c.json({ error: BILLING_OWNER_ONLY_ERROR }, 403);
|
||||
}
|
||||
|
||||
try {
|
||||
const customer = await findStripeCustomerByUserId(
|
||||
supabase,
|
||||
ownerContext.billingState.owner_user_id
|
||||
);
|
||||
|
||||
if (!customer) {
|
||||
return c.json({ error: "No subscription found to reactivate" }, 404);
|
||||
|
|
|
|||
|
|
@ -2,12 +2,8 @@ import { DeleteObjectsCommand, ListObjectsV2Command, PutObjectCommand } from "@a
|
|||
import type { Tables } from "@xtablo/shared-types";
|
||||
import { Hono } from "hono";
|
||||
import { createFactory } from "hono/factory";
|
||||
import {
|
||||
createInvitedUser,
|
||||
getOrganizationPlan,
|
||||
MAX_TABLO_LIMIT,
|
||||
MAX_TEAM_MEMBER_LIMIT,
|
||||
} from "../helpers/helpers.js";
|
||||
import { getOrganizationBillingState } from "../helpers/billing.js";
|
||||
import { createInvitedUser, getOrganizationPlan, MAX_TABLO_LIMIT } from "../helpers/helpers.js";
|
||||
import type { AuthEnv } from "../types/app.types.js";
|
||||
|
||||
const factory = createFactory<AuthEnv>();
|
||||
|
|
@ -317,6 +313,14 @@ const getOrganization = factory.createHandlers(async (c) => {
|
|||
return c.json({ error: "Failed to resolve organization plan" }, 500);
|
||||
}
|
||||
|
||||
const { data: billingState, error: billingError } = await getOrganizationBillingState(
|
||||
supabase,
|
||||
organizationId
|
||||
);
|
||||
if (billingError || !billingState) {
|
||||
return c.json({ error: "Failed to resolve organization billing state" }, 500);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
organization: {
|
||||
id: organization.id,
|
||||
|
|
@ -326,6 +330,14 @@ const getOrganization = factory.createHandlers(async (c) => {
|
|||
tablo_count: tabloCount || 0,
|
||||
},
|
||||
members: members || [],
|
||||
trial_starts_at: billingState.trial_starts_at,
|
||||
trial_ends_at: billingState.trial_ends_at,
|
||||
is_trial_expired: billingState.is_trial_expired,
|
||||
required_plan: billingState.required_plan,
|
||||
required_team_quantity: billingState.required_team_quantity,
|
||||
active_subscription_plan: billingState.active_subscription_plan,
|
||||
active_subscription_quantity: billingState.active_subscription_quantity,
|
||||
is_billing_owner: billingState.owner_user_id === user.id,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -398,10 +410,12 @@ const inviteToOrganization = factory.createHandlers(async (c) => {
|
|||
}
|
||||
|
||||
const organizationId = senderProfile.organization_id;
|
||||
const { plan, error: planError } = await getOrganizationPlan(supabase, organizationId);
|
||||
|
||||
if (planError) {
|
||||
return c.json({ error: "Failed to resolve organization plan" }, 500);
|
||||
const { data: billingState, error: billingError } = await getOrganizationBillingState(
|
||||
supabase,
|
||||
organizationId
|
||||
);
|
||||
if (billingError || !billingState) {
|
||||
return c.json({ error: "Failed to resolve organization billing state" }, 500);
|
||||
}
|
||||
|
||||
const { data: existingUser, error: existingUserError } = await supabase
|
||||
|
|
@ -422,31 +436,32 @@ const inviteToOrganization = factory.createHandlers(async (c) => {
|
|||
return c.json({ error: "This email already belongs to another organization" }, 409);
|
||||
}
|
||||
|
||||
const { count: membersCount, error: membersCountError } = await supabase
|
||||
.from("profiles")
|
||||
.select("id", { count: "exact", head: true })
|
||||
.eq("organization_id", organizationId);
|
||||
if (billingState.is_trial_expired && billingState.active_subscription_plan !== "annual") {
|
||||
const requiredSeatsForInvite = billingState.member_count + 1;
|
||||
|
||||
if (membersCountError) {
|
||||
return c.json({ error: "Failed to check organization size" }, 500);
|
||||
}
|
||||
if (billingState.active_subscription_plan === "team") {
|
||||
if (billingState.active_subscription_quantity < requiredSeatsForInvite) {
|
||||
return c.json(
|
||||
{
|
||||
error:
|
||||
"Your Teams subscription does not have enough seats for this invite. Please increase seats in billing and try again.",
|
||||
},
|
||||
403
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const noSubscriptionMessage =
|
||||
billingState.required_plan === "solo"
|
||||
? "Your trial has ended. Solo allows 1 member only. Upgrade to Teams to invite more members."
|
||||
: "Your trial has ended. An active Teams subscription is required to invite members. Please subscribe or increase seats in billing.";
|
||||
|
||||
if (plan === "solo" && (membersCount || 0) >= 1) {
|
||||
return c.json(
|
||||
{
|
||||
error: `Solo plan allows a single user only. Upgrade to Team to invite collaborators.`,
|
||||
},
|
||||
403
|
||||
);
|
||||
}
|
||||
|
||||
if (plan === "team" && (membersCount || 0) >= MAX_TEAM_MEMBER_LIMIT) {
|
||||
return c.json(
|
||||
{
|
||||
error: `Team plan allows up to ${MAX_TEAM_MEMBER_LIMIT} users. Upgrade to Annual to invite more.`,
|
||||
},
|
||||
403
|
||||
);
|
||||
return c.json(
|
||||
{
|
||||
error: noSubscriptionMessage,
|
||||
},
|
||||
403
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const invitedUser = await createInvitedUser(
|
||||
|
|
@ -533,9 +548,9 @@ const inviteToOrganization = factory.createHandlers(async (c) => {
|
|||
return c.json({
|
||||
message: "Invitation sent successfully",
|
||||
limits: {
|
||||
plan,
|
||||
plan: billingState.active_subscription_plan ?? billingState.required_plan,
|
||||
max_tablos_for_solo: MAX_TABLO_LIMIT,
|
||||
max_members_for_team: MAX_TEAM_MEMBER_LIMIT,
|
||||
required_team_quantity_for_next_invite: billingState.member_count + 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,4 +10,9 @@ VITE_STRIPE_PUBLISHABLE_KEY=pk_live_51Qc159AmcXPHW4mTHUTW6it2mdZ3KQTxZGXZ188DKpX
|
|||
VITE_STRIPE_STANDARD_MONTHLY_PRICE_ID=price_1SO0HAAmcXPHW4mTkFIh3CvF
|
||||
VITE_STRIPE_INFINITE_PRICE_ID=price_1SXHp8AmcXPHW4mTbus6j4Za
|
||||
|
||||
VITE_API_URL=https://xablo-api-636270553187.europe-west1.run.app
|
||||
VITE_API_URL=https://xablo-api-636270553187.europe-west1.run.app
|
||||
VITE_STRIPE_TEAM_MONTHLY_PRICE_ID=price_team_placeholder
|
||||
|
||||
VITE_STRIPE_FOUNDER_PRICE_ID=price_annual_placeholder
|
||||
|
||||
VITE_STRIPE_SOLO_PRICE_ID=price_solo_placeholder
|
||||
|
|
|
|||
|
|
@ -9,4 +9,10 @@ VITE_STREAM_CHAT_API_KEY="t5vvvddteapa"
|
|||
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_51SPKLPAto3YQ7YhIrM5ViAUXWuSwKJeHyOyOINVg9cnwxxOcbMlyhxQcDYWDSLNQJukafxbc7kqpkGI82lFezaiM00rgcALKB0
|
||||
VITE_STRIPE_STANDARD_MONTHLY_PRICE_ID=price_1SPr3qAto3YQ7YhIALNeFBva
|
||||
|
||||
VITE_API_URL=https://xablo-api-staging-636270553187.europe-west1.run.app
|
||||
VITE_API_URL=https://xablo-api-staging-636270553187.europe-west1.run.app
|
||||
|
||||
VITE_STRIPE_SOLO_PRICE_ID=price_1T8iT4Ato3YQ7YhIYCIIk0RA
|
||||
VITE_STRIPE_TEAM_MONTHLY_PRICE_ID=price_1T8hZfAto3YQ7YhIRK9YUSub
|
||||
VITE_STRIPE_FOUNDER_PRICE_ID=price_1T8hawAto3YQ7YhIrVyKHggH
|
||||
|
||||
VITE_STRIPE_INFINITE_PRICE_ID=price_infinite_placeholder
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { ThemeProvider } from "@xtablo/shared/contexts/ThemeContext";
|
|||
import { Toaster } from "@xtablo/ui/components/sonner";
|
||||
import { BrowserRouter as Router, useRoutes } from "react-router-dom";
|
||||
import { CookieBanner } from "./components/CookieBanner";
|
||||
import { PendingFounderCheckout } from "./components/PendingFounderCheckout";
|
||||
import { TrialUpsellModal } from "./components/TrialUpsellModal";
|
||||
import { UpgradePanel } from "./components/UpgradePanel";
|
||||
import { UpgradeBlockProvider } from "./contexts/UpgradeBlockContext";
|
||||
|
|
@ -29,6 +30,7 @@ const Routes = () => {
|
|||
return (
|
||||
<UserStoreProvider>
|
||||
<UpgradeBlockProvider>
|
||||
<PendingFounderCheckout />
|
||||
<UpgradePanel />
|
||||
<TrialUpsellModal />
|
||||
{appElement}
|
||||
|
|
|
|||
|
|
@ -47,7 +47,8 @@ import { useTranslation } from "react-i18next";
|
|||
import { Link as RouterLink, useLocation } from "react-router-dom";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useLogout } from "../hooks/auth";
|
||||
import { normalizeBillingPlan, useCreateCheckoutSession } from "../hooks/stripe";
|
||||
import { useOrganization } from "../hooks/organization";
|
||||
import { useCreateCheckoutSession } from "../hooks/stripe";
|
||||
import { useTablosList } from "../hooks/tablos";
|
||||
import { isProd, isStaging } from "../lib/env";
|
||||
import { useIsReadOnlyUser, useUser } from "../providers/UserStoreProvider";
|
||||
|
|
@ -378,16 +379,18 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
|
|||
const user = useUser();
|
||||
const { t } = useTranslation("navigation");
|
||||
const { mutate: createCheckout, isPending: checkoutPending } = useCreateCheckoutSession();
|
||||
const { data: organizationData } = useOrganization();
|
||||
|
||||
const TEAM_MONTHLY_PRICE_ID =
|
||||
import.meta.env.VITE_STRIPE_TEAM_MONTHLY_PRICE_ID ||
|
||||
import.meta.env.VITE_STRIPE_STANDARD_MONTHLY_PRICE_ID ||
|
||||
"";
|
||||
const hasCompliantTeamPlan =
|
||||
organizationData?.active_subscription_plan === "annual" ||
|
||||
(organizationData?.active_subscription_plan === "team" &&
|
||||
organizationData.active_subscription_quantity >= organizationData.required_team_quantity);
|
||||
|
||||
const currentPlan = normalizeBillingPlan(user.plan);
|
||||
|
||||
// Show upsell when organization is on Solo plan
|
||||
const shouldShowSoloUpsell = currentPlan === "solo" && !user.is_temporary;
|
||||
const shouldShowSoloUpsell =
|
||||
!user.is_temporary &&
|
||||
!!organizationData &&
|
||||
organizationData.required_plan === "team" &&
|
||||
!hasCompliantTeamPlan;
|
||||
|
||||
type List<T> = T[];
|
||||
|
||||
|
|
@ -530,10 +533,10 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
|
|||
<Sparkles className="w-4 h-4 text-purple-600 dark:text-purple-400 shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium text-purple-900 dark:text-purple-100">
|
||||
Plan Solo
|
||||
Plan Teams
|
||||
</p>
|
||||
<p className="text-xs mt-0.5 text-purple-700 dark:text-purple-300">
|
||||
Passez au plan Team pour inviter jusqu'à 3 utilisateurs.
|
||||
Ajoutez des sièges Teams pour inviter des membres.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -541,12 +544,12 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
|
|||
size="sm"
|
||||
onClick={() =>
|
||||
createCheckout({
|
||||
priceId: TEAM_MONTHLY_PRICE_ID,
|
||||
plan: "team",
|
||||
successUrl: `${window.location.origin}?upgraded=true`,
|
||||
cancelUrl: `${window.location.origin}?canceled=true`,
|
||||
})
|
||||
}
|
||||
disabled={checkoutPending || !TEAM_MONTHLY_PRICE_ID}
|
||||
disabled={checkoutPending}
|
||||
className="w-full h-7 text-xs gap-1 bg-linear-to-r from-purple-500 to-blue-500 hover:from-purple-600 hover:to-blue-600"
|
||||
>
|
||||
{checkoutPending ? (
|
||||
|
|
|
|||
49
apps/main/src/components/PendingFounderCheckout.tsx
Normal file
49
apps/main/src/components/PendingFounderCheckout.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
import { useOrganization } from "../hooks/organization";
|
||||
import { useCreateCheckoutSession } from "../hooks/stripe";
|
||||
import { PENDING_BILLING_CHECKOUT_PLAN_KEY } from "../lib/billing";
|
||||
import { useMaybeUser } from "../providers/UserStoreProvider";
|
||||
|
||||
export function PendingFounderCheckout() {
|
||||
const user = useMaybeUser();
|
||||
const { data: organizationData, isLoading } = useOrganization();
|
||||
const { mutate: createCheckout } = useCreateCheckoutSession();
|
||||
const hasTriggered = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasTriggered.current) return;
|
||||
if (!user || isLoading || !organizationData) return;
|
||||
|
||||
const pendingCheckout = localStorage.getItem(PENDING_BILLING_CHECKOUT_PLAN_KEY);
|
||||
if (pendingCheckout !== "founder") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (organizationData.active_subscription_plan === "annual") {
|
||||
localStorage.removeItem(PENDING_BILLING_CHECKOUT_PLAN_KEY);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!organizationData.is_billing_owner) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasTriggered.current = true;
|
||||
localStorage.removeItem(PENDING_BILLING_CHECKOUT_PLAN_KEY);
|
||||
createCheckout(
|
||||
{
|
||||
plan: "founder",
|
||||
successUrl: `${window.location.origin}/settings?success=true`,
|
||||
cancelUrl: `${window.location.origin}/settings?canceled=true`,
|
||||
},
|
||||
{
|
||||
onError: () => {
|
||||
localStorage.setItem(PENDING_BILLING_CHECKOUT_PLAN_KEY, "founder");
|
||||
hasTriggered.current = false;
|
||||
},
|
||||
}
|
||||
);
|
||||
}, [createCheckout, isLoading, organizationData, user]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import {
|
|||
CardTitle,
|
||||
} from "@xtablo/ui/components/card";
|
||||
import { AlertCircle, CheckCircle2, CreditCard, Loader2Icon, Sparkles } from "lucide-react";
|
||||
import { useOrganization } from "../hooks/organization";
|
||||
import {
|
||||
normalizeBillingPlan,
|
||||
useCancelSubscription,
|
||||
|
|
@ -18,18 +19,12 @@ import {
|
|||
} from "../hooks/stripe";
|
||||
import { useUser } from "../providers/UserStoreProvider";
|
||||
|
||||
const allowedInfiniteUsers = [
|
||||
"arbelleville@gmail.com",
|
||||
"baptiste.belleville74@gmail.com",
|
||||
"hugo@xtablo.com",
|
||||
];
|
||||
|
||||
/**
|
||||
* Subscription management card for Settings page
|
||||
* Shows current subscription status and allows users to upgrade/manage
|
||||
* Subscription management card for Settings page.
|
||||
*/
|
||||
export function SubscriptionCard() {
|
||||
const user = useUser();
|
||||
const { data: organizationData } = useOrganization();
|
||||
const { data: subscription, isLoading: subscriptionLoading } = useSubscription();
|
||||
const { mutate: createCheckout, isPending: checkoutPending } = useCreateCheckoutSession();
|
||||
const { mutate: openPortal, isPending: portalPending } = useCreatePortalSession();
|
||||
|
|
@ -37,33 +32,22 @@ export function SubscriptionCard() {
|
|||
const { mutate: reactivateSubscription, isPending: reactivatePending } =
|
||||
useReactivateSubscription();
|
||||
|
||||
const normalizedPlan = normalizeBillingPlan(user.plan);
|
||||
const isPaying = normalizedPlan === "team" || normalizedPlan === "annual";
|
||||
const isAnnual = normalizedPlan === "annual";
|
||||
const isSolo = normalizedPlan === "solo";
|
||||
const fallbackPlan = normalizeBillingPlan(user.plan);
|
||||
const activePlan = organizationData?.active_subscription_plan ?? fallbackPlan;
|
||||
const isBillingOwner = organizationData?.is_billing_owner ?? false;
|
||||
|
||||
// Replace with your actual price ID from Stripe Dashboard
|
||||
const isPaying = activePlan === "team" || activePlan === "annual";
|
||||
const isFounder = activePlan === "annual";
|
||||
|
||||
const infinitePriceId = import.meta.env.VITE_STRIPE_INFINITE_PRICE_ID || "";
|
||||
const teamPriceId =
|
||||
import.meta.env.VITE_STRIPE_TEAM_MONTHLY_PRICE_ID ||
|
||||
import.meta.env.VITE_STRIPE_STANDARD_MONTHLY_PRICE_ID ||
|
||||
"";
|
||||
const annualPriceId = import.meta.env.VITE_STRIPE_ANNUAL_PRICE_ID || "";
|
||||
const soloPriceId = import.meta.env.VITE_STRIPE_SOLO_PRICE_ID || "";
|
||||
|
||||
const priceId =
|
||||
allowedInfiniteUsers.includes(user.email!) && infinitePriceId
|
||||
? infinitePriceId
|
||||
: teamPriceId || annualPriceId || soloPriceId;
|
||||
const requiredPlan = organizationData?.required_plan ?? "solo";
|
||||
const requiredTeamQuantity = organizationData?.required_team_quantity ?? 1;
|
||||
|
||||
const getStatusBadge = () => {
|
||||
// Annual plan badge
|
||||
if (isAnnual) {
|
||||
if (isFounder) {
|
||||
return (
|
||||
<Badge className="gap-1.5 bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600">
|
||||
<Sparkles className="w-3 h-3" />
|
||||
Annual
|
||||
Founder
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
|
@ -72,7 +56,7 @@ export function SubscriptionCard() {
|
|||
return (
|
||||
<Badge variant="secondary" className="gap-1.5">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
Gratuit
|
||||
Sans abonnement
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
|
@ -119,11 +103,11 @@ export function SubscriptionCard() {
|
|||
{getStatusBadge()}
|
||||
</div>
|
||||
<CardDescription>
|
||||
{isAnnual
|
||||
? "Vous disposez du plan Annual avec limites illimitées"
|
||||
{isFounder
|
||||
? "Vous disposez du plan Founder (annuel) avec limites illimitées"
|
||||
: isPaying
|
||||
? "Gérez votre abonnement et votre facturation"
|
||||
: "Passez au plan Team ou Annual pour travailler en équipe"}
|
||||
: "Choisissez Solo, Teams ou Founder"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
|
|
@ -133,76 +117,96 @@ export function SubscriptionCard() {
|
|||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Annual Plan */}
|
||||
{isAnnual && (
|
||||
{isFounder && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-gradient-to-br from-purple-50 to-pink-50 dark:from-purple-950/20 dark:to-pink-950/20 border border-purple-200 dark:border-purple-800 rounded-lg p-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
||||
<p className="text-sm font-medium text-purple-900 dark:text-purple-100">
|
||||
Plan Annual
|
||||
Plan Founder (annuel)
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-purple-700 dark:text-purple-300">
|
||||
Utilisateurs et tablos illimités
|
||||
Utilisateurs et tablos illimités.
|
||||
</p>
|
||||
<div className="pt-2 border-t border-purple-200 dark:border-purple-800">
|
||||
<p className="text-xs text-purple-600 dark:text-purple-400">
|
||||
Votre organisation bénéficie des capacités maximales.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSolo && (
|
||||
<div className="space-y-4">
|
||||
{!isFounder && (
|
||||
<div className="space-y-3">
|
||||
<div className="bg-gradient-to-br from-purple-50 to-blue-50 dark:from-purple-950/20 dark:to-blue-950/20 border border-purple-200 dark:border-purple-800 rounded-lg p-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-purple-900 dark:text-purple-100">
|
||||
Plan Solo
|
||||
</p>
|
||||
<p className="text-xs text-purple-700 dark:text-purple-300">
|
||||
1 utilisateur et 10 tablos maximum pour votre organisation.
|
||||
<p className="text-sm font-medium text-purple-900 dark:text-purple-100">
|
||||
Plan recommandé: {requiredPlan === "team" ? "Teams" : "Solo"}
|
||||
</p>
|
||||
<p className="text-xs text-purple-700 dark:text-purple-300 mt-1">
|
||||
{requiredPlan === "team"
|
||||
? `Votre organisation nécessite ${requiredTeamQuantity} siège${requiredTeamQuantity > 1 ? "s" : ""} Teams.`
|
||||
: "Votre organisation peut continuer avec Solo (1 utilisateur, 10 tablos actifs)."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isBillingOwner ? (
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
<Button
|
||||
onClick={() =>
|
||||
createCheckout({
|
||||
plan: requiredPlan === "team" ? "team" : "solo",
|
||||
successUrl: `${window.location.origin}/settings?success=true`,
|
||||
cancelUrl: `${window.location.origin}/settings?canceled=true`,
|
||||
})
|
||||
}
|
||||
disabled={checkoutPending}
|
||||
className="w-full gap-2 bg-gradient-to-r from-purple-500 to-blue-500 hover:from-purple-600 hover:to-blue-600"
|
||||
>
|
||||
{checkoutPending ? (
|
||||
<>
|
||||
<Loader2Icon className="w-4 h-4 animate-spin" />
|
||||
Chargement...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
Passer au plan {requiredPlan === "team" ? "Teams" : "Solo"}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
createCheckout({
|
||||
plan: "founder",
|
||||
successUrl: `${window.location.origin}/settings?success=true`,
|
||||
cancelUrl: `${window.location.origin}/settings?canceled=true`,
|
||||
})
|
||||
}
|
||||
disabled={checkoutPending}
|
||||
>
|
||||
Devenir Founder (99€/an)
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 p-3 bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-800 rounded-lg">
|
||||
<AlertCircle className="w-4 h-4 text-amber-600 dark:text-amber-400 shrink-0" />
|
||||
<p className="text-xs text-amber-700 dark:text-amber-300">
|
||||
Seul le propriétaire de facturation de l'organisation peut modifier
|
||||
l'abonnement.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() =>
|
||||
createCheckout({
|
||||
priceId: priceId,
|
||||
successUrl: `${window.location.origin}/settings?success=true`,
|
||||
cancelUrl: `${window.location.origin}/settings?canceled=true`,
|
||||
})
|
||||
}
|
||||
disabled={checkoutPending || !priceId}
|
||||
className="w-full gap-2 bg-gradient-to-r from-purple-500 to-blue-500 hover:from-purple-600 hover:to-blue-600"
|
||||
>
|
||||
{checkoutPending ? (
|
||||
<>
|
||||
<Loader2Icon className="w-4 h-4 animate-spin" />
|
||||
Chargement...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
Passer au plan Team
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Standard Tier - Active */}
|
||||
{isPaying && subscription && !subscription.cancel_at_period_end && (
|
||||
{isPaying && subscription && !subscription.cancel_at_period_end && isBillingOwner && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-gradient-to-br from-green-50 to-emerald-50 dark:from-green-950/20 dark:to-emerald-950/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-green-900 dark:text-green-100">
|
||||
Plan {isAnnual ? "Annual" : "Team"}
|
||||
Plan {isFounder ? "Founder" : "Teams"}
|
||||
</p>
|
||||
<p className="text-xs text-green-700 dark:text-green-300 mt-1">
|
||||
Collaboration d'organisation activée
|
||||
|
|
@ -254,8 +258,7 @@ export function SubscriptionCard() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Standard Tier - Canceling */}
|
||||
{isPaying && subscription?.cancel_at_period_end && (
|
||||
{isPaying && subscription?.cancel_at_period_end && isBillingOwner && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-gradient-to-br from-orange-50 to-red-50 dark:from-orange-950/20 dark:to-red-950/20 border border-orange-200 dark:border-orange-800 rounded-lg p-4">
|
||||
<div className="space-y-3">
|
||||
|
|
@ -266,7 +269,7 @@ export function SubscriptionCard() {
|
|||
Abonnement en cours d'annulation
|
||||
</p>
|
||||
<p className="text-xs text-orange-700 dark:text-orange-300 mt-1">
|
||||
Votre abonnement {isAnnual ? "Annual" : "Team"} sera annulé le{" "}
|
||||
Votre abonnement {isFounder ? "Founder" : "Teams"} sera annulé le{" "}
|
||||
{subscription.current_period_end &&
|
||||
new Date(subscription.current_period_end * 1000).toLocaleDateString(
|
||||
"fr-FR",
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
} from "@xtablo/ui/components/dialog";
|
||||
import { AlertCircle, CheckCircle2, CreditCard, Loader2Icon, Sparkles } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useOrganization } from "../hooks/organization";
|
||||
import { useCreateCheckoutSession, useTrialExpiration } from "../hooks/stripe";
|
||||
import { useMaybeUser } from "../providers/UserStoreProvider";
|
||||
|
||||
|
|
@ -16,19 +17,23 @@ const MODAL_INTERVAL_MS = 15 * 60 * 1000; // 15 minutes
|
|||
const LAST_SHOWN_KEY = "trial-upsell-modal-last-shown";
|
||||
|
||||
/**
|
||||
* Auto-opening modal that shows every 15 minutes to remind users about trial expiration
|
||||
* Only shows if daysRemaining is not null (user is in trial period)
|
||||
* Auto-opening modal that shows every 15 minutes to remind users about trial expiration.
|
||||
*/
|
||||
export function TrialUpsellModal() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { daysRemaining } = useTrialExpiration();
|
||||
const { data: organizationData } = useOrganization();
|
||||
const user = useMaybeUser();
|
||||
const { mutate: createCheckout, isPending: checkoutPending } = useCreateCheckoutSession();
|
||||
|
||||
const STANDARD_MONTHLY_PRICE_ID = import.meta.env.VITE_STRIPE_STANDARD_MONTHLY_PRICE_ID || "";
|
||||
const shouldShowModal =
|
||||
!!organizationData &&
|
||||
!organizationData.is_trial_expired &&
|
||||
!organizationData.active_subscription_plan &&
|
||||
!user?.is_temporary;
|
||||
|
||||
// Only show modal for users in trial period (not beta, not paid, and daysRemaining exists)
|
||||
const shouldShowModal = daysRemaining !== null && user?.plan === "none" && !user?.is_temporary;
|
||||
const requiredPlan = organizationData?.required_plan ?? "solo";
|
||||
const checkoutPlan = requiredPlan === "team" ? "team" : "solo";
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldShowModal) return;
|
||||
|
|
@ -37,22 +42,19 @@ export function TrialUpsellModal() {
|
|||
const lastShown = localStorage.getItem(LAST_SHOWN_KEY);
|
||||
const now = Date.now();
|
||||
|
||||
if (!lastShown || now - parseInt(lastShown) >= MODAL_INTERVAL_MS) {
|
||||
if (!lastShown || now - Number.parseInt(lastShown, 10) >= MODAL_INTERVAL_MS) {
|
||||
setIsOpen(true);
|
||||
localStorage.setItem(LAST_SHOWN_KEY, now.toString());
|
||||
}
|
||||
};
|
||||
|
||||
// Check immediately on mount
|
||||
checkAndShowModal();
|
||||
|
||||
// Set up interval to check every 15 minutes
|
||||
const interval = setInterval(checkAndShowModal, MODAL_INTERVAL_MS);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [shouldShowModal]);
|
||||
|
||||
if (!shouldShowModal) {
|
||||
if (!shouldShowModal || daysRemaining === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -75,22 +77,35 @@ export function TrialUpsellModal() {
|
|||
: `${daysRemaining} jour${daysRemaining > 1 ? "s" : ""} restants dans votre période d'essai`}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-center">
|
||||
{isUrgent
|
||||
? "Ne perdez pas l'accès à vos projets ! Passez au plan Starter pour continuer."
|
||||
: "Profitez de toutes les fonctionnalités sans limite en passant au plan Starter."}
|
||||
{requiredPlan === "team"
|
||||
? "Après l'essai, votre organisation passera sur Teams (facturation par siège)."
|
||||
: "Après l'essai, vous pourrez continuer avec Solo."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 py-4">
|
||||
<p className="text-sm font-medium">Avec Starter, vous bénéficiez de :</p>
|
||||
<p className="text-sm font-medium">
|
||||
{requiredPlan === "team"
|
||||
? "Avec Teams, vous bénéficiez de :"
|
||||
: "Avec Solo, vous bénéficiez de :"}
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{[
|
||||
"Tablos et projets illimités",
|
||||
"Planification avancée",
|
||||
"Chat en temps réel",
|
||||
"Stockage de fichiers",
|
||||
"Support prioritaire",
|
||||
].map((feature) => (
|
||||
{(requiredPlan === "team"
|
||||
? [
|
||||
"Utilisateurs multiples (par siège)",
|
||||
"Tablos illimités",
|
||||
"Planification avancée",
|
||||
"Chat en temps réel",
|
||||
"Support prioritaire",
|
||||
]
|
||||
: [
|
||||
"1 utilisateur",
|
||||
"10 tablos actifs",
|
||||
"Planification avancée",
|
||||
"Chat en temps réel",
|
||||
"Support prioritaire",
|
||||
]
|
||||
).map((feature) => (
|
||||
<li key={feature} className="flex items-center gap-2 text-sm">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-600 dark:text-green-400 shrink-0" />
|
||||
<span>{feature}</span>
|
||||
|
|
@ -106,13 +121,13 @@ export function TrialUpsellModal() {
|
|||
<Button
|
||||
onClick={() => {
|
||||
createCheckout({
|
||||
priceId: STANDARD_MONTHLY_PRICE_ID,
|
||||
plan: checkoutPlan,
|
||||
successUrl: `${window.location.origin}?upgraded=true`,
|
||||
cancelUrl: `${window.location.origin}?canceled=true`,
|
||||
});
|
||||
setIsOpen(false);
|
||||
}}
|
||||
disabled={checkoutPending || !STANDARD_MONTHLY_PRICE_ID}
|
||||
disabled={checkoutPending}
|
||||
className="sm:flex-1 gap-2 bg-gradient-to-r from-purple-500 to-blue-500 hover:from-purple-600 hover:to-blue-600"
|
||||
>
|
||||
{checkoutPending ? (
|
||||
|
|
@ -123,7 +138,7 @@ export function TrialUpsellModal() {
|
|||
) : (
|
||||
<>
|
||||
<CreditCard className="w-4 h-4" />
|
||||
Passer au plan Starter
|
||||
Passer au plan {requiredPlan === "team" ? "Teams" : "Solo"}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { Text } from "@xtablo/ui/components/typography";
|
|||
import { AlertCircle, CheckCircle2, CreditCard, Loader2Icon, Sparkles } from "lucide-react";
|
||||
import { useUpgradeBlock } from "../contexts/UpgradeBlockContext";
|
||||
import { useLogout } from "../hooks/auth";
|
||||
import { useOrganization } from "../hooks/organization";
|
||||
import { useCreateCheckoutSession } from "../hooks/stripe";
|
||||
|
||||
/**
|
||||
|
|
@ -20,13 +21,22 @@ export function UpgradePanel() {
|
|||
const { isBlocked } = useUpgradeBlock();
|
||||
const { mutate: createCheckout, isPending: checkoutPending } = useCreateCheckoutSession();
|
||||
const { mutate: signOut } = useLogout();
|
||||
|
||||
const STANDARD_MONTHLY_PRICE_ID = import.meta.env.VITE_STRIPE_STANDARD_MONTHLY_PRICE_ID || "";
|
||||
const { data: organizationData } = useOrganization();
|
||||
|
||||
if (!isBlocked) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const requiredPlan = organizationData?.required_plan ?? "solo";
|
||||
const isBillingOwner = organizationData?.is_billing_owner ?? false;
|
||||
const requiredTeamQuantity = organizationData?.required_team_quantity ?? 1;
|
||||
const checkoutPlan = requiredPlan === "team" ? "team" : "solo";
|
||||
|
||||
const checkoutLabel =
|
||||
checkoutPlan === "team"
|
||||
? `Passer au plan Teams (${requiredTeamQuantity} siège${requiredTeamQuantity > 1 ? "s" : ""})`
|
||||
: "Passer au plan Solo";
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/95 backdrop-blur-sm">
|
||||
<div className="w-full max-w-lg mx-4">
|
||||
|
|
@ -37,22 +47,34 @@ export function UpgradePanel() {
|
|||
</div>
|
||||
<CardTitle className="text-2xl">Votre période d'essai est terminée</CardTitle>
|
||||
<CardDescription className="text-base">
|
||||
Pour continuer à utiliser XTablo, passez au plan Starter et débloquez toutes les
|
||||
fonctionnalités
|
||||
Pour continuer à utiliser XTablo, activez votre abonnement{" "}
|
||||
{requiredPlan === "team" ? "Teams" : "Solo"}.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Features list */}
|
||||
<div className="space-y-3">
|
||||
<Text className="text-sm font-medium">Ce que vous obtenez avec Starter :</Text>
|
||||
<Text className="text-sm font-medium">
|
||||
{requiredPlan === "team"
|
||||
? "Ce que vous obtenez avec Teams :"
|
||||
: "Ce que vous obtenez avec Solo :"}
|
||||
</Text>
|
||||
<ul className="space-y-2">
|
||||
{[
|
||||
"Tablos illimités",
|
||||
"Planification avancée",
|
||||
"Chat en temps réel",
|
||||
"Stockage de fichiers",
|
||||
"Support prioritaire",
|
||||
].map((feature) => (
|
||||
{(requiredPlan === "team"
|
||||
? [
|
||||
"Utilisateurs multiples (facturation par siège)",
|
||||
"Tablos illimités",
|
||||
"Chat en temps réel",
|
||||
"Stockage de fichiers",
|
||||
"Support prioritaire",
|
||||
]
|
||||
: [
|
||||
"1 utilisateur",
|
||||
"10 tablos actifs",
|
||||
"Chat en temps réel",
|
||||
"Stockage de fichiers",
|
||||
"Support prioritaire",
|
||||
]
|
||||
).map((feature) => (
|
||||
<li key={feature} className="flex items-center gap-2 text-sm">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-600 dark:text-green-400 shrink-0" />
|
||||
<span>{feature}</span>
|
||||
|
|
@ -61,17 +83,16 @@ export function UpgradePanel() {
|
|||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Upgrade button */}
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
onClick={() =>
|
||||
createCheckout({
|
||||
priceId: STANDARD_MONTHLY_PRICE_ID,
|
||||
plan: checkoutPlan,
|
||||
successUrl: `${window.location.origin}?upgraded=true`,
|
||||
cancelUrl: `${window.location.origin}?canceled=true`,
|
||||
})
|
||||
}
|
||||
disabled={checkoutPending || !STANDARD_MONTHLY_PRICE_ID}
|
||||
disabled={checkoutPending || !isBillingOwner}
|
||||
className="w-full gap-2 bg-gradient-to-r from-purple-500 to-blue-500 hover:from-purple-600 hover:to-blue-600 h-12 text-base"
|
||||
>
|
||||
{checkoutPending ? (
|
||||
|
|
@ -82,22 +103,37 @@ export function UpgradePanel() {
|
|||
) : (
|
||||
<>
|
||||
<CreditCard className="w-5 h-5" />
|
||||
Passer au plan Starter
|
||||
{checkoutLabel}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{!STANDARD_MONTHLY_PRICE_ID && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
createCheckout({
|
||||
plan: "founder",
|
||||
successUrl: `${window.location.origin}?upgraded=true`,
|
||||
cancelUrl: `${window.location.origin}?canceled=true`,
|
||||
})
|
||||
}
|
||||
disabled={checkoutPending || !isBillingOwner}
|
||||
className="w-full"
|
||||
>
|
||||
Devenir Founder (99€/an)
|
||||
</Button>
|
||||
|
||||
{!isBillingOwner && (
|
||||
<div className="flex items-center gap-2 p-3 bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<AlertCircle className="w-4 h-4 text-red-600 dark:text-red-400 shrink-0" />
|
||||
<Text className="text-xs text-red-600 dark:text-red-400">
|
||||
Configuration Stripe requise. Veuillez contacter le support.
|
||||
Seul le propriétaire de facturation de l'organisation peut modifier
|
||||
l'abonnement.
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Help text */}
|
||||
<div className="text-center pt-2 border-t">
|
||||
<Text className="text-xs text-muted-foreground">
|
||||
Des questions ?{" "}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,11 @@ import { useState } from "react";
|
|||
import { useNavigate } from "react-router-dom";
|
||||
import { match } from "ts-pattern";
|
||||
import { api } from "../lib/api";
|
||||
import {
|
||||
PENDING_BILLING_CHECKOUT_PLAN_KEY,
|
||||
SIGNUP_BILLING_INTENT_KEY,
|
||||
SignupBillingIntent,
|
||||
} from "../lib/billing";
|
||||
import { supabase } from "../lib/supabase";
|
||||
|
||||
export type User = SupabaseUser & {
|
||||
|
|
@ -26,6 +31,7 @@ interface SignUpData {
|
|||
first_name: string;
|
||||
last_name: string;
|
||||
business_name: string;
|
||||
billing_intent?: SignupBillingIntent;
|
||||
}
|
||||
|
||||
interface LoginData {
|
||||
|
|
@ -57,6 +63,7 @@ export function useSignUp({ redirectUrl }: { redirectUrl: string | null }) {
|
|||
first_name: data.first_name,
|
||||
last_name: data.last_name,
|
||||
business_name: data.business_name,
|
||||
billing_intent: data.billing_intent ?? "trial",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -68,18 +75,71 @@ export function useSignUp({ redirectUrl }: { redirectUrl: string | null }) {
|
|||
}
|
||||
return response;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
onSuccess: async (data, variables) => {
|
||||
const wantsFounderCheckout = variables.billing_intent === "founder";
|
||||
|
||||
if (wantsFounderCheckout) {
|
||||
localStorage.setItem(SIGNUP_BILLING_INTENT_KEY, "founder");
|
||||
localStorage.setItem(PENDING_BILLING_CHECKOUT_PLAN_KEY, "founder");
|
||||
} else {
|
||||
localStorage.removeItem(SIGNUP_BILLING_INTENT_KEY);
|
||||
localStorage.removeItem(PENDING_BILLING_CHECKOUT_PLAN_KEY);
|
||||
}
|
||||
|
||||
// If there's no session, it means email confirmation is required
|
||||
if (!data.user?.email_confirmed_at) {
|
||||
// Store the email for the confirmation page
|
||||
localStorage.setItem("pendingConfirmationEmail", data.user?.email || "");
|
||||
navigate("/confirm-email");
|
||||
} else if (redirectUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (wantsFounderCheckout && data.session?.access_token) {
|
||||
try {
|
||||
const checkoutResponse = await api.post(
|
||||
"/api/v1/stripe/create-checkout-session",
|
||||
{
|
||||
plan: "founder",
|
||||
successUrl: `${window.location.origin}/settings?success=true`,
|
||||
cancelUrl: `${window.location.origin}/settings?canceled=true`,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${data.session.access_token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (checkoutResponse.data?.url) {
|
||||
localStorage.removeItem(SIGNUP_BILLING_INTENT_KEY);
|
||||
localStorage.removeItem(PENDING_BILLING_CHECKOUT_PLAN_KEY);
|
||||
window.location.href = checkoutResponse.data.url;
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Founder checkout bootstrap failed after signup:", error);
|
||||
toast.add({
|
||||
title: "Paiement Founder",
|
||||
description:
|
||||
"Votre compte est créé, mais la redirection vers le paiement a échoué. Vous pourrez finaliser le paiement dans l'application.",
|
||||
type: "warning",
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.removeItem(SIGNUP_BILLING_INTENT_KEY);
|
||||
if (!wantsFounderCheckout) {
|
||||
localStorage.removeItem(PENDING_BILLING_CHECKOUT_PLAN_KEY);
|
||||
}
|
||||
|
||||
if (redirectUrl) {
|
||||
localStorage.removeItem("redirectUrl");
|
||||
navigate(decodeURIComponent(redirectUrl));
|
||||
} else {
|
||||
navigate("/");
|
||||
return;
|
||||
}
|
||||
|
||||
navigate("/");
|
||||
},
|
||||
onError: (error) => {
|
||||
const errMap: Record<string, string> = {};
|
||||
|
|
|
|||
|
|
@ -25,6 +25,14 @@ export interface OrganizationMember {
|
|||
export interface OrganizationResponse {
|
||||
organization: OrganizationSummary;
|
||||
members: OrganizationMember[];
|
||||
trial_starts_at: string;
|
||||
trial_ends_at: string;
|
||||
is_trial_expired: boolean;
|
||||
required_plan: "solo" | "team";
|
||||
required_team_quantity: number;
|
||||
active_subscription_plan: "solo" | "team" | "annual" | null;
|
||||
active_subscription_quantity: number;
|
||||
is_billing_owner: boolean;
|
||||
}
|
||||
|
||||
export const useOrganization = () => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { loadStripe } from "@stripe/stripe-js";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
queryClient,
|
||||
|
|
@ -9,13 +8,12 @@ import {
|
|||
} from "@xtablo/shared";
|
||||
import { useMemo } from "react";
|
||||
import { supabase } from "../lib/supabase";
|
||||
import { useMaybeUser, useUser } from "../providers/UserStoreProvider";
|
||||
import { useUser } from "../providers/UserStoreProvider";
|
||||
import { useAuthedApi } from "./auth";
|
||||
|
||||
// Initialize Stripe
|
||||
const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || "");
|
||||
import { useOrganization } from "./organization";
|
||||
|
||||
export type BillingPlan = "solo" | "team" | "annual";
|
||||
export type CheckoutPlan = "solo" | "team" | "founder";
|
||||
|
||||
export const normalizeBillingPlan = (plan: string | null | undefined): BillingPlan => {
|
||||
if (!plan) return "solo";
|
||||
|
|
@ -25,6 +23,38 @@ export const normalizeBillingPlan = (plan: string | null | undefined): BillingPl
|
|||
return "solo";
|
||||
};
|
||||
|
||||
const hasCompliantPaidPlan = (input: {
|
||||
required_plan: "solo" | "team";
|
||||
required_team_quantity: number;
|
||||
active_subscription_plan: "solo" | "team" | "annual" | null;
|
||||
active_subscription_quantity: number;
|
||||
}) => {
|
||||
const {
|
||||
required_plan,
|
||||
required_team_quantity,
|
||||
active_subscription_plan,
|
||||
active_subscription_quantity,
|
||||
} = input;
|
||||
|
||||
if (!active_subscription_plan) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (active_subscription_plan === "annual") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (required_plan === "solo") {
|
||||
return active_subscription_plan === "solo" || active_subscription_plan === "team";
|
||||
}
|
||||
|
||||
if (active_subscription_plan !== "team") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return active_subscription_quantity >= required_team_quantity;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get user's subscription status from Supabase
|
||||
* Uses RPC function to access stripe data without modifying stripe schema
|
||||
|
|
@ -73,29 +103,52 @@ export function useIsPayingUser() {
|
|||
}
|
||||
|
||||
export const useIsPastTrial = () => {
|
||||
const user = useMaybeUser();
|
||||
const { data: organizationData, isLoading } = useOrganization();
|
||||
|
||||
// Trial model is deprecated in favor of explicit billing plans.
|
||||
// Keep this hook returning false to avoid blocking existing users unexpectedly.
|
||||
const isPastTrial = useMemo(() => {
|
||||
void user;
|
||||
return false;
|
||||
}, [user]);
|
||||
if (!organizationData) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return { isPastTrial, isLoading: !user };
|
||||
if (!organizationData.is_trial_expired) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !hasCompliantPaidPlan({
|
||||
required_plan: organizationData.required_plan,
|
||||
required_team_quantity: organizationData.required_team_quantity,
|
||||
active_subscription_plan: organizationData.active_subscription_plan,
|
||||
active_subscription_quantity: organizationData.active_subscription_quantity,
|
||||
});
|
||||
}, [organizationData]);
|
||||
|
||||
return { isPastTrial, isLoading };
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get when the user's trial expires
|
||||
* Returns the trial expiration date (creation date + 7 days)
|
||||
* Hook to get when the organization's trial expires
|
||||
* Returns remaining trial days when trial is active, otherwise null.
|
||||
*/
|
||||
export const useTrialExpiration = () => {
|
||||
const user = useMaybeUser();
|
||||
const { data: organizationData, isLoading } = useOrganization();
|
||||
|
||||
// Trial model is deprecated in favor of explicit billing plans.
|
||||
const daysRemaining = useMemo(() => null as number | null, [user]);
|
||||
const daysRemaining = useMemo(() => {
|
||||
if (!organizationData || organizationData.is_trial_expired) {
|
||||
return null as number | null;
|
||||
}
|
||||
|
||||
return { daysRemaining, isLoading: !user };
|
||||
const trialEnd = new Date(organizationData.trial_ends_at).getTime();
|
||||
const now = Date.now();
|
||||
|
||||
const diffMs = trialEnd - now;
|
||||
if (diffMs <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.ceil(diffMs / (1000 * 60 * 60 * 24));
|
||||
}, [organizationData]);
|
||||
|
||||
return { daysRemaining, isLoading };
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -149,15 +202,22 @@ export function useCreateCheckoutSession() {
|
|||
|
||||
const { mutate, isPending } = useMutation({
|
||||
mutationFn: async ({
|
||||
plan,
|
||||
priceId,
|
||||
successUrl,
|
||||
cancelUrl,
|
||||
}: {
|
||||
priceId: string;
|
||||
plan?: CheckoutPlan;
|
||||
priceId?: string;
|
||||
successUrl?: string;
|
||||
cancelUrl?: string;
|
||||
}) => {
|
||||
if (!plan && !priceId) {
|
||||
throw new Error("A checkout plan or priceId is required");
|
||||
}
|
||||
|
||||
const response = await api.post("/api/v1/stripe/create-checkout-session", {
|
||||
plan,
|
||||
priceId,
|
||||
successUrl,
|
||||
cancelUrl,
|
||||
|
|
@ -167,12 +227,6 @@ export function useCreateCheckoutSession() {
|
|||
throw new Error("No checkout URL returned");
|
||||
}
|
||||
|
||||
// Redirect to Stripe Checkout
|
||||
const stripe = await stripePromise;
|
||||
if (!stripe) {
|
||||
throw new Error("Stripe failed to load");
|
||||
}
|
||||
|
||||
// Use the URL directly for redirect
|
||||
window.location.href = response.data.url;
|
||||
|
||||
|
|
@ -246,6 +300,7 @@ export function useCancelSubscription() {
|
|||
queryClient.invalidateQueries({ queryKey: ["subscription"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["is-paying"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["user"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["organization"] });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.add({
|
||||
|
|
@ -281,6 +336,7 @@ export function useReactivateSubscription() {
|
|||
queryClient.invalidateQueries({ queryKey: ["subscription"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["is-paying"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["user"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["organization"] });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.add({
|
||||
|
|
|
|||
4
apps/main/src/lib/billing.ts
Normal file
4
apps/main/src/lib/billing.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export type SignupBillingIntent = "trial" | "founder";
|
||||
|
||||
export const SIGNUP_BILLING_INTENT_KEY = "signupBillingIntent";
|
||||
export const PENDING_BILLING_CHECKOUT_PLAN_KEY = "pendingBillingCheckoutPlan";
|
||||
|
|
@ -66,6 +66,10 @@ export default function SettingsPage() {
|
|||
const [organizationName, setOrganizationName] = useState("");
|
||||
const [inviteEmail, setInviteEmail] = useState("");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const displayedOrganizationPlan =
|
||||
organizationData?.organization?.plan === "annual"
|
||||
? "founder"
|
||||
: organizationData?.organization?.plan;
|
||||
|
||||
useEffect(() => {
|
||||
if (organizationData?.organization?.name) {
|
||||
|
|
@ -329,7 +333,7 @@ export default function SettingsPage() {
|
|||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings:organization.currentPlan", {
|
||||
plan: organizationData?.organization?.plan || "solo",
|
||||
plan: displayedOrganizationPlan || "solo",
|
||||
})}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -5,17 +5,12 @@ import { Button } from "@xtablo/ui/components/button";
|
|||
import { FieldError } from "@xtablo/ui/components/field";
|
||||
import { Input } from "@xtablo/ui/components/input";
|
||||
import { Label } from "@xtablo/ui/components/label";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
MonitorIcon,
|
||||
MoonIcon,
|
||||
SparklesIcon,
|
||||
SunIcon,
|
||||
} from "lucide-react";
|
||||
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 { useSignUp } from "../hooks/auth";
|
||||
import type { SignupBillingIntent } from "../lib/billing";
|
||||
|
||||
export function SignUpV2Page() {
|
||||
const { t } = useTranslation(["auth", "common"]);
|
||||
|
|
@ -35,6 +30,7 @@ export function SignUpV2Page() {
|
|||
business_name: "",
|
||||
});
|
||||
const [termsAccepted, setTermsAccepted] = useState(false);
|
||||
const [billingIntent, setBillingIntent] = useState<SignupBillingIntent>("trial");
|
||||
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
|
|
@ -101,6 +97,7 @@ export function SignUpV2Page() {
|
|||
last_name: formData.last_name,
|
||||
confirm_password: formData.confirmPassword,
|
||||
business_name: formData.business_name,
|
||||
billing_intent: billingIntent,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -132,27 +129,14 @@ export function SignUpV2Page() {
|
|||
</div>
|
||||
|
||||
<div className="mb-5">
|
||||
<img
|
||||
src="/logo_dark.png"
|
||||
alt="Xtablo"
|
||||
className="h-10 w-auto block dark:hidden"
|
||||
/>
|
||||
<img
|
||||
src="/logo_white.png"
|
||||
alt="Xtablo"
|
||||
className="h-10 w-auto hidden dark:block"
|
||||
/>
|
||||
<img src="/logo_dark.png" alt="Xtablo" className="h-10 w-auto block dark:hidden" />
|
||||
<img src="/logo_white.png" alt="Xtablo" className="h-10 w-auto hidden dark:block" />
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">
|
||||
{t("auth:signup.title")}
|
||||
</h1>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("auth:signup.title")}</h1>
|
||||
<p className="text-sm text-muted-foreground mb-7">
|
||||
{t("auth:signup.alreadyAccount")}{" "}
|
||||
<Link
|
||||
to="/login-v2"
|
||||
className="text-[#804EEC] hover:text-[#6f3fd4] font-semibold"
|
||||
>
|
||||
<Link to="/login-v2" className="text-[#804EEC] hover:text-[#6f3fd4] font-semibold">
|
||||
{t("auth:signup.loginLink")}
|
||||
</Link>
|
||||
</p>
|
||||
|
|
@ -161,97 +145,76 @@ export function SignUpV2Page() {
|
|||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="first_name">
|
||||
{t("auth:signup.firstName")}{" "}
|
||||
<span className="text-red-500">*</span>
|
||||
{t("auth:signup.firstName")} <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="first_name"
|
||||
name="first_name"
|
||||
type="text"
|
||||
value={formData.first_name}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, first_name: e.target.value })
|
||||
}
|
||||
onChange={(e) => setFormData({ ...formData, first_name: e.target.value })}
|
||||
required
|
||||
placeholder={t("auth:signup.firstNamePlaceholder")}
|
||||
className="h-11"
|
||||
/>
|
||||
{errors?.first_name && (
|
||||
<FieldError errors={[{ message: errors.first_name }]} />
|
||||
)}
|
||||
{errors?.first_name && <FieldError errors={[{ message: errors.first_name }]} />}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="last_name">
|
||||
{t("auth:signup.lastName")}{" "}
|
||||
<span className="text-red-500">*</span>
|
||||
{t("auth:signup.lastName")} <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="last_name"
|
||||
name="last_name"
|
||||
type="text"
|
||||
value={formData.last_name}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, last_name: e.target.value })
|
||||
}
|
||||
onChange={(e) => setFormData({ ...formData, last_name: e.target.value })}
|
||||
required
|
||||
placeholder={t("auth:signup.lastNamePlaceholder")}
|
||||
className="h-11"
|
||||
/>
|
||||
{errors?.last_name && (
|
||||
<FieldError errors={[{ message: errors.last_name }]} />
|
||||
)}
|
||||
{errors?.last_name && <FieldError errors={[{ message: errors.last_name }]} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">
|
||||
{t("auth:signup.email")}{" "}
|
||||
<span className="text-red-500">*</span>
|
||||
{t("auth:signup.email")} <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, email: e.target.value })
|
||||
}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
required
|
||||
placeholder={t("auth:signup.emailPlaceholder")}
|
||||
className="h-11"
|
||||
/>
|
||||
{errors?.email && (
|
||||
<FieldError errors={[{ message: errors.email }]} />
|
||||
)}
|
||||
{errors?.email && <FieldError errors={[{ message: errors.email }]} />}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">
|
||||
{t("common:labels.password")}{" "}
|
||||
<span className="text-red-500">*</span>
|
||||
{t("common:labels.password")} <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, password: e.target.value })
|
||||
}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
required
|
||||
placeholder={t("auth:signup.passwordPlaceholder")}
|
||||
className="h-11"
|
||||
/>
|
||||
{errors?.password && (
|
||||
<FieldError errors={[{ message: errors.password }]} />
|
||||
)}
|
||||
{errors?.password && <FieldError errors={[{ message: errors.password }]} />}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">
|
||||
{t("auth:signup.confirmPassword")}{" "}
|
||||
<span className="text-red-500">*</span>
|
||||
{t("auth:signup.confirmPassword")} <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
|
|
@ -273,6 +236,40 @@ export function SignUpV2Page() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Plan</Label>
|
||||
<div className="grid gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBillingIntent("trial")}
|
||||
className={`w-full rounded-md border p-3 text-left transition-colors ${
|
||||
billingIntent === "trial"
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-border hover:bg-muted/40"
|
||||
}`}
|
||||
>
|
||||
<p className="text-sm font-medium">Trial (14 jours)</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Plan Solo ou Teams après la période d'essai.
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBillingIntent("founder")}
|
||||
className={`w-full rounded-md border p-3 text-left transition-colors ${
|
||||
billingIntent === "founder"
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-border hover:bg-muted/40"
|
||||
}`}
|
||||
>
|
||||
<p className="text-sm font-medium">Founder (99€/an)</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Paiement immédiat après la création du compte.
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<input
|
||||
|
|
@ -284,10 +281,7 @@ export function SignUpV2Page() {
|
|||
className="mt-1 h-4 w-4 rounded border border-border bg-background"
|
||||
required
|
||||
/>
|
||||
<Label
|
||||
htmlFor="terms"
|
||||
className="text-xs text-muted-foreground leading-relaxed"
|
||||
>
|
||||
<Label htmlFor="terms" className="text-xs text-muted-foreground leading-relaxed">
|
||||
{t("auth:signup.termsAccept")}{" "}
|
||||
<Link
|
||||
to="/legal-notice"
|
||||
|
|
@ -306,18 +300,14 @@ export function SignUpV2Page() {
|
|||
</Link>
|
||||
</Label>
|
||||
</div>
|
||||
{errors?.terms && (
|
||||
<FieldError errors={[{ message: errors.terms }]} />
|
||||
)}
|
||||
{errors?.terms && <FieldError errors={[{ message: errors.terms }]} />}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full h-11 bg-[#804EEC] hover:bg-[#6f3fd4] text-white"
|
||||
type="submit"
|
||||
>
|
||||
{isPending
|
||||
? t("auth:common.creatingAccount")
|
||||
: t("auth:signup.signupButton")}
|
||||
{isPending ? t("auth:common.creatingAccount") : t("auth:signup.signupButton")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
|
|
@ -358,9 +348,7 @@ export function SignUpV2Page() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-bold text-center mb-3">
|
||||
{t("auth:signup.asideTitle")}
|
||||
</h2>
|
||||
<h2 className="text-2xl font-bold text-center mb-3">{t("auth:signup.asideTitle")}</h2>
|
||||
<p className="text-center text-muted-foreground mb-8">
|
||||
{t("auth:signup.asideDescription")}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -193,10 +193,44 @@ describe("SignUpPage", () => {
|
|||
last_name: "Doe",
|
||||
confirm_password: "password123",
|
||||
business_name: "",
|
||||
billing_intent: "trial",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("submits founder billing intent when selected", async () => {
|
||||
renderWithProviders(<SignUpPage />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /founder/i }));
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: /create my account/i });
|
||||
const termsCheckbox = screen.getByRole("checkbox", { name: /i accept/i });
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/your first name/i), {
|
||||
target: { value: "John" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText(/your last name/i), {
|
||||
target: { value: "Doe" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText(/your email/i), {
|
||||
target: { value: "john@example.com" },
|
||||
});
|
||||
fireEvent.change(screen.getAllByPlaceholderText(/your password/i)[0], {
|
||||
target: { value: "password123" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText(/confirm your password/i), {
|
||||
target: { value: "password123" },
|
||||
});
|
||||
fireEvent.click(termsCheckbox);
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSignUp).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ billing_intent: "founder" })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("requires terms checkbox to be checked", () => {
|
||||
renderWithProviders(<SignUpPage />);
|
||||
|
||||
|
|
|
|||
|
|
@ -8,13 +8,13 @@ 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, useNavigate } from "react-router-dom";
|
||||
import { Link } from "react-router-dom";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useSignUp } from "../hooks/auth";
|
||||
import type { SignupBillingIntent } from "../lib/billing";
|
||||
|
||||
export function SignUpPage() {
|
||||
const { t } = useTranslation(["auth", "common"]);
|
||||
const navigate = useNavigate();
|
||||
const redirectUrl = localStorage.getItem("redirectUrl");
|
||||
const { mutate: signUp, isPending } = useSignUp({
|
||||
redirectUrl: redirectUrl ?? null,
|
||||
|
|
@ -31,6 +31,7 @@ export function SignUpPage() {
|
|||
business_name: "",
|
||||
});
|
||||
const [termsAccepted, setTermsAccepted] = useState(false);
|
||||
const [billingIntent, setBillingIntent] = useState<SignupBillingIntent>("trial");
|
||||
|
||||
// 3D Parallax effect
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -137,14 +138,12 @@ export function SignUpPage() {
|
|||
last_name: formData.last_name,
|
||||
confirm_password: formData.confirmPassword,
|
||||
business_name: formData.business_name,
|
||||
billing_intent: billingIntent,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center bg-linear-to-br from-primary/10 via-background to-secondary/5 animate-gradient-x bg-size-[400%_400%] relative overflow-hidden"
|
||||
onClick={() => navigate("/")}
|
||||
>
|
||||
<div className="min-h-screen flex items-center justify-center bg-linear-to-br from-primary/10 via-background to-secondary/5 animate-gradient-x bg-size-[400%_400%] relative overflow-hidden">
|
||||
<AnimatedBackground />
|
||||
<div
|
||||
ref={cardRef}
|
||||
|
|
@ -155,7 +154,6 @@ export function SignUpPage() {
|
|||
style={{ transform }}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Glow effect behind the card */}
|
||||
<div className="absolute inset-0 rounded-2xl bg-linear-to-br from-primary/10 via-primary/5 to-secondary/10 blur-xl -z-10"></div>
|
||||
|
|
@ -324,6 +322,42 @@ export function SignUpPage() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Plan</Label>
|
||||
<div className="grid gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBillingIntent("trial")}
|
||||
className={twMerge(
|
||||
"w-full rounded-md border p-3 text-left transition-colors",
|
||||
billingIntent === "trial"
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-border hover:bg-muted/40"
|
||||
)}
|
||||
>
|
||||
<p className="text-sm font-medium">Trial (14 jours)</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Plan Solo ou Teams après la période d'essai.
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBillingIntent("founder")}
|
||||
className={twMerge(
|
||||
"w-full rounded-md border p-3 text-left transition-colors",
|
||||
billingIntent === "founder"
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-border hover:bg-muted/40"
|
||||
)}
|
||||
>
|
||||
<p className="text-sm font-medium">Founder (99€/an)</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Paiement immédiat après la création du compte.
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<Input
|
||||
|
|
|
|||
|
|
@ -0,0 +1,158 @@
|
|||
-- Extend Stripe subscription -> profile.plan mapping so Founder plans
|
||||
-- are normalized to internal annual semantics.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.update_profile_subscription_status() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
v_user_id uuid;
|
||||
v_customer_id text;
|
||||
v_plan_hint text;
|
||||
v_has_paid_subscription boolean := FALSE;
|
||||
v_plan public.subscription_plan := 'solo';
|
||||
BEGIN
|
||||
-- Resolve customer id from the changed stripe row
|
||||
IF TG_TABLE_NAME = 'subscriptions' THEN
|
||||
v_customer_id := NEW.customer;
|
||||
ELSIF TG_TABLE_NAME = 'subscription_items' THEN
|
||||
SELECT s.customer
|
||||
INTO v_customer_id
|
||||
FROM stripe.subscriptions s
|
||||
WHERE s.id = NEW.subscription;
|
||||
ELSE
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
IF v_customer_id IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Resolve application user id from Stripe customer metadata
|
||||
SELECT (c.metadata->>'user_id')::uuid
|
||||
INTO v_user_id
|
||||
FROM stripe.customers c
|
||||
WHERE c.id = v_customer_id;
|
||||
|
||||
IF v_user_id IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Pick the best active/trialing subscription for this user and infer plan from
|
||||
-- price/product metadata or lookup key. Fall back to Team when paid but unmapped.
|
||||
WITH candidate_subscriptions AS (
|
||||
SELECT
|
||||
s.status::text AS status,
|
||||
lower(
|
||||
coalesce(
|
||||
NULLIF(pr.metadata->>'plan', ''),
|
||||
NULLIF(pr.lookup_key, ''),
|
||||
NULLIF(pd.metadata->>'plan', ''),
|
||||
NULLIF(pd.name, '')
|
||||
)
|
||||
) AS plan_hint,
|
||||
CASE s.status::text
|
||||
WHEN 'active' THEN 3
|
||||
WHEN 'past_due' THEN 2
|
||||
WHEN 'trialing' THEN 1
|
||||
ELSE 0
|
||||
END AS status_weight,
|
||||
CASE
|
||||
WHEN lower(
|
||||
coalesce(
|
||||
NULLIF(pr.metadata->>'plan', ''),
|
||||
NULLIF(pr.lookup_key, ''),
|
||||
NULLIF(pd.metadata->>'plan', ''),
|
||||
NULLIF(pd.name, '')
|
||||
)
|
||||
) LIKE '%founder%' OR lower(
|
||||
coalesce(
|
||||
NULLIF(pr.metadata->>'plan', ''),
|
||||
NULLIF(pr.lookup_key, ''),
|
||||
NULLIF(pd.metadata->>'plan', ''),
|
||||
NULLIF(pd.name, '')
|
||||
)
|
||||
) LIKE '%annual%' OR lower(
|
||||
coalesce(
|
||||
NULLIF(pr.metadata->>'plan', ''),
|
||||
NULLIF(pr.lookup_key, ''),
|
||||
NULLIF(pd.metadata->>'plan', ''),
|
||||
NULLIF(pd.name, '')
|
||||
)
|
||||
) LIKE '%beta%' THEN 3
|
||||
WHEN lower(
|
||||
coalesce(
|
||||
NULLIF(pr.metadata->>'plan', ''),
|
||||
NULLIF(pr.lookup_key, ''),
|
||||
NULLIF(pd.metadata->>'plan', ''),
|
||||
NULLIF(pd.name, '')
|
||||
)
|
||||
) LIKE '%team%' OR lower(
|
||||
coalesce(
|
||||
NULLIF(pr.metadata->>'plan', ''),
|
||||
NULLIF(pr.lookup_key, ''),
|
||||
NULLIF(pd.metadata->>'plan', ''),
|
||||
NULLIF(pd.name, '')
|
||||
)
|
||||
) LIKE '%standard%' THEN 2
|
||||
WHEN lower(
|
||||
coalesce(
|
||||
NULLIF(pr.metadata->>'plan', ''),
|
||||
NULLIF(pr.lookup_key, ''),
|
||||
NULLIF(pd.metadata->>'plan', ''),
|
||||
NULLIF(pd.name, '')
|
||||
)
|
||||
) LIKE '%solo%' THEN 1
|
||||
ELSE 0
|
||||
END AS plan_weight
|
||||
FROM stripe.subscriptions s
|
||||
JOIN stripe.customers c
|
||||
ON c.id = s.customer
|
||||
LEFT JOIN stripe.subscription_items si
|
||||
ON si.subscription = s.id
|
||||
LEFT JOIN stripe.prices pr
|
||||
ON pr.id = si.price
|
||||
LEFT JOIN stripe.products pd
|
||||
ON pd.id = pr.product
|
||||
WHERE (c.metadata->>'user_id')::uuid = v_user_id
|
||||
AND s.status::text IN ('active', 'past_due', 'trialing')
|
||||
AND (
|
||||
si.current_period_end IS NULL
|
||||
OR to_timestamp(si.current_period_end) > now()
|
||||
)
|
||||
)
|
||||
SELECT
|
||||
TRUE,
|
||||
cs.plan_hint
|
||||
INTO v_has_paid_subscription, v_plan_hint
|
||||
FROM candidate_subscriptions cs
|
||||
ORDER BY cs.plan_weight DESC, cs.status_weight DESC
|
||||
LIMIT 1;
|
||||
|
||||
IF v_has_paid_subscription THEN
|
||||
IF v_plan_hint LIKE '%founder%' OR v_plan_hint LIKE '%annual%' OR v_plan_hint LIKE '%beta%' THEN
|
||||
v_plan := 'annual';
|
||||
ELSIF v_plan_hint LIKE '%team%' OR v_plan_hint LIKE '%standard%' THEN
|
||||
v_plan := 'team';
|
||||
ELSIF v_plan_hint LIKE '%solo%' OR v_plan_hint LIKE '%free%' THEN
|
||||
v_plan := 'solo';
|
||||
ELSE
|
||||
-- paid but unmapped => Team by default (backward compatibility)
|
||||
v_plan := 'team';
|
||||
END IF;
|
||||
ELSE
|
||||
v_plan := 'solo';
|
||||
END IF;
|
||||
|
||||
UPDATE public.profiles
|
||||
SET plan = v_plan
|
||||
WHERE id = v_user_id;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
ALTER FUNCTION public.update_profile_subscription_status() OWNER TO postgres;
|
||||
|
||||
COMMENT ON FUNCTION public.update_profile_subscription_status() IS
|
||||
'Maps Stripe subscription state to profile.plan and normalizes founder/annual to annual.';
|
||||
Loading…
Reference in a new issue