diff --git a/packages/backend/src/lib/seats.ts b/packages/backend/src/lib/seats.ts index c1266ad..6f444a4 100644 --- a/packages/backend/src/lib/seats.ts +++ b/packages/backend/src/lib/seats.ts @@ -33,8 +33,13 @@ export async function updateSeatCount(userId: number) { return; } + const stripeSubscriptionItemId = subscription.stripeSubscriptionItemId; + if (!stripeSubscriptionItemId) { + return; + } + // update stripe - await stripe.subscriptionItems.update(subscription.stripeSubscriptionItemId!, { + await stripe.subscriptionItems.update(stripeSubscriptionItemId, { quantity: newQuantity, proration_behavior: "always_invoice", }); diff --git a/packages/backend/src/routes/subscription/create-checkout-session.ts b/packages/backend/src/routes/subscription/create-checkout-session.ts index 4faf1bc..b0e72d0 100644 --- a/packages/backend/src/routes/subscription/create-checkout-session.ts +++ b/packages/backend/src/routes/subscription/create-checkout-session.ts @@ -1,5 +1,4 @@ -import type { BunRequest } from "bun"; -import { withAuth, withCors, withCSRF } from "../../auth/middleware"; +import { type AuthedRequest, withAuth, withCors, withCSRF } from "../../auth/middleware"; import { getOrganisationMembers, getOrganisationsByUserId } from "../../db/queries/organisations"; import { getUserById } from "../../db/queries/users"; import { STRIPE_PRICE_ANNUAL, STRIPE_PRICE_MONTHLY, stripe } from "../../stripe/client"; @@ -7,7 +6,7 @@ import { errorResponse } from "../../validation"; const BASE_URL = process.env.FRONTEND_URL || "http://localhost:1420"; -async function handler(req: BunRequest) { +async function handler(req: AuthedRequest) { if (req.method !== "POST") { return errorResponse("method not allowed", "METHOD_NOT_ALLOWED", 405); } @@ -20,7 +19,7 @@ async function handler(req: BunRequest) { return errorResponse("missing required fields", "VALIDATION_ERROR", 400); } - const userId = (req as any).userId; + const { userId } = req; const user = await getUserById(userId); if (!user) { diff --git a/packages/backend/src/routes/subscription/create-portal-session.ts b/packages/backend/src/routes/subscription/create-portal-session.ts index 2b8f66a..09beeb9 100644 --- a/packages/backend/src/routes/subscription/create-portal-session.ts +++ b/packages/backend/src/routes/subscription/create-portal-session.ts @@ -1,18 +1,17 @@ -import type { BunRequest } from "bun"; -import { withAuth, withCors, withCSRF } from "../../auth/middleware"; +import { type AuthedRequest, withAuth, withCors, withCSRF } from "../../auth/middleware"; import { getSubscriptionByUserId } from "../../db/queries/subscriptions"; import { stripe } from "../../stripe/client"; import { errorResponse } from "../../validation"; const BASE_URL = process.env.FRONTEND_URL || "http://localhost:1420"; -async function handler(req: BunRequest) { +async function handler(req: AuthedRequest) { if (req.method !== "POST") { return errorResponse("method not allowed", "METHOD_NOT_ALLOWED", 405); } try { - const userId = (req as any).userId; + const { userId } = req; const subscription = await getSubscriptionByUserId(userId); if (!subscription?.stripeCustomerId) { return errorResponse("no active subscription found", "NOT_FOUND", 404); diff --git a/packages/backend/src/routes/subscription/get.ts b/packages/backend/src/routes/subscription/get.ts index 3b0473f..59abaa5 100644 --- a/packages/backend/src/routes/subscription/get.ts +++ b/packages/backend/src/routes/subscription/get.ts @@ -1,15 +1,14 @@ -import type { BunRequest } from "bun"; -import { withAuth, withCors } from "../../auth/middleware"; +import { type AuthedRequest, withAuth, withCors } from "../../auth/middleware"; import { getSubscriptionByUserId } from "../../db/queries/subscriptions"; import { errorResponse } from "../../validation"; -async function handler(req: BunRequest) { +async function handler(req: AuthedRequest) { if (req.method !== "GET") { return errorResponse("method not allowed", "METHOD_NOT_ALLOWED", 405); } try { - const userId = (req as any).userId; + const { userId } = req; const subscription = await getSubscriptionByUserId(userId); return new Response(JSON.stringify({ subscription }), { diff --git a/packages/backend/src/routes/subscription/webhook.ts b/packages/backend/src/routes/subscription/webhook.ts index fc67f0d..4040ea5 100644 --- a/packages/backend/src/routes/subscription/webhook.ts +++ b/packages/backend/src/routes/subscription/webhook.ts @@ -9,7 +9,15 @@ import { import { updateUser } from "../../db/queries/users"; import { stripe } from "../../stripe/client"; -const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET || ""; +const webhookSecret = requireEnv("STRIPE_WEBHOOK_SECRET"); + +function requireEnv(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`${name} is required`); + } + return value; +} export default async function webhook(req: BunRequest) { if (req.method !== "POST") { @@ -61,6 +69,13 @@ export default async function webhook(req: BunRequest) { break; } + // stripe types use snake_case for these fields + const sub = stripeSubscription as unknown as { + current_period_start: number; + current_period_end: number; + trial_end: number | null; + }; + await createSubscription({ userId, stripeCustomerId: session.customer as string, @@ -69,11 +84,9 @@ export default async function webhook(req: BunRequest) { stripePriceId: session.metadata?.priceId || "", status: stripeSubscription.status, quantity: parseInt(session.metadata?.quantity || "1", 10), - currentPeriodStart: new Date((stripeSubscription as any).current_period_start * 1000), - currentPeriodEnd: new Date((stripeSubscription as any).current_period_end * 1000), - trialEnd: stripeSubscription.trial_end - ? new Date(stripeSubscription.trial_end * 1000) - : undefined, + currentPeriodStart: new Date(sub.current_period_start * 1000), + currentPeriodEnd: new Date(sub.current_period_end * 1000), + trialEnd: sub.trial_end ? new Date(sub.trial_end * 1000) : undefined, }); await updateUser(userId, { plan: "pro" }); @@ -99,11 +112,16 @@ export default async function webhook(req: BunRequest) { break; } // safely convert timestamps to dates - const currentPeriodStart = (subscription as any).current_period_start - ? new Date((subscription as any).current_period_start * 1000) + // stripe types use snake_case for these fields + const sub = subscription as unknown as { + current_period_start: number | null; + current_period_end: number | null; + }; + const currentPeriodStart = sub.current_period_start + ? new Date(sub.current_period_start * 1000) : undefined; - const currentPeriodEnd = (subscription as any).current_period_end - ? new Date((subscription as any).current_period_end * 1000) + const currentPeriodEnd = sub.current_period_end + ? new Date(sub.current_period_end * 1000) : undefined; await updateSubscription(localSub.id, { @@ -136,34 +154,45 @@ export default async function webhook(req: BunRequest) { case "invoice.payment_succeeded": { const invoice = event.data.object as Stripe.Invoice; - if (!(invoice as any).subscription) break; + // stripe types use snake_case for these fields + const inv = invoice as unknown as { + subscription: string | null; + payment_intent: string | null; + }; - const localSub = await getSubscriptionByStripeId((invoice as any).subscription as string); + if (!inv.subscription) break; + + const localSub = await getSubscriptionByStripeId(inv.subscription); if (!localSub) break; await createPayment({ subscriptionId: localSub.id, - stripePaymentIntentId: (invoice as any).payment_intent as string, + stripePaymentIntentId: inv.payment_intent || "", amount: invoice.amount_paid, currency: invoice.currency, status: "succeeded", }); - console.log(`payment recorded for subscription ${(invoice as any).subscription}`); + console.log(`payment recorded for subscription ${inv.subscription}`); break; } case "invoice.payment_failed": { const invoice = event.data.object as Stripe.Invoice; - if (!(invoice as any).subscription) break; + // stripe types use snake_case for these fields + const inv = invoice as unknown as { + subscription: string | null; + }; - const localSub = await getSubscriptionByStripeId((invoice as any).subscription as string); + if (!inv.subscription) break; + + const localSub = await getSubscriptionByStripeId(inv.subscription); if (!localSub) break; await updateSubscription(localSub.id, { status: "past_due" }); - console.log(`payment failed for subscription ${(invoice as any).subscription}`); + console.log(`payment failed for subscription ${inv.subscription}`); break; } } diff --git a/packages/backend/src/stripe/client.ts b/packages/backend/src/stripe/client.ts index 8fab7e0..1700678 100644 --- a/packages/backend/src/stripe/client.ts +++ b/packages/backend/src/stripe/client.ts @@ -1,14 +1,18 @@ import Stripe from "stripe"; -const stripeSecretKey = process.env.STRIPE_SECRET_KEY; - -if (!stripeSecretKey) { - throw new Error("STRIPE_SECRET_KEY is required"); -} +const stripeSecretKey = requireEnv("STRIPE_SECRET_KEY"); export const stripe = new Stripe(stripeSecretKey, { - apiVersion: "2024-12-18.acacia", + apiVersion: "2025-12-15.clover", }); -export const STRIPE_PRICE_MONTHLY = process.env.STRIPE_PRICE_MONTHLY!; -export const STRIPE_PRICE_ANNUAL = process.env.STRIPE_PRICE_ANNUAL!; +export const STRIPE_PRICE_MONTHLY = requireEnv("STRIPE_PRICE_MONTHLY"); +export const STRIPE_PRICE_ANNUAL = requireEnv("STRIPE_PRICE_ANNUAL"); + +function requireEnv(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`${name} is required`); + } + return value; +} diff --git a/packages/frontend/src/lib/query/hooks/index.ts b/packages/frontend/src/lib/query/hooks/index.ts index c548710..b998c4e 100644 --- a/packages/frontend/src/lib/query/hooks/index.ts +++ b/packages/frontend/src/lib/query/hooks/index.ts @@ -4,5 +4,6 @@ export * from "@/lib/query/hooks/issues"; export * from "@/lib/query/hooks/organisations"; export * from "@/lib/query/hooks/projects"; export * from "@/lib/query/hooks/sprints"; +export * from "@/lib/query/hooks/subscriptions"; export * from "@/lib/query/hooks/timers"; export * from "@/lib/query/hooks/users"; diff --git a/packages/frontend/src/lib/query/hooks/subscriptions.ts b/packages/frontend/src/lib/query/hooks/subscriptions.ts new file mode 100644 index 0000000..1ad0e29 --- /dev/null +++ b/packages/frontend/src/lib/query/hooks/subscriptions.ts @@ -0,0 +1,44 @@ +import type { + CreateCheckoutSessionRequest, + CreateCheckoutSessionResponse, + CreatePortalSessionResponse, + GetSubscriptionResponse, +} from "@sprint/shared"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { queryKeys } from "@/lib/query/keys"; +import { apiClient } from "@/lib/server"; + +export function useSubscription() { + return useQuery({ + queryKey: queryKeys.subscription.current(), + queryFn: async () => { + const { data, error } = await apiClient.subscriptionGet(); + if (error) throw new Error(error); + return (data ?? { subscription: null }) as GetSubscriptionResponse; + }, + }); +} + +export function useCreateCheckoutSession() { + return useMutation({ + mutationKey: ["subscription", "checkout"], + mutationFn: async (input) => { + const { data, error } = await apiClient.subscriptionCreateCheckoutSession({ body: input }); + if (error) throw new Error(error); + if (!data) throw new Error("failed to create checkout session"); + return data as CreateCheckoutSessionResponse; + }, + }); +} + +export function useCreatePortalSession() { + return useMutation({ + mutationKey: ["subscription", "portal"], + mutationFn: async () => { + const { data, error } = await apiClient.subscriptionCreatePortalSession({ body: {} }); + if (error) throw new Error(error); + if (!data) throw new Error("failed to create portal session"); + return data as CreatePortalSessionResponse; + }, + }); +} diff --git a/packages/frontend/src/lib/query/keys.ts b/packages/frontend/src/lib/query/keys.ts index 2c5903e..19f3782 100644 --- a/packages/frontend/src/lib/query/keys.ts +++ b/packages/frontend/src/lib/query/keys.ts @@ -39,4 +39,8 @@ export const queryKeys = { all: ["users"] as const, byUsername: (username: string) => [...queryKeys.users.all, "by-username", username] as const, }, + subscription: { + all: ["subscription"] as const, + current: () => [...queryKeys.subscription.all, "current"] as const, + }, }; diff --git a/packages/frontend/src/lib/server/subscription/createCheckoutSession.ts b/packages/frontend/src/lib/server/subscription/createCheckoutSession.ts deleted file mode 100644 index a6e88eb..0000000 --- a/packages/frontend/src/lib/server/subscription/createCheckoutSession.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { getServerURL } from "@/lib/utils"; - -interface CreateCheckoutParams { - billingPeriod: "monthly" | "annual"; - csrfToken: string; - onSuccess?: (url: string) => void; - onError?: (error: string) => void; -} - -export async function createCheckoutSession({ - billingPeriod, - csrfToken, - onSuccess, - onError, -}: CreateCheckoutParams) { - try { - const response = await fetch(`${getServerURL()}/subscription/create-checkout-session`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-CSRF-Token": csrfToken, - }, - credentials: "include", - body: JSON.stringify({ billingPeriod }), - }); - - const data = await response.json(); - - if (!response.ok) { - onError?.(data.error || "Failed to create checkout session"); - return; - } - - onSuccess?.(data.url); - } catch (error) { - onError?.("Network error"); - } -} diff --git a/packages/frontend/src/lib/server/subscription/createPortalSession.ts b/packages/frontend/src/lib/server/subscription/createPortalSession.ts deleted file mode 100644 index 9395f19..0000000 --- a/packages/frontend/src/lib/server/subscription/createPortalSession.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { getServerURL } from "@/lib/utils"; - -interface CreatePortalParams { - csrfToken: string; - onSuccess?: (url: string) => void; - onError?: (error: string) => void; -} - -export async function createPortalSession({ csrfToken, onSuccess, onError }: CreatePortalParams) { - try { - const response = await fetch(`${getServerURL()}/subscription/create-portal-session`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-CSRF-Token": csrfToken, - }, - credentials: "include", - }); - - const data = await response.json(); - - if (!response.ok) { - onError?.(data.error || "Failed to create portal session"); - return; - } - - onSuccess?.(data.url); - } catch (error) { - onError?.("Network error"); - } -} diff --git a/packages/frontend/src/lib/server/subscription/getSubscription.ts b/packages/frontend/src/lib/server/subscription/getSubscription.ts deleted file mode 100644 index e23d3c3..0000000 --- a/packages/frontend/src/lib/server/subscription/getSubscription.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { SubscriptionRecord } from "@sprint/shared"; -import { getServerURL } from "@/lib/utils"; - -interface GetSubscriptionParams { - onSuccess?: (subscription: SubscriptionRecord | null) => void; - onError?: (error: string) => void; -} - -export async function getSubscription({ onSuccess, onError }: GetSubscriptionParams) { - try { - const response = await fetch(`${getServerURL()}/subscription/get`, { - method: "GET", - credentials: "include", - }); - - const data = await response.json(); - - if (!response.ok) { - onError?.(data.error || "Failed to fetch subscription"); - return; - } - - onSuccess?.(data.subscription); - } catch (error) { - onError?.("Network error"); - } -} diff --git a/packages/frontend/src/pages/Plans.tsx b/packages/frontend/src/pages/Plans.tsx index 62d146a..b0bf507 100644 --- a/packages/frontend/src/pages/Plans.tsx +++ b/packages/frontend/src/pages/Plans.tsx @@ -1,3 +1,4 @@ +import type { SubscriptionResponse } from "@sprint/shared"; import { useEffect, useState } from "react"; import { Link, useNavigate } from "react-router-dom"; import { LoginModal } from "@/components/login-modal"; @@ -9,44 +10,37 @@ import { Switch } from "@/components/ui/switch"; import { createCheckoutSession } from "@/lib/server/subscription/createCheckoutSession"; import { createPortalSession } from "@/lib/server/subscription/createPortalSession"; import { getSubscription } from "@/lib/server/subscription/getSubscription"; -import { cn, getCsrfToken } from "@/lib/utils"; - -interface SubscriptionData { - status: string; - currentPeriodEnd: Date | null; - quantity: number; -} +import { cn } from "@/lib/utils"; export default function Plans() { const { user, isLoading } = useSession(); const navigate = useNavigate(); const [billingPeriod, setBillingPeriod] = useState<"monthly" | "annual">("annual"); const [loginModalOpen, setLoginModalOpen] = useState(false); - const [subscription, setSubscription] = useState(null); - const [loadingSubscription, setLoadingSubscription] = useState(false); + const [subscription, setSubscription] = useState(null); const [processingTier, setProcessingTier] = useState(null); // fetch subscription if user is logged in useEffect(() => { if (user) { - setLoadingSubscription(true); - getSubscription({ - onSuccess: (data) => { - setSubscription(data); - setLoadingSubscription(false); - }, - onError: () => { + getSubscription() + .then((result) => { + const data = result.data as { subscription?: SubscriptionResponse } | null; + if (data?.subscription) { + setSubscription(data.subscription); + } else { + setSubscription(null); + } + }) + .catch(() => { setSubscription(null); - setLoadingSubscription(false); - }, - }); + }); } }, [user]); const hasProSubscription = subscription?.status === "active"; - const csrfToken = getCsrfToken() || ""; - const handleTierAction = (tierName: string) => { + const handleTierAction = async (tierName: string) => { if (!user) { setLoginModalOpen(true); return; @@ -56,28 +50,23 @@ export default function Plans() { if (hasProSubscription) { // open customer portal setProcessingTier(tierName); - createPortalSession({ - csrfToken, - onSuccess: (url) => { - window.location.href = url; - }, - onError: () => { - setProcessingTier(null); - }, - }); + const result = await createPortalSession(); + const portalData = result.data as { url?: string } | null; + if (portalData?.url) { + window.location.href = portalData.url; + } else { + setProcessingTier(null); + } } else { // start checkout setProcessingTier(tierName); - createCheckoutSession({ - billingPeriod, - csrfToken, - onSuccess: (url) => { - window.location.href = url; - }, - onError: () => { - setProcessingTier(null); - }, - }); + const result = await createCheckoutSession({ billingPeriod }); + const checkoutData = result.data as { url?: string } | null; + if (checkoutData?.url) { + window.location.href = checkoutData.url; + } else { + setProcessingTier(null); + } } } // starter tier - just go to issues if not already there @@ -220,6 +209,33 @@ export default function Plans() {

30-day no-risk policy

+ + {/* FAQ */} +
+

Frequently Asked Questions

+
+
+

Can I switch plans?

+

+ Yes, you can upgrade or downgrade at any time. Changes take effect immediately. +

+
+
+

What happens when I add team members?

+

+ Pro plan pricing scales with your team. Add or remove users anytime, and we'll adjust your + billing automatically. +

+
+
+

Can I cancel my subscription?

+

+ Absolutely. Cancel anytime with no questions asked. You'll keep access until the end of your + billing period. +

+
+
+
diff --git a/packages/shared/src/api-schemas.ts b/packages/shared/src/api-schemas.ts index e6ac4d0..fbd614d 100644 --- a/packages/shared/src/api-schemas.ts +++ b/packages/shared/src/api-schemas.ts @@ -602,3 +602,48 @@ export const SuccessResponseSchema = z.object({ }); export type SuccessResponse = z.infer; + +// subscription schemas + +export const CreateCheckoutSessionRequestSchema = z.object({ + billingPeriod: z.enum(["monthly", "annual"]), +}); + +export type CreateCheckoutSessionRequest = z.infer; + +export const CreateCheckoutSessionResponseSchema = z.object({ + url: z.string(), +}); + +export type CreateCheckoutSessionResponse = z.infer; + +export const CreatePortalSessionResponseSchema = z.object({ + url: z.string(), +}); + +export type CreatePortalSessionResponse = z.infer; + +export const SubscriptionRecordSchema = z.object({ + id: z.number(), + userId: z.number(), + stripeCustomerId: z.string().nullable(), + stripeSubscriptionId: z.string().nullable(), + stripeSubscriptionItemId: z.string().nullable(), + stripePriceId: z.string().nullable(), + status: z.string(), + currentPeriodStart: z.string().nullable().optional(), + currentPeriodEnd: z.string().nullable().optional(), + cancelAtPeriodEnd: z.boolean(), + trialEnd: z.string().nullable().optional(), + quantity: z.number(), + createdAt: z.string().nullable().optional(), + updatedAt: z.string().nullable().optional(), +}); + +export type SubscriptionRecord = z.infer; + +export const GetSubscriptionResponseSchema = z.object({ + subscription: SubscriptionRecordSchema.nullable(), +}); + +export type GetSubscriptionResponse = z.infer; diff --git a/packages/shared/src/contract.ts b/packages/shared/src/contract.ts index 139f95e..3141c29 100644 --- a/packages/shared/src/contract.ts +++ b/packages/shared/src/contract.ts @@ -3,6 +3,10 @@ import { z } from "zod"; import { ApiErrorSchema, AuthResponseSchema, + CreateCheckoutSessionRequestSchema, + CreateCheckoutSessionResponseSchema, + CreatePortalSessionResponseSchema, + GetSubscriptionResponseSchema, IssueByIdQuerySchema, IssueCommentCreateRequestSchema, IssueCommentDeleteRequestSchema, @@ -600,6 +604,38 @@ export const apiContract = c.router({ 200: z.array(timerListItemResponseSchema), }, }, + + subscriptionCreateCheckoutSession: { + method: "POST", + path: "/subscription/create-checkout-session", + body: CreateCheckoutSessionRequestSchema, + responses: { + 200: CreateCheckoutSessionResponseSchema, + 400: ApiErrorSchema, + 404: ApiErrorSchema, + 500: ApiErrorSchema, + }, + headers: csrfHeaderSchema, + }, + subscriptionCreatePortalSession: { + method: "POST", + path: "/subscription/create-portal-session", + body: emptyBodySchema, + responses: { + 200: CreatePortalSessionResponseSchema, + 404: ApiErrorSchema, + 500: ApiErrorSchema, + }, + headers: csrfHeaderSchema, + }, + subscriptionGet: { + method: "GET", + path: "/subscription/get", + responses: { + 200: GetSubscriptionResponseSchema, + 500: ApiErrorSchema, + }, + }, }); export type ApiContract = typeof apiContract; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 587882e..108dc0a 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,6 +1,10 @@ export type { ApiError, AuthResponse, + CreateCheckoutSessionRequest, + CreateCheckoutSessionResponse, + CreatePortalSessionResponse, + GetSubscriptionResponse, IssueByIdQuery, IssueCommentCreateRequest, IssueCommentDeleteRequest, @@ -46,6 +50,7 @@ export type { SprintsByProjectQuery, SprintUpdateRequest, StatusCountResponse, + SubscriptionRecord as SubscriptionResponse, SuccessResponse, TimerEndRequest, TimerGetQuery, @@ -62,6 +67,10 @@ export type { export { ApiErrorSchema, AuthResponseSchema, + CreateCheckoutSessionRequestSchema, + CreateCheckoutSessionResponseSchema, + CreatePortalSessionResponseSchema, + GetSubscriptionResponseSchema, IssueByIdQuerySchema, IssueCommentCreateRequestSchema, IssueCommentDeleteRequestSchema, @@ -110,6 +119,7 @@ export { SprintsByProjectQuerySchema, SprintUpdateRequestSchema, StatusCountResponseSchema, + SubscriptionRecordSchema as SubscriptionRecordApiSchema, SuccessResponseSchema, TimerEndRequestSchema, TimerGetQuerySchema, @@ -165,7 +175,7 @@ export type { SprintInsert, SprintRecord, SubscriptionInsert, - SubscriptionRecord, + SubscriptionRecord as SubscriptionRecordType, TimedSessionInsert, TimedSessionRecord, TimerState,