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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
import prismaMock from "@calcom/testing/lib/__mocks__/prismaMock";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { DueInvoiceService } from "./DueInvoiceService";

const mockFindTeamMembersWithPermission = vi.fn();

vi.mock("../../../teams/repositories/TeamRepository", () => ({
TeamRepository: class MockTeamRepository {
findTeamMembersWithPermission = mockFindTeamMembersWithPermission;
},
}));

function createProration({
id,
teamId,
teamName,
isOrganization = false,
invoiceUrl = null,
daysOld = 0,
}: {
id: string;
teamId: number;
teamName: string;
isOrganization?: boolean;
invoiceUrl?: string | null;
daysOld?: number;
}) {
const createdAt = new Date();
createdAt.setDate(createdAt.getDate() - daysOld);

return {
id,
teamId,
proratedAmount: 1000,
createdAt,
monthKey: "2026-03",
invoiceUrl,
status: "INVOICE_CREATED",
team: {
id: teamId,
name: teamName,
isOrganization,
},
};
}

describe("DueInvoiceService", () => {
let service: DueInvoiceService;

beforeEach(() => {
mockFindTeamMembersWithPermission.mockReset();
service = new DueInvoiceService(prismaMock);
});

afterEach(() => {
vi.restoreAllMocks();
});

describe("getBannerDataForUser", () => {
it("returns empty array when user has no memberships", async () => {
prismaMock.membership.findMany.mockResolvedValue([]);

const result = await service.getBannerDataForUser(1);

expect(result).toEqual([]);
});

it("returns prorations for teams where user has billing permission", async () => {
const userId = 1;
const teamId = 10;

prismaMock.membership.findMany
.mockResolvedValueOnce([
{ teamId, team: { id: teamId, isOrganization: false } },
] as never)
.mockResolvedValueOnce([{ teamId }] as never);

mockFindTeamMembersWithPermission.mockResolvedValue([
{ id: userId, name: "User", email: "u@test.com", locale: null },
]);

const proration = createProration({
id: "pror-1",
teamId,
teamName: "Team A",
invoiceUrl: "https://stripe.com/invoice/123",
});

prismaMock.monthlyProration.findMany.mockResolvedValueOnce([proration] as never);

const result = await service.getBannerDataForUser(userId);

expect(result).toHaveLength(1);
expect(result[0].teamId).toBe(teamId);
expect(result[0].invoiceUrl).toBe("https://stripe.com/invoice/123");
});

it("returns mailto prorations for regular members without billing permission", async () => {
const userId = 2;
const billingTeamId = 10;
const memberTeamId = 20;

prismaMock.membership.findMany
.mockResolvedValueOnce([
{ teamId: billingTeamId, team: { id: billingTeamId, isOrganization: false } },
] as never)
.mockResolvedValueOnce([{ teamId: billingTeamId }, { teamId: memberTeamId }] as never);

mockFindTeamMembersWithPermission.mockResolvedValue([
{ id: userId, name: "User", email: "u@test.com", locale: null },
]);

const billingProration = createProration({
id: "pror-billing",
teamId: billingTeamId,
teamName: "Billing Team",
invoiceUrl: "https://stripe.com/invoice/456",
});

const mailtoProration = createProration({
id: "pror-mailto",
teamId: memberTeamId,
teamName: "Org With Mailto",
isOrganization: true,
invoiceUrl: "mailto:billing@org.com",
});

prismaMock.monthlyProration.findMany
.mockResolvedValueOnce([billingProration] as never)
.mockResolvedValueOnce([mailtoProration] as never);

const result = await service.getBannerDataForUser(userId);

expect(result).toHaveLength(2);
expect(result.map((r) => r.prorationId)).toContain("pror-billing");
expect(result.map((r) => r.prorationId)).toContain("pror-mailto");
});

it("does not return non-mailto prorations for regular members", async () => {
const userId = 3;
const memberTeamId = 30;

prismaMock.membership.findMany
.mockResolvedValueOnce([] as never)
.mockResolvedValueOnce([{ teamId: memberTeamId }] as never);

const mailtoProration = createProration({
id: "pror-mailto",
teamId: memberTeamId,
teamName: "Org",
invoiceUrl: "mailto:admin@org.com",
});

prismaMock.monthlyProration.findMany.mockResolvedValueOnce([mailtoProration] as never);

const result = await service.getBannerDataForUser(userId);

expect(result).toHaveLength(1);
expect(result[0].invoiceUrl).toBe("mailto:admin@org.com");
});

it("does not query non-billing teams when user has billing permission on all teams", async () => {
const userId = 4;
const teamId = 40;

prismaMock.membership.findMany
.mockResolvedValueOnce([
{ teamId, team: { id: teamId, isOrganization: true } },
] as never)
.mockResolvedValueOnce([{ teamId }] as never);

mockFindTeamMembersWithPermission.mockResolvedValue([
{ id: userId, name: "User", email: "u@test.com", locale: null },
]);

const proration = createProration({
id: "pror-dedup",
teamId,
teamName: "Org",
isOrganization: true,
invoiceUrl: "mailto:billing@org.com",
});

prismaMock.monthlyProration.findMany.mockResolvedValueOnce([proration] as never);

const result = await service.getBannerDataForUser(userId);

expect(result).toHaveLength(1);
expect(result[0].prorationId).toBe("pror-dedup");
expect(prismaMock.monthlyProration.findMany).toHaveBeenCalledTimes(1);
});

it("correctly sets isBlocking for prorations older than 7 days", async () => {
const userId = 5;
const teamId = 50;

prismaMock.membership.findMany
.mockResolvedValueOnce([] as never)
.mockResolvedValueOnce([{ teamId }] as never);

const recentProration = createProration({
id: "pror-recent",
teamId,
teamName: "Org",
invoiceUrl: "mailto:billing@org.com",
daysOld: 3,
});

const oldProration = createProration({
id: "pror-old",
teamId,
teamName: "Org",
invoiceUrl: "mailto:billing@org.com",
daysOld: 10,
});

prismaMock.monthlyProration.findMany.mockResolvedValueOnce(
[recentProration, oldProration] as never
);

const result = await service.getBannerDataForUser(userId);

expect(result).toHaveLength(2);
const recent = result.find((r) => r.prorationId === "pror-recent");
const old = result.find((r) => r.prorationId === "pror-old");
expect(recent?.isBlocking).toBe(false);
expect(old?.isBlocking).toBe(true);
});

it("returns empty when user is a member but no mailto prorations exist", async () => {
const userId = 6;
const memberTeamId = 60;

prismaMock.membership.findMany
.mockResolvedValueOnce([] as never)
.mockResolvedValueOnce([{ teamId: memberTeamId }] as never);

prismaMock.monthlyProration.findMany.mockResolvedValueOnce([] as never);

const result = await service.getBannerDataForUser(userId);

expect(result).toEqual([]);
});
});
});
113 changes: 87 additions & 26 deletions packages/features/ee/billing/service/dueInvoice/DueInvoiceService.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
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 {
Expand Down Expand Up @@ -119,43 +118,88 @@ export class DueInvoiceService {
}

/**
* Get banner data for a user - returns overdue prorations for teams where user has billing permission
* Get banner data for a user - returns overdue prorations for teams where user has billing permission,
* plus prorations with mailto: invoiceUrl for any team/org the user is a member of.
*/
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: {
// Run both queries in parallel:
// 1. Teams where user has billing permission (existing behavior)
// 2. All teams where user is a member (for mailto: invoiceUrl prorations)
const [teamsWithBillingPermission, allMemberTeamIds] = await Promise.all([
this.findTeamsWithBillingPermission(userId, teamRepository),
this.findAllMemberTeamIds(userId),
]);

const billingTeamIds = teamsWithBillingPermission.map((t) => t.id);

// Find team IDs that user is a member of but does NOT have billing permission for
const nonBillingTeamIds = allMemberTeamIds.filter((id) => !billingTeamIds.includes(id));

// Build queries in parallel:
// 1. All overdue prorations for billing-permitted teams (existing behavior)
// 2. Overdue prorations with mailto: invoiceUrl for non-billing teams
const prorationQueries = [];

if (billingTeamIds.length > 0) {
prorationQueries.push(
this.prisma.monthlyProration.findMany({
where: {
teamId: { in: billingTeamIds },
status: { in: ["FAILED", "INVOICE_CREATED"] },
},
select: {
id: true,
name: true,
isOrganization: true,
teamId: true,
proratedAmount: true,
createdAt: true,
monthKey: true,
invoiceUrl: true,
team: { select: { id: true, name: true, isOrganization: true } },
},
},
},
orderBy: { createdAt: "asc" },
orderBy: { createdAt: "asc" },
})
);
}

if (nonBillingTeamIds.length > 0) {
prorationQueries.push(
this.prisma.monthlyProration.findMany({
where: {
teamId: { in: nonBillingTeamIds },
status: { in: ["FAILED", "INVOICE_CREATED"] },
invoiceUrl: { startsWith: "mailto:" },
},
select: {
id: true,
teamId: true,
proratedAmount: true,
createdAt: true,
monthKey: true,
invoiceUrl: true,
team: { select: { id: true, name: true, isOrganization: true } },
},
orderBy: { createdAt: "asc" },
})
);
}

const results = await Promise.all(prorationQueries);
const allProrations = results.flat();

// Deduplicate by proration ID (in case of overlap)
const seen = new Set<string>();
const uniqueProrations = allProrations.filter((p) => {
if (seen.has(p.id)) return false;
seen.add(p.id);
return true;
});

return overdueProrations.map((proration) => ({
return uniqueProrations.map((proration) => ({
teamId: proration.teamId,
teamName: proration.team.name,
isOrganization: proration.team.isOrganization,
Expand All @@ -167,6 +211,23 @@ export class DueInvoiceService {
}));
}

/**
* Find all team IDs where user is an accepted member
*/
private async findAllMemberTeamIds(userId: number): Promise<number[]> {
const memberships = await this.prisma.membership.findMany({
where: {
userId,
accepted: true,
},
select: {
teamId: true,
},
});

return memberships.map((m) => m.teamId);
}

/**
* Find teams where user has billing management permission
*/
Expand Down
Loading