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
53 changes: 51 additions & 2 deletions apps/desktop/src/billing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<JwtClaims>(accessToken);
return decoded.entitlements ?? [];
} catch {
return [];
}
}

export function getSubscriptionInfoFromToken(accessToken: string): {
status: "trialing" | "active" | null;
trialEnd: number | null;
} {
try {
const decoded = jwtDecode<JwtClaims>(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;
};

Expand All @@ -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`);
}, []);
Expand All @@ -53,9 +100,11 @@ export function BillingProvider({ children }: { children: ReactNode }) {
() => ({
entitlements,
isPro,
isTrialing,
trialDaysRemaining,
upgradeToPro,
}),
[entitlements, isPro, upgradeToPro],
[entitlements, isPro, isTrialing, trialDaysRemaining, upgradeToPro],
);

return (
Expand Down
24 changes: 22 additions & 2 deletions apps/desktop/src/components/settings/general/account.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -140,7 +160,7 @@ export function AccountSettings() {

<Container
title="Plan & Billing"
description={`Your current plan is ${isPro ? "PRO" : "FREE"}. `}
description={getPlanDescription(isPro, isTrialing, trialDaysRemaining)}
action={<BillingButton />}
>
<p className="text-sm text-neutral-600">
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
$$;
Loading