diff --git a/apps/web/actions/organization/manage-billing.ts b/apps/web/actions/organization/manage-billing.ts index 73c132bc0b..e53848e6b6 100644 --- a/apps/web/actions/organization/manage-billing.ts +++ b/apps/web/actions/organization/manage-billing.ts @@ -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, @@ -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`, }); diff --git a/apps/web/app/(org)/dashboard/dashboard-data.ts b/apps/web/app/(org)/dashboard/dashboard-data.ts index e2a45473a0..2af35ce8db 100644 --- a/apps/web/app/(org)/dashboard/dashboard-data.ts +++ b/apps/web/app/(org)/dashboard/dashboard-data.ts @@ -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< @@ -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, }, @@ -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` - ${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: { @@ -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, }; }), ), diff --git a/apps/web/app/(org)/dashboard/settings/organization/components/InviteDialog.tsx b/apps/web/app/(org)/dashboard/settings/organization/components/InviteDialog.tsx index 3b76536866..1581c89021 100644 --- a/apps/web/app/(org)/dashboard/settings/organization/components/InviteDialog.tsx +++ b/apps/web/app/(org)/dashboard/settings/organization/components/InviteDialog.tsx @@ -39,9 +39,8 @@ export const InviteDialog = ({ const { activeOrganization } = useDashboardContext(); const [inviteEmails, setInviteEmails] = useState([]); const [emailInput, setEmailInput] = useState(""); - const [upgradeLoading, setUpgradeLoading] = useState(false); - const { inviteQuota, remainingSeats } = calculateSeats( + const { paidSeats, remainingPaidSeats } = calculateSeats( activeOrganization || {}, ); @@ -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(""); }; @@ -66,21 +58,6 @@ 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) { @@ -88,12 +65,6 @@ export const InviteDialog = ({ 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, @@ -120,7 +91,7 @@ export const InviteDialog = ({ } - 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." > Invite to{" "} @@ -130,65 +101,49 @@ export const InviteDialog = ({
- {remainingSeats > 0 ? ( - <> - setEmailInput(e.target.value)} - placeholder="name@company.com" - onBlur={handleAddEmails} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === ",") { - e.preventDefault(); - handleAddEmails(); + setEmailInput(e.target.value)} + placeholder="name@company.com" + onBlur={handleAddEmails} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === ",") { + e.preventDefault(); + handleAddEmails(); + } + }} + /> +
+ {inviteEmails.map((email) => ( +
+ {email} + -
- ))} + type="button" + variant="destructive" + size="xs" + onClick={() => handleRemoveEmail(email)} + disabled={!isOwner} + > + Remove +
- - ) : ( -
-

No Seats Available

-

- You've reached your seat limit. Please upgrade your plan or - remove existing members to invite new ones. -

- -
+ ))} +
+ {paidSeats > 0 && ( +

+ 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. +

)} @@ -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 diff --git a/apps/web/app/(org)/dashboard/settings/organization/components/MembersCard.tsx b/apps/web/app/(org)/dashboard/settings/organization/components/MembersCard.tsx index d704f3f42f..6d24506ea4 100644 --- a/apps/web/app/(org)/dashboard/settings/organization/components/MembersCard.tsx +++ b/apps/web/app/(org)/dashboard/settings/organization/components/MembersCard.tsx @@ -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; @@ -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) { @@ -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); } diff --git a/apps/web/app/(org)/dashboard/settings/organization/components/SeatsInfoCards.tsx b/apps/web/app/(org)/dashboard/settings/organization/components/SeatsInfoCards.tsx index 1654c2e87b..cfc0abccc4 100644 --- a/apps/web/app/(org)/dashboard/settings/organization/components/SeatsInfoCards.tsx +++ b/apps/web/app/(org)/dashboard/settings/organization/components/SeatsInfoCards.tsx @@ -1,7 +1,11 @@ "use client"; import { Card } from "@cap/ui"; -import { faChair, faUserGroup } from "@fortawesome/free-solid-svg-icons"; +import { + faChair, + faCrown, + faUserGroup, +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { calculateSeats } from "@/utils/organization"; @@ -9,33 +13,43 @@ import { useDashboardContext } from "../../../Contexts"; export const SeatsInfoCards = () => { const { activeOrganization } = useDashboardContext(); - const { inviteQuota, remainingSeats } = calculateSeats( - activeOrganization || {}, - ); + const { paidSeats, memberCount, paidMemberCount, remainingPaidSeats } = + calculateSeats(activeOrganization || {}); return (
- +

- Seats Remaining + Total Members + {memberCount} +

+
+ +
+ +
+

+ Paid Seats - {remainingSeats} + {paidMemberCount} / {paidSeats}

- +

- Seats Capacity - {inviteQuota} + Available Paid Seats + + {remainingPaidSeats} +

diff --git a/apps/web/app/api/invite/accept/route.ts b/apps/web/app/api/invite/accept/route.ts index 71d216a795..ff4e6da34f 100644 --- a/apps/web/app/api/invite/accept/route.ts +++ b/apps/web/app/api/invite/accept/route.ts @@ -4,6 +4,7 @@ import { nanoId } from "@cap/database/helpers"; import { organizationInvites, organizationMembers, + organizations, users, } from "@cap/database/schema"; import { and, eq } from "drizzle-orm"; @@ -18,7 +19,6 @@ export async function POST(request: NextRequest) { const { inviteId } = await request.json(); try { - // Find the invite const [invite] = await db() .select() .from(organizationInvites) @@ -32,19 +32,12 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "Email mismatch" }, { status: 403 }); } - const [organizationOwner] = await db() + const [org] = await db() .select({ - stripeSubscriptionId: users.stripeSubscriptionId, + stripeSubscriptionId: organizations.stripeSubscriptionId, }) - .from(users) - .where(eq(users.id, invite.invitedByUserId)); - - if (!organizationOwner || !organizationOwner.stripeSubscriptionId) { - return NextResponse.json( - { error: "Organization owner not found or has no subscription" }, - { status: 404 }, - ); - } + .from(organizations) + .where(eq(organizations.id, invite.organizationId)); const [existingMembership] = await db() .select({ id: organizationMembers.id }) @@ -63,6 +56,7 @@ export async function POST(request: NextRequest) { organizationId: invite.organizationId, userId: user.id, role: invite.role, + seatType: "free", }); } @@ -76,7 +70,7 @@ export async function POST(request: NextRequest) { await db() .update(users) .set({ - thirdPartyStripeSubscriptionId: organizationOwner.stripeSubscriptionId, + thirdPartyStripeSubscriptionId: org?.stripeSubscriptionId || null, activeOrganizationId: invite.organizationId, defaultOrgId: invite.organizationId, onboardingSteps, diff --git a/apps/web/app/api/settings/billing/subscribe/route.ts b/apps/web/app/api/settings/billing/subscribe/route.ts index ebb9b816ba..d8dbeab545 100644 --- a/apps/web/app/api/settings/billing/subscribe/route.ts +++ b/apps/web/app/api/settings/billing/subscribe/route.ts @@ -11,7 +11,8 @@ import type Stripe from "stripe"; export async function POST(request: NextRequest) { const user = await getCurrentUser(); let customerId = user?.stripeCustomerId; - const { priceId, quantity, isOnBoarding } = await request.json(); + const { priceId, quantity, isOnBoarding, organizationId } = + await request.json(); if (!priceId) { console.error("Price ID not found"); @@ -63,6 +64,8 @@ export async function POST(request: NextRequest) { customerId = customer.id; } + const targetOrgId = organizationId || user.activeOrganizationId; + const checkoutSession = await stripe().checkout.sessions.create({ customer: customerId as string, line_items: [{ price: priceId, quantity: quantity }], @@ -78,6 +81,7 @@ export async function POST(request: NextRequest) { platform: "web", dubCustomerId: user.id, isOnBoarding: isOnBoarding ? "true" : "false", + organizationId: targetOrgId || "", }, }); diff --git a/apps/web/app/api/webhooks/stripe/route.ts b/apps/web/app/api/webhooks/stripe/route.ts index 8624da99b1..c382d69c91 100644 --- a/apps/web/app/api/webhooks/stripe/route.ts +++ b/apps/web/app/api/webhooks/stripe/route.ts @@ -1,10 +1,10 @@ import { db } from "@cap/database"; import { nanoId } from "@cap/database/helpers"; -import { users } from "@cap/database/schema"; +import { organizations, users } from "@cap/database/schema"; import { buildEnv, serverEnv } from "@cap/env"; import { stripe } from "@cap/utils"; import { Organisation, User } from "@cap/web-domain"; -import { eq } from "drizzle-orm"; +import { and, eq, isNull } from "drizzle-orm"; import { NextResponse } from "next/server"; import { PostHog } from "posthog-node"; import type Stripe from "stripe"; @@ -45,6 +45,29 @@ async function createGuestUser( return newUser; } +async function findTargetOrganization( + userId: User.UserId, + orgId?: string, +): Promise { + if (orgId) { + const [org] = await db() + .select() + .from(organizations) + .where(eq(organizations.id, orgId as Organisation.OrganisationId)); + if (org) return org; + } + + const userOrgs = await db() + .select() + .from(organizations) + .where( + and(eq(organizations.ownerId, userId), isNull(organizations.tombstoneAt)), + ) + .limit(1); + + return userOrgs[0] ?? null; +} + async function findUserWithRetry( email: string, userId?: User.UserId, @@ -227,20 +250,40 @@ export const POST = async (req: Request) => { status: subscription.status, }); - const inviteQuota = subscription.items.data.reduce( + const paidSeats = subscription.items.data.reduce( (total, item) => total + (item.quantity || 1), 0, ); const isOnBoarding = session.metadata?.isOnBoarding === "true"; + const orgId = session.metadata?.organizationId; - console.log("Updating user in database with:", { - subscriptionId: session.subscription, - status: subscription.status, - customerId: customer.id, - inviteQuota, - }); console.log("Session metadata:", session.metadata); console.log("Is onboarding:", isOnBoarding); + console.log("Organization ID:", orgId); + + const targetOrg = await findTargetOrganization(dbUser.id, orgId); + + if (targetOrg) { + console.log("Updating organization billing:", { + orgId: targetOrg.id, + subscriptionId: session.subscription, + status: subscription.status, + customerId: customer.id, + paidSeats, + }); + + await db() + .update(organizations) + .set({ + stripeSubscriptionId: session.subscription as string, + stripeSubscriptionStatus: subscription.status, + stripeCustomerId: customer.id, + paidSeats: paidSeats, + }) + .where(eq(organizations.id, targetOrg.id)); + + console.log("Successfully updated organization billing"); + } await db() .update(users) @@ -248,7 +291,7 @@ export const POST = async (req: Request) => { stripeSubscriptionId: session.subscription as string, stripeSubscriptionStatus: subscription.status, stripeCustomerId: customer.id, - inviteQuota: inviteQuota, + inviteQuota: paidSeats, onboarding_completed_at: isOnBoarding ? new Date() : undefined, }) .where(eq(users.id, dbUser.id)); @@ -269,13 +312,14 @@ export const POST = async (req: Request) => { properties: { subscription_id: subscription.id, subscription_status: subscription.status, - invite_quota: inviteQuota, + paid_seats: paidSeats, price_id: subscription.items.data[0]?.price.id, - quantity: inviteQuota, + quantity: paidSeats, is_onboarding: session.metadata?.isOnBoarding === "true", platform: session.metadata?.platform === "web", is_first_purchase: isFirstPurchase, is_guest_checkout: isGuestCheckout, + organization_id: targetOrg?.id, }, }); @@ -341,16 +385,16 @@ export const POST = async (req: Request) => { name: dbUser.name, }); - const subscriptions = await stripe().subscriptions.list({ + const subscriptionsList = await stripe().subscriptions.list({ customer: customer.id, status: "active", }); console.log("Retrieved all active subscriptions:", { - count: subscriptions.data.length, + count: subscriptionsList.data.length, }); - const inviteQuota = subscriptions.data.reduce((total, sub) => { + const paidSeats = subscriptionsList.data.reduce((total, sub) => { return ( total + sub.items.data.reduce( @@ -360,12 +404,46 @@ export const POST = async (req: Request) => { ); }, 0); - console.log("Updating user in database with:", { - subscriptionId: subscription.id, - status: subscription.status, - customerId: customer.id, - inviteQuota, - }); + const [orgWithSubscription] = await db() + .select() + .from(organizations) + .where(eq(organizations.stripeSubscriptionId, subscription.id)) + .limit(1); + + if (orgWithSubscription) { + console.log("Updating organization billing:", { + orgId: orgWithSubscription.id, + subscriptionId: subscription.id, + status: subscription.status, + paidSeats, + }); + + await db() + .update(organizations) + .set({ + stripeSubscriptionId: subscription.id, + stripeSubscriptionStatus: subscription.status, + stripeCustomerId: customer.id, + paidSeats: paidSeats, + }) + .where(eq(organizations.id, orgWithSubscription.id)); + + console.log("Successfully updated organization billing"); + } else { + const targetOrg = await findTargetOrganization(dbUser.id); + if (targetOrg) { + await db() + .update(organizations) + .set({ + stripeSubscriptionId: subscription.id, + stripeSubscriptionStatus: subscription.status, + stripeCustomerId: customer.id, + paidSeats: paidSeats, + }) + .where(eq(organizations.id, targetOrg.id)); + console.log("Updated fallback organization:", targetOrg.id); + } + } await db() .update(users) @@ -373,13 +451,13 @@ export const POST = async (req: Request) => { stripeSubscriptionId: subscription.id, stripeSubscriptionStatus: subscription.status, stripeCustomerId: customer.id, - inviteQuota: inviteQuota, + inviteQuota: paidSeats, }) .where(eq(users.id, dbUser.id)); console.log( - "Successfully updated user in database with new invite quota:", - inviteQuota, + "Successfully updated user in database with new paid seats:", + paidSeats, ); } @@ -433,6 +511,28 @@ export const POST = async (req: Request) => { return new Response("No user found", { status: 400 }); } + const [orgWithSubscription] = await db() + .select() + .from(organizations) + .where(eq(organizations.stripeSubscriptionId, subscription.id)) + .limit(1); + + if (orgWithSubscription) { + await db() + .update(organizations) + .set({ + stripeSubscriptionId: subscription.id, + stripeSubscriptionStatus: subscription.status, + paidSeats: 0, + }) + .where(eq(organizations.id, orgWithSubscription.id)); + + console.log("Organization billing reset", { + orgId: orgWithSubscription.id, + paidSeats: 0, + }); + } + await db() .update(users) .set({ diff --git a/apps/web/utils/organization.ts b/apps/web/utils/organization.ts index e8a40e6fa5..4b5464475d 100644 --- a/apps/web/utils/organization.ts +++ b/apps/web/utils/organization.ts @@ -1,26 +1,28 @@ +import type { OrganisationMemberSeatType } from "@cap/database/schema"; import { buildEnv } from "@cap/env"; -/** - * Calculate organization seats information - */ export function calculateSeats(organization: { - inviteQuota?: number; - members?: { id: string }[]; + paidSeats?: number; + members?: { id: string; seatType?: OrganisationMemberSeatType }[]; invites?: { id: string }[]; }) { - const inviteQuota = organization?.inviteQuota ?? 1; + const paidSeats = organization?.paidSeats ?? 0; const memberCount = organization?.members?.length ?? 0; const pendingInvitesCount = organization?.invites?.length ?? 0; - const totalUsedSeats = memberCount + pendingInvitesCount; - const remainingSeats = buildEnv.NEXT_PUBLIC_IS_CAP - ? Math.max(0, inviteQuota - totalUsedSeats) + const paidMemberCount = + organization?.members?.filter((m) => m.seatType === "paid").length ?? 0; + const usedPaidSeats = paidMemberCount; + const remainingPaidSeats = buildEnv.NEXT_PUBLIC_IS_CAP + ? Math.max(0, paidSeats - usedPaidSeats) : Number.MAX_SAFE_INTEGER; return { - inviteQuota, + paidSeats, memberCount, pendingInvitesCount, - totalUsedSeats, - remainingSeats, + paidMemberCount, + usedPaidSeats, + remainingPaidSeats, + canInviteUnlimited: true, }; } diff --git a/core b/core index 9e3a488172..f7fb0af388 100644 Binary files a/core and b/core differ diff --git a/packages/database/auth/drizzle-adapter.ts b/packages/database/auth/drizzle-adapter.ts index 7edaca2ea4..53ecdb746c 100644 --- a/packages/database/auth/drizzle-adapter.ts +++ b/packages/database/auth/drizzle-adapter.ts @@ -107,7 +107,7 @@ export function DrizzleAdapter(db: MySql2Database): Adapter { limit: 100, }); - const inviteQuota = subscriptions.data.reduce((total, sub) => { + const paidSeats = subscriptions.data.reduce((total, sub) => { return ( total + sub.items.data.reduce( @@ -126,11 +126,23 @@ export function DrizzleAdapter(db: MySql2Database): Adapter { ...(mostRecentSubscription && { stripeSubscriptionId: mostRecentSubscription.id, stripeSubscriptionStatus: mostRecentSubscription.status, - inviteQuota: inviteQuota || 1, + inviteQuota: paidSeats || 1, }), }) .where(eq(users.id, row.id)); + if (mostRecentSubscription && row.activeOrganizationId) { + await db + .update(organizations) + .set({ + stripeCustomerId: customer.id, + stripeSubscriptionId: mostRecentSubscription.id, + stripeSubscriptionStatus: mostRecentSubscription.status, + paidSeats: paidSeats || 0, + }) + .where(eq(organizations.id, row.activeOrganizationId)); + } + const [updatedRow] = await db .select() .from(users) diff --git a/packages/database/migrations/0009_org_billing.sql b/packages/database/migrations/0009_org_billing.sql new file mode 100644 index 0000000000..fc879c3e10 --- /dev/null +++ b/packages/database/migrations/0009_org_billing.sql @@ -0,0 +1,6 @@ +ALTER TABLE `organizations` ADD `stripeCustomerId` varchar(255);--> statement-breakpoint +ALTER TABLE `organizations` ADD `stripeSubscriptionId` varchar(255);--> statement-breakpoint +ALTER TABLE `organizations` ADD `stripeSubscriptionStatus` varchar(255);--> statement-breakpoint +ALTER TABLE `organizations` ADD `stripeSubscriptionPriceId` varchar(255);--> statement-breakpoint +ALTER TABLE `organizations` ADD `paidSeats` int NOT NULL DEFAULT 0;--> statement-breakpoint +ALTER TABLE `organization_members` ADD `seatType` varchar(255) NOT NULL DEFAULT 'free'; diff --git a/packages/database/migrations/meta/_journal.json b/packages/database/migrations/meta/_journal.json index 6fdc4535ef..419d5d0121 100644 --- a/packages/database/migrations/meta/_journal.json +++ b/packages/database/migrations/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1762428905323, "tag": "0008_fat_ender_wiggin", "breakpoints": true + }, + { + "idx": 9, + "version": "5", + "when": 1764619200000, + "tag": "0009_org_billing", + "breakpoints": true } ] } diff --git a/packages/database/migrations/org_billing_backfill.ts b/packages/database/migrations/org_billing_backfill.ts new file mode 100644 index 0000000000..25e89e20cd --- /dev/null +++ b/packages/database/migrations/org_billing_backfill.ts @@ -0,0 +1,226 @@ +import { db } from "@cap/database"; +import { + organizationMembers, + organizations, + sharedVideos, + users, +} from "@cap/database/schema"; +import { Organisation, User } from "@cap/web-domain"; +import { and, count, eq, isNotNull, isNull, sql } from "drizzle-orm"; + +const CHUNK_SIZE = 100; + +interface UserWithBilling { + id: User.UserId; + stripeCustomerId: string | null; + stripeSubscriptionId: string | null; + stripeSubscriptionStatus: string | null; + stripeSubscriptionPriceId: string | null; + inviteQuota: number; +} + +interface OrgWithStats { + id: Organisation.OrganisationId; + ownerId: User.UserId; + memberCount: number; + sharedVideoCount: number; +} + +async function findBestOrgForUser(userId: User.UserId): Promise { + const orgsOwned = await db() + .select({ + id: organizations.id, + ownerId: organizations.ownerId, + }) + .from(organizations) + .where( + and(eq(organizations.ownerId, userId), isNull(organizations.tombstoneAt)), + ); + + if (orgsOwned.length === 0) { + return null; + } + + const orgStats: OrgWithStats[] = []; + + for (const org of orgsOwned) { + const memberCountResult = await db() + .select({ value: count(organizationMembers.id) }) + .from(organizationMembers) + .where(eq(organizationMembers.organizationId, org.id)); + + const sharedVideoCountResult = await db() + .select({ value: count(sharedVideos.id) }) + .from(sharedVideos) + .where(eq(sharedVideos.organizationId, org.id)); + + orgStats.push({ + id: org.id, + ownerId: org.ownerId, + memberCount: memberCountResult[0]?.value || 0, + sharedVideoCount: sharedVideoCountResult[0]?.value || 0, + }); + } + + orgStats.sort((a, b) => { + if (a.memberCount !== b.memberCount) { + return b.memberCount - a.memberCount; + } + return b.sharedVideoCount - a.sharedVideoCount; + }); + + return orgStats[0]?.id || null; +} + +async function migrateUserBillingToOrg(user: UserWithBilling): Promise { + const targetOrgId = await findBestOrgForUser(user.id); + + if (!targetOrgId) { + console.log(` āš ļø No organizations found for user ${user.id}, skipping`); + return; + } + + const paidSeats = user.inviteQuota || 0; + + await db() + .update(organizations) + .set({ + stripeCustomerId: user.stripeCustomerId, + stripeSubscriptionId: user.stripeSubscriptionId, + stripeSubscriptionStatus: user.stripeSubscriptionStatus, + stripeSubscriptionPriceId: user.stripeSubscriptionPriceId, + paidSeats: paidSeats, + }) + .where(eq(organizations.id, targetOrgId)); + + console.log( + ` āœ… Migrated billing to org ${targetOrgId} with ${paidSeats} paid seats`, + ); + + const membersInOrg = await db() + .select({ + id: organizationMembers.id, + userId: organizationMembers.userId, + }) + .from(organizationMembers) + .where(eq(organizationMembers.organizationId, targetOrgId)) + .limit(paidSeats > 0 ? paidSeats : 1); + + if (membersInOrg.length > 0 && paidSeats > 0) { + const memberIdsToUpgrade = membersInOrg + .slice(0, paidSeats) + .map((m) => m.id); + + await db() + .update(organizationMembers) + .set({ seatType: "paid" }) + .where( + sql`${organizationMembers.id} IN (${sql.join( + memberIdsToUpgrade.map((id) => sql`${id}`), + sql`, `, + )})`, + ); + + console.log( + ` āœ… Upgraded ${memberIdsToUpgrade.length} members to paid seats`, + ); + } +} + +async function getInitialStats(): Promise { + console.log("šŸ“ˆ Getting initial stats..."); + + const usersWithBilling = await db() + .select({ count: sql`count(*)` }) + .from(users) + .where(isNotNull(users.stripeSubscriptionId)); + + const orgsWithBilling = await db() + .select({ count: sql`count(*)` }) + .from(organizations) + .where(isNotNull(organizations.stripeSubscriptionId)); + + const totalOrgs = await db() + .select({ count: sql`count(*)` }) + .from(organizations) + .where(isNull(organizations.tombstoneAt)); + + console.log("šŸ“Š Initial stats:"); + console.log(` Users with billing: ${usersWithBilling[0]?.count || 0}`); + console.log(` Orgs with billing: ${orgsWithBilling[0]?.count || 0}`); + console.log(` Total active orgs: ${totalOrgs[0]?.count || 0}`); + console.log(""); +} + +async function backfillOrgBilling(): Promise { + console.log("šŸ’³ Starting org billing backfill..."); + + let processed = 0; + let offset = 0; + + while (true) { + const usersWithBilling = await db() + .select({ + id: users.id, + stripeCustomerId: users.stripeCustomerId, + stripeSubscriptionId: users.stripeSubscriptionId, + stripeSubscriptionStatus: users.stripeSubscriptionStatus, + stripeSubscriptionPriceId: users.stripeSubscriptionPriceId, + inviteQuota: users.inviteQuota, + }) + .from(users) + .where(isNotNull(users.stripeSubscriptionId)) + .limit(CHUNK_SIZE) + .offset(offset); + + if (usersWithBilling.length === 0) break; + + for (const user of usersWithBilling) { + console.log(`\nšŸ‘¤ Processing user ${user.id}...`); + await migrateUserBillingToOrg(user); + processed++; + } + + offset += CHUNK_SIZE; + console.log(`\nšŸ“ Processed ${processed} users so far...`); + } + + console.log( + `\nāœ… Org billing backfill complete. Processed ${processed} users.`, + ); +} + +async function validateBackfill(): Promise { + console.log("\nšŸ” Validating backfill results..."); + + const orgsWithBilling = await db() + .select({ count: sql`count(*)` }) + .from(organizations) + .where(isNotNull(organizations.stripeSubscriptionId)); + + const paidMembers = await db() + .select({ count: sql`count(*)` }) + .from(organizationMembers) + .where(eq(organizationMembers.seatType, "paid")); + + const freeMembers = await db() + .select({ count: sql`count(*)` }) + .from(organizationMembers) + .where(eq(organizationMembers.seatType, "free")); + + console.log("šŸ“Š Validation results:"); + console.log(` Orgs with billing: ${orgsWithBilling[0]?.count || 0}`); + console.log(` Paid seat members: ${paidMembers[0]?.count || 0}`); + console.log(` Free seat members: ${freeMembers[0]?.count || 0}`); +} + +export async function runOrgBillingBackfill(): Promise { + console.log("šŸš€ Starting org billing backfill script"); + console.log(`šŸ“¦ Processing in chunks of ${CHUNK_SIZE} users\n`); + + await getInitialStats(); + await backfillOrgBilling(); + await validateBackfill(); + + console.log("\nšŸŽ‰ Migration complete!"); +} diff --git a/packages/database/schema.ts b/packages/database/schema.ts index 14a2e9685e..a6e88c2d31 100644 --- a/packages/database/schema.ts +++ b/packages/database/schema.ts @@ -199,6 +199,15 @@ export const organizations = mysqlTable( updatedAt: timestamp("updatedAt").notNull().defaultNow().onUpdateNow(), workosOrganizationId: varchar("workosOrganizationId", { length: 255 }), workosConnectionId: varchar("workosConnectionId", { length: 255 }), + stripeCustomerId: varchar("stripeCustomerId", { length: 255 }), + stripeSubscriptionId: varchar("stripeSubscriptionId", { length: 255 }), + stripeSubscriptionStatus: varchar("stripeSubscriptionStatus", { + length: 255, + }), + stripeSubscriptionPriceId: varchar("stripeSubscriptionPriceId", { + length: 255, + }), + paidSeats: int("paidSeats").notNull().default(0), }, (table) => ({ ownerIdTombstoneIndex: index("owner_id_tombstone_idx").on( @@ -210,6 +219,7 @@ export const organizations = mysqlTable( ); export type OrganisationMemberRole = "owner" | "member"; +export type OrganisationMemberSeatType = "free" | "paid"; export const organizationMembers = mysqlTable( "organization_members", { @@ -221,6 +231,10 @@ export const organizationMembers = mysqlTable( role: varchar("role", { length: 255 }) .notNull() .$type(), + seatType: varchar("seatType", { length: 255 }) + .notNull() + .default("free") + .$type(), createdAt: timestamp("createdAt").notNull().defaultNow(), updatedAt: timestamp("updatedAt").notNull().defaultNow().onUpdateNow(), }, diff --git a/packages/utils/src/constants/plans.ts b/packages/utils/src/constants/plans.ts index 0f44770079..7b1e447f79 100644 --- a/packages/utils/src/constants/plans.ts +++ b/packages/utils/src/constants/plans.ts @@ -11,6 +11,15 @@ export const STRIPE_PLAN_IDS = { }, }; +export const isActiveSubscription = (status?: string | null): boolean => { + return ( + status === "active" || + status === "trialing" || + status === "complete" || + status === "paid" + ); +}; + export const userIsPro = ( user?: { stripeSubscriptionStatus?: string | null; @@ -23,16 +32,40 @@ export const userIsPro = ( const { stripeSubscriptionStatus, thirdPartyStripeSubscriptionId } = user; - // Check for third-party subscription first if (thirdPartyStripeSubscriptionId) { return true; } - // Then check regular subscription status + return isActiveSubscription(stripeSubscriptionStatus); +}; + +export const orgIsPro = ( + org?: { + stripeSubscriptionStatus?: string | null; + paidSeats?: number | null; + } | null, +) => { + if (!buildEnv.NEXT_PUBLIC_IS_CAP) return true; + + if (!org) return false; + + return isActiveSubscription(org.stripeSubscriptionStatus); +}; + +export const memberHasPaidSeat = ( + member?: { + seatType?: string | null; + } | null, + org?: { + stripeSubscriptionStatus?: string | null; + } | null, +) => { + if (!buildEnv.NEXT_PUBLIC_IS_CAP) return true; + + if (!member || !org) return false; + return ( - stripeSubscriptionStatus === "active" || - stripeSubscriptionStatus === "trialing" || - stripeSubscriptionStatus === "complete" || - stripeSubscriptionStatus === "paid" + member.seatType === "paid" && + isActiveSubscription(org.stripeSubscriptionStatus) ); };