diff --git a/packages/backend/src/db/queries/subscriptions.ts b/packages/backend/src/db/queries/subscriptions.ts index 389213e..a6732e3 100644 --- a/packages/backend/src/db/queries/subscriptions.ts +++ b/packages/backend/src/db/queries/subscriptions.ts @@ -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) diff --git a/packages/backend/src/lib/seats.ts b/packages/backend/src/lib/seats.ts index 0f4b563..9d4d018 100644 --- a/packages/backend/src/lib/seats.ts +++ b/packages/backend/src/lib/seats.ts @@ -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); diff --git a/packages/backend/src/routes/organisation/add-member.ts b/packages/backend/src/routes/organisation/add-member.ts index aec6224..8b60f66 100644 --- a/packages/backend/src/routes/organisation/add-member.ts +++ b/packages/backend/src/routes/organisation/add-member.ts @@ -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); } diff --git a/packages/backend/src/routes/organisation/remove-member.ts b/packages/backend/src/routes/organisation/remove-member.ts index 2497768..5d588b8 100644 --- a/packages/backend/src/routes/organisation/remove-member.ts +++ b/packages/backend/src/routes/organisation/remove-member.ts @@ -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 }); } diff --git a/packages/backend/src/routes/subscription/create-checkout-session.ts b/packages/backend/src/routes/subscription/create-checkout-session.ts new file mode 100644 index 0000000..baef7e2 --- /dev/null +++ b/packages/backend/src/routes/subscription/create-checkout-session.ts @@ -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))); diff --git a/packages/backend/src/routes/subscription/create-portal-session.ts b/packages/backend/src/routes/subscription/create-portal-session.ts new file mode 100644 index 0000000..2b8f66a --- /dev/null +++ b/packages/backend/src/routes/subscription/create-portal-session.ts @@ -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))); diff --git a/packages/backend/src/routes/subscription/get.ts b/packages/backend/src/routes/subscription/get.ts new file mode 100644 index 0000000..3b0473f --- /dev/null +++ b/packages/backend/src/routes/subscription/get.ts @@ -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)); diff --git a/packages/backend/src/routes/subscription/webhook.ts b/packages/backend/src/routes/subscription/webhook.ts new file mode 100644 index 0000000..fc67f0d --- /dev/null +++ b/packages/backend/src/routes/subscription/webhook.ts @@ -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 }); + } +}