mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
fix: address dashboard and finance review findings
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
85c8b8f72a
commit
f63b9e3243
12 files changed
+145
-45
No files matched your search
@@ -0,0 +1,29 @@
|
||||
import { Command } from "commander";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerCostCommands } from "../commands/client/cost.js";
|
||||
|
||||
function program() {
|
||||
const result = new Command().exitOverride().configureOutput({ writeOut: () => {}, writeErr: () => {} });
|
||||
registerCostCommands(result);
|
||||
return result;
|
||||
}
|
||||
const context = ["--company-id", "test-company", "--api-base", "http://paperclip.test", "--api-key", "test-key", "--json"];
|
||||
afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); });
|
||||
describe("cost report date options", () => {
|
||||
it.each([["cost", "summary", "costs/summary"], ["finance", "events", "costs/finance-events"]])("forwards bounds and all-time for %s %s", async (group, name, path) => {
|
||||
const fetch = vi.fn().mockImplementation(() => Promise.resolve(Response.json({})));
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
await program().parseAsync([group, name, ...context, "--all-time"], { from: "user" });
|
||||
expect(fetch.mock.calls[0][0]).toBe(`http://paperclip.test/api/companies/test-company/${path}?period=all`);
|
||||
await program().parseAsync([group, name, ...context, "--from", "2026-09-01", "--to", "2026-09-02"], { from: "user" });
|
||||
expect(fetch.mock.calls[1][0]).toBe(`http://paperclip.test/api/companies/test-company/${path}?from=2026-09-01&to=2026-09-02`);
|
||||
});
|
||||
it.each([["accounting", "health"], ["accounting", "inspect"], ["accounting", "invoices"], ["budget", "overview"], ["cost", "window-spend"], ["cost", "quota-windows"]])("rejects unsupported date flags for %s %s", async (group, name) => {
|
||||
const fetch = vi.fn(); vi.stubGlobal("fetch", fetch);
|
||||
for (const args of [["--all-time"], ["--from", "2026-09-01"], ["--to", "2026-09-02"]]) {
|
||||
await expect(program().parseAsync([group, name, ...context, ...args], { from: "user" })).rejects.toThrow("unknown option");
|
||||
}
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -36,7 +36,7 @@ export function registerCostCommands(program: Command): void {
|
||||
["window-spend", "costs/window-spend"],
|
||||
["quota-windows", "costs/quota-windows"],
|
||||
] as const) {
|
||||
addCompanyGet(cost, name, `Get ${name} cost data`, path);
|
||||
addCompanyGet(cost, name, `Get ${name} cost data`, path, name !== "window-spend" && name !== "quota-windows");
|
||||
}
|
||||
|
||||
addCommonClientOptions(
|
||||
@@ -80,10 +80,10 @@ export function registerCostCommands(program: Command): void {
|
||||
|
||||
const finance = program.command("finance").description("Finance event and summary operations");
|
||||
addCompanyPostJson(finance, "event:create", "Record a finance event", "finance-events");
|
||||
addCompanyGet(finance, "events", "List finance events", "costs/finance-events");
|
||||
addCompanyGet(finance, "summary", "Get finance summary", "costs/finance-summary");
|
||||
addCompanyGet(finance, "by-biller", "Get finance summary by biller", "costs/finance-by-biller");
|
||||
addCompanyGet(finance, "by-kind", "Get finance summary by kind", "costs/finance-by-kind");
|
||||
addCompanyGet(finance, "events", "List finance events", "costs/finance-events", true);
|
||||
addCompanyGet(finance, "summary", "Get finance summary", "costs/finance-summary", true);
|
||||
addCompanyGet(finance, "by-biller", "Get finance summary by biller", "costs/finance-by-biller", true);
|
||||
addCompanyGet(finance, "by-kind", "Get finance summary by kind", "costs/finance-by-kind", true);
|
||||
|
||||
const budget = program.command("budget").description("Budget policy and incident operations");
|
||||
addCompanyGet(budget, "overview", "Get budget overview", "budgets/overview");
|
||||
@@ -147,16 +147,16 @@ export function registerCostCommands(program: Command): void {
|
||||
);
|
||||
}
|
||||
|
||||
function addCompanyGet(parent: Command, name: string, description: string, path: string): void {
|
||||
addCommonClientOptions(
|
||||
parent
|
||||
.command(name)
|
||||
.description(description)
|
||||
.option("-C, --company-id <id>", "Company ID")
|
||||
function addCompanyGet(parent: Command, name: string, description: string, path: string, dateRange = false): void {
|
||||
const command = parent.command(name).description(description).option("-C, --company-id <id>", "Company ID");
|
||||
if (dateRange) {
|
||||
command
|
||||
.option("--from <iso-date>", "Inclusive start date (default: current UTC month)")
|
||||
.option("--to <iso-date>", "Inclusive end date")
|
||||
.option("--all-time", "Report all recorded history")
|
||||
.action(async (opts: CompanyOptions) => {
|
||||
.option("--all-time", "Report all recorded history");
|
||||
}
|
||||
addCommonClientOptions(
|
||||
command.action(async (opts: CompanyOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
||||
const params = new URLSearchParams();
|
||||
|
||||
@@ -40,7 +40,7 @@ Reservations are estimates, not provider-enforced spending caps. Delayed receipt
|
||||
|
||||
## Dates, completeness, and UI
|
||||
|
||||
Cost and finance endpoints default to the current UTC month through now. Explicit `from`/`to` bounds remain inclusive; `period=all` requests all time. Reversed or malformed bounds are rejected. CLI cost/finance reads expose `--from`, `--to`, and `--all-time`. The board sends explicit ranges, and month/year-to-date starts use UTC. Rolling spend excludes future events.
|
||||
Cost and finance endpoints default to the current UTC month through now. Explicit `from`/`to` bounds remain inclusive; `period=all` requests all time. Reversed or malformed bounds are rejected. Date-filterable CLI cost/finance reports expose `--from`, `--to`, and `--all-time`. Operational accounting, budget, live quota and rolling-window commands reject these flags because their endpoints do not filter by arbitrary dates. The board sends explicit ranges, and month/year-to-date starts use UTC. Rolling spend excludes future events. The dashboard compares spend with the monthly cap only for Month to Date; historical ranges show the monthly limit without a utilization percentage. Provider report imports default to the previous complete UTC day, with an exclusive end date.
|
||||
|
||||
Cost summaries expose `eventCount`, `estimatedEventCount`, `unpricedEventCount`, `pendingRunCount`, and `pricingComplete`. Estimated charges count toward budgets, while the UI distinguishes estimates from provider-reported dollars. Agent totals and expanded model rows show **Estimated** when every charge in that group is estimated, or **Partially estimated** for a mix. Groups with no estimates have no estimate badge. These labels follow the selected date range and use ledger-event counts, not run counts; they do not infer pricing status from the provider or model name. `pricingComplete` means no missing prices or pending receipts; it does not mean invoice reconciliation has occurred. The Costs page warns when totals are incomplete. Provider cards include cached tokens and calculate subscription shares using each token once. Provider spend is compared with the real company budget, not a fabricated proportional allocation. Run displays prefer cache-adjusted prices and preserve explicit zeroes.
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ interface BillerSpendCardProps {
|
||||
row: CostByBiller;
|
||||
weekSpendCents: number;
|
||||
budgetMonthlyCents: number;
|
||||
/** Only compare a current-month report with the monthly budget. */
|
||||
showBudgetUtilization?: boolean;
|
||||
totalCompanySpendCents: number;
|
||||
providerRows: CostByProviderModel[];
|
||||
}
|
||||
@@ -16,6 +18,7 @@ export function BillerSpendCard({
|
||||
row,
|
||||
weekSpendCents,
|
||||
budgetMonthlyCents,
|
||||
showBudgetUtilization = true,
|
||||
totalCompanySpendCents,
|
||||
providerRows,
|
||||
}: BillerSpendCardProps) {
|
||||
@@ -44,10 +47,7 @@ export function BillerSpendCard({
|
||||
return Array.from(map.entries()).sort((a, b) => b[1] - a[1]);
|
||||
}, [providerRows]);
|
||||
|
||||
const providerBudgetShare =
|
||||
budgetMonthlyCents > 0 && totalCompanySpendCents > 0
|
||||
? (row.costCents / totalCompanySpendCents) * budgetMonthlyCents
|
||||
: budgetMonthlyCents;
|
||||
const providerBudgetShare = budgetMonthlyCents;
|
||||
const budgetPct =
|
||||
providerBudgetShare > 0
|
||||
? Math.min(100, (row.costCents / providerBudgetShare) * 100)
|
||||
@@ -78,12 +78,12 @@ export function BillerSpendCard({
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="px-4 pb-4 pt-3 space-y-4">
|
||||
{budgetMonthlyCents > 0 && (
|
||||
{showBudgetUtilization && budgetMonthlyCents > 0 && (
|
||||
<QuotaBar
|
||||
label="Period spend"
|
||||
percentUsed={budgetPct}
|
||||
leftLabel={formatCents(row.costCents)}
|
||||
rightLabel={`${Math.round(budgetPct)}% of allocation`}
|
||||
rightLabel={`${Math.round(budgetPct)}% of company budget`}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -199,6 +199,25 @@ describe("financial event entry", () => {
|
||||
expect(mocks.provider).toHaveBeenCalledWith("company-one", { provider: "anthropic", secretId, accountId: "org_example", scopeIds: ["workspace_one", "workspace_two"], from: "2026-09-01", to: "2026-09-02" });
|
||||
expect(document.querySelector("form")).toBeNull();
|
||||
});
|
||||
it("defaults provider reports to the previous complete UTC day and explains invalid ranges", async () => {
|
||||
const secretId = "00000000-0000-4000-8000-000000000001";
|
||||
mocks.secrets.mockResolvedValue([{ id: secretId, name: "Admin", scope: "company" }]);
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const yesterday = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10);
|
||||
await render(); await click("Record or import charges"); await click("Provider report");
|
||||
const dates = [...document.querySelectorAll<HTMLInputElement>('input[type="date"]')].map(input => input.value);
|
||||
expect(dates).toEqual([yesterday, today]);
|
||||
await fill("Company admin credential", secretId);
|
||||
await fill("Provider organization ID", "org_example");
|
||||
await fill("Project IDs", "project_one");
|
||||
await fill("From (UTC)", today);
|
||||
await submit();
|
||||
expect(mocks.provider).not.toHaveBeenCalled();
|
||||
expect(document.body.textContent).toContain("End date must be after start date (exclusive).");
|
||||
await fill("From (UTC)", yesterday);
|
||||
await submit();
|
||||
expect(mocks.provider).toHaveBeenCalledWith("company-one", expect.objectContaining({ from: yesterday, to: today }));
|
||||
});
|
||||
it("clears an open draft when changing company", async () => {
|
||||
await render();
|
||||
await click("Record or import charges");
|
||||
|
||||
@@ -41,6 +41,7 @@ function Entry({ companyId }: { companyId: string }) {
|
||||
const [secretId, setSecretId] = useState("");
|
||||
const [accountId, setAccountId] = useState("");
|
||||
const [scopes, setScopes] = useState("");
|
||||
const [providerFrom, setProviderFrom] = useState(() => new Date(Date.now() - 86_400_000).toISOString().slice(0, 10));
|
||||
const [endDate, setEndDate] = useState(new Date().toISOString().slice(0, 10));
|
||||
const secrets = useQuery({
|
||||
queryKey: ["billing-credentials", companyId],
|
||||
@@ -52,6 +53,10 @@ function Entry({ companyId }: { companyId: string }) {
|
||||
const action = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (mode === "provider") {
|
||||
if (providerFrom && endDate && providerFrom >= endDate) {
|
||||
setValidation("End date must be after start date (exclusive).");
|
||||
return false;
|
||||
}
|
||||
const input = importProviderCostsSchema.safeParse({
|
||||
provider,
|
||||
secretId,
|
||||
@@ -60,7 +65,7 @@ function Entry({ companyId }: { companyId: string }) {
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
from: date,
|
||||
from: providerFrom,
|
||||
to: endDate,
|
||||
});
|
||||
if (!input.success) {
|
||||
@@ -306,8 +311,8 @@ function Entry({ companyId }: { companyId: string }) {
|
||||
<span>From (UTC)</span>
|
||||
<Input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
value={providerFrom}
|
||||
onChange={(e) => setProviderFrom(e.target.value)}
|
||||
required
|
||||
disabled={action.isPending}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BillerSpendCard } from "./BillerSpendCard";
|
||||
import { ProviderQuotaCard } from "./ProviderQuotaCard";
|
||||
|
||||
describe("provider cost shares", () => {
|
||||
@@ -14,4 +15,21 @@ describe("provider cost shares", () => {
|
||||
expect(html).toContain("50% of company budget");
|
||||
expect(html).not.toContain("of allocation");
|
||||
});
|
||||
it("hides historical spend-to-monthly-budget comparisons for providers and billers", () => {
|
||||
const common = { budgetMonthlyCents: 1000, totalCompanySpendCents: 20000, weekSpendCents: 0, showBudgetUtilization: false };
|
||||
const provider = renderToStaticMarkup(<ProviderQuotaCard {...common} provider="openai" rows={[]} windowRows={[]} showDeficitNotch={false} />);
|
||||
const biller = renderToStaticMarkup(<BillerSpendCard {...common} providerRows={[]} row={{ biller: "openai", costCents: 20000, inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, apiRunCount: 0, subscriptionRunCount: 0, providerCount: 1, modelCount: 1, subscriptionInputTokens: 0, subscriptionCachedInputTokens: 0, subscriptionOutputTokens: 0 }} />);
|
||||
for (const html of [provider, biller]) {
|
||||
expect(html).not.toContain("Period spend");
|
||||
expect(html).not.toContain("of allocation");
|
||||
expect(html).not.toContain("of company budget");
|
||||
}
|
||||
});
|
||||
|
||||
it("compares biller spend with the actual company cap", () => {
|
||||
const html = renderToStaticMarkup(<BillerSpendCard budgetMonthlyCents={1000} totalCompanySpendCents={800} weekSpendCents={0} providerRows={[]} row={{ biller: "openai", costCents: 200, inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, apiRunCount: 0, subscriptionRunCount: 0, providerCount: 1, modelCount: 1, subscriptionInputTokens: 0, subscriptionCachedInputTokens: 0, subscriptionOutputTokens: 0 }} />);
|
||||
expect(html).toContain("20% of company budget");
|
||||
expect(html).not.toContain("80% of allocation");
|
||||
});
|
||||
|
||||
});
|
||||
@@ -23,6 +23,8 @@ interface ProviderQuotaCardProps {
|
||||
rows: CostByProviderModel[];
|
||||
/** company monthly budget in cents (0 means unlimited) */
|
||||
budgetMonthlyCents: number;
|
||||
/** Only compare a current-month report with the monthly budget. */
|
||||
showBudgetUtilization?: boolean;
|
||||
/** total company spend in this period in cents, all providers */
|
||||
totalCompanySpendCents: number;
|
||||
/** spend in the current calendar week in cents, this provider only */
|
||||
@@ -37,13 +39,13 @@ interface ProviderQuotaCardProps {
|
||||
quotaError?: string | null;
|
||||
quotaSource?: string | null;
|
||||
quotaLoading?: boolean;
|
||||
quotaAccounts?: ProviderQuotaResult[];
|
||||
}
|
||||
|
||||
export function ProviderQuotaCard({
|
||||
provider,
|
||||
rows,
|
||||
budgetMonthlyCents,
|
||||
showBudgetUtilization = true,
|
||||
totalCompanySpendCents,
|
||||
weekSpendCents,
|
||||
windowRows,
|
||||
@@ -54,7 +56,6 @@ export function ProviderQuotaCard({
|
||||
quotaError = null,
|
||||
quotaSource = null,
|
||||
quotaLoading = false,
|
||||
quotaAccounts,
|
||||
}: ProviderQuotaCardProps) {
|
||||
// single-pass aggregation over rows — memoized so the 8 derived values are not
|
||||
// recomputed on every parent render tick (providers tab polls every 30s, and each
|
||||
@@ -115,7 +116,7 @@ export function ProviderQuotaCard({
|
||||
const weekPct =
|
||||
weeklyBudgetShare > 0 ? Math.min(100, (weekSpendCents / weeklyBudgetShare) * 100) : 0;
|
||||
|
||||
const hasBudget = budgetMonthlyCents > 0;
|
||||
const hasBudget = showBudgetUtilization && budgetMonthlyCents > 0;
|
||||
|
||||
// memoized so the Map and max are not reconstructed on every parent render tick
|
||||
const windowMap = useMemo(
|
||||
|
||||
@@ -123,12 +123,30 @@ describe("Shared Costs surfaces", () => {
|
||||
await act(async () => { await vi.waitFor(() => expect(upsertPolicyMock).toHaveBeenCalled()); });
|
||||
expect(upsertPolicyMock).toHaveBeenCalledWith("company-1", {
|
||||
scopeType: summary.scopeType, scopeId: summary.scopeId, metric: summary.metric, windowKind: summary.windowKind,
|
||||
amount: field === "amount" ? 100 : 500, warnPercent: 65, hardStopEnabled: false, notifyEnabled: false, isActive: false,
|
||||
unpricedUsagePolicy: field === "unknown price" ? "block" : "allow", reservationCents: field === "reservation" ? "100.0000000" : "2.0000000",
|
||||
...(field === "amount" ? { amount: 100 } : field === "unknown price" ? { unpricedUsagePolicy: "block" } : { reservationCents: "100.0000000" }),
|
||||
});
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
it("compares monthly budgets only with month-to-date spend", async () => {
|
||||
for (const mock of Object.values(costsApiMocks)) mock.mockResolvedValue([]);
|
||||
costsApiMocks.summary.mockResolvedValue({ spendCents: 200, budgetCents: 1000, utilizationPercent: 20, pricingComplete: true });
|
||||
costsApiMocks.financeSummary.mockResolvedValue({ netCents: 0, debitCents: 0, creditCents: 0, estimatedDebitCents: 0, eventCount: 0 });
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
root = createRoot(container);
|
||||
await act(async () => root.render(<MemoryRouter><QueryClientProvider client={queryClient}><Costs /></QueryClientProvider></MemoryRouter>));
|
||||
await act(async () => { await vi.waitFor(() => expect(container.textContent).toContain("20% of monthly budget consumed this month")); });
|
||||
costsApiMocks.summary.mockResolvedValue({ spendCents: 12000, budgetCents: 1000, utilizationPercent: 1200, pricingComplete: true });
|
||||
await act(async () => [...container.querySelectorAll("button")].find(b => b.textContent === "All Time")!.click());
|
||||
await act(async () => { await vi.waitFor(() => expect(container.textContent).toContain("$120.00")); });
|
||||
expect(container.textContent).toContain("Monthly budget $10.00");
|
||||
expect(container.textContent).toContain("Monthly limit");
|
||||
expect(container.textContent).not.toContain("1200%");
|
||||
expect(container.textContent).not.toContain("of monthly budget consumed");
|
||||
expect(costsApiMocks.summary).toHaveBeenLastCalledWith("company-1", undefined, undefined);
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
it.each(surfaces)("shows incomplete accounting and currency boundaries on the %s page", async (_name, props) => {
|
||||
for (const mock of Object.values(costsApiMocks)) mock.mockResolvedValue([]);
|
||||
costsApiMocks.summary.mockResolvedValue({ spendCents: 12.4, budgetCents: 0, pricingComplete: false, unpricedEventCount: 2, pendingRunCount: 1 });
|
||||
|
||||
+13
-16
@@ -246,13 +246,6 @@ export function Costs({
|
||||
scopeId: summary.scopeId,
|
||||
metric: summary.metric,
|
||||
windowKind: summary.windowKind,
|
||||
amount: summary.amount,
|
||||
warnPercent: summary.warnPercent,
|
||||
hardStopEnabled: summary.hardStopEnabled,
|
||||
notifyEnabled: summary.notifyEnabled,
|
||||
isActive: summary.isActive,
|
||||
unpricedUsagePolicy: summary.unpricedUsagePolicy,
|
||||
reservationCents: summary.reservationCents,
|
||||
...changes,
|
||||
}),
|
||||
onSuccess: invalidateBudgetViews,
|
||||
@@ -651,14 +644,14 @@ export function Costs({
|
||||
label="Budget"
|
||||
value={activeBudgetIncidents.length > 0 ? String(activeBudgetIncidents.length) : (
|
||||
spendData?.summary.budgetCents && spendData.summary.budgetCents > 0
|
||||
? `${spendData.summary.utilizationPercent}%`
|
||||
? preset === "mtd" ? `${spendData.summary.utilizationPercent}%` : formatCents(spendData.summary.budgetCents)
|
||||
: "Open"
|
||||
)}
|
||||
subtitle={
|
||||
activeBudgetIncidents.length > 0
|
||||
? `${budgetData?.pausedAgentCount ?? 0} agents paused · ${budgetData?.pausedProjectCount ?? 0} projects paused`
|
||||
: spendData?.summary.budgetCents && spendData.summary.budgetCents > 0
|
||||
? `${formatCents(spendData.summary.spendCents)} of ${formatCents(spendData.summary.budgetCents)}`
|
||||
? preset === "mtd" ? `${formatCents(spendData.summary.spendCents)} of ${formatCents(spendData.summary.budgetCents)} this month` : "Monthly limit"
|
||||
: "No monthly cap configured"
|
||||
}
|
||||
icon={Coins}
|
||||
@@ -741,7 +734,7 @@ export function Costs({
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
{spendData?.summary.budgetCents && spendData.summary.budgetCents > 0
|
||||
? `Budget ${formatCents(spendData.summary.budgetCents)}`
|
||||
? `Monthly budget ${formatCents(spendData.summary.budgetCents)}`
|
||||
: "Unlimited budget"}
|
||||
</div>
|
||||
</div>
|
||||
@@ -752,7 +745,7 @@ export function Costs({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{spendData?.summary.budgetCents && spendData.summary.budgetCents > 0 ? (
|
||||
{preset === "mtd" && spendData?.summary.budgetCents && spendData.summary.budgetCents > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="h-2 overflow-hidden bg-muted">
|
||||
<div
|
||||
@@ -768,7 +761,7 @@ export function Costs({
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{spendData.summary.utilizationPercent}% of monthly budget consumed in this range.
|
||||
{spendData.summary.utilizationPercent}% of monthly budget consumed this month.
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -1049,13 +1042,14 @@ export function Costs({
|
||||
provider={provider}
|
||||
rows={byProvider.get(provider) ?? []}
|
||||
budgetMonthlyCents={spendData?.summary.budgetCents ?? 0}
|
||||
showBudgetUtilization={preset === "mtd"}
|
||||
totalCompanySpendCents={spendData?.summary.spendCents ?? 0}
|
||||
weekSpendCents={weekSpendByProvider.get(provider) ?? 0}
|
||||
windowRows={windowSpendByProvider.get(provider) ?? []}
|
||||
showDeficitNotch={deficitNotchByProvider.get(provider) ?? false}
|
||||
quotaAccounts={quotaData?.filter(account => account.provider === provider)}
|
||||
quotaRequestFailed={Boolean(quotaFetchError)}
|
||||
quotaWindows={quotaWindowsByProvider.get(provider) ?? []}
|
||||
quotaRequestFailed={Boolean(quotaFetchError)}
|
||||
quotaWindows={quotaWindowsByProvider.get(provider) ?? []}
|
||||
quotaError={quotaErrorsByProvider.get(provider) ?? null}
|
||||
quotaSource={quotaSourcesByProvider.get(provider) ?? null}
|
||||
quotaLoading={quotaLoading}
|
||||
@@ -1071,13 +1065,14 @@ export function Costs({
|
||||
provider={provider}
|
||||
rows={byProvider.get(provider) ?? []}
|
||||
budgetMonthlyCents={spendData?.summary.budgetCents ?? 0}
|
||||
showBudgetUtilization={preset === "mtd"}
|
||||
totalCompanySpendCents={spendData?.summary.spendCents ?? 0}
|
||||
weekSpendCents={weekSpendByProvider.get(provider) ?? 0}
|
||||
windowRows={windowSpendByProvider.get(provider) ?? []}
|
||||
showDeficitNotch={deficitNotchByProvider.get(provider) ?? false}
|
||||
quotaAccounts={quotaData?.filter(account => account.provider === provider)}
|
||||
quotaRequestFailed={Boolean(quotaFetchError)}
|
||||
quotaWindows={quotaWindowsByProvider.get(provider) ?? []}
|
||||
quotaRequestFailed={Boolean(quotaFetchError)}
|
||||
quotaWindows={quotaWindowsByProvider.get(provider) ?? []}
|
||||
quotaError={quotaErrorsByProvider.get(provider) ?? null}
|
||||
quotaSource={quotaSourcesByProvider.get(provider) ?? null}
|
||||
quotaLoading={quotaLoading}
|
||||
@@ -1112,6 +1107,7 @@ export function Costs({
|
||||
row={row}
|
||||
weekSpendCents={weekSpendByBiller.get(biller) ?? 0}
|
||||
budgetMonthlyCents={spendData?.summary.budgetCents ?? 0}
|
||||
showBudgetUtilization={preset === "mtd"}
|
||||
totalCompanySpendCents={spendData?.summary.spendCents ?? 0}
|
||||
providerRows={providerRows}
|
||||
/>
|
||||
@@ -1131,6 +1127,7 @@ export function Costs({
|
||||
row={row}
|
||||
weekSpendCents={weekSpendByBiller.get(biller) ?? 0}
|
||||
budgetMonthlyCents={spendData?.summary.budgetCents ?? 0}
|
||||
showBudgetUtilization={preset === "mtd"}
|
||||
totalCompanySpendCents={spendData?.summary.spendCents ?? 0}
|
||||
providerRows={providerRows}
|
||||
/>
|
||||
|
||||
@@ -30,4 +30,16 @@ describe("Costs story finance actions", () => {
|
||||
const summary = await (await fixture("costs/finance-summary", request("costs/finance-summary")))!.json();
|
||||
expect(summary).toMatchObject({ debitCents: 50, debitCentsExact: "50.0000000", providerReportedCents: 250, providerReportedCentsExact: "250.0000000", eventCount: 2 });
|
||||
});
|
||||
it("keeps timestamp bounds exact while date-only bounds include the full day", async () => {
|
||||
const fixture = createCostsFinanceFixtures("preview");
|
||||
for (const [hour, id] of [["12", "at-boundary"], ["18", "after-boundary"]]) {
|
||||
await fixture("finance-events", request("finance-events", { ...charge, idempotencyKey: id, occurredAt: `2026-09-01T${hour}:00:00Z` }));
|
||||
}
|
||||
const exact = await (await fixture("costs/finance-events", request("costs/finance-events?to=2026-09-01T12:00:00Z")))!.json();
|
||||
expect(exact).toHaveLength(1);
|
||||
expect(exact[0].occurredAt).toBe("2026-09-01T12:00:00.000Z");
|
||||
const wholeDay = await (await fixture("costs/finance-events", request("costs/finance-events?to=2026-09-01")))!.json();
|
||||
expect(wholeDay).toHaveLength(2);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -34,7 +34,8 @@ export function createCostsFinanceFixtures(companyId: string) {
|
||||
return async (resource: string, request: Request): Promise<Response | null> => {
|
||||
const url = new URL(request.url);
|
||||
const from = url.searchParams.get("from"); const to = url.searchParams.get("to");
|
||||
const visible = events.filter(row => (!from || row.occurredAt >= new Date(from)) && (!to || row.occurredAt <= new Date(`${to.slice(0, 10)}T23:59:59.999Z`)));
|
||||
const until = to ? new Date(/^\d{4}-\d{2}-\d{2}$/.test(to) ? `${to}T23:59:59.999Z` : to) : null;
|
||||
const visible = events.filter(row => (!from || row.occurredAt >= new Date(from)) && (!until || row.occurredAt <= until));
|
||||
if (resource === "costs/finance-summary") return Response.json({ companyId, ...summary(visible), eventCount: visible.length, currencies: [...new Set(visible.map(row => row.currency))].map(currency => summary(visible, currency)) });
|
||||
if (resource === "costs/finance-events") return Response.json(visible);
|
||||
if (resource === "costs/finance-by-biller" || resource === "costs/finance-by-kind") {
|
||||
|
||||
Reference in new issue
Block a user