diff --git a/bun.lock b/bun.lock index 6224f0d..3657e1f 100644 --- a/bun.lock +++ b/bun.lock @@ -42,6 +42,7 @@ "@iconify/react": "^6.0.2", "@nsmr/pixelart-react": "^2.0.0", "@phosphor-icons/react": "^2.1.10", + "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", @@ -279,6 +280,8 @@ "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], + "@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw=="], + "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], @@ -837,6 +840,8 @@ "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], + "@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 82877cc..1d37a40 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -108,6 +108,7 @@ const main = async () => { "/subscription/create-portal-session": withGlobalAuthed( withAuth(withCSRF(routes.subscriptionCreatePortalSession)), ), + "/subscription/cancel": withGlobalAuthed(withAuth(withCSRF(routes.subscriptionCancel))), "/subscription/get": withGlobalAuthed(withAuth(routes.subscriptionGet)), "/subscription/webhook": withGlobal(routes.subscriptionWebhook), }, diff --git a/packages/backend/src/routes/index.ts b/packages/backend/src/routes/index.ts index d52e2b9..8ed144b 100644 --- a/packages/backend/src/routes/index.ts +++ b/packages/backend/src/routes/index.ts @@ -38,6 +38,7 @@ import sprintCreate from "./sprint/create"; import sprintDelete from "./sprint/delete"; import sprintUpdate from "./sprint/update"; import sprintsByProject from "./sprints/by-project"; +import subscriptionCancel from "./subscription/cancel"; import subscriptionCreateCheckoutSession from "./subscription/create-checkout-session"; import subscriptionCreatePortalSession from "./subscription/create-portal-session"; import subscriptionGet from "./subscription/get"; @@ -113,6 +114,7 @@ export const routes = { subscriptionCreateCheckoutSession, subscriptionCreatePortalSession, + subscriptionCancel, subscriptionGet, subscriptionWebhook, }; diff --git a/packages/backend/src/routes/subscription/cancel.ts b/packages/backend/src/routes/subscription/cancel.ts new file mode 100644 index 0000000..74806f0 --- /dev/null +++ b/packages/backend/src/routes/subscription/cancel.ts @@ -0,0 +1,68 @@ +import { type AuthedRequest, withAuth, withCors, withCSRF } from "../../auth/middleware"; +import { getSubscriptionByUserId, updateSubscription } from "../../db/queries/subscriptions"; +import { stripe } from "../../stripe/client"; +import { errorResponse } from "../../validation"; + +async function handler(req: AuthedRequest) { + if (req.method !== "POST") { + return errorResponse("method not allowed", "METHOD_NOT_ALLOWED", 405); + } + + try { + const { userId } = req; + const subscription = await getSubscriptionByUserId(userId); + if (!subscription?.stripeSubscriptionId) { + return errorResponse("no active subscription found", "NOT_FOUND", 404); + } + + const stripeCurrent = (await stripe.subscriptions.retrieve( + subscription.stripeSubscriptionId, + )) as unknown as { + status: string; + cancel_at_period_end: boolean | null; + current_period_end: number | null; + }; + + const currentPeriodEnd = stripeCurrent.current_period_end + ? new Date(stripeCurrent.current_period_end * 1000) + : undefined; + + if (stripeCurrent.status === "canceled" || stripeCurrent.cancel_at_period_end) { + const updated = await updateSubscription(subscription.id, { + status: stripeCurrent.status, + cancelAtPeriodEnd: stripeCurrent.cancel_at_period_end ?? subscription.cancelAtPeriodEnd, + ...(currentPeriodEnd && { currentPeriodEnd }), + }); + return new Response(JSON.stringify({ subscription: updated }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + const stripeSubscription = (await stripe.subscriptions.update(subscription.stripeSubscriptionId, { + cancel_at_period_end: true, + })) as unknown as { + status: string; + cancel_at_period_end: boolean | null; + current_period_end: number | null; + }; + + const updated = await updateSubscription(subscription.id, { + status: stripeSubscription.status, + cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end ?? true, + currentPeriodEnd: stripeSubscription.current_period_end + ? new Date(stripeSubscription.current_period_end * 1000) + : undefined, + }); + + return new Response(JSON.stringify({ subscription: updated }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (error) { + console.error("subscription cancel error:", error); + return errorResponse("failed to cancel subscription", "CANCEL_ERROR", 500); + } +} + +export default withCors(withAuth(withCSRF(handler))); diff --git a/packages/backend/src/routes/subscription/webhook.ts b/packages/backend/src/routes/subscription/webhook.ts index 4040ea5..92d4b8e 100644 --- a/packages/backend/src/routes/subscription/webhook.ts +++ b/packages/backend/src/routes/subscription/webhook.ts @@ -11,6 +11,15 @@ import { stripe } from "../../stripe/client"; const webhookSecret = requireEnv("STRIPE_WEBHOOK_SECRET"); +function toStripeDate(seconds: number | null | undefined, field: string) { + if (seconds === null || seconds === undefined) return undefined; + if (!Number.isFinite(seconds)) { + console.warn(`invalid ${field} timestamp:`, seconds); + return undefined; + } + return new Date(seconds * 1000); +} + function requireEnv(name: string): string { const value = process.env[name]; if (!value) { @@ -71,8 +80,8 @@ export default async function webhook(req: BunRequest) { // stripe types use snake_case for these fields const sub = stripeSubscription as unknown as { - current_period_start: number; - current_period_end: number; + current_period_start: number | null; + current_period_end: number | null; trial_end: number | null; }; @@ -84,9 +93,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(sub.current_period_start * 1000), - currentPeriodEnd: new Date(sub.current_period_end * 1000), - trialEnd: sub.trial_end ? new Date(sub.trial_end * 1000) : undefined, + currentPeriodStart: toStripeDate(sub.current_period_start, "current_period_start"), + currentPeriodEnd: toStripeDate(sub.current_period_end, "current_period_end"), + trialEnd: toStripeDate(sub.trial_end, "trial_end"), }); await updateUser(userId, { plan: "pro" }); @@ -117,12 +126,8 @@ export default async function webhook(req: BunRequest) { 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 = sub.current_period_end - ? new Date(sub.current_period_end * 1000) - : undefined; + const currentPeriodStart = toStripeDate(sub.current_period_start, "current_period_start"); + const currentPeriodEnd = toStripeDate(sub.current_period_end, "current_period_end"); await updateSubscription(localSub.id, { status: subscription.status, diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 711e00c..71458e7 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -14,6 +14,7 @@ "@ts-rest/core": "^3.52.1", "@nsmr/pixelart-react": "^2.0.0", "@phosphor-icons/react": "^2.1.10", + "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", diff --git a/packages/frontend/src/components/ui/alert-dialog.tsx b/packages/frontend/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..d7281f9 --- /dev/null +++ b/packages/frontend/src/components/ui/alert-dialog.tsx @@ -0,0 +1,131 @@ +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; +import type * as React from "react"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +function AlertDialog({ ...props }: React.ComponentProps) { + return ; +} + +function AlertDialogTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function AlertDialogPortal({ ...props }: React.ComponentProps) { + return ; +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + ); +} + +function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogTitle({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +type AlertDialogActionProps = React.ComponentProps & + Omit, "asChild">; + +function AlertDialogAction({ className, ...props }: AlertDialogActionProps) { + return ( + +
+ {user && isProUser && ( +
+
+
+

Cancel subscription

+

+ {isCancellationScheduled || isCanceled + ? `Cancelled, benefits end on ${cancellationEndDate ?? "your billing end date"}.` + : "Canceling will keep access until the end of your billing period."} +

+
+ { + setCancelDialogOpen(open); + if (!open) setCancelError(null); + }} + > + + + + + + Cancel subscription? + + You will keep Pro access until the end of your current billing period. + + + + Keep subscription + + {cancelSubscription.isPending ? "Canceling..." : "Confirm cancel"} + + + {cancelError &&

{cancelError}

} +
+
+
+
+ )} + {/* trust signals */}
diff --git a/packages/shared/src/api-schemas.ts b/packages/shared/src/api-schemas.ts index fbd614d..e505e11 100644 --- a/packages/shared/src/api-schemas.ts +++ b/packages/shared/src/api-schemas.ts @@ -647,3 +647,9 @@ export const GetSubscriptionResponseSchema = z.object({ }); export type GetSubscriptionResponse = z.infer; + +export const CancelSubscriptionResponseSchema = z.object({ + subscription: SubscriptionRecordSchema, +}); + +export type CancelSubscriptionResponse = z.infer; diff --git a/packages/shared/src/contract.ts b/packages/shared/src/contract.ts index 3141c29..90e8079 100644 --- a/packages/shared/src/contract.ts +++ b/packages/shared/src/contract.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { ApiErrorSchema, AuthResponseSchema, + CancelSubscriptionResponseSchema, CreateCheckoutSessionRequestSchema, CreateCheckoutSessionResponseSchema, CreatePortalSessionResponseSchema, @@ -628,6 +629,17 @@ export const apiContract = c.router({ }, headers: csrfHeaderSchema, }, + subscriptionCancel: { + method: "POST", + path: "/subscription/cancel", + body: emptyBodySchema, + responses: { + 200: CancelSubscriptionResponseSchema, + 404: ApiErrorSchema, + 500: ApiErrorSchema, + }, + headers: csrfHeaderSchema, + }, subscriptionGet: { method: "GET", path: "/subscription/get", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 108dc0a..fc80037 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,6 +1,7 @@ export type { ApiError, AuthResponse, + CancelSubscriptionResponse, CreateCheckoutSessionRequest, CreateCheckoutSessionResponse, CreatePortalSessionResponse, @@ -67,6 +68,7 @@ export type { export { ApiErrorSchema, AuthResponseSchema, + CancelSubscriptionResponseSchema, CreateCheckoutSessionRequestSchema, CreateCheckoutSessionResponseSchema, CreatePortalSessionResponseSchema,