diff --git a/apps/desktop/src/billing.tsx b/apps/desktop/src/billing.tsx index 5ba6f482af..4c16fc85d0 100644 --- a/apps/desktop/src/billing.tsx +++ b/apps/desktop/src/billing.tsx @@ -11,18 +11,41 @@ import { import { useAuth } from "./auth"; import { env } from "./env"; +type JwtClaims = { + entitlements?: string[]; + subscription_status?: "trialing" | "active"; + trial_end?: number; +}; + export function getEntitlementsFromToken(accessToken: string): string[] { try { - const decoded = jwtDecode<{ entitlements?: string[] }>(accessToken); + const decoded = jwtDecode(accessToken); return decoded.entitlements ?? []; } catch { return []; } } +export function getSubscriptionInfoFromToken(accessToken: string): { + status: "trialing" | "active" | null; + trialEnd: number | null; +} { + try { + const decoded = jwtDecode(accessToken); + return { + status: decoded.subscription_status ?? null, + trialEnd: decoded.trial_end ?? null, + }; + } catch { + return { status: null, trialEnd: null }; + } +} + type BillingContextValue = { entitlements: string[]; isPro: boolean; + isTrialing: boolean; + trialDaysRemaining: number | null; upgradeToPro: () => void; }; @@ -40,11 +63,35 @@ export function BillingProvider({ children }: { children: ReactNode }) { return getEntitlementsFromToken(auth.session.access_token); }, [auth?.session?.access_token]); + const subscriptionInfo = useMemo(() => { + if (!auth?.session?.access_token) { + return { status: null, trialEnd: null }; + } + return getSubscriptionInfoFromToken(auth.session.access_token); + }, [auth?.session?.access_token]); + const isPro = useMemo( () => entitlements.includes("hyprnote_pro"), [entitlements], ); + const isTrialing = useMemo( + () => subscriptionInfo.status === "trialing", + [subscriptionInfo.status], + ); + + const trialDaysRemaining = useMemo(() => { + if (!subscriptionInfo.trialEnd) { + return null; + } + const now = Math.floor(Date.now() / 1000); + const secondsRemaining = subscriptionInfo.trialEnd - now; + if (secondsRemaining <= 0) { + return 0; + } + return Math.ceil(secondsRemaining / (24 * 60 * 60)); + }, [subscriptionInfo.trialEnd]); + const upgradeToPro = useCallback(() => { void openUrl(`${env.VITE_APP_URL}/app/checkout?period=monthly`); }, []); @@ -53,9 +100,11 @@ export function BillingProvider({ children }: { children: ReactNode }) { () => ({ entitlements, isPro, + isTrialing, + trialDaysRemaining, upgradeToPro, }), - [entitlements, isPro, upgradeToPro], + [entitlements, isPro, isTrialing, trialDaysRemaining, upgradeToPro], ); return ( diff --git a/apps/desktop/src/components/settings/general/account.tsx b/apps/desktop/src/components/settings/general/account.tsx index d8f681b434..895c364d00 100644 --- a/apps/desktop/src/components/settings/general/account.tsx +++ b/apps/desktop/src/components/settings/general/account.tsx @@ -14,9 +14,29 @@ import { env } from "../../../env"; const WEB_APP_BASE_URL = env.VITE_APP_URL ?? "http://localhost:3000"; +function getPlanDescription( + isPro: boolean, + isTrialing: boolean, + trialDaysRemaining: number | null, +): string { + if (!isPro) { + return "Your current plan is FREE."; + } + if (isTrialing && trialDaysRemaining !== null) { + if (trialDaysRemaining === 0) { + return "Your trial ends today. Add a payment method to continue."; + } + if (trialDaysRemaining === 1) { + return "Your trial ends tomorrow. Add a payment method to continue."; + } + return `Your trial ends in ${trialDaysRemaining} days.`; + } + return "Your current plan is PRO."; +} + export function AccountSettings() { const auth = useAuth(); - const { isPro } = useBillingAccess(); + const { isPro, isTrialing, trialDaysRemaining } = useBillingAccess(); const isAuthenticated = !!auth?.session; const [isPending, setIsPending] = useState(false); @@ -140,7 +160,7 @@ export function AccountSettings() { } >

diff --git a/supabase/migrations/20250101000006_auth_hook_add_subscription_claims.sql b/supabase/migrations/20250101000006_auth_hook_add_subscription_claims.sql new file mode 100644 index 0000000000..db03f1a9ef --- /dev/null +++ b/supabase/migrations/20250101000006_auth_hook_add_subscription_claims.sql @@ -0,0 +1,56 @@ +CREATE OR REPLACE FUNCTION public.custom_access_token_hook(event jsonb) +RETURNS jsonb +LANGUAGE plpgsql +STABLE +AS $$ +DECLARE + claims jsonb; + entitlements jsonb := '[]'::jsonb; + v_customer_id text; + v_subscription_status text; + v_trial_end bigint; +BEGIN + SELECT p.stripe_customer_id INTO v_customer_id + FROM public.profiles p + WHERE p.id = (event->>'user_id')::uuid; + + SELECT + COALESCE( + jsonb_agg(ae.lookup_key ORDER BY ae.lookup_key) + FILTER (WHERE ae.lookup_key IS NOT NULL), + '[]'::jsonb + ) + INTO entitlements + FROM public.profiles p + JOIN stripe.active_entitlements ae + ON ae.customer = p.stripe_customer_id + WHERE p.id = (event->>'user_id')::uuid; + + IF v_customer_id IS NOT NULL THEN + SELECT + s.status::text, + (s.trial_end #>> '{}')::bigint + INTO v_subscription_status, v_trial_end + FROM stripe.subscriptions s + WHERE s.customer = v_customer_id + AND s.status IN ('trialing', 'active') + ORDER BY s.created DESC + LIMIT 1; + END IF; + + claims := event->'claims'; + claims := jsonb_set(claims, '{entitlements}', entitlements); + + IF v_subscription_status IS NOT NULL THEN + claims := jsonb_set(claims, '{subscription_status}', to_jsonb(v_subscription_status)); + END IF; + + IF v_trial_end IS NOT NULL THEN + claims := jsonb_set(claims, '{trial_end}', to_jsonb(v_trial_end)); + END IF; + + event := jsonb_set(event, '{claims}', claims); + + RETURN event; +END; +$$;