feat(cloud): unlimited seats (#166)

* feat: add unlimitedSeats to subscription

* feat: set unlimited seats to true for pro plans

* feat: add unlimited invites messaging

* feat: upgrade to pro via auth client

* feat: add subscription repo funcs

* chore: move subscription utils to shared

* feat: skip updating subscription if unlimited seats
This commit is contained in:
Henry
2025-09-07 22:50:20 +01:00
committed by GitHub
parent 061a54dadd
commit 5a4835091f
13 changed files with 2807 additions and 76 deletions

View File

@@ -2,8 +2,10 @@ import { TRPCError } from "@trpc/server";
import { z } from "zod";
import * as memberRepo from "@kan/db/repository/member.repo";
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
import * as userRepo from "@kan/db/repository/user.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { getSubscriptionByPlan, hasUnlimitedSeats } from "@kan/shared/utils";
import { updateSubscriptionSeats } from "@kan/stripe";
import { createTRPCRouter, protectedProcedure } from "../trpc";
@@ -62,29 +64,34 @@ 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"),
const subscriptions = await subscriptionRepo.getByReferenceId(
ctx.db,
workspace.publicId,
);
if (!activeSubscription) {
// get the active subscriptions
const activeTeamSubscription = getSubscriptionByPlan(
subscriptions,
"team",
);
const activeProSubscription = getSubscriptionByPlan(
subscriptions,
"pro",
);
const unlimitedSeats = hasUnlimitedSeats(subscriptions);
if (!activeTeamSubscription && !activeProSubscription) {
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) {
// Update the Stripe subscription
if (activeTeamSubscription?.stripeSubscriptionId && !unlimitedSeats) {
try {
await updateSubscriptionSeats(
activeSubscription.stripeSubscriptionId,
activeTeamSubscription.stripeSubscriptionId,
1,
);
} catch (error) {
@@ -204,22 +211,23 @@ export const memberRouter = createTRPCRouter({
// 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"),
const subscriptions = await subscriptionRepo.getByReferenceId(
ctx.db,
workspace.publicId,
);
// get the active subscriptions
const activeTeamSubscription = getSubscriptionByPlan(
subscriptions,
"team",
);
const unlimitedSeats = hasUnlimitedSeats(subscriptions);
// Only decrease seats if there's an active subscription and stripeSubscriptionId
if (activeSubscription?.stripeSubscriptionId) {
if (activeTeamSubscription?.stripeSubscriptionId && !unlimitedSeats) {
try {
await updateSubscriptionSeats(
activeSubscription.stripeSubscriptionId,
activeTeamSubscription.stripeSubscriptionId,
-1,
);
} catch (error) {

View File

@@ -10,6 +10,7 @@ import { env } from "next-runtime-env";
import type { dbClient } from "@kan/db/client";
import * as memberRepo from "@kan/db/repository/member.repo";
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
import * as userRepo from "@kan/db/repository/user.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import * as schema from "@kan/db/schema";
@@ -203,6 +204,24 @@ export const initAuth = (db: dbClient) => {
},
};
},
onSubscriptionComplete: async ({
subscription,
stripeSubscription,
}) => {
// Set unlimited seats to true for pro plans
if (subscription.plan === "pro") {
await subscriptionRepo.updateByStripeSubscriptionId(
db,
stripeSubscription.id,
{
unlimitedSeats: true,
},
);
console.log(
`Pro subscription ${stripeSubscription.id} activated with unlimited seats`,
);
}
},
},
}),
]

View File

@@ -0,0 +1 @@
ALTER TABLE "subscription" ADD COLUMN "unlimitedSeats" boolean DEFAULT false NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -71,6 +71,13 @@
"when": 1756803246096,
"tag": "20250902085406_AddSubscriptions",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1757271312974,
"tag": "20250907185512_AddUnlimitedSeatsToSubscription",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,77 @@
import { eq } from "drizzle-orm";
import type { dbClient } from "@kan/db/client";
import { subscription } from "@kan/db/schema";
export const updateById = async (
db: dbClient,
subscriptionId: number,
updates: {
unlimitedSeats?: boolean;
status?: string;
seats?: number | null;
periodStart?: Date | null;
periodEnd?: Date | null;
cancelAtPeriodEnd?: boolean | null;
},
) => {
const [result] = await db
.update(subscription)
.set({
...updates,
updatedAt: new Date(),
})
.where(eq(subscription.id, subscriptionId))
.returning({
id: subscription.id,
plan: subscription.plan,
status: subscription.status,
unlimitedSeats: subscription.unlimitedSeats,
});
return result;
};
export const updateByStripeSubscriptionId = async (
db: dbClient,
stripeSubscriptionId: string,
updates: {
unlimitedSeats?: boolean;
status?: string;
seats?: number | null;
periodStart?: Date | null;
periodEnd?: Date | null;
cancelAtPeriodEnd?: boolean | null;
},
) => {
const [result] = await db
.update(subscription)
.set({
...updates,
updatedAt: new Date(),
})
.where(eq(subscription.stripeSubscriptionId, stripeSubscriptionId))
.returning({
id: subscription.id,
plan: subscription.plan,
status: subscription.status,
unlimitedSeats: subscription.unlimitedSeats,
});
return result;
};
export const getByStripeSubscriptionId = async (
db: dbClient,
stripeSubscriptionId: string,
) => {
return await db.query.subscription.findFirst({
where: eq(subscription.stripeSubscriptionId, stripeSubscriptionId),
});
};
export const getByReferenceId = async (db: dbClient, referenceId: string) => {
return await db.query.subscription.findMany({
where: eq(subscription.referenceId, referenceId),
});
};

View File

@@ -127,6 +127,7 @@ export const getByPublicIdWithMembers = (
plan: true,
status: true,
seats: true,
unlimitedSeats: true,
periodStart: true,
periodEnd: true,
},

View File

@@ -23,6 +23,7 @@ export const subscription = pgTable("subscription", {
periodEnd: timestamp("periodEnd"),
cancelAtPeriodEnd: boolean("cancelAtPeriodEnd"),
seats: integer("seats"),
unlimitedSeats: boolean("unlimitedSeats").default(false).notNull(),
trialStart: timestamp("trialStart"),
trialEnd: timestamp("trialEnd"),
createdAt: timestamp("createdAt").notNull().defaultNow(),

View File

@@ -1,2 +1,3 @@
export * from "./generateUID";
export * from "./generateSlug";
export * from "./subscriptions";

View File

@@ -0,0 +1,57 @@
export type SubscriptionStatus =
| "active"
| "trialing"
| "past_due"
| "canceled"
| "unpaid";
export type SubscriptionPlan = "team" | "pro";
export interface Subscription {
id: number | null;
plan: string;
status: string;
seats: number | null;
unlimitedSeats: boolean;
periodStart: Date | null;
periodEnd: Date | null;
referenceId: string;
stripeSubscriptionId: string | null;
stripeCustomerId: string | null;
createdAt: Date;
updatedAt: Date;
}
export const getActiveSubscriptions = (
subscriptions: Subscription[] | undefined,
) => {
if (!subscriptions) return [];
return subscriptions.filter(
(sub) => sub.status === "active" || sub.status === "trialing",
);
};
export const getSubscriptionByPlan = (
subscriptions: Subscription[] | undefined,
plan: SubscriptionPlan,
) => {
if (!subscriptions) return undefined;
return subscriptions.find(
(sub) =>
sub.plan === plan &&
(sub.status === "active" || sub.status === "trialing"),
);
};
export const hasActiveSubscription = (
subscriptions: Subscription[] | undefined,
plan: SubscriptionPlan,
) => {
return getSubscriptionByPlan(subscriptions, plan) !== undefined;
};
export const hasUnlimitedSeats = (
subscriptions: Subscription[] | undefined,
) => {
const activeSubscriptions = getActiveSubscriptions(subscriptions);
return activeSubscriptions.some((sub) => sub.unlimitedSeats);
};