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:
@@ -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) {
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "subscription" ADD COLUMN "unlimitedSeats" boolean DEFAULT false NOT NULL;
|
||||
2543
packages/db/migrations/meta/20250907185512_snapshot.json
Normal file
2543
packages/db/migrations/meta/20250907185512_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -71,6 +71,13 @@
|
||||
"when": 1756803246096,
|
||||
"tag": "20250902085406_AddSubscriptions",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1757271312974,
|
||||
"tag": "20250907185512_AddUnlimitedSeatsToSubscription",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
77
packages/db/src/repository/subscription.repo.ts
Normal file
77
packages/db/src/repository/subscription.repo.ts
Normal 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),
|
||||
});
|
||||
};
|
||||
@@ -127,6 +127,7 @@ export const getByPublicIdWithMembers = (
|
||||
plan: true,
|
||||
status: true,
|
||||
seats: true,
|
||||
unlimitedSeats: true,
|
||||
periodStart: true,
|
||||
periodEnd: true,
|
||||
},
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./generateUID";
|
||||
export * from "./generateSlug";
|
||||
export * from "./subscriptions";
|
||||
|
||||
57
packages/shared/src/utils/subscriptions.ts
Normal file
57
packages/shared/src/utils/subscriptions.ts
Normal 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);
|
||||
};
|
||||
Reference in New Issue
Block a user