feat(cloud): enable seat based pricing (#161)

* feat: setup subscriptions

* feat: prompt user to create subscription if inactive

* feat: update subscription when increasing/decreasing workspace members

* feat: rework upgrade

* chore: remove pricing notice

* chore: add translations

* chore: update proration comment
This commit is contained in:
Henry
2025-09-02 22:36:09 +01:00
committed by GitHub
parent e3d7ace8bd
commit 06c268bed1
33 changed files with 7483 additions and 4352 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, {
@@ -165,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

@@ -32,6 +32,11 @@ const createAuthWithHeaders = (
headers,
body: { email: input.email, callbackURL: input.callbackURL },
}),
listActiveSubscriptions: (input: { workspacePublicId: string }) =>
auth.api.listActiveSubscriptions({
headers,
query: { referenceId: input.workspacePublicId },
}),
},
};
};

View File

@@ -37,6 +37,7 @@
},
"prettier": "@kan/prettier-config",
"dependencies": {
"better-auth": "^1.2.8"
"@better-auth/stripe": "^1.3.7",
"better-auth": "^1.3.7"
}
}

View File

@@ -1,4 +1,5 @@
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { stripe } from "@better-auth/stripe";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { createAuthEndpoint, createAuthMiddleware } from "better-auth/api";
@@ -10,6 +11,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 userRepo from "@kan/db/repository/user.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import * as schema from "@kan/db/schema";
import { cloudMailerClient, sendEmail } from "@kan/email";
import { createStripeClient } from "@kan/stripe";
@@ -153,6 +155,51 @@ export const initAuth = (db: dbClient) => {
},
plugins: [
socialProvidersPlugin(),
...(process.env.NEXT_PUBLIC_KAN_ENV === "cloud"
? [
stripe({
stripeClient: createStripeClient(),
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
createCustomerOnSignUp: true,
subscription: {
enabled: true,
plans: [
{
name: "team",
priceId: process.env.STRIPE_TEAM_PLAN_MONTHLY_PRICE_ID!,
annualDiscountPriceId:
process.env.STRIPE_TEAM_PLAN_YEARLY_PRICE_ID!,
},
{
name: "pro",
priceId: process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID!,
annualDiscountPriceId:
process.env.STRIPE_PRO_PLAN_YEARLY_PRICE_ID!,
},
],
authorizeReference: async (data) => {
const workspace = await workspaceRepo.getByPublicId(
db,
data.referenceId,
);
if (!workspace) {
return Promise.resolve(false);
}
const isUserInWorkspace =
await workspaceRepo.isUserInWorkspace(
db,
data.user.id,
workspace.id,
);
return isUserInWorkspace;
},
},
}),
]
: []),
// @todo: hasing is disabled due to a bug in the api key plugin
apiKey({ disableKeyHashing: true }),
magicLink({

View File

@@ -1,5 +1,6 @@
import type { BetterAuthClientPlugin } from "better-auth";
import type { BetterFetchOption } from "better-auth/react";
import { stripeClient } from "@better-auth/stripe/client";
import {
apiKeyClient,
genericOAuthClient,
@@ -27,6 +28,9 @@ const socialProvidersPluginClient = {
export const authClient = createAuthClient({
plugins: [
stripeClient({
subscription: true,
}),
magicLinkClient(),
apiKeyClient(),
genericOAuthClient(),

View File

@@ -0,0 +1,23 @@
CREATE TABLE IF NOT EXISTS "subscription" (
"id" bigserial PRIMARY KEY NOT NULL,
"plan" varchar(255) NOT NULL,
"referenceId" varchar(12) NOT NULL,
"stripeCustomerId" varchar(255),
"stripeSubscriptionId" varchar(255),
"status" varchar(255) NOT NULL,
"periodStart" timestamp,
"periodEnd" timestamp,
"cancelAtPeriodEnd" boolean,
"seats" integer,
"trialStart" timestamp,
"trialEnd" timestamp,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "subscription" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "subscription" ADD CONSTRAINT "subscription_referenceId_workspace_publicId_fk" FOREIGN KEY ("referenceId") REFERENCES "public"."workspace"("publicId") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

File diff suppressed because it is too large Load Diff

View File

@@ -64,6 +64,13 @@
"when": 1755094668761,
"tag": "20250813141748_AddChecklistActivityTypes",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1756803246096,
"tag": "20250902085406_AddSubscriptions",
"breakpoints": true
}
]
}

View File

@@ -1,12 +1,12 @@
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import { drizzle as drizzlePgLite } from "drizzle-orm/pglite";
import { PGlite } from "@electric-sql/pglite";
import { uuid_ossp } from "@electric-sql/pglite/contrib/uuid_ossp";
import { drizzle as drizzlePg } from "drizzle-orm/node-postgres";
import { drizzle as drizzlePgLite } from "drizzle-orm/pglite";
import { migrate } from "drizzle-orm/pglite/migrator";
import { Pool } from "pg";
import * as schema from "./schema";
import { PGlite } from "@electric-sql/pglite";
import { migrate } from "drizzle-orm/pglite/migrator";
export type dbClient = NodePgDatabase<typeof schema> & {
$client: Pool;
@@ -18,7 +18,10 @@ export const createDrizzleClient = (): dbClient => {
if (!connectionString) {
console.log("POSTGRES_URL environment variable is not set, using PGLite");
const client = new PGlite({ dataDir: "./pgdata", extensions: { uuid_ossp }});
const client = new PGlite({
dataDir: "./pgdata",
extensions: { uuid_ossp },
});
const db = drizzlePgLite(client, { schema });
migrate(db, { migrationsFolder: "../../packages/db/migrations" });

View File

@@ -121,6 +121,16 @@ export const getByPublicIdWithMembers = (
},
},
},
subscriptions: {
columns: {
id: true,
plan: true,
status: true,
seats: true,
periodStart: true,
periodEnd: true,
},
},
},
where: and(
eq(workspaces.publicId, workspacePublicId),

View File

@@ -10,3 +10,4 @@ export * from "./lists";
export * from "./users";
export * from "./integrations";
export * from "./workspaces";
export * from "./subscriptions";

View File

@@ -0,0 +1,37 @@
import { relations } from "drizzle-orm";
import {
bigserial,
boolean,
integer,
pgTable,
timestamp,
varchar,
} from "drizzle-orm/pg-core";
import { workspaces } from "./workspaces";
export const subscription = pgTable("subscription", {
id: bigserial("id", { mode: "number" }).primaryKey(),
plan: varchar("plan", { length: 255 }).notNull(),
referenceId: varchar("referenceId", { length: 12 })
.notNull()
.references(() => workspaces.publicId),
stripeCustomerId: varchar("stripeCustomerId", { length: 255 }),
stripeSubscriptionId: varchar("stripeSubscriptionId", { length: 255 }),
status: varchar("status", { length: 255 }).notNull(),
periodStart: timestamp("periodStart"),
periodEnd: timestamp("periodEnd"),
cancelAtPeriodEnd: boolean("cancelAtPeriodEnd"),
seats: integer("seats"),
trialStart: timestamp("trialStart"),
trialEnd: timestamp("trialEnd"),
createdAt: timestamp("createdAt").notNull().defaultNow(),
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
}).enableRLS();
export const subscriptionsRelations = relations(subscription, ({ one }) => ({
workspace: one(workspaces, {
fields: [subscription.referenceId],
references: [workspaces.publicId],
}),
}));

View File

@@ -11,6 +11,7 @@ import {
} from "drizzle-orm/pg-core";
import { boards } from "./boards";
import { subscription } from "./subscriptions";
import { users } from "./users";
export const memberRoles = ["admin", "member", "guest"] as const;
@@ -60,6 +61,7 @@ export const workspaceRelations = relations(workspaces, ({ one, many }) => ({
}),
members: many(workspaceMembers),
boards: many(boards),
subscriptions: many(subscription),
}));
export const workspaceMembers = pgTable("workspace_members", {

View File

@@ -10,11 +10,65 @@ const createStripeClient = () => {
}
const stripe = new Stripe(stripeSecretKey, {
apiVersion: "2025-05-28.basil",
httpClient: Stripe.createFetchHttpClient(),
apiVersion: "2025-08-27.basil",
});
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", // Invoice immediately
},
);
return updatedSubscription;
};
export { createStripeClient };