subscription routes

This commit is contained in:
2026-01-28 18:31:34 +00:00
parent 8d623315fb
commit 6cf7e79f20
8 changed files with 337 additions and 7 deletions

View File

@@ -26,10 +26,7 @@ export async function createSubscription(data: {
}
export async function getSubscriptionByUserId(userId: number) {
const [subscription] = await db
.select()
.from(Subscription)
.where(eq(Subscription.userId, userId));
const [subscription] = await db.select().from(Subscription).where(eq(Subscription.userId, userId));
return subscription;
}
@@ -51,7 +48,7 @@ export async function updateSubscription(
cancelAtPeriodEnd: boolean;
trialEnd: Date;
quantity: number;
}>
}>,
) {
const [subscription] = await db
.update(Subscription)

View File

@@ -1,7 +1,7 @@
import { stripe } from "../stripe/client";
import { getOrganisationsByUserId, getOrganisationMembers } from "../db/queries/organisations";
import { getOrganisationMembers, getOrganisationsByUserId } from "../db/queries/organisations";
import { getSubscriptionByUserId, updateSubscription } from "../db/queries/subscriptions";
import { getUserById } from "../db/queries/users";
import { stripe } from "../stripe/client";
export async function updateSeatCount(userId: number) {
const user = await getUserById(userId);

View File

@@ -6,6 +6,7 @@ import {
getOrganisationMemberRole,
getUserById,
} from "../../db/queries";
import { updateSeatCount } from "../../lib/seats";
import { errorResponse, parseJsonBody } from "../../validation";
export default async function organisationAddMember(req: AuthedRequest) {
@@ -40,5 +41,10 @@ export default async function organisationAddMember(req: AuthedRequest) {
const member = await createOrganisationMember(organisationId, userId, role);
// update seat count if the requester is the owner
if (requesterMember.role === "owner") {
await updateSeatCount(req.userId);
}
return Response.json(member);
}

View File

@@ -1,6 +1,7 @@
import { OrgRemoveMemberRequestSchema } from "@sprint/shared";
import type { AuthedRequest } from "../../auth/middleware";
import { getOrganisationById, getOrganisationMemberRole, removeOrganisationMember } from "../../db/queries";
import { updateSeatCount } from "../../lib/seats";
import { errorResponse, parseJsonBody } from "../../validation";
export default async function organisationRemoveMember(req: AuthedRequest) {
@@ -34,5 +35,10 @@ export default async function organisationRemoveMember(req: AuthedRequest) {
await removeOrganisationMember(organisationId, userId);
// update seat count if the requester is the owner
if (requesterMember.role === "owner") {
await updateSeatCount(req.userId);
}
return Response.json({ success: true });
}

View File

@@ -0,0 +1,81 @@
import type { BunRequest } from "bun";
import { 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";
import { errorResponse } from "../../validation";
const BASE_URL = process.env.FRONTEND_URL || "http://localhost:1420";
async function handler(req: BunRequest) {
if (req.method !== "POST") {
return errorResponse("method not allowed", "METHOD_NOT_ALLOWED", 405);
}
try {
const body = await req.json();
const { billingPeriod } = body as { billingPeriod: "monthly" | "annual" | undefined };
if (!billingPeriod) {
return errorResponse("missing required fields", "VALIDATION_ERROR", 400);
}
const userId = (req as any).userId;
const user = await getUserById(userId);
if (!user) {
return errorResponse("user not found", "NOT_FOUND", 404);
}
// calculate seat quantity across all owned organisations
const organisations = await getOrganisationsByUserId(userId);
const ownedOrgs = organisations.filter((o) => o.OrganisationMember.role === "owner");
let totalMembers = 0;
for (const org of ownedOrgs) {
const members = await getOrganisationMembers(org.Organisation.id);
totalMembers += members.length;
}
const quantity = Math.max(1, totalMembers - 4);
const priceId = billingPeriod === "annual" ? STRIPE_PRICE_ANNUAL : STRIPE_PRICE_MONTHLY;
// build customer data - use username as email if no email field exists
const customerEmail = user.username.includes("@")
? user.username
: `${user.username}@localhost.local`;
const session = await stripe.checkout.sessions.create({
customer_email: customerEmail,
line_items: [
{
price: priceId,
quantity: quantity,
},
],
mode: "subscription",
success_url: `${BASE_URL}/plans?success=true&session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${BASE_URL}/plans?canceled=true`,
subscription_data: {
metadata: {
userId: userId.toString(),
},
},
metadata: {
userId: userId.toString(),
priceId: priceId,
quantity: quantity.toString(),
},
});
return new Response(JSON.stringify({ url: session.url }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("checkout session error:", error);
return errorResponse("failed to create checkout session", "CHECKOUT_ERROR", 500);
}
}
export default withCors(withAuth(withCSRF(handler)));

View File

@@ -0,0 +1,36 @@
import type { BunRequest } from "bun";
import { 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) {
if (req.method !== "POST") {
return errorResponse("method not allowed", "METHOD_NOT_ALLOWED", 405);
}
try {
const userId = (req as any).userId;
const subscription = await getSubscriptionByUserId(userId);
if (!subscription?.stripeCustomerId) {
return errorResponse("no active subscription found", "NOT_FOUND", 404);
}
const portalSession = await stripe.billingPortal.sessions.create({
customer: subscription.stripeCustomerId,
return_url: `${BASE_URL}/plans`,
});
return new Response(JSON.stringify({ url: portalSession.url }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("portal session error:", error);
return errorResponse("failed to create portal session", "PORTAL_ERROR", 500);
}
}
export default withCors(withAuth(withCSRF(handler)));

View File

@@ -0,0 +1,25 @@
import type { BunRequest } from "bun";
import { withAuth, withCors } from "../../auth/middleware";
import { getSubscriptionByUserId } from "../../db/queries/subscriptions";
import { errorResponse } from "../../validation";
async function handler(req: BunRequest) {
if (req.method !== "GET") {
return errorResponse("method not allowed", "METHOD_NOT_ALLOWED", 405);
}
try {
const userId = (req as any).userId;
const subscription = await getSubscriptionByUserId(userId);
return new Response(JSON.stringify({ subscription }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("fetch subscription error:", error);
return errorResponse("failed to fetch subscription", "FETCH_ERROR", 500);
}
}
export default withCors(withAuth(handler));

View File

@@ -0,0 +1,179 @@
import type { BunRequest } from "bun";
import type Stripe from "stripe";
import {
createPayment,
createSubscription,
getSubscriptionByStripeId,
updateSubscription,
} from "../../db/queries/subscriptions";
import { updateUser } from "../../db/queries/users";
import { stripe } from "../../stripe/client";
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET || "";
export default async function webhook(req: BunRequest) {
if (req.method !== "POST") {
return new Response("Method not allowed", { status: 405 });
}
const payload = await req.text();
const signature = req.headers.get("stripe-signature");
if (!signature) {
return new Response("Missing signature", { status: 400 });
}
let event: Stripe.Event;
try {
// use async version for Bun compatibility
event = await stripe.webhooks.constructEventAsync(payload, signature, webhookSecret);
} catch (err) {
console.error("webhook signature verification failed:", err);
return new Response("Invalid signature", { status: 400 });
}
try {
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
if (session.mode !== "subscription" || !session.subscription) {
break;
}
const userId = parseInt(session.metadata?.userId || "0", 10);
if (!userId) {
console.error("missing userId in session metadata");
break;
}
// fetch full subscription to get item id
const stripeSubscription = await stripe.subscriptions.retrieve(
session.subscription as string,
);
if (!stripeSubscription) {
console.error("failed to retrieve subscription:", session.subscription);
break;
}
if (!stripeSubscription.items.data[0]) {
console.error("subscription has no items:", stripeSubscription.id);
break;
}
await createSubscription({
userId,
stripeCustomerId: session.customer as string,
stripeSubscriptionId: stripeSubscription.id,
stripeSubscriptionItemId: stripeSubscription.items.data[0].id,
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,
});
await updateUser(userId, { plan: "pro" });
console.log(`subscription activated for user ${userId}`);
break;
}
case "customer.subscription.updated": {
const subscription = event.data.object as Stripe.Subscription;
if (!subscription) {
console.error("failed to retrieve subscription (customer.subscription.updated)");
break;
}
if (!subscription.items.data[0]) {
console.error("subscription has no items:", subscription.id);
break;
}
const localSub = await getSubscriptionByStripeId(subscription.id);
if (!localSub) {
console.error("subscription not found:", subscription.id);
break;
}
// safely convert timestamps to dates
const currentPeriodStart = (subscription as any).current_period_start
? new Date((subscription as any).current_period_start * 1000)
: undefined;
const currentPeriodEnd = (subscription as any).current_period_end
? new Date((subscription as any).current_period_end * 1000)
: undefined;
await updateSubscription(localSub.id, {
status: subscription.status,
...(currentPeriodStart && { currentPeriodStart }),
...(currentPeriodEnd && { currentPeriodEnd }),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
quantity: subscription.items.data[0].quantity || 1,
});
console.log(`subscription updated: ${subscription.id}`);
break;
}
case "customer.subscription.deleted": {
const subscription = event.data.object as Stripe.Subscription;
const localSub = await getSubscriptionByStripeId(subscription.id);
if (!localSub) break;
// delete subscription from database
const { deleteSubscription } = await import("../../db/queries/subscriptions");
await deleteSubscription(localSub.id);
await updateUser(localSub.userId, { plan: "free" });
console.log(`subscription deleted: ${subscription.id}`);
break;
}
case "invoice.payment_succeeded": {
const invoice = event.data.object as Stripe.Invoice;
if (!(invoice as any).subscription) break;
const localSub = await getSubscriptionByStripeId((invoice as any).subscription as string);
if (!localSub) break;
await createPayment({
subscriptionId: localSub.id,
stripePaymentIntentId: (invoice as any).payment_intent as string,
amount: invoice.amount_paid,
currency: invoice.currency,
status: "succeeded",
});
console.log(`payment recorded for subscription ${(invoice as any).subscription}`);
break;
}
case "invoice.payment_failed": {
const invoice = event.data.object as Stripe.Invoice;
if (!(invoice as any).subscription) break;
const localSub = await getSubscriptionByStripeId((invoice as any).subscription as string);
if (!localSub) break;
await updateSubscription(localSub.id, { status: "past_due" });
console.log(`payment failed for subscription ${(invoice as any).subscription}`);
break;
}
}
return new Response(JSON.stringify({ received: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("webhook processing error:", error);
return new Response("Webhook handler failed", { status: 500 });
}
}