Compare commits

..

8 Commits

Author SHA1 Message Date
Henry
9df9b9146c Merge branch 'main' into feat/seats 2025-09-02 22:35:56 +01:00
Henry
7966ec01f5 chore: update proration comment 2025-09-02 22:34:27 +01:00
Henry
2779c06bcc chore: add translations 2025-09-02 22:24:45 +01:00
Henry
4f9841ceaf chore: remove pricing notice 2025-09-02 22:24:33 +01:00
Henry
a596b1c40b feat: rework upgrade 2025-09-02 22:19:42 +01:00
Henry
bc98fc848f feat: update subscription when increasing/decreasing workspace members 2025-09-02 14:05:35 +01:00
Henry
5f4678ab4e feat: prompt user to create subscription if inactive 2025-09-02 13:12:43 +01:00
Henry
a7c68db90e feat: setup subscriptions 2025-09-01 22:17:09 +01:00
19 changed files with 116 additions and 2843 deletions

View File

@@ -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,
}, },
], ],

View File

@@ -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);

View File

@@ -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}

View File

@@ -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>

View File

@@ -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);
} }
}; };

View File

@@ -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}

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -1 +0,0 @@
ALTER TABLE "subscription" ADD COLUMN "unlimitedSeats" boolean DEFAULT false NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -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
} }
] ]
} }

View File

@@ -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),
});
};

View File

@@ -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,
}, },

View File

@@ -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(),

View File

@@ -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);

View File

@@ -1,3 +1,2 @@
export * from "./generateUID"; export * from "./generateUID";
export * from "./generateSlug"; export * from "./generateSlug";
export * from "./subscriptions";

View File

@@ -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);
};

View File

@@ -64,7 +64,7 @@ export const updateSubscriptionSeats = async (
quantity: newQuantity, quantity: newQuantity,
}, },
], ],
proration_behavior: "create_prorations", proration_behavior: "always_invoice", // Invoice immediately
}, },
); );

View File

@@ -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",