Compare commits
8 Commits
feat/unlim
...
feat/seats
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9df9b9146c | ||
|
|
7966ec01f5 | ||
|
|
2779c06bcc | ||
|
|
4f9841ceaf | ||
|
|
a596b1c40b | ||
|
|
bc98fc848f | ||
|
|
5f4678ab4e | ||
|
|
a7c68db90e |
@@ -70,7 +70,7 @@ export default async function handler(
|
|||||||
mode: "subscription",
|
mode: "subscription",
|
||||||
line_items: [
|
line_items: [
|
||||||
{
|
{
|
||||||
price: process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID,
|
price: process.env.STRIPE_PRO_PLAN_PRICE_ID,
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export default async function handler(
|
|||||||
const event = stripe.webhooks.constructEvent(
|
const event = stripe.webhooks.constructEvent(
|
||||||
rawBody,
|
rawBody,
|
||||||
sig,
|
sig,
|
||||||
process.env.STRIPE_WEBHOOK_SECRET_LEGACY!,
|
process.env.STRIPE_WEBHOOK_SECRET!,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { db } = await createNextApiContext(req);
|
const { db } = await createNextApiContext(req);
|
||||||
|
|||||||
@@ -7,9 +7,7 @@ import { HiXMark } from "react-icons/hi2";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import type { InviteMemberInput } from "@kan/api/types";
|
import type { InviteMemberInput } from "@kan/api/types";
|
||||||
import type { Subscription } from "@kan/shared/utils";
|
|
||||||
import { authClient } from "@kan/auth/client";
|
import { authClient } from "@kan/auth/client";
|
||||||
import { getSubscriptionByPlan } from "@kan/shared/utils";
|
|
||||||
|
|
||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
import Input from "~/components/Input";
|
import Input from "~/components/Input";
|
||||||
@@ -21,13 +19,20 @@ import { api } from "~/utils/api";
|
|||||||
|
|
||||||
export function InviteMemberForm({
|
export function InviteMemberForm({
|
||||||
numberOfMembers,
|
numberOfMembers,
|
||||||
subscriptions,
|
activeTeamSubscription,
|
||||||
unlimitedSeats,
|
|
||||||
userId,
|
userId,
|
||||||
}: {
|
}: {
|
||||||
numberOfMembers: number;
|
numberOfMembers: number;
|
||||||
subscriptions: Subscription[] | undefined;
|
activeTeamSubscription:
|
||||||
unlimitedSeats: boolean;
|
| {
|
||||||
|
id: number | null;
|
||||||
|
plan: string;
|
||||||
|
status: string;
|
||||||
|
seats: number | null;
|
||||||
|
periodStart: Date | null;
|
||||||
|
periodEnd: Date | null;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
userId: string | undefined;
|
userId: string | undefined;
|
||||||
}) {
|
}) {
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
@@ -82,19 +87,16 @@ export function InviteMemberForm({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const teamSubscription = getSubscriptionByPlan(subscriptions, "team");
|
|
||||||
const proSubscription = getSubscriptionByPlan(subscriptions, "pro");
|
|
||||||
|
|
||||||
const hasTeamSubscription = !!teamSubscription;
|
|
||||||
const hasProSubscription = !!proSubscription;
|
|
||||||
|
|
||||||
let isYearly = false;
|
let isYearly = false;
|
||||||
let price = t`$10/month`;
|
let price = t`$10/month`;
|
||||||
let billingType = t`monthly billing`;
|
let billingType = t`monthly billing`;
|
||||||
|
|
||||||
if (teamSubscription?.periodStart && teamSubscription?.periodEnd) {
|
if (
|
||||||
const periodStartDate = new Date(teamSubscription.periodStart);
|
activeTeamSubscription?.periodStart &&
|
||||||
const periodEndDate = new Date(teamSubscription.periodEnd);
|
activeTeamSubscription?.periodEnd
|
||||||
|
) {
|
||||||
|
const periodStartDate = new Date(activeTeamSubscription.periodStart);
|
||||||
|
const periodEndDate = new Date(activeTeamSubscription.periodEnd);
|
||||||
const diffInDays = Math.round(
|
const diffInDays = Math.round(
|
||||||
(periodEndDate.getTime() - periodStartDate.getTime()) /
|
(periodEndDate.getTime() - periodStartDate.getTime()) /
|
||||||
(1000 * 60 * 60 * 24),
|
(1000 * 60 * 60 * 24),
|
||||||
@@ -161,8 +163,7 @@ export function InviteMemberForm({
|
|||||||
placeholder={t`Email`}
|
placeholder={t`Email`}
|
||||||
disabled={
|
disabled={
|
||||||
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||||
!hasTeamSubscription &&
|
!activeTeamSubscription?.id
|
||||||
!hasProSubscription
|
|
||||||
}
|
}
|
||||||
{...register("email", { required: true })}
|
{...register("email", { required: true })}
|
||||||
onKeyDown={async (e) => {
|
onKeyDown={async (e) => {
|
||||||
@@ -176,15 +177,13 @@ export function InviteMemberForm({
|
|||||||
|
|
||||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" && (
|
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" && (
|
||||||
<div className="mt-3 rounded-md bg-light-100 p-3 text-xs text-light-900 dark:bg-dark-200 dark:text-dark-900">
|
<div className="mt-3 rounded-md bg-light-100 p-3 text-xs text-light-900 dark:bg-dark-200 dark:text-dark-900">
|
||||||
{hasTeamSubscription || hasProSubscription ? (
|
{activeTeamSubscription?.id ? (
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium text-emerald-500 dark:text-emerald-400">
|
<span className="font-medium text-emerald-500 dark:text-emerald-400">
|
||||||
{hasTeamSubscription ? t`Team Plan` : t`Pro Plan ∞`}
|
{t`Team Plan`}
|
||||||
</span>
|
</span>
|
||||||
<p className="mt-1">
|
<p className="mt-1">
|
||||||
{unlimitedSeats
|
{t`Adding a new member will cost an additional ${price} (${billingType}) per seat.`}
|
||||||
? t`You have unlimited seats with your Pro Plan. There is no additional charge for new members!`
|
|
||||||
: t`Adding a new member will cost an additional ${price} (${billingType}) per seat.`}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -202,7 +201,7 @@ export function InviteMemberForm({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||||
{(hasTeamSubscription || hasProSubscription) &&
|
{activeTeamSubscription?.id &&
|
||||||
env("NEXT_PUBLIC_KAN_ENV") === "cloud" && (
|
env("NEXT_PUBLIC_KAN_ENV") === "cloud" && (
|
||||||
<Toggle
|
<Toggle
|
||||||
label={t`Invite another`}
|
label={t`Invite another`}
|
||||||
@@ -214,8 +213,7 @@ export function InviteMemberForm({
|
|||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||||
!hasTeamSubscription &&
|
!activeTeamSubscription?.id ? (
|
||||||
!hasProSubscription ? (
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleUpgrade}
|
onClick={handleUpgrade}
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import { env } from "next-runtime-env";
|
|
||||||
import { HiEllipsisHorizontal, HiOutlinePlusSmall } from "react-icons/hi2";
|
import { HiEllipsisHorizontal, HiOutlinePlusSmall } from "react-icons/hi2";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
import type { Subscription } from "@kan/shared/utils";
|
|
||||||
import { authClient } from "@kan/auth/client";
|
import { authClient } from "@kan/auth/client";
|
||||||
import { getSubscriptionByPlan, hasUnlimitedSeats } from "@kan/shared/utils";
|
|
||||||
|
|
||||||
import Avatar from "~/components/Avatar";
|
import Avatar from "~/components/Avatar";
|
||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
@@ -14,6 +11,7 @@ import FeedbackModal from "~/components/FeedbackModal";
|
|||||||
import Modal from "~/components/modal";
|
import Modal from "~/components/modal";
|
||||||
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import { env } from "~/env";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { useWorkspace } from "~/providers/workspace";
|
import { useWorkspace } from "~/providers/workspace";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
@@ -32,12 +30,13 @@ export default function MembersPage() {
|
|||||||
|
|
||||||
const { data: session } = authClient.useSession();
|
const { data: session } = authClient.useSession();
|
||||||
|
|
||||||
const subscriptions = data?.subscriptions as Subscription[] | undefined;
|
const subscription = data?.subscriptions;
|
||||||
|
|
||||||
const teamSubscription = getSubscriptionByPlan(subscriptions, "team");
|
const activeTeamSubscription = subscription?.find(
|
||||||
const proSubscription = getSubscriptionByPlan(subscriptions, "pro");
|
(sub: any) =>
|
||||||
|
sub.status === "active" ||
|
||||||
const unlimitedSeats = hasUnlimitedSeats(subscriptions);
|
(sub.status === "trialing" && sub.plan === "team"),
|
||||||
|
);
|
||||||
|
|
||||||
const TableRow = ({
|
const TableRow = ({
|
||||||
memberPublicId,
|
memberPublicId,
|
||||||
@@ -172,24 +171,17 @@ export default function MembersPage() {
|
|||||||
{t`Members`}
|
{t`Members`}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" && (
|
{env.NEXT_PUBLIC_KAN_ENV === "cloud" && (
|
||||||
<div
|
<div
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"flex items-center rounded-full border px-3 py-1 text-center text-xs",
|
"flex items-center rounded-full border px-3 py-1 text-center text-xs",
|
||||||
teamSubscription || proSubscription
|
activeTeamSubscription
|
||||||
? "border-emerald-300 bg-emerald-50 text-emerald-400 dark:border-emerald-700 dark:bg-emerald-950 dark:text-emerald-400"
|
? "border-emerald-300 bg-emerald-50 text-emerald-400 dark:border-emerald-700 dark:bg-emerald-950 dark:text-emerald-400"
|
||||||
: "border-light-300 bg-light-50 text-light-1000 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900",
|
: "border-light-300 bg-light-50 text-light-1000 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{proSubscription
|
{activeTeamSubscription ? t`Team Plan` : t`Free Plan`}
|
||||||
? t`Pro Plan`
|
|
||||||
: teamSubscription
|
|
||||||
? t`Team Plan`
|
|
||||||
: t`Free Plan`}
|
|
||||||
{proSubscription && unlimitedSeats && (
|
|
||||||
<span className="ml-1 text-xs">∞</span>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -276,8 +268,7 @@ export default function MembersPage() {
|
|||||||
<InviteMemberForm
|
<InviteMemberForm
|
||||||
userId={session?.user.id}
|
userId={session?.user.id}
|
||||||
numberOfMembers={data?.members.length ?? 1}
|
numberOfMembers={data?.members.length ?? 1}
|
||||||
subscriptions={subscriptions}
|
activeTeamSubscription={activeTeamSubscription}
|
||||||
unlimitedSeats={unlimitedSeats}
|
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
|||||||
@@ -1,42 +1,37 @@
|
|||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
|
|
||||||
import { authClient } from "@kan/auth/client";
|
|
||||||
|
|
||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { usePopup } from "~/providers/popup";
|
|
||||||
|
|
||||||
export function CustomURLConfirmation({
|
export function CustomURLConfirmation({
|
||||||
userId,
|
|
||||||
workspacePublicId,
|
workspacePublicId,
|
||||||
}: {
|
}: {
|
||||||
userId: string;
|
|
||||||
workspacePublicId: string;
|
workspacePublicId: string;
|
||||||
}) {
|
}) {
|
||||||
const { closeModal, entityId } = useModal();
|
const { closeModal, entityId } = useModal();
|
||||||
const { showPopup } = usePopup();
|
|
||||||
|
|
||||||
const handleUpgrade = async () => {
|
const handleUpgrade = async () => {
|
||||||
const { data, error } = await authClient.subscription.upgrade({
|
try {
|
||||||
plan: "pro",
|
const response = await fetch("/api/stripe/create_checkout_session", {
|
||||||
referenceId: workspacePublicId,
|
method: "POST",
|
||||||
metadata: { userId, workspacePublicId, workspaceSlug: entityId },
|
headers: {
|
||||||
successUrl: "/settings",
|
"Content-Type": "application/json",
|
||||||
cancelUrl: "/settings",
|
},
|
||||||
returnUrl: "/settings",
|
body: JSON.stringify({
|
||||||
disableRedirect: true,
|
slug: entityId,
|
||||||
});
|
workspacePublicId: workspacePublicId,
|
||||||
|
cancelUrl: "/settings",
|
||||||
if (data?.url) {
|
successUrl: "/settings",
|
||||||
window.location.href = data.url;
|
}),
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
showPopup({
|
|
||||||
header: t`Error upgrading subscription`,
|
|
||||||
message: t`Please try again later, or contact customer support.`,
|
|
||||||
icon: "error",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { url } = (await response.json()) as { url: string };
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
window.location.href = url;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating checkout session:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -22,11 +22,6 @@ services:
|
|||||||
# Stripe
|
# Stripe
|
||||||
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
|
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
|
||||||
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
|
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
|
||||||
- STRIPE_WEBHOOK_SECRET_LEGACY=${STRIPE_WEBHOOK_SECRET_LEGACY}
|
|
||||||
- STRIPE_TEAM_PLAN_MONTHLY_PRICE_ID=${STRIPE_TEAM_PLAN_MONTHLY_PRICE_ID}
|
|
||||||
- STRIPE_TEAM_PLAN_YEARLY_PRICE_ID=${STRIPE_TEAM_PLAN_YEARLY_PRICE_ID}
|
|
||||||
- STRIPE_PRO_PLAN_MONTHLY_PRICE_ID=${STRIPE_PRO_PLAN_MONTHLY_PRICE_ID}
|
|
||||||
- STRIPE_PRO_PLAN_YEARLY_PRICE_ID=${STRIPE_PRO_PLAN_YEARLY_PRICE_ID}
|
|
||||||
|
|
||||||
# Email
|
# Email
|
||||||
- SMTP_HOST=${SMTP_HOST}
|
- SMTP_HOST=${SMTP_HOST}
|
||||||
|
|||||||
@@ -2,10 +2,8 @@ import { TRPCError } from "@trpc/server";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import * as memberRepo from "@kan/db/repository/member.repo";
|
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 userRepo from "@kan/db/repository/user.repo";
|
||||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||||
import { getSubscriptionByPlan, hasUnlimitedSeats } from "@kan/shared/utils";
|
|
||||||
import { updateSubscriptionSeats } from "@kan/stripe";
|
import { updateSubscriptionSeats } from "@kan/stripe";
|
||||||
|
|
||||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||||
@@ -64,34 +62,29 @@ export const memberRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (process.env.NEXT_PUBLIC_KAN_ENV === "cloud") {
|
if (process.env.NEXT_PUBLIC_KAN_ENV === "cloud") {
|
||||||
const subscriptions = await subscriptionRepo.getByReferenceId(
|
const subscriptions = await ctx.auth.api.listActiveSubscriptions({
|
||||||
ctx.db,
|
workspacePublicId: workspace.publicId,
|
||||||
workspace.publicId,
|
});
|
||||||
|
|
||||||
|
// get the active subscription
|
||||||
|
const activeSubscription = subscriptions.find(
|
||||||
|
(sub) =>
|
||||||
|
sub.status === "active" ||
|
||||||
|
(sub.status === "trialing" && sub.plan === "team"),
|
||||||
);
|
);
|
||||||
|
|
||||||
// get the active subscriptions
|
if (!activeSubscription) {
|
||||||
const activeTeamSubscription = getSubscriptionByPlan(
|
|
||||||
subscriptions,
|
|
||||||
"team",
|
|
||||||
);
|
|
||||||
const activeProSubscription = getSubscriptionByPlan(
|
|
||||||
subscriptions,
|
|
||||||
"pro",
|
|
||||||
);
|
|
||||||
const unlimitedSeats = hasUnlimitedSeats(subscriptions);
|
|
||||||
|
|
||||||
if (!activeTeamSubscription && !activeProSubscription) {
|
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
message: `Workspace with public ID ${workspace.publicId} does not have an active subscription`,
|
message: `Workspace with public ID ${workspace.publicId} does not have an active subscription`,
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the Stripe subscription
|
// Update the Stripe subscription to add a seat with immediate proration
|
||||||
if (activeTeamSubscription?.stripeSubscriptionId && !unlimitedSeats) {
|
if (activeSubscription.stripeSubscriptionId) {
|
||||||
try {
|
try {
|
||||||
await updateSubscriptionSeats(
|
await updateSubscriptionSeats(
|
||||||
activeTeamSubscription.stripeSubscriptionId,
|
activeSubscription.stripeSubscriptionId,
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -211,23 +204,22 @@ export const memberRouter = createTRPCRouter({
|
|||||||
|
|
||||||
// Handle subscription seat decrement for cloud environment
|
// Handle subscription seat decrement for cloud environment
|
||||||
if (process.env.NEXT_PUBLIC_KAN_ENV === "cloud") {
|
if (process.env.NEXT_PUBLIC_KAN_ENV === "cloud") {
|
||||||
const subscriptions = await subscriptionRepo.getByReferenceId(
|
const subscriptions = await ctx.auth.api.listActiveSubscriptions({
|
||||||
ctx.db,
|
workspacePublicId: workspace.publicId,
|
||||||
workspace.publicId,
|
});
|
||||||
);
|
|
||||||
|
|
||||||
// get the active subscriptions
|
// get the active subscription
|
||||||
const activeTeamSubscription = getSubscriptionByPlan(
|
const activeSubscription = subscriptions.find(
|
||||||
subscriptions,
|
(sub) =>
|
||||||
"team",
|
sub.status === "active" ||
|
||||||
|
(sub.status === "trialing" && sub.plan === "team"),
|
||||||
);
|
);
|
||||||
const unlimitedSeats = hasUnlimitedSeats(subscriptions);
|
|
||||||
|
|
||||||
// Only decrease seats if there's an active subscription and stripeSubscriptionId
|
// Only decrease seats if there's an active subscription and stripeSubscriptionId
|
||||||
if (activeTeamSubscription?.stripeSubscriptionId && !unlimitedSeats) {
|
if (activeSubscription?.stripeSubscriptionId) {
|
||||||
try {
|
try {
|
||||||
await updateSubscriptionSeats(
|
await updateSubscriptionSeats(
|
||||||
activeTeamSubscription.stripeSubscriptionId,
|
activeSubscription.stripeSubscriptionId,
|
||||||
-1,
|
-1,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { env } from "next-runtime-env";
|
|||||||
|
|
||||||
import type { dbClient } from "@kan/db/client";
|
import type { dbClient } from "@kan/db/client";
|
||||||
import * as memberRepo from "@kan/db/repository/member.repo";
|
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 userRepo from "@kan/db/repository/user.repo";
|
||||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||||
import * as schema from "@kan/db/schema";
|
import * as schema from "@kan/db/schema";
|
||||||
@@ -115,13 +114,13 @@ async function downloadImage(url: string): Promise<Buffer> {
|
|||||||
export const initAuth = (db: dbClient) => {
|
export const initAuth = (db: dbClient) => {
|
||||||
return betterAuth({
|
return betterAuth({
|
||||||
secret: process.env.BETTER_AUTH_SECRET!,
|
secret: process.env.BETTER_AUTH_SECRET!,
|
||||||
baseURL: env("NEXT_PUBLIC_BASE_URL"),
|
baseURL: process.env.NEXT_PUBLIC_BASE_URL!,
|
||||||
trustedOrigins: process.env.BETTER_AUTH_TRUSTED_ORIGINS
|
trustedOrigins: process.env.BETTER_AUTH_TRUSTED_ORIGINS
|
||||||
? [
|
? [
|
||||||
env("NEXT_PUBLIC_BASE_URL") ?? "",
|
process.env.NEXT_PUBLIC_BASE_URL!,
|
||||||
...process.env.BETTER_AUTH_TRUSTED_ORIGINS.split(","),
|
...process.env.BETTER_AUTH_TRUSTED_ORIGINS.split(","),
|
||||||
]
|
]
|
||||||
: [env("NEXT_PUBLIC_BASE_URL") ?? ""],
|
: [process.env.NEXT_PUBLIC_BASE_URL!],
|
||||||
database: drizzleAdapter(db, {
|
database: drizzleAdapter(db, {
|
||||||
provider: "pg",
|
provider: "pg",
|
||||||
schema: {
|
schema: {
|
||||||
@@ -197,31 +196,6 @@ export const initAuth = (db: dbClient) => {
|
|||||||
|
|
||||||
return isUserInWorkspace;
|
return isUserInWorkspace;
|
||||||
},
|
},
|
||||||
getCheckoutSessionParams: () => {
|
|
||||||
return {
|
|
||||||
params: {
|
|
||||||
allow_promotion_codes: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
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`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
@@ -343,17 +317,37 @@ export const initAuth = (db: dbClient) => {
|
|||||||
},
|
},
|
||||||
hooks: {
|
hooks: {
|
||||||
after: createAuthMiddleware(async (ctx) => {
|
after: createAuthMiddleware(async (ctx) => {
|
||||||
if (
|
if (ctx.path.startsWith("/get-session")) {
|
||||||
|
const user = ctx.context.session?.user;
|
||||||
|
|
||||||
|
if (
|
||||||
|
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||||
|
user &&
|
||||||
|
!user.stripeCustomerId
|
||||||
|
) {
|
||||||
|
const stripe = createStripeClient();
|
||||||
|
const stripeCustomer = await stripe.customers.create({
|
||||||
|
email: user.email,
|
||||||
|
metadata: {
|
||||||
|
userId: user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await userRepo.update(db, user.id, {
|
||||||
|
stripeCustomerId: stripeCustomer.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
ctx.path === "/magic-link/verify" &&
|
ctx.path === "/magic-link/verify" &&
|
||||||
(ctx.query?.callbackURL as string | undefined)?.includes(
|
(ctx.query?.callbackURL as string | undefined)?.includes(
|
||||||
"type=invite",
|
"type=invite",
|
||||||
)
|
) &&
|
||||||
|
ctx.query?.memberPublicId
|
||||||
) {
|
) {
|
||||||
const userId = ctx.context.newSession?.session.userId;
|
const userId = ctx.context.newSession?.session.userId;
|
||||||
const callbackURL = ctx.query?.callbackURL as string | undefined;
|
const memberPublicId = ctx.query.memberPublicId as string;
|
||||||
const memberPublicId = callbackURL?.split("memberPublicId=")[1];
|
|
||||||
|
|
||||||
if (userId && memberPublicId) {
|
if (userId) {
|
||||||
const member = await memberRepo.getByPublicId(db, memberPublicId);
|
const member = await memberRepo.getByPublicId(db, memberPublicId);
|
||||||
|
|
||||||
if (member?.id) {
|
if (member?.id) {
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
ALTER TABLE "subscription" ADD COLUMN "unlimitedSeats" boolean DEFAULT false NOT NULL;
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -71,13 +71,6 @@
|
|||||||
"when": 1756803246096,
|
"when": 1756803246096,
|
||||||
"tag": "20250902085406_AddSubscriptions",
|
"tag": "20250902085406_AddSubscriptions",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 10,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1757271312974,
|
|
||||||
"tag": "20250907185512_AddUnlimitedSeatsToSubscription",
|
|
||||||
"breakpoints": true
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
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,7 +127,6 @@ export const getByPublicIdWithMembers = (
|
|||||||
plan: true,
|
plan: true,
|
||||||
status: true,
|
status: true,
|
||||||
seats: true,
|
seats: true,
|
||||||
unlimitedSeats: true,
|
|
||||||
periodStart: true,
|
periodStart: true,
|
||||||
periodEnd: true,
|
periodEnd: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ export const subscription = pgTable("subscription", {
|
|||||||
periodEnd: timestamp("periodEnd"),
|
periodEnd: timestamp("periodEnd"),
|
||||||
cancelAtPeriodEnd: boolean("cancelAtPeriodEnd"),
|
cancelAtPeriodEnd: boolean("cancelAtPeriodEnd"),
|
||||||
seats: integer("seats"),
|
seats: integer("seats"),
|
||||||
unlimitedSeats: boolean("unlimitedSeats").default(false).notNull(),
|
|
||||||
trialStart: timestamp("trialStart"),
|
trialStart: timestamp("trialStart"),
|
||||||
trialEnd: timestamp("trialEnd"),
|
trialEnd: timestamp("trialEnd"),
|
||||||
createdAt: timestamp("createdAt").notNull().defaultNow(),
|
createdAt: timestamp("createdAt").notNull().defaultNow(),
|
||||||
|
|||||||
@@ -45,14 +45,14 @@ export const sendEmail = async (
|
|||||||
html,
|
html,
|
||||||
};
|
};
|
||||||
|
|
||||||
// if (cloudMailerClient) {
|
if (cloudMailerClient) {
|
||||||
// const response = await cloudMailerClient.emails.send({
|
const response = await cloudMailerClient.emails.send({
|
||||||
// ...options,
|
...options,
|
||||||
// body: html,
|
body: html,
|
||||||
// });
|
});
|
||||||
|
|
||||||
// return response;
|
return response;
|
||||||
// }
|
}
|
||||||
|
|
||||||
const response = await transporter.sendMail(options);
|
const response = await transporter.sendMail(options);
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
export * from "./generateUID";
|
export * from "./generateUID";
|
||||||
export * from "./generateSlug";
|
export * from "./generateSlug";
|
||||||
export * from "./subscriptions";
|
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
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);
|
|
||||||
};
|
|
||||||
@@ -64,7 +64,7 @@ export const updateSubscriptionSeats = async (
|
|||||||
quantity: newQuantity,
|
quantity: newQuantity,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
proration_behavior: "create_prorations",
|
proration_behavior: "always_invoice", // Invoice immediately
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -99,11 +99,7 @@
|
|||||||
"NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY",
|
"NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY",
|
||||||
"STRIPE_SECRET_KEY",
|
"STRIPE_SECRET_KEY",
|
||||||
"STRIPE_WEBHOOK_SECRET",
|
"STRIPE_WEBHOOK_SECRET",
|
||||||
"STRIPE_WEBHOOK_SECRET_LEGACY",
|
"STRIPE_PRO_PLAN_PRICE_ID",
|
||||||
"STRIPE_PRO_PLAN_MONTHLY_PRICE_ID",
|
|
||||||
"STRIPE_PRO_PLAN_YEARLY_PRICE_ID",
|
|
||||||
"STRIPE_TEAM_PLAN_MONTHLY_PRICE_ID",
|
|
||||||
"STRIPE_TEAM_PLAN_YEARLY_PRICE_ID",
|
|
||||||
"NEXT_PUBLIC_STORAGE_DOMAIN",
|
"NEXT_PUBLIC_STORAGE_DOMAIN",
|
||||||
"NEXT_PUBLIC_STORAGE_URL",
|
"NEXT_PUBLIC_STORAGE_URL",
|
||||||
"NEXT_PUBLIC_AVATAR_BUCKET_NAME",
|
"NEXT_PUBLIC_AVATAR_BUCKET_NAME",
|
||||||
|
|||||||
Reference in New Issue
Block a user