feat: update subscription when increasing/decreasing workspace members
This commit is contained in:
@@ -4,6 +4,7 @@ import { z } from "zod";
|
|||||||
import * as memberRepo from "@kan/db/repository/member.repo";
|
import * as memberRepo from "@kan/db/repository/member.repo";
|
||||||
import * as userRepo from "@kan/db/repository/user.repo";
|
import * as userRepo from "@kan/db/repository/user.repo";
|
||||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||||
|
import { updateSubscriptionSeats } from "@kan/stripe";
|
||||||
|
|
||||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||||
import { assertUserInWorkspace } from "../utils/auth";
|
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 existingUser = await userRepo.getByEmail(ctx.db, input.email);
|
||||||
|
|
||||||
const invite = await memberRepo.create(ctx.db, {
|
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;
|
return invite;
|
||||||
}),
|
}),
|
||||||
delete: protectedProcedure
|
delete: protectedProcedure
|
||||||
@@ -178,6 +202,35 @@ export const memberRouter = createTRPCRouter({
|
|||||||
code: "INTERNAL_SERVER_ERROR",
|
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 };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,16 +28,14 @@ const createAuthWithHeaders = (
|
|||||||
api: {
|
api: {
|
||||||
getSession: () => auth.api.getSession({ headers }),
|
getSession: () => auth.api.getSession({ headers }),
|
||||||
signInMagicLink: (input: { email: string; callbackURL: string }) =>
|
signInMagicLink: (input: { email: string; callbackURL: string }) =>
|
||||||
// @ts-expect-error - types need fixing
|
|
||||||
auth.api.signInMagicLink({
|
auth.api.signInMagicLink({
|
||||||
headers,
|
headers,
|
||||||
body: { email: input.email, callbackURL: input.callbackURL },
|
body: { email: input.email, callbackURL: input.callbackURL },
|
||||||
}),
|
}),
|
||||||
listActiveSubscriptions: (input: { userId: string }) =>
|
listActiveSubscriptions: (input: { workspacePublicId: string }) =>
|
||||||
// @ts-expect-error - types need fixing
|
|
||||||
auth.api.listActiveSubscriptions({
|
auth.api.listActiveSubscriptions({
|
||||||
headers,
|
headers,
|
||||||
query: { referenceId: input.userId },
|
query: { referenceId: input.workspacePublicId },
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,4 +16,59 @@ const createStripeClient = () => {
|
|||||||
return stripe;
|
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 };
|
export { createStripeClient };
|
||||||
|
|||||||
Reference in New Issue
Block a user