diff --git a/mcpjam-inspector/client/src/components/billing/PaymentsHistorySection.tsx b/mcpjam-inspector/client/src/components/billing/PaymentsHistorySection.tsx index fa2e7ee9a9..4b208cda52 100644 --- a/mcpjam-inspector/client/src/components/billing/PaymentsHistorySection.tsx +++ b/mcpjam-inspector/client/src/components/billing/PaymentsHistorySection.tsx @@ -24,8 +24,12 @@ import { } from "@/hooks/usePaymentsHistory"; const formatUsd = (cents: number): string => { - const dollars = cents / 100; - return Number.isInteger(dollars) ? `$${dollars}` : `$${dollars.toFixed(2)}`; + const sign = cents < 0 ? "-" : ""; + const dollars = Math.abs(cents) / 100; + const formatted = Number.isInteger(dollars) + ? `$${dollars}` + : `$${dollars.toFixed(2)}`; + return `${sign}${formatted}`; }; const formatDate = (epochMs: number): string => { @@ -110,6 +114,7 @@ function PaymentsTable({ entries }: { entries: PaymentHistoryEntry[] }) { Date Amount Credits + Details Status Receipt @@ -126,6 +131,9 @@ function PaymentsTable({ entries }: { entries: PaymentHistoryEntry[] }) { {entry.displayCredits} + + {entry.details} + @@ -159,6 +167,7 @@ function MobileRow({ entry }: { entry: PaymentHistoryEntry }) {
{entry.displayCredits}
+
{entry.details}
@@ -191,11 +200,33 @@ function StatusBadge({ entry }: { entry: PaymentHistoryEntry }) { ); } + if (status === "pending_refund") { + return ( + + + ); + } + if (status === "credited" || status === "refunded_and_credited") { + return ( + + + ); + } if (status === "refunded" || status === "partially_refunded") { const isPartial = status === "partially_refunded"; // Hover detail like "$3 of $5 refunded" when we know the reversed amount. const detail = - typeof entry.reversedPaidCents === "number" + entry.pricePaidCents > 0 && typeof entry.reversedPaidCents === "number" ? `${formatUsd(entry.reversedPaidCents)} of ${formatUsd( entry.pricePaidCents )} refunded` diff --git a/mcpjam-inspector/client/src/components/billing/__tests__/PaymentsHistorySection.test.tsx b/mcpjam-inspector/client/src/components/billing/__tests__/PaymentsHistorySection.test.tsx index 8c82e8ef95..e0cd5ebde2 100644 --- a/mcpjam-inspector/client/src/components/billing/__tests__/PaymentsHistorySection.test.tsx +++ b/mcpjam-inspector/client/src/components/billing/__tests__/PaymentsHistorySection.test.tsx @@ -32,6 +32,7 @@ function makeEntry( sessionId: overrides.sessionId ?? `cs_${overrides.id}`, pricePaidCents: overrides.pricePaidCents ?? 1000, displayCredits: overrides.displayCredits ?? "1,000 credits", + details: overrides.details ?? "Credit top-up", status: overrides.status ?? "succeeded", occurredAt: overrides.occurredAt ?? Date.now(), ...(overrides.reversedPaidCents !== undefined @@ -249,5 +250,48 @@ describe("PaymentsHistorySection", () => { expect(screen.getAllByText("Disputed")).toHaveLength(2); expect(screen.queryByText("Succeeded")).not.toBeInTheDocument(); }); + + it("renders a seat refund row without a receipt link", () => { + renderWithStatus( + makeEntry({ + id: "seat-refund", + sessionId: "seat-downgrade:sub:item:period:3->2", + pricePaidCents: -35997, + displayCredits: "-9,974 credits", + details: "-1 Team seat · prorated", + status: "refunded", + reversedPaidCents: 35997, + occurredAt: Date.UTC(2026, 5, 10), + }) + ); + + expect(screen.getAllByText("-$359.97")).toHaveLength(2); + expect(screen.getAllByText("-1 Team seat · prorated")).toHaveLength(2); + expect(screen.getAllByText("Refunded")).toHaveLength(2); + expect(screen.queryByRole("link", { name: /View receipt/ })).toBeNull(); + }); + + it("renders a split refund and credit fallback row", () => { + renderWithStatus( + makeEntry({ + id: "seat-split", + sessionId: "seat-downgrade:sub:item:period:4->3", + pricePaidCents: -35997, + displayCredits: "-9,974 credits", + details: + "-1 Team seat · prorated · $300 card refund + $59.97 account credit", + status: "refunded_and_credited", + reversedPaidCents: 30000, + accountCreditedCents: 5997, + }) + ); + + expect(screen.getAllByText("Refunded + credited")).toHaveLength(2); + expect( + screen.getAllByText( + "-1 Team seat · prorated · $300 card refund + $59.97 account credit" + ) + ).toHaveLength(2); + }); }); }); diff --git a/mcpjam-inspector/client/src/hooks/__tests__/usePaymentsHistory.test.ts b/mcpjam-inspector/client/src/hooks/__tests__/usePaymentsHistory.test.ts index e433762263..c1b32e3b65 100644 --- a/mcpjam-inspector/client/src/hooks/__tests__/usePaymentsHistory.test.ts +++ b/mcpjam-inspector/client/src/hooks/__tests__/usePaymentsHistory.test.ts @@ -79,6 +79,7 @@ describe("usePaymentsHistory", () => { sessionId: "cs_1", pricePaidCents: 2500, displayCredits: "2,500 credits", + details: "Credit top-up", status: "succeeded", occurredAt: 100, receiptUrl: "https://pay.stripe.com/receipts/abc", @@ -102,6 +103,35 @@ describe("usePaymentsHistory", () => { expect(result.current.entries?.[0].receiptUrl).toBeUndefined(); }); + it("normalizes a seat refund row with account credit fallback fields", () => { + queryReturn = { + items: [ + { + id: "seat_refund_1", + sessionId: "seat-downgrade:sub:item:period:3->2", + pricePaidCents: -35997, + displayCredits: "-9,974 credits", + details: + "-1 Team seat · prorated · $300 card refund + $59.97 account credit", + status: "refunded_and_credited", + occurredAt: 300, + reversedPaidCents: 30000, + accountCreditedCents: 5997, + }, + ], + }; + const { result } = renderHook(() => usePaymentsHistory(ORG_ID)); + expect(result.current.entries).toHaveLength(1); + expect(result.current.entries?.[0]).toMatchObject({ + id: "seat_refund_1", + pricePaidCents: -35997, + details: + "-1 Team seat · prorated · $300 card refund + $59.97 account credit", + status: "refunded_and_credited", + accountCreditedCents: 5997, + }); + }); + it("drops malformed rows but keeps valid neighbors", () => { queryReturn = { items: [ diff --git a/mcpjam-inspector/client/src/hooks/usePaymentsHistory.ts b/mcpjam-inspector/client/src/hooks/usePaymentsHistory.ts index ac078f8a7f..6a7d5eaea0 100644 --- a/mcpjam-inspector/client/src/hooks/usePaymentsHistory.ts +++ b/mcpjam-inspector/client/src/hooks/usePaymentsHistory.ts @@ -8,15 +8,20 @@ export type PaymentHistoryStatus = | "failed" | "refunded" | "partially_refunded" - | "disputed"; + | "disputed" + | "credited" + | "refunded_and_credited" + | "pending_refund"; export interface PaymentHistoryEntry { id: string; sessionId: string; pricePaidCents: number; displayCredits: string; + details: string; /** Paid cents handed back when refunded/charged back. Reversed rows only. */ reversedPaidCents?: number; + accountCreditedCents?: number; status: PaymentHistoryStatus; occurredAt: number; receiptUrl?: string; @@ -27,7 +32,9 @@ interface RawEntry { sessionId?: unknown; pricePaidCents?: unknown; displayCredits?: unknown; + details?: unknown; reversedPaidCents?: unknown; + accountCreditedCents?: unknown; status?: unknown; occurredAt?: unknown; receiptUrl?: unknown; @@ -40,6 +47,9 @@ const VALID_STATUSES = new Set([ "refunded", "partially_refunded", "disputed", + "credited", + "refunded_and_credited", + "pending_refund", ]); const isValidStatus = (value: unknown): value is PaymentHistoryStatus => @@ -73,11 +83,16 @@ const normalize = (raw: unknown): PaymentHistoryEntry[] | undefined => { sessionId: item.sessionId, pricePaidCents: item.pricePaidCents, displayCredits: item.displayCredits, + details: + typeof item.details === "string" ? item.details : "Credit top-up", status: item.status, occurredAt: item.occurredAt, ...(typeof item.reversedPaidCents === "number" ? { reversedPaidCents: item.reversedPaidCents } : {}), + ...(typeof item.accountCreditedCents === "number" + ? { accountCreditedCents: item.accountCreditedCents } + : {}), ...(typeof item.receiptUrl === "string" && item.receiptUrl.length > 0 ? { receiptUrl: item.receiptUrl } : {}),