fix: proper cancellation handling

This commit is contained in:
2026-01-28 21:13:15 +00:00
parent 65964d64f6
commit d4cc50f289
12 changed files with 359 additions and 20 deletions

View File

@@ -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),
},

View File

@@ -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,
};

View File

@@ -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)));

View File

@@ -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,