feat: add invoice banner + block additions to teams/orgs (#27327)

* feat: add invoice URL to proration and test seed scripts

- Add invoiceUrl field to MonthlyProration schema
- Update billing service to return hosted_invoice_url after finalizing
- Save invoice URL when creating proration invoices
- Add seed script for testing proration with real Stripe data
- Add cleanup script for test data removal

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: add due invoice banner and invitation blocking for orgs

- Add DueInvoiceBanner component showing overdue prorations
- Add DueInvoiceService for checking blocking status and banner data
- Block invitations when proration invoices are 7+ days overdue
- Allow sub-team invites for existing org members (exception)
- Show banner to users with billing management permissions
- Link directly to Stripe invoice URL when available

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: set subscriptionId in team metadata in seed script

The seed script was not setting subscriptionId on the team metadata,
causing checkIfOrgNeedsUpgrade to treat the org as needing upgrade
and showing the "trialing" banner.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* push trigger test to script

* fix mocks

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
sean-brydonandClaude Opus 4.5 authored and GitHub committed 2026-01-29 11:23:27 +00:00
1 parent ca5d931165
commit 3c91703415
9 files changed
+742 -26

No files matched your search

@@ -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 (
<TopBanner
variant={hasBlocking ? "error" : "warning"}
text={t(hasBlocking ? "invoice_overdue_blocking" : "invoice_due", {
teamName: displayProration.teamName,
})}
actions={
<a
href={paymentUrl}
target={displayProration.invoiceUrl ? "_blank" : undefined}
rel={displayProration.invoiceUrl ? "noopener noreferrer" : undefined}
className="border-b border-b-black hover:opacity-80">
{t("pay_now")}
</a>
}
/>
);
}
@@ -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) => (
<InvalidAppCredentialBanners {...props} />
),
dueInvoiceBanner: (props: DueInvoiceBannerProps) => <DueInvoiceBanner {...props} />,
};
interface BannerContainerProps {
@@ -82,6 +88,9 @@ export const BannerContainer: React.FC<BannerContainerProps> = ({ banners }) =>
} else if (key === "invalidAppCredentialBanners") {
const Banner = BannerComponent[key];
return <Banner data={banners[key]} key={key} />;
} else if (key === "dueInvoiceBanner") {
const Banner = BannerComponent[key];
return <Banner data={banners[key]} key={key} />;
}
})}
</div>
@@ -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",
@@ -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<boolean> {
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<CanInviteResult> {
// 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<boolean> {
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<BannerData[]> {
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;
}
}
@@ -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<string> {
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<StripeResources> {
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<TriggerOrgResult> {
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<SeedResult> {
const stripe = getStripeClient();
@@ -527,6 +864,21 @@ async function seedProrationTest(): Promise<SeedResult> {
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<SeedResult> {
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<SeedResult> {
{ 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(
@@ -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<TrpcSessionUser>;
};
};
export const getDueInvoiceBannerDataHandler = async ({ ctx }: Props) => {
const dueInvoiceService = new DueInvoiceService();
return await dueInvoiceService.getBannerDataForUser(ctx.user.id);
};
@@ -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 : [],
};
};
@@ -37,7 +37,11 @@ vi.mock("@trpc/server", () => {
vi.mock("@calcom/prisma", () => {
return {
prisma: vi.fn(),
prisma: {
monthlyProration: {
findFirst: vi.fn().mockResolvedValue(null),
},
},
};
});
@@ -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;