Compare commits
7 Commits
fix/react-
...
feat/unlim
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6a4c60807 | ||
|
|
10d04838bd | ||
|
|
277f3285f7 | ||
|
|
64fc1258ed | ||
|
|
97f08fa9fb | ||
|
|
cdda0a9de1 | ||
|
|
fdf7d7cecf |
@@ -7,7 +7,9 @@ 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";
|
||||||
@@ -19,20 +21,13 @@ import { api } from "~/utils/api";
|
|||||||
|
|
||||||
export function InviteMemberForm({
|
export function InviteMemberForm({
|
||||||
numberOfMembers,
|
numberOfMembers,
|
||||||
activeTeamSubscription,
|
subscriptions,
|
||||||
|
unlimitedSeats,
|
||||||
userId,
|
userId,
|
||||||
}: {
|
}: {
|
||||||
numberOfMembers: number;
|
numberOfMembers: number;
|
||||||
activeTeamSubscription:
|
subscriptions: Subscription[] | undefined;
|
||||||
| {
|
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();
|
||||||
@@ -87,16 +82,19 @@ 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 (
|
if (teamSubscription?.periodStart && teamSubscription?.periodEnd) {
|
||||||
activeTeamSubscription?.periodStart &&
|
const periodStartDate = new Date(teamSubscription.periodStart);
|
||||||
activeTeamSubscription?.periodEnd
|
const periodEndDate = new Date(teamSubscription.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),
|
||||||
@@ -163,7 +161,8 @@ export function InviteMemberForm({
|
|||||||
placeholder={t`Email`}
|
placeholder={t`Email`}
|
||||||
disabled={
|
disabled={
|
||||||
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||||
!activeTeamSubscription?.id
|
!hasTeamSubscription &&
|
||||||
|
!hasProSubscription
|
||||||
}
|
}
|
||||||
{...register("email", { required: true })}
|
{...register("email", { required: true })}
|
||||||
onKeyDown={async (e) => {
|
onKeyDown={async (e) => {
|
||||||
@@ -177,13 +176,15 @@ 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">
|
||||||
{activeTeamSubscription?.id ? (
|
{hasTeamSubscription || hasProSubscription ? (
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium text-emerald-500 dark:text-emerald-400">
|
<span className="font-medium text-emerald-500 dark:text-emerald-400">
|
||||||
{t`Team Plan`}
|
{hasTeamSubscription ? t`Team Plan` : t`Pro Plan ∞`}
|
||||||
</span>
|
</span>
|
||||||
<p className="mt-1">
|
<p className="mt-1">
|
||||||
{t`Adding a new member will cost an additional ${price} (${billingType}) per seat.`}
|
{unlimitedSeats
|
||||||
|
? 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>
|
||||||
) : (
|
) : (
|
||||||
@@ -201,7 +202,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">
|
||||||
{activeTeamSubscription?.id &&
|
{(hasTeamSubscription || hasProSubscription) &&
|
||||||
env("NEXT_PUBLIC_KAN_ENV") === "cloud" && (
|
env("NEXT_PUBLIC_KAN_ENV") === "cloud" && (
|
||||||
<Toggle
|
<Toggle
|
||||||
label={t`Invite another`}
|
label={t`Invite another`}
|
||||||
@@ -213,7 +214,8 @@ export function InviteMemberForm({
|
|||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||||
!activeTeamSubscription?.id ? (
|
!hasTeamSubscription &&
|
||||||
|
!hasProSubscription ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleUpgrade}
|
onClick={handleUpgrade}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ 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";
|
||||||
@@ -30,13 +32,12 @@ export default function MembersPage() {
|
|||||||
|
|
||||||
const { data: session } = authClient.useSession();
|
const { data: session } = authClient.useSession();
|
||||||
|
|
||||||
const subscription = data?.subscriptions;
|
const subscriptions = data?.subscriptions as Subscription[] | undefined;
|
||||||
|
|
||||||
const activeTeamSubscription = subscription?.find(
|
const teamSubscription = getSubscriptionByPlan(subscriptions, "team");
|
||||||
(sub: any) =>
|
const proSubscription = getSubscriptionByPlan(subscriptions, "pro");
|
||||||
sub.status === "active" ||
|
|
||||||
(sub.status === "trialing" && sub.plan === "team"),
|
const unlimitedSeats = hasUnlimitedSeats(subscriptions);
|
||||||
);
|
|
||||||
|
|
||||||
const TableRow = ({
|
const TableRow = ({
|
||||||
memberPublicId,
|
memberPublicId,
|
||||||
@@ -175,13 +176,20 @@ export default function MembersPage() {
|
|||||||
<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",
|
||||||
activeTeamSubscription
|
teamSubscription || proSubscription
|
||||||
? "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">
|
||||||
{activeTeamSubscription ? t`Team Plan` : t`Free Plan`}
|
{proSubscription
|
||||||
|
? t`Pro Plan`
|
||||||
|
: teamSubscription
|
||||||
|
? t`Team Plan`
|
||||||
|
: t`Free Plan`}
|
||||||
|
{proSubscription && unlimitedSeats && (
|
||||||
|
<span className="ml-1 text-xs">∞</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -268,7 +276,8 @@ export default function MembersPage() {
|
|||||||
<InviteMemberForm
|
<InviteMemberForm
|
||||||
userId={session?.user.id}
|
userId={session?.user.id}
|
||||||
numberOfMembers={data?.members.length ?? 1}
|
numberOfMembers={data?.members.length ?? 1}
|
||||||
activeTeamSubscription={activeTeamSubscription}
|
subscriptions={subscriptions}
|
||||||
|
unlimitedSeats={unlimitedSeats}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
|||||||
@@ -1,37 +1,42 @@
|
|||||||
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 () => {
|
||||||
try {
|
const { data, error } = await authClient.subscription.upgrade({
|
||||||
const response = await fetch("/api/stripe/create_checkout_session", {
|
plan: "pro",
|
||||||
method: "POST",
|
referenceId: workspacePublicId,
|
||||||
headers: {
|
metadata: { userId, workspacePublicId, workspaceSlug: entityId },
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
slug: entityId,
|
|
||||||
workspacePublicId: workspacePublicId,
|
|
||||||
cancelUrl: "/settings",
|
|
||||||
successUrl: "/settings",
|
successUrl: "/settings",
|
||||||
}),
|
cancelUrl: "/settings",
|
||||||
|
returnUrl: "/settings",
|
||||||
|
disableRedirect: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { url } = (await response.json()) as { url: string };
|
if (data?.url) {
|
||||||
|
window.location.href = data.url;
|
||||||
if (url) {
|
|
||||||
window.location.href = url;
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error("Error creating checkout session:", error);
|
if (error) {
|
||||||
|
showPopup({
|
||||||
|
header: t`Error upgrading subscription`,
|
||||||
|
message: t`Please try again later, or contact customer support.`,
|
||||||
|
icon: "error",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ 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";
|
||||||
@@ -62,29 +64,34 @@ export const memberRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (process.env.NEXT_PUBLIC_KAN_ENV === "cloud") {
|
if (process.env.NEXT_PUBLIC_KAN_ENV === "cloud") {
|
||||||
const subscriptions = await ctx.auth.api.listActiveSubscriptions({
|
const subscriptions = await subscriptionRepo.getByReferenceId(
|
||||||
workspacePublicId: workspace.publicId,
|
ctx.db,
|
||||||
});
|
workspace.publicId,
|
||||||
|
|
||||||
// get the active subscription
|
|
||||||
const activeSubscription = subscriptions.find(
|
|
||||||
(sub) =>
|
|
||||||
sub.status === "active" ||
|
|
||||||
(sub.status === "trialing" && sub.plan === "team"),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
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({
|
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 to add a seat with immediate proration
|
// Update the Stripe subscription
|
||||||
if (activeSubscription.stripeSubscriptionId) {
|
if (activeTeamSubscription?.stripeSubscriptionId && !unlimitedSeats) {
|
||||||
try {
|
try {
|
||||||
await updateSubscriptionSeats(
|
await updateSubscriptionSeats(
|
||||||
activeSubscription.stripeSubscriptionId,
|
activeTeamSubscription.stripeSubscriptionId,
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -204,22 +211,23 @@ 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 ctx.auth.api.listActiveSubscriptions({
|
const subscriptions = await subscriptionRepo.getByReferenceId(
|
||||||
workspacePublicId: workspace.publicId,
|
ctx.db,
|
||||||
});
|
workspace.publicId,
|
||||||
|
|
||||||
// get the active subscription
|
|
||||||
const activeSubscription = subscriptions.find(
|
|
||||||
(sub) =>
|
|
||||||
sub.status === "active" ||
|
|
||||||
(sub.status === "trialing" && sub.plan === "team"),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// get the active subscriptions
|
||||||
|
const activeTeamSubscription = getSubscriptionByPlan(
|
||||||
|
subscriptions,
|
||||||
|
"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 (activeSubscription?.stripeSubscriptionId) {
|
if (activeTeamSubscription?.stripeSubscriptionId && !unlimitedSeats) {
|
||||||
try {
|
try {
|
||||||
await updateSubscriptionSeats(
|
await updateSubscriptionSeats(
|
||||||
activeSubscription.stripeSubscriptionId,
|
activeTeamSubscription.stripeSubscriptionId,
|
||||||
-1,
|
-1,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ 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";
|
||||||
@@ -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,
|
"when": 1756803246096,
|
||||||
"tag": "20250902085406_AddSubscriptions",
|
"tag": "20250902085406_AddSubscriptions",
|
||||||
"breakpoints": true
|
"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,
|
plan: true,
|
||||||
status: true,
|
status: true,
|
||||||
seats: true,
|
seats: true,
|
||||||
|
unlimitedSeats: true,
|
||||||
periodStart: true,
|
periodStart: true,
|
||||||
periodEnd: true,
|
periodEnd: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ 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(),
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from "./generateUID";
|
export * from "./generateUID";
|
||||||
export * from "./generateSlug";
|
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