From bc98fc848f3d5454ec201bc45505385decb4f760 Mon Sep 17 00:00:00 2001 From: Henry Date: Tue, 2 Sep 2025 14:05:35 +0100 Subject: [PATCH] feat: update subscription when increasing/decreasing workspace members --- packages/api/src/routers/member.ts | 79 +++++++++++++++++++++++++----- packages/api/src/trpc.ts | 6 +-- packages/stripe/src/index.ts | 55 +++++++++++++++++++++ 3 files changed, 123 insertions(+), 17 deletions(-) diff --git a/packages/api/src/routers/member.ts b/packages/api/src/routers/member.ts index 7056361f..1a245a75 100644 --- a/packages/api/src/routers/member.ts +++ b/packages/api/src/routers/member.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import * as memberRepo from "@kan/db/repository/member.repo"; import * as userRepo from "@kan/db/repository/user.repo"; import * as workspaceRepo from "@kan/db/repository/workspace.repo"; +import { updateSubscriptionSeats } from "@kan/stripe"; import { createTRPCRouter, protectedProcedure } from "../trpc"; import { assertUserInWorkspace } from "../utils/auth"; @@ -60,6 +61,42 @@ export const memberRouter = createTRPCRouter({ }); } + if (process.env.NEXT_PUBLIC_KAN_ENV === "cloud") { + const subscriptions = await ctx.auth.api.listActiveSubscriptions({ + workspacePublicId: workspace.publicId, + }); + + // get the active subscription + const activeSubscription = subscriptions.find( + (sub) => + sub.status === "active" || + (sub.status === "trialing" && sub.plan === "team"), + ); + + if (!activeSubscription) { + throw new TRPCError({ + message: `Workspace with public ID ${workspace.publicId} does not have an active subscription`, + code: "NOT_FOUND", + }); + } + + // Update the Stripe subscription to add a seat with immediate proration + if (activeSubscription.stripeSubscriptionId) { + try { + await updateSubscriptionSeats( + activeSubscription.stripeSubscriptionId, + 1, + ); + } catch (error) { + console.error("Failed to update Stripe subscription seats:", error); + throw new TRPCError({ + message: `Failed to update subscription for the new member.`, + code: "INTERNAL_SERVER_ERROR", + }); + } + } + } + const existingUser = await userRepo.getByEmail(ctx.db, input.email); const invite = await memberRepo.create(ctx.db, { @@ -100,19 +137,6 @@ export const memberRouter = createTRPCRouter({ }); } - if (process.env.NEXT_PUBLIC_KAN_ENV === "cloud") { - const subscriptions = await ctx.auth.api.listActiveSubscriptions({ - userId, - }); - - // get the active subscription - const activeSubscription = subscriptions.find( - (sub) => sub.status === "active" || sub.status === "trialing", - ); - - // @todo: update the subscription with the new seat - } - return invite; }), delete: protectedProcedure @@ -178,6 +202,35 @@ export const memberRouter = createTRPCRouter({ code: "INTERNAL_SERVER_ERROR", }); + // Handle subscription seat decrement for cloud environment + if (process.env.NEXT_PUBLIC_KAN_ENV === "cloud") { + const subscriptions = await ctx.auth.api.listActiveSubscriptions({ + workspacePublicId: workspace.publicId, + }); + + // get the active subscription + const activeSubscription = subscriptions.find( + (sub) => + sub.status === "active" || + (sub.status === "trialing" && sub.plan === "team"), + ); + + // Only decrease seats if there's an active subscription and stripeSubscriptionId + if (activeSubscription?.stripeSubscriptionId) { + try { + await updateSubscriptionSeats( + activeSubscription.stripeSubscriptionId, + -1, + ); + } catch (error) { + console.error( + "Failed to decrease Stripe subscription seats:", + error, + ); + } + } + } + return { success: true }; }), }); diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts index 11e58998..d8032486 100644 --- a/packages/api/src/trpc.ts +++ b/packages/api/src/trpc.ts @@ -28,16 +28,14 @@ const createAuthWithHeaders = ( api: { getSession: () => auth.api.getSession({ headers }), signInMagicLink: (input: { email: string; callbackURL: string }) => - // @ts-expect-error - types need fixing auth.api.signInMagicLink({ headers, body: { email: input.email, callbackURL: input.callbackURL }, }), - listActiveSubscriptions: (input: { userId: string }) => - // @ts-expect-error - types need fixing + listActiveSubscriptions: (input: { workspacePublicId: string }) => auth.api.listActiveSubscriptions({ headers, - query: { referenceId: input.userId }, + query: { referenceId: input.workspacePublicId }, }), }, }; diff --git a/packages/stripe/src/index.ts b/packages/stripe/src/index.ts index 92b5f989..a2cf39ab 100644 --- a/packages/stripe/src/index.ts +++ b/packages/stripe/src/index.ts @@ -16,4 +16,59 @@ const createStripeClient = () => { return stripe; }; +export const updateSubscriptionSeats = async ( + stripeSubscriptionId: string, + seatIncrement = 1, +): Promise => { + const stripe = createStripeClient(); + + // First, retrieve the current subscription to get the subscription items + const subscription = await stripe.subscriptions.retrieve( + stripeSubscriptionId, + { + expand: ["items"], + }, + ); + + if (!subscription.items.data.length) { + throw new Error( + `No subscription items found for subscription ${stripeSubscriptionId}`, + ); + } + + // Get the first subscription item + const subscriptionItem = subscription.items.data[0]; + if (!subscriptionItem) { + throw new Error( + `No subscription item found for subscription ${stripeSubscriptionId}`, + ); + } + + const currentQuantity = subscriptionItem.quantity ?? 1; + const newQuantity = currentQuantity + seatIncrement; + + // Ensure we don't go below 1 seat + if (newQuantity < 1) { + throw new Error( + `Cannot reduce seats below 1. Current: ${currentQuantity}, Requested change: ${seatIncrement}`, + ); + } + + // Update the subscription with the new quantity and immediate invoicing + const updatedSubscription = await stripe.subscriptions.update( + stripeSubscriptionId, + { + items: [ + { + id: subscriptionItem.id, + quantity: newQuantity, + }, + ], + proration_behavior: "always_invoice", // Accumulate charges for next billing cycle + }, + ); + + return updatedSubscription; +}; + export { createStripeClient };