diff --git a/apps/web/actions/organization/get-subscription-details.ts b/apps/web/actions/organization/get-subscription-details.ts new file mode 100644 index 0000000000..2b0b2df381 --- /dev/null +++ b/apps/web/actions/organization/get-subscription-details.ts @@ -0,0 +1,74 @@ +"use server"; + +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { organizations, users } from "@cap/database/schema"; +import { stripe } from "@cap/utils"; +import type { Organisation } from "@cap/web-domain"; +import { eq } from "drizzle-orm"; + +export type SubscriptionDetails = { + planName: string; + status: string; + billingInterval: "month" | "year"; + pricePerSeat: number; + currentQuantity: number; + currentPeriodEnd: number; + currency: string; +}; + +export async function getSubscriptionDetails( + organizationId: Organisation.OrganisationId, +): Promise { + const user = await getCurrentUser(); + if (!user) throw new Error("Unauthorized"); + + const [organization] = await db() + .select() + .from(organizations) + .where(eq(organizations.id, organizationId)) + .limit(1); + + if (!organization) throw new Error("Organization not found"); + if (organization.ownerId !== user.id) + throw new Error("Only the owner can view subscription details"); + + const [owner] = await db() + .select({ + stripeSubscriptionId: users.stripeSubscriptionId, + }) + .from(users) + .where(eq(users.id, user.id)) + .limit(1); + + if (!owner?.stripeSubscriptionId) { + return null; + } + + const subscription = await stripe().subscriptions.retrieve( + owner.stripeSubscriptionId, + ); + + if (subscription.status !== "active" && subscription.status !== "trialing") { + return null; + } + + const item = subscription.items.data[0]; + if (!item) return null; + + const price = item.price; + const unitAmount = price.unit_amount ?? 0; + const interval = price.recurring?.interval === "year" ? "year" : "month"; + const pricePerSeat = + interval === "year" ? unitAmount / 100 / 12 : unitAmount / 100; + + return { + planName: "Cap Pro", + status: subscription.status, + billingInterval: interval, + pricePerSeat, + currentQuantity: item.quantity ?? 1, + currentPeriodEnd: subscription.current_period_end, + currency: price.currency, + }; +} diff --git a/apps/web/actions/organization/send-invites.ts b/apps/web/actions/organization/send-invites.ts index 183a115b24..6ec05bb770 100644 --- a/apps/web/actions/organization/send-invites.ts +++ b/apps/web/actions/organization/send-invites.ts @@ -5,10 +5,15 @@ import { getCurrentUser } from "@cap/database/auth/session"; import { sendEmail } from "@cap/database/emails/config"; import { OrganizationInvite } from "@cap/database/emails/organization-invite"; import { nanoId } from "@cap/database/helpers"; -import { organizationInvites, organizations } from "@cap/database/schema"; +import { + organizationInvites, + organizationMembers, + organizations, + users, +} from "@cap/database/schema"; import { serverEnv } from "@cap/env"; import type { Organisation } from "@cap/web-domain"; -import { eq } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import { revalidatePath } from "next/cache"; export async function sendOrganizationInvites( @@ -21,48 +26,136 @@ export async function sendOrganizationInvites( throw new Error("Unauthorized"); } - const organization = await db() + const [organization] = await db() .select() .from(organizations) .where(eq(organizations.id, organizationId)); - if (!organization || organization.length === 0) { + if (!organization) { throw new Error("Organization not found"); } - if (organization[0]?.ownerId !== user.id) { - throw new Error("Only the owner can send organization invites"); + if (organization.ownerId !== user.id) { + throw new Error("Only the organization owner can send invites"); + } + + const MAX_INVITES = 50; + if (invitedEmails.length > MAX_INVITES) { + throw new Error(`Cannot send more than ${MAX_INVITES} invites at once`); } const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - const validEmails = invitedEmails.filter((email) => - emailRegex.test(email.trim()), + const validEmails = Array.from( + new Set( + invitedEmails + .map((email) => email.trim().toLowerCase()) + .filter((email) => emailRegex.test(email)), + ), + ); + + if (validEmails.length === 0) { + return { success: true, failedEmails: [] as string[] }; + } + + const inviteRecords = await db().transaction(async (tx) => { + const [existingInvites, existingMembers] = await Promise.all([ + tx + .select({ invitedEmail: organizationInvites.invitedEmail }) + .from(organizationInvites) + .where( + and( + eq(organizationInvites.organizationId, organizationId), + inArray(organizationInvites.invitedEmail, validEmails), + ), + ), + tx + .select({ email: users.email }) + .from(organizationMembers) + .innerJoin(users, eq(organizationMembers.userId, users.id)) + .where( + and( + eq(organizationMembers.organizationId, organizationId), + inArray(users.email, validEmails), + ), + ), + ]); + + const existingInviteEmails = new Set( + existingInvites.map((i) => i.invitedEmail.toLowerCase()), + ); + + const existingMemberEmails = new Set( + existingMembers.map((m) => m.email.toLowerCase()), + ); + + const emailsToInvite = validEmails.filter( + (email) => + !existingInviteEmails.has(email) && !existingMemberEmails.has(email), + ); + + const records = emailsToInvite.map((email) => ({ + id: nanoId(), + email, + })); + + if (records.length > 0) { + await tx.insert(organizationInvites).values( + records.map((r) => ({ + id: r.id, + organizationId: organizationId, + invitedEmail: r.email, + invitedByUserId: user.id, + role: "member" as const, + })), + ); + } + + return records; + }); + + const emailResults = await Promise.allSettled( + inviteRecords.map((record) => { + const inviteUrl = `${serverEnv().WEB_URL}/invite/${record.id}`; + return sendEmail({ + email: record.email, + subject: `Invitation to join ${organization.name} on Cap`, + react: OrganizationInvite({ + email: record.email, + url: inviteUrl, + organizationName: organization.name, + }), + }); + }), + ); + + const failedInvites = inviteRecords.filter( + (_, i) => emailResults[i]?.status === "rejected", ); + const failedEmails = failedInvites.map((r) => r.email); - for (const email of validEmails) { - const inviteId = nanoId(); - await db().insert(organizationInvites).values({ - id: inviteId, - organizationId: organizationId, - invitedEmail: email.trim(), - invitedByUserId: user.id, - role: "member", - }); - - // Send invitation email - const inviteUrl = `${serverEnv().WEB_URL}/invite/${inviteId}`; - await sendEmail({ - email: email.trim(), - subject: `Invitation to join ${organization[0].name} on Cap`, - react: OrganizationInvite({ - email: email.trim(), - url: inviteUrl, - organizationName: organization[0].name, - }), - }); + if (failedInvites.length > 0) { + try { + await db() + .delete(organizationInvites) + .where( + inArray( + organizationInvites.id, + failedInvites.map((r) => r.id), + ), + ); + } catch (cleanupError) { + console.error( + "Failed to clean up invite records after email delivery failure:", + { + failedInviteIds: failedInvites.map((r) => r.id), + failedEmails, + error: cleanupError, + }, + ); + } } revalidatePath("/dashboard/settings/organization"); - return { success: true }; + return { success: true, failedEmails }; } diff --git a/apps/web/actions/organization/toggle-pro-seat.ts b/apps/web/actions/organization/toggle-pro-seat.ts new file mode 100644 index 0000000000..5337a6689e --- /dev/null +++ b/apps/web/actions/organization/toggle-pro-seat.ts @@ -0,0 +1,160 @@ +"use server"; + +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { + organizationMembers, + organizations, + users, +} from "@cap/database/schema"; +import type { Organisation } from "@cap/web-domain"; +import { and, eq } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { calculateProSeats } from "@/utils/organization"; + +export async function toggleProSeat( + memberId: string, + organizationId: Organisation.OrganisationId, + enable: boolean, +) { + const user = await getCurrentUser(); + if (!user) throw new Error("Unauthorized"); + + const [organization] = await db() + .select() + .from(organizations) + .where(eq(organizations.id, organizationId)) + .limit(1); + + if (!organization) { + throw new Error("Organization not found"); + } + + if (organization.ownerId !== user.id) { + throw new Error("Only the owner can manage Pro seats"); + } + + await db().transaction(async (tx) => { + const [member] = await tx + .select() + .from(organizationMembers) + .where( + and( + eq(organizationMembers.id, memberId), + eq(organizationMembers.organizationId, organizationId), + ), + ) + .for("update"); + + if (!member) { + throw new Error("Member not found"); + } + + if (member.userId === organization.ownerId) { + throw new Error("Cannot toggle Pro seat for the organization owner"); + } + + if (member.hasProSeat === enable) { + return { success: true }; + } + + if (enable) { + const allMembers = await tx + .select({ + id: organizationMembers.id, + hasProSeat: organizationMembers.hasProSeat, + }) + .from(organizationMembers) + .where(eq(organizationMembers.organizationId, organizationId)) + .for("update"); + + const [owner] = await tx + .select({ + inviteQuota: users.inviteQuota, + stripeSubscriptionId: users.stripeSubscriptionId, + }) + .from(users) + .where(eq(users.id, organization.ownerId)) + .limit(1); + + const { proSeatsRemaining } = calculateProSeats({ + inviteQuota: owner?.inviteQuota ?? 1, + members: allMembers, + }); + + if (proSeatsRemaining <= 0) { + throw new Error( + "No Pro seats remaining. Purchase more seats to continue.", + ); + } + + await tx + .update(organizationMembers) + .set({ hasProSeat: true }) + .where(eq(organizationMembers.id, memberId)); + + if (owner?.stripeSubscriptionId) { + await tx + .update(users) + .set({ thirdPartyStripeSubscriptionId: owner.stripeSubscriptionId }) + .where(eq(users.id, member.userId)); + } + } else { + await tx + .update(organizationMembers) + .set({ hasProSeat: false }) + .where(eq(organizationMembers.id, memberId)); + + const otherProSeats = await tx + .select({ id: organizationMembers.id }) + .from(organizationMembers) + .where( + and( + eq(organizationMembers.userId, member.userId), + eq(organizationMembers.hasProSeat, true), + ), + ) + .limit(1); + + if (otherProSeats.length === 0) { + await tx + .update(users) + .set({ thirdPartyStripeSubscriptionId: null }) + .where(eq(users.id, member.userId)); + } else { + const [remainingOrg] = await tx + .select({ stripeSubscriptionId: users.stripeSubscriptionId }) + .from(organizationMembers) + .innerJoin( + organizations, + eq(organizationMembers.organizationId, organizations.id), + ) + .innerJoin(users, eq(organizations.ownerId, users.id)) + .where( + and( + eq(organizationMembers.userId, member.userId), + eq(organizationMembers.hasProSeat, true), + ), + ) + .limit(1); + + if (remainingOrg?.stripeSubscriptionId) { + await tx + .update(users) + .set({ + thirdPartyStripeSubscriptionId: remainingOrg.stripeSubscriptionId, + }) + .where(eq(users.id, member.userId)); + } else { + await tx + .update(users) + .set({ thirdPartyStripeSubscriptionId: null }) + .where(eq(users.id, member.userId)); + } + } + } + }); + + revalidatePath("/dashboard/settings/organization"); + return { success: true }; +} diff --git a/apps/web/actions/organization/update-seat-quantity.ts b/apps/web/actions/organization/update-seat-quantity.ts new file mode 100644 index 0000000000..cbdf42af5d --- /dev/null +++ b/apps/web/actions/organization/update-seat-quantity.ts @@ -0,0 +1,179 @@ +"use server"; + +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { + organizationMembers, + organizations, + users, +} from "@cap/database/schema"; +import { stripe } from "@cap/utils"; +import type { Organisation } from "@cap/web-domain"; +import { eq } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { calculateProSeats } from "@/utils/organization"; + +async function getOwnerSubscription( + organizationId: Organisation.OrganisationId, +) { + const user = await getCurrentUser(); + if (!user) throw new Error("Unauthorized"); + + const [organization] = await db() + .select() + .from(organizations) + .where(eq(organizations.id, organizationId)) + .limit(1); + + if (!organization) throw new Error("Organization not found"); + if (organization.ownerId !== user.id) + throw new Error("Only the owner can manage seats"); + + const [owner] = await db() + .select({ + stripeSubscriptionId: users.stripeSubscriptionId, + stripeCustomerId: users.stripeCustomerId, + inviteQuota: users.inviteQuota, + }) + .from(users) + .where(eq(users.id, user.id)) + .limit(1); + + if (!owner?.stripeSubscriptionId || !owner.stripeCustomerId) { + throw new Error("No active subscription found"); + } + + const subscription = await stripe().subscriptions.retrieve( + owner.stripeSubscriptionId, + ); + + const subscriptionItem = subscription.items.data[0]; + if (!subscriptionItem) { + throw new Error("No subscription item found"); + } + + const allMembers = await db() + .select({ + id: organizationMembers.id, + hasProSeat: organizationMembers.hasProSeat, + }) + .from(organizationMembers) + .where(eq(organizationMembers.organizationId, organizationId)); + + const { proSeatsUsed } = calculateProSeats({ + inviteQuota: owner.inviteQuota ?? 1, + members: allMembers, + }); + + return { owner, subscription, subscriptionItem, proSeatsUsed, user }; +} + +const MAX_SEATS = 500; + +function validateQuantity(quantity: number): void { + if (!Number.isInteger(quantity) || quantity < 1 || quantity > MAX_SEATS) { + throw new Error(`Quantity must be an integer between 1 and ${MAX_SEATS}`); + } +} + +export async function previewSeatChange( + organizationId: Organisation.OrganisationId, + newQuantity: number, +) { + validateQuantity(newQuantity); + const { owner, subscriptionItem, proSeatsUsed } = + await getOwnerSubscription(organizationId); + const customerId = owner.stripeCustomerId; + const subscriptionId = owner.stripeSubscriptionId; + + if (!customerId || !subscriptionId) { + throw new Error("No active subscription found"); + } + + if (newQuantity < proSeatsUsed) { + throw new Error( + `Cannot reduce below ${proSeatsUsed} seats (currently assigned)`, + ); + } + + const previewParams = { + customer: customerId, + subscription: subscriptionId, + subscription_items: [ + { + id: subscriptionItem.id, + quantity: newQuantity, + }, + ], + subscription_proration_behavior: "create_prorations" as const, + }; + + const preview = await stripe().invoices.retrieveUpcoming(previewParams); + const previewLines = preview.lines.has_more + ? await stripe() + .invoices.listUpcomingLines(previewParams) + .autoPagingToArray({ limit: 1000 }) + : preview.lines.data; + + const currentQuantity = subscriptionItem.quantity ?? 1; + const proratedAmount = previewLines.reduce((total, line) => { + if (!line.proration) return total; + return total + line.amount; + }, 0); + const nextPaymentDate = preview.period_end; + + return { + proratedAmount, + nextPaymentDate, + currentQuantity, + newQuantity, + currency: preview.currency, + }; +} + +export async function updateSeatQuantity( + organizationId: Organisation.OrganisationId, + newQuantity: number, +) { + validateQuantity(newQuantity); + const { subscription, subscriptionItem, proSeatsUsed, user } = + await getOwnerSubscription(organizationId); + + if (newQuantity < proSeatsUsed) { + throw new Error( + `Cannot reduce below ${proSeatsUsed} seats (currently assigned)`, + ); + } + + await stripe().subscriptions.update(subscription.id, { + items: [ + { + id: subscriptionItem.id, + quantity: newQuantity, + }, + ], + proration_behavior: "create_prorations", + }); + + try { + await db() + .update(users) + .set({ inviteQuota: newQuantity }) + .where(eq(users.id, user.id)); + } catch (dbError) { + console.error( + "CRITICAL: Stripe updated to quantity", + newQuantity, + "but DB update failed for user", + user.id, + dbError, + ); + throw new Error( + "Billing update succeeded but local state could not be saved. Please contact support.", + ); + } + + revalidatePath("/dashboard/settings/organization"); + + return { success: true, newQuantity }; +} diff --git a/apps/web/app/(org)/dashboard/Contexts.tsx b/apps/web/app/(org)/dashboard/Contexts.tsx index faa2a7f135..44c86c4c0c 100644 --- a/apps/web/app/(org)/dashboard/Contexts.tsx +++ b/apps/web/app/(org)/dashboard/Contexts.tsx @@ -4,6 +4,7 @@ import { buildEnv } from "@cap/env"; import Cookies from "js-cookie"; import { redirect, usePathname } from "next/navigation"; import { createContext, useContext, useEffect, useState } from "react"; +import { InviteDialog } from "@/app/(org)/dashboard/settings/organization/components/InviteDialog"; import { type CurrentUser, useCurrentUser } from "@/app/Layout/AuthContext"; import { UpgradeModal } from "@/components/UpgradeModal"; import type { @@ -30,6 +31,8 @@ type SharedContext = { sidebarCollapsed: boolean; upgradeModalOpen: boolean; setUpgradeModalOpen: (open: boolean) => void; + inviteDialogOpen: boolean; + setInviteDialogOpen: (open: boolean) => void; referClickedState: boolean; setReferClickedStateHandler: (referClicked: boolean) => void; isDeveloperSection: boolean; @@ -86,6 +89,7 @@ export function DashboardContexts({ initialSidebarCollapsed, ); const [upgradeModalOpen, setUpgradeModalOpen] = useState(false); + const [inviteDialogOpen, setInviteDialogOpen] = useState(false); const [referClickedState, setReferClickedState] = useState(referClicked); const [developerApps, setDeveloperApps] = useState( null, @@ -182,6 +186,8 @@ export function DashboardContexts({ sidebarCollapsed, upgradeModalOpen, setUpgradeModalOpen, + inviteDialogOpen, + setInviteDialogOpen, referClickedState, setReferClickedStateHandler, isDeveloperSection, @@ -191,7 +197,11 @@ export function DashboardContexts({ > {children} - {/* Global upgrade modal that persists regardless of navigation state */} + + {buildEnv.NEXT_PUBLIC_IS_CAP && ( { name: "Organization Settings", href: `/dashboard/settings/organization`, ownerOnly: true, + matchChildren: true, icon: , subNav: [], }, @@ -300,6 +302,7 @@ const AdminNavItems = ({ toggleMobileNav }: Props) => { +