Keep founder plan during invites
This commit is contained in:
parent
992b846a85
commit
76f497d2c8
10 changed files with 341 additions and 12 deletions
|
|
@ -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",
|
||||
},
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
Loading…
Reference in a new issue