Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 71 additions & 2 deletions apps/desktop/src/billing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,57 @@ import { useAuth } from "./auth";
import { env } from "./env";
import { getScheme } from "./utils";

type SubscriptionStatus =
| "none"
| "active"
| "trialing"
| "past_due"
| "canceled"
| "incomplete"
| "incomplete_expired"
| "unpaid";

type JwtClaims = {
entitlements?: string[];
subscription_status?: SubscriptionStatus;
trial_end?: number;
};

export function getEntitlementsFromToken(accessToken: string): string[] {
try {
const decoded = jwtDecode<{ entitlements?: string[] }>(accessToken);
const decoded = jwtDecode<JwtClaims>(accessToken);
return decoded.entitlements ?? [];
} catch {
return [];
}
}

export function getSubscriptionStatusFromToken(
accessToken: string,
): SubscriptionStatus | undefined {
try {
const decoded = jwtDecode<JwtClaims>(accessToken);
return decoded.subscription_status;
} catch {
return undefined;
}
}

export function getTrialEndFromToken(accessToken: string): number | undefined {
try {
const decoded = jwtDecode<JwtClaims>(accessToken);
return decoded.trial_end;
} catch {
return undefined;
}
}

type BillingContextValue = {
entitlements: string[];
isPro: boolean;
subscriptionStatus: SubscriptionStatus;
isOnTrial: boolean;
trialEnd: number | undefined;
canStartTrial: boolean;
upgradeToPro: () => void;
};
Expand All @@ -46,11 +85,30 @@ export function BillingProvider({ children }: { children: ReactNode }) {
return getEntitlementsFromToken(auth.session.access_token);
}, [auth?.session?.access_token]);

const subscriptionStatus = useMemo<SubscriptionStatus>(() => {
if (!auth?.session?.access_token) {
return "none";
}
return getSubscriptionStatusFromToken(auth.session.access_token) ?? "none";
}, [auth?.session?.access_token]);

const trialEnd = useMemo(() => {
if (!auth?.session?.access_token) {
return undefined;
}
return getTrialEndFromToken(auth.session.access_token);
}, [auth?.session?.access_token]);

const isPro = useMemo(
() => entitlements.includes("hyprnote_pro"),
[entitlements],
);

const isOnTrial = useMemo(
() => subscriptionStatus === "trialing",
[subscriptionStatus],
);

const canTrialQuery = useQuery({
enabled: !!auth?.session && !isPro,
queryKey: [auth?.session?.user.id ?? "", "canStartTrial"],
Expand Down Expand Up @@ -82,10 +140,21 @@ export function BillingProvider({ children }: { children: ReactNode }) {
() => ({
entitlements,
isPro,
subscriptionStatus,
isOnTrial,
trialEnd,
canStartTrial,
upgradeToPro,
}),
[entitlements, isPro, canStartTrial, upgradeToPro],
[
entitlements,
isPro,
subscriptionStatus,
isOnTrial,
trialEnd,
canStartTrial,
upgradeToPro,
],
);

return (
Expand Down
18 changes: 14 additions & 4 deletions apps/desktop/src/components/settings/general/account.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const WEB_APP_BASE_URL = env.VITE_APP_URL ?? "http://localhost:3000";

export function AccountSettings() {
const auth = useAuth();
const { isPro } = useBillingAccess();
const { isPro, isOnTrial, trialEnd } = useBillingAccess();
const store = settings.UI.useStore(settings.STORE_ID);

const isAuthenticated = !!auth?.session;
Expand Down Expand Up @@ -171,7 +171,17 @@ export function AccountSettings() {

<Container
title="Plan & Billing"
description={`Your current plan is ${isPro ? "PRO" : "FREE"}. `}
description={`Your current plan is ${
isOnTrial
? `TRIAL${
trialEnd
? ` (ends ${new Date(trialEnd * 1000).toLocaleDateString()})`
: ""
}`
: isPro
? "PRO"
: "FREE"
}. `}
action={<BillingButton />}
>
<p className="text-sm text-neutral-600">
Expand All @@ -191,7 +201,7 @@ export function AccountSettings() {

function BillingButton() {
const auth = useAuth();
const { isPro } = useBillingAccess();
const { isPro, isOnTrial } = useBillingAccess();
const { open: openTrialBeginModal } = useTrialBeginModal();

const canTrialQuery = useQuery({
Expand Down Expand Up @@ -262,7 +272,7 @@ function BillingButton() {
void openerCommands.openUrl(`${WEB_APP_BASE_URL}/app/account`, null);
}, []);

if (isPro) {
if (isPro || isOnTrial) {
return (
<Button
variant="outline"
Expand Down
18 changes: 10 additions & 8 deletions apps/web/src/functions/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,21 +196,23 @@ export const syncAfterSuccess = createServerFn({ method: "POST" }).handler(

const subscriptions = await stripe.subscriptions.list({
customer: stripeCustomerId,
limit: 1,
status: "all",
});

if (subscriptions.data.length === 0) {
// Find the first active or trialing subscription (prioritize active over trialing)
const activeSubscription =
subscriptions.data.find((sub) => sub.status === "active") ||
subscriptions.data.find((sub) => sub.status === "trialing");

if (!activeSubscription) {
return { status: "none" };
}

const subscription = subscriptions.data[0];

return {
subscriptionId: subscription.id,
status: subscription.status,
priceId: subscription.items.data[0].price.id,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
subscriptionId: activeSubscription.id,
status: activeSubscription.status,
priceId: activeSubscription.items.data[0].price.id,
cancelAtPeriodEnd: activeSubscription.cancel_at_period_end,
};
},
);
Expand Down
2 changes: 1 addition & 1 deletion packages/supabase/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@ export type { SupabaseClient } from "@supabase/supabase-js";

export { createRemoteJWKSet, jwtVerify } from "jose";

export type { SupabaseJwtPayload } from "./jwt";
export type { SubscriptionStatus, SupabaseJwtPayload } from "./jwt";
export { createJwksVerifier } from "./jwt";
12 changes: 12 additions & 0 deletions packages/supabase/src/jwt.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
import { createRemoteJWKSet, jwtVerify } from "jose";

export type SubscriptionStatus =
| "none"
| "active"
| "trialing"
| "past_due"
| "canceled"
| "incomplete"
| "incomplete_expired"
| "unpaid";

export type SupabaseJwtPayload = {
sub?: string;
entitlements?: string[];
subscription_status?: SubscriptionStatus;
trial_end?: number;
};

export type JwksVerifier = {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
-- Add subscription status and trial information to JWT claims
-- This allows the desktop app to distinguish between trial and paid pro users

CREATE OR REPLACE FUNCTION public.custom_access_token_hook(event jsonb)
RETURNS jsonb
LANGUAGE plpgsql
STABLE
AS $$
DECLARE
claims jsonb;
entitlements jsonb := '[]'::jsonb;
subscription_status text := 'none';
trial_end_ts bigint := NULL;
user_stripe_customer_id text;
BEGIN
-- Get user's stripe customer ID
SELECT stripe_customer_id INTO user_stripe_customer_id
FROM public.profiles
WHERE id = (event->>'user_id')::uuid;

-- Get entitlements
SELECT
COALESCE(
jsonb_agg(ae.lookup_key ORDER BY ae.lookup_key)
FILTER (WHERE ae.lookup_key IS NOT NULL),
'[]'::jsonb
)
INTO entitlements
FROM stripe.active_entitlements ae
WHERE ae.customer = user_stripe_customer_id;

-- Get subscription status and trial end date if exists
IF user_stripe_customer_id IS NOT NULL THEN
SELECT
s.status,
(s.trial_end #>> '{}')::bigint
INTO subscription_status, trial_end_ts
FROM stripe.subscriptions s
WHERE s.customer = user_stripe_customer_id
AND s.status IN ('active', 'trialing', 'past_due')
ORDER BY s.created DESC
LIMIT 1;

-- If no active subscription found, set status to 'none'
IF subscription_status IS NULL THEN
subscription_status := 'none';
END IF;
END IF;

-- Build claims
claims := event->'claims';
claims := jsonb_set(claims, '{entitlements}', entitlements);
claims := jsonb_set(claims, '{subscription_status}', to_jsonb(subscription_status));

-- Only add trial_end if it exists
IF trial_end_ts IS NOT NULL THEN
claims := jsonb_set(claims, '{trial_end}', to_jsonb(trial_end_ts));
END IF;

event := jsonb_set(event, '{claims}', claims);

RETURN event;
END;
$$;

-- Grant necessary permissions for the new subscription query
GRANT SELECT ON TABLE stripe.subscriptions TO supabase_auth_admin;

CREATE POLICY "Allow auth admin to read subscriptions"
ON stripe.subscriptions
AS PERMISSIVE FOR SELECT
TO supabase_auth_admin
USING (true);
Loading