Skip to content

Commit a8f1bd6

Browse files
fix: harmonize account settings between browser and desktop app
- Add subscription_status and trial_end claims to JWT via auth hook - Update desktop billing context to extract and expose subscription status - Update account settings UI to show 'TRIAL' for trialing users instead of 'PRO' - Add trial end date display when available - Export SubscriptionStatus type from @hypr/supabase package This fixes the discrepancy where browser showed 'Trial' but desktop showed 'PRO' for users on trial subscriptions. Co-Authored-By: john@hyprnote.com <john@hyprnote.com>
1 parent 4411a23 commit a8f1bd6

File tree

5 files changed

+162
-7
lines changed

5 files changed

+162
-7
lines changed

apps/desktop/src/billing.tsx

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,23 +11,53 @@ import {
1111
import { getRpcCanStartTrial } from "@hypr/api-client";
1212
import { createClient } from "@hypr/api-client/client";
1313
import { commands as openerCommands } from "@hypr/plugin-opener2";
14+
import type { SubscriptionStatus } from "@hypr/supabase";
1415

1516
import { useAuth } from "./auth";
1617
import { env } from "./env";
1718
import { getScheme } from "./utils";
1819

20+
type JwtClaims = {
21+
entitlements?: string[];
22+
subscription_status?: SubscriptionStatus;
23+
trial_end?: number;
24+
};
25+
1926
export function getEntitlementsFromToken(accessToken: string): string[] {
2027
try {
21-
const decoded = jwtDecode<{ entitlements?: string[] }>(accessToken);
28+
const decoded = jwtDecode<JwtClaims>(accessToken);
2229
return decoded.entitlements ?? [];
2330
} catch {
2431
return [];
2532
}
2633
}
2734

35+
export function getSubscriptionStatusFromToken(
36+
accessToken: string,
37+
): SubscriptionStatus | undefined {
38+
try {
39+
const decoded = jwtDecode<JwtClaims>(accessToken);
40+
return decoded.subscription_status;
41+
} catch {
42+
return undefined;
43+
}
44+
}
45+
46+
export function getTrialEndFromToken(accessToken: string): number | undefined {
47+
try {
48+
const decoded = jwtDecode<JwtClaims>(accessToken);
49+
return decoded.trial_end;
50+
} catch {
51+
return undefined;
52+
}
53+
}
54+
2855
type BillingContextValue = {
2956
entitlements: string[];
3057
isPro: boolean;
58+
subscriptionStatus: SubscriptionStatus;
59+
isOnTrial: boolean;
60+
trialEnd: number | undefined;
3161
canStartTrial: boolean;
3262
upgradeToPro: () => void;
3363
};
@@ -46,11 +76,30 @@ export function BillingProvider({ children }: { children: ReactNode }) {
4676
return getEntitlementsFromToken(auth.session.access_token);
4777
}, [auth?.session?.access_token]);
4878

79+
const subscriptionStatus = useMemo<SubscriptionStatus>(() => {
80+
if (!auth?.session?.access_token) {
81+
return "none";
82+
}
83+
return getSubscriptionStatusFromToken(auth.session.access_token) ?? "none";
84+
}, [auth?.session?.access_token]);
85+
86+
const trialEnd = useMemo(() => {
87+
if (!auth?.session?.access_token) {
88+
return undefined;
89+
}
90+
return getTrialEndFromToken(auth.session.access_token);
91+
}, [auth?.session?.access_token]);
92+
4993
const isPro = useMemo(
5094
() => entitlements.includes("hyprnote_pro"),
5195
[entitlements],
5296
);
5397

98+
const isOnTrial = useMemo(
99+
() => subscriptionStatus === "trialing",
100+
[subscriptionStatus],
101+
);
102+
54103
const canTrialQuery = useQuery({
55104
enabled: !!auth?.session && !isPro,
56105
queryKey: [auth?.session?.user.id ?? "", "canStartTrial"],
@@ -82,10 +131,21 @@ export function BillingProvider({ children }: { children: ReactNode }) {
82131
() => ({
83132
entitlements,
84133
isPro,
134+
subscriptionStatus,
135+
isOnTrial,
136+
trialEnd,
85137
canStartTrial,
86138
upgradeToPro,
87139
}),
88-
[entitlements, isPro, canStartTrial, upgradeToPro],
140+
[
141+
entitlements,
142+
isPro,
143+
subscriptionStatus,
144+
isOnTrial,
145+
trialEnd,
146+
canStartTrial,
147+
upgradeToPro,
148+
],
89149
);
90150

91151
return (

apps/desktop/src/components/settings/general/account.tsx

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ const WEB_APP_BASE_URL = env.VITE_APP_URL ?? "http://localhost:3000";
1919

2020
export function AccountSettings() {
2121
const auth = useAuth();
22-
const { isPro } = useBillingAccess();
22+
const { isPro, isOnTrial, trialEnd } = useBillingAccess();
2323
const store = settings.UI.useStore(settings.STORE_ID);
2424

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

172172
<Container
173173
title="Plan & Billing"
174-
description={`Your current plan is ${isPro ? "PRO" : "FREE"}. `}
174+
description={`Your current plan is ${
175+
isOnTrial
176+
? `TRIAL${
177+
trialEnd
178+
? ` (ends ${new Date(trialEnd * 1000).toLocaleDateString()})`
179+
: ""
180+
}`
181+
: isPro
182+
? "PRO"
183+
: "FREE"
184+
}. `}
175185
action={<BillingButton />}
176186
>
177187
<p className="text-sm text-neutral-600">
@@ -191,7 +201,7 @@ export function AccountSettings() {
191201

192202
function BillingButton() {
193203
const auth = useAuth();
194-
const { isPro } = useBillingAccess();
204+
const { isPro, isOnTrial } = useBillingAccess();
195205
const { open: openTrialBeginModal } = useTrialBeginModal();
196206

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

265-
if (isPro) {
275+
if (isPro || isOnTrial) {
266276
return (
267277
<Button
268278
variant="outline"

packages/supabase/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,5 @@ export type { SupabaseClient } from "@supabase/supabase-js";
33

44
export { createRemoteJWKSet, jwtVerify } from "jose";
55

6-
export type { SupabaseJwtPayload } from "./jwt";
6+
export type { SubscriptionStatus, SupabaseJwtPayload } from "./jwt";
77
export { createJwksVerifier } from "./jwt";

packages/supabase/src/jwt.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,20 @@
11
import { createRemoteJWKSet, jwtVerify } from "jose";
22

3+
export type SubscriptionStatus =
4+
| "none"
5+
| "active"
6+
| "trialing"
7+
| "past_due"
8+
| "canceled"
9+
| "incomplete"
10+
| "incomplete_expired"
11+
| "unpaid";
12+
313
export type SupabaseJwtPayload = {
414
sub?: string;
515
entitlements?: string[];
16+
subscription_status?: SubscriptionStatus;
17+
trial_end?: number;
618
};
719

820
export type JwksVerifier = {
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
-- Add subscription status and trial information to JWT claims
2+
-- This allows the desktop app to distinguish between trial and paid pro users
3+
4+
CREATE OR REPLACE FUNCTION public.custom_access_token_hook(event jsonb)
5+
RETURNS jsonb
6+
LANGUAGE plpgsql
7+
STABLE
8+
AS $$
9+
DECLARE
10+
claims jsonb;
11+
entitlements jsonb := '[]'::jsonb;
12+
subscription_status text := 'none';
13+
trial_end_ts bigint := NULL;
14+
user_stripe_customer_id text;
15+
BEGIN
16+
-- Get user's stripe customer ID
17+
SELECT stripe_customer_id INTO user_stripe_customer_id
18+
FROM public.profiles
19+
WHERE id = (event->>'user_id')::uuid;
20+
21+
-- Get entitlements
22+
SELECT
23+
COALESCE(
24+
jsonb_agg(ae.lookup_key ORDER BY ae.lookup_key)
25+
FILTER (WHERE ae.lookup_key IS NOT NULL),
26+
'[]'::jsonb
27+
)
28+
INTO entitlements
29+
FROM stripe.active_entitlements ae
30+
WHERE ae.customer = user_stripe_customer_id;
31+
32+
-- Get subscription status and trial end date if exists
33+
IF user_stripe_customer_id IS NOT NULL THEN
34+
SELECT
35+
s.status,
36+
(s.trial_end #>> '{}')::bigint
37+
INTO subscription_status, trial_end_ts
38+
FROM stripe.subscriptions s
39+
WHERE s.customer = user_stripe_customer_id
40+
AND s.status IN ('active', 'trialing', 'past_due')
41+
ORDER BY s.created DESC
42+
LIMIT 1;
43+
44+
-- If no active subscription found, set status to 'none'
45+
IF subscription_status IS NULL THEN
46+
subscription_status := 'none';
47+
END IF;
48+
END IF;
49+
50+
-- Build claims
51+
claims := event->'claims';
52+
claims := jsonb_set(claims, '{entitlements}', entitlements);
53+
claims := jsonb_set(claims, '{subscription_status}', to_jsonb(subscription_status));
54+
55+
-- Only add trial_end if it exists
56+
IF trial_end_ts IS NOT NULL THEN
57+
claims := jsonb_set(claims, '{trial_end}', to_jsonb(trial_end_ts));
58+
END IF;
59+
60+
event := jsonb_set(event, '{claims}', claims);
61+
62+
RETURN event;
63+
END;
64+
$$;
65+
66+
-- Grant necessary permissions for the new subscription query
67+
GRANT SELECT ON TABLE stripe.subscriptions TO supabase_auth_admin;
68+
69+
CREATE POLICY "Allow auth admin to read subscriptions"
70+
ON stripe.subscriptions
71+
AS PERMISSIVE FOR SELECT
72+
TO supabase_auth_admin
73+
USING (true);

0 commit comments

Comments
 (0)