Commit Graph
3 Commits
Author SHA1 Message Date
Nicky LeachandClaude Opus 5.5 11ac90e31f fix(build): typecheck server/src/__tests__ and fix every type error (#18739)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The server has about 1,100 test and helper files in
`server/src/__tests__`
> - `server/tsconfig.json` excludes that directory, Vitest does not
check types, and no script runs the tsconfig in that directory
> - So a test can call a function with the wrong arguments and still
pass, and no tool reports it
> - This gap hid 2,168 real type errors, and some of them hid tests that
did not test what they claim
> - This pull request fixes all of these errors and adds the directory
to the server layers from paperclipai/paperclip#18737
> - The benefit is that a type error in a server test now fails the
typecheck, with no baseline and no backlog

## Linked Issues or Issue Description

Refs paperclipai/paperclip#18737, which is merged and added the server
layers. Refs paperclipai/paperclip#18738, which is merged and checks the
test projects outside `pnpm -r`, so it is not a duplicate. Refs
paperclipai/paperclip#18137, which records that this directory has no
check.

**What happened?**

No tool checked the types of 1,074 of the 1,101 files in
`server/src/__tests__`. I searched `package.json`, the workflows, and
all tsconfig files. 27 files are in the program of another tsconfig, and
these 27 have 0 errors. 11 of the 27 are in
`helpers/maintenance/tsconfig.json`. No script ran that project when I
measured this. paperclipai/paperclip#18738 now runs it.

**Expected behavior**

The server typecheck checks these files, and a type error fails the
check.

**Steps to reproduce**

1. On the base commit, run `tsc -p server/src/__tests__/tsconfig.json
--noEmit`. It reports 6,536 errors and needs 12.3 GiB.
2. Add a type error to any file in `server/src/__tests__`.
3. Run `pnpm --filter @paperclipai/server typecheck`. It passes.

**Paperclip version or commit**

I measured the error breakdown below on commit `8dfd632452`, with
TypeScript 7.0.2. The branch now includes master up to
paperclipai/paperclip#18865.

**Deployment mode**

Not applicable. This issue applies to development and CI. No production
source file in the server changes.

### Error breakdown

Each row adds one change to the row above.

| Step | Total errors | Change | Cause |
| --- | --- | --- | --- |
| Config as committed | 6,536 | | |
| Add `src/types/*.d.ts` | 4,447 | -2,089 | Config. The Express request
types were missing. |
| Set `rootDir` to the repository root | 2,436 | -2,011 | Config. |
| Set `allowImportingTsExtensions` | 2,181 | -255 | Config. Vitest
accepts `.ts` imports. |
| Add `ES2024.Promise` to `lib` | 2,138 | -43 | Config. Node 24 has
`Promise.withResolvers`. |
| Set `allowJs` for `.mjs` imports | 2,148 | +10 | Config. It removes 8
`TS7016` errors and shows new real errors. |
| Set `declaration` to false | 2,142 | -6 | Config. Tests emit nothing.
|
| Count only files in `server/src` | 2,058 | -84 | Other packages: 53
Daytona, 29 Runner test, 2 UI. |

So 4,478 of the 6,536 errors (69%) came from config or scope, and 2,058
are real type errors in 314 files. The 29 Runner test errors are also
real, which gives 2,087 errors. Tests that master added after this
measurement had 81 more errors, so this pull request fixes 2,168 errors.

This table classifies the first 2,058 errors.

| Kind of real error | Errors |
| --- | --- |
| Wrong argument or assignment type | 1,023 |
| Stale or partial fixture objects | 525 |
| Property that the type does not have | 317 |
| Missing narrowing of null or unknown | 101 |
| Implicit any | 60 |
| Missing name or module | 16 |
| Other | 16 |

### What the type errors hid

Most errors were stale fixtures and loose mocks. These errors hid real
test problems, and this pull request fixes them:

- **A test that never ran.** `instrumentation.test.ts` required
`sdk-trace-base`, which is not installed. So 3 span tests were skipped.
They now run and pass.
- **Assertions that could not fail.**
- `issue-thread-interactions-service` used a company ID as an issue ID.
- `status-card-evaluation-work` compared `[]` with `[]` in a rollback
check.
- `slack-conversation-lifecycle` read `item.issueId`, which does not
exist.
  - `email-channels` used a permission key that does not exist.
- `heartbeat-project-env` imported `LOW_TRUST_REVIEW_PRESET`, which is
not exported. The value was undefined.
- **Postgres processes that did not stop.** 11 test files called `stop`
on the test database helper. That method does not exist, and the helper
method is `cleanup`.
- **Arguments that the service ignored.**
- `methodKey` is now `connectionMethodKey` in the Daytona, Unikraft,
1Password, and Railway tests.
- 108 calls passed `"owner-user"` to a parameter that expects an options
object.
- 9 chat wizard fixtures passed `managementContext` as `source`, so they
ran `existing_app` and not `manifest`.
  - 2 tests passed a `{ userId }` actor that the function ignores.
- Inserts into `environments` passed a `companyId` column that the table
does not have.
- **Problems in tests that master added during this work.**
- `sandbox-creation-operations` read `database.connectionString` when an
external test database was set. In that mode `database` is undefined, so
the fixture would stop with a TypeError. It now uses the external URL.
- `idle-completed-history` set a Dot operation to `dispatched`. That is
a Muse status, and the Dot type does not have it. The fixture now uses
`admitted`, which is a real in-flight Dot status.

### Possible product problems, not changed

These may be real problems in production code or types. I did not change
them, because this pull request does not change production behavior:

- `decisions-service` `service.create` does not validate the lenient
`cancel_issue_tree` input.
- `createConnection` ignores `input.connectionPurpose`.
- The `createConnection` route schema default stores `{}` for
`transportConfig`.
- `prepareManagedAiRuntime` accepted the invalid value `mode:
"connection"`.
- `createBinding` accepts invalid `projectionClass` strings outside
class 3.
- `ensureManagedProjectWorkspace` forwards an untyped `repoRef` by
spread.
- `routineVariableSchema.label` is optional, but `RoutineVariable.label`
is required.
- `ToolRunContext.projectId` may be too strict.
- `withdrawInteraction` reads `issue.status`, which its type omits.
- `issueService.list` and `reconcileNativeFinalizations` omit fields
that they return at runtime.
- `createGitHubExternalObjectProvider` handles `tokenProvider: null`,
but its type forbids this value.

## What Changed

- Fixed the 2,168 type errors. The diff changes 322 test and helper
files.
- Corrected `server/src/__tests__/tsconfig.json`. Editors and the
typecheck now read the same options and report the same errors.
- Added three `test-tree` layers to
`server/scripts/typecheck-layers.mjs`. They read the server layers as
declarations, and any error fails them. A hash of the path selects the
shard, so a new file does not move other files.
- Added `server/scripts/typecheck-stubs/daytona-sdk.d.ts`. Server tests
reach Daytona plugin source, and the root install does not have
`@daytonaio/sdk`.
- Changed two imports in `ui/src/lib/issue-execution-policy.ts` to use
`.js` extensions, because server tests import this file.
- Made `declarationsConfig` and `--declarations-only` skip the
`test-tree` layers, because these layers emit nothing. Other projects
read only the source layers. Added a test for this.
- Updated `doc/DEVELOPING.md`.

## Verification

Run these commands:

```sh
pnpm --filter @paperclipai/server typecheck
pnpm exec vitest run server/scripts/typecheck-layers.test.mjs
```

Results on macOS (arm64), Node 24, 16 cores. Each check ran after the
merge of paperclipai/paperclip#18738 unless the row says otherwise:

| Check | Commit | Result |
| --- | --- | --- |
| Server typecheck, `PAPERCLIP_TYPECHECK_LOW_MEMORY=1`, all 7 layers |
`6e45a08573` | Exit 0, 0 errors, 62 seconds, peak 3.52 GiB |
| `node scripts/typecheck-test-projects.mjs --low-memory` | `a3306bd13e`
| Exit 0, 0 errors, 42 seconds, peak 3.25 GiB |
| The same script with `--reuse-declarations` | `1b878aabc0` | Exit 0, 0
errors |
| `typecheck-layers.test.mjs` and `live-recovery-work.test.ts` |
`01db5634e4` | 90 passed. These include real compiler runs for a pass, a
type error, and an invalid tsconfig. |
| `tsconfig.declarations.json` after a full server typecheck |
`0fb1e4fb0a` | It references only `below`, `cycle`, and `above`. No
generated config names a `tsconfig.layers.json` file. |
| Owned runtime test in `idle-completed-history` | `e8f2f448ac` |
Passed, with the fix that master later replaced |
| `idle-incoming-wake`, `plugin-idle-manager`, and the changed
`chat-channels` test | `6e45a08573` | 30 passed |
| Runner `native-execution.test.ts` | `eab4dcc1f4` | 114 passed |
| The 19 server test files that changed since `eab4dcc1f4`, and
`typecheck-layers.test.mjs` | `259737b700` | 4,275 of 4,277 passed. The
2 failures are in `chat-channels` and are in the list of 7 below. |

The peak is the largest single process, from `/usr/bin/time -l`. Peaks
for the server typecheck were between 3.42 GiB and 3.56 GiB across 8
runs. Peaks for the test projects were 3.25 GiB and 3.37 GiB across 2
runs. I did not measure a cgroup.

**Runtime check of the fixes.** Types do not prove that a test still
works. So I ran all 309 changed server test files, 11,929 tests, before
the rebase onto paperclipai/paperclip#18737:

| Result | Tests | Detail |
| --- | --- | --- |
| Passed | 11,913 | Includes the 2 tests and the 54 `dot-runner` tests
that passed in a later run, as described below. |
| Failed | 7 | The same 7 tests also fail on the commit before the
fixes. |
| Skipped | 9 | These tests have their own skip conditions: `sentry` 5,
and 1 each in `bundled-plugin-registration`, `customer-success-cloud`,
`onepassword-connection`, and `workspace-runtime-https-live-exercise`. |

The 7 tests that fail with and without this change, on my machine:

- 3 tests in `chat-channels` and `email-channels` stop with `ENOENT` on
a `SKILL.md` file. The path is in the parent checkout, above the git
worktree that I used, so I expect that the worktree is the cause. I did
not prove this.
- `workspace-instance-cleanup` compares `/private/var/...` with
`/var/...`. This is a macOS symlink difference.
- 2 tests in `git-reproducible-managed-checkouts` get
`unknown_base_git_files` and expect `unpushed_managed_checkout`. I did
not find the cause.
- 1 more test in `chat-channels`, `materializes direct external-chat
finals once`, gets `undefined`. I did not find the cause.

CI is the check for these 7 tests. `ci / verify` passed on `eab4dcc1f4`.

Failures during this work:

- My fix to `agent-runtime-connector-access` changed an invalid
permission key in the setup, but not in the assertion. The runtime check
found this. I corrected the assertion, and the file passes.
- `heartbeat-plugin-environment` failed one wait in the full run. It
passed three times alone, so I think that load caused the failure. It
can fail again under load.
- `dot-runner` skipped all 54 tests in the full run because `cargo` was
not on my `PATH`. It then passed 54 of 54 with the toolchain on the
`PATH`.
- My first `lib` value removed the default DOM types, and the error
count went up by 375. I added the DOM libraries.
- My Daytona stub with bare `any` left 48 errors, because generic
helpers then inferred `unknown`. I added shapes that return promises.
- One fix to `agent-secrets-routes` used `class_3_static_lease`, and the
test failed. That class allows only specific paths. I reverted to
`unclassified`.
- `ci / General tests (server (8/12))` failed on `259737b700` because of
the flaky port in `idle-completed-history`. paperclipai/paperclip#18853
fixed this test on master, so this pull request keeps that fix.

**Audit of the diff.** I counted these patterns in added and removed
lines, to find fixes that only hide an error:

| Pattern | Added | Removed |
| --- | --- | --- |
| `@ts-ignore` | 0 | 0 |
| `@ts-expect-error` | 0 | 1 |
| `.skip` | 0 | 0 |
| `as any` | 9 | 9 |
| `: any` | 11 | 2 |
| `as never` | 8 | 2 |
| `as unknown as` | 35 | 4 |
| `Promise<unknown>` | 16 | 5 |
| `expect(` | 128 | 113 |

No hunk removes more `expect(` calls than it adds. Three removals of
test content were intentional, and I checked each one:

- `muse-control` removed `signalToken`. Line 1012 already asserts that
it is absent.
- `merged-pr-confirmation-sweep` removed legacy aliases. Other tests
cover them.
- `heartbeat-workspace-branch-containment` removed a `finalize` branch
that no test row can reach.

Some partial fixtures stay unchecked through `as unknown as` and
`Promise<unknown>`, mostly in `issue-agent-mutation-ownership-routes`
and `issue-comment-reopen-routes`.

**Accuracy of the check.** One program over the tree and the three
shards reported the same errors at the same positions. Memory, as the
peak of the largest process on one thread, was 3.93 GiB for one program
and 2.43 GiB for the largest shard. I measured these two values before
the fixes.

Not run or not proven:

- I did not run the full `pnpm test:run`, or a root `pnpm build`. I
expect CI to run the full suites.
- I did not run the 4 GiB container check, or the full
`scripts/typecheck-low-memory.mjs`. The peaks above come from macOS, so
they are not a Linux cgroup measurement.
- I did not run the UI typecheck after the merges.
- `workspace-runtime-https-live-exercise` is skipped, so its `cleanup`
fix did not run.

## Risks

- **A large diff in test files.** 328 files change. A pull request that
edits the same tests can get merge conflicts. New tests on master can
also add type errors before this merges. Any such error fails this check
and needs a fix here.
- **A higher cost for changes to source types.** A change to a type can
add errors in test files that the author did not touch. The author must
then fix them. This is the purpose of the check, but it is new work.
- **Changed test behavior.** Some tests now run paths that they did not
run before, such as the 9 chat wizard fixtures and the 3 span tests.
They pass here, but they can be more sensitive to timing or to the
environment.
- **The Daytona stub can go stale.** The stub is written by hand. If the
plugin uses a new SDK member, the typecheck reports an error until
someone updates the stub.
- **The release path.** `release-verify.yml` and `scripts/release.sh`
run `pnpm -r typecheck`, so they now run this check.
- **The tsconfig change.** The test transform reads
`server/src/__tests__/tsconfig.json`. The options that I changed are for
type checks, and the runs above passed.
- **A gap that remains.** Tsconfigs in `packages/paperclip-runner` and
in most sandbox provider plugins exclude `*.test.ts`. This check does
not read most of those test files, so they can still drift. That work is
separate from this pull request.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

Anthropic Claude Opus 5.5 (`claude-opus-5-5`) through Claude Code, with
reasoning, tool use, code execution, and subagents. The runtime does not
show the context window size.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [ ] I have run tests locally and they pass (7 tests fail locally, with
and without this change. See Verification.)
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:27:38 -08:00
23bd238f05 fix(costs): stop OAuth refreshes from duplicating unverified subscriptions (#16512)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The Costs page shows one card for each AI subscription account. Each
card comes from an `ai_subscriptions` row.
> - Paperclip cannot verify an xAI/Grok account with the provider. So
each Grok row used a key made from a hash of the raw credential.
> - The Grok CLI refreshes its OAuth tokens during runs. Paperclip
writes the refreshed `auth.json` back to the vault and keeps the
secret's `aiSessionEpoch`.
> - The next run sees a new credential hash.
`subscriptionService.register()` finds no match and adds a new
subscription row. The old row stays on the Costs page. One Grok
connection becomes many duplicate cards.
> - Claude and OpenAI rows usually avoid this problem, because a
provider profile check merges them. Grok rows never get verified, so
nothing merges them.
> - This pull request keys unverified rows on the credential session
(secret id + `aiSessionEpoch`), not on the token bytes.
> - The benefit is one subscription card per Grok connection through
token refreshes. A real reconnect still gets its own card.

## Linked Issues or Issue Description

No public issue exists. Bug description:

**What happened?**
On a hosted staging instance, the Costs → Subscriptions list showed many
duplicate Grok subscriptions for one Grok connection. Each Grok OAuth
token refresh added one more card.

**Expected behavior**
One Grok connection shows one subscription card. A token refresh does
not change it.

**Steps to reproduce**
1. Connect an xAI/Grok subscription account.
2. Run an agent until the Grok CLI refreshes its OAuth token. Paperclip
writes the refreshed credential back to the vault.
3. Run the agent again.
4. Open Costs → Subscriptions. A second Grok card is there. Each refresh
adds another card.

**Agent adapter(s) involved**
Not adapter-specific (core bug). The trigger is the Grok OAuth refresh
write-back in `server/src/services/ai-connection-runtime.ts`.

**Paperclip version or commit**
`master` at 8aaef59.

## What Changed

- `server/src/services/subscriptions.ts`: For an unverified identity,
`register()` now reads the grant's `ai.credential` secret ref and that
secret's `aiSessionEpoch`. The account key becomes `grant + session +
secretId + epoch`. Before, it was `grant + credential hash`.
- Refresh write-backs use `preserveAiSessionEpoch: true`, so a refreshed
token keeps the same row.
- A reconnect advances the epoch, so it still gets a separate row. A
reconnect can sign in to a different account.
- A grant with no vaulted secret ref falls back to the old
credential-scoped key.
- Unverified rows stay grant-scoped. Only a verified provider
observation can match the billing identity of a different grant.
- Stale sessions are rejected. `SubscriptionConnection` now carries the
`session` (secret id + epoch) captured together with the credential. If
that session does not match the grant's current secret and epoch,
`register()` returns `409` and does not use the live epoch. Before this,
a run could read account A's credential, pause, and register after a
reconnect to account B. It then got B's subscription id, so A's usage
was saved against B.
- `server/src/services/ai-connection-runtime.ts`: The run passes the
secret id and epoch from its existing freshness check (read together
with the credential).
- `server/src/services/subscription-refresh.ts`: Discovery reads the
session before it reads the credential. Then a reconnect at any time
after that read makes `register()` reject the stale session.
- `server/src/__tests__/subscriptions.test.ts`: Added regression tests.
- Two refreshes keep one row and its saved price. A reconnect (epoch
bump) makes a second row, and a refresh after that stays on the second
row.
- A registration with an older epoch, or with no session, is rejected
with `409`. The grant binding stays on the current account.
- `server/src/__tests__/subscription-refresh.test.ts`: The module mock
now exports `subscriptionSession`.
- This pull request adds no code comments. The explanation is in this
description.

## Verification

Run on `master` + this branch (head `11900e4`):

- `cd server && npx vitest run src/__tests__/subscriptions.test.ts
src/__tests__/subscription-refresh.test.ts
src/__tests__/subscription-routes.test.ts
src/__tests__/ai-connections.test.ts
src/__tests__/ai-connection-session.test.ts
src/__tests__/costs-service.test.ts` → 6 files, 191 tests pass.
- The refresh test fails on `master` without the service change. The
refreshed token gets a new subscription id (`expected '6683…' to be
'942b…'`).
- The stale-session test fails when the guard is removed. The stale
registration resolves to the reconnected account's id instead of
rejecting.
- `pnpm -r typecheck` passes for every package except `server` and
`cli`. The runner passes `typecheck:typescript`.
- I could not complete some checks in my sandbox. CI must confirm them:
- The `server` and `cli` typechecks hit the 4 GB sandbox memory limit
(OOM kill). A narrow `tsc --noEmit` of `subscriptions.ts` and its
imports passes with 0 errors. A narrow check of
`ai-connection-runtime.ts` and `subscription-refresh.ts` also hits the
memory limit, because they import most of the server.
  - The full `pnpm test:run` hit the same memory limit.
- `pnpm build` and the runner `typecheck:rust` need `cargo`, and the
sandbox does not have it. This change does not touch Rust or the runner.

Manual check after deploy: Connect Grok and run an agent through a token
refresh. Then make sure that Costs → Subscriptions shows one Grok card.

## Risks

- Low risk. There is no schema change and no migration.
- Existing unverified rows keep their old credential-scoped keys. On the
next refresh after deploy, each existing Grok connection can get one
more row in the new key format. After that, it stays stable.
- This change does not remove duplicate rows that exist now. You can
merge them with the **Link** control on each Costs card. A separate data
repair is planned.
- `register()` does one more indexed read of `company_secrets` (by id)
when a run registers an unverified identity. Discovery does one more
read of that row for each connection. The cached-binding fast path does
not change.
- A run that loses the race with a reconnect now fails its preparation
with `409` ("The AI credential changed. Retry this execution."). This is
the same retry behavior as the existing credential-freshness guard.
Discovery logs the conflict and checks again on the next pass.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Claude Opus 5.5 (Anthropic), model ID `claude-opus-5-5`, run through
Claude Code as a Paperclip agent with tool use (shell, file edit, test
execution).

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass (targeted suites; the full
suite is not complete, see Verification)
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes (no
documentation change is necessary)
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green (pending CI)
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
(pending review)
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Amy (Opus) <noreply@paperclip.ing>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: devinfoley <139239+devinfoley@users.noreply.github.com>
2026-10-23 10:21:19 -07:00
Devin FoleyandPaperclip 98e3cec78e feat(costs): report monthly subscriptions beside API spend (#15634)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Its Costs page reports API charges and subscription token usage.
> - Subscription runs do not show the recurring fee paid for the
account.
> - Several agents can share one paid seat, so a fee per agent would
overstate cost.
> - This pull request identifies connected subscription accounts and
reports each monthly fee once.
> - Users can compare API spend and subscription costs, see uncertainty,
and enter their actual price.

## Linked Issues or Issue Description

Refs #498 and #3406 for related subscription reporting needs. This
change does not add subscription budget enforcement or automatic
optimization.

Related prior approaches: #10544 and #11104 proposed API-equivalent
token costs for subscription work. This PR reports recurring account
fees separately from usage charges and budgets. A search found no open
PR for monthly account fee reporting.

**Subsystem affected**
Costs, managed AI connections, shared contracts, database schema, and
board UI.

**Problem or motivation**
An organization can pay for subscriptions even when included runs record
no additional dollar charge. The Costs page cannot show that monthly
commitment or distinguish a shared paid seat from several separate
seats.

**Proposed solution**
Discover account identity from the managed connection selected for a
run. Use exact known plans for labeled monthly list-price estimates.
Keep ambiguous plans and unconfirmed identities unpriced until the user
supplies a price. Sum current monthly fees separately from date-filtered
API charges and token usage.

**Alternatives considered**
A fee per agent double-counts shared accounts. Token-based API price
estimates do not measure the recurring subscription fee. Provider
invoices would be more exact but are not available through these account
credentials.

**Roadmap alignment**
Extends the shipped Better Budgeting and Personal & Shared AI Accounts
capabilities. The feature and UI were reviewed with the maintainer
before this PR.

## What Changed

- Add company-scoped subscription accounts, connection bindings, and
immutable price revisions in replay-safe migration 0322.
- Identify OpenAI workspace/seat and Claude organization/account pairs
only after provider authentication. Keep provisional records
grant-scoped; unchecked ID-token claims cannot combine accounts or grant
billing editors. Keep credentials and raw provider identities out of
reports.
- Attach the selected subscription to new run receipts. Resolve
duplicate aliases without rewriting old monetary receipts.
- Add cached background discovery with four provider checks and twenty
discovery requests at a time. Accepted requests wait for capacity;
excess requests receive HTTP 429 and a UI retry action. Preserve saved
prices and observations when a provider check fails. Dashboard polls
load current prices only; audit revisions stay in the database.
- Support owner-controlled monthly or annual prices, fractional-cent
precision, separate currency totals, tracking status, and duplicate
linking. Preserve both personal owners and shared-account managers when
discovery finds the same paid seat.
- Show API spend beside monthly subscription commitment, with separate
token totals and clear estimate labels. Account details and fee totals
follow billing-owner/shared-audience visibility; company-wide token
totals remain available to cost readers.
- Use consistent amount and estimate badge formatting for agent, user,
project, and model rows.
- Add real PostgreSQL tests, route authorization tests, UI tests,
interactive Storybook fixtures, and operator documentation.

## Verification

- Current head `d7b5c78dadf5033a3c447d83434676ac94687e77` passed all 53
checks, with two checks skipped. This includes the full test matrix,
browser tests, build, typecheck, migration policy, canary packaging, and
security checks.
- Greptile Apex reviewed that exact head at 5/5 with no unresolved
findings.
- Local `pnpm -r typecheck`, `pnpm build`, `pnpm check:token-gates`,
migration safety checks, and `git diff --check` passed.
- All 238 focused tests passed across accounting, subscription
persistence, authorization, UI, migration replay, and snapshot drift.
- The expanded local chat suite passed 1,149 tests. Two cases failed
because this workspace found an unrelated ancestor skill directory; both
passed unchanged in an isolated checkout. The full local Vitest command
was not repeated after the rebase because of this environment issue. CI
covered the complete test matrix on the final head.
- Migration `0322_reflective_kree.sql` follows master's
`0321_acoustic_human_fly.sql`. Its snapshot and journal were
regenerated. Master's migration and snapshot are unchanged. The
subscription SQL retains its original content and hash.
- Disposable PostgreSQL upgrade fixtures applied master's added
lifecycle and Slack migrations without changing saved subscription
prices or cost receipts. The renamed subscription migration was
recognized by hash, retained one history row, and a second migration run
was a no-op.
- Regression tests cover provider capacity, fractional-cent edits,
billing visibility after grant revocation, and
fixed-price/credit/unknown usage. The new UI billing-label tests fail
against the old wording and pass with the fix.
- Browser checks passed for desktop and mobile layouts, shared-account
totals, annual price edits, unknown prices, and stale provider data.
- A live OpenAI account probe returned HTTP 200 and a verified Pro
account identity. The generic Pro plan correctly remains unpriced.
- Live Claude discovery identified Max 20x and a $200/month estimate.
Two real Claude agent runs were attributed to that same account, with
two usage receipts, two agents, one monthly fee, and zero additional
usage charges. Two OpenAI connections also deduplicated to one account.
- Live Codex execution is not verified: both Paperclip's managed hello
test and a direct host `codex exec` fail with a workspace-routing
authentication error. The current credential passes the usage API probe.
This is separate from the successful account-discovery check.
- A local performance fixture with 50,000 usage events and 5,001 saved
price revisions produced a 1,589-byte report in 11.5 ms median / 12.2 ms
maximum over ten warmed runs. PostgreSQL used the subscription/revision
index to read one current price row. These are local measurements, not a
production latency guarantee.
- [Desktop, account details, and mobile
screenshots](https://pages.paperclip.ing/pr-15634-subscription-costs-d5d285e2/)
show the production Costs page with synthetic Storybook fixtures.
- To inspect the UI, run `pnpm storybook` and open **Costs /
Subscriptions**. Edits there change fixture data only.

## Risks

- Provider plan identifiers can change. Unknown plans remain unpriced.
Automatic prices are estimates before tax; discounts and negotiated
prices require a user-supplied amount.
- Subscription fees describe the current monthly commitment. They do not
follow the selected usage date range and do not consume agent budgets or
create finance events.
- Monthly fees cover only accounts visible to the viewer; company-wide
token totals retain their existing scope.
- Legacy or unmanaged usage can remain unattributed. Unconfirmed
identities require manual prices and may need explicit duplicate linking
after credential changes.
- Disconnecting a connection does not prove provider billing stopped.
Users must end or exclude tracking explicitly.
- Migration 0322 adds tables, a nullable cost-event column, and an
index. It is replay-safe, but normal migration locks still apply.

## Model Used

OpenAI Codex, based on GPT-6. This session did not expose a more
specific backend model ID or context-window size. Capabilities used:
code editing, shell execution, database tests, and browser verification.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-09 13:38:01 -07:00