diff --git a/apps/web/modules/ee/billing/components/DueInvoiceBanner.tsx b/apps/web/modules/ee/billing/components/DueInvoiceBanner.tsx new file mode 100644 index 0000000000..3401db1a65 --- /dev/null +++ b/apps/web/modules/ee/billing/components/DueInvoiceBanner.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import type { RouterOutputs } from "@calcom/trpc/react"; +import { TopBanner } from "@calcom/ui/components/top-banner"; + +export type DueInvoiceBannerProps = { + data: RouterOutputs["viewer"]["me"]["getUserTopBanners"]["dueInvoiceBanner"]; +}; + +export function DueInvoiceBanner({ data }: DueInvoiceBannerProps) { + const { t } = useLocale(); + + if (!data || data.length === 0) return null; + + // Get the first proration to display (prioritize blocking ones) + const blockingProrations = data.filter((d) => d.isBlocking); + const displayProration = blockingProrations[0] ?? data[0]; + const hasBlocking = blockingProrations.length > 0; + + // Use Stripe invoice URL if available, otherwise fall back to billing settings + const paymentUrl = + displayProration.invoiceUrl ?? `/settings/teams/${displayProration.teamId}/billing`; + + return ( + + {t("pay_now")} + + } + /> + ); +} diff --git a/apps/web/modules/shell/banners/LayoutBanner.tsx b/apps/web/modules/shell/banners/LayoutBanner.tsx index dca8274031..a44479aa47 100644 --- a/apps/web/modules/shell/banners/LayoutBanner.tsx +++ b/apps/web/modules/shell/banners/LayoutBanner.tsx @@ -1,3 +1,7 @@ +import { + DueInvoiceBanner, + type DueInvoiceBannerProps, +} from "@calcom/web/modules/ee/billing/components/DueInvoiceBanner"; import ImpersonatingBanner, { type ImpersonatingBannerProps, } from "@calcom/web/modules/ee/impersonation/components/ImpersonatingBanner"; @@ -31,6 +35,7 @@ type BannerTypeProps = { impersonationBanner: ImpersonatingBannerProps; calendarCredentialBanner: CalendarCredentialBannerProps; invalidAppCredentialBanners: InvalidAppCredentialBannersProps; + dueInvoiceBanner: DueInvoiceBannerProps; }; type BannerType = keyof BannerTypeProps; @@ -51,6 +56,7 @@ export const BannerComponent: BannerComponent = { invalidAppCredentialBanners: (props: InvalidAppCredentialBannersProps) => ( ), + dueInvoiceBanner: (props: DueInvoiceBannerProps) => , }; interface BannerContainerProps { @@ -82,6 +88,9 @@ export const BannerContainer: React.FC = ({ banners }) => } else if (key === "invalidAppCredentialBanners") { const Banner = BannerComponent[key]; return ; + } else if (key === "dueInvoiceBanner") { + const Banner = BannerComponent[key]; + return ; } })} diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 57b7245b83..33954c14b5 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -1889,6 +1889,9 @@ "calendars": "Calendars", "2fa_auth": "Two factor authentication", "invoices": "Invoices", + "invoice_due": "You have an unpaid invoice for \"{{teamName}}\". Please pay to avoid service interruption.", + "invoice_overdue_blocking": "Your invoice for \"{{teamName}}\" is overdue. Member invitations are blocked until payment.", + "invitations_blocked_unpaid_invoice": "Member invitations are blocked due to an unpaid invoice over 7 days past due.", "embeds": "Embeds", "impersonation": "Impersonation", "impersonation_description": "Settings to manage user impersonation", diff --git a/packages/features/ee/billing/service/dueInvoice/DueInvoiceService.ts b/packages/features/ee/billing/service/dueInvoice/DueInvoiceService.ts new file mode 100644 index 0000000000..d6aa2d5570 --- /dev/null +++ b/packages/features/ee/billing/service/dueInvoice/DueInvoiceService.ts @@ -0,0 +1,228 @@ +import type { PrismaClient } from "@calcom/prisma"; +import { prisma as defaultPrisma } from "@calcom/prisma"; +import { MembershipRole } from "@calcom/prisma/enums"; + +import { TeamRepository } from "../../../teams/repositories/TeamRepository"; + +export interface CanInviteResult { + allowed: boolean; + reason?: string; +} + +export interface BannerData { + teamId: number; + teamName: string; + isOrganization: boolean; + amountDue: number; + isBlocking: boolean; + prorationId: string; + monthKey: string; + invoiceUrl: string | null; +} + +const BLOCKING_THRESHOLD_DAYS = 7; + +export class DueInvoiceService { + private prisma: PrismaClient; + + constructor(prisma?: PrismaClient) { + this.prisma = prisma || defaultPrisma; + } + + /** + * Check if a team has a blocking (7+ days overdue) proration invoice + */ + async hasBlockingProration(teamId: number): Promise { + const sevenDaysAgo = new Date(); + sevenDaysAgo.setDate(sevenDaysAgo.getDate() - BLOCKING_THRESHOLD_DAYS); + + const blockingProration = await this.prisma.monthlyProration.findFirst({ + where: { + teamId, + status: { + in: ["FAILED", "INVOICE_CREATED"], + }, + createdAt: { + lte: sevenDaysAgo, + }, + }, + select: { id: true }, + }); + + return blockingProration !== null; + } + + /** + * Check if invitations can be sent for a team + * Sub-team exception: Allow inviting existing org members to sub-teams even with blocking prorations + */ + async canInviteToTeam({ + teamId, + inviteeEmails, + isSubTeam, + parentOrgId, + }: { + teamId: number; + inviteeEmails: string[]; + isSubTeam: boolean; + parentOrgId: number | null; + }): Promise { + // Determine which team to check for billing (org or team itself) + const billingTeamId = parentOrgId ?? teamId; + const hasBlocking = await this.hasBlockingProration(billingTeamId); + + if (!hasBlocking) { + return { allowed: true }; + } + + // Sub-team exception: allow if all invitees are already org members + if (isSubTeam && parentOrgId) { + const allAreOrgMembers = await this.checkAllInviteesAreOrgMembers(inviteeEmails, parentOrgId); + if (allAreOrgMembers) { + return { allowed: true }; + } + } + + return { allowed: false, reason: "invitations_blocked_unpaid_invoice" }; + } + + /** + * Check if all invitees are already members of the organization + */ + private async checkAllInviteesAreOrgMembers(emails: string[], orgId: number): Promise { + if (emails.length === 0) { + return true; + } + + // Find users by email who are members of the org + const orgMembers = await this.prisma.membership.findMany({ + where: { + teamId: orgId, + accepted: true, + user: { + email: { + in: emails, + }, + }, + }, + select: { + user: { + select: { + email: true, + }, + }, + }, + }); + + const memberEmails = new Set(orgMembers.map((m) => m.user.email.toLowerCase())); + return emails.every((email) => memberEmails.has(email.toLowerCase())); + } + + /** + * Get banner data for a user - returns overdue prorations for teams where user has billing permission + */ + async getBannerDataForUser(userId: number): Promise { + const teamRepository = new TeamRepository(this.prisma); + + // Get teams where user has billing permission (ADMIN/OWNER or specific permission) + const teamsWithBillingPermission = await this.findTeamsWithBillingPermission(userId, teamRepository); + + if (teamsWithBillingPermission.length === 0) { + return []; + } + + const teamIds = teamsWithBillingPermission.map((t) => t.id); + const sevenDaysAgo = new Date(); + sevenDaysAgo.setDate(sevenDaysAgo.getDate() - BLOCKING_THRESHOLD_DAYS); + + // Get overdue prorations for these teams + const overdueProrations = await this.prisma.monthlyProration.findMany({ + where: { + teamId: { in: teamIds }, + status: { + in: ["FAILED", "INVOICE_CREATED"], + }, + }, + include: { + team: { + select: { + id: true, + name: true, + isOrganization: true, + }, + }, + }, + orderBy: { createdAt: "asc" }, + }); + + return overdueProrations.map((proration) => ({ + teamId: proration.teamId, + teamName: proration.team.name, + isOrganization: proration.team.isOrganization, + amountDue: proration.proratedAmount, + isBlocking: proration.createdAt <= sevenDaysAgo, + prorationId: proration.id, + monthKey: proration.monthKey, + invoiceUrl: proration.invoiceUrl, + })); + } + + /** + * Find teams where user has billing management permission + */ + private async findTeamsWithBillingPermission( + userId: number, + teamRepository: TeamRepository + ): Promise<{ id: number; isOrganization: boolean }[]> { + // Get all teams/orgs where user is ADMIN or OWNER + const memberships = await this.prisma.membership.findMany({ + where: { + userId, + accepted: true, + role: { + in: [MembershipRole.ADMIN, MembershipRole.OWNER], + }, + }, + select: { + teamId: true, + team: { + select: { + id: true, + isOrganization: true, + }, + }, + }, + }); + + // For each team, check if user has billing permission using PBAC or legacy role + const teamsWithPermission: { id: number; isOrganization: boolean }[] = []; + + for (const membership of memberships) { + const permission = membership.team.isOrganization ? "organization.manageBilling" : "team.manageBilling"; + + try { + const usersWithPermission = await teamRepository.findTeamMembersWithPermission({ + teamId: membership.teamId, + permission, + fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER], + }); + + const hasPermission = usersWithPermission.some((u) => u.id === userId); + if (hasPermission) { + teamsWithPermission.push({ + id: membership.team.id, + isOrganization: membership.team.isOrganization, + }); + } + } catch { + // If permission check fails, fall back to role-based check (already filtered above) + teamsWithPermission.push({ + id: membership.team.id, + isOrganization: membership.team.isOrganization, + }); + } + } + + return teamsWithPermission; + } +} diff --git a/packages/features/ee/billing/service/dueInvoice/seed-proration-test.ts b/packages/features/ee/billing/service/dueInvoice/seed-proration-test.ts index f13269489b..a461489da5 100644 --- a/packages/features/ee/billing/service/dueInvoice/seed-proration-test.ts +++ b/packages/features/ee/billing/service/dueInvoice/seed-proration-test.ts @@ -31,7 +31,7 @@ import bcrypt from "bcryptjs"; import Stripe from "stripe"; import { prisma } from "@calcom/prisma"; -import { MembershipRole } from "@calcom/prisma/enums"; +import { BillingPeriod, MembershipRole } from "@calcom/prisma/enums"; async function hashPassword(password: string): Promise { const salt = await bcrypt.genSalt(10); @@ -42,6 +42,26 @@ const TEST_PASSWORD = "password123"; const SKIP_STRIPE = process.argv.includes("--skip-stripe"); const CLEANUP_FIRST = process.argv.includes("--cleanup"); +const TRIGGER_ORG_SLUG = "trigger-test-org"; +const TRIGGER_TEAM_SLUG = "trigger-test-team"; +const TRIGGER_ADMIN_EMAIL = "trigger-admin@example.com"; +const TRIGGER_MEMBER_EMAILS = Array.from({ length: 4 }, (_, i) => `trigger-user-${i + 1}@example.com`); + +/** + * Compute the monthKey the scheduleMonthlyProration trigger will use. + * The trigger processes the previous month in YYYY-MM format (UTC). + */ +function computeTriggerMonthKey(): string { + const now = new Date(); + const startOfCurrentMonthUtc = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); + // Previous month + const prevMonth = new Date(startOfCurrentMonthUtc); + prevMonth.setUTCMonth(prevMonth.getUTCMonth() - 1); + const year = prevMonth.getUTCFullYear(); + const month = String(prevMonth.getUTCMonth() + 1).padStart(2, "0"); + return `${year}-${month}`; +} + interface StripeResources { customer: Stripe.Customer | null; subscription: Stripe.Subscription | null; @@ -49,12 +69,21 @@ interface StripeResources { price: Stripe.Price | null; } +interface TriggerOrgResult { + organization: { id: number; name: string; slug: string }; + team: { id: number; name: string; slug: string }; + monthKey: string; + unprocessedSeatChanges: number; + stripe: StripeResources; +} + interface SeedResult { organization: { id: number; name: string; slug: string }; team: { id: number; name: string; slug: string }; users: Array<{ email: string; name: string; role: string }>; prorations: Array<{ id: string; status: string; isBlocking: boolean }>; stripe: StripeResources; + triggerOrg: TriggerOrgResult | null; } function getStripeClient(): Stripe | null { @@ -118,30 +147,33 @@ async function createTestUser(email: string, name: string, username: string, org async function cleanupStripeResources(stripe: Stripe) { console.log("Cleaning up existing Stripe test resources..."); - // Find and delete test customers - const customers = await stripe.customers.list({ - limit: 100, - email: "proration-admin@example.com", - }); + // Find and delete test customers for both orgs + const emailsToCleanup = ["proration-admin@example.com", TRIGGER_ADMIN_EMAIL]; + for (const email of emailsToCleanup) { + const customers = await stripe.customers.list({ + limit: 100, + email, + }); - for (const customer of customers.data) { - try { - // Cancel subscriptions first - const subscriptions = await stripe.subscriptions.list({ - customer: customer.id, - status: "all", - }); - for (const sub of subscriptions.data) { - if (sub.status !== "canceled") { - await stripe.subscriptions.cancel(sub.id); - console.log(` Cancelled subscription: ${sub.id}`); + for (const customer of customers.data) { + try { + // Cancel subscriptions first + const subscriptions = await stripe.subscriptions.list({ + customer: customer.id, + status: "all", + }); + for (const sub of subscriptions.data) { + if (sub.status !== "canceled") { + await stripe.subscriptions.cancel(sub.id); + console.log(` Cancelled subscription: ${sub.id}`); + } } + // Delete customer + await stripe.customers.del(customer.id); + console.log(` Deleted customer: ${customer.id}`); + } catch (error) { + console.log(` Could not delete customer ${customer.id}:`, error); } - // Delete customer - await stripe.customers.del(customer.id); - console.log(` Deleted customer: ${customer.id}`); - } catch (error) { - console.log(` Could not delete customer ${customer.id}:`, error); } } @@ -151,7 +183,7 @@ async function cleanupStripeResources(stripe: Stripe) { }); for (const product of products.data) { - if (product.name.startsWith("Proration Test") && product.active) { + if ((product.name.startsWith("Proration Test") || product.name.startsWith("Trigger Test")) && product.active) { await stripe.products.update(product.id, { active: false }); console.log(` Archived product: ${product.id}`); } @@ -269,9 +301,109 @@ async function createFailedInvoice( return finalizedInvoice; } +async function createStripeResourcesForTriggerOrg(stripe: Stripe, orgId: number): Promise { + console.log("Creating Stripe resources for trigger org (annual)..."); + + const product = await stripe.products.create({ + name: `Trigger Test Product - ${Date.now()}`, + description: "Test product for trigger-ready org (annual billing)", + metadata: { + testData: "true", + orgId: orgId.toString(), + }, + }); + console.log(` Created product: ${product.id}`); + + // Annual pricing at $150/seat/year ($12.50/month equivalent) + const price = await stripe.prices.create({ + product: product.id, + unit_amount: 15000, // $150.00/year + currency: "usd", + recurring: { + interval: "year", + usage_type: "licensed", + }, + metadata: { testData: "true" }, + }); + console.log(` Created price: ${price.id}`); + + const customer = await stripe.customers.create({ + email: TRIGGER_ADMIN_EMAIL, + name: "Trigger Test Org", + metadata: { + testData: "true", + orgId: orgId.toString(), + calOrgId: orgId.toString(), + }, + }); + console.log(` Created customer: ${customer.id}`); + + const paymentMethod = await stripe.paymentMethods.create({ + type: "card", + card: { token: "tok_visa" }, + }); + + await stripe.paymentMethods.attach(paymentMethod.id, { customer: customer.id }); + await stripe.customers.update(customer.id, { + invoice_settings: { default_payment_method: paymentMethod.id }, + }); + console.log(` Attached payment method: ${paymentMethod.id}`); + + // Create annual subscription with 3 seats + const subscription = await stripe.subscriptions.create({ + customer: customer.id, + items: [{ price: price.id, quantity: 3 }], + metadata: { + testData: "true", + orgId: orgId.toString(), + }, + }); + console.log(` Created subscription: ${subscription.id}`); + + return { customer, subscription, product, price }; +} + async function cleanupDatabaseResources() { console.log("Cleaning up database test resources..."); + // Clean up trigger-test-org + const triggerOrg = await prisma.team.findFirst({ + where: { slug: TRIGGER_ORG_SLUG }, + }); + + if (triggerOrg) { + await prisma.monthlyProration.deleteMany({ where: { teamId: triggerOrg.id } }); + await prisma.seatChangeLog.deleteMany({ where: { teamId: triggerOrg.id } }); + await prisma.organizationBilling.deleteMany({ where: { teamId: triggerOrg.id } }); + + const triggerChildTeams = await prisma.team.findMany({ where: { parentId: triggerOrg.id } }); + for (const t of triggerChildTeams) { + await prisma.membership.deleteMany({ where: { teamId: t.id } }); + await prisma.team.delete({ where: { id: t.id } }); + } + + await prisma.membership.deleteMany({ where: { teamId: triggerOrg.id } }); + await prisma.team.delete({ where: { id: triggerOrg.id } }); + + const triggerEmails = [TRIGGER_ADMIN_EMAIL, ...TRIGGER_MEMBER_EMAILS]; + for (const email of triggerEmails) { + try { + const user = await prisma.user.findUnique({ where: { email } }); + if (user) { + await prisma.password.deleteMany({ where: { userId: user.id } }); + await prisma.membership.deleteMany({ where: { userId: user.id } }); + await prisma.profile.deleteMany({ where: { userId: user.id } }); + await prisma.user.delete({ where: { id: user.id } }); + } + } catch { + // Ignore errors + } + } + + console.log(" Trigger org cleanup complete"); + } + + // Clean up proration-test-org const org = await prisma.team.findFirst({ where: { slug: "proration-test-org" }, }); @@ -304,13 +436,18 @@ async function cleanupDatabaseResources() { await prisma.team.delete({ where: { id: org.id } }); // Delete test users - const testEmails = ["proration-admin@example.com", "proration-member@example.com"]; + const testEmails = [ + "proration-admin@example.com", + "proration-member@example.com", + ...Array.from({ length: 6 }, (_, i) => `proration-user-${i + 1}@example.com`), + ]; for (const email of testEmails) { try { const user = await prisma.user.findUnique({ where: { email } }); if (user) { await prisma.password.deleteMany({ where: { userId: user.id } }); await prisma.membership.deleteMany({ where: { userId: user.id } }); + await prisma.profile.deleteMany({ where: { userId: user.id } }); await prisma.user.delete({ where: { id: user.id } }); } } catch { @@ -321,6 +458,206 @@ async function cleanupDatabaseResources() { console.log(" Database cleanup complete"); } +async function seedTriggerReadyOrg(stripe: Stripe | null): Promise { + const monthKey = computeTriggerMonthKey(); + console.log(`\nCreating trigger-ready organization (monthKey: ${monthKey})...`); + + // Create organization + let org = await prisma.team.findFirst({ + where: { slug: TRIGGER_ORG_SLUG, isOrganization: true }, + }); + + if (!org) { + org = await prisma.team.create({ + data: { + name: "Trigger Test Org", + slug: TRIGGER_ORG_SLUG, + isOrganization: true, + }, + }); + } + console.log(` Organization created: ${org.name} (ID: ${org.id})`); + + // Create OrganizationSettings + await prisma.organizationSettings.upsert({ + where: { organizationId: org.id }, + update: {}, + create: { + organizationId: org.id, + orgAutoAcceptEmail: "example.com", + isOrganizationConfigured: true, + isOrganizationVerified: true, + }, + }); + + // Create child team + let team = await prisma.team.findFirst({ + where: { slug: TRIGGER_TEAM_SLUG, parentId: org.id }, + }); + + if (!team) { + team = await prisma.team.create({ + data: { + name: "Trigger Test Team", + slug: TRIGGER_TEAM_SLUG, + parentId: org.id, + }, + }); + } + console.log(` Team created: ${team.name} (ID: ${team.id})`); + + // Create users: 1 admin + 4 members = 5 total org members + const adminUser = await createTestUser(TRIGGER_ADMIN_EMAIL, "Trigger Admin", "trigger-admin", org.id); + const members = []; + for (let i = 0; i < TRIGGER_MEMBER_EMAILS.length; i++) { + const user = await createTestUser( + TRIGGER_MEMBER_EMAILS[i], + `Trigger User ${i + 1}`, + `trigger-user-${i + 1}`, + org.id + ); + members.push(user); + } + + // Add all 5 users to org + await prisma.membership.upsert({ + where: { userId_teamId: { userId: adminUser.id, teamId: org.id } }, + update: { role: MembershipRole.OWNER }, + create: { userId: adminUser.id, teamId: org.id, role: MembershipRole.OWNER, accepted: true }, + }); + + for (const user of members) { + await prisma.membership.upsert({ + where: { userId_teamId: { userId: user.id, teamId: org.id } }, + update: { role: MembershipRole.MEMBER }, + create: { userId: user.id, teamId: org.id, role: MembershipRole.MEMBER, accepted: true }, + }); + } + + // Add admin + 2 members to the sub-team (3 team memberships) + await prisma.membership.upsert({ + where: { userId_teamId: { userId: adminUser.id, teamId: team.id } }, + update: { role: MembershipRole.OWNER }, + create: { userId: adminUser.id, teamId: team.id, role: MembershipRole.OWNER, accepted: true }, + }); + for (let i = 0; i < 2; i++) { + await prisma.membership.upsert({ + where: { userId_teamId: { userId: members[i].id, teamId: team.id } }, + update: { role: MembershipRole.MEMBER }, + create: { userId: members[i].id, teamId: team.id, role: MembershipRole.MEMBER, accepted: true }, + }); + } + + console.log(" Users and memberships created (5 org members, 3 team members)"); + + // Stripe resources or fake IDs + let stripeResources: StripeResources = { customer: null, subscription: null, product: null, price: null }; + let stripeCustomerId = `cus_fake_trigger_${Date.now()}`; + let stripeSubscriptionId = `sub_fake_trigger_${Date.now()}`; + let stripeSubscriptionItemId = `si_fake_trigger_${Date.now()}`; + + if (stripe) { + stripeResources = await createStripeResourcesForTriggerOrg(stripe, org.id); + stripeCustomerId = stripeResources.customer!.id; + stripeSubscriptionId = stripeResources.subscription!.id; + stripeSubscriptionItemId = stripeResources.subscription!.items.data[0].id; + } + + // Annual subscription dates: started ~6 months ago, ends ~6 months from now + const now = new Date(); + const subscriptionStart = new Date(now); + subscriptionStart.setUTCMonth(subscriptionStart.getUTCMonth() - 6); + const subscriptionEnd = new Date(now); + subscriptionEnd.setUTCMonth(subscriptionEnd.getUTCMonth() + 6); + + // Create OrganizationBilling with ANNUALLY period and no trial + await prisma.organizationBilling.upsert({ + where: { teamId: org.id }, + update: { + customerId: stripeCustomerId, + subscriptionId: stripeSubscriptionId, + subscriptionItemId: stripeSubscriptionItemId, + billingPeriod: BillingPeriod.ANNUALLY, + pricePerSeat: 15000, + paidSeats: 3, + subscriptionStart, + subscriptionEnd, + subscriptionTrialEnd: null, + }, + create: { + teamId: org.id, + customerId: stripeCustomerId, + subscriptionId: stripeSubscriptionId, + subscriptionItemId: stripeSubscriptionItemId, + status: "ACTIVE", + planName: "ORGANIZATION", + billingPeriod: BillingPeriod.ANNUALLY, + pricePerSeat: 15000, + paidSeats: 3, + subscriptionStart, + subscriptionEnd, + subscriptionTrialEnd: null, + }, + }); + console.log(" OrganizationBilling created (ANNUALLY, $150/seat, 3 paid seats, no trial)"); + + // Set team metadata so org upgrade banner check is satisfied + await prisma.team.update({ + where: { id: org.id }, + data: { + metadata: { + subscriptionId: stripeSubscriptionId, + subscriptionItemId: stripeSubscriptionItemId, + }, + }, + }); + + // Get billing record for linking seat changes + const orgBilling = await prisma.organizationBilling.findUnique({ + where: { teamId: org.id }, + }); + + // Clear any existing seat changes / prorations (idempotent re-runs) + await prisma.seatChangeLog.deleteMany({ where: { teamId: org.id } }); + await prisma.monthlyProration.deleteMany({ where: { teamId: org.id } }); + + // Create 2 unprocessed ADDITION seat change logs with the trigger's monthKey + const changeDate = new Date(); + changeDate.setUTCDate(15); // Mid-month change + // Set to the month matching monthKey + const [mkYear, mkMonth] = monthKey.split("-").map(Number); + changeDate.setUTCFullYear(mkYear); + changeDate.setUTCMonth(mkMonth - 1); + + for (let i = 0; i < 2; i++) { + await prisma.seatChangeLog.create({ + data: { + teamId: org.id, + changeType: "ADDITION", + seatCount: 1, + userId: members[2 + i].id, // Members 3 and 4 (the ones beyond paidSeats of 3) + triggeredBy: adminUser.id, + changeDate, + monthKey, + processedInProrationId: null, // Unprocessed - key for trigger pickup + organizationBillingId: orgBilling?.id, + metadata: { source: "seed-script", note: "Unprocessed seat addition for trigger test" }, + }, + }); + } + + console.log(` Created 2 unprocessed SeatChangeLog entries (monthKey: ${monthKey})`); + console.log(" NO MonthlyProration records created (trigger-ready state)"); + + return { + organization: { id: org.id, name: org.name, slug: org.slug! }, + team: { id: team.id, name: team.name, slug: team.slug! }, + monthKey, + unprocessedSeatChanges: 2, + stripe: stripeResources, + }; +} + async function seedProrationTest(): Promise { const stripe = getStripeClient(); @@ -527,6 +864,21 @@ async function seedProrationTest(): Promise { console.log("Organization billing created"); + // Set subscriptionId in team metadata so the org upgrade banner check is satisfied. + // checkIfOrgNeedsUpgrade.handler.ts looks at team.metadata.subscriptionId to determine + // if the org has been fully set up (not in trial/needs-upgrade state). + await prisma.team.update({ + where: { id: org.id }, + data: { + metadata: { + subscriptionId: stripeSubscriptionId, + subscriptionItemId: stripeSubscriptionItemId, + }, + }, + }); + + console.log("Organization team metadata updated with subscriptionId"); + // Get the organization billing record for linking seat changes const orgBilling = await prisma.organizationBilling.findUnique({ where: { teamId: org.id }, @@ -657,6 +1009,9 @@ async function seedProrationTest(): Promise { console.log("Proration and seat change records created"); + // Seed the trigger-ready org (no MonthlyProration, unprocessed seat changes) + const triggerOrgResult = await seedTriggerReadyOrg(stripe); + return { organization: { id: org.id, name: org.name, slug: org.slug! }, team: { id: team.id, name: team.name, slug: team.slug! }, @@ -669,6 +1024,7 @@ async function seedProrationTest(): Promise { { id: warningProration.id, status: "INVOICE_CREATED", isBlocking: false }, ], stripe: stripeResources, + triggerOrg: triggerOrgResult, }; } @@ -720,7 +1076,7 @@ async function main() { console.log(""); if (result.stripe.customer) { - console.log("=== Stripe Dashboard ===\n"); + console.log("=== Stripe Dashboard (Proration Org) ===\n"); console.log(`Customer: https://dashboard.stripe.com/test/customers/${result.stripe.customer.id}`); console.log( `Subscription: https://dashboard.stripe.com/test/subscriptions/${result.stripe.subscription?.id}` @@ -728,6 +1084,41 @@ async function main() { console.log(""); } + // Trigger-ready org output + if (result.triggerOrg) { + const t = result.triggerOrg; + console.log("=== Trigger-Ready Organization ===\n"); + console.log("Organization:", t.organization); + console.log("Team:", t.team); + console.log(`MonthKey: ${t.monthKey}`); + console.log(`Unprocessed SeatChangeLogs: ${t.unprocessedSeatChanges}`); + console.log("MonthlyProration records: 0 (intentionally absent)"); + + if (t.stripe.customer) { + console.log(`\nStripe (Trigger Org):`); + console.log(` Customer: https://dashboard.stripe.com/test/customers/${t.stripe.customer.id}`); + console.log( + ` Subscription: https://dashboard.stripe.com/test/subscriptions/${t.stripe.subscription?.id}` + ); + } + + console.log("\n=== Trigger Testing Instructions ===\n"); + console.log("When scheduleMonthlyProration runs, it will:"); + console.log(` 1. Query for annual orgs with unprocessed seat changes for monthKey "${t.monthKey}"`); + console.log(` 2. Find trigger-test-org with ${t.unprocessedSeatChanges} additions, 0 removals`); + console.log(" 3. Calculate prorated amount based on remaining annual subscription days"); + console.log(" 4. Create a MonthlyProration record"); + console.log(" 5. Mark SeatChangeLog entries as processed"); + console.log(" 6. Create a Stripe invoice (if amount > 0)"); + console.log(""); + console.log("To verify after trigger runs:"); + console.log(` SELECT * FROM "MonthlyProration" WHERE "teamId" = ${t.organization.id};`); + console.log( + ` SELECT * FROM "SeatChangeLog" WHERE "teamId" = ${t.organization.id} AND "processedInProrationId" IS NOT NULL;` + ); + console.log(""); + } + console.log("=== Cleanup ===\n"); console.log("To clean up all test data, run:"); console.log( diff --git a/packages/trpc/server/routers/viewer/me/getDueInvoiceBannerData.handler.ts b/packages/trpc/server/routers/viewer/me/getDueInvoiceBannerData.handler.ts new file mode 100644 index 0000000000..c41c9e71f7 --- /dev/null +++ b/packages/trpc/server/routers/viewer/me/getDueInvoiceBannerData.handler.ts @@ -0,0 +1,13 @@ +import { DueInvoiceService } from "@calcom/features/ee/billing/service/dueInvoice/DueInvoiceService"; +import type { TrpcSessionUser } from "@calcom/trpc/server/types"; + +type Props = { + ctx: { + user: NonNullable; + }; +}; + +export const getDueInvoiceBannerDataHandler = async ({ ctx }: Props) => { + const dueInvoiceService = new DueInvoiceService(); + return await dueInvoiceService.getBannerDataForUser(ctx.user.id); +}; diff --git a/packages/trpc/server/routers/viewer/me/getUserTopBanners.handler.ts b/packages/trpc/server/routers/viewer/me/getUserTopBanners.handler.ts index 93a0070e92..0b65bbb9eb 100644 --- a/packages/trpc/server/routers/viewer/me/getUserTopBanners.handler.ts +++ b/packages/trpc/server/routers/viewer/me/getUserTopBanners.handler.ts @@ -10,6 +10,7 @@ import type { TrpcSessionUser } from "@calcom/trpc/server/types"; import { checkIfOrgNeedsUpgradeHandler } from "../organizations/checkIfOrgNeedsUpgrade.handler"; import { getUpgradeableHandler } from "../teams/getUpgradeable.handler"; import { checkInvalidAppCredentials } from "./checkForInvalidAppCredentials"; +import { getDueInvoiceBannerDataHandler } from "./getDueInvoiceBannerData.handler"; import { shouldVerifyEmailHandler } from "./shouldVerifyEmail.handler"; type Props = { @@ -45,6 +46,7 @@ export const getUserTopBannersHandler = async ({ ctx }: Props) => { const shouldEmailVerify = shouldVerifyEmailHandler({ ctx }); // const isInvalidCalendarCredential = checkInvalidGoogleCalendarCredentials({ ctx }); const appsWithInavlidCredentials = checkInvalidAppCredentials({ ctx }); + const dueInvoiceBannerData = getDueInvoiceBannerDataHandler({ ctx }); const [ teamUpgradeBanner, @@ -52,12 +54,14 @@ export const getUserTopBannersHandler = async ({ ctx }: Props) => { verifyEmailBanner, // calendarCredentialBanner, invalidAppCredentialBanners, + dueInvoiceBanner, ] = await Promise.allSettled([ upgradeableTeamMememberships, upgradeableOrgMememberships, shouldEmailVerify, // isInvalidCalendarCredential, appsWithInavlidCredentials, + dueInvoiceBannerData, ]); return { @@ -67,5 +71,6 @@ export const getUserTopBannersHandler = async ({ ctx }: Props) => { calendarCredentialBanner: false, invalidAppCredentialBanners: invalidAppCredentialBanners.status === "fulfilled" ? invalidAppCredentialBanners.value : [], + dueInvoiceBanner: dueInvoiceBanner.status === "fulfilled" ? dueInvoiceBanner.value : [], }; }; diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.test.ts b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.test.ts index 8c8654617b..eabaf3ff1e 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.test.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.test.ts @@ -37,7 +37,11 @@ vi.mock("@trpc/server", () => { vi.mock("@calcom/prisma", () => { return { - prisma: vi.fn(), + prisma: { + monthlyProration: { + findFirst: vi.fn().mockResolvedValue(null), + }, + }, }; }); diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts index 52d4205d62..67509ef632 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts @@ -1,6 +1,7 @@ import { type TFunction } from "i18next"; import { getTeamBillingServiceFactory } from "@calcom/ee/billing/di/containers/Billing"; +import { DueInvoiceService } from "@calcom/features/ee/billing/service/dueInvoice/DueInvoiceService"; import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; import { UserRepository } from "@calcom/features/users/repositories/UserRepository"; import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError"; @@ -264,6 +265,26 @@ const inviteMembers = async ({ ctx, input }: InviteMemberOptions) => { }); } + // Check if invitations are blocked due to unpaid invoices + const dueInvoiceService = new DueInvoiceService(); + const inviteeEmails = (typeof usernameOrEmail === "string" ? [usernameOrEmail] : usernameOrEmail).map((u) => + typeof u === "string" ? u : u.email + ); + const canInvite = await dueInvoiceService.canInviteToTeam({ + teamId: team.id, + inviteeEmails, + isSubTeam: !!team.parentId, + parentOrgId: team.parentId, + }); + + if (!canInvite.allowed) { + const translation = await getTranslation(input.language ?? "en", "common"); + throw new TRPCError({ + code: "FORBIDDEN", + message: translation(canInvite.reason ?? "invitations_blocked_unpaid_invoice"), + }); + } + const requestedSlugForTeam = team?.metadata?.requestedSlug ?? null; const isTeamAnOrg = team.isOrganization; const organization = inviter.profile.organization;