mirror of
https://github.com/hex248/sprint.git
synced 2026-02-07 18:23:03 +00:00
fix: proper cancellation handling
This commit is contained in:
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
68
packages/backend/src/routes/subscription/cancel.ts
Normal file
68
packages/backend/src/routes/subscription/cancel.ts
Normal 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)));
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user