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..5814ed825a --- /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.created", { + 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.deleted", { 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 7c5c3faa96..af27cdae03 100644 --- a/apps/api/src/runtime/http-graph.ts +++ b/apps/api/src/runtime/http-graph.ts @@ -15,6 +15,7 @@ import { HttpGithubCostLive } from "@/routes/internal/github-cost.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" @@ -170,6 +171,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 1c59876a3d..5ec8a58566 100644 --- a/apps/api/src/runtime/service-graph.ts +++ b/apps/api/src/runtime/service-graph.ts @@ -68,6 +68,7 @@ import { SupabaseLogsService } from "@maple/backend/services/integrations/Supaba import { SupabaseOAuthService } from "@maple/backend/services/auth/SupabaseOAuthService" import { GithubCostSourceService } from "@maple/backend/services/cost/GithubCostSourceService" 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" @@ -190,6 +191,7 @@ export const HttpServicesLive = Layer.mergeAll( OrgOpenRouterKeyService.layer, OpenRouterCostConnectionService.layer, GithubCostSourceService.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 0aad134db4..874a3f0611 100644 --- a/apps/web/src/lab/cost-fixture.test.ts +++ b/apps/web/src/lab/cost-fixture.test.ts @@ -25,7 +25,7 @@ describe("cost lab fixture", () => { .rows, (row) => row.key, ), - ).toEqual(["aws", "github"]) + ).toEqual(["aws", "vercel", "github"]) // Filtered to OpenRouter there is nothing left: AWS cost must not show under that filter. expect(labTotals(openRouterOnly).cost).toBeGreaterThan(0) @@ -46,7 +46,11 @@ describe("cost lab fixture", () => { test("a GitHub organization is added and removed on its own, and the other keeps its source and cost", () => { const github = (changes: Readonly>) => { - const sources = labSourcesWith(LAB_SOURCES.synced, { openRouter: null, github: changes }) + const sources = labSourcesWith(LAB_SOURCES.synced, { + openRouter: null, + github: changes, + vercel: null, + }) return { sources: Arr.map( Arr.filter(sources.sources, (source) => source.provider === "github"), @@ -57,7 +61,7 @@ describe("cost lab fixture", () => { (installation) => `${installation.accountLogin} ${installation.state}`, ), costRemoved: Arr.contains( - labRemovedProviders({ openRouter: null, github: changes }), + labRemovedProviders({ openRouter: null, github: changes, vercel: null }), "github", ), } @@ -89,4 +93,52 @@ describe("cost lab fixture", () => { }) expect(github({ [org]: true })).toMatchObject({ sources: ["example-org pending"], costRemoved: true }) }) + test("a removed Vercel source leaves every read too, and the other providers stay", () => { + expect(labTotals({ ...window, without: ["vercel"] }).cost).toBeLessThan(labTotals(window).cost) + expect( + Arr.map( + labBreakdown({ ...window, without: ["vercel"], groupBy: "provider", sortBy: "cost" }).rows, + (row) => row.key, + ), + ).toEqual(["aws", "openrouter", "github"]) + expect(labTotals({ days: 30, filters: { provider: ["vercel"] }, without: ["vercel"] }).cost).toBe(0) + }) + + test("a Vercel source added again after its removal is a new one that waits for its first sync", () => { + const after = (vercel: boolean | null) => { + const changes = { openRouter: null, github: {}, vercel } + const sources = labSourcesWith(LAB_SOURCES.synced, changes) + return { + vercel: Arr.map( + Arr.filter(sources.sources, (source) => source.provider === "vercel"), + (source) => `${source.accountName} ${source.status}`, + ), + others: Arr.filter(sources.sources, (source) => source.provider !== "vercel").length, + costRemoved: Arr.contains(labRemovedProviders(changes), "vercel"), + } + } + const others = LAB_SOURCES.synced.sources.length - 1 + expect(after(null)).toEqual({ vercel: ["example-team ok"], others, costRemoved: false }) + expect(after(false)).toEqual({ vercel: [], others, costRemoved: true }) + // Not the synced row it started with, and no cost until a sync the lab does not model. + expect(after(true)).toEqual({ vercel: ["example-team pending"], others, costRemoved: true }) + }) + + test("grouped by resource, cost splits by GitHub repository and Vercel project, and the rest has none", () => { + expect( + Arr.map(labBreakdown({ ...window, groupBy: "resource", sortBy: "cost" }).rows, (row) => row.key), + ).toEqual(["", "example-web", "example-api", "example-docs", "example-site"]) + // Narrowed to a provider, only its own resources are left. + expect( + Arr.map( + labBreakdown({ + days: 30, + filters: { provider: ["github"] }, + groupBy: "resource", + sortBy: "cost", + }).rows, + (row) => row.key, + ), + ).toEqual(["example-api", "", "example-site"]) + }) }) diff --git a/apps/web/src/lab/cost-fixture.ts b/apps/web/src/lab/cost-fixture.ts index f5a420c407..1cbd9b8046 100644 --- a/apps/web/src/lab/cost-fixture.ts +++ b/apps/web/src/lab/cost-fixture.ts @@ -37,6 +37,7 @@ const line = ( service_category, region, usage_type, + resource: "", account: LAB_ACCOUNT, daily, drift, @@ -53,6 +54,7 @@ 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, @@ -65,17 +67,52 @@ const LAB_GITHUB_INSTALLATION = "0190f2b3-0000-4000-8000-00000000e061" const LAB_GITHUB_OTHER_ORG = "example-labs" const LAB_GITHUB_OTHER_INSTALLATION = "0190f2b3-0000-4000-8000-00000000e062" -const githubLine = (product: string, sku: string, daily: number, drift: number): Line => ({ +/** `repository` is the row's resource; a seat product names none. */ +const githubLine = ( + product: string, + sku: string, + repository: string, + daily: number, + drift: number, +): Line => ({ cloud_service: product, provider: "github", service_category: "Developer Tools", region: "global", usage_type: sku, + resource: repository, account: LAB_GITHUB_ORG, daily, drift, }) +/** The Vercel team the lab's source reads: its id is the account, its slug what the page shows. */ +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), @@ -99,10 +136,32 @@ 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), - githubLine("Actions", "Actions Linux", 9.4, 0.25), - githubLine("Actions", "Actions storage", 0.7, 0.05), - githubLine("Copilot", "Copilot Business", 6.3, 0.0), - githubLine("Packages", "Packages storage", 0.4, -0.1), + githubLine("Actions", "Actions Linux", "example-api", 9.4, 0.25), + githubLine("Actions", "Actions storage", "example-site", 0.7, 0.05), + githubLine("Copilot", "Copilot Business", "", 6.3, 0.0), + githubLine("Packages", "Packages storage", "example-api", 0.4, -0.1), + 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`. */ @@ -118,7 +177,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]) @@ -284,6 +351,18 @@ const githubSource = 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-00000000c063", + provider: "vercel", + connectionRef: "0190f2b3-0000-7000-8000-00000000a063", + accountId: LAB_VERCEL_TEAM.id, + accountName: LAB_VERCEL_TEAM.slug, + backfillDate: "2025-12-01", + lastSyncedAt: LAB_NOW_MS - 2 * 3_600_000, + requestsThisMonth: 0, +}) + /** A source that was just added: it waits for its first sync. */ const justAdded = (entry: SourceWire): SourceWire => ({ ...entry, @@ -311,12 +390,13 @@ export const labGithubLogin = (installationId: string): string => ) /** - * What the lab's reader did since the state loaded. `openRouter`: added (true), removed (false) or - * neither (null). `github`: an installation's id to whether it is a source now. + * What the lab's reader did since the state loaded. `openRouter` and `vercel`: added (true), + * removed (false) or neither (null). `github`: an installation's id to whether it is a source now. */ export type LabSourceChanges = Readonly<{ openRouter: boolean | null github: Readonly> + vercel: boolean | null }> /** @@ -328,7 +408,9 @@ export const labRemovedProviders = (changes: LabSourceChanges): ReadonlyArray (provider === "openrouter" && changes.openRouter === false) || - (provider === "github" && Rec.has(changes.github, LAB_GITHUB_INSTALLATION)), + (provider === "github" && Rec.has(changes.github, LAB_GITHUB_INSTALLATION)) || + // A team added again is a new source: it has no cost until a sync the lab does not model. + (provider === "vercel" && changes.vercel !== null), ) /** @@ -342,13 +424,16 @@ export const labSourcesWith = ( const kept = Arr.filter(Schema.encodeSync(CostSourcesResponse)(sources).sources, (entry) => entry.provider === "openrouter" ? changes.openRouter !== false - : entry.provider !== "github" || !Rec.has(changes.github, entry.connectionRef), + : entry.provider === "vercel" + ? changes.vercel === null + : entry.provider !== "github" || !Rec.has(changes.github, entry.connectionRef), ) const hasOpenRouter = Arr.some(kept, (entry) => entry.provider === "openrouter") return decodeSources({ sources: [ ...kept, ...(changes.openRouter === true && !hasOpenRouter ? [justAdded(openRouterSource)] : []), + ...(changes.vercel === true ? [justAdded(vercelSource)] : []), ...Arr.map( Arr.filter(LAB_GITHUB_ORGS, (org) => changes.github[org.installationId] === true), (org) => @@ -399,7 +484,7 @@ export const labGithubInstallations = (sources: CostSourcesResponse): GithubCost } export const LAB_SOURCES = { - synced: decodeSources({ sources: [source({}), openRouterSource, githubSource] }), + synced: decodeSources({ sources: [source({}), openRouterSource, githubSource, vercelSource] }), none: decodeSources({ sources: [] }), pending: decodeSources({ sources: [ @@ -437,6 +522,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 2a9dd1ffde..6d22618774 100644 --- a/apps/web/src/lab/cost-lab.ts +++ b/apps/web/src/lab/cost-lab.ts @@ -4,6 +4,9 @@ import { GithubCostSourceRemoved, OpenRouterCostKeyRemoved, OpenRouterCostKeySaved, + VercelCostSourceAdded, + VercelCostSourceRemoved, + VercelCostStatus, } from "@maple/domain/http" import { VcsInstallationId } from "@maple/domain/vcs" import * as Policy from "@maple/domain/policy" @@ -16,6 +19,7 @@ import { defineMessageUnion } from "foldkit/message" import { fromString } from "foldkit/url" import { AddGithubCostSource, + AddVercelCostSource, BreakdownInput, FacetsInput, FetchCostBreakdown, @@ -24,9 +28,11 @@ import { FetchCostTimeseries, FetchCostTotals, FetchGithubCostInstallations, + FetchVercelCostStatus, ReadCostGrants, RemoveGithubCostSource, RemoveOpenRouterCostKey, + RemoveVercelCostSource, ResolveWindow, SaveOpenRouterCostKey, TimeseriesInput, @@ -44,6 +50,7 @@ import { LAB_OPENROUTER_KEY, LAB_SOURCES, LAB_TODAY, + LAB_VERCEL_TEAM, type LabSourceChanges, labBreakdown, labGithubInstallations, @@ -69,6 +76,7 @@ export const LabState = Schema.Literals([ "empty-window", "loading", "by-provider", + "by-resource", "add-source", "add-openrouter", "key-refused", @@ -78,6 +86,10 @@ export const LabState = Schema.Literals([ "github-refused", "github-none", "remove-github", + "add-vercel", + "vercel-not-connected", + "vercel-refused", + "remove-vercel", "member", "member-onramp", ]) @@ -99,6 +111,8 @@ export const Model = Schema.Struct({ openRouter: Schema.NullOr(Schema.Boolean), /** GitHub installations added (true) or removed (false) since the state loaded, by id. */ github: Schema.Record(Schema.String, Schema.Boolean), + /** Vercel added (true) or removed (false) since the state loaded; null before either. */ + vercel: Schema.NullOr(Schema.Boolean), cost: CostModel, }) export type Model = typeof Model.Type @@ -116,7 +130,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 => @@ -125,7 +141,10 @@ const startsEmpty = (state: LabState): boolean => state === "add-source" || state === "add-openrouter" || state === "key-refused" || - state === "github-none" + state === "github-none" || + 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 => { @@ -164,6 +183,17 @@ const openingOf = (state: LabState): ReadonlyArray => { ] case "remove-github": return removalOf("github") + 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 removalOf("vercel") // Without Clerk the page starts with every permission: the member states take them away. case "member": case "member-onramp": @@ -174,7 +204,7 @@ const openingOf = (state: LabState): ReadonlyArray => { } /** The click on Remove in the row of the lab's source of `provider`. */ -const removalOf = (provider: "openrouter" | "github"): ReadonlyArray => +const removalOf = (provider: "openrouter" | "github" | "vercel"): ReadonlyArray => Arr.map( Arr.filter(LAB_SOURCES.synced.sources, (source) => source.provider === provider), (source) => CostMessage.ClickedRemoveSource({ sourceId: source.id }), @@ -187,6 +217,12 @@ const refusedBilling = { "GitHub has not granted Maple access to billing yet. Accept the new permission in the organization's GitHub App settings.", } +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.", +} + const refusedKey = { ...toLoadFailure("refused"), message: @@ -213,6 +249,17 @@ const sourcesFor = (state: LabState) => ? LAB_SOURCES.mixed : LAB_SOURCES.synced +/** 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: Arr.some( + labSourcesWith(sourcesFor(world.state), world).sources, + (source) => source.provider === "vercel", + ), + }) + const failure = toLoadFailure("The warehouse did not answer in time") const argsOf = (input: Schema.Codec) => @@ -364,6 +411,25 @@ const answerOf = (world: World, search: Search, command: CostCommand): Option.Op result: Result.succeed(new GithubCostSourceRemoved({ 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() } @@ -378,7 +444,7 @@ type Answered = Readonly<{ command: CostCommand; answer: CostMessage }> const changesAfter = (changes: LabSourceChanges, answered: ReadonlyArray): LabSourceChanges => Arr.reduce(answered, changes, (current, { command, answer }): LabSourceChanges => { const withGithub = (installationId: string, isSource: boolean): LabSourceChanges => ({ - openRouter: current.openRouter, + ...current, github: { ...current.github, [installationId]: isSource }, }) switch (answer._tag) { @@ -395,6 +461,10 @@ const changesAfter = (changes: LabSourceChanges, answered: ReadonlyArray Result.isSuccess(answer.result) ? withGithub(installationId, 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 } @@ -413,7 +483,13 @@ const settle = (world: World, step: Step): Settled => { const answers = Arr.map(answered, ({ answer }) => answer) const kept = keepLocal(step.commands) if (answers.length === 0) { - return { model: step.model, commands: kept, openRouter: world.openRouter, github: world.github } + return { + model: step.model, + commands: kept, + openRouter: world.openRouter, + github: world.github, + vercel: world.vercel, + } } const next = Arr.reduce( answers, @@ -442,18 +518,24 @@ 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, github: {} } + const world: World = { state: flags.state, openRouter: null, github: {}, 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, github: step.github }, + { ...world, openRouter: step.openRouter, github: step.github, vercel: step.vercel }, { model: next.model, commands: next.commands ?? [] }, ) return { ...settled, commands: [...step.commands, ...settled.commands] } }) return { - model: { state: flags.state, openRouter: ready.openRouter, github: ready.github, cost: ready.model }, + model: { + state: flags.state, + openRouter: ready.openRouter, + github: ready.github, + vercel: ready.vercel, + cost: ready.model, + }, commands: lift(ready.commands), } } @@ -468,11 +550,22 @@ 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, github: model.github }, + { + state: model.state, + openRouter: model.openRouter, + github: model.github, + vercel: model.vercel, + }, { model: moved.model, commands: [...(step.commands ?? []), ...(moved.commands ?? [])] }, ) return { - model: { ...model, openRouter: ready.openRouter, github: ready.github, cost: ready.model }, + model: { + ...model, + openRouter: ready.openRouter, + github: ready.github, + 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 717c6087df..a030740ced 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 { VcsInstallationId } from "@maple/domain/vcs" import { reloadSpin } from "@maple/views/data/timing" import { settleTo } from "@maple/views/data/load-failure" @@ -174,3 +179,31 @@ export const RemoveGithubCostSource = Command.define("RemoveGithubCostSource", { client.githubCost.remove({ params: { installationId } }), ).pipe(settleTo((result) => Message.CompletedRemoveGithubSource({ 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 735525dc73..5597a77a3d 100644 --- a/apps/web/src/page/cost/cost.scene.test.ts +++ b/apps/web/src/page/cost/cost.scene.test.ts @@ -10,6 +10,9 @@ import { GithubCostSourceRemoved, OpenRouterCostKeyRemoved, OpenRouterCostKeySaved, + VercelCostSourceAdded, + VercelCostSourceRemoved, + VercelCostStatus, } from "@maple/domain/http" import * as Policy from "@maple/domain/policy" import { VcsInstallationId } from "@maple/domain/vcs" @@ -35,6 +38,7 @@ import { PageOutMessage, type PageContext } from "../contract" import * as TimeRangePicker from "../kit/time-range-picker" import { chartRows, + perDimension, isUnderADay, keyLabel, previousPeriodOf, @@ -164,6 +168,7 @@ const answered = (model: Model, totalsAnswer = totals()): Model => const OPENROUTER_SOURCE = Schema.decodeSync(CostSourceId)("0190f2b3-0000-7000-8000-00000000c060") const GITHUB_SOURCE = Schema.decodeSync(CostSourceId)("0190f2b3-0000-7000-8000-00000000c061") const GITHUB_ORG = Schema.decodeSync(VcsInstallationId)("0190f2b3-0000-4000-8000-00000000e061") +const VERCEL_SOURCE = Schema.decodeSync(CostSourceId)("0190f2b3-0000-7000-8000-00000000c063") const GITHUB_LABS = Schema.decodeSync(VcsInstallationId)("0190f2b3-0000-4000-8000-00000000e062") type InstallationWire = (typeof GithubCostInstallations.Encoded)["installations"][number] @@ -224,7 +229,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 }, @@ -234,7 +239,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 and GitHub requests are free/)).toExist(), + expectView(text(/OpenRouter, GitHub 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" }), @@ -357,12 +363,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. @@ -422,7 +428,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. @@ -439,7 +445,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(), ) @@ -495,7 +501,7 @@ describe("cost page", () => { resolveTheme, dialogMounted, expectView(role("button", { name: /^GitHub/ })).toExist(), - expectView(text(/OpenRouter and GitHub requests are free/)).toExist(), + expectView(text(/OpenRouter, GitHub and Vercel requests are free/)).toExist(), ) const reading = pageUpdate(opened, Message.ClickedAddGithub()) expect(names(reading.commands)).toEqual(["FetchGithubCostInstallations"]) @@ -531,11 +537,21 @@ describe("cost page", () => { given(githubListed(reading.model, [])), resolveTheme, dialogMounted, - expectView(text("No GitHub organization is connected to Maple yet.")).toExist(), + expectView(text(/No GitHub organization is connected to Maple yet/)).toExist(), + // The way on is the step's primary action, in the footer beside Back. expectView(role("link", { name: "Connect GitHub" })).toHaveAttr( "href", "/sources/new?integration=github", ), + expectView(role("button", { name: "Back" })).toExist(), + ) + // While the read is in flight, and once it lists organizations, the footer offers only Back. + scene( + { update: pageUpdate, view }, + given(reading.model), + resolveTheme, + dialogMounted, + expectView(role("link", { name: "Connect GitHub" })).not.toExist(), ) // An answer to a read the step has since replaced is dropped. expect( @@ -677,8 +693,8 @@ describe("cost page", () => { expectView(text("example-org")).toExist(), expectView(text(/0 provider requests/)).not.toExist(), // It reads through the GitHub connection: there is no key to replace. - expectView(role("button", { name: "Replace key" })).not.toExist(), - expectView(role("button", { name: "Remove" })).toExist(), + expectView(role("button", { name: /^Replace/ })).not.toExist(), + expectView(role("button", { name: "Remove GitHub source" })).toExist(), ) const confirming = pageUpdate(model, Message.ClickedRemoveSource({ sourceId: GITHUB_SOURCE })).model @@ -733,6 +749,663 @@ describe("cost page", () => { expect(failed.outMessage).toMatchObject({ type: "error", title: "Couldn't remove the source" }) }) + 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: VERCEL_SOURCE, + 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({ sourceId: VERCEL_SOURCE })).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: OPENROUTER_SOURCE, + provider: "openrouter", + accountId: "user_2openrouterAccount", + }) + const vercel = source({ + id: VERCEL_SOURCE, + provider: "vercel", + accountId: "team_1", + accountName: "acme", + }) + // GitHub's removal is the same handler: one more row here would add nothing. + const removals: ReadonlyArray< + Readonly<{ only: SourceWire; sourceId: CostSourceId; removed: Message }> + > = [ + { + only: vercel, + sourceId: VERCEL_SOURCE, + removed: Message.CompletedRemoveVercel({ + result: Result.succeed(new VercelCostSourceRemoved({ removed: true })), + }), + }, + { + only: openRouter, + sourceId: OPENROUTER_SOURCE, + removed: Message.CompletedRemoveKey({ + result: Result.succeed(new OpenRouterCostKeyRemoved({ removed: true })), + }), + }, + ] + Arr.forEach(removals, ({ only, sourceId, 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({ sourceId })).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: VERCEL_SOURCE, + 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: OPENROUTER_SOURCE, + provider: "openrouter", + accountId: "user_2openrouterAccount", + }) + const vercel = source({ + id: VERCEL_SOURCE, + provider: "vercel", + accountId: "team_1", + accountName: "acme", + }) + const model = answered(entered({}, [openRouter, vercel]).model) + const removing = pageUpdate( + pageUpdate(model, Message.ClickedRemoveSource({ sourceId: OPENROUTER_SOURCE })).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("with several sources of one provider, a row's actions name the account they act on", () => { + const github = (id: string, connectionRef: string, accountId: string) => + source({ id, provider: "github", connectionRef, accountId, requestsThisMonth: 0 }) + const org = github(GITHUB_SOURCE, GITHUB_ORG, "example-org") + const labs = github( + "0190f2b3-0000-7000-8000-00000000c064", + "0190f2b3-0000-4000-8000-00000000e062", + "example-labs", + ) + const secondAws = source({ id: "0190f2b3-0000-7000-8000-00000000c058", accountId: "210987654321" }) + const openRouter = source({ + id: OPENROUTER_SOURCE, + provider: "openrouter", + accountId: "user_2openrouterAccount", + }) + scene( + { update: pageUpdate, view }, + given(answered(entered({}, [source(), secondAws, openRouter, org, labs]).model)), + resolveTheme, + // Two organizations: each Remove says which, and neither is the bare name. + expectView(role("button", { name: "Remove GitHub source example-org" })).toExist(), + expectView(role("button", { name: "Remove GitHub source example-labs" })).toExist(), + expectView(role("button", { name: "Remove GitHub source" })).not.toExist(), + // Two AWS accounts: each link says whose connection it opens. + expectView(role("link", { name: "Manage AWS connection 123456789012" })).toExist(), + expectView(role("link", { name: "Manage AWS connection 210987654321" })).toExist(), + expectView(role("link", { name: "Manage AWS connection" })).not.toExist(), + // The org's only OpenRouter source keeps the short names. + expectView(role("button", { name: "Replace OpenRouter key" })).toExist(), + expectView(role("button", { name: "Remove OpenRouter source" })).toExist(), + ) + // One organization left: its Remove needs no account to tell it apart. + scene( + { update: pageUpdate, view }, + given(answered(entered({}, [source(), org]).model)), + resolveTheme, + expectView(role("button", { name: "Remove GitHub source" })).toExist(), + expectView(role("link", { name: "Manage AWS connection" })).toExist(), + ) + // A Vercel team is named by its slug, as its row shows it, not by its team id. + const team = (id: string, accountId: string, accountName: string) => + source({ id, provider: "vercel", accountId, accountName }) + scene( + { update: pageUpdate, view }, + given( + answered( + entered({}, [ + team(VERCEL_SOURCE, "team_1", "acme"), + team("0190f2b3-0000-7000-8000-00000000c065", "team_2", "beta"), + ]).model, + ), + ), + resolveTheme, + expectView(role("button", { name: "Remove Vercel source acme" })).toExist(), + expectView(role("button", { name: "Remove Vercel source beta" })).toExist(), + expectView(role("link", { name: "Manage Vercel connection 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)({ @@ -767,9 +1440,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 }, @@ -799,13 +1472,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 or a GitHub repository is the row's resource; a charge of neither says so. + expect(keyLabel("resource", "web")).toBe("web") + expect(keyLabel("resource", "")).toBe("No resource") + expect(requestFilters({ resource: ["web"] })).toEqual({ resource: ["web"] }) expect(keyLabel("provider", "github")).toBe("GitHub") // A provider this build does not know still shows, as the rows name it. expect(keyLabel("provider", "somecloud")).toBe("somecloud") @@ -903,6 +1581,30 @@ describe("cost page", () => { text(/GitHub brings in this month and the last, then the two months before/), ).toExist(), ) + // A Vercel team reads back like an AWS account, and each provider on the page is named. + const vercel = source({ + ...unsynced, + id: VERCEL_SOURCE, + 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, openRouter, github, vercel]).model), + resolveTheme, + expectView(text(/AWS brings in this month and the last/)).toExist(), + expectView(text(/OpenRouter brings in its last four weeks/)).toExist(), + expectView(text(/GitHub brings in this month and the last/)).toExist(), + expectView(text(/Vercel brings 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..d85e46f5ad 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,48 @@ 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: resources, until + * the rail's read says some cost names one. + */ +export const drillTarget = ( + search: Search, + dimension: CostGroupBy, + unsplit: ReadonlyArray = unsplitDimensions(undefined), +): 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 = unsplitDimensions(undefined), +): 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 e0271e194c..9f18738f0e 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 { drilledInto, 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 = ( @@ -76,7 +85,9 @@ describe("cost key marks", () => { // A product GitHub starts billing later: GitHub's when no AWS account could bill it. const githubOnly = [source("github", "example-org"), source("openrouter", "user_one")] expect(keyMark("cloud_service", "Models", githubOnly)?.icon).toBe(github) - expect(keyMark("cloud_service", "Models", both)?.icon).toBe(aws) + // Beside an AWS account its name says neither: a neutral glyph, never a logo that is a guess. + const unknown = keyMark("cloud_service", "Models", both)?.icon + expect([unknown === aws, unknown === github]).toEqual([false, false]) expect(keyMark("service_category", "Developer Tools", [])?.icon).toBe("CodeIcon") // GitHub bills no region: its rows are `global`, which is the globe and not a logo. expect(keyMark("region", "global", both)?.icon).toBe("GlobeIcon") @@ -98,6 +109,96 @@ describe("cost key marks", () => { }) }) + 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("an org with AWS, GitHub and Vercel: each key wears its own platform's mark, or none", () => { + const aws = keyMark("provider", "aws", [])?.icon + const github = keyMark("provider", "github", [])?.icon + const vercel = keyMark("provider", "vercel", [])?.icon + const all = [ + source("aws", "465760687006"), + source("github", "example-org", "0195f1c2-7a3b-7c4d-8e5f-6a7b8c9d0e21"), + { ...source("vercel", "team_1", "0195f1c2-7a3b-7c4d-8e5f-6a7b8c9d0e22"), accountName: "acme" }, + ] + // Services: a name that says whose it is, else no logo. + expect(keyMark("cloud_service", "Amazon Simple Storage Service", all)?.icon).toBe(aws) + expect(keyMark("cloud_service", "Actions", all)?.icon).toBe(github) + const unnamed = keyMark("cloud_service", "Function Duration", all)?.icon + expect([unnamed === aws, unnamed === github, unnamed === vercel]).toEqual([false, false, false]) + // Regions: AWS's codes, Vercel's ids, and the globe for what bills none. + expect(keyMark("region", "eu-west-1", all)?.icon).toBe(aws) + expect(keyMark("region", "iad1", all)?.icon).toBe(vercel) + expect(keyMark("region", "global", all)?.icon).toBe("GlobeIcon") + const elsewhere = keyMark("region", "north-1", all)?.icon + expect([elsewhere === aws, elsewhere === vercel]).toEqual([false, false]) + // Accounts read as what they are. + expect([ + keyLabel("account", "465760687006", all), + keyLabel("account", "example-org", all), + keyLabel("account", "team_1", all), + ]).toEqual(["AWS 465760687006", "GitHub example-org", "Vercel acme"]) + expect(keyMark("account", "example-org", all)?.icon).toBe(github) + expect(keyMark("account", "team_1", all)?.icon).toBe(vercel) + }) + + test("a resource is a GitHub repository or a Vercel project: it reads by its name under one glyph", () => { + const sources = [source("github", "example-org"), source("vercel", "team_1")] + // The key does not say who bills it, so a repository and a project wear the same mark. + const repository = keyMark("resource", "example-api", sources) + expect(repository?.icon).toBe("FolderIcon") + expect(keyMark("resource", "web", sources)).toEqual(repository) + expect(keyLabel("resource", "example-api", sources)).toBe("example-api") + expect(keyDetail("resource", "example-api", sources)).toBeNull() + // A seat product names no repository, a plan fee no project. + expect(keyLabel("resource", "", sources)).toBe("No resource") + expect(keyMark("resource", "", sources)).toBeNull() + }) + + 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() @@ -111,6 +212,48 @@ 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") + // A GitHub product goes on to its repositories the same way. + expect( + drilledInto( + { provider: ["github"], region: ["global"] }, + { dimension: "cloud_service", value: "Actions" }, + 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 c1bceba1d9..f242b06dae 100644 --- a/apps/web/src/page/cost/marks.ts +++ b/apps/web/src/page/cost/marks.ts @@ -23,6 +23,34 @@ 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. One rule + * for every provider: a name first, then this, and never a logo that is only a guess. + */ +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) @@ -50,18 +78,9 @@ const GITHUB_PRODUCTS: ReadonlySet = new Set([ "Git LFS", ]) -/** - * The provider that bills a service which is not a model: GitHub for its known products, else the - * org's only provider with such services (a product GitHub bills that is not named above), else AWS. - */ -const serviceProvider = (key: string, sources: ReadonlyArray): CostProvider => { - if (GITHUB_PRODUCTS.has(key)) return "github" - const billing = Arr.dedupe( - Arr.flatMap(sources, (source) => (source.provider === "openrouter" ? [] : [source.provider])), - ) - const [only, ...others] = billing - return only === undefined || others.length > 0 ? "aws" : only -} +/** The platform a service's own name says bills it, when it says: a GitHub product, an AWS service. */ +const namedServiceOwner = (key: string): CostProvider | undefined => + GITHUB_PRODUCTS.has(key) ? "github" : isAwsServiceName(key) ? "aws" : undefined const CATEGORY_ICONS = { "AI and Machine Learning": "SparkleIcon", @@ -92,13 +111,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(serviceProvider(key, sources)) + if (key === "AWS Lambda") return { icon: "AwsLambdaIcon" } + return markOf(namedServiceOwner(key) ?? 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 GitHub repository, a Vercel project): its key does not say whose. + case "resource": + return glyph("FolderIcon") case "account": { const provider = accountProvider(key, sources) return provider === undefined ? glyph("IdBadgeIcon") : providerMark(provider) @@ -107,9 +133,10 @@ export const keyMark = ( } /** - * An account by what reads it: an AWS one by its id, a GitHub one by its organization, 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 GitHub one by its organization, 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) @@ -117,6 +144,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 974c7ae83b..4ba303050e 100644 --- a/apps/web/src/page/cost/message.ts +++ b/apps/web/src/page/cost/message.ts @@ -10,6 +10,9 @@ import { GithubCostSourceRemoved, OpenRouterCostKeyRemoved, OpenRouterCostKeySaved, + VercelCostSourceAdded, + VercelCostSourceRemoved, + VercelCostStatus, } from "@maple/domain/http" import * as Policy from "@maple/domain/policy" import { VcsInstallationId } from "@maple/domain/vcs" @@ -68,6 +71,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: {}, @@ -91,6 +99,7 @@ export const Message = defineMessageUnion({ ClickedConfirmRemove: {}, CompletedRemoveKey: { result: Schema.Result(OpenRouterCostKeyRemoved, LoadFailure) }, CompletedRemoveGithubSource: { result: Schema.Result(GithubCostSourceRemoved, 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 4b7cce74fc..9d2a1c3448 100644 --- a/apps/web/src/page/cost/model.ts +++ b/apps/web/src/page/cost/model.ts @@ -5,6 +5,8 @@ import { CostTimeseriesResponse, CostTotalsResponse, GithubCostInstallations, + VercelCostStatus, + type VercelCostTeam, } from "@maple/domain/http" import * as Policy from "@maple/domain/policy" import { VcsInstallationId } from "@maple/domain/vcs" @@ -24,10 +26,26 @@ import * as TimeRangePicker from "../kit/time-range-picker" export type Search = typeof CostSearch.Type -/** The add-source dialog's faces: the providers, then the chosen one's form or list. */ -export const AddStep = Schema.Literals(["pick", "openrouter", "github"]) +/** The add-source dialog's faces: the providers, then the chosen one's form, list or step. */ +export const AddStep = Schema.Literals(["pick", "openrouter", "github", "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), @@ -70,6 +88,10 @@ export const Model = Schema.Struct({ needsPermission: Schema.Boolean, }), ), + /** 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, /** The source the remove confirm is about, as it was when Remove was clicked. */ removeTarget: Schema.NullOr(CostSourceSummary), diff --git a/apps/web/src/page/cost/providers.ts b/apps/web/src/page/cost/providers.ts index d781b8b7d9..1935e799e6 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, @@ -33,4 +34,10 @@ export const COST_PROVIDERS = { iconClassName: PROVIDERS.github.iconClassName, 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 f381bdf3c6..b9923c807b 100644 --- a/apps/web/src/page/cost/update.ts +++ b/apps/web/src/page/cost/update.ts @@ -1,4 +1,9 @@ -import { type CostSourceSummary, CostSourcesResponse, OPENROUTER_KEY_MAX_LENGTH } from "@maple/domain/http" +import { + type CostSourceSummary, + CostSourcesResponse, + OPENROUTER_KEY_MAX_LENGTH, + type VercelCostTeam, +} from "@maple/domain/http" import * as Policy from "@maple/domain/policy" import { VcsInstallationId } from "@maple/domain/vcs" import type { CostSourceId } from "@maple/primitives" @@ -24,6 +29,7 @@ import type { PresetValue } from "../kit/time-controls" import * as TimeRangePicker from "../kit/time-range-picker" import { AddGithubCostSource, + AddVercelCostSource, type BreakdownInput, CompleteReloadSpin, FetchCostBreakdown, @@ -33,9 +39,11 @@ import { FetchCostTimeseries, FetchCostTotals, FetchGithubCostInstallations, + FetchVercelCostStatus, ReadCostGrants, RemoveGithubCostSource, RemoveOpenRouterCostKey, + RemoveVercelCostSource, ResolveWindow, SaveOpenRouterCostKey, type TimeseriesInput, @@ -57,7 +65,7 @@ import { withoutFilters, } from "./derive" import { Message } from "./message" -import type { AddStep, Model, Search } from "./model" +import { type AddStep, type Model, type Search, vercelStepOf } from "./model" import { COST_PROVIDERS } from "./providers" type Return = PageReturn @@ -65,6 +73,9 @@ 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 @@ -102,7 +113,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, @@ -179,6 +203,8 @@ export const init = (route: { readonly search: Search }, context: PageContext): addStep: "pick", keyDraft: Field.init(), keyError: null, + vercel: Keyed.idle(), + vercelError: null, githubInstallations: AsyncData.Idle(), githubRead: 0, githubAdding: null, @@ -226,8 +252,41 @@ const clearedDialog = (model: Model): Model => ({ keyDraft: Field.init(), keyError: null, githubRefusal: 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" ? { model } @@ -273,7 +332,7 @@ const decodeInstallationId = Schema.decodeUnknownOption(VcsInstallationId) /** The public code of `GithubCostPermissionMissingError`: the organization lifts it in its GitHub App settings. */ export const GITHUB_PERMISSION_MISSING_CODE = "github_cost_permission_missing" -/** The call that removes `source`: OpenRouter's key, or the GitHub source of its installation. */ +/** The call that removes `source`: OpenRouter's key, the GitHub source of its installation, the org's Vercel source. */ const removeCommand = (source: CostSourceSummary): ReadonlyArray => { switch (source.provider) { case "openrouter": @@ -282,6 +341,8 @@ const removeCommand = (source: CostSourceSummary): ReadonlyArray => { return Arr.map(Option.toArray(decodeInstallationId(source.connectionRef)), (installationId) => RemoveGithubCostSource({ installationId }), ) + case "vercel": + return [RemoveVercelCostSource()] // An AWS source leaves with its connection's Cost module, on the integration's page. case "aws": return [] @@ -291,9 +352,21 @@ const removeCommand = (source: CostSourceSummary): ReadonlyArray => { /** A removal answered: the confirm closes, and on success the source's row and cost leave the page. */ const removed = (model: Model, result: Result.Result, context: PageContext): Return => { const target = model.removeTarget + // 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( Result.isSuccess(result) - ? withoutSource({ ...model, sourceWrite: "Settling" }, target?.id) + ? withoutSource( + { + ...model, + sourceWrite: "Settling", + totals: emptied(model.totals), + timeseries: emptied(model.timeseries), + breakdown: emptied(model.breakdown), + facets: emptied(model.facets), + }, + target?.id, + ) : { ...model, sourceWrite: "Idle" }, Dialog.close(model.removeDialog), ) @@ -316,6 +389,19 @@ const removed = (model: Model, result: Result.Result, cont }) } +/** 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( + clearedDialog({ ...model, sourceWrite: "Settling", githubAdding: null }), + Dialog.close(model.addDialog), + ), + outMessage: toast("success", title, description), + }, + context, + ) + /** Whether the org has an OpenRouter source, which a saved key then replaces the key of. */ const hasOpenRouterSource = (model: Model): boolean => Option.exists(AsyncData.getData(model.sources.data), ({ sources }) => @@ -498,20 +584,20 @@ 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( - clearedDialog({ ...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, + ), }), ClickedAddGithub: () => model.sourceWrite !== "Idle" @@ -565,18 +651,46 @@ export const update = (model: Model, message: Message, context: PageContext): Re }, }, }), - onSuccess: (added): Return => { - const closed = liftAddDialog( - clearedDialog({ ...model, sourceWrite: "Settling", githubAdding: null }), - Dialog.close(model.addDialog), - ) - const outMessage = toast( - "success", + onSuccess: (source): Return => + added( + model, "GitHub added", - `The first sync of ${added.accountLogin} runs within a few minutes.`, - ) - return withReread({ ...closed, outMessage }, context) + `The first sync of ${source.accountLogin} runs within a few minutes.`, + context, + ), + }), + ClickedAddVercel: () => + model.sourceWrite !== "Idle" + ? { model } + : requestVercelStatus({ ...clearedDialog(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), }), ClickedRemoveSource: ({ sourceId }) => { const target = sourceOf(model, sourceId) @@ -596,5 +710,6 @@ export const update = (model: Model, message: Message, context: PageContext): Re }, CompletedRemoveKey: ({ result }) => removed(model, result, context), CompletedRemoveGithubSource: ({ result }) => removed(model, result, context), + CompletedRemoveVercel: ({ result }) => removed(model, 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 e7daa6a261..8f0c383338 100644 --- a/apps/web/src/page/cost/view-add-source.ts +++ b/apps/web/src/page/cost/view-add-source.ts @@ -1,5 +1,5 @@ import type { CostSourceSummary, GithubCostInstallation } from "@maple/domain/http" -import { cn } from "@maple/ui/lib/utils" +import { CostProvider } from "@maple/domain/cost" import { Array as Arr, String as Str } from "effect" import { AsyncData } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" @@ -21,6 +21,7 @@ import { AppRoute, hrefOf } from "../../route" import { Message } from "./message" import type { Model } from "./model" import { COST_PROVIDERS, type CostProviderEntry } from "./providers" +import { providerMark, vercelStep } from "./view-add-vercel" type H = HtmlBuilder @@ -34,51 +35,96 @@ const OPENROUTER_KEYS_URL = "https://openrouter.ai/settings/management-keys" /** Where a GitHub organization is connected to Maple. */ const CONNECT_GITHUB_HREF = hrefOf(AppRoute.SourcesNew({ search: { integration: "github" } })) -/** 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 }), + }) + +const nameList = new Intl.ListFormat("en-GB", { type: "conjunction" }) + +/** The providers whose requests cost their customer nothing, as one list: "A, B and C". */ +const FREE_REQUESTS = nameList.format( + Arr.map( + Arr.filter(CostProvider.literals, (provider) => COST_PROVIDERS[provider].requestUsd === null), + (provider) => COST_PROVIDERS[provider].name, + ), +) -/** The providers to add: AWS goes to its connect flow, OpenRouter to the key form, GitHub to its organizations. */ -const providers = (h: H, hasOpenRouter: boolean): Html => +/** + * The providers to add: AWS goes to its connect flow, OpenRouter to the key form, GitHub to its + * organizations, Vercel to its team. + */ +const providers = (h: H, sources: ReadonlyArray): 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, { - divided: true, - 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, { - divided: true, - 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: hasSourceOf(sources, "openrouter"), + meta: "Daily spend by model and serving provider", + addedMeta: "Replace its key in the Sources panel", + onClick: Message.ClickedAddOpenRouter(), + divided: true, + }), + // An org can add several organizations, so the row stays open with sources on the page. listRow(h, { + divided: true, onClick: Message.ClickedAddGithub(), - leading: mark(h, COST_PROVIDERS.github), + leading: providerMark(h, COST_PROVIDERS.github), title: COST_PROVIDERS.github.name, - meta: "Daily billed usage by product", + meta: "Daily billed usage by product and repository", trailing: icon(h, "ChevronRightIcon", { size: 14 }), }), + addableRow(h, COST_PROVIDERS.vercel, { + isAdded: hasSourceOf(sources, "vercel"), + 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 and GitHub requests are free. ", + `AWS bills USD\u00a00.01 for each request Maple makes, a few dollars a month per account. ${FREE_REQUESTS} requests are free. `, textLink(h, { href: AWS_INTEGRATION_HREF, tone: "inherit", className: "underline" }, [ "Turn on Cost for an AWS account that is connected", ]), @@ -143,7 +189,7 @@ const githubInstallationRow = (h: H, model: Model, installation: GithubCostInsta const refusal = model.githubRefusal?.installationId === installation.installationId ? model.githubRefusal : undefined const row = listRow(h, { - leading: mark(h, COST_PROVIDERS.github), + leading: providerMark(h, COST_PROVIDERS.github), title: installation.accountLogin, meta: GITHUB_STATE_NOTE[installation.state], wrapMeta: true, @@ -249,23 +295,31 @@ const githubInstallations = (h: H, model: Model): Html => " first.", ]), ]) - : vstack(h, { gap: "3", align: "start" }, [ - typography(h, { size: "sm", tone: "muted", as: "p", className: "font-prose" }, [ - "No GitHub organization is connected to Maple yet.", - ]), - linkButton(h, { variant: "outline", size: "sm", href: CONNECT_GITHUB_HREF }, [ - "Connect GitHub", - ]), + : // The way on is the step's primary action, in the footer beside Back. + typography(h, { size: "sm", tone: "muted", as: "p", className: "font-prose" }, [ + "No GitHub organization is connected to Maple yet. Connect one, then add it here as a cost source.", ]), }) +/** The GitHub step's primary action: connecting an organization, while the org has none to pick. */ +const githubPrimary = (h: H, model: Model): ReadonlyArray => + AsyncData.matchData(model.githubInstallations, { + onEmpty: () => [], + onFailure: () => [], + onData: ({ installations }) => + Arr.isReadonlyArrayNonEmpty(installations) + ? [] + : [linkButton(h, { href: CONNECT_GITHUB_HREF }, ["Connect GitHub"])], + }) + /** - * Add a source, or replace the OpenRouter key: the providers first, then OpenRouter's key form or - * GitHub's organizations. 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 OpenRouter's key form, + * GitHub's organizations or Vercel's team. Opened on the 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" @@ -279,115 +333,129 @@ export const addSourceDialog = (h: H, model: Model, sources: ReadonlyArray portal ?? h.empty, toContent: (close) => - model.addStep === "github" - ? [ - DialogView.dialogHeader(h, "dialog", [ - DialogView.dialogTitle(h, "dialog", id, "Add GitHub"), - DialogView.dialogDescription( - h, - "dialog", - id, - "Maple reads an organization's billed usage through the GitHub App it already has. No key is needed.", - ), - ]), - DialogView.dialogPanel(h, "dialog", id, [githubInstallations(h, model)]), - DialogView.dialogFooter(h, "dialog", [ - button( - h, - { - variant: "outline", - isDisabled: isSaving, - attributes: [h.OnClick(Message.ClickedBackToProviders())], - }, - ["Back"], - ), - ]), - ] - : onPick + model.addStep === "vercel" + ? vercelStep(h, model, id) + : model.addStep === "github" ? [ DialogView.dialogHeader(h, "dialog", [ - DialogView.dialogTitle(h, "dialog", id, "Add a cost source"), + DialogView.dialogTitle(h, "dialog", id, "Add GitHub"), DialogView.dialogDescription( h, "dialog", id, - "Maple reads what the source bills each day and keeps it next to your telemetry.", + "Maple reads an organization's billed usage through the GitHub App it already has. No key is needed.", ), ]), - DialogView.dialogPanel(h, "dialog", id, [providers(h, isReplacing)]), + DialogView.dialogPanel(h, "dialog", id, [githubInstallations(h, model)]), DialogView.dialogFooter(h, "dialog", [ - button(h, { variant: "outline", attributes: [...close] }, ["Close"]), + button( + h, + { + variant: "outline", + isDisabled: isSaving, + attributes: [h.OnClick(Message.ClickedBackToProviders())], + }, + ["Back"], + ), + ...githubPrimary(h, model), ]), ] - : [ - formDialogForm(h, canSubmit ? Message.SubmittedKey() : undefined, [ + : onPick + ? [ 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, sources)]), 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"], + ), + ]), + ]), + ], }, }) } /** What removing a source says: what leaves the page, and what adding it again brings back. */ -const removeCopy = (target: CostSourceSummary | null): Readonly<{ title: string; description: string }> => - target?.provider === "github" - ? { +const removeCopy = (target: CostSourceSummary | null): Readonly<{ title: string; description: string }> => { + switch (target?.provider) { + case "github": + return { title: "Remove the GitHub source?", description: `Maple stops reading the billed usage of ${target.accountId}, and its cost leaves this page. The GitHub connection stays and no key is deleted. Adding it again brings back this month and the three before it.`, } - : { + case "vercel": + return { + 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.", + } + // An AWS source has no Remove: it leaves with its connection's Cost module. + default: + return { 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.", } + } +} /** The confirm before a source is removed. */ export const removeSourceDialog = (h: H, model: Model): Html => 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 1dbb570d3b..e8626bb9b7 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,46 +31,74 @@ const STATUS = { Readonly<{ label: string; variant: "ok" | "info" | "muted" | "crit" }> > -const removeButton = (h: H, source: CostSourceSummary, isBusy: boolean): Html => +/** The account as its row names it: a Vercel team's slug, else the id the provider knows it by. */ +const accountOf = (source: CostSourceSummary): string => source.accountName ?? source.accountId + +/** + * What a row's action is called to assistive technology: the action on the provider's source, + * and the account too when the org has several sources of that provider, which would else all + * read the same ("Remove GitHub source acme-labs"). + */ +const actionName = (action: string, source: CostSourceSummary, isRepeated: boolean): string => + isRepeated ? `${action} ${accountOf(source)}` : action + +/** 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.OnClick(Message.ClickedRemoveSource({ sourceId: source.id }))], + attributes: [h.AriaLabel(name), h.OnClick(onClick)], }, - ["Remove"], + [label], ) +/** Where the source's connection is changed: an account's Cost module, a team's token. */ +const connectionLink = (h: H, name: string, href: string): Html => + linkButton(h, { variant: "ghost", size: "sm", href, attributes: [h.AriaLabel(name)] }, [ + "Manage connection", + ]) + /** * What a row offers: an AWS source is its connection's, an OpenRouter one is its key, and a - * GitHub one only leaves, since it reads through the GitHub connection. + * GitHub one only leaves, since it reads through the GitHub connection. A Vercel one leaves here + * too, and links to its connection, where the token it reads with is replaced. */ -const actions = (h: H, source: CostSourceSummary, isBusy: boolean): ReadonlyArray => { +const actions = ( + h: H, + source: CostSourceSummary, + options: Readonly<{ isBusy: boolean; isRepeated: boolean }>, +): ReadonlyArray => { + const { name } = COST_PROVIDERS[source.provider] + const named = (action: string) => actionName(action, source, options.isRepeated) + const remove = rowButton( + h, + "Remove", + named(`Remove ${name} source`), + Message.ClickedRemoveSource({ sourceId: source.id }), + options.isBusy, + ) + const manage = (href: string) => connectionLink(h, named(`Manage ${name} connection`), href) switch (source.provider) { case "openrouter": return [ - button( + rowButton( h, - { - variant: "ghost", - size: "sm", - isDisabled: isBusy, - attributes: [h.OnClick(Message.ClickedReplaceKey())], - }, - ["Replace key"], + "Replace key", + named(`Replace ${name} key`), + Message.ClickedReplaceKey(), + options.isBusy, ), - removeButton(h, source, isBusy), + remove, ] case "github": - return [removeButton(h, source, isBusy)] + return [remove] + case "vercel": + return [manage(VERCEL_INTEGRATION_HREF), remove] case "aws": - return [ - linkButton(h, { variant: "ghost", size: "sm", href: AWS_INTEGRATION_HREF }, [ - "Manage connection", - ]), - ] + return [manage(AWS_INTEGRATION_HREF)] } } @@ -127,7 +156,7 @@ const sourceRow = ( source: CostSourceSummary, nowMs: number, timeZone: string, - options: Readonly<{ isBusy: boolean; canManage: boolean }>, + options: Readonly<{ isBusy: boolean; canManage: boolean; isRepeated: boolean }>, ): Html => { const provider = COST_PROVIDERS[source.provider] const status = STATUS[source.status] @@ -148,18 +177,12 @@ 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, + accountOf(source), ]), badge(h, { variant: status.variant, size: "sm" }, [status.label]), ]), ...(options.canManage - ? [ - hstack( - h, - { gap: "1", className: "-my-1 shrink-0" }, - actions(h, source, options.isBusy), - ), - ] + ? [hstack(h, { gap: "1", className: "-my-1 shrink-0" }, actions(h, source, options))] : []), ]), hstack( @@ -209,6 +232,12 @@ export const sourcesSection = ( vstack( h, { as: "ul", gap: "0" }, - Arr.map(sources, (source) => sourceRow(h, source, nowMs, timeZone, options)), + Arr.map(sources, (source) => + sourceRow(h, source, nowMs, timeZone, { + ...options, + // Several sources of one provider: their actions name the account too. + isRepeated: Arr.filter(sources, (other) => other.provider === source.provider).length > 1, + }), + ), ), ]) 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 4c260d40c1..b9449a5eea 100644 --- a/apps/web/src/page/cost/view.ts +++ b/apps/web/src/page/cost/view.ts @@ -66,7 +66,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, a GitHub organization by product.", + "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 GitHub organization by product and repository, a Vercel team by product and project.", ]), // A member's save would be refused: say who can, and offer no form. ...(!options.cannotManage @@ -121,6 +121,10 @@ const FIRST_SYNC = { alone: "It brings in this month and the last, then the two months before.", named: "GitHub brings in this month and the last, then the two months before.", }, + vercel: { + alone: "It brings in this month and the last, and older months fill in after that.", + named: "Vercel brings in this month and the last, and older months fill in after that.", + }, } as const satisfies Record> /** What the first syncs bring in, by the providers that have no cost yet (waiting or failing alike). */ @@ -201,11 +205,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/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 c582a7b51c..98197b3336 100644 --- a/docs/cloud-cost-plan.md +++ b/docs/cloud-cost-plan.md @@ -1,7 +1,7 @@ # 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 and GitHub followed on 2026-11-12. +into `cost_daily` and shown on `/cost`. OpenRouter, GitHub and Vercel followed on 2026-11-12. 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 +11,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: @@ -47,7 +47,9 @@ The registry covers cost only. The integrations themselves are not refactored. A not an integration (OpenRouter) keeps its credential in a table of its own, and `cost_sources.connection_ref` points at that row. GitHub keeps no credential: its source points at the GitHub App installation (`vcs_installations.id`), and the adapter reaches GitHub through the -VCS provider port, like every other reader of that installation. +VCS provider port, like every other reader of that installation. A connection that exists for +more than cost (a GitHub installation, a Vercel team) 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 { @@ -92,7 +94,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 @@ -116,7 +119,9 @@ The windows above are the AWS ones. Each provider has a profile (`COST_SYNC_PROF 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. GitHub's report is asked for one calendar month at a time, back to the first day -of the month three months before the current one. +of the month three months before the current one. 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. @@ -145,22 +150,27 @@ next read. ### 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 or account with - filters, and each source's sync state. 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, and GitHub by picking one of the organizations the GitHub App is - installed on, which the page can remove again. Recommendations (list and detail with the - evidence chart) come with phase 2. + 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: a GitHub + repository, a Vercel project. 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, GitHub by picking one of the organizations the GitHub App is installed on, + and Vercel by adding the team the org has connected, both of 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`), `PUT` / `DELETE /internal/cost/openrouter` for the - OpenRouter key, and `GET /internal/cost/github` with `PUT` / `DELETE - /internal/cost/github/{installationId}` for GitHub sources. A public `/v2/cost/*` is not built. + OpenRouter key, `GET /internal/cost/github` with `PUT` / `DELETE + /internal/cost/github/{installationId}` for GitHub sources, 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) @@ -261,6 +271,63 @@ next read. organization's. A suspended installation that comes back has to be added again. - Not built: user accounts, enterprise-level billing, Copilot seat endpoints, budgets. +### 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 @@ -318,9 +385,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 and GitHub 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. +3. **More providers.** OpenRouter, GitHub 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 3edded7cd1..23f4ec1f39 100644 --- a/packages/backend/src/queries/cost-reads.test.ts +++ b/packages/backend/src/queries/cost-reads.test.ts @@ -666,6 +666,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 58e72ec869..1c15493fe7 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,7 +119,11 @@ 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 { @@ -127,6 +131,7 @@ const toSourceSummary = (row: CostSourceRow, nowMs: number): CostSourceSummary = provider: row.provider, connectionRef: row.connectionRef, accountId: row.accountId, + ...(accountName === undefined ? undefined : { accountName }), status: !row.enabled ? "paused" : row.lastError !== null @@ -313,7 +318,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 d5bc328569..a9c6b72ac5 100644 --- a/packages/backend/src/services/cost/CostSyncService.test.ts +++ b/packages/backend/src/services/cost/CostSyncService.test.ts @@ -31,6 +31,11 @@ import { AwsCostProvider } from "./providers/aws/AwsCostProvider" import { insertInstallation } from "./providers/github/fake-github" import { GithubCostProvider } from "./providers/github/GithubCostProvider" 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[] = [] @@ -49,6 +54,7 @@ const INSTALLATION_A = "0190f2b3-0000-4000-8000-0000000000e1" const INSTALLATION_B = "0190f2b3-0000-4000-8000-0000000000e2" const INSTALLATION_C = "0190f2b3-0000-4000-8000-0000000000e3" const INSTALLATION_D = "0190f2b3-0000-4000-8000-0000000000e4" +const VERCEL_CONNECTION_A = "0190f2b3-0000-7000-8000-0000000000e1" /** What every fake pull reports as billable. */ const REQUESTS_PER_PULL = 2 @@ -203,6 +209,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[] = [] @@ -247,6 +266,7 @@ const harness = (options: FakeProviderOptions = {}) => { Layer.succeed(AwsCostProvider, adapterOf("aws")), Layer.succeed(OpenRouterCostProvider, adapterOf("openrouter")), Layer.succeed(GithubCostProvider, adapterOf("github")), + Layer.succeed(VercelCostProvider, vercelAdapter), warehouse, featureFlags, ), @@ -311,6 +331,26 @@ const harness = (options: FakeProviderOptions = {}) => { [orgId, installation.id, installation.login], ) }) + /** 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", @@ -351,6 +391,7 @@ const harness = (options: FakeProviderOptions = {}) => { sql, connect, saveOpenRouterKey, + connectVercel, installGithub, sourceRefs, sourceState, @@ -543,6 +584,106 @@ describe("CostSyncService GitHub 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 3c68446f02..f7bca88939 100644 --- a/packages/backend/src/services/cost/CostSyncService.ts +++ b/packages/backend/src/services/cost/CostSyncService.ts @@ -31,9 +31,11 @@ import type { CostProviderAdapter, CostProviderError } from "./CostProvider" import { reconcileAwsCostSources } from "./aws-cost-sources" import { reconcileGithubCostSources } from "./github-cost-sources" import { reconcileOpenRouterCostSources } from "./openrouter-cost-sources" +import { reconcileVercelCostSources } from "./vercel-cost-sources" import { AwsCostProvider } from "./providers/aws/AwsCostProvider" import { GithubCostProvider } from "./providers/github/GithubCostProvider" import { OpenRouterCostProvider } from "./providers/openrouter/OpenRouterCostProvider" +import { VercelCostProvider } from "./providers/vercel/VercelCostProvider" import { advanceCostSync, batchWholeDays, @@ -168,6 +170,7 @@ export class CostSyncService extends Context.Service const dbExecute = makeDbExecute( database, @@ -216,11 +219,23 @@ 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), reconcileGithub()], { - discard: true, - }) + Effect.all( + [reconcileAws(now), reconcileOpenRouter(now), reconcileGithub(), reconcileVercel()], + { + discard: true, + }, + ) /** Enabled sources with a pull due and no backoff, healthy ones first. */ const loadDue = (now: number) => { @@ -683,6 +698,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 c064ae0ae3..4286146108 100644 --- a/packages/backend/src/services/cost/sync-plan.test.ts +++ b/packages/backend/src/services/cost/sync-plan.test.ts @@ -411,6 +411,56 @@ describe("planCostSync for a provider that serves a few months up to today", () }) }) +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 a76fc6cb17..2df2991c6a 100644 --- a/packages/backend/src/services/cost/sync-plan.ts +++ b/packages/backend/src/services/cost/sync-plan.ts @@ -36,6 +36,11 @@ const OPENROUTER_HISTORY_DAYS = 28 * documented. History stops at the first day of the month this many months back, well inside that. */ const GITHUB_HISTORY_MONTHS = 3 +/** + * 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 @@ -72,6 +77,7 @@ const COST_SYNC_PROFILES = { aws: { floor: (today) => monthStart(today, -HISTORY_MONTHS), lagDays: 0 }, openrouter: { floor: (today) => addDays(today, -OPENROUTER_HISTORY_DAYS), lagDays: 1 }, github: { floor: (today) => monthStart(today, -GITHUB_HISTORY_MONTHS), lagDays: 0 }, + 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/cost.ts b/packages/domain/src/cost.ts index f858d3c9ed..ed2590d110 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", "github"]) +export const CostProvider = Schema.Literals(["aws", "openrouter", "github", "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 f3a1e32235..78c0cbfa1d 100644 --- a/packages/domain/src/http/index.ts +++ b/packages/domain/src/http/index.ts @@ -10,6 +10,7 @@ export * from "./ai-triage" export * from "./openrouter-key" export * from "./openrouter-cost" export * from "./github-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 38bec215da..7a36efb4d4 100644 --- a/packages/domain/src/http/internal-api.ts +++ b/packages/domain/src/http/internal-api.ts @@ -23,6 +23,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" /** @@ -63,6 +64,7 @@ export class MapleInternalApi extends HttpApi.make("MapleInternalApi") .add(OpenRouterKeyApiGroup) .add(OpenRouterCostApiGroup) .add(GithubCostApiGroup) + .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 63ef5c3d3f..675908b062 100644 --- a/packages/domain/src/http/query-engine.ts +++ b/packages/domain/src/http/query-engine.ts @@ -3279,6 +3279,8 @@ export const CostSourceSummary = Schema.Struct({ /** The id of the provider's connection the source reads through, which its row actions name. */ connectionRef: Schema.String, 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) {}