Keep founder plan during invites

This commit is contained in:
Arthur Belleville 2026-03-08 21:43:31 +01:00
parent 992b846a85
commit 76f497d2c8
No known key found for this signature in database
10 changed files with 341 additions and 12 deletions

View file

@ -12,11 +12,13 @@ describe("billing helpers", () => {
id: "owner-user",
created_at: "2026-01-01T10:00:00.000Z",
is_temporary: false,
plan: "annual",
},
{
id: "late-user",
created_at: "2026-01-02T10:00:00.000Z",
is_temporary: false,
plan: "solo",
},
]);
@ -29,16 +31,19 @@ describe("billing helpers", () => {
id: "user-1",
created_at: "2026-01-01T10:00:00.000Z",
is_temporary: false,
plan: "solo",
},
{
id: "temp-1",
created_at: "2026-01-02T10:00:00.000Z",
is_temporary: true,
plan: "solo",
},
{
id: "user-2",
created_at: "2026-01-03T10:00:00.000Z",
is_temporary: null,
plan: "team",
},
]);

View file

@ -7,6 +7,7 @@ type BillingProfileRow = {
id: string;
created_at: string | null;
is_temporary: boolean | null;
plan: string | null;
};
type StripeCustomerRow = {
@ -141,6 +142,20 @@ const inferBillingPlan = (planHint: string | null | undefined): BillingPlan | nu
return null;
};
const normalizeProfilePlan = (plan: string | null | undefined): BillingPlan => {
const normalized = (plan ?? "").toLowerCase();
if (normalized === "annual" || normalized === "beta" || normalized === "founder") {
return "annual";
}
if (normalized === "team" || normalized === "standard") {
return "team";
}
return "solo";
};
const getPlanHint = (price: StripePriceRow | undefined, product: StripeProductRow | undefined) => {
const parts = [
price?.metadata?.plan,
@ -159,7 +174,7 @@ const getPlanHint = (price: StripePriceRow | undefined, product: StripeProductRo
const getOrganizationProfiles = async (supabase: SupabaseClient, organizationId: number) => {
const { data, error } = await supabase
.from("profiles")
.select("id, created_at, is_temporary")
.select("id, created_at, is_temporary, plan")
.eq("organization_id", organizationId)
.order("created_at", { ascending: true });
@ -177,7 +192,8 @@ const getOrganizationProfiles = async (supabase: SupabaseClient, organizationId:
const resolveActiveSubscription = async (
supabase: SupabaseClient,
ownerUserId: string
ownerUserId: string,
ownerProfilePlan: string | null
): Promise<{ plan: BillingPlan | null; quantity: number }> => {
const { data: customers, error: customersError } = await supabase
.schema("stripe")
@ -213,6 +229,8 @@ const resolveActiveSubscription = async (
return { plan: null, quantity: 0 };
}
const ownerFallbackPlan = normalizeProfilePlan(ownerProfilePlan);
const subscriptionIds = normalizedSubscriptions.map((subscription) => subscription.id);
const { data: subscriptionItems, error: subscriptionItemsError } = await supabase
.schema("stripe")
@ -297,7 +315,7 @@ const resolveActiveSubscription = async (
});
if (candidates.length === 0) {
return { plan: "team", quantity: 1 };
return { plan: ownerFallbackPlan === "solo" ? "team" : ownerFallbackPlan, quantity: 1 };
}
candidates.sort((a, b) => {
@ -318,8 +336,9 @@ const resolveActiveSubscription = async (
const winner = candidates[0];
return {
// Backward-compatible default for unknown paid subscriptions.
plan: winner.plan ?? "team",
// Keep legacy fallback for unknown paid subscriptions but prefer owner profile plan
// so founder/beta organizations do not downgrade to team because of metadata gaps.
plan: winner.plan ?? (ownerFallbackPlan === "solo" ? "team" : ownerFallbackPlan),
quantity: winner.quantity,
};
};
@ -354,7 +373,7 @@ export const getOrganizationBillingState = async (
const requiredPlan = resolveRequiredPlan(memberCount);
try {
const activeSubscription = await resolveActiveSubscription(supabase, owner.id);
const activeSubscription = await resolveActiveSubscription(supabase, owner.id, owner.plan);
return {
data: {

View file

@ -20,7 +20,9 @@ const PLAN_WEIGHT: Record<NormalizedPlan, number> = {
export const normalizePlan = (plan: string | null | undefined): NormalizedPlan => {
if (!plan) return "solo";
if (plan === "annual" || plan === "beta") return "annual";
if (plan === "annual" || plan === "beta" || plan === "founder" || plan === "infinite") {
return "annual";
}
if (plan === "team" || plan === "standard") return "team";
return "solo";
};

View file

@ -7,6 +7,8 @@ import { createInvitedUser, getOrganizationPlan, MAX_TABLO_LIMIT } from "../help
import type { AuthEnv } from "../types/app.types.js";
const factory = createFactory<AuthEnv>();
const isMissingRelationError = (code: string | undefined) =>
code === "42P01" || code === "PGRST205";
const signUpToStream = factory.createHandlers(async (c) => {
const { id } = c.get("user");
@ -339,6 +341,80 @@ const getOrganization = factory.createHandlers(async (c) => {
return c.json({ error: "Failed to resolve organization billing state" }, 500);
}
let invitesSent: Array<{
id: number;
invited_email: string;
invited_user_id: string | null;
created_at: string;
invited_member: {
id: string;
email: string | null;
name: string | null;
first_name: string | null;
last_name: string | null;
avatar_url: string | null;
} | null;
}> = [];
const { data: invitesData, error: invitesError } = await supabase
.from("organization_invites")
.select(
`
id,
invited_email,
invited_user_id,
created_at,
invited_member:profiles!organization_invites_invited_user_id_fkey(
id,
email,
name,
first_name,
last_name,
avatar_url
)
`
)
.eq("organization_id", organizationId)
.eq("invited_by", user.id)
.order("created_at", { ascending: false });
if (invitesError && !isMissingRelationError(invitesError.code)) {
return c.json({ error: "Failed to load organization invites" }, 500);
}
if (!invitesError && invitesData) {
invitesSent = invitesData.map((invite) => {
const invitedMemberRaw = invite.invited_member as
| {
id: string;
email: string | null;
name: string | null;
first_name: string | null;
last_name: string | null;
avatar_url: string | null;
}
| {
id: string;
email: string | null;
name: string | null;
first_name: string | null;
last_name: string | null;
avatar_url: string | null;
}[]
| null;
return {
id: invite.id as number,
invited_email: (invite.invited_email as string) ?? "",
invited_user_id: (invite.invited_user_id as string | null) ?? null,
created_at: (invite.created_at as string) ?? new Date().toISOString(),
invited_member: Array.isArray(invitedMemberRaw)
? (invitedMemberRaw[0] ?? null)
: invitedMemberRaw,
};
});
}
return c.json({
organization: {
id: organization.id,
@ -356,6 +432,7 @@ const getOrganization = factory.createHandlers(async (c) => {
active_subscription_plan: billingState.active_subscription_plan,
active_subscription_quantity: billingState.active_subscription_quantity,
is_billing_owner: billingState.owner_user_id === user.id,
invites_sent: invitesSent,
});
});
@ -564,6 +641,17 @@ const inviteToOrganization = factory.createHandlers(async (c) => {
}
}
const { error: inviteHistoryError } = await supabase.from("organization_invites").insert({
organization_id: organizationId,
invited_by: user.id,
invited_email: recipientEmail,
invited_user_id: invitedUser.userId,
});
if (inviteHistoryError && !isMissingRelationError(inviteHistoryError.code)) {
console.error("Failed to store organization invite history:", inviteHistoryError);
}
return c.json({
message: "Invitation sent successfully",
limits: {

View file

@ -25,6 +25,7 @@ export interface OrganizationMember {
export interface OrganizationResponse {
organization: OrganizationSummary;
members: OrganizationMember[];
invites_sent: OrganizationInvite[];
trial_starts_at: string;
trial_ends_at: string;
is_trial_expired: boolean;
@ -35,6 +36,21 @@ export interface OrganizationResponse {
is_billing_owner: boolean;
}
export interface OrganizationInvite {
id: number;
invited_email: string;
invited_user_id: string | null;
created_at: string;
invited_member: {
id: string;
email: string | null;
name: string | null;
first_name: string | null;
last_name: string | null;
avatar_url: string | null;
} | null;
}
export const useOrganization = () => {
const api = useAuthedApi();

View file

@ -18,7 +18,9 @@ export type CheckoutPlan = "solo" | "team" | "founder";
export const normalizeBillingPlan = (plan: string | null | undefined): BillingPlan => {
if (!plan) return "solo";
if (plan === "annual" || plan === "beta") return "annual";
if (plan === "annual" || plan === "beta" || plan === "founder" || plan === "infinite") {
return "annual";
}
if (plan === "team" || plan === "standard") return "team";
return "solo";
};

View file

@ -49,7 +49,15 @@
"emailPlaceholder": "name@company.com",
"hint": "The invited user will get credentials by email.",
"invite": "Send invite",
"inviting": "Sending..."
"inviting": "Sending...",
"invitedByYouTitle": "Users invited by you",
"noInvitesYet": "You have not invited anyone yet.",
"invitedOn": "Invited on {{date}}",
"membersTitle": "Other organization members",
"noOtherMembers": "No other members in your organization yet.",
"joinedOn": "Joined on {{date}}",
"unknownDate": "unknown date",
"unknownUser": "Unknown user"
},
"cookies": {
"title": "Cookie Preferences",

View file

@ -49,7 +49,15 @@
"emailPlaceholder": "nom@entreprise.com",
"hint": "L'utilisateur invité recevra ses identifiants par email.",
"invite": "Envoyer l'invitation",
"inviting": "Envoi..."
"inviting": "Envoi...",
"invitedByYouTitle": "Utilisateurs invités par vous",
"noInvitesYet": "Vous n'avez encore invité personne.",
"invitedOn": "Invité le {{date}}",
"membersTitle": "Autres membres de l'organisation",
"noOtherMembers": "Aucun autre membre dans votre organisation pour le moment.",
"joinedOn": "A rejoint le {{date}}",
"unknownDate": "date inconnue",
"unknownUser": "Utilisateur inconnu"
},
"cookies": {
"title": "Préférences des cookies",

View file

@ -39,7 +39,7 @@ import { useUser } from "../providers/UserStoreProvider";
export default function SettingsPage() {
const user = useUser();
const { t } = useTranslation(["settings", "common"]);
const { t, i18n } = useTranslation(["settings", "common"]);
const {
introduction,
updateIntroduction,
@ -70,6 +70,35 @@ export default function SettingsPage() {
organizationData?.organization?.plan === "annual"
? "founder"
: organizationData?.organization?.plan;
const invitedByCurrentUser = organizationData?.invites_sent || [];
const organizationMembers = (organizationData?.members || []).filter(
(member) => member.id !== user?.id
);
const getDisplayName = (input: {
first_name?: string | null;
last_name?: string | null;
name?: string | null;
email?: string | null;
}) => {
const combinedName = [input.first_name, input.last_name].filter(Boolean).join(" ").trim();
if (combinedName) {
return combinedName;
}
if (input.name) {
return input.name;
}
return input.email || t("settings:teamInvite.unknownUser");
};
const formatDate = (date: string) =>
new Date(date).toLocaleDateString(i18n.language || undefined, {
day: "numeric",
month: "short",
year: "numeric",
});
useEffect(() => {
if (organizationData?.organization?.name) {
@ -362,7 +391,7 @@ export default function SettingsPage() {
<CardTitle>{t("settings:teamInvite.title")}</CardTitle>
<CardDescription>{t("settings:teamInvite.description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<CardContent className="space-y-6">
<div className="space-y-2">
<Label htmlFor="inviteOrganizationEmail">
{t("settings:teamInvite.emailLabel")}
@ -390,6 +419,83 @@ export default function SettingsPage() {
: t("settings:teamInvite.invite")}
</Button>
</div>
<div className="space-y-3">
<p className="text-sm font-medium">{t("settings:teamInvite.invitedByYouTitle")}</p>
{invitedByCurrentUser.length === 0 ? (
<p className="text-xs text-muted-foreground">
{t("settings:teamInvite.noInvitesYet")}
</p>
) : (
<div className="space-y-2">
{invitedByCurrentUser.map((invite) => {
const member = invite.invited_member;
const displayName = getDisplayName({
first_name: member?.first_name,
last_name: member?.last_name,
name: member?.name,
email: member?.email || invite.invited_email,
});
return (
<div
key={invite.id}
className="flex items-center justify-between rounded-md border px-3 py-2"
>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{displayName}</p>
<p className="text-xs text-muted-foreground truncate">
{member?.email || invite.invited_email}
</p>
</div>
<p className="text-xs text-muted-foreground shrink-0 ml-3">
{t("settings:teamInvite.invitedOn", {
date: formatDate(invite.created_at),
})}
</p>
</div>
);
})}
</div>
)}
</div>
<div className="space-y-3">
<p className="text-sm font-medium">{t("settings:teamInvite.membersTitle")}</p>
{organizationMembers.length === 0 ? (
<p className="text-xs text-muted-foreground">
{t("settings:teamInvite.noOtherMembers")}
</p>
) : (
<div className="space-y-2">
{organizationMembers.map((member) => (
<div
key={member.id}
className="flex items-center justify-between rounded-md border px-3 py-2"
>
<div className="min-w-0">
<p className="text-sm font-medium truncate">
{getDisplayName({
first_name: member.first_name,
last_name: member.last_name,
name: member.name,
email: member.email,
})}
</p>
<p className="text-xs text-muted-foreground truncate">{member.email}</p>
</div>
<p className="text-xs text-muted-foreground shrink-0 ml-3">
{t("settings:teamInvite.joinedOn", {
date: member.created_at
? formatDate(member.created_at)
: t("settings:teamInvite.unknownDate"),
})}
</p>
</div>
))}
</div>
)}
</div>
</CardContent>
</Card>

View file

@ -0,0 +1,75 @@
-- Track organization invitations sent by members so Settings can display invite history.
CREATE TABLE IF NOT EXISTS public.organization_invites (
id bigserial PRIMARY KEY,
organization_id integer NOT NULL,
invited_by uuid NOT NULL,
invited_email character varying(255) NOT NULL,
invited_user_id uuid,
created_at timestamp with time zone NOT NULL DEFAULT now()
);
ALTER TABLE public.organization_invites
DROP CONSTRAINT IF EXISTS organization_invites_organization_id_fkey;
ALTER TABLE public.organization_invites
ADD CONSTRAINT organization_invites_organization_id_fkey
FOREIGN KEY (organization_id)
REFERENCES public.organizations(id)
ON DELETE CASCADE;
ALTER TABLE public.organization_invites
DROP CONSTRAINT IF EXISTS organization_invites_invited_by_fkey;
ALTER TABLE public.organization_invites
ADD CONSTRAINT organization_invites_invited_by_fkey
FOREIGN KEY (invited_by)
REFERENCES public.profiles(id)
ON DELETE CASCADE;
ALTER TABLE public.organization_invites
DROP CONSTRAINT IF EXISTS organization_invites_invited_user_id_fkey;
ALTER TABLE public.organization_invites
ADD CONSTRAINT organization_invites_invited_user_id_fkey
FOREIGN KEY (invited_user_id)
REFERENCES public.profiles(id)
ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS organization_invites_organization_id_idx
ON public.organization_invites (organization_id);
CREATE INDEX IF NOT EXISTS organization_invites_invited_by_idx
ON public.organization_invites (invited_by);
CREATE INDEX IF NOT EXISTS organization_invites_created_at_idx
ON public.organization_invites (created_at DESC);
COMMENT ON TABLE public.organization_invites IS 'History of organization invitations sent by members.';
COMMENT ON COLUMN public.organization_invites.organization_id IS 'Organization where the invite was sent.';
COMMENT ON COLUMN public.organization_invites.invited_by IS 'Profile ID of the member who sent the invite.';
COMMENT ON COLUMN public.organization_invites.invited_email IS 'Email that was invited.';
COMMENT ON COLUMN public.organization_invites.invited_user_id IS 'Profile ID created for the invited email, when available.';
ALTER TABLE public.organization_invites ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "Users can view organization invites they sent" ON public.organization_invites;
CREATE POLICY "Users can view organization invites they sent"
ON public.organization_invites
FOR SELECT
USING (
organization_id = public.current_user_organization_id()
AND invited_by = auth.uid()
);
DROP POLICY IF EXISTS "Users can insert organization invites they sent" ON public.organization_invites;
CREATE POLICY "Users can insert organization invites they sent"
ON public.organization_invites
FOR INSERT
WITH CHECK (
organization_id = public.current_user_organization_id()
AND invited_by = auth.uid()
);
GRANT SELECT, INSERT ON public.organization_invites TO authenticated;
GRANT USAGE, SELECT ON SEQUENCE public.organization_invites_id_seq TO authenticated;