Skip to content
Merged

refunds #2615

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down Expand Up @@ -110,6 +114,7 @@ function PaymentsTable({ entries }: { entries: PaymentHistoryEntry[] }) {
<TableHead>Date</TableHead>
<TableHead>Amount</TableHead>
<TableHead>Credits</TableHead>
<TableHead>Details</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Receipt</TableHead>
</TableRow>
Expand All @@ -126,6 +131,9 @@ function PaymentsTable({ entries }: { entries: PaymentHistoryEntry[] }) {
<TableCell className="whitespace-nowrap text-sm">
{entry.displayCredits}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{entry.details}
</TableCell>
<TableCell>
<StatusBadge entry={entry} />
</TableCell>
Expand Down Expand Up @@ -159,6 +167,7 @@ function MobileRow({ entry }: { entry: PaymentHistoryEntry }) {
<div className="text-xs text-muted-foreground">
{entry.displayCredits}
</div>
<div className="text-xs text-muted-foreground">{entry.details}</div>
<div className="flex items-center justify-between">
<StatusBadge entry={entry} />
<ReceiptCell entry={entry} />
Expand Down Expand Up @@ -191,11 +200,33 @@ function StatusBadge({ entry }: { entry: PaymentHistoryEntry }) {
</Badge>
);
}
if (status === "pending_refund") {
return (
<Badge
variant="outline"
className="border-amber-300 bg-amber-50 text-amber-900 dark:border-amber-900/50 dark:bg-amber-950/40 dark:text-amber-100"
>
<Clock aria-hidden="true" />
Refund pending
</Badge>
);
}
if (status === "credited" || status === "refunded_and_credited") {
return (
<Badge
variant="outline"
className="border-slate-300 bg-slate-50 text-slate-700 dark:border-slate-700/60 dark:bg-slate-900/40 dark:text-slate-200"
>
<Undo2 aria-hidden="true" />
{status === "credited" ? "Credited" : "Refunded + credited"}
</Badge>
);
}
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`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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: [
Expand Down
17 changes: 16 additions & 1 deletion mcpjam-inspector/client/src/hooks/usePaymentsHistory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -27,7 +32,9 @@ interface RawEntry {
sessionId?: unknown;
pricePaidCents?: unknown;
displayCredits?: unknown;
details?: unknown;
reversedPaidCents?: unknown;
accountCreditedCents?: unknown;
status?: unknown;
occurredAt?: unknown;
receiptUrl?: unknown;
Expand All @@ -40,6 +47,9 @@ const VALID_STATUSES = new Set<PaymentHistoryStatus>([
"refunded",
"partially_refunded",
"disputed",
"credited",
"refunded_and_credited",
"pending_refund",
]);

const isValidStatus = (value: unknown): value is PaymentHistoryStatus =>
Expand Down Expand Up @@ -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 }
: {}),
Expand Down
Loading