feat: show invoices on team/org (#27120)

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
sean-brydonandDevin AI authored and GitHub committed 2026-02-02 13:37:25 +00:00
1 parent 29ec2b9cb6
commit 4f0a1d0f9b
9 files changed
+544 -4

No files matched your search

@@ -10,8 +10,8 @@ import { showToast } from "@calcom/ui/components/toast";
import { usePathname } from "next/navigation";
import { useSession } from "next-auth/react";
import { useState } from "react";
import BillingCredits from "~/settings/billing/components/BillingCredits";
import { InvoicesTable } from "~/settings/billing/components/InvoicesTable";
interface CtaRowProps {
title: string;
@@ -146,6 +146,7 @@ const BillingView = () => {
</div>
</div>
<BillingCredits />
<InvoicesTable />
<Dialog open={showSkipTrialDialog} onOpenChange={setShowSkipTrialDialog}>
<DialogContent>
@@ -0,0 +1,236 @@
"use client";
import { keepPreviousData } from "@tanstack/react-query";
import { usePathname } from "next/navigation";
import { useSession } from "next-auth/react";
import { useState, useEffect, useRef } from "react";
import dayjs from "@calcom/dayjs";
import {
DataTableProvider,
ColumnFilterType,
useFilterValue,
ZDateRangeFilterValue,
type FilterableColumn,
} from "@calcom/features/data-table";
import { useSegments } from "@calcom/features/data-table/hooks/useSegments";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import type { FilterType } from "@calcom/types/data-table";
import { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import { PanelCard } from "@calcom/ui/components/card";
import { Icon } from "@calcom/ui/components/icon";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@calcom/ui/components/table/TableNew";
import { DateRangeFilter } from "~/data-table/components";
import { InvoicesTableSkeleton } from "./InvoicesTableSkeleton";
const DEFAULT_PAGE_SIZE = 10;
const createdAtColumn: Extract<FilterableColumn, { type: Extract<FilterType, "dr"> }> = {
id: "createdAt",
title: "Date",
type: ColumnFilterType.DATE_RANGE,
};
function formatCurrency(amount: number, currency: string) {
return new Intl.NumberFormat(undefined, {
style: "currency",
currency: currency.toUpperCase(),
}).format(amount / 100);
}
function getStatusBadgeColor(status: string | null): "green" | "orange" | "gray" | "red" {
switch (status) {
case "paid":
return "green";
case "open":
return "orange";
case "draft":
return "gray";
case "void":
case "uncollectible":
return "red";
default:
return "gray";
}
}
export function InvoicesTable() {
const pathname = usePathname();
if (!pathname) return null;
return (
<DataTableProvider tableIdentifier={`${pathname}-invoices`} useSegments={useSegments}>
<InvoicesTableContent />
</DataTableProvider>
);
}
function InvoicesTableContent() {
const { t } = useLocale();
const pathname = usePathname();
const session = useSession();
const [cursors, setCursors] = useState<(string | null)[]>([null]);
const [currentPage, setCurrentPage] = useState(0);
const dateRangeFilter = useFilterValue("createdAt", ZDateRangeFilterValue)?.data;
// Derive teamId from context (same logic as billing-view.tsx)
const getTeamIdFromContext = () => {
if (!pathname) return null;
if (pathname.includes("/teams/") && pathname.includes("/billing")) {
const teamIdMatch = pathname.match(/\/teams\/(\d+)\/billing/);
return teamIdMatch ? parseInt(teamIdMatch[1], 10) : null;
}
if (pathname.includes("/organizations/billing")) {
return session.data?.user?.org?.id ?? null;
}
return null;
};
const teamId = getTeamIdFromContext();
const startDate = dateRangeFilter?.startDate ? new Date(dateRangeFilter.startDate) : undefined;
const endDate = dateRangeFilter?.endDate ? new Date(dateRangeFilter.endDate) : undefined;
const { data, isLoading, isFetching } = trpc.viewer.teams.listInvoices.useQuery(
{
teamId: teamId!,
limit: DEFAULT_PAGE_SIZE,
cursor: cursors[currentPage],
startDate,
endDate,
},
{ enabled: !!teamId, placeholderData: keepPreviousData }
);
// Reset pagination when date filter changes
const isFirstRender = useRef(true);
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
setCursors([null]);
setCurrentPage(0);
}, [dateRangeFilter?.startDate, dateRangeFilter?.endDate]);
if (!teamId) return null;
if (isLoading) return <InvoicesTableSkeleton />;
const handleNextPage = () => {
if (data?.nextCursor) {
setCursors((prev) => [...prev, data.nextCursor]);
setCurrentPage((prev) => prev + 1);
}
};
const handlePrevPage = () => {
if (currentPage > 0) {
setCurrentPage((prev) => prev - 1);
}
};
return (
<PanelCard title={t("invoices")} headerContent={<DateRangeFilter column={createdAtColumn} />} className="mt-5">
{!data?.invoices.length && currentPage === 0 ? (
<div className="text-subtle p-6 text-center text-sm">{t("no_invoices_in_date_range")}</div>
) : (
<>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("date")}</TableHead>
<TableHead>{t("invoice_number")}</TableHead>
<TableHead>{t("amount")}</TableHead>
<TableHead>{t("status")}</TableHead>
<TableHead>{t("description")}</TableHead>
<TableHead className="text-right">{t("actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.invoices.map((invoice) => (
<TableRow key={invoice.id}>
<TableCell>{dayjs.unix(invoice.created).format("MMM D, YYYY")}</TableCell>
<TableCell className="font-mono text-sm">
{invoice.number && invoice.hostedInvoiceUrl ? (
<a
href={invoice.hostedInvoiceUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-800 hover:underline">
{invoice.number}
<Icon name="external-link" className="h-3 w-3" />
</a>
) : (
invoice.number || "-"
)}
</TableCell>
<TableCell>
{formatCurrency(invoice.amountPaid || invoice.amountDue, invoice.currency)}
</TableCell>
<TableCell>
<Badge color={getStatusBadgeColor(invoice.status)}>
{invoice.status ? t(`invoice_status_${invoice.status}`) : "-"}
</Badge>
</TableCell>
<TableCell className="max-w-[200px] truncate">
{invoice.description || invoice.lineItems[0]?.description || "-"}
</TableCell>
<TableCell className="text-right">
{invoice.invoicePdf && (
<Button
variant="icon"
color="minimal"
href={invoice.invoicePdf}
target="_blank"
rel="noopener noreferrer"
StartIcon="download"
tooltip={t("download_pdf")}
/>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Pagination */}
{(currentPage > 0 || data?.hasMore) && (
<div className="border-muted flex items-center justify-end gap-2 border-t px-4 py-3">
<Button
color="secondary"
size="sm"
onClick={handlePrevPage}
disabled={currentPage === 0 || isFetching}
StartIcon="arrow-left">
{t("previous")}
</Button>
<Button
color="secondary"
size="sm"
onClick={handleNextPage}
disabled={!data?.hasMore || isFetching}
EndIcon="arrow-right">
{t("next")}
</Button>
</div>
)}
</>
)}
</PanelCard>
);
}
@@ -0,0 +1,38 @@
"use client";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { PanelCard } from "@calcom/ui/components/card";
import { SkeletonText } from "@calcom/ui/components/skeleton";
export function InvoicesTableSkeleton() {
const { t } = useLocale();
return (
<PanelCard title={t("invoices")} className="mt-5">
<div className="p-4">
{/* Header row */}
<div className="mb-3 flex gap-4 border-b pb-3">
<SkeletonText className="h-4 w-20" />
<SkeletonText className="h-4 w-24" />
<SkeletonText className="h-4 w-16" />
<SkeletonText className="h-4 w-16" />
<SkeletonText className="h-4 flex-1" />
<SkeletonText className="h-4 w-24" />
<SkeletonText className="h-4 w-16" />
</div>
{/* Data rows */}
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex gap-4 py-3">
<SkeletonText className="h-4 w-20" />
<SkeletonText className="h-4 w-24" />
<SkeletonText className="h-4 w-16" />
<SkeletonText className="h-4 w-16" />
<SkeletonText className="h-4 flex-1" />
<SkeletonText className="h-4 w-24" />
<SkeletonText className="h-4 w-8" />
</div>
))}
</div>
</PanelCard>
);
}
@@ -4536,6 +4536,22 @@
"error_enabling_feature": "Error enabling feature. Please try again.",
"set_organizer_as_contact_owner": "Set booking organizer as contact owner",
"overwrite_existing_contact_owner": "Overwrite existing contact owner",
"invoice_number": "Invoice #",
"payment_method": "Payment Method",
"download_pdf": "Download PDF",
"no_invoices_yet": "No invoices yet",
"invoice_status_paid": "Paid",
"invoice_status_open": "Open",
"invoice_status_draft": "Draft",
"invoice_status_void": "Void",
"invoice_status_uncollectible": "Uncollectible",
"no_invoices_in_date_range": "No invoices found in the selected date range",
"view_and_download_invoices": "View and download your invoices",
"last_7_days": "Last 7 days",
"last_30_days": "Last 30 days",
"last_3_months": "Last 3 months",
"last_6_months": "Last 6 months",
"last_12_months": "Last 12 months",
"calendar_events_disabled_video_limitation": "When calendar events are disabled, meeting links cannot be generated for apps whose link generation is tied to calendar event creation, for example, Google Meet and Microsoft Teams. Other video apps like Cal Video will continue to work.",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -1,5 +1,4 @@
import type Stripe from "stripe";
import type { SubscriptionStatus } from "../../repository/billing/IBillingRepository";
export interface IBillingProviderService {
@@ -123,4 +122,41 @@ export interface IBillingProviderService {
current_period_end: number;
trial_end: number | null;
} | null>;
// Invoice listing
listInvoices(args: {
customerId: string;
subscriptionId?: string;
limit: number;
startingAfter?: string;
createdGte?: number;
createdLte?: number;
}): Promise<{
invoices: Array<{
id: string;
number: string | null;
created: number;
amountDue: number;
amountPaid: number;
currency: string;
status: string | null;
hostedInvoiceUrl: string | null;
invoicePdf: string | null;
lineItems: Array<{
id: string;
description: string | null;
amount: number;
quantity: number | null;
}>;
description: string | null;
paymentMethod: {
type: string;
card?: {
last4: string;
brand: string;
};
} | null;
}>;
hasMore: boolean;
}>;
}
@@ -1,6 +1,5 @@
import logger from "@calcom/lib/logger";
import type Stripe from "stripe";
import { SubscriptionStatus } from "../../repository/billing/IBillingRepository";
import type { IBillingProviderService } from "./IBillingProviderService";
@@ -404,4 +403,67 @@ export class StripeBillingService implements IBillingProviderService {
trial_end: subscription.trial_end,
};
}
async listInvoices(args: {
customerId: string;
subscriptionId?: string;
limit: number;
startingAfter?: string;
createdGte?: number;
createdLte?: number;
}) {
const { customerId, subscriptionId, limit, startingAfter, createdGte, createdLte } = args;
const invoicesResponse = await this.stripe.invoices.list({
customer: customerId,
subscription: subscriptionId,
limit,
starting_after: startingAfter,
expand: ["data.default_payment_method"],
...(createdGte || createdLte
? {
created: {
...(createdGte ? { gte: createdGte } : {}),
...(createdLte ? { lte: createdLte } : {}),
},
}
: {}),
});
return {
invoices: invoicesResponse.data.map((invoice) => ({
id: invoice.id,
number: invoice.number,
created: invoice.created,
amountDue: invoice.amount_due,
amountPaid: invoice.amount_paid,
currency: invoice.currency,
status: invoice.status,
hostedInvoiceUrl: invoice.hosted_invoice_url ?? null,
invoicePdf: invoice.invoice_pdf ?? null,
lineItems: invoice.lines.data.map((line) => ({
id: line.id,
description: line.description,
amount: line.amount,
quantity: line.quantity,
})),
description: invoice.description,
paymentMethod: this.extractPaymentMethod(
invoice.default_payment_method as Stripe.PaymentMethod | null
),
})),
hasMore: invoicesResponse.has_more,
};
}
private extractPaymentMethod(pm: Stripe.PaymentMethod | string | null): {
type: string;
card?: { last4: string; brand: string };
} | null {
if (!pm || typeof pm === "string") return null;
return {
type: pm.type,
card: pm.card ? { last4: pm.card.last4, brand: pm.card.brand } : undefined,
};
}
}
@@ -1,5 +1,4 @@
import type { NextApiRequest } from "next";
import authedProcedure from "../../../procedures/authedProcedure";
import { router } from "../../../trpc";
import { ZAcceptOrLeaveInputSchema } from "./acceptOrLeave.schema";
@@ -22,6 +21,7 @@ import { ZInviteMemberInputSchema } from "./inviteMember/inviteMember.schema";
import { ZInviteMemberByTokenSchemaInputSchema } from "./inviteMemberByToken.schema";
import { ZLegacyListMembersInputSchema } from "./legacyListMembers.schema";
import { ZGetListSchema } from "./list.schema";
import { ZListInvoicesInputSchema } from "./listInvoices.schema";
import { ZListMembersInputSchema } from "./listMembers.schema";
import { ZGetManagedEventUsersToReassignInputSchema } from "./managedEvents/getManagedEventUsersToReassign.schema";
import { ZManagedEventManualReassignInputSchema } from "./managedEvents/managedEventManualReassign.schema";
@@ -229,4 +229,8 @@ export const viewerTeamsRouter = router({
const { default: handler } = await import("./getSubscriptionStatus.handler");
return handler(opts);
}),
listInvoices: authedProcedure.input(ZListInvoicesInputSchema).query(async (opts) => {
const { default: handler } = await import("./listInvoices.handler");
return handler(opts);
}),
});
@@ -0,0 +1,136 @@
import { getBillingProviderService } from "@calcom/ee/billing/di/containers/Billing";
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import prisma from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../types";
import type { TListInvoicesInputSchema } from "./listInvoices.schema";
const log = logger.getSubLogger({ prefix: ["listInvoices"] });
type ListInvoicesOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TListInvoicesInputSchema;
};
export interface InvoiceLineItem {
id: string;
description: string | null;
amount: number;
quantity: number | null;
}
export interface InvoicePaymentMethod {
type: string;
card?: {
last4: string;
brand: string;
};
}
export interface Invoice {
id: string;
number: string | null;
created: number;
amountDue: number;
amountPaid: number;
currency: string;
status: string | null;
hostedInvoiceUrl: string | null;
invoicePdf: string | null;
lineItems: InvoiceLineItem[];
description: string | null;
paymentMethod: InvoicePaymentMethod | null;
}
export const listInvoicesHandler = async ({ ctx, input }: ListInvoicesOptions) => {
if (!IS_TEAM_BILLING_ENABLED) {
return { invoices: [] as Invoice[], hasMore: false, nextCursor: null };
}
const { teamId, limit, cursor, startDate, endDate } = input;
// Get team
const teamRepository = new TeamRepository(prisma);
const team = await teamRepository.findByIdIncludePlatformBilling({ id: teamId });
if (!team) {
throw new TRPCError({ code: "NOT_FOUND", message: "Team not found" });
}
// Permission check
const permissionService = new PermissionCheckService();
const hasPermission = await permissionService.checkPermission({
userId: ctx.user.id,
teamId,
permission: team.isOrganization ? "organization.manageBilling" : "team.manageBilling",
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
});
if (!hasPermission) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You don't have permission to view invoices",
});
}
// Get subscription ID from team metadata or platformBilling
let subscriptionId: string | null = null;
if (team.isPlatform) {
subscriptionId = team.platformBilling?.subscriptionId || null;
} else {
const metadata = teamMetadataSchema.safeParse(team.metadata);
subscriptionId = metadata.success ? metadata.data?.subscriptionId || null : null;
}
if (!subscriptionId) {
return { invoices: [] as Invoice[], hasMore: false, nextCursor: null };
}
try {
const billingService = getBillingProviderService();
// Get customer ID from subscription
const subscription = await billingService.getSubscription(subscriptionId);
const customerId = subscription?.customer;
if (!customerId) {
return { invoices: [] as Invoice[], hasMore: false, nextCursor: null };
}
// Fetch invoices using the billing service, filtered by subscription and date range
const invoicesResponse = await billingService.listInvoices({
customerId,
subscriptionId,
limit: limit + 1, // Fetch one extra to check if there are more
startingAfter: cursor || undefined,
createdGte: startDate ? Math.floor(startDate.getTime() / 1000) : undefined,
createdLte: endDate ? Math.floor(endDate.getTime() / 1000) : undefined,
});
const hasMore = invoicesResponse.invoices.length > limit;
const invoices = invoicesResponse.invoices.slice(0, limit);
const nextCursor = hasMore && invoices.length > 0 ? invoices[invoices.length - 1]?.id : null;
return {
invoices: invoices as Invoice[],
hasMore,
nextCursor,
};
} catch (error) {
log.error("Failed to fetch invoices", { teamId, error });
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to fetch invoices",
});
}
};
export default listInvoicesHandler;
@@ -0,0 +1,11 @@
import { z } from "zod";
export const ZListInvoicesInputSchema = z.object({
teamId: z.number(),
limit: z.number().min(1).max(100).default(10),
cursor: z.string().optional().nullable(),
startDate: z.date().optional(),
endDate: z.date().optional(),
});
export type TListInvoicesInputSchema = z.infer<typeof ZListInvoicesInputSchema>;