diff --git a/apps/api/src/routes/internal/vercel-cost.http.ts b/apps/api/src/routes/internal/vercel-cost.http.ts new file mode 100644 index 0000000000..330031b96b --- /dev/null +++ b/apps/api/src/routes/internal/vercel-cost.http.ts @@ -0,0 +1,62 @@ +import { HttpApiBuilder } from "effect/http-api" +import { + CurrentTenant, + IntegrationsForbiddenError, + MapleInternalApi, + VercelCostSourceAdded, + VercelCostSourceRemoved, + VercelCostStatus, +} from "@maple/domain/http" +import { Effect } from "effect" +import * as Policy from "@maple/domain/policy" +import { recordHttpAudit } from "@maple/backend/services/audit/AuditLogService" +import { VercelCostSourceService } from "@maple/backend/services/cost/VercelCostSourceService" + +// A cost source is a connection like any other: the role that manages integrations manages it. +const requireIntegrationsManager = (roles: CurrentTenant.TenantSchema["roles"]) => + Policy.check( + roles, + "integrations:manage", + (denial) => new IntegrationsForbiddenError({ message: denial.message }), + ) + +export const HttpVercelCostLive = HttpApiBuilder.group(MapleInternalApi, "vercelCost", (handlers) => + Effect.gen(function* () { + const sources = yield* VercelCostSourceService + + return handlers + .handle("status", () => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + const status = yield* sources.status(tenant.orgId) + return new VercelCostStatus(status) + }).pipe(Effect.withSpan("HttpVercelCost.status")), + ) + .handle("add", ({ payload }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + yield* requireIntegrationsManager(tenant.roles) + const added = yield* sources.add(tenant.orgId, payload.teamId) + // Adding what is a source already changed nothing, so it leaves no entry. + if (added.created) { + yield* recordHttpAudit("cost_source.added", { + metadata: { provider: "vercel", account: added.team.slug }, + }) + } + return new VercelCostSourceAdded({ team: added.team }) + }).pipe(Effect.withSpan("HttpVercelCost.add")), + ) + .handle("remove", () => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + yield* requireIntegrationsManager(tenant.roles) + const removed = yield* sources.remove(tenant.orgId) + // Removing what is not there changed nothing, so it leaves no entry. + if (removed.removed) { + yield* recordHttpAudit("cost_source.removed", { metadata: { provider: "vercel" } }) + } + return new VercelCostSourceRemoved(removed) + }).pipe(Effect.withSpan("HttpVercelCost.remove")), + ) + }), +) diff --git a/apps/api/src/runtime/http-graph.ts b/apps/api/src/runtime/http-graph.ts index 7f4669a5f0..bd4b53e9b8 100644 --- a/apps/api/src/runtime/http-graph.ts +++ b/apps/api/src/runtime/http-graph.ts @@ -14,6 +14,7 @@ import { HttpAiTriageLive } from "@/routes/internal/ai-triage.http" import { HttpOpenRouterCostLive } from "@/routes/internal/openrouter-cost.http" import { HttpOpenRouterKeyLive } from "@/routes/internal/openrouter-key.http" import { HttpServiceMapWorldLive } from "@/routes/internal/service-map-world.http" +import { HttpVercelCostLive } from "@/routes/internal/vercel-cost.http" import { HttpAuthLive, HttpAuthPublicLive, HttpAuthSessionLive } from "@/routes/auth.http" import { HttpBillingLive } from "@/routes/internal/billing.http" import { HttpBillingPublicLive } from "@/routes/billing-public.http" @@ -168,6 +169,7 @@ const ApiInternalRoutes = HttpApiBuilder.layer(MapleInternalApi).pipe( HttpOpenRouterCostLive, HttpOpenRouterKeyLive, HttpServiceMapWorldLive, + HttpVercelCostLive, ), ), Layer.provide( diff --git a/apps/api/src/runtime/service-graph.ts b/apps/api/src/runtime/service-graph.ts index 79a5af8d58..d1037acbdb 100644 --- a/apps/api/src/runtime/service-graph.ts +++ b/apps/api/src/runtime/service-graph.ts @@ -67,6 +67,7 @@ import { SupabaseInsightsService } from "@maple/backend/services/integrations/Su import { SupabaseLogsService } from "@maple/backend/services/integrations/SupabaseLogsService" import { SupabaseOAuthService } from "@maple/backend/services/auth/SupabaseOAuthService" import { OpenRouterCostConnectionService } from "@maple/backend/services/cost/OpenRouterCostConnectionService" +import { VercelCostSourceService } from "@maple/backend/services/cost/VercelCostSourceService" import { OrgOpenRouterKeyService } from "@maple/backend/services/org/OrgOpenRouterKeyService" import { ServiceMapWorldService } from "@maple/backend/services/service-map/ServiceMapWorldService" import { ScrapeTargetsService } from "@maple/backend/services/integrations/ScrapeTargetsService" @@ -188,6 +189,7 @@ export const HttpServicesLive = Layer.mergeAll( AwsIngestService.layer, OrgOpenRouterKeyService.layer, OpenRouterCostConnectionService.layer, + VercelCostSourceService.layer, ServiceMapWorldService.layer, AuditLogService.layer, WarehouseQueryService.layer, diff --git a/apps/web/src/lab/cost-fixture.test.ts b/apps/web/src/lab/cost-fixture.test.ts index 884a79b808..972cf1bbc0 100644 --- a/apps/web/src/lab/cost-fixture.test.ts +++ b/apps/web/src/lab/cost-fixture.test.ts @@ -1,6 +1,6 @@ import { Array as Arr } from "effect" import { describe, expect, test } from "vitest" -import { labBreakdown, labTimeseries, labTotals } from "./cost-fixture" +import { LAB_SOURCES, labBreakdown, labSourcesWith, labTimeseries, labTotals } from "./cost-fixture" const window = { days: 30, filters: undefined } const openRouterOnly = { days: 30, filters: { provider: ["openrouter"] } } @@ -8,31 +8,64 @@ const openRouterOnly = { days: 30, filters: { provider: ["openrouter"] } } describe("cost lab fixture", () => { test("a removed OpenRouter source leaves every read, also under a filter naming it", () => { const before = labTotals(window).cost - const after = labTotals({ ...window, withoutOpenRouter: true }).cost + const after = labTotals({ ...window, without: ["openrouter"] }).cost expect(after).toBeGreaterThan(0) expect(after).toBeLessThan(before) expect( Arr.map( - labBreakdown({ ...window, withoutOpenRouter: true, groupBy: "provider", sortBy: "cost" }) + labBreakdown({ ...window, without: ["openrouter"], groupBy: "provider", sortBy: "cost" }) .rows, (row) => row.key, ), - ).toEqual(["aws"]) + ).toEqual(["aws", "vercel"]) // Filtered to OpenRouter there is nothing left: AWS cost must not show under that filter. expect(labTotals(openRouterOnly).cost).toBeGreaterThan(0) - expect(labTotals({ ...openRouterOnly, withoutOpenRouter: true }).cost).toBe(0) + expect(labTotals({ ...openRouterOnly, without: ["openrouter"] }).cost).toBe(0) expect( labBreakdown({ ...openRouterOnly, - withoutOpenRouter: true, + without: ["openrouter"], groupBy: "cloud_service", sortBy: "cost", }).rows, ).toEqual([]) expect( - labTimeseries({ ...openRouterOnly, withoutOpenRouter: true, groupBy: "provider", limit: 8 }) + labTimeseries({ ...openRouterOnly, without: ["openrouter"], groupBy: "provider", limit: 8 }) .series, ).toEqual([]) }) + + test("a removed Vercel source leaves every read too, and the other providers stay", () => { + const removed = { ...window, without: ["vercel"] } + expect(labTotals(removed).cost).toBeLessThan(labTotals(window).cost) + expect( + Arr.map(labBreakdown({ ...removed, groupBy: "provider", sortBy: "cost" }).rows, (row) => row.key), + ).toEqual(["aws", "openrouter"]) + expect(labTotals({ days: 30, filters: { provider: ["vercel"] }, without: ["vercel"] }).cost).toBe(0) + }) + + test("a source added again after its removal is a new one that waits for its first sync", () => { + const statusOf = (response: typeof LAB_SOURCES.synced, provider: string) => + Arr.map( + Arr.filter(response.sources, (source) => source.provider === provider), + (source) => [source.status, source.watermarkDate], + ) + // As the state has it, then removed, then added: not the synced row it started with. + expect(statusOf(labSourcesWith(LAB_SOURCES.synced, "vercel", null), "vercel")).toEqual([ + ["ok", "2026-11-09"], + ]) + expect(statusOf(labSourcesWith(LAB_SOURCES.synced, "vercel", false), "vercel")).toEqual([]) + const readded = labSourcesWith(LAB_SOURCES.synced, "vercel", true) + expect(statusOf(readded, "vercel")).toEqual([["pending", null]]) + // The other sources are untouched. + expect(statusOf(readded, "aws")).toEqual([["ok", "2026-11-09"]]) + expect(readded.sources).toHaveLength(LAB_SOURCES.synced.sources.length) + }) + + test("grouped by resource, a Vercel team's cost splits by project and the rest has none", () => { + expect( + Arr.map(labBreakdown({ ...window, groupBy: "resource", sortBy: "cost" }).rows, (row) => row.key), + ).toEqual(["", "example-web", "example-docs"]) + }) }) diff --git a/apps/web/src/lab/cost-fixture.ts b/apps/web/src/lab/cost-fixture.ts index 4d520ac28b..1ca6b2a288 100644 --- a/apps/web/src/lab/cost-fixture.ts +++ b/apps/web/src/lab/cost-fixture.ts @@ -35,6 +35,7 @@ const line = ( service_category, region, usage_type, + resource: "", account: LAB_ACCOUNT, daily, drift, @@ -51,11 +52,39 @@ const modelLine = (model: string, servedBy: string, daily: number, drift: number service_category: "AI and Machine Learning", region: "global", usage_type: servedBy, + resource: "", account: LAB_OPENROUTER_ACCOUNT, daily, drift, }) +/** The Vercel team the lab's source reads, by its slug. */ +export const LAB_VERCEL_TEAM = { + id: "team_labExample000000000000", + name: "Example Team", + slug: "example-team", +} + +const productLine = ( + product: string, + category: string, + region: string, + sku: string, + project: string, + daily: number, + drift: number, +): Line => ({ + cloud_service: product, + provider: "vercel", + service_category: category, + region, + usage_type: sku, + resource: project, + account: LAB_VERCEL_TEAM.id, + daily, + drift, +}) + /** `daily` is the charge per day today; `drift` how much it grew or shrank over the last 30 days. */ const LINES: ReadonlyArray = [ line("Example Compute", "Compute", "north-1", "north-1:instance-hours", 61.2, 0.18), @@ -79,6 +108,28 @@ const LINES: ReadonlyArray = [ modelLine("example/large-model", "Example Cloud", 6.2, 0.1), modelLine("example/fast-model", "Example Inference", 12.9, -0.15), modelLine("example/embedding-model", "Example Labs", 1.8, 0.0), + productLine("Example Function Duration", "Compute", "iad1", "function-duration", "example-web", 9.4, 0.2), + productLine( + "Example Function Duration", + "Compute", + "fra1", + "function-duration", + "example-docs", + 3.1, + 0.05, + ), + productLine("Example Data Transfer", "Networking", "global", "data-transfer", "example-web", 6.7, 0.12), + productLine( + "Example Image Optimization", + "Web", + "global", + "image-optimization", + "example-docs", + 2.2, + -0.08, + ), + // The plan's monthly fee, as Vercel spreads it over the days: it belongs to no project. + productLine("Example Pro Plan", "Other", "global", "", "", 0.67, 0.0), ] /** The day `offset` days before the lab's today, as `YYYY-MM-DD`. */ @@ -94,7 +145,15 @@ const chargeAt = (entry: Line, index: number, offset: number): number => { const matches = (entry: Line, filters: CostFilters | undefined): boolean => Arr.every( - ["cloud_service", "provider", "service_category", "region", "usage_type", "account"] as const, + [ + "cloud_service", + "provider", + "service_category", + "region", + "usage_type", + "resource", + "account", + ] as const, (dimension) => { const values = filters?.[dimension] ?? [] return values.length === 0 || Arr.contains(values, entry[dimension]) @@ -104,15 +163,14 @@ const matches = (entry: Line, filters: CostFilters | undefined): boolean => type Request = Readonly<{ days: number filters: CostFilters | undefined - /** The lab's reader removed OpenRouter: its lines are in no read, whatever the filters say. */ - withoutOpenRouter?: boolean + /** Providers whose source the lab's reader removed: their lines are in no read, whatever the filters say. */ + without?: ReadonlyArray }> /** Each matching line with its charge on every day of the window (oldest first) and of the period before. */ const windowOf = (request: Request) => Arr.flatMap(LINES, (entry, index) => - matches(entry, request.filters) && - !(request.withoutOpenRouter === true && entry.provider === "openrouter") + matches(entry, request.filters) && !Arr.contains(request.without ?? [], entry.provider) ? [ { entry, @@ -248,36 +306,50 @@ const openRouterSource = source({ requestsThisMonth: 0, }) +/** The Vercel source: eleven months of history, all one request serves, and no billed requests. */ +const vercelSource = source({ + id: "0190f2b3-0000-7000-8000-00000000c061", + provider: "vercel", + accountId: LAB_VERCEL_TEAM.id, + accountName: LAB_VERCEL_TEAM.slug, + backfillDate: "2025-12-01", + lastSyncedAt: LAB_NOW_MS - 2 * 3_600_000, + requestsThisMonth: 0, +}) + +/** The sources the lab's reader can add and remove on the page, as they are once synced. */ +const ADDABLE = { openrouter: openRouterSource, vercel: vercelSource } as const +type LabAddable = keyof typeof ADDABLE + /** - * `sources` after the lab's reader added OpenRouter (true: it waits for its first sync) or removed - * it (false); null leaves them as the state has them. + * `sources` after the lab's reader added a provider's source (true: a new one, which waits for + * its first sync whatever the state had) or removed it (false); null leaves it as the state has it. */ export const labSourcesWith = ( sources: CostSourcesResponse, - openRouter: boolean | null, + provider: LabAddable, + added: boolean | null, ): CostSourcesResponse => { - if (openRouter === null) return sources - const others = Arr.filter(sources.sources, (entry) => entry.provider !== "openrouter") - if (!openRouter) return new CostSourcesResponse({ sources: others }) - return others.length === sources.sources.length - ? decodeSources({ - sources: [ - ...Schema.encodeSync(CostSourcesResponse)(sources).sources, - { - ...openRouterSource, - status: "pending", - watermarkDate: null, - backfillDate: null, - historyComplete: false, - lastSyncedAt: null, - }, - ], - }) - : sources + if (added === null) return sources + const others = Arr.filter(sources.sources, (entry) => entry.provider !== provider) + if (!added) return new CostSourcesResponse({ sources: others }) + return decodeSources({ + sources: [ + ...Schema.encodeSync(CostSourcesResponse)(new CostSourcesResponse({ sources: others })).sources, + { + ...ADDABLE[provider], + status: "pending", + watermarkDate: null, + backfillDate: null, + historyComplete: false, + lastSyncedAt: null, + }, + ], + }) } export const LAB_SOURCES = { - synced: decodeSources({ sources: [source({}), openRouterSource] }), + synced: decodeSources({ sources: [source({}), openRouterSource, vercelSource] }), none: decodeSources({ sources: [] }), pending: decodeSources({ sources: [ @@ -315,6 +387,7 @@ export const LAB_SOURCES = { requestsThisMonth: 0, }), openRouterSource, + vercelSource, ], }), } as const diff --git a/apps/web/src/lab/cost-lab.ts b/apps/web/src/lab/cost-lab.ts index 0838405634..c7c85ff852 100644 --- a/apps/web/src/lab/cost-lab.ts +++ b/apps/web/src/lab/cost-lab.ts @@ -1,4 +1,10 @@ -import { OpenRouterCostKeyRemoved, OpenRouterCostKeySaved } from "@maple/domain/http" +import { + OpenRouterCostKeyRemoved, + OpenRouterCostKeySaved, + VercelCostSourceAdded, + VercelCostSourceRemoved, + VercelCostStatus, +} from "@maple/domain/http" import * as Policy from "@maple/domain/policy" import { toLoadFailure } from "@maple/views/data/load-failure" import * as Field from "@maple/views/ui/field" @@ -8,6 +14,7 @@ import type { Html, HtmlBuilder } from "foldkit/html" import { defineMessageUnion } from "foldkit/message" import { fromString } from "foldkit/url" import { + AddVercelCostSource, BreakdownInput, FacetsInput, FetchCostBreakdown, @@ -15,8 +22,10 @@ import { FetchCostSources, FetchCostTimeseries, FetchCostTotals, + FetchVercelCostStatus, ReadCostGrants, RemoveOpenRouterCostKey, + RemoveVercelCostSource, ResolveWindow, SaveOpenRouterCostKey, TimeseriesInput, @@ -34,6 +43,7 @@ import { LAB_OPENROUTER_KEY, LAB_SOURCES, LAB_TODAY, + LAB_VERCEL_TEAM, labBreakdown, labSourcesWith, labTimeseries, @@ -55,11 +65,16 @@ export const LabState = Schema.Literals([ "empty-window", "loading", "by-provider", + "by-resource", "add-source", "add-openrouter", "key-refused", "replace-key", "remove-source", + "add-vercel", + "vercel-not-connected", + "vercel-refused", + "remove-vercel", "member", "member-onramp", ]) @@ -79,6 +94,8 @@ export const Model = Schema.Struct({ state: LabState, /** OpenRouter added (true) or removed (false) since the state loaded; null before either. */ openRouter: Schema.NullOr(Schema.Boolean), + /** The same for Vercel. */ + vercel: Schema.NullOr(Schema.Boolean), cost: CostModel, }) export type Model = typeof Model.Type @@ -87,8 +104,13 @@ export const Message = defineMessageUnion({ GotCostMessage: { message: CostMessa export type Message = typeof Message.Type type Return = Update.Return -/** What the lab answers from: the state it loaded in, and what the reader did to OpenRouter since. */ -type World = Readonly<{ state: LabState; openRouter: boolean | null }> +/** + * What the reader did to each source since the state loaded: added it (true: a new source, with + * no cost until a sync the lab does not model) or removed it (false); null before either. + */ +type Changes = Readonly<{ openRouter: boolean | null; vercel: boolean | null }> +/** What the lab answers from: the state it loaded in, and what the reader did to its sources since. */ +type World = Readonly<{ state: LabState }> & Changes type CostCommand = Command.Command const searchFor = (state: LabState): Search => @@ -96,7 +118,9 @@ const searchFor = (state: LabState): Search => ? { groupBy: "region", cloud_service: ["Example Compute"] } : state === "by-provider" ? { groupBy: "provider" } - : {} + : state === "by-resource" + ? { groupBy: "resource" } + : {} /** The states that start with nothing connected, so the dialog offers every provider. */ const startsEmpty = (state: LabState): boolean => @@ -104,7 +128,10 @@ const startsEmpty = (state: LabState): boolean => state === "member-onramp" || state === "add-source" || state === "add-openrouter" || - state === "key-refused" + state === "key-refused" || + state === "add-vercel" || + state === "vercel-not-connected" || + state === "vercel-refused" /** What the reader did before the state's screenshot: the clicks that open its dialog. */ const openingOf = (state: LabState): ReadonlyArray => { @@ -125,7 +152,18 @@ const openingOf = (state: LabState): ReadonlyArray => { case "replace-key": return [CostMessage.ClickedReplaceKey()] case "remove-source": - return [CostMessage.ClickedRemoveSource()] + return [CostMessage.ClickedRemoveSource({ provider: "openrouter" })] + case "add-vercel": + case "vercel-not-connected": + return [CostMessage.ClickedAddSource(), CostMessage.ClickedAddVercel()] + case "vercel-refused": + return [ + CostMessage.ClickedAddSource(), + CostMessage.ClickedAddVercel(), + CostMessage.ClickedConfirmAddVercel(), + ] + case "remove-vercel": + return [CostMessage.ClickedRemoveSource({ provider: "vercel" })] // Without Clerk the page starts with every permission: the member states take them away. case "member": case "member-onramp": @@ -141,6 +179,24 @@ const refusedKey = { "This is a regular API key. Reading spend needs a management key, which you create under Settings, Management keys in OpenRouter.", } +const refusedVercel = { + ...toLoadFailure("refused"), + message: + "This Vercel token's role cannot read the team's billing. Use a token from an Owner, Member, Developer, Security, Billing or Enterprise Viewer role.", +} + +/** Whether the state starts with a source of the provider. */ +const stateHas = (state: LabState, provider: "openrouter" | "vercel"): boolean => + Arr.some(sourcesFor(state).sources, (source) => source.provider === provider) + +/** The org's Vercel connection as the state has it, and whether the page's sources hold its team. */ +const vercelStatusOf = (world: World): VercelCostStatus => + new VercelCostStatus({ + team: world.state === "vercel-not-connected" ? null : LAB_VERCEL_TEAM, + authFailed: false, + added: world.vercel ?? stateHas(world.state, "vercel"), + }) + /** The days a preset spans, for the fixture's window; a custom range reads as the default. */ const DAYS = new Map([ ["1w", 7], @@ -191,14 +247,23 @@ const answerOf = (world: World, search: Search, command: CostCommand): Option.Op // An empty window: the sources have synced, and nothing matches. const filtersOf = (filters: WindowInput["filters"]) => state === "empty-window" ? { account: ["000000000000"] } : filters - // A removed source's cost is not read any more, under any filter. - const withoutOpenRouter = world.openRouter === false + // A removed source's cost is not read any more, under any filter, and one added since has none yet. + const without = [ + ...(world.openRouter === null ? [] : ["openrouter"]), + ...(world.vercel === null ? [] : ["vercel"]), + ] switch (command.name) { case FetchCostSources.name: return Option.map(keyArgs(command.args), ({ key }) => CostMessage.CompletedLoadSources({ key, - result: Result.succeed(labSourcesWith(sourcesFor(state), world.openRouter)), + result: Result.succeed( + labSourcesWith( + labSourcesWith(sourcesFor(state), "openrouter", world.openRouter), + "vercel", + world.vercel, + ), + ), }), ) case FetchCostTotals.name: @@ -206,7 +271,7 @@ const answerOf = (world: World, search: Search, command: CostCommand): Option.Op CostMessage.CompletedLoadTotals({ key, result: settled(state, () => - labTotals({ days, filters: filtersOf(input.filters), withoutOpenRouter }), + labTotals({ days, filters: filtersOf(input.filters), without }), ), }), ) @@ -218,7 +283,7 @@ const answerOf = (world: World, search: Search, command: CostCommand): Option.Op labTimeseries({ days, filters: filtersOf(input.filters), - withoutOpenRouter, + without, groupBy: input.groupBy, limit: input.limit, }), @@ -233,7 +298,7 @@ const answerOf = (world: World, search: Search, command: CostCommand): Option.Op labBreakdown({ days, filters: filtersOf(input.filters), - withoutOpenRouter, + without, groupBy: input.groupBy, sortBy: input.sortBy, }), @@ -250,7 +315,7 @@ const answerOf = (world: World, search: Search, command: CostCommand): Option.Op labBreakdown({ days, filters: filtersOf(input.filters[groupBy]), - withoutOpenRouter, + without, groupBy, sortBy: "cost", }).rows, @@ -279,22 +344,56 @@ const answerOf = (world: World, search: Search, command: CostCommand): Option.Op result: Result.succeed(new OpenRouterCostKeyRemoved({ removed: true })), }), ) + case FetchVercelCostStatus.name: + return Option.map(keyArgs(command.args), ({ key }) => + CostMessage.CompletedLoadVercelStatus({ key, result: Result.succeed(vercelStatusOf(world)) }), + ) + case AddVercelCostSource.name: + return Option.some( + CostMessage.CompletedAddVercel({ + result: + state === "vercel-refused" + ? Result.fail(refusedVercel) + : Result.succeed(new VercelCostSourceAdded({ team: LAB_VERCEL_TEAM })), + }), + ) + case RemoveVercelCostSource.name: + return Option.some( + CostMessage.CompletedRemoveVercel({ + result: Result.succeed(new VercelCostSourceRemoved({ removed: true })), + }), + ) default: return Option.none() } } type Step = Readonly<{ model: CostModel; commands: ReadonlyArray }> -type Settled = Step & Pick +type Settled = Step & Changes -/** A saved key adds OpenRouter to the sources the lab answers with next, a removal takes it out. */ -const openRouterAfter = (openRouter: boolean | null, answers: ReadonlyArray) => - Arr.reduce(answers, openRouter, (current, answer) => - answer._tag === "CompletedSaveKey" && Result.isSuccess(answer.result) - ? true - : answer._tag === "CompletedRemoveKey" && Result.isSuccess(answer.result) - ? false - : current, +/** A saved key or an added team is a new source in what the lab answers next, a removal takes it out. */ +const changesAfter = (world: World, answers: ReadonlyArray): Changes => + Arr.reduce( + answers, + { openRouter: world.openRouter, vercel: world.vercel }, + (current, answer): Changes => { + switch (answer._tag) { + // A key saved while the source exists replaces its key: the source stays as it is. + case "CompletedSaveKey": + return Result.isSuccess(answer.result) && + !(current.openRouter ?? stateHas(world.state, "openrouter")) + ? { ...current, openRouter: true } + : current + case "CompletedRemoveKey": + return Result.isSuccess(answer.result) ? { ...current, openRouter: false } : current + case "CompletedAddVercel": + return Result.isSuccess(answer.result) ? { ...current, vercel: true } : current + case "CompletedRemoveVercel": + return Result.isSuccess(answer.result) ? { ...current, vercel: false } : current + default: + return current + } + }, ) /** @@ -306,7 +405,9 @@ const settle = (world: World, step: Step): Settled => { Option.toArray(answerOf(world, step.model.search, command)), ) const kept = keepLocal(step.commands) - if (answers.length === 0) return { model: step.model, commands: kept, openRouter: world.openRouter } + if (answers.length === 0) { + return { model: step.model, commands: kept, openRouter: world.openRouter, vercel: world.vercel } + } const next = Arr.reduce( answers, { model: step.model, commands: Arr.empty() }, @@ -315,7 +416,7 @@ const settle = (world: World, step: Step): Settled => { return { model: result.model, commands: [...acc.commands, ...(result.commands ?? [])] } }, ) - const rest = settle({ ...world, openRouter: openRouterAfter(world.openRouter, answers) }, next) + const rest = settle({ ...world, ...changesAfter(world, answers) }, next) return { ...rest, commands: [...kept, ...rest.commands] } } @@ -334,18 +435,23 @@ const searchOfUrl = (url: string): Search => export const init = (flags: Flags): Return => { const entered = Cost.init({ search: searchFor(flags.state) }, LAB_CONTEXT) - const world: World = { state: flags.state, openRouter: null } + const world: World = { state: flags.state, openRouter: null, vercel: null } const loaded = settle(world, { model: entered.model, commands: entered.commands ?? [] }) const ready = Arr.reduce(openingOf(flags.state), loaded, (step, message) => { const next = Cost.update(step.model, message, LAB_CONTEXT) const settled = settle( - { ...world, openRouter: step.openRouter }, + { ...world, openRouter: step.openRouter, vercel: step.vercel }, { model: next.model, commands: next.commands ?? [] }, ) return { ...settled, commands: [...step.commands, ...settled.commands] } }) return { - model: { state: flags.state, openRouter: ready.openRouter, cost: ready.model }, + model: { + state: flags.state, + openRouter: ready.openRouter, + vercel: ready.vercel, + cost: ready.model, + }, commands: lift(ready.commands), } } @@ -360,11 +466,11 @@ export const update = (model: Model, message: Message): Return => ? Cost.routeChanged(step.model, { search: searchOfUrl(out.url) }, LAB_CONTEXT) : { model: step.model } const ready = settle( - { state: model.state, openRouter: model.openRouter }, + { state: model.state, openRouter: model.openRouter, vercel: model.vercel }, { model: moved.model, commands: [...(step.commands ?? []), ...(moved.commands ?? [])] }, ) return { - model: { ...model, openRouter: ready.openRouter, cost: ready.model }, + model: { ...model, openRouter: ready.openRouter, vercel: ready.vercel, cost: ready.model }, commands: lift(ready.commands), } }, diff --git a/apps/web/src/page/cost/command.ts b/apps/web/src/page/cost/command.ts index d2bc34c558..2afe7e22c8 100644 --- a/apps/web/src/page/cost/command.ts +++ b/apps/web/src/page/cost/command.ts @@ -1,4 +1,9 @@ -import { CostBreakdownSortBy, CostFilters, OpenRouterKeyUpdateRequest } from "@maple/domain/http" +import { + CostBreakdownSortBy, + CostFilters, + OpenRouterKeyUpdateRequest, + VercelCostAddRequest, +} from "@maple/domain/http" import { reloadSpin } from "@maple/views/data/timing" import { settleTo } from "@maple/views/data/load-failure" import { @@ -144,3 +149,31 @@ export const RemoveOpenRouterCostKey = Command.define("RemoveOpenRouterCostKey", settleTo((result) => Message.CompletedRemoveKey({ result })), ), }) + +/** Whether the org has a Vercel team connected, and whether it is a cost source already. */ +export const FetchVercelCostStatus = Command.define("FetchVercelCostStatus", { + args: { key: Schema.String }, + messages: [Message.CompletedLoadVercelStatus], + execute: ({ key }) => + Effect.flatMap(MapleInternalApiClient, (client) => client.vercelCost.status()).pipe( + settleTo((result) => Message.CompletedLoadVercelStatus({ key, result })), + ), +}) + +/** Makes the connected Vercel team a cost source, once Vercel let its token read the billing. */ +export const AddVercelCostSource = Command.define("AddVercelCostSource", { + // The team the dialog showed: the API refuses the add when another one is connected by now. + args: { teamId: Schema.String }, + messages: [Message.CompletedAddVercel], + execute: ({ teamId }) => + Effect.flatMap(MapleInternalApiClient, (client) => + client.vercelCost.add({ payload: new VercelCostAddRequest({ teamId }) }), + ).pipe(settleTo((result) => Message.CompletedAddVercel({ result }))), +}) + +export const RemoveVercelCostSource = Command.define("RemoveVercelCostSource", { + messages: [Message.CompletedRemoveVercel], + execute: Effect.flatMap(MapleInternalApiClient, (client) => client.vercelCost.remove()).pipe( + settleTo((result) => Message.CompletedRemoveVercel({ result })), + ), +}) diff --git a/apps/web/src/page/cost/cost.scene.test.ts b/apps/web/src/page/cost/cost.scene.test.ts index 84eb78769d..1cc8248c72 100644 --- a/apps/web/src/page/cost/cost.scene.test.ts +++ b/apps/web/src/page/cost/cost.scene.test.ts @@ -7,6 +7,9 @@ import { CostTotalsResponse, OpenRouterCostKeyRemoved, OpenRouterCostKeySaved, + VercelCostSourceAdded, + VercelCostSourceRemoved, + VercelCostStatus, } from "@maple/domain/http" import * as Policy from "@maple/domain/policy" import { Option, Record as Rec, Result, Schema, Array as Arr, String as Str } from "effect" @@ -30,6 +33,7 @@ import { PageOutMessage, type PageContext } from "../contract" import * as TimeRangePicker from "../kit/time-range-picker" import { chartRows, + perDimension, isUnderADay, keyLabel, previousPeriodOf, @@ -37,7 +41,7 @@ import { seriesLabels, withFilter, } from "./derive" -import { Message } from "./message" +import { Message, type RemovableProvider } from "./message" import type { Model, Search } from "./model" import { contextChanged, init, routeChanged, update } from "./update" import { view as pageView } from "./view" @@ -173,7 +177,7 @@ describe("cost page", () => { ) }) - test("adding a source offers AWS through its connect flow and OpenRouter through a key form", () => { + test("adding a source offers AWS through its connect flow, OpenRouter through a key form and Vercel through its team", () => { const opened = pageUpdate(entered({}, []).model, Message.ClickedAddSource()).model scene( { update: pageUpdate, view }, @@ -183,7 +187,8 @@ describe("cost page", () => { expectView(text("Add a cost source")).toExist(), expectView(role("link", { name: /^AWS/ })).toHaveAttr("href", "/sources/new?integration=aws"), expectView(text(/AWS bills USD.0\.01 for each request Maple makes/)).toExist(), - expectView(text(/OpenRouter requests are free/)).toExist(), + expectView(text(/OpenRouter and Vercel requests are free/)).toExist(), + expectView(role("button", { name: /^Vercel/ })).toExist(), // An account already connected turns its Cost module on from the AWS integration. expectView( role("link", { name: "Turn on Cost for an AWS account that is connected" }), @@ -306,12 +311,12 @@ describe("cost page", () => { // Request billing is AWS's: one line for the AWS source, none for OpenRouter. expectView(text("86 provider requests this month (USD 0.86)")).toExist(), expectView(text(/0 provider requests/)).not.toExist(), - expectView(role("link", { name: "Manage connection" })).toHaveAttr( + expectView(role("link", { name: "Manage AWS connection" })).toHaveAttr( "href", "/integrations?integration=aws", ), - expectView(role("button", { name: "Replace key" })).toExist(), - expectView(role("button", { name: "Remove" })).toExist(), + expectView(role("button", { name: "Replace OpenRouter key" })).toExist(), + expectView(role("button", { name: "Remove OpenRouter source" })).toExist(), ) // Replace opens the key form with no providers to go back to. @@ -334,7 +339,7 @@ describe("cost page", () => { expectView(role("button", { name: /^OpenRouter/ })).not.toExist(), ) - const confirming = pageUpdate(model, Message.ClickedRemoveSource()).model + const confirming = pageUpdate(model, Message.ClickedRemoveSource({ provider: "openrouter" })).model expect(confirming.removeDialog.isOpen).toBe(true) const removing = pageUpdate(confirming, Message.ClickedConfirmRemove()) expect(names(removing.commands)).toEqual(["RemoveOpenRouterCostKey"]) @@ -361,7 +366,7 @@ describe("cost page", () => { resolveTheme, dialogMounted, expectView(text("user_2openrouterAccount")).not.toExist(), - expectView(role("button", { name: "Replace key" })).not.toExist(), + expectView(role("button", { name: "Replace OpenRouter key" })).not.toExist(), expectView(role("button", { name: "Add source" })).toBeDisabled(), ) // That read failing brings no action back on the removed source: the list keeps without it. @@ -378,7 +383,7 @@ describe("cost page", () => { resolveTheme, dialogMounted, expectView(text("123456789012")).toExist(), - expectView(role("button", { name: "Replace key" })).not.toExist(), + expectView(role("button", { name: "Replace OpenRouter key" })).not.toExist(), expectView(role("button", { name: "Add source" })).toBeEnabled(), ) @@ -426,6 +431,603 @@ describe("cost page", () => { ) }) + const vercelTeam = { id: "team_1", name: "Acme Inc", slug: "acme" } + const vercelStatus = (over: Partial = {}) => + new VercelCostStatus({ team: vercelTeam, authFailed: false, added: false, ...over }) + + /** The add dialog turned to Vercel, with the read of the org's connection in flight. */ + const vercelStepOpened = () => + pageUpdate( + pageUpdate(entered({}, []).model, Message.ClickedAddSource()).model, + Message.ClickedAddVercel(), + ) + + /** The Vercel step once the connection's read answered `status`. */ + const vercelStepWith = (status: VercelCostStatus): Model => { + const opened = vercelStepOpened().model + return pageUpdate( + opened, + Message.CompletedLoadVercelStatus({ + key: opened.vercel.key ?? "", + result: Result.succeed(status), + }), + ).model + } + + test("the Vercel step reads the org's connection and adds its team with one click", () => { + const opened = vercelStepOpened() + expect(names(opened.commands)).toEqual(["FetchVercelCostStatus"]) + scene( + { update: pageUpdate, view }, + given(opened.model), + resolveTheme, + dialogMounted, + expectView(text("Add Vercel")).toExist(), + expectView(text("Reading the Vercel connection")).toExist(), + // Nothing to add until the connection is known. + expectView(role("button", { name: "Add source" })).not.toExist(), + ) + expect(names(pageUpdate(opened.model, Message.ClickedConfirmAddVercel()).commands)).toEqual([]) + + const connected = vercelStepWith(vercelStatus()) + scene( + { update: pageUpdate, view }, + given(connected), + resolveTheme, + dialogMounted, + expectView(text("Acme Inc")).toExist(), + expectView(text("acme")).toExist(), + expectView( + text(/Owner, Member, Developer, Security, Billing or Enterprise Viewer role/), + ).toExist(), + expectView(role("button", { name: "Add source" })).toBeEnabled(), + expectView(role("button", { name: "Back" })).toBeEnabled(), + ) + const adding = pageUpdate(connected, Message.ClickedConfirmAddVercel()) + // The add names the team on show: another one connected since is refused, not added. + expect(adding.commands).toMatchObject([{ name: "AddVercelCostSource", args: { teamId: "team_1" } }]) + // A second click while the first is in flight sends nothing more, and Back waits. + expect(names(pageUpdate(adding.model, Message.ClickedConfirmAddVercel()).commands)).toEqual([]) + expect(pageUpdate(adding.model, Message.ClickedBackToProviders()).model.addStep).toBe("vercel") + + const added = pageUpdate( + adding.model, + Message.CompletedAddVercel({ + result: Result.succeed(new VercelCostSourceAdded({ team: vercelTeam })), + }), + ) + expect(added.model.addDialog.isOpen).toBe(false) + expect(names(added.commands)).toContain("FetchCostSources") + expect(added.outMessage).toEqual( + PageOutMessage.RequestedToast({ + type: "success", + title: "Vercel added", + description: "The first sync runs within a few minutes.", + }), + ) + }) + + test("an org with no Vercel team is sent to connect one, and a rejected token to its connection", () => { + const notConnected = vercelStepWith(vercelStatus({ team: null })) + scene( + { update: pageUpdate, view }, + given(notConnected), + resolveTheme, + dialogMounted, + expectView(text(/No Vercel team is connected yet/)).toExist(), + expectView(role("link", { name: "Connect Vercel" })).toHaveAttr( + "href", + "/sources/new?integration=vercel", + ), + expectView(role("button", { name: "Add source" })).not.toExist(), + ) + // Nothing connected is nothing to add, however the message arrives. + expect(names(pageUpdate(notConnected, Message.ClickedConfirmAddVercel()).commands)).toEqual([]) + + const rejected = vercelStepWith(vercelStatus({ authFailed: true })) + scene( + { update: pageUpdate, view }, + given(rejected), + resolveTheme, + dialogMounted, + expectView(text("Acme Inc")).toExist(), + expectView(text(/Vercel rejected this team's token/)).toExist(), + expectView(role("link", { name: "Open the Vercel connection" })).toHaveAttr( + "href", + "/integrations?integration=vercel", + ), + expectView(role("button", { name: "Add source" })).not.toExist(), + ) + expect(names(pageUpdate(rejected, Message.ClickedConfirmAddVercel()).commands)).toEqual([]) + + // A team that is a source already and has lost its token: the dialog says how to repair it. + scene( + { update: pageUpdate, view }, + given(vercelStepWith(vercelStatus({ added: true, authFailed: true }))), + resolveTheme, + dialogMounted, + expectView(text("Already added")).toExist(), + expectView(text(/its cost has stopped syncing/)).toExist(), + expectView(role("link", { name: "Open the Vercel connection" })).toHaveAttr( + "href", + "/integrations?integration=vercel", + ), + expectView(role("button", { name: "Add source" })).not.toExist(), + ) + + // Added by someone else since the page's list was read: said, with nothing to add twice. + scene( + { update: pageUpdate, view }, + given(vercelStepWith(vercelStatus({ added: true }))), + resolveTheme, + dialogMounted, + expectView(text("Already added")).toExist(), + expectView(role("button", { name: "Add source" })).not.toExist(), + ) + }) + + test("an add Vercel refuses stays in the dialog with the reason, until the step is left", () => { + const adding = pageUpdate(vercelStepWith(vercelStatus()), Message.ClickedConfirmAddVercel()).model + const reason = + "This Vercel token's role cannot read the team's billing. Use a token from an Owner, Member, Developer, Security, Billing or Enterprise Viewer role." + const refused = pageUpdate( + adding, + Message.CompletedAddVercel({ + result: Result.fail({ ...toLoadFailure("refused"), message: reason }), + }), + ) + expect(refused.model.addDialog.isOpen).toBe(true) + expect(names(refused.commands)).toEqual([]) + scene( + { update: pageUpdate, view }, + given(refused.model), + resolveTheme, + dialogMounted, + expectView(role("alert")).toContainText(reason), + // The refusal is about the token, which is replaced on the connection. + expectView(role("link", { name: "Open the Vercel connection" })).toHaveAttr( + "href", + "/integrations?integration=vercel", + ), + // The same click asks again, once the token's role is fixed. + expectView(role("button", { name: "Add source" })).toBeEnabled(), + ) + const back = pageUpdate(refused.model, Message.ClickedBackToProviders()).model + expect([back.addStep, back.vercelError]).toEqual(["pick", null]) + }) + + test("an add refused because the connection changed shows the team connected now, and adds that one", () => { + const adding = pageUpdate(vercelStepWith(vercelStatus()), Message.ClickedConfirmAddVercel()).model + const reason = + "The Vercel connection now reads another team (beta). Check the team shown, then add it again." + const refused = pageUpdate( + adding, + Message.CompletedAddVercel({ + result: Result.fail({ + ...toLoadFailure("refused"), + code: "integration_connection_changed", + message: reason, + }), + }), + ) + // The team on show is no longer the one connected: the connection is read again. + expect(names(refused.commands)).toContain("FetchVercelCostStatus") + expect(names(pageUpdate(refused.model, Message.ClickedConfirmAddVercel()).commands)).not.toContain( + "AddVercelCostSource", + ) + const reread = pageUpdate( + refused.model, + Message.CompletedLoadVercelStatus({ + key: refused.model.vercel.key ?? "", + result: Result.succeed( + vercelStatus({ team: { id: "team_2", name: "Beta Inc", slug: "beta" } }), + ), + }), + ).model + scene( + { update: pageUpdate, view }, + given(reread), + resolveTheme, + dialogMounted, + expectView(text("Beta Inc")).toExist(), + expectView(text("Acme Inc")).not.toExist(), + expectView(role("alert")).toContainText(reason), + ) + // The next click adds the team that is shown now. + expect(pageUpdate(reread, Message.ClickedConfirmAddVercel()).commands).toMatchObject([ + { name: "AddVercelCostSource", args: { teamId: "team_2" } }, + ]) + }) + + test("the Vercel step reads the connection anew each time it opens, and an older read cannot answer", () => { + const notConnected = vercelStepWith(vercelStatus({ team: null })) + // Back to the providers and in again, after connecting a team in another tab. + const reopened = pageUpdate( + pageUpdate(notConnected, Message.ClickedBackToProviders()).model, + Message.ClickedAddVercel(), + ) + expect(names(reopened.commands)).toEqual(["FetchVercelCostStatus"]) + // The answer from before is not on show while the new read is in flight. + scene( + { update: pageUpdate, view }, + given(reopened.model), + resolveTheme, + dialogMounted, + expectView(text("Reading the Vercel connection")).toExist(), + expectView(text(/No Vercel team is connected yet/)).not.toExist(), + expectView(role("link", { name: "Connect Vercel" })).not.toExist(), + ) + // A new read that fails says so: the old answer does not stand in for it. + const failed = pageUpdate( + reopened.model, + Message.CompletedLoadVercelStatus({ + key: reopened.model.vercel.key ?? "", + result: Result.fail(toLoadFailure("unreachable")), + }), + ).model + scene( + { update: pageUpdate, view }, + given(failed), + resolveTheme, + dialogMounted, + expectView(role("alert")).toExist(), + expectView(text(/No Vercel team is connected yet/)).not.toExist(), + ) + + // The first read's answer, arriving after the step was opened again, is dropped. + const firstKey = notConnected.vercel.key ?? "" + expect(reopened.model.vercel.key).not.toBe(firstKey) + const stale = pageUpdate( + reopened.model, + Message.CompletedLoadVercelStatus({ key: firstKey, result: Result.succeed(vercelStatus()) }), + ).model + expect(names(pageUpdate(stale, Message.ClickedConfirmAddVercel()).commands)).toEqual([]) + scene( + { update: pageUpdate, view }, + given(stale), + resolveTheme, + dialogMounted, + expectView(text("Reading the Vercel connection")).toExist(), + expectView(role("button", { name: "Add source" })).not.toExist(), + ) + }) + + test("a failed read of the Vercel connection says so and asks again on Try again", () => { + const opened = vercelStepOpened().model + const failed = pageUpdate( + opened, + Message.CompletedLoadVercelStatus({ + key: opened.vercel.key ?? "", + result: Result.fail(toLoadFailure("unreachable")), + }), + ).model + scene( + { update: pageUpdate, view }, + given(failed), + resolveTheme, + dialogMounted, + expectView(role("alert")).toExist(), + expectView(role("button", { name: "Add source" })).not.toExist(), + expectView(role("button", { name: "Try again" })).toExist(), + ) + // Try again reads it again, and shows the read in flight in place of the old error. + const retried = pageUpdate(failed, Message.ClickedAddVercel()) + expect(names(retried.commands)).toEqual(["FetchVercelCostStatus"]) + scene( + { update: pageUpdate, view }, + given(retried.model), + resolveTheme, + dialogMounted, + expectView(text("Reading the Vercel connection")).toExist(), + expectView(role("alert")).not.toExist(), + ) + }) + + test("a Vercel source is named by its team, bills no requests and is removed after a confirm", () => { + const vercel = source({ + id: "0190f2b3-0000-7000-8000-00000000c061", + provider: "vercel", + accountId: "team_1", + accountName: "acme", + backfillDate: "2025-12-01", + requestsThisMonth: 0, + }) + const model = answered(entered({}, [source(), vercel]).model) + scene( + { update: pageUpdate, view }, + given(model), + resolveTheme, + expectView(text("Vercel")).toExist(), + expectView(text("acme")).toExist(), + // Request billing is AWS's: one line for the AWS source, none for Vercel. + expectView(text("86 provider requests this month (USD 0.86)")).toExist(), + expectView(text(/0 provider requests/)).not.toExist(), + // The connection holds the token: it is replaced there, and there is no key to replace here. + expectView(role("link", { name: "Manage Vercel connection" })).toHaveAttr( + "href", + "/integrations?integration=vercel", + ), + expectView(role("button", { name: /^Replace/ })).not.toExist(), + expectView(role("button", { name: "Remove Vercel source" })).toExist(), + ) + // The providers list the team as added, with nothing to click. + scene( + { update: pageUpdate, view }, + given(pageUpdate(model, Message.ClickedAddSource()).model), + resolveTheme, + dialogMounted, + expectView(text("Already added")).toExist(), + expectView(role("button", { name: /^Vercel/ })).not.toExist(), + expectView(role("button", { name: /^OpenRouter/ })).toExist(), + ) + + const confirming = pageUpdate(model, Message.ClickedRemoveSource({ provider: "vercel" })).model + scene( + { update: pageUpdate, view }, + given(confirming), + resolveTheme, + dialogMounted, + expectView(text("Remove the Vercel source?")).toExist(), + expectView(text(/The Vercel connection stays as it is/)).toExist(), + ) + const removing = pageUpdate(confirming, Message.ClickedConfirmRemove()) + expect(names(removing.commands)).toEqual(["RemoveVercelCostSource"]) + + // A removal that failed leaves the source where it was and says why. + const failed = pageUpdate( + removing.model, + Message.CompletedRemoveVercel({ result: Result.fail(toLoadFailure("unreachable")) }), + ) + expect(failed.model.removeDialog.isOpen).toBe(false) + expect(failed.outMessage).toMatchObject({ type: "error", title: "Couldn't remove the source" }) + scene( + { update: pageUpdate, view }, + given(failed.model), + resolveTheme, + dialogMounted, + expectView(role("button", { name: "Remove Vercel source" })).toBeEnabled(), + ) + + const removed = pageUpdate( + removing.model, + Message.CompletedRemoveVercel({ + result: Result.succeed(new VercelCostSourceRemoved({ removed: true })), + }), + ) + expect(removed.outMessage).toEqual( + PageOutMessage.RequestedToast({ type: "success", title: "Vercel removed", description: "" }), + ) + // The reads name the org's sources: without reading them again the removed team's cost stays. + expect(names(removed.commands)).toEqual( + expect.arrayContaining([ + "FetchCostSources", + "FetchCostTotals", + "FetchCostTimeseries", + "FetchCostBreakdown", + ]), + ) + // The cost on show included the removed team's: it leaves with the row, while the reads run. + expect(model.totals.key).not.toBe(removed.model.totals.key) + scene( + { update: pageUpdate, view }, + given(removed.model), + resolveTheme, + dialogMounted, + expectView(text("$1,200.00")).not.toExist(), + ) + // A reread that fails leaves no removed cost beside its error. + const unreachable = Result.fail(toLoadFailure("unreachable")) + const rereadFailed = Arr.reduce( + [ + Message.CompletedLoadTotals({ key: removed.model.totals.key ?? "", result: unreachable }), + Message.CompletedLoadTimeseries({ + key: removed.model.timeseries.key ?? "", + result: unreachable, + }), + Message.CompletedLoadBreakdown({ + key: removed.model.breakdown.key ?? "", + result: unreachable, + }), + Message.CompletedLoadFacets({ key: removed.model.facets.key ?? "", result: unreachable }), + ], + removed.model, + (next, message) => pageUpdate(next, message).model, + ) + scene( + { update: pageUpdate, view }, + given(rereadFailed), + resolveTheme, + dialogMounted, + expectView(text("Couldn't load cost")).toExist(), + expectView(text("$1,200.00")).not.toExist(), + expectView(text("north-1")).not.toExist(), + ) + // The read from before the removal, answering late, is not the reread's answer. + const late = pageUpdate( + removed.model, + Message.CompletedLoadTotals({ key: model.totals.key ?? "", result: Result.succeed(totals()) }), + ).model + scene( + { update: pageUpdate, view }, + given(late), + resolveTheme, + dialogMounted, + expectView(text("$1,200.00")).not.toExist(), + ) + + // The row is gone at once, and the AWS account's stays. + scene( + { update: pageUpdate, view }, + given(removed.model), + resolveTheme, + dialogMounted, + expectView(text("acme")).not.toExist(), + expectView(text("123456789012")).toExist(), + ) + }) + + test("cost read for a source that was removed never stands in for the next source's", () => { + const openRouter = source({ + id: "0190f2b3-0000-7000-8000-00000000c060", + provider: "openrouter", + accountId: "user_2openrouterAccount", + }) + const vercel = source({ + id: "0190f2b3-0000-7000-8000-00000000c061", + provider: "vercel", + accountId: "team_1", + accountName: "acme", + }) + const removals: ReadonlyArray< + Readonly<{ only: SourceWire; provider: RemovableProvider; removed: Message }> + > = [ + { + only: vercel, + provider: "vercel", + removed: Message.CompletedRemoveVercel({ + result: Result.succeed(new VercelCostSourceRemoved({ removed: true })), + }), + }, + { + only: openRouter, + provider: "openrouter", + removed: Message.CompletedRemoveKey({ + result: Result.succeed(new OpenRouterCostKeyRemoved({ removed: true })), + }), + }, + ] + Arr.forEach(removals, ({ only, provider, removed: answer }) => { + // The removed source was the only one with cost: the reads are skipped, not refreshed. + const model = answered(entered({}, [only]).model) + const totalsKey = model.totals.key ?? "" + const removed = pageUpdate( + pageUpdate( + pageUpdate(model, Message.ClickedRemoveSource({ provider })).model, + Message.ClickedConfirmRemove(), + ).model, + answer, + ) + expect(names(removed.commands)).toContain("FetchCostSources") + expect(names(removed.commands)).not.toContain("FetchCostTotals") + // Another source has synced by the time the list is read again, over the same window. + const next = pageUpdate( + removed.model, + Message.CompletedLoadSources({ + key: removed.model.sources.key ?? "", + result: Result.succeed(sources([source()])), + }), + ) + // Its cost is asked for: the removed source's answer under the same key is not kept. + expect(names(next.commands)).toEqual( + expect.arrayContaining(["FetchCostTotals", "FetchCostTimeseries", "FetchCostBreakdown"]), + ) + expect(next.model.totals.key).toBe(totalsKey) + scene( + { update: pageUpdate, view }, + given(next.model), + resolveTheme, + dialogMounted, + expectView(text("$1,200.00")).not.toExist(), + ) + }) + }) + + /** The rail's read answered: `resources` are the keys of its resource facet, by cost. */ + const withFacets = (model: Model, resources: ReadonlyArray): Model => + pageUpdate( + model, + Message.CompletedLoadFacets({ + key: model.facets.key ?? "", + result: Result.succeed( + perDimension((dimension) => + dimension === "resource" + ? Arr.map(resources, (key) => ({ key, cost: 40, previousCost: 30 })) + : dimension === "account" + ? [{ key: "team_1", cost: 40, previousCost: 30 }] + : breakdown.rows, + ), + ), + }), + ).model + + test("the rail offers resources where cost has one, and a service under a region breaks down by them", () => { + const vercel = source({ + id: "0190f2b3-0000-7000-8000-00000000c061", + provider: "vercel", + accountId: "team_1", + accountName: "acme", + }) + const underRegion = answered(entered({ region: ["north-1"] }, [source(), vercel]).model) + // A Vercel team's projects are its resources: the rail lists them, the plan fee has none. + scene( + { update: pageUpdate, view }, + given(withFacets(underRegion, ["web", ""])), + resolveTheme, + expectView(role("checkbox", { name: "web" })).toExist(), + expectView(role("checkbox", { name: "No resource" })).toExist(), + // The account reads as the team it is. + expectView(text("Vercel acme")).toExist(), + expectView(role("link", { name: "Unassigned" })).toHaveAttr( + "href", + "/cost?groupBy=resource&cloud_service=%5B%22%22%5D®ion=%5B%22north-1%22%5D", + ), + ) + // An org whose cost names no resource gets no such section, and the drill-down passes it over. + scene( + { update: pageUpdate, view }, + given(withFacets(underRegion, [""])), + resolveTheme, + expectView(role("checkbox", { name: "No resource" })).not.toExist(), + expectView(role("link", { name: "Unassigned" })).toHaveAttr( + "href", + "/cost?groupBy=account&cloud_service=%5B%22%22%5D®ion=%5B%22north-1%22%5D", + ), + ) + // A filter by resource from the URL keeps its section, so its checkbox can clear it. + const filtered = answered(entered({ resource: ["gone"] }, [source()]).model) + scene( + { update: pageUpdate, view }, + given(withFacets(filtered, [""])), + resolveTheme, + expectView(role("checkbox", { name: "No resource" })).toExist(), + ) + }) + + test("removing one provider's source leaves the other's on the page", () => { + const openRouter = source({ + id: "0190f2b3-0000-7000-8000-00000000c060", + provider: "openrouter", + accountId: "user_2openrouterAccount", + }) + const vercel = source({ + id: "0190f2b3-0000-7000-8000-00000000c061", + provider: "vercel", + accountId: "team_1", + accountName: "acme", + }) + const model = answered(entered({}, [openRouter, vercel]).model) + const removing = pageUpdate( + pageUpdate(model, Message.ClickedRemoveSource({ provider: "openrouter" })).model, + Message.ClickedConfirmRemove(), + ) + // The confirm removes the source its dialog asked about. + expect(names(removing.commands)).toEqual(["RemoveOpenRouterCostKey"]) + const removed = pageUpdate( + removing.model, + Message.CompletedRemoveKey({ + result: Result.succeed(new OpenRouterCostKeyRemoved({ removed: true })), + }), + ).model + scene( + { update: pageUpdate, view }, + given(removed), + resolveTheme, + dialogMounted, + expectView(text("user_2openrouterAccount")).not.toExist(), + expectView(text("acme")).toExist(), + ) + }) + test("a view filtered to a provider compares with the period before when that provider's history reaches it", () => { const aws = Schema.decodeSync(CostSourcesResponse)({ sources: [source()] }).sources const both = Schema.decodeSync(CostSourcesResponse)({ @@ -460,9 +1062,9 @@ describe("cost page", () => { // The sources still show how their sync is doing. expectView(text("user_2openrouterAccount")).toExist(), expectView(role("button", { name: "Add source" })).not.toExist(), - expectView(role("button", { name: "Replace key" })).not.toExist(), - expectView(role("button", { name: "Remove" })).not.toExist(), - expectView(role("link", { name: "Manage connection" })).not.toExist(), + expectView(role("button", { name: /^Replace/ })).not.toExist(), + expectView(role("button", { name: /^Remove/ })).not.toExist(), + expectView(role("link", { name: /^Manage/ })).not.toExist(), ) scene( { update: pageUpdate, view }, @@ -492,13 +1094,18 @@ describe("cost page", () => { ), resolveTheme, expectView(role("button", { name: "Add source" })).toExist(), - expectView(role("button", { name: "Replace key" })).toExist(), + expectView(role("button", { name: "Replace OpenRouter key" })).toExist(), ) }) test("grouped by provider, a row reads the provider's name and filters by its id", () => { expect(keyLabel("provider", "openrouter")).toBe("OpenRouter") expect(keyLabel("provider", "aws")).toBe("AWS") + expect(keyLabel("provider", "vercel")).toBe("Vercel") + // A Vercel project is the row's resource; a charge of no project says so. + expect(keyLabel("resource", "web")).toBe("web") + expect(keyLabel("resource", "")).toBe("No resource") + expect(requestFilters({ resource: ["web"] })).toEqual({ resource: ["web"] }) // A provider this build does not know still shows, as the rows name it. expect(keyLabel("provider", "somecloud")).toBe("somecloud") expect(requestFilters({ provider: ["openrouter"] })).toEqual({ provider: ["openrouter"] }) @@ -572,6 +1179,27 @@ describe("cost page", () => { expectView(text(/AWS brings in this month and the last/)).toExist(), expectView(text(/OpenRouter brings in its last four weeks/)).toExist(), ) + // A Vercel team reads back like an AWS account: the two are named together. + const vercel = source({ + ...unsynced, + id: "0190f2b3-0000-7000-8000-00000000c061", + provider: "vercel", + accountId: "team_1", + accountName: "acme", + status: "pending", + }) + scene( + { update: pageUpdate, view }, + given(entered({}, [vercel]).model), + resolveTheme, + expectView(text(/It brings in this month and the last, and older months fill in/)).toExist(), + ) + scene( + { update: pageUpdate, view }, + given(entered({}, [failingAws, vercel, openRouter]).model), + resolveTheme, + expectView(text(/AWS and Vercel bring in this month and the last/)).toExist(), + ) }) test("a synced source asks for the totals, the daily series and the breakdown of the window", () => { diff --git a/apps/web/src/page/cost/derive.ts b/apps/web/src/page/cost/derive.ts index 6079ead891..fcc94f3155 100644 --- a/apps/web/src/page/cost/derive.ts +++ b/apps/web/src/page/cost/derive.ts @@ -26,6 +26,7 @@ export const perDimension = (of: (dimension: CostGroupBy) => A): Readonly> @@ -105,23 +108,47 @@ export const withFilter = (search: Search, filter: ActiveFilter): Search => { * narrowed. Grouped by service it moves to region, by anything else to service. */ const DRILL_ORDER = { - cloud_service: ["region", "account"], - provider: ["cloud_service", "account", "region"], - service_category: ["cloud_service", "region"], - region: ["cloud_service", "account"], - account: ["cloud_service", "region"], + cloud_service: ["region", "resource", "account"], + provider: ["cloud_service", "account", "region", "resource"], + service_category: ["cloud_service", "region", "resource"], + region: ["cloud_service", "resource", "account"], + resource: ["cloud_service", "region", "account"], + account: ["cloud_service", "region", "resource"], } as const satisfies Record> -/** The grouping a row's click switches to, or the current one when every next step is narrowed. */ -export const drillTarget = (search: Search, dimension: CostGroupBy): CostGroupBy => - Arr.findFirst(DRILL_ORDER[dimension], (next) => (search[next] ?? []).length === 0).pipe( - Option.getOrElse(() => dimension), - ) +/** + * The dimensions the cost on the page does not split by, which the rail and a drill-down leave + * out. Only resources can be missing: no provider names one for every charge, and some for none. + */ +export const unsplitDimensions = ( + facets: Readonly>>> | undefined, +): ReadonlyArray => + facets !== undefined && Arr.some(facets.resource, (row) => row.key !== "") ? [] : ["resource"] + +/** + * The grouping a row's click switches to, or the current one when every next step is narrowed. + * `unsplit` are the dimensions with nothing to split by, which it passes over. + */ +export const drillTarget = ( + search: Search, + dimension: CostGroupBy, + unsplit: ReadonlyArray = [], +): CostGroupBy => + Arr.findFirst( + DRILL_ORDER[dimension], + (next) => (search[next] ?? []).length === 0 && !Arr.contains(unsplit, next), + ).pipe(Option.getOrElse(() => dimension)) /** A row's click: the page narrowed to the row's key, then split by the next dimension. */ -export const drilledInto = (search: Search, filter: ActiveFilter): Search => { +export const drilledInto = ( + search: Search, + filter: ActiveFilter, + unsplit: ReadonlyArray = [], +): Search => { const narrowed = withFilter(search, filter) - return narrowed === search ? search : { ...narrowed, groupBy: drillTarget(narrowed, filter.dimension) } + return narrowed === search + ? search + : { ...narrowed, groupBy: drillTarget(narrowed, filter.dimension, unsplit) } } /** A rail checkbox: the value leaves the dimension's filter when it is in it, else joins it. */ diff --git a/apps/web/src/page/cost/marks.test.ts b/apps/web/src/page/cost/marks.test.ts index 70f688758c..c5cfb5d169 100644 --- a/apps/web/src/page/cost/marks.test.ts +++ b/apps/web/src/page/cost/marks.test.ts @@ -1,7 +1,16 @@ import { CostSourceSummary } from "@maple/domain/http" import { Schema } from "effect" import { describe, expect, test } from "vitest" -import { facetFilters, keyLabel, onlyFilter, toggledFilter } from "./derive" +import { + type ActiveFilter, + drilledInto, + facetFilters, + keyLabel, + onlyFilter, + perDimension, + toggledFilter, + unsplitDimensions, +} from "./derive" import { keyDetail, keyMark } from "./marks" const source = ( @@ -51,6 +60,53 @@ describe("cost key marks", () => { expect(keyMark("cloud_service", "", [])).toBeNull() }) + test("a Vercel account reads as the team it is, with the Vercel mark and nothing aside", () => { + // The account is the team's id, which a rename leaves alone; the slug is how it is known. + const team = source("vercel", "team_1", "0195f1c2-7a3b-7c4d-8e5f-6a7b8c9d0e21") + const vercel = [...sources, { ...team, accountName: "acme" }] + expect(keyLabel("account", "team_1", vercel)).toBe("Vercel acme") + expect(keyDetail("account", "team_1", vercel)).toBeNull() + expect(keyMark("account", "team_1", vercel)?.icon).toBe(keyMark("provider", "vercel", [])?.icon) + expect(keyLabel("provider", "vercel")).toBe("Vercel") + // A team whose name was not read is still Vercel's, never a bare team id. + expect(keyLabel("account", "team_1", [...sources, team])).toBe("Vercel") + }) + + test("a service and a region wear the mark of the only platform that could have billed them", () => { + const aws = keyMark("provider", "aws", [])?.icon + const vercel = keyMark("provider", "vercel", [])?.icon + const team = source("vercel", "acme", "0195f1c2-7a3b-7c4d-8e5f-6a7b8c9d0e21") + // Vercel beside OpenRouter, whose services are model slugs: every other service is Vercel's. + const vercelOnly = [team, source("openrouter", "user_2zZ0ZRlIh83bwUonpAHz1xTcuG6")] + expect(keyMark("cloud_service", "Function Duration", vercelOnly)?.icon).toBe(vercel) + expect(keyMark("region", "iad1", vercelOnly)?.icon).toBe(vercel) + expect(keyMark("cloud_service", "openai/gpt-5", vercelOnly)?.icon).not.toBe(vercel) + + // With AWS too, a name says whose it is or it wears neither logo: never the wrong one. + const both = [source("aws", "465760687006"), team] + expect(keyMark("cloud_service", "Amazon Simple Storage Service", both)?.icon).toBe(aws) + expect(keyMark("cloud_service", "AWS Lambda", both)?.icon).not.toBe(vercel) + const unnamed = keyMark("cloud_service", "Function Duration", both)?.icon + expect([unnamed === aws, unnamed === vercel]).toEqual([false, false]) + expect(keyMark("region", "eu-west-1", both)?.icon).toBe(aws) + expect(keyMark("region", "fra1", both)?.icon).toBe(vercel) + // An airport code is Vercel's only for an org that reads Vercel. + expect(keyMark("region", "fra1", sources)?.icon).toBe(aws) + expect(keyMark("region", "global", both)?.icon).toBe(keyMark("region", "global", [])?.icon) + }) + + test("a resource wears its own glyph, since its key does not say who bills it", () => { + const both = [source("aws", "465760687006"), source("vercel", "acme")] + const mark = keyMark("resource", "web", both)?.icon + expect(mark).toBeDefined() + expect([ + mark === keyMark("provider", "aws", [])?.icon, + mark === keyMark("provider", "vercel", [])?.icon, + ]).toEqual([false, false]) + expect(keyMark("resource", "", both)).toBeNull() + expect(keyLabel("resource", "", both)).toBe("No resource") + }) + test("an AWS region says where it is; an unknown one adds nothing", () => { expect(keyDetail("region", "eu-central-1", [])).toBe("Frankfurt") expect(keyDetail("region", "mars-north-1", [])).toBeNull() @@ -64,6 +120,40 @@ describe("cost filter rail", () => { expect(facetFilters(search, "cloud_service")).toEqual({ provider: ["aws"], region: ["eu-west-1"] }) }) + test("resources are a dimension of their own in the rail's reads", () => { + const search = { provider: ["vercel"], resource: ["web"] } + expect(facetFilters(search, "resource")).toEqual({ provider: ["vercel"] }) + expect(facetFilters(search, "cloud_service")).toEqual({ provider: ["vercel"], resource: ["web"] }) + }) + + test("a drill-down reaches resources after regions, and passes over them where no cost has one", () => { + const row = (key: string) => ({ key }) + const withProjects = unsplitDimensions( + perDimension((dimension) => (dimension === "resource" ? [row(""), row("web")] : [row("x")])), + ) + // Only charges with no resource: nothing to split by. + const without = unsplitDimensions( + perDimension((dimension) => (dimension === "resource" ? [row("")] : [row("x")])), + ) + expect([withProjects, without, unsplitDimensions(undefined)]).toEqual([ + [], + ["resource"], + ["resource"], + ]) + + const service: ActiveFilter = { dimension: "cloud_service", value: "Function Duration" } + // A service splits by region first, as it did before resources were a dimension. + expect(drilledInto({}, service, withProjects).groupBy).toBe("region") + // Under a region it moves on to the projects, where the org has any, else to accounts. + expect(drilledInto({ region: ["iad1"] }, service, withProjects).groupBy).toBe("resource") + expect(drilledInto({ region: ["iad1"] }, service, without).groupBy).toBe("account") + // A project splits by its services. + expect(drilledInto({}, { dimension: "resource", value: "web" }, withProjects)).toEqual({ + resource: ["web"], + groupBy: "cloud_service", + }) + }) + test("a checkbox adds the value or takes it out; Only leaves it alone in its dimension", () => { const search = { provider: ["aws"] } expect(toggledFilter(search, { dimension: "provider", value: "openrouter" }).provider).toEqual([ diff --git a/apps/web/src/page/cost/marks.ts b/apps/web/src/page/cost/marks.ts index e65034f63e..04d916819a 100644 --- a/apps/web/src/page/cost/marks.ts +++ b/apps/web/src/page/cost/marks.ts @@ -23,6 +23,33 @@ const providerMark = (provider: CostProvider): KeyMark => { /** OpenRouter names a model `vendor/model`; an AWS service name never has a slash. */ const isModelSlug = (key: string): boolean => Str.includes("/")(key) +/** How AWS names its services: "Amazon Simple Storage Service", "AWS Lambda", "EC2 - Other". */ +const isAwsServiceName = (key: string): boolean => /^(Amazon|AWS|EC2|Elastic|CloudWatch)/u.test(key) + +/** A Vercel region is an airport code and a digit: `iad1`, `fra1`. */ +const isVercelRegionId = (key: string): boolean => /^[a-z]{3}\d$/u.test(key) + +/** The org's providers that bill services and regions of their own. OpenRouter's are model slugs. */ +const platformsOf = (sources: ReadonlyArray): ReadonlyArray => + Arr.dedupe( + Arr.filter( + Arr.map(sources, (source) => source.provider), + (provider) => provider !== "openrouter", + ), + ) + +/** + * Whose service or region a key is, when its name does not say: the org's only platform, AWS + * while no source is known, and nobody's when two platforms could each have billed it. + */ +const onlyPlatform = (sources: ReadonlyArray): CostProvider | undefined => { + const platforms = platformsOf(sources) + return platforms.length > 1 ? undefined : (platforms[0] ?? "aws") +} + +const markOf = (provider: CostProvider | undefined, fallback: IconName): KeyMark => + provider === undefined ? glyph(fallback) : providerMark(provider) + /** An AWS account id is twelve digits; an OpenRouter one is its user or org id. */ const isAwsAccountId = (key: string): boolean => /^\d{12}$/u.test(key) @@ -70,11 +97,20 @@ export const keyMark = ( return Option.match(decodeProvider(key), { onNone: () => null, onSome: providerMark }) case "cloud_service": if (isModelSlug(key)) return { icon: modelSlugIcon(key) } - return key === "AWS Lambda" ? { icon: "AwsLambdaIcon" } : providerMark("aws") + if (key === "AWS Lambda") return { icon: "AwsLambdaIcon" } + return markOf(isAwsServiceName(key) ? "aws" : onlyPlatform(sources), "CubeIcon") case "service_category": return glyph(isCategory(key) ? CATEGORY_ICONS[key] : "CubeIcon") case "region": - return key === "global" ? glyph("GlobeIcon") : providerMark("aws") + if (key === "global") return glyph("GlobeIcon") + if (isAwsRegion(key)) return providerMark("aws") + if (isVercelRegionId(key) && Arr.contains(platformsOf(sources), "vercel")) { + return providerMark("vercel") + } + return markOf(onlyPlatform(sources), "GlobeIcon") + // A resource is what a charge is for (a Vercel project): its provider is in the row, not the key. + case "resource": + return glyph("FolderIcon") case "account": { const provider = accountProvider(key, sources) return provider === undefined ? glyph("IdBadgeIcon") : providerMark(provider) @@ -83,8 +119,9 @@ export const keyMark = ( } /** - * An account by what reads it: an AWS one by its id, an OpenRouter one by name alone while it is - * the org's only one (its id means nothing to a reader). Unknown ids read as they are. + * An account by what reads it: an AWS one by its id, a Vercel one by its team's slug (its id is a + * team id, kept stable through a rename), an OpenRouter one by name alone while it is the org's + * only one (its id means nothing to a reader). Unknown ids read as they are. */ export const accountLabel = (key: string, sources: ReadonlyArray): string => { const provider = accountProvider(key, sources) @@ -92,6 +129,11 @@ export const accountLabel = (key: string, sources: ReadonlyArray source.provider === provider) + const accountName = Arr.findFirst(sameProvider, (source) => source.accountId === key).pipe( + Option.flatMapNullishOr((source) => source.accountName), + Option.getOrUndefined, + ) + if (accountName !== undefined) return `${name} ${accountName}` return sameProvider.length <= 1 ? name : `${name} ${key}` } diff --git a/apps/web/src/page/cost/message.ts b/apps/web/src/page/cost/message.ts index 3ebb95de73..637cc73d64 100644 --- a/apps/web/src/page/cost/message.ts +++ b/apps/web/src/page/cost/message.ts @@ -7,6 +7,9 @@ import { CostTotalsResponse, OpenRouterCostKeyRemoved, OpenRouterCostKeySaved, + VercelCostSourceAdded, + VercelCostSourceRemoved, + VercelCostStatus, } from "@maple/domain/http" import * as Policy from "@maple/domain/policy" import { Schema } from "effect" @@ -36,6 +39,10 @@ const FacetRows = Schema.Array(CostBreakdownRow) export const CostFacets = Schema.Struct(perDimension(() => FacetRows)) export type CostFacets = typeof CostFacets.Type +/** The providers whose source the page itself removes; an AWS source goes with its connection. */ +export const RemovableProvider = Schema.Literals(["openrouter", "vercel"]) +export type RemovableProvider = typeof RemovableProvider.Type + export const Message = defineMessageUnion({ CompletedResolveWindow: { window: TimeWindow, nowMs: Schema.Number, mode: RequestMode }, CompletedLoadSources: result(CostSourcesResponse), @@ -63,6 +70,11 @@ export const Message = defineMessageUnion({ /** Opens the add-source dialog on the providers. */ ClickedAddSource: {}, ClickedAddOpenRouter: {}, + /** Turns the dialog to Vercel and reads the org's connection; also the step's Try again. */ + ClickedAddVercel: {}, + CompletedLoadVercelStatus: result(VercelCostStatus), + ClickedConfirmAddVercel: {}, + CompletedAddVercel: { result: Schema.Result(VercelCostSourceAdded, LoadFailure) }, ClickedBackToProviders: {}, /** Opens the dialog on the key form, for the source that exists. */ ClickedReplaceKey: {}, @@ -70,10 +82,11 @@ export const Message = defineMessageUnion({ GotKeyDraftMessage: { message: Field.Message }, SubmittedKey: {}, CompletedSaveKey: { result: Schema.Result(OpenRouterCostKeySaved, LoadFailure) }, - ClickedRemoveSource: {}, + ClickedRemoveSource: { provider: RemovableProvider }, GotRemoveDialogMessage: { message: Dialog.Message }, ClickedConfirmRemove: {}, CompletedRemoveKey: { result: Schema.Result(OpenRouterCostKeyRemoved, LoadFailure) }, + CompletedRemoveVercel: { result: Schema.Result(VercelCostSourceRemoved, LoadFailure) }, ...ChartEnvMessages, }) export type Message = typeof Message.Type diff --git a/apps/web/src/page/cost/model.ts b/apps/web/src/page/cost/model.ts index 5c90413e03..686b3ca02a 100644 --- a/apps/web/src/page/cost/model.ts +++ b/apps/web/src/page/cost/model.ts @@ -4,6 +4,8 @@ import { CostSourcesResponse, CostTimeseriesResponse, CostTotalsResponse, + VercelCostStatus, + type VercelCostTeam, } from "@maple/domain/http" import * as Policy from "@maple/domain/policy" import { Option, Schema } from "effect" @@ -17,15 +19,31 @@ import * as Field from "@maple/views/ui/field" import * as FilterSections from "@maple/views/ui/filters/filter-sections" import { TimeWindow } from "../../data/time-range" import { CostSearch } from "../../route/search" -import { CostFacets } from "./message" +import { CostFacets, RemovableProvider } from "./message" import * as TimeRangePicker from "../kit/time-range-picker" export type Search = typeof CostSearch.Type -/** The add-source dialog's two faces: the providers, then the chosen one's form. */ -export const AddStep = Schema.Literals(["pick", "openrouter"]) +/** The add-source dialog's faces: the providers, then the chosen one's step. */ +export const AddStep = Schema.Literals(["pick", "openrouter", "vercel"]) export type AddStep = typeof AddStep.Type +/** + * What the Vercel step can do with the connection it read: only a `Ready` team can be added. A + * team that is a source already can still have lost its token, which stops its sync. + */ +export type VercelStep = + | Readonly<{ _tag: "NotConnected" }> + | Readonly<{ _tag: "TokenRejected" | "Ready"; team: VercelCostTeam }> + | Readonly<{ _tag: "Added"; team: VercelCostTeam; isTokenRejected: boolean }> + +export const vercelStepOf = (status: VercelCostStatus): VercelStep => { + if (status.team === null) return { _tag: "NotConnected" } + return status.added + ? { _tag: "Added", team: status.team, isTokenRejected: status.authFailed } + : { _tag: status.authFailed ? "TokenRejected" : "Ready", team: status.team } +} + export const Model = Schema.Struct({ search: CostSearch, window: Schema.NullOr(TimeWindow), @@ -51,7 +69,13 @@ export const Model = Schema.Struct({ keyDraft: Field.Model, /** Why the last save was refused, shown under the field until the key changes. */ keyError: Schema.NullOr(Schema.String), + /** The org's Vercel connection, read when the dialog turns to Vercel. */ + vercel: KeyedSchema(VercelCostStatus, LoadFailure), + /** Why Vercel refused the last add, shown in the step until it is left. */ + vercelError: Schema.NullOr(Schema.String), removeDialog: Dialog.Model, + /** Whose source the remove dialog asks about. */ + removeProvider: RemovableProvider, /** `Settling`: the write answered and the sources are being read again; the list on show is the old one. */ sourceWrite: Schema.Literals(["Idle", "Saving", "Removing", "Settling"]), }) diff --git a/apps/web/src/page/cost/providers.ts b/apps/web/src/page/cost/providers.ts index 83bf9a0025..daf0a1d673 100644 --- a/apps/web/src/page/cost/providers.ts +++ b/apps/web/src/page/cost/providers.ts @@ -14,6 +14,7 @@ export type CostProviderEntry = Readonly<{ }> // Not every cost provider is an integration: OpenRouter supplies cost only, so it is named here. +// Vercel's requests are free, like OpenRouter's. export const COST_PROVIDERS = { aws: { name: PROVIDERS.aws.name, @@ -27,4 +28,10 @@ export const COST_PROVIDERS = { iconClassName: "text-foreground", requestUsd: null, }, + vercel: { + name: PROVIDERS.vercel.name, + icon: PROVIDERS.vercel.icon, + iconClassName: PROVIDERS.vercel.iconClassName, + requestUsd: null, + }, } as const satisfies Record diff --git a/apps/web/src/page/cost/update.ts b/apps/web/src/page/cost/update.ts index fb70b385ef..a550c08f6e 100644 --- a/apps/web/src/page/cost/update.ts +++ b/apps/web/src/page/cost/update.ts @@ -1,4 +1,4 @@ -import { CostSourcesResponse, OPENROUTER_KEY_MAX_LENGTH } from "@maple/domain/http" +import { CostSourcesResponse, OPENROUTER_KEY_MAX_LENGTH, type VercelCostTeam } from "@maple/domain/http" import * as Policy from "@maple/domain/policy" import { Array as Arr, Option, Result, String as Str } from "effect" import { AsyncData, Command } from "foldkit" @@ -6,7 +6,7 @@ import * as Chart from "@maple/views/charts/chart" import { chartEnvHandlers } from "@maple/views/charts/chart-env" import { LoadChartRuntime } from "@maple/views/charts/chart-runtime" import * as Keyed from "@maple/views/data/keyed" -import { failureMessage } from "@maple/views/data/load-failure" +import { failureMessage, type LoadFailure } from "@maple/views/data/load-failure" import { requestKey } from "@maple/views/data/request-key" import * as Dialog from "@maple/views/ui/dialog" import * as Field from "@maple/views/ui/field" @@ -21,6 +21,7 @@ import { routeWindowStep, snapModeOf } from "../kit/route-window" import type { PresetValue } from "../kit/time-controls" import * as TimeRangePicker from "../kit/time-range-picker" import { + AddVercelCostSource, type BreakdownInput, CompleteReloadSpin, FetchCostBreakdown, @@ -29,8 +30,10 @@ import { FetchCostSources, FetchCostTimeseries, FetchCostTotals, + FetchVercelCostStatus, ReadCostGrants, RemoveOpenRouterCostKey, + RemoveVercelCostSource, ResolveWindow, SaveOpenRouterCostKey, type TimeseriesInput, @@ -51,14 +54,18 @@ import { withoutFilter, withoutFilters, } from "./derive" -import { Message } from "./message" -import type { AddStep, Model, Search } from "./model" +import { Message, type RemovableProvider } from "./message" +import { type AddStep, type Model, type Search, vercelStepOf } from "./model" +import { COST_PROVIDERS } from "./providers" type Return = PageReturn type Cmd = Command.Command export const DEFAULT_PRESET: PresetValue = "30d" +/** The API's code for a write refused because the org's connection changed under it. */ +const CONNECTION_CHANGED_CODE = "integration_connection_changed" + /** The keys that get their own series in the chart; the rest fold into "Other". */ export const SERIES_LIMIT = 8 const BREAKDOWN_LIMIT = 100 @@ -96,7 +103,20 @@ const requestSources = (model: Model, context: PageContext, mode: Keyed.ReadMode /** The window's reads, once a source has cost to read: an org with none sends no warehouse query. */ const requestData = (model: Model, context: PageContext, mode: Keyed.ReadMode): Return => { const sources = Option.getOrUndefined(AsyncData.getData(model.sources.data)) - if (model.window === null || sources === undefined || !hasSynced(sources.sources)) return { model } + if (model.window === null || sources === undefined) return { model } + // No source has cost: what was read for the sources there were is dropped. The reads are + // keyed by the window, not by the sources, so it would stand in for the next source's cost. + if (!hasSynced(sources.sources)) { + return { + model: { + ...model, + totals: Keyed.idle(), + timeseries: Keyed.idle(), + breakdown: Keyed.idle(), + facets: Keyed.idle(), + }, + } + } const filters = requestFilters(model.search) const window: WindowInput = { startTime: model.window.startTime, @@ -173,7 +193,10 @@ export const init = (route: { readonly search: Search }, context: PageContext): addStep: "pick", keyDraft: Field.init(), keyError: null, + vercel: Keyed.idle(), + vercelError: null, removeDialog: Dialog.init("cost-remove-source", "alert-dialog"), + removeProvider: "openrouter", sourceWrite: "Idle", } return chain( @@ -210,7 +233,44 @@ const liftRemoveDialog = (model: Model, step: ReturnType): }) /** Nothing typed and no refusal on show: how the dialog opens and what it leaves behind. */ -const clearedKey = (model: Model): Model => ({ ...model, keyDraft: Field.init(), keyError: null }) +const clearedKey = (model: Model): Model => ({ + ...model, + keyDraft: Field.init(), + keyError: null, + vercelError: null, +}) + +/** + * A slot with nothing on show that keeps its key: the read after it is the key's next generation, + * so an older read in flight cannot answer, and a failure has no earlier answer to stand beside. + */ +const emptied = (slot: Keyed.Keyed): Keyed.Keyed => ({ + ...Keyed.idle(), + key: slot.key, +}) + +/** + * The org's Vercel connection, read again each time the dialog turns to Vercel. The answer on + * show is dropped first: the step never offers an add on a connection read before it was left, + * and a read that fails says so. + */ +const requestVercelStatus = (model: Model, context: PageContext): Return => + applySteps(model, [ + keyed( + emptied(model.vercel), + requestKey(context.orgId, "vercelCostStatus", {}), + "Supersede", + (vercel) => ({ vercel }), + (key) => FetchVercelCostStatus({ key }), + ), + ]) + +/** The team on show, when it can be added: a token Vercel takes and no source yet. */ +const addableVercelTeam = (model: Model): Option.Option => + Option.flatMap(AsyncData.getData(model.vercel.data), (status) => { + const step = vercelStepOf(status) + return step._tag === "Ready" ? Option.some(step.team) : Option.none() + }) const openAdd = (model: Model, addStep: AddStep): Return => model.sourceWrite !== "Idle" @@ -229,10 +289,10 @@ const withReread = (step: Return, context: PageContext): Return => ) /** - * The list on show without the OpenRouter source, which a removal just deleted: its row is gone + * The list on show without the provider's source, which a removal just deleted: its row is gone * at once, so no refresh, late or failed, leaves actions on a source that no longer exists. */ -const withoutOpenRouterSource = (model: Model): Model => ({ +const withoutSourceOf = (model: Model, provider: RemovableProvider): Model => ({ ...model, sources: { ...model.sources, @@ -240,7 +300,7 @@ const withoutOpenRouterSource = (model: Model): Model => ({ model.sources.data, (response) => new CostSourcesResponse({ - sources: Arr.filter(response.sources, (source) => source.provider !== "openrouter"), + sources: Arr.filter(response.sources, (source) => source.provider !== provider), }), ), }, @@ -252,6 +312,57 @@ const hasOpenRouterSource = (model: Model): boolean => Arr.some(sources, (source) => source.provider === "openrouter"), ) +/** A removal's answer: the dialog closes either way, and a removed source leaves the page at once. */ +const removed = ( + model: Model, + provider: RemovableProvider, + failure: Option.Option, + context: PageContext, +): Return => { + // The cost on show includes the removed source's: it goes with the row, so the reads that + // follow start empty and one that fails leaves no removed cost beside its error. + const closed = liftRemoveDialog( + Option.isNone(failure) + ? withoutSourceOf( + { + ...model, + sourceWrite: "Settling", + totals: emptied(model.totals), + timeseries: emptied(model.timeseries), + breakdown: emptied(model.breakdown), + facets: emptied(model.facets), + }, + provider, + ) + : { ...model, sourceWrite: "Idle" }, + Dialog.close(model.removeDialog), + ) + return Option.match(failure, { + onSome: (reason): Return => ({ + ...closed, + outMessage: toast("error", "Couldn't remove the source", failureMessage(reason)), + }), + onNone: (): Return => + withReread( + { ...closed, outMessage: toast("success", `${COST_PROVIDERS[provider].name} removed`) }, + context, + ), + }) +} + +/** A source was added or its key replaced: the dialog closes, and the page reads what it now has. */ +const added = (model: Model, title: string, description: string, context: PageContext): Return => + withReread( + { + ...liftAddDialog( + clearedKey({ ...model, sourceWrite: "Settling" }), + Dialog.close(model.addDialog), + ), + outMessage: toast("success", title, description), + }, + context, + ) + export const routeChanged = ( model: Model, route: { readonly search: Search }, @@ -385,6 +496,39 @@ export const update = (model: Model, message: Message, context: PageContext): Re CompletedReadGrants: ({ grants }) => ({ model: { ...model, grants } }), ClickedAddSource: () => openAdd(model, "pick"), ClickedAddOpenRouter: () => ({ model: { ...clearedKey(model), addStep: "openrouter" } }), + ClickedAddVercel: () => + model.sourceWrite !== "Idle" + ? { model } + : requestVercelStatus({ ...clearedKey(model), addStep: "vercel" }, context), + CompletedLoadVercelStatus: ({ key, result }) => ({ + model: { ...model, vercel: Keyed.accept(model.vercel, key, result) }, + }), + // The add names the team on show: one connected since is refused, not added in its place. + ClickedConfirmAddVercel: () => + Option.match(model.sourceWrite === "Idle" ? addableVercelTeam(model) : Option.none(), { + onNone: (): Return => ({ model }), + onSome: (team): Return => ({ + model: { ...model, sourceWrite: "Saving", vercelError: null }, + commands: [AddVercelCostSource({ teamId: team.id })], + }), + }), + // A refused add keeps the dialog open with Vercel's reason under the team. When the reason + // is that the connection changed, it is read again: the team on show is the one to add now. + CompletedAddVercel: ({ result }) => + Result.match(result, { + onFailure: (failure): Return => { + const refused: Model = { + ...model, + sourceWrite: "Idle", + vercelError: failureMessage(failure), + } + return failure.code === CONNECTION_CHANGED_CODE + ? requestVercelStatus(refused, context) + : { model: refused } + }, + onSuccess: (): Return => + added(model, "Vercel added", "The first sync runs within a few minutes.", context), + }), ClickedBackToProviders: () => model.sourceWrite === "Idle" ? { model: { ...clearedKey(model), addStep: "pick" } } : { model }, ClickedReplaceKey: () => openAdd(model, "openrouter"), @@ -426,25 +570,25 @@ export const update = (model: Model, message: Message, context: PageContext): Re onFailure: (failure): Return => ({ model: { ...model, sourceWrite: "Idle", keyError: failureMessage(failure) }, }), - onSuccess: (): Return => { - const closed = liftAddDialog( - clearedKey({ ...model, sourceWrite: "Settling" }), - Dialog.close(model.addDialog), - ) - const outMessage = hasOpenRouterSource(model) - ? toast( - "success", + onSuccess: (): Return => + hasOpenRouterSource(model) + ? added( + model, "OpenRouter key replaced", "The next sync runs within a few minutes.", + context, ) - : toast("success", "OpenRouter added", "The first sync runs within a few minutes.") - return withReread({ ...closed, outMessage }, context) - }, + : added( + model, + "OpenRouter added", + "The first sync runs within a few minutes.", + context, + ), }), - ClickedRemoveSource: () => + ClickedRemoveSource: ({ provider }) => model.sourceWrite !== "Idle" ? { model } - : liftRemoveDialog(model, Dialog.open(model.removeDialog)), + : liftRemoveDialog({ ...model, removeProvider: provider }, Dialog.open(model.removeDialog)), GotRemoveDialogMessage: ({ message: inner }) => model.sourceWrite === "Removing" ? { model } @@ -452,22 +596,15 @@ export const update = (model: Model, message: Message, context: PageContext): Re ClickedConfirmRemove: () => model.sourceWrite !== "Idle" ? { model } - : { model: { ...model, sourceWrite: "Removing" }, commands: [RemoveOpenRouterCostKey()] }, - CompletedRemoveKey: ({ result }) => { - const closed = liftRemoveDialog( - Result.isSuccess(result) - ? withoutOpenRouterSource({ ...model, sourceWrite: "Settling" }) - : { ...model, sourceWrite: "Idle" }, - Dialog.close(model.removeDialog), - ) - return Result.match(result, { - onFailure: (failure): Return => ({ - ...closed, - outMessage: toast("error", "Couldn't remove the source", failureMessage(failure)), - }), - onSuccess: (): Return => - withReread({ ...closed, outMessage: toast("success", "OpenRouter removed") }, context), - }) - }, + : { + model: { ...model, sourceWrite: "Removing" }, + commands: [ + model.removeProvider === "vercel" + ? RemoveVercelCostSource() + : RemoveOpenRouterCostKey(), + ], + }, + CompletedRemoveKey: ({ result }) => removed(model, "openrouter", Result.getFailure(result), context), + CompletedRemoveVercel: ({ result }) => removed(model, "vercel", Result.getFailure(result), context), ...chartEnvHandlers(model), }) diff --git a/apps/web/src/page/cost/view-add-source.ts b/apps/web/src/page/cost/view-add-source.ts index 42b233f638..1b6523bc7f 100644 --- a/apps/web/src/page/cost/view-add-source.ts +++ b/apps/web/src/page/cost/view-add-source.ts @@ -1,5 +1,4 @@ import type { CostSourceSummary } from "@maple/domain/http" -import { cn } from "@maple/ui/lib/utils" import { Array as Arr, String as Str } from "effect" import type { Html, HtmlBuilder } from "foldkit/html" import { button } from "@maple/views/ui/button" @@ -15,9 +14,10 @@ import { vstack } from "@maple/views/ui/stack" import { textLink } from "@maple/views/ui/text-link" import { typography } from "@maple/views/ui/typography" import { AppRoute, hrefOf } from "../../route" -import { Message } from "./message" +import { Message, type RemovableProvider } from "./message" import type { Model } from "./model" import { COST_PROVIDERS, type CostProviderEntry } from "./providers" +import { providerMark, vercelStep } from "./view-add-vercel" type H = HtmlBuilder @@ -28,42 +28,74 @@ export const AWS_INTEGRATION_HREF = hrefOf(AppRoute.Integrations({ search: { int const OPENROUTER_KEYS_URL = "https://openrouter.ai/settings/management-keys" -/** Whether the org has its OpenRouter source, which there is at most one of. */ -const hasOpenRouterSource = (sources: ReadonlyArray): boolean => - Arr.some(sources, (source) => source.provider === "openrouter") +/** Whether the org has its source of a provider it can have one of at most. */ +const hasSourceOf = ( + sources: ReadonlyArray, + provider: CostSourceSummary["provider"], +): boolean => Arr.some(sources, (source) => source.provider === provider) -const mark = (h: H, provider: CostProviderEntry): Html => - icon(h, provider.icon, { size: 18, className: cn("shrink-0", provider.iconClassName) }) +/** + * A provider the org has one source of at most: a row to click while it has none, and one that + * says where the source is changed once it does. + */ +const addableRow = ( + h: H, + provider: CostProviderEntry, + options: Readonly<{ + isAdded: boolean + /** What the source brings in. */ + meta: string + /** Where an added source is changed. */ + addedMeta: string + onClick: Message + divided?: boolean + }>, +): Html => + options.isAdded + ? listRow(h, { + divided: options.divided ?? false, + leading: providerMark(h, provider), + title: provider.name, + meta: options.addedMeta, + trailing: badge(h, { variant: "muted", size: "sm" }, ["Already added"]), + }) + : listRow(h, { + divided: options.divided ?? false, + onClick: options.onClick, + leading: providerMark(h, provider), + title: provider.name, + meta: options.meta, + trailing: icon(h, "ChevronRightIcon", { size: 14 }), + }) -/** The providers to add: AWS goes to its connect flow, OpenRouter to the key form. */ -const providers = (h: H, hasOpenRouter: boolean): Html => +/** The providers to add: AWS goes to its connect flow, OpenRouter to the key form, Vercel to its team. */ +const providers = (h: H, hasOpenRouter: boolean, hasVercel: boolean): Html => vstack(h, { gap: "3" }, [ vstack(h, { gap: "0" }, [ listRow(h, { divided: true, href: CONNECT_AWS_HREF, - leading: mark(h, COST_PROVIDERS.aws), + leading: providerMark(h, COST_PROVIDERS.aws), title: COST_PROVIDERS.aws.name, meta: "Daily cost by service, region and usage type", trailing: icon(h, "ChevronRightIcon", { size: 14 }), }), - hasOpenRouter - ? listRow(h, { - leading: mark(h, COST_PROVIDERS.openrouter), - title: COST_PROVIDERS.openrouter.name, - meta: "Replace its key in the Sources panel", - trailing: badge(h, { variant: "muted", size: "sm" }, ["Already added"]), - }) - : listRow(h, { - onClick: Message.ClickedAddOpenRouter(), - leading: mark(h, COST_PROVIDERS.openrouter), - title: COST_PROVIDERS.openrouter.name, - meta: "Daily spend by model and serving provider", - trailing: icon(h, "ChevronRightIcon", { size: 14 }), - }), + addableRow(h, COST_PROVIDERS.openrouter, { + isAdded: hasOpenRouter, + meta: "Daily spend by model and serving provider", + addedMeta: "Replace its key in the Sources panel", + onClick: Message.ClickedAddOpenRouter(), + divided: true, + }), + addableRow(h, COST_PROVIDERS.vercel, { + isAdded: hasVercel, + meta: "Daily charges by product, project and region", + addedMeta: "Remove it in the Sources panel", + onClick: Message.ClickedAddVercel(), + }), ]), typography(h, { size: "xs", tone: "muted", as: "p", className: "font-prose" }, [ - "AWS bills USD\u00a00.01 for each request Maple makes, a few dollars a month per account. OpenRouter requests are free. ", + "AWS bills USD\u00a00.01 for each request Maple makes, a few dollars a month per account. OpenRouter and Vercel requests are free. ", textLink(h, { href: AWS_INTEGRATION_HREF, tone: "inherit", className: "underline" }, [ "Turn on Cost for an AWS account that is connected", ]), @@ -116,15 +148,14 @@ const keyField = (h: H, model: Model): Html => { } /** - * Add a source, or replace the OpenRouter key: the providers first, then OpenRouter's key form. - * Opened on the form for a replace, which has no providers to go back to. + * Add a source, or replace the OpenRouter key: the providers first, then the chosen one's step. + * Opened on the key form for a replace, which has no providers to go back to. */ export const addSourceDialog = (h: H, model: Model, sources: ReadonlyArray): Html => { const id = model.addDialog.id - const isReplacing = hasOpenRouterSource(sources) + const isReplacing = hasSourceOf(sources, "openrouter") const isSaving = model.sourceWrite === "Saving" const canSubmit = !isSaving && Str.trim(model.keyDraft.value) !== "" - const onPick = model.addStep === "pick" return h.submodel({ slotId: "cost-add-source", model: model.addDialog, @@ -135,86 +166,104 @@ export const addSourceDialog = (h: H, model: Model, sources: ReadonlyArray portal ?? h.empty, toContent: (close) => - onPick - ? [ - DialogView.dialogHeader(h, "dialog", [ - DialogView.dialogTitle(h, "dialog", id, "Add a cost source"), - DialogView.dialogDescription( - h, - "dialog", - id, - "Maple reads what the source bills each day and keeps it next to your telemetry.", - ), - ]), - DialogView.dialogPanel(h, "dialog", id, [providers(h, isReplacing)]), - DialogView.dialogFooter(h, "dialog", [ - button(h, { variant: "outline", attributes: [...close] }, ["Close"]), - ]), - ] - : [ - formDialogForm(h, canSubmit ? Message.SubmittedKey() : undefined, [ + model.addStep === "vercel" + ? vercelStep(h, model, id) + : model.addStep === "pick" + ? [ DialogView.dialogHeader(h, "dialog", [ - DialogView.dialogTitle( - h, - "dialog", - id, - isReplacing ? "Replace the OpenRouter key" : "Add OpenRouter", - ), + DialogView.dialogTitle(h, "dialog", id, "Add a cost source"), DialogView.dialogDescription( h, "dialog", id, - isReplacing - ? "The source and its history stay. Use a key from the same OpenRouter account." - : "Maple reads the last four weeks at once, then every day from here on.", + "Maple reads what the source bills each day and keeps it next to your telemetry.", ), ]), - DialogView.dialogPanel(h, "dialog", id, [keyField(h, model)]), + DialogView.dialogPanel(h, "dialog", id, [ + providers(h, isReplacing, hasSourceOf(sources, "vercel")), + ]), DialogView.dialogFooter(h, "dialog", [ - isReplacing - ? button( - h, - { - variant: "outline", - isDisabled: isSaving, - attributes: isSaving ? [] : [...close], - }, - ["Cancel"], - ) - : button( - h, - { - variant: "outline", - isDisabled: isSaving, - attributes: [h.OnClick(Message.ClickedBackToProviders())], - }, - ["Back"], - ), - button( - h, - { - type: "submit", - isLoading: isSaving, - isDisabled: !canSubmit, - }, - [isReplacing ? "Replace key" : "Add source"], - ), + button(h, { variant: "outline", attributes: [...close] }, ["Close"]), + ]), + ] + : [ + formDialogForm(h, canSubmit ? Message.SubmittedKey() : undefined, [ + DialogView.dialogHeader(h, "dialog", [ + DialogView.dialogTitle( + h, + "dialog", + id, + isReplacing ? "Replace the OpenRouter key" : "Add OpenRouter", + ), + DialogView.dialogDescription( + h, + "dialog", + id, + isReplacing + ? "The source and its history stay. Use a key from the same OpenRouter account." + : "Maple reads the last four weeks at once, then every day from here on.", + ), + ]), + DialogView.dialogPanel(h, "dialog", id, [keyField(h, model)]), + DialogView.dialogFooter(h, "dialog", [ + isReplacing + ? button( + h, + { + variant: "outline", + isDisabled: isSaving, + attributes: isSaving ? [] : [...close], + }, + ["Cancel"], + ) + : button( + h, + { + variant: "outline", + isDisabled: isSaving, + attributes: [ + h.OnClick(Message.ClickedBackToProviders()), + ], + }, + ["Back"], + ), + button( + h, + { + type: "submit", + isLoading: isSaving, + isDisabled: !canSubmit, + }, + [isReplacing ? "Replace key" : "Add source"], + ), + ]), ]), - ]), - ], + ], }, }) } -/** Removing the OpenRouter source: what leaves the page, and what adding it again brings back. */ +/** What removing each provider's source takes away, and what adding it again brings back. */ +const REMOVAL = { + openrouter: { + title: "Remove the OpenRouter source?", + description: + "Maple deletes the key and stops reading the account's spend, and its cost leaves this page. Adding OpenRouter again brings back the last four weeks only.", + }, + vercel: { + title: "Remove the Vercel source?", + description: + "Maple stops reading the team's charges, and its cost leaves this page. The Vercel connection stays as it is. Adding Vercel again reads the last eleven months again.", + }, +} as const satisfies Record> + +/** Removing a source the page added: what leaves the page, and what adding it again brings back. */ export const removeSourceDialog = (h: H, model: Model): Html => confirmDialog(h, { slotId: "cost-remove-source", model: model.removeDialog, toParentMessage: (message) => Message.GotRemoveDialogMessage({ message }), - title: "Remove the OpenRouter source?", - description: - "Maple deletes the key and stops reading the account's spend, and its cost leaves this page. Adding OpenRouter again brings back the last four weeks only.", + ...REMOVAL[model.removeProvider], confirmLabel: "Remove source", onConfirm: Message.ClickedConfirmRemove(), isPending: model.sourceWrite === "Removing", diff --git a/apps/web/src/page/cost/view-add-vercel.ts b/apps/web/src/page/cost/view-add-vercel.ts new file mode 100644 index 0000000000..7be1fc6af9 --- /dev/null +++ b/apps/web/src/page/cost/view-add-vercel.ts @@ -0,0 +1,195 @@ +import type { VercelCostTeam } from "@maple/domain/http" +import { cn } from "@maple/ui/lib/utils" +import { AsyncData } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { failureMessage } from "@maple/views/data/load-failure" +import { alert, alertDescription } from "@maple/views/ui/alert" +import { button, linkButton } from "@maple/views/ui/button" +import * as DialogView from "@maple/views/ui/dialog-view" +import { badge } from "@maple/views/ui/display" +import { icon } from "@maple/views/ui/icon" +import { listRow } from "@maple/views/ui/list-row" +import { spinner } from "@maple/views/ui/spinner" +import { hstack, vstack } from "@maple/views/ui/stack" +import { textLink } from "@maple/views/ui/text-link" +import { typography } from "@maple/views/ui/typography" +import { AppRoute, hrefOf } from "../../route" +import { Message } from "./message" +import { type Model, type VercelStep, vercelStepOf } from "./model" +import { COST_PROVIDERS, type CostProviderEntry } from "./providers" + +type H = HtmlBuilder + +/** Where a Vercel team is connected: the token is pasted and the team picked there. */ +const CONNECT_VERCEL_HREF = hrefOf(AppRoute.SourcesNew({ search: { integration: "vercel" } })) + +/** Where the Vercel connection's token is replaced. */ +export const VERCEL_INTEGRATION_HREF = hrefOf(AppRoute.Integrations({ search: { integration: "vercel" } })) + +/** A provider's mark, as the dialog's rows lead with it. */ +export const providerMark = (h: H, provider: CostProviderEntry): Html => + icon(h, provider.icon, { size: 18, className: cn("shrink-0", provider.iconClassName) }) + +/** The connected team as the row the step is about. */ +const teamRow = (h: H, team: VercelCostTeam, isAdded: boolean): Html => + listRow(h, { + leading: providerMark(h, COST_PROVIDERS.vercel), + title: team.name, + meta: team.slug, + ...(isAdded + ? { trailing: badge(h, { variant: "muted", size: "sm" }, ["Already added"]) } + : undefined), + }) + +/** What the step says about the connection it read. */ +const connection = (h: H, model: Model, step: VercelStep): ReadonlyArray => { + switch (step._tag) { + case "NotConnected": + return [ + typography(h, { size: "sm", tone: "muted", as: "p", className: "font-prose" }, [ + "No Vercel team is connected yet. Connect one, then add it here as a cost source.", + ]), + ] + case "Added": + return [ + teamRow(h, step.team, true), + ...(step.isTokenRejected + ? [ + alert(h, { variant: "warn", size: "sm" }, [ + alertDescription(h, [ + "Vercel rejected this team's token, so its cost has stopped syncing. Replace the token on the Vercel connection.", + ]), + ]), + ] + : []), + ] + case "TokenRejected": + return [ + teamRow(h, step.team, false), + alert(h, { variant: "warn", size: "sm" }, [ + alertDescription(h, [ + "Vercel rejected this team's token. Replace the token on the Vercel connection, then add the team here.", + ]), + ]), + ] + case "Ready": + return [ + teamRow(h, step.team, false), + typography(h, { size: "xs", tone: "muted", as: "p", className: "font-prose" }, [ + "Maple reads this month and the last at once, then every day from here on. Older months fill in after that, back to eleven months. The connection's token needs an Owner, Member, Developer, Security, Billing or Enterprise Viewer role on the team.", + ]), + // A refusal is about the token: the way on is the connection, where it is replaced. + ...(model.vercelError === null + ? [] + : [ + alert(h, { variant: "crit", size: "sm" }, [ + alertDescription(h, [ + model.vercelError, + textLink( + h, + { + href: VERCEL_INTEGRATION_HREF, + tone: "inherit", + // The description is a column: the link keeps its own width. + className: "self-start underline", + }, + ["Open the Vercel connection"], + ), + ]), + ]), + ]), + ] + } +} + +const connectionLink = (h: H): Html => + linkButton(h, { href: VERCEL_INTEGRATION_HREF }, ["Open the Vercel connection"]) + +/** The step's primary action, by what the connection allows: add it, connect one, or fix its token. */ +const primary = (h: H, model: Model, step: VercelStep): ReadonlyArray => { + switch (step._tag) { + case "NotConnected": + return [linkButton(h, { href: CONNECT_VERCEL_HREF }, ["Connect Vercel"])] + case "Added": + return step.isTokenRejected ? [connectionLink(h)] : [] + case "TokenRejected": + return [connectionLink(h)] + case "Ready": + return [ + button( + h, + { + isLoading: model.sourceWrite === "Saving", + attributes: [h.OnClick(Message.ClickedConfirmAddVercel())], + }, + ["Add source"], + ), + ] + } +} + +/** + * The dialog's Vercel step: the team the org has connected, added with one click. Nothing is + * typed: the connection already holds the token. + */ +export const vercelStep = (h: H, model: Model, id: string): ReadonlyArray => { + const step = AsyncData.matchData(model.vercel.data, { + onEmpty: () => null, + onFailure: () => null, + onData: vercelStepOf, + }) + return [ + DialogView.dialogHeader(h, "dialog", [ + DialogView.dialogTitle(h, "dialog", id, "Add Vercel"), + DialogView.dialogDescription( + h, + "dialog", + id, + "Maple reads what Vercel bills the team each day by product, project and region, plan fees included.", + ), + ]), + DialogView.dialogPanel(h, "dialog", id, [ + vstack( + h, + { gap: "3" }, + AsyncData.matchData(model.vercel.data, { + onEmpty: () => [ + hstack(h, { gap: "2" }, [ + spinner(h, undefined, 14, true), + typography(h, { size: "sm", tone: "muted" }, ["Reading the Vercel connection"]), + ]), + ], + onFailure: (failure) => [ + alert(h, { variant: "crit", size: "sm" }, [ + alertDescription(h, [failureMessage(failure)]), + ]), + hstack(h, {}, [ + button( + h, + { + variant: "outline", + size: "sm", + attributes: [h.OnClick(Message.ClickedAddVercel())], + }, + ["Try again"], + ), + ]), + ], + onData: (status) => connection(h, model, vercelStepOf(status)), + }), + ), + ]), + DialogView.dialogFooter(h, "dialog", [ + button( + h, + { + variant: "outline", + isDisabled: model.sourceWrite === "Saving", + attributes: [h.OnClick(Message.ClickedBackToProviders())], + }, + ["Back"], + ), + ...(step === null ? [] : primary(h, model, step)), + ]), + ] +} diff --git a/apps/web/src/page/cost/view-filters.ts b/apps/web/src/page/cost/view-filters.ts index 44aabcae1e..cea856cc36 100644 --- a/apps/web/src/page/cost/view-filters.ts +++ b/apps/web/src/page/cost/view-filters.ts @@ -12,7 +12,7 @@ import { filterSidebarHeader, filterSidebarLoading, } from "@maple/views/ui/filters/filter-sidebar" -import { activeFilters, type CostGroupBy, DIMENSIONS, keyLabel } from "./derive" +import { activeFilters, type CostGroupBy, DIMENSIONS, keyLabel, unsplitDimensions } from "./derive" import { keyDetail, keyMark } from "./marks" import { type CostFacets, Message } from "./message" import { sourcesOf, type Model } from "./model" @@ -24,12 +24,25 @@ const RAIL_ORDER = [ "provider", "account", "cloud_service", + "resource", "service_category", "region", ] as const satisfies ReadonlyArray /** Long lists get a search field. */ -const SEARCHABLE = new Set(["cloud_service", "region", "account"]) +const SEARCHABLE = new Set(["cloud_service", "resource", "region", "account"]) + +/** + * The sections on show: a dimension no cost splits by is left out, unless the URL filters by it + * (its chip must have a checkbox to clear). + */ +const railSections = (model: Model, facets: CostFacets): ReadonlyArray => { + const unsplit = unsplitDimensions(facets) + return Arr.filter( + RAIL_ORDER, + (dimension) => !Arr.contains(unsplit, dimension) || (model.search[dimension] ?? []).length > 0, + ) +} const toSections = (message: FilterSections.Message) => Message.GotFilterSectionsMessage({ message }) @@ -97,7 +110,7 @@ export const filtersRail = (h: H, model: Model): Html => ...filterSidebarBody( h, "cost-filters", - Arr.map(RAIL_ORDER, (dimension) => section(h, model, facets, dimension)), + Arr.map(railSections(model, facets), (dimension) => section(h, model, facets, dimension)), ), ]), }) diff --git a/apps/web/src/page/cost/view-sources.ts b/apps/web/src/page/cost/view-sources.ts index 4eff5e349d..24e453667b 100644 --- a/apps/web/src/page/cost/view-sources.ts +++ b/apps/web/src/page/cost/view-sources.ts @@ -15,6 +15,7 @@ import { formatDay, requestsCost } from "./derive" import { Message } from "./message" import { COST_PROVIDERS } from "./providers" import { AWS_INTEGRATION_HREF } from "./view-add-source" +import { VERCEL_INTEGRATION_HREF } from "./view-add-vercel" type H = HtmlBuilder @@ -30,32 +31,65 @@ const STATUS = { Readonly<{ label: string; variant: "ok" | "info" | "muted" | "crit" }> > -/** What a row offers: an AWS source is its connection's, an OpenRouter one is its key. */ -const actions = (h: H, source: CostSourceSummary, isBusy: boolean): ReadonlyArray => - source.provider === "openrouter" - ? [ - button( +/** A row's action. Its accessible name says whose source it acts on: two rows can offer the same one. */ +const rowButton = (h: H, label: string, name: string, onClick: Message, isBusy: boolean): Html => + button( + h, + { + variant: "ghost", + size: "sm", + isDisabled: isBusy, + attributes: [h.AriaLabel(name), h.OnClick(onClick)], + }, + [label], + ) + +/** Where the source's connection is changed: an account's Cost module, a team's token. */ +const connectionLink = (h: H, provider: CostSourceSummary["provider"], href: string): Html => + linkButton( + h, + { + variant: "ghost", + size: "sm", + href, + attributes: [h.AriaLabel(`Manage ${COST_PROVIDERS[provider].name} connection`)], + }, + ["Manage connection"], + ) + +/** + * What a row offers: an AWS source is its connection's, an OpenRouter one is its key, and a + * Vercel one is removed here while its connection, which holds the token, stays. + */ +const actions = (h: H, source: CostSourceSummary, isBusy: boolean): ReadonlyArray => { + const { name } = COST_PROVIDERS[source.provider] + switch (source.provider) { + case "openrouter": + return [ + rowButton(h, "Replace key", `Replace ${name} key`, Message.ClickedReplaceKey(), isBusy), + rowButton( h, - { - variant: "ghost", - size: "sm", - isDisabled: isBusy, - attributes: [h.OnClick(Message.ClickedReplaceKey())], - }, - ["Replace key"], + "Remove", + `Remove ${name} source`, + Message.ClickedRemoveSource({ provider: "openrouter" }), + isBusy, ), - button( + ] + case "vercel": + return [ + connectionLink(h, "vercel", VERCEL_INTEGRATION_HREF), + rowButton( h, - { - variant: "ghost", - size: "sm", - isDisabled: isBusy, - attributes: [h.OnClick(Message.ClickedRemoveSource())], - }, - ["Remove"], + "Remove", + `Remove ${name} source`, + Message.ClickedRemoveSource({ provider: "vercel" }), + isBusy, ), ] - : [linkButton(h, { variant: "ghost", size: "sm", href: AWS_INTEGRATION_HREF }, ["Manage connection"])] + case "aws": + return [connectionLink(h, "aws", AWS_INTEGRATION_HREF)] + } +} /** What the provider billed the account for Maple's requests this month; nothing where requests are free. */ const requestsFact = (source: CostSourceSummary): ReadonlyArray => { @@ -132,7 +166,7 @@ const sourceRow = ( hstack(h, { gap: "2", wrap: true }, [ typography(h, { size: "sm", weight: "medium" }, [provider.name]), typography(h, { size: "xs", tone: "muted", mono: true, numeric: true }, [ - source.accountId, + source.accountName ?? source.accountId, ]), badge(h, { variant: status.variant, size: "sm" }, [status.label]), ]), diff --git a/apps/web/src/page/cost/view-table.ts b/apps/web/src/page/cost/view-table.ts index cba6e37073..ea035c2c07 100644 --- a/apps/web/src/page/cost/view-table.ts +++ b/apps/web/src/page/cost/view-table.ts @@ -32,6 +32,7 @@ import { typography } from "@maple/views/ui/typography" import { DIMENSIONS, drilledInto, + unsplitDimensions, formatMoney, formatMoneyChange, groupByOf, @@ -127,7 +128,11 @@ const row = ( const color = colors.get(entry.key) const mark = keyMark(dimension, entry.key, sources) const detail = keyDetail(dimension, entry.key, sources) - const next = drilledInto(model.search, filter) + const next = drilledInto( + model.search, + filter, + unsplitDimensions(Option.getOrUndefined(AsyncData.getData(model.facets.data))), + ) const name = hstack(h, { gap: "2", shrink: true }, [ ...(mark === null ? [] diff --git a/apps/web/src/page/cost/view.ts b/apps/web/src/page/cost/view.ts index f379aa8441..b7947606cd 100644 --- a/apps/web/src/page/cost/view.ts +++ b/apps/web/src/page/cost/view.ts @@ -1,3 +1,4 @@ +import { CostProvider } from "@maple/domain/cost" import type { CostSourceSummary } from "@maple/domain/http" import { Option, Array as Arr } from "effect" import { AsyncData, Submodel } from "foldkit" @@ -39,6 +40,7 @@ import { Message } from "./message" import { LONG_RANGE_MAX_SECONDS } from "../../route/session-time-range" import { keyMark } from "./marks" import { canManageSources, cannotManageSources, sourcesOf, type Model } from "./model" +import { COST_PROVIDERS } from "./providers" import { DEFAULT_PRESET } from "./update" import { addSourceDialog, removeSourceDialog } from "./view-add-source" import { dailyChart } from "./view-chart" @@ -65,7 +67,7 @@ const onRamp = ( emptyMedia(h, { variant: "icon" }, [icon(h, "CircleDollarIcon")]), emptyTitle(h, ["No cost source yet"]), emptyDescription(h, [ - "Add a source and Maple reads what it bills each day: an AWS account by service, region and account, an OpenRouter account by model.", + "Add a source and Maple reads what it bills each day: an AWS account by service, region and account, an OpenRouter account by model, a Vercel team by product and project.", ]), // A member's save would be refused: say who can, and offer no form. ...(!options.cannotManage @@ -108,17 +110,27 @@ const onRamp = ( /** * What the first syncs bring in, by the providers that have no cost yet (waiting or failing - * alike): AWS keeps over a year, OpenRouter four weeks. + * alike): AWS and Vercel keep about a year, OpenRouter four weeks. */ const firstSyncScope = (sources: ReadonlyArray): string => { - const unsynced = Arr.filter(sources, (source) => source.watermarkDate === null) - const hasOpenRouter = Arr.some(unsynced, (source) => source.provider === "openrouter") - const hasOther = Arr.some(unsynced, (source) => source.provider !== "openrouter") - return hasOpenRouter && hasOther - ? "AWS brings in this month and the last, and older months fill in after that. OpenRouter brings in its last four weeks." - : hasOpenRouter - ? "It brings in the last four weeks, which is all OpenRouter keeps." - : "It brings in this month and the last, and older months fill in after that." + const unsynced = Arr.dedupe( + Arr.map( + Arr.filter(sources, (source) => source.watermarkDate === null), + (source) => source.provider, + ), + ) + const hasOpenRouter = Arr.contains(unsynced, "openrouter") + // In the order the page names providers everywhere else. + const others = Arr.map( + Arr.filter( + CostProvider.literals, + (provider) => provider !== "openrouter" && Arr.contains(unsynced, provider), + ), + (provider) => COST_PROVIDERS[provider].name, + ) + if (!hasOpenRouter) return "It brings in this month and the last, and older months fill in after that." + if (others.length === 0) return "It brings in the last four weeks, which is all OpenRouter keeps." + return `${Arr.join(others, " and ")} ${others.length === 1 ? "brings" : "bring"} in this month and the last, and older months fill in after that. OpenRouter brings in its last four weeks.` } /** Sources exist and none has cost yet: what the reader is waiting for. */ @@ -183,11 +195,13 @@ const toolbar = (h: H, model: Model): Html => { className: "mb-0", }) return hstack(h, { gap: "4", wrap: true, className: "gap-y-2" }, [ - hstack(h, { gap: "2" }, [ - typography(h, { size: "xs", tone: "muted" }, ["Group by"]), + hstack(h, { gap: "2", className: "min-w-0 max-w-full" }, [ + typography(h, { size: "xs", tone: "muted", className: "shrink-0" }, ["Group by"]), segmentedSelect(h, { ariaLabel: "Group by", size: "sm", + // Six options outgrow a narrow page beside the rail: the group scrolls, not the page. + className: "min-w-0 overflow-x-auto", value: groupByOf(model.search), options: Arr.map(COST_GROUP_BYS, (dimension) => ({ value: dimension, diff --git a/apps/web/src/page/settings/kit/audit-log-resource.ts b/apps/web/src/page/settings/kit/audit-log-resource.ts index 5b4fc16e52..4e27cde624 100644 --- a/apps/web/src/page/settings/kit/audit-log-resource.ts +++ b/apps/web/src/page/settings/kit/audit-log-resource.ts @@ -101,6 +101,7 @@ function resourceLink(entry: AuditEntryResource): AuditResourceLink | null { case "ai_provider_key": return { to: "/settings", search: { tab: "ai" } } case "cost_source_key": + case "cost_source": return { to: "/cost" } case "warehouse_settings": return { to: "/settings", search: { tab: "data-platform" } } diff --git a/apps/web/src/route/search.ts b/apps/web/src/route/search.ts index 30b707057d..3c1a7e4858 100644 --- a/apps/web/src/route/search.ts +++ b/apps/web/src/route/search.ts @@ -566,7 +566,14 @@ export const AnomaliesSearch = Schema.Struct({ export const CodeReviewSearch = Schema.Struct(CodeReviewSearchFields) /** The dimensions `/cost` groups by and filters on (a subset of `CostDimension`). */ -export const COST_GROUP_BYS = ["cloud_service", "provider", "service_category", "region", "account"] as const +export const COST_GROUP_BYS = [ + "cloud_service", + "provider", + "service_category", + "region", + "resource", + "account", +] as const /** * `/cost`: the day range, the grouping (`cloud_service` when absent), the table's order (`cost` @@ -580,6 +587,7 @@ export const CostSearch = Schema.Struct({ provider: OptionalStringArrayParam, service_category: OptionalStringArrayParam, region: OptionalStringArrayParam, + resource: OptionalStringArrayParam, account: OptionalStringArrayParam, }) diff --git a/docs/cloud-cost-plan.md b/docs/cloud-cost-plan.md index 79b3525cc8..99fb3508d9 100644 --- a/docs/cloud-cost-plan.md +++ b/docs/cloud-cost-plan.md @@ -1,7 +1,8 @@ # Cloud cost: plan Status: **phase 1 built, 2026-11-11, behind the `cloudCost` org flag.** AWS spend is synced daily -into `cost_daily` and shown on `/cost`. OpenRouter followed on 2026-11-12 as the second source. +into `cost_daily` and shown on `/cost`. OpenRouter followed on 2026-11-12 as the second source, +Vercel the same day as the third. Phase 0 checked the provider APIs against docs, public schemas and one real AWS account. Recommendations (phase 2) and the other providers are not built. @@ -11,8 +12,8 @@ An org sees what its cloud and platform accounts cost, per day, service and reso telemetry Maple already has for the same resources. Maple then lists what to cut, with the usage chart that backs each suggestion and an estimated monthly saving. -It reads from the connections an org already has (AWS, GCP, Cloudflare, Railway, PlanetScale, -Supabase). No second onboarding. +It reads from the connections an org already has (AWS, Vercel, GCP, Cloudflare, Railway, +PlanetScale, Supabase). No second onboarding. Non-goals for v1: @@ -45,7 +46,9 @@ warehouse are not. One adapter per provider, in `packages/backend/src/services/cost/providers/`, behind one port. The registry covers cost only. The integrations themselves are not refactored. A provider that is not an integration (OpenRouter) keeps its credential in a table of its own, and -`cost_sources.connection_ref` points at that row. +`cost_sources.connection_ref` points at that row. A connection that exists for more than cost +(Vercel) supplies cost only once the org adds it on the Cost page: its `cost_sources` row is the +opt-in, and no column on the connection says so. ```ts interface CostProvider { @@ -90,7 +93,8 @@ It is a small table (hundreds of rows per source per day), so it needs no rollup - `cost_sources`: one row per connection that supplies cost. Its lease, frontiers (newest and oldest day pulled), last error and its reason, and the provider requests made this month. A connection that stops supplying cost pauses its source and keeps its history. Only a deleted - connection deletes it. + connection deletes it. A Vercel source is also deleted when the org removes it on the Cost page + and when its connection moves to another team, whose spend would overwrite the source's days. - `cost_recommendations` (phase 2): followed across runs by `(source, fingerprint)`. Kind, resource, title, estimated monthly saving and currency, effort, risk, evidence (jsonb), origin (`provider` or `maple`), status (`open`, `dismissed`, `accepted`, `resolved`). A resolved one @@ -113,7 +117,8 @@ The windows above are the AWS ones. Each provider has a profile (`COST_SYNC_PROF `sync-plan.ts`): the oldest day it serves and how far its newest day trails today. No window reaches outside them, because a day the provider no longer serves comes back empty, and an empty day replaces the stored one. OpenRouter serves 30 completed days: its sync asks for 28 at most and -never for today. +never for today. Vercel serves a year in one request, today included: its history starts at the +first day of the month eleven months back, so no window is longer than a year. Each step writes its days whole under one `IngestedAt`. A day a pull returned nothing for gets a marker row (`ChargeCategory = 'none'`), so it supersedes the older pull. Reads leave it out. @@ -138,20 +143,24 @@ currency the figures are in and lists the others. Nothing is added across curren ### Surfaces - Web: `/cost` behind the `cloudCost` flag. One page: the window against the period before, a - daily chart, a breakdown by service, provider, category, region, usage type or account with - filters, and each source's sync state. Sources are added from the page: AWS through its + daily chart, a breakdown by service, provider, category, region, resource or account, a filter + rail with a section per dimension, and each source's sync state. A row click narrows to the + row and regroups by the next dimension (a service under a region goes on to its resources). + The resource section and that step show only where some cost names a resource. Sources are added from the page: AWS through its connection (which has a Cost switch), OpenRouter by pasting a management key, which the page can - also replace or remove. Recommendations (list and detail with the evidence chart) come with - phase 2. + also replace or remove, Vercel by adding the team the org has connected, which the page can + remove again. Recommendations (list and detail with the evidence chart) come with phase 2. - API: four internal endpoints in the query-engine group (`cost-totals`, `cost-timeseries`, - `cost-breakdown`, `cost-sources`), and `PUT` / `DELETE /internal/cost/openrouter` for the - OpenRouter key. A public `/v2/cost/*` is not built. + `cost-breakdown`, `cost-sources`), `PUT` / `DELETE /internal/cost/openrouter` for the + OpenRouter key, and `GET` / `PUT` / `DELETE /internal/cost/vercel` for the Vercel source. A + public `/v2/cost/*` is not built. - MCP: `cost_overview`, `cost_breakdown`, `compare_cost_periods`, `list_cost_recommendations`, `inspect_cost_recommendation`. ## Providers: what phase 0 found -"Live" means checked against a real account on 2026-11-11. "Docs" and "schema" mean read, not run. +"Live" means checked against a real account on 2026-11-11. "Docs", "spec" and "schema" mean read, +not run. ### AWS (billed) @@ -215,6 +224,63 @@ currency the figures are in and lists the others. Nothing is added across curren - A day OpenRouter answers nothing for is stored as empty, like any provider's. An empty answer for an account that had spend would hide up to 28 days until the next weekly re-pull. +### Vercel (billed) + +Nothing here was run against a real team: there was none to test with. The request and the +response shape are Vercel's OpenAPI spec. The adapter, the normalizer and the page are tested +against a fake server that answers what the spec describes. + +- Spec: `GET /v1/billing/charges?teamId=&from=&to=` returns the team's billed charges as FOCUS + v1.3 JSONL, one charge per line, at one-day granularity. `from` is inclusive and `to` exclusive, + both ISO 8601 in UTC, a year apart at most. The answer is streamed, so Maple reads it line by + line and sums as it goes. +- It takes the access token of the Vercel connection the org already has. No second credential + and no new table: the source points at `vercel_connections.id`. Its account, and the account + on every row, is the team's id, not its slug: a team can rename its slug, and rows already + stored keep the account they were pulled with, so a slug would split one team into two + accounts. The slug is read with the sources and is what the page shows ("Vercel "). +- Adding names the team the dialog showed. A connection that reads another team by then, or + whose token was replaced while the check read was in flight, is refused as changed. +- Spec: readable by the team's Owner, Member, Developer, Security, Billing and Enterprise Viewer + roles. A token of another role answers 403, which the source reports as not ready with the + roles that work. Adding the source reads yesterday and today once first (the days every daily + pull ends on), so that refusal shows in the dialog and not as a first sync that fails. +- Spec: 401 is a rejected token (the source needs the team connected again), 404 and 410 a team + or billing data that is gone, 429 a rate limit. A connection whose token the integration's + poller already found rejected is not asked at all. A new token saved for the same team makes + a source due at the next tick, whenever it last synced. A 400 is a request Vercel calls + invalid: the source keeps Vercel's own message, and an add shows it in the dialog, since the + request shape is the part not run live. +- Requests are free, so the source counts none. +- Rows: the product (`ServiceName`) is `CloudService`, its `SkuId` the `UsageType` (left empty + when it equals the product name), `ServiceCategory` the category (`Other` when absent), + `RegionId` the region (`global` when absent), and the project (`Tags.ProjectName`, else + `Tags.ProjectId`) the resource, which the page groups and filters by. On the page the team + reads as "Vercel " (the slug comes with the sources read, the key is the team id), and + its services and regions wear the Vercel mark where no other + platform of the org could have billed them. `ChargeCategory` maps + onto Maple's (`Usage`, `Purchase`, `Credit`, `Tax`, `Adjustment`), so plan fees and credits + are their own rows. Lines of one day, + product, SKU, project, region and kind of charge are summed. A sum with no billed and no + effective cost is left out. Credits are negative and kept. +- Spec: no list price is reported, so `ListCost` is the billed cost. +- Every field but `BilledCost`, `ChargePeriodStart` and `ServiceName` is read as optional, since + the spec is the only source. A line that is not such a charge fails the whole pull as an + invalid response: storing part of a day would hide the older pull of it. So does a + `ChargePeriodStart` that is not a UTC date-time on a real calendar day (no February 31st, a + February 29th in leap years only), an amount or quantity that is not finite (`1e309` is valid + JSON), lines that sum past what a number holds, and any answer but a 200 (a 204 or a 206 + would store days empty or by halves). +- Open risk: a 200 with an empty body reads as a team with no charges, and the window's days + are stored empty. Nothing in the answer tells an empty month from a broken one, so a broken + empty answer would hide up to two months until the next re-pull of them. The JSONL has no + count or end marker to check against. +- Open: when a day stops changing. Today and yesterday are stored as not final, and the four + newest days are pulled again daily. +- Open: how large a busy team's answer is. A day above 10,000 rows is refused like any + provider's. +- Not split: the team's telemetry service per project. `ServiceName` (Maple's) stays empty. + ### GCP (billed, needs a customer export) - Docs: no API returns cost. The customer must enable the detailed usage cost export to BigQuery @@ -272,9 +338,9 @@ Each phase is one PR stack. Plan imports. First Maple rules, each backed by a metric already ingested: load balancers with no requests, databases with idle connections and low CPU, container services reserving far more than they use. -3. **More providers.** OpenRouter is built. Railway, GCP, PlanetScale, Supabase, Cloudflare, one - PR each. Each provider adds a literal to `CostProvider`, an adapter, a reconcile function and a - sync profile. +3. **More providers.** OpenRouter and Vercel are built. Railway, GCP, PlanetScale, Supabase, + Cloudflare, one PR each. Each provider adds a literal to `CostProvider`, an adapter, a + reconcile function and a sync profile. 4. **Closing the loop.** Budgets and cost anomalies as managed alert signals, a cost block in the weekly digest, cost per request on service pages, realized savings once an accepted recommendation stops firing. diff --git a/packages/backend/src/queries/cost-reads.test.ts b/packages/backend/src/queries/cost-reads.test.ts index 110c2995b0..ec54068384 100644 --- a/packages/backend/src/queries/cost-reads.test.ts +++ b/packages/backend/src/queries/cost-reads.test.ts @@ -639,6 +639,40 @@ describe("makeCostReads", () => { }, ) + it.effect("names a Vercel source by its team's slug, beside the team id its rows carry", () => { + const testDb = createTestDb(trackedDbs) + return Effect.gen(function* () { + yield* insertSources(testDb, [{ id: SOURCE_A }]) + yield* Effect.promise(() => + executeSql( + testDb, + `WITH source AS ( + INSERT INTO sources (id, org_id, name, platform, kind, created_at, updated_at) + VALUES (gen_random_uuid(), $1, 'Acme', 'vercel', 'integration', now(), now()) RETURNING id + ), connection AS ( + INSERT INTO vercel_connections + (id, org_id, source_id, team_id, team_slug, team_name, token_ciphertext, token_iv, token_tag, + connected_by_user_id, project_scope, created_at, updated_at) + SELECT 'conn_vercel', $1, id, 'team_1', 'acme-labs', 'Acme', 'sealed', 'iv', 'tag', 'user_a', 'all', now(), now() + FROM source RETURNING id + ) + INSERT INTO cost_sources (id, org_id, provider, connection_ref, account_id, created_at, updated_at) + SELECT $2, $1, 'vercel', id, 'team_1', now(), now() FROM connection`, + [ORG, SOURCE_B], + ), + ) + const { reads } = yield* makeHarness(testDb) + const { sources } = yield* reads.sources(tenant) + expect( + Arr.map(sources, (source) => [source.provider, source.accountId, source.accountName]), + ).toEqual([ + // An AWS account is known by its id: it carries no other name. + ["aws", "000000000000", undefined], + ["vercel", "team_1", "acme-labs"], + ]) + }).pipe(Effect.provide(testDb.layer)) + }) + it.effect("refuses a window wider than a cost read takes, before asking the warehouse", () => { const testDb = createTestDb(trackedDbs) return Effect.gen(function* () { diff --git a/packages/backend/src/queries/cost-reads.ts b/packages/backend/src/queries/cost-reads.ts index 7f621b28c8..9ccd376fa8 100644 --- a/packages/backend/src/queries/cost-reads.ts +++ b/packages/backend/src/queries/cost-reads.ts @@ -1,7 +1,7 @@ // Cloud cost reads for the dashboard: the registry queries over `cost_daily`, narrowed to the // org's connected sources and shaped for the wire, and the sources' own sync state from Postgres. import * as PG from "@maple-dev/effect-orm/postgres" -import { CostSources, type CostSourceRow } from "@maple/db/tables" +import { CostSources, type CostSourceRow, VercelConnections } from "@maple/db/tables" import { CloudCostDisabledError, CloudCostPersistenceError, @@ -119,13 +119,18 @@ const estimatedFromOf = (rows: ReadonlyArray) => ), ) -const toSourceSummary = (row: CostSourceRow, nowMs: number): CostSourceSummary => { +const toSourceSummary = ( + row: CostSourceRow, + nowMs: number, + accountName: string | undefined, +): CostSourceSummary => { const historyComplete = row.backfillDate !== null && row.backfillDate <= historyFloor(row.provider, utcToday(nowMs)) return { id: row.id, provider: row.provider, accountId: row.accountId, + ...(accountName === undefined ? undefined : { accountName }), status: !row.enabled ? "paused" : row.lastError !== null @@ -308,7 +313,24 @@ export const makeCostReads = (deps: CostReadsDeps) => { const sources = Effect.fn("CostReads.sources")(function* (tenant: TenantContext) { const rows = yield* sourcesInRollout(tenant.orgId) const nowMs = yield* Clock.currentTimeMillis - return { sources: Arr.map(rows, (row) => toSourceSummary(row, nowMs)) } + // A Vercel source's account is its team's id, which is stable; the team's slug names it. + const teams = Arr.some(rows, (row) => row.provider === "vercel") + ? yield* dbExecute((db) => + db.run( + PG.from(VercelConnections) + .select("id", "teamSlug") + .where(($) => [$.orgId.eq(tenant.orgId)]), + ), + ) + : [] + const nameOf = (row: CostSourceRow) => + row.provider === "vercel" + ? Arr.findFirst(teams, (team) => team.id === row.connectionRef).pipe( + Option.map((team) => team.teamSlug), + Option.getOrUndefined, + ) + : undefined + return { sources: Arr.map(rows, (row) => toSourceSummary(row, nowMs, nameOf(row))) } }) return { totals, timeseries, breakdown, sources } as const diff --git a/packages/backend/src/services/cost/CostSyncService.test.ts b/packages/backend/src/services/cost/CostSyncService.test.ts index ddfe364642..d901d30939 100644 --- a/packages/backend/src/services/cost/CostSyncService.test.ts +++ b/packages/backend/src/services/cost/CostSyncService.test.ts @@ -29,6 +29,11 @@ import { type CostProviderAdapter, CostProviderError, type CostPullRow } from ". import { CostSyncService } from "./CostSyncService" import { AwsCostProvider } from "./providers/aws/AwsCostProvider" import { OpenRouterCostProvider } from "./providers/openrouter/OpenRouterCostProvider" +import { + VercelCostProvider, + type VercelCostProviderApi, + VercelCostReadError, +} from "./providers/vercel/VercelCostProvider" import { BACKFILL_REQUEST_BUDGET, daysOfWindow, MONTHLY_REQUEST_BUDGET } from "./sync-plan" const trackedDbs: TestDb[] = [] @@ -43,6 +48,7 @@ const CONNECTION_A = "0190f2b3-0000-7000-8000-0000000000a1" const CONNECTION_B = "0190f2b3-0000-7000-8000-0000000000b1" const CONNECTION_C = "0190f2b3-0000-7000-8000-0000000000c1" const OPENROUTER_KEY_A = "0190f2b3-0000-7000-8000-0000000000d1" +const VERCEL_CONNECTION_A = "0190f2b3-0000-7000-8000-0000000000e1" /** What every fake pull reports as billable. */ const REQUESTS_PER_PULL = 2 @@ -197,6 +203,19 @@ const harness = (options: FakeProviderOptions = {}) => { } }), }) + // The sync only pulls; `read` is what an add checks with, answered the same way here. + const vercelPull = adapterOf("vercel") + const vercelAdapter: VercelCostProviderApi = { + ...vercelPull, + read: (source, window) => + vercelPull + .pull(source, window) + .pipe( + Effect.mapError( + ({ message, reason }) => new VercelCostReadError({ message, reason, kind: "other" }), + ), + ), + } const writes: Array> = [] const datasources: string[] = [] const tenants: string[] = [] @@ -240,6 +259,7 @@ const harness = (options: FakeProviderOptions = {}) => { Layer.mergeAll( Layer.succeed(AwsCostProvider, adapterOf("aws")), Layer.succeed(OpenRouterCostProvider, adapterOf("openrouter")), + Layer.succeed(VercelCostProvider, vercelAdapter), warehouse, featureFlags, ), @@ -282,6 +302,26 @@ const harness = (options: FakeProviderOptions = {}) => { VALUES ($1, $2, 'sealed', 'iv', 'tag', 'sk-or-v1-abc...123', 'user_account_a', now(), now(), 'user_a')`, [key.id, key.orgId ?? "org_a"], ) + /** An org's Vercel connection, and with `asCostSource` the source the org added for it. */ + const connectVercel = (connection: { readonly id: string; readonly asCostSource: boolean }) => + Effect.promise(async () => { + const sourceId = await insertTestSource(testDb, { orgId: "org_a", platform: "vercel" }) + await executeSql( + testDb, + `INSERT INTO vercel_connections + (id, org_id, source_id, team_id, team_slug, team_name, token_ciphertext, token_iv, token_tag, + connected_by_user_id, project_scope, created_at, updated_at) + VALUES ($1, 'org_a', $2, 'team_1', 'acme', 'Acme', 'sealed', 'iv', 'tag', 'user_a', 'all', now(), now())`, + [connection.id, sourceId], + ) + if (!connection.asCostSource) return + await executeSql( + testDb, + `INSERT INTO cost_sources (id, org_id, provider, connection_ref, account_id, created_at, updated_at) + VALUES ('0190f2b3-0000-7000-8000-0000000000f1', 'org_a', 'vercel', $1, 'team_1', now(), now())`, + [connection.id], + ) + }) const sourceRefs = Effect.promise(() => testDb.pglite.query<{ org: string; ref: string; account: string }>( "SELECT org_id AS org, connection_ref AS ref, account_id AS account FROM cost_sources ORDER BY org_id, connection_ref", @@ -322,6 +362,7 @@ const harness = (options: FakeProviderOptions = {}) => { sql, connect, saveOpenRouterKey, + connectVercel, sourceRefs, sourceState, sourceStateOf, @@ -430,6 +471,106 @@ describe("CostSyncService OpenRouter sources", () => { }) }) +describe("CostSyncService Vercel sources", () => { + it.effect("makes no source for a Vercel connection the org did not add as one", () => { + const h = harness() + return Effect.gen(function* () { + yield* TestClock.setTime(NOW) + yield* h.connectVercel({ id: VERCEL_CONNECTION_A, asCostSource: false }) + const summary = yield* poll + assert.deepStrictEqual([yield* h.sourceRefs, summary.due, h.pulls.length], [[], 0, 0]) + }).pipe(Effect.provide(h.layer)) + }) + + it.effect("pulls an added source from the previous month through today, then a month of history", () => { + const h = harness() + return Effect.gen(function* () { + yield* TestClock.setTime(NOW) + yield* h.connectVercel({ id: VERCEL_CONNECTION_A, asCostSource: true }) + const summary = yield* poll + assert.deepStrictEqual([summary.due, summary.synced], [1, 1]) + // Today is served, and each window is one request far under Vercel's year. + assert.deepStrictEqual(h.windows(), [ + ["2026-10-01", "2026-11-11"], + ["2026-09-01", "2026-09-30"], + ["2026-08-01", "2026-08-31"], + ]) + const state = yield* h.sourceStateOf("org_a", VERCEL_CONNECTION_A) + assert.deepStrictEqual( + [state?.watermark, state?.backfill, state?.error], + ["2026-11-11", "2026-08-01", null], + ) + assert.isTrue(Arr.every(h.writtenRows(), (row) => row.Provider === "vercel")) + }).pipe(Effect.provide(h.layer)) + }) + + it.effect("walks history down to eleven months before this one, and no further", () => { + const h = harness() + return Effect.gen(function* () { + yield* TestClock.setTime(NOW) + yield* h.connectVercel({ id: VERCEL_CONNECTION_A, asCostSource: true }) + yield* h.sql( + `UPDATE cost_sources SET watermark_date = '2026-11-11', backfill_date = '2026-01-01', + last_synced_at = now(), last_settled_at = now()`, + ) + yield* poll + assert.deepStrictEqual(h.windows(), [["2025-12-01", "2025-12-31"]]) + // The floor is reached: a year back from today would start a day past what one request serves. + yield* TestClock.adjust("5 minutes") + assert.strictEqual((yield* poll).due, 0) + }).pipe(Effect.provide(h.layer)) + }) + + it.effect( + "pulls a source whose token was replaced at the next tick, though it synced minutes ago", + () => { + const h = harness() + return Effect.gen(function* () { + yield* TestClock.setTime(NOW) + yield* h.connectVercel({ id: VERCEL_CONNECTION_A, asCostSource: true }) + // History walked to the floor and the head pulled just now: nothing is due for a day. + yield* h.sql( + `UPDATE cost_sources SET watermark_date = '2026-11-11', backfill_date = '2025-12-01', + last_synced_at = now(), last_settled_at = now()`, + ) + assert.strictEqual((yield* poll).due, 0) + // What a new token for the team writes to the source (`releaseVercelCostSources`). + yield* h.sql( + `UPDATE cost_sources SET last_synced_at = NULL, next_attempt_at = NULL, lease_until = NULL, + last_error = NULL, last_error_reason = NULL, consecutive_failures = 0`, + ) + yield* TestClock.adjust("5 minutes") + const summary = yield* poll + assert.deepStrictEqual([summary.due, summary.synced], [1, 1]) + // The trailing days again, read with the new token; history is not walked twice. + assert.deepStrictEqual(h.windows(), [["2026-11-08", "2026-11-11"]]) + }).pipe(Effect.provide(h.layer)) + }, + ) + + it.effect( + "removes the source of a connection that is gone, and leaves the org's AWS source alone", + () => { + const h = harness() + return Effect.gen(function* () { + yield* TestClock.setTime(NOW) + yield* h.connectVercel({ id: VERCEL_CONNECTION_A, asCostSource: true }) + yield* h.connect({ id: CONNECTION_A }) + yield* poll + assert.deepStrictEqual(yield* h.sourceRefs, [ + ["org_a", CONNECTION_A, "123456789012"], + ["org_a", VERCEL_CONNECTION_A, "team_1"], + ]) + + yield* h.sql("DELETE FROM vercel_connections") + yield* TestClock.adjust("1 hour") + yield* poll + assert.deepStrictEqual(yield* h.sourceRefs, [["org_a", CONNECTION_A, "123456789012"]]) + }).pipe(Effect.provide(h.layer)) + }, + ) +}) + describe("CostSyncService sources", () => { it.effect("makes a source only for a verified connection that asks for cost and is granted it", () => { const h = harness() diff --git a/packages/backend/src/services/cost/CostSyncService.ts b/packages/backend/src/services/cost/CostSyncService.ts index c978694bca..77722b765b 100644 --- a/packages/backend/src/services/cost/CostSyncService.ts +++ b/packages/backend/src/services/cost/CostSyncService.ts @@ -30,8 +30,10 @@ import { pollFleet } from "../integrations/poll-fleet" import type { CostProviderAdapter, CostProviderError } from "./CostProvider" import { reconcileAwsCostSources } from "./aws-cost-sources" import { reconcileOpenRouterCostSources } from "./openrouter-cost-sources" +import { reconcileVercelCostSources } from "./vercel-cost-sources" import { AwsCostProvider } from "./providers/aws/AwsCostProvider" import { OpenRouterCostProvider } from "./providers/openrouter/OpenRouterCostProvider" +import { VercelCostProvider } from "./providers/vercel/VercelCostProvider" import { advanceCostSync, batchWholeDays, @@ -165,6 +167,7 @@ export class CostSyncService extends Context.Service const dbExecute = makeDbExecute( database, @@ -204,9 +207,20 @@ export class CostSyncService extends Context.Service reconcileVercelCostSources(db)) + yield* Effect.annotateCurrentSpan({ + "maple.cost.provider": "vercel", + "maple.cost.sources_removed": reconciled.removed, + }) + }) + /** One function per provider; each owns the sources of its own connection table. */ const reconcile = (now: number) => - Effect.all([reconcileAws(now), reconcileOpenRouter(now)], { discard: true }) + Effect.all([reconcileAws(now), reconcileOpenRouter(now), reconcileVercel()], { + discard: true, + }) /** Enabled sources with a pull due and no backoff, healthy ones first. */ const loadDue = (now: number) => { @@ -668,6 +682,7 @@ export class CostSyncService extends Context.Service cleanupTestDbs(trackedDbs)) + +const ORG = Schema.decodeSync(OrgId)("org_cost") +const NOW = DateTime.toEpochMillis(DateTime.makeUnsafe("2026-11-12T09:00:00Z")) +/** The check read: yesterday and today, as a head pull ends. */ +const CHECK_URL = + "https://api.vercel.com/v1/billing/charges?teamId=team_1&from=2026-11-11T00%3A00%3A00Z&to=2026-11-13T00%3A00%3A00Z" + +const run = ( + testDb: TestDb, + world: FakeVercel, + effect: Effect.Effect, + encryptionKey?: string, +) => + Effect.gen(function* () { + yield* TestClock.setTime(NOW) + return yield* effect + }).pipe( + Effect.provide( + Layer.effect(VercelCostSourceService, VercelCostSourceService.make).pipe( + Layer.provide(Layer.effect(VercelCostProvider, VercelCostProvider.make)), + Layer.provideMerge(fakeVercelDeps(testDb, world, encryptionKey)), + ), + ), + ) + +const status = VercelCostSourceService.use((service) => service.status(ORG)) +/** Adds the team the Cost page was shown, `team_1` unless said otherwise. */ +const addTeam = (teamId: string) => VercelCostSourceService.use((service) => service.add(ORG, teamId)) +const add = addTeam("team_1") +const remove = VercelCostSourceService.use((service) => service.remove(ORG)) + +interface Source { + readonly connection_ref: string + readonly account_id: string + readonly enabled: boolean + readonly sources: number +} + +/** The org's Vercel source, with how many it has. */ +const source = (testDb: TestDb) => + Effect.promise(() => + queryFirstRow( + testDb, + `SELECT connection_ref, account_id, enabled, (count(*) OVER ())::int AS sources + FROM cost_sources WHERE org_id = 'org_cost' AND provider = 'vercel'`, + ), + ) + +const sql = (testDb: TestDb, statement: string) => Effect.promise(() => testDb.pglite.query(statement)) + +describe("VercelCostSourceService", () => { + it.effect("says whether the org has a Vercel team, and whether it is a cost source", () => { + const testDb = createTestDb(trackedDbs) + const world = makeFakeVercel() + return run( + testDb, + world, + Effect.gen(function* () { + assert.deepStrictEqual(yield* status, { team: null, authFailed: false, added: false }) + yield* connectFakeVercel({ orgId: ORG, teamName: "Acme Inc", teamSlug: "acme" }) + const team = { id: "team_1", name: "Acme Inc", slug: "acme" } + assert.deepStrictEqual(yield* status, { team, authFailed: false, added: false }) + yield* add + assert.deepStrictEqual(yield* status, { team, authFailed: false, added: true }) + // A team the org only connected reads no billing: status alone asks Vercel nothing. + assert.lengthOf(world.urls, 1) + yield* sql(testDb, "UPDATE vercel_connections SET auth_failed_at = now()") + assert.deepStrictEqual(yield* status, { team, authFailed: true, added: true }) + }), + ) + }) + + it.effect("adds the connected team once Vercel let its token read the newest charges", () => { + const testDb = createTestDb(trackedDbs) + const world = makeFakeVercel() + return run( + testDb, + world, + Effect.gen(function* () { + const connectionRef = yield* connectFakeVercel({ orgId: ORG, token: "vc_token_abc" }) + const added = yield* add + assert.deepStrictEqual(added, { + team: { id: "team_1", name: "Acme", slug: "acme" }, + created: true, + }) + assert.deepStrictEqual(world.urls, [CHECK_URL]) + assert.deepStrictEqual(world.authorizations, ["Bearer vc_token_abc"]) + // The source points at the connection. + assert.deepStrictEqual(yield* source(testDb), { + connection_ref: connectionRef, + // The team's id: its slug can be renamed, and rows already stored keep their account. + account_id: "team_1", + enabled: true, + sources: 1, + }) + }), + ) + }) + + it.effect("adding a team that is a source already changes nothing and asks Vercel nothing", () => { + const testDb = createTestDb(trackedDbs) + const world = makeFakeVercel() + return run( + testDb, + world, + Effect.gen(function* () { + yield* connectFakeVercel({ orgId: ORG }) + yield* add + const first = yield* source(testDb) + // Vercel would refuse now, but the source exists: there is nothing to check. + world.chargesStatus = 403 + assert.deepStrictEqual(yield* add, { + team: { id: "team_1", name: "Acme", slug: "acme" }, + created: false, + }) + assert.lengthOf(world.urls, 1) + assert.deepStrictEqual(yield* source(testDb), first) + }), + ) + }) + + it.effect("refuses, with the reason, a token Vercel does not let read the team's billing", () => { + const testDb = createTestDb(trackedDbs) + const world = makeFakeVercel() + return run( + testDb, + world, + Effect.gen(function* () { + yield* connectFakeVercel({ orgId: ORG }) + const refusals = yield* Effect.forEach([403, 401, 404], (status) => + Effect.gen(function* () { + world.chargesStatus = status + const error = yield* Effect.flip(add) + return [error._tag, error.message] + }), + ) + assert.deepStrictEqual(refusals, [ + [ + "@maple/http/errors/IntegrationsValidationError", + "This Vercel token's role cannot read the team's billing. Use a token from an Owner, Member, Developer, Security, Billing or Enterprise Viewer role.", + ], + [ + "@maple/http/errors/IntegrationsValidationError", + "Vercel rejected the saved token. Replace the token of the Vercel connection.", + ], + [ + "@maple/http/errors/IntegrationsValidationError", + "Vercel has no billing data for this team, or the team is gone.", + ], + ]) + assert.isUndefined(yield* source(testDb)) + }), + ) + }) + + it.effect("refuses a team whose token the integration found rejected, without asking Vercel", () => { + const testDb = createTestDb(trackedDbs) + const world = makeFakeVercel() + return run( + testDb, + world, + Effect.gen(function* () { + yield* connectFakeVercel({ orgId: ORG, authFailedAt: NOW - 60_000 }) + const error = yield* Effect.flip(add) + assert.strictEqual(error._tag, "@maple/http/errors/IntegrationsValidationError") + assert.include(error.message, "Replace the token") + assert.deepStrictEqual(world.urls, []) + assert.isUndefined(yield* source(testDb)) + }), + ) + }) + + it.effect("reports a Vercel outage or an unreadable answer as retryable, not as a bad token", () => { + const testDb = createTestDb(trackedDbs) + const world = makeFakeVercel() + return run( + testDb, + world, + Effect.gen(function* () { + yield* connectFakeVercel({ orgId: ORG }) + const tags = yield* Effect.forEach([500, 429], (status) => + Effect.gen(function* () { + world.chargesStatus = status + return (yield* Effect.flip(add))._tag + }), + ) + world.chargesStatus = 200 + world.charges = "maintenance" + const unreadable = yield* Effect.flip(add) + assert.deepStrictEqual( + [...tags, unreadable._tag], + [ + "@maple/http/errors/IntegrationsUpstreamError", + "@maple/http/errors/IntegrationsUpstreamError", + "@maple/http/errors/IntegrationsUpstreamError", + ], + ) + assert.isUndefined(yield* source(testDb)) + }), + ) + }) + + it.effect("does not blame the token when Maple cannot open it", () => { + const testDb = createTestDb(trackedDbs) + const world = makeFakeVercel() + return Effect.gen(function* () { + // Sealed with the usable key, read on a deployment that has lost it. + yield* run(testDb, world, connectFakeVercel({ orgId: ORG })) + const error = yield* Effect.flip(run(testDb, world, add, "not-a-32-byte-key")) + assert.strictEqual(error._tag, "@maple/http/errors/IntegrationsUpstreamError") + assert.deepStrictEqual(world.urls, []) + assert.isUndefined(yield* source(testDb)) + }) + }) + + it.effect( + "refuses an org with no Vercel connection, and one outside the rollout, before any read", + () => { + const testDb = createTestDb(trackedDbs) + const world = makeFakeVercel() + return run( + testDb, + world, + Effect.gen(function* () { + const notConnected = yield* Effect.flip(add) + assert.strictEqual(notConnected._tag, "@maple/http/errors/IntegrationsNotConnectedError") + + yield* connectFakeVercel({ orgId: ORG }) + world.cloudCost = false + assert.strictEqual( + (yield* Effect.flip(add))._tag, + "@maple/http/errors/CloudCostDisabledError", + ) + assert.strictEqual( + (yield* Effect.flip(status))._tag, + "@maple/http/errors/CloudCostDisabledError", + ) + assert.deepStrictEqual(world.urls, []) + assert.isUndefined(yield* source(testDb)) + }), + ) + }, + ) + + it.effect("makes no source for a connection that moved to another team during the check", () => { + const testDb = createTestDb(trackedDbs) + const world = makeFakeVercel() + return run( + testDb, + world, + Effect.gen(function* () { + yield* connectFakeVercel({ orgId: ORG }) + // The check read the old team: its answer says nothing about the new one. + world.onRequest = () => + testDb.pglite + .query("UPDATE vercel_connections SET team_id = 'team_2', team_slug = 'beta'") + .then(() => undefined) + const moved = yield* Effect.flip(add) + assert.strictEqual(moved._tag, "@maple/http/errors/IntegrationsConnectionChangedError") + assert.isUndefined(yield* source(testDb)) + + // A new token saved for the same team during the check: it was not the one that read. + yield* sql(testDb, "UPDATE vercel_connections SET team_id = 'team_1', team_slug = 'acme'") + world.onRequest = () => + testDb.pglite + .query("UPDATE vercel_connections SET token_ciphertext = token_ciphertext || 'x'") + .then(() => undefined) + const replaced = yield* Effect.flip(add) + assert.strictEqual(replaced._tag, "@maple/http/errors/IntegrationsConnectionChangedError") + assert.isUndefined(yield* source(testDb)) + + // Disconnected during the check: the same answer. + world.onRequest = () => + testDb.pglite.query("DELETE FROM vercel_connections").then(() => undefined) + const gone = yield* Effect.flip(add) + assert.strictEqual(gone._tag, "@maple/http/errors/IntegrationsConnectionChangedError") + assert.isUndefined(yield* source(testDb)) + assert.lengthOf(world.urls, 3) + }), + ) + }) + + it.effect("refuses to add another team than the one the Cost page showed, before any read", () => { + const testDb = createTestDb(trackedDbs) + const world = makeFakeVercel() + return run( + testDb, + world, + Effect.gen(function* () { + // Shown team_1 in the dialog; the org's connection was moved to team_2 since. + yield* connectFakeVercel({ orgId: ORG, teamId: "team_2", teamSlug: "beta", teamName: "Beta" }) + const error = yield* Effect.flip(addTeam("team_1")) + assert.strictEqual(error._tag, "@maple/http/errors/IntegrationsConnectionChangedError") + assert.include(error.message, "another team (beta)") + assert.deepStrictEqual(world.urls, []) + assert.isUndefined(yield* source(testDb)) + // Naming the team that is connected adds it. + assert.strictEqual((yield* addTeam("team_2")).created, true) + assert.strictEqual((yield* source(testDb))?.account_id, "team_2") + }), + ) + }) + + it.effect("shows what Vercel said about a request it calls invalid, which no retry fixes", () => { + const testDb = createTestDb(trackedDbs) + const world = makeFakeVercel({ chargesStatus: 400 }) + return run( + testDb, + world, + Effect.gen(function* () { + yield* connectFakeVercel({ orgId: ORG }) + const error = yield* Effect.flip(add) + // A public error: the dialog shows this text, not a fixed sentence. + assert.deepStrictEqual( + [error._tag, error.message], + [ + "@maple/http/errors/IntegrationsValidationError", + "Vercel refused the billing request as invalid: status 400", + ], + ) + assert.isUndefined(yield* source(testDb)) + }), + ) + }) + + it.effect("removes the source and leaves the connection, without needing the rollout", () => { + const testDb = createTestDb(trackedDbs) + const world = makeFakeVercel() + return run( + testDb, + world, + Effect.gen(function* () { + yield* connectFakeVercel({ orgId: ORG }) + yield* add + world.cloudCost = false + assert.deepStrictEqual(yield* remove, { removed: true }) + assert.isUndefined(yield* source(testDb)) + // Nothing left to remove: said, not failed. + assert.deepStrictEqual(yield* remove, { removed: false }) + const connection = yield* Effect.promise(() => + queryFirstRow<{ team_slug: string }>(testDb, "SELECT team_slug FROM vercel_connections"), + ) + assert.strictEqual(connection?.team_slug, "acme") + }), + ) + }) +}) diff --git a/packages/backend/src/services/cost/VercelCostSourceService.ts b/packages/backend/src/services/cost/VercelCostSourceService.ts new file mode 100644 index 0000000000..9b35243693 --- /dev/null +++ b/packages/backend/src/services/cost/VercelCostSourceService.ts @@ -0,0 +1,226 @@ +/** + * The org's Vercel team as a cost source. The integration already holds the team and its token, + * so the `cost_sources` row is all there is to add or remove. The connection is never changed here. + */ +import { + CloudCostDisabledError, + IntegrationsConnectionChangedError, + IntegrationsNotConnectedError, + IntegrationsPersistenceError, + IntegrationsUpstreamError, + IntegrationsValidationError, + type OrgId, + type VercelCostStatus, + type VercelCostTeam, +} from "@maple/domain/http" +import * as PG from "@maple-dev/effect-orm/postgres" +import { CostSources, VercelConnections } from "@maple/db/tables" +import { CostSourceId } from "@maple/primitives" +import { Clock, Context, Effect, Layer } from "effect" +import { randomUUID } from "node:crypto" +import { Database } from "@maple/backend/platform/DatabaseLive" +import { makeDbExecute, makePersistenceErrorMapper } from "@maple/backend/platform/db-execute" +import { OrganizationFeatureFlagsService } from "@maple/backend/services/org/OrganizationFeatureFlagsService" +import { VercelCostProvider, type VercelCostReadError } from "./providers/vercel/VercelCostProvider" +import { addDays, utcToday } from "./sync-plan" +import { removeVercelCostSources } from "./vercel-cost-sources" + +const toPersistenceError = makePersistenceErrorMapper( + IntegrationsPersistenceError, + "Vercel cost source database error", +) + +const CONNECTION_CHANGED = "The Vercel connection changed while the source was added. Try again." + +/** + * Why the check read was refused, as what the Cost page shows. A token or role Vercel refuses and + * a request it calls invalid keep their reason: the first is the org's to fix, the second says + * what Vercel did not take. An outage or a secret key Maple cannot use answers a fixed sentence. + */ +const toAddError = ( + error: VercelCostReadError, +): IntegrationsValidationError | IntegrationsConnectionChangedError | IntegrationsUpstreamError => { + switch (error.kind) { + case "token_rejected": + case "not_ready": + case "invalid_request": + return new IntegrationsValidationError({ message: error.message }) + // Disconnected between the service's read of the connection and the adapter's. + case "no_connection": + return new IntegrationsConnectionChangedError({ message: CONNECTION_CHANGED }) + case "other": + return new IntegrationsUpstreamError({ message: error.message }) + } +} + +/** `VercelCostStatus` as plain data. */ +export type VercelCostStatusInfo = typeof VercelCostStatus.Encoded + +export interface VercelCostSourceServiceApi { + readonly status: ( + orgId: OrgId, + ) => Effect.Effect + /** + * Makes the connected team a cost source, once Vercel has let its token read the billing. + * `teamId` is the team the caller was shown: another one connected since is refused. + * `created` is false when it was a source already. + */ + readonly add: ( + orgId: OrgId, + teamId: string, + ) => Effect.Effect< + { readonly team: VercelCostTeam; readonly created: boolean }, + | CloudCostDisabledError + | IntegrationsNotConnectedError + | IntegrationsConnectionChangedError + | IntegrationsValidationError + | IntegrationsUpstreamError + | IntegrationsPersistenceError + > + readonly remove: ( + orgId: OrgId, + ) => Effect.Effect<{ readonly removed: boolean }, IntegrationsPersistenceError> +} + +export class VercelCostSourceService extends Context.Service< + VercelCostSourceService, + VercelCostSourceServiceApi +>()("@maple/backend/services/cost/VercelCostSourceService", { + make: Effect.gen(function* () { + const database = yield* Database + const featureFlags = yield* OrganizationFeatureFlagsService + const provider = yield* VercelCostProvider + const dbExecute = makeDbExecute(database, "VercelCostSourceService", toPersistenceError) + + const requireRollout = (orgId: OrgId) => + Effect.gen(function* () { + const flags = yield* featureFlags.flags(orgId) + if (!flags.cloudCost) { + return yield* new CloudCostDisabledError({ + message: "Cloud cost is not enabled for this organization.", + }) + } + }) + + /** The org's connection and whether it is a cost source, read together. */ + const load = (orgId: OrgId) => + dbExecute((db) => + db.run( + PG.from(VercelConnections, "c") + .leftJoin(CostSources, "s", (connection, source) => + source.orgId + .eq(connection.orgId) + .and(source.provider.eq("vercel")) + .and(source.connectionRef.eq(connection.id)), + ) + .select(($) => ({ + id: $.id, + teamId: $.teamId, + teamSlug: $.teamSlug, + teamName: $.teamName, + tokenCiphertext: $.tokenCiphertext, + authFailedAt: $.authFailedAt, + sourceId: $.s.id, + })) + .where(($) => [$.orgId.eq(orgId)]) + .limit(1), + ), + ).pipe(Effect.map((rows) => rows[0] ?? null)) + + const status = Effect.fn("VercelCostSourceService.status")(function* (orgId: OrgId) { + yield* Effect.annotateCurrentSpan({ orgId }) + yield* requireRollout(orgId) + const connection = yield* load(orgId) + return connection === null + ? { team: null, authFailed: false, added: false } + : { + team: { id: connection.teamId, name: connection.teamName, slug: connection.teamSlug }, + authFailed: connection.authFailedAt !== null, + added: connection.sourceId !== null, + } + }) + + const add = Effect.fn("VercelCostSourceService.add")(function* (orgId: OrgId, teamId: string) { + yield* Effect.annotateCurrentSpan({ orgId }) + yield* requireRollout(orgId) + const connection = yield* load(orgId) + if (connection === null) { + return yield* new IntegrationsNotConnectedError({ + message: "Connect a Vercel team first, then add it as a cost source.", + }) + } + // The caller saw another team: this one's spend is not what they chose to add. + if (connection.teamId !== teamId) { + return yield* new IntegrationsConnectionChangedError({ + message: `The Vercel connection now reads another team (${connection.teamSlug}). Check the team shown, then add it again.`, + }) + } + const team = { id: connection.teamId, name: connection.teamName, slug: connection.teamSlug } + if (connection.sourceId !== null) return { team, created: false } + // Yesterday and today, read with the stored token: a token or role Vercel refuses is said + // now, not by a source that fails its first sync. Through today, as every head pull asks. + const now = yield* Clock.currentTimeMillis + const today = utcToday(now) + yield* provider + .read( + { orgId, connectionRef: connection.id }, + { startDay: addDays(today, -1), endDay: today }, + ) + .pipe(Effect.mapError(toAddError)) + const added = yield* dbExecute((db) => + db.transaction( + Effect.gen(function* () { + // The share lock makes a disconnect, a move to another team or a new token wait for + // the insert. A token saved since the check was not the one the check read with. + const [current] = yield* db.run( + PG.from(VercelConnections) + .select("id") + .where(($) => [ + $.orgId.eq(orgId), + $.id.eq(connection.id), + $.teamId.eq(connection.teamId), + $.tokenCiphertext.eq(connection.tokenCiphertext), + ]) + .limit(1) + .forShare(), + ) + if (current === undefined) return null + const inserted = yield* db.run( + PG.insertInto(CostSources) + .values({ + id: CostSourceId.make(randomUUID()), + orgId, + provider: "vercel", + connectionRef: connection.id, + // The team's id, not its slug: a renamed team stays one account. + accountId: connection.teamId, + createdAt: now, + updatedAt: now, + }) + // Another request added it first. + .onConflictDoNothing() + .returning(($) => ({ id: $.id })), + ) + return inserted.length > 0 + }), + ), + ) + if (added === null) { + return yield* new IntegrationsConnectionChangedError({ message: CONNECTION_CHANGED }) + } + return { team, created: added } + }) + + const remove = Effect.fn("VercelCostSourceService.remove")(function* (orgId: OrgId) { + yield* Effect.annotateCurrentSpan({ orgId }) + const removed = yield* dbExecute((db) => removeVercelCostSources(db, orgId)) + return { removed: removed > 0 } + }) + + return { status, add, remove } satisfies VercelCostSourceServiceApi + }), +}) { + static readonly layer = Layer.effect(this, this.make).pipe( + Layer.provide(Layer.mergeAll(VercelCostProvider.layer, OrganizationFeatureFlagsService.layer)), + ) +} diff --git a/packages/backend/src/services/cost/providers/vercel/VercelCostProvider.test.ts b/packages/backend/src/services/cost/providers/vercel/VercelCostProvider.test.ts new file mode 100644 index 0000000000..b00bb6f9dc --- /dev/null +++ b/packages/backend/src/services/cost/providers/vercel/VercelCostProvider.test.ts @@ -0,0 +1,376 @@ +import { afterEach, assert, describe, it } from "@effect/vitest" +import { OrgId } from "@maple/domain/http" +import { DateTime, Effect, Layer, Order, Schema, Array as Arr, String as Str } from "effect" +import { TestClock } from "effect/testing" +import { cleanupTestDbs, createTestDb, type TestDb } from "@maple/backend/testing/test-pglite" +import type { Database } from "@maple/backend/platform/DatabaseLive" +import type { Env } from "@maple/backend/platform/Env" +import type { CostProviderError, CostPullWindow } from "../../CostProvider" +import { connectFakeVercel, type FakeVercel, fakeVercelDeps, jsonl, makeFakeVercel } from "./fake-vercel" +import { VercelCostProvider } from "./VercelCostProvider" + +const trackedDbs: TestDb[] = [] +afterEach(() => cleanupTestDbs(trackedDbs)) + +const ORG = Schema.decodeSync(OrgId)("org_cost") +const OTHER_ORG = Schema.decodeSync(OrgId)("org_other") +const WINDOW = { startDay: "2026-11-01", endDay: "2026-11-04" } +/** The pull runs on the window's last day. */ +const NOW = DateTime.toEpochMillis(DateTime.makeUnsafe("2026-11-04T06:00:00Z")) +const CHARGES_URL = + "https://api.vercel.com/v1/billing/charges?teamId=team_1&from=2026-11-01T00%3A00%3A00Z&to=2026-11-05T00%3A00%3A00Z" + +const run = ( + testDb: TestDb, + world: FakeVercel, + effect: Effect.Effect, + encryptionKey?: string, +) => + Effect.gen(function* () { + yield* TestClock.setTime(NOW) + return yield* effect + }).pipe( + Effect.provide( + Layer.effect(VercelCostProvider, VercelCostProvider.make).pipe( + Layer.provideMerge(fakeVercelDeps(testDb, world, encryptionKey)), + ), + ), + ) + +const pull = (orgId: OrgId, connectionRef: string, window: CostPullWindow = WINDOW) => + VercelCostProvider.use((provider) => provider.pull({ orgId, connectionRef }, window)) + +/** A charge line whose numbers are written as given: JSON has no way to encode an overflowing one. */ +const numbersLine = (numbers: Readonly>): string => { + const fields = { BilledCost: "1", EffectiveCost: "1", ConsumedQuantity: "1", ...numbers } + return `{"BilledCost":${fields.BilledCost},"EffectiveCost":${fields.EffectiveCost},"ConsumedQuantity":${fields.ConsumedQuantity},"ChargePeriodStart":"2026-11-02T00:00:00Z","ServiceName":"Function Duration"}\n` +} + +const reasonOf = (error: CostProviderError) => [error.reason, error.requests, error.provider] + +const charge = (overrides: Readonly> = {}) => ({ + BilledCost: 1.5, + BillingCurrency: "USD", + ChargeCategory: "Usage", + ChargePeriodStart: "2026-11-02T00:00:00.000Z", + ChargePeriodEnd: "2026-11-03T00:00:00.000Z", + ConsumedQuantity: 300, + ConsumedUnit: "GB-Hours", + EffectiveCost: 1.5, + RegionId: "iad1", + RegionName: "Washington, D.C., USA", + ServiceCategory: "Compute", + ServiceName: "Function Duration", + ServiceProviderName: "Vercel", + SkuId: "function-duration", + SubAccountId: "team_1", + Tags: { ProjectId: "prj_web", ProjectName: "web" }, + ...overrides, +}) + +describe("VercelCostProvider", () => { + it.effect("reads the team's charges with the connection's token, the window's days whole", () => { + const testDb = createTestDb(trackedDbs) + const world = makeFakeVercel({ + charges: [ + charge(), + charge({ BilledCost: 2 }), + charge({ ChargePeriodStart: "2026-11-04T00:00:00.000Z", BilledCost: 4, RegionId: null }), + charge({ ChargePeriodStart: "2026-11-03T00:00:00.000Z", BilledCost: 0, EffectiveCost: 0 }), + ], + }) + return run( + testDb, + world, + Effect.gen(function* () { + const connectionRef = yield* connectFakeVercel({ orgId: ORG, token: "vc_token_abc" }) + const pulled = yield* pull(ORG, connectionRef) + // One request: from the first day, to the day after the last, which `to` leaves out. + assert.deepStrictEqual(world.urls, [CHARGES_URL]) + assert.deepStrictEqual(world.authorizations, ["Bearer vc_token_abc"]) + assert.strictEqual(pulled.requests, 0) + // Rows carry the team's id, lines of one key are summed, and today can still change. + assert.deepStrictEqual( + Arr.map(pulled.rows, (row) => [ + row.day, + row.cloudService, + row.resourceId, + row.region, + row.billedCost, + row.accountId, + row.estimated, + ]), + [ + ["2026-11-02", "Function Duration", "web", "iad1", 3.5, "team_1", false], + ["2026-11-04", "Function Duration", "web", "global", 4, "team_1", true], + ], + ) + }), + ) + }) + + it.effect("reads a body of several lines, skipping the blank ones", () => { + const testDb = createTestDb(trackedDbs) + const lines = [ + charge({ SkuId: "a", BilledCost: 1 }), + charge({ SkuId: "b", BilledCost: 2 }), + charge({ SkuId: "c", BilledCost: 3 }), + ] + // A blank line between charges, Windows line ends, and no newline after the last one. + const body = `\n${Arr.join( + Arr.map(lines, (line) => Str.trimEnd(jsonl([line]))), + "\r\n\r\n", + )}` + const world = makeFakeVercel({ charges: body }) + return run( + testDb, + world, + Effect.gen(function* () { + const connectionRef = yield* connectFakeVercel({ orgId: ORG }) + const pulled = yield* pull(ORG, connectionRef) + assert.deepStrictEqual( + Arr.map(pulled.rows, (row) => [row.usageType, row.billedCost]), + [ + ["a", 1], + ["b", 2], + ["c", 3], + ], + ) + }), + ) + }) + + it.effect("stores an empty window for a team Vercel billed nothing, body or no body", () => { + const testDb = createTestDb(trackedDbs) + const world = makeFakeVercel({ charges: "" }) + return run( + testDb, + world, + Effect.gen(function* () { + const connectionRef = yield* connectFakeVercel({ orgId: ORG }) + assert.deepStrictEqual((yield* pull(ORG, connectionRef)).rows, []) + world.charges = "\n\n" + assert.deepStrictEqual((yield* pull(ORG, connectionRef)).rows, []) + world.charges = null + assert.deepStrictEqual((yield* pull(ORG, connectionRef)).rows, []) + assert.lengthOf(world.urls, 3) + }), + ) + }) + + it.effect("fails the whole pull on one line it cannot read, with nothing to store", () => { + const testDb = createTestDb(trackedDbs) + const world = makeFakeVercel() + return run( + testDb, + world, + Effect.gen(function* () { + const connectionRef = yield* connectFakeVercel({ orgId: ORG }) + // A good line, then one cut off mid-object. + world.charges = `${jsonl([charge()])}{"BilledCost": 2, "ServiceName": "Fun\n` + assert.deepStrictEqual(reasonOf(yield* Effect.flip(pull(ORG, connectionRef))), [ + "invalid_response", + 0, + "vercel", + ]) + const unreadable = [ + "maintenance", + jsonl([charge({ BilledCost: "1.50" })]), + jsonl([charge({ ChargePeriodStart: "soon" })]), + // A day followed by anything but a UTC time is not a day Maple may file the cost under. + jsonl([charge({ ChargePeriodStart: "2026-11-02invalid" })]), + jsonl([charge({ ChargePeriodStart: "2026-11-02T00:00:00+02:00" })]), + // Days the calendar does not have: filed, they would land under no day or another one. + jsonl([charge({ ChargePeriodStart: "2026-02-31T00:00:00Z" })]), + jsonl([charge({ ChargePeriodStart: "2026-04-31T00:00:00Z" })]), + jsonl([charge({ ChargePeriodStart: "2026-02-29T00:00:00Z" })]), + jsonl([charge({ ChargePeriodStart: "2100-02-29T00:00:00Z" })]), + // Valid JSON that parses to Infinity: no amount to sum or store. + numbersLine({ BilledCost: "1e309" }), + numbersLine({ EffectiveCost: "-1e309" }), + numbersLine({ ConsumedQuantity: "1e999" }), + jsonl([{ BilledCost: 1, ChargePeriodStart: "2026-11-02T00:00:00Z" }]), + ] + const reasons = yield* Effect.forEach(unreadable, (body) => + Effect.gen(function* () { + world.charges = body + return (yield* Effect.flip(pull(ORG, connectionRef))).reason + }), + ) + assert.deepStrictEqual(reasons, Arr.replicate("invalid_response", 13)) + }), + ) + }) + + it.effect("reads a charge of a leap day, and of the last day of a long and of a short month", () => { + const testDb = createTestDb(trackedDbs) + const days = ["2028-02-29", "2028-01-31", "2028-04-30", "2028-12-31"] + const world = makeFakeVercel({ + charges: Arr.map(days, (day) => charge({ ChargePeriodStart: `${day}T00:00:00.000Z` })), + }) + return run( + testDb, + world, + Effect.gen(function* () { + const connectionRef = yield* connectFakeVercel({ orgId: ORG }) + const pulled = yield* pull(ORG, connectionRef, { + startDay: "2028-01-01", + endDay: "2028-12-31", + }) + assert.deepStrictEqual( + Arr.sort( + Arr.map(pulled.rows, (row) => row.day), + Order.String, + ), + ["2028-01-31", "2028-02-29", "2028-04-30", "2028-12-31"], + ) + }), + ) + }) + + it.effect("refuses lines that are each finite and sum past what a number holds", () => { + const testDb = createTestDb(trackedDbs) + const world = makeFakeVercel({ + charges: `${numbersLine({ BilledCost: "1e308" })}${numbersLine({ BilledCost: "1e308" })}`, + }) + return run( + testDb, + world, + Effect.gen(function* () { + const connectionRef = yield* connectFakeVercel({ orgId: ORG }) + assert.strictEqual((yield* Effect.flip(pull(ORG, connectionRef))).reason, "invalid_response") + // One such line alone is an amount like any other. + world.charges = numbersLine({ BilledCost: "1e308" }) + assert.deepStrictEqual( + Arr.map((yield* pull(ORG, connectionRef)).rows, (row) => row.billedCost), + [1e308], + ) + }), + ) + }) + + it.effect("takes only a 200 as the whole window: no content or part of it stores nothing", () => { + const testDb = createTestDb(trackedDbs) + const world = makeFakeVercel({ charges: [charge()] }) + return run( + testDb, + world, + Effect.gen(function* () { + const connectionRef = yield* connectFakeVercel({ orgId: ORG }) + const reasons = yield* Effect.forEach([204, 206], (status) => + Effect.gen(function* () { + world.chargesStatus = status + return (yield* Effect.flip(pull(ORG, connectionRef))).reason + }), + ) + // Stored, a 204 would empty the window's days and a 206 leave them by halves. + assert.deepStrictEqual(reasons, ["invalid_response", "invalid_response"]) + world.chargesStatus = 200 + assert.lengthOf((yield* pull(ORG, connectionRef)).rows, 1) + }), + ) + }) + + it.effect("says what the sync should do about each way Vercel refuses", () => { + const testDb = createTestDb(trackedDbs) + const world = makeFakeVercel() + return run( + testDb, + world, + Effect.gen(function* () { + const connectionRef = yield* connectFakeVercel({ orgId: ORG }) + const refusals = yield* Effect.forEach([401, 403, 404, 410, 429, 500, 503], (status) => + Effect.gen(function* () { + world.chargesStatus = status + const error = yield* Effect.flip(pull(ORG, connectionRef)) + return [status, error.reason, error.requests] + }), + ) + assert.deepStrictEqual(refusals, [ + [401, "auth", 0], + [403, "not_ready", 0], + [404, "not_ready", 0], + [410, "not_ready", 0], + [429, "rate_limited", 0], + [500, "upstream", 0], + [503, "upstream", 0], + ]) + // A role without billing access is told which roles have it. + world.chargesStatus = 403 + assert.include( + (yield* Effect.flip(pull(ORG, connectionRef))).message, + "Security, Billing or Enterprise Viewer role", + ) + // A request Vercel calls invalid is Maple's to fix: its own words are kept. + world.chargesStatus = 400 + const invalid = yield* Effect.flip(pull(ORG, connectionRef)) + assert.deepStrictEqual( + [invalid.reason, invalid.message], + ["upstream", "Vercel refused the billing request as invalid: status 400"], + ) + }), + ) + }) + + it.effect("does not ask Vercel with a token its poller found rejected", () => { + const testDb = createTestDb(trackedDbs) + const world = makeFakeVercel() + return run( + testDb, + world, + Effect.gen(function* () { + const connectionRef = yield* connectFakeVercel({ orgId: ORG, authFailedAt: NOW - 60_000 }) + const error = yield* Effect.flip(pull(ORG, connectionRef)) + assert.deepStrictEqual([error.reason, world.urls], ["auth", []]) + assert.include(error.message, "Replace the token") + }), + ) + }) + + it.effect("refuses a window longer than Vercel serves in one request, before asking", () => { + const testDb = createTestDb(trackedDbs) + const world = makeFakeVercel() + return run( + testDb, + world, + Effect.gen(function* () { + const connectionRef = yield* connectFakeVercel({ orgId: ORG }) + // 2024 is a leap year: all of it is 366 days, one more day is too many. + yield* pull(ORG, connectionRef, { startDay: "2024-01-01", endDay: "2024-12-31" }) + assert.lengthOf(world.urls, 1) + const error = yield* Effect.flip( + pull(ORG, connectionRef, { startDay: "2024-01-01", endDay: "2025-01-01" }), + ) + assert.deepStrictEqual([error.reason, world.urls.length], ["too_large", 1]) + }), + ) + }) + + it.effect("is built on a deployment whose secret key is unusable, and fails only its own pulls", () => { + const testDb = createTestDb(trackedDbs) + const world = makeFakeVercel() + return Effect.gen(function* () { + // Sealed with the usable key, read on a deployment that has lost it. + const connectionRef = yield* run(testDb, world, connectFakeVercel({ orgId: ORG })) + const error = yield* Effect.flip( + run(testDb, world, pull(ORG, connectionRef), "not-a-32-byte-key"), + ) + assert.deepStrictEqual(["reason" in error ? error.reason : error._tag, world.urls], ["auth", []]) + }) + }) + + it.effect("never reads a token through another org's source, or a connection that is gone", () => { + const testDb = createTestDb(trackedDbs) + const world = makeFakeVercel() + return run( + testDb, + world, + Effect.gen(function* () { + const connectionRef = yield* connectFakeVercel({ orgId: ORG }) + assert.strictEqual((yield* Effect.flip(pull(OTHER_ORG, connectionRef))).reason, "auth") + assert.strictEqual((yield* Effect.flip(pull(ORG, "no-such-connection"))).reason, "auth") + assert.deepStrictEqual(world.urls, []) + }), + ) + }) +}) diff --git a/packages/backend/src/services/cost/providers/vercel/VercelCostProvider.ts b/packages/backend/src/services/cost/providers/vercel/VercelCostProvider.ts new file mode 100644 index 0000000000..29d9ca9f6a --- /dev/null +++ b/packages/backend/src/services/cost/providers/vercel/VercelCostProvider.ts @@ -0,0 +1,210 @@ +/** + * Vercel as a cost provider: a team's daily billed charges by product, project and region, from + * the billing charges API, read with the token of the org's Vercel connection. + */ +import * as PG from "@maple-dev/effect-orm/postgres" +import { VercelConnections } from "@maple/db/tables" +import { Clock, Context, Duration, Effect, Layer, Schema, Stream, Array as Arr } from "effect" +import { FetchHttpClient, HttpClient } from "effect/http" +import { Database } from "@maple/backend/platform/DatabaseLive" +import { makeDbExecute } from "@maple/backend/platform/db-execute" +import { Env } from "@maple/backend/platform/Env" +import { secretKeyringFromEnv } from "@maple/backend/platform/SecretBox" +import { streamBillingCharges, type VercelApiError } from "../../../integrations/vercel/api" +import { openVercelToken } from "../../../integrations/vercel/token" +import { + type CostProviderAdapter, + CostProviderError, + type CostPull, + type CostPullRow, + type CostPullSource, + type CostPullWindow, +} from "../../CostProvider" +import { addDays, daysOfWindow, utcToday } from "../../sync-plan" +import { addCharge, emptyChargeTotals, rowsOf } from "./normalize" + +/** A window's answer streams in for longer the more days it holds: 45 seconds and a quarter more per day. */ +const pullTimeout = (days: number) => Duration.millis(45_000 + days * 250) +/** Vercel serves a year at most in one request. The sync's windows are far shorter. */ +const MAX_REQUEST_DAYS = 366 + +type Reason = CostProviderError["reason"] + +const isFiniteRow = (row: CostPullRow): boolean => + Arr.every([row.billedCost, row.effectiveCost, row.listCost, row.consumedQuantity], Number.isFinite) + +/** + * A read of the charges that failed. `reason` is what the sync does next; `kind` keeps apart what + * an add has to tell the person adding: no connection, a token or role Vercel refuses, a request + * Vercel calls invalid. + */ +export class VercelCostReadError extends Schema.TaggedError()( + "@maple/backend/cost/VercelCostReadError", + { + message: Schema.String, + reason: CostProviderError.fields.reason, + kind: Schema.Literals(["no_connection", "token_rejected", "not_ready", "invalid_request", "other"]), + }, +) {} + +const failed = (reason: Reason, message: string, kind: VercelCostReadError["kind"] = "other") => + new VercelCostReadError({ message, reason, kind }) + +const tokenRejected = () => + failed( + "auth", + "Vercel rejected the saved token. Replace the token of the Vercel connection.", + "token_rejected", + ) + +/** Vercel's failures, as what the sync does next and the Cost page shows. */ +const toReadError = (error: VercelApiError): VercelCostReadError => { + switch (error.kind) { + case "unauthorized": + return tokenRejected() + case "forbidden": + return failed( + "not_ready", + "This Vercel token's role cannot read the team's billing. Use a token from an Owner, Member, Developer, Security, Billing or Enterprise Viewer role.", + "not_ready", + ) + case "not_found": + return failed( + "not_ready", + "Vercel has no billing data for this team, or the team is gone.", + "not_ready", + ) + case "rate_limited": + return failed("rate_limited", "Vercel is rate limiting billing requests.") + case "invalid_response": + return failed("invalid_response", "Vercel's billing answer could not be read.") + case "upstream": + // A 400 is the request Maple built: Vercel's own words say what it did not take. + return error.status === 400 + ? failed( + "upstream", + `Vercel refused the billing request as invalid${error.detail === undefined ? "" : `: ${error.detail}`}`, + "invalid_request", + ) + : failed("upstream", "Vercel did not answer the billing request.") + } +} + +export interface VercelCostProviderApi extends CostProviderAdapter { + /** `pull`, failing with what an add needs to tell a refusal from an outage. */ + readonly read: ( + source: CostPullSource, + window: CostPullWindow, + ) => Effect.Effect +} + +export class VercelCostProvider extends Context.Service()( + "@maple/backend/services/cost/VercelCostProvider", + { + make: Effect.gen(function* () { + const database = yield* Database + const env = yield* Env + const httpClient = yield* HttpClient.HttpClient + // Read at the first pull, not here: an unusable key must not stop the sync of other providers. + const keyring = yield* Effect.cached( + secretKeyringFromEnv(env, (message) => failed("auth", message)), + ) + const dbExecute = makeDbExecute(database, "VercelCostProvider", () => + failed("upstream", "Vercel connection database error"), + ) + + const read = Effect.fn("VercelCostProvider.read")( + function* (source: CostPullSource, window: CostPullWindow) { + yield* Effect.annotateCurrentSpan({ + orgId: source.orgId, + "maple.cost.provider": "vercel", + "maple.cost.start_day": window.startDay, + "maple.cost.end_day": window.endDay, + }) + const days = daysOfWindow(window).length + if (days > MAX_REQUEST_DAYS) { + return yield* failed( + "too_large", + `Vercel serves ${MAX_REQUEST_DAYS} days in one request at most.`, + ) + } + // By org as well as id: a source never reads another org's token. + const [row] = yield* dbExecute((db) => + db.run( + PG.from(VercelConnections) + .select() + .where(($) => [$.orgId.eq(source.orgId), $.id.eq(source.connectionRef)]) + .limit(1), + ), + ) + if (row === undefined) { + return yield* failed( + "auth", + "No Vercel connection is saved for this source.", + "no_connection", + ) + } + // The integration's poller found the token rejected: asking again would only be refused. + if (row.authFailedAt !== null) return yield* tokenRejected() + const secrets = yield* keyring + const token = yield* openVercelToken(secrets, row, (message) => failed("auth", message)) + // Today and yesterday: Vercel may still be counting a day that ended hours ago. + const now = yield* Clock.currentTimeMillis + const openDay = addDays(utcToday(now), -1) + const totals = yield* streamBillingCharges(httpClient, token, row.teamId, { + from: `${window.startDay}T00:00:00Z`, + // `to` is exclusive. + to: `${addDays(window.endDay, 1)}T00:00:00Z`, + }).pipe( + // The team's id is the account: its slug can be renamed, and stored rows keep theirs. + Stream.runFold(emptyChargeTotals, (sums, charge) => + addCharge(sums, charge, row.teamId, window, openDay), + ), + // The page shows the mapped message; which failure it was stays on the span. + Effect.tapError((error) => + Effect.annotateCurrentSpan({ + "maple.cost.vercel.error": error.kind, + "maple.cost.vercel.status": error.status ?? 0, + "maple.cost.vercel.detail": error.detail ?? "", + }), + ), + Effect.mapError(toReadError), + Effect.timeoutOrElse({ + duration: pullTimeout(days), + orElse: () => Effect.fail(failed("upstream", "Vercel took too long to answer.")), + }), + ) + const rows = rowsOf(totals) + // Each line is finite, but a sum of them can still run past what a number holds. + if (!Arr.every(rows, isFiniteRow)) { + return yield* failed( + "invalid_response", + "Vercel's billing answer sums to no finite amount.", + ) + } + yield* Effect.annotateCurrentSpan({ + "maple.cost.rows": rows.length, + "maple.cost.vercel.charge_groups": totals.size, + }) + return { rows, requests: 0 } satisfies CostPull + }, + Effect.tapError((error) => + Effect.annotateCurrentSpan({ "maple.cost.error_reason": error.reason }), + ), + ) + + // Vercel does not bill for API requests, so no failure counts any. + const pull = (source: CostPullSource, window: CostPullWindow) => + read(source, window).pipe( + Effect.mapError( + ({ message, reason }) => + new CostProviderError({ message, provider: "vercel", reason, requests: 0 }), + ), + ) + + return { provider: "vercel", pull, read } satisfies VercelCostProviderApi + }), + }, +) { + static readonly layer = Layer.effect(this, this.make).pipe(Layer.provide(FetchHttpClient.layer)) +} diff --git a/packages/backend/src/services/cost/providers/vercel/fake-vercel.ts b/packages/backend/src/services/cost/providers/vercel/fake-vercel.ts new file mode 100644 index 0000000000..6d82ea2676 --- /dev/null +++ b/packages/backend/src/services/cost/providers/vercel/fake-vercel.ts @@ -0,0 +1,175 @@ +// A fake Vercel for tests: `GET /v1/billing/charges` answered from a mutable world as JSONL, with +// every call recorded, and a connected team written the way the integration stores one. +import { VercelConnections } from "@maple/db/tables" +import type { OrgId } from "@maple/domain/http" +import { DISABLED_ORGANIZATION_FEATURE_FLAGS } from "@maple/domain/organization-feature-flags" +import * as PG from "@maple-dev/effect-orm/postgres" +import { ConfigProvider, Effect, Layer, Schema, Array as Arr } from "effect" +import { FetchHttpClient } from "effect/http" +import { Database } from "@maple/backend/platform/DatabaseLive" +import { Env } from "@maple/backend/platform/Env" +import { sealSecret, secretKeyringFromEnv } from "@maple/backend/platform/SecretBox" +import { vercelTokenSlot } from "@maple/backend/platform/secret-slots" +import { createIntegrationSource } from "@maple/backend/services/integrations/integration-sources" +import { OrganizationFeatureFlagsService } from "@maple/backend/services/org/OrganizationFeatureFlagsService" +import type { TestDb } from "@maple/backend/testing/test-pglite" +import { CostProviderError } from "../../CostProvider" + +export interface FakeVercel { + /** Status of `GET /v1/billing/charges`. */ + chargesStatus: number + /** The body: charges, served one JSON object per line, raw text for anything else, or null for no body. */ + charges: ReadonlyArray>> | string | null + /** Whether the org is in the `cloudCost` rollout. */ + cloudCost: boolean + /** Runs when a request arrives, before it is answered: what changes while a read is in flight. */ + onRequest: () => Promise + readonly urls: Array + readonly authorizations: Array +} + +export const makeFakeVercel = (overrides: Partial = {}): FakeVercel => ({ + chargesStatus: 200, + charges: [], + cloudCost: true, + onRequest: () => Promise.resolve(), + urls: [], + authorizations: [], + ...overrides, +}) + +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)) + +/** Charges as Vercel streams them: one JSON object per line. */ +export const jsonl = (charges: ReadonlyArray>>): string => + Arr.join( + Arr.map(charges, (charge) => `${encodeJson(charge)}\n`), + "", + ) + +const fetchStub = + (world: FakeVercel): typeof fetch => + async (input: string | URL | Request, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url + world.urls.push(url) + world.authorizations.push(new Headers(init?.headers).get("authorization")) + await world.onRequest() + // A 2xx that is not the 200 the charges come with: no content, or part of them. + if (world.chargesStatus === 204) return new Response(null, { status: 204 }) + if (world.chargesStatus === 206) { + return new Response( + typeof world.charges === "string" ? world.charges : jsonl(world.charges ?? []), + { + status: 206, + }, + ) + } + if (world.chargesStatus !== 200) { + return Response.json( + { error: { code: "refused", message: `status ${world.chargesStatus}` } }, + { status: world.chargesStatus }, + ) + } + const { charges } = world + return new Response(charges === null || typeof charges === "string" ? charges : jsonl(charges), { + status: 200, + headers: { "content-type": "application/jsonl" }, + }) + } + +const config = (encryptionKey: string) => + ConfigProvider.layer( + ConfigProvider.fromUnknown({ + PORT: "3472", + TINYBIRD_HOST: "https://api.tinybird.co", + TINYBIRD_TOKEN: "test-token", + MAPLE_AUTH_MODE: "self_hosted", + MAPLE_ROOT_PASSWORD: "test-root-password", + MAPLE_DEFAULT_ORG_ID: "default", + MAPLE_INGEST_KEY_ENCRYPTION_KEY: encryptionKey, + MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY: "maple-test-lookup-secret", + MAPLE_INGEST_PUBLIC_URL: "https://ingest.test", + }), + ) + +const ENCRYPTION_KEY = Buffer.alloc(32, 9).toString("base64") + +/** Everything the Vercel cost services need, over the fake and a test database. */ +export const fakeVercelDeps = ( + testDb: TestDb, + world: FakeVercel, + /** `MAPLE_INGEST_KEY_ENCRYPTION_KEY`, for a deployment whose secret key is unusable. */ + encryptionKey = ENCRYPTION_KEY, +) => + Layer.mergeAll( + FetchHttpClient.layer, + Layer.succeed(OrganizationFeatureFlagsService, { + flags: () => + Effect.sync(() => ({ ...DISABLED_ORGANIZATION_FEATURE_FLAGS, cloudCost: world.cloudCost })), + }), + testDb.layer, + Env.layer, + ).pipe( + Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetchStub(world))), + Layer.provide(config(encryptionKey)), + ) + +export interface FakeVercelConnection { + readonly orgId: OrgId + readonly id?: string + readonly teamId?: string + readonly teamSlug?: string + readonly teamName?: string + readonly token?: string + /** When the integration's poller found the token rejected. */ + readonly authFailedAt?: number +} + +const setupFailed = (message: string) => + new CostProviderError({ message, provider: "vercel", reason: "auth", requests: 0 }) + +/** An org's Vercel connection as the integration saves it, token sealed; answers its id. */ +export const connectFakeVercel = (connection: FakeVercelConnection) => + Effect.gen(function* () { + const database = yield* Database + const env = yield* Env + const keyring = yield* secretKeyringFromEnv(env, setupFailed) + const { orgId } = connection + const id = connection.id ?? "0190f2b3-0000-7000-8000-0000000000e1" + const sealed = yield* sealSecret( + keyring, + vercelTokenSlot, + { orgId }, + connection.token ?? "vc_token_123", + setupFailed, + ) + yield* database.execute((db) => + Effect.gen(function* () { + const sourceId = yield* createIntegrationSource( + db, + orgId, + { platform: "vercel", name: connection.teamName ?? "Acme" }, + 0, + ) + yield* db.run( + PG.insertInto(VercelConnections).values({ + id, + orgId, + sourceId, + teamId: connection.teamId ?? "team_1", + teamSlug: connection.teamSlug ?? "acme", + teamName: connection.teamName ?? "Acme", + tokenCiphertext: sealed.ciphertext, + tokenIv: sealed.iv, + tokenTag: sealed.tag, + connectedByUserId: "user_cost", + projectScope: "all", + authFailedAt: connection.authFailedAt ?? null, + createdAt: 0, + updatedAt: 0, + }), + ) + }), + ) + return id + }).pipe(Effect.orDie) diff --git a/packages/backend/src/services/cost/providers/vercel/normalize.test.ts b/packages/backend/src/services/cost/providers/vercel/normalize.test.ts new file mode 100644 index 0000000000..15983bffd1 --- /dev/null +++ b/packages/backend/src/services/cost/providers/vercel/normalize.test.ts @@ -0,0 +1,210 @@ +import { assert, describe, it } from "@effect/vitest" +import { Array as Arr, Order } from "effect" +import type { VercelCharge } from "../../../integrations/vercel/api" +import { addCharge, emptyChargeTotals, rowsOf as rowsOfTotals } from "./normalize" + +const WINDOW = { startDay: "2026-11-01", endDay: "2026-11-04" } +const ACCOUNT = "acme" +/** Yesterday at pull time: it and today can still change. */ +const OPEN_DAY = "2026-11-03" + +const charge = (overrides: Partial = {}): VercelCharge => ({ + BilledCost: 1.5, + BillingCurrency: "USD", + ChargeCategory: "Usage", + ChargePeriodStart: "2026-11-02T00:00:00.000Z", + ConsumedQuantity: 300, + ConsumedUnit: "GB-Hours", + EffectiveCost: 1.5, + RegionId: "iad1", + ServiceCategory: "Compute", + ServiceName: "Function Duration", + SkuId: "function-duration", + Tags: { ProjectId: "prj_web", ProjectName: "web" }, + ...overrides, +}) + +/** A whole answer's rows, folded charge by charge the way the adapter folds the stream. */ +const rowsOf = (charges: ReadonlyArray) => + rowsOfTotals( + Arr.reduce(charges, emptyChargeTotals(), (totals, line) => + addCharge(totals, line, ACCOUNT, WINDOW, OPEN_DAY), + ), + ) + +/** `day | service | SKU | project | region | kind | billed | quantity` of each row, in a stable order. */ +const summary = (charges: ReadonlyArray) => + Arr.sort( + Arr.map(rowsOf(charges), (row) => + Arr.join( + [ + row.day, + row.cloudService, + row.usageType, + row.resourceId, + row.region, + row.chargeCategory, + `${row.billedCost}`, + `${row.consumedQuantity}`, + ], + " | ", + ), + ), + Order.String, + ) + +describe("Vercel charges as cost rows", () => { + it("names the product as the service, its SKU as the usage type and the project as the resource", () => { + assert.deepStrictEqual(rowsOf([charge()]), [ + { + day: "2026-11-02", + accountId: "acme", + cloudService: "Function Duration", + serviceCategory: "Compute", + usageType: "function-duration", + region: "iad1", + resourceId: "web", + resourceType: "project", + serviceName: "", + chargeCategory: "usage", + basis: "billed", + currency: "USD", + billedCost: 1.5, + effectiveCost: 1.5, + // Vercel reports no list price. + listCost: 1.5, + consumedQuantity: 300, + consumedUnit: "GB-Hours", + tags: {}, + estimated: false, + }, + ]) + }) + + it("reads a charge that carries only the three fields Vercel must send", () => { + const [row] = rowsOf([ + { BilledCost: 20, ChargePeriodStart: "2026-11-01T00:00:00Z", ServiceName: "Pro Plan" }, + ]) + assert.deepStrictEqual( + [ + row?.usageType, + row?.serviceCategory, + row?.region, + row?.resourceId, + row?.resourceType, + row?.chargeCategory, + row?.currency, + row?.effectiveCost, + row?.consumedQuantity, + row?.consumedUnit, + ], + ["", "Other", "global", "", "", "unknown", "USD", 20, 0, ""], + ) + }) + + it("treats a null field like a missing one, and a SKU named like its product as no usage type", () => { + const [row] = rowsOf([ + charge({ + SkuId: "Function Duration", + RegionId: null, + ServiceCategory: null, + ConsumedQuantity: null, + ConsumedUnit: null, + EffectiveCost: null, + BillingCurrency: null, + Tags: null, + }), + ]) + assert.deepStrictEqual( + [row?.usageType, row?.region, row?.serviceCategory, row?.resourceId, row?.effectiveCost], + ["", "global", "Other", "", 1.5], + ) + }) + + it("falls back to the project's id when Vercel sends no name", () => { + const [row] = rowsOf([charge({ Tags: { ProjectId: "prj_web" } })]) + assert.deepStrictEqual([row?.resourceId, row?.resourceType], ["prj_web", "project"]) + }) + + it("maps each FOCUS charge category, and one it does not know to unknown", () => { + const categories = ["Usage", "Purchase", "Credit", "Tax", "Adjustment", "Refund"] + const rows = rowsOf( + Arr.map(categories, (ChargeCategory) => charge({ ChargeCategory, SkuId: ChargeCategory })), + ) + assert.deepStrictEqual( + Arr.map(rows, (row) => row.chargeCategory), + ["usage", "purchase", "credit", "tax", "adjustment", "unknown"], + ) + }) + + it("sums lines that share day, product, SKU, project and region, and keeps the others apart", () => { + assert.deepStrictEqual( + summary([ + charge({ BilledCost: 1, ConsumedQuantity: 100 }), + charge({ BilledCost: 2.5, ConsumedQuantity: 250 }), + charge({ BilledCost: 4, RegionId: "fra1" }), + charge({ BilledCost: 8, Tags: { ProjectId: "prj_api", ProjectName: "api" } }), + charge({ BilledCost: 16, ChargePeriodStart: "2026-11-01T00:00:00.000Z" }), + charge({ BilledCost: 32, SkuId: "function-invocations" }), + ]), + [ + "2026-11-01 | Function Duration | function-duration | web | iad1 | usage | 16 | 300", + "2026-11-02 | Function Duration | function-duration | api | iad1 | usage | 8 | 300", + "2026-11-02 | Function Duration | function-duration | web | fra1 | usage | 4 | 300", + "2026-11-02 | Function Duration | function-duration | web | iad1 | usage | 3.5 | 350", + "2026-11-02 | Function Duration | function-invocations | web | iad1 | usage | 32 | 300", + ], + ) + }) + + it("keeps a credit as its own negative row beside the usage it offsets", () => { + assert.deepStrictEqual( + summary([ + charge({ BilledCost: 5 }), + charge({ BilledCost: -5, ChargeCategory: "Credit", ConsumedQuantity: null }), + ]), + [ + "2026-11-02 | Function Duration | function-duration | web | iad1 | credit | -5 | 0", + "2026-11-02 | Function Duration | function-duration | web | iad1 | usage | 5 | 300", + ], + ) + }) + + it("leaves out what Vercel billed nothing for, but not a charge with an effective cost only", () => { + const rows = rowsOf([ + charge({ BilledCost: 0, EffectiveCost: 0, SkuId: "included" }), + charge({ BilledCost: 0, EffectiveCost: 0.75, SkuId: "committed" }), + // Two lines of one key that cancel out are nothing billed either. + charge({ BilledCost: 2, EffectiveCost: 2, SkuId: "reversed" }), + charge({ BilledCost: -2, EffectiveCost: -2, SkuId: "reversed" }), + ]) + assert.deepStrictEqual( + Arr.map(rows, (row) => [row.usageType, row.billedCost, row.effectiveCost]), + [["committed", 0, 0.75]], + ) + }) + + it("marks yesterday and today as still open to change", () => { + const days = ["2026-11-01", "2026-11-02", "2026-11-03", "2026-11-04"] + const rows = rowsOf(Arr.map(days, (day) => charge({ ChargePeriodStart: `${day}T00:00:00.000Z` }))) + assert.deepStrictEqual( + Arr.map(rows, (row) => [row.day, row.estimated]), + [ + ["2026-11-01", false], + ["2026-11-02", false], + ["2026-11-03", true], + ["2026-11-04", true], + ], + ) + }) + + it("drops a charge of a day outside the window", () => { + assert.deepStrictEqual( + summary([ + charge({ ChargePeriodStart: "2026-10-31T00:00:00.000Z" }), + charge({ ChargePeriodStart: "2026-11-05T00:00:00.000Z" }), + ]), + [], + ) + }) +}) diff --git a/packages/backend/src/services/cost/providers/vercel/normalize.ts b/packages/backend/src/services/cost/providers/vercel/normalize.ts new file mode 100644 index 0000000000..5904d0d65a --- /dev/null +++ b/packages/backend/src/services/cost/providers/vercel/normalize.ts @@ -0,0 +1,96 @@ +// Vercel billing charges as `cost_daily` rows: one per day, product, SKU, project, region and kind of charge. +import { CostChargeCategory } from "@maple/domain/cost" +import { Array as Arr, Option, Schema, String as Str } from "effect" +import type { VercelCharge } from "../../../integrations/vercel/api" +import type { CostPullRow, CostPullWindow } from "../../CostProvider" + +const decodeChargeCategory = Schema.decodeUnknownOption(CostChargeCategory) + +/** A FOCUS `ChargeCategory` (`Usage`, `Credit`, ...) as the domain names it; one it does not know is `unknown`. */ +const chargeCategoryOf = (value: string | null | undefined): CostChargeCategory => + Option.getOrElse(decodeChargeCategory(Str.toLowerCase(value ?? "")), (): CostChargeCategory => "unknown") + +/** What a missing or empty field reads as. */ +const orElse = (value: string | null | undefined, fallback: string): string => + value === undefined || value === null || value === "" ? fallback : value + +/** One charge as a row of its day. Vercel reports no list price, so the billed cost stands in. */ +const toCostRow = (charge: VercelCharge, accountId: string, openDay: string): CostPullRow => { + const day = Str.slice(0, 10)(charge.ChargePeriodStart) + const sku = orElse(charge.SkuId, "") + const project = orElse(charge.Tags?.ProjectName, orElse(charge.Tags?.ProjectId, "")) + return { + day, + accountId, + cloudService: charge.ServiceName, + serviceCategory: orElse(charge.ServiceCategory, "Other"), + usageType: sku === charge.ServiceName ? "" : sku, + region: orElse(charge.RegionId, "global"), + resourceId: project, + resourceType: project === "" ? "" : "project", + serviceName: "", + chargeCategory: chargeCategoryOf(charge.ChargeCategory), + basis: "billed", + currency: orElse(charge.BillingCurrency, "USD"), + billedCost: charge.BilledCost, + effectiveCost: charge.EffectiveCost ?? charge.BilledCost, + listCost: charge.BilledCost, + consumedQuantity: charge.ConsumedQuantity ?? 0, + consumedUnit: orElse(charge.ConsumedUnit, ""), + tags: {}, + estimated: day >= openDay, + } +} + +// NUL cannot be in any of the fields, so the key keeps them apart. +const keyOf = (row: CostPullRow): string => + Arr.join( + [ + row.day, + row.cloudService, + row.usageType, + row.resourceId, + row.region, + row.chargeCategory, + row.currency, + ], + "\u0000", + ) + +/** The window's charges so far, summed by day, product, SKU, project, region and kind of charge. */ +export type ChargeTotals = Map + +export const emptyChargeTotals = (): ChargeTotals => new Map() + +/** + * Adds a charge to `totals`, in place; a charge of a day outside the window is left out. Days from + * `openDay` on can still change: Vercel keeps counting a day for a while after it ends. + */ +export const addCharge = ( + totals: ChargeTotals, + charge: VercelCharge, + accountId: string, + window: CostPullWindow, + openDay: string, +): ChargeTotals => { + const row = toCostRow(charge, accountId, openDay) + if (row.day < window.startDay || row.day > window.endDay) return totals + const key = keyOf(row) + const sum = totals.get(key) + return totals.set( + key, + sum === undefined + ? row + : { + ...sum, + billedCost: sum.billedCost + row.billedCost, + effectiveCost: sum.effectiveCost + row.effectiveCost, + listCost: sum.listCost + row.listCost, + consumedQuantity: sum.consumedQuantity + row.consumedQuantity, + }, + ) +} + +/** The rows worth storing: a sum Vercel billed nothing for is left out, a credit (negative) is kept. */ +export const rowsOf = (totals: ChargeTotals): ReadonlyArray => + Arr.filter(Arr.fromIterable(totals.values()), (row) => row.billedCost !== 0 || row.effectiveCost !== 0) diff --git a/packages/backend/src/services/cost/sync-plan.test.ts b/packages/backend/src/services/cost/sync-plan.test.ts index b56cd2bb0f..990cc01c67 100644 --- a/packages/backend/src/services/cost/sync-plan.test.ts +++ b/packages/backend/src/services/cost/sync-plan.test.ts @@ -359,6 +359,56 @@ describe("planCostSync for a provider that serves 30 completed days", () => { }) }) +describe("planCostSync for a provider that serves a year in one request", () => { + const VERCEL_NEW: CostSyncState = { ...NEW, provider: "vercel" } + const vercelPulled = ( + now: number, + backfillDate: string, + ago: { readonly head: number; readonly settle: number }, + ): CostSyncState => ({ ...pulled(now, backfillDate, ago), provider: "vercel" }) + + it("starts with the previous month through today, like a provider with a longer history", () => { + const now = at("2026-11-20T12:00:00Z") + assert.deepStrictEqual(planCostSync(VERCEL_NEW, now), { + mode: "settle", + startDay: "2026-10-01", + endDay: "2026-11-20", + }) + }) + + it("keeps its history floor at the first day of the month eleven months back", () => { + assert.deepStrictEqual( + [historyFloor("vercel", "2026-11-20"), historyFloor("vercel", "2024-12-31")], + ["2025-12-01", "2024-01-01"], + ) + }) + + it("never asks for more than a year, whatever day and however long it was held", () => { + // The longest window there is: a source held since before the floor, on a leap year's last day. + const now = at("2024-12-31T12:00:00Z") + const held = vercelPulled(now, "2023-06-01", { head: 400 * DAY_MS, settle: 400 * DAY_MS }) + const plan = planCostSync(held, now) + assert.deepStrictEqual(plan, { mode: "settle", startDay: "2024-01-01", endDay: "2024-12-31" }) + assert.strictEqual(plan.mode === "idle" ? 0 : daysOfWindow(plan).length, 366) + }) + + it("walks history a month per step and stops at the floor", () => { + const now = at("2026-11-20T12:00:00Z") + const current = { head: HOUR_MS, settle: HOUR_MS } + assert.deepStrictEqual(planCostSync(vercelPulled(now, "2026-02-01", current), now), { + mode: "backfill", + startDay: "2026-01-01", + endDay: "2026-01-31", + }) + assert.deepStrictEqual(planCostSync(vercelPulled(now, "2026-01-01", current), now), { + mode: "backfill", + startDay: "2025-12-01", + endDay: "2025-12-31", + }) + assert.deepStrictEqual(planCostSync(vercelPulled(now, "2025-12-01", current), now), { mode: "idle" }) + }) +}) + describe("planCostSync request budgets", () => { const now = at("2026-11-11T12:00:00Z") const current = { head: HOUR_MS, settle: HOUR_MS } diff --git a/packages/backend/src/services/cost/sync-plan.ts b/packages/backend/src/services/cost/sync-plan.ts index b178bb1ec7..29e44b9142 100644 --- a/packages/backend/src/services/cost/sync-plan.ts +++ b/packages/backend/src/services/cost/sync-plan.ts @@ -31,6 +31,11 @@ export const HISTORY_MONTHS = 13 * dropped comes back empty, and an empty day replaces the stored one. */ const OPENROUTER_HISTORY_DAYS = 28 +/** + * Vercel serves a year at most in one request, so its history starts at the first day of the month + * this many months before the current one: a window from there to today is never over a year. + */ +const VERCEL_HISTORY_MONTHS = 11 // Through this day of the month the previous month's bill is still open; the first settle after // it takes the previous month once more, so its last pull is never from before it closed. export const PREVIOUS_MONTH_OPEN_DAYS = 10 @@ -66,6 +71,7 @@ interface CostSyncProfile { const COST_SYNC_PROFILES = { aws: { floor: (today) => monthStart(today, -HISTORY_MONTHS), lagDays: 0 }, openrouter: { floor: (today) => addDays(today, -OPENROUTER_HISTORY_DAYS), lagDays: 1 }, + vercel: { floor: (today) => monthStart(today, -VERCEL_HISTORY_MONTHS), lagDays: 0 }, } as const satisfies Record /** The oldest day the sync pulls from `provider`. */ diff --git a/packages/backend/src/services/cost/vercel-cost-sources.ts b/packages/backend/src/services/cost/vercel-cost-sources.ts new file mode 100644 index 0000000000..cd5cc28781 --- /dev/null +++ b/packages/backend/src/services/cost/vercel-cost-sources.ts @@ -0,0 +1,72 @@ +// An org's Vercel `cost_sources`. The connection does not make the source: the org adds its team +// on the Cost page, and that row is the opt-in. It goes when the org removes it, with the +// connection, and when the connection moves to another team (whose spend would overwrite its days). +import type { MapleDb } from "@maple/db/client" +import { CostSources, VercelConnections } from "@maple/db/tables" +import type { OrgId } from "@maple/primitives" +import * as PG from "@maple-dev/effect-orm/postgres" +import { Array as Arr, Effect } from "effect" + +/** Sources one run removes. */ +const RECONCILE_LIMIT = 200 + +/** Deletes the org's Vercel sources (it has one connection, so one source at most) and answers how many. */ +export const removeVercelCostSources = (db: MapleDb, orgId: OrgId) => + db + .run( + PG.deleteFrom(CostSources) + .where(($) => [$.orgId.eq(orgId), $.provider.eq("vercel")]) + .returning(($) => ({ id: $.id })), + ) + .pipe(Effect.map((rows) => rows.length)) + +/** + * A new token was saved for the team the source reads. The source is due at the next tick (no + * hold, and no last sync to wait a day from), and a pull in flight on the old token loses its claim. + */ +export const releaseVercelCostSources = (db: MapleDb, orgId: OrgId, now: number) => + db.run( + PG.update(CostSources) + .set({ + lastSyncedAt: null, + leaseUntil: null, + lastError: null, + lastErrorReason: null, + nextAttemptAt: null, + consecutiveFailures: 0, + updatedAt: now, + }) + .where(($) => [$.orgId.eq(orgId), $.provider.eq("vercel")]), + ) + +/** + * Removes the sources whose connection is gone, in every org. The sync lane runs it each tick: a + * connection can also vanish with its integration source, which no service of this provider + * sees. Joined by org, so a ref never crosses orgs. + */ +export const reconcileVercelCostSources = Effect.fnUntraced(function* (db: MapleDb) { + const orphaned = yield* db.run( + PG.from(CostSources, "s") + .leftJoin(VercelConnections, "c", (source, connection) => + source.orgId.eq(connection.orgId).and(source.connectionRef.eq(connection.id)), + ) + .select(($) => ({ id: $.id })) + .where(($) => [$.provider.eq("vercel"), $.c.id.isNull()]) + .orderBy(["id", "asc"]) + .limit(RECONCILE_LIMIT), + ) + const removed = Arr.isReadonlyArrayNonEmpty(orphaned) + ? yield* db.run( + PG.deleteFrom(CostSources) + .where(($) => [ + $.provider.eq("vercel"), + PG.inList( + $.id, + Arr.map(orphaned, (source) => source.id), + ), + ]) + .returning(($) => ({ id: $.id })), + ) + : [] + return { removed: removed.length } +}) diff --git a/packages/backend/src/services/integrations/VercelIntegrationService.test.ts b/packages/backend/src/services/integrations/VercelIntegrationService.test.ts index 66850c0ed9..8090e7b606 100644 --- a/packages/backend/src/services/integrations/VercelIntegrationService.test.ts +++ b/packages/backend/src/services/integrations/VercelIntegrationService.test.ts @@ -1396,6 +1396,135 @@ describe("VercelIntegrationService", () => { ) }) + it.effect( + "a cost source stays through a reconnect of its team, and goes with the team or the connection", + () => { + const testDb = createTestDb(trackedDbs) + const fake = makeFake() + fake.teams = [ + { id: "team_1", slug: "acme", name: "Acme" }, + { id: "team_2", slug: "beta", name: "Beta" }, + ] + /** The org adds its connected team as a cost source, the way the Cost page does. */ + const addCostSource = Effect.promise(() => + testDb.pglite.query( + `INSERT INTO cost_sources (id, org_id, provider, connection_ref, account_id, created_at, updated_at) + SELECT gen_random_uuid()::text, org_id, 'vercel', id, team_id, now(), now() FROM vercel_connections`, + ), + ) + const held = Effect.promise(() => + queryFirstRow<{ + error: string | null + reason: string | null + held: boolean + failures: number + leased: boolean + synced: boolean + }>( + testDb, + `SELECT last_error AS error, last_error_reason AS reason, next_attempt_at IS NOT NULL AS held, + consecutive_failures AS failures, lease_until IS NOT NULL AS leased, + last_synced_at IS NOT NULL AS synced + FROM cost_sources WHERE provider = 'vercel'`, + ), + ) + const costSources = Effect.promise(() => + testDb.pglite.query<{ account: string }>( + "SELECT account_id AS account FROM cost_sources WHERE provider = 'vercel'", + ), + ).pipe(Effect.map((result) => Arr.map(result.rows, (row) => row.account))) + return run( + testDb, + fake, + Effect.gen(function* () { + yield* connect() + yield* addCostSource + // The source is held for the old token's failure, with a pull on it still in flight. + yield* Effect.promise(() => + testDb.pglite.query( + `UPDATE cost_sources SET last_error = 'Vercel rejected the saved token.', + last_error_reason = 'auth', next_attempt_at = now() + interval '6 hours', + consecutive_failures = 3, lease_until = now() + interval '20 minutes', + watermark_date = '2026-10-18', last_synced_at = now()`, + ), + ) + // The team renamed its slug at Vercel since: the connection learns it on the reconnect. + fake.teams = [ + { id: "team_1", slug: "acme-labs", name: "Acme" }, + { id: "team_2", slug: "beta", name: "Beta" }, + ] + // A new token for the same team: the source and its history stay, and its account is the + // team's id, which the rename leaves alone, so stored rows and new ones stay one account. + yield* connect({ token: "vc_token_456" }) + assert.deepStrictEqual(yield* costSources, ["team_1"]) + // It is due at the next tick: no hold, no claim, and no last sync to wait a day from. + assert.deepStrictEqual(yield* held, { + error: null, + reason: null, + held: false, + failures: 0, + leased: false, + synced: false, + }) + // Another team: its spend would overwrite the days the source holds. + yield* connect({ token: "vc_token_789", teamId: "team_2" }) + assert.deepStrictEqual(yield* costSources, []) + + yield* addCostSource + assert.deepStrictEqual(yield* costSources, ["team_2"]) + yield* disconnect() + assert.deepStrictEqual(yield* costSources, []) + }), + ) + }, + ) + + it.effect( + "a reconnect decides from the team the connection has when it writes, not from an earlier read", + () => { + const testDb = createTestDb(trackedDbs) + const fake = makeFake() + fake.teams = [ + { id: "team_1", slug: "acme", name: "Acme" }, + { id: "team_2", slug: "beta", name: "Beta" }, + ] + const costSources = Effect.promise(() => + testDb.pglite.query<{ account: string }>( + "SELECT account_id AS account FROM cost_sources WHERE provider = 'vercel'", + ), + ).pipe(Effect.map((result) => Arr.map(result.rows, (row) => row.account))) + return run( + testDb, + fake, + Effect.gen(function* () { + yield* connect() + // This reconnect read team_1 as the connection's team. While it removes the old + // team's drains, another request commits the move to team_2 and adds its cost source. + let moved = false + fake.respond = (call) => { + if (moved || call.method !== "GET" || call.path !== "/v1/drains") return undefined + moved = true + return testDb.pglite + .query("UPDATE vercel_connections SET team_id = 'team_2', team_slug = 'beta'") + .then(() => + testDb.pglite.query( + `INSERT INTO cost_sources (id, org_id, provider, connection_ref, account_id, created_at, updated_at) + SELECT gen_random_uuid()::text, org_id, 'vercel', id, 'team_2', now(), now() FROM vercel_connections`, + ), + ) + .then(() => json({ drains: [] })) + } + const reconnected = yield* connect({ token: "vc_token_456", teamId: "team_2" }) + assert.isTrue(moved) + assert.strictEqual(reconnected.team?.id, "team_2") + // The connection was on team_2 already when this write ran: its source is that + // team's and stays. Decided from the earlier read, it would have been deleted. + assert.deepStrictEqual(yield* costSources, ["team_2"]) + }), + ) + }, + ) + it.effect("a first connect Vercel refuses deletes the drains it made and stays disconnected", () => { const testDb = createTestDb(trackedDbs) const fake = makeFake() diff --git a/packages/backend/src/services/integrations/VercelIntegrationService.ts b/packages/backend/src/services/integrations/VercelIntegrationService.ts index 5642198db6..545a7af8cd 100644 --- a/packages/backend/src/services/integrations/VercelIntegrationService.ts +++ b/packages/backend/src/services/integrations/VercelIntegrationService.ts @@ -53,7 +53,7 @@ import { String as Str, } from "effect" import { FetchHttpClient, HttpClient } from "effect/http" -import { openSecret, sealSecret, secretKeyringFromEnv } from "@maple/backend/platform/SecretBox" +import { sealSecret, secretKeyringFromEnv } from "@maple/backend/platform/SecretBox" import { vercelTokenSlot } from "@maple/backend/platform/secret-slots" import { Database } from "@maple/backend/platform/DatabaseLive" import { makeDbExecute, makePersistenceErrorMapper } from "@maple/backend/platform/db-execute" @@ -68,6 +68,7 @@ import { reportPoll, upsertWithSource, } from "@maple/backend/services/integrations/integration-sources" +import { releaseVercelCostSources, removeVercelCostSources } from "../cost/vercel-cost-sources" import { createDrain, deleteDrain, @@ -84,6 +85,25 @@ import { type VercelProjectListing, type VercelTeamInfo, } from "./vercel/api" +import { openVercelToken } from "./vercel/token" + +/** What a connection forgets when it moves to another team: that team's drains and deployments. */ +const NEW_TEAM_RESET = { + logDrainId: null, + traceDrainId: null, + aiGatewayDrainId: null, + speedInsightsDrainId: null, + webAnalyticsDrainId: null, + auditLogDrainId: null, + logDrainState: null, + traceDrainState: null, + aiGatewayDrainState: null, + speedInsightsDrainState: null, + webAnalyticsDrainState: null, + auditLogDrainState: null, + drainKeyHash: null, + deploymentsWatermarkAt: null, +} as const const DISCOVERY_TTL_MS = 60 * 60_000 const LEASE_MS = 4 * 60_000 @@ -469,8 +489,9 @@ export class VercelIntegrationService extends Context.Service< .where(($) => [$.orgId.eq(orgId)]) .returning("id", "sourceId"), ) - // The connection's source goes with it. + // The connection's source goes with it, and so does its cost source. yield* deleteSourcesOf(db, orgId, rows) + yield* removeVercelCostSources(db, orgId) yield* db.run(PG.deleteFrom(VercelDeployments).where(($) => [$.orgId.eq(orgId)])) yield* db.run(PG.deleteFrom(VercelProjects).where(($) => [$.orgId.eq(orgId)])) }), @@ -478,11 +499,9 @@ export class VercelIntegrationService extends Context.Service< ) const decryptToken = (connection: VercelConnectionRow) => - openSecret( + openVercelToken( keyring, - vercelTokenSlot, - { orgId: connection.orgId }, - { ciphertext: connection.tokenCiphertext, iv: connection.tokenIv, tag: connection.tokenTag }, + connection, () => new IntegrationsPersistenceError({ message: "Failed to decrypt the Vercel token" }), ) @@ -1196,60 +1215,54 @@ export class VercelIntegrationService extends Context.Service< // A reconnect keeps the connection id and so its source. const connection = yield* dbExecute((db) => db.transaction( - upsertWithSource( - db, - orgId, - { platform: "vercel", name: integrationSourceName.vercel({ teamName: team.name }) }, - now, - db - .run( - PG.from(VercelConnections) - .select("sourceId") - .where(($) => [$.orgId.eq(orgId)]) - .limit(1) - // Held until the upsert, so a concurrent disconnect cannot delete the reused source. - .forUpdate(), - ) - .pipe(Effect.map(([row]) => row?.sourceId ?? null)), - (sourceId) => - db - .run( - PG.insertInto(VercelConnections) - .values({ - id: randomUUID(), - orgId, - sourceId, - ...fields, - createdAt: now, - }) - .onConflictDoUpdate({ - target: ["orgId"], - // A new team starts its drains and deployment history over. - set: () => - sameTeam - ? fields - : { - ...fields, - logDrainId: null, - traceDrainId: null, - aiGatewayDrainId: null, - speedInsightsDrainId: null, - webAnalyticsDrainId: null, - auditLogDrainId: null, - logDrainState: null, - traceDrainState: null, - aiGatewayDrainState: null, - speedInsightsDrainState: null, - webAnalyticsDrainState: null, - auditLogDrainState: null, - drainKeyHash: null, - deploymentsWatermarkAt: null, - }, - }) - .returning(), - ) - .pipe(Effect.map(([row]) => row)), - ), + Effect.gen(function* () { + // Held until the upsert, so a concurrent disconnect cannot delete the reused source. + // The team is read here, not before: a reconnect that committed since may have moved it. + const [locked] = yield* db.run( + PG.from(VercelConnections) + .select("sourceId", "teamId") + .where(($) => [$.orgId.eq(orgId)]) + .limit(1) + .forUpdate(), + ) + const keepsTeam = locked !== undefined && locked.teamId === team.id + const saved = yield* upsertWithSource( + db, + orgId, + { + platform: "vercel", + name: integrationSourceName.vercel({ teamName: team.name }), + }, + now, + Effect.succeed(locked?.sourceId ?? null), + (sourceId) => + db + .run( + PG.insertInto(VercelConnections) + .values({ + id: randomUUID(), + orgId, + sourceId, + ...fields, + createdAt: now, + }) + .onConflictDoUpdate({ + target: ["orgId"], + // A new team starts its drains and deployment history over. + set: () => + keepsTeam ? fields : { ...fields, ...NEW_TEAM_RESET }, + }) + .returning(), + ) + .pipe(Effect.map(([row]) => row)), + ) + // A cost source reads one team: the new team's spend would overwrite its days. + // The same team with a new token keeps it, and makes it due again at once. + yield* keepsTeam + ? Effect.asVoid(releaseVercelCostSources(db, orgId, now)) + : Effect.asVoid(removeVercelCostSources(db, orgId)) + return saved + }), ), ) if (connection === undefined) { diff --git a/packages/backend/src/services/integrations/vercel/api.ts b/packages/backend/src/services/integrations/vercel/api.ts index 6231379482..f267f29f5e 100644 --- a/packages/backend/src/services/integrations/vercel/api.ts +++ b/packages/backend/src/services/integrations/vercel/api.ts @@ -3,7 +3,7 @@ * No Effect SDK exists, so this decodes only the fields Maple reads. Failures fold into one * {@link VercelApiError} whose `kind` drives the poller (auth failure, back-off, missing drain). */ -import { Duration, Effect, Option, Schema, Array as Arr, Record as Rec, String as Str } from "effect" +import { Duration, Effect, Option, Schema, Stream, Array as Arr, Record as Rec, String as Str } from "effect" import { HttpClient, HttpClientRequest } from "effect/http" const BASE_URL = "https://api.vercel.com" @@ -16,7 +16,15 @@ export class VercelApiError extends Schema.TaggedError()( "@maple/backend/integrations/VercelApiError", { message: Schema.String, - kind: Schema.Literals(["unauthorized", "forbidden", "rate_limited", "not_found", "upstream"]), + // `invalid_response`: a 2xx answer Maple could not read. Only the charges read tells it from `upstream`. + kind: Schema.Literals([ + "unauthorized", + "forbidden", + "rate_limited", + "not_found", + "upstream", + "invalid_response", + ]), status: Schema.optionalKey(Schema.Number), /** Vercel's own error message, without the status prefix. */ detail: Schema.optionalKey(Schema.String), @@ -40,6 +48,17 @@ const errorKind = (status: number): VercelApiError["kind"] => ? "rate_limited" : "upstream" +/** A non-2xx answer as the error callers branch on, with Vercel's own message when the body has one. */ +const refusal = (status: number, body: unknown) => { + const detail = Option.getOrUndefined(decodeErrorBody(body))?.error.message + return new VercelApiError({ + message: `Vercel API returned ${status}${detail === undefined ? "" : `: ${Str.slice(0, 300)(detail)}`}`, + kind: errorKind(status), + status, + ...(detail === undefined ? undefined : { detail: Str.slice(0, 300)(detail) }), + }) +} + const call = ( httpClient: HttpClient.HttpClient, token: string, @@ -51,15 +70,7 @@ const call = ( return Effect.gen(function* () { const response = yield* httpClient.execute(HttpClientRequest.bearerToken(request, token)) const body = yield* response.json.pipe(Effect.orElseSucceed(() => null)) - if (response.status >= 300) { - const detail = Option.getOrUndefined(decodeErrorBody(body))?.error.message - return yield* new VercelApiError({ - message: `Vercel API returned ${response.status}${detail === undefined ? "" : `: ${Str.slice(0, 300)(detail)}`}`, - kind: errorKind(response.status), - status: response.status, - ...(detail === undefined ? undefined : { detail: Str.slice(0, 300)(detail) }), - }) - } + if (response.status >= 300) return yield* refusal(response.status, body) return yield* Schema.decodeEffect(schema)(body) }).pipe( Effect.timeout(REQUEST_TIMEOUT), @@ -463,3 +474,120 @@ export const deleteDrain = Effect.fn("VercelApi.deleteDrain")(function* ( ), ) }) + +// Billing + +const optional = (schema: S) => Schema.optionalKey(Schema.NullOr(schema)) + +/** An ISO 8601 date-time in UTC, to the minute at least: `2026-11-02T00:00:00.000Z`. */ +const UTC_DATE_TIME = + /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])T([01]\d|2[0-3]):[0-5]\d(:[0-5]\d(\.\d+)?)?(Z|[+-]00:00)$/ + +const isLeapYear = (year: number): boolean => (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0 + +const daysInMonth = (year: number, month: number): number => + month === 2 ? (isLeapYear(year) ? 29 : 28) : Arr.contains([4, 6, 9, 11], month) ? 30 : 31 + +/** Whether the day a date-time starts with exists: no February 31st, a February 29th in leap years only. */ +const startsWithRealDay = (value: string): boolean => + Number(Str.slice(8, 10)(value)) <= + daysInMonth(Number(Str.slice(0, 4)(value)), Number(Str.slice(5, 7)(value))) + +/** + * One line of the charges answer: a FOCUS v1.3 charge. The shape is Vercel's OpenAPI spec, not a + * recorded answer, so everything Maple can do without is optional and may be null. + */ +export const VercelCharge = Schema.Struct({ + // Finite: `1e309` is valid JSON and parses as Infinity, which no sum or store survives. + BilledCost: Schema.Finite, + /** Inclusive, ISO 8601 in UTC: its first ten characters are the usage day. */ + ChargePeriodStart: Schema.String.check( + Schema.isPattern(UTC_DATE_TIME), + Schema.makeFilter(startsWithRealDay, { expected: "a date-time on a day of the calendar" }), + ), + /** The product's display name. */ + ServiceName: Schema.String, + BillingCurrency: optional(Schema.String), + /** `Usage`, `Purchase`, `Credit`, `Tax` or `Adjustment`. */ + ChargeCategory: optional(Schema.String), + /** Null when the charge is not usage. */ + ConsumedQuantity: optional(Schema.Finite), + ConsumedUnit: optional(Schema.String), + EffectiveCost: optional(Schema.Finite), + RegionId: optional(Schema.String), + /** A FOCUS category: `Compute`, `Networking`, `Storage`, ... */ + ServiceCategory: optional(Schema.String), + /** The product's stable id. */ + SkuId: optional(Schema.String), + Tags: optional( + Schema.Struct({ ProjectId: optional(Schema.String), ProjectName: optional(Schema.String) }), + ), +}) +export type VercelCharge = typeof VercelCharge.Type + +const decodeChargeLine = Schema.decodeUnknownEffect(Schema.fromJsonString(VercelCharge)) + +/** + * The team's billed charges from `from` (inclusive) to `to` (exclusive), both ISO 8601 in UTC, one + * per line of the JSONL answer as it streams in. Blank lines are skipped. A line that is not a + * charge fails the read as `invalid_response`. + */ +export const streamBillingCharges = ( + httpClient: HttpClient.HttpClient, + token: string, + teamId: string, + range: { readonly from: string; readonly to: string }, +): Stream.Stream => + Stream.unwrap( + Effect.gen(function* () { + const response = yield* httpClient + .execute( + HttpClientRequest.bearerToken( + teamUrl("/v1/billing/charges", teamId, { from: range.from, to: range.to }), + token, + ), + ) + .pipe(Effect.annotateSpans("peer.service", "vercel")) + if (response.status >= 300) { + const body = yield* response.json.pipe(Effect.orElseSucceed(() => null)) + return yield* refusal(response.status, body) + } + // Only a 200 is the whole range: a 204 or a 206 would store days empty or by halves. + if (response.status !== 200) { + return yield* new VercelApiError({ + message: `Vercel API answered ${response.status} where the charges are a 200`, + kind: "invalid_response", + status: response.status, + }) + } + return response.stream.pipe( + Stream.decodeText(), + Stream.splitLines, + Stream.map(Str.trim), + Stream.filter(Str.isNonEmpty), + Stream.mapEffect((line) => decodeChargeLine(line)), + ) + }), + ).pipe( + Stream.catchTags({ + HttpClientError: (cause) => + // A team with no charges in the range may answer with no body at all. + cause.reason._tag === "EmptyBodyError" + ? Stream.empty + : Stream.fail( + new VercelApiError({ + message: "Vercel API request failed", + kind: "upstream", + cause, + }), + ), + SchemaError: (cause) => + Stream.fail( + new VercelApiError({ + message: "Vercel API answered with a charge Maple could not read", + kind: "invalid_response", + cause, + }), + ), + }), + ) diff --git a/packages/backend/src/services/integrations/vercel/token.ts b/packages/backend/src/services/integrations/vercel/token.ts new file mode 100644 index 0000000000..401fbaef7a --- /dev/null +++ b/packages/backend/src/services/integrations/vercel/token.ts @@ -0,0 +1,17 @@ +import type { VercelConnectionRow } from "@maple/db/tables" +import { openSecret, type SecretKeyring } from "@maple/backend/platform/SecretBox" +import { vercelTokenSlot } from "@maple/backend/platform/secret-slots" + +/** The connection's Vercel access token, unsealed. Shared by the integration and the cost adapter. */ +export const openVercelToken = ( + keyring: SecretKeyring, + connection: Pick, + onError: (message: string) => E, +) => + openSecret( + keyring, + vercelTokenSlot, + { orgId: connection.orgId }, + { ciphertext: connection.tokenCiphertext, iv: connection.tokenIv, tag: connection.tokenTag }, + onError, + ) diff --git a/packages/domain/src/audit-actions.ts b/packages/domain/src/audit-actions.ts index be2fecaccf..98bd9bd541 100644 --- a/packages/domain/src/audit-actions.ts +++ b/packages/domain/src/audit-actions.ts @@ -131,6 +131,8 @@ export const AuditResources = { cloudflare_integration: { verbs: ["zones_updated"] }, /** A provider key the org's spend is read with, one per provider; the key is never in `changes`. */ cost_source_key: { verbs: ["updated", "deleted"] }, + /** A connection the org already has, added to or taken off the Cost page; the provider is in `metadata`. */ + cost_source: { verbs: ["added", "removed"] }, /** * Org membership, learned from Clerk's webhook; the web app changes members * in Clerk directly, so nothing reaches Maple's own API. The member is the @@ -248,6 +250,7 @@ export const AuditResourceLabels = { planetscale_integration: "PlanetScale integration", cloudflare_integration: "Cloudflare integration", cost_source_key: "Cost source key", + cost_source: "Cost source", member: "Member", organization: "Organization", scrape_target: "Scrape target", @@ -375,6 +378,8 @@ export const AuditActionLabels = { "cloudflare_integration.zones_updated": "Updated Cloudflare zones", "cost_source_key.updated": "Updated cost source key", "cost_source_key.deleted": "Removed cost source key", + "cost_source.added": "Added cost source", + "cost_source.removed": "Removed cost source", "member.added": "Added member", "member.role_changed": "Changed member role", "member.removed": "Removed member", diff --git a/packages/domain/src/cost.ts b/packages/domain/src/cost.ts index b18d84f62e..8139ac8ec4 100644 --- a/packages/domain/src/cost.ts +++ b/packages/domain/src/cost.ts @@ -24,7 +24,7 @@ export const CostDimension = Schema.Literals([ export type CostDimension = typeof CostDimension.Type /** Providers Maple reads cost from. One literal per built adapter. */ -export const CostProvider = Schema.Literals(["aws", "openrouter"]) +export const CostProvider = Schema.Literals(["aws", "openrouter", "vercel"]) export type CostProvider = typeof CostProvider.Type /** `billed`: the provider reported the money. `estimated`: usage times list price. */ diff --git a/packages/domain/src/http/index.ts b/packages/domain/src/http/index.ts index e777872091..f3e654d9c5 100644 --- a/packages/domain/src/http/index.ts +++ b/packages/domain/src/http/index.ts @@ -9,6 +9,7 @@ export * from "./dependency-scan" export * from "./ai-triage" export * from "./openrouter-key" export * from "./openrouter-cost" +export * from "./vercel-cost" export * from "./service-map-world" export * from "./incident-triage" export * from "./cancellation-review" diff --git a/packages/domain/src/http/internal-api.ts b/packages/domain/src/http/internal-api.ts index 6613fd5b0d..f7995d9099 100644 --- a/packages/domain/src/http/internal-api.ts +++ b/packages/domain/src/http/internal-api.ts @@ -22,6 +22,7 @@ import { QueryEngineApiGroup } from "./query-engine" import { ServiceMapWorldApiGroup } from "./service-map-world" import { SessionReplaysInternalApiGroup } from "./session-replay" import { UsersInternalApiGroup } from "./users" +import { VercelCostApiGroup } from "./vercel-cost" import { ApiSchemaErrors, ApiUnexpectedErrors } from "./api-boundary" /** @@ -61,6 +62,7 @@ export class MapleInternalApi extends HttpApi.make("MapleInternalApi") .add(McpInternalApiGroup) .add(OpenRouterKeyApiGroup) .add(OpenRouterCostApiGroup) + .add(VercelCostApiGroup) .add(OrgClickHouseSettingsApiGroup) .add(OrganizationsApiGroup) .add(OrganizationCreationApiGroup) diff --git a/packages/domain/src/http/query-engine.ts b/packages/domain/src/http/query-engine.ts index b362946620..da216c6b10 100644 --- a/packages/domain/src/http/query-engine.ts +++ b/packages/domain/src/http/query-engine.ts @@ -3277,6 +3277,8 @@ export const CostSourceSummary = Schema.Struct({ id: CostSourceId, provider: CostProvider, accountId: Schema.String, + /** How a reader knows the account, where its id is not it: a Vercel team's slug beside its team id. */ + accountName: Schema.optionalKey(Schema.String), status: CostSourceStatus, /** The newest and oldest UTC day pulled, `YYYY-MM-DD`; null before the first pull. */ watermarkDate: Schema.NullOr(Schema.String), diff --git a/packages/domain/src/http/vercel-cost.ts b/packages/domain/src/http/vercel-cost.ts new file mode 100644 index 0000000000..98b1947181 --- /dev/null +++ b/packages/domain/src/http/vercel-cost.ts @@ -0,0 +1,74 @@ +import { HttpApiEndpoint, HttpApiGroup } from "effect/http-api" +import { Schema } from "effect" +import { SessionAuthorization } from "./current-tenant" +import { + IntegrationsConnectionChangedError, + IntegrationsForbiddenError, + IntegrationsNotConnectedError, + IntegrationsPersistenceError, + IntegrationsUpstreamError, + IntegrationsValidationError, +} from "./integrations" +import { CloudCostDisabledError } from "./query-engine" + +/** The Vercel team the org's connection reads: its id at Vercel, and how the Cost page names it. */ +export const VercelCostTeam = Schema.Struct({ id: Schema.String, name: Schema.String, slug: Schema.String }) +export type VercelCostTeam = typeof VercelCostTeam.Type + +/** What the Cost page needs to offer Vercel: the connected team, if any, and whether it is a source. */ +export class VercelCostStatus extends Schema.Class("VercelCostStatus")({ + /** Null when the org has no Vercel connection. */ + team: Schema.NullOr(VercelCostTeam), + /** Vercel rejected the stored token: the team has to be connected again before it can supply cost. */ + authFailed: Schema.Boolean, + /** The team is a cost source already. */ + added: Schema.Boolean, +}) {} + +/** The team the dialog showed: an add of a connection that has moved to another team is refused. */ +export class VercelCostAddRequest extends Schema.Class("VercelCostAddRequest")({ + teamId: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(128)), +}) {} + +export class VercelCostSourceAdded extends Schema.Class("VercelCostSourceAdded")({ + team: VercelCostTeam, +}) {} + +export class VercelCostSourceRemoved extends Schema.Class("VercelCostSourceRemoved")( + { + /** False when there was no Vercel source to remove. */ + removed: Schema.Boolean, + }, +) {} + +/** The org's Vercel team as a cost source: adding makes the source, removing deletes it. */ +export class VercelCostApiGroup extends HttpApiGroup.make("vercelCost") + .add( + HttpApiEndpoint.get("status", "/", { + success: VercelCostStatus, + error: [IntegrationsPersistenceError, CloudCostDisabledError], + }), + ) + .add( + HttpApiEndpoint.put("add", "/", { + payload: VercelCostAddRequest, + success: VercelCostSourceAdded, + error: [ + IntegrationsPersistenceError, + IntegrationsForbiddenError, + IntegrationsNotConnectedError, + IntegrationsConnectionChangedError, + IntegrationsValidationError, + IntegrationsUpstreamError, + CloudCostDisabledError, + ], + }), + ) + .add( + HttpApiEndpoint.delete("remove", "/", { + success: VercelCostSourceRemoved, + error: [IntegrationsPersistenceError, IntegrationsForbiddenError], + }), + ) + .prefix("/internal/cost/vercel") + .middleware(SessionAuthorization) {}