feat: setup subscriptions

This commit is contained in:
Henry
2025-09-01 22:17:09 +01:00
parent fecc6cf742
commit a7c68db90e
16 changed files with 6045 additions and 3445 deletions

View File

@@ -6,7 +6,7 @@
"scripts": {
"build": "pnpm with-env next build",
"clean": "git clean -xdf .cache .next .turbo node_modules",
"dev": "pnpm with-env next dev | pino-pretty",
"dev": "pnpm with-env next dev",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"start": "pnpm with-env next start",

View File

@@ -42,7 +42,7 @@ export function InviteMemberForm() {
const refetchBoards = () => utils.board.all.refetch();
const createBoard = api.member.invite.useMutation({
const inviteMember = api.member.invite.useMutation({
onSuccess: async () => {
closeModal();
await utils.workspace.byId.refetch();
@@ -68,8 +68,24 @@ export function InviteMemberForm() {
},
});
const onSubmit = (data: InviteMemberInput) => {
createBoard.mutate(data);
const onSubmit = async (_data: InviteMemberInput) => {
const { data, error } = await authClient.subscription.upgrade({
plan: "team",
// subscriptionId: "sub_123",
metadata: { userId: "123" },
seats: 1,
successUrl: "/members",
cancelUrl: "/members",
returnUrl: "/members",
disableRedirect: true,
});
if (data?.url) {
window.location.href = data.url;
}
// console.log(data, error);
// inviteMember.mutate(data);
};
useEffect(() => {
@@ -117,7 +133,7 @@ export function InviteMemberForm() {
<div>
<Button
type="submit"
isLoading={createBoard.isPending}
isLoading={inviteMember.isPending}
className="inline-flex w-full justify-center rounded-md bg-light-1000 px-3 py-2 text-sm font-semibold text-light-50 shadow-sm focus-visible:outline-none dark:bg-dark-1000 dark:text-dark-50"
>
{t`Invite member`}

View File

@@ -31,5 +31,11 @@
"turbo": "^2.3.1",
"typescript": "catalog:"
},
"prettier": "@kan/prettier-config"
"prettier": "@kan/prettier-config",
"pnpm": {
"overrides": {
"@types/minimatch": "npm:minimatch@*",
"@types/glob": "npm:@types/glob@^8.1.0"
}
}
}

View File

@@ -100,6 +100,19 @@ 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

View File

@@ -28,10 +28,17 @@ 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
auth.api.listActiveSubscriptions({
headers,
query: { referenceId: input.userId },
}),
},
};
};

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";
@@ -153,6 +154,32 @@ 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!,
},
],
},
}),
]
: []),
// @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" uuid,
"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_user_id_fk" FOREIGN KEY ("referenceId") REFERENCES "public"."user"("id") ON DELETE set null 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": 1756757589852,
"tag": "20250901201309_concerned_doctor_octopus",
"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

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

View File

@@ -0,0 +1,38 @@
import { relations } from "drizzle-orm";
import {
bigserial,
boolean,
integer,
pgTable,
timestamp,
uuid,
varchar,
} from "drizzle-orm/pg-core";
import { users } from "./users";
export const subscription = pgTable("subscription", {
id: bigserial("id", { mode: "number" }).primaryKey(),
plan: varchar("plan", { length: 255 }).notNull(),
referenceId: uuid("referenceId").references(() => users.id, {
onDelete: "set null",
}),
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 }) => ({
user: one(users, {
fields: [subscription.referenceId],
references: [users.id],
}),
}));

View File

@@ -10,8 +10,7 @@ const createStripeClient = () => {
}
const stripe = new Stripe(stripeSecretKey, {
apiVersion: "2025-05-28.basil",
httpClient: Stripe.createFetchHttpClient(),
apiVersion: "2025-08-27.basil",
});
return stripe;

6783
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff