Skip to content
Draft
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 @@ -24,9 +24,29 @@ import { useTrialBeginModal } from "../../devtool/trial-begin-modal";

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 @@ -156,7 +176,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
72 changes: 72 additions & 0 deletions apps/web/src/functions/billing-access.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { createServerFn } from "@tanstack/react-start";

import { getSupabaseServerClient } from "@/functions/supabase";

const PRO_ENTITLEMENT = "hyprnote_pro";

type JwtClaims = {
entitlements?: string[];
subscription_status?: "trialing" | "active";
trial_end?: number;
};

function decodeJwtPayload(accessToken: string): JwtClaims {
try {
const [, payloadBase64] = accessToken.split(".");
if (!payloadBase64) return {};

return JSON.parse(
Buffer.from(payloadBase64, "base64url").toString("utf-8"),
);
} catch {
return {};
}
}

export type BillingAccess = {
entitlements: string[];
isPro: boolean;
isTrialing: boolean;
trialDaysRemaining: number | null;
};

export const fetchBillingAccess = createServerFn({ method: "GET" }).handler(
async (): Promise<BillingAccess> => {
const supabase = getSupabaseServerClient();
const { data } = await supabase.auth.getSession();

if (!data.session?.access_token) {
return {
entitlements: [],
isPro: false,
isTrialing: false,
trialDaysRemaining: null,
};
}

const claims = decodeJwtPayload(data.session.access_token);
const entitlements = Array.isArray(claims.entitlements)
? claims.entitlements
: [];
const isPro = entitlements.includes(PRO_ENTITLEMENT);
const isTrialing = claims.subscription_status === "trialing";

let trialDaysRemaining: number | null = null;
if (claims.trial_end) {
const now = Math.floor(Date.now() / 1000);
const secondsRemaining = claims.trial_end - now;
if (secondsRemaining <= 0) {
trialDaysRemaining = 0;
} else {
trialDaysRemaining = Math.ceil(secondsRemaining / (24 * 60 * 60));
}
}

return {
entitlements,
isPro,
isTrialing,
trialDaysRemaining,
};
},
);
8 changes: 8 additions & 0 deletions apps/web/src/hooks/use-billing-access.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { useMatch } from "@tanstack/react-router";

import type { BillingAccess } from "@/functions/billing-access";

export function useBillingAccess(): BillingAccess {
const match = useMatch({ from: "/_view/app", shouldThrow: true });
return match.context.billingAccess;
}
59 changes: 33 additions & 26 deletions apps/web/src/routes/_view/app/account.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@ import {
canStartTrial,
createPortalSession,
createTrialCheckoutSession,
syncAfterSuccess,
} from "@/functions/billing";
import { useBillingAccess } from "@/hooks/use-billing-access";

export const Route = createFileRoute("/_view/app/account")({
component: Component,
loader: async ({ context }) => ({ user: context.user }),
loader: async ({ context }) => ({
user: context.user,
billingAccess: context.billingAccess,
}),
});

function Component() {
Expand Down Expand Up @@ -52,15 +55,33 @@ function Component() {
);
}

function getPlanDescription(
isPro: boolean,
isTrialing: boolean,
trialDaysRemaining: number | null,
): string {
if (!isPro) {
return "Free";
}
if (isTrialing && trialDaysRemaining !== null) {
if (trialDaysRemaining === 0) {
return "Trial (ends today)";
}
if (trialDaysRemaining === 1) {
return "Trial (ends tomorrow)";
}
return `Trial (${trialDaysRemaining} days left)`;
}
return "Pro";
}

function AccountSettingsCard() {
const billingQuery = useQuery({
queryKey: ["billing"],
queryFn: () => syncAfterSuccess(),
});
const { isPro, isTrialing, trialDaysRemaining } = useBillingAccess();

const canTrialQuery = useQuery({
queryKey: ["canStartTrial"],
queryFn: () => canStartTrial(),
enabled: !isPro,
});

const manageBillingMutation = useMutation({
Expand All @@ -81,26 +102,16 @@ function AccountSettingsCard() {
},
});

const currentPlan = (() => {
if (!billingQuery.data || billingQuery.data.status === "none") {
return "free";
}
const status = billingQuery.data.status;
if (status === "trialing") return "trial";
if (status === "active") return "pro";
return "free";
})();

const renderPlanButton = () => {
if (billingQuery.isLoading || canTrialQuery.isLoading) {
if (canTrialQuery.isLoading) {
return (
<div className="px-4 h-8 flex items-center text-sm text-neutral-400">
Loading...
</div>
);
}

if (currentPlan === "free") {
if (!isPro) {
if (canTrialQuery.data) {
return (
<button
Expand Down Expand Up @@ -135,13 +146,6 @@ function AccountSettingsCard() {
);
};

const getPlanDisplay = () => {
if (billingQuery.isLoading) return "...";
if (currentPlan === "trial") return "Trial";
if (currentPlan === "pro") return "Pro";
return "Free";
};

return (
<div className="border border-neutral-100 rounded-sm">
<div className="p-4">
Expand All @@ -155,7 +159,10 @@ function AccountSettingsCard() {

<div className="flex items-center justify-between border-t border-neutral-100 p-4">
<div className="text-sm">
Current plan: <span className="font-medium">{getPlanDisplay()}</span>
Current plan:{" "}
<span className="font-medium">
{getPlanDescription(isPro, isTrialing, trialDaysRemaining)}
</span>
</div>
{renderPlanButton()}
</div>
Expand Down
10 changes: 8 additions & 2 deletions apps/web/src/routes/_view/app/route.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import { createFileRoute, redirect } from "@tanstack/react-router";

import { fetchUser } from "@/functions/auth";
import { fetchBillingAccess } from "@/functions/billing-access";

export const Route = createFileRoute("/_view/app")({
beforeLoad: async ({ location }) => {
const user = await fetchUser();
const [user, billingAccess] = await Promise.all([
fetchUser(),
fetchBillingAccess(),
]);

if (!user) {
const searchStr =
Object.keys(location.search).length > 0
Expand All @@ -18,6 +23,7 @@ export const Route = createFileRoute("/_view/app")({
},
});
}
return { user };

return { user, billingAccess };
},
});
Loading
Loading