feat: setup subscriptions
This commit is contained in:
@@ -6,7 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "pnpm with-env next build",
|
"build": "pnpm with-env next build",
|
||||||
"clean": "git clean -xdf .cache .next .turbo node_modules",
|
"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",
|
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"start": "pnpm with-env next start",
|
"start": "pnpm with-env next start",
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function InviteMemberForm() {
|
|||||||
|
|
||||||
const refetchBoards = () => utils.board.all.refetch();
|
const refetchBoards = () => utils.board.all.refetch();
|
||||||
|
|
||||||
const createBoard = api.member.invite.useMutation({
|
const inviteMember = api.member.invite.useMutation({
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
closeModal();
|
closeModal();
|
||||||
await utils.workspace.byId.refetch();
|
await utils.workspace.byId.refetch();
|
||||||
@@ -68,8 +68,24 @@ export function InviteMemberForm() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = (data: InviteMemberInput) => {
|
const onSubmit = async (_data: InviteMemberInput) => {
|
||||||
createBoard.mutate(data);
|
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(() => {
|
useEffect(() => {
|
||||||
@@ -117,7 +133,7 @@ export function InviteMemberForm() {
|
|||||||
<div>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
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"
|
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`}
|
{t`Invite member`}
|
||||||
|
|||||||
@@ -31,5 +31,11 @@
|
|||||||
"turbo": "^2.3.1",
|
"turbo": "^2.3.1",
|
||||||
"typescript": "catalog:"
|
"typescript": "catalog:"
|
||||||
},
|
},
|
||||||
"prettier": "@kan/prettier-config"
|
"prettier": "@kan/prettier-config",
|
||||||
|
"pnpm": {
|
||||||
|
"overrides": {
|
||||||
|
"@types/minimatch": "npm:minimatch@*",
|
||||||
|
"@types/glob": "npm:@types/glob@^8.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
return invite;
|
||||||
}),
|
}),
|
||||||
delete: protectedProcedure
|
delete: protectedProcedure
|
||||||
|
|||||||
@@ -28,10 +28,17 @@ 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 }) =>
|
||||||
|
// @ts-expect-error - types need fixing
|
||||||
|
auth.api.listActiveSubscriptions({
|
||||||
|
headers,
|
||||||
|
query: { referenceId: input.userId },
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
},
|
},
|
||||||
"prettier": "@kan/prettier-config",
|
"prettier": "@kan/prettier-config",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-auth": "^1.2.8"
|
"@better-auth/stripe": "^1.3.7",
|
||||||
|
"better-auth": "^1.3.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
||||||
|
import { stripe } from "@better-auth/stripe";
|
||||||
import { betterAuth } from "better-auth";
|
import { betterAuth } from "better-auth";
|
||||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||||
import { createAuthEndpoint, createAuthMiddleware } from "better-auth/api";
|
import { createAuthEndpoint, createAuthMiddleware } from "better-auth/api";
|
||||||
@@ -153,6 +154,32 @@ export const initAuth = (db: dbClient) => {
|
|||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
socialProvidersPlugin(),
|
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
|
// @todo: hasing is disabled due to a bug in the api key plugin
|
||||||
apiKey({ disableKeyHashing: true }),
|
apiKey({ disableKeyHashing: true }),
|
||||||
magicLink({
|
magicLink({
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { BetterAuthClientPlugin } from "better-auth";
|
import type { BetterAuthClientPlugin } from "better-auth";
|
||||||
import type { BetterFetchOption } from "better-auth/react";
|
import type { BetterFetchOption } from "better-auth/react";
|
||||||
|
import { stripeClient } from "@better-auth/stripe/client";
|
||||||
import {
|
import {
|
||||||
apiKeyClient,
|
apiKeyClient,
|
||||||
genericOAuthClient,
|
genericOAuthClient,
|
||||||
@@ -27,6 +28,9 @@ const socialProvidersPluginClient = {
|
|||||||
|
|
||||||
export const authClient = createAuthClient({
|
export const authClient = createAuthClient({
|
||||||
plugins: [
|
plugins: [
|
||||||
|
stripeClient({
|
||||||
|
subscription: true,
|
||||||
|
}),
|
||||||
magicLinkClient(),
|
magicLinkClient(),
|
||||||
apiKeyClient(),
|
apiKeyClient(),
|
||||||
genericOAuthClient(),
|
genericOAuthClient(),
|
||||||
|
|||||||
@@ -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 $$;
|
||||||
2536
packages/db/migrations/meta/20250901201309_snapshot.json
Normal file
2536
packages/db/migrations/meta/20250901201309_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -64,6 +64,13 @@
|
|||||||
"when": 1755094668761,
|
"when": 1755094668761,
|
||||||
"tag": "20250813141748_AddChecklistActivityTypes",
|
"tag": "20250813141748_AddChecklistActivityTypes",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 9,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1756757589852,
|
||||||
|
"tag": "20250901201309_concerned_doctor_octopus",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
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 { uuid_ossp } from "@electric-sql/pglite/contrib/uuid_ossp";
|
||||||
import { drizzle as drizzlePg } from "drizzle-orm/node-postgres";
|
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 { Pool } from "pg";
|
||||||
|
|
||||||
import * as schema from "./schema";
|
import * as schema from "./schema";
|
||||||
import { PGlite } from "@electric-sql/pglite";
|
|
||||||
import { migrate } from "drizzle-orm/pglite/migrator";
|
|
||||||
|
|
||||||
export type dbClient = NodePgDatabase<typeof schema> & {
|
export type dbClient = NodePgDatabase<typeof schema> & {
|
||||||
$client: Pool;
|
$client: Pool;
|
||||||
@@ -18,7 +18,10 @@ export const createDrizzleClient = (): dbClient => {
|
|||||||
if (!connectionString) {
|
if (!connectionString) {
|
||||||
console.log("POSTGRES_URL environment variable is not set, using PGLite");
|
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 });
|
const db = drizzlePgLite(client, { schema });
|
||||||
|
|
||||||
migrate(db, { migrationsFolder: "../../packages/db/migrations" });
|
migrate(db, { migrationsFolder: "../../packages/db/migrations" });
|
||||||
|
|||||||
@@ -10,3 +10,4 @@ export * from "./lists";
|
|||||||
export * from "./users";
|
export * from "./users";
|
||||||
export * from "./integrations";
|
export * from "./integrations";
|
||||||
export * from "./workspaces";
|
export * from "./workspaces";
|
||||||
|
export * from "./subscriptions";
|
||||||
|
|||||||
38
packages/db/src/schema/subscriptions.ts
Normal file
38
packages/db/src/schema/subscriptions.ts
Normal 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],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
@@ -10,8 +10,7 @@ const createStripeClient = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const stripe = new Stripe(stripeSecretKey, {
|
const stripe = new Stripe(stripeSecretKey, {
|
||||||
apiVersion: "2025-05-28.basil",
|
apiVersion: "2025-08-27.basil",
|
||||||
httpClient: Stripe.createFetchHttpClient(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return stripe;
|
return stripe;
|
||||||
|
|||||||
6783
pnpm-lock.yaml
generated
6783
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user