From 4f0a1d0f9ba7f6099f2a2a2c7231d76911d036d9 Mon Sep 17 00:00:00 2001 From: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Date: Mon, 2 Feb 2026 13:37:25 +0000 Subject: [PATCH] feat: show invoices on team/org (#27120) Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../modules/settings/billing/billing-view.tsx | 3 +- .../billing/components/InvoicesTable.tsx | 236 ++++++++++++++++++ .../components/InvoicesTableSkeleton.tsx | 38 +++ apps/web/public/static/locales/en/common.json | 16 ++ .../IBillingProviderService.ts | 38 ++- .../billingProvider/StripeBillingService.ts | 64 ++++- .../server/routers/viewer/teams/_router.tsx | 6 +- .../viewer/teams/listInvoices.handler.ts | 136 ++++++++++ .../viewer/teams/listInvoices.schema.ts | 11 + 9 files changed, 544 insertions(+), 4 deletions(-) create mode 100644 apps/web/modules/settings/billing/components/InvoicesTable.tsx create mode 100644 apps/web/modules/settings/billing/components/InvoicesTableSkeleton.tsx create mode 100644 packages/trpc/server/routers/viewer/teams/listInvoices.handler.ts create mode 100644 packages/trpc/server/routers/viewer/teams/listInvoices.schema.ts diff --git a/apps/web/modules/settings/billing/billing-view.tsx b/apps/web/modules/settings/billing/billing-view.tsx index ffa15e2732..e3454bb37e 100644 --- a/apps/web/modules/settings/billing/billing-view.tsx +++ b/apps/web/modules/settings/billing/billing-view.tsx @@ -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 = () => { + diff --git a/apps/web/modules/settings/billing/components/InvoicesTable.tsx b/apps/web/modules/settings/billing/components/InvoicesTable.tsx new file mode 100644 index 0000000000..f6500a49d3 --- /dev/null +++ b/apps/web/modules/settings/billing/components/InvoicesTable.tsx @@ -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 }> = { + 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 ( + + + + ); +} + +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 ; + + const handleNextPage = () => { + if (data?.nextCursor) { + setCursors((prev) => [...prev, data.nextCursor]); + setCurrentPage((prev) => prev + 1); + } + }; + + const handlePrevPage = () => { + if (currentPage > 0) { + setCurrentPage((prev) => prev - 1); + } + }; + + return ( + } className="mt-5"> + {!data?.invoices.length && currentPage === 0 ? ( + {t("no_invoices_in_date_range")} + ) : ( + <> + + + + + {t("date")} + {t("invoice_number")} + {t("amount")} + {t("status")} + {t("description")} + {t("actions")} + + + + {data?.invoices.map((invoice) => ( + + {dayjs.unix(invoice.created).format("MMM D, YYYY")} + + {invoice.number && invoice.hostedInvoiceUrl ? ( + + {invoice.number} + + + ) : ( + invoice.number || "-" + )} + + + {formatCurrency(invoice.amountPaid || invoice.amountDue, invoice.currency)} + + + + {invoice.status ? t(`invoice_status_${invoice.status}`) : "-"} + + + + {invoice.description || invoice.lineItems[0]?.description || "-"} + + + {invoice.invoicePdf && ( + + )} + + + ))} + + + + + {/* Pagination */} + {(currentPage > 0 || data?.hasMore) && ( + + + {t("previous")} + + + {t("next")} + + + )} + > + )} + + ); +} diff --git a/apps/web/modules/settings/billing/components/InvoicesTableSkeleton.tsx b/apps/web/modules/settings/billing/components/InvoicesTableSkeleton.tsx new file mode 100644 index 0000000000..ef177043e3 --- /dev/null +++ b/apps/web/modules/settings/billing/components/InvoicesTableSkeleton.tsx @@ -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 ( + + + {/* Header row */} + + + + + + + + + + {/* Data rows */} + {Array.from({ length: 5 }).map((_, i) => ( + + + + + + + + + + ))} + + + ); +} diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index bd7307b197..7cac4dc55f 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/packages/features/ee/billing/service/billingProvider/IBillingProviderService.ts b/packages/features/ee/billing/service/billingProvider/IBillingProviderService.ts index ed665771d3..7e84522e32 100644 --- a/packages/features/ee/billing/service/billingProvider/IBillingProviderService.ts +++ b/packages/features/ee/billing/service/billingProvider/IBillingProviderService.ts @@ -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; + }>; } diff --git a/packages/features/ee/billing/service/billingProvider/StripeBillingService.ts b/packages/features/ee/billing/service/billingProvider/StripeBillingService.ts index 02f26dd9af..c383e842c2 100644 --- a/packages/features/ee/billing/service/billingProvider/StripeBillingService.ts +++ b/packages/features/ee/billing/service/billingProvider/StripeBillingService.ts @@ -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, + }; + } } diff --git a/packages/trpc/server/routers/viewer/teams/_router.tsx b/packages/trpc/server/routers/viewer/teams/_router.tsx index 023db2582f..193ae3beed 100644 --- a/packages/trpc/server/routers/viewer/teams/_router.tsx +++ b/packages/trpc/server/routers/viewer/teams/_router.tsx @@ -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); + }), }); diff --git a/packages/trpc/server/routers/viewer/teams/listInvoices.handler.ts b/packages/trpc/server/routers/viewer/teams/listInvoices.handler.ts new file mode 100644 index 0000000000..1ce5e9f5c2 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/listInvoices.handler.ts @@ -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; + }; + 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; diff --git a/packages/trpc/server/routers/viewer/teams/listInvoices.schema.ts b/packages/trpc/server/routers/viewer/teams/listInvoices.schema.ts new file mode 100644 index 0000000000..9b802c9f4a --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/listInvoices.schema.ts @@ -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;