feat: update subscription when increasing/decreasing workspace members

This commit is contained in:
Henry
2025-09-02 14:05:35 +01:00
parent 5f4678ab4e
commit bc98fc848f
3 changed files with 123 additions and 17 deletions

View File

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

View File

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

View File

@@ -16,4 +16,59 @@ const createStripeClient = () => {
return stripe;
};
export const updateSubscriptionSeats = async (
stripeSubscriptionId: string,
seatIncrement = 1,
): Promise<Stripe.Subscription> => {
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 };