Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 38 additions & 6 deletions apps/web/actions/organization/manage-billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,44 @@

import { db } from "@cap/database";
import { getCurrentUser } from "@cap/database/auth/session";
import { users } from "@cap/database/schema";
import { organizations, users } from "@cap/database/schema";
import { serverEnv } from "@cap/env";
import { stripe } from "@cap/utils";
import { eq } from "drizzle-orm";
import type { Organisation } from "@cap/web-domain";
import { and, eq, isNull } from "drizzle-orm";
import type Stripe from "stripe";

export async function manageBilling() {
export async function manageBilling(
organizationId?: Organisation.OrganisationId,
) {
const user = await getCurrentUser();
let customerId = user?.stripeCustomerId;

if (!user) {
throw new Error("Unauthorized");
}

if (!user.stripeCustomerId) {
const targetOrgId = organizationId || user.activeOrganizationId;

let customerId: string | null = null;

if (targetOrgId) {
const [org] = await db()
.select({
stripeCustomerId: organizations.stripeCustomerId,
})
.from(organizations)
.where(eq(organizations.id, targetOrgId));

if (org?.stripeCustomerId) {
customerId = org.stripeCustomerId;
}
}

if (!customerId) {
customerId = user.stripeCustomerId || null;
}

if (!customerId) {
const existingCustomers = await stripe().customers.list({
email: user.email,
limit: 1,
Expand Down Expand Up @@ -48,11 +71,20 @@ export async function manageBilling() {
})
.where(eq(users.id, user.id));

if (targetOrgId) {
await db()
.update(organizations)
.set({
stripeCustomerId: customer.id,
})
.where(eq(organizations.id, targetOrgId));
}

customerId = customer.id;
}

const { url } = await stripe().billingPortal.sessions.create({
customer: customerId as string,
customer: customerId,
return_url: `${serverEnv().WEB_URL}/dashboard/settings/organization`,
});

Expand Down
47 changes: 10 additions & 37 deletions apps/web/app/(org)/dashboard/dashboard-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ export type Organization = {
> & { image?: ImageUpload.ImageUrl | null };
})[];
invites: (typeof organizationInvites.$inferSelect)[];
inviteQuota: number;
totalInvites: number;
paidSeats: number;
totalMembers: number;
};

export type OrganizationSettings = NonNullable<
Expand All @@ -54,12 +54,12 @@ export async function getDashboardData(user: typeof userSelectProps) {
settings: organizations.settings,
member: organizationMembers,
iconUrl: organizations.iconUrl,
paidSeats: organizations.paidSeats,
user: {
id: users.id,
name: users.name,
lastName: users.lastName,
email: users.email,
inviteQuota: users.inviteQuota,
image: users.image,
defaultOrgId: users.defaultOrgId,
},
Expand Down Expand Up @@ -297,43 +297,16 @@ export async function getDashboardData(user: typeof userSelectProps) {
.where(eq(organizationMembers.organizationId, organization.id)),
);

const owner = yield* db.use((db) =>
const totalMembersResult = yield* db.use((db) =>
db
.select({
inviteQuota: users.inviteQuota,
value: count(organizationMembers.id),
})
.from(users)
.where(eq(users.id, organization.ownerId))
.then((result) => result[0]),
);

const totalInvitesResult = yield* db.use((db) =>
db
.select({
value: sql<number>`
${count(organizationMembers.id)} + ${count(
organizationInvites.id,
)}
`,
})
.from(organizations)
.leftJoin(
organizationMembers,
eq(organizations.id, organizationMembers.organizationId),
)
.leftJoin(
organizationInvites,
eq(organizations.id, organizationInvites.organizationId),
)
.where(
and(
eq(organizations.ownerId, organization.ownerId),
isNull(organizations.tombstoneAt),
),
),
.from(organizationMembers)
.where(eq(organizationMembers.organizationId, organization.id)),
);

const totalInvites = totalInvitesResult[0]?.value || 0;
const totalMembers = totalMembersResult[0]?.value || 0;

return {
organization: {
Expand Down Expand Up @@ -361,8 +334,8 @@ export async function getDashboardData(user: typeof userSelectProps) {
invites: organizationInvitesData.filter(
(invite) => invite.organizationId === organization.id,
),
inviteQuota: owner?.inviteQuota || 1,
totalInvites,
paidSeats: organization.paidSeats || 0,
totalMembers,
};
}),
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,8 @@ export const InviteDialog = ({
const { activeOrganization } = useDashboardContext();
const [inviteEmails, setInviteEmails] = useState<string[]>([]);
const [emailInput, setEmailInput] = useState("");
const [upgradeLoading, setUpgradeLoading] = useState(false);

const { inviteQuota, remainingSeats } = calculateSeats(
const { paidSeats, remainingPaidSeats } = calculateSeats(
activeOrganization || {},
);

Expand All @@ -51,13 +50,6 @@ export const InviteDialog = ({
.map((email) => email.trim())
.filter((email) => email !== "");

if (inviteEmails.length + newEmails.length > remainingSeats) {
toast.error(
`Not enough seats available. You have ${remainingSeats} seats remaining.`,
);
return;
}

setInviteEmails([...new Set([...inviteEmails, ...newEmails])]);
setEmailInput("");
};
Expand All @@ -66,34 +58,13 @@ export const InviteDialog = ({
setInviteEmails(inviteEmails.filter((e) => e !== email));
};

const handleUpgradePlan = async () => {
if (!isOwner) {
showOwnerToast();
return;
}

setUpgradeLoading(true);
setIsOpen(false);
try {
await handleManageBilling();
} catch (error) {
setUpgradeLoading(false);
}
};

const sendInvites = useMutation({
mutationFn: async () => {
if (!isOwner) {
showOwnerToast();
throw new Error("Not authorized");
}

if (inviteEmails.length > remainingSeats) {
throw new Error(
`Not enough seats available. You have ${remainingSeats} seats remaining.`,
);
}

return await sendOrganizationInvites(
inviteEmails,
activeOrganization?.organization.id as Organisation.OrganisationId,
Expand All @@ -120,7 +91,7 @@ export const InviteDialog = ({
<DialogContent className="p-0 w-full max-w-md rounded-xl border bg-gray-2 border-gray-4">
<DialogHeader
icon={<FontAwesomeIcon icon={faUserGroup} className="size-3.5" />}
description="Invite your teammates to join the organization"
description="Invite teammates to join your organization. Invited members will be on the free plan by default."
>
<DialogTitle>
Invite to{" "}
Expand All @@ -130,65 +101,49 @@ export const InviteDialog = ({
</DialogTitle>
</DialogHeader>
<div className="p-5">
{remainingSeats > 0 ? (
<>
<Input
id="emails"
value={emailInput}
onChange={(e) => setEmailInput(e.target.value)}
placeholder="[email protected]"
onBlur={handleAddEmails}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
handleAddEmails();
<Input
id="emails"
value={emailInput}
onChange={(e) => setEmailInput(e.target.value)}
placeholder="[email protected]"
onBlur={handleAddEmails}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
handleAddEmails();
}
}}
/>
<div className="flex overflow-y-auto flex-col gap-2.5 mt-4 max-h-60">
{inviteEmails.map((email) => (
<div
key={email}
className="flex justify-between items-center p-3 rounded-xl border transition-colors duration-200 cursor-pointer border-gray-4 hover:bg-gray-3"
>
<span className="text-sm text-gray-12">{email}</span>
<Button
style={
{
"--gradient-border-radius": "8px",
} as React.CSSProperties
}
}}
/>
<div className="flex overflow-y-auto flex-col gap-2.5 mt-4 max-h-60">
{inviteEmails.map((email) => (
<div
key={email}
className="flex justify-between items-center p-3 rounded-xl border transition-colors duration-200 cursor-pointer border-gray-4 hover:bg-gray-3"
>
<span className="text-sm text-gray-12">{email}</span>
<Button
style={
{
"--gradient-border-radius": "8px",
} as React.CSSProperties
}
type="button"
variant="destructive"
size="xs"
onClick={() => handleRemoveEmail(email)}
disabled={!isOwner}
>
Remove
</Button>
</div>
))}
type="button"
variant="destructive"
size="xs"
onClick={() => handleRemoveEmail(email)}
disabled={!isOwner}
>
Remove
</Button>
</div>
</>
) : (
<div className="flex flex-col gap-2 p-4 bg-amber-50 rounded-xl border border-amber-200">
<p className="font-medium text-amber-800">No Seats Available</p>
<p className="text-sm text-amber-700">
You've reached your seat limit. Please upgrade your plan or
remove existing members to invite new ones.
</p>
<Button
type="button"
size="sm"
variant="dark"
className="self-start mt-2"
spinner={upgradeLoading}
disabled={upgradeLoading || !isOwner}
onClick={handleUpgradePlan}
>
Upgrade Plan
</Button>
</div>
))}
</div>
{paidSeats > 0 && (
<p className="mt-3 text-xs text-gray-11">
You have {remainingPaidSeats} paid seat
{remainingPaidSeats !== 1 ? "s" : ""} available. New members will
join on the free plan and can be upgraded to paid seats later.
</p>
)}
</div>
<DialogFooter className="p-5 border-t border-gray-4">
Expand All @@ -205,11 +160,7 @@ export const InviteDialog = ({
size="sm"
variant="dark"
spinner={sendInvites.isPending}
disabled={
sendInvites.isPending ||
inviteEmails.length === 0 ||
remainingSeats === 0
}
disabled={sendInvites.isPending || inviteEmails.length === 0}
onClick={() => sendInvites.mutate()}
>
Send Invites
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import { removeOrganizationMember } from "@/actions/organization/remove-member";
import { ConfirmationDialog } from "@/app/(org)/dashboard/_components/ConfirmationDialog";
import { useDashboardContext } from "@/app/(org)/dashboard/Contexts";
import { Tooltip } from "@/components/Tooltip";
import { calculateSeats } from "@/utils/organization";

interface MembersCardProps {
isOwner: boolean;
Expand All @@ -44,7 +43,6 @@ export const MembersCard = ({
}: MembersCardProps) => {
const router = useRouter();
const { activeOrganization } = useDashboardContext();
const { remainingSeats } = calculateSeats(activeOrganization || {});

const handleDeleteInvite = async (inviteId: string) => {
if (!isOwner) {
Expand Down Expand Up @@ -173,10 +171,6 @@ export const MembersCard = ({
onClick={() => {
if (!isOwner) {
showOwnerToast();
} else if (remainingSeats <= 0) {
toast.error(
"Invite limit reached, please purchase more seats",
);
} else {
setIsInviteDialogOpen(true);
}
Expand Down
Loading
Loading