From 59982a844761bd0ba2e2bdc7de347101560ad745 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:23:55 +0900 Subject: [PATCH 1/4] feat: add invoices for all Cap purchases --- apps/web/__tests__/unit/billing-page.test.ts | 8 +- .../__tests__/unit/invoice-actions.test.ts | 258 ++++++++++++++++++ .../__tests__/unit/invoice-documents.test.ts | 158 +++++++++++ .../__tests__/unit/invoice-history.test.ts | 168 ++++++++++++ .../unit/invoice-verified-email.test.ts | 46 ++++ apps/web/__tests__/unit/sso-billing.test.ts | 21 ++ apps/web/__tests__/unit/sso-card.test.ts | 193 +------------ apps/web/actions/billing/invoices.ts | 233 ++++++++++++++++ .../dashboard/_components/Navbar/Top.tsx | 11 +- .../(org)/dashboard/settings/account/page.tsx | 10 + .../settings/invoices/invoice-history.tsx | 228 ++++++++++++++++ .../dashboard/settings/invoices/loading.tsx | 3 + .../dashboard/settings/invoices/page.tsx | 29 ++ .../organization/_components/SettingsNav.tsx | 1 + .../settings/organization/billing/page.tsx | 19 -- .../organization/components/SsoCard.tsx | 95 +------ apps/web/lib/billing/invoices.ts | 150 ++++++++++ apps/web/lib/billing/verified-email.ts | 39 +++ apps/web/lib/sso/billing.ts | 33 +-- .../database/billing/license-customers.ts | 29 ++ packages/database/package.json | 1 + packages/env/server.ts | 1 + 22 files changed, 1414 insertions(+), 320 deletions(-) create mode 100644 apps/web/__tests__/unit/invoice-actions.test.ts create mode 100644 apps/web/__tests__/unit/invoice-documents.test.ts create mode 100644 apps/web/__tests__/unit/invoice-history.test.ts create mode 100644 apps/web/__tests__/unit/invoice-verified-email.test.ts create mode 100644 apps/web/actions/billing/invoices.ts create mode 100644 apps/web/app/(org)/dashboard/settings/invoices/invoice-history.tsx create mode 100644 apps/web/app/(org)/dashboard/settings/invoices/loading.tsx create mode 100644 apps/web/app/(org)/dashboard/settings/invoices/page.tsx create mode 100644 apps/web/lib/billing/invoices.ts create mode 100644 apps/web/lib/billing/verified-email.ts create mode 100644 packages/database/billing/license-customers.ts diff --git a/apps/web/__tests__/unit/billing-page.test.ts b/apps/web/__tests__/unit/billing-page.test.ts index bca08aa2c9e..f9711d32831 100644 --- a/apps/web/__tests__/unit/billing-page.test.ts +++ b/apps/web/__tests__/unit/billing-page.test.ts @@ -73,12 +73,10 @@ beforeEach(() => { }); describe("SSO invoice discovery on Billing", () => { - it("links the owner to the selected organization's Security page without loading invoices or starting billing", () => { + it("does not push Members down with a separate SSO invoice card", () => { const markup = renderToStaticMarkup(createElement(BillingAndMembersPage)); - expect(markup).toContain( - 'href="/dashboard/settings/organization/security?organizationId=org%2Finvoice%3Fhistory"', - ); - expect(markup).toContain("Open SSO invoices in Security settings"); + expect(markup).not.toContain("SAML SSO invoices"); + expect(markup).not.toContain("Security settings"); expect(mocks.invoices).not.toHaveBeenCalled(); expect(mocks.portal).not.toHaveBeenCalled(); expect(mocks.checkout).not.toHaveBeenCalled(); diff --git a/apps/web/__tests__/unit/invoice-actions.test.ts b/apps/web/__tests__/unit/invoice-actions.test.ts new file mode 100644 index 00000000000..d398df2ef6a --- /dev/null +++ b/apps/web/__tests__/unit/invoice-actions.test.ts @@ -0,0 +1,258 @@ +import { createHash } from "node:crypto"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + getInvoiceHistory, + getInvoiceHistoryPage, +} from "@/actions/billing/invoices"; + +const mocks = vi.hoisted(() => ({ + user: { + id: "owner", + email: "owner@example.com", + emailVerified: new Date(), + stripeCustomerId: "cus_pro", + } as { + id: string; + email: string; + emailVerified: Date | null; + stripeCustomerId: string | null; + } | null, + isCap: true, + licenses: vi.fn(), + documents: vi.fn(), + sso: vi.fn(), + rows: {} as Record[]>, +})); +type Condition = + | { column: string; value: unknown } + | { conditions: Condition[] } + | { column: string; values: unknown[] } + | { notNull: string }; +function matches(row: Record, condition: Condition): boolean { + if ("conditions" in condition) + return condition.conditions.every((entry) => matches(row, entry)); + if ("notNull" in condition) return row[condition.notNull] != null; + if ("values" in condition) + return condition.values.includes(row[condition.column]); + return row[condition.column] === condition.value; +} +vi.mock("@cap/database", () => ({ + db: () => ({ + select: (fields: Record) => ({ + from: (table: { _table: string }) => { + const query = { + innerJoin: () => query, + where: (condition: Condition) => + Promise.resolve( + (mocks.rows[table._table] ?? []) + .filter((row) => matches(row, condition)) + .map((row) => + Object.fromEntries( + Object.entries(fields).map(([key, column]) => [ + key, + row[column], + ]), + ), + ), + ), + }; + return query; + }, + }), + }), +})); +vi.mock("@cap/database/schema", () => { + const table = (name: string, columns: string[]) => ({ + _table: name, + ...Object.fromEntries( + columns.map((column) => [column, `${name}.${column}`]), + ), + }); + return { + accounts: table("accounts", [ + "userId", + "provider", + "providerAccountId", + "id_token", + ]), + users: table("users", ["id", "stripeCustomerId"]), + organizations: table("organizations", [ + "id", + "ownerId", + "name", + "tombstoneAt", + ]), + organizationSso: table("sso", ["organizationId", "stripeSubscriptionId"]), + developerCreditAccounts: table("credits", ["ownerId", "stripeCustomerId"]), + }; +}); +vi.mock("drizzle-orm", () => ({ + eq: (column: string, value: unknown) => ({ column, value }), + and: (...conditions: Condition[]) => ({ conditions }), + isNull: (column: string) => ({ column, value: null }), + isNotNull: (notNull: string) => ({ notNull }), + inArray: (column: string, values: unknown[]) => ({ column, values }), +})); +vi.mock("@cap/database/auth/session", () => ({ + getCurrentUser: async () => mocks.user, +})); +vi.mock("@cap/database/billing/license-customers", () => ({ + getLicenseCustomerIds: mocks.licenses, +})); +vi.mock("@cap/env", () => ({ + buildEnv: { + get NEXT_PUBLIC_IS_CAP() { + return mocks.isCap; + }, + }, +})); +vi.mock("@/lib/billing/invoices", () => ({ + listCustomerDocuments: mocks.documents, +})); +vi.mock("@/lib/sso/billing", () => ({ listSsoInvoices: mocks.sso })); + +const sourceId = (key: string, user = "owner") => + createHash("sha256").update(`${user}:${key}`).digest("hex"); +beforeEach(() => { + mocks.user = { + id: "owner", + email: "owner@example.com", + emailVerified: new Date(), + stripeCustomerId: "cus_pro", + }; + mocks.isCap = true; + mocks.licenses.mockReset().mockResolvedValue(["cus_license", "cus_pro"]); + mocks.documents.mockReset().mockResolvedValue({ documents: [], next: null }); + mocks.sso.mockReset().mockResolvedValue({ invoices: [], hasMore: false }); + mocks.rows = { + organizations: [ + { + "organizations.id": "org_owner", + "organizations.ownerId": "owner", + "organizations.name": "Example", + "organizations.tombstoneAt": null, + "sso.stripeSubscriptionId": "sub_sso", + }, + { + "organizations.id": "org_other", + "organizations.ownerId": "other", + "organizations.name": "Other", + "organizations.tombstoneAt": null, + "sso.stripeSubscriptionId": "sub_other", + }, + ], + credits: [ + { "credits.ownerId": "owner", "credits.stripeCustomerId": "cus_credits" }, + ], + }; +}); +describe("invoice authorization", () => { + it("combines owned sources, deduplicates customers and limits SSO to owned organizations", async () => { + const result = await getInvoiceHistory(); + expect(result.sections).toHaveLength(4); + expect(mocks.documents.mock.calls.map(([id]) => id)).toEqual([ + "cus_pro", + "cus_credits", + "cus_license", + ]); + expect(mocks.sso).toHaveBeenCalledExactlyOnceWith("org_owner", { + limit: 25, + ownerId: "owner", + startingAfter: undefined, + }); + expect(mocks.licenses).toHaveBeenCalledWith("owner@example.com"); + expect(JSON.stringify(result)).not.toContain("cus_"); + }); + it.each(["signed-out", "self-hosted"])( + "rejects %s requests before any billing reads", + async (mode) => { + if (mode === "signed-out") mocks.user = null; + else mocks.isCap = false; + await expect(getInvoiceHistory()).rejects.toThrow("Unauthorized"); + expect(mocks.licenses).not.toHaveBeenCalled(); + expect(mocks.documents).not.toHaveBeenCalled(); + }, + ); + it("requires verified email before looking up desktop purchases", async () => { + if (mocks.user) mocks.user.emailVerified = null; + const result = await getInvoiceHistory(); + expect(result.warnings[0]).toContain("verification code"); + expect(mocks.licenses).not.toHaveBeenCalled(); + expect(mocks.documents).not.toHaveBeenCalledWith( + "cus_license", + expect.anything(), + expect.anything(), + ); + }); + + it("accepts the verified email of a persisted Google account", async () => { + if (mocks.user) mocks.user.emailVerified = null; + mocks.rows.accounts = [ + { + "accounts.userId": "owner", + "accounts.provider": "google", + "accounts.providerAccountId": "subject", + "accounts.id_token": `header.${Buffer.from(JSON.stringify({ iss: "https://accounts.google.com", sub: "subject", email: "owner@example.com", email_verified: true })).toString("base64url")}.signature`, + }, + ]; + const result = await getInvoiceHistory(); + expect(result.warnings).toEqual([]); + expect(mocks.licenses).toHaveBeenCalledWith("owner@example.com"); + }); + it("excludes a license customer assigned to a different Cap user", async () => { + mocks.rows.users = [ + { "users.id": "other", "users.stripeCustomerId": "cus_license" }, + ]; + await getInvoiceHistory(); + expect(mocks.documents.mock.calls.map(([id]) => id)).not.toContain( + "cus_license", + ); + }); + it("reauthorizes pagination after an organization transfer", async () => { + await getInvoiceHistory(); + mocks.sso.mockClear(); + mocks.rows.organizations = []; + await expect( + getInvoiceHistoryPage(sourceId("sso:org_owner"), { + invoices: "in_last", + receipts: null, + }), + ).rejects.toThrow("no longer available"); + expect(mocks.sso).not.toHaveBeenCalled(); + }); + it("rejects a source identifier from a different signed-in user", async () => { + if (mocks.user) mocks.user.id = "other"; + await expect(getInvoiceHistoryPage(sourceId("cus_pro"))).rejects.toThrow( + "no longer available", + ); + expect(mocks.documents).not.toHaveBeenCalled(); + }); + it("rejects malformed pagination before reading provider data", async () => { + await expect( + getInvoiceHistoryPage(sourceId("cus_pro"), { + invoices: "https://evil.test", + receipts: null, + }), + ).rejects.toThrow(); + expect(mocks.documents).not.toHaveBeenCalled(); + }); + it("keeps a failed provider section visible and recovers other purchases", async () => { + mocks.licenses.mockRejectedValue(new Error("Unavailable")); + mocks.documents.mockRejectedValueOnce(new Error("Unavailable")); + const result = await getInvoiceHistory(); + expect(result.warnings).toHaveLength(1); + expect(result.sections[0]?.page).toBeNull(); + expect(result.sections[1]?.page).not.toBeNull(); + }); + it("passes SSO pagination to the subscription-scoped reader", async () => { + await getInvoiceHistoryPage(sourceId("sso:org_owner"), { + invoices: "in_last", + receipts: null, + }); + expect(mocks.sso).toHaveBeenCalledWith("org_owner", { + startingAfter: "in_last", + limit: 25, + ownerId: "owner", + }); + }); +}); diff --git a/apps/web/__tests__/unit/invoice-documents.test.ts b/apps/web/__tests__/unit/invoice-documents.test.ts new file mode 100644 index 00000000000..76c755e29a4 --- /dev/null +++ b/apps/web/__tests__/unit/invoice-documents.test.ts @@ -0,0 +1,158 @@ +import type Stripe from "stripe"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + listCustomerDocuments, + stripeDocumentUrl, +} from "@/lib/billing/invoices"; + +const mocks = vi.hoisted(() => ({ + customers: { retrieve: vi.fn() }, + invoices: { list: vi.fn() }, + charges: { list: vi.fn() }, +})); +vi.mock("@cap/utils", async (importOriginal) => ({ + ...(await importOriginal()), + stripe: () => mocks, +})); + +const invoice = (overrides: Partial = {}) => ({ + id: "in_pro", + customer: "cus_owner", + number: "CAP-001", + description: "Cap Pro", + created: 100, + total: 1000, + currency: "usd", + status: "paid", + invoice_pdf: "https://pay.stripe.com/invoice/example/pdf", + lines: { data: [] }, + ...overrides, +}); +const charge = (overrides: Partial = {}) => ({ + id: "ch_license", + customer: "cus_owner", + invoice: null, + paid: true, + status: "succeeded", + created: 200, + amount: 2900, + amount_refunded: 0, + refunded: false, + currency: "usd", + receipt_number: null, + description: "Desktop license", + receipt_url: "https://pay.stripe.com/receipts/example", + ...overrides, +}); + +beforeEach(() => { + mocks.customers.retrieve.mockResolvedValue({ + id: "cus_owner", + metadata: { userId: "owner" }, + }); + mocks.invoices.list.mockResolvedValue({ data: [invoice()], has_more: false }); + mocks.charges.list.mockResolvedValue({ data: [charge()], has_more: false }); +}); + +describe("invoice documents", () => { + it("combines invoices and historical receipts without duplicating invoiced charges", async () => { + mocks.charges.list.mockResolvedValue({ + data: [ + charge(), + charge({ id: "ch_invoiced", invoice: "in_pro" }), + charge({ id: "ch_failed", paid: false }), + ], + has_more: false, + }); + const result = await listCustomerDocuments("cus_owner", "owner"); + expect( + result.documents.map((document) => [document.id, document.kind]), + ).toEqual([ + ["ch_license", "receipt"], + ["in_pro", "invoice"], + ]); + expect(result.next).toBeNull(); + expect(mocks.invoices.list).toHaveBeenCalledWith({ + customer: "cus_owner", + limit: 25, + }); + }); + it("omits drafts and SSO invoices from account-wide history", async () => { + mocks.invoices.list.mockResolvedValue({ + data: [ + invoice({ status: "draft" }), + invoice({ + id: "in_sso", + subscription_details: { metadata: { type: "saml_sso" } }, + }), + ], + has_more: false, + }); + mocks.charges.list.mockResolvedValue({ data: [], has_more: false }); + expect( + (await listCustomerDocuments("cus_owner", "owner")).documents, + ).toEqual([]); + }); + it.each([ + { id: "cus_owner", deleted: true }, + { id: "cus_owner", metadata: { userId: "other" } }, + { id: "cus_other", metadata: {} }, + ])( + "fails closed for unavailable or reassigned billing customers", + async (customer) => { + mocks.customers.retrieve.mockResolvedValue(customer); + await expect(listCustomerDocuments("cus_owner", "owner")).rejects.toThrow( + "ownership", + ); + expect(mocks.invoices.list).not.toHaveBeenCalled(); + expect(mocks.charges.list).not.toHaveBeenCalled(); + }, + ); + it("rejects a provider response containing another customer's document", async () => { + mocks.charges.list.mockResolvedValue({ + data: [charge({ customer: "cus_other" })], + has_more: false, + }); + await expect(listCustomerDocuments("cus_owner", "owner")).rejects.toThrow( + "do not match", + ); + }); + it("advances past filtered records and does not restart exhausted invoice streams", async () => { + mocks.invoices.list.mockResolvedValue({ + data: [invoice({ status: "draft" })], + has_more: true, + }); + const first = await listCustomerDocuments("cus_owner", "owner"); + expect(first.next).toEqual({ invoices: "in_pro", receipts: null }); + await listCustomerDocuments("cus_owner", "owner", first.next ?? undefined); + expect(mocks.charges.list).toHaveBeenCalledTimes(1); + expect(mocks.invoices.list).toHaveBeenLastCalledWith({ + customer: "cus_owner", + limit: 25, + starting_after: "in_pro", + }); + }); + it("reports partial refunds and keeps documents with no downloadable PDF", async () => { + mocks.invoices.list.mockResolvedValue({ + data: [invoice({ invoice_pdf: null })], + has_more: false, + }); + mocks.charges.list.mockResolvedValue({ + data: [charge({ amount_refunded: 100 })], + has_more: false, + }); + const result = await listCustomerDocuments("cus_owner", "owner"); + expect(result.documents[0]?.status).toBe("partially refunded"); + expect(result.documents[1]?.url).toBeNull(); + }); + it.each([ + "javascript:alert(1)", + "http://pay.stripe.com/x", + "https://pay.stripe.com.evil.test/x", + "https://user@pay.stripe.com/x", + "https://pay.stripe.com:8443/x", + "not a URL", + ])("rejects an untrusted document URL: %s", (url) => { + expect(stripeDocumentUrl(url)).toBeNull(); + }); +}); diff --git a/apps/web/__tests__/unit/invoice-history.test.ts b/apps/web/__tests__/unit/invoice-history.test.ts new file mode 100644 index 00000000000..c306596a1fd --- /dev/null +++ b/apps/web/__tests__/unit/invoice-history.test.ts @@ -0,0 +1,168 @@ +// @vitest-environment jsdom + +import { act, type ComponentProps, createElement, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + getInvoiceHistory, + getInvoiceHistoryPage, +} from "@/actions/billing/invoices"; +import { InvoiceHistory } from "@/app/(org)/dashboard/settings/invoices/invoice-history"; +import type { BillingDocument } from "@/lib/billing/invoices"; + +vi.mock("@/actions/billing/invoices", () => ({ + getInvoiceHistory: vi.fn(), + getInvoiceHistoryPage: vi.fn(), +})); +vi.mock("@cap/ui", () => { + const container = ({ children }: { children?: ReactNode }) => + createElement("div", null, children); + return { + Card: container, + CardHeader: container, + CardTitle: container, + CardDescription: container, + Button: ({ + children, + onClick, + disabled, + }: ComponentProps<"button"> & { spinner?: boolean }) => + createElement("button", { type: "button", onClick, disabled }, children), + }; +}); +const invoice = ( + overrides: Partial = {}, +): BillingDocument => ({ + id: "in_example", + number: "CAP-001", + description: "Cap Pro", + created: 100, + total: 1000, + currency: "usd", + status: "paid", + kind: "invoice", + url: "https://pay.stripe.com/invoice/example/pdf", + ...overrides, +}); +const fixture = (): NonNullable< + ComponentProps["initialHistory"] +> => ({ + sections: [ + { + id: "source", + title: "Account purchases", + page: { + documents: [invoice()], + next: { invoices: "in_example", receipts: null }, + }, + }, + ], + warnings: [], +}); +const environment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; +}; +let root: Root; +let container: HTMLDivElement; +const button = (text: string) => + Array.from(container.querySelectorAll("button")).find( + (element) => element.textContent === text, + ); +const render = async ( + history: ComponentProps["initialHistory"] = fixture(), +) => { + await act(async () => + root.render(createElement(InvoiceHistory, { initialHistory: history })), + ); +}; +beforeEach(() => { + environment.IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + vi.mocked(getInvoiceHistory).mockReset().mockResolvedValue(fixture()); + vi.mocked(getInvoiceHistoryPage) + .mockReset() + .mockResolvedValue({ documents: [], next: null }); +}); +afterEach(async () => { + await act(async () => root.unmount()); + document.body.replaceChildren(); + delete environment.IS_REACT_ACT_ENVIRONMENT; +}); +describe("invoice history interactions", () => { + it("shows payment state and documents with accessible download names", async () => { + const history = fixture(); + history.sections[0]?.page?.documents.push( + invoice({ + id: "ch_desktop", + description: "Desktop license", + number: null, + kind: "receipt", + url: "https://pay.stripe.com/receipts/example", + }), + invoice({ id: "in_pending", number: "CAP-002", url: null }), + ); + await render(history); + expect(container.textContent).toContain("$10.00"); + expect(container.textContent).toContain("paid"); + expect(container.textContent).toContain("Download PDF for CAP-001"); + expect(container.textContent).toContain("View receipt for Desktop license"); + expect(container.textContent).toContain("Not available yet"); + expect(container.querySelectorAll("a")).toHaveLength(2); + }); + it("appends older documents without duplicates and removes the exhausted pager", async () => { + vi.mocked(getInvoiceHistoryPage).mockResolvedValue({ + documents: [ + invoice(), + invoice({ id: "in_older", number: "CAP-000", created: 1 }), + ], + next: null, + }); + await render(); + await act(async () => button("Load older documents")?.click()); + expect(getInvoiceHistoryPage).toHaveBeenCalledWith("source", { + invoices: "in_example", + receipts: null, + }); + expect(container.querySelectorAll("tbody tr")).toHaveLength(2); + expect(button("Load older documents")).toBeUndefined(); + }); + it("prevents duplicate page requests and preserves loaded invoices on failure", async () => { + let fail: (error: Error) => void = () => {}; + vi.mocked(getInvoiceHistoryPage).mockReturnValueOnce( + new Promise((_resolve, reject) => { + fail = reject; + }), + ); + await render(); + await act(async () => { + const target = button("Load older documents"); + target?.click(); + target?.click(); + }); + expect(getInvoiceHistoryPage).toHaveBeenCalledTimes(1); + expect(button("Load older documents")?.disabled).toBe(true); + await act(async () => fail(new Error("Unavailable"))); + expect(container.textContent).toContain("CAP-001"); + expect(container.querySelector('[role="alert"]')).not.toBeNull(); + await act(async () => button("Try again")?.click()); + expect(container.querySelector('[role="alert"]')).toBeNull(); + }); + it("retries a failed initial section without pretending it was empty", async () => { + await render({ + sections: [{ id: "source", title: "Account purchases", page: null }], + warnings: [], + }); + expect(container.textContent).not.toContain("No invoices"); + await act(async () => button("Try again")?.click()); + expect(getInvoiceHistoryPage).toHaveBeenCalledWith("source", undefined); + expect(container.textContent).toContain("No invoices or receipts found."); + }); + it("refreshes failed source discovery and discards old section state", async () => { + await render({ sections: [], warnings: ["License invoices unavailable"] }); + await act(async () => button("Refresh invoices")?.click()); + expect(container.textContent).not.toContain("License invoices unavailable"); + expect(container.textContent).toContain("CAP-001"); + }); +}); diff --git a/apps/web/__tests__/unit/invoice-verified-email.test.ts b/apps/web/__tests__/unit/invoice-verified-email.test.ts new file mode 100644 index 00000000000..ca757678a57 --- /dev/null +++ b/apps/web/__tests__/unit/invoice-verified-email.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { hasVerifiedProviderEmail } from "@/lib/billing/verified-email"; + +function account(claims: Record = {}) { + return { + provider: "google", + providerAccountId: "example-subject", + idToken: `header.${Buffer.from(JSON.stringify({ iss: "https://accounts.google.com", sub: "example-subject", email: "owner@example.com", email_verified: true, ...claims })).toString("base64url")}.signature`, + }; +} +describe("persisted verified provider email", () => { + it("supports verified Google and Apple accounts with matching email and subject", () => { + expect(hasVerifiedProviderEmail("OWNER@example.com", account())).toBe(true); + expect( + hasVerifiedProviderEmail("owner@example.com", { + ...account({ + iss: "https://appleid.apple.com", + email_verified: "true", + }), + provider: "apple", + }), + ).toBe(true); + }); + it.each([ + { email_verified: false }, + { email_verified: "false" }, + { email: "other@example.com" }, + { sub: "other" }, + { iss: "https://evil.test" }, + ])("rejects mismatched or unverified claims: %j", (claims) => { + expect(hasVerifiedProviderEmail("owner@example.com", account(claims))).toBe( + false, + ); + }); + it.each([null, "bad", "header.not-json.signature"])( + "rejects unavailable or corrupt tokens", + (idToken) => { + expect( + hasVerifiedProviderEmail("owner@example.com", { + ...account(), + idToken, + }), + ).toBe(false); + }, + ); +}); diff --git a/apps/web/__tests__/unit/sso-billing.test.ts b/apps/web/__tests__/unit/sso-billing.test.ts index 3f018bc8da9..71670027b36 100644 --- a/apps/web/__tests__/unit/sso-billing.test.ts +++ b/apps/web/__tests__/unit/sso-billing.test.ts @@ -1234,6 +1234,27 @@ describe("read-only SSO invoices", () => { }); }); + it("rejects an owner who changed after source discovery", async () => { + await expect( + listSsoInvoices(organizationId, { ownerId: "user_previous_owner" }), + ).rejects.toThrow("ownership has changed"); + expect(mocks.stripe.invoices.list).not.toHaveBeenCalled(); + }); + + it("paginates within the linked subscription", async () => { + await listSsoInvoices(organizationId, { + ownerId: userId, + limit: 25, + startingAfter: "in_previous", + }); + expect(mocks.stripe.invoices.list).toHaveBeenCalledWith({ + customer: "cus_owner", + subscription: "sub_sso", + limit: 25, + starting_after: "in_previous", + }); + }); + it("lists only the linked SSO customer and subscription, preserving separate Pro billing", async () => { state.users.set(userId, { id: userId, diff --git a/apps/web/__tests__/unit/sso-card.test.ts b/apps/web/__tests__/unit/sso-card.test.ts index b71e5d0ce68..79bf1143c3f 100644 --- a/apps/web/__tests__/unit/sso-card.test.ts +++ b/apps/web/__tests__/unit/sso-card.test.ts @@ -106,23 +106,7 @@ const render = async (overrides: Partial = {}) => { ); }; -type InvoiceResult = Awaited>; -const invoiceResult = (): InvoiceResult => ({ - invoices: [ - { - id: "in_sso", - number: "SSO-0001", - created: 1790812800, - total: 20000, - currency: "usd", - status: "paid", - pdfUrl: "https://pay.stripe.test/invoice/sso.pdf", - }, - ], - hasMore: false, -}); - -describe("SSO invoices", () => { +describe("SSO settings", () => { beforeAll(() => { actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; }); @@ -132,9 +116,7 @@ describe("SSO invoices", () => { "", "/dashboard/settings/organization/security", ); - vi.mocked(getOrganizationSsoInvoices) - .mockReset() - .mockResolvedValue(invoiceResult()); + vi.mocked(getOrganizationSsoInvoices).mockReset(); vi.mocked(getOrganizationSsoSettings) .mockReset() .mockResolvedValue(settings()); @@ -150,176 +132,25 @@ describe("SSO invoices", () => { delete actEnvironment.IS_REACT_ACT_ENVIRONMENT; }); - it.each([ - { subscriptionStatus: "active", entitled: true, cancelAtPeriodEnd: false }, - { subscriptionStatus: "active", entitled: true, cancelAtPeriodEnd: true }, - { subscriptionStatus: "unpaid", entitled: false, cancelAtPeriodEnd: false }, - { - subscriptionStatus: "canceled", - entitled: false, - cancelAtPeriodEnd: false, - }, - ])( - "offers owners billing history for $subscriptionStatus subscriptions, canceling=$cancelAtPeriodEnd", - async (overrides) => { - await render(overrides); - expect(button("View invoices")?.disabled).toBe(false); - }, - ); - - it("hides billing from admins while preserving SSO setup", async () => { - await render({ canManageBilling: false }); + it("keeps invoice history out of Security while preserving SSO setup", async () => { + await render(); expect(button("View invoices")).toBeUndefined(); expect(button("Manage SSO")).toBeDefined(); - }); - - it("keeps invoice history available when SSO setup is unavailable", async () => { - await render({ ssoAvailable: false }); - expect(button("View invoices")?.disabled).toBe(false); - expect(button("Manage SSO")?.disabled).toBe(true); - await act(async () => button("View invoices")?.click()); - expect(getOrganizationSsoInvoices).toHaveBeenCalledExactlyOnceWith( - organizationId, - ); - }); - - it("hides billing before an owner has a subscription", async () => { - await render({ - hasSubscription: false, - entitled: false, - subscriptionStatus: null, - }); - expect(button("View invoices")).toBeUndefined(); - expect(button("Add SAML SSO · $200.00/month")).toBeDefined(); - }); - - it("loads only invoice PDFs without opening billing, checkout or SSO setup", async () => { - await render(); expect(getOrganizationSsoInvoices).not.toHaveBeenCalled(); - const initialUrl = window.location.href; - await act(async () => button("View invoices")?.click()); - expect(getOrganizationSsoInvoices).toHaveBeenCalledExactlyOnceWith( - organizationId, - ); expect(manageOrganizationSsoBilling).not.toHaveBeenCalled(); expect(openOrganizationSsoPortal).not.toHaveBeenCalled(); expect(startOrganizationSsoCheckout).not.toHaveBeenCalled(); - expect(window.location.href).toBe(initialUrl); - expect(button("View invoices")?.disabled).toBe(false); - expect(container.textContent).toContain("SSO-0001"); - const link = container.querySelector( - 'a[href="https://pay.stripe.test/invoice/sso.pdf"]', - ); - expect(link).not.toBeNull(); - expect(container.textContent).not.toMatch(/Manage (SSO )?subscription/); }); - - it("blocks repeated clicks and other actions while invoices load", async () => { - let complete: (value: InvoiceResult) => void = () => {}; - vi.mocked(getOrganizationSsoInvoices).mockReturnValue( - new Promise((resolve) => { - complete = resolve; - }), - ); - await render(); - const target = button("View invoices"); - expect(target).toBeDefined(); - await act(async () => { - target?.click(); - target?.click(); - }); - expect(getOrganizationSsoInvoices).toHaveBeenCalledTimes(1); - expect(target?.disabled).toBe(true); - expect(target?.dataset.spinner).toBe("true"); - expect(button("Manage SSO")?.disabled).toBe(true); - expect(button("Refresh status")?.disabled).toBe(true); - await act(async () => complete(invoiceResult())); - expect(button("View invoices")?.disabled).toBe(false); - expect(button("View invoices")?.dataset.spinner).toBe("false"); - }); - - it("shows an invoice error and allows retry after failure", async () => { - vi.mocked(getOrganizationSsoInvoices).mockRejectedValueOnce( - new Error("Unavailable"), - ); - await render(); - await act(async () => button("View invoices")?.click()); - expect(container.querySelector('[role="alert"]')?.textContent).toBe( - "We couldn't load SSO invoices. Please try again or contact support.", - ); - expect(button("View invoices")?.disabled).toBe(false); - expect(button("View invoices")?.dataset.spinner).toBe("false"); - await act(async () => button("View invoices")?.click()); - expect(getOrganizationSsoInvoices).toHaveBeenCalledTimes(2); - expect(container.querySelector('[role="alert"]')).toBeNull(); - expect(container.textContent).toContain("SSO-0001"); + it("keeps setup available to admins", async () => { + await render({ canManageBilling: false }); + expect(button("Manage SSO")).toBeDefined(); }); - - it("does not leak a late invoice response after switching organizations", async () => { - let complete: (value: InvoiceResult) => void = () => {}; - vi.mocked(getOrganizationSsoInvoices).mockReturnValueOnce( - new Promise((resolve) => { - complete = resolve; - }), - ); - await render(); - await act(async () => button("View invoices")?.click()); - const nextOrganizationId = Organisation.OrganisationId.make("org_another"); + it("offers checkout for an unsubscribed owner", async () => { await render({ - organizationId: nextOrganizationId, - organizationName: "Another Organization", - }); - expect(button("View invoices")?.disabled).toBe(false); - expect(container.textContent).not.toContain("SSO-0001"); - vi.mocked(getOrganizationSsoInvoices).mockResolvedValueOnce({ - invoices: [], - hasMore: false, - }); - await act(async () => button("View invoices")?.click()); - expect(getOrganizationSsoInvoices).toHaveBeenLastCalledWith( - nextOrganizationId, - ); - await act(async () => complete(invoiceResult())); - expect(container.textContent).toContain("Another Organization"); - expect(container.textContent).toContain("No SSO invoices found."); - expect(container.textContent).not.toContain("SSO-0001"); - expect(container.querySelector('a[href*="stripe"]')).toBeNull(); - }); - - it("shows an explicit empty state without billing portal links", async () => { - vi.mocked(getOrganizationSsoInvoices).mockResolvedValue({ - invoices: [], - hasMore: false, - }); - await render(); - await act(async () => button("View invoices")?.click()); - expect(container.textContent).toMatch(/no .*invoices/i); - expect(container.querySelector('a[href*="stripe"]')).toBeNull(); - }); - - it("keeps an invoice without a PDF visible without inventing a download link", async () => { - const result = invoiceResult(); - const invoice = result.invoices[0]; - if (!invoice) throw new Error("Missing invoice fixture"); - invoice.pdfUrl = null; - vi.mocked(getOrganizationSsoInvoices).mockResolvedValue(result); - await render(); - await act(async () => button("View invoices")?.click()); - expect(container.textContent).toContain("SSO-0001"); - expect(container.textContent).toMatch( - /PDF.*(unavailable|available|ready)/i, - ); - expect(container.querySelector('a[href*="stripe"]')).toBeNull(); - }); - - it("warns when invoice history exceeds the first 100 results", async () => { - vi.mocked(getOrganizationSsoInvoices).mockResolvedValue({ - ...invoiceResult(), - hasMore: true, + hasSubscription: false, + entitled: false, + subscriptionStatus: null, }); - await render(); - await act(async () => button("View invoices")?.click()); - expect(container.textContent).toContain("100"); - expect(container.textContent).toMatch(/older|more/i); + expect(button("Add SAML SSO · $200.00/month")).toBeDefined(); }); }); diff --git a/apps/web/actions/billing/invoices.ts b/apps/web/actions/billing/invoices.ts new file mode 100644 index 00000000000..e95b580d459 --- /dev/null +++ b/apps/web/actions/billing/invoices.ts @@ -0,0 +1,233 @@ +"use server"; + +import { createHash } from "node:crypto"; +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { getLicenseCustomerIds } from "@cap/database/billing/license-customers"; +import { + accounts, + developerCreditAccounts, + organizationSso, + organizations, + users, +} from "@cap/database/schema"; +import { buildEnv } from "@cap/env"; +import type { Organisation } from "@cap/web-domain"; +import { and, eq, inArray, isNotNull, isNull } from "drizzle-orm"; +import { z } from "zod"; +import { + type InvoiceCursor, + type InvoicePage, + listCustomerDocuments, +} from "@/lib/billing/invoices"; +import { hasVerifiedProviderEmail } from "@/lib/billing/verified-email"; +import { listSsoInvoices } from "@/lib/sso/billing"; + +type BillingUser = NonNullable>>; +type InvoiceSource = { + id: string; + title: string; +} & ( + | { kind: "customer"; customerId: string } + | { kind: "sso"; organizationId: Organisation.OrganisationId } +); + +export type InvoiceSection = { + id: string; + title: string; + page: InvoicePage | null; +}; + +const cursorSchema = z + .object({ + invoices: z + .string() + .regex(/^in_[a-zA-Z0-9_]+$/) + .max(255) + .nullable(), + receipts: z + .string() + .regex(/^ch_[a-zA-Z0-9_]+$/) + .max(255) + .nullable(), + }) + .strict(); + +async function requireBillingUser() { + const user = await getCurrentUser(); + if (!user || !buildEnv.NEXT_PUBLIC_IS_CAP) throw new Error("Unauthorized"); + return user; +} + +function sourceId(user: BillingUser, key: string) { + return createHash("sha256").update(`${user.id}:${key}`).digest("hex"); +} + +async function invoiceSources(user: BillingUser) { + const warnings: string[] = []; + const canReadLicenseEmail = + user.emailVerified || + ( + await db() + .select({ + provider: accounts.provider, + providerAccountId: accounts.providerAccountId, + idToken: accounts.id_token, + }) + .from(accounts) + .where( + and( + eq(accounts.userId, user.id), + inArray(accounts.provider, ["google", "apple"]), + ), + ) + ).some((account) => hasVerifiedProviderEmail(user.email, account)); + const [licenseIds, sso, credits] = await Promise.all([ + canReadLicenseEmail + ? getLicenseCustomerIds(user.email).catch(() => { + warnings.push( + "Desktop and self-hosted license invoices couldn’t be loaded. Please try again.", + ); + return []; + }) + : Promise.resolve([]), + db() + .select({ + organizationId: organizations.id, + name: organizations.name, + }) + .from(organizations) + .innerJoin( + organizationSso, + eq(organizationSso.organizationId, organizations.id), + ) + .where( + and( + eq(organizations.ownerId, user.id), + isNull(organizations.tombstoneAt), + isNotNull(organizationSso.stripeSubscriptionId), + ), + ), + db() + .select({ customerId: developerCreditAccounts.stripeCustomerId }) + .from(developerCreditAccounts) + .where(eq(developerCreditAccounts.ownerId, user.id)), + ]); + if (!canReadLicenseEmail) { + warnings.push( + "To see desktop and self-hosted license invoices, sign in using the email verification code sent to your purchase email.", + ); + } + const sources: InvoiceSource[] = []; + const customers = new Map(); + if (user.stripeCustomerId) + customers.set(user.stripeCustomerId, "Account purchases"); + for (const row of credits) { + if (row.customerId && !customers.has(row.customerId)) { + customers.set(row.customerId, "Developer credits"); + } + } + const claimedCustomers = licenseIds.length + ? await db() + .select({ userId: users.id, customerId: users.stripeCustomerId }) + .from(users) + .where(inArray(users.stripeCustomerId, licenseIds)) + : []; + for (const id of licenseIds) { + if ( + claimedCustomers.some( + (row) => row.customerId === id && row.userId !== user.id, + ) + ) + continue; + if (!customers.has(id)) + customers.set(id, "Desktop and self-hosted licenses"); + } + for (const [customerId, title] of customers) { + sources.push({ + id: sourceId(user, customerId), + title, + kind: "customer", + customerId, + }); + } + for (const organization of sso) { + sources.push({ + id: sourceId(user, `sso:${organization.organizationId}`), + title: `${organization.name} · SSO`, + kind: "sso", + organizationId: organization.organizationId, + }); + } + return { sources, warnings }; +} + +async function sourcePage( + user: BillingUser, + source: InvoiceSource, + cursor?: InvoiceCursor, +): Promise { + if (source.kind === "customer") { + return listCustomerDocuments(source.customerId, user.id, cursor); + } + if (cursor?.invoices === null) return { documents: [], next: null }; + const result = await listSsoInvoices(source.organizationId, { + limit: 25, + ownerId: user.id, + startingAfter: cursor?.invoices ?? undefined, + }); + return { + documents: result.invoices + .filter((invoice) => invoice.status !== "draft") + .map((invoice) => ({ + id: invoice.id, + number: invoice.number, + description: "SAML SSO", + created: invoice.created, + total: invoice.total, + currency: invoice.currency, + status: invoice.status ?? "unknown", + url: invoice.pdfUrl, + kind: "invoice", + })), + next: result.hasMore + ? { invoices: result.invoices.at(-1)?.id ?? null, receipts: null } + : null, + }; +} + +export async function getInvoiceHistory() { + const user = await requireBillingUser(); + const { sources, warnings } = await invoiceSources(user); + const sections: InvoiceSection[] = []; + for (let i = 0; i < sources.length; i += 4) { + sections.push( + ...(await Promise.all( + sources.slice(i, i + 4).map(async (source) => ({ + id: source.id, + title: source.title, + page: await sourcePage(user, source).catch(() => null), + })), + )), + ); + } + return { sections, warnings }; +} + +export async function getInvoiceHistoryPage( + id: string, + cursor?: InvoiceCursor, +) { + if (typeof id !== "string" || !/^[a-f0-9]{64}$/.test(id)) + throw new Error("Invalid invoice history."); + const parsedCursor = + cursor === undefined ? undefined : cursorSchema.parse(cursor); + const user = await requireBillingUser(); + const { sources } = await invoiceSources(user); + const source = sources.find((entry) => entry.id === id); + if (!source) + throw new Error( + "Invoice history is no longer available. Refresh the page.", + ); + return sourcePage(user, source, parsedCursor); +} diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/Top.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/Top.tsx index dab863e629a..61de325ae80 100644 --- a/apps/web/app/(org)/dashboard/_components/Navbar/Top.tsx +++ b/apps/web/app/(org)/dashboard/_components/Navbar/Top.tsx @@ -15,7 +15,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useClickAway } from "@uidotdev/usehooks"; import clsx from "clsx"; import { AnimatePresence } from "framer-motion"; -import { Moon, MoreVertical, Sun } from "lucide-react"; +import { Moon, MoreVertical, ReceiptText, Sun } from "lucide-react"; import Link from "next/link"; import { useParams, usePathname } from "next/navigation"; import { signOut } from "next-auth/react"; @@ -68,6 +68,7 @@ const Top = () => { "/dashboard/settings/organization/content": "Organization Settings", "/dashboard/settings/organization/billing": "Organization Settings", "/dashboard/settings/organization/members": "Organization Settings", + "/dashboard/settings/invoices": "Invoices", "/dashboard/settings/account": "Account Settings", "/dashboard/settings/notifications": "Notification Settings", "/dashboard/spaces": "Spaces", @@ -252,6 +253,14 @@ const User = () => { iconClassName: "text-gray-11 group-hover:text-gray-12", showCondition: true, }, + { + name: "Invoices", + icon: , + href: "/dashboard/settings/invoices", + onClick: () => setMenuOpen(false), + iconClassName: "text-gray-11 group-hover:text-gray-12", + showCondition: buildEnv.NEXT_PUBLIC_IS_CAP, + }, { name: "Sign Out", icon: , diff --git a/apps/web/app/(org)/dashboard/settings/account/page.tsx b/apps/web/app/(org)/dashboard/settings/account/page.tsx index e674a60496a..cf17cd64459 100644 --- a/apps/web/app/(org)/dashboard/settings/account/page.tsx +++ b/apps/web/app/(org)/dashboard/settings/account/page.tsx @@ -1,4 +1,6 @@ +import { buildEnv } from "@cap/env"; import type { Metadata } from "next"; +import Link from "next/link"; import { CliApiKeys } from "./components/CliApiKeys"; import { Settings } from "./Settings"; import { listCliApiKeys } from "./server"; @@ -13,6 +15,14 @@ export default async function SettingsPage() { const cliApiKeys = await listCliApiKeys().catch(() => null); return ( <> + {buildEnv.NEXT_PUBLIC_IS_CAP && ( + + View invoices and receipts + + )} { + if (inFlight.current) return; + inFlight.current = true; + setFailed(false); + startTransition(async () => { + try { + const result = await getInvoiceHistoryPage( + section.id, + page?.next ?? undefined, + ); + setPage((current) => ({ + documents: [ + ...new Map( + [...(current?.documents ?? []), ...result.documents].map( + (document) => [document.id, document], + ), + ).values(), + ].sort((a, b) => b.created - a.created || a.id.localeCompare(b.id)), + next: result.next, + })); + } catch { + setFailed(true); + } finally { + inFlight.current = false; + } + }); + }; + return ( + + + {section.title} + + {page?.documents.length ? ( +
+ + + + + {["Date", "Purchase", "Amount", "Status", "Document"].map( + (label) => ( + + ), + )} + + + + {page.documents.map((document) => ( + + + + + + + + ))} + +
+ {section.title} invoices and receipts +
+ {label} +
+ {new Date(document.created * 1000).toLocaleDateString( + "en-GB", + { + day: "numeric", + month: "short", + year: "numeric", + timeZone: "UTC", + }, + )} + +

{document.description}

+ {document.number && ( +

+ {document.number} +

+ )} +
+ {amount(document)} + + {document.status.replaceAll("_", " ")} + + {document.url ? ( + + {document.kind === "invoice" + ? "Download PDF" + : "View receipt"} + + {" "} + for {document.number ?? document.description} + + + ) : ( + Not available yet + )} +
+
+ ) : page ? ( +

+ No invoices or receipts found. +

+ ) : null} + {(failed || !page) && ( +

+ These invoices couldn’t be loaded. Please try again. +

+ )} + {(!page || page.next) && ( + + )} +
+ ); +} + +export function InvoiceHistory({ + initialHistory, +}: { + initialHistory: Awaited> | null; +}) { + const [history, setHistory] = useState(initialHistory); + const [version, setVersion] = useState(0); + const [failed, setFailed] = useState(false); + const [pending, startTransition] = useTransition(); + const visibleSections = history?.sections.filter( + (section) => + !section.page || section.page.documents.length > 0 || section.page.next, + ); + return ( +
+ + + Invoices + + Invoices and receipts for your Cap purchases, including Pro, desktop + licenses, and organization subscriptions you manage. + + + + {(failed || !history) && ( +

+ Invoices couldn’t be loaded. Please try again. +

+ )} + {history?.warnings.map((warning) => ( + + {warning} + + ))} +
+ {visibleSections?.map((section) => ( + + ))} + {history && !visibleSections?.length && !history.warnings.length && ( +

+ No invoices or receipts found for this account. Sign in with the email + you used at checkout. +

+ )} +
+ ); +} diff --git a/apps/web/app/(org)/dashboard/settings/invoices/loading.tsx b/apps/web/app/(org)/dashboard/settings/invoices/loading.tsx new file mode 100644 index 00000000000..7e6aeaf3e29 --- /dev/null +++ b/apps/web/app/(org)/dashboard/settings/invoices/loading.tsx @@ -0,0 +1,3 @@ +export default function LoadingInvoices() { + return

Loading invoices…

; +} diff --git a/apps/web/app/(org)/dashboard/settings/invoices/page.tsx b/apps/web/app/(org)/dashboard/settings/invoices/page.tsx new file mode 100644 index 00000000000..ef2c18c18fa --- /dev/null +++ b/apps/web/app/(org)/dashboard/settings/invoices/page.tsx @@ -0,0 +1,29 @@ +import { getCurrentUser } from "@cap/database/auth/session"; +import { buildEnv } from "@cap/env"; +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; +import { getInvoiceHistory } from "@/actions/billing/invoices"; +import { getOrganizationAccess } from "@/actions/organization/authorization"; +import { canViewOrganizationSettings } from "@/lib/permissions/roles"; +import { SettingsNav } from "../organization/_components/SettingsNav"; +import { InvoiceHistory } from "./invoice-history"; + +export const metadata: Metadata = { title: "Invoices — Cap" }; + +export default async function InvoicesPage() { + const user = await getCurrentUser(); + if (!user) redirect("/auth/signin"); + if (!buildEnv.NEXT_PUBLIC_IS_CAP) redirect("/dashboard/settings/account"); + const [history, access] = await Promise.all([ + getInvoiceHistory().catch(() => null), + user.activeOrganizationId + ? getOrganizationAccess(user.id, user.activeOrganizationId) + : null, + ]); + return ( +
+ {access && canViewOrganizationSettings(access.role) && } + +
+ ); +} diff --git a/apps/web/app/(org)/dashboard/settings/organization/_components/SettingsNav.tsx b/apps/web/app/(org)/dashboard/settings/organization/_components/SettingsNav.tsx index f346434782c..41f896ac4ee 100644 --- a/apps/web/app/(org)/dashboard/settings/organization/_components/SettingsNav.tsx +++ b/apps/web/app/(org)/dashboard/settings/organization/_components/SettingsNav.tsx @@ -28,6 +28,7 @@ export function SettingsNav() { }, ...(buildEnv.NEXT_PUBLIC_IS_CAP ? [ + { label: "Invoices", href: "/dashboard/settings/invoices" }, { label: "Security & Compliance", href: "/dashboard/settings/organization/security", diff --git a/apps/web/app/(org)/dashboard/settings/organization/billing/page.tsx b/apps/web/app/(org)/dashboard/settings/organization/billing/page.tsx index 473213104cf..b8064d86e4f 100644 --- a/apps/web/app/(org)/dashboard/settings/organization/billing/page.tsx +++ b/apps/web/app/(org)/dashboard/settings/organization/billing/page.tsx @@ -2,7 +2,6 @@ import { buildEnv } from "@cap/env"; import { Card, CardDescription, CardHeader, CardTitle } from "@cap/ui"; -import Link from "next/link"; import { useDashboardContext } from "@/app/(org)/dashboard/Contexts"; import { canManageOrganizationBilling, @@ -32,24 +31,6 @@ export default function BillingAndMembersPage() { (canManageBilling ? ( <> - {activeOrganization && ( - - - SAML SSO invoices - - If your organization has an SSO subscription, open its - invoices from Security settings. - - - - Open SSO invoices in Security settings - - - )} {activeOrganization?.hasActiveProSeatProvider ? (
diff --git a/apps/web/app/(org)/dashboard/settings/organization/components/SsoCard.tsx b/apps/web/app/(org)/dashboard/settings/organization/components/SsoCard.tsx index 72d45f1df47..43b2c802bd1 100644 --- a/apps/web/app/(org)/dashboard/settings/organization/components/SsoCard.tsx +++ b/apps/web/app/(org)/dashboard/settings/organization/components/SsoCard.tsx @@ -22,7 +22,6 @@ import { import { toast } from "sonner"; import { confirmOrganizationSsoCheckout, - getOrganizationSsoInvoices, getOrganizationSsoSettings, type OrganizationSsoSettings, openOrganizationSsoPortal, @@ -34,13 +33,11 @@ import { type SupportedCurrency, } from "@/utils/currency"; -type SsoAction = "checkout" | "portal" | "invoices" | "refresh" | "confirm"; +type SsoAction = "checkout" | "portal" | "refresh" | "confirm"; const actionErrors: Record = { checkout: "We couldn't open checkout. Please try again or contact support.", portal: "We couldn't open SSO setup. Please try again or contact support.", - invoices: - "We couldn't load SSO invoices. Please try again or contact support.", refresh: "We couldn't refresh SSO status. Please try again.", confirm: "We couldn't confirm your SSO subscription yet. Refresh the status to try again, or contact support if you've been charged.", @@ -63,9 +60,6 @@ export function SsoCard({ checkoutSessionId ? "confirm" : null, ); const [error, setError] = useState(null); - const [invoiceHistory, setInvoiceHistory] = useState - > | null>(null); const [isPending, startTransition] = useTransition(); const actionInFlight = useRef(false); const confirmationStarted = useRef(null); @@ -129,12 +123,7 @@ export function SsoCard({ } catch { setError(actionErrors[action]); } finally { - if ( - !succeeded || - action === "refresh" || - action === "confirm" || - action === "invoices" - ) { + if (!succeeded || action === "refresh" || action === "confirm") { actionInFlight.current = false; setPendingAction(null); } @@ -436,86 +425,6 @@ export function SsoCard({

)} - {settings.canManageBilling && settings.hasSubscription && ( -
- - {invoiceHistory && ( -
-

- SSO invoices -

- {invoiceHistory.invoices.length ? ( - - ) : ( -

- No SSO invoices found. -

- )} - {invoiceHistory.hasMore && ( -

- Showing the latest 100 invoices. Contact{" "} - - hello@cap.so - {" "} - for older invoices. -

- )} -
- )} -
- )} - {isActive && settings.signInUrl && (