From b36ad75ad0ed990ad901f65d79bdce95949e050f Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 12 Nov 2026 23:42:23 +0100 Subject: [PATCH 1/8] feat(cost): Vercel as a cost source, read with the team's existing connection An org's Vercel spend is read from the billing charges API: daily billed charges per product, SKU, project and region, plan fees and credits included, as FOCUS JSONL that is read line by line as it streams in. The Vercel integration already holds the team and its token, so nothing new is stored. The org adds its connected team on the Cost page, and that cost_sources row is the opt-in: GET /internal/cost/vercel says whether a team is connected and whether it is a source, PUT adds it after one read of yesterday (so a token whose role cannot read billing is refused up front, with the roles that can), DELETE removes it. The source also goes when the connection is deleted or moves to another team, whose spend would overwrite the source's days. The sync only removes sources whose connection is gone. Vercel serves a year in one request, today included: the sync profile starts history at the first day of the month eleven months back, and the adapter refuses a window over 366 days. Today and yesterday are stored as not final. Requests are free, so the source counts none. The response shape is Vercel's OpenAPI spec and has not been run against a real team. Everything but the three fields a row cannot do without is read as optional, and a line that is not a charge fails the pull as an invalid response. --- .../src/routes/internal/vercel-cost.http.ts | 59 ++++ apps/api/src/runtime/http-graph.ts | 2 + apps/api/src/runtime/service-graph.ts | 2 + docs/cloud-cost-plan.md | 73 ++++- .../src/services/cost/CostSyncService.test.ts | 97 ++++++ .../src/services/cost/CostSyncService.ts | 17 +- .../cost/VercelCostSourceService.test.ts | 288 ++++++++++++++++++ .../services/cost/VercelCostSourceService.ts | 200 ++++++++++++ .../vercel/VercelCostProvider.test.ts | 276 +++++++++++++++++ .../providers/vercel/VercelCostProvider.ts | 144 +++++++++ .../cost/providers/vercel/fake-vercel.ts | 164 ++++++++++ .../cost/providers/vercel/normalize.test.ts | 204 +++++++++++++ .../cost/providers/vercel/normalize.ts | 109 +++++++ .../src/services/cost/sync-plan.test.ts | 50 +++ .../backend/src/services/cost/sync-plan.ts | 6 + .../src/services/cost/vercel-cost-sources.ts | 58 ++++ .../VercelIntegrationService.test.ts | 43 +++ .../integrations/VercelIntegrationService.ts | 16 +- .../src/services/integrations/vercel/api.ts | 124 +++++++- .../src/services/integrations/vercel/token.ts | 17 ++ packages/domain/src/audit-actions.ts | 5 + packages/domain/src/cost.ts | 2 +- packages/domain/src/http/index.ts | 1 + packages/domain/src/http/internal-api.ts | 2 + packages/domain/src/http/vercel-cost.ts | 68 +++++ 25 files changed, 1994 insertions(+), 33 deletions(-) create mode 100644 apps/api/src/routes/internal/vercel-cost.http.ts create mode 100644 packages/backend/src/services/cost/VercelCostSourceService.test.ts create mode 100644 packages/backend/src/services/cost/VercelCostSourceService.ts create mode 100644 packages/backend/src/services/cost/providers/vercel/VercelCostProvider.test.ts create mode 100644 packages/backend/src/services/cost/providers/vercel/VercelCostProvider.ts create mode 100644 packages/backend/src/services/cost/providers/vercel/fake-vercel.ts create mode 100644 packages/backend/src/services/cost/providers/vercel/normalize.test.ts create mode 100644 packages/backend/src/services/cost/providers/vercel/normalize.ts create mode 100644 packages/backend/src/services/cost/vercel-cost-sources.ts create mode 100644 packages/backend/src/services/integrations/vercel/token.ts create mode 100644 packages/domain/src/http/vercel-cost.ts 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..2d81d53515 --- /dev/null +++ b/apps/api/src/routes/internal/vercel-cost.http.ts @@ -0,0 +1,59 @@ +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", () => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + yield* requireIntegrationsManager(tenant.roles) + const added = yield* sources.add(tenant.orgId) + yield* recordHttpAudit("cost_source.added", { + metadata: { provider: "vercel", account: added.team.slug }, + }) + return new VercelCostSourceAdded(added) + }).pipe(Effect.withSpan("HttpVercelCost.add")), + ) + .handle("remove", () => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + yield* requireIntegrationsManager(tenant.roles) + const removed = yield* sources.remove(tenant.orgId) + // Removing what is not there changed nothing, so it leaves no entry. + if (removed.removed) { + yield* recordHttpAudit("cost_source.removed", { metadata: { provider: "vercel" } }) + } + return new VercelCostSourceRemoved(removed) + }).pipe(Effect.withSpan("HttpVercelCost.remove")), + ) + }), +) diff --git a/apps/api/src/runtime/http-graph.ts b/apps/api/src/runtime/http-graph.ts index 7f4669a5f0..bd4b53e9b8 100644 --- a/apps/api/src/runtime/http-graph.ts +++ b/apps/api/src/runtime/http-graph.ts @@ -14,6 +14,7 @@ import { HttpAiTriageLive } from "@/routes/internal/ai-triage.http" import { HttpOpenRouterCostLive } from "@/routes/internal/openrouter-cost.http" import { HttpOpenRouterKeyLive } from "@/routes/internal/openrouter-key.http" import { HttpServiceMapWorldLive } from "@/routes/internal/service-map-world.http" +import { HttpVercelCostLive } from "@/routes/internal/vercel-cost.http" import { HttpAuthLive, HttpAuthPublicLive, HttpAuthSessionLive } from "@/routes/auth.http" import { HttpBillingLive } from "@/routes/internal/billing.http" import { HttpBillingPublicLive } from "@/routes/billing-public.http" @@ -168,6 +169,7 @@ const ApiInternalRoutes = HttpApiBuilder.layer(MapleInternalApi).pipe( HttpOpenRouterCostLive, HttpOpenRouterKeyLive, HttpServiceMapWorldLive, + HttpVercelCostLive, ), ), Layer.provide( diff --git a/apps/api/src/runtime/service-graph.ts b/apps/api/src/runtime/service-graph.ts index 79a5af8d58..d1037acbdb 100644 --- a/apps/api/src/runtime/service-graph.ts +++ b/apps/api/src/runtime/service-graph.ts @@ -67,6 +67,7 @@ import { SupabaseInsightsService } from "@maple/backend/services/integrations/Su import { SupabaseLogsService } from "@maple/backend/services/integrations/SupabaseLogsService" import { SupabaseOAuthService } from "@maple/backend/services/auth/SupabaseOAuthService" import { OpenRouterCostConnectionService } from "@maple/backend/services/cost/OpenRouterCostConnectionService" +import { VercelCostSourceService } from "@maple/backend/services/cost/VercelCostSourceService" import { OrgOpenRouterKeyService } from "@maple/backend/services/org/OrgOpenRouterKeyService" import { ServiceMapWorldService } from "@maple/backend/services/service-map/ServiceMapWorldService" import { ScrapeTargetsService } from "@maple/backend/services/integrations/ScrapeTargetsService" @@ -188,6 +189,7 @@ export const HttpServicesLive = Layer.mergeAll( AwsIngestService.layer, OrgOpenRouterKeyService.layer, OpenRouterCostConnectionService.layer, + VercelCostSourceService.layer, ServiceMapWorldService.layer, AuditLogService.layer, WarehouseQueryService.layer, diff --git a/docs/cloud-cost-plan.md b/docs/cloud-cost-plan.md index 79b3525cc8..5558829105 100644 --- a/docs/cloud-cost-plan.md +++ b/docs/cloud-cost-plan.md @@ -1,7 +1,8 @@ # Cloud cost: plan Status: **phase 1 built, 2026-11-11, behind the `cloudCost` org flag.** AWS spend is synced daily -into `cost_daily` and shown on `/cost`. OpenRouter followed on 2026-11-12 as the second source. +into `cost_daily` and shown on `/cost`. OpenRouter followed on 2026-11-12 as the second source, +Vercel the same day as the third. Phase 0 checked the provider APIs against docs, public schemas and one real AWS account. Recommendations (phase 2) and the other providers are not built. @@ -11,8 +12,8 @@ An org sees what its cloud and platform accounts cost, per day, service and reso telemetry Maple already has for the same resources. Maple then lists what to cut, with the usage chart that backs each suggestion and an estimated monthly saving. -It reads from the connections an org already has (AWS, GCP, Cloudflare, Railway, PlanetScale, -Supabase). No second onboarding. +It reads from the connections an org already has (AWS, Vercel, GCP, Cloudflare, Railway, +PlanetScale, Supabase). No second onboarding. Non-goals for v1: @@ -45,7 +46,9 @@ warehouse are not. One adapter per provider, in `packages/backend/src/services/cost/providers/`, behind one port. The registry covers cost only. The integrations themselves are not refactored. A provider that is not an integration (OpenRouter) keeps its credential in a table of its own, and -`cost_sources.connection_ref` points at that row. +`cost_sources.connection_ref` points at that row. A connection that exists for more than cost +(Vercel) supplies cost only once the org adds it on the Cost page: its `cost_sources` row is the +opt-in, and no column on the connection says so. ```ts interface CostProvider { @@ -90,7 +93,8 @@ It is a small table (hundreds of rows per source per day), so it needs no rollup - `cost_sources`: one row per connection that supplies cost. Its lease, frontiers (newest and oldest day pulled), last error and its reason, and the provider requests made this month. A connection that stops supplying cost pauses its source and keeps its history. Only a deleted - connection deletes it. + connection deletes it. A Vercel source is also deleted when the org removes it on the Cost page + and when its connection moves to another team, whose spend would overwrite the source's days. - `cost_recommendations` (phase 2): followed across runs by `(source, fingerprint)`. Kind, resource, title, estimated monthly saving and currency, effort, risk, evidence (jsonb), origin (`provider` or `maple`), status (`open`, `dismissed`, `accepted`, `resolved`). A resolved one @@ -113,7 +117,8 @@ The windows above are the AWS ones. Each provider has a profile (`COST_SYNC_PROF `sync-plan.ts`): the oldest day it serves and how far its newest day trails today. No window reaches outside them, because a day the provider no longer serves comes back empty, and an empty day replaces the stored one. OpenRouter serves 30 completed days: its sync asks for 28 at most and -never for today. +never for today. Vercel serves a year in one request, today included: its history starts at the +first day of the month eleven months back, so no window is longer than a year. Each step writes its days whole under one `IngestedAt`. A day a pull returned nothing for gets a marker row (`ChargeCategory = 'none'`), so it supersedes the older pull. Reads leave it out. @@ -141,17 +146,19 @@ currency the figures are in and lists the others. Nothing is added across curren daily chart, a breakdown by service, provider, category, region, usage type or account with filters, and each source's sync state. Sources are added from the page: AWS through its connection (which has a Cost switch), OpenRouter by pasting a management key, which the page can - also replace or remove. Recommendations (list and detail with the evidence chart) come with - phase 2. + also replace or remove, Vercel by adding the team the org has connected, which the page can + remove again. Recommendations (list and detail with the evidence chart) come with phase 2. - API: four internal endpoints in the query-engine group (`cost-totals`, `cost-timeseries`, - `cost-breakdown`, `cost-sources`), and `PUT` / `DELETE /internal/cost/openrouter` for the - OpenRouter key. A public `/v2/cost/*` is not built. + `cost-breakdown`, `cost-sources`), `PUT` / `DELETE /internal/cost/openrouter` for the + OpenRouter key, and `GET` / `PUT` / `DELETE /internal/cost/vercel` for the Vercel source. A + public `/v2/cost/*` is not built. - MCP: `cost_overview`, `cost_breakdown`, `compare_cost_periods`, `list_cost_recommendations`, `inspect_cost_recommendation`. ## Providers: what phase 0 found -"Live" means checked against a real account on 2026-11-11. "Docs" and "schema" mean read, not run. +"Live" means checked against a real account on 2026-11-11. "Docs", "spec" and "schema" mean read, +not run. ### AWS (billed) @@ -215,6 +222,44 @@ currency the figures are in and lists the others. Nothing is added across curren - A day OpenRouter answers nothing for is stored as empty, like any provider's. An empty answer for an account that had spend would hide up to 28 days until the next weekly re-pull. +### Vercel (billed) + +Nothing here was run against a real team: there was none to test with. The request and the +response shape are Vercel's OpenAPI spec. The adapter, the normalizer and the page are tested +against a fake server that answers what the spec describes. + +- Spec: `GET /v1/billing/charges?teamId=&from=&to=` returns the team's billed charges as FOCUS + v1.3 JSONL, one charge per line, at one-day granularity. `from` is inclusive and `to` exclusive, + both ISO 8601 in UTC, a year apart at most. The answer is streamed, so Maple reads it line by + line and sums as it goes. +- It takes the access token of the Vercel connection the org already has. No second credential + and no new table: the source points at `vercel_connections.id`, and its account is the team's + slug. +- 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 once first, 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. +- 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. `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. +- Open: when a day stops changing. Today and yesterday are stored as not final, and the four + newest days are pulled again daily. +- Open: how large a busy team's answer is. A day above 10,000 rows is refused like any + provider's. +- Not split: the team's telemetry service per project. `ServiceName` (Maple's) stays empty. + ### GCP (billed, needs a customer export) - Docs: no API returns cost. The customer must enable the detailed usage cost export to BigQuery @@ -272,9 +317,9 @@ Each phase is one PR stack. Plan imports. First Maple rules, each backed by a metric already ingested: load balancers with no requests, databases with idle connections and low CPU, container services reserving far more than they use. -3. **More providers.** OpenRouter is built. Railway, GCP, PlanetScale, Supabase, Cloudflare, one - PR each. Each provider adds a literal to `CostProvider`, an adapter, a reconcile function and a - sync profile. +3. **More providers.** OpenRouter and Vercel are built. Railway, GCP, PlanetScale, Supabase, + Cloudflare, one PR each. Each provider adds a literal to `CostProvider`, an adapter, a + reconcile function and a sync profile. 4. **Closing the loop.** Budgets and cost anomalies as managed alert signals, a cost block in the weekly digest, cost per request on service pages, realized savings once an accepted recommendation stops firing. diff --git a/packages/backend/src/services/cost/CostSyncService.test.ts b/packages/backend/src/services/cost/CostSyncService.test.ts index ddfe364642..aa30279590 100644 --- a/packages/backend/src/services/cost/CostSyncService.test.ts +++ b/packages/backend/src/services/cost/CostSyncService.test.ts @@ -29,6 +29,7 @@ import { type CostProviderAdapter, CostProviderError, type CostPullRow } from ". import { CostSyncService } from "./CostSyncService" import { AwsCostProvider } from "./providers/aws/AwsCostProvider" import { OpenRouterCostProvider } from "./providers/openrouter/OpenRouterCostProvider" +import { VercelCostProvider } from "./providers/vercel/VercelCostProvider" import { BACKFILL_REQUEST_BUDGET, daysOfWindow, MONTHLY_REQUEST_BUDGET } from "./sync-plan" const trackedDbs: TestDb[] = [] @@ -43,6 +44,7 @@ const CONNECTION_A = "0190f2b3-0000-7000-8000-0000000000a1" const CONNECTION_B = "0190f2b3-0000-7000-8000-0000000000b1" const CONNECTION_C = "0190f2b3-0000-7000-8000-0000000000c1" const OPENROUTER_KEY_A = "0190f2b3-0000-7000-8000-0000000000d1" +const VERCEL_CONNECTION_A = "0190f2b3-0000-7000-8000-0000000000e1" /** What every fake pull reports as billable. */ const REQUESTS_PER_PULL = 2 @@ -240,6 +242,7 @@ const harness = (options: FakeProviderOptions = {}) => { Layer.mergeAll( Layer.succeed(AwsCostProvider, adapterOf("aws")), Layer.succeed(OpenRouterCostProvider, adapterOf("openrouter")), + Layer.succeed(VercelCostProvider, adapterOf("vercel")), warehouse, featureFlags, ), @@ -282,6 +285,26 @@ const harness = (options: FakeProviderOptions = {}) => { VALUES ($1, $2, 'sealed', 'iv', 'tag', 'sk-or-v1-abc...123', 'user_account_a', now(), now(), 'user_a')`, [key.id, key.orgId ?? "org_a"], ) + /** An org's Vercel connection, and with `asCostSource` the source the org added for it. */ + const connectVercel = (connection: { readonly id: string; readonly asCostSource: boolean }) => + Effect.promise(async () => { + const sourceId = await insertTestSource(testDb, { orgId: "org_a", platform: "vercel" }) + await executeSql( + testDb, + `INSERT INTO vercel_connections + (id, org_id, source_id, team_id, team_slug, team_name, token_ciphertext, token_iv, token_tag, + connected_by_user_id, project_scope, created_at, updated_at) + VALUES ($1, 'org_a', $2, 'team_1', 'acme', 'Acme', 'sealed', 'iv', 'tag', 'user_a', 'all', now(), now())`, + [connection.id, sourceId], + ) + if (!connection.asCostSource) return + await executeSql( + testDb, + `INSERT INTO cost_sources (id, org_id, provider, connection_ref, account_id, created_at, updated_at) + VALUES ('0190f2b3-0000-7000-8000-0000000000f1', 'org_a', 'vercel', $1, 'acme', now(), now())`, + [connection.id], + ) + }) const sourceRefs = Effect.promise(() => testDb.pglite.query<{ org: string; ref: string; account: string }>( "SELECT org_id AS org, connection_ref AS ref, account_id AS account FROM cost_sources ORDER BY org_id, connection_ref", @@ -322,6 +345,7 @@ const harness = (options: FakeProviderOptions = {}) => { sql, connect, saveOpenRouterKey, + connectVercel, sourceRefs, sourceState, sourceStateOf, @@ -430,6 +454,79 @@ describe("CostSyncService OpenRouter sources", () => { }) }) +describe("CostSyncService Vercel sources", () => { + it.effect("makes no source for a Vercel connection the org did not add as one", () => { + const h = harness() + return Effect.gen(function* () { + yield* TestClock.setTime(NOW) + yield* h.connectVercel({ id: VERCEL_CONNECTION_A, asCostSource: false }) + const summary = yield* poll + assert.deepStrictEqual([yield* h.sourceRefs, summary.due, h.pulls.length], [[], 0, 0]) + }).pipe(Effect.provide(h.layer)) + }) + + it.effect("pulls an added source from the previous month through today, then a month of history", () => { + const h = harness() + return Effect.gen(function* () { + yield* TestClock.setTime(NOW) + yield* h.connectVercel({ id: VERCEL_CONNECTION_A, asCostSource: true }) + const summary = yield* poll + assert.deepStrictEqual([summary.due, summary.synced], [1, 1]) + // Today is served, and each window is one request far under Vercel's year. + assert.deepStrictEqual(h.windows(), [ + ["2026-10-01", "2026-11-11"], + ["2026-09-01", "2026-09-30"], + ["2026-08-01", "2026-08-31"], + ]) + const state = yield* h.sourceStateOf("org_a", VERCEL_CONNECTION_A) + assert.deepStrictEqual( + [state?.watermark, state?.backfill, state?.error], + ["2026-11-11", "2026-08-01", null], + ) + assert.isTrue(Arr.every(h.writtenRows(), (row) => row.Provider === "vercel")) + }).pipe(Effect.provide(h.layer)) + }) + + it.effect("walks history down to eleven months before this one, and no further", () => { + const h = harness() + return Effect.gen(function* () { + yield* TestClock.setTime(NOW) + yield* h.connectVercel({ id: VERCEL_CONNECTION_A, asCostSource: true }) + yield* h.sql( + `UPDATE cost_sources SET watermark_date = '2026-11-11', backfill_date = '2026-01-01', + last_synced_at = now(), last_settled_at = now()`, + ) + yield* poll + assert.deepStrictEqual(h.windows(), [["2025-12-01", "2025-12-31"]]) + // The floor is reached: a year back from today would start a day past what one request serves. + yield* TestClock.adjust("5 minutes") + assert.strictEqual((yield* poll).due, 0) + }).pipe(Effect.provide(h.layer)) + }) + + it.effect( + "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, "acme"], + ]) + + yield* h.sql("DELETE FROM vercel_connections") + yield* TestClock.adjust("1 hour") + yield* poll + assert.deepStrictEqual(yield* h.sourceRefs, [["org_a", CONNECTION_A, "123456789012"]]) + }).pipe(Effect.provide(h.layer)) + }, + ) +}) + describe("CostSyncService sources", () => { it.effect("makes a source only for a verified connection that asks for cost and is granted it", () => { const h = harness() diff --git a/packages/backend/src/services/cost/CostSyncService.ts b/packages/backend/src/services/cost/CostSyncService.ts index c978694bca..77722b765b 100644 --- a/packages/backend/src/services/cost/CostSyncService.ts +++ b/packages/backend/src/services/cost/CostSyncService.ts @@ -30,8 +30,10 @@ import { pollFleet } from "../integrations/poll-fleet" import type { CostProviderAdapter, CostProviderError } from "./CostProvider" import { reconcileAwsCostSources } from "./aws-cost-sources" import { reconcileOpenRouterCostSources } from "./openrouter-cost-sources" +import { reconcileVercelCostSources } from "./vercel-cost-sources" import { AwsCostProvider } from "./providers/aws/AwsCostProvider" import { OpenRouterCostProvider } from "./providers/openrouter/OpenRouterCostProvider" +import { VercelCostProvider } from "./providers/vercel/VercelCostProvider" import { advanceCostSync, batchWholeDays, @@ -165,6 +167,7 @@ export class CostSyncService extends Context.Service const dbExecute = makeDbExecute( database, @@ -204,9 +207,20 @@ export class CostSyncService extends Context.Service reconcileVercelCostSources(db)) + yield* Effect.annotateCurrentSpan({ + "maple.cost.provider": "vercel", + "maple.cost.sources_removed": reconciled.removed, + }) + }) + /** One function per provider; each owns the sources of its own connection table. */ const reconcile = (now: number) => - Effect.all([reconcileAws(now), reconcileOpenRouter(now)], { discard: true }) + Effect.all([reconcileAws(now), reconcileOpenRouter(now), reconcileVercel()], { + discard: true, + }) /** Enabled sources with a pull due and no backoff, healthy ones first. */ const loadDue = (now: number) => { @@ -668,6 +682,7 @@ export class CostSyncService extends Context.Service cleanupTestDbs(trackedDbs)) + +const ORG = Schema.decodeSync(OrgId)("org_cost") +const NOW = DateTime.toEpochMillis(DateTime.makeUnsafe("2026-11-12T09:00:00Z")) +/** The check read: yesterday alone. */ +const CHECK_URL = + "https://api.vercel.com/v1/billing/charges?teamId=team_1&from=2026-11-11T00%3A00%3A00Z&to=2026-11-12T00%3A00%3A00Z" + +const run = ( + testDb: TestDb, + world: FakeVercel, + effect: Effect.Effect, +) => + 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)), + ), + ), + ) + +const status = VercelCostSourceService.use((service) => service.status(ORG)) +const add = VercelCostSourceService.use((service) => service.add(ORG)) +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 = { 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 yesterday's 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: { name: "Acme", slug: "acme" } }) + assert.deepStrictEqual(world.urls, [CHECK_URL]) + assert.deepStrictEqual(world.authorizations, ["Bearer vc_token_abc"]) + // The source points at the connection and is named after the team's slug. + assert.deepStrictEqual(yield* source(testDb), { + connection_ref: connectionRef, + account_id: "acme", + 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: { name: "Acme", slug: "acme" } }) + 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 or Billing role.", + ], + [ + "@maple/http/errors/IntegrationsValidationError", + "Vercel rejected the saved token. Connect Vercel again with a new token.", + ], + [ + "@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, "Connect Vercel again") + 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( + "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)) + + // 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)) + }), + ) + }) + + 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..135851f60b --- /dev/null +++ b/packages/backend/src/services/cost/VercelCostSourceService.ts @@ -0,0 +1,200 @@ +/** + * The org's Vercel team as a cost source. The Vercel integration already holds the team and its + * token, so adding the source stores nothing new: the `cost_sources` row is the opt-in, and + * removing it is the opt-out. The connection itself is never changed here. + */ +import { + CloudCostDisabledError, + IntegrationsConnectionChangedError, + IntegrationsNotConnectedError, + IntegrationsPersistenceError, + IntegrationsUpstreamError, + IntegrationsValidationError, + type OrgId, + 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 type { CostProviderError } from "./CostProvider" +import { VercelCostProvider } 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", +) + +/** + * Why the check read was refused, as what the Cost page shows. A token or a role Vercel refuses is + * the org's to fix and keeps its reason; an outage answers the provider error's fixed sentence. + */ +const toAddError = (error: CostProviderError): IntegrationsValidationError | IntegrationsUpstreamError => + error.reason === "auth" || error.reason === "not_ready" + ? new IntegrationsValidationError({ message: error.message }) + : new IntegrationsUpstreamError({ message: error.message }) + +export interface VercelCostStatusInfo { + /** Null when the org has no Vercel connection. */ + readonly team: VercelCostTeam | null + readonly authFailed: boolean + readonly added: boolean +} + +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. */ + readonly add: ( + orgId: OrgId, + ) => Effect.Effect< + { readonly team: VercelCostTeam }, + | 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, + 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: { name: connection.teamName, slug: connection.teamSlug }, + authFailed: connection.authFailedAt !== null, + added: connection.sourceId !== null, + } + }) + + const add = Effect.fn("VercelCostSourceService.add")(function* (orgId: OrgId) { + 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.", + }) + } + const team = { name: connection.teamName, slug: connection.teamSlug } + if (connection.sourceId !== null) return { team } + // One day's read with the stored token: a token or role Vercel refuses is said now, not + // by a source that fails its first sync. + const now = yield* Clock.currentTimeMillis + const yesterday = addDays(utcToday(now), -1) + yield* provider + .pull({ orgId, connectionRef: connection.id }, { startDay: yesterday, endDay: yesterday }) + .pipe(Effect.mapError(toAddError)) + const added = yield* dbExecute((db) => + db.transaction( + Effect.gen(function* () { + // The share lock makes a disconnect or a move to another team wait for the insert. + const [current] = yield* db.run( + PG.from(VercelConnections) + .select("id") + .where(($) => [ + $.orgId.eq(orgId), + $.id.eq(connection.id), + $.teamId.eq(connection.teamId), + ]) + .limit(1) + .forShare(), + ) + if (current === undefined) return false + yield* db.run( + PG.insertInto(CostSources) + .values({ + id: CostSourceId.make(randomUUID()), + orgId, + provider: "vercel", + connectionRef: connection.id, + accountId: connection.teamSlug, + createdAt: now, + updatedAt: now, + }) + // Another request added it first. + .onConflictDoNothing(), + ) + return true + }), + ), + ) + if (!added) { + return yield* new IntegrationsConnectionChangedError({ + message: "The Vercel connection changed while the source was added. Try again.", + }) + } + return { team } + }) + + 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..29a227dbce --- /dev/null +++ b/packages/backend/src/services/cost/providers/vercel/VercelCostProvider.test.ts @@ -0,0 +1,276 @@ +import { afterEach, assert, describe, it } from "@effect/vitest" +import { OrgId } from "@maple/domain/http" +import { DateTime, Effect, Layer, 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)) + +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 slug, 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, "acme", false], + ["2026-11-04", "Function Duration", "web", "global", 4, "acme", 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, []) + }), + ) + }) + + 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" })]), + 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", 4)) + }), + ) + }) + + 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, "Billing role") + }), + ) + }) + + 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, "Connect Vercel again") + }), + ) + }) + + 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([error.reason, 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..a6ee98022d --- /dev/null +++ b/packages/backend/src/services/cost/providers/vercel/VercelCostProvider.ts @@ -0,0 +1,144 @@ +/** + * 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, Stream } 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 CostPullSource, + type CostPullWindow, +} from "../../CostProvider" +import { addDays, daysOfWindow, utcToday } from "../../sync-plan" +import { addCharge, emptyChargeTotals, rowsOf } from "./normalize" + +const PULL_TIMEOUT = Duration.seconds(45) +/** 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"] + +// Vercel does not bill for API requests, so no failure counts any. +const failed = (reason: Reason, message: string) => + new CostProviderError({ message, provider: "vercel", reason, requests: 0 }) + +const TOKEN_REJECTED = "Vercel rejected the saved token. Connect Vercel again with a new token." + +/** Vercel's failures, as what the sync does next and the Cost page shows. */ +const toCostProviderError = (error: VercelApiError): CostProviderError => { + switch (error.kind) { + case "unauthorized": + return failed("auth", TOKEN_REJECTED) + 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 or Billing role.", + ) + case "not_found": + return failed("not_ready", "Vercel has no billing data for this team, or the team is gone.") + 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": + return failed("upstream", "Vercel did not answer the billing request.") + } +} + +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 pull = Effect.fn("VercelCostProvider.pull")( + 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, + }) + if (daysOfWindow(window).length > 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.") + } + // The integration's poller found the token rejected: asking again would only be refused. + if (row.authFailedAt !== null) return yield* failed("auth", TOKEN_REJECTED) + 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( + Stream.runFold(emptyChargeTotals, (sums, charge) => + addCharge(sums, charge, row.teamSlug, 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, + }), + ), + Effect.mapError(toCostProviderError), + Effect.timeoutOrElse({ + duration: PULL_TIMEOUT, + orElse: () => Effect.fail(failed("upstream", "Vercel took too long to answer.")), + }), + ) + const rows = rowsOf(totals) + 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 }), + ), + ) + + return { provider: "vercel", pull } satisfies CostProviderAdapter + }), + }, +) { + 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..b3d9cd4a51 --- /dev/null +++ b/packages/backend/src/services/cost/providers/vercel/fake-vercel.ts @@ -0,0 +1,164 @@ +// 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, or raw text for anything else. */ + charges: ReadonlyArray>> | string + /** 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() + if (world.chargesStatus !== 200) { + return Response.json( + { error: { code: "refused", message: `status ${world.chargesStatus}` } }, + { status: world.chargesStatus }, + ) + } + return new Response(typeof world.charges === "string" ? world.charges : jsonl(world.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..dfff616731 --- /dev/null +++ b/packages/backend/src/services/cost/providers/vercel/normalize.test.ts @@ -0,0 +1,204 @@ +import { assert, describe, it } from "@effect/vitest" +import { Array as Arr, Order } from "effect" +import type { VercelCharge } from "../../../integrations/vercel/api" +import { toCostRows } 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, +}) + +const rowsOf = (charges: ReadonlyArray) => toCostRows(charges, 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("toCostRows", () => { + 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..8c87db8dc5 --- /dev/null +++ b/packages/backend/src/services/cost/providers/vercel/normalize.ts @@ -0,0 +1,109 @@ +// 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) + +/** A whole answer's charges as rows. The adapter folds the same way, line by line as they arrive. */ +export const toCostRows = ( + charges: ReadonlyArray, + accountId: string, + window: CostPullWindow, + openDay: string, +): ReadonlyArray => + rowsOf( + Arr.reduce(charges, emptyChargeTotals(), (totals, charge) => + addCharge(totals, charge, accountId, window, openDay), + ), + ) diff --git a/packages/backend/src/services/cost/sync-plan.test.ts b/packages/backend/src/services/cost/sync-plan.test.ts index b56cd2bb0f..990cc01c67 100644 --- a/packages/backend/src/services/cost/sync-plan.test.ts +++ b/packages/backend/src/services/cost/sync-plan.test.ts @@ -359,6 +359,56 @@ describe("planCostSync for a provider that serves 30 completed days", () => { }) }) +describe("planCostSync for a provider that serves a year in one request", () => { + const VERCEL_NEW: CostSyncState = { ...NEW, provider: "vercel" } + const vercelPulled = ( + now: number, + backfillDate: string, + ago: { readonly head: number; readonly settle: number }, + ): CostSyncState => ({ ...pulled(now, backfillDate, ago), provider: "vercel" }) + + it("starts with the previous month through today, like a provider with a longer history", () => { + const now = at("2026-11-20T12:00:00Z") + assert.deepStrictEqual(planCostSync(VERCEL_NEW, now), { + mode: "settle", + startDay: "2026-10-01", + endDay: "2026-11-20", + }) + }) + + it("keeps its history floor at the first day of the month eleven months back", () => { + assert.deepStrictEqual( + [historyFloor("vercel", "2026-11-20"), historyFloor("vercel", "2024-12-31")], + ["2025-12-01", "2024-01-01"], + ) + }) + + it("never asks for more than a year, whatever day and however long it was held", () => { + // The longest window there is: a source held since before the floor, on a leap year's last day. + const now = at("2024-12-31T12:00:00Z") + const held = vercelPulled(now, "2023-06-01", { head: 400 * DAY_MS, settle: 400 * DAY_MS }) + const plan = planCostSync(held, now) + assert.deepStrictEqual(plan, { mode: "settle", startDay: "2024-01-01", endDay: "2024-12-31" }) + assert.strictEqual(plan.mode === "idle" ? 0 : daysOfWindow(plan).length, 366) + }) + + it("walks history a month per step and stops at the floor", () => { + const now = at("2026-11-20T12:00:00Z") + const current = { head: HOUR_MS, settle: HOUR_MS } + assert.deepStrictEqual(planCostSync(vercelPulled(now, "2026-02-01", current), now), { + mode: "backfill", + startDay: "2026-01-01", + endDay: "2026-01-31", + }) + assert.deepStrictEqual(planCostSync(vercelPulled(now, "2026-01-01", current), now), { + mode: "backfill", + startDay: "2025-12-01", + endDay: "2025-12-31", + }) + assert.deepStrictEqual(planCostSync(vercelPulled(now, "2025-12-01", current), now), { mode: "idle" }) + }) +}) + describe("planCostSync request budgets", () => { const now = at("2026-11-11T12:00:00Z") const current = { head: HOUR_MS, settle: HOUR_MS } diff --git a/packages/backend/src/services/cost/sync-plan.ts b/packages/backend/src/services/cost/sync-plan.ts index b178bb1ec7..29e44b9142 100644 --- a/packages/backend/src/services/cost/sync-plan.ts +++ b/packages/backend/src/services/cost/sync-plan.ts @@ -31,6 +31,11 @@ export const HISTORY_MONTHS = 13 * dropped comes back empty, and an empty day replaces the stored one. */ const OPENROUTER_HISTORY_DAYS = 28 +/** + * Vercel serves a year at most in one request, so its history starts at the first day of the month + * this many months before the current one: a window from there to today is never over a year. + */ +const VERCEL_HISTORY_MONTHS = 11 // Through this day of the month the previous month's bill is still open; the first settle after // it takes the previous month once more, so its last pull is never from before it closed. export const PREVIOUS_MONTH_OPEN_DAYS = 10 @@ -66,6 +71,7 @@ interface CostSyncProfile { const COST_SYNC_PROFILES = { aws: { floor: (today) => monthStart(today, -HISTORY_MONTHS), lagDays: 0 }, openrouter: { floor: (today) => addDays(today, -OPENROUTER_HISTORY_DAYS), lagDays: 1 }, + vercel: { floor: (today) => monthStart(today, -VERCEL_HISTORY_MONTHS), lagDays: 0 }, } as const satisfies Record /** The oldest day the sync pulls from `provider`. */ diff --git a/packages/backend/src/services/cost/vercel-cost-sources.ts b/packages/backend/src/services/cost/vercel-cost-sources.ts new file mode 100644 index 0000000000..e7bb5cea27 --- /dev/null +++ b/packages/backend/src/services/cost/vercel-cost-sources.ts @@ -0,0 +1,58 @@ +// An org's Vercel `cost_sources`. Unlike the other providers the connection does not make the +// source: the org adds its Vercel team on the Cost page, and that row is the opt-in. So nothing +// here creates one. A source goes when the org removes it, when the connection is deleted, and +// when the connection moves to another team, whose spend would overwrite the source's 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)) + +/** + * Removes the sources whose connection is gone, in every org or only in `orgId`. 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, orgId?: OrgId) { + 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(), + ...(orgId === undefined ? [] : [$.orgId.eq(orgId)]), + ]) + .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..dd79e1b7e4 100644 --- a/packages/backend/src/services/integrations/VercelIntegrationService.test.ts +++ b/packages/backend/src/services/integrations/VercelIntegrationService.test.ts @@ -1396,6 +1396,49 @@ 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_slug, now(), now() FROM vercel_connections`, + ), + ) + 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 + // A new token for the same team: the source and its history stay. + yield* connect({ token: "vc_token_456" }) + assert.deepStrictEqual(yield* costSources, ["acme"]) + // 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, ["beta"]) + yield* disconnect() + assert.deepStrictEqual(yield* costSources, []) + }), + ) + }, + ) + 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..7277a019d7 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 { removeVercelCostSources } from "../cost/vercel-cost-sources" import { createDrain, deleteDrain, @@ -84,6 +85,7 @@ import { type VercelProjectListing, type VercelTeamInfo, } from "./vercel/api" +import { openVercelToken } from "./vercel/token" const DISCOVERY_TTL_MS = 60 * 60_000 const LEASE_MS = 4 * 60_000 @@ -469,8 +471,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 +481,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" }), ) @@ -1249,6 +1250,9 @@ export class VercelIntegrationService extends Context.Service< .returning(), ) .pipe(Effect.map(([row]) => row)), + ).pipe( + // A cost source reads one team: the new team's spend would overwrite its days. + Effect.tap(() => (sameTeam ? Effect.void : removeVercelCostSources(db, orgId))), ), ), ) diff --git a/packages/backend/src/services/integrations/vercel/api.ts b/packages/backend/src/services/integrations/vercel/api.ts index 6231379482..2db9d3bdf0 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,94 @@ export const deleteDrain = Effect.fn("VercelApi.deleteDrain")(function* ( ), ) }) + +// Billing + +const optional = (schema: S) => Schema.optionalKey(Schema.NullOr(schema)) + +/** + * 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({ + BilledCost: Schema.Number, + /** Inclusive, ISO 8601 in UTC: its first ten characters are the usage day. */ + ChargePeriodStart: Schema.String.check(Schema.isPattern(/^\d{4}-\d{2}-\d{2}/)), + /** 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.Number), + ConsumedUnit: optional(Schema.String), + EffectiveCost: optional(Schema.Number), + 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) + } + return response.stream.pipe( + Stream.decodeText(), + Stream.splitLines, + Stream.map(Str.trim), + Stream.filter(Str.isNonEmpty), + Stream.mapEffect((line) => decodeChargeLine(line)), + ) + }), + ).pipe( + Stream.catchTags({ + HttpClientError: (cause) => + // A team with no charges in the range may answer with no body at all. + cause.reason._tag === "EmptyBodyError" + ? Stream.empty + : Stream.fail( + new VercelApiError({ + message: "Vercel API request failed", + kind: "upstream", + cause, + }), + ), + SchemaError: (cause) => + Stream.fail( + new VercelApiError({ + message: "Vercel API answered with a charge Maple could not read", + kind: "invalid_response", + cause, + }), + ), + }), + ) diff --git a/packages/backend/src/services/integrations/vercel/token.ts b/packages/backend/src/services/integrations/vercel/token.ts new file mode 100644 index 0000000000..401fbaef7a --- /dev/null +++ b/packages/backend/src/services/integrations/vercel/token.ts @@ -0,0 +1,17 @@ +import type { VercelConnectionRow } from "@maple/db/tables" +import { openSecret, type SecretKeyring } from "@maple/backend/platform/SecretBox" +import { vercelTokenSlot } from "@maple/backend/platform/secret-slots" + +/** The connection's Vercel access token, unsealed. Shared by the integration and the cost adapter. */ +export const openVercelToken = ( + keyring: SecretKeyring, + connection: Pick, + onError: (message: string) => E, +) => + openSecret( + keyring, + vercelTokenSlot, + { orgId: connection.orgId }, + { ciphertext: connection.tokenCiphertext, iv: connection.tokenIv, tag: connection.tokenTag }, + onError, + ) diff --git a/packages/domain/src/audit-actions.ts b/packages/domain/src/audit-actions.ts index be2fecaccf..98bd9bd541 100644 --- a/packages/domain/src/audit-actions.ts +++ b/packages/domain/src/audit-actions.ts @@ -131,6 +131,8 @@ export const AuditResources = { cloudflare_integration: { verbs: ["zones_updated"] }, /** A provider key the org's spend is read with, one per provider; the key is never in `changes`. */ cost_source_key: { verbs: ["updated", "deleted"] }, + /** A connection the org already has, added to or taken off the Cost page; the provider is in `metadata`. */ + cost_source: { verbs: ["added", "removed"] }, /** * Org membership, learned from Clerk's webhook; the web app changes members * in Clerk directly, so nothing reaches Maple's own API. The member is the @@ -248,6 +250,7 @@ export const AuditResourceLabels = { planetscale_integration: "PlanetScale integration", cloudflare_integration: "Cloudflare integration", cost_source_key: "Cost source key", + cost_source: "Cost source", member: "Member", organization: "Organization", scrape_target: "Scrape target", @@ -375,6 +378,8 @@ export const AuditActionLabels = { "cloudflare_integration.zones_updated": "Updated Cloudflare zones", "cost_source_key.updated": "Updated cost source key", "cost_source_key.deleted": "Removed cost source key", + "cost_source.added": "Added cost source", + "cost_source.removed": "Removed cost source", "member.added": "Added member", "member.role_changed": "Changed member role", "member.removed": "Removed member", diff --git a/packages/domain/src/cost.ts b/packages/domain/src/cost.ts index b18d84f62e..8139ac8ec4 100644 --- a/packages/domain/src/cost.ts +++ b/packages/domain/src/cost.ts @@ -24,7 +24,7 @@ export const CostDimension = Schema.Literals([ export type CostDimension = typeof CostDimension.Type /** Providers Maple reads cost from. One literal per built adapter. */ -export const CostProvider = Schema.Literals(["aws", "openrouter"]) +export const CostProvider = Schema.Literals(["aws", "openrouter", "vercel"]) export type CostProvider = typeof CostProvider.Type /** `billed`: the provider reported the money. `estimated`: usage times list price. */ diff --git a/packages/domain/src/http/index.ts b/packages/domain/src/http/index.ts index e777872091..f3e654d9c5 100644 --- a/packages/domain/src/http/index.ts +++ b/packages/domain/src/http/index.ts @@ -9,6 +9,7 @@ export * from "./dependency-scan" export * from "./ai-triage" export * from "./openrouter-key" export * from "./openrouter-cost" +export * from "./vercel-cost" export * from "./service-map-world" export * from "./incident-triage" export * from "./cancellation-review" diff --git a/packages/domain/src/http/internal-api.ts b/packages/domain/src/http/internal-api.ts index 6613fd5b0d..f7995d9099 100644 --- a/packages/domain/src/http/internal-api.ts +++ b/packages/domain/src/http/internal-api.ts @@ -22,6 +22,7 @@ import { QueryEngineApiGroup } from "./query-engine" import { ServiceMapWorldApiGroup } from "./service-map-world" import { SessionReplaysInternalApiGroup } from "./session-replay" import { UsersInternalApiGroup } from "./users" +import { VercelCostApiGroup } from "./vercel-cost" import { ApiSchemaErrors, ApiUnexpectedErrors } from "./api-boundary" /** @@ -61,6 +62,7 @@ export class MapleInternalApi extends HttpApi.make("MapleInternalApi") .add(McpInternalApiGroup) .add(OpenRouterKeyApiGroup) .add(OpenRouterCostApiGroup) + .add(VercelCostApiGroup) .add(OrgClickHouseSettingsApiGroup) .add(OrganizationsApiGroup) .add(OrganizationCreationApiGroup) diff --git a/packages/domain/src/http/vercel-cost.ts b/packages/domain/src/http/vercel-cost.ts new file mode 100644 index 0000000000..ed0af026ae --- /dev/null +++ b/packages/domain/src/http/vercel-cost.ts @@ -0,0 +1,68 @@ +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, as the Cost page names it. */ +export const VercelCostTeam = Schema.Struct({ 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, +}) {} + +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", "/", { + 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) {} From 9ce119000bea4f4081e712346ac8f546965ed515 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 12 Nov 2026 23:43:51 +0100 Subject: [PATCH 2/8] feat(cost): add and remove the Vercel source on the Cost page "Add source" lists Vercel. Its step reads the org's Vercel connection: a connected team is added with one click, an org with none is sent to the connect flow, and a team whose token Vercel rejected to its connection. An add Vercel refuses stays in the dialog with the reason. A Vercel source has Remove on its row, after a confirm that says the connection stays. The remove dialog and its confirm now name the provider they act on, so the OpenRouter and Vercel sources of one org are removed apart. The first-sync wait names every provider that brings in months, and the cost lab has Vercel lines, a Vercel source and a state for each step. --- apps/web/src/lab/cost-fixture.test.ts | 21 +- apps/web/src/lab/cost-fixture.ts | 66 +++- apps/web/src/lab/cost-lab.ts | 142 ++++++-- apps/web/src/page/cost/command.ts | 25 ++ apps/web/src/page/cost/cost.scene.test.ts | 317 +++++++++++++++++- apps/web/src/page/cost/message.ts | 12 +- apps/web/src/page/cost/model.ts | 15 +- apps/web/src/page/cost/providers.ts | 7 + apps/web/src/page/cost/update.ts | 141 ++++++-- apps/web/src/page/cost/view-add-source.ts | 182 ++++++---- apps/web/src/page/cost/view-add-vercel.ts | 149 ++++++++ apps/web/src/page/cost/view-sources.ts | 48 ++- apps/web/src/page/cost/view.ts | 32 +- .../page/settings/kit/audit-log-resource.ts | 1 + 14 files changed, 974 insertions(+), 184 deletions(-) create mode 100644 apps/web/src/page/cost/view-add-vercel.ts diff --git a/apps/web/src/lab/cost-fixture.test.ts b/apps/web/src/lab/cost-fixture.test.ts index 884a79b808..eaee669ea2 100644 --- a/apps/web/src/lab/cost-fixture.test.ts +++ b/apps/web/src/lab/cost-fixture.test.ts @@ -8,31 +8,40 @@ const openRouterOnly = { days: 30, filters: { provider: ["openrouter"] } } describe("cost lab fixture", () => { test("a removed OpenRouter source leaves every read, also under a filter naming it", () => { const before = labTotals(window).cost - const after = labTotals({ ...window, withoutOpenRouter: true }).cost + const after = labTotals({ ...window, without: ["openrouter"] }).cost expect(after).toBeGreaterThan(0) expect(after).toBeLessThan(before) expect( Arr.map( - labBreakdown({ ...window, withoutOpenRouter: true, groupBy: "provider", sortBy: "cost" }) + labBreakdown({ ...window, without: ["openrouter"], groupBy: "provider", sortBy: "cost" }) .rows, (row) => row.key, ), - ).toEqual(["aws"]) + ).toEqual(["aws", "vercel"]) // Filtered to OpenRouter there is nothing left: AWS cost must not show under that filter. expect(labTotals(openRouterOnly).cost).toBeGreaterThan(0) - expect(labTotals({ ...openRouterOnly, withoutOpenRouter: true }).cost).toBe(0) + expect(labTotals({ ...openRouterOnly, without: ["openrouter"] }).cost).toBe(0) expect( labBreakdown({ ...openRouterOnly, - withoutOpenRouter: true, + without: ["openrouter"], groupBy: "cloud_service", sortBy: "cost", }).rows, ).toEqual([]) expect( - labTimeseries({ ...openRouterOnly, withoutOpenRouter: true, groupBy: "provider", limit: 8 }) + labTimeseries({ ...openRouterOnly, without: ["openrouter"], groupBy: "provider", limit: 8 }) .series, ).toEqual([]) }) + + test("a removed Vercel source leaves every read too, and the other providers stay", () => { + const removed = { ...window, without: ["vercel"] } + expect(labTotals(removed).cost).toBeLessThan(labTotals(window).cost) + expect( + Arr.map(labBreakdown({ ...removed, groupBy: "provider", sortBy: "cost" }).rows, (row) => row.key), + ).toEqual(["aws", "openrouter"]) + expect(labTotals({ days: 30, filters: { provider: ["vercel"] }, without: ["vercel"] }).cost).toBe(0) + }) }) diff --git a/apps/web/src/lab/cost-fixture.ts b/apps/web/src/lab/cost-fixture.ts index fcf71aed1f..b5892f616b 100644 --- a/apps/web/src/lab/cost-fixture.ts +++ b/apps/web/src/lab/cost-fixture.ts @@ -56,6 +56,27 @@ const modelLine = (model: string, servedBy: string, daily: number, drift: number drift, }) +/** The Vercel team the lab's source reads, by its slug. */ +export const LAB_VERCEL_TEAM = { name: "Example Team", slug: "example-team" } + +const productLine = ( + product: string, + category: string, + region: string, + sku: string, + daily: number, + drift: number, +): Line => ({ + cloud_service: product, + provider: "vercel", + service_category: category, + region, + usage_type: sku, + account: LAB_VERCEL_TEAM.slug, + daily, + drift, +}) + /** `daily` is the charge per day today; `drift` how much it grew or shrank over the last 30 days. */ const LINES: ReadonlyArray = [ line("Example Compute", "Compute", "north-1", "north-1:instance-hours", 61.2, 0.18), @@ -79,6 +100,12 @@ const LINES: ReadonlyArray = [ modelLine("example/large-model", "Example Cloud", 6.2, 0.1), modelLine("example/fast-model", "Example Inference", 12.9, -0.15), modelLine("example/embedding-model", "Example Labs", 1.8, 0.0), + productLine("Example Function Duration", "Compute", "east-1", "function-duration", 9.4, 0.2), + productLine("Example Function Duration", "Compute", "central-1", "function-duration", 3.1, 0.05), + productLine("Example Data Transfer", "Networking", "global", "data-transfer", 6.7, 0.12), + productLine("Example Image Optimization", "Web", "global", "image-optimization", 2.2, -0.08), + // The plan's monthly fee, as Vercel spreads it over the days. + productLine("Example Pro Plan", "Other", "global", "", 0.67, 0.0), ] /** The day `offset` days before the lab's today, as `YYYY-MM-DD`. */ @@ -104,15 +131,14 @@ const matches = (entry: Line, filters: CostFilters | undefined): boolean => type Request = Readonly<{ days: number filters: CostFilters | undefined - /** The lab's reader removed OpenRouter: its lines are in no read, whatever the filters say. */ - withoutOpenRouter?: boolean + /** Providers whose source the lab's reader removed: their lines are in no read, whatever the filters say. */ + without?: ReadonlyArray }> /** Each matching line with its charge on every day of the window (oldest first) and of the period before. */ const windowOf = (request: Request) => Arr.flatMap(LINES, (entry, index) => - matches(entry, request.filters) && - !(request.withoutOpenRouter === true && entry.provider === "openrouter") + matches(entry, request.filters) && !Arr.contains(request.without ?? [], entry.provider) ? [ { entry, @@ -248,23 +274,38 @@ const openRouterSource = source({ requestsThisMonth: 0, }) +/** The Vercel source: eleven months of history, all one request serves, and no billed requests. */ +const vercelSource = source({ + id: "0190f2b3-0000-7000-8000-00000000c061", + provider: "vercel", + accountId: LAB_VERCEL_TEAM.slug, + backfillDate: "2025-12-01", + lastSyncedAt: LAB_NOW_MS - 2 * 3_600_000, + requestsThisMonth: 0, +}) + +/** The sources the lab's reader can add and remove on the page, as they are once synced. */ +const ADDABLE = { openrouter: openRouterSource, vercel: vercelSource } as const +export type LabAddable = keyof typeof ADDABLE + /** - * `sources` after the lab's reader added OpenRouter (true: it waits for its first sync) or removed - * it (false); null leaves them as the state has them. + * `sources` after the lab's reader added a provider's source (true: it waits for its first sync) + * or removed it (false); null leaves it as the state has it. */ export const labSourcesWith = ( sources: CostSourcesResponse, - openRouter: boolean | null, + provider: LabAddable, + added: boolean | null, ): CostSourcesResponse => { - if (openRouter === null) return sources - const others = Arr.filter(sources.sources, (entry) => entry.provider !== "openrouter") - if (!openRouter) return new CostSourcesResponse({ sources: others }) + if (added === null) return sources + const others = Arr.filter(sources.sources, (entry) => entry.provider !== provider) + if (!added) return new CostSourcesResponse({ sources: others }) return others.length === sources.sources.length ? decodeSources({ sources: [ ...Schema.encodeSync(CostSourcesResponse)(sources).sources, { - ...openRouterSource, + ...ADDABLE[provider], status: "pending", watermarkDate: null, backfillDate: null, @@ -277,7 +318,7 @@ export const labSourcesWith = ( } export const LAB_SOURCES = { - synced: decodeSources({ sources: [source({}), openRouterSource] }), + synced: decodeSources({ sources: [source({}), openRouterSource, vercelSource] }), none: decodeSources({ sources: [] }), pending: decodeSources({ sources: [ @@ -315,6 +356,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 8023445b12..b15ce87858 100644 --- a/apps/web/src/lab/cost-lab.ts +++ b/apps/web/src/lab/cost-lab.ts @@ -1,4 +1,10 @@ -import { OpenRouterCostKeyRemoved, OpenRouterCostKeySaved } from "@maple/domain/http" +import { + OpenRouterCostKeyRemoved, + OpenRouterCostKeySaved, + VercelCostSourceAdded, + VercelCostSourceRemoved, + VercelCostStatus, +} from "@maple/domain/http" import * as Policy from "@maple/domain/policy" import { toLoadFailure } from "@maple/views/data/load-failure" import * as Field from "@maple/views/ui/field" @@ -8,13 +14,16 @@ import type { Html, HtmlBuilder } from "foldkit/html" import { defineMessageUnion } from "foldkit/message" import { fromString } from "foldkit/url" import { + AddVercelCostSource, BreakdownInput, FetchCostBreakdown, FetchCostSources, FetchCostTimeseries, FetchCostTotals, + FetchVercelCostStatus, ReadCostGrants, RemoveOpenRouterCostKey, + RemoveVercelCostSource, ResolveWindow, SaveOpenRouterCostKey, TimeseriesInput, @@ -31,6 +40,7 @@ import { LAB_OPENROUTER_KEY, LAB_SOURCES, LAB_TODAY, + LAB_VERCEL_TEAM, labBreakdown, labSourcesWith, labTimeseries, @@ -57,6 +67,10 @@ export const LabState = Schema.Literals([ "key-refused", "replace-key", "remove-source", + "add-vercel", + "vercel-not-connected", + "vercel-refused", + "remove-vercel", "member", "member-onramp", ]) @@ -76,6 +90,8 @@ export const Model = Schema.Struct({ state: LabState, /** OpenRouter added (true) or removed (false) since the state loaded; null before either. */ openRouter: Schema.NullOr(Schema.Boolean), + /** The same for Vercel. */ + vercel: Schema.NullOr(Schema.Boolean), cost: CostModel, }) export type Model = typeof Model.Type @@ -84,8 +100,10 @@ export const Message = defineMessageUnion({ GotCostMessage: { message: CostMessa export type Message = typeof Message.Type type Return = Update.Return -/** What the lab answers from: the state it loaded in, and what the reader did to OpenRouter since. */ -type World = Readonly<{ state: LabState; openRouter: boolean | null }> +/** What the reader added (true) or removed (false) since the state loaded; null before either. */ +type Changes = Readonly<{ openRouter: boolean | null; vercel: boolean | null }> +/** What the lab answers from: the state it loaded in, and what the reader did to its sources since. */ +type World = Readonly<{ state: LabState }> & Changes type CostCommand = Command.Command const searchFor = (state: LabState): Search => @@ -101,7 +119,10 @@ const startsEmpty = (state: LabState): boolean => state === "member-onramp" || state === "add-source" || state === "add-openrouter" || - state === "key-refused" + state === "key-refused" || + state === "add-vercel" || + state === "vercel-not-connected" || + state === "vercel-refused" /** What the reader did before the state's screenshot: the clicks that open its dialog. */ const openingOf = (state: LabState): ReadonlyArray => { @@ -122,7 +143,18 @@ const openingOf = (state: LabState): ReadonlyArray => { case "replace-key": return [CostMessage.ClickedReplaceKey()] case "remove-source": - return [CostMessage.ClickedRemoveSource()] + return [CostMessage.ClickedRemoveSource({ provider: "openrouter" })] + case "add-vercel": + case "vercel-not-connected": + return [CostMessage.ClickedAddSource(), CostMessage.ClickedAddVercel()] + case "vercel-refused": + return [ + CostMessage.ClickedAddSource(), + CostMessage.ClickedAddVercel(), + CostMessage.ClickedConfirmAddVercel(), + ] + case "remove-vercel": + return [CostMessage.ClickedRemoveSource({ provider: "vercel" })] // Without Clerk the page starts with every permission: the member states take them away. case "member": case "member-onramp": @@ -138,6 +170,20 @@ const refusedKey = { "This is a regular API key. Reading spend needs a management key, which you create under Settings, Management keys in OpenRouter.", } +const refusedVercel = { + ...toLoadFailure("refused"), + message: + "This Vercel token's role cannot read the team's billing. Use a token from an Owner, Member, Developer or Billing role.", +} + +/** The org's Vercel connection as the state has it, and whether the page's sources hold its team. */ +const vercelStatusOf = (world: World): VercelCostStatus => + new VercelCostStatus({ + team: world.state === "vercel-not-connected" ? null : LAB_VERCEL_TEAM, + authFailed: false, + added: world.vercel ?? !startsEmpty(world.state), + }) + /** The days a preset spans, for the fixture's window; a custom range reads as the default. */ const DAYS = new Map([ ["1w", 7], @@ -188,13 +234,22 @@ const answerOf = (world: World, search: Search, command: CostCommand): Option.Op const filtersOf = (filters: WindowInput["filters"]) => state === "empty-window" ? { account: ["000000000000"] } : filters // A removed source's cost is not read any more, under any filter. - const withoutOpenRouter = world.openRouter === false + const without = [ + ...(world.openRouter === false ? ["openrouter"] : []), + ...(world.vercel === false ? ["vercel"] : []), + ] switch (command.name) { case FetchCostSources.name: return Option.map(keyArgs(command.args), ({ key }) => CostMessage.CompletedLoadSources({ key, - result: Result.succeed(labSourcesWith(sourcesFor(state), world.openRouter)), + result: Result.succeed( + labSourcesWith( + labSourcesWith(sourcesFor(state), "openrouter", world.openRouter), + "vercel", + world.vercel, + ), + ), }), ) case FetchCostTotals.name: @@ -202,7 +257,7 @@ const answerOf = (world: World, search: Search, command: CostCommand): Option.Op CostMessage.CompletedLoadTotals({ key, result: settled(state, () => - labTotals({ days, filters: filtersOf(input.filters), withoutOpenRouter }), + labTotals({ days, filters: filtersOf(input.filters), without }), ), }), ) @@ -214,7 +269,7 @@ const answerOf = (world: World, search: Search, command: CostCommand): Option.Op labTimeseries({ days, filters: filtersOf(input.filters), - withoutOpenRouter, + without, groupBy: input.groupBy, limit: input.limit, }), @@ -229,7 +284,7 @@ const answerOf = (world: World, search: Search, command: CostCommand): Option.Op labBreakdown({ days, filters: filtersOf(input.filters), - withoutOpenRouter, + without, groupBy: input.groupBy, sortBy: input.sortBy, }), @@ -257,23 +312,49 @@ const answerOf = (world: World, search: Search, command: CostCommand): Option.Op result: Result.succeed(new OpenRouterCostKeyRemoved({ removed: true })), }), ) + case FetchVercelCostStatus.name: + return Option.map(keyArgs(command.args), ({ key }) => + CostMessage.CompletedLoadVercelStatus({ key, result: Result.succeed(vercelStatusOf(world)) }), + ) + case AddVercelCostSource.name: + return Option.some( + CostMessage.CompletedAddVercel({ + result: + state === "vercel-refused" + ? Result.fail(refusedVercel) + : Result.succeed(new VercelCostSourceAdded({ team: LAB_VERCEL_TEAM })), + }), + ) + case RemoveVercelCostSource.name: + return Option.some( + CostMessage.CompletedRemoveVercel({ + result: Result.succeed(new VercelCostSourceRemoved({ removed: true })), + }), + ) default: return Option.none() } } type Step = Readonly<{ model: CostModel; commands: ReadonlyArray }> -type Settled = Step & Pick +type Settled = Step & Changes -/** A saved key adds OpenRouter to the sources the lab answers with next, a removal takes it out. */ -const openRouterAfter = (openRouter: boolean | null, answers: ReadonlyArray) => - Arr.reduce(answers, openRouter, (current, answer) => - answer._tag === "CompletedSaveKey" && Result.isSuccess(answer.result) - ? true - : answer._tag === "CompletedRemoveKey" && Result.isSuccess(answer.result) - ? false - : current, - ) +/** A saved key or an added team joins the sources the lab answers with next, a removal takes it out. */ +const changesAfter = (changes: Changes, answers: ReadonlyArray): Changes => + Arr.reduce(answers, changes, (current, answer) => { + switch (answer._tag) { + case "CompletedSaveKey": + return Result.isSuccess(answer.result) ? { ...current, openRouter: true } : current + case "CompletedRemoveKey": + return Result.isSuccess(answer.result) ? { ...current, openRouter: false } : current + case "CompletedAddVercel": + return Result.isSuccess(answer.result) ? { ...current, vercel: true } : current + case "CompletedRemoveVercel": + return Result.isSuccess(answer.result) ? { ...current, vercel: false } : current + default: + return current + } + }) /** * Runs the page's reads against the fixture until none is left: an answer can ask for more (the @@ -284,7 +365,9 @@ const settle = (world: World, step: Step): Settled => { Option.toArray(answerOf(world, step.model.search, command)), ) const kept = keepLocal(step.commands) - if (answers.length === 0) return { model: step.model, commands: kept, openRouter: world.openRouter } + if (answers.length === 0) { + return { model: step.model, commands: kept, openRouter: world.openRouter, vercel: world.vercel } + } const next = Arr.reduce( answers, { model: step.model, commands: Arr.empty() }, @@ -293,7 +376,7 @@ const settle = (world: World, step: Step): Settled => { return { model: result.model, commands: [...acc.commands, ...(result.commands ?? [])] } }, ) - const rest = settle({ ...world, openRouter: openRouterAfter(world.openRouter, answers) }, next) + const rest = settle({ ...world, ...changesAfter(world, answers) }, next) return { ...rest, commands: [...kept, ...rest.commands] } } @@ -312,18 +395,23 @@ const searchOfUrl = (url: string): Search => export const init = (flags: Flags): Return => { const entered = Cost.init({ search: searchFor(flags.state) }, LAB_CONTEXT) - const world: World = { state: flags.state, openRouter: null } + const world: World = { state: flags.state, openRouter: null, vercel: null } const loaded = settle(world, { model: entered.model, commands: entered.commands ?? [] }) const ready = Arr.reduce(openingOf(flags.state), loaded, (step, message) => { const next = Cost.update(step.model, message, LAB_CONTEXT) const settled = settle( - { ...world, openRouter: step.openRouter }, + { ...world, openRouter: step.openRouter, vercel: step.vercel }, { model: next.model, commands: next.commands ?? [] }, ) return { ...settled, commands: [...step.commands, ...settled.commands] } }) return { - model: { state: flags.state, openRouter: ready.openRouter, cost: ready.model }, + model: { + state: flags.state, + openRouter: ready.openRouter, + vercel: ready.vercel, + cost: ready.model, + }, commands: lift(ready.commands), } } @@ -338,11 +426,11 @@ export const update = (model: Model, message: Message): Return => ? Cost.routeChanged(step.model, { search: searchOfUrl(out.url) }, LAB_CONTEXT) : { model: step.model } const ready = settle( - { state: model.state, openRouter: model.openRouter }, + { state: model.state, openRouter: model.openRouter, vercel: model.vercel }, { model: moved.model, commands: [...(step.commands ?? []), ...(moved.commands ?? [])] }, ) return { - model: { ...model, openRouter: ready.openRouter, cost: ready.model }, + model: { ...model, openRouter: ready.openRouter, vercel: ready.vercel, cost: ready.model }, commands: lift(ready.commands), } }, diff --git a/apps/web/src/page/cost/command.ts b/apps/web/src/page/cost/command.ts index f3c9c32d96..0a8d82cf4e 100644 --- a/apps/web/src/page/cost/command.ts +++ b/apps/web/src/page/cost/command.ts @@ -107,3 +107,28 @@ export const RemoveOpenRouterCostKey = Command.define("RemoveOpenRouterCostKey", settleTo((result) => Message.CompletedRemoveKey({ result })), ), }) + +/** Whether the org has a Vercel team connected, and whether it is a cost source already. */ +export const FetchVercelCostStatus = Command.define("FetchVercelCostStatus", { + args: { key: Schema.String }, + messages: [Message.CompletedLoadVercelStatus], + execute: ({ key }) => + Effect.flatMap(MapleInternalApiClient, (client) => client.vercelCost.status()).pipe( + settleTo((result) => Message.CompletedLoadVercelStatus({ key, result })), + ), +}) + +/** Makes the connected Vercel team a cost source, once Vercel let its token read the billing. */ +export const AddVercelCostSource = Command.define("AddVercelCostSource", { + messages: [Message.CompletedAddVercel], + execute: Effect.flatMap(MapleInternalApiClient, (client) => client.vercelCost.add()).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 61c9640f8a..373abcc1cd 100644 --- a/apps/web/src/page/cost/cost.scene.test.ts +++ b/apps/web/src/page/cost/cost.scene.test.ts @@ -7,6 +7,9 @@ import { CostTotalsResponse, OpenRouterCostKeyRemoved, OpenRouterCostKeySaved, + VercelCostSourceAdded, + VercelCostSourceRemoved, + VercelCostStatus, } from "@maple/domain/http" import * as Policy from "@maple/domain/policy" import { Option, Record as Rec, Result, Schema, Array as Arr, String as Str } from "effect" @@ -173,7 +176,7 @@ describe("cost page", () => { ) }) - test("adding a source offers AWS through its connect flow and OpenRouter through a key form", () => { + test("adding a source offers AWS through its connect flow, OpenRouter through a key form and Vercel through its team", () => { const opened = pageUpdate(entered({}, []).model, Message.ClickedAddSource()).model scene( { update: pageUpdate, view }, @@ -183,7 +186,8 @@ describe("cost page", () => { expectView(text("Add a cost source")).toExist(), expectView(role("link", { name: /^AWS/ })).toHaveAttr("href", "/sources/new?integration=aws"), expectView(text(/AWS bills USD.0\.01 for each request Maple makes/)).toExist(), - expectView(text(/OpenRouter requests are free/)).toExist(), + expectView(text(/OpenRouter and Vercel requests are free/)).toExist(), + expectView(role("button", { name: /^Vercel/ })).toExist(), // An account already connected turns its Cost module on from the AWS integration. expectView( role("link", { name: "Turn on Cost for an AWS account that is connected" }), @@ -334,7 +338,7 @@ describe("cost page", () => { expectView(role("button", { name: /^OpenRouter/ })).not.toExist(), ) - const confirming = pageUpdate(model, Message.ClickedRemoveSource()).model + const confirming = pageUpdate(model, Message.ClickedRemoveSource({ provider: "openrouter" })).model expect(confirming.removeDialog.isOpen).toBe(true) const removing = pageUpdate(confirming, Message.ClickedConfirmRemove()) expect(names(removing.commands)).toEqual(["RemoveOpenRouterCostKey"]) @@ -426,6 +430,292 @@ describe("cost page", () => { ) }) + const vercelTeam = { 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 or Billing role/)).toExist(), + expectView(role("button", { name: "Add source" })).toBeEnabled(), + expectView(role("button", { name: "Back" })).toBeEnabled(), + ) + const adding = pageUpdate(connected, Message.ClickedConfirmAddVercel()) + expect(names(adding.commands)).toEqual(["AddVercelCostSource"]) + // 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([]) + + // 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 or Billing 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")).toHaveText(reason), + // 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("a failed read of the Vercel connection says so and asks again on Try again", () => { + const opened = vercelStepOpened().model + const failed = pageUpdate( + opened, + Message.CompletedLoadVercelStatus({ + key: opened.vercel.key ?? "", + result: Result.fail(toLoadFailure("unreachable")), + }), + ).model + scene( + { update: pageUpdate, view }, + given(failed), + resolveTheme, + dialogMounted, + expectView(role("alert")).toExist(), + expectView(role("button", { name: "Add source" })).not.toExist(), + expectView(role("button", { name: "Try again" })).toExist(), + ) + // Try again reads it again, and shows the read in flight in place of the old error. + const retried = pageUpdate(failed, Message.ClickedAddVercel()) + expect(names(retried.commands)).toEqual(["FetchVercelCostStatus"]) + scene( + { update: pageUpdate, view }, + given(retried.model), + resolveTheme, + dialogMounted, + expectView(text("Reading the Vercel connection")).toExist(), + expectView(role("alert")).not.toExist(), + ) + }) + + test("a Vercel source is named by its team, bills no requests and is removed after a confirm", () => { + const vercel = source({ + id: "0190f2b3-0000-7000-8000-00000000c061", + provider: "vercel", + accountId: "acme", + backfillDate: "2025-12-01", + requestsThisMonth: 0, + }) + const model = answered(entered({}, [vercel]).model) + scene( + { update: pageUpdate, view }, + given(model), + resolveTheme, + expectView(text("Vercel")).toExist(), + expectView(text("acme")).toExist(), + expectView(text(/provider requests/)).not.toExist(), + // The connection holds the token: there is no key to replace here. + expectView(role("button", { name: "Replace key" })).not.toExist(), + expectView(role("button", { name: "Remove" })).toExist(), + ) + // The providers list the team as added, with nothing to click. + scene( + { update: pageUpdate, view }, + given(pageUpdate(model, Message.ClickedAddSource()).model), + resolveTheme, + dialogMounted, + expectView(text("Already added")).toExist(), + expectView(role("button", { name: /^Vercel/ })).not.toExist(), + expectView(role("button", { name: /^OpenRouter/ })).toExist(), + ) + + const confirming = pageUpdate(model, Message.ClickedRemoveSource({ provider: "vercel" })).model + scene( + { update: pageUpdate, view }, + given(confirming), + resolveTheme, + dialogMounted, + expectView(text("Remove the Vercel source?")).toExist(), + expectView(text(/The Vercel connection stays as it is/)).toExist(), + ) + const removing = pageUpdate(confirming, Message.ClickedConfirmRemove()) + expect(names(removing.commands)).toEqual(["RemoveVercelCostSource"]) + + // A removal that failed leaves the source where it was and says why. + const failed = pageUpdate( + removing.model, + Message.CompletedRemoveVercel({ result: Result.fail(toLoadFailure("unreachable")) }), + ) + expect(failed.model.removeDialog.isOpen).toBe(false) + expect(failed.outMessage).toMatchObject({ type: "error", title: "Couldn't remove the source" }) + scene( + { update: pageUpdate, view }, + given(failed.model), + resolveTheme, + dialogMounted, + expectView(role("button", { name: "Remove" })).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 row is gone at once, and the sources are read again for what is left. + expect(names(removed.commands)).toContain("FetchCostSources") + scene( + { update: pageUpdate, view }, + given(removed.model), + resolveTheme, + dialogMounted, + expectView(text("acme")).not.toExist(), + expectView(text("No cost source yet")).toExist(), + ) + }) + + test("removing one provider's source leaves the other's on the page", () => { + const openRouter = source({ + id: "0190f2b3-0000-7000-8000-00000000c060", + provider: "openrouter", + accountId: "user_2openrouterAccount", + }) + const vercel = source({ + id: "0190f2b3-0000-7000-8000-00000000c061", + provider: "vercel", + accountId: "acme", + }) + const model = answered(entered({}, [openRouter, vercel]).model) + const removing = pageUpdate( + pageUpdate(model, Message.ClickedRemoveSource({ provider: "openrouter" })).model, + Message.ClickedConfirmRemove(), + ) + // The confirm removes the source its dialog asked about. + expect(names(removing.commands)).toEqual(["RemoveOpenRouterCostKey"]) + const removed = pageUpdate( + removing.model, + Message.CompletedRemoveKey({ + result: Result.succeed(new OpenRouterCostKeyRemoved({ removed: true })), + }), + ).model + scene( + { update: pageUpdate, view }, + given(removed), + resolveTheme, + dialogMounted, + expectView(text("user_2openrouterAccount")).not.toExist(), + expectView(text("acme")).toExist(), + ) + }) + test("a view filtered to a provider compares with the period before when that provider's history reaches it", () => { const aws = Schema.decodeSync(CostSourcesResponse)({ sources: [source()] }).sources const both = Schema.decodeSync(CostSourcesResponse)({ @@ -499,6 +789,7 @@ describe("cost page", () => { 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 provider this build does not know still shows, as the rows name it. expect(keyLabel("provider", "somecloud")).toBe("somecloud") expect(requestFilters({ provider: ["openrouter"] })).toEqual({ provider: ["openrouter"] }) @@ -572,6 +863,26 @@ describe("cost page", () => { expectView(text(/AWS brings in this month and the last/)).toExist(), expectView(text(/OpenRouter brings in its last four weeks/)).toExist(), ) + // A Vercel team reads back like an AWS account: the two are named together. + const vercel = source({ + ...unsynced, + id: "0190f2b3-0000-7000-8000-00000000c061", + provider: "vercel", + accountId: "acme", + status: "pending", + }) + scene( + { update: pageUpdate, view }, + given(entered({}, [vercel]).model), + resolveTheme, + expectView(text(/It brings in this month and the last, and older months fill in/)).toExist(), + ) + scene( + { update: pageUpdate, view }, + given(entered({}, [failingAws, vercel, openRouter]).model), + resolveTheme, + expectView(text(/AWS and Vercel bring in this month and the last/)).toExist(), + ) }) test("a synced source asks for the totals, the daily series and the breakdown of the window", () => { diff --git a/apps/web/src/page/cost/message.ts b/apps/web/src/page/cost/message.ts index ce3cb4292a..eb36c069ef 100644 --- a/apps/web/src/page/cost/message.ts +++ b/apps/web/src/page/cost/message.ts @@ -6,6 +6,9 @@ import { CostTotalsResponse, OpenRouterCostKeyRemoved, OpenRouterCostKeySaved, + VercelCostSourceAdded, + VercelCostSourceRemoved, + VercelCostStatus, } from "@maple/domain/http" import * as Policy from "@maple/domain/policy" import { Schema } from "effect" @@ -19,6 +22,7 @@ import * as Field from "@maple/views/ui/field" import { TimeWindow } from "../../data/time-range" import { COST_GROUP_BYS } from "../../route/search" import * as TimeRangePicker from "../kit/time-range-picker" +import { RemovableProvider } from "./model" /** A read's answer; `key` is what it was requested under, so a stale one is dropped. */ const result = (schema: Schema.Codec) => ({ @@ -49,6 +53,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: {}, @@ -56,10 +65,11 @@ export const Message = defineMessageUnion({ GotKeyDraftMessage: { message: Field.Message }, SubmittedKey: {}, CompletedSaveKey: { result: Schema.Result(OpenRouterCostKeySaved, LoadFailure) }, - ClickedRemoveSource: {}, + ClickedRemoveSource: { provider: RemovableProvider }, GotRemoveDialogMessage: { message: Dialog.Message }, ClickedConfirmRemove: {}, CompletedRemoveKey: { result: Schema.Result(OpenRouterCostKeyRemoved, LoadFailure) }, + CompletedRemoveVercel: { result: Schema.Result(VercelCostSourceRemoved, LoadFailure) }, ...ChartEnvMessages, }) export type Message = typeof Message.Type diff --git a/apps/web/src/page/cost/model.ts b/apps/web/src/page/cost/model.ts index 07c172eac8..0c14b12efa 100644 --- a/apps/web/src/page/cost/model.ts +++ b/apps/web/src/page/cost/model.ts @@ -3,6 +3,7 @@ import { CostSourcesResponse, CostTimeseriesResponse, CostTotalsResponse, + VercelCostStatus, } from "@maple/domain/http" import * as Policy from "@maple/domain/policy" import { Schema } from "effect" @@ -18,10 +19,14 @@ import * as TimeRangePicker from "../kit/time-range-picker" export type Search = typeof CostSearch.Type -/** The add-source dialog's two faces: the providers, then the chosen one's form. */ -export const AddStep = Schema.Literals(["pick", "openrouter"]) +/** The add-source dialog's faces: the providers, then the chosen one's step. */ +export const AddStep = Schema.Literals(["pick", "openrouter", "vercel"]) export type AddStep = typeof AddStep.Type +/** The providers whose source the page itself removes; an AWS source goes with its connection. */ +export const RemovableProvider = Schema.Literals(["openrouter", "vercel"]) +export type RemovableProvider = typeof RemovableProvider.Type + export const Model = Schema.Struct({ search: CostSearch, window: Schema.NullOr(TimeWindow), @@ -44,7 +49,13 @@ export const Model = Schema.Struct({ keyDraft: Field.Model, /** Why the last save was refused, shown under the field until the key changes. */ keyError: Schema.NullOr(Schema.String), + /** The org's Vercel connection, read when the dialog turns to Vercel. */ + vercel: KeyedSchema(VercelCostStatus, LoadFailure), + /** Why Vercel refused the last add, shown in the step until it is left. */ + vercelError: Schema.NullOr(Schema.String), removeDialog: Dialog.Model, + /** Whose source the remove dialog asks about. */ + removeProvider: RemovableProvider, /** `Settling`: the write answered and the sources are being read again; the list on show is the old one. */ sourceWrite: Schema.Literals(["Idle", "Saving", "Removing", "Settling"]), }) diff --git a/apps/web/src/page/cost/providers.ts b/apps/web/src/page/cost/providers.ts index 83bf9a0025..daf0a1d673 100644 --- a/apps/web/src/page/cost/providers.ts +++ b/apps/web/src/page/cost/providers.ts @@ -14,6 +14,7 @@ export type CostProviderEntry = Readonly<{ }> // Not every cost provider is an integration: OpenRouter supplies cost only, so it is named here. +// Vercel's requests are free, like OpenRouter's. export const COST_PROVIDERS = { aws: { name: PROVIDERS.aws.name, @@ -27,4 +28,10 @@ export const COST_PROVIDERS = { iconClassName: "text-foreground", requestUsd: null, }, + vercel: { + name: PROVIDERS.vercel.name, + icon: PROVIDERS.vercel.icon, + iconClassName: PROVIDERS.vercel.iconClassName, + requestUsd: null, + }, } as const satisfies Record diff --git a/apps/web/src/page/cost/update.ts b/apps/web/src/page/cost/update.ts index bf16c34f56..661458e1be 100644 --- a/apps/web/src/page/cost/update.ts +++ b/apps/web/src/page/cost/update.ts @@ -6,7 +6,7 @@ import * as Chart from "@maple/views/charts/chart" import { chartEnvHandlers } from "@maple/views/charts/chart-env" import { LoadChartRuntime } from "@maple/views/charts/chart-runtime" import * as Keyed from "@maple/views/data/keyed" -import { failureMessage } from "@maple/views/data/load-failure" +import { failureMessage, type LoadFailure } from "@maple/views/data/load-failure" import { requestKey } from "@maple/views/data/request-key" import * as Dialog from "@maple/views/ui/dialog" import * as Field from "@maple/views/ui/field" @@ -20,14 +20,17 @@ import { routeWindowStep, snapModeOf } from "../kit/route-window" import type { PresetValue } from "../kit/time-controls" import * as TimeRangePicker from "../kit/time-range-picker" import { + AddVercelCostSource, type BreakdownInput, CompleteReloadSpin, FetchCostBreakdown, FetchCostSources, FetchCostTimeseries, FetchCostTotals, + FetchVercelCostStatus, ReadCostGrants, RemoveOpenRouterCostKey, + RemoveVercelCostSource, ResolveWindow, SaveOpenRouterCostKey, type TimeseriesInput, @@ -45,7 +48,8 @@ import { withoutFilters, } from "./derive" import { Message } from "./message" -import type { AddStep, Model, Search } from "./model" +import type { AddStep, Model, RemovableProvider, Search } from "./model" +import { COST_PROVIDERS } from "./providers" type Return = PageReturn type Cmd = Command.Command @@ -149,7 +153,10 @@ export const init = (route: { readonly search: Search }, context: PageContext): addStep: "pick", keyDraft: Field.init(), keyError: null, + vercel: Keyed.idle(), + vercelError: null, removeDialog: Dialog.init("cost-remove-source", "alert-dialog"), + removeProvider: "openrouter", sourceWrite: "Idle", } return chain( @@ -183,7 +190,34 @@ const liftRemoveDialog = (model: Model, step: ReturnType): }) /** Nothing typed and no refusal on show: how the dialog opens and what it leaves behind. */ -const clearedKey = (model: Model): Model => ({ ...model, keyDraft: Field.init(), keyError: null }) +const clearedKey = (model: Model): Model => ({ + ...model, + keyDraft: Field.init(), + keyError: null, + vercelError: null, +}) + +/** + * The org's Vercel connection, read again each time the dialog turns to Vercel. A failed read is + * forgotten first, so Try again shows the read in flight and not the old error. + */ +const requestVercelStatus = (model: Model, context: PageContext): Return => + applySteps(model, [ + keyed( + Keyed.forgetFailure(model.vercel), + requestKey(context.orgId, "vercelCostStatus", {}), + "Supersede", + (vercel) => ({ vercel }), + (key) => FetchVercelCostStatus({ key }), + ), + ]) + +/** Whether the connection on show can be added: a team, a token Vercel takes, and no source yet. */ +const canAddVercel = (model: Model): boolean => + Option.exists( + AsyncData.getData(model.vercel.data), + (status) => status.team !== null && !status.authFailed && !status.added, + ) const openAdd = (model: Model, addStep: AddStep): Return => model.sourceWrite !== "Idle" @@ -202,10 +236,10 @@ const withReread = (step: Return, context: PageContext): Return => ) /** - * The list on show without the OpenRouter source, which a removal just deleted: its row is gone + * The list on show without the provider's source, which a removal just deleted: its row is gone * at once, so no refresh, late or failed, leaves actions on a source that no longer exists. */ -const withoutOpenRouterSource = (model: Model): Model => ({ +const withoutSourceOf = (model: Model, provider: RemovableProvider): Model => ({ ...model, sources: { ...model.sources, @@ -213,7 +247,7 @@ const withoutOpenRouterSource = (model: Model): Model => ({ model.sources.data, (response) => new CostSourcesResponse({ - sources: Arr.filter(response.sources, (source) => source.provider !== "openrouter"), + sources: Arr.filter(response.sources, (source) => source.provider !== provider), }), ), }, @@ -225,6 +259,32 @@ const hasOpenRouterSource = (model: Model): boolean => Arr.some(sources, (source) => source.provider === "openrouter"), ) +/** A removal's answer: the dialog closes either way, and a removed source leaves the page at once. */ +const removed = ( + model: Model, + provider: RemovableProvider, + failure: Option.Option, + context: PageContext, +): Return => { + const closed = liftRemoveDialog( + Option.isNone(failure) + ? withoutSourceOf({ ...model, sourceWrite: "Settling" }, provider) + : { ...model, sourceWrite: "Idle" }, + Dialog.close(model.removeDialog), + ) + return Option.match(failure, { + onSome: (reason): Return => ({ + ...closed, + outMessage: toast("error", "Couldn't remove the source", failureMessage(reason)), + }), + onNone: (): Return => + withReread( + { ...closed, outMessage: toast("success", `${COST_PROVIDERS[provider].name} removed`) }, + context, + ), + }) +} + export const routeChanged = ( model: Model, route: { readonly search: Search }, @@ -344,6 +404,44 @@ export const update = (model: Model, message: Message, context: PageContext): Re CompletedReadGrants: ({ grants }) => ({ model: { ...model, grants } }), ClickedAddSource: () => openAdd(model, "pick"), ClickedAddOpenRouter: () => ({ model: { ...clearedKey(model), addStep: "openrouter" } }), + ClickedAddVercel: () => + model.sourceWrite !== "Idle" + ? { model } + : requestVercelStatus({ ...clearedKey(model), addStep: "vercel" }, context), + CompletedLoadVercelStatus: ({ key, result }) => ({ + model: { ...model, vercel: Keyed.accept(model.vercel, key, result) }, + }), + ClickedConfirmAddVercel: () => + model.sourceWrite !== "Idle" || !canAddVercel(model) + ? { model } + : { + model: { ...model, sourceWrite: "Saving", vercelError: null }, + commands: [AddVercelCostSource()], + }, + // A refused add keeps the dialog open with Vercel's reason under the team. + CompletedAddVercel: ({ result }) => + Result.match(result, { + onFailure: (failure): Return => ({ + model: { ...model, sourceWrite: "Idle", vercelError: failureMessage(failure) }, + }), + onSuccess: (): Return => { + const closed = liftAddDialog( + clearedKey({ ...model, sourceWrite: "Settling" }), + Dialog.close(model.addDialog), + ) + return withReread( + { + ...closed, + outMessage: toast( + "success", + "Vercel added", + "The first sync runs within a few minutes.", + ), + }, + context, + ) + }, + }), ClickedBackToProviders: () => model.sourceWrite === "Idle" ? { model: { ...clearedKey(model), addStep: "pick" } } : { model }, ClickedReplaceKey: () => openAdd(model, "openrouter"), @@ -400,10 +498,10 @@ export const update = (model: Model, message: Message, context: PageContext): Re return withReread({ ...closed, outMessage }, context) }, }), - ClickedRemoveSource: () => + ClickedRemoveSource: ({ provider }) => model.sourceWrite !== "Idle" ? { model } - : liftRemoveDialog(model, Dialog.open(model.removeDialog)), + : liftRemoveDialog({ ...model, removeProvider: provider }, Dialog.open(model.removeDialog)), GotRemoveDialogMessage: ({ message: inner }) => model.sourceWrite === "Removing" ? { model } @@ -411,22 +509,15 @@ export const update = (model: Model, message: Message, context: PageContext): Re ClickedConfirmRemove: () => model.sourceWrite !== "Idle" ? { model } - : { model: { ...model, sourceWrite: "Removing" }, commands: [RemoveOpenRouterCostKey()] }, - CompletedRemoveKey: ({ result }) => { - const closed = liftRemoveDialog( - Result.isSuccess(result) - ? withoutOpenRouterSource({ ...model, sourceWrite: "Settling" }) - : { ...model, sourceWrite: "Idle" }, - Dialog.close(model.removeDialog), - ) - return Result.match(result, { - onFailure: (failure): Return => ({ - ...closed, - outMessage: toast("error", "Couldn't remove the source", failureMessage(failure)), - }), - onSuccess: (): Return => - withReread({ ...closed, outMessage: toast("success", "OpenRouter removed") }, context), - }) - }, + : { + model: { ...model, sourceWrite: "Removing" }, + commands: [ + model.removeProvider === "vercel" + ? RemoveVercelCostSource() + : RemoveOpenRouterCostKey(), + ], + }, + CompletedRemoveKey: ({ result }) => removed(model, "openrouter", Result.getFailure(result), context), + CompletedRemoveVercel: ({ result }) => removed(model, "vercel", Result.getFailure(result), context), ...chartEnvHandlers(model), }) diff --git a/apps/web/src/page/cost/view-add-source.ts b/apps/web/src/page/cost/view-add-source.ts index 42b233f638..0a1806cfb9 100644 --- a/apps/web/src/page/cost/view-add-source.ts +++ b/apps/web/src/page/cost/view-add-source.ts @@ -16,8 +16,9 @@ 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 } from "./model" +import type { Model, RemovableProvider } from "./model" import { COST_PROVIDERS, type CostProviderEntry } from "./providers" +import { vercelStep } from "./view-add-vercel" type H = HtmlBuilder @@ -28,15 +29,17 @@ export const AWS_INTEGRATION_HREF = hrefOf(AppRoute.Integrations({ search: { int const OPENROUTER_KEYS_URL = "https://openrouter.ai/settings/management-keys" -/** Whether the org has its OpenRouter source, which there is at most one of. */ -const hasOpenRouterSource = (sources: ReadonlyArray): boolean => - Arr.some(sources, (source) => source.provider === "openrouter") +/** Whether the org has its source of a provider it can have one of at most. */ +const hasSourceOf = ( + sources: ReadonlyArray, + provider: CostSourceSummary["provider"], +): boolean => Arr.some(sources, (source) => source.provider === provider) const mark = (h: H, provider: CostProviderEntry): Html => icon(h, provider.icon, { size: 18, className: cn("shrink-0", provider.iconClassName) }) -/** The providers to add: AWS goes to its connect flow, OpenRouter to the key form. */ -const providers = (h: H, hasOpenRouter: boolean): Html => +/** The providers to add: AWS goes to its connect flow, OpenRouter to the key form, Vercel to its team. */ +const providers = (h: H, hasOpenRouter: boolean, hasVercel: boolean): Html => vstack(h, { gap: "3" }, [ vstack(h, { gap: "0" }, [ listRow(h, { @@ -49,21 +52,37 @@ const providers = (h: H, hasOpenRouter: boolean): Html => }), 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 }), }), + hasVercel + ? listRow(h, { + leading: mark(h, COST_PROVIDERS.vercel), + title: COST_PROVIDERS.vercel.name, + meta: "Remove it in the Sources panel", + trailing: badge(h, { variant: "muted", size: "sm" }, ["Already added"]), + }) + : listRow(h, { + onClick: Message.ClickedAddVercel(), + leading: mark(h, COST_PROVIDERS.vercel), + title: COST_PROVIDERS.vercel.name, + meta: "Daily charges by product, project and region", + trailing: icon(h, "ChevronRightIcon", { size: 14 }), + }), ]), typography(h, { size: "xs", tone: "muted", as: "p", className: "font-prose" }, [ - "AWS bills USD\u00a00.01 for each request Maple makes, a few dollars a month per account. OpenRouter requests are free. ", + "AWS bills USD\u00a00.01 for each request Maple makes, a few dollars a month per account. OpenRouter and Vercel requests are free. ", textLink(h, { href: AWS_INTEGRATION_HREF, tone: "inherit", className: "underline" }, [ "Turn on Cost for an AWS account that is connected", ]), @@ -116,15 +135,14 @@ const keyField = (h: H, model: Model): Html => { } /** - * Add a source, or replace the OpenRouter key: the providers first, then OpenRouter's key form. - * Opened on the form for a replace, which has no providers to go back to. + * Add a source, or replace the OpenRouter key: the providers first, then the chosen one's step. + * Opened on the key form for a replace, which has no providers to go back to. */ export const addSourceDialog = (h: H, model: Model, sources: ReadonlyArray): Html => { const id = model.addDialog.id - const isReplacing = hasOpenRouterSource(sources) + const isReplacing = hasSourceOf(sources, "openrouter") const isSaving = model.sourceWrite === "Saving" const canSubmit = !isSaving && Str.trim(model.keyDraft.value) !== "" - const onPick = model.addStep === "pick" return h.submodel({ slotId: "cost-add-source", model: model.addDialog, @@ -135,86 +153,104 @@ export const addSourceDialog = (h: H, model: Model, sources: ReadonlyArray portal ?? h.empty, toContent: (close) => - onPick - ? [ - DialogView.dialogHeader(h, "dialog", [ - DialogView.dialogTitle(h, "dialog", id, "Add a cost source"), - DialogView.dialogDescription( - h, - "dialog", - id, - "Maple reads what the source bills each day and keeps it next to your telemetry.", - ), - ]), - DialogView.dialogPanel(h, "dialog", id, [providers(h, isReplacing)]), - DialogView.dialogFooter(h, "dialog", [ - button(h, { variant: "outline", attributes: [...close] }, ["Close"]), - ]), - ] - : [ - formDialogForm(h, canSubmit ? Message.SubmittedKey() : undefined, [ + model.addStep === "vercel" + ? vercelStep(h, model, id) + : model.addStep === "pick" + ? [ DialogView.dialogHeader(h, "dialog", [ - DialogView.dialogTitle( - h, - "dialog", - id, - isReplacing ? "Replace the OpenRouter key" : "Add OpenRouter", - ), + DialogView.dialogTitle(h, "dialog", id, "Add a cost source"), DialogView.dialogDescription( h, "dialog", id, - isReplacing - ? "The source and its history stay. Use a key from the same OpenRouter account." - : "Maple reads the last four weeks at once, then every day from here on.", + "Maple reads what the source bills each day and keeps it next to your telemetry.", ), ]), - DialogView.dialogPanel(h, "dialog", id, [keyField(h, model)]), + DialogView.dialogPanel(h, "dialog", id, [ + providers(h, isReplacing, hasSourceOf(sources, "vercel")), + ]), DialogView.dialogFooter(h, "dialog", [ - isReplacing - ? button( - h, - { - variant: "outline", - isDisabled: isSaving, - attributes: isSaving ? [] : [...close], - }, - ["Cancel"], - ) - : button( - h, - { - variant: "outline", - isDisabled: isSaving, - attributes: [h.OnClick(Message.ClickedBackToProviders())], - }, - ["Back"], - ), - button( - h, - { - type: "submit", - isLoading: isSaving, - isDisabled: !canSubmit, - }, - [isReplacing ? "Replace key" : "Add source"], - ), + button(h, { variant: "outline", attributes: [...close] }, ["Close"]), + ]), + ] + : [ + formDialogForm(h, canSubmit ? Message.SubmittedKey() : undefined, [ + DialogView.dialogHeader(h, "dialog", [ + DialogView.dialogTitle( + h, + "dialog", + id, + isReplacing ? "Replace the OpenRouter key" : "Add OpenRouter", + ), + DialogView.dialogDescription( + h, + "dialog", + id, + isReplacing + ? "The source and its history stay. Use a key from the same OpenRouter account." + : "Maple reads the last four weeks at once, then every day from here on.", + ), + ]), + DialogView.dialogPanel(h, "dialog", id, [keyField(h, model)]), + DialogView.dialogFooter(h, "dialog", [ + isReplacing + ? button( + h, + { + variant: "outline", + isDisabled: isSaving, + attributes: isSaving ? [] : [...close], + }, + ["Cancel"], + ) + : button( + h, + { + variant: "outline", + isDisabled: isSaving, + attributes: [ + h.OnClick(Message.ClickedBackToProviders()), + ], + }, + ["Back"], + ), + button( + h, + { + type: "submit", + isLoading: isSaving, + isDisabled: !canSubmit, + }, + [isReplacing ? "Replace key" : "Add source"], + ), + ]), ]), - ]), - ], + ], }, }) } -/** Removing the OpenRouter source: what leaves the page, and what adding it again brings back. */ +/** What removing each provider's source takes away, and what adding it again brings back. */ +const REMOVAL = { + openrouter: { + title: "Remove the OpenRouter source?", + description: + "Maple deletes the key and stops reading the account's spend, and its cost leaves this page. Adding OpenRouter again brings back the last four weeks only.", + }, + vercel: { + title: "Remove the Vercel source?", + description: + "Maple stops reading the team's charges, and its cost leaves this page. The Vercel connection stays as it is. Adding Vercel again reads the last eleven months again.", + }, +} as const satisfies Record> + +/** Removing a source the page added: what leaves the page, and what adding it again brings back. */ export const removeSourceDialog = (h: H, model: Model): Html => confirmDialog(h, { slotId: "cost-remove-source", model: model.removeDialog, toParentMessage: (message) => Message.GotRemoveDialogMessage({ message }), - title: "Remove the OpenRouter source?", - description: - "Maple deletes the key and stops reading the account's spend, and its cost leaves this page. Adding OpenRouter again brings back the last four weeks only.", + ...REMOVAL[model.removeProvider], confirmLabel: "Remove source", onConfirm: Message.ClickedConfirmRemove(), isPending: model.sourceWrite === "Removing", diff --git a/apps/web/src/page/cost/view-add-vercel.ts b/apps/web/src/page/cost/view-add-vercel.ts new file mode 100644 index 0000000000..46201e86f4 --- /dev/null +++ b/apps/web/src/page/cost/view-add-vercel.ts @@ -0,0 +1,149 @@ +import type { VercelCostStatus } from "@maple/domain/http" +import { cn } from "@maple/ui/lib/utils" +import { AsyncData } from "foldkit" +import type { Attribute, 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 { typography } from "@maple/views/ui/typography" +import { AppRoute, hrefOf } from "../../route" +import { Message } from "./message" +import type { Model } from "./model" +import { COST_PROVIDERS } 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 and its token are changed. */ +const VERCEL_INTEGRATION_HREF = hrefOf(AppRoute.Integrations({ search: { integration: "vercel" } })) + +const note = (h: H, text: string): Html => + typography(h, { size: "sm", tone: "muted", as: "p", className: "font-prose" }, [text]) + +/** The connected team as the row the step is about. */ +const teamRow = (h: H, team: NonNullable, isAdded: boolean): Html => + listRow(h, { + leading: icon(h, COST_PROVIDERS.vercel.icon, { + size: 18, + className: cn("shrink-0", COST_PROVIDERS.vercel.iconClassName), + }), + 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, and whether the team can be added. */ +const connection = (h: H, model: Model, status: VercelCostStatus): ReadonlyArray => { + if (status.team === null) { + return [note(h, "No Vercel team is connected yet. Connect one, then add it here as a cost source.")] + } + if (status.added) return [teamRow(h, status.team, true)] + if (status.authFailed) { + return [ + teamRow(h, status.team, false), + alert(h, { variant: "warn", size: "sm" }, [ + alertDescription(h, [ + "Vercel rejected this team's token. Connect the team again with a new token, then add it here.", + ]), + ]), + ] + } + return [ + teamRow(h, status.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 or Billing role on the team.", + ]), + ...(model.vercelError === null + ? [] + : [alert(h, { variant: "crit", size: "sm" }, [alertDescription(h, [model.vercelError])])]), + ] +} + +/** The step's primary action, by what the connection allows: add it, connect one, or fix its token. */ +const primary = (h: H, model: Model): ReadonlyArray => + AsyncData.matchData(model.vercel.data, { + onEmpty: () => [], + onFailure: () => [], + onData: (status) => + status.team === null + ? [linkButton(h, { href: CONNECT_VERCEL_HREF }, ["Connect Vercel"])] + : status.added + ? [] + : status.authFailed + ? [linkButton(h, { href: VERCEL_INTEGRATION_HREF }, ["Open the Vercel connection"])] + : [ + 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 isSaving = model.sourceWrite === "Saving" + const back: ReadonlyArray> = [h.OnClick(Message.ClickedBackToProviders())] + 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, status), + }), + ), + ]), + DialogView.dialogFooter(h, "dialog", [ + button(h, { variant: "outline", isDisabled: isSaving, attributes: back }, ["Back"]), + ...primary(h, model), + ]), + ] +} diff --git a/apps/web/src/page/cost/view-sources.ts b/apps/web/src/page/cost/view-sources.ts index 4eff5e349d..a526cd7bbf 100644 --- a/apps/web/src/page/cost/view-sources.ts +++ b/apps/web/src/page/cost/view-sources.ts @@ -30,32 +30,30 @@ const STATUS = { Readonly<{ label: string; variant: "ok" | "info" | "muted" | "crit" }> > -/** What a row offers: an AWS source is its connection's, an OpenRouter one is its key. */ -const actions = (h: H, source: CostSourceSummary, isBusy: boolean): ReadonlyArray => - source.provider === "openrouter" - ? [ - button( - h, - { - variant: "ghost", - size: "sm", - isDisabled: isBusy, - attributes: [h.OnClick(Message.ClickedReplaceKey())], - }, - ["Replace key"], - ), - button( - h, - { - variant: "ghost", - size: "sm", - isDisabled: isBusy, - attributes: [h.OnClick(Message.ClickedRemoveSource())], - }, - ["Remove"], - ), +const rowButton = (h: H, label: string, onClick: Message, isBusy: boolean): Html => + button(h, { variant: "ghost", size: "sm", isDisabled: isBusy, attributes: [h.OnClick(onClick)] }, [label]) + +/** + * What a row offers: an AWS source is its connection's, an OpenRouter one is its key, and a + * Vercel one is removed here while its connection stays. + */ +const actions = (h: H, source: CostSourceSummary, isBusy: boolean): ReadonlyArray => { + switch (source.provider) { + case "openrouter": + return [ + rowButton(h, "Replace key", Message.ClickedReplaceKey(), isBusy), + rowButton(h, "Remove", Message.ClickedRemoveSource({ provider: "openrouter" }), isBusy), + ] + case "vercel": + return [rowButton(h, "Remove", Message.ClickedRemoveSource({ provider: "vercel" }), isBusy)] + case "aws": + return [ + linkButton(h, { variant: "ghost", size: "sm", href: AWS_INTEGRATION_HREF }, [ + "Manage connection", + ]), ] - : [linkButton(h, { variant: "ghost", size: "sm", href: AWS_INTEGRATION_HREF }, ["Manage connection"])] + } +} /** What the provider billed the account for Maple's requests this month; nothing where requests are free. */ const requestsFact = (source: CostSourceSummary): ReadonlyArray => { diff --git a/apps/web/src/page/cost/view.ts b/apps/web/src/page/cost/view.ts index 4479537619..93a12f352b 100644 --- a/apps/web/src/page/cost/view.ts +++ b/apps/web/src/page/cost/view.ts @@ -1,3 +1,4 @@ +import { CostProvider } from "@maple/domain/cost" import type { CostSourceSummary } from "@maple/domain/http" import { Option, Array as Arr } from "effect" import { AsyncData, Submodel } from "foldkit" @@ -38,6 +39,7 @@ import { activeFilters, DIMENSIONS, groupByOf, hasSynced, keyLabel } from "./der import { Message } from "./message" import { LONG_RANGE_MAX_SECONDS } from "../../route/session-time-range" import { canManageSources, cannotManageSources, type Model } from "./model" +import { COST_PROVIDERS } from "./providers" import { DEFAULT_PRESET } from "./update" import { addSourceDialog, removeSourceDialog } from "./view-add-source" import { dailyChart } from "./view-chart" @@ -63,7 +65,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 usage type, an OpenRouter account by model.", + "Add a source and Maple reads what it bills each day: an AWS account by service, region and usage type, an OpenRouter account by model, a Vercel team by product and project.", ]), // A member's save would be refused: say who can, and offer no form. ...(!options.cannotManage @@ -106,17 +108,27 @@ const onRamp = ( /** * What the first syncs bring in, by the providers that have no cost yet (waiting or failing - * alike): AWS keeps over a year, OpenRouter four weeks. + * alike): AWS and Vercel keep about a year, OpenRouter four weeks. */ const firstSyncScope = (sources: ReadonlyArray): string => { - const unsynced = Arr.filter(sources, (source) => source.watermarkDate === null) - const hasOpenRouter = Arr.some(unsynced, (source) => source.provider === "openrouter") - const hasOther = Arr.some(unsynced, (source) => source.provider !== "openrouter") - return hasOpenRouter && hasOther - ? "AWS brings in this month and the last, and older months fill in after that. OpenRouter brings in its last four weeks." - : hasOpenRouter - ? "It brings in the last four weeks, which is all OpenRouter keeps." - : "It brings in this month and the last, and older months fill in after that." + const unsynced = Arr.dedupe( + Arr.map( + Arr.filter(sources, (source) => source.watermarkDate === null), + (source) => source.provider, + ), + ) + const hasOpenRouter = Arr.contains(unsynced, "openrouter") + // In the order the page names providers everywhere else. + const others = Arr.map( + Arr.filter( + CostProvider.literals, + (provider) => provider !== "openrouter" && Arr.contains(unsynced, provider), + ), + (provider) => COST_PROVIDERS[provider].name, + ) + if (!hasOpenRouter) return "It brings in this month and the last, and older months fill in after that." + if (others.length === 0) return "It brings in the last four weeks, which is all OpenRouter keeps." + return `${Arr.join(others, " and ")} ${others.length === 1 ? "brings" : "bring"} in this month and the last, and older months fill in after that. OpenRouter brings in its last four weeks.` } /** Sources exist and none has cost yet: what the reader is waiting for. */ diff --git a/apps/web/src/page/settings/kit/audit-log-resource.ts b/apps/web/src/page/settings/kit/audit-log-resource.ts index 5b4fc16e52..4e27cde624 100644 --- a/apps/web/src/page/settings/kit/audit-log-resource.ts +++ b/apps/web/src/page/settings/kit/audit-log-resource.ts @@ -101,6 +101,7 @@ function resourceLink(entry: AuditEntryResource): AuditResourceLink | null { case "ai_provider_key": return { to: "/settings", search: { tab: "ai" } } case "cost_source_key": + case "cost_source": return { to: "/cost" } case "warehouse_settings": return { to: "/settings", search: { tab: "data-platform" } } From 99092695e6dc8d807f138c6d18511299378905e1 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 00:04:14 +0100 Subject: [PATCH 3/8] fix(cost): group cost by resource, release a held Vercel source on a new token, read the connection anew in the dialog The dialog says Vercel is read per project, but the page could not show it: a row's project is its resource, and the page had no such grouping. Cost can now be grouped and filtered by resource. A new token saved for the team a source reads makes that source due again at once, and takes the claim of a pull still in flight on the old token. Before, the source kept the old token's error and its hold of hours. Adding the source reads yesterday and today, the days every daily pull ends on, so the check asks what the sync will ask. A secret key Maple cannot use or a connection deleted during the check no longer reads as a token Vercel refused. A 400 keeps Vercel's own message, a pull's timeout grows with its window, and an add that changed nothing leaves no audit entry. The Vercel step drops the answer on show each time it opens, so it never offers an add on a connection read before it was left and a failed read says so. A refused add links to the connection, where the token is replaced. In the cost lab a state without a Vercel source no longer says it has one. --- .../src/routes/internal/vercel-cost.http.ts | 11 +- apps/web/src/lab/cost-fixture.test.ts | 6 + apps/web/src/lab/cost-fixture.ts | 52 +++++- apps/web/src/lab/cost-lab.ts | 9 +- apps/web/src/page/cost/cost.scene.test.ts | 84 +++++++++- apps/web/src/page/cost/derive.ts | 2 + apps/web/src/page/cost/model.ts | 11 ++ apps/web/src/page/cost/update.ts | 70 ++++---- apps/web/src/page/cost/view-add-source.ts | 83 ++++++---- apps/web/src/page/cost/view-add-vercel.ts | 156 +++++++++++------- apps/web/src/route/search.ts | 2 + docs/cloud-cost-plan.md | 14 +- .../cost/VercelCostSourceService.test.ts | 30 +++- .../services/cost/VercelCostSourceService.ts | 75 +++++---- .../vercel/VercelCostProvider.test.ts | 12 +- .../providers/vercel/VercelCostProvider.ts | 30 ++-- .../cost/providers/vercel/fake-vercel.ts | 7 +- .../cost/providers/vercel/normalize.test.ts | 12 +- .../cost/providers/vercel/normalize.ts | 13 -- .../src/services/cost/vercel-cost-sources.ts | 40 +++-- .../VercelIntegrationService.test.ts | 31 +++- .../integrations/VercelIntegrationService.ts | 9 +- 22 files changed, 515 insertions(+), 244 deletions(-) diff --git a/apps/api/src/routes/internal/vercel-cost.http.ts b/apps/api/src/routes/internal/vercel-cost.http.ts index 2d81d53515..158b497d11 100644 --- a/apps/api/src/routes/internal/vercel-cost.http.ts +++ b/apps/api/src/routes/internal/vercel-cost.http.ts @@ -37,10 +37,13 @@ export const HttpVercelCostLive = HttpApiBuilder.group(MapleInternalApi, "vercel const tenant = yield* CurrentTenant.Context yield* requireIntegrationsManager(tenant.roles) const added = yield* sources.add(tenant.orgId) - yield* recordHttpAudit("cost_source.added", { - metadata: { provider: "vercel", account: added.team.slug }, - }) - return new VercelCostSourceAdded(added) + // Adding what is a source already changed nothing, so it leaves no entry. + if (added.created) { + yield* recordHttpAudit("cost_source.added", { + metadata: { provider: "vercel", account: added.team.slug }, + }) + } + return new VercelCostSourceAdded({ team: added.team }) }).pipe(Effect.withSpan("HttpVercelCost.add")), ) .handle("remove", () => diff --git a/apps/web/src/lab/cost-fixture.test.ts b/apps/web/src/lab/cost-fixture.test.ts index eaee669ea2..0149de7e1b 100644 --- a/apps/web/src/lab/cost-fixture.test.ts +++ b/apps/web/src/lab/cost-fixture.test.ts @@ -44,4 +44,10 @@ describe("cost lab fixture", () => { ).toEqual(["aws", "openrouter"]) expect(labTotals({ days: 30, filters: { provider: ["vercel"] }, without: ["vercel"] }).cost).toBe(0) }) + + test("grouped by resource, a Vercel team's cost splits by project and the rest has none", () => { + expect( + Arr.map(labBreakdown({ ...window, groupBy: "resource", sortBy: "cost" }).rows, (row) => row.key), + ).toEqual(["", "example-web", "example-docs"]) + }) }) diff --git a/apps/web/src/lab/cost-fixture.ts b/apps/web/src/lab/cost-fixture.ts index b5892f616b..2ce5d0999e 100644 --- a/apps/web/src/lab/cost-fixture.ts +++ b/apps/web/src/lab/cost-fixture.ts @@ -35,6 +35,7 @@ const line = ( service_category, region, usage_type, + resource: "", account: LAB_ACCOUNT, daily, drift, @@ -51,6 +52,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, @@ -64,6 +66,7 @@ const productLine = ( category: string, region: string, sku: string, + project: string, daily: number, drift: number, ): Line => ({ @@ -72,6 +75,7 @@ const productLine = ( service_category: category, region, usage_type: sku, + resource: project, account: LAB_VERCEL_TEAM.slug, daily, drift, @@ -100,12 +104,36 @@ const LINES: ReadonlyArray = [ modelLine("example/large-model", "Example Cloud", 6.2, 0.1), modelLine("example/fast-model", "Example Inference", 12.9, -0.15), modelLine("example/embedding-model", "Example Labs", 1.8, 0.0), - productLine("Example Function Duration", "Compute", "east-1", "function-duration", 9.4, 0.2), - productLine("Example Function Duration", "Compute", "central-1", "function-duration", 3.1, 0.05), - productLine("Example Data Transfer", "Networking", "global", "data-transfer", 6.7, 0.12), - productLine("Example Image Optimization", "Web", "global", "image-optimization", 2.2, -0.08), - // The plan's monthly fee, as Vercel spreads it over the days. - productLine("Example Pro Plan", "Other", "global", "", 0.67, 0.0), + productLine( + "Example Function Duration", + "Compute", + "east-1", + "function-duration", + "example-web", + 9.4, + 0.2, + ), + productLine( + "Example Function Duration", + "Compute", + "central-1", + "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`. */ @@ -121,7 +149,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]) @@ -286,7 +322,7 @@ const vercelSource = source({ /** The sources the lab's reader can add and remove on the page, as they are once synced. */ const ADDABLE = { openrouter: openRouterSource, vercel: vercelSource } as const -export type LabAddable = keyof typeof ADDABLE +type LabAddable = keyof typeof ADDABLE /** * `sources` after the lab's reader added a provider's source (true: it waits for its first sync) diff --git a/apps/web/src/lab/cost-lab.ts b/apps/web/src/lab/cost-lab.ts index b15ce87858..fc23d91346 100644 --- a/apps/web/src/lab/cost-lab.ts +++ b/apps/web/src/lab/cost-lab.ts @@ -62,6 +62,7 @@ export const LabState = Schema.Literals([ "empty-window", "loading", "by-provider", + "by-resource", "add-source", "add-openrouter", "key-refused", @@ -111,7 +112,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 => @@ -181,7 +184,9 @@ const vercelStatusOf = (world: World): VercelCostStatus => new VercelCostStatus({ team: world.state === "vercel-not-connected" ? null : LAB_VERCEL_TEAM, authFailed: false, - added: world.vercel ?? !startsEmpty(world.state), + added: + world.vercel ?? + Arr.some(sourcesFor(world.state).sources, (source) => source.provider === "vercel"), }) /** The days a preset spans, for the fixture's window; a custom range reads as the default. */ diff --git a/apps/web/src/page/cost/cost.scene.test.ts b/apps/web/src/page/cost/cost.scene.test.ts index 373abcc1cd..85ad98bf85 100644 --- a/apps/web/src/page/cost/cost.scene.test.ts +++ b/apps/web/src/page/cost/cost.scene.test.ts @@ -564,7 +564,12 @@ describe("cost page", () => { given(refused.model), resolveTheme, dialogMounted, - expectView(role("alert")).toHaveText(reason), + 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(), ) @@ -572,6 +577,59 @@ describe("cost page", () => { expect([back.addStep, back.vercelError]).toEqual(["pick", null]) }) + 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( @@ -611,14 +669,16 @@ describe("cost page", () => { backfillDate: "2025-12-01", requestsThisMonth: 0, }) - const model = answered(entered({}, [vercel]).model) + const model = answered(entered({}, [source(), vercel]).model) scene( { update: pageUpdate, view }, given(model), resolveTheme, expectView(text("Vercel")).toExist(), expectView(text("acme")).toExist(), - expectView(text(/provider requests/)).not.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: there is no key to replace here. expectView(role("button", { name: "Replace key" })).not.toExist(), expectView(role("button", { name: "Remove" })).toExist(), @@ -670,15 +730,23 @@ describe("cost page", () => { expect(removed.outMessage).toEqual( PageOutMessage.RequestedToast({ type: "success", title: "Vercel removed", description: "" }), ) - // The row is gone at once, and the sources are read again for what is left. - expect(names(removed.commands)).toContain("FetchCostSources") + // 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 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("No cost source yet")).toExist(), + expectView(text("123456789012")).toExist(), ) }) @@ -790,6 +858,10 @@ describe("cost page", () => { expect(keyLabel("provider", "openrouter")).toBe("OpenRouter") expect(keyLabel("provider", "aws")).toBe("AWS") expect(keyLabel("provider", "vercel")).toBe("Vercel") + // A Vercel project is the row's resource; a charge of no project says so. + expect(keyLabel("resource", "web")).toBe("web") + expect(keyLabel("resource", "")).toBe("No resource") + expect(requestFilters({ resource: ["web"] })).toEqual({ resource: ["web"] }) // A provider this build does not know still shows, as the rows name it. expect(keyLabel("provider", "somecloud")).toBe("somecloud") expect(requestFilters({ provider: ["openrouter"] })).toEqual({ provider: ["openrouter"] }) diff --git a/apps/web/src/page/cost/derive.ts b/apps/web/src/page/cost/derive.ts index 7a4c82c6e5..c45ac40406 100644 --- a/apps/web/src/page/cost/derive.ts +++ b/apps/web/src/page/cost/derive.ts @@ -28,6 +28,8 @@ export const DIMENSIONS = { service_category: { label: "Category", noun: "category", empty: "Uncategorized" }, region: { label: "Region", noun: "region", empty: "No region" }, usage_type: { label: "Usage type", noun: "usage type", empty: "No usage type" }, + // What a charge is billed for, where the provider says: a Vercel project. + resource: { label: "Resource", noun: "resource", empty: "No resource" }, // The account the connection reads, which for a management account bills its members' cost too. account: { label: "Connected account", noun: "connected account", empty: "No account" }, } as const satisfies Record> diff --git a/apps/web/src/page/cost/model.ts b/apps/web/src/page/cost/model.ts index 0c14b12efa..d102b9092b 100644 --- a/apps/web/src/page/cost/model.ts +++ b/apps/web/src/page/cost/model.ts @@ -4,6 +4,7 @@ import { CostTimeseriesResponse, CostTotalsResponse, VercelCostStatus, + type VercelCostTeam, } from "@maple/domain/http" import * as Policy from "@maple/domain/policy" import { Schema } from "effect" @@ -27,6 +28,16 @@ export type AddStep = typeof AddStep.Type export const RemovableProvider = Schema.Literals(["openrouter", "vercel"]) export type RemovableProvider = typeof RemovableProvider.Type +/** What the Vercel step can do with the connection it read: only a `Ready` team can be added. */ +export type VercelStep = + | Readonly<{ _tag: "NotConnected" }> + | Readonly<{ _tag: "Added" | "TokenRejected" | "Ready"; team: VercelCostTeam }> + +export const vercelStepOf = (status: VercelCostStatus): VercelStep => + status.team === null + ? { _tag: "NotConnected" } + : { _tag: status.added ? "Added" : status.authFailed ? "TokenRejected" : "Ready", team: status.team } + export const Model = Schema.Struct({ search: CostSearch, window: Schema.NullOr(TimeWindow), diff --git a/apps/web/src/page/cost/update.ts b/apps/web/src/page/cost/update.ts index 661458e1be..054e98832f 100644 --- a/apps/web/src/page/cost/update.ts +++ b/apps/web/src/page/cost/update.ts @@ -1,4 +1,4 @@ -import { CostSourcesResponse, OPENROUTER_KEY_MAX_LENGTH } from "@maple/domain/http" +import { CostSourcesResponse, OPENROUTER_KEY_MAX_LENGTH, type VercelCostStatus } from "@maple/domain/http" import * as Policy from "@maple/domain/policy" import { Array as Arr, Option, Result, String as Str } from "effect" import { AsyncData, Command } from "foldkit" @@ -48,7 +48,7 @@ import { withoutFilters, } from "./derive" import { Message } from "./message" -import type { AddStep, Model, RemovableProvider, Search } from "./model" +import { type AddStep, type Model, type RemovableProvider, type Search, vercelStepOf } from "./model" import { COST_PROVIDERS } from "./providers" type Return = PageReturn @@ -198,13 +198,14 @@ const clearedKey = (model: Model): Model => ({ }) /** - * The org's Vercel connection, read again each time the dialog turns to Vercel. A failed read is - * forgotten first, so Try again shows the read in flight and not the old error. + * 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. The key stays, so an older read in flight cannot answer. */ const requestVercelStatus = (model: Model, context: PageContext): Return => applySteps(model, [ keyed( - Keyed.forgetFailure(model.vercel), + { ...Keyed.idle(), key: model.vercel.key }, requestKey(context.orgId, "vercelCostStatus", {}), "Supersede", (vercel) => ({ vercel }), @@ -214,10 +215,7 @@ const requestVercelStatus = (model: Model, context: PageContext): Return => /** Whether the connection on show can be added: a team, a token Vercel takes, and no source yet. */ const canAddVercel = (model: Model): boolean => - Option.exists( - AsyncData.getData(model.vercel.data), - (status) => status.team !== null && !status.authFailed && !status.added, - ) + Option.exists(AsyncData.getData(model.vercel.data), (status) => vercelStepOf(status)._tag === "Ready") const openAdd = (model: Model, addStep: AddStep): Return => model.sourceWrite !== "Idle" @@ -285,6 +283,19 @@ const removed = ( }) } +/** A source was added or its key replaced: the dialog closes, and the page reads what it now has. */ +const added = (model: Model, title: string, description: string, context: PageContext): Return => + withReread( + { + ...liftAddDialog( + clearedKey({ ...model, sourceWrite: "Settling" }), + Dialog.close(model.addDialog), + ), + outMessage: toast("success", title, description), + }, + context, + ) + export const routeChanged = ( model: Model, route: { readonly search: Search }, @@ -424,23 +435,8 @@ export const update = (model: Model, message: Message, context: PageContext): Re onFailure: (failure): Return => ({ model: { ...model, sourceWrite: "Idle", vercelError: failureMessage(failure) }, }), - onSuccess: (): Return => { - const closed = liftAddDialog( - clearedKey({ ...model, sourceWrite: "Settling" }), - Dialog.close(model.addDialog), - ) - return withReread( - { - ...closed, - outMessage: toast( - "success", - "Vercel added", - "The first sync runs within a few minutes.", - ), - }, - context, - ) - }, + onSuccess: (): Return => + added(model, "Vercel added", "The first sync runs within a few minutes.", context), }), ClickedBackToProviders: () => model.sourceWrite === "Idle" ? { model: { ...clearedKey(model), addStep: "pick" } } : { model }, @@ -483,20 +479,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( - clearedKey({ ...model, sourceWrite: "Settling" }), - Dialog.close(model.addDialog), - ) - const outMessage = hasOpenRouterSource(model) - ? toast( - "success", + onSuccess: (): Return => + hasOpenRouterSource(model) + ? added( + model, "OpenRouter key replaced", "The next sync runs within a few minutes.", + context, ) - : toast("success", "OpenRouter added", "The first sync runs within a few minutes.") - return withReread({ ...closed, outMessage }, context) - }, + : added( + model, + "OpenRouter added", + "The first sync runs within a few minutes.", + context, + ), }), ClickedRemoveSource: ({ provider }) => model.sourceWrite !== "Idle" diff --git a/apps/web/src/page/cost/view-add-source.ts b/apps/web/src/page/cost/view-add-source.ts index 0a1806cfb9..4c00923be3 100644 --- a/apps/web/src/page/cost/view-add-source.ts +++ b/apps/web/src/page/cost/view-add-source.ts @@ -1,5 +1,4 @@ import type { CostSourceSummary } from "@maple/domain/http" -import { cn } from "@maple/ui/lib/utils" import { Array as Arr, String as Str } from "effect" import type { Html, HtmlBuilder } from "foldkit/html" import { button } from "@maple/views/ui/button" @@ -18,7 +17,7 @@ import { AppRoute, hrefOf } from "../../route" import { Message } from "./message" import type { Model, RemovableProvider } from "./model" import { COST_PROVIDERS, type CostProviderEntry } from "./providers" -import { vercelStep } from "./view-add-vercel" +import { providerMark, vercelStep } from "./view-add-vercel" type H = HtmlBuilder @@ -35,8 +34,39 @@ const hasSourceOf = ( provider: CostSourceSummary["provider"], ): boolean => Arr.some(sources, (source) => source.provider === provider) -const mark = (h: H, provider: CostProviderEntry): Html => - icon(h, provider.icon, { size: 18, className: cn("shrink-0", provider.iconClassName) }) +/** + * A provider the org has one source of at most: a row to click while it has none, and one that + * says where the source is changed once it does. + */ +const addableRow = ( + h: H, + provider: CostProviderEntry, + options: Readonly<{ + isAdded: boolean + /** What the source brings in. */ + meta: string + /** Where an added source is changed. */ + addedMeta: string + onClick: Message + divided?: boolean + }>, +): Html => + options.isAdded + ? listRow(h, { + divided: options.divided ?? false, + leading: providerMark(h, provider), + title: provider.name, + meta: options.addedMeta, + trailing: badge(h, { variant: "muted", size: "sm" }, ["Already added"]), + }) + : listRow(h, { + divided: options.divided ?? false, + onClick: options.onClick, + leading: providerMark(h, provider), + title: provider.name, + meta: options.meta, + trailing: icon(h, "ChevronRightIcon", { size: 14 }), + }) /** The providers to add: AWS goes to its connect flow, OpenRouter to the key form, Vercel to its team. */ const providers = (h: H, hasOpenRouter: boolean, hasVercel: boolean): Html => @@ -45,41 +75,24 @@ const providers = (h: H, hasOpenRouter: boolean, hasVercel: boolean): Html => 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 }), - }), - hasVercel - ? listRow(h, { - leading: mark(h, COST_PROVIDERS.vercel), - title: COST_PROVIDERS.vercel.name, - meta: "Remove it in the Sources panel", - trailing: badge(h, { variant: "muted", size: "sm" }, ["Already added"]), - }) - : listRow(h, { - onClick: Message.ClickedAddVercel(), - leading: mark(h, COST_PROVIDERS.vercel), - title: COST_PROVIDERS.vercel.name, - meta: "Daily charges by product, project and region", - trailing: icon(h, "ChevronRightIcon", { size: 14 }), - }), + addableRow(h, COST_PROVIDERS.openrouter, { + isAdded: hasOpenRouter, + meta: "Daily spend by model and serving provider", + addedMeta: "Replace its key in the Sources panel", + onClick: Message.ClickedAddOpenRouter(), + divided: true, + }), + addableRow(h, COST_PROVIDERS.vercel, { + isAdded: hasVercel, + meta: "Daily charges by product, project and region", + addedMeta: "Remove it in the Sources panel", + onClick: Message.ClickedAddVercel(), + }), ]), typography(h, { size: "xs", tone: "muted", as: "p", className: "font-prose" }, [ "AWS bills USD\u00a00.01 for each request Maple makes, a few dollars a month per account. OpenRouter and Vercel requests are free. ", diff --git a/apps/web/src/page/cost/view-add-vercel.ts b/apps/web/src/page/cost/view-add-vercel.ts index 46201e86f4..bce08adbc0 100644 --- a/apps/web/src/page/cost/view-add-vercel.ts +++ b/apps/web/src/page/cost/view-add-vercel.ts @@ -1,7 +1,7 @@ -import type { VercelCostStatus } from "@maple/domain/http" +import type { VercelCostTeam } from "@maple/domain/http" import { cn } from "@maple/ui/lib/utils" import { AsyncData } from "foldkit" -import type { Attribute, Html, HtmlBuilder } from "foldkit/html" +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" @@ -11,30 +11,29 @@ 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 } from "./model" -import { COST_PROVIDERS } from "./providers" +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 and its token are changed. */ +/** Where the Vercel connection's token is replaced. */ const VERCEL_INTEGRATION_HREF = hrefOf(AppRoute.Integrations({ search: { integration: "vercel" } })) -const note = (h: H, text: string): Html => - typography(h, { size: "sm", tone: "muted", as: "p", className: "font-prose" }, [text]) +/** 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: NonNullable, isAdded: boolean): Html => +const teamRow = (h: H, team: VercelCostTeam, isAdded: boolean): Html => listRow(h, { - leading: icon(h, COST_PROVIDERS.vercel.icon, { - size: 18, - className: cn("shrink-0", COST_PROVIDERS.vercel.iconClassName), - }), + leading: providerMark(h, COST_PROVIDERS.vercel), title: team.name, meta: team.slug, ...(isAdded @@ -42,64 +41,89 @@ const teamRow = (h: H, team: NonNullable, isAdded: boo : undefined), }) -/** What the step says about the connection it read, and whether the team can be added. */ -const connection = (h: H, model: Model, status: VercelCostStatus): ReadonlyArray => { - if (status.team === null) { - return [note(h, "No Vercel team is connected yet. Connect one, then add it here as a cost source.")] - } - if (status.added) return [teamRow(h, status.team, true)] - if (status.authFailed) { - return [ - teamRow(h, status.team, false), - alert(h, { variant: "warn", size: "sm" }, [ - alertDescription(h, [ - "Vercel rejected this team's token. Connect the team again with a new token, then add it here.", +/** 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)] + 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 or Billing 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"], + ), + ]), + ]), + ]), + ] } - return [ - teamRow(h, status.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 or Billing role on the team.", - ]), - ...(model.vercelError === null - ? [] - : [alert(h, { variant: "crit", size: "sm" }, [alertDescription(h, [model.vercelError])])]), - ] } /** The step's primary action, by what the connection allows: add it, connect one, or fix its token. */ -const primary = (h: H, model: Model): ReadonlyArray => - AsyncData.matchData(model.vercel.data, { - onEmpty: () => [], - onFailure: () => [], - onData: (status) => - status.team === null - ? [linkButton(h, { href: CONNECT_VERCEL_HREF }, ["Connect Vercel"])] - : status.added - ? [] - : status.authFailed - ? [linkButton(h, { href: VERCEL_INTEGRATION_HREF }, ["Open the Vercel connection"])] - : [ - button( - h, - { - isLoading: model.sourceWrite === "Saving", - attributes: [h.OnClick(Message.ClickedConfirmAddVercel())], - }, - ["Add source"], - ), - ], - }) +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 [] + case "TokenRejected": + return [linkButton(h, { href: VERCEL_INTEGRATION_HREF }, ["Open the Vercel connection"])] + 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 isSaving = model.sourceWrite === "Saving" - const back: ReadonlyArray> = [h.OnClick(Message.ClickedBackToProviders())] + 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"), @@ -137,13 +161,21 @@ export const vercelStep = (h: H, model: Model, id: string): ReadonlyArray ), ]), ], - onData: (status) => connection(h, model, status), + onData: (status) => connection(h, model, vercelStepOf(status)), }), ), ]), DialogView.dialogFooter(h, "dialog", [ - button(h, { variant: "outline", isDisabled: isSaving, attributes: back }, ["Back"]), - ...primary(h, model), + 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/route/search.ts b/apps/web/src/route/search.ts index 998eea40f6..5cab566f95 100644 --- a/apps/web/src/route/search.ts +++ b/apps/web/src/route/search.ts @@ -572,6 +572,7 @@ export const COST_GROUP_BYS = [ "service_category", "region", "usage_type", + "resource", "account", ] as const @@ -588,6 +589,7 @@ export const CostSearch = Schema.Struct({ service_category: OptionalStringArrayParam, region: OptionalStringArrayParam, usage_type: OptionalStringArrayParam, + resource: OptionalStringArrayParam, account: OptionalStringArrayParam, }) diff --git a/docs/cloud-cost-plan.md b/docs/cloud-cost-plan.md index 5558829105..2bf2fa23bb 100644 --- a/docs/cloud-cost-plan.md +++ b/docs/cloud-cost-plan.md @@ -143,8 +143,8 @@ currency the figures are in and lists the others. Nothing is added across curren ### Surfaces - Web: `/cost` behind the `cloudCost` flag. One page: the window against the period before, a - daily chart, a breakdown by service, provider, category, region, usage type or account with - filters, and each source's sync state. Sources are added from the page: AWS through its + daily chart, a breakdown by service, provider, category, region, usage type, resource 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, Vercel by adding the team the org has connected, which the page can remove again. Recommendations (list and detail with the evidence chart) come with phase 2. @@ -237,16 +237,18 @@ against a fake server that answers what the spec describes. slug. - 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 once first, so that refusal shows in the - dialog and not as a first sync that fails. + 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. + poller already found rejected is not asked at all. A new token saved for the same team makes + a source held for the old one due again at once. A 400 is a request Vercel calls invalid: + the source keeps Vercel's own message, 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. `ChargeCategory` maps onto Maple's (`Usage`, `Purchase`, + `Tags.ProjectId`) the resource, which the page groups and filters by. `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. diff --git a/packages/backend/src/services/cost/VercelCostSourceService.test.ts b/packages/backend/src/services/cost/VercelCostSourceService.test.ts index 11b79238c4..e8b2d1726b 100644 --- a/packages/backend/src/services/cost/VercelCostSourceService.test.ts +++ b/packages/backend/src/services/cost/VercelCostSourceService.test.ts @@ -19,14 +19,15 @@ afterEach(() => cleanupTestDbs(trackedDbs)) const ORG = Schema.decodeSync(OrgId)("org_cost") const NOW = DateTime.toEpochMillis(DateTime.makeUnsafe("2026-11-12T09:00:00Z")) -/** The check read: yesterday alone. */ +/** 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-12T00%3A00%3A00Z" + "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) @@ -35,7 +36,7 @@ const run = ( Effect.provide( Layer.effect(VercelCostSourceService, VercelCostSourceService.make).pipe( Layer.provide(Layer.effect(VercelCostProvider, VercelCostProvider.make)), - Layer.provideMerge(fakeVercelDeps(testDb, world)), + Layer.provideMerge(fakeVercelDeps(testDb, world, encryptionKey)), ), ), ) @@ -85,7 +86,7 @@ describe("VercelCostSourceService", () => { ) }) - it.effect("adds the connected team once Vercel let its token read yesterday's charges", () => { + it.effect("adds the connected team once Vercel let its token read the newest charges", () => { const testDb = createTestDb(trackedDbs) const world = makeFakeVercel() return run( @@ -94,7 +95,7 @@ describe("VercelCostSourceService", () => { Effect.gen(function* () { const connectionRef = yield* connectFakeVercel({ orgId: ORG, token: "vc_token_abc" }) const added = yield* add - assert.deepStrictEqual(added, { team: { name: "Acme", slug: "acme" } }) + assert.deepStrictEqual(added, { team: { 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 and is named after the team's slug. @@ -120,7 +121,7 @@ describe("VercelCostSourceService", () => { 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: { name: "Acme", slug: "acme" } }) + assert.deepStrictEqual(yield* add, { team: { name: "Acme", slug: "acme" }, created: false }) assert.lengthOf(world.urls, 1) assert.deepStrictEqual(yield* source(testDb), first) }), @@ -149,7 +150,7 @@ describe("VercelCostSourceService", () => { ], [ "@maple/http/errors/IntegrationsValidationError", - "Vercel rejected the saved token. Connect Vercel again with a new token.", + "Vercel rejected the saved token. Replace the token of the Vercel connection.", ], [ "@maple/http/errors/IntegrationsValidationError", @@ -171,7 +172,7 @@ describe("VercelCostSourceService", () => { 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, "Connect Vercel again") + assert.include(error.message, "Replace the token") assert.deepStrictEqual(world.urls, []) assert.isUndefined(yield* source(testDb)) }), @@ -208,6 +209,19 @@ describe("VercelCostSourceService", () => { ) }) + 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", () => { diff --git a/packages/backend/src/services/cost/VercelCostSourceService.ts b/packages/backend/src/services/cost/VercelCostSourceService.ts index 135851f60b..14803da735 100644 --- a/packages/backend/src/services/cost/VercelCostSourceService.ts +++ b/packages/backend/src/services/cost/VercelCostSourceService.ts @@ -1,7 +1,6 @@ /** - * The org's Vercel team as a cost source. The Vercel integration already holds the team and its - * token, so adding the source stores nothing new: the `cost_sources` row is the opt-in, and - * removing it is the opt-out. The connection itself is never changed here. + * 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, @@ -11,6 +10,7 @@ import { IntegrationsUpstreamError, IntegrationsValidationError, type OrgId, + type VercelCostStatus, type VercelCostTeam, } from "@maple/domain/http" import * as PG from "@maple-dev/effect-orm/postgres" @@ -22,7 +22,11 @@ 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 type { CostProviderError } from "./CostProvider" -import { VercelCostProvider } from "./providers/vercel/VercelCostProvider" +import { + VERCEL_NO_CONNECTION, + VERCEL_TOKEN_REJECTED, + VercelCostProvider, +} from "./providers/vercel/VercelCostProvider" import { addDays, utcToday } from "./sync-plan" import { removeVercelCostSources } from "./vercel-cost-sources" @@ -31,31 +35,40 @@ const toPersistenceError = makePersistenceErrorMapper( "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 a role Vercel refuses is - * the org's to fix and keeps its reason; an outage answers the provider error's fixed sentence. + * the org's to fix and keeps its reason. Anything else (an outage, a secret key Maple cannot use) + * is not, and answers the upstream error's fixed sentence. */ -const toAddError = (error: CostProviderError): IntegrationsValidationError | IntegrationsUpstreamError => - error.reason === "auth" || error.reason === "not_ready" - ? new IntegrationsValidationError({ message: error.message }) +const toAddError = ( + error: CostProviderError, +): IntegrationsValidationError | IntegrationsConnectionChangedError | IntegrationsUpstreamError => { + if (error.reason === "not_ready" || error.message === VERCEL_TOKEN_REJECTED) { + return new IntegrationsValidationError({ message: error.message }) + } + // Disconnected between the service's read of the connection and the adapter's. + return error.message === VERCEL_NO_CONNECTION + ? new IntegrationsConnectionChangedError({ message: CONNECTION_CHANGED }) : new IntegrationsUpstreamError({ message: error.message }) - -export interface VercelCostStatusInfo { - /** Null when the org has no Vercel connection. */ - readonly team: VercelCostTeam | null - readonly authFailed: boolean - readonly added: boolean } +/** `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. */ + /** + * Makes the connected team a cost source, once Vercel has let its token read the billing. + * `created` is false when it was one already. + */ readonly add: ( orgId: OrgId, ) => Effect.Effect< - { readonly team: VercelCostTeam }, + { readonly team: VercelCostTeam; readonly created: boolean }, | CloudCostDisabledError | IntegrationsNotConnectedError | IntegrationsConnectionChangedError @@ -135,13 +148,16 @@ export class VercelCostSourceService extends Context.Service< }) } const team = { name: connection.teamName, slug: connection.teamSlug } - if (connection.sourceId !== null) return { team } - // One day's read with the stored token: a token or role Vercel refuses is said now, not - // by a source that fails its first sync. + 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 yesterday = addDays(utcToday(now), -1) + const today = utcToday(now) yield* provider - .pull({ orgId, connectionRef: connection.id }, { startDay: yesterday, endDay: yesterday }) + .pull( + { orgId, connectionRef: connection.id }, + { startDay: addDays(today, -1), endDay: today }, + ) .pipe(Effect.mapError(toAddError)) const added = yield* dbExecute((db) => db.transaction( @@ -158,8 +174,8 @@ export class VercelCostSourceService extends Context.Service< .limit(1) .forShare(), ) - if (current === undefined) return false - yield* db.run( + if (current === undefined) return null + const inserted = yield* db.run( PG.insertInto(CostSources) .values({ id: CostSourceId.make(randomUUID()), @@ -171,18 +187,17 @@ export class VercelCostSourceService extends Context.Service< updatedAt: now, }) // Another request added it first. - .onConflictDoNothing(), + .onConflictDoNothing() + .returning(($) => ({ id: $.id })), ) - return true + return inserted.length > 0 }), ), ) - if (!added) { - return yield* new IntegrationsConnectionChangedError({ - message: "The Vercel connection changed while the source was added. Try again.", - }) + if (added === null) { + return yield* new IntegrationsConnectionChangedError({ message: CONNECTION_CHANGED }) } - return { team } + return { team, created: added } }) const remove = Effect.fn("VercelCostSourceService.remove")(function* (orgId: OrgId) { diff --git a/packages/backend/src/services/cost/providers/vercel/VercelCostProvider.test.ts b/packages/backend/src/services/cost/providers/vercel/VercelCostProvider.test.ts index 29a227dbce..bca1312bd3 100644 --- a/packages/backend/src/services/cost/providers/vercel/VercelCostProvider.test.ts +++ b/packages/backend/src/services/cost/providers/vercel/VercelCostProvider.test.ts @@ -145,6 +145,9 @@ describe("VercelCostProvider", () => { 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) }), ) }) @@ -208,6 +211,13 @@ describe("VercelCostProvider", () => { // A role without billing access is told which roles have it. world.chargesStatus = 403 assert.include((yield* Effect.flip(pull(ORG, connectionRef))).message, "Billing 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"], + ) }), ) }) @@ -222,7 +232,7 @@ describe("VercelCostProvider", () => { 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, "Connect Vercel again") + assert.include(error.message, "Replace the token") }), ) }) diff --git a/packages/backend/src/services/cost/providers/vercel/VercelCostProvider.ts b/packages/backend/src/services/cost/providers/vercel/VercelCostProvider.ts index a6ee98022d..8f297dc63a 100644 --- a/packages/backend/src/services/cost/providers/vercel/VercelCostProvider.ts +++ b/packages/backend/src/services/cost/providers/vercel/VercelCostProvider.ts @@ -22,7 +22,8 @@ import { import { addDays, daysOfWindow, utcToday } from "../../sync-plan" import { addCharge, emptyChargeTotals, rowsOf } from "./normalize" -const PULL_TIMEOUT = Duration.seconds(45) +/** 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 @@ -32,13 +33,16 @@ type Reason = CostProviderError["reason"] const failed = (reason: Reason, message: string) => new CostProviderError({ message, provider: "vercel", reason, requests: 0 }) -const TOKEN_REJECTED = "Vercel rejected the saved token. Connect Vercel again with a new token." +/** The two `auth` failures a caller can tell apart from an unusable secret key: the token, and no connection. */ +export const VERCEL_TOKEN_REJECTED = + "Vercel rejected the saved token. Replace the token of the Vercel connection." +export const VERCEL_NO_CONNECTION = "No Vercel connection is saved for this source." /** Vercel's failures, as what the sync does next and the Cost page shows. */ const toCostProviderError = (error: VercelApiError): CostProviderError => { switch (error.kind) { case "unauthorized": - return failed("auth", TOKEN_REJECTED) + return failed("auth", VERCEL_TOKEN_REJECTED) case "forbidden": return failed( "not_ready", @@ -51,7 +55,13 @@ const toCostProviderError = (error: VercelApiError): CostProviderError => { case "invalid_response": return failed("invalid_response", "Vercel's billing answer could not be read.") case "upstream": - return failed("upstream", "Vercel did not answer the billing request.") + // 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}`}`, + ) + : failed("upstream", "Vercel did not answer the billing request.") } } @@ -78,7 +88,8 @@ export class VercelCostProvider extends Context.Service MAX_REQUEST_DAYS) { + 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.`, @@ -93,11 +104,9 @@ export class VercelCostProvider extends Context.Service failed("auth", message)) // Today and yesterday: Vercel may still be counting a day that ended hours ago. @@ -116,11 +125,12 @@ export class VercelCostProvider extends Context.Service Effect.fail(failed("upstream", "Vercel took too long to answer.")), }), ) diff --git a/packages/backend/src/services/cost/providers/vercel/fake-vercel.ts b/packages/backend/src/services/cost/providers/vercel/fake-vercel.ts index b3d9cd4a51..34d0f16141 100644 --- a/packages/backend/src/services/cost/providers/vercel/fake-vercel.ts +++ b/packages/backend/src/services/cost/providers/vercel/fake-vercel.ts @@ -18,8 +18,8 @@ import { CostProviderError } from "../../CostProvider" export interface FakeVercel { /** Status of `GET /v1/billing/charges`. */ chargesStatus: number - /** The body: charges, served one JSON object per line, or raw text for anything else. */ - charges: ReadonlyArray>> | string + /** 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. */ @@ -60,7 +60,8 @@ const fetchStub = { status: world.chargesStatus }, ) } - return new Response(typeof world.charges === "string" ? world.charges : jsonl(world.charges), { + const { charges } = world + return new Response(charges === null || typeof charges === "string" ? charges : jsonl(charges), { status: 200, headers: { "content-type": "application/jsonl" }, }) diff --git a/packages/backend/src/services/cost/providers/vercel/normalize.test.ts b/packages/backend/src/services/cost/providers/vercel/normalize.test.ts index dfff616731..15983bffd1 100644 --- a/packages/backend/src/services/cost/providers/vercel/normalize.test.ts +++ b/packages/backend/src/services/cost/providers/vercel/normalize.test.ts @@ -1,7 +1,7 @@ import { assert, describe, it } from "@effect/vitest" import { Array as Arr, Order } from "effect" import type { VercelCharge } from "../../../integrations/vercel/api" -import { toCostRows } from "./normalize" +import { addCharge, emptyChargeTotals, rowsOf as rowsOfTotals } from "./normalize" const WINDOW = { startDay: "2026-11-01", endDay: "2026-11-04" } const ACCOUNT = "acme" @@ -24,7 +24,13 @@ const charge = (overrides: Partial = {}): VercelCharge => ({ ...overrides, }) -const rowsOf = (charges: ReadonlyArray) => toCostRows(charges, ACCOUNT, WINDOW, OPEN_DAY) +/** 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) => @@ -47,7 +53,7 @@ const summary = (charges: ReadonlyArray) => Order.String, ) -describe("toCostRows", () => { +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()]), [ { diff --git a/packages/backend/src/services/cost/providers/vercel/normalize.ts b/packages/backend/src/services/cost/providers/vercel/normalize.ts index 8c87db8dc5..5904d0d65a 100644 --- a/packages/backend/src/services/cost/providers/vercel/normalize.ts +++ b/packages/backend/src/services/cost/providers/vercel/normalize.ts @@ -94,16 +94,3 @@ export const addCharge = ( /** 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) - -/** A whole answer's charges as rows. The adapter folds the same way, line by line as they arrive. */ -export const toCostRows = ( - charges: ReadonlyArray, - accountId: string, - window: CostPullWindow, - openDay: string, -): ReadonlyArray => - rowsOf( - Arr.reduce(charges, emptyChargeTotals(), (totals, charge) => - addCharge(totals, charge, accountId, window, openDay), - ), - ) diff --git a/packages/backend/src/services/cost/vercel-cost-sources.ts b/packages/backend/src/services/cost/vercel-cost-sources.ts index e7bb5cea27..335e52bd40 100644 --- a/packages/backend/src/services/cost/vercel-cost-sources.ts +++ b/packages/backend/src/services/cost/vercel-cost-sources.ts @@ -1,7 +1,6 @@ -// An org's Vercel `cost_sources`. Unlike the other providers the connection does not make the -// source: the org adds its Vercel team on the Cost page, and that row is the opt-in. So nothing -// here creates one. A source goes when the org removes it, when the connection is deleted, and -// when the connection moves to another team, whose spend would overwrite the source's days. +// 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" @@ -22,22 +21,37 @@ export const removeVercelCostSources = (db: MapleDb, orgId: OrgId) => .pipe(Effect.map((rows) => rows.length)) /** - * Removes the sources whose connection is gone, in every org or only in `orgId`. 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. + * A new token was saved for the team the source reads: a source held for the old token's failure + * is due again, and a pull still in flight on the old token loses its claim, so its failure + * writes no hold. */ -export const reconcileVercelCostSources = Effect.fnUntraced(function* (db: MapleDb, orgId?: OrgId) { +export const releaseVercelCostSources = (db: MapleDb, orgId: OrgId, now: number) => + db.run( + PG.update(CostSources) + .set({ + 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(), - ...(orgId === undefined ? [] : [$.orgId.eq(orgId)]), - ]) + .where(($) => [$.provider.eq("vercel"), $.c.id.isNull()]) .orderBy(["id", "asc"]) .limit(RECONCILE_LIMIT), ) diff --git a/packages/backend/src/services/integrations/VercelIntegrationService.test.ts b/packages/backend/src/services/integrations/VercelIntegrationService.test.ts index dd79e1b7e4..2bd93aff25 100644 --- a/packages/backend/src/services/integrations/VercelIntegrationService.test.ts +++ b/packages/backend/src/services/integrations/VercelIntegrationService.test.ts @@ -1412,6 +1412,20 @@ describe("VercelIntegrationService", () => { SELECT gen_random_uuid()::text, org_id, 'vercel', id, team_slug, now(), now() FROM vercel_connections`, ), ) + const held = Effect.promise(() => + queryFirstRow<{ + error: string | null + reason: string | null + held: boolean + failures: number + leased: 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 + 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'", @@ -1423,9 +1437,24 @@ describe("VercelIntegrationService", () => { Effect.gen(function* () { yield* connect() yield* addCostSource - // A new token for the same team: the source and its history stay. + // 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'`, + ), + ) + // A new token for the same team: the source and its history stay, and it is due again. yield* connect({ token: "vc_token_456" }) assert.deepStrictEqual(yield* costSources, ["acme"]) + assert.deepStrictEqual(yield* held, { + error: null, + reason: null, + held: false, + failures: 0, + leased: 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, []) diff --git a/packages/backend/src/services/integrations/VercelIntegrationService.ts b/packages/backend/src/services/integrations/VercelIntegrationService.ts index 7277a019d7..f227571f71 100644 --- a/packages/backend/src/services/integrations/VercelIntegrationService.ts +++ b/packages/backend/src/services/integrations/VercelIntegrationService.ts @@ -68,7 +68,7 @@ import { reportPoll, upsertWithSource, } from "@maple/backend/services/integrations/integration-sources" -import { removeVercelCostSources } from "../cost/vercel-cost-sources" +import { releaseVercelCostSources, removeVercelCostSources } from "../cost/vercel-cost-sources" import { createDrain, deleteDrain, @@ -1252,7 +1252,12 @@ export class VercelIntegrationService extends Context.Service< .pipe(Effect.map(([row]) => row)), ).pipe( // A cost source reads one team: the new team's spend would overwrite its days. - Effect.tap(() => (sameTeam ? Effect.void : removeVercelCostSources(db, orgId))), + // The same team with a new token keeps it, and makes it due again at once. + Effect.tap(() => + sameTeam + ? Effect.asVoid(releaseVercelCostSources(db, orgId, now)) + : Effect.asVoid(removeVercelCostSources(db, orgId)), + ), ), ), ) From 2efc8d36480e4539d967d76a08a2480d1c6fff3d Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 00:37:59 +0100 Subject: [PATCH 4/8] fix(cost): add the Vercel team that was shown, keep its account with its slug, take only a whole answer Adding a Vercel source names the team the dialog showed. A connection that reads another team by then is refused as changed, where it used to become the source. So is one whose token was replaced while the check read was in flight: the token that passed the check was not the one the source would sync with. A reconnect of the same team rewrites the connection's slug, which rows are stamped with, while the source kept the slug from when it was added: one team became two accounts. The source's account is now rewritten in that transaction. A new token also makes the source due at the next tick. Clearing its hold was not enough for one that had synced within the day. Only a 200 is taken as the charges of the whole range: a 204 or a 206 fails the pull, since storing them would leave days empty or by halves. A charge's ChargePeriodStart must be a UTC date-time, not just start with a day. A request Vercel calls invalid is shown in the dialog with Vercel's own message. The upstream error it used to be is redacted, and the request shape has never run against a real team. The adapter's failures carry what an add tells apart as a field, in place of matching on their messages. --- .../src/routes/internal/vercel-cost.http.ts | 4 +- docs/cloud-cost-plan.md | 24 ++++-- .../src/services/cost/CostSyncService.test.ts | 27 +++++++ .../cost/VercelCostSourceService.test.ts | 70 +++++++++++++++- .../services/cost/VercelCostSourceService.ts | 54 +++++++------ .../vercel/VercelCostProvider.test.ts | 27 ++++++- .../providers/vercel/VercelCostProvider.ts | 79 +++++++++++++++---- .../cost/providers/vercel/fake-vercel.ts | 10 +++ .../src/services/cost/vercel-cost-sources.ts | 10 ++- .../VercelIntegrationService.test.ts | 19 ++++- .../integrations/VercelIntegrationService.ts | 2 +- .../src/services/integrations/vercel/api.ts | 14 +++- packages/domain/src/http/vercel-cost.ts | 10 ++- 13 files changed, 286 insertions(+), 64 deletions(-) diff --git a/apps/api/src/routes/internal/vercel-cost.http.ts b/apps/api/src/routes/internal/vercel-cost.http.ts index 158b497d11..330031b96b 100644 --- a/apps/api/src/routes/internal/vercel-cost.http.ts +++ b/apps/api/src/routes/internal/vercel-cost.http.ts @@ -32,11 +32,11 @@ export const HttpVercelCostLive = HttpApiBuilder.group(MapleInternalApi, "vercel return new VercelCostStatus(status) }).pipe(Effect.withSpan("HttpVercelCost.status")), ) - .handle("add", () => + .handle("add", ({ payload }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context yield* requireIntegrationsManager(tenant.roles) - const added = yield* sources.add(tenant.orgId) + const added = yield* sources.add(tenant.orgId, payload.teamId) // Adding what is a source already changed nothing, so it leaves no entry. if (added.created) { yield* recordHttpAudit("cost_source.added", { diff --git a/docs/cloud-cost-plan.md b/docs/cloud-cost-plan.md index 2bf2fa23bb..982e6dd611 100644 --- a/docs/cloud-cost-plan.md +++ b/docs/cloud-cost-plan.md @@ -234,7 +234,11 @@ against a fake server that answers what the spec describes. 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`, and its account is the team's - slug. + slug. Rows are stamped with the connection's slug, and every write of that slug (a reconnect + after the team renamed itself) rewrites the source's account in the same transaction, so one + team is never two accounts. +- 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 @@ -242,20 +246,28 @@ against a fake server that answers what the spec describes. - 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 held for the old one due again at once. A 400 is a request Vercel calls invalid: - the source keeps Vercel's own message, since the request shape is the part not run live. + 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. `ChargeCategory` maps onto Maple's (`Usage`, `Purchase`, - `Credit`, `Tax`, `Adjustment`), so plan fees and credits are their own rows. Lines of one day, + `Tags.ProjectId`) the resource, which the page groups and filters by. `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. + 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, 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 diff --git a/packages/backend/src/services/cost/CostSyncService.test.ts b/packages/backend/src/services/cost/CostSyncService.test.ts index aa30279590..5e6e9c478c 100644 --- a/packages/backend/src/services/cost/CostSyncService.test.ts +++ b/packages/backend/src/services/cost/CostSyncService.test.ts @@ -504,6 +504,33 @@ describe("CostSyncService Vercel sources", () => { }).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", () => { diff --git a/packages/backend/src/services/cost/VercelCostSourceService.test.ts b/packages/backend/src/services/cost/VercelCostSourceService.test.ts index e8b2d1726b..e88b869463 100644 --- a/packages/backend/src/services/cost/VercelCostSourceService.test.ts +++ b/packages/backend/src/services/cost/VercelCostSourceService.test.ts @@ -42,7 +42,9 @@ const run = ( ) const status = VercelCostSourceService.use((service) => service.status(ORG)) -const add = VercelCostSourceService.use((service) => service.add(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 { @@ -74,7 +76,7 @@ describe("VercelCostSourceService", () => { Effect.gen(function* () { assert.deepStrictEqual(yield* status, { team: null, authFailed: false, added: false }) yield* connectFakeVercel({ orgId: ORG, teamName: "Acme Inc", teamSlug: "acme" }) - const team = { name: "Acme Inc", slug: "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 }) @@ -95,7 +97,10 @@ describe("VercelCostSourceService", () => { Effect.gen(function* () { const connectionRef = yield* connectFakeVercel({ orgId: ORG, token: "vc_token_abc" }) const added = yield* add - assert.deepStrictEqual(added, { team: { name: "Acme", slug: "acme" }, created: true }) + 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 and is named after the team's slug. @@ -121,7 +126,10 @@ describe("VercelCostSourceService", () => { 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: { name: "Acme", slug: "acme" }, created: false }) + 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) }), @@ -268,12 +276,66 @@ describe("VercelCostSourceService", () => { 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, "beta") + }), + ) + }) + + 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)) }), ) }) diff --git a/packages/backend/src/services/cost/VercelCostSourceService.ts b/packages/backend/src/services/cost/VercelCostSourceService.ts index 14803da735..05a360b238 100644 --- a/packages/backend/src/services/cost/VercelCostSourceService.ts +++ b/packages/backend/src/services/cost/VercelCostSourceService.ts @@ -21,12 +21,7 @@ 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 type { CostProviderError } from "./CostProvider" -import { - VERCEL_NO_CONNECTION, - VERCEL_TOKEN_REJECTED, - VercelCostProvider, -} from "./providers/vercel/VercelCostProvider" +import { VercelCostProvider, type VercelCostReadError } from "./providers/vercel/VercelCostProvider" import { addDays, utcToday } from "./sync-plan" import { removeVercelCostSources } from "./vercel-cost-sources" @@ -38,20 +33,24 @@ const toPersistenceError = makePersistenceErrorMapper( 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 a role Vercel refuses is - * the org's to fix and keeps its reason. Anything else (an outage, a secret key Maple cannot use) - * is not, and answers the upstream error's fixed sentence. + * 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: CostProviderError, + error: VercelCostReadError, ): IntegrationsValidationError | IntegrationsConnectionChangedError | IntegrationsUpstreamError => { - if (error.reason === "not_ready" || error.message === VERCEL_TOKEN_REJECTED) { - return new IntegrationsValidationError({ message: error.message }) + 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 }) } - // Disconnected between the service's read of the connection and the adapter's. - return error.message === VERCEL_NO_CONNECTION - ? new IntegrationsConnectionChangedError({ message: CONNECTION_CHANGED }) - : new IntegrationsUpstreamError({ message: error.message }) } /** `VercelCostStatus` as plain data. */ @@ -63,10 +62,12 @@ export interface VercelCostSourceServiceApi { ) => Effect.Effect /** * Makes the connected team a cost source, once Vercel has let its token read the billing. - * `created` is false when it was one already. + * `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 @@ -117,6 +118,7 @@ export class VercelCostSourceService extends Context.Service< teamId: $.teamId, teamSlug: $.teamSlug, teamName: $.teamName, + tokenCiphertext: $.tokenCiphertext, authFailedAt: $.authFailedAt, sourceId: $.s.id, })) @@ -132,13 +134,13 @@ export class VercelCostSourceService extends Context.Service< return connection === null ? { team: null, authFailed: false, added: false } : { - team: { name: connection.teamName, slug: connection.teamSlug }, + 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) { + const add = Effect.fn("VercelCostSourceService.add")(function* (orgId: OrgId, teamId: string) { yield* Effect.annotateCurrentSpan({ orgId }) yield* requireRollout(orgId) const connection = yield* load(orgId) @@ -147,14 +149,20 @@ export class VercelCostSourceService extends Context.Service< message: "Connect a Vercel team first, then add it as a cost source.", }) } - const team = { name: connection.teamName, slug: connection.teamSlug } + // 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 - .pull( + .read( { orgId, connectionRef: connection.id }, { startDay: addDays(today, -1), endDay: today }, ) @@ -162,7 +170,8 @@ export class VercelCostSourceService extends Context.Service< const added = yield* dbExecute((db) => db.transaction( Effect.gen(function* () { - // The share lock makes a disconnect or a move to another team wait for the insert. + // 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") @@ -170,6 +179,7 @@ export class VercelCostSourceService extends Context.Service< $.orgId.eq(orgId), $.id.eq(connection.id), $.teamId.eq(connection.teamId), + $.tokenCiphertext.eq(connection.tokenCiphertext), ]) .limit(1) .forShare(), diff --git a/packages/backend/src/services/cost/providers/vercel/VercelCostProvider.test.ts b/packages/backend/src/services/cost/providers/vercel/VercelCostProvider.test.ts index bca1312bd3..a4434b10be 100644 --- a/packages/backend/src/services/cost/providers/vercel/VercelCostProvider.test.ts +++ b/packages/backend/src/services/cost/providers/vercel/VercelCostProvider.test.ts @@ -171,6 +171,9 @@ describe("VercelCostProvider", () => { "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" })]), jsonl([{ BilledCost: 1, ChargePeriodStart: "2026-11-02T00:00:00Z" }]), ] const reasons = yield* Effect.forEach(unreadable, (body) => @@ -179,7 +182,29 @@ describe("VercelCostProvider", () => { return (yield* Effect.flip(pull(ORG, connectionRef))).reason }), ) - assert.deepStrictEqual(reasons, Arr.replicate("invalid_response", 4)) + assert.deepStrictEqual(reasons, Arr.replicate("invalid_response", 6)) + }), + ) + }) + + 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) }), ) }) diff --git a/packages/backend/src/services/cost/providers/vercel/VercelCostProvider.ts b/packages/backend/src/services/cost/providers/vercel/VercelCostProvider.ts index 8f297dc63a..78cf14daab 100644 --- a/packages/backend/src/services/cost/providers/vercel/VercelCostProvider.ts +++ b/packages/backend/src/services/cost/providers/vercel/VercelCostProvider.ts @@ -4,7 +4,7 @@ */ import * as PG from "@maple-dev/effect-orm/postgres" import { VercelConnections } from "@maple/db/tables" -import { Clock, Context, Duration, Effect, Layer, Stream } from "effect" +import { Clock, Context, Duration, Effect, Layer, Schema, Stream } from "effect" import { FetchHttpClient, HttpClient } from "effect/http" import { Database } from "@maple/backend/platform/DatabaseLive" import { makeDbExecute } from "@maple/backend/platform/db-execute" @@ -29,27 +29,47 @@ const MAX_REQUEST_DAYS = 366 type Reason = CostProviderError["reason"] -// Vercel does not bill for API requests, so no failure counts any. -const failed = (reason: Reason, message: string) => - new CostProviderError({ message, provider: "vercel", reason, requests: 0 }) +/** + * 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"]), + }, +) {} -/** The two `auth` failures a caller can tell apart from an unusable secret key: the token, and no connection. */ -export const VERCEL_TOKEN_REJECTED = - "Vercel rejected the saved token. Replace the token of the Vercel connection." -export const VERCEL_NO_CONNECTION = "No Vercel connection is saved for this source." +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 toCostProviderError = (error: VercelApiError): CostProviderError => { +const toReadError = (error: VercelApiError): VercelCostReadError => { switch (error.kind) { case "unauthorized": - return failed("auth", VERCEL_TOKEN_REJECTED) + 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 or Billing role.", + "not_ready", ) case "not_found": - return failed("not_ready", "Vercel has no billing data for this team, or the team is gone.") + 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": @@ -60,12 +80,21 @@ const toCostProviderError = (error: VercelApiError): CostProviderError => { ? 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 class VercelCostProvider extends Context.Service()( +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* () { @@ -80,7 +109,7 @@ export class VercelCostProvider extends Context.Service failed("auth", message)) // Today and yesterday: Vercel may still be counting a day that ended hours ago. @@ -117,6 +152,7 @@ export class VercelCostProvider extends Context.Service addCharge(sums, charge, row.teamSlug, window, openDay), ), @@ -128,7 +164,7 @@ export class VercelCostProvider extends Context.Service Effect.fail(failed("upstream", "Vercel took too long to answer.")), @@ -146,7 +182,16 @@ export class VercelCostProvider extends Context.Service + read(source, window).pipe( + Effect.mapError( + ({ message, reason }) => + new CostProviderError({ message, provider: "vercel", reason, requests: 0 }), + ), + ) + + return { provider: "vercel", pull, read } satisfies VercelCostProviderApi }), }, ) { diff --git a/packages/backend/src/services/cost/providers/vercel/fake-vercel.ts b/packages/backend/src/services/cost/providers/vercel/fake-vercel.ts index 34d0f16141..6d82ea2676 100644 --- a/packages/backend/src/services/cost/providers/vercel/fake-vercel.ts +++ b/packages/backend/src/services/cost/providers/vercel/fake-vercel.ts @@ -54,6 +54,16 @@ const fetchStub = 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}` } }, diff --git a/packages/backend/src/services/cost/vercel-cost-sources.ts b/packages/backend/src/services/cost/vercel-cost-sources.ts index 335e52bd40..36ab429ac1 100644 --- a/packages/backend/src/services/cost/vercel-cost-sources.ts +++ b/packages/backend/src/services/cost/vercel-cost-sources.ts @@ -21,14 +21,16 @@ export const removeVercelCostSources = (db: MapleDb, orgId: OrgId) => .pipe(Effect.map((rows) => rows.length)) /** - * A new token was saved for the team the source reads: a source held for the old token's failure - * is due again, and a pull still in flight on the old token loses its claim, so its failure - * writes no hold. + * 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), a pull in flight on the old token loses its claim, + * and the account follows the team's slug, which the connection may just have rewritten. */ -export const releaseVercelCostSources = (db: MapleDb, orgId: OrgId, now: number) => +export const releaseVercelCostSources = (db: MapleDb, orgId: OrgId, teamSlug: string, now: number) => db.run( PG.update(CostSources) .set({ + accountId: teamSlug, + lastSyncedAt: null, leaseUntil: null, lastError: null, lastErrorReason: null, diff --git a/packages/backend/src/services/integrations/VercelIntegrationService.test.ts b/packages/backend/src/services/integrations/VercelIntegrationService.test.ts index 2bd93aff25..9428d0911b 100644 --- a/packages/backend/src/services/integrations/VercelIntegrationService.test.ts +++ b/packages/backend/src/services/integrations/VercelIntegrationService.test.ts @@ -1419,10 +1419,12 @@ describe("VercelIntegrationService", () => { 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 + 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'`, ), ) @@ -1442,18 +1444,27 @@ describe("VercelIntegrationService", () => { 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'`, + consecutive_failures = 3, lease_until = now() + interval '20 minutes', + watermark_date = '2026-10-18', last_synced_at = now()`, ), ) - // A new token for the same team: the source and its history stay, and it is due again. + // 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 + // follows the slug its rows are stamped with from here on. yield* connect({ token: "vc_token_456" }) - assert.deepStrictEqual(yield* costSources, ["acme"]) + assert.deepStrictEqual(yield* costSources, ["acme-labs"]) + // 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" }) diff --git a/packages/backend/src/services/integrations/VercelIntegrationService.ts b/packages/backend/src/services/integrations/VercelIntegrationService.ts index f227571f71..e5364d3acc 100644 --- a/packages/backend/src/services/integrations/VercelIntegrationService.ts +++ b/packages/backend/src/services/integrations/VercelIntegrationService.ts @@ -1255,7 +1255,7 @@ export class VercelIntegrationService extends Context.Service< // The same team with a new token keeps it, and makes it due again at once. Effect.tap(() => sameTeam - ? Effect.asVoid(releaseVercelCostSources(db, orgId, now)) + ? Effect.asVoid(releaseVercelCostSources(db, orgId, team.slug, now)) : Effect.asVoid(removeVercelCostSources(db, orgId)), ), ), diff --git a/packages/backend/src/services/integrations/vercel/api.ts b/packages/backend/src/services/integrations/vercel/api.ts index 2db9d3bdf0..f8766cab2e 100644 --- a/packages/backend/src/services/integrations/vercel/api.ts +++ b/packages/backend/src/services/integrations/vercel/api.ts @@ -479,6 +479,10 @@ export const deleteDrain = Effect.fn("VercelApi.deleteDrain")(function* ( 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)$/ + /** * 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. @@ -486,7 +490,7 @@ const optional = (schema: S) => Schema.optionalKey(Schema. export const VercelCharge = Schema.Struct({ BilledCost: Schema.Number, /** Inclusive, ISO 8601 in UTC: its first ten characters are the usage day. */ - ChargePeriodStart: Schema.String.check(Schema.isPattern(/^\d{4}-\d{2}-\d{2}/)), + ChargePeriodStart: Schema.String.check(Schema.isPattern(UTC_DATE_TIME)), /** The product's display name. */ ServiceName: Schema.String, BillingCurrency: optional(Schema.String), @@ -534,6 +538,14 @@ export const streamBillingCharges = ( 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, diff --git a/packages/domain/src/http/vercel-cost.ts b/packages/domain/src/http/vercel-cost.ts index ed0af026ae..98b1947181 100644 --- a/packages/domain/src/http/vercel-cost.ts +++ b/packages/domain/src/http/vercel-cost.ts @@ -11,8 +11,8 @@ import { } from "./integrations" import { CloudCostDisabledError } from "./query-engine" -/** The Vercel team the org's connection reads, as the Cost page names it. */ -export const VercelCostTeam = Schema.Struct({ name: Schema.String, slug: Schema.String }) +/** 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. */ @@ -25,6 +25,11 @@ export class VercelCostStatus extends Schema.Class("VercelCost 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, }) {} @@ -46,6 +51,7 @@ export class VercelCostApiGroup extends HttpApiGroup.make("vercelCost") ) .add( HttpApiEndpoint.put("add", "/", { + payload: VercelCostAddRequest, success: VercelCostSourceAdded, error: [ IntegrationsPersistenceError, From 69f30456a295d1fe8384bf387a395d00f22d023c Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 00:37:59 +0100 Subject: [PATCH 5/8] fix(cost): keep Group by inside the page, drop cost read for removed sources, name row actions by provider With seven options the Group by control was wider than the page at phone and tablet widths and at a narrow desktop with the sidebar open, and the whole page panned sideways. The control now scrolls inside its own width. The window's reads are keyed by the window, not by the sources. When a removal left no source with cost, the removed source's answers stayed in the model and were shown again for the next source to sync over the same window. They are dropped whenever no source has cost, for every provider. The row actions say whose source they act on to assistive technology (Remove Vercel source, Replace OpenRouter key, Manage AWS connection): two rows can offer the same action. A Vercel row links to its connection, where the token is replaced, and the dialog says so for a team that is a source already and has lost its token. In the cost lab a source added after its removal is a new one: it waits for its first sync and has no cost, where it used to come back synced. --- apps/web/src/lab/cost-fixture.test.ts | 20 +++- apps/web/src/lab/cost-fixture.ts | 38 +++---- apps/web/src/lab/cost-lab.ts | 59 ++++++----- apps/web/src/page/cost/command.ts | 16 ++- apps/web/src/page/cost/cost.scene.test.ts | 117 +++++++++++++++++++--- apps/web/src/page/cost/model.ts | 18 ++-- apps/web/src/page/cost/update.ts | 39 ++++++-- apps/web/src/page/cost/view-add-vercel.ts | 22 +++- apps/web/src/page/cost/view-sources.ts | 56 +++++++++-- apps/web/src/page/cost/view.ts | 6 +- 10 files changed, 296 insertions(+), 95 deletions(-) diff --git a/apps/web/src/lab/cost-fixture.test.ts b/apps/web/src/lab/cost-fixture.test.ts index 0149de7e1b..972cf1bbc0 100644 --- a/apps/web/src/lab/cost-fixture.test.ts +++ b/apps/web/src/lab/cost-fixture.test.ts @@ -1,6 +1,6 @@ import { Array as Arr } from "effect" import { describe, expect, test } from "vitest" -import { labBreakdown, labTimeseries, labTotals } from "./cost-fixture" +import { LAB_SOURCES, labBreakdown, labSourcesWith, labTimeseries, labTotals } from "./cost-fixture" const window = { days: 30, filters: undefined } const openRouterOnly = { days: 30, filters: { provider: ["openrouter"] } } @@ -45,6 +45,24 @@ describe("cost lab fixture", () => { expect(labTotals({ days: 30, filters: { provider: ["vercel"] }, without: ["vercel"] }).cost).toBe(0) }) + test("a source added again after its removal is a new one that waits for its first sync", () => { + const statusOf = (response: typeof LAB_SOURCES.synced, provider: string) => + Arr.map( + Arr.filter(response.sources, (source) => source.provider === provider), + (source) => [source.status, source.watermarkDate], + ) + // As the state has it, then removed, then added: not the synced row it started with. + expect(statusOf(labSourcesWith(LAB_SOURCES.synced, "vercel", null), "vercel")).toEqual([ + ["ok", "2026-11-09"], + ]) + expect(statusOf(labSourcesWith(LAB_SOURCES.synced, "vercel", false), "vercel")).toEqual([]) + const readded = labSourcesWith(LAB_SOURCES.synced, "vercel", true) + expect(statusOf(readded, "vercel")).toEqual([["pending", null]]) + // The other sources are untouched. + expect(statusOf(readded, "aws")).toEqual([["ok", "2026-11-09"]]) + expect(readded.sources).toHaveLength(LAB_SOURCES.synced.sources.length) + }) + test("grouped by resource, a Vercel team's cost splits by project and the rest has none", () => { expect( Arr.map(labBreakdown({ ...window, groupBy: "resource", sortBy: "cost" }).rows, (row) => row.key), diff --git a/apps/web/src/lab/cost-fixture.ts b/apps/web/src/lab/cost-fixture.ts index 2ce5d0999e..ec5cffe6e7 100644 --- a/apps/web/src/lab/cost-fixture.ts +++ b/apps/web/src/lab/cost-fixture.ts @@ -59,7 +59,11 @@ const modelLine = (model: string, servedBy: string, daily: number, drift: number }) /** The Vercel team the lab's source reads, by its slug. */ -export const LAB_VERCEL_TEAM = { name: "Example Team", slug: "example-team" } +export const LAB_VERCEL_TEAM = { + id: "team_labExample000000000000", + name: "Example Team", + slug: "example-team", +} const productLine = ( product: string, @@ -325,8 +329,8 @@ const ADDABLE = { openrouter: openRouterSource, vercel: vercelSource } as const type LabAddable = keyof typeof ADDABLE /** - * `sources` after the lab's reader added a provider's source (true: it waits for its first sync) - * or removed it (false); null leaves it as the state has it. + * `sources` after the lab's reader added a provider's source (true: a new one, which waits for + * its first sync whatever the state had) or removed it (false); null leaves it as the state has it. */ export const labSourcesWith = ( sources: CostSourcesResponse, @@ -336,21 +340,19 @@ export const labSourcesWith = ( if (added === null) return sources const others = Arr.filter(sources.sources, (entry) => entry.provider !== provider) if (!added) return new CostSourcesResponse({ sources: others }) - return others.length === sources.sources.length - ? decodeSources({ - sources: [ - ...Schema.encodeSync(CostSourcesResponse)(sources).sources, - { - ...ADDABLE[provider], - status: "pending", - watermarkDate: null, - backfillDate: null, - historyComplete: false, - lastSyncedAt: null, - }, - ], - }) - : sources + return decodeSources({ + sources: [ + ...Schema.encodeSync(CostSourcesResponse)(new CostSourcesResponse({ sources: others })).sources, + { + ...ADDABLE[provider], + status: "pending", + watermarkDate: null, + backfillDate: null, + historyComplete: false, + lastSyncedAt: null, + }, + ], + }) } export const LAB_SOURCES = { diff --git a/apps/web/src/lab/cost-lab.ts b/apps/web/src/lab/cost-lab.ts index fc23d91346..7a15f4e642 100644 --- a/apps/web/src/lab/cost-lab.ts +++ b/apps/web/src/lab/cost-lab.ts @@ -101,7 +101,10 @@ export const Message = defineMessageUnion({ GotCostMessage: { message: CostMessa export type Message = typeof Message.Type type Return = Update.Return -/** What the reader added (true) or removed (false) since the state loaded; null before either. */ +/** + * What the reader did to each source since the state loaded: added it (true: a new source, with + * no cost until a sync the lab does not model) or removed it (false); null before either. + */ type Changes = Readonly<{ openRouter: boolean | null; vercel: boolean | null }> /** What the lab answers from: the state it loaded in, and what the reader did to its sources since. */ type World = Readonly<{ state: LabState }> & Changes @@ -179,14 +182,16 @@ const refusedVercel = { "This Vercel token's role cannot read the team's billing. Use a token from an Owner, Member, Developer or Billing role.", } +/** Whether the state starts with a source of the provider. */ +const stateHas = (state: LabState, provider: "openrouter" | "vercel"): boolean => + Arr.some(sourcesFor(state).sources, (source) => source.provider === provider) + /** The org's Vercel connection as the state has it, and whether the page's sources hold its team. */ const vercelStatusOf = (world: World): VercelCostStatus => new VercelCostStatus({ team: world.state === "vercel-not-connected" ? null : LAB_VERCEL_TEAM, authFailed: false, - added: - world.vercel ?? - Arr.some(sourcesFor(world.state).sources, (source) => source.provider === "vercel"), + added: world.vercel ?? stateHas(world.state, "vercel"), }) /** The days a preset spans, for the fixture's window; a custom range reads as the default. */ @@ -238,10 +243,10 @@ const answerOf = (world: World, search: Search, command: CostCommand): Option.Op // An empty window: the sources have synced, and nothing matches. const filtersOf = (filters: WindowInput["filters"]) => state === "empty-window" ? { account: ["000000000000"] } : filters - // A removed source's cost is not read any more, under any filter. + // A removed source's cost is not read any more, under any filter, and one added since has none yet. const without = [ - ...(world.openRouter === false ? ["openrouter"] : []), - ...(world.vercel === false ? ["vercel"] : []), + ...(world.openRouter === null ? [] : ["openrouter"]), + ...(world.vercel === null ? [] : ["vercel"]), ] switch (command.name) { case FetchCostSources.name: @@ -344,22 +349,30 @@ const answerOf = (world: World, search: Search, command: CostCommand): Option.Op type Step = Readonly<{ model: CostModel; commands: ReadonlyArray }> type Settled = Step & Changes -/** A saved key or an added team joins the sources the lab answers with next, a removal takes it out. */ -const changesAfter = (changes: Changes, answers: ReadonlyArray): Changes => - Arr.reduce(answers, changes, (current, answer) => { - switch (answer._tag) { - case "CompletedSaveKey": - return Result.isSuccess(answer.result) ? { ...current, openRouter: true } : current - case "CompletedRemoveKey": - return Result.isSuccess(answer.result) ? { ...current, openRouter: false } : current - case "CompletedAddVercel": - return Result.isSuccess(answer.result) ? { ...current, vercel: true } : current - case "CompletedRemoveVercel": - return Result.isSuccess(answer.result) ? { ...current, vercel: false } : current - default: - return current - } - }) +/** A saved key or an added team is a new source in what the lab answers next, a removal takes it out. */ +const changesAfter = (world: World, answers: ReadonlyArray): Changes => + Arr.reduce( + answers, + { openRouter: world.openRouter, vercel: world.vercel }, + (current, answer): Changes => { + switch (answer._tag) { + // A key saved while the source exists replaces its key: the source stays as it is. + case "CompletedSaveKey": + return Result.isSuccess(answer.result) && + !(current.openRouter ?? stateHas(world.state, "openrouter")) + ? { ...current, openRouter: true } + : current + case "CompletedRemoveKey": + return Result.isSuccess(answer.result) ? { ...current, openRouter: false } : current + case "CompletedAddVercel": + return Result.isSuccess(answer.result) ? { ...current, vercel: true } : current + case "CompletedRemoveVercel": + return Result.isSuccess(answer.result) ? { ...current, vercel: false } : current + default: + return current + } + }, + ) /** * Runs the page's reads against the fixture until none is left: an answer can ask for more (the diff --git a/apps/web/src/page/cost/command.ts b/apps/web/src/page/cost/command.ts index 0a8d82cf4e..6d8bc23551 100644 --- a/apps/web/src/page/cost/command.ts +++ b/apps/web/src/page/cost/command.ts @@ -1,4 +1,9 @@ -import { CostBreakdownSortBy, CostFilters, OpenRouterKeyUpdateRequest } from "@maple/domain/http" +import { + CostBreakdownSortBy, + CostFilters, + OpenRouterKeyUpdateRequest, + VercelCostAddRequest, +} from "@maple/domain/http" import { reloadSpin } from "@maple/views/data/timing" import { settleTo } from "@maple/views/data/load-failure" import { @@ -120,10 +125,13 @@ export const FetchVercelCostStatus = Command.define("FetchVercelCostStatus", { /** 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: Effect.flatMap(MapleInternalApiClient, (client) => client.vercelCost.add()).pipe( - settleTo((result) => Message.CompletedAddVercel({ result })), - ), + 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", { diff --git a/apps/web/src/page/cost/cost.scene.test.ts b/apps/web/src/page/cost/cost.scene.test.ts index 85ad98bf85..022434d845 100644 --- a/apps/web/src/page/cost/cost.scene.test.ts +++ b/apps/web/src/page/cost/cost.scene.test.ts @@ -41,7 +41,7 @@ import { withFilter, } from "./derive" import { Message } from "./message" -import type { Model, Search } from "./model" +import type { Model, RemovableProvider, Search } from "./model" import { contextChanged, init, routeChanged, update } from "./update" import { view as pageView } from "./view" @@ -310,12 +310,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. @@ -365,7 +365,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. @@ -382,7 +382,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(), ) @@ -430,7 +430,7 @@ describe("cost page", () => { ) }) - const vercelTeam = { name: "Acme Inc", slug: "acme" } + const vercelTeam = { id: "team_1", name: "Acme Inc", slug: "acme" } const vercelStatus = (over: Partial = {}) => new VercelCostStatus({ team: vercelTeam, authFailed: false, added: false, ...over }) @@ -481,7 +481,8 @@ describe("cost page", () => { expectView(role("button", { name: "Back" })).toBeEnabled(), ) const adding = pageUpdate(connected, Message.ClickedConfirmAddVercel()) - expect(names(adding.commands)).toEqual(["AddVercelCostSource"]) + // 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") @@ -536,6 +537,21 @@ describe("cost page", () => { ) 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 }, @@ -679,9 +695,13 @@ describe("cost page", () => { // 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: there is no key to replace here. - expectView(role("button", { name: "Replace key" })).not.toExist(), - expectView(role("button", { name: "Remove" })).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( @@ -718,7 +738,7 @@ describe("cost page", () => { given(failed.model), resolveTheme, dialogMounted, - expectView(role("button", { name: "Remove" })).toBeEnabled(), + expectView(role("button", { name: "Remove Vercel source" })).toBeEnabled(), ) const removed = pageUpdate( @@ -750,6 +770,71 @@ describe("cost page", () => { ) }) + test("cost read for a source that was removed never stands in for the next source's", () => { + const openRouter = source({ + id: "0190f2b3-0000-7000-8000-00000000c060", + provider: "openrouter", + accountId: "user_2openrouterAccount", + }) + const vercel = source({ + id: "0190f2b3-0000-7000-8000-00000000c061", + provider: "vercel", + accountId: "acme", + }) + const removals: ReadonlyArray< + Readonly<{ only: SourceWire; provider: RemovableProvider; removed: Message }> + > = [ + { + only: vercel, + provider: "vercel", + removed: Message.CompletedRemoveVercel({ + result: Result.succeed(new VercelCostSourceRemoved({ removed: true })), + }), + }, + { + only: openRouter, + provider: "openrouter", + removed: Message.CompletedRemoveKey({ + result: Result.succeed(new OpenRouterCostKeyRemoved({ removed: true })), + }), + }, + ] + Arr.forEach(removals, ({ only, provider, removed: answer }) => { + // The removed source was the only one with cost: the reads are skipped, not refreshed. + const model = answered(entered({}, [only]).model) + const totalsKey = model.totals.key ?? "" + const removed = pageUpdate( + pageUpdate( + pageUpdate(model, Message.ClickedRemoveSource({ provider })).model, + Message.ClickedConfirmRemove(), + ).model, + answer, + ) + expect(names(removed.commands)).toContain("FetchCostSources") + expect(names(removed.commands)).not.toContain("FetchCostTotals") + // Another source has synced by the time the list is read again, over the same window. + const next = pageUpdate( + removed.model, + Message.CompletedLoadSources({ + key: removed.model.sources.key ?? "", + result: Result.succeed(sources([source()])), + }), + ) + // Its cost is asked for: the removed source's answer under the same key is not kept. + expect(names(next.commands)).toEqual( + expect.arrayContaining(["FetchCostTotals", "FetchCostTimeseries", "FetchCostBreakdown"]), + ) + expect(next.model.totals.key).toBe(totalsKey) + scene( + { update: pageUpdate, view }, + given(next.model), + resolveTheme, + dialogMounted, + expectView(text("$1,200.00")).not.toExist(), + ) + }) + }) + test("removing one provider's source leaves the other's on the page", () => { const openRouter = source({ id: "0190f2b3-0000-7000-8000-00000000c060", @@ -818,9 +903,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 }, @@ -850,7 +935,7 @@ 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(), ) }) diff --git a/apps/web/src/page/cost/model.ts b/apps/web/src/page/cost/model.ts index d102b9092b..66d8fd2ddd 100644 --- a/apps/web/src/page/cost/model.ts +++ b/apps/web/src/page/cost/model.ts @@ -28,15 +28,21 @@ export type AddStep = typeof AddStep.Type export const RemovableProvider = Schema.Literals(["openrouter", "vercel"]) export type RemovableProvider = typeof RemovableProvider.Type -/** What the Vercel step can do with the connection it read: only a `Ready` team can be added. */ +/** + * 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: "Added" | "TokenRejected" | "Ready"; team: VercelCostTeam }> + | Readonly<{ _tag: "TokenRejected" | "Ready"; team: VercelCostTeam }> + | Readonly<{ _tag: "Added"; team: VercelCostTeam; isTokenRejected: boolean }> -export const vercelStepOf = (status: VercelCostStatus): VercelStep => - status.team === null - ? { _tag: "NotConnected" } - : { _tag: status.added ? "Added" : status.authFailed ? "TokenRejected" : "Ready", team: status.team } +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, diff --git a/apps/web/src/page/cost/update.ts b/apps/web/src/page/cost/update.ts index 054e98832f..0d8768c9cc 100644 --- a/apps/web/src/page/cost/update.ts +++ b/apps/web/src/page/cost/update.ts @@ -1,4 +1,9 @@ -import { CostSourcesResponse, OPENROUTER_KEY_MAX_LENGTH, type VercelCostStatus } from "@maple/domain/http" +import { + CostSourcesResponse, + OPENROUTER_KEY_MAX_LENGTH, + type VercelCostStatus, + type VercelCostTeam, +} from "@maple/domain/http" import * as Policy from "@maple/domain/policy" import { Array as Arr, Option, Result, String as Str } from "effect" import { AsyncData, Command } from "foldkit" @@ -91,7 +96,14 @@ 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() }, + } + } const filters = requestFilters(model.search) const window: WindowInput = { startTime: model.window.startTime, @@ -213,9 +225,12 @@ const requestVercelStatus = (model: Model, context: PageContext): Return => ), ]) -/** Whether the connection on show can be added: a team, a token Vercel takes, and no source yet. */ -const canAddVercel = (model: Model): boolean => - Option.exists(AsyncData.getData(model.vercel.data), (status) => vercelStepOf(status)._tag === "Ready") +/** 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" @@ -422,13 +437,15 @@ export const update = (model: Model, message: Message, context: PageContext): Re 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: () => - model.sourceWrite !== "Idle" || !canAddVercel(model) - ? { model } - : { - model: { ...model, sourceWrite: "Saving", vercelError: null }, - commands: [AddVercelCostSource()], - }, + 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. CompletedAddVercel: ({ result }) => Result.match(result, { diff --git a/apps/web/src/page/cost/view-add-vercel.ts b/apps/web/src/page/cost/view-add-vercel.ts index bce08adbc0..9c4dd69271 100644 --- a/apps/web/src/page/cost/view-add-vercel.ts +++ b/apps/web/src/page/cost/view-add-vercel.ts @@ -24,7 +24,7 @@ type H = HtmlBuilder const CONNECT_VERCEL_HREF = hrefOf(AppRoute.SourcesNew({ search: { integration: "vercel" } })) /** Where the Vercel connection's token is replaced. */ -const VERCEL_INTEGRATION_HREF = hrefOf(AppRoute.Integrations({ search: { integration: "vercel" } })) +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 => @@ -51,7 +51,18 @@ const connection = (h: H, model: Model, step: VercelStep): ReadonlyArray = ]), ] case "Added": - return [teamRow(h, step.team, true)] + 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), @@ -91,15 +102,18 @@ const connection = (h: H, model: Model, step: VercelStep): ReadonlyArray = } } +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 [] + return step.isTokenRejected ? [connectionLink(h)] : [] case "TokenRejected": - return [linkButton(h, { href: VERCEL_INTEGRATION_HREF }, ["Open the Vercel connection"])] + return [connectionLink(h)] case "Ready": return [ button( diff --git a/apps/web/src/page/cost/view-sources.ts b/apps/web/src/page/cost/view-sources.ts index a526cd7bbf..bff00a7908 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,28 +31,63 @@ const STATUS = { Readonly<{ label: string; variant: "ok" | "info" | "muted" | "crit" }> > -const rowButton = (h: H, label: string, onClick: Message, isBusy: boolean): Html => - button(h, { variant: "ghost", size: "sm", isDisabled: isBusy, attributes: [h.OnClick(onClick)] }, [label]) +/** A row's action. Its accessible name says whose source it acts on: two rows can offer the same one. */ +const rowButton = (h: H, label: string, name: string, onClick: Message, isBusy: boolean): Html => + button( + h, + { + variant: "ghost", + size: "sm", + isDisabled: isBusy, + attributes: [h.AriaLabel(name), h.OnClick(onClick)], + }, + [label], + ) + +/** Where the source's connection is changed: an account's Cost module, a team's token. */ +const connectionLink = (h: H, provider: CostSourceSummary["provider"], href: string): Html => + linkButton( + h, + { + variant: "ghost", + size: "sm", + href, + attributes: [h.AriaLabel(`Manage ${COST_PROVIDERS[provider].name} connection`)], + }, + ["Manage connection"], + ) /** * What a row offers: an AWS source is its connection's, an OpenRouter one is its key, and a - * Vercel one is removed here while its connection stays. + * Vercel one is removed here while its connection, which holds the token, stays. */ const actions = (h: H, source: CostSourceSummary, isBusy: boolean): ReadonlyArray => { + const { name } = COST_PROVIDERS[source.provider] switch (source.provider) { case "openrouter": return [ - rowButton(h, "Replace key", Message.ClickedReplaceKey(), isBusy), - rowButton(h, "Remove", Message.ClickedRemoveSource({ provider: "openrouter" }), isBusy), + rowButton(h, "Replace key", `Replace ${name} key`, Message.ClickedReplaceKey(), isBusy), + rowButton( + h, + "Remove", + `Remove ${name} source`, + Message.ClickedRemoveSource({ provider: "openrouter" }), + isBusy, + ), ] case "vercel": - return [rowButton(h, "Remove", Message.ClickedRemoveSource({ provider: "vercel" }), isBusy)] - case "aws": return [ - linkButton(h, { variant: "ghost", size: "sm", href: AWS_INTEGRATION_HREF }, [ - "Manage connection", - ]), + connectionLink(h, "vercel", VERCEL_INTEGRATION_HREF), + rowButton( + h, + "Remove", + `Remove ${name} source`, + Message.ClickedRemoveSource({ provider: "vercel" }), + isBusy, + ), ] + case "aws": + return [connectionLink(h, "aws", AWS_INTEGRATION_HREF)] } } diff --git a/apps/web/src/page/cost/view.ts b/apps/web/src/page/cost/view.ts index 93a12f352b..3b5d7c9bd9 100644 --- a/apps/web/src/page/cost/view.ts +++ b/apps/web/src/page/cost/view.ts @@ -192,11 +192,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", + // Seven options outgrow a narrow page: 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, From afb8b860b694fe9a17d0744b1fe9fe77238fbfce Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 01:25:33 +0100 Subject: [PATCH 6/8] fix(cost): refuse a Vercel charge dated on a day the calendar does not have The check of ChargePeriodStart took any day from 01 to 31 in any month, so 2026-02-31T00:00:00Z passed. Its cost would be filed under a day that does not exist, which no window holds, and silently dropped. The day must now exist in its month, a February 29th in leap years only, or the line fails the pull as an invalid response. --- .../vercel/VercelCostProvider.test.ts | 35 +++++++++++++++++-- .../src/services/integrations/vercel/api.ts | 15 +++++++- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/services/cost/providers/vercel/VercelCostProvider.test.ts b/packages/backend/src/services/cost/providers/vercel/VercelCostProvider.test.ts index a4434b10be..89ad2182a9 100644 --- a/packages/backend/src/services/cost/providers/vercel/VercelCostProvider.test.ts +++ b/packages/backend/src/services/cost/providers/vercel/VercelCostProvider.test.ts @@ -1,6 +1,6 @@ import { afterEach, assert, describe, it } from "@effect/vitest" import { OrgId } from "@maple/domain/http" -import { DateTime, Effect, Layer, Schema, Array as Arr, String as Str } from "effect" +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" @@ -174,6 +174,11 @@ describe("VercelCostProvider", () => { // 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" })]), jsonl([{ BilledCost: 1, ChargePeriodStart: "2026-11-02T00:00:00Z" }]), ] const reasons = yield* Effect.forEach(unreadable, (body) => @@ -182,7 +187,33 @@ describe("VercelCostProvider", () => { return (yield* Effect.flip(pull(ORG, connectionRef))).reason }), ) - assert.deepStrictEqual(reasons, Arr.replicate("invalid_response", 6)) + assert.deepStrictEqual(reasons, Arr.replicate("invalid_response", 10)) + }), + ) + }) + + 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"], + ) }), ) }) diff --git a/packages/backend/src/services/integrations/vercel/api.ts b/packages/backend/src/services/integrations/vercel/api.ts index f8766cab2e..30179177d8 100644 --- a/packages/backend/src/services/integrations/vercel/api.ts +++ b/packages/backend/src/services/integrations/vercel/api.ts @@ -483,6 +483,16 @@ const optional = (schema: S) => Schema.optionalKey(Schema. 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. @@ -490,7 +500,10 @@ const UTC_DATE_TIME = export const VercelCharge = Schema.Struct({ BilledCost: Schema.Number, /** Inclusive, ISO 8601 in UTC: its first ten characters are the usage day. */ - ChargePeriodStart: Schema.String.check(Schema.isPattern(UTC_DATE_TIME)), + 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), From 5171c5cdaa3b0ddb2b92bed264bea7c4151cec60 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 01:44:06 +0100 Subject: [PATCH 7/8] fix(cost): key a Vercel source by its team id, decide a reconnect under the lock, refuse amounts that are not finite A Vercel source's account, and the account on its rows, is now the team's id. It was the slug, which a team can rename: rows already stored keep the account they were pulled with, so a renamed team read as two accounts. The sources read answers the team's slug as the account's name, for the page to show. A reconnect chose between keeping and removing the cost source from a read of the connection made before its transaction. A reconnect that committed in between could leave the source of one team on a connection to another, or delete the right one. The team is now read under the connection's lock, and the drain reset and the cost source follow that. A charge's cost and quantity must be finite: 1e309 is valid JSON and parses as Infinity. Lines that are each finite and sum past what a number holds fail the pull too. The 403 guidance names every role Vercel lets read billing: Security and Enterprise Viewer were missing. The sync test's Vercel stub has the read member its service type requires; the backend typecheck leaves test files out, so that was not caught. --- docs/cloud-cost-plan.md | 14 +- .../backend/src/queries/cost-reads.test.ts | 34 +++++ packages/backend/src/queries/cost-reads.ts | 28 +++- .../src/services/cost/CostSyncService.test.ts | 25 +++- .../cost/VercelCostSourceService.test.ts | 9 +- .../services/cost/VercelCostSourceService.ts | 3 +- .../vercel/VercelCostProvider.test.ts | 46 ++++++- .../providers/vercel/VercelCostProvider.ts | 19 ++- .../src/services/cost/vercel-cost-sources.ts | 6 +- .../VercelIntegrationService.test.ts | 56 +++++++- .../integrations/VercelIntegrationService.ts | 124 +++++++++--------- .../src/services/integrations/vercel/api.ts | 7 +- packages/domain/src/http/query-engine.ts | 2 + 13 files changed, 273 insertions(+), 100 deletions(-) diff --git a/docs/cloud-cost-plan.md b/docs/cloud-cost-plan.md index 9b93ee5f6b..99fb3508d9 100644 --- a/docs/cloud-cost-plan.md +++ b/docs/cloud-cost-plan.md @@ -235,10 +235,10 @@ against a fake server that answers what the spec describes. 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`, and its account is the team's - slug. Rows are stamped with the connection's slug, and every write of that slug (a reconnect - after the team renamed itself) rewrites the source's account in the same transaction, so one - team is never two accounts. + 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 @@ -256,7 +256,8 @@ against a fake server that answers what the spec describes. 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 ", and its services and regions wear the Vercel mark where no other + 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, @@ -267,7 +268,8 @@ against a fake server that answers what the spec describes. 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), and any answer but a 200 (a 204 or a 206 + 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 diff --git a/packages/backend/src/queries/cost-reads.test.ts b/packages/backend/src/queries/cost-reads.test.ts index 110c2995b0..ec54068384 100644 --- a/packages/backend/src/queries/cost-reads.test.ts +++ b/packages/backend/src/queries/cost-reads.test.ts @@ -639,6 +639,40 @@ describe("makeCostReads", () => { }, ) + it.effect("names a Vercel source by its team's slug, beside the team id its rows carry", () => { + const testDb = createTestDb(trackedDbs) + return Effect.gen(function* () { + yield* insertSources(testDb, [{ id: SOURCE_A }]) + yield* Effect.promise(() => + executeSql( + testDb, + `WITH source AS ( + INSERT INTO sources (id, org_id, name, platform, kind, created_at, updated_at) + VALUES (gen_random_uuid(), $1, 'Acme', 'vercel', 'integration', now(), now()) RETURNING id + ), connection AS ( + INSERT INTO vercel_connections + (id, org_id, source_id, team_id, team_slug, team_name, token_ciphertext, token_iv, token_tag, + connected_by_user_id, project_scope, created_at, updated_at) + SELECT 'conn_vercel', $1, id, 'team_1', 'acme-labs', 'Acme', 'sealed', 'iv', 'tag', 'user_a', 'all', now(), now() + FROM source RETURNING id + ) + INSERT INTO cost_sources (id, org_id, provider, connection_ref, account_id, created_at, updated_at) + SELECT $2, $1, 'vercel', id, 'team_1', now(), now() FROM connection`, + [ORG, SOURCE_B], + ), + ) + const { reads } = yield* makeHarness(testDb) + const { sources } = yield* reads.sources(tenant) + expect( + Arr.map(sources, (source) => [source.provider, source.accountId, source.accountName]), + ).toEqual([ + // An AWS account is known by its id: it carries no other name. + ["aws", "000000000000", undefined], + ["vercel", "team_1", "acme-labs"], + ]) + }).pipe(Effect.provide(testDb.layer)) + }) + it.effect("refuses a window wider than a cost read takes, before asking the warehouse", () => { const testDb = createTestDb(trackedDbs) return Effect.gen(function* () { diff --git a/packages/backend/src/queries/cost-reads.ts b/packages/backend/src/queries/cost-reads.ts index 7f621b28c8..9ccd376fa8 100644 --- a/packages/backend/src/queries/cost-reads.ts +++ b/packages/backend/src/queries/cost-reads.ts @@ -1,7 +1,7 @@ // Cloud cost reads for the dashboard: the registry queries over `cost_daily`, narrowed to the // org's connected sources and shaped for the wire, and the sources' own sync state from Postgres. import * as PG from "@maple-dev/effect-orm/postgres" -import { CostSources, type CostSourceRow } from "@maple/db/tables" +import { CostSources, type CostSourceRow, VercelConnections } from "@maple/db/tables" import { CloudCostDisabledError, CloudCostPersistenceError, @@ -119,13 +119,18 @@ const estimatedFromOf = (rows: ReadonlyArray) => ), ) -const toSourceSummary = (row: CostSourceRow, nowMs: number): CostSourceSummary => { +const toSourceSummary = ( + row: CostSourceRow, + nowMs: number, + accountName: string | undefined, +): CostSourceSummary => { const historyComplete = row.backfillDate !== null && row.backfillDate <= historyFloor(row.provider, utcToday(nowMs)) return { id: row.id, provider: row.provider, accountId: row.accountId, + ...(accountName === undefined ? undefined : { accountName }), status: !row.enabled ? "paused" : row.lastError !== null @@ -308,7 +313,24 @@ export const makeCostReads = (deps: CostReadsDeps) => { const sources = Effect.fn("CostReads.sources")(function* (tenant: TenantContext) { const rows = yield* sourcesInRollout(tenant.orgId) const nowMs = yield* Clock.currentTimeMillis - return { sources: Arr.map(rows, (row) => toSourceSummary(row, nowMs)) } + // A Vercel source's account is its team's id, which is stable; the team's slug names it. + const teams = Arr.some(rows, (row) => row.provider === "vercel") + ? yield* dbExecute((db) => + db.run( + PG.from(VercelConnections) + .select("id", "teamSlug") + .where(($) => [$.orgId.eq(tenant.orgId)]), + ), + ) + : [] + const nameOf = (row: CostSourceRow) => + row.provider === "vercel" + ? Arr.findFirst(teams, (team) => team.id === row.connectionRef).pipe( + Option.map((team) => team.teamSlug), + Option.getOrUndefined, + ) + : undefined + return { sources: Arr.map(rows, (row) => toSourceSummary(row, nowMs, nameOf(row))) } }) return { totals, timeseries, breakdown, sources } as const diff --git a/packages/backend/src/services/cost/CostSyncService.test.ts b/packages/backend/src/services/cost/CostSyncService.test.ts index 5e6e9c478c..d901d30939 100644 --- a/packages/backend/src/services/cost/CostSyncService.test.ts +++ b/packages/backend/src/services/cost/CostSyncService.test.ts @@ -29,7 +29,11 @@ import { type CostProviderAdapter, CostProviderError, type CostPullRow } from ". import { CostSyncService } from "./CostSyncService" import { AwsCostProvider } from "./providers/aws/AwsCostProvider" import { OpenRouterCostProvider } from "./providers/openrouter/OpenRouterCostProvider" -import { VercelCostProvider } from "./providers/vercel/VercelCostProvider" +import { + VercelCostProvider, + type VercelCostProviderApi, + VercelCostReadError, +} from "./providers/vercel/VercelCostProvider" import { BACKFILL_REQUEST_BUDGET, daysOfWindow, MONTHLY_REQUEST_BUDGET } from "./sync-plan" const trackedDbs: TestDb[] = [] @@ -199,6 +203,19 @@ const harness = (options: FakeProviderOptions = {}) => { } }), }) + // The sync only pulls; `read` is what an add checks with, answered the same way here. + const vercelPull = adapterOf("vercel") + const vercelAdapter: VercelCostProviderApi = { + ...vercelPull, + read: (source, window) => + vercelPull + .pull(source, window) + .pipe( + Effect.mapError( + ({ message, reason }) => new VercelCostReadError({ message, reason, kind: "other" }), + ), + ), + } const writes: Array> = [] const datasources: string[] = [] const tenants: string[] = [] @@ -242,7 +259,7 @@ const harness = (options: FakeProviderOptions = {}) => { Layer.mergeAll( Layer.succeed(AwsCostProvider, adapterOf("aws")), Layer.succeed(OpenRouterCostProvider, adapterOf("openrouter")), - Layer.succeed(VercelCostProvider, adapterOf("vercel")), + Layer.succeed(VercelCostProvider, vercelAdapter), warehouse, featureFlags, ), @@ -301,7 +318,7 @@ const harness = (options: FakeProviderOptions = {}) => { 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, 'acme', now(), now())`, + VALUES ('0190f2b3-0000-7000-8000-0000000000f1', 'org_a', 'vercel', $1, 'team_1', now(), now())`, [connection.id], ) }) @@ -542,7 +559,7 @@ describe("CostSyncService Vercel sources", () => { yield* poll assert.deepStrictEqual(yield* h.sourceRefs, [ ["org_a", CONNECTION_A, "123456789012"], - ["org_a", VERCEL_CONNECTION_A, "acme"], + ["org_a", VERCEL_CONNECTION_A, "team_1"], ]) yield* h.sql("DELETE FROM vercel_connections") diff --git a/packages/backend/src/services/cost/VercelCostSourceService.test.ts b/packages/backend/src/services/cost/VercelCostSourceService.test.ts index e88b869463..01a5aa3d11 100644 --- a/packages/backend/src/services/cost/VercelCostSourceService.test.ts +++ b/packages/backend/src/services/cost/VercelCostSourceService.test.ts @@ -103,10 +103,11 @@ describe("VercelCostSourceService", () => { }) assert.deepStrictEqual(world.urls, [CHECK_URL]) assert.deepStrictEqual(world.authorizations, ["Bearer vc_token_abc"]) - // The source points at the connection and is named after the team's slug. + // The source points at the connection. assert.deepStrictEqual(yield* source(testDb), { connection_ref: connectionRef, - account_id: "acme", + // The team's id: its slug can be renamed, and rows already stored keep their account. + account_id: "team_1", enabled: true, sources: 1, }) @@ -154,7 +155,7 @@ describe("VercelCostSourceService", () => { 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 or Billing role.", + "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", @@ -313,7 +314,7 @@ describe("VercelCostSourceService", () => { 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, "beta") + assert.strictEqual((yield* source(testDb))?.account_id, "team_2") }), ) }) diff --git a/packages/backend/src/services/cost/VercelCostSourceService.ts b/packages/backend/src/services/cost/VercelCostSourceService.ts index 05a360b238..9b35243693 100644 --- a/packages/backend/src/services/cost/VercelCostSourceService.ts +++ b/packages/backend/src/services/cost/VercelCostSourceService.ts @@ -192,7 +192,8 @@ export class VercelCostSourceService extends Context.Service< orgId, provider: "vercel", connectionRef: connection.id, - accountId: connection.teamSlug, + // The team's id, not its slug: a renamed team stays one account. + accountId: connection.teamId, createdAt: now, updatedAt: now, }) diff --git a/packages/backend/src/services/cost/providers/vercel/VercelCostProvider.test.ts b/packages/backend/src/services/cost/providers/vercel/VercelCostProvider.test.ts index 89ad2182a9..b00bb6f9dc 100644 --- a/packages/backend/src/services/cost/providers/vercel/VercelCostProvider.test.ts +++ b/packages/backend/src/services/cost/providers/vercel/VercelCostProvider.test.ts @@ -40,6 +40,12 @@ const run = ( 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> = {}) => ({ @@ -83,7 +89,7 @@ describe("VercelCostProvider", () => { assert.deepStrictEqual(world.urls, [CHARGES_URL]) assert.deepStrictEqual(world.authorizations, ["Bearer vc_token_abc"]) assert.strictEqual(pulled.requests, 0) - // Rows carry the team's slug, lines of one key are summed, and today can still change. + // 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, @@ -95,8 +101,8 @@ describe("VercelCostProvider", () => { row.estimated, ]), [ - ["2026-11-02", "Function Duration", "web", "iad1", 3.5, "acme", false], - ["2026-11-04", "Function Duration", "web", "global", 4, "acme", true], + ["2026-11-02", "Function Duration", "web", "iad1", 3.5, "team_1", false], + ["2026-11-04", "Function Duration", "web", "global", 4, "team_1", true], ], ) }), @@ -179,6 +185,10 @@ describe("VercelCostProvider", () => { 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) => @@ -187,7 +197,7 @@ describe("VercelCostProvider", () => { return (yield* Effect.flip(pull(ORG, connectionRef))).reason }), ) - assert.deepStrictEqual(reasons, Arr.replicate("invalid_response", 10)) + assert.deepStrictEqual(reasons, Arr.replicate("invalid_response", 13)) }), ) }) @@ -218,6 +228,27 @@ describe("VercelCostProvider", () => { ) }) + 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()] }) @@ -266,7 +297,10 @@ describe("VercelCostProvider", () => { ]) // A role without billing access is told which roles have it. world.chargesStatus = 403 - assert.include((yield* Effect.flip(pull(ORG, connectionRef))).message, "Billing role") + 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)) @@ -321,7 +355,7 @@ describe("VercelCostProvider", () => { const error = yield* Effect.flip( run(testDb, world, pull(ORG, connectionRef), "not-a-32-byte-key"), ) - assert.deepStrictEqual([error.reason, world.urls], ["auth", []]) + assert.deepStrictEqual(["reason" in error ? error.reason : error._tag, world.urls], ["auth", []]) }) }) diff --git a/packages/backend/src/services/cost/providers/vercel/VercelCostProvider.ts b/packages/backend/src/services/cost/providers/vercel/VercelCostProvider.ts index 78cf14daab..29d9ca9f6a 100644 --- a/packages/backend/src/services/cost/providers/vercel/VercelCostProvider.ts +++ b/packages/backend/src/services/cost/providers/vercel/VercelCostProvider.ts @@ -4,7 +4,7 @@ */ import * as PG from "@maple-dev/effect-orm/postgres" import { VercelConnections } from "@maple/db/tables" -import { Clock, Context, Duration, Effect, Layer, Schema, Stream } from "effect" +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" @@ -16,6 +16,7 @@ import { type CostProviderAdapter, CostProviderError, type CostPull, + type CostPullRow, type CostPullSource, type CostPullWindow, } from "../../CostProvider" @@ -29,6 +30,9 @@ 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 @@ -61,7 +65,7 @@ const toReadError = (error: VercelApiError): VercelCostReadError => { 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 or Billing role.", + "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": @@ -152,9 +156,9 @@ export class VercelCostProvider extends Context.Service - addCharge(sums, charge, row.teamSlug, window, openDay), + addCharge(sums, charge, row.teamId, window, openDay), ), // The page shows the mapped message; which failure it was stays on the span. Effect.tapError((error) => @@ -171,6 +175,13 @@ export class VercelCostProvider extends Context.Service /** * 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), a pull in flight on the old token loses its claim, - * and the account follows the team's slug, which the connection may just have rewritten. + * 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, teamSlug: string, now: number) => +export const releaseVercelCostSources = (db: MapleDb, orgId: OrgId, now: number) => db.run( PG.update(CostSources) .set({ - accountId: teamSlug, lastSyncedAt: null, leaseUntil: null, lastError: null, diff --git a/packages/backend/src/services/integrations/VercelIntegrationService.test.ts b/packages/backend/src/services/integrations/VercelIntegrationService.test.ts index 9428d0911b..8090e7b606 100644 --- a/packages/backend/src/services/integrations/VercelIntegrationService.test.ts +++ b/packages/backend/src/services/integrations/VercelIntegrationService.test.ts @@ -1409,7 +1409,7 @@ describe("VercelIntegrationService", () => { 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_slug, now(), now() FROM vercel_connections`, + SELECT gen_random_uuid()::text, org_id, 'vercel', id, team_id, now(), now() FROM vercel_connections`, ), ) const held = Effect.promise(() => @@ -1453,10 +1453,10 @@ describe("VercelIntegrationService", () => { { 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 - // follows the slug its rows are stamped with from here on. + // 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, ["acme-labs"]) + 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, @@ -1471,7 +1471,7 @@ describe("VercelIntegrationService", () => { assert.deepStrictEqual(yield* costSources, []) yield* addCostSource - assert.deepStrictEqual(yield* costSources, ["beta"]) + assert.deepStrictEqual(yield* costSources, ["team_2"]) yield* disconnect() assert.deepStrictEqual(yield* costSources, []) }), @@ -1479,6 +1479,52 @@ describe("VercelIntegrationService", () => { }, ) + 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 e5364d3acc..545a7af8cd 100644 --- a/packages/backend/src/services/integrations/VercelIntegrationService.ts +++ b/packages/backend/src/services/integrations/VercelIntegrationService.ts @@ -87,6 +87,24 @@ import { } 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 /** One org's pass must end inside its lease; what is left resumes next tick. */ @@ -1197,68 +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)), - ).pipe( + 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. - Effect.tap(() => - sameTeam - ? Effect.asVoid(releaseVercelCostSources(db, orgId, team.slug, now)) - : Effect.asVoid(removeVercelCostSources(db, orgId)), - ), - ), + 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 30179177d8..f267f29f5e 100644 --- a/packages/backend/src/services/integrations/vercel/api.ts +++ b/packages/backend/src/services/integrations/vercel/api.ts @@ -498,7 +498,8 @@ const startsWithRealDay = (value: string): boolean => * recorded answer, so everything Maple can do without is optional and may be null. */ export const VercelCharge = Schema.Struct({ - BilledCost: Schema.Number, + // 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), @@ -510,9 +511,9 @@ export const VercelCharge = Schema.Struct({ /** `Usage`, `Purchase`, `Credit`, `Tax` or `Adjustment`. */ ChargeCategory: optional(Schema.String), /** Null when the charge is not usage. */ - ConsumedQuantity: optional(Schema.Number), + ConsumedQuantity: optional(Schema.Finite), ConsumedUnit: optional(Schema.String), - EffectiveCost: optional(Schema.Number), + EffectiveCost: optional(Schema.Finite), RegionId: optional(Schema.String), /** A FOCUS category: `Compute`, `Networking`, `Storage`, ... */ ServiceCategory: optional(Schema.String), diff --git a/packages/domain/src/http/query-engine.ts b/packages/domain/src/http/query-engine.ts index b362946620..da216c6b10 100644 --- a/packages/domain/src/http/query-engine.ts +++ b/packages/domain/src/http/query-engine.ts @@ -3277,6 +3277,8 @@ export const CostSourceSummary = Schema.Struct({ id: CostSourceId, provider: CostProvider, accountId: Schema.String, + /** How a reader knows the account, where its id is not it: a Vercel team's slug beside its team id. */ + accountName: Schema.optionalKey(Schema.String), status: CostSourceStatus, /** The newest and oldest UTC day pulled, `YYYY-MM-DD`; null before the first pull. */ watermarkDate: Schema.NullOr(Schema.String), From 4c047248304328145f50126b2f27ecb0f49438cc Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 01:44:06 +0100 Subject: [PATCH 8/8] fix(cost): empty the cost on show when a source is removed, show the team connected now after a changed-connection refusal A removal that left another synced source kept the old totals, series, breakdown and rail on screen while they were read again, and a reread that failed left the removed source's cost beside its error. The four are emptied when the removal answers, for every provider, keeping their keys so a read from before it cannot answer for the new one. An add refused because the connection changed kept the old team in the dialog, and the next click sent the same team again. The connection is read again after that refusal, so the dialog shows the team to add now. A Vercel team is labelled by the slug the sources read names it by, with its team id as the key. The role guidance names all six roles. --- apps/web/src/lab/cost-fixture.ts | 5 +- apps/web/src/lab/cost-lab.ts | 2 +- apps/web/src/page/cost/cost.scene.test.ts | 115 ++++++++++++++++++++-- apps/web/src/page/cost/marks.test.ts | 13 ++- apps/web/src/page/cost/marks.ts | 16 +-- apps/web/src/page/cost/update.ts | 53 +++++++--- apps/web/src/page/cost/view-add-vercel.ts | 2 +- apps/web/src/page/cost/view-sources.ts | 2 +- 8 files changed, 170 insertions(+), 38 deletions(-) diff --git a/apps/web/src/lab/cost-fixture.ts b/apps/web/src/lab/cost-fixture.ts index 420992a117..1ca6b2a288 100644 --- a/apps/web/src/lab/cost-fixture.ts +++ b/apps/web/src/lab/cost-fixture.ts @@ -80,7 +80,7 @@ const productLine = ( region, usage_type: sku, resource: project, - account: LAB_VERCEL_TEAM.slug, + account: LAB_VERCEL_TEAM.id, daily, drift, }) @@ -310,7 +310,8 @@ const openRouterSource = source({ const vercelSource = source({ id: "0190f2b3-0000-7000-8000-00000000c061", provider: "vercel", - accountId: LAB_VERCEL_TEAM.slug, + accountId: LAB_VERCEL_TEAM.id, + accountName: LAB_VERCEL_TEAM.slug, backfillDate: "2025-12-01", lastSyncedAt: LAB_NOW_MS - 2 * 3_600_000, requestsThisMonth: 0, diff --git a/apps/web/src/lab/cost-lab.ts b/apps/web/src/lab/cost-lab.ts index a508ead913..c7c85ff852 100644 --- a/apps/web/src/lab/cost-lab.ts +++ b/apps/web/src/lab/cost-lab.ts @@ -182,7 +182,7 @@ const refusedKey = { const refusedVercel = { ...toLoadFailure("refused"), message: - "This Vercel token's role cannot read the team's billing. Use a token from an Owner, Member, Developer or Billing role.", + "This Vercel token's role cannot read the team's billing. Use a token from an Owner, Member, Developer, Security, Billing or Enterprise Viewer role.", } /** Whether the state starts with a source of the provider. */ diff --git a/apps/web/src/page/cost/cost.scene.test.ts b/apps/web/src/page/cost/cost.scene.test.ts index 63eb6ffd86..1cc8248c72 100644 --- a/apps/web/src/page/cost/cost.scene.test.ts +++ b/apps/web/src/page/cost/cost.scene.test.ts @@ -477,7 +477,9 @@ describe("cost page", () => { dialogMounted, expectView(text("Acme Inc")).toExist(), expectView(text("acme")).toExist(), - expectView(text(/Owner, Member, Developer or Billing role/)).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(), ) @@ -567,7 +569,7 @@ describe("cost page", () => { 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 or Billing role." + "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({ @@ -594,6 +596,49 @@ describe("cost page", () => { 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. @@ -682,7 +727,8 @@ describe("cost page", () => { const vercel = source({ id: "0190f2b3-0000-7000-8000-00000000c061", provider: "vercel", - accountId: "acme", + accountId: "team_1", + accountName: "acme", backfillDate: "2025-12-01", requestsThisMonth: 0, }) @@ -760,6 +806,55 @@ describe("cost page", () => { "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 }, @@ -780,7 +875,8 @@ describe("cost page", () => { const vercel = source({ id: "0190f2b3-0000-7000-8000-00000000c061", provider: "vercel", - accountId: "acme", + accountId: "team_1", + accountName: "acme", }) const removals: ReadonlyArray< Readonly<{ only: SourceWire; provider: RemovableProvider; removed: Message }> @@ -847,7 +943,7 @@ describe("cost page", () => { dimension === "resource" ? Arr.map(resources, (key) => ({ key, cost: 40, previousCost: 30 })) : dimension === "account" - ? [{ key: "acme", cost: 40, previousCost: 30 }] + ? [{ key: "team_1", cost: 40, previousCost: 30 }] : breakdown.rows, ), ), @@ -858,7 +954,8 @@ describe("cost page", () => { const vercel = source({ id: "0190f2b3-0000-7000-8000-00000000c061", provider: "vercel", - accountId: "acme", + 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. @@ -905,7 +1002,8 @@ describe("cost page", () => { const vercel = source({ id: "0190f2b3-0000-7000-8000-00000000c061", provider: "vercel", - accountId: "acme", + accountId: "team_1", + accountName: "acme", }) const model = answered(entered({}, [openRouter, vercel]).model) const removing = pageUpdate( @@ -1086,7 +1184,8 @@ describe("cost page", () => { ...unsynced, id: "0190f2b3-0000-7000-8000-00000000c061", provider: "vercel", - accountId: "acme", + accountId: "team_1", + accountName: "acme", status: "pending", }) scene( diff --git a/apps/web/src/page/cost/marks.test.ts b/apps/web/src/page/cost/marks.test.ts index bdd04c7c59..c5cfb5d169 100644 --- a/apps/web/src/page/cost/marks.test.ts +++ b/apps/web/src/page/cost/marks.test.ts @@ -61,12 +61,15 @@ describe("cost key marks", () => { }) test("a Vercel account reads as the team it is, with the Vercel mark and nothing aside", () => { - const vercel = [...sources, source("vercel", "acme", "0195f1c2-7a3b-7c4d-8e5f-6a7b8c9d0e21")] - // The slug is how the team is known, so it stays in the label also while it is the only team. - expect(keyLabel("account", "acme", vercel)).toBe("Vercel acme") - expect(keyDetail("account", "acme", vercel)).toBeNull() - expect(keyMark("account", "acme", vercel)?.icon).toBe(keyMark("provider", "vercel", [])?.icon) + // 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", () => { diff --git a/apps/web/src/page/cost/marks.ts b/apps/web/src/page/cost/marks.ts index e699eba314..04d916819a 100644 --- a/apps/web/src/page/cost/marks.ts +++ b/apps/web/src/page/cost/marks.ts @@ -118,20 +118,22 @@ export const keyMark = ( } } -/** Providers whose account id a reader knows the account by: an AWS account id, a Vercel team's slug. */ -const NAMED_BY_ID: ReadonlyArray = ["aws", "vercel"] - /** - * An account by what reads it: an AWS one by its id, a Vercel one by its team's slug, an - * OpenRouter one by name alone while it is the org's only one (its id means nothing to a reader). - * Unknown ids read as they are. + * An account by what reads it: an AWS one by its id, a Vercel one by its team's slug (its id is a + * team id, kept stable through a rename), an OpenRouter one by name alone while it is the org's + * only one (its id means nothing to a reader). Unknown ids read as they are. */ export const accountLabel = (key: string, sources: ReadonlyArray): string => { const provider = accountProvider(key, sources) if (provider === undefined) return key const name = COST_PROVIDERS[provider].name - if (Arr.contains(NAMED_BY_ID, provider)) return `${name} ${key}` + if (provider === "aws") return `${name} ${key}` const sameProvider = Arr.filter(sources, (source) => 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/update.ts b/apps/web/src/page/cost/update.ts index 2b00087e30..a550c08f6e 100644 --- a/apps/web/src/page/cost/update.ts +++ b/apps/web/src/page/cost/update.ts @@ -1,9 +1,4 @@ -import { - CostSourcesResponse, - OPENROUTER_KEY_MAX_LENGTH, - type VercelCostStatus, - type VercelCostTeam, -} from "@maple/domain/http" +import { CostSourcesResponse, OPENROUTER_KEY_MAX_LENGTH, type VercelCostTeam } from "@maple/domain/http" import * as Policy from "@maple/domain/policy" import { Array as Arr, Option, Result, String as Str } from "effect" import { AsyncData, Command } from "foldkit" @@ -68,6 +63,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 @@ -242,15 +240,24 @@ const clearedKey = (model: Model): Model => ({ 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. The key stays, so an older read in flight cannot answer. + * and a read that fails says so. */ const requestVercelStatus = (model: Model, context: PageContext): Return => applySteps(model, [ keyed( - { ...Keyed.idle(), key: model.vercel.key }, + emptied(model.vercel), requestKey(context.orgId, "vercelCostStatus", {}), "Supersede", (vercel) => ({ vercel }), @@ -312,9 +319,21 @@ const removed = ( failure: Option.Option, context: PageContext, ): Return => { + // The cost on show includes the removed source's: it goes with the row, so the reads that + // follow start empty and one that fails leaves no removed cost beside its error. const closed = liftRemoveDialog( Option.isNone(failure) - ? withoutSourceOf({ ...model, sourceWrite: "Settling" }, provider) + ? withoutSourceOf( + { + ...model, + sourceWrite: "Settling", + totals: emptied(model.totals), + timeseries: emptied(model.timeseries), + breakdown: emptied(model.breakdown), + facets: emptied(model.facets), + }, + provider, + ) : { ...model, sourceWrite: "Idle" }, Dialog.close(model.removeDialog), ) @@ -493,12 +512,20 @@ export const update = (model: Model, message: Message, context: PageContext): Re commands: [AddVercelCostSource({ teamId: team.id })], }), }), - // A refused add keeps the dialog open with Vercel's reason under the team. + // 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 => ({ - model: { ...model, sourceWrite: "Idle", vercelError: failureMessage(failure) }, - }), + 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), }), diff --git a/apps/web/src/page/cost/view-add-vercel.ts b/apps/web/src/page/cost/view-add-vercel.ts index 9c4dd69271..7be1fc6af9 100644 --- a/apps/web/src/page/cost/view-add-vercel.ts +++ b/apps/web/src/page/cost/view-add-vercel.ts @@ -76,7 +76,7 @@ const connection = (h: H, model: Model, step: VercelStep): ReadonlyArray = 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 or Billing role on the team.", + "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 diff --git a/apps/web/src/page/cost/view-sources.ts b/apps/web/src/page/cost/view-sources.ts index bff00a7908..24e453667b 100644 --- a/apps/web/src/page/cost/view-sources.ts +++ b/apps/web/src/page/cost/view-sources.ts @@ -166,7 +166,7 @@ const sourceRow = ( hstack(h, { gap: "2", wrap: true }, [ typography(h, { size: "sm", weight: "medium" }, [provider.name]), typography(h, { size: "xs", tone: "muted", mono: true, numeric: true }, [ - source.accountId, + source.accountName ?? source.accountId, ]), badge(h, { variant: status.variant, size: "sm" }, [status.label]), ]),