mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
fix: make run accounting durable and budget updates atomic
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
f9163760b9
commit
4154638e71
88 files changed
+57570
-898
No files matched your search
@@ -0,0 +1,155 @@
|
||||
# Cost accounting and budget enforcement
|
||||
|
||||
This document describes the implementation after the September 2026 reliability audit. The execution ledger and the finance ledger serve different purposes: `cost_events` records USD usage attributed to agent work; `finance_events` records billing charges and credits in their original currencies. Provider invoices can be imported explicitly for comparison and reviewed corrections. Operators can also fetch OpenAI and Anthropic organization cost reports using a company-owned admin credential. Provider reports, invoices, and run estimates remain separate; Paperclip does not guess exchange rates.
|
||||
|
||||
## From a run to a report
|
||||
|
||||
1. Before provider dispatch, the heartbeat stores issue/project/billing-code attribution and marks accounting pending. Adapters normalize usage into input tokens (excluding cache reads, including cache writes), cached input tokens, output tokens, billing identity, and an optional USD price. Usage is per run unless the adapter explicitly declares session-cumulative counters. A missing price is distinct from a reported zero.
|
||||
2. Eligible direct OpenAI API receipts receive a versioned token-price estimate when the provider supplies no dollars (see below). Native execution and supported CLI/ACPX adapters checkpoint usage before workspace and issue finalization. Each checkpoint is versioned and fsynced to a private instance spool before its database write; database outages leave the file for replay. The heartbeat stores the final adapter receipt on `heartbeat_runs`, including partial usage returned for failed or timed-out execution. `subscription_included` usage contributes tokens but no incremental monetary charge. An explicit cache-adjusted price takes precedence over the nominal price.
|
||||
3. `accountRunCost` acquires the company accounting lock and then locks the run. In one PostgreSQL transaction it inserts idempotent cost receipts, updates monthly spend projections, evaluates budgets, increments lifetime runtime totals, settles any reservation, and acknowledges run accounting. The original reported amount is preserved; a reviewed correction appends an audit record and changes only the effective valuation. Every new run receipt has a company-scoped idempotency key. Duplicate delivery cannot increment totals again. Complete per-model receipts are split only when both their tokens and prices reconcile to the run total; otherwise the aggregate receipt is retained.
|
||||
4. Startup and periodic recovery scan terminal pending runs, independently of heartbeat scheduling being enabled. Failed writes roll back, leave the marker pending, and retry. A bounded batch rotates failed attempts so one bad receipt cannot starve other pending runs. Dispatched runs that have not produced a final receipt remain pending, including when cancellation marks a run terminal before its provider stops. A late final receipt can then be recorded exactly once. Recovery does not invent a zero-valued receipt or price. Proven pre-provider failures are acknowledged without creating a charge.
|
||||
5. Reports aggregate `cost_events`. Company and agent monthly counters and runtime lifetime counters are projections. New receipts use the attribution captured for the run. Deleted issue/project targets become unallocated; foreign-company references are rejected. Legacy project attribution is inferred only when run activity identifies one project. The unallocated bucket is included so project totals conserve company spend.
|
||||
|
||||
Amounts remain denominated in **cents**, including fractional cents. Ledger amounts and spend projections use PostgreSQL `numeric(24,7)`, preserving nanodollar precision instead of rounding every run to a whole cent. Arithmetic uses integer nanodollars, with one half-away-from-zero rounding step at the storage boundary. Monetary inputs accept decimal strings; use strings when exact representation matters. Unsafe large numeric inputs are rejected. Existing JSON number fields remain for compatibility, alongside exact decimal fields such as `costCentsExact`, `spendCentsExact`, `amountCentsExact`, `netCentsExact`, and budget `observedAmountExact`. Display rounding never changes storage or admission decisions. Budget limits remain whole cents.
|
||||
|
||||
## Ingestion and retries
|
||||
|
||||
`POST /api/companies/:companyId/cost-events` accepts an optional `idempotencyKey` (1–200 characters). Retrying the same normalized receipt with the same key returns its existing event. Reusing the key with different content returns `409`. Keys are scoped to a company; callers must reuse the original `occurredAt`, not regenerate it on retry. Without a key, each POST intentionally creates a new event. Automatic run accounting always supplies keys.
|
||||
|
||||
All linked agent, run, issue, project, and goal references must belong to the reporting company, and a linked run must belong to the reported agent. Non-finite/negative costs and negative/fractional token counts are rejected. The receipt, projections, budget incidents, approvals, and local activity-log rows commit together. Live activity publication and provider cancellation happen after commit; a notification or cancellation transport failure cannot undo a recorded charge.
|
||||
|
||||
Finance ingestion uses the same company-scoped key/conflict semantics. Credits remain separate nonnegative events with `direction: credit`. The top-level finance summary is explicitly USD; `currencies` contains independent totals for every recorded currency. Biller/kind groups retain currency. No exchange rate is guessed and unlike currencies are never added into one monetary total.
|
||||
|
||||
## Budgets and incomplete accounting
|
||||
|
||||
Company, agent, and project policies all apply. Monthly windows use UTC calendar months; lifetime policies coexist with monthly policies. A zero or inactive limit does not enforce a stop. Generic company/agent budget updates synchronize the policy in the same transaction as the legacy budget field.
|
||||
|
||||
Active hard-stop policies block admission when recorded spend reaches the limit, when a terminal run still awaits accounting, or when usage lacks a reliable price. `unpricedUsagePolicy: block` is the default. An operator can explicitly choose `allow` to permit work despite missing prices; reports continue to identify incomplete pricing. This choice does not bypass pending accounting transactions. Subscription-included usage does not count as a monetary pricing gap.
|
||||
|
||||
At a hard stop the budget service pauses the scope, creates one open incident/approval for the policy window, and persists a cancellation delivery version. Cancellation is retried after failures and may be delivered more than once. It rechecks the policy and limits cancellation to work that existed at the check, so a delayed delivery does not cancel newly admitted work after a budget grant. Provider shutdown is best effort.
|
||||
|
||||
Raising a limit must exceed **current** observed spend. Resuming also requires all other hard-stop policies on that scope to permit work. Calendar rollover or disabling a policy releases budget-owned pauses only; manual pauses, terminated agents, and archived companies are preserved. Choosing to keep a scope paused dismisses the incident without generating a fresh approval on each admission check. A subsequent limit increase followed by another threshold crossing creates a new incident.
|
||||
|
||||
A legacy budget pause with no policy remains blocked until a policy or explicit operator resume resolves it. Manual agent pauses keep the normal agent admission error. Accounting activity is excluded from task-progress evidence, so recording a receipt cannot suppress plan-only recovery.
|
||||
|
||||
Policies optionally configure `reservationCents` (default zero), also editable as “Reserve per run (USD)” on the Costs budget cards. Before dispatch, Paperclip locks the company, checks all relevant policies, and reserves the maximum configured estimate against company, agent, and project capacity. Concurrent dispatches cannot claim the same capacity. Zero disables the estimate; a zero-valued reservation still fences duplicate dispatch of the same run. Existing reservations retain their original amount after policy edits and carry across UTC month boundaries until settled.
|
||||
|
||||
A final accounting transaction settles the reservation against the actual charge. Positive pre-provider failure evidence releases it without a charge. Time passing, a missing process, cancellation intent, or a policy change alone does not prove provider work has stopped and never releases a held reservation. The health panel displays held capacity. A run permanently missing its final receipt requires evidence recovery; the system has no “assume zero” button.
|
||||
|
||||
Reservations are estimates, not provider-enforced spending caps. Delayed receipts and in-progress calls can exceed the estimate. Provider spending controls are needed for an external invoice ceiling.
|
||||
|
||||
## Dates, completeness, and UI
|
||||
|
||||
Cost and finance endpoints default to the current UTC month through now. Explicit `from`/`to` bounds remain inclusive; `period=all` requests all time. Reversed or malformed bounds are rejected. CLI cost/finance reads expose `--from`, `--to`, and `--all-time`. The board sends explicit ranges, and month/year-to-date starts use UTC. Rolling spend excludes future events.
|
||||
|
||||
Cost summaries expose `eventCount`, `estimatedEventCount`, `unpricedEventCount`, `pendingRunCount`, and `pricingComplete`. Estimated charges count toward budgets, while the UI distinguishes estimates from provider-reported dollars. Agent totals and expanded model rows show **Estimated** when every charge in that group is estimated, or **Partially estimated** for a mix. Groups with no estimates have no estimate badge. These labels follow the selected date range and use ledger-event counts, not run counts; they do not infer pricing status from the provider or model name. `pricingComplete` means no missing prices or pending receipts; it does not mean invoice reconciliation has occurred. The Costs page warns when totals are incomplete. Provider cards include cached tokens and calculate subscription shares using each token once. Provider spend is compared with the real company budget, not a fabricated proportional allocation. Run displays prefer cache-adjusted prices and preserve explicit zeroes.
|
||||
|
||||
Gemini token normalization follows the [CLI stream statistics](https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/output/stream-json-formatter.ts) and [API usage metadata](https://ai.google.dev/api/generate-content#UsageMetadata): cached prompt tokens are separated from input, and thinking tokens contribute to output.
|
||||
|
||||
## Upgrade and operational limits
|
||||
|
||||
The generated migrations widen monetary columns, add receipt identities and run recovery markers, and restrict incident uniqueness to open incidents. Existing whole-cent values retain their value. Column type changes and index creation acquire database locks; schedule upgrades according to database size. The new nonnegative check rejects invalid legacy ledger rows rather than silently rewriting them.
|
||||
|
||||
Historical rounded charges, missing receipts, ambiguous token semantics, and absent provider prices cannot be reconstructed from these migrations. Old runs are not automatically re-billed. Imported invoices and reviewed corrections can resolve historical prices when there is evidence. Historical runtime totals are retained as an explicit baseline; the inspector identifies legacy totals that lack one instead of fabricating a reconstruction. When a receipt is available but has no price, operators can explicitly allow unpriced usage; doing so does not make the displayed total complete. Runs that permanently lose their final receipt remain pending and require recovery or explicit disabling of the hard-stop policy. Automated reconstruction of missing receipts from arbitrary provider logs is not implemented.
|
||||
|
||||
Tests use disposable PostgreSQL databases and cover company isolation, concurrent duplicate delivery, sub-cent threshold crossings, complete rollback after a late write failure, pending-run recovery, mixed-model conservation, currency separation, monthly rollover, manual pause preservation, simultaneous policies, cancellation delivery failures, and recovery foreign-key lock compatibility. Adapter tests cover timeout receipts and cache semantics, alongside UI amount/share tests. Provider report tests exercise request/response contracts, credential isolation, pagination, revisions, transaction rollback and duplicate delivery with fixture HTTP responses. They do not establish live billing-account permissions.
|
||||
|
||||
## Reproducing reliability checks
|
||||
|
||||
Run `pnpm test:accounting` for the accounting test typecheck, focused server tests, historical migration tests, V8 coverage, and mutation sentinels. The thirteen accounting service modules each require at least 98% line coverage, 90% branch coverage, 96% statement coverage, and 96% function coverage. HTML and JSON reports are written to `coverage/accounting/`. These percentages do not include the full heartbeat service, adapters, browser UI, or child server processes.
|
||||
|
||||
The expanded accounting gate includes prospective Codex pricing and provider-report imports. All four deliberately introduced accounting mutations must be caught by their intended assertions. Separate regressions cover late pre-provider cancellation proof, reservation release without replacing the stop result, lost-channel receipt incompleteness, and exact reservation editing. The full test runner includes every adapter project configured in the root Vitest configuration; a roster check prevents silent omissions.
|
||||
|
||||
The server suite includes four SIGKILL cases: before receipt insertion, after the ledger write but before runtime totals, at the last write before commit, and after commit but before acknowledging cancellation delivery. SQL barriers make these boundaries deterministic. After killing the server process group, the harness terminates the orphan database connection still waiting at the barrier, restarts the real server, and checks exactly one receipt, one set of totals, and one incident/approval. A second restart proves replay does not duplicate them. This tests application crash recovery, not a database power failure. Eight fixed random seeds exercise 640 mixed operations with concurrent receipt retries, policy changes, admission checks, recovery, and injected cancellation failures.
|
||||
|
||||
The migration fixtures restore the predecessor column/index/journal shape, populate historical maximum integer amounts, rounded-zero receipts, finance credits/currencies, and closed incidents, then use the production migrator. An invalid negative legacy receipt must reject the upgrade without changing the schema or migration journal; an explicit repair allows retry.
|
||||
|
||||
Run `pnpm exec playwright test --config tests/e2e/playwright.config.ts tests/e2e/cost-accounting.spec.ts` for the browser workflow. It boots a throwaway instance and the built UI, invokes a deterministic local provider through the real Claude adapter, checks cost display and the budget stop, rejects a manual wake while paused, raises the budget in the UI, and starts another run. It asserts exact cents and cache tokens through the API, then imports an invoice, reviews and applies a sub-cent correction, and independently verifies the resulting totals. Stopped, resumed, and reconciled screenshots are attached to the Playwright report. It uses no paid provider credentials or calls.
|
||||
|
||||
|
||||
## Durable capture and operator recovery
|
||||
|
||||
The spool lives at `<instance-root>/accounting-receipts`. Directories use mode 0700 and receipt files 0600. Files contain accounting fields only; transcript text, prompts, tools, and credentials are excluded. Publication awaits file and directory fsync on POSIX. Each record carries company/run identity, a controller source ID, sequence, timestamp, normalized usage, completeness, and price. Replay validates the schema and fingerprint and rotates failures behind fresh entries in bounded batches. Old controller snapshots are retained as evidence but cannot overwrite a replacement controller or an acknowledged run. Multiple provider attempts are accounted together; incomplete attempts and capture failures remain incomplete.
|
||||
|
||||
The spool survives process death on persistent local storage, including the tested boundary before the first database write. It does not survive destruction of that storage, and cannot recover provider activity that was never emitted as usage. Back up persistent instance storage together with PostgreSQL. No arbitrary provider-log scraping or automatic provider re-execution occurs during accounting recovery.
|
||||
|
||||
Board-only routes under `/api/companies/:companyId/accounting` provide:
|
||||
|
||||
| Route | Behavior |
|
||||
| --- | --- |
|
||||
| `GET /health` | Pending and unpriced counts, oldest pending time, last errors/retry counts, cancellation backlog, held capacity |
|
||||
| `GET /inspect` | Independent ledger/projection and original run-receipt comparison; no data repair |
|
||||
| `POST /repair` | Rebuild repairable totals using the reviewed `fingerprint` and a required `reason`; stale review returns 409 |
|
||||
| `POST /retry` | Retry a company-owned `runId`; record the request and any failed attempt |
|
||||
| `GET` / `POST /invoices` | List or idempotently import normalized invoice evidence and financial events |
|
||||
| `POST /provider-costs/import` | Fetch completed daily provider reports for explicitly selected projects/workspaces |
|
||||
| `GET /invoices/:invoiceId` | Compare exact amounts and expose unmatched, ambiguous, non-inference, or unsupported-currency lines |
|
||||
| `GET` / `POST /events/:eventId/adjustments` | Read correction history or apply a reviewed correction |
|
||||
|
||||
Agents cannot use operator endpoints. Board viewers may inspect but cannot mutate. Repairs and corrections record the operator and reason in local activity history. An inspection fingerprint binds the repair to the observed discrepancies; the service recomputes them under the company lock before writing. Missing original evidence and legacy runtime uncertainty are never silently repaired. New runtime totals reconcile against an explicit historical baseline plus acknowledged v2 run receipts, including backdated receipts.
|
||||
|
||||
The CLI exposes `accounting health`, `accounting inspect`, `accounting repair`, `accounting retry`, `accounting invoices`, `accounting invoice:import`, `accounting invoice:review <id>`, `accounting provider:import`, and `accounting event:correct <id>`, using the ordinary authenticated company context. Mutations take `--payload-json`; inspection is the dry run for repair.
|
||||
|
||||
## Invoice review and corrections
|
||||
|
||||
Imports use the normalized format in [the invoice template](examples/accounting-invoice.json). Map provider exports to this format explicitly. Invoice identity is `(company, biller, externalId)`; same-content replay is harmless, different-content reuse returns 409. Line order does not affect identity. No provider credentials or remote API calls are needed.
|
||||
|
||||
A match requires a unique company/biller-scoped `costEventId`, `runId` (optionally with model), or `providerRequestId`. All supplied identifiers must agree. Multiple candidates or multiple lines claiming one charge are ambiguous and cannot be applied as invoice-backed corrections. Non-USD invoices, fees, and credits remain visible evidence and are never silently converted into USD inference spend. Every imported invoice line also creates an idempotent finance event in the same transaction: inference charges, fees, and credits retain their kind and currency. An import either commits invoice evidence and timeline entries together or commits neither. Replaying an invoice creates no duplicate charge. Importing an invoice does not automatically change run valuations.
|
||||
|
||||
A correction requires `idempotencyKey`, exact `expectedCents`, `correctedCents`, `reason`, and `pricing` provenance. An optional `invoiceLineId` must uniquely match and support the corrected amount. The expected value guards against stale review; the correction key prevents duplicate application after an ambiguous response. `reportedCostCents` and the original receipt fingerprint remain unchanged. `cost_adjustments` preserves every prior valuation, actor, reason, evidence, and pricing revision, including the original provenance in `previousPricing`. The effective `costCents`, projections, runtime totals for supported new receipts, and budget state update atomically. Pricing an unpriced receipt can release a budget-owned pause; other policies and manual pauses still apply.
|
||||
|
||||
## Scale and fault qualification
|
||||
|
||||
`pnpm benchmark:accounting` creates its own disposable PostgreSQL database, seeds one million events across twelve months, measures report latency and eight concurrent writers on one company, exercises two active budget policies, recovers 250 pending receipts, records an analyzed project-budget query plan, and runs the independent integrity checker. `PAPERCLIP_ACCOUNTING_BENCH_ROWS` optionally sets 1,000–10,000,000 events. Results go to `coverage/accounting/scale.json`. There are no machine-dependent latency assertions in CI.
|
||||
|
||||
The September 28 local run on macOS arm64 / Node 25.6.1 measured all-time summary p95 39 ms and project grouping p95 64 ms. With eight concurrent writers, throughput was about 325 writes/s without active policies and 22 writes/s with two active policies; p95 was 72 ms and 359 ms respectively. The budgeted 250-receipt backlog drained in three bounded batches in about 12.7 seconds, with zero integrity discrepancies. Active policies deliberately read the authoritative ledger, which is more expensive than updating a projection. These synthetic warm local results are evidence, not production service-level guarantees.
|
||||
|
||||
Monthly projections carry an explicit UTC month marker. The first write after upgrade or rollover initializes from the ledger; subsequent same-month writes use exact atomic increments. Backdated events do not increment the current month. Reports and budget decisions continue using the ledger. Composite project/date and partial unpriced indexes support targeted queries; pending recovery is indexed in its actual update-time order.
|
||||
|
||||
Additional fault tests use independent Node writer processes, kill a recorder after fsync but before database persistence, and proxy PostgreSQL to discard the `COMMIT` acknowledgement after the server commits. Retrying all of these preserves exactly one charge and correct projections. Mutation tests remove deduplication, company filtering, projection updates, and the inclusive hard-stop comparison in Vite memory. Each must fail its named assertion after an unchanged baseline passes; import errors and skipped tests do not count as detected mutations. Workspace source is never rewritten by the mutation runner.
|
||||
|
||||
|
||||
## Prospective Codex estimates
|
||||
|
||||
Codex commonly reports tokens without dollars. For new receipts, `codex-pricing.ts` applies the immutable `openai-standard-2026-09-30` catalog only when both provider and biller are OpenAI, billing is metered API, usage is per run, and the exact model ID is supported. The initial catalog covers `gpt-6-astra`, `gpt-6-sol`, `gpt-6.1-sol`, `gpt-6-luna`, and `gpt-5.6-sol`, from the [official pricing page](https://developers.openai.com/api/docs/pricing). Unknown models, unsupported tiers, session-cumulative receipts and mixed-model receipts remain unpriced. OpenAI-compatible custom endpoints do not establish OpenAI billing. Existing provider-reported amounts, including zero, take precedence.
|
||||
|
||||
Calculation separates ordinary input, cached reads, cache writes, and output. Cache writes are a subset of input, not extra tokens. Integer arithmetic rounds once to nanodollars. An explicit pricing context can select the supported service/context tier; when request-level context is unavailable, the estimate records standard processing and short-request assumptions. A large cumulative run is not evidence that any individual request crossed a long-context threshold. Such estimates can differ from a bill because of tier/context assumptions, negotiated prices, or fees. They are useful estimates, not externally enforced budget ceilings.
|
||||
|
||||
The recorder calculates and freezes the amount, rate version, base rates and assumptions **before** writing its durable spool. Finalization, a process restart, or a later catalog change cannot silently reprice that captured receipt. Native runs carry their managed AI connection's billing identity into this path. Estimated costs feed the same exact, idempotent accounting and budget transaction as provider-reported costs. The original receipt remains available after an invoice correction. Historical missing dollars are not backfilled automatically.
|
||||
|
||||
## Getting financial events into Paperclip
|
||||
|
||||
Open **Costs → Recent financial events → Record or import charges**. There are three entry paths:
|
||||
|
||||
1. **Record charge** records a subscription payment, fee, credit or refund. Enter USD, the UTC date and a description. The client keeps its idempotency key through an ambiguous failed response, so retrying the same entry cannot charge twice.
|
||||
2. **Import invoice** accepts the normalized [invoice JSON format](examples/accounting-invoice.json). Stable biller/invoice/line IDs provide idempotency. Invoice import produces timeline events atomically and preserves evidence for separately reviewed run corrections.
|
||||
3. **Provider report** fetches API cost reports from OpenAI or Anthropic. Save a provider organization admin key as a **company secret**, then select it, the matching provider organization ID, explicit project/workspace IDs, and a UTC interval. Dates cover at most 31 completed days; the end date is exclusive. Use `default` only to intentionally include provider rows with no project/workspace ID.
|
||||
|
||||
The provider importer uses fixed HTTPS endpoints, refuses redirects, verifies Anthropic organization identity, sends the selected OpenAI organization header, filters selected scopes and bounds time, pages and response size. It reads all pages before writing. Missing days, overlapping scoped results or invalid currencies/amounts reject the import. OpenAI amounts arrive in USD; Anthropic reports decimal cents. A complete bucket without a selected scope is an explicit zero.
|
||||
|
||||
Each daily company/provider/account/scope snapshot has a revision. Reimporting unchanged data writes no event. Increases append debit deltas; decreases append credits, including corrections back to a previous total. A slow older fetch cannot overwrite a newer changed snapshot. The snapshot, finance event and activity record commit together.
|
||||
|
||||
**Provider report totals are separate from invoice/manual-charge totals.** The finance summary exposes `providerReportedCentsExact`; report rows remain labeled in the timeline but are excluded from net charges and biller/kind charge groupings. This prevents importing a report and its invoice from adding the same expenditure twice. Neither is added to run-cost estimates. A manually entered charge and an invoice representing the same payment must still be kept distinct by the operator; Paperclip cannot infer that identity from unrelated IDs.
|
||||
|
||||
API: `POST /api/companies/:companyId/accounting/provider-costs/import`. CLI: `paperclipai accounting provider:import --payload-json '<json>'`, with the normal authenticated company context. Payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"provider": "openai",
|
||||
"secretId": "00000000-0000-4000-8000-000000000001",
|
||||
"accountId": "org_example",
|
||||
"scopeIds": ["proj_example"],
|
||||
"from": "2026-09-01",
|
||||
"to": "2026-09-02"
|
||||
}
|
||||
```
|
||||
|
||||
Imports are initiated explicitly; this implementation does not schedule background billing synchronization. Repeated API/CLI invocations are safe for an external scheduler. It does not retrieve ChatGPT/Claude subscription invoices, apply exchange rates, or claim provider API reports cover every invoice item. [Anthropic's report](https://platform.claude.com/docs/en/manage-claude/usage-cost-api) excludes Priority Tier costs. The [OpenAI Costs API](https://platform.openai.com/docs/api-reference/usage/audio_transcriptions_object) requires organization billing access; an ordinary inference key may be insufficient. These accounts need to be configured before live imports work.
|
||||
|
||||
## Subscription quotas and refresh behavior
|
||||
|
||||
Company quota requests resolve the caller's accessible subscription AI connections and read each account with its own managed credential. They do not substitute the server host's login for a remote agent account. Private, revoked or cross-company connections are filtered through AI connection authorization. Unmanaged remote credentials require connecting the account in AI connections before its quota is available.
|
||||
|
||||
Responses carry an opaque account identity and successful capture timestamp. The 30-second cache is scoped to company, user, connection, grant and credential revision; authorization and revision metadata are checked before reading cached results. Transient failures retain the last successful UI observation with its original timestamp. Rotation, revocation and explicit authentication rejection clear obsolete windows. Public messages never contain provider subprocess commands or raw diagnostics.
|
||||
|
||||
Both Costs page variants retain loaded reports and expanded rows during background refresh failures, with a small stale-data notice. Initial failures remain visible. Company/date changes isolate cached reports. The run-count label is `runs: 0 api · 11 sub`: distinct recorded runs, not tasks or individual model requests. Input totals include cache reads, with the cached portion shown explicitly.
|
||||
@@ -141,7 +141,7 @@ Human auth tables (`users`, `sessions`, and provider-specific auth artifacts) ar
|
||||
- `issue_prefix` text not null
|
||||
- `issue_counter` int not null
|
||||
- `budget_monthly_cents` int not null default 0
|
||||
- `spent_monthly_cents` int not null default 0
|
||||
- `spent_monthly_cents` numeric(24,7) not null default 0
|
||||
- `require_board_approval_for_new_agents` boolean not null default false
|
||||
- feedback sharing consent fields
|
||||
|
||||
@@ -164,7 +164,7 @@ Invariant: every business record belongs to exactly one company.
|
||||
- `default_environment_id` uuid fk `environments.id` null
|
||||
- `context_mode` enum: `thin | fat` default `thin`
|
||||
- `budget_monthly_cents` int not null default 0
|
||||
- `spent_monthly_cents` int not null default 0
|
||||
- `spent_monthly_cents` numeric(24,7) not null default 0
|
||||
- pause fields: `pause_reason`, `paused_at`
|
||||
- `permissions` jsonb not null default `{}`
|
||||
- `last_heartbeat_at` timestamptz null
|
||||
@@ -325,7 +325,9 @@ Invariants:
|
||||
- `cost_status` text not null default `reported`; `unpriced` when usage exists but no price was reported
|
||||
- `input_tokens` int not null default 0
|
||||
- `output_tokens` int not null default 0
|
||||
- `cost_cents` int not null
|
||||
- `cost_cents` numeric(24,7) not null
|
||||
- `idempotency_key` text null; unique within a company when present
|
||||
- `receipt_hash` text null; canonical immutable-receipt fingerprint
|
||||
- `occurred_at` timestamptz not null
|
||||
|
||||
Invariant: each event must attach to agent and company; rollups are aggregation, never manually edited.
|
||||
@@ -1451,7 +1453,9 @@ for contracts, recovery behavior, Storybook, and acceptance workflows.
|
||||
- block new checkout/invocation for that agent
|
||||
- emit high-priority activity event
|
||||
|
||||
Board may override by raising budget or explicitly resuming agent.
|
||||
Board may raise the budget or disable its hard stop. An explicit resume cannot bypass an active blocking policy. Budget-owned pauses release when the applicable UTC window expires; manual pauses remain unchanged. Active hard stops also block pending terminal-run accounting and unpriced usage unless the operator explicitly allows unpriced usage.
|
||||
|
||||
Optional per-run budget reservations serialize available-capacity admission with ledger writes. Durable usage checkpoints, exact decimal reporting, operator integrity inspection and guarded repairs, and reviewed invoice corrections support accounting recovery. Reservations are estimates; provider charges can exceed them. See [Cost accounting and budget enforcement](COST-ACCOUNTING.md) for receipt recovery, retry guarantees, and provider-side limits.
|
||||
|
||||
## 13.3 Cost Event Ingestion
|
||||
|
||||
@@ -1474,7 +1478,8 @@ Board may override by raising budget or explicitly resuming agent.
|
||||
Validation:
|
||||
|
||||
- non-negative token counts
|
||||
- `costCents >= 0`
|
||||
- finite `costCents >= 0`, retaining fractional cents to seven decimal places
|
||||
- optional company-scoped `idempotencyKey`: identical retries return the original receipt; changed content conflicts
|
||||
- company ownership checks for all linked entities
|
||||
|
||||
## 13.4 Rollups
|
||||
@@ -1482,6 +1487,8 @@ Validation:
|
||||
Read-time aggregate queries are acceptable for V1.
|
||||
Materialized rollups can be added later if query latency exceeds targets.
|
||||
|
||||
Cost and finance queries default to month-to-date in UTC; explicit bounds are inclusive and `period=all` requests all time. Project rollups retain an unallocated bucket and must conserve total spend. Cost summaries expose missing-price and pending-run counts. Finance summaries retain original currencies without adding unlike currencies together.
|
||||
|
||||
## 14. UI Requirements (Board App)
|
||||
|
||||
V1 UI routes:
|
||||
|
||||
@@ -326,6 +326,8 @@ Full hierarchy: **Initiative** (company goal) → Projects → Milestones → Is
|
||||
|
||||
## 6. Cost Tracking [DRAFT]
|
||||
|
||||
The implemented receipt, recovery, precision, currency, and budget-admission contracts are described in [Cost accounting and budget enforcement](COST-ACCOUNTING.md) and [the V1 implementation spec](SPEC-implementation.md#13-cost-and-budget-system).
|
||||
|
||||
Token/LLM cost budgeting is a core part of Paperclip. External revenue and expense tracking is a future plugin.
|
||||
|
||||
### Cost Reporting
|
||||
@@ -349,6 +351,8 @@ Three tiers:
|
||||
2. **Soft alerts** — configurable thresholds (e.g. warn at 80% of budget)
|
||||
3. **Hard ceiling** — auto-pause the Agent when budget is hit. Board notified. Board can override/raise the limit.
|
||||
|
||||
The implementation also supports estimated per-run reservations, durable receipt recovery, exact decimal reporting, independent integrity inspection, and audited invoice corrections. Recorded spend and reservations do not replace a provider-enforced invoice cap. See [cost accounting](COST-ACCOUNTING.md).
|
||||
|
||||
Budgets can be set to **unlimited** (no ceiling).
|
||||
|
||||
### Open Questions
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"biller": "anthropic",
|
||||
"externalId": "example-invoice-2026-09",
|
||||
"currency": "USD",
|
||||
"lines": [
|
||||
{
|
||||
"externalId": "example-line-1",
|
||||
"kind": "inference",
|
||||
"amountCents": "12.5000000",
|
||||
"occurredAt": "2026-09-01T00:00:00Z",
|
||||
"providerRequestId": "replace-with-the-provider-request-id",
|
||||
"pricing": { "source": "provider_invoice", "version": "2026-09", "evidence": "example-invoice-2026-09" }
|
||||
}
|
||||
]
|
||||
}
|
||||
+5
-1
@@ -22,6 +22,7 @@
|
||||
"typecheck:build-gaps": "pnpm run preflight:workspace-links && pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && pnpm --filter @paperclipai/server build && node scripts/run-typecheck-build-gaps.mjs",
|
||||
"test": "pnpm run test:run",
|
||||
"test:watch": "pnpm run preflight:workspace-links && vitest",
|
||||
"test:accounting": "pnpm run preflight:workspace-links && pnpm --filter @paperclipai/server exec tsc -p tsconfig.accounting-tests.json && vitest run --config server/vitest.accounting.config.ts && vitest run --project @paperclipai/db packages/db/src/cost-accounting-migration.test.ts && pnpm test:accounting:mutations",
|
||||
"test:run": "pnpm run preflight:workspace-links && node scripts/run-vitest-stable.mjs",
|
||||
"test:runner-acceptance": "vitest run --config tests/runner-acceptance/vitest.config.ts",
|
||||
"test:runner-acceptance:typecheck": "tsc -p tests/runner-acceptance/tsconfig.json",
|
||||
@@ -90,10 +91,13 @@
|
||||
"connections:ingest-app-definitions": "node scripts/ingest-app-definitions.mjs",
|
||||
"test:lifecycle-baseline": "node tests/lifecycle-baseline/run.mjs",
|
||||
"test:lifecycle-baseline:support": "node --test tests/lifecycle-baseline/report.test.mjs",
|
||||
"test:lifecycle-baseline:typecheck": "tsc -p tests/lifecycle-baseline/tsconfig.json"
|
||||
"test:lifecycle-baseline:typecheck": "tsc -p tests/lifecycle-baseline/tsconfig.json",
|
||||
"test:accounting:mutations": "node scripts/test-accounting-mutations.mjs",
|
||||
"benchmark:accounting": "node cli/node_modules/tsx/dist/cli.mjs server/scripts/benchmark-accounting.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@vitest/coverage-v8": "^4.1.11",
|
||||
"agentmail": "^0.5.31",
|
||||
"cross-env": "^10.1.0",
|
||||
"esbuild": "^0.28.2",
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import postgres from "postgres";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyPendingMigrations, inspectMigrations } from "./client.js";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./test-embedded-postgres.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
const journal = JSON.parse(readFileSync(new URL("./migrations/meta/_journal.json", import.meta.url), "utf8"));
|
||||
const hashes = journal.entries.filter((entry: { idx: number }) => entry.idx >= 295).map((entry: { tag: string }) => `${entry.tag}.sql`).map((name: string) =>
|
||||
createHash("sha256").update(readFileSync(new URL(`./migrations/${name}`, import.meta.url))).digest("hex"));
|
||||
|
||||
async function historicalDatabase() {
|
||||
const database = await startEmbeddedPostgresTestDatabase("paperclip-accounting-upgrade-");
|
||||
const sql = postgres(database.connectionString, { max: 1, onnotice: () => {} });
|
||||
// Restore the complete predecessor shape for the changed tables, including
|
||||
// the old uniqueness predicate and migration journal. Upgrade uses the real
|
||||
// production migrator, not hand-picked statements from the migration.
|
||||
await sql.unsafe(`
|
||||
DROP TABLE provider_billing_snapshots, cost_adjustments, billing_invoice_lines, billing_invoices, budget_reservations, run_usage_receipts, accounting_runtime_baselines;
|
||||
DROP INDEX cost_events_company_project_occurred_idx;
|
||||
DROP INDEX cost_events_unpriced_idx;
|
||||
ALTER TABLE cost_events DROP COLUMN provider_request_id, DROP COLUMN reported_cost_cents, DROP COLUMN pricing_provenance;
|
||||
ALTER TABLE heartbeat_runs DROP COLUMN accounting_projection_version, DROP COLUMN accounting_last_attempt_at, DROP COLUMN accounting_last_error, DROP COLUMN accounting_attempt_count;
|
||||
ALTER TABLE budget_policies DROP COLUMN reservation_cents;
|
||||
ALTER TABLE companies DROP COLUMN spend_month_utc;
|
||||
ALTER TABLE agents DROP COLUMN spend_month_utc;
|
||||
ALTER TABLE finance_events ALTER COLUMN amount_cents TYPE integer;
|
||||
ALTER TABLE cost_events DROP CONSTRAINT cost_events_nonnegative_amounts;
|
||||
ALTER TABLE cost_events DROP COLUMN idempotency_key, DROP COLUMN receipt_hash;
|
||||
ALTER TABLE finance_events DROP COLUMN idempotency_key, DROP COLUMN receipt_hash;
|
||||
ALTER TABLE heartbeat_runs DROP COLUMN cost_accounting_pending, DROP COLUMN cost_accounted_at;
|
||||
ALTER TABLE budget_policies DROP COLUMN enforcement_version, DROP COLUMN enforcement_delivered_version, DROP COLUMN unpriced_usage_policy;
|
||||
ALTER TABLE cost_events ALTER COLUMN cost_cents TYPE integer;
|
||||
ALTER TABLE companies ALTER COLUMN spent_monthly_cents TYPE integer;
|
||||
ALTER TABLE agents ALTER COLUMN spent_monthly_cents TYPE integer;
|
||||
ALTER TABLE agent_runtime_state ALTER COLUMN total_cost_cents TYPE integer;
|
||||
ALTER TABLE budget_incidents ALTER COLUMN amount_observed TYPE integer;
|
||||
DROP INDEX budget_incidents_policy_window_threshold_idx;
|
||||
CREATE UNIQUE INDEX budget_incidents_policy_window_threshold_idx
|
||||
ON budget_incidents (policy_id, window_start, threshold_type) WHERE status <> 'dismissed';
|
||||
`);
|
||||
await sql`DELETE FROM drizzle.__drizzle_migrations WHERE hash IN ${sql(hashes)}`;
|
||||
return { database, sql, close: async () => { await sql.end(); await database.cleanup(); } };
|
||||
}
|
||||
|
||||
(support.supported ? describe : describe.skip)("cost accounting historical upgrade", () => {
|
||||
it("preserves historical receipts, counters, references, finance currencies and incident history", async () => {
|
||||
const f = await historicalDatabase();
|
||||
const { sql } = f;
|
||||
const company = randomUUID(), agent = randomUUID(), run = randomUUID(), policy = randomUUID();
|
||||
try {
|
||||
await sql`INSERT INTO companies (id,name,issue_prefix,spent_monthly_cents) VALUES (${company},'Historical','HIST',2147483647)`;
|
||||
await sql`INSERT INTO agents (id,company_id,name,spent_monthly_cents) VALUES (${agent},${company},'Historical worker',2147483647)`;
|
||||
await sql`INSERT INTO heartbeat_runs (id,company_id,agent_id,status,usage_json,finished_at)
|
||||
VALUES (${run},${company},${agent},'succeeded','{"inputTokens":42,"costUsd":0.004}', '2025-12-31T23:59:59Z')`;
|
||||
await sql`INSERT INTO agent_runtime_state (agent_id,company_id,adapter_type,total_cost_cents,total_input_tokens)
|
||||
VALUES (${agent},${company},'codex_local',2147483647,42)`;
|
||||
await sql`INSERT INTO cost_events (company_id,agent_id,heartbeat_run_id,provider,model,input_tokens,output_tokens,cost_cents,occurred_at)
|
||||
VALUES (${company},${agent},${run},'historical','old-model',42,5,2147483647,'2025-12-31T23:59:59Z'),
|
||||
(${company},${agent},${run},'historical','old-model',0,0,0,'2026-01-01T00:00:00Z')`;
|
||||
await sql`INSERT INTO finance_events (company_id,event_kind,direction,biller,amount_cents,currency,occurred_at,external_invoice_id)
|
||||
VALUES (${company},'adjustment','debit','historical',12345,'EUR',now(),'invoice-1'),
|
||||
(${company},'adjustment','credit','historical',789,'USD',now(),'invoice-1')`;
|
||||
await sql`INSERT INTO budget_policies (id,company_id,scope_type,scope_id,metric,window_kind,amount)
|
||||
VALUES (${policy},${company},'agent',${agent},'billed_cents','calendar_month_utc',100)`;
|
||||
await sql`INSERT INTO budget_incidents (company_id,policy_id,scope_type,scope_id,metric,window_kind,window_start,window_end,threshold_type,amount_limit,amount_observed,status)
|
||||
VALUES (${company},${policy},'agent',${agent},'billed_cents','calendar_month_utc','2026-01-01Z','2026-02-01Z','hard',100,101,'resolved'),
|
||||
(${company},${policy},'agent',${agent},'billed_cents','calendar_month_utc','2026-01-01Z','2026-02-01Z','hard',100,101,'dismissed')`;
|
||||
const before = await sql`SELECT id,company_id,agent_id,heartbeat_run_id,provider,model,cost_cents::text,input_tokens,occurred_at FROM cost_events ORDER BY id`;
|
||||
const financeBefore = await sql`SELECT id,event_kind,direction,amount_cents,currency,external_invoice_id FROM finance_events ORDER BY id`;
|
||||
await applyPendingMigrations(f.database.connectionString);
|
||||
await applyPendingMigrations(f.database.connectionString);
|
||||
expect((await inspectMigrations(f.database.connectionString)).status).toBe("upToDate");
|
||||
const after = await sql`SELECT id,company_id,agent_id,heartbeat_run_id,provider,model,cost_cents::text,input_tokens,occurred_at FROM cost_events ORDER BY id`;
|
||||
expect(after.map(row => ({ ...row, cost_cents: Number(row.cost_cents) }))).toEqual(before.map(row => ({ ...row, cost_cents: Number(row.cost_cents) })));
|
||||
expect((await sql`SELECT id,event_kind,direction,amount_cents,currency,external_invoice_id FROM finance_events ORDER BY id`).map(row => ({ ...row, amount_cents: Number(row.amount_cents) }))).toEqual(financeBefore);
|
||||
expect(await sql`SELECT cost_accounting_pending,cost_accounted_at FROM heartbeat_runs WHERE id=${run}`).toEqual([{ cost_accounting_pending: false, cost_accounted_at: null }]);
|
||||
expect(await sql`SELECT enforcement_version,enforcement_delivered_version,unpriced_usage_policy FROM budget_policies WHERE id=${policy}`)
|
||||
.toEqual([{ enforcement_version: 0, enforcement_delivered_version: 0, unpriced_usage_policy: "block" }]);
|
||||
for (const [table, column] of [["companies","spent_monthly_cents"],["agents","spent_monthly_cents"],["agent_runtime_state","total_cost_cents"]]) {
|
||||
expect((await sql.unsafe(`SELECT ${column}::text AS value FROM ${table}`))[0].value).toBe("2147483647.0000000");
|
||||
}
|
||||
expect(await sql`SELECT DISTINCT idempotency_key,receipt_hash FROM cost_events`).toEqual([{ idempotency_key: null, receipt_hash: null }]);
|
||||
expect((await sql`SELECT reservation_cents::text FROM budget_policies WHERE id=${policy}`)[0].reservation_cents).toBe("0.0000000");
|
||||
expect((await sql`SELECT spend_month_utc FROM companies WHERE id=${company}`)[0].spend_month_utc).toBeNull();
|
||||
await sql`INSERT INTO finance_events (company_id,event_kind,biller,amount_cents,currency,occurred_at) VALUES (${company},'adjustment','new',0.0000001,'USD',now())`;
|
||||
expect((await sql`SELECT amount_cents::text FROM finance_events WHERE biller='new'`)[0].amount_cents).toBe("0.0000001");
|
||||
// Closed historical incidents must not prevent a fresh threshold crossing.
|
||||
const insertIncident = () => sql`INSERT INTO budget_incidents (company_id,policy_id,scope_type,scope_id,metric,window_kind,window_start,window_end,threshold_type,amount_limit,amount_observed,status)
|
||||
VALUES (${company},${policy},'agent',${agent},'billed_cents','calendar_month_utc','2026-01-01Z','2026-02-01Z','hard',150,150.4,'open')`;
|
||||
await insertIncident();
|
||||
await expect(insertIncident()).rejects.toMatchObject({ code: "23505" });
|
||||
expect(await sql`SELECT status FROM budget_incidents ORDER BY status`).toEqual([{ status:"dismissed" },{ status:"open" },{ status:"resolved" }]);
|
||||
await sql`INSERT INTO cost_events (company_id,agent_id,provider,model,cost_cents,occurred_at,idempotency_key)
|
||||
VALUES (${company},${agent},'new','model',0.0000001,now(),'new-receipt')`;
|
||||
expect((await sql`SELECT cost_cents::text FROM cost_events WHERE idempotency_key='new-receipt'`)[0].cost_cents).toBe("0.0000001");
|
||||
await expect(sql`INSERT INTO cost_events (company_id,agent_id,provider,model,cost_cents,occurred_at,idempotency_key)
|
||||
VALUES (${company},${agent},'new','model',1,now(),'new-receipt')`).rejects.toMatchObject({ code: "23505" });
|
||||
// A development deployment can have applied this schema under an older
|
||||
// migration number. Replaying the SQL must preserve both old and new rows.
|
||||
const beforeReplay = await sql`SELECT id,cost_cents::text FROM cost_events ORDER BY id`;
|
||||
const migration = readFileSync(new URL("./migrations/0295_lean_baron_strucker.sql", import.meta.url), "utf8");
|
||||
await sql.begin(async (tx) => {
|
||||
for (const statement of migration.split("--> statement-breakpoint")) await tx.unsafe(statement);
|
||||
});
|
||||
expect(await sql`SELECT id,cost_cents::text FROM cost_events ORDER BY id`).toEqual(beforeReplay);
|
||||
} finally { await f.close(); }
|
||||
}, 60_000);
|
||||
|
||||
it("rejects invalid historical amounts atomically and can retry after explicit repair", async () => {
|
||||
const f = await historicalDatabase(); const { sql } = f;
|
||||
const company = randomUUID(), agent = randomUUID();
|
||||
try {
|
||||
await sql`INSERT INTO companies (id,name,issue_prefix) VALUES (${company},'Invalid historical','BAD')`;
|
||||
await sql`INSERT INTO agents (id,company_id,name) VALUES (${agent},${company},'Worker')`;
|
||||
await sql`INSERT INTO cost_events (company_id,agent_id,provider,model,cost_cents,occurred_at) VALUES (${company},${agent},'old','old',-1,now())`;
|
||||
const journal = await sql`SELECT * FROM drizzle.__drizzle_migrations ORDER BY id`;
|
||||
await expect(applyPendingMigrations(f.database.connectionString)).rejects.toThrow();
|
||||
expect(await sql`SELECT * FROM drizzle.__drizzle_migrations ORDER BY id`).toEqual(journal);
|
||||
expect((await sql`SELECT data_type FROM information_schema.columns WHERE table_name='cost_events' AND column_name='cost_cents'`)[0].data_type).toBe("integer");
|
||||
expect(await sql`SELECT column_name FROM information_schema.columns WHERE table_name='cost_events' AND column_name='idempotency_key'`).toHaveLength(0);
|
||||
expect((await sql`SELECT cost_cents FROM cost_events`)[0].cost_cents).toBe(-1);
|
||||
await sql`UPDATE cost_events SET cost_cents=0`;
|
||||
await applyPendingMigrations(f.database.connectionString);
|
||||
expect((await inspectMigrations(f.database.connectionString)).status).toBe("upToDate");
|
||||
} finally { await f.close(); }
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
-- Preserve values while upgrading exact accounting. Additions are replay-safe.
|
||||
CREATE TABLE IF NOT EXISTS "accounting_runtime_baselines" (
|
||||
"agent_id" uuid PRIMARY KEY NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"cost_cents" numeric(24, 7) NOT NULL,
|
||||
"input_tokens" numeric(30, 0) NOT NULL,
|
||||
"cached_input_tokens" numeric(30, 0) NOT NULL,
|
||||
"output_tokens" numeric(30, 0) NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "billing_invoice_lines" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"invoice_id" uuid NOT NULL,
|
||||
"external_id" text NOT NULL,
|
||||
"kind" text NOT NULL,
|
||||
"amount_cents" numeric(24, 7) NOT NULL,
|
||||
"occurred_at" timestamp with time zone NOT NULL,
|
||||
"cost_event_id" uuid,
|
||||
"run_id" uuid,
|
||||
"provider_request_id" text,
|
||||
"model" text,
|
||||
"pricing" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
CONSTRAINT "billing_invoice_lines_amount_check" CHECK ("billing_invoice_lines"."amount_cents" >= 0),
|
||||
CONSTRAINT "billing_invoice_lines_kind_check" CHECK ("billing_invoice_lines"."kind" in ('inference', 'fee', 'credit'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "billing_invoices" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"biller" text NOT NULL,
|
||||
"external_id" text NOT NULL,
|
||||
"currency" text NOT NULL,
|
||||
"receipt_hash" text NOT NULL,
|
||||
"imported_by" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "budget_reservations" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"run_id" uuid NOT NULL,
|
||||
"agent_id" uuid NOT NULL,
|
||||
"project_id" uuid,
|
||||
"amount_cents" numeric(24, 7) NOT NULL,
|
||||
"state" text DEFAULT 'held' NOT NULL,
|
||||
"provider_started_at" timestamp with time zone,
|
||||
"settled_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "budget_reservations_state_check" CHECK ("budget_reservations"."state" in ('held', 'settled', 'released')),
|
||||
CONSTRAINT "budget_reservations_amount_check" CHECK ("budget_reservations"."amount_cents" >= 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "cost_adjustments" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"cost_event_id" uuid NOT NULL,
|
||||
"invoice_line_id" uuid,
|
||||
"idempotency_key" text NOT NULL,
|
||||
"receipt_hash" text NOT NULL,
|
||||
"previous_cents" numeric(24, 7) NOT NULL,
|
||||
"corrected_cents" numeric(24, 7) NOT NULL,
|
||||
"previous_status" text NOT NULL,
|
||||
"previous_pricing" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"reason" text NOT NULL,
|
||||
"actor_id" text NOT NULL,
|
||||
"pricing" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "cost_adjustments_amount_check" CHECK ("cost_adjustments"."previous_cents" >= 0 and "cost_adjustments"."corrected_cents" >= 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "provider_billing_snapshots" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"provider" text NOT NULL,
|
||||
"account_id" text NOT NULL,
|
||||
"scope_id" text NOT NULL,
|
||||
"day" text NOT NULL,
|
||||
"amount_cents" numeric(24, 7) NOT NULL,
|
||||
"revision" integer DEFAULT 1 NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "provider_billing_snapshots_amount_check" CHECK ("provider_billing_snapshots"."amount_cents" >= 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "run_usage_receipts" (
|
||||
"id" uuid PRIMARY KEY NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"run_id" uuid NOT NULL,
|
||||
"source_id" uuid NOT NULL,
|
||||
"sequence" integer NOT NULL,
|
||||
"receipt_hash" text NOT NULL,
|
||||
"receipt_json" jsonb NOT NULL,
|
||||
"complete" boolean NOT NULL,
|
||||
"received_at" timestamp with time zone NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "run_usage_receipts_positive_sequence" CHECK ("run_usage_receipts"."sequence" > 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DROP INDEX IF EXISTS "budget_incidents_policy_window_threshold_idx";--> statement-breakpoint
|
||||
ALTER TABLE "agent_runtime_state" ALTER COLUMN "total_cost_cents" SET DATA TYPE numeric(24, 7);--> statement-breakpoint
|
||||
ALTER TABLE "agents" ALTER COLUMN "spent_monthly_cents" SET DATA TYPE numeric(24, 7);--> statement-breakpoint
|
||||
ALTER TABLE "budget_incidents" ALTER COLUMN "amount_observed" SET DATA TYPE numeric(24, 7);--> statement-breakpoint
|
||||
ALTER TABLE "companies" ALTER COLUMN "spent_monthly_cents" SET DATA TYPE numeric(24, 7);--> statement-breakpoint
|
||||
ALTER TABLE "cost_events" ALTER COLUMN "cost_cents" SET DATA TYPE numeric(24, 7);--> statement-breakpoint
|
||||
ALTER TABLE "finance_events" ALTER COLUMN "amount_cents" SET DATA TYPE numeric(24, 7);--> statement-breakpoint
|
||||
ALTER TABLE "agents" ADD COLUMN IF NOT EXISTS "spend_month_utc" text;--> statement-breakpoint
|
||||
ALTER TABLE "budget_policies" ADD COLUMN IF NOT EXISTS "reservation_cents" numeric(24, 7) DEFAULT '0' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "budget_policies" ADD COLUMN IF NOT EXISTS "unpriced_usage_policy" text DEFAULT 'block' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "budget_policies" ADD COLUMN IF NOT EXISTS "enforcement_version" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "budget_policies" ADD COLUMN IF NOT EXISTS "enforcement_delivered_version" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "companies" ADD COLUMN IF NOT EXISTS "spend_month_utc" text;--> statement-breakpoint
|
||||
ALTER TABLE "cost_events" ADD COLUMN IF NOT EXISTS "idempotency_key" text;--> statement-breakpoint
|
||||
ALTER TABLE "cost_events" ADD COLUMN IF NOT EXISTS "receipt_hash" text;--> statement-breakpoint
|
||||
ALTER TABLE "cost_events" ADD COLUMN IF NOT EXISTS "provider_request_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "cost_events" ADD COLUMN IF NOT EXISTS "reported_cost_cents" numeric(24, 7);--> statement-breakpoint
|
||||
ALTER TABLE "cost_events" ADD COLUMN IF NOT EXISTS "pricing_provenance" jsonb;--> statement-breakpoint
|
||||
ALTER TABLE "finance_events" ADD COLUMN IF NOT EXISTS "idempotency_key" text;--> statement-breakpoint
|
||||
ALTER TABLE "finance_events" ADD COLUMN IF NOT EXISTS "receipt_hash" text;--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "cost_accounting_pending" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "cost_accounted_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "accounting_projection_version" text;--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "accounting_last_attempt_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "accounting_last_error" text;--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "accounting_attempt_count" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'accounting_runtime_baselines_agent_id_agents_id_fk' AND conrelid = 'public.accounting_runtime_baselines'::regclass) THEN
|
||||
ALTER TABLE "accounting_runtime_baselines" ADD CONSTRAINT "accounting_runtime_baselines_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'accounting_runtime_baselines_company_id_companies_id_fk' AND conrelid = 'public.accounting_runtime_baselines'::regclass) THEN
|
||||
ALTER TABLE "accounting_runtime_baselines" ADD CONSTRAINT "accounting_runtime_baselines_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'billing_invoice_lines_company_id_companies_id_fk' AND conrelid = 'public.billing_invoice_lines'::regclass) THEN
|
||||
ALTER TABLE "billing_invoice_lines" ADD CONSTRAINT "billing_invoice_lines_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'billing_invoice_lines_invoice_id_billing_invoices_id_fk' AND conrelid = 'public.billing_invoice_lines'::regclass) THEN
|
||||
ALTER TABLE "billing_invoice_lines" ADD CONSTRAINT "billing_invoice_lines_invoice_id_billing_invoices_id_fk" FOREIGN KEY ("invoice_id") REFERENCES "public"."billing_invoices"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'billing_invoice_lines_cost_event_id_cost_events_id_fk' AND conrelid = 'public.billing_invoice_lines'::regclass) THEN
|
||||
ALTER TABLE "billing_invoice_lines" ADD CONSTRAINT "billing_invoice_lines_cost_event_id_cost_events_id_fk" FOREIGN KEY ("cost_event_id") REFERENCES "public"."cost_events"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'billing_invoices_company_id_companies_id_fk' AND conrelid = 'public.billing_invoices'::regclass) THEN
|
||||
ALTER TABLE "billing_invoices" ADD CONSTRAINT "billing_invoices_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'budget_reservations_company_id_companies_id_fk' AND conrelid = 'public.budget_reservations'::regclass) THEN
|
||||
ALTER TABLE "budget_reservations" ADD CONSTRAINT "budget_reservations_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'budget_reservations_run_id_heartbeat_runs_id_fk' AND conrelid = 'public.budget_reservations'::regclass) THEN
|
||||
ALTER TABLE "budget_reservations" ADD CONSTRAINT "budget_reservations_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'budget_reservations_agent_id_agents_id_fk' AND conrelid = 'public.budget_reservations'::regclass) THEN
|
||||
ALTER TABLE "budget_reservations" ADD CONSTRAINT "budget_reservations_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'cost_adjustments_company_id_companies_id_fk' AND conrelid = 'public.cost_adjustments'::regclass) THEN
|
||||
ALTER TABLE "cost_adjustments" ADD CONSTRAINT "cost_adjustments_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'cost_adjustments_cost_event_id_cost_events_id_fk' AND conrelid = 'public.cost_adjustments'::regclass) THEN
|
||||
ALTER TABLE "cost_adjustments" ADD CONSTRAINT "cost_adjustments_cost_event_id_cost_events_id_fk" FOREIGN KEY ("cost_event_id") REFERENCES "public"."cost_events"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'cost_adjustments_invoice_line_id_billing_invoice_lines_id_fk' AND conrelid = 'public.cost_adjustments'::regclass) THEN
|
||||
ALTER TABLE "cost_adjustments" ADD CONSTRAINT "cost_adjustments_invoice_line_id_billing_invoice_lines_id_fk" FOREIGN KEY ("invoice_line_id") REFERENCES "public"."billing_invoice_lines"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'provider_billing_snapshots_company_id_companies_id_fk' AND conrelid = 'public.provider_billing_snapshots'::regclass) THEN
|
||||
ALTER TABLE "provider_billing_snapshots" ADD CONSTRAINT "provider_billing_snapshots_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'run_usage_receipts_company_id_companies_id_fk' AND conrelid = 'public.run_usage_receipts'::regclass) THEN
|
||||
ALTER TABLE "run_usage_receipts" ADD CONSTRAINT "run_usage_receipts_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'run_usage_receipts_run_id_heartbeat_runs_id_fk' AND conrelid = 'public.run_usage_receipts'::regclass) THEN
|
||||
ALTER TABLE "run_usage_receipts" ADD CONSTRAINT "run_usage_receipts_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "accounting_runtime_baselines_company_idx" ON "accounting_runtime_baselines" USING btree ("company_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "billing_invoice_lines_identity_idx" ON "billing_invoice_lines" USING btree ("invoice_id","external_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "billing_invoice_lines_company_idx" ON "billing_invoice_lines" USING btree ("company_id","invoice_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "billing_invoices_identity_idx" ON "billing_invoices" USING btree ("company_id","biller","external_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "budget_reservations_run_idx" ON "budget_reservations" USING btree ("company_id","run_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "budget_reservations_active_idx" ON "budget_reservations" USING btree ("company_id","agent_id","project_id") WHERE "budget_reservations"."state" = 'held';--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "cost_adjustments_receipt_idx" ON "cost_adjustments" USING btree ("company_id","idempotency_key");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "cost_adjustments_event_idx" ON "cost_adjustments" USING btree ("company_id","cost_event_id","created_at");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "cost_adjustments_invoice_line_idx" ON "cost_adjustments" USING btree ("company_id","invoice_line_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "provider_billing_snapshots_identity_idx" ON "provider_billing_snapshots" USING btree ("company_id","provider","account_id","scope_id","day");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "run_usage_receipts_source_sequence_idx" ON "run_usage_receipts" USING btree ("company_id","run_id","source_id","sequence");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "run_usage_receipts_run_latest_idx" ON "run_usage_receipts" USING btree ("company_id","run_id","received_at");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "cost_events_company_receipt_idx" ON "cost_events" USING btree ("company_id","idempotency_key");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "cost_events_provider_request_idx" ON "cost_events" USING btree ("company_id","biller","provider_request_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "cost_events_company_project_occurred_idx" ON "cost_events" USING btree ("company_id","project_id","occurred_at");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "cost_events_unpriced_idx" ON "cost_events" USING btree ("company_id","occurred_at","id") WHERE "cost_events"."cost_status" = 'unpriced' and "cost_events"."billing_type" <> 'subscription_included';--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "finance_events_company_receipt_idx" ON "finance_events" USING btree ("company_id","idempotency_key");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "heartbeat_runs_cost_accounting_pending_idx" ON "heartbeat_runs" USING btree ("updated_at","id") WHERE "heartbeat_runs"."cost_accounting_pending" = true;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "budget_incidents_policy_window_threshold_idx" ON "budget_incidents" USING btree ("policy_id","window_start","threshold_type") WHERE "budget_incidents"."status" = 'open';--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'budget_policies_reservation_nonnegative' AND conrelid = 'public.budget_policies'::regclass) THEN
|
||||
ALTER TABLE "budget_policies" ADD CONSTRAINT "budget_policies_reservation_nonnegative" CHECK ("budget_policies"."reservation_cents" >= 0);
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'cost_events_nonnegative_amounts' AND conrelid = 'public.cost_events'::regclass) THEN
|
||||
ALTER TABLE "cost_events" ADD CONSTRAINT "cost_events_nonnegative_amounts" CHECK ("cost_events"."cost_cents" >= 0 and "cost_events"."input_tokens" >= 0 and "cost_events"."cached_input_tokens" >= 0 and "cost_events"."output_tokens" >= 0);
|
||||
END IF;
|
||||
END $$;
|
||||
+51689
File diff suppressed because it is too large.
Load diff
+7
@@ -2052,6 +2052,13 @@
|
||||
"when": 1790891286527,
|
||||
"tag": "0294_chilly_marvel_apes",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 295,
|
||||
"version": "7",
|
||||
"when": 1790983662974,
|
||||
"tag": "0295_lean_baron_strucker",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { boolean, check, index, integer, jsonb, numeric, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { agents } from "./agents.js";
|
||||
import { heartbeatRuns } from "./heartbeat_runs.js";
|
||||
|
||||
export const providerBillingSnapshots = pgTable("provider_billing_snapshots", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
provider: text("provider").notNull(),
|
||||
accountId: text("account_id").notNull(),
|
||||
scopeId: text("scope_id").notNull(),
|
||||
day: text("day").notNull(),
|
||||
amountCents: numeric("amount_cents", { precision: 24, scale: 7 }).notNull(),
|
||||
revision: integer("revision").notNull().default(1),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
}, table => ({
|
||||
identity: uniqueIndex("provider_billing_snapshots_identity_idx").on(table.companyId, table.provider, table.accountId, table.scopeId, table.day),
|
||||
amount: check("provider_billing_snapshots_amount_check", sql`${table.amountCents} >= 0`),
|
||||
}));
|
||||
import { costEvents } from "./cost_events.js";
|
||||
|
||||
export const runUsageReceipts = pgTable("run_usage_receipts", {
|
||||
id: uuid("id").primaryKey(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
runId: uuid("run_id").notNull().references(() => heartbeatRuns.id, { onDelete: "cascade" }),
|
||||
sourceId: uuid("source_id").notNull(),
|
||||
sequence: integer("sequence").notNull(),
|
||||
receiptHash: text("receipt_hash").notNull(),
|
||||
receiptJson: jsonb("receipt_json").$type<Record<string, unknown>>().notNull(),
|
||||
complete: boolean("complete").notNull(),
|
||||
receivedAt: timestamp("received_at", { withTimezone: true }).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
}, table => ({
|
||||
sourceSequence: uniqueIndex("run_usage_receipts_source_sequence_idx").on(table.companyId, table.runId, table.sourceId, table.sequence),
|
||||
runLatest: index("run_usage_receipts_run_latest_idx").on(table.companyId, table.runId, table.receivedAt),
|
||||
positiveSequence: check("run_usage_receipts_positive_sequence", sql`${table.sequence} > 0`),
|
||||
}));
|
||||
|
||||
export const accountingRuntimeBaselines = pgTable("accounting_runtime_baselines", {
|
||||
agentId: uuid("agent_id").primaryKey().references(() => agents.id, { onDelete: "cascade" }),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
// Historical totals are preserved explicitly. Only v2 acknowledgements add
|
||||
// to this baseline, including receipts whose occurrence date is backdated.
|
||||
costCents: numeric("cost_cents", { precision: 24, scale: 7 }).notNull(),
|
||||
inputTokens: numeric("input_tokens", { precision: 30, scale: 0 }).notNull(),
|
||||
cachedInputTokens: numeric("cached_input_tokens", { precision: 30, scale: 0 }).notNull(),
|
||||
outputTokens: numeric("output_tokens", { precision: 30, scale: 0 }).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
}, table => ({ company: index("accounting_runtime_baselines_company_idx").on(table.companyId) }));
|
||||
|
||||
export const budgetReservations = pgTable("budget_reservations", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
runId: uuid("run_id").notNull().references(() => heartbeatRuns.id, { onDelete: "cascade" }),
|
||||
agentId: uuid("agent_id").notNull().references(() => agents.id, { onDelete: "cascade" }),
|
||||
projectId: uuid("project_id"),
|
||||
amountCents: numeric("amount_cents", { precision: 24, scale: 7 }).notNull(),
|
||||
state: text("state").notNull().default("held"),
|
||||
providerStartedAt: timestamp("provider_started_at", { withTimezone: true }),
|
||||
settledAt: timestamp("settled_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
}, table => ({
|
||||
run: uniqueIndex("budget_reservations_run_idx").on(table.companyId, table.runId),
|
||||
active: index("budget_reservations_active_idx").on(table.companyId, table.agentId, table.projectId).where(sql`${table.state} = 'held'`),
|
||||
stateCheck: check("budget_reservations_state_check", sql`${table.state} in ('held', 'settled', 'released')`),
|
||||
amountCheck: check("budget_reservations_amount_check", sql`${table.amountCents} >= 0`),
|
||||
}));
|
||||
|
||||
export const billingInvoices = pgTable("billing_invoices", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
biller: text("biller").notNull(),
|
||||
externalId: text("external_id").notNull(),
|
||||
currency: text("currency").notNull(),
|
||||
receiptHash: text("receipt_hash").notNull(),
|
||||
importedBy: text("imported_by").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
}, table => ({ identity: uniqueIndex("billing_invoices_identity_idx").on(table.companyId, table.biller, table.externalId) }));
|
||||
|
||||
export const billingInvoiceLines = pgTable("billing_invoice_lines", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
invoiceId: uuid("invoice_id").notNull().references(() => billingInvoices.id, { onDelete: "cascade" }),
|
||||
externalId: text("external_id").notNull(),
|
||||
kind: text("kind").notNull(),
|
||||
amountCents: numeric("amount_cents", { precision: 24, scale: 7 }).notNull(),
|
||||
occurredAt: timestamp("occurred_at", { withTimezone: true }).notNull(),
|
||||
costEventId: uuid("cost_event_id").references(() => costEvents.id, { onDelete: "cascade" }),
|
||||
runId: uuid("run_id"),
|
||||
providerRequestId: text("provider_request_id"),
|
||||
model: text("model"),
|
||||
pricing: jsonb("pricing").$type<Record<string, unknown>>().notNull().default({}),
|
||||
}, table => ({
|
||||
identity: uniqueIndex("billing_invoice_lines_identity_idx").on(table.invoiceId, table.externalId),
|
||||
company: index("billing_invoice_lines_company_idx").on(table.companyId, table.invoiceId),
|
||||
amount: check("billing_invoice_lines_amount_check", sql`${table.amountCents} >= 0`),
|
||||
kindCheck: check("billing_invoice_lines_kind_check", sql`${table.kind} in ('inference', 'fee', 'credit')`),
|
||||
}));
|
||||
|
||||
export const costAdjustments = pgTable("cost_adjustments", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
costEventId: uuid("cost_event_id").notNull().references(() => costEvents.id, { onDelete: "cascade" }),
|
||||
invoiceLineId: uuid("invoice_line_id").references(() => billingInvoiceLines.id, { onDelete: "cascade" }),
|
||||
idempotencyKey: text("idempotency_key").notNull(),
|
||||
receiptHash: text("receipt_hash").notNull(),
|
||||
previousCents: numeric("previous_cents", { precision: 24, scale: 7 }).notNull(),
|
||||
correctedCents: numeric("corrected_cents", { precision: 24, scale: 7 }).notNull(),
|
||||
previousStatus: text("previous_status").notNull(),
|
||||
previousPricing: jsonb("previous_pricing").$type<Record<string, unknown>>().notNull().default({}),
|
||||
reason: text("reason").notNull(),
|
||||
actorId: text("actor_id").notNull(),
|
||||
pricing: jsonb("pricing").$type<Record<string, unknown>>().notNull().default({}),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
}, table => ({
|
||||
identity: uniqueIndex("cost_adjustments_receipt_idx").on(table.companyId, table.idempotencyKey),
|
||||
event: index("cost_adjustments_event_idx").on(table.companyId, table.costEventId, table.createdAt),
|
||||
invoiceLine: uniqueIndex("cost_adjustments_invoice_line_idx").on(table.companyId, table.invoiceLineId),
|
||||
amount: check("cost_adjustments_amount_check", sql`${table.previousCents} >= 0 and ${table.correctedCents} >= 0`),
|
||||
}));
|
||||
@@ -1,4 +1,4 @@
|
||||
import { pgTable, uuid, text, timestamp, jsonb, bigint, index } from "drizzle-orm/pg-core";
|
||||
import { numeric, pgTable, uuid, text, timestamp, jsonb, bigint, index } from "drizzle-orm/pg-core";
|
||||
import { agents } from "./agents.js";
|
||||
import { companies } from "./companies.js";
|
||||
|
||||
@@ -15,7 +15,7 @@ export const agentRuntimeState = pgTable(
|
||||
totalInputTokens: bigint("total_input_tokens", { mode: "number" }).notNull().default(0),
|
||||
totalOutputTokens: bigint("total_output_tokens", { mode: "number" }).notNull().default(0),
|
||||
totalCachedInputTokens: bigint("total_cached_input_tokens", { mode: "number" }).notNull().default(0),
|
||||
totalCostCents: bigint("total_cost_cents", { mode: "number" }).notNull().default(0),
|
||||
totalCostCents: numeric("total_cost_cents", { precision: 24, scale: 7, mode: "number" }).notNull().default(0),
|
||||
lastError: text("last_error"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { AgentAppearance } from "@paperclipai/shared";
|
||||
import {
|
||||
type AnyPgColumn,
|
||||
pgTable,
|
||||
numeric,
|
||||
uuid,
|
||||
text,
|
||||
integer,
|
||||
@@ -31,7 +32,8 @@ export const agents = pgTable(
|
||||
runtimeConfig: jsonb("runtime_config").$type<Record<string, unknown>>().notNull().default({}),
|
||||
defaultEnvironmentId: uuid("default_environment_id").references(() => environments.id, { onDelete: "set null" }),
|
||||
budgetMonthlyCents: integer("budget_monthly_cents").notNull().default(0),
|
||||
spentMonthlyCents: integer("spent_monthly_cents").notNull().default(0),
|
||||
spendMonthUtc: text("spend_month_utc"),
|
||||
spentMonthlyCents: numeric("spent_monthly_cents", { precision: 24, scale: 7, mode: "number" }).notNull().default(0),
|
||||
pauseReason: text("pause_reason"),
|
||||
pausedAt: timestamp("paused_at", { withTimezone: true }),
|
||||
errorReason: text("error_reason"),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { index, integer, pgTable, text, timestamp, uuid, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import { numeric, index, integer, pgTable, text, timestamp, uuid, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import { approvals } from "./approvals.js";
|
||||
import { budgetPolicies } from "./budget_policies.js";
|
||||
import { companies } from "./companies.js";
|
||||
@@ -18,7 +18,7 @@ export const budgetIncidents = pgTable(
|
||||
windowEnd: timestamp("window_end", { withTimezone: true }).notNull(),
|
||||
thresholdType: text("threshold_type").notNull(),
|
||||
amountLimit: integer("amount_limit").notNull(),
|
||||
amountObserved: integer("amount_observed").notNull(),
|
||||
amountObserved: numeric("amount_observed", { precision: 24, scale: 7, mode: "number" }).notNull(),
|
||||
status: text("status").notNull().default("open"),
|
||||
approvalId: uuid("approval_id").references(() => approvals.id),
|
||||
resolvedAt: timestamp("resolved_at", { withTimezone: true }),
|
||||
@@ -37,6 +37,6 @@ export const budgetIncidents = pgTable(
|
||||
table.policyId,
|
||||
table.windowStart,
|
||||
table.thresholdType,
|
||||
).where(sql`${table.status} <> 'dismissed'`),
|
||||
).where(sql`${table.status} = 'open'`),
|
||||
}),
|
||||
);
|
||||
@@ -1,4 +1,5 @@
|
||||
import { boolean, index, integer, pgTable, text, timestamp, uuid, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { check, boolean, index, integer, pgTable, text, timestamp, uuid, uniqueIndex, numeric } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
|
||||
export const budgetPolicies = pgTable(
|
||||
@@ -11,16 +12,21 @@ export const budgetPolicies = pgTable(
|
||||
metric: text("metric").notNull().default("billed_cents"),
|
||||
windowKind: text("window_kind").notNull(),
|
||||
amount: integer("amount").notNull().default(0),
|
||||
reservationCents: numeric("reservation_cents", { precision: 24, scale: 7 }).notNull().default("0"),
|
||||
warnPercent: integer("warn_percent").notNull().default(80),
|
||||
hardStopEnabled: boolean("hard_stop_enabled").notNull().default(true),
|
||||
unpricedUsagePolicy: text("unpriced_usage_policy").notNull().default("block"),
|
||||
notifyEnabled: boolean("notify_enabled").notNull().default(true),
|
||||
isActive: boolean("is_active").notNull().default(true),
|
||||
enforcementVersion: integer("enforcement_version").notNull().default(0),
|
||||
enforcementDeliveredVersion: integer("enforcement_delivered_version").notNull().default(0),
|
||||
createdByUserId: text("created_by_user_id"),
|
||||
updatedByUserId: text("updated_by_user_id"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
reservationNonnegative: check("budget_policies_reservation_nonnegative", sql`${table.reservationCents} >= 0`),
|
||||
companyScopeActiveIdx: index("budget_policies_company_scope_active_idx").on(
|
||||
table.companyId,
|
||||
table.scopeType,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { InteractionResolverGovernance } from "@paperclipai/shared";
|
||||
import { pgTable, uuid, text, integer, timestamp, boolean, jsonb, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import { numeric, pgTable, uuid, text, integer, timestamp, boolean, jsonb, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
|
||||
export const companies = pgTable(
|
||||
"companies",
|
||||
@@ -13,7 +13,8 @@ export const companies = pgTable(
|
||||
issuePrefix: text("issue_prefix").notNull().default("PAP"),
|
||||
issueCounter: integer("issue_counter").notNull().default(0),
|
||||
budgetMonthlyCents: integer("budget_monthly_cents").notNull().default(0),
|
||||
spentMonthlyCents: integer("spent_monthly_cents").notNull().default(0),
|
||||
spendMonthUtc: text("spend_month_utc"),
|
||||
spentMonthlyCents: numeric("spent_monthly_cents", { precision: 24, scale: 7, mode: "number" }).notNull().default(0),
|
||||
defaultResponsibleUserId: text("default_responsible_user_id"),
|
||||
requireBoardApprovalForNewAgents: boolean("require_board_approval_for_new_agents")
|
||||
.notNull()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { pgTable, uuid, text, timestamp, integer, index } from "drizzle-orm/pg-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { pgTable, uuid, text, timestamp, integer, index, numeric, uniqueIndex, check, jsonb } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { agents } from "./agents.js";
|
||||
import { issues } from "./issues.js";
|
||||
@@ -17,6 +18,11 @@ export const costEvents = pgTable(
|
||||
goalId: uuid("goal_id").references(() => goals.id),
|
||||
heartbeatRunId: uuid("heartbeat_run_id").references(() => heartbeatRuns.id),
|
||||
billingCode: text("billing_code"),
|
||||
idempotencyKey: text("idempotency_key"),
|
||||
receiptHash: text("receipt_hash"),
|
||||
providerRequestId: text("provider_request_id"),
|
||||
reportedCostCents: numeric("reported_cost_cents", { precision: 24, scale: 7 }),
|
||||
pricingProvenance: jsonb("pricing_provenance").$type<Record<string, unknown>>(),
|
||||
provider: text("provider").notNull(),
|
||||
biller: text("biller").notNull().default("unknown"),
|
||||
billingType: text("billing_type").notNull().default("unknown"),
|
||||
@@ -25,11 +31,16 @@ export const costEvents = pgTable(
|
||||
inputTokens: integer("input_tokens").notNull().default(0),
|
||||
cachedInputTokens: integer("cached_input_tokens").notNull().default(0),
|
||||
outputTokens: integer("output_tokens").notNull().default(0),
|
||||
costCents: integer("cost_cents").notNull(),
|
||||
costCents: numeric("cost_cents", { precision: 24, scale: 7, mode: "number" }).notNull(),
|
||||
occurredAt: timestamp("occurred_at", { withTimezone: true }).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
receiptUniqueIdx: uniqueIndex("cost_events_company_receipt_idx").on(table.companyId, table.idempotencyKey),
|
||||
providerRequestIdx: index("cost_events_provider_request_idx").on(table.companyId, table.biller, table.providerRequestId),
|
||||
nonnegativeAmounts: check("cost_events_nonnegative_amounts", sql`${table.costCents} >= 0 and ${table.inputTokens} >= 0 and ${table.cachedInputTokens} >= 0 and ${table.outputTokens} >= 0`),
|
||||
companyProjectOccurredIdx: index("cost_events_company_project_occurred_idx").on(table.companyId, table.projectId, table.occurredAt),
|
||||
unpricedIdx: index("cost_events_unpriced_idx").on(table.companyId, table.occurredAt, table.id).where(sql`${table.costStatus} = 'unpriced' and ${table.billingType} <> 'subscription_included'`),
|
||||
companyOccurredIdx: index("cost_events_company_occurred_idx").on(table.companyId, table.occurredAt),
|
||||
companyAgentOccurredIdx: index("cost_events_company_agent_occurred_idx").on(
|
||||
table.companyId,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { pgTable, uuid, text, timestamp, integer, index, boolean, jsonb } from "drizzle-orm/pg-core";
|
||||
import { pgTable, uuid, text, timestamp, integer, numeric, index, uniqueIndex, boolean, jsonb } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { agents } from "./agents.js";
|
||||
import { issues } from "./issues.js";
|
||||
@@ -18,6 +18,8 @@ export const financeEvents = pgTable(
|
||||
goalId: uuid("goal_id").references(() => goals.id),
|
||||
heartbeatRunId: uuid("heartbeat_run_id").references(() => heartbeatRuns.id),
|
||||
costEventId: uuid("cost_event_id").references(() => costEvents.id),
|
||||
idempotencyKey: text("idempotency_key"),
|
||||
receiptHash: text("receipt_hash"),
|
||||
billingCode: text("billing_code"),
|
||||
description: text("description"),
|
||||
eventKind: text("event_kind").notNull(),
|
||||
@@ -30,7 +32,7 @@ export const financeEvents = pgTable(
|
||||
model: text("model"),
|
||||
quantity: integer("quantity"),
|
||||
unit: text("unit"),
|
||||
amountCents: integer("amount_cents").notNull(),
|
||||
amountCents: numeric("amount_cents", { precision: 24, scale: 7, mode: "number" }).notNull(),
|
||||
currency: text("currency").notNull().default("USD"),
|
||||
estimated: boolean("estimated").notNull().default(false),
|
||||
externalInvoiceId: text("external_invoice_id"),
|
||||
@@ -39,6 +41,7 @@ export const financeEvents = pgTable(
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
receiptUniqueIdx: uniqueIndex("finance_events_company_receipt_idx").on(table.companyId, table.idempotencyKey),
|
||||
companyOccurredIdx: index("finance_events_company_occurred_idx").on(table.companyId, table.occurredAt),
|
||||
companyBillerOccurredIdx: index("finance_events_company_biller_occurred_idx").on(
|
||||
table.companyId,
|
||||
|
||||
@@ -39,6 +39,12 @@ export const heartbeatRuns = pgTable(
|
||||
wakeupRequestId: uuid("wakeup_request_id").references(() => agentWakeupRequests.id),
|
||||
exitCode: integer("exit_code"),
|
||||
signal: text("signal"),
|
||||
costAccountingPending: boolean("cost_accounting_pending").notNull().default(false),
|
||||
costAccountedAt: timestamp("cost_accounted_at", { withTimezone: true }),
|
||||
accountingProjectionVersion: text("accounting_projection_version"),
|
||||
accountingLastAttemptAt: timestamp("accounting_last_attempt_at", { withTimezone: true }),
|
||||
accountingLastError: text("accounting_last_error"),
|
||||
accountingAttemptCount: integer("accounting_attempt_count").notNull().default(0),
|
||||
usageJson: jsonb("usage_json").$type<Record<string, unknown>>(),
|
||||
resultJson: jsonb("result_json").$type<Record<string, unknown>>(),
|
||||
runtimeMode: text("runtime_mode").notNull().default("legacy"),
|
||||
@@ -98,6 +104,7 @@ export const heartbeatRuns = pgTable(
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
costAccountingPendingIdx: index("heartbeat_runs_cost_accounting_pending_idx").on(table.updatedAt, table.id).where(sql`${table.costAccountingPending} = true`),
|
||||
executionStatusDeliveryIdx: index("heartbeat_runs_execution_status_delivery_idx")
|
||||
.on(table.executionStatusDeliveryId).where(sql`${table.executionStatusDeliveryId} is not null`),
|
||||
executionControlDeadlineIdx: index("heartbeat_runs_execution_control_deadline_idx")
|
||||
|
||||
@@ -218,3 +218,5 @@ export { browserUseSettings, browserUseSessions, browserUseRuns, browserUseBrows
|
||||
|
||||
|
||||
export * from "./company_skill_sources.js";
|
||||
|
||||
export * from "./accounting.js";
|
||||
@@ -0,0 +1,115 @@
|
||||
import { z } from "zod";
|
||||
import { centsToUnits, normalizeCents } from "./money.js";
|
||||
|
||||
export const exactCentsSchema = z.union([z.string().max(160), z.number().finite()]).transform((value, ctx) => {
|
||||
try {
|
||||
if ((typeof value === "number" ? value < 0 : value.startsWith("-")) || centsToUnits(value) < 0n) throw new Error("Money cannot be negative");
|
||||
return normalizeCents(value);
|
||||
} catch (error) {
|
||||
ctx.addIssue({ code: "custom", message: error instanceof Error ? error.message : "Invalid money" });
|
||||
return z.NEVER;
|
||||
}
|
||||
});
|
||||
export const pricingProvenanceSchema = z.object({
|
||||
source: z.enum(["provider_reported", "provider_invoice", "operator", "rate_card", "unknown"]).default("unknown"),
|
||||
version: z.string().max(100).optional(),
|
||||
evidence: z.string().max(1000).optional(),
|
||||
inputCentsPerMillion: exactCentsSchema.optional(),
|
||||
cachedInputCentsPerMillion: exactCentsSchema.optional(),
|
||||
outputCentsPerMillion: exactCentsSchema.optional(),
|
||||
cacheWriteCentsPerMillion: exactCentsSchema.optional(),
|
||||
serviceTier: z.string().max(50).optional(),
|
||||
contextTier: z.enum(["short", "long"]).optional(),
|
||||
});
|
||||
export const importBillingInvoiceSchema = z.object({
|
||||
biller: z.string().trim().min(1).max(150),
|
||||
externalId: z.string().trim().min(1).max(200),
|
||||
currency: z.string().regex(/^[A-Za-z]{3}$/).transform(value => value.toUpperCase()),
|
||||
lines: z.array(z.object({
|
||||
externalId: z.string().trim().min(1).max(200),
|
||||
kind: z.enum(["inference", "fee", "credit"]).default("inference"),
|
||||
amountCents: exactCentsSchema,
|
||||
occurredAt: z.iso.datetime({ offset: true }),
|
||||
costEventId: z.string().uuid().optional(),
|
||||
runId: z.string().uuid().optional(),
|
||||
providerRequestId: z.string().min(1).max(250).optional(),
|
||||
model: z.string().min(1).max(250).optional(),
|
||||
pricing: pricingProvenanceSchema.optional(),
|
||||
})).min(1).max(2000),
|
||||
}).superRefine((invoice, ctx) => {
|
||||
if (new Set(invoice.lines.map(line => line.externalId)).size !== invoice.lines.length)
|
||||
ctx.addIssue({ code: "custom", path: ["lines"], message: "Invoice line identifiers must be unique" });
|
||||
});
|
||||
export const adjustCostSchema = z.object({
|
||||
idempotencyKey: z.string().trim().min(1).max(200),
|
||||
expectedCents: exactCentsSchema,
|
||||
correctedCents: exactCentsSchema,
|
||||
invoiceLineId: z.string().uuid().optional(),
|
||||
reason: z.string().trim().min(1).max(1000),
|
||||
pricing: pricingProvenanceSchema,
|
||||
});
|
||||
export const repairAccountingSchema = z.object({ fingerprint: z.string().regex(/^[a-f0-9]{64}$/), reason: z.string().trim().min(1).max(1000) });
|
||||
export const retryAccountingSchema = z.object({ runId: z.string().uuid() });
|
||||
export const importProviderCostsSchema = z.object({
|
||||
provider: z.enum(["openai", "anthropic"]),
|
||||
secretId: z.string().uuid(),
|
||||
accountId: z.string().trim().min(1).max(150).regex(/^[a-zA-Z0-9_-]+$/),
|
||||
scopeIds: z.array(z.string().trim().min(1).max(150)).min(1).max(25),
|
||||
from: z.iso.date(),
|
||||
to: z.iso.date(),
|
||||
}).superRefine((input, ctx) => {
|
||||
const duration = Date.parse(input.to) - Date.parse(input.from);
|
||||
if (duration <= 0 || duration > 31 * 86400000 || Date.parse(input.to) > Math.floor(Date.now() / 86400000) * 86400000)
|
||||
ctx.addIssue({ code: "custom", message: "Choose up to 31 completed UTC days; the end date is exclusive." });
|
||||
if (new Set(input.scopeIds).size !== input.scopeIds.length)
|
||||
ctx.addIssue({ code: "custom", message: "Project/workspace identifiers must be unique." });
|
||||
});
|
||||
export type ImportProviderCosts = z.input<typeof importProviderCostsSchema>;
|
||||
export type ImportBillingInvoice = z.input<typeof importBillingInvoiceSchema>;
|
||||
export type AdjustCost = z.input<typeof adjustCostSchema>;
|
||||
export interface AccountingFinding {
|
||||
kind: "company_projection" | "agent_projection" | "runtime_projection" | "missing_receipt" | "missing_acknowledgement" | "legacy_runtime" | "receipt_mismatch";
|
||||
entityId: string;
|
||||
repairable: boolean;
|
||||
actual: Record<string, string>;
|
||||
expected: Record<string, string>;
|
||||
}
|
||||
export interface AccountingInspection {
|
||||
companyId: string;
|
||||
fingerprint: string;
|
||||
findings: AccountingFinding[];
|
||||
checkedAt: string;
|
||||
}
|
||||
export interface AccountingHealth {
|
||||
companyId: string;
|
||||
pendingRunCount: number;
|
||||
unpricedEventCount: number;
|
||||
oldestPendingAt: string | null;
|
||||
pendingCancellationCount: number;
|
||||
heldReservationCents: string;
|
||||
items: Array<{ runId: string | null; costEventId?: string; agentId: string; state: "waiting_for_receipt" | "retryable" | "unpriced"; lastError: string | null; attempts: number; since: string; lastAttemptAt: string | null }>;
|
||||
}
|
||||
export interface BillingInvoice {
|
||||
id: string; companyId: string; biller: string; externalId: string; currency: string; createdAt: string;
|
||||
}
|
||||
export interface CostAdjustment {
|
||||
id: string;
|
||||
companyId: string;
|
||||
costEventId: string;
|
||||
invoiceLineId: string | null;
|
||||
idempotencyKey: string;
|
||||
receiptHash: string;
|
||||
previousCents: string;
|
||||
correctedCents: string;
|
||||
previousStatus: string;
|
||||
previousPricing: Record<string, unknown>;
|
||||
pricing: Record<string, unknown>;
|
||||
reason: string;
|
||||
actorId: string;
|
||||
createdAt: string;
|
||||
}
|
||||
export interface BillingReconciliation {
|
||||
invoice: BillingInvoice;
|
||||
lines: Array<{ id: string; externalId: string; kind: string; amountCents: string; status: string;
|
||||
matchedEventId: string | null; recordedCents: string | null; differenceCents: string | null }>;
|
||||
}
|
||||
@@ -833,7 +833,7 @@ export const BILLING_TYPES = [
|
||||
] as const;
|
||||
export type BillingType = (typeof BILLING_TYPES)[number];
|
||||
|
||||
export const COST_STATUSES = ["reported", "unpriced"] as const;
|
||||
export const COST_STATUSES = ["reported", "estimated", "unpriced"] as const;
|
||||
export type CostStatus = (typeof COST_STATUSES)[number];
|
||||
|
||||
export const FINANCE_EVENT_KINDS = [
|
||||
|
||||
@@ -2816,3 +2816,6 @@ export * from "./browser-use.js";
|
||||
export * from "./types/skill-source.js";
|
||||
export * from "./validators/skill-source.js";
|
||||
export * from "./github-skill-repository.js";
|
||||
|
||||
export * from "./money.js";
|
||||
export * from "./accounting.js";
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { addCents, centsToUnits, centsToUsd, compareCents, formatUsdExact, MAX_MONEY_UNITS, normalizeCents, subtractCents, unitsToCents, usdToCents } from "./money.js";
|
||||
|
||||
describe("fixed-point money", () => {
|
||||
it.each([
|
||||
["0.00000005", "0.0000001"], ["-0.00000005", "-0.0000001"],
|
||||
["0.000000049", "0.0000000"], ["1e-7", "0.0000001"],
|
||||
["12345678901234567.1234567", "12345678901234567.1234567"],
|
||||
])("normalizes %s exactly", (input, expected) => expect(normalizeCents(input)).toBe(expected));
|
||||
it("does exact arithmetic beyond floating-point precision", () => {
|
||||
expect(addCents("12345678901234567.1234567", "0.0000001")).toBe("12345678901234567.1234568");
|
||||
expect(subtractCents("9007199254740992.0000001", "9007199254740992")).toBe("0.0000001");
|
||||
expect(compareCents("9007199254740992.0000001", "9007199254740992")).toBe(1);
|
||||
expect(addCents(0.1, 0.2)).toBe("0.3000000");
|
||||
});
|
||||
it("converts USD without intermediate multiplication or double rounding", () => {
|
||||
expect(usdToCents("0.000000001")).toBe("0.0000001");
|
||||
expect(usdToCents("0.00000000049")).toBe("0.0000000");
|
||||
expect(usdToCents("0.0123456789")).toBe("1.2345679");
|
||||
expect(formatUsdExact("4.5")).toBe("$0.05");
|
||||
});
|
||||
it("round-trips the full database range", () => expect(centsToUnits(unitsToCents(MAX_MONEY_UNITS))).toBe(MAX_MONEY_UNITS));
|
||||
it.each(["0.0000001", "9007199254740992.0000001", "-0.0000001", unitsToCents(MAX_MONEY_UNITS)])("preserves an editable USD value for %s cents", cents => {
|
||||
expect(usdToCents(centsToUsd(cents))).toBe(cents);
|
||||
});
|
||||
it.each(["NaN", "Infinity", "", " 1", "1.2.3", "1e101", "100000000000000000", Number.NaN, Number.POSITIVE_INFINITY, Number.MAX_SAFE_INTEGER + 1])("rejects invalid/out-of-range %s", input => {
|
||||
expect(() => centsToUnits(input)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
/** Monetary storage uses nanodollars: 10^7 units per cent, 10^9 per USD.
|
||||
* Numbers are accepted only at compatibility boundaries. All arithmetic uses
|
||||
* integers; decimal inputs are rounded once, half away from zero. */
|
||||
export const MONEY_SCALE = 10_000_000n;
|
||||
export const MAX_MONEY_UNITS = 10n ** 24n - 1n;
|
||||
export type MoneyInput = string | number;
|
||||
|
||||
function decimalUnits(value: MoneyInput, places: number): bigint {
|
||||
if (typeof value === "number" && !Number.isFinite(value)) throw new Error("Money must be finite");
|
||||
if (typeof value === "number" && Math.abs(value) > Number.MAX_SAFE_INTEGER) throw new Error("Use a decimal string for large monetary values");
|
||||
const text = String(value);
|
||||
if (text.length > 160) throw new Error("Money exceeds supported precision");
|
||||
const match = /^([+-]?)(\d+)(?:\.(\d+))?(?:e([+-]?\d+))?$/i.exec(text);
|
||||
if (!match) throw new Error("Invalid decimal money amount");
|
||||
const exponent = Number(match[4] ?? 0);
|
||||
if (!Number.isInteger(exponent) || Math.abs(exponent) > 100) throw new Error("Money exponent is out of range");
|
||||
const digits = BigInt(match[2] + (match[3] ?? ""));
|
||||
const shift = places + exponent - (match[3]?.length ?? 0);
|
||||
const divisor = shift < 0 ? 10n ** BigInt(-shift) : 1n;
|
||||
const magnitude = shift >= 0 ? digits * 10n ** BigInt(shift) : (digits + divisor / 2n) / divisor;
|
||||
if (magnitude > MAX_MONEY_UNITS) throw new Error("Money exceeds numeric(24,7) storage");
|
||||
return match[1] === "-" ? -magnitude : magnitude;
|
||||
}
|
||||
|
||||
export function centsToUnits(value: MoneyInput): bigint { return decimalUnits(value, 7); }
|
||||
export function usdToUnits(value: MoneyInput): bigint { return decimalUnits(value, 9); }
|
||||
export function unitsToCents(value: bigint): string {
|
||||
if (value > MAX_MONEY_UNITS || value < -MAX_MONEY_UNITS) throw new Error("Money exceeds numeric(24,7) storage");
|
||||
const magnitude = value < 0n ? -value : value;
|
||||
return `${value < 0n ? "-" : ""}${magnitude / MONEY_SCALE}.${String(magnitude % MONEY_SCALE).padStart(7, "0")}`;
|
||||
}
|
||||
export function normalizeCents(value: MoneyInput): string { return unitsToCents(centsToUnits(value)); }
|
||||
export function usdToCents(value: MoneyInput): string { return unitsToCents(usdToUnits(value)); }
|
||||
/** Exact editable USD value; unlike display formatting, this never rounds cents. */
|
||||
export function centsToUsd(value: MoneyInput): string {
|
||||
const units = centsToUnits(value);
|
||||
const magnitude = units < 0n ? -units : units;
|
||||
return `${units < 0n ? "-" : ""}${magnitude / 1_000_000_000n}.${String(magnitude % 1_000_000_000n).padStart(9, "0")}`;
|
||||
}
|
||||
/** Approximate legacy/UI value. Never use this conversion for a money decision. */
|
||||
export function centsToNumber(value: MoneyInput): number { return Number(normalizeCents(value)); }
|
||||
export function addCents(...values: MoneyInput[]): string { return unitsToCents(values.reduce((sum, value) => sum + centsToUnits(value), 0n)); }
|
||||
export function subtractCents(a: MoneyInput, b: MoneyInput): string { return unitsToCents(centsToUnits(a) - centsToUnits(b)); }
|
||||
export function compareCents(a: MoneyInput, b: MoneyInput): number {
|
||||
const left = centsToUnits(a), right = centsToUnits(b);
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
export function formatUsdExact(cents: MoneyInput): string {
|
||||
const units = centsToUnits(cents);
|
||||
const magnitude = units < 0n ? -units : units;
|
||||
const rounded = (magnitude + MONEY_SCALE / 2n) / MONEY_SCALE;
|
||||
return `${units < 0n ? "-" : ""}$${rounded / 100n}.${String(rounded % 100n).padStart(2, "0")}`;
|
||||
}
|
||||
@@ -16,8 +16,10 @@ export interface BudgetPolicy {
|
||||
metric: BudgetMetric;
|
||||
windowKind: BudgetWindowKind;
|
||||
amount: number;
|
||||
reservationCents?: string;
|
||||
warnPercent: number;
|
||||
hardStopEnabled: boolean;
|
||||
unpricedUsagePolicy: "block" | "allow";
|
||||
notifyEnabled: boolean;
|
||||
isActive: boolean;
|
||||
createdByUserId: string | null;
|
||||
@@ -35,11 +37,16 @@ export interface BudgetPolicySummary {
|
||||
metric: BudgetMetric;
|
||||
windowKind: BudgetWindowKind;
|
||||
amount: number;
|
||||
reservationCents?: string;
|
||||
observedAmount: number;
|
||||
observedAmountExact?: string;
|
||||
unpricedEventCount: number;
|
||||
pendingRunCount: number;
|
||||
remainingAmount: number;
|
||||
utilizationPercent: number;
|
||||
warnPercent: number;
|
||||
hardStopEnabled: boolean;
|
||||
unpricedUsagePolicy: "block" | "allow";
|
||||
notifyEnabled: boolean;
|
||||
isActive: boolean;
|
||||
status: "ok" | "warning" | "hard_stop";
|
||||
@@ -86,8 +93,10 @@ export interface BudgetPolicyUpsertInput {
|
||||
metric?: BudgetMetric;
|
||||
windowKind?: BudgetWindowKind;
|
||||
amount: number;
|
||||
reservationCents?: string | number;
|
||||
warnPercent?: number;
|
||||
hardStopEnabled?: boolean;
|
||||
unpricedUsagePolicy?: "block" | "allow";
|
||||
notifyEnabled?: boolean;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface CostEvent {
|
||||
goalId: string | null;
|
||||
heartbeatRunId: string | null;
|
||||
billingCode: string | null;
|
||||
idempotencyKey: string | null;
|
||||
provider: string;
|
||||
biller: string;
|
||||
billingType: BillingType;
|
||||
@@ -19,13 +20,20 @@ export interface CostEvent {
|
||||
cachedInputTokens: number;
|
||||
outputTokens: number;
|
||||
costCents: number;
|
||||
costCentsExact?: string;
|
||||
occurredAt: Date;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface CostSummary {
|
||||
eventCount: number;
|
||||
pendingRunCount: number;
|
||||
unpricedEventCount: number;
|
||||
estimatedEventCount?: number;
|
||||
pricingComplete: boolean;
|
||||
companyId: string;
|
||||
spendCents: number;
|
||||
spendCentsExact?: string;
|
||||
budgetCents: number;
|
||||
utilizationPercent: number;
|
||||
}
|
||||
@@ -35,6 +43,7 @@ export interface IssueCostSummary {
|
||||
issueCount: number;
|
||||
includeDescendants: boolean;
|
||||
costCents: number;
|
||||
costCentsExact?: string;
|
||||
inputTokens: number;
|
||||
cachedInputTokens: number;
|
||||
outputTokens: number;
|
||||
@@ -51,7 +60,11 @@ export interface CostByAgent {
|
||||
agentAppearance?: AgentAppearance | null;
|
||||
avatarUrl?: string;
|
||||
agentStatus: string | null;
|
||||
/** Ledger events in this group and selected date range, not distinct runs. */
|
||||
eventCount: number;
|
||||
estimatedEventCount: number;
|
||||
costCents: number;
|
||||
costCentsExact?: string;
|
||||
inputTokens: number;
|
||||
cachedInputTokens: number;
|
||||
outputTokens: number;
|
||||
@@ -68,6 +81,7 @@ export interface CostByProviderModel {
|
||||
billingType: BillingType;
|
||||
model: string;
|
||||
costCents: number;
|
||||
costCentsExact?: string;
|
||||
inputTokens: number;
|
||||
cachedInputTokens: number;
|
||||
outputTokens: number;
|
||||
@@ -81,6 +95,7 @@ export interface CostByProviderModel {
|
||||
export interface CostByBiller {
|
||||
biller: string;
|
||||
costCents: number;
|
||||
costCentsExact?: string;
|
||||
inputTokens: number;
|
||||
cachedInputTokens: number;
|
||||
outputTokens: number;
|
||||
@@ -103,7 +118,11 @@ export interface CostByAgentModel {
|
||||
biller: string;
|
||||
billingType: BillingType;
|
||||
model: string;
|
||||
/** Ledger events in this group and selected date range, not distinct runs. */
|
||||
eventCount: number;
|
||||
estimatedEventCount: number;
|
||||
costCents: number;
|
||||
costCentsExact?: string;
|
||||
inputTokens: number;
|
||||
cachedInputTokens: number;
|
||||
outputTokens: number;
|
||||
@@ -118,6 +137,7 @@ export interface CostWindowSpendRow {
|
||||
/** rolling window duration in hours */
|
||||
windowHours: number;
|
||||
costCents: number;
|
||||
costCentsExact?: string;
|
||||
inputTokens: number;
|
||||
cachedInputTokens: number;
|
||||
outputTokens: number;
|
||||
@@ -128,6 +148,7 @@ export interface CostByProject {
|
||||
projectId: string | null;
|
||||
projectName: string | null;
|
||||
costCents: number;
|
||||
costCentsExact?: string;
|
||||
inputTokens: number;
|
||||
cachedInputTokens: number;
|
||||
outputTokens: number;
|
||||
|
||||
@@ -22,39 +22,75 @@ export interface FinanceEvent {
|
||||
quantity: number | null;
|
||||
unit: FinanceUnit | null;
|
||||
amountCents: number;
|
||||
amountCentsExact?: string;
|
||||
currency: string;
|
||||
estimated: boolean;
|
||||
externalInvoiceId: string | null;
|
||||
idempotencyKey: string | null;
|
||||
metadataJson: Record<string, unknown> | null;
|
||||
occurredAt: Date;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface FinanceCurrencySummary {
|
||||
currency: string;
|
||||
providerReportedCentsExact?: string;
|
||||
debitCents: number;
|
||||
debitCentsExact?: string;
|
||||
creditCents: number;
|
||||
creditCentsExact?: string;
|
||||
netCents: number;
|
||||
netCentsExact?: string;
|
||||
estimatedDebitCents: number;
|
||||
estimatedDebitCentsExact?: string;
|
||||
eventCount: number;
|
||||
}
|
||||
|
||||
export interface FinanceSummary {
|
||||
/** Provider cost reports overlap invoices and run estimates; never add these totals. */
|
||||
providerReportedCents?: number;
|
||||
providerReportedCentsExact?: string;
|
||||
/** Compatibility totals below are USD only; no implicit currency conversion. */
|
||||
currency: "USD";
|
||||
currencies: FinanceCurrencySummary[];
|
||||
companyId: string;
|
||||
debitCents: number;
|
||||
debitCentsExact?: string;
|
||||
creditCents: number;
|
||||
creditCentsExact?: string;
|
||||
netCents: number;
|
||||
netCentsExact?: string;
|
||||
estimatedDebitCents: number;
|
||||
estimatedDebitCentsExact?: string;
|
||||
eventCount: number;
|
||||
}
|
||||
|
||||
export interface FinanceByBiller {
|
||||
currency: string;
|
||||
biller: string;
|
||||
debitCents: number;
|
||||
debitCentsExact?: string;
|
||||
creditCents: number;
|
||||
creditCentsExact?: string;
|
||||
netCents: number;
|
||||
netCentsExact?: string;
|
||||
estimatedDebitCents: number;
|
||||
estimatedDebitCentsExact?: string;
|
||||
eventCount: number;
|
||||
kindCount: number;
|
||||
}
|
||||
|
||||
export interface FinanceByKind {
|
||||
currency: string;
|
||||
eventKind: FinanceEventKind;
|
||||
debitCents: number;
|
||||
debitCentsExact?: string;
|
||||
creditCents: number;
|
||||
creditCentsExact?: string;
|
||||
netCents: number;
|
||||
netCentsExact?: string;
|
||||
estimatedDebitCents: number;
|
||||
estimatedDebitCentsExact?: string;
|
||||
eventCount: number;
|
||||
billerCount: number;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { exactCentsSchema } from "../accounting.js";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
BUDGET_INCIDENT_RESOLUTION_ACTIONS,
|
||||
@@ -11,9 +12,11 @@ export const upsertBudgetPolicySchema = z.object({
|
||||
scopeId: z.string().guid(),
|
||||
metric: z.enum(BUDGET_METRICS).optional().default("billed_cents"),
|
||||
windowKind: z.enum(BUDGET_WINDOW_KINDS).optional().default("calendar_month_utc"),
|
||||
amount: z.number().int().nonnegative(),
|
||||
amount: z.number().int().nonnegative().max(2_147_483_647),
|
||||
reservationCents: exactCentsSchema.optional(),
|
||||
warnPercent: z.number().int().min(1).max(99).optional().default(80),
|
||||
hardStopEnabled: z.boolean().optional().default(true),
|
||||
unpricedUsagePolicy: z.enum(["block", "allow"]).optional(),
|
||||
notifyEnabled: z.boolean().optional().default(true),
|
||||
isActive: z.boolean().optional().default(true),
|
||||
});
|
||||
@@ -22,7 +25,7 @@ export type UpsertBudgetPolicy = z.infer<typeof upsertBudgetPolicySchema>;
|
||||
|
||||
export const resolveBudgetIncidentSchema = z.object({
|
||||
action: z.enum(BUDGET_INCIDENT_RESOLUTION_ACTIONS),
|
||||
amount: z.number().int().nonnegative().optional(),
|
||||
amount: z.number().int().nonnegative().max(2_147_483_647).optional(),
|
||||
decisionNote: z.string().optional().nullable(),
|
||||
}).superRefine((value, ctx) => {
|
||||
if (value.action === "raise_budget_and_resume" && typeof value.amount !== "number") {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { exactCentsSchema, pricingProvenanceSchema } from "../accounting.js";
|
||||
import { z } from "zod";
|
||||
import { BILLING_TYPES, COST_STATUSES } from "../constants.js";
|
||||
|
||||
export const createCostEventSchema = z.object({
|
||||
idempotencyKey: z.string().trim().min(1).max(200).optional().nullable(),
|
||||
agentId: z.string().guid(),
|
||||
issueId: z.string().guid().optional().nullable(),
|
||||
projectId: z.string().guid().optional().nullable(),
|
||||
@@ -13,17 +15,19 @@ export const createCostEventSchema = z.object({
|
||||
billingType: z.enum(BILLING_TYPES).optional().default("unknown"),
|
||||
costStatus: z.enum(COST_STATUSES).optional().default("reported"),
|
||||
model: z.string().min(1),
|
||||
inputTokens: z.number().int().nonnegative().optional().default(0),
|
||||
cachedInputTokens: z.number().int().nonnegative().optional().default(0),
|
||||
outputTokens: z.number().int().nonnegative().optional().default(0),
|
||||
costCents: z.number().int().nonnegative(),
|
||||
inputTokens: z.number().int().nonnegative().max(2_147_483_647).optional().default(0),
|
||||
cachedInputTokens: z.number().int().nonnegative().max(2_147_483_647).optional().default(0),
|
||||
outputTokens: z.number().int().nonnegative().max(2_147_483_647).optional().default(0),
|
||||
costCents: exactCentsSchema,
|
||||
providerRequestId: z.string().min(1).max(250).nullable().optional(),
|
||||
pricingProvenance: pricingProvenanceSchema.nullable().optional(),
|
||||
occurredAt: z.string().datetime(),
|
||||
}).transform((value) => ({
|
||||
...value,
|
||||
biller: value.biller ?? value.provider,
|
||||
}));
|
||||
|
||||
export type CreateCostEvent = z.infer<typeof createCostEventSchema>;
|
||||
export type CreateCostEvent = z.input<typeof createCostEventSchema>;
|
||||
|
||||
export const updateBudgetSchema = z.object({
|
||||
budgetMonthlyCents: z.number().int().nonnegative(),
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { exactCentsSchema } from "../accounting.js";
|
||||
import { z } from "zod";
|
||||
import { AGENT_ADAPTER_TYPES, FINANCE_DIRECTIONS, FINANCE_EVENT_KINDS, FINANCE_UNITS } from "../constants.js";
|
||||
|
||||
export const createFinanceEventSchema = z.object({
|
||||
idempotencyKey: z.string().trim().min(1).max(200).optional().nullable(),
|
||||
agentId: z.string().guid().optional().nullable(),
|
||||
issueId: z.string().guid().optional().nullable(),
|
||||
projectId: z.string().guid().optional().nullable(),
|
||||
@@ -20,8 +22,8 @@ export const createFinanceEventSchema = z.object({
|
||||
model: z.string().min(1).optional().nullable(),
|
||||
quantity: z.number().int().nonnegative().optional().nullable(),
|
||||
unit: z.enum(FINANCE_UNITS).optional().nullable(),
|
||||
amountCents: z.number().int().nonnegative(),
|
||||
currency: z.string().length(3).optional().default("USD"),
|
||||
amountCents: exactCentsSchema,
|
||||
currency: z.string().regex(/^[A-Za-z]{3}$/).optional().default("USD"),
|
||||
estimated: z.boolean().optional().default(false),
|
||||
externalInvoiceId: z.string().optional().nullable(),
|
||||
metadataJson: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
@@ -31,4 +33,4 @@ export const createFinanceEventSchema = z.object({
|
||||
currency: value.currency.toUpperCase(),
|
||||
}));
|
||||
|
||||
export type CreateFinanceEvent = z.infer<typeof createFinanceEventSchema>;
|
||||
export type CreateFinanceEvent = z.input<typeof createFinanceEventSchema>;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtemp, readFile, rm, writeFile, mkdir } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
const root = fileURLToPath(new URL("..", import.meta.url));
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "accounting-mutations-"));
|
||||
const targets = { deduplication: "deduplicates a retried receipt", company: "isolates company reporting", projection: "conserves agent projections", threshold: "stops at the exact budget boundary" };
|
||||
const evidence = [];
|
||||
try {
|
||||
for (const name of ["baseline", ...Object.keys(targets)]) {
|
||||
const report = path.join(directory, `${name}.json`);
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, ["node_modules/vitest/vitest.mjs", "run", "--config", "server/vitest.accounting-mutations.config.ts", "--reporter=json", `--outputFile=${report}`], {
|
||||
cwd: root, env: { ...process.env, PAPERCLIP_ACCOUNTING_MUTATION: name === "baseline" ? "" : name }, stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let log = ""; const capture = data => { log += data; };
|
||||
child.stdout.on("data", capture); child.stderr.on("data", capture);
|
||||
child.once("error", reject); child.once("exit", code => resolve({ code, log }));
|
||||
});
|
||||
const json = JSON.parse(await readFile(report, "utf8").catch(() => { throw new Error(`${name} produced no test evidence: ${result.log}`); }));
|
||||
const assertions = json.testResults.flatMap(file => file.assertionResults);
|
||||
if (name === "baseline") {
|
||||
if (result.code !== 0 || assertions.length !== 4 || assertions.some(test => test.status !== "passed")) throw new Error(`Mutation baseline failed or skipped: ${result.log}`);
|
||||
} else {
|
||||
const sentinel = assertions.find(test => test.title === targets[name]);
|
||||
if (result.code === 0 || !result.log.includes(`ACCOUNTING_MUTATION_APPLIED ${name}`) || sentinel?.status !== "failed") throw new Error(`Mutation ${name} survived or failed without its sentinel: ${result.log}`);
|
||||
}
|
||||
evidence.push({ mutation: name, result: name === "baseline" ? "passed" : "killed", assertions: assertions.map(({ title, status }) => ({ title, status })) });
|
||||
process.stdout.write(`${name}: ${name === "baseline" ? "passed" : "killed by expected assertion"}\n`);
|
||||
}
|
||||
await mkdir(path.join(root, "coverage/accounting"), { recursive: true });
|
||||
await writeFile(path.join(root, "coverage/accounting/mutations.json"), JSON.stringify({ generatedAt: new Date().toISOString(), evidence }, null, 2));
|
||||
} finally { await rm(directory, { recursive: true, force: true }); }
|
||||
@@ -0,0 +1,80 @@
|
||||
import { budgetService } from "../src/services/budgets.js";
|
||||
import { sql } from "drizzle-orm";
|
||||
/** Reproducible synthetic PostgreSQL benchmark. Never reads DATABASE_URL or
|
||||
* provider credentials; startEmbeddedPostgresTestDatabase owns all data. */
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { agents, companies, projects, createDb, startEmbeddedPostgresTestDatabase } from "@paperclipai/db";
|
||||
import { costService } from "../src/services/costs.js";
|
||||
import { withAccountingTransaction } from "../src/services/accounting-transaction.js";
|
||||
import { accountingIntegrityService } from "../src/services/accounting-integrity.js";
|
||||
import { reconcileRunCosts } from "../src/services/run-cost-accounting.js";
|
||||
const count = Number(process.env.PAPERCLIP_ACCOUNTING_BENCH_ROWS ?? 1_000_000);
|
||||
if (!Number.isInteger(count) || count < 1000 || count > 10_000_000) throw new Error("Benchmark row count must be 1,000–10,000,000");
|
||||
const database = await startEmbeddedPostgresTestDatabase("accounting-scale-");
|
||||
const db = createDb(database.connectionString);
|
||||
const started = performance.now();
|
||||
function distribution(values: number[]) {
|
||||
const sorted = [...values].sort((a,b) => a-b);
|
||||
const pick = (p: number) => Number(sorted[Math.min(sorted.length - 1, Math.ceil(p * sorted.length) - 1)].toFixed(2));
|
||||
return { samples: sorted.length, p50Ms: pick(0.5), p95Ms: pick(0.95), p99Ms: pick(0.99), maxMs: pick(1) };
|
||||
}
|
||||
async function time(work: () => Promise<unknown>) { const start = performance.now(); await work(); return performance.now() - start; }
|
||||
try {
|
||||
const [company] = await db.insert(companies).values({ name: "Scale fixture", issuePrefix: "SCALE" }).returning();
|
||||
const [agent] = await db.insert(agents).values({ companyId: company.id, name: "Worker", role: "engineer", adapterType: "process", status: "idle" }).returning();
|
||||
const [project] = await db.insert(projects).values({ companyId: company.id, name: "Hot project" }).returning();
|
||||
const seedMs = await time(async () => {
|
||||
await db.$client`insert into cost_events (company_id,agent_id,project_id,provider,biller,model,cost_cents,input_tokens,cached_input_tokens,output_tokens,occurred_at)
|
||||
select ${company.id},${agent.id}, case when i % 100 = 0 then ${project.id}::uuid else null end, 'fixture','fixture','fixture', 0.1234567,7,11,3,
|
||||
date_trunc('month',now() at time zone 'UTC') at time zone 'UTC' - (i % 12) * interval '1 month'
|
||||
from generate_series(1,${count}) i`;
|
||||
await db.$client`analyze cost_events`;
|
||||
// Seed projections through an explicit reviewed repair, not benchmark-only SQL.
|
||||
const integrity = accountingIntegrityService(db); const review = await integrity.inspect(company.id);
|
||||
await integrity.repair(company.id, review.fingerprint, "Initialize synthetic benchmark projections", "benchmark");
|
||||
});
|
||||
const reports: number[] = [], projectReports: number[] = [];
|
||||
for (let i = 0; i < 20; i++) {
|
||||
reports.push(await time(() => costService(db).summary(company.id, { allTime: true })));
|
||||
projectReports.push(await time(() => costService(db).byProject(company.id, { allTime: true })));
|
||||
}
|
||||
const writes: number[] = []; const concurrency = 8; const writeStarted = performance.now();
|
||||
for (let batch = 0; batch < 8; batch++) await Promise.all(Array.from({ length: concurrency }, (_, index) => time(() => costService(db).createEvent(company.id, {
|
||||
agentId: agent.id, provider: "fixture", model: "fixture", costCents: "0.0000001", idempotencyKey: `bench:${batch}:${index}`, occurredAt: new Date(),
|
||||
})).then(ms => writes.push(ms))));
|
||||
const writeMs = performance.now() - writeStarted;
|
||||
await budgetService(db).upsertPolicy(company.id, { scopeType: "company", scopeId: company.id, amount: 2_000_000_000 }, "benchmark");
|
||||
await budgetService(db).upsertPolicy(company.id, { scopeType: "agent", scopeId: agent.id, amount: 2_000_000_000 }, "benchmark");
|
||||
const budgetedWrites: number[] = [];
|
||||
const budgetedStart = performance.now();
|
||||
for (let batch = 0; batch < 4; batch++) await Promise.all(Array.from({ length: concurrency }, (_, index) => time(() => costService(db).createEvent(company.id, {
|
||||
agentId: agent.id, provider: "fixture", model: "fixture", costCents: "0.0000001", idempotencyKey: `budgeted:${batch}:${index}`, occurredAt: new Date(),
|
||||
})).then(ms => budgetedWrites.push(ms))));
|
||||
const budgetedMs = performance.now() - budgetedStart;
|
||||
const waits: number[] = [];
|
||||
await Promise.all(Array.from({ length: concurrency }, () => {
|
||||
const start = performance.now();
|
||||
return withAccountingTransaction(db, company.id, async tx => { waits.push(performance.now() - start); await tx.execute(sql`select pg_sleep(0.005)`); });
|
||||
}));
|
||||
await db.$client`insert into heartbeat_runs (company_id,agent_id,status,cost_accounting_pending,usage_json,finished_at)
|
||||
select ${company.id},${agent.id},'failed',true,'{"accountingReceiptReady":true,"costUsdExact":"0.000000001","inputTokens":1}'::jsonb, now() from generate_series(1,250)`;
|
||||
const recovery: Array<{ milliseconds: number; scanned: number; accounted: number }> = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const before = performance.now(); const result = await reconcileRunCosts(db); recovery.push({ milliseconds: performance.now()-before, ...result });
|
||||
}
|
||||
if (recovery.reduce((sum, r) => sum+r.accounted,0) !== 250) throw new Error("Benchmark recovery did not conserve receipt count");
|
||||
const integrity = await accountingIntegrityService(db).inspect(company.id);
|
||||
if (integrity.findings.length) throw new Error(`Benchmark violated accounting integrity: ${JSON.stringify(integrity.findings)}`);
|
||||
const plan = await db.$client`explain (analyze,buffers,format json) select sum(cost_cents) from cost_events where company_id = ${company.id} and project_id = ${project.id}
|
||||
and occurred_at >= date_trunc('month',now() at time zone 'UTC') at time zone 'UTC'`;
|
||||
const report = { generatedAt: new Date().toISOString(), platform: `${os.platform()} ${os.arch()}`, cpus: os.cpus().length, node: process.version,
|
||||
seedRows: count, seedMs, concurrency, allTimeSummary: distribution(reports), allTimeProjects: distribution(projectReports),
|
||||
hotCompanyWrites: { activePolicies: 0, ...distribution(writes), writesPerSecond: writes.length / (writeMs / 1000) }, companyLockAcquisition: distribution(waits),
|
||||
budgetedCompanyWrites: { activePolicies: 2, ...distribution(budgetedWrites), writesPerSecond: budgetedWrites.length / (budgetedMs / 1000) },
|
||||
recovery, projectBudgetQueryPlan: plan, integrityFindings: integrity.findings.length, totalMs: performance.now()-started };
|
||||
const output = path.resolve("coverage/accounting/scale.json"); await mkdir(path.dirname(output), { recursive: true }); await writeFile(output, JSON.stringify(report,null,2));
|
||||
process.stdout.write(JSON.stringify({ ...report, projectBudgetQueryPlan: "see coverage/accounting/scale.json" },null,2)+"\n");
|
||||
} finally { await db.$client.end(); await database.cleanup(); }
|
||||
@@ -22,308 +22,8 @@ vi.mock("../services/activity-log.js", () => ({
|
||||
logActivity: mockLogActivity,
|
||||
}));
|
||||
|
||||
type SelectResult = unknown[];
|
||||
|
||||
function createDbStub(selectResults: SelectResult[]) {
|
||||
const pendingSelects = [...selectResults];
|
||||
const selectWhere = vi.fn(async () => pendingSelects.shift() ?? []);
|
||||
const selectThen = vi.fn((resolve: (value: unknown[]) => unknown) => Promise.resolve(resolve(pendingSelects.shift() ?? [])));
|
||||
const selectOrderBy = vi.fn(async () => pendingSelects.shift() ?? []);
|
||||
const selectFrom = vi.fn(() => ({
|
||||
where: selectWhere,
|
||||
then: selectThen,
|
||||
orderBy: selectOrderBy,
|
||||
}));
|
||||
const select = vi.fn(() => ({
|
||||
from: selectFrom,
|
||||
}));
|
||||
|
||||
const insertValues = vi.fn();
|
||||
const insertReturning = vi.fn(async () => pendingInserts.shift() ?? []);
|
||||
const insert = vi.fn(() => ({
|
||||
values: insertValues.mockImplementation(() => ({
|
||||
returning: insertReturning,
|
||||
})),
|
||||
}));
|
||||
|
||||
const updateSet = vi.fn();
|
||||
const updateWhere = vi.fn(async () => pendingUpdates.shift() ?? []);
|
||||
const update = vi.fn(() => ({
|
||||
set: updateSet.mockImplementation(() => ({
|
||||
where: updateWhere,
|
||||
})),
|
||||
}));
|
||||
|
||||
const pendingInserts: unknown[][] = [];
|
||||
const pendingUpdates: unknown[][] = [];
|
||||
|
||||
return {
|
||||
db: {
|
||||
select,
|
||||
insert,
|
||||
update,
|
||||
},
|
||||
queueInsert: (rows: unknown[]) => {
|
||||
pendingInserts.push(rows);
|
||||
},
|
||||
queueUpdate: (rows: unknown[] = []) => {
|
||||
pendingUpdates.push(rows);
|
||||
},
|
||||
selectWhere,
|
||||
insertValues,
|
||||
updateSet,
|
||||
};
|
||||
}
|
||||
|
||||
describe("budgetService", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("creates a hard-stop incident and pauses an agent when spend exceeds a budget", async () => {
|
||||
const policy = {
|
||||
id: "policy-1",
|
||||
companyId: "company-1",
|
||||
scopeType: "agent",
|
||||
scopeId: "agent-1",
|
||||
metric: "billed_cents",
|
||||
windowKind: "calendar_month_utc",
|
||||
amount: 100,
|
||||
warnPercent: 80,
|
||||
hardStopEnabled: true,
|
||||
notifyEnabled: false,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
const dbStub = createDbStub([
|
||||
[policy],
|
||||
[{ total: 150 }],
|
||||
[],
|
||||
[{
|
||||
companyId: "company-1",
|
||||
name: "Budget Agent",
|
||||
status: "running",
|
||||
pauseReason: null,
|
||||
}],
|
||||
]);
|
||||
|
||||
dbStub.queueInsert([{
|
||||
id: "approval-1",
|
||||
companyId: "company-1",
|
||||
status: "pending",
|
||||
}]);
|
||||
dbStub.queueInsert([{
|
||||
id: "incident-1",
|
||||
companyId: "company-1",
|
||||
policyId: "policy-1",
|
||||
approvalId: "approval-1",
|
||||
}]);
|
||||
dbStub.queueUpdate([]);
|
||||
const cancelWorkForScope = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const service = budgetService(dbStub.db as any, { cancelWorkForScope });
|
||||
await service.evaluateCostEvent({
|
||||
companyId: "company-1",
|
||||
agentId: "agent-1",
|
||||
projectId: null,
|
||||
} as any);
|
||||
|
||||
expect(dbStub.insertValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
companyId: "company-1",
|
||||
type: "budget_override_required",
|
||||
status: "pending",
|
||||
}),
|
||||
);
|
||||
expect(dbStub.insertValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
companyId: "company-1",
|
||||
policyId: "policy-1",
|
||||
thresholdType: "hard",
|
||||
amountLimit: 100,
|
||||
amountObserved: 150,
|
||||
approvalId: "approval-1",
|
||||
}),
|
||||
);
|
||||
expect(dbStub.updateSet).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: "paused",
|
||||
pauseReason: "budget",
|
||||
pausedAt: expect.any(Date),
|
||||
}),
|
||||
);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "budget.hard_threshold_crossed",
|
||||
entityId: "incident-1",
|
||||
}),
|
||||
);
|
||||
expect(cancelWorkForScope).toHaveBeenCalledWith({
|
||||
companyId: "company-1",
|
||||
scopeType: "agent",
|
||||
scopeId: "agent-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks new work when an agent hard-stop remains exceeded even if the agent is not paused yet", async () => {
|
||||
const agentPolicy = {
|
||||
id: "policy-agent-1",
|
||||
companyId: "company-1",
|
||||
scopeType: "agent",
|
||||
scopeId: "agent-1",
|
||||
metric: "billed_cents",
|
||||
windowKind: "calendar_month_utc",
|
||||
amount: 100,
|
||||
warnPercent: 80,
|
||||
hardStopEnabled: true,
|
||||
notifyEnabled: true,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
const dbStub = createDbStub([
|
||||
[{
|
||||
status: "running",
|
||||
pauseReason: null,
|
||||
companyId: "company-1",
|
||||
name: "Budget Agent",
|
||||
}],
|
||||
[{
|
||||
status: "active",
|
||||
name: "Paperclip",
|
||||
}],
|
||||
[],
|
||||
[agentPolicy],
|
||||
[{ total: 120 }],
|
||||
]);
|
||||
|
||||
const service = budgetService(dbStub.db as any);
|
||||
const block = await service.getInvocationBlock("company-1", "agent-1");
|
||||
|
||||
expect(block).toEqual({
|
||||
scopeType: "agent",
|
||||
scopeId: "agent-1",
|
||||
scopeName: "Budget Agent",
|
||||
reason: "Agent cannot start because its budget hard-stop is still exceeded.",
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces a budget-owned company pause distinctly from a manual pause", async () => {
|
||||
const dbStub = createDbStub([
|
||||
[{
|
||||
status: "idle",
|
||||
pauseReason: null,
|
||||
companyId: "company-1",
|
||||
name: "Budget Agent",
|
||||
}],
|
||||
[{
|
||||
status: "paused",
|
||||
pauseReason: "budget",
|
||||
name: "Paperclip",
|
||||
}],
|
||||
]);
|
||||
|
||||
const service = budgetService(dbStub.db as any);
|
||||
const block = await service.getInvocationBlock("company-1", "agent-1");
|
||||
|
||||
expect(block).toEqual({
|
||||
scopeType: "company",
|
||||
scopeId: "company-1",
|
||||
scopeName: "Paperclip",
|
||||
reason: "Company is paused because its budget hard-stop was reached.",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses live observed spend when raising a budget incident", async () => {
|
||||
const dbStub = createDbStub([
|
||||
[{
|
||||
id: "incident-1",
|
||||
companyId: "company-1",
|
||||
policyId: "policy-1",
|
||||
amountObserved: 120,
|
||||
approvalId: "approval-1",
|
||||
}],
|
||||
[{
|
||||
id: "policy-1",
|
||||
companyId: "company-1",
|
||||
scopeType: "company",
|
||||
scopeId: "company-1",
|
||||
metric: "billed_cents",
|
||||
windowKind: "calendar_month_utc",
|
||||
}],
|
||||
[{ total: 150 }],
|
||||
]);
|
||||
|
||||
const service = budgetService(dbStub.db as any);
|
||||
|
||||
await expect(
|
||||
service.resolveIncident(
|
||||
"company-1",
|
||||
"incident-1",
|
||||
{ action: "raise_budget_and_resume", amount: 140 },
|
||||
"board-user",
|
||||
),
|
||||
).rejects.toThrow("New budget must exceed current observed spend");
|
||||
});
|
||||
|
||||
it("syncs company monthly budget when raising and resuming a company incident", async () => {
|
||||
const now = new Date();
|
||||
const dbStub = createDbStub([
|
||||
[{
|
||||
id: "incident-1",
|
||||
companyId: "company-1",
|
||||
policyId: "policy-1",
|
||||
scopeType: "company",
|
||||
scopeId: "company-1",
|
||||
metric: "billed_cents",
|
||||
windowKind: "calendar_month_utc",
|
||||
windowStart: now,
|
||||
windowEnd: now,
|
||||
thresholdType: "hard",
|
||||
amountLimit: 100,
|
||||
amountObserved: 120,
|
||||
status: "open",
|
||||
approvalId: "approval-1",
|
||||
resolvedAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}],
|
||||
[{
|
||||
id: "policy-1",
|
||||
companyId: "company-1",
|
||||
scopeType: "company",
|
||||
scopeId: "company-1",
|
||||
metric: "billed_cents",
|
||||
windowKind: "calendar_month_utc",
|
||||
amount: 100,
|
||||
}],
|
||||
[{ total: 120 }],
|
||||
[{ id: "approval-1", status: "approved" }],
|
||||
[{
|
||||
companyId: "company-1",
|
||||
name: "Paperclip",
|
||||
status: "paused",
|
||||
pauseReason: "budget",
|
||||
pausedAt: now,
|
||||
}],
|
||||
]);
|
||||
|
||||
const service = budgetService(dbStub.db as any);
|
||||
await service.resolveIncident(
|
||||
"company-1",
|
||||
"incident-1",
|
||||
{ action: "raise_budget_and_resume", amount: 175 },
|
||||
"board-user",
|
||||
);
|
||||
|
||||
expect(dbStub.updateSet).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
budgetMonthlyCents: 175,
|
||||
updatedAt: expect.any(Date),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
// Budget behavior is exercised against PostgreSQL below and in
|
||||
// cost-accounting-reliability.test.ts, including transaction and retry paths.
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
@@ -488,7 +188,7 @@ describeEmbeddedPostgres("budgetService release gate enforcement", () => {
|
||||
expect(agentAfterHardStop).toMatchObject({ status: "paused", pauseReason: "budget" });
|
||||
expect(agentAfterHardStop?.pausedAt).toBeInstanceOf(Date);
|
||||
expect(cancelWorkForScope).toHaveBeenCalledTimes(2);
|
||||
expect(cancelWorkForScope).toHaveBeenCalledWith({ companyId, scopeType: "agent", scopeId: agentId });
|
||||
expect(cancelWorkForScope).toHaveBeenCalledWith({ companyId, scopeType: "agent", scopeId: agentId, createdBefore: expect.any(Date) });
|
||||
|
||||
const block = await service.getInvocationBlock(companyId, agentId);
|
||||
expect(block).toEqual({
|
||||
@@ -573,7 +273,7 @@ describeEmbeddedPostgres("budgetService release gate enforcement", () => {
|
||||
.from(projects);
|
||||
expect(projectAfterHardStop?.pauseReason).toBe("budget");
|
||||
expect(projectAfterHardStop?.pausedAt).toBeInstanceOf(Date);
|
||||
expect(cancelWorkForScope).toHaveBeenCalledWith({ companyId, scopeType: "project", scopeId: projectId });
|
||||
expect(cancelWorkForScope).toHaveBeenCalledWith({ companyId, scopeType: "project", scopeId: projectId, createdBefore: expect.any(Date) });
|
||||
|
||||
const overviewWhileBlocked = await service.overview(companyId);
|
||||
expect(overviewWhileBlocked.pausedProjectCount).toBe(1);
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AdapterUsageCheckpoint } from "@paperclipai/adapter-utils";
|
||||
import { priceCodexReceipt } from "../services/codex-pricing.js";
|
||||
|
||||
const receipt: AdapterUsageCheckpoint = {
|
||||
provider: "openai",
|
||||
biller: "openai",
|
||||
billingType: "metered_api",
|
||||
model: "gpt-6-astra",
|
||||
complete: true,
|
||||
usageBasis: "per_run",
|
||||
costUsd: null,
|
||||
usage: {
|
||||
inputTokens: 123536,
|
||||
cachedInputTokens: 1567209,
|
||||
outputTokens: 6625,
|
||||
},
|
||||
};
|
||||
describe("prospective Codex pricing", () => {
|
||||
it.each([
|
||||
["gpt-6-sol", "12.200000000"],
|
||||
["gpt-6.1-sol", "12.100000000"],
|
||||
["gpt-6-luna", "0.610000000"],
|
||||
["gpt-5.6-sol", "24.400000000"],
|
||||
])("uses the distinct published cache rate for %s", (model, expected) => {
|
||||
expect(
|
||||
priceCodexReceipt({
|
||||
...receipt,
|
||||
model,
|
||||
usage: {
|
||||
inputTokens: 1_000_000,
|
||||
cachedInputTokens: 1_000_000,
|
||||
outputTokens: 1_000_000,
|
||||
},
|
||||
}).costUsdExact,
|
||||
).toBe(expected);
|
||||
});
|
||||
it("prices the staging token shape without counting cached input twice", () => {
|
||||
const result = priceCodexReceipt(receipt);
|
||||
expect(result.costUsdExact).toBe("3.133819000");
|
||||
expect(result.costStatus).toBe("estimated");
|
||||
expect(result.pricingProvenance).toMatchObject({
|
||||
source: "rate_card",
|
||||
version: "openai-standard-2026-09-30",
|
||||
});
|
||||
expect(result.pricingProvenance?.evidence).toContain(
|
||||
"short per-request context assumed",
|
||||
);
|
||||
expect(receipt.costUsdExact).toBeUndefined();
|
||||
});
|
||||
it.each([
|
||||
{ costUsd: 0 },
|
||||
{ costUsd: 4.21 },
|
||||
{ costUsdExact: "0.004" },
|
||||
{ cacheAdjustedCostUsd: 0.01 },
|
||||
{ provider: "other" },
|
||||
{ biller: "proxy" },
|
||||
{ model: "gpt-6-astra-custom" },
|
||||
{ model: "unknown" },
|
||||
{ model: "constructor" },
|
||||
{ model: "__proto__" },
|
||||
{ usageBasis: undefined },
|
||||
{ usageBasis: null },
|
||||
{ billingType: "unknown" },
|
||||
{ billingType: "subscription_included" },
|
||||
{ usageBasis: "session_cumulative" },
|
||||
{ pricingContext: { serviceTier: "unsupported" } },
|
||||
{ usage: undefined },
|
||||
{ usage: { inputTokens: -1, outputTokens: 0 } },
|
||||
{ usage: { inputTokens: 1, outputTokens: 0, cacheWriteTokens: 2 } },
|
||||
])(
|
||||
"preserves reported costs and refuses ambiguous pricing: %j",
|
||||
(override) => {
|
||||
const input = { ...receipt, ...override } as AdapterUsageCheckpoint;
|
||||
expect(priceCodexReceipt(input)).toBe(input);
|
||||
},
|
||||
);
|
||||
it.each([
|
||||
["standard", "short", "61.000000000"],
|
||||
["fast", "short", "122.000000000"],
|
||||
["flex", "short", "30.500000000"],
|
||||
["standard", "long", "97.000000000"],
|
||||
["batch", "long", "48.500000000"],
|
||||
] as const)("handles %s/%s rates", (serviceTier, contextTier, expected) => {
|
||||
const result = priceCodexReceipt({
|
||||
...receipt,
|
||||
pricingContext: { serviceTier, contextTier },
|
||||
usage: {
|
||||
inputTokens: 1_000_000,
|
||||
cachedInputTokens: 1_000_000,
|
||||
outputTokens: 1_000_000,
|
||||
cacheWriteTokens: 0,
|
||||
},
|
||||
});
|
||||
expect(result.costUsdExact).toBe(expected);
|
||||
});
|
||||
it("charges cache writes once as a subset of input", () => {
|
||||
expect(
|
||||
priceCodexReceipt({
|
||||
...receipt,
|
||||
usage: {
|
||||
inputTokens: 1_000_000,
|
||||
cacheWriteTokens: 1_000_000,
|
||||
outputTokens: 0,
|
||||
},
|
||||
}).costUsdExact,
|
||||
).toBe("12.500000000");
|
||||
});
|
||||
it("retains nanodollar precision and does not reprice frozen receipts", () => {
|
||||
const priced = priceCodexReceipt({
|
||||
...receipt,
|
||||
model: "gpt-6-luna",
|
||||
usage: { inputTokens: 0, cachedInputTokens: 1, outputTokens: 0 },
|
||||
});
|
||||
expect(priced.costUsdExact).toBe("0.000000010");
|
||||
expect(priceCodexReceipt(priced)).toBe(priced);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createUsageCheckpointLog } from "@paperclipai/adapter-utils/usage-checkpoint";
|
||||
import { parseClaudeStreamJson } from "../../../packages/adapters/claude-local/src/server/parse.js";
|
||||
import { parseCodexJsonl } from "../../../packages/adapters/codex-local/src/server/parse.js";
|
||||
import { parseCursorJsonl } from "../../../packages/adapters/cursor-local/src/server/parse.js";
|
||||
import { parseGeminiJsonl } from "../../../packages/adapters/gemini-local/src/server/parse.js";
|
||||
import { parseOpenCodeJsonl } from "../../../packages/adapters/opencode-local/src/server/parse.js";
|
||||
import { parsePiJsonl } from "../../../packages/adapters/pi-local/src/server/parse.js";
|
||||
const secret = "TRANSCRIPT_CONTENT_MUST_NOT_ENTER_ACCOUNTING_BUFFER";
|
||||
const fixtures = [
|
||||
{ name: "claude", event: { type: "result", total_cost_usd: 0.0123, result: secret, usage: { input_tokens: 7, cache_read_input_tokens: 11, output_tokens: 3 } }, parse: parseClaudeStreamJson, input: 7, cached: 11, output: 3 },
|
||||
{ name: "codex", event: { type: "turn.completed", text: secret, usage: { input_tokens: 18, cached_input_tokens: 11, output_tokens: 3 } }, parse: parseCodexJsonl, input: 7, cached: 11, output: 3 },
|
||||
{ name: "cursor", event: { type: "result", result: secret, usage: { inputTokens: 7, cachedInputTokens: 11, outputTokens: 3 } }, parse: parseCursorJsonl, input: 7, cached: 11, output: 3 },
|
||||
{ name: "gemini", event: { type: "result", message: secret, stats: { input_tokens: 18, cached: 11, output_tokens: 1, total_tokens: 21 } }, parse: parseGeminiJsonl, input: 7, cached: 11, output: 3 },
|
||||
{ name: "opencode", event: { type: "step_finish", part: { text: secret, cost: 0.0123, tokens: { input: 5, cache: { read: 11, write: 2 }, output: 1, reasoning: 2 } } }, parse: parseOpenCodeJsonl, input: 7, cached: 11, output: 3 },
|
||||
{ name: "pi", event: { type: "turn_end", message: { role: "assistant", content: secret, usage: { input: 5, cacheWrite: 2, cacheRead: 11, output: 3, cost: { total: 0.0123 } } } }, parse: parsePiJsonl, input: 7, cached: 11, output: 3 },
|
||||
];
|
||||
describe("durable accounting stream projection", () => {
|
||||
it.each(fixtures)("captures split $name usage without retaining transcript content", async f => {
|
||||
const onUsage = vi.fn(async (..._args: any[]) => {}), onLog = vi.fn(async (..._args: any[]) => {});
|
||||
const parse = vi.fn((source: string) => {
|
||||
expect(source).not.toContain(secret);
|
||||
const result = f.parse(source); return { usage: result.usage ?? undefined, complete: true };
|
||||
});
|
||||
const log = createUsageCheckpointLog(onLog, onUsage, parse);
|
||||
const line = JSON.stringify(f.event) + "\n";
|
||||
for (let index = 0; index < line.length; index += 7) await log("stdout", line.slice(index,index+7));
|
||||
expect(onUsage).toHaveBeenCalledTimes(1);
|
||||
expect(onUsage.mock.calls[0][0]).toMatchObject({ complete: true, attemptId: expect.any(String), usage: { inputTokens: f.input, cachedInputTokens: f.cached, outputTokens: f.output } });
|
||||
expect(onLog.mock.calls.map(call => call[1]).join("")).toBe(line);
|
||||
});
|
||||
it("awaits durability before publication, suppresses identical snapshots and separates attempts", async () => {
|
||||
const order: string[] = [];
|
||||
const onUsage = vi.fn(async (..._args: any[]) => { order.push("durable"); });
|
||||
const parse = () => ({ usage: { inputTokens: 1, outputTokens: 2 }, complete: false });
|
||||
const log = createUsageCheckpointLog(async () => { order.push("published"); }, onUsage, parse);
|
||||
const message = '{"type":"result","usage":{"input_tokens":1}}\n';
|
||||
await log("stdout", message); await log("stdout", message);
|
||||
expect(order).toEqual(["durable", "published", "published"]);
|
||||
const other = createUsageCheckpointLog(async () => {}, onUsage, parse); await other("stdout", message);
|
||||
expect(onUsage.mock.calls[0][0].attemptId).not.toBe(onUsage.mock.calls[1][0].attemptId);
|
||||
});
|
||||
it("propagates receipt failures and leaves ordinary logs and stderr alone when disabled", async () => {
|
||||
const onLog = vi.fn(async (..._args: any[]) => {});
|
||||
expect(createUsageCheckpointLog(onLog, undefined, () => null)).toBe(onLog);
|
||||
const onUsage = vi.fn(async () => { throw new Error("spool full"); });
|
||||
const parse = vi.fn(() => ({ complete: false }));
|
||||
const log = createUsageCheckpointLog(onLog, onUsage, parse);
|
||||
await log("stderr", '{"usage":1}\n'); await log("stdout", 'invalid json\n{"text":"plain"}\n');
|
||||
expect(onUsage).not.toHaveBeenCalled(); onLog.mockClear();
|
||||
await expect(log("stdout", '{"usage":{"input_tokens":1}}\n')).rejects.toThrow("spool full");
|
||||
expect(onLog).not.toHaveBeenCalled();
|
||||
await expect(log("stdout", "x".repeat(8*1024*1024+1))).rejects.toThrow("exceeds");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,645 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
agents,
|
||||
companies,
|
||||
companySecrets,
|
||||
costEvents,
|
||||
financeEvents,
|
||||
heartbeatRuns,
|
||||
providerBillingSnapshots,
|
||||
createDb,
|
||||
} from "@paperclipai/db";
|
||||
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { billingReconciliationService } from "../services/billing-reconciliation.js";
|
||||
import { financeService } from "../services/finance.js";
|
||||
import {
|
||||
applyProviderDailyCosts,
|
||||
importProviderDailyCosts,
|
||||
} from "../services/provider-billing-import.js";
|
||||
import * as secretsModule from "../services/secrets.js";
|
||||
import {
|
||||
createRunUsageRecorder,
|
||||
persistUsageReceipt,
|
||||
} from "../services/usage-receipts.js";
|
||||
import { accountRunCost } from "../services/run-cost-accounting.js";
|
||||
import { costService, getMonthlySpendTotal } from "../services/costs.js";
|
||||
import { budgetService } from "../services/budgets.js";
|
||||
import type { ImportProviderCosts } from "@paperclipai/shared";
|
||||
|
||||
describe("completed cost reporting paths (PostgreSQL)", () => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
let spool: string;
|
||||
beforeAll(async () => {
|
||||
database = await startEmbeddedPostgresTestDatabase(
|
||||
"accounting-completion-",
|
||||
);
|
||||
db = createDb(database.connectionString);
|
||||
spool = await mkdtemp(join(tmpdir(), "completion-spool-"));
|
||||
}, 90000);
|
||||
afterAll(async () => {
|
||||
await database?.cleanup();
|
||||
if (spool) await rm(spool, { recursive: true, force: true });
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
const company = async () =>
|
||||
(
|
||||
await db
|
||||
.insert(companies)
|
||||
.values({
|
||||
name: "Completion",
|
||||
issuePrefix: `C${randomUUID().slice(0, 7)}`,
|
||||
})
|
||||
.returning()
|
||||
)[0];
|
||||
it("reads monthly spend from the ledger with company, agent and UTC month boundaries", async () => {
|
||||
const c = await company();
|
||||
const other = await company();
|
||||
const [first, second] = await db.insert(agents).values([
|
||||
{ companyId: c.id, name: "First", spentMonthlyCents: 9999 },
|
||||
{ companyId: c.id, name: "Second", spentMonthlyCents: 9999 },
|
||||
]).returning();
|
||||
const [outsider] = await db.insert(agents).values({ companyId: other.id, name: "Other" }).returning();
|
||||
const now = new Date();
|
||||
const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
||||
const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1));
|
||||
const base = { companyId: c.id, agentId: first.id, provider: "test", model: "test", costCents: 1.125, occurredAt: start };
|
||||
await db.insert(costEvents).values([
|
||||
base,
|
||||
{ ...base, agentId: second.id, costCents: 2.25, occurredAt: new Date(end.getTime() - 1) },
|
||||
{ ...base, costCents: 500, occurredAt: new Date(start.getTime() - 1) },
|
||||
{ ...base, costCents: 600, occurredAt: end },
|
||||
{ ...base, companyId: other.id, agentId: outsider.id, costCents: 700 },
|
||||
]);
|
||||
expect(await getMonthlySpendTotal(db, { companyId: c.id })).toBe(3.375);
|
||||
expect(await getMonthlySpendTotal(db, { companyId: c.id, agentId: first.id })).toBe(1.125);
|
||||
expect(await getMonthlySpendTotal(db, { companyId: c.id, agentId: second.id })).toBe(2.25);
|
||||
expect(await getMonthlySpendTotal(db, { companyId: c.id, agentId: outsider.id })).toBe(0);
|
||||
expect(await getMonthlySpendTotal(db, { companyId: randomUUID() })).toBe(0);
|
||||
});
|
||||
|
||||
it("scopes agent and model estimate counts to the company and selected period", async () => {
|
||||
const c = await company();
|
||||
const other = await company();
|
||||
const [codie, bender] = await db.insert(agents).values([
|
||||
{ companyId: c.id, name: "Codie", adapterType: "paperclip_runner" },
|
||||
{ companyId: c.id, name: "Bender", adapterType: "claude_local" },
|
||||
]).returning();
|
||||
const [outsider] = await db.insert(agents).values({ companyId: other.id, name: "Other", adapterType: "process" }).returning();
|
||||
const base = { companyId: c.id, agentId: codie.id, provider: "openai", biller: "openai", billingType: "metered_api", model: "gpt-6-astra", costCents: 100, occurredAt: new Date("2026-09-10T12:00:00Z") };
|
||||
await db.insert(costEvents).values([
|
||||
{ ...base, costStatus: "estimated" },
|
||||
{ ...base, costStatus: "estimated" },
|
||||
{ ...base, model: "gpt-6-sol", costStatus: "reported" },
|
||||
{ ...base, costStatus: "reported", occurredAt: new Date("2026-08-31T23:59:59Z") },
|
||||
{ ...base, costStatus: "estimated", occurredAt: new Date("2026-10-01T00:00:00Z") },
|
||||
{ ...base, agentId: bender.id, costStatus: "reported" },
|
||||
{ ...base, companyId: other.id, agentId: outsider.id, costStatus: "estimated" },
|
||||
]);
|
||||
const range = { from: new Date("2026-09-01T00:00:00Z"), to: new Date("2026-09-30T23:59:59Z") };
|
||||
const service = costService(db);
|
||||
const totals = await service.byAgent(c.id, range);
|
||||
expect(totals).toHaveLength(2);
|
||||
expect(totals.find(row => row.agentId === codie.id)).toMatchObject({ costCents: 300, eventCount: 3, estimatedEventCount: 2, apiRunCount: 0 });
|
||||
expect(totals.find(row => row.agentId === bender.id)).toMatchObject({ eventCount: 1, estimatedEventCount: 0 });
|
||||
const models = (await service.byAgentModel(c.id, range)).filter(row => row.agentId === codie.id);
|
||||
expect(models).toHaveLength(2);
|
||||
expect(models.find(row => row.model === "gpt-6-astra")).toMatchObject({ eventCount: 2, estimatedEventCount: 2 });
|
||||
expect(models.find(row => row.model === "gpt-6-sol")).toMatchObject({ eventCount: 1, estimatedEventCount: 0 });
|
||||
});
|
||||
it("imports inference, fees and credits atomically into the timeline, without duplicating on retry", async () => {
|
||||
const c = await company();
|
||||
const service = billingReconciliationService(db);
|
||||
const invoice = {
|
||||
biller: "openai",
|
||||
externalId: "invoice-1",
|
||||
currency: "USD",
|
||||
lines: [
|
||||
{
|
||||
externalId: "usage",
|
||||
kind: "inference" as const,
|
||||
amountCents: "10.0000001",
|
||||
occurredAt: "2026-09-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
externalId: "subscription",
|
||||
kind: "fee" as const,
|
||||
amountCents: "2000",
|
||||
occurredAt: "2026-09-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
externalId: "refund",
|
||||
kind: "credit" as const,
|
||||
amountCents: "100",
|
||||
occurredAt: "2026-09-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
};
|
||||
const imports = await Promise.all([
|
||||
service.importInvoice(c.id, invoice, "board"),
|
||||
service.importInvoice(c.id, invoice, "board"),
|
||||
]);
|
||||
expect(imports[0].id).toBe(imports[1].id);
|
||||
expect(
|
||||
await financeService(db).list(c.id, {
|
||||
from: new Date("2026-09-01"),
|
||||
to: new Date("2026-09-30"),
|
||||
}),
|
||||
).toHaveLength(3);
|
||||
expect(
|
||||
(
|
||||
await financeService(db).summary(c.id, {
|
||||
from: new Date("2026-09-01"),
|
||||
to: new Date("2026-09-30"),
|
||||
})
|
||||
).netCentsExact,
|
||||
).toBe("1910.0000001");
|
||||
expect(
|
||||
await db.select().from(costEvents).where(eq(costEvents.companyId, c.id)),
|
||||
).toHaveLength(0);
|
||||
await expect(
|
||||
service.importInvoice(
|
||||
c.id,
|
||||
{ ...invoice, lines: [{ ...invoice.lines[0], amountCents: "99" }] },
|
||||
"board",
|
||||
),
|
||||
).rejects.toThrow("different contents");
|
||||
expect(
|
||||
await db
|
||||
.select()
|
||||
.from(financeEvents)
|
||||
.where(eq(financeEvents.companyId, c.id)),
|
||||
).toHaveLength(3);
|
||||
});
|
||||
it("records provider report corrections as deltas, including decreases and returns to an earlier total", async () => {
|
||||
const c = await company();
|
||||
const input: ImportProviderCosts = {
|
||||
provider: "openai",
|
||||
accountId: "org_test",
|
||||
secretId: randomUUID(),
|
||||
scopeIds: ["project"],
|
||||
from: "2026-09-01",
|
||||
to: "2026-09-02",
|
||||
};
|
||||
const update = (amountCents: string) =>
|
||||
applyProviderDailyCosts(
|
||||
db,
|
||||
c.id,
|
||||
input,
|
||||
[{ day: "2026-09-01", scopeId: "project", amountCents }],
|
||||
"board",
|
||||
);
|
||||
await update("100");
|
||||
await update("100");
|
||||
await update("120");
|
||||
await update("90");
|
||||
await update("100");
|
||||
const rows = await financeService(db).list(c.id, {
|
||||
from: new Date("2026-09-01"),
|
||||
to: new Date("2026-09-30"),
|
||||
});
|
||||
expect(rows).toHaveLength(4);
|
||||
expect(rows.filter((row) => row.direction === "credit")).toHaveLength(1);
|
||||
const summary = await financeService(db).summary(c.id, { allTime: true });
|
||||
expect(summary).toMatchObject({
|
||||
netCentsExact: "0.0000000",
|
||||
providerReportedCentsExact: "100.0000000",
|
||||
});
|
||||
expect(await financeService(db).byBiller(c.id, { allTime: true })).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(await financeService(db).byKind(c.id, { allTime: true })).toEqual(
|
||||
[],
|
||||
);
|
||||
const [snapshot] = await db
|
||||
.select()
|
||||
.from(providerBillingSnapshots)
|
||||
.where(eq(providerBillingSnapshots.companyId, c.id));
|
||||
expect(snapshot.amountCents).toBe("100.0000000");
|
||||
expect(snapshot.revision).toBe(4);
|
||||
await expect(
|
||||
applyProviderDailyCosts(
|
||||
db,
|
||||
c.id,
|
||||
input,
|
||||
[{ day: "2026-09-01", scopeId: "project", amountCents: "80" }],
|
||||
"board",
|
||||
new Map([["2026-09-01:project", 0]]),
|
||||
),
|
||||
).rejects.toThrow("newer provider report");
|
||||
const other = await company();
|
||||
await applyProviderDailyCosts(
|
||||
db,
|
||||
other.id,
|
||||
input,
|
||||
[{ day: "2026-09-01", scopeId: "project", amountCents: "1" }],
|
||||
"board",
|
||||
);
|
||||
expect(
|
||||
await db
|
||||
.select()
|
||||
.from(financeEvents)
|
||||
.where(eq(financeEvents.companyId, c.id)),
|
||||
).toHaveLength(4);
|
||||
});
|
||||
it("fetches with a company credential and keeps provider reports separate from invoice charges", async () => {
|
||||
const c = await company();
|
||||
const [secret] = await db
|
||||
.insert(companySecrets)
|
||||
.values({ companyId: c.id, key: "billing", name: "Billing admin" })
|
||||
.returning();
|
||||
const resolveSecretValue = vi.fn().mockResolvedValue("fixture-admin-key");
|
||||
vi.spyOn(secretsModule, "secretService").mockReturnValue({
|
||||
resolveSecretValue,
|
||||
} as unknown as ReturnType<typeof secretsModule.secretService>);
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async () =>
|
||||
Response.json({
|
||||
data: [
|
||||
{
|
||||
start_time: Date.parse("2026-09-01") / 1000,
|
||||
end_time: Date.parse("2026-09-02") / 1000,
|
||||
results: [
|
||||
{
|
||||
project_id: "project",
|
||||
amount: { value: 0.06, currency: "usd" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
has_more: false,
|
||||
}),
|
||||
);
|
||||
const input: ImportProviderCosts = {
|
||||
provider: "openai",
|
||||
accountId: "org_test",
|
||||
secretId: secret.id,
|
||||
scopeIds: ["project"],
|
||||
from: "2026-09-01",
|
||||
to: "2026-09-02",
|
||||
};
|
||||
expect(
|
||||
await importProviderDailyCosts(db, c.id, input, "board", fetcher),
|
||||
).toEqual({ daysRead: 1, eventsCreated: 1 });
|
||||
expect(resolveSecretValue).toHaveBeenCalledWith(
|
||||
c.id,
|
||||
secret.id,
|
||||
"latest",
|
||||
expect.objectContaining({
|
||||
accessContext: expect.objectContaining({
|
||||
actorId: "board",
|
||||
consumerId: "provider-billing-import",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(fetcher.mock.calls[0][1]?.headers).toMatchObject({
|
||||
Authorization: "Bearer fixture-admin-key",
|
||||
"OpenAI-Organization": "org_test",
|
||||
});
|
||||
expect(
|
||||
await importProviderDailyCosts(db, c.id, input, "board", fetcher),
|
||||
).toEqual({ daysRead: 1, eventsCreated: 0 });
|
||||
await billingReconciliationService(db).importInvoice(
|
||||
c.id,
|
||||
{
|
||||
biller: "openai",
|
||||
externalId: "separate-invoice",
|
||||
currency: "USD",
|
||||
lines: [
|
||||
{
|
||||
externalId: "usage",
|
||||
amountCents: "6",
|
||||
kind: "inference",
|
||||
occurredAt: "2026-09-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
"board",
|
||||
);
|
||||
expect(
|
||||
await financeService(db).summary(c.id, { allTime: true }),
|
||||
).toMatchObject({
|
||||
netCentsExact: "6.0000000",
|
||||
providerReportedCentsExact: "6.0000000",
|
||||
eventCount: 2,
|
||||
});
|
||||
expect(await financeService(db).byBiller(c.id, { allTime: true })).toEqual([
|
||||
expect.objectContaining({
|
||||
biller: "openai",
|
||||
netCentsExact: "6.0000000",
|
||||
eventCount: 1,
|
||||
}),
|
||||
]);
|
||||
const other = await company();
|
||||
await expect(
|
||||
importProviderDailyCosts(db, other.id, input, "board", fetcher),
|
||||
).rejects.toThrow("company billing credential");
|
||||
expect(resolveSecretValue).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it("rejects a slow report when another importer changes the same provider snapshot", async () => {
|
||||
const c = await company();
|
||||
const [secret] = await db
|
||||
.insert(companySecrets)
|
||||
.values({ companyId: c.id, key: "billing-race", name: "Billing admin" })
|
||||
.returning();
|
||||
vi.spyOn(secretsModule, "secretService").mockReturnValue({
|
||||
resolveSecretValue: vi.fn().mockResolvedValue("fixture-admin-key"),
|
||||
} as unknown as ReturnType<typeof secretsModule.secretService>);
|
||||
const input: ImportProviderCosts = {
|
||||
provider: "openai",
|
||||
accountId: "org_test",
|
||||
secretId: secret.id,
|
||||
scopeIds: ["project"],
|
||||
from: "2026-09-01",
|
||||
to: "2026-09-02",
|
||||
};
|
||||
const report = (value: number) =>
|
||||
Response.json({
|
||||
data: [
|
||||
{
|
||||
start_time: Date.parse(input.from) / 1000,
|
||||
end_time: Date.parse(input.to) / 1000,
|
||||
results: [
|
||||
{ project_id: "project", amount: { currency: "usd", value } },
|
||||
],
|
||||
},
|
||||
],
|
||||
has_more: false,
|
||||
});
|
||||
let release!: (response: Response) => void;
|
||||
let markStarted!: () => void;
|
||||
const slowResponse = new Promise<Response>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const started = new Promise<void>((resolve) => {
|
||||
markStarted = resolve;
|
||||
});
|
||||
const slowImport = importProviderDailyCosts(
|
||||
db,
|
||||
c.id,
|
||||
input,
|
||||
"board",
|
||||
async () => {
|
||||
markStarted();
|
||||
return slowResponse;
|
||||
},
|
||||
);
|
||||
await started;
|
||||
await importProviderDailyCosts(db, c.id, input, "board", async () =>
|
||||
report(2),
|
||||
);
|
||||
release(report(1));
|
||||
await expect(slowImport).rejects.toThrow("newer provider report");
|
||||
expect(
|
||||
await financeService(db).summary(c.id, { allTime: true }),
|
||||
).toMatchObject({
|
||||
providerReportedCentsExact: "200.0000000",
|
||||
eventCount: 1,
|
||||
});
|
||||
});
|
||||
it("rolls back an entire provider import when a later snapshot is invalid", async () => {
|
||||
const c = await company();
|
||||
const input: ImportProviderCosts = {
|
||||
provider: "openai",
|
||||
accountId: "org_test",
|
||||
secretId: randomUUID(),
|
||||
scopeIds: ["project"],
|
||||
from: "2026-09-01",
|
||||
to: "2026-09-03",
|
||||
};
|
||||
await expect(
|
||||
applyProviderDailyCosts(
|
||||
db,
|
||||
c.id,
|
||||
input,
|
||||
[
|
||||
{ day: "2026-09-01", scopeId: "project", amountCents: "5" },
|
||||
{ day: "2026-09-02", scopeId: "project", amountCents: "-1" },
|
||||
],
|
||||
"board",
|
||||
),
|
||||
).rejects.toThrow();
|
||||
expect(
|
||||
await db
|
||||
.select()
|
||||
.from(providerBillingSnapshots)
|
||||
.where(eq(providerBillingSnapshots.companyId, c.id)),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
await db
|
||||
.select()
|
||||
.from(financeEvents)
|
||||
.where(eq(financeEvents.companyId, c.id)),
|
||||
).toEqual([]);
|
||||
});
|
||||
it("stores an explicit zero report and credits an amended report down to zero only once", async () => {
|
||||
const c = await company();
|
||||
const input: ImportProviderCosts = {
|
||||
provider: "anthropic",
|
||||
accountId: "org_test",
|
||||
secretId: randomUUID(),
|
||||
scopeIds: ["default"],
|
||||
from: "2026-09-01",
|
||||
to: "2026-09-02",
|
||||
};
|
||||
const update = (amountCents: string) =>
|
||||
applyProviderDailyCosts(
|
||||
db,
|
||||
c.id,
|
||||
input,
|
||||
[{ day: input.from, scopeId: "default", amountCents }],
|
||||
"board",
|
||||
);
|
||||
expect((await update("0")).eventsCreated).toBe(0);
|
||||
expect((await update("0")).eventsCreated).toBe(0);
|
||||
expect((await update("1.0000001")).eventsCreated).toBe(1);
|
||||
expect((await update("0")).eventsCreated).toBe(1);
|
||||
expect((await update("0")).eventsCreated).toBe(0);
|
||||
expect(
|
||||
await financeService(db).summary(c.id, { allTime: true }),
|
||||
).toMatchObject({
|
||||
providerReportedCentsExact: "0.0000000",
|
||||
netCentsExact: "0.0000000",
|
||||
eventCount: 2,
|
||||
});
|
||||
});
|
||||
it("freezes estimated dollars before finalization and persists them exactly once into costs and budgets", async () => {
|
||||
const c = await company();
|
||||
const budgets = budgetService(db);
|
||||
await budgets.upsertPolicy(c.id, { scopeType: "company", scopeId: c.id, amount: 100 }, "board");
|
||||
const [agent] = await db
|
||||
.insert(agents)
|
||||
.values({
|
||||
companyId: c.id,
|
||||
name: "Codie",
|
||||
adapterType: "paperclip_runner",
|
||||
})
|
||||
.returning();
|
||||
const [run] = await db
|
||||
.insert(heartbeatRuns)
|
||||
.values({
|
||||
companyId: c.id,
|
||||
agentId: agent.id,
|
||||
invocationSource: "on_demand",
|
||||
status: "running",
|
||||
})
|
||||
.returning();
|
||||
const recorder = await createRunUsageRecorder(
|
||||
db,
|
||||
{ companyId: c.id, runId: run.id, adapterType: "paperclip_runner" },
|
||||
spool,
|
||||
);
|
||||
const captured = await recorder.capture({
|
||||
provider: "openai",
|
||||
biller: "openai",
|
||||
billingType: "metered_api",
|
||||
model: "gpt-6-astra",
|
||||
usageBasis: "per_run",
|
||||
complete: true,
|
||||
costUsd: null,
|
||||
usage: {
|
||||
inputTokens: 123536,
|
||||
cachedInputTokens: 1567209,
|
||||
outputTokens: 6625,
|
||||
},
|
||||
});
|
||||
expect(captured.costUsdExact).toBe("3.133819000");
|
||||
await db
|
||||
.update(heartbeatRuns)
|
||||
.set({ status: "failed", finishedAt: new Date() })
|
||||
.where(eq(heartbeatRuns.id, run.id));
|
||||
await accountRunCost(db, run.id);
|
||||
await accountRunCost(db, run.id);
|
||||
const [event] = await db
|
||||
.select()
|
||||
.from(costEvents)
|
||||
.where(eq(costEvents.heartbeatRunId, run.id));
|
||||
expect(event.costStatus).toBe("estimated");
|
||||
expect(event.pricingProvenance).toMatchObject({
|
||||
source: "rate_card",
|
||||
version: "openai-standard-2026-09-30",
|
||||
});
|
||||
const summary = await costService(db).summary(c.id);
|
||||
expect(summary.spendCentsExact).toBe("313.3819000");
|
||||
expect(summary.estimatedEventCount).toBe(1);
|
||||
expect(await budgets.getInvocationBlock(c.id, agent.id)).not.toBeNull();
|
||||
});
|
||||
it("preserves cache-write evidence and pricing across multiple provider attempts", async () => {
|
||||
const c = await company();
|
||||
const [agent] = await db
|
||||
.insert(agents)
|
||||
.values({
|
||||
companyId: c.id,
|
||||
name: "Retry",
|
||||
adapterType: "paperclip_runner",
|
||||
})
|
||||
.returning();
|
||||
const [run] = await db
|
||||
.insert(heartbeatRuns)
|
||||
.values({ companyId: c.id, agentId: agent.id, status: "running" })
|
||||
.returning();
|
||||
const recorder = await createRunUsageRecorder(
|
||||
db,
|
||||
{ companyId: c.id, runId: run.id, adapterType: "paperclip_runner" },
|
||||
spool,
|
||||
);
|
||||
await recorder.capture({
|
||||
attemptId: randomUUID(),
|
||||
complete: true,
|
||||
provider: "openai",
|
||||
biller: "openai",
|
||||
billingType: "api",
|
||||
model: "gpt-6-astra",
|
||||
usageBasis: "per_run",
|
||||
usage: { inputTokens: 10, cacheWriteTokens: 4, outputTokens: 2 },
|
||||
});
|
||||
const result = await recorder.capture({
|
||||
attemptId: randomUUID(),
|
||||
complete: true,
|
||||
provider: "openai",
|
||||
biller: "openai",
|
||||
billingType: "api",
|
||||
costUsd: 2,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
complete: true,
|
||||
costUsdExact: "2.000210000",
|
||||
costStatus: "estimated",
|
||||
pricingProvenance: { source: "rate_card", version: "per-attempt/v1" },
|
||||
usage: {
|
||||
inputTokens: 10,
|
||||
cacheWriteTokens: 4,
|
||||
cachedInputTokens: 0,
|
||||
outputTokens: 2,
|
||||
},
|
||||
});
|
||||
await db
|
||||
.update(heartbeatRuns)
|
||||
.set({ status: "succeeded", finishedAt: new Date() })
|
||||
.where(eq(heartbeatRuns.id, run.id));
|
||||
await accountRunCost(db, run.id);
|
||||
expect(await costService(db).summary(c.id)).toMatchObject({
|
||||
spendCentsExact: "200.0210000",
|
||||
estimatedEventCount: 1,
|
||||
});
|
||||
});
|
||||
it("recovers older durable receipts without pricing metadata while preserving explicit zero", async () => {
|
||||
const c = await company();
|
||||
const sourceId = randomUUID();
|
||||
const [agent] = await db
|
||||
.insert(agents)
|
||||
.values({
|
||||
companyId: c.id,
|
||||
name: "Legacy receipt",
|
||||
adapterType: "process",
|
||||
})
|
||||
.returning();
|
||||
const [run] = await db
|
||||
.insert(heartbeatRuns)
|
||||
.values({
|
||||
companyId: c.id,
|
||||
agentId: agent.id,
|
||||
status: "running",
|
||||
usageJson: { accountingReceiptSourceId: sourceId },
|
||||
})
|
||||
.returning();
|
||||
const cases = [
|
||||
{ costUsd: null },
|
||||
{ costUsd: 0 },
|
||||
{ costUsdExact: "0.000000001" },
|
||||
];
|
||||
for (const [index, receipt] of cases.entries()) {
|
||||
await persistUsageReceipt(db, {
|
||||
schema: "paperclip/accounting-receipt/v1",
|
||||
id: randomUUID(),
|
||||
companyId: c.id,
|
||||
runId: run.id,
|
||||
adapterType: "process",
|
||||
sourceId,
|
||||
sequence: index + 1,
|
||||
receivedAt: new Date().toISOString(),
|
||||
receipt: { ...receipt, complete: true },
|
||||
});
|
||||
const [stored] = await db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, run.id));
|
||||
expect(stored.usageJson?.pricingProvenance).toEqual({
|
||||
source: index === 0 ? "unknown" : "provider_reported",
|
||||
version: "accounting-receipt/v1",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { agents, agentRuntimeState, approvals, budgetIncidents, budgetPolicies, companies, costEvents, createDb, heartbeatRuns, projects } from "@paperclipai/db";
|
||||
import { costService } from "../services/costs.js";
|
||||
import { budgetService, type BudgetServiceHooks } from "../services/budgets.js";
|
||||
import { accountRunCost, reconcileRunCosts } from "../services/run-cost-accounting.js";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
|
||||
function random(seed: number) {
|
||||
return () => { seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; return seed / 2 ** 32; };
|
||||
}
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
(support.supported ? describe : describe.skip)("seeded concurrent accounting invariants", () => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
beforeAll(async () => { database = await startEmbeddedPostgresTestDatabase("paperclip-accounting-interleavings-"); db = createDb(database.connectionString); }, 30_000);
|
||||
afterAll(async () => { await database?.cleanup(); });
|
||||
|
||||
it.each([1, 7, 42, 97, 31337, 65537, 104729, 0xdeadbeef])("conserves the ledger, totals, incidents and delivery state for seed %s", async (seed) => {
|
||||
const rng = random(seed);
|
||||
const [company] = await db.insert(companies).values({ name: `Interleaving ${seed}`, issuePrefix: `R${randomUUID().slice(0,7)}` }).returning();
|
||||
const [agent] = await db.insert(agents).values({ companyId: company.id, name: "Worker", role: "engineer", status: "idle", adapterType: "process" }).returning();
|
||||
const [project] = await db.insert(projects).values({ companyId: company.id, name: "Project" }).returning();
|
||||
const oracle = new Map<string, { ticks: bigint; input: number; cached: number; output: number; run: boolean }>();
|
||||
const operations: Array<() => Promise<unknown>> = [];
|
||||
let cancellationAttempts = 0;
|
||||
let successfulDeliveries = 0;
|
||||
const hooks: BudgetServiceHooks = { cancelWorkForScope: async (scope) => {
|
||||
expect(scope.companyId).toBe(company.id);
|
||||
expect(scope.createdBefore).toBeInstanceOf(Date);
|
||||
cancellationAttempts++;
|
||||
if (cancellationAttempts % 4 === 1) throw new Error(`Injected delivery interruption (seed ${seed})`);
|
||||
successfulDeliveries++;
|
||||
} };
|
||||
const budgets = budgetService(db, hooks);
|
||||
const costs = costService(db, hooks);
|
||||
const now = new Date();
|
||||
for (let index = 0; index < 12; index++) {
|
||||
const ticks = Math.floor(rng() * 9_000_000) + 1;
|
||||
const input = Math.floor(rng() * 300), cached = Math.floor(rng() * 500), output = Math.floor(rng() * 80);
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId: company.id, agentId: agent.id,
|
||||
status: ["succeeded", "failed", "cancelled", "timed_out"][index % 4], finishedAt: now, costAccountingPending: true,
|
||||
usageJson: { accountingReceiptReady: true, provider: "fixture", model: `model-${index % 3}`, billingType: "metered_api", inputTokens: input, cachedInputTokens: cached, outputTokens: output, costUsd: ticks / 1e9, ledgerScope: { projectId: project.id } },
|
||||
}).returning();
|
||||
oracle.set(`heartbeat:${run.id}:final`, { ticks: BigInt(ticks), input, cached, output, run: true });
|
||||
operations.push(() => accountRunCost(db, run.id, hooks), () => accountRunCost(db, run.id, hooks));
|
||||
}
|
||||
for (let index = 0; index < 10; index++) {
|
||||
const ticks = Math.floor(rng() * 3_000_000) + 1;
|
||||
const key = `manual-${index}`;
|
||||
const receipt = { idempotencyKey: key, agentId: agent.id, projectId: project.id, provider: "fixture", model: "manual", billingType: "metered_api", costCents: ticks / 1e7, occurredAt: now };
|
||||
oracle.set(key, { ticks: BigInt(ticks), input: 0, cached: 0, output: 0, run: false });
|
||||
operations.push(() => costs.createEvent(company.id, receipt), () => costs.createEvent(company.id, receipt));
|
||||
}
|
||||
for (let index = 0; index < 12; index++) {
|
||||
const amount = rng() < 0.5 ? 1 : 200;
|
||||
const scopeType = index % 2 ? "agent" as const : "project" as const;
|
||||
operations.push(() => budgets.upsertPolicy(company.id, { scopeType, scopeId: scopeType === "agent" ? agent.id : project.id, amount, notifyEnabled: false }, "board"));
|
||||
operations.push(() => budgets.getInvocationBlock(company.id, agent.id, { projectId: project.id }));
|
||||
operations.push(() => reconcileRunCosts(db, hooks));
|
||||
}
|
||||
// Reproducible submission order and jitter; the database is free to choose
|
||||
// the actual interleaving. The oracle is independent of execution order.
|
||||
for (let i = operations.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(rng() * (i + 1)); [operations[i], operations[j]] = [operations[j], operations[i]];
|
||||
}
|
||||
for (let offset = 0; offset < operations.length; offset += 8) {
|
||||
await Promise.all(operations.slice(offset, offset + 8).map(async operation => {
|
||||
await new Promise(resolve => setTimeout(resolve, Math.floor(rng() * 5)));
|
||||
return operation();
|
||||
}));
|
||||
const incidents = await db.select().from(budgetIncidents).where(and(eq(budgetIncidents.companyId, company.id), eq(budgetIncidents.status, "open")));
|
||||
expect(new Set(incidents.map(row => `${row.policyId}:${row.windowStart.toISOString()}:${row.thresholdType}`)).size).toBe(incidents.length);
|
||||
}
|
||||
await reconcileRunCosts(db, hooks);
|
||||
const rows = await db.select().from(costEvents).where(eq(costEvents.companyId, company.id));
|
||||
expect(rows).toHaveLength(oracle.size);
|
||||
for (const row of rows) {
|
||||
const expected = oracle.get(row.idempotencyKey!);
|
||||
expect(expected, `unexpected receipt ${row.idempotencyKey}; seed ${seed}`).toBeDefined();
|
||||
expect(BigInt(Math.round(row.costCents * 1e7))).toBe(expected!.ticks);
|
||||
expect([row.inputTokens, row.cachedInputTokens, row.outputTokens]).toEqual([expected!.input, expected!.cached, expected!.output]);
|
||||
}
|
||||
const total = [...oracle.values()].reduce((sum, row) => sum + row.ticks, 0n);
|
||||
expect(BigInt(Math.round((await costs.summary(company.id)).spendCents * 1e7))).toBe(total);
|
||||
expect(BigInt(Math.round((await costs.byProject(company.id))[0].costCents * 1e7))).toBe(total);
|
||||
const [companyProjection] = await db.select().from(companies).where(eq(companies.id, company.id));
|
||||
const [agentProjection] = await db.select().from(agents).where(eq(agents.id, agent.id));
|
||||
expect(BigInt(Math.round(companyProjection.spentMonthlyCents * 1e7))).toBe(total);
|
||||
expect(BigInt(Math.round(agentProjection.spentMonthlyCents * 1e7))).toBe(total);
|
||||
const [runtime] = await db.select().from(agentRuntimeState).where(eq(agentRuntimeState.agentId, agent.id));
|
||||
const runs = [...oracle.values()].filter(row => row.run);
|
||||
expect(BigInt(Math.round(runtime.totalCostCents * 1e7))).toBe(runs.reduce((sum, row) => sum + row.ticks, 0n));
|
||||
expect([runtime.totalInputTokens,runtime.totalCachedInputTokens,runtime.totalOutputTokens])
|
||||
.toEqual(["input","cached","output"].map(key => runs.reduce((sum,row) => sum + Number(row[key as "input" | "cached" | "output"]),0)));
|
||||
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, company.id))).every(run => !run.costAccountingPending && run.costAccountedAt !== null)).toBe(true);
|
||||
// Finish in a known hard-stop state, deliver failed effects, then grant more
|
||||
// budget. This checks the final observable state, not only receipt counts.
|
||||
await budgets.upsertPolicy(company.id, { scopeType: "agent", scopeId: agent.id, amount: 1, notifyEnabled: false }, "board");
|
||||
expect(await budgets.getInvocationBlock(company.id, agent.id)).toMatchObject({ scopeType: "agent" });
|
||||
for (let pass = 0; pass < 4; pass++) await budgets.deliverPendingEnforcement(company.id);
|
||||
expect(successfulDeliveries).toBeGreaterThan(0);
|
||||
const policies = await db.select().from(budgetPolicies).where(eq(budgetPolicies.companyId, company.id));
|
||||
expect(policies.every(row => row.enforcementVersion === row.enforcementDeliveredVersion)).toBe(true);
|
||||
const allIncidents = await db.select().from(budgetIncidents).where(eq(budgetIncidents.companyId, company.id));
|
||||
const approvalRows = await db.select().from(approvals).where(eq(approvals.companyId, company.id));
|
||||
expect(new Set(allIncidents.map(row => row.approvalId).filter(Boolean)).size).toBe(approvalRows.length);
|
||||
await budgets.upsertPolicy(company.id, { scopeType: "agent", scopeId: agent.id, amount: 200 }, "board");
|
||||
await budgets.upsertPolicy(company.id, { scopeType: "project", scopeId: project.id, amount: 200 }, "board");
|
||||
expect(await budgets.getInvocationBlock(company.id, agent.id, { projectId: project.id })).toBeNull();
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { randomUUID, randomInt } from "node:crypto";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { createServer } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { agents, companies, costEvents, heartbeatRuns, budgetPolicies, createDb } from "@paperclipai/db";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
const root = fileURLToPath(new URL("../../..", import.meta.url));
|
||||
async function until<T>(read: () => Promise<T>, accept: (value: T) => boolean, diagnostic: () => string = () => "", timeout = 40_000) {
|
||||
const deadline = Date.now() + timeout;
|
||||
while (Date.now() < deadline) {
|
||||
const value = await read();
|
||||
if (accept(value)) return value;
|
||||
await new Promise(resolve => setTimeout(resolve, 30));
|
||||
}
|
||||
throw new Error(`Accounting process checkpoint timed out. ${diagnostic()}`);
|
||||
}
|
||||
|
||||
(support.supported && process.platform !== "win32" ? describe : describe.skip)("accounting survives real server SIGKILL", () => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
beforeAll(async () => { database = await startEmbeddedPostgresTestDatabase("paperclip-accounting-crash-"); }, 30_000);
|
||||
afterAll(async () => { await database?.cleanup(); });
|
||||
|
||||
it.each(["before_receipt", "before_runtime_totals", "before_commit", "after_commit_before_delivery_ack"] as const)("recovers exactly once after %s", async (phase) => {
|
||||
const db = createDb(database.connectionString);
|
||||
const observer = createDb(database.connectionString).$client;
|
||||
const blocker = await createDb(database.connectionString).$client.reserve();
|
||||
const home = await mkdtemp(path.join(tmpdir(), "pc-accounting-kill-"));
|
||||
const key = randomInt(1, 2_000_000_000);
|
||||
const [company] = await db.insert(companies).values({ name: "Crash accounting", issuePrefix: `K${randomUUID().slice(0,7)}` }).returning();
|
||||
const [agent] = await db.insert(agents).values({ companyId: company.id, name: "Worker", role: "engineer", status: "idle", adapterType: "process", runtimeConfig: { heartbeat: { enabled: false } } }).returning();
|
||||
const [policy] = await db.insert(budgetPolicies).values({ companyId: company.id, scopeType: "agent", scopeId: agent.id, metric: "billed_cents", windowKind: "calendar_month_utc", amount: 1, notifyEnabled: false }).returning();
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId: company.id, agentId: agent.id, status: "failed", finishedAt: new Date(), costAccountingPending: true,
|
||||
usageJson: { accountingReceiptReady: true, provider: "fixture", model: "fixture", billingType: "metered_api", inputTokens: 7, cachedInputTokens: 11, outputTokens: 3, costUsd: 0.0125 },
|
||||
}).returning();
|
||||
const table = phase === "before_receipt" ? "cost_events" : phase === "before_runtime_totals" ? "agent_runtime_state"
|
||||
: phase === "before_commit" ? "heartbeat_runs" : "budget_policies";
|
||||
const event = phase === "after_commit_before_delivery_ack" || phase === "before_commit" ? "UPDATE" : "INSERT";
|
||||
const condition = phase === "after_commit_before_delivery_ack" ? "AND NEW.enforcement_delivered_version > OLD.enforcement_delivered_version"
|
||||
: phase === "before_commit" ? "AND OLD.cost_accounting_pending = false AND NEW.cost_accounted_at IS NOT NULL" : "";
|
||||
let child: ChildProcess | undefined;
|
||||
let logs = "";
|
||||
async function kill() {
|
||||
if (!child || child.exitCode !== null || child.signalCode !== null) return;
|
||||
const stopped = new Promise<void>((resolve) => child!.once("exit", () => resolve()));
|
||||
process.kill(-child.pid!, "SIGKILL");
|
||||
await stopped;
|
||||
}
|
||||
async function start() {
|
||||
const port = await new Promise<number>((resolve, reject) => {
|
||||
const listener = createServer(); listener.on("error", reject);
|
||||
listener.listen(0, "127.0.0.1", () => { const address = listener.address() as { port: number }; listener.close(() => resolve(address.port)); });
|
||||
});
|
||||
logs = "";
|
||||
child = spawn(process.execPath, ["cli/node_modules/tsx/dist/cli.mjs", "server/src/index.ts"], {
|
||||
cwd: root, detached: true, stdio: ["ignore", "pipe", "pipe"],
|
||||
env: { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR,
|
||||
NODE_ENV: "test", DATABASE_URL: database.connectionString, PORT: String(port),
|
||||
PAPERCLIP_HOME: home, PAPERCLIP_CONFIG: path.join(home, "config.json"), PAPERCLIP_INSTANCE_ID: "accounting-crash",
|
||||
PAPERCLIP_DEPLOYMENT_MODE: "local_trusted", PAPERCLIP_BIND: "loopback", SERVE_UI: "false",
|
||||
PAPERCLIP_OPEN_ON_LISTEN: "false", PAPERCLIP_TELEMETRY_DISABLED: "1", PAPERCLIP_ANNOUNCEMENTS_ENABLED: "false",
|
||||
HEARTBEAT_SCHEDULER_ENABLED: "false", PAPERCLIP_DECISION_SIGNING_SECRET: "accounting-crash-test-decision-secret",
|
||||
PAPERCLIP_AGENT_JWT_SECRET: "accounting-crash-test-agent-secret",
|
||||
},
|
||||
});
|
||||
const capture = (data: Buffer) => { logs = (logs + data.toString()).slice(-16000); };
|
||||
child.stdout!.on("data", capture); child.stderr!.on("data", capture);
|
||||
return `http://127.0.0.1:${port}`;
|
||||
}
|
||||
try {
|
||||
await blocker`SELECT pg_advisory_lock(${key})`;
|
||||
await observer.unsafe(`CREATE FUNCTION accounting_crash_barrier() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN IF NEW.company_id = '${company.id}'::uuid ${condition} THEN PERFORM pg_advisory_lock(${key}); END IF; RETURN NEW; END $$;
|
||||
CREATE TRIGGER accounting_crash_barrier BEFORE ${event} ON ${table} FOR EACH ROW EXECUTE FUNCTION accounting_crash_barrier()`);
|
||||
await start();
|
||||
const waiting = await until(() => {
|
||||
if (child!.exitCode !== null || child!.signalCode !== null) throw new Error(`Accounting server exited before the barrier: ${logs}`);
|
||||
return observer`SELECT pid FROM pg_locks WHERE locktype='advisory' AND objid=${key} AND NOT granted`;
|
||||
}, rows => rows.length > 0, () => logs);
|
||||
const committed = phase === "after_commit_before_delivery_ack";
|
||||
expect(await db.select().from(costEvents).where(eq(costEvents.heartbeatRunId, run.id))).toHaveLength(committed ? 1 : 0);
|
||||
await kill();
|
||||
expect(child?.signalCode).toBe("SIGKILL");
|
||||
// A backend blocked inside our artificial barrier cannot observe its
|
||||
// dead client until the query returns. Terminate that orphan connection
|
||||
// before releasing the barrier, forcing PostgreSQL's disconnect rollback.
|
||||
for (const row of waiting) await observer`SELECT pg_terminate_backend(${row.pid})`;
|
||||
await until(() => observer`SELECT pid FROM pg_locks WHERE locktype='advisory' AND objid=${key} AND NOT granted`, rows => rows.length === 0);
|
||||
await blocker`SELECT pg_advisory_unlock(${key})`;
|
||||
await observer.unsafe(`DROP TRIGGER accounting_crash_barrier ON ${table}; DROP FUNCTION accounting_crash_barrier()`);
|
||||
const baseUrl = await start();
|
||||
await until(() => observer`SELECT cost_accounting_pending,cost_accounted_at FROM heartbeat_runs WHERE id=${run.id}`,
|
||||
rows => rows[0].cost_accounting_pending === false && rows[0].cost_accounted_at !== null, () => logs);
|
||||
await until(() => observer`SELECT enforcement_version,enforcement_delivered_version FROM budget_policies WHERE id=${policy.id}`,
|
||||
rows => rows[0].enforcement_version > 0 && rows[0].enforcement_version === rows[0].enforcement_delivered_version, () => logs);
|
||||
const assertLedger = async () => {
|
||||
expect((await observer`SELECT count(*)::int AS count,sum(cost_cents)::text AS cents FROM cost_events WHERE heartbeat_run_id=${run.id}`)[0]).toEqual({ count: 1, cents: "1.2500000" });
|
||||
expect((await observer`SELECT total_input_tokens,total_cached_input_tokens,total_output_tokens,total_cost_cents::text FROM agent_runtime_state WHERE agent_id=${agent.id}`)[0])
|
||||
.toEqual({ total_input_tokens: "7", total_cached_input_tokens: "11", total_output_tokens: "3", total_cost_cents: "1.2500000" });
|
||||
expect((await observer`SELECT spent_monthly_cents::text,status FROM agents WHERE id=${agent.id}`)[0]).toEqual({ spent_monthly_cents: "1.2500000", status: "paused" });
|
||||
expect((await observer`SELECT count(*)::int AS count FROM budget_incidents WHERE policy_id=${policy.id}`)[0].count).toBe(1);
|
||||
expect((await observer`SELECT count(*)::int AS count FROM approvals WHERE company_id=${company.id}`)[0].count).toBe(1);
|
||||
};
|
||||
await assertLedger();
|
||||
await until(async () => fetch(`${baseUrl}/api/health`).then(r => r.ok).catch(() => false), Boolean, () => logs);
|
||||
await kill();
|
||||
const replayUrl = await start();
|
||||
await until(async () => fetch(`${replayUrl}/api/health`).then(r => r.ok).catch(() => false), Boolean, () => logs);
|
||||
await until(async () => logs.includes(`Server startup recovery complete on 127.0.0.1:${new URL(replayUrl).port}`), Boolean, () => logs);
|
||||
await assertLedger();
|
||||
} finally {
|
||||
await kill();
|
||||
await blocker`SELECT pg_advisory_unlock_all()`;
|
||||
blocker.release();
|
||||
await observer.unsafe(`DROP TRIGGER IF EXISTS accounting_crash_barrier ON ${table}; DROP FUNCTION IF EXISTS accounting_crash_barrier()`);
|
||||
await observer.end();
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
}, 150_000);
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { activityLog, agents, companies, costEvents, createDb, goals, heartbeatRuns, issues, projects } from "@paperclipai/db";
|
||||
import { costService } from "../services/costs.js";
|
||||
import { financeService } from "../services/finance.js";
|
||||
import { budgetService } from "../services/budgets.js";
|
||||
import { accountRunCost, reconcileRunCosts } from "../services/run-cost-accounting.js";
|
||||
import { receiptFingerprint } from "../services/receipt-fingerprint.js";
|
||||
import { subscribeCompanyLiveEvents } from "../services/live-events.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
(support.supported ? describe : describe.skip)("accounting validation and recovery edges (PostgreSQL)", () => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
beforeAll(async () => { database = await startEmbeddedPostgresTestDatabase("paperclip-accounting-edges-"); db = createDb(database.connectionString); }, 30_000);
|
||||
afterAll(async () => { await database?.cleanup(); });
|
||||
async function fixture() {
|
||||
const [company] = await db.insert(companies).values({ name: "Accounting edges", issuePrefix: `E${randomUUID().slice(0, 7)}` }).returning();
|
||||
const [agent] = await db.insert(agents).values({ companyId: company.id, name: "Worker", role: "engineer", adapterType: "process", status: "idle" }).returning();
|
||||
const [project] = await db.insert(projects).values({ companyId: company.id, name: "Project" }).returning();
|
||||
const [issue] = await db.insert(issues).values({ companyId: company.id, title: "Work", projectId: project.id }).returning();
|
||||
const [goal] = await db.insert(goals).values({ companyId: company.id, title: "Goal" }).returning();
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId: company.id, agentId: agent.id }).returning();
|
||||
const receipt = { agentId: agent.id, provider: "fixture", model: "fixture", billingType: "metered_api", costCents: 0.1234567, occurredAt: new Date() };
|
||||
const cost = await costService(db).createEvent(company.id, receipt);
|
||||
const finance = { eventKind: "inference_charge", biller: "fixture", amountCents: 123, occurredAt: new Date() };
|
||||
return { company, agent, project, issue, goal, run, cost, receipt, finance,
|
||||
links: { agentId: agent.id, projectId: project.id, issueId: issue.id, goalId: goal.id, heartbeatRunId: run.id, costEventId: cost.id } };
|
||||
}
|
||||
|
||||
it.each(["agentId", "projectId", "issueId", "goalId", "heartbeatRunId", "costEventId"] as const)("rejects foreign and missing finance %s, accepts company-owned links", async field => {
|
||||
const a = await fixture(), b = await fixture();
|
||||
const service = financeService(db);
|
||||
await expect(service.createEvent(a.company.id, { ...a.finance, [field]: b.links[field] })).rejects.toMatchObject({ status: 422 });
|
||||
await expect(service.createEvent(a.company.id, { ...a.finance, [field]: randomUUID() })).rejects.toMatchObject({ status: 404 });
|
||||
expect(await service.list(a.company.id)).toHaveLength(0);
|
||||
const event = await service.createEvent(a.company.id, { ...a.finance, ...a.links }, { actorType: "user", actorId: "board", agentId: a.agent.id });
|
||||
expect(event[field]).toBe(a.links[field]);
|
||||
expect(await service.list(a.company.id)).toEqual([event]);
|
||||
});
|
||||
|
||||
it("normalizes finance retries and rolls back changed keys and invalid amounts/currencies", async () => {
|
||||
const f = await fixture(), service = financeService(db);
|
||||
const receipt = { ...f.finance, idempotencyKey: "invoice", currency: "usd", metadataJson: { entries: [{ z: 2, a: 1 }, null] } };
|
||||
const event = await service.createEvent(f.company.id, receipt);
|
||||
expect((await service.createEvent(f.company.id, { ...receipt, currency: "USD", metadataJson: { entries: [{ a: 1, z: 2 }, null] } })).id).toBe(event.id);
|
||||
await expect(service.createEvent(f.company.id, { ...receipt, amountCents: 124 })).rejects.toMatchObject({ status: 409 });
|
||||
for (const invalid of [{ amountCents: -1 }, { amountCents: "1.2.3" }, { amountCents: Number.POSITIVE_INFINITY }, { currency: "US" }]) {
|
||||
await expect(service.createEvent(f.company.id, { ...f.finance, ...invalid })).rejects.toMatchObject({ status: 422 });
|
||||
}
|
||||
expect(await service.list(f.company.id)).toHaveLength(1);
|
||||
expect((await db.select().from(activityLog).where(eq(activityLog.companyId, f.company.id))).filter(row => row.action === "finance_event.reported")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps finance credits, estimates, currencies, dates, and companies separate in every report", async () => {
|
||||
const f = await fixture(), foreign = await fixture(), service = financeService(db);
|
||||
const date = new Date("2026-08-15T12:00:00Z");
|
||||
await service.createEvent(f.company.id, { ...f.finance, occurredAt: date, amountCents: 150, estimated: true });
|
||||
await service.createEvent(f.company.id, { ...f.finance, occurredAt: new Date(date.getTime() + 1), eventKind: "credit_refund", direction: "credit", amountCents: 40 });
|
||||
await service.createEvent(f.company.id, { ...f.finance, occurredAt: date, amountCents: 900, currency: "EUR" });
|
||||
await service.createEvent(f.company.id, { ...f.finance, occurredAt: new Date("2026-08-16T00:00:00Z"), amountCents: 999 });
|
||||
await service.createEvent(foreign.company.id, { ...foreign.finance, occurredAt: date, amountCents: 999 });
|
||||
const range = { from: date, to: new Date(date.getTime() + 1) };
|
||||
expect(await service.summary(f.company.id, range)).toMatchObject({ debitCents: 150, creditCents: 40, estimatedDebitCents: 150, netCents: 110, eventCount: 2,
|
||||
currencies: [expect.objectContaining({ currency: "EUR", netCents: 900, eventCount: 1 }), expect.objectContaining({ currency: "USD", netCents: 110, eventCount: 2 })] });
|
||||
expect(await service.byBiller(f.company.id, range)).toEqual([
|
||||
expect.objectContaining({ biller: "fixture", currency: "EUR", netCents: 900, eventCount: 1, kindCount: 1 }),
|
||||
expect.objectContaining({ biller: "fixture", currency: "USD", netCents: 110, eventCount: 2, kindCount: 2 }),
|
||||
]);
|
||||
expect(await service.byKind(f.company.id, range)).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ eventKind: "inference_charge", currency: "EUR", netCents: 900 }),
|
||||
expect.objectContaining({ eventKind: "inference_charge", currency: "USD", netCents: 150, estimatedDebitCents: 150 }),
|
||||
expect.objectContaining({ eventKind: "credit_refund", currency: "USD", netCents: -40, billerCount: 1 }),
|
||||
]));
|
||||
expect(await service.list(f.company.id, range, 1)).toEqual([expect.objectContaining({ direction: "credit", amountCents: 40 })]);
|
||||
expect(await service.summary(randomUUID())).toMatchObject({ currencies: [], debitCents: 0, creditCents: 0, netCents: 0, eventCount: 0 });
|
||||
});
|
||||
|
||||
it("rejects missing companies, missing links, run/agent mismatches, and invalid cost inputs without side effects", async () => {
|
||||
const f = await fixture(), costs = costService(db);
|
||||
await expect(costs.createEvent(randomUUID(), f.receipt)).rejects.toMatchObject({ status: 404 });
|
||||
await expect(costs.summary(randomUUID())).rejects.toMatchObject({ status: 404 });
|
||||
for (const field of ["agentId", "issueId", "projectId", "goalId", "heartbeatRunId"]) {
|
||||
await expect(costs.createEvent(f.company.id, { ...f.receipt, [field]: randomUUID() })).rejects.toMatchObject({ status: 404 });
|
||||
}
|
||||
const [otherAgent] = await db.insert(agents).values({ companyId: f.company.id, name: "Other", role: "engineer", adapterType: "process" }).returning();
|
||||
await expect(costs.createEvent(f.company.id, { ...f.receipt, agentId: otherAgent.id, heartbeatRunId: f.run.id })).rejects.toThrow("does not belong to agent");
|
||||
for (const invalid of [{ costCents: -1 }, { costCents: NaN }, { inputTokens: 0.5 }, { cachedInputTokens: -1 }, { outputTokens: 2 ** 31 }]) {
|
||||
await expect(costs.createEvent(f.company.id, { ...f.receipt, ...invalid })).rejects.toMatchObject({ status: 422 });
|
||||
}
|
||||
expect((await costs.summary(f.company.id)).spendCents).toBe(f.receipt.costCents);
|
||||
expect(await db.select().from(costEvents).where(eq(costEvents.companyId, f.company.id))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("aggregates billers with fractional cents, cache tokens, and subscription runs without cross-company leakage", async () => {
|
||||
const f = await fixture(), foreign = await fixture(), costs = costService(db);
|
||||
const date = new Date("2026-08-15T12:00:00Z");
|
||||
await costs.createEvent(f.company.id, { ...f.receipt, occurredAt: date, biller: "gateway", provider: "a", model: "a", heartbeatRunId: f.run.id, inputTokens: 2, cachedInputTokens: 3, outputTokens: 5 });
|
||||
await costs.createEvent(f.company.id, { ...f.receipt, occurredAt: date, biller: "gateway", provider: "b", model: "b", heartbeatRunId: f.run.id, billingType: "subscription_included", costCents: 0, inputTokens: 7, cachedInputTokens: 11, outputTokens: 13 });
|
||||
await costs.createEvent(foreign.company.id, { ...foreign.receipt, occurredAt: date, biller: "gateway", costCents: 999 });
|
||||
expect(await costs.byBiller(f.company.id, { from: date, to: date })).toEqual([{
|
||||
biller: "gateway", costCents: 0.1234567, costCentsExact: "0.1234567", inputTokens: 9, cachedInputTokens: 14, outputTokens: 18,
|
||||
apiRunCount: 1, subscriptionRunCount: 1, subscriptionInputTokens: 7, subscriptionCachedInputTokens: 11, subscriptionOutputTokens: 13, providerCount: 2, modelCount: 2,
|
||||
}]);
|
||||
});
|
||||
|
||||
it("commits a receipt and its audit record even when a live subscriber throws", async () => {
|
||||
const f = await fixture();
|
||||
const listener = vi.fn(() => { throw new Error("Injected disconnected live subscriber"); });
|
||||
const unsubscribe = subscribeCompanyLiveEvents(f.company.id, listener);
|
||||
const warning = vi.spyOn(logger, "warn").mockImplementation(() => undefined);
|
||||
try {
|
||||
const receipt = { ...f.receipt, idempotencyKey: "committed-before-publication" };
|
||||
const event = await costService(db).createEvent(f.company.id, receipt, { actorType: "user", actorId: "board" });
|
||||
expect((await costService(db).createEvent(f.company.id, receipt)).id).toBe(event.id);
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(warning).toHaveBeenCalledOnce();
|
||||
expect((await costService(db).summary(f.company.id)).spendCents).toBe(0.2469134);
|
||||
expect(await db.select().from(activityLog).where(eq(activityLog.entityId, event.id))).toHaveLength(1);
|
||||
} finally { unsubscribe(); warning.mockRestore(); }
|
||||
});
|
||||
|
||||
it("reports lifetime history consistently across every cost grouping and finance view", async () => {
|
||||
const f = await fixture(), costs = costService(db), finance = financeService(db);
|
||||
const occurredAt = new Date("2000-01-01T00:00:00Z");
|
||||
await costs.createEvent(f.company.id, { ...f.receipt, occurredAt, costCents: 5, projectId: f.project.id, inputTokens: 2 });
|
||||
await finance.createEvent(f.company.id, { ...f.finance, occurredAt });
|
||||
const lifetime = { allTime: true };
|
||||
for (const read of [costs.byAgent, costs.byAgentModel, costs.byProvider, costs.byBiller, costs.byProject]) {
|
||||
const rows = await read(f.company.id, lifetime);
|
||||
expect(rows.reduce((sum, row) => sum + row.costCents, 0)).toBe(5.1234567);
|
||||
expect(rows.reduce((sum, row) => sum + row.inputTokens, 0)).toBe(2);
|
||||
}
|
||||
expect((await costs.summary(f.company.id)).spendCents).toBe(0.1234567);
|
||||
expect((await costs.summary(f.company.id, lifetime)).spendCents).toBe(5.1234567);
|
||||
expect((await finance.summary(f.company.id)).eventCount).toBe(0);
|
||||
expect((await finance.summary(f.company.id, lifetime)).debitCents).toBe(123);
|
||||
expect(await finance.list(f.company.id, lifetime)).toHaveLength(1);
|
||||
expect((await finance.byBiller(f.company.id, lifetime))[0].netCents).toBe(123);
|
||||
expect((await finance.byKind(f.company.id, lifetime))[0].netCents).toBe(123);
|
||||
await db.update(companies).set({ budgetMonthlyCents: 1 }).where(eq(companies.id, f.company.id));
|
||||
expect((await costs.summary(f.company.id)).utilizationPercent).toBe(12.35);
|
||||
});
|
||||
|
||||
it.each(["company", "agent", "project"] as const)("rejects nonexistent and foreign %s budget scopes without creating policies", async scopeType => {
|
||||
const f = await fixture(), foreign = await fixture(), budgets = budgetService(db);
|
||||
await expect(budgets.upsertPolicy(f.company.id, { scopeType, scopeId: randomUUID(), amount: 10 }, "board")).rejects.toMatchObject({ status: 404 });
|
||||
await expect(budgets.upsertPolicy(f.company.id, { scopeType, scopeId: foreign[scopeType].id, amount: 10 }, "board")).rejects.toMatchObject({ status: 422 });
|
||||
expect(await budgets.listPolicies(f.company.id)).toHaveLength(0);
|
||||
if (scopeType !== "company") {
|
||||
await expect(budgets.getInvocationBlock(f.company.id, scopeType === "agent" ? foreign.agent.id : f.agent.id,
|
||||
scopeType === "project" ? { projectId: foreign.project.id } : undefined)).rejects.toMatchObject({ status: 404 });
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["unpriced", "pending"] as const)("refuses an incident budget raise while accounting is %s, without partial mutations", async kind => {
|
||||
const f = await fixture(), budgets = budgetService(db);
|
||||
await budgets.upsertPolicy(f.company.id, { scopeType: "agent", scopeId: f.agent.id, amount: 10 }, "board");
|
||||
if (kind === "unpriced") {
|
||||
await costService(db).createEvent(f.company.id, { ...f.receipt, costStatus: "unpriced", costCents: 0 });
|
||||
} else {
|
||||
await db.update(heartbeatRuns).set({ status: "failed", costAccountingPending: true, finishedAt: new Date(), usageJson: { accountingReceiptReady: false } }).where(eq(heartbeatRuns.id, f.run.id));
|
||||
await budgets.getInvocationBlock(f.company.id, f.agent.id);
|
||||
}
|
||||
const [incident] = (await budgets.overview(f.company.id)).activeIncidents;
|
||||
const resolution = { action: "raise_budget_and_resume" as const, amount: 100 };
|
||||
await expect(budgets.resolveIncident(f.company.id, randomUUID(), resolution, "board")).rejects.toMatchObject({ status: 404 });
|
||||
const foreign = await fixture();
|
||||
await expect(budgets.resolveIncident(foreign.company.id, incident.id, resolution, "board")).rejects.toMatchObject({ status: 404 });
|
||||
await expect(budgets.resolveIncident(f.company.id, incident.id, resolution, "board")).rejects.toThrow(kind === "unpriced" ? /unpriced usage/ : /finish accounting/);
|
||||
expect((await budgets.listPolicies(f.company.id))[0].amount).toBe(10);
|
||||
expect((await budgets.overview(f.company.id)).activeIncidents).toEqual([expect.objectContaining({ id: incident.id, status: "open" })]);
|
||||
expect((await db.select().from(agents).where(eq(agents.id, f.agent.id)))[0].status).toBe("paused");
|
||||
});
|
||||
|
||||
it.each(["company", "project"] as const)("preserves an operator's %s pause even without a policy", async scopeType => {
|
||||
const f = await fixture();
|
||||
if (scopeType === "company") await db.update(companies).set({ status: "paused", pauseReason: "manual", pausedAt: new Date() }).where(eq(companies.id, f.company.id));
|
||||
else await db.update(projects).set({ pauseReason: "manual", pausedAt: new Date() }).where(eq(projects.id, f.project.id));
|
||||
expect(await budgetService(db).getInvocationBlock(f.company.id, f.agent.id, { projectId: f.project.id }))
|
||||
.toMatchObject({ scopeType, reason: expect.stringContaining("paused and cannot start") });
|
||||
});
|
||||
|
||||
it("resolves a company incident and updates the legacy monthly budget together", async () => {
|
||||
const f = await fixture(), budgets = budgetService(db);
|
||||
await budgets.upsertPolicy(f.company.id, { scopeType: "company", scopeId: f.company.id, amount: 1 }, "board");
|
||||
await costService(db).createEvent(f.company.id, { ...f.receipt, costCents: 2 });
|
||||
const [incident] = (await budgets.overview(f.company.id)).activeIncidents;
|
||||
await budgets.resolveIncident(f.company.id, incident.id, { action: "raise_budget_and_resume", amount: 10 }, "board");
|
||||
expect((await db.select().from(companies).where(eq(companies.id, f.company.id)))[0]).toMatchObject({ status: "active", pauseReason: null, budgetMonthlyCents: 10 });
|
||||
expect((await budgets.listPolicies(f.company.id))[0].amount).toBe(10);
|
||||
expect(await budgets.getInvocationBlock(f.company.id, f.agent.id)).toBeNull();
|
||||
});
|
||||
|
||||
it.each([false, true])("records subscription tokens at zero incremental charge (split=%s)", async split => {
|
||||
const f = await fixture();
|
||||
await db.update(heartbeatRuns).set({ status: "succeeded", costAccountingPending: true,
|
||||
usageJson: { billingType: "subscription_included", costUsd: 1, inputTokens: 3, cachedInputTokens: 5, outputTokens: 7,
|
||||
...(split ? { usageByModel: [{ model: "a", costUsd: 1, usage: { inputTokens: 3, cachedInputTokens: 5, outputTokens: 7 } }] } : {}),
|
||||
},
|
||||
}).where(eq(heartbeatRuns.id, f.run.id));
|
||||
expect(await accountRunCost(db, f.run.id)).toBe(true);
|
||||
expect(await db.select().from(costEvents).where(eq(costEvents.heartbeatRunId, f.run.id))).toEqual([
|
||||
expect.objectContaining({ costCents: 0, costStatus: "reported", inputTokens: 3, cachedInputTokens: 5, outputTokens: 7, billingType: "subscription_included" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("rotates a full poisoned recovery batch so the next valid receipt cannot starve", async () => {
|
||||
const f = await fixture(), foreign = await fixture();
|
||||
const poison = await db.insert(heartbeatRuns).values(Array.from({ length: 100 }, () => ({
|
||||
companyId: f.company.id, agentId: f.agent.id, status: "failed", costAccountingPending: true, updatedAt: new Date(0),
|
||||
usageJson: { accountingReceiptReady: true, costUsd: 0.01, ledgerScope: { projectId: foreign.project.id } },
|
||||
}))).returning();
|
||||
const [valid] = await db.insert(heartbeatRuns).values({ companyId: f.company.id, agentId: f.agent.id, status: "succeeded", costAccountingPending: true,
|
||||
updatedAt: new Date(1), usageJson: { accountingReceiptReady: true, costUsd: 0.02 },
|
||||
}).returning();
|
||||
const error = vi.spyOn(logger, "error").mockImplementation(() => undefined);
|
||||
try {
|
||||
expect(await reconcileRunCosts(db)).toEqual({ scanned: 100, accounted: 0 });
|
||||
expect(await reconcileRunCosts(db)).toEqual({ scanned: 100, accounted: 1 });
|
||||
expect(error).toHaveBeenCalledTimes(199);
|
||||
expect(await db.select().from(costEvents).where(eq(costEvents.heartbeatRunId, valid.id))).toEqual([expect.objectContaining({ costCents: 2 })]);
|
||||
const rows = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, f.company.id));
|
||||
expect(rows.filter(row => row.costAccountingPending)).toHaveLength(100);
|
||||
expect(rows.filter(row => poison.some(bad => bad.id === row.id)).every(row => row.costAccountedAt === null)).toBe(true);
|
||||
expect((await costService(db).summary(f.company.id)).spendCents).toBe(2.1234567);
|
||||
expect(await accountRunCost(db, valid.id)).toBe(false);
|
||||
expect(await accountRunCost(db, randomUUID())).toBe(false);
|
||||
} finally { error.mockRestore(); }
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
it("fingerprints nested receipts independently of object order while preserving array order and values", () => {
|
||||
const a = { items: [{ z: 2, a: 1 }, null], at: new Date("2026-01-01T00:00:00Z") };
|
||||
expect(receiptFingerprint(a)).toBe(receiptFingerprint({ at: a.at, items: [{ a: 1, z: 2 }, null] }));
|
||||
expect(receiptFingerprint(a)).not.toBe(receiptFingerprint({ ...a, items: [null, { z: 2, a: 1 }] }));
|
||||
expect(receiptFingerprint(a)).not.toBe(receiptFingerprint({ ...a, items: [{ z: 3, a: 1 }, null] }));
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { agents, companies, createDb } from "@paperclipai/db";
|
||||
import { costService } from "../services/costs.js";
|
||||
import { budgetService } from "../services/budgets.js";
|
||||
import { accountingIntegrityService } from "../services/accounting-integrity.js";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
(support.supported ? describe : describe.skip)("accounting mutation sentinels", () => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
beforeAll(async () => { database = await startEmbeddedPostgresTestDatabase("accounting-mutation-"); db = createDb(database.connectionString); }, 30_000);
|
||||
afterAll(async () => { await database?.cleanup(); });
|
||||
async function fixture() {
|
||||
const [company] = await db.insert(companies).values({ name: "Mutation", issuePrefix: `M${randomUUID().slice(0,7)}` }).returning();
|
||||
const [agent] = await db.insert(agents).values({ companyId: company.id, name: "Worker", role: "engineer", adapterType: "process", status: "idle" }).returning();
|
||||
return { company, agent, receipt: { agentId: agent.id, provider: "fixture", model: "fixture", costCents: "1.0000001", idempotencyKey: randomUUID(), occurredAt: new Date() } };
|
||||
}
|
||||
it("deduplicates a retried receipt", async () => {
|
||||
const f = await fixture(), service = costService(db);
|
||||
const first = await service.createEvent(f.company.id, f.receipt);
|
||||
const second = await service.createEvent(f.company.id, f.receipt);
|
||||
expect(second.id).toBe(first.id);
|
||||
expect((await service.summary(f.company.id)).eventCount).toBe(1);
|
||||
});
|
||||
it("isolates company reporting", async () => {
|
||||
const f = await fixture(), other = await fixture(), service = costService(db);
|
||||
await service.createEvent(f.company.id, f.receipt);
|
||||
await service.createEvent(other.company.id, { ...other.receipt, costCents: 1000 });
|
||||
expect((await service.summary(f.company.id)).spendCentsExact).toBe("1.0000001");
|
||||
});
|
||||
it("conserves agent projections", async () => {
|
||||
const f = await fixture();
|
||||
await costService(db).createEvent(f.company.id, f.receipt);
|
||||
await costService(db).createEvent(f.company.id, { ...f.receipt, idempotencyKey: "second" });
|
||||
expect((await accountingIntegrityService(db).inspect(f.company.id)).findings).toEqual([]);
|
||||
});
|
||||
it("stops at the exact budget boundary", async () => {
|
||||
const f = await fixture();
|
||||
const budgets = budgetService(db);
|
||||
await budgets.upsertPolicy(f.company.id, { scopeType: "agent", scopeId: f.agent.id, amount: 1 }, "board");
|
||||
await costService(db).createEvent(f.company.id, { ...f.receipt, costCents: 1 });
|
||||
expect(await budgets.getInvocationBlock(f.company.id, f.agent.id)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { accountingRuntimeBaselines, agents, agentRuntimeState, billingInvoiceLines, budgetPolicies, budgetReservations, companies, costEvents, createDb, heartbeatRuns, projects, runUsageReceipts } from "@paperclipai/db";
|
||||
import { accountingIntegrityService } from "../services/accounting-integrity.js";
|
||||
import { billingReconciliationService } from "../services/billing-reconciliation.js";
|
||||
import { budgetService } from "../services/budgets.js";
|
||||
import { reserveRunBudget } from "../services/budget-reservations.js";
|
||||
import { costService } from "../services/costs.js";
|
||||
import { financeService } from "../services/finance.js";
|
||||
import { accountRunCost, reconcileRunCosts } from "../services/run-cost-accounting.js";
|
||||
import { createRunUsageRecorder, persistUsageReceipt, replayUsageReceipts, spoolUsageReceipt, usageReceiptSpoolPath, type UsageReceiptEnvelope } from "../services/usage-receipts.js";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
(support.supported ? describe : describe.skip)("accounting operational edge cases", () => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>, db: ReturnType<typeof createDb>, directory: string;
|
||||
beforeAll(async () => { database = await startEmbeddedPostgresTestDatabase("accounting-operational-edges-"); db = createDb(database.connectionString); directory = await fs.mkdtemp(path.join(os.tmpdir(), "accounting-edges-")); },30_000);
|
||||
afterAll(async () => { vi.restoreAllMocks(); await database?.cleanup(); await fs.rm(directory, { recursive: true, force: true }); });
|
||||
async function fixture() {
|
||||
const [company] = await db.insert(companies).values({ name: "Edges", issuePrefix: `E${randomUUID().slice(0,7)}` }).returning();
|
||||
const [agent] = await db.insert(agents).values({ companyId: company.id, name: "Worker", role: "engineer", adapterType: "process", status: "idle" }).returning();
|
||||
const [project] = await db.insert(projects).values({ companyId: company.id, name: "Work" }).returning();
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId: company.id, agentId: agent.id, status: "running", usageJson: {} }).returning();
|
||||
return { company, agent, project, run };
|
||||
}
|
||||
const usage = { inputTokens: 7, cachedInputTokens: 11, outputTokens: 3 };
|
||||
async function event(f: Awaited<ReturnType<typeof fixture>>, extras: Record<string, unknown> = {}) {
|
||||
return costService(db).createEvent(f.company.id, { agentId: f.agent.id, provider: "fixture", model: "model", costCents: "0.1234567", occurredAt: new Date(), ...extras });
|
||||
}
|
||||
it("initializes an old month once, excludes backdated events, and repairs current-month drift", async () => {
|
||||
const f = await fixture();
|
||||
await db.update(companies).set({ spendMonthUtc: "2000-01-01", spentMonthlyCents: 999 }).where(eq(companies.id,f.company.id));
|
||||
await event(f); await event(f, { occurredAt: new Date("2000-01-01") }); await event(f);
|
||||
const [row] = await db.select().from(companies).where(eq(companies.id,f.company.id));
|
||||
expect(row.spentMonthlyCents).toBe(0.2469134);
|
||||
expect((await accountingIntegrityService(db).inspect(f.company.id)).findings).toEqual([]);
|
||||
await db.update(agents).set({ spentMonthlyCents: 4 }).where(eq(agents.id,f.agent.id));
|
||||
const review = await accountingIntegrityService(db).inspect(f.company.id);
|
||||
await accountingIntegrityService(db).repair(f.company.id,review.fingerprint,"Repair drift","board");
|
||||
await event(f);
|
||||
expect((await accountingIntegrityService(db).inspect(f.company.id)).findings).toEqual([]);
|
||||
});
|
||||
it("keeps fractional finance credits and net totals exact beyond number precision", async () => {
|
||||
const f = await fixture(), finance = financeService(db);
|
||||
const base = { biller: "fixture", eventKind: "inference_charge", occurredAt: new Date() };
|
||||
await finance.createEvent(f.company.id,{ ...base, amountCents: "9007199254740992.0000001" });
|
||||
await finance.createEvent(f.company.id,{ ...base, direction: "credit", amountCents: "9007199254740992" });
|
||||
expect((await finance.summary(f.company.id)).netCentsExact).toBe("0.0000001");
|
||||
expect((await finance.byBiller(f.company.id))[0].netCentsExact).toBe("0.0000001");
|
||||
expect((await finance.byKind(f.company.id))[0].netCentsExact).toBe("0.0000001");
|
||||
});
|
||||
it("checks agent and project capacity, preserves reservations through policy edits, and releases proven bootstrap failures", async () => {
|
||||
const f = await fixture(), budgets = budgetService(db);
|
||||
await budgets.upsertPolicy(f.company.id,{ scopeType:"agent",scopeId:f.agent.id,amount:20,reservationCents:"3" },"board");
|
||||
await budgets.upsertPolicy(f.company.id,{ scopeType:"project",scopeId:f.project.id,windowKind:"lifetime",amount:10,reservationCents:"6" },"board");
|
||||
await budgets.upsertPolicy(f.company.id,{ scopeType:"company",scopeId:f.company.id,amount:20,reservationCents:"1" },"board");
|
||||
const reserved = await reserveRunBudget(db,f.company.id,f.run.id,f.project.id);
|
||||
expect(reserved.amountCents).toBe("6.0000000");
|
||||
await budgets.upsertPolicy(f.company.id,{ scopeType:"project",scopeId:f.project.id,windowKind:"lifetime",amount:10,reservationCents:"0" },"board");
|
||||
await budgets.upsertPolicy(f.company.id,{ scopeType:"agent",scopeId:f.agent.id,amount:20,reservationCents:"0" },"board");
|
||||
expect((await db.select().from(budgetReservations).where(eq(budgetReservations.runId,f.run.id)))[0].amountCents).toBe("6.0000000");
|
||||
await db.update(heartbeatRuns).set({ status:"failed",resultJson:{executionRecovery:{providerWorkStarted:false}} }).where(eq(heartbeatRuns.id,f.run.id));
|
||||
await accountRunCost(db,f.run.id);
|
||||
expect((await db.select().from(budgetReservations).where(eq(budgetReservations.runId,f.run.id)))[0].state).toBe("released");
|
||||
expect(await db.select().from(costEvents).where(eq(costEvents.companyId,f.company.id))).toEqual([]);
|
||||
});
|
||||
it("reports over-limit advisory budgets without blocking, and tolerates legacy active zero limits", async () => {
|
||||
const f = await fixture(), budgets = budgetService(db);
|
||||
const policy = await budgets.upsertPolicy(f.company.id, { scopeType: "company", scopeId: f.company.id, amount: 1, hardStopEnabled: false }, "board");
|
||||
await event(f, { costCents: "2.0000001" });
|
||||
expect(await budgets.getInvocationBlock(f.company.id, f.agent.id)).toBeNull();
|
||||
expect((await budgets.overview(f.company.id)).policies[0]).toMatchObject({ status: "hard_stop", observedAmountExact: "2.0000001", paused: false });
|
||||
// Older databases can contain an active zero policy; it must never become
|
||||
// an accidental hard stop during an upgrade or a projection inspection.
|
||||
await db.update(budgetPolicies).set({ amount: 0, isActive: true }).where(eq(budgetPolicies.id, policy.policyId));
|
||||
expect((await budgets.overview(f.company.id)).policies[0]).toMatchObject({ status: "ok", amount: 0 });
|
||||
expect(await budgets.getInvocationBlock(f.company.id, f.agent.id)).toBeNull();
|
||||
});
|
||||
it("recovers late bootstrap proof without replacing the stop owner's terminal metadata", async () => {
|
||||
const f = await fixture();
|
||||
await reserveRunBudget(db, f.company.id, f.run.id, null);
|
||||
const stopped = { executionCancellation: { state: "acknowledged" } };
|
||||
await db.update(heartbeatRuns).set({ status: "cancelled", resultJson: stopped,
|
||||
usageJson: { accountingReceiptReady: false, accountingProviderWorkStarted: false },
|
||||
}).where(eq(heartbeatRuns.id, f.run.id));
|
||||
const integrity = accountingIntegrityService(db);
|
||||
expect((await integrity.health(f.company.id)).items[0].state).toBe("retryable");
|
||||
expect((await integrity.inspect(f.company.id)).findings).toEqual([]);
|
||||
await reconcileRunCosts(db);
|
||||
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.run.id));
|
||||
expect(run).toMatchObject({ status: "cancelled", resultJson: stopped, costAccountingPending: false });
|
||||
expect(run.costAccountedAt).not.toBeNull();
|
||||
expect((await db.select().from(budgetReservations).where(eq(budgetReservations.runId, f.run.id)))[0].state).toBe("released");
|
||||
expect(await db.select().from(costEvents).where(eq(costEvents.heartbeatRunId, f.run.id))).toEqual([]);
|
||||
});
|
||||
it("rejects absent, foreign, finished and blocked dispatch without changing the ledger", async () => {
|
||||
const f = await fixture(), other = await fixture();
|
||||
await expect(reserveRunBudget(db,f.company.id,randomUUID(),null)).rejects.toThrow("not found");
|
||||
await expect(reserveRunBudget(db,other.company.id,f.run.id,null)).rejects.toThrow("not found");
|
||||
await expect(reserveRunBudget(db,f.company.id,f.run.id,other.project.id)).rejects.toThrow("not found");
|
||||
await db.update(heartbeatRuns).set({status:"failed"}).where(eq(heartbeatRuns.id,f.run.id));
|
||||
await expect(reserveRunBudget(db,f.company.id,f.run.id,null)).rejects.toThrow("no longer");
|
||||
await db.update(heartbeatRuns).set({status:"running",costAccountedAt:new Date()}).where(eq(heartbeatRuns.id,f.run.id));
|
||||
await expect(reserveRunBudget(db,f.company.id,f.run.id,null)).rejects.toThrow("no longer");
|
||||
await db.update(heartbeatRuns).set({costAccountedAt:null}).where(eq(heartbeatRuns.id,f.run.id));
|
||||
await budgetService(db).upsertPolicy(f.company.id,{scopeType:"agent",scopeId:f.agent.id,amount:1},"board");
|
||||
await event(f,{costCents:1});
|
||||
await expect(reserveRunBudget(db,f.company.id,f.run.id,null)).rejects.toThrow("paused");
|
||||
await expect(budgetService(db).upsertPolicy(f.company.id,{scopeType:"agent",scopeId:f.agent.id,amount:1,reservationCents:-1},"board")).rejects.toThrow("negative");
|
||||
});
|
||||
it("distinguishes legacy totals, missing acknowledgements, missing receipts, and corrupted receipt amounts", async () => {
|
||||
const f = await fixture(); const integrity = accountingIntegrityService(db);
|
||||
await db.insert(agentRuntimeState).values({companyId:f.company.id,agentId:f.agent.id,adapterType:"process",totalCostCents:4});
|
||||
expect((await integrity.inspect(f.company.id)).findings[0].kind).toBe("legacy_runtime");
|
||||
await db.update(heartbeatRuns).set({status:"failed",costAccountingPending:true,usageJson:{...usage,costUsdExact:"0.01",accountingReceiptReady:true}}).where(eq(heartbeatRuns.id,f.run.id));
|
||||
expect((await integrity.inspect(f.company.id)).findings.some(f => f.kind === "missing_acknowledgement")).toBe(true);
|
||||
expect(await integrity.retry(f.company.id,f.run.id,"board")).toEqual({accounted:true});
|
||||
await db.update(heartbeatRuns).set({usageJson:{...usage,costUsdExact:"0.02"}}).where(eq(heartbeatRuns.id,f.run.id));
|
||||
expect((await integrity.inspect(f.company.id)).findings.map(f=>f.kind)).toContain("receipt_mismatch");
|
||||
await db.update(heartbeatRuns).set({usageJson:{...usage,costUsdExact:"invalid"}}).where(eq(heartbeatRuns.id,f.run.id));
|
||||
expect((await integrity.inspect(f.company.id)).findings.find(f=>f.kind==="receipt_mismatch")?.expected.cents).toBe("invalid receipt");
|
||||
await db.delete(costEvents).where(eq(costEvents.heartbeatRunId,f.run.id));
|
||||
expect((await integrity.inspect(f.company.id)).findings.map(f=>f.kind)).toContain("missing_receipt");
|
||||
await expect(integrity.repair(f.company.id,"a".repeat(64)," ","board")).rejects.toThrow("reason");
|
||||
await expect(integrity.retry(f.company.id,randomUUID(),"board")).rejects.toThrow("not found");
|
||||
});
|
||||
it("records a failed operator retry and keeps it actionable", async () => {
|
||||
const f = await fixture();
|
||||
await db.update(heartbeatRuns).set({status:"failed",costAccountingPending:true,usageJson:{inputTokens:0.1,accountingReceiptReady:true}}).where(eq(heartbeatRuns.id,f.run.id));
|
||||
const integrity = accountingIntegrityService(db);
|
||||
await expect(integrity.retry(f.company.id,f.run.id,"board")).rejects.toThrow("Invalid cost");
|
||||
const health = await integrity.health(f.company.id);
|
||||
expect(health.items[0]).toMatchObject({state:"retryable",attempts:1,lastError:"Invalid cost receipt"});
|
||||
expect(health.items[0].lastAttemptAt).not.toBeNull();
|
||||
});
|
||||
it("matches provider request IDs and run/model pairs, leaves ambiguous and non-inference lines unresolved", async () => {
|
||||
const f = await fixture(), service = billingReconciliationService(db);
|
||||
await event(f,{heartbeatRunId:f.run.id,providerRequestId:"request"}); await event(f,{heartbeatRunId:f.run.id,model:"second"});
|
||||
const line = {amountCents:"1",occurredAt:new Date().toISOString()};
|
||||
const invoice = await service.importInvoice(f.company.id,{biller:"fixture",externalId:"requests",currency:"USD",lines:[
|
||||
{...line,externalId:"provider",providerRequestId:"request"}, {...line,externalId:"model",runId:f.run.id,model:"second"},
|
||||
{...line,externalId:"ambiguous",runId:f.run.id}, {...line,externalId:"unknown",providerRequestId:"absent"},
|
||||
{...line,externalId:"unlinked"}, {...line,externalId:"fee",kind:"fee"}, {...line,externalId:"credit",kind:"credit"},
|
||||
]},"board");
|
||||
expect((await service.list(f.company.id))[0].id).toBe(invoice.id);
|
||||
const report = await service.reconcile(f.company.id,invoice.id);
|
||||
expect(Object.fromEntries(report.lines.map(l=>[l.externalId,l.status]))).toEqual({provider:"difference",model:"difference",ambiguous:"ambiguous",unknown:"unmatched",unlinked:"unmatched",fee:"non_inference",credit:"non_inference"});
|
||||
await expect(service.importInvoice(f.company.id,{biller:"fixture",externalId:"missing-run",currency:"USD",lines:[{...line,externalId:"run",runId:randomUUID()}]},"board")).rejects.toThrow("run not found");
|
||||
});
|
||||
it("rejects contradictory invoice evidence and reused correction keys, and preserves legacy originals", async () => {
|
||||
const f = await fixture(), service = billingReconciliationService(db); const charge = await event(f);
|
||||
await db.update(costEvents).set({reportedCostCents:null}).where(eq(costEvents.id,charge.id));
|
||||
const input = {idempotencyKey:"correction",expectedCents:"0.1234567",correctedCents:"1",reason:"Reviewed",pricing:{source:"operator" as const}};
|
||||
await expect(service.adjust(f.company.id,charge.id,{...input,invoiceLineId:randomUUID()},"board")).rejects.toThrow("not found");
|
||||
const invoice = await service.importInvoice(f.company.id,{biller:"fixture",externalId:"bad-evidence",currency:"USD",lines:[{externalId:"line",amountCents:"2",occurredAt:new Date().toISOString(),costEventId:charge.id}]},"board");
|
||||
const [line] = (await service.reconcile(f.company.id,invoice.id)).lines;
|
||||
await expect(service.adjust(f.company.id,charge.id,{...input,invoiceLineId:line.id},"board")).rejects.toThrow("does not support");
|
||||
const noMatch = await service.importInvoice(f.company.id,{biller:"fixture",externalId:"unverified",currency:"USD",lines:[{externalId:"line",amountCents:"1",occurredAt:new Date().toISOString()}]},"board");
|
||||
await expect(service.adjust(f.company.id,charge.id,{...input,invoiceLineId:(await service.reconcile(f.company.id,noMatch.id)).lines[0].id},"board")).rejects.toThrow("unverified");
|
||||
await service.adjust(f.company.id,charge.id,input,"board");
|
||||
await expect(service.adjust(f.company.id,charge.id,{...input,correctedCents:"2"},"board")).rejects.toThrow("different contents");
|
||||
expect((await db.select().from(costEvents).where(eq(costEvents.id,charge.id)))[0].reportedCostCents).toBe("0.1234567");
|
||||
});
|
||||
it("aggregates attempts exactly, rejects incomplete attempts, and never lets a late old attempt replace the current attempt", async () => {
|
||||
const f = await fixture(), spool = path.join(directory,randomUUID());
|
||||
const recorder = await createRunUsageRecorder(db,{companyId:f.company.id,runId:f.run.id,adapterType:"process"},spool);
|
||||
const first=randomUUID(), second=randomUUID();
|
||||
await recorder.capture({attemptId:first,usage,costUsdExact:"0.010000001",complete:true,billingType:"api"});
|
||||
await recorder.capture({attemptId:second,usage,costUsd:0.02,complete:false,billingType:"api"});
|
||||
await recorder.capture({attemptId:first,usage,costUsdExact:"0.010000001",complete:true,billingType:"api"});
|
||||
const result = await recorder.complete({exitCode:0,signal:null,timedOut:false,usage,costUsd:0.03,billingType:"api"});
|
||||
expect(result).toMatchObject({costUsdExact:"0.040000001",complete:true,usage:{inputTokens:14,cachedInputTokens:22,outputTokens:6}});
|
||||
await recorder.capture({attemptId:randomUUID(),usage,complete:false,billingType:"subscription"});
|
||||
const uncertain = await recorder.complete({exitCode:1,signal:null,timedOut:false,usage});
|
||||
expect(uncertain).toMatchObject({costUsd:null,costUsdExact:null,billingType:"unknown"});
|
||||
const partial = await recorder.complete({exitCode:1,signal:null,timedOut:false});
|
||||
expect(partial.complete).toBe(false);
|
||||
expect(partial.usage?.inputTokens).toBe(21);
|
||||
});
|
||||
it("refuses invalid checkpoints and does not certify a run after capture failure", async () => {
|
||||
const f = await fixture();
|
||||
const recorder = await createRunUsageRecorder(db,{companyId:f.company.id,runId:f.run.id,adapterType:"process"},path.join(directory,randomUUID()));
|
||||
await expect(recorder.capture({complete:true,costUsdExact:"NaN"})).rejects.toThrow();
|
||||
const result = await recorder.complete({exitCode:0,signal:null,timedOut:false,usage,costUsd:0.01});
|
||||
expect(result.complete).toBe(false);
|
||||
await expect(createRunUsageRecorder(db,{companyId:f.company.id,runId:randomUUID(),adapterType:"process"},path.join(directory,randomUUID()))).rejects.toThrow("cannot accept");
|
||||
});
|
||||
it("preserves disk errors, rejects missing runs, and handles invalid spool entries without deleting evidence", async () => {
|
||||
const f=await fixture(), spool=path.join(directory,randomUUID());
|
||||
const envelope:UsageReceiptEnvelope={schema:"paperclip/accounting-receipt/v1",id:randomUUID(),companyId:f.company.id,runId:randomUUID(),sourceId:randomUUID(),sequence:1,receivedAt:new Date().toISOString(),adapterType:"process",receipt:{complete:true,costUsd:1}};
|
||||
await expect(persistUsageReceipt(db,envelope)).rejects.toThrow("not found");
|
||||
const rename=vi.spyOn(fs,"rename").mockRejectedValueOnce(new Error("Disk rename unavailable"));
|
||||
try { await expect(spoolUsageReceipt(envelope,spool)).rejects.toThrow("Disk rename"); } finally { rename.mockRestore(); }
|
||||
expect(await fs.readdir(spool)).toEqual([]);
|
||||
await fs.writeFile(path.join(spool,"large.json"),"x".repeat(1024*1024+1));
|
||||
expect((await replayUsageReceipts(db,spool)).failed).toBe(1);
|
||||
const read=vi.spyOn(fs,"readdir").mockRejectedValueOnce(Object.assign(new Error("denied"),{code:"EACCES"}));
|
||||
try { await expect(replayUsageReceipts(db,spool)).rejects.toThrow("denied"); } finally { read.mockRestore(); }
|
||||
});
|
||||
it("uses the isolated instance spool and retains evidence when retry rotation itself fails", async () => {
|
||||
const f = await fixture();
|
||||
const envelope: UsageReceiptEnvelope = { schema: "paperclip/accounting-receipt/v1", id: randomUUID(), companyId: f.company.id, runId: f.run.id,
|
||||
sourceId: randomUUID(), sequence: 1, receivedAt: new Date().toISOString(), adapterType: "process", receipt: { complete: true, costUsd: 0 } };
|
||||
const file = await spoolUsageReceipt(envelope);
|
||||
expect(path.dirname(file)).toBe(usageReceiptSpoolPath());
|
||||
expect((await replayUsageReceipts(db)).replayed).toBe(1);
|
||||
const spool = path.join(directory, randomUUID()); await fs.mkdir(spool);
|
||||
const bad = path.join(spool, "bad.json"); await fs.writeFile(bad, "invalid");
|
||||
const rename = vi.spyOn(fs, "rename").mockRejectedValueOnce(new Error("read-only directory"));
|
||||
try { expect(await replayUsageReceipts(db, spool)).toEqual({ replayed: 0, failed: 1 }); } finally { rename.mockRestore(); }
|
||||
expect(await fs.readFile(bad, "utf8")).toBe("invalid");
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { agents, agentRuntimeState, budgetReservations, companies, costEvents, costAdjustments, createDb, heartbeatRuns, runUsageReceipts } from "@paperclipai/db";
|
||||
import { accountingIntegrityService } from "../services/accounting-integrity.js";
|
||||
import { billingReconciliationService } from "../services/billing-reconciliation.js";
|
||||
import { budgetService } from "../services/budgets.js";
|
||||
import { reserveRunBudget } from "../services/budget-reservations.js";
|
||||
import { costService } from "../services/costs.js";
|
||||
import { accountRunCost } from "../services/run-cost-accounting.js";
|
||||
import { createRunUsageRecorder, persistUsageReceipt, replayUsageReceipts, spoolUsageReceipt, type UsageReceiptEnvelope } from "../services/usage-receipts.js";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
(support.supported ? describe : describe.skip)("accounting operations and durable receipts (PostgreSQL)", () => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
let directory: string;
|
||||
beforeAll(async () => { database = await startEmbeddedPostgresTestDatabase("paperclip-accounting-operators-"); db = createDb(database.connectionString); directory = await fs.mkdtemp(path.join(os.tmpdir(), "accounting-spool-test-")); }, 30_000);
|
||||
afterAll(async () => { await database?.cleanup(); await fs.rm(directory, { recursive: true, force: true }); });
|
||||
async function fixture() {
|
||||
const [company] = await db.insert(companies).values({ name: "Accounting", issuePrefix: `A${randomUUID().slice(0,7)}` }).returning();
|
||||
const [agent] = await db.insert(agents).values({ companyId: company.id, name: "Worker", role: "engineer", adapterType: "process", status: "idle" }).returning();
|
||||
return { company, agent };
|
||||
}
|
||||
async function runFor(f: Awaited<ReturnType<typeof fixture>>, status = "running") {
|
||||
return (await db.insert(heartbeatRuns).values({ companyId: f.company.id, agentId: f.agent.id, invocationSource: "on_demand", status }).returning())[0];
|
||||
}
|
||||
async function receipt(f: Awaited<ReturnType<typeof fixture>>, costCents: number | string = "1.1234567") {
|
||||
return costService(db).createEvent(f.company.id, { agentId: f.agent.id, provider: "test", model: "test-model", costCents, occurredAt: new Date(), idempotencyKey: randomUUID() });
|
||||
}
|
||||
|
||||
it("keeps the last nanocent exact in storage, reports, projections and admission", async () => {
|
||||
const f = await fixture();
|
||||
await budgetService(db).upsertPolicy(f.company.id, { scopeType: "agent", scopeId: f.agent.id, amount: 2_000_000_000 }, "board");
|
||||
await receipt(f, "1999999999.9999999");
|
||||
expect(await budgetService(db).getInvocationBlock(f.company.id, f.agent.id)).toBeNull();
|
||||
expect((await costService(db).summary(f.company.id)).spendCentsExact).toBe("1999999999.9999999");
|
||||
await receipt(f, "0.0000001");
|
||||
expect(await budgetService(db).getInvocationBlock(f.company.id, f.agent.id)).not.toBeNull();
|
||||
const huge = await fixture();
|
||||
await receipt(huge, "99999999999999999.0000001");
|
||||
expect((await accountingIntegrityService(db).inspect(huge.company.id)).findings).toEqual([]);
|
||||
});
|
||||
|
||||
it("admits only affordable concurrent reservations and settles atomically with the receipt", async () => {
|
||||
const f = await fixture(); const runs = await Promise.all(Array.from({ length: 4 }, () => runFor(f)));
|
||||
await budgetService(db).upsertPolicy(f.company.id, { scopeType: "company", scopeId: f.company.id, amount: 10, reservationCents: "6" }, "board");
|
||||
const results = await Promise.allSettled(runs.map(run => reserveRunBudget(db, f.company.id, run.id, null)));
|
||||
expect(results.filter(r => r.status === "fulfilled")).toHaveLength(1);
|
||||
const index = results.findIndex(r => r.status === "fulfilled"); const run = runs[index];
|
||||
expect((await accountingIntegrityService(db).health(f.company.id)).heldReservationCents).toBe("6.0000000");
|
||||
await expect(reserveRunBudget(db, f.company.id, run.id, null)).rejects.toThrow("already reserved");
|
||||
// Reservations never disappear merely because wall-clock time passed.
|
||||
await db.update(budgetReservations).set({ createdAt: new Date("2000-01-01") }).where(eq(budgetReservations.runId, run.id));
|
||||
const denied = runs[(index + 1) % runs.length];
|
||||
await expect(reserveRunBudget(db, f.company.id, denied.id, null)).rejects.toThrow("reserved");
|
||||
await db.update(heartbeatRuns).set({ status: "succeeded", finishedAt: new Date(), usageJson: { costUsdExact: "0.04", billingType: "metered_api", accountingReceiptReady: true } }).where(eq(heartbeatRuns.id, run.id));
|
||||
expect(await accountRunCost(db, run.id)).toBe(true);
|
||||
expect((await accountingIntegrityService(db).health(f.company.id)).heldReservationCents).toBe("0.0000000");
|
||||
await reserveRunBudget(db, f.company.id, denied.id, null);
|
||||
expect((await accountingIntegrityService(db).inspect(f.company.id)).findings).toEqual([]);
|
||||
});
|
||||
|
||||
it("independently detects and repairs corrupted projections with a stale-review guard and audit", async () => {
|
||||
const f = await fixture(); const run = await runFor(f, "succeeded");
|
||||
await db.update(heartbeatRuns).set({ costAccountingPending: true, usageJson: { accountingReceiptReady: true, costUsdExact: "0.012345678", inputTokens: 7, cachedInputTokens: 3, outputTokens: 11 } }).where(eq(heartbeatRuns.id, run.id));
|
||||
await accountRunCost(db, run.id);
|
||||
const service = accountingIntegrityService(db);
|
||||
expect((await service.inspect(f.company.id)).findings).toEqual([]);
|
||||
await db.update(companies).set({ spentMonthlyCents: 999 }).where(eq(companies.id, f.company.id));
|
||||
await db.update(agents).set({ spentMonthlyCents: 888 }).where(eq(agents.id, f.agent.id));
|
||||
await db.update(agentRuntimeState).set({ totalCostCents: 777, totalInputTokens: 600 }).where(eq(agentRuntimeState.agentId, f.agent.id));
|
||||
const review = await service.inspect(f.company.id);
|
||||
expect(review.findings.map(f => f.kind)).toEqual(["agent_projection", "company_projection", "runtime_projection"]);
|
||||
await db.update(companies).set({ spentMonthlyCents: 998 }).where(eq(companies.id, f.company.id));
|
||||
await expect(service.repair(f.company.id, review.fingerprint, "Fix drift", "board")).rejects.toThrow("changed since inspection");
|
||||
const fresh = await service.inspect(f.company.id);
|
||||
expect((await service.repair(f.company.id, fresh.fingerprint, "Fix drift", "board")).findings).toEqual([]);
|
||||
const audit = await db.execute(sql`select details from activity_log where company_id = ${f.company.id} and action = 'accounting.projections_repaired'`);
|
||||
expect(audit).toHaveLength(1);
|
||||
expect(await accountRunCost(db, run.id)).toBe(false);
|
||||
expect((await service.inspect(f.company.id)).findings).toEqual([]);
|
||||
});
|
||||
|
||||
it("imports invoices idempotently and appends reviewed corrections without losing the provider receipt", async () => {
|
||||
const f = await fixture();
|
||||
const originalPricing = { source: "rate_card" as const, version: "2026-09-01", inputCentsPerMillion: "12.3456789" };
|
||||
const event = await costService(db).createEvent(f.company.id, { agentId: f.agent.id, provider: "test", model: "test-model",
|
||||
costCents: "1.1234567", occurredAt: new Date(), pricingProvenance: originalPricing });
|
||||
const service = billingReconciliationService(db);
|
||||
const invoice = { biller: "test", externalId: "invoice-1", currency: "usd", lines: [{ externalId: "request-1", amountCents: "2.0000001", occurredAt: new Date().toISOString(), costEventId: event.id }] };
|
||||
const imported = await service.importInvoice(f.company.id, invoice, "board");
|
||||
expect((await service.importInvoice(f.company.id, invoice, "board")).id).toBe(imported.id);
|
||||
await expect(service.importInvoice(f.company.id, { ...invoice, currency: "EUR" }, "board")).rejects.toThrow("different contents");
|
||||
const review = await service.reconcile(f.company.id, imported.id);
|
||||
expect(review.lines[0]).toMatchObject({ status: "difference", recordedCents: "1.1234567", differenceCents: "0.8765434" });
|
||||
const input = { idempotencyKey: "fix-1", expectedCents: "1.1234567", correctedCents: "2.0000001", invoiceLineId: review.lines[0].id, reason: "Provider invoice", pricing: { source: "provider_invoice" as const, evidence: "invoice-1" } };
|
||||
const corrected = await service.adjust(f.company.id, event.id, input, "board");
|
||||
expect(corrected.previousPricing).toEqual(originalPricing);
|
||||
expect(corrected.pricing).toEqual(input.pricing);
|
||||
expect((await service.adjust(f.company.id, event.id, input, "board")).id).toBe(corrected.id);
|
||||
expect((await service.reconcile(f.company.id, imported.id)).lines[0].status).toBe("matched");
|
||||
const [row] = await db.select().from(costEvents).where(eq(costEvents.id, event.id));
|
||||
expect(row.reportedCostCents).toBe("1.1234567"); expect(row.receiptHash).toBe(event.receiptHash);
|
||||
expect((await service.adjustments(f.company.id, event.id))).toHaveLength(1);
|
||||
expect((await accountingIntegrityService(db).inspect(f.company.id)).findings).toEqual([]);
|
||||
await expect(service.adjust(f.company.id, event.id, { ...input, idempotencyKey: "stale" }, "board")).rejects.toThrow("changed since review");
|
||||
const second = await service.adjust(f.company.id, event.id, { ...input, invoiceLineId: undefined, idempotencyKey: "fix-2",
|
||||
expectedCents: "2.0000001", correctedCents: "3", pricing: { source: "operator" }, reason: "Reviewed updated evidence" }, "board");
|
||||
expect(second.previousPricing).toEqual(input.pricing);
|
||||
expect((await service.adjustments(f.company.id, event.id))).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("refuses cross-company references, guesses, duplicate invoice matches, and currency conversion", async () => {
|
||||
const f = await fixture(), foreign = await fixture(); const event = await receipt(f);
|
||||
const service = billingReconciliationService(db);
|
||||
const base = { biller: "test", externalId: "invoice", currency: "USD", lines: [{ externalId: "line", amountCents: "1", occurredAt: new Date().toISOString(), costEventId: event.id }] };
|
||||
await expect(service.importInvoice(foreign.company.id, base, "board")).rejects.toThrow("not found");
|
||||
await expect(service.adjust(foreign.company.id, event.id, { idempotencyKey: "bad", expectedCents: 0, correctedCents: 1, reason: "test", pricing: { source: "operator" } }, "board")).rejects.toThrow("not found");
|
||||
const duplicate = await service.importInvoice(f.company.id, { ...base, lines: [...base.lines, { ...base.lines[0], externalId: "second" }] }, "board");
|
||||
expect((await service.reconcile(f.company.id, duplicate.id)).lines.map(l => l.status)).toEqual(["ambiguous", "ambiguous"]);
|
||||
const eur = await service.importInvoice(f.company.id, { ...base, externalId: "eur", currency: "EUR" }, "board");
|
||||
expect((await service.reconcile(f.company.id, eur.id)).lines[0].status).toBe("unsupported_currency");
|
||||
await expect(service.reconcile(foreign.company.id, eur.id)).rejects.toThrow("not found");
|
||||
expect(await db.select().from(costAdjustments).where(eq(costAdjustments.companyId, f.company.id))).toEqual([]);
|
||||
});
|
||||
|
||||
it("prices unknown usage from an invoice and updates runtime, admission and independent verification", async () => {
|
||||
const f = await fixture(); const run = await runFor(f, "failed");
|
||||
await budgetService(db).upsertPolicy(f.company.id, { scopeType: "agent", scopeId: f.agent.id, amount: 10 }, "board");
|
||||
await db.update(heartbeatRuns).set({ costAccountingPending: true, usageJson: { accountingReceiptReady: true, inputTokens: 9, provider: "test" } }).where(eq(heartbeatRuns.id, run.id));
|
||||
await accountRunCost(db, run.id);
|
||||
expect((await accountingIntegrityService(db).health(f.company.id)).unpricedEventCount).toBe(1);
|
||||
expect(await budgetService(db).getInvocationBlock(f.company.id, f.agent.id)).not.toBeNull();
|
||||
const [event] = await db.select().from(costEvents).where(eq(costEvents.heartbeatRunId, run.id));
|
||||
await billingReconciliationService(db).adjust(f.company.id, event.id, { idempotencyKey: "pricing", expectedCents: 0, correctedCents: "1.0000001", reason: "Verified provider invoice", pricing: { source: "provider_invoice", evidence: "billing-export" } }, "board");
|
||||
expect(await budgetService(db).getInvocationBlock(f.company.id, f.agent.id)).toBeNull();
|
||||
expect((await accountingIntegrityService(db).health(f.company.id)).unpricedEventCount).toBe(0);
|
||||
expect((await accountingIntegrityService(db).inspect(f.company.id)).findings).toEqual([]);
|
||||
});
|
||||
|
||||
it("spools a receipt during database failure, replays once, fences old sources, and preserves partial uncertainty", async () => {
|
||||
const f = await fixture(); const run = await runFor(f); const spool = path.join(directory, randomUUID());
|
||||
const recorder = await createRunUsageRecorder(db, { companyId: f.company.id, runId: run.id, adapterType: "process" }, spool);
|
||||
const transaction = vi.spyOn(db, "transaction").mockRejectedValueOnce(new Error("Database unavailable"));
|
||||
await recorder.capture({ costUsdExact: "0.010000001", usage: { inputTokens: 7, outputTokens: 2 }, complete: false });
|
||||
transaction.mockRestore();
|
||||
expect((await fs.readdir(spool)).filter(n => n.endsWith(".json"))).toHaveLength(1);
|
||||
expect(await replayUsageReceipts(db, spool)).toEqual({ replayed: 1, failed: 0 });
|
||||
expect(await replayUsageReceipts(db, spool)).toEqual({ replayed: 0, failed: 0 });
|
||||
await db.update(heartbeatRuns).set({ status: "failed" }).where(eq(heartbeatRuns.id, run.id));
|
||||
expect(await accountRunCost(db, run.id)).toBe(false);
|
||||
expect((await accountingIntegrityService(db).health(f.company.id)).items[0].state).toBe("waiting_for_receipt");
|
||||
await recorder.complete({ exitCode: 0, signal: null, timedOut: false, costUsdExact: "0.010000001", usage: { inputTokens: 7, outputTokens: 2 } });
|
||||
expect(await accountRunCost(db, run.id)).toBe(true);
|
||||
const receipts = await db.select().from(runUsageReceipts).where(eq(runUsageReceipts.runId, run.id));
|
||||
expect(receipts).toHaveLength(2);
|
||||
await persistUsageReceipt(db, receipts[0].receiptJson as UsageReceiptEnvelope);
|
||||
expect((await costService(db).summary(f.company.id)).spendCentsExact).toBe("1.0000001");
|
||||
const tampered = { ...(receipts[0].receiptJson as UsageReceiptEnvelope), receipt: { complete: true, costUsd: 123 } };
|
||||
await expect(persistUsageReceipt(db, tampered)).rejects.toThrow("Conflicting usage");
|
||||
});
|
||||
|
||||
it("retains invalid spool evidence and bounds recovery work", async () => {
|
||||
const spool = path.join(directory, randomUUID()); await fs.mkdir(spool);
|
||||
await fs.writeFile(path.join(spool, "bad.json"), "not json");
|
||||
expect(await replayUsageReceipts(db, spool, 1)).toEqual({ replayed: 0, failed: 1 });
|
||||
expect((await fs.readdir(spool))[0]).toMatch(/^retry-/);
|
||||
expect(await replayUsageReceipts(db, path.join(spool, "missing"))).toEqual({ replayed: 0, failed: 0 });
|
||||
const f = await fixture(); const run = await runFor(f);
|
||||
const unknown: UsageReceiptEnvelope = { schema: "paperclip/accounting-receipt/v1", id: randomUUID(), companyId: f.company.id, runId: run.id,
|
||||
sourceId: randomUUID(), sequence: 1, receivedAt: new Date().toISOString(), adapterType: "process", receipt: { complete: true, costUsd: 10 } };
|
||||
await spoolUsageReceipt(unknown, spool);
|
||||
expect((await replayUsageReceipts(db, spool, 1)).replayed).toBe(1);
|
||||
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id)))[0].costAccountingPending).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { createConnection, createServer, type Socket } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { agents, companies, costEvents, createDb, heartbeatRuns } from "@paperclipai/db";
|
||||
import { costService } from "../services/costs.js";
|
||||
import { accountingIntegrityService } from "../services/accounting-integrity.js";
|
||||
import { replayUsageReceipts } from "../services/usage-receipts.js";
|
||||
import { accountRunCost } from "../services/run-cost-accounting.js";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
const root = fileURLToPath(new URL("../../..", import.meta.url));
|
||||
(support.supported && process.platform !== "win32" ? describe : describe.skip)("accounting across process and connection failures", () => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
beforeAll(async () => { database = await startEmbeddedPostgresTestDatabase("accounting-processes-"); db = createDb(database.connectionString); }, 30_000);
|
||||
afterAll(async () => { await database?.cleanup(); });
|
||||
async function fixture() {
|
||||
const [company] = await db.insert(companies).values({ name: "Process", issuePrefix: `P${randomUUID().slice(0,7)}` }).returning();
|
||||
const [agent] = await db.insert(agents).values({ companyId: company.id, name: "Worker", role: "engineer", adapterType: "process", status: "idle" }).returning();
|
||||
return { company, agent };
|
||||
}
|
||||
function worker(mode: string, input: unknown) {
|
||||
return spawn(process.execPath, ["cli/node_modules/tsx/dist/cli.mjs", "server/src/__tests__/fixtures/accounting-writer.ts", mode, JSON.stringify(input)], {
|
||||
cwd: root, detached: true, stdio: ["ignore", "pipe", "pipe"],
|
||||
env: { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR, PAPERCLIP_ACCOUNTING_TEST_DATABASE: database.connectionString, PAPERCLIP_TELEMETRY_DISABLED: "1" },
|
||||
});
|
||||
}
|
||||
it("deduplicates the same receipt from independent server processes", async () => {
|
||||
const f = await fixture();
|
||||
const receipt = { agentId: f.agent.id, provider: "fixture", model: "fixture", costCents: "0.1234567", idempotencyKey: "shared-key", occurredAt: new Date().toISOString() };
|
||||
const children = Array.from({ length: 4 }, () => worker("write", { companyId: f.company.id, receipt }));
|
||||
try {
|
||||
await Promise.all(children.map(child => new Promise<void>((resolve, reject) => {
|
||||
let log = ""; child.stdout.on("data", d => log += d); child.stderr.on("data", d => log += d);
|
||||
child.once("error", reject); child.once("exit", code => code === 0 && log.includes("WRITES_COMMITTED") ? resolve() : reject(new Error(log)));
|
||||
})));
|
||||
expect((await db.select().from(costEvents).where(eq(costEvents.companyId, f.company.id)))).toHaveLength(1);
|
||||
expect((await accountingIntegrityService(db).inspect(f.company.id)).findings).toEqual([]);
|
||||
} finally { for (const child of children) if (child.exitCode === null && child.signalCode === null) process.kill(-child.pid!, "SIGKILL"); }
|
||||
}, 45_000);
|
||||
|
||||
it("recovers a receipt after SIGKILL before its first database persistence", async () => {
|
||||
const f = await fixture(); const directory = await mkdtemp(path.join(tmpdir(), "accounting-early-kill-"));
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId: f.company.id, agentId: f.agent.id, status: "running", costAccountingPending: true, usageJson: { accountingReceiptReady: false } }).returning();
|
||||
const child = worker("receipt", { companyId: f.company.id, runId: run.id, directory });
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let log = ""; child.stdout.on("data", data => { log += data; if (log.includes("RECEIPT_DURABLE")) resolve(); });
|
||||
child.stderr.on("data", data => log += data); child.once("error", reject); child.once("exit", () => reject(new Error(`Exited before checkpoint: ${log}`)));
|
||||
});
|
||||
const stopped = new Promise(resolve => child.once("exit", resolve)); process.kill(-child.pid!, "SIGKILL"); await stopped;
|
||||
expect(await db.select().from(costEvents).where(eq(costEvents.companyId, f.company.id))).toEqual([]);
|
||||
await db.update(heartbeatRuns).set({ status: "failed", finishedAt: new Date() }).where(eq(heartbeatRuns.id, run.id));
|
||||
expect(await replayUsageReceipts(db, directory)).toEqual({ replayed: 1, failed: 0 });
|
||||
expect(await accountRunCost(db, run.id)).toBe(true);
|
||||
expect(await accountRunCost(db, run.id)).toBe(false);
|
||||
expect((await costService(db).summary(f.company.id)).spendCentsExact).toBe("1.2345678");
|
||||
expect((await accountingIntegrityService(db).inspect(f.company.id)).findings).toEqual([]);
|
||||
} finally {
|
||||
if (child.exitCode === null && child.signalCode === null) process.kill(-child.pid!, "SIGKILL");
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}, 45_000);
|
||||
|
||||
it("retries safely when PostgreSQL commits but its acknowledgement is lost", async () => {
|
||||
const f = await fixture(); const target = new URL(database.connectionString); const sockets = new Set<Socket>(); let dropped = false;
|
||||
// Parse PostgreSQL server frames, suppress precisely CommandComplete(COMMIT),
|
||||
// then break TCP. The database has committed while the writer sees failure.
|
||||
const proxy = createServer(client => {
|
||||
const upstream = createConnection({ host: target.hostname, port: Number(target.port) }); sockets.add(client); sockets.add(upstream);
|
||||
client.on("error", () => {}); upstream.on("error", () => client.destroy()); client.pipe(upstream);
|
||||
let pending = Buffer.alloc(0);
|
||||
upstream.on("data", chunk => {
|
||||
pending = Buffer.concat([pending, chunk]);
|
||||
while (pending.length >= 5) {
|
||||
const size = pending.readInt32BE(1) + 1;
|
||||
if (size < 5 || size > 16 * 1024 * 1024) { client.destroy(); upstream.destroy(); return; }
|
||||
if (pending.length < size) return;
|
||||
const message = pending.subarray(0, size); pending = pending.subarray(size);
|
||||
if (!dropped && message[0] === 67 && message.subarray(5).toString() === "COMMIT\0") {
|
||||
dropped = true; client.destroy(); upstream.destroy(); return;
|
||||
}
|
||||
client.write(message);
|
||||
}
|
||||
});
|
||||
client.on("close", () => { sockets.delete(client); upstream.destroy(); });
|
||||
upstream.on("close", () => { sockets.delete(upstream); client.destroy(); });
|
||||
});
|
||||
await new Promise<void>(resolve => proxy.listen(0, "127.0.0.1", resolve));
|
||||
const forwarded = new URL(database.connectionString); forwarded.hostname = "127.0.0.1"; forwarded.port = String((proxy.address() as { port: number }).port); forwarded.searchParams.set("sslmode", "disable");
|
||||
const writer = createDb(forwarded.toString());
|
||||
const receipt = { agentId: f.agent.id, provider: "fixture", model: "fixture", costCents: "0.9999999", idempotencyKey: "ambiguous-commit", occurredAt: new Date() };
|
||||
try {
|
||||
await expect(costService(writer).createEvent(f.company.id, receipt)).rejects.toThrow();
|
||||
expect(dropped).toBe(true);
|
||||
expect(await db.select().from(costEvents).where(eq(costEvents.companyId, f.company.id))).toHaveLength(1);
|
||||
await costService(writer).createEvent(f.company.id, receipt);
|
||||
expect((await costService(db).summary(f.company.id)).spendCentsExact).toBe("0.9999999");
|
||||
expect((await accountingIntegrityService(db).inspect(f.company.id)).findings).toEqual([]);
|
||||
} finally {
|
||||
await writer.$client.end({ timeout: 1 }); for (const socket of sockets) socket.destroy(); await new Promise<void>(resolve => proxy.close(() => resolve()));
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -0,0 +1,363 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { activityLog, agentRuntimeState, agents, approvals, budgetIncidents, budgetPolicies, companies, costEvents, createDb, financeEvents, goals, heartbeatRuns, issues, projects } from "@paperclipai/db";
|
||||
import { costService } from "../services/costs.js";
|
||||
import { budgetService } from "../services/budgets.js";
|
||||
import { accountRunCost, reconcileRunCosts } from "../services/run-cost-accounting.js";
|
||||
import { financeService } from "../services/finance.js";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { hoistModuleGraph } from "./helpers/hoist-module-graph.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
const databaseDescribe = support.supported ? describe : describe.skip;
|
||||
|
||||
databaseDescribe("cost accounting reliability (PostgreSQL)", () => {
|
||||
const services = hoistModuleGraph(() => {}, async () => {
|
||||
const [agentModule, companyModule] = await Promise.all([
|
||||
import("../services/agents.js"), import("../services/companies.js"),
|
||||
]);
|
||||
return { agentService: agentModule.agentService, companyService: companyModule.companyService };
|
||||
});
|
||||
let db: ReturnType<typeof createDb>;
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
beforeAll(async () => {
|
||||
database = await startEmbeddedPostgresTestDatabase("paperclip-cost-reliability-");
|
||||
db = createDb(database.connectionString);
|
||||
}, 30_000);
|
||||
afterAll(async () => { await database?.cleanup(); });
|
||||
|
||||
async function fixture() {
|
||||
const [company] = await db.insert(companies).values({ name: "Accounting", issuePrefix: `T${randomUUID().slice(0, 7)}` }).returning();
|
||||
const [agent] = await db.insert(agents).values({ companyId: company.id, name: "Worker", role: "engineer", status: "active", adapterType: "codex_local" }).returning();
|
||||
const [project] = await db.insert(projects).values({ companyId: company.id, name: "Project", status: "in_progress" }).returning();
|
||||
const [issue] = await db.insert(issues).values({ companyId: company.id, projectId: project.id, title: "Work" }).returning();
|
||||
const [goal] = await db.insert(goals).values({ companyId: company.id, title: "Goal" }).returning();
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId: company.id, agentId: agent.id, invocationSource: "on_demand" }).returning();
|
||||
const receipt = { agentId: agent.id, provider: "test", model: "test", billingType: "metered_api", costCents: 100, occurredAt: new Date() };
|
||||
return { company, agent, project, issue, goal, run, receipt };
|
||||
}
|
||||
|
||||
it.each(["issueId", "projectId", "goalId", "heartbeatRunId"] as const)("rejects a foreign-company %s without writing anything", async (field) => {
|
||||
const a = await fixture(); const b = await fixture();
|
||||
const foreignIds = { issueId: b.issue.id, projectId: b.project.id, goalId: b.goal.id, heartbeatRunId: b.run.id };
|
||||
await expect(costService(db).createEvent(a.company.id, { ...a.receipt, [field]: foreignIds[field] })).rejects.toThrow(/company/);
|
||||
expect(await db.select().from(costEvents).where(eq(costEvents.companyId, a.company.id))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("allows admission inside a caller transaction holding a company foreign-key lock", async () => {
|
||||
const f = await fixture();
|
||||
// The recovery service dispatches through another connection while its
|
||||
// transaction still owns FK KEY SHARE locks on the company. Accounting
|
||||
// must serialize without requesting the conflicting FOR UPDATE mode.
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(issues).values({ companyId: f.company.id, title: "Concurrent recovery" });
|
||||
expect(await budgetService(db).getInvocationBlock(f.company.id, f.agent.id)).toBeNull();
|
||||
});
|
||||
}, 5000);
|
||||
|
||||
it("deduplicates concurrent retries and rejects changed receipt content", async () => {
|
||||
const f = await fixture();
|
||||
const receipt = { ...f.receipt, idempotencyKey: "provider-receipt-1" };
|
||||
const events = await Promise.all(Array.from({ length: 8 }, () => costService(db).createEvent(f.company.id, receipt)));
|
||||
expect(new Set(events.map((event) => event.id)).size).toBe(1);
|
||||
expect((await costService(db).summary(f.company.id)).spendCents).toBe(100);
|
||||
await expect(costService(db).createEvent(f.company.id, { ...receipt, costCents: 200 })).rejects.toThrow(/different|conflict/i);
|
||||
// Keys are scoped to the reporting company.
|
||||
const other = await fixture();
|
||||
await expect(costService(db).createEvent(other.company.id, { ...other.receipt, idempotencyKey: receipt.idempotencyKey })).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
it("retains sub-cent spend and enforces the sum, not rounded individual receipts", async () => {
|
||||
const f = await fixture(); const budgets = budgetService(db);
|
||||
await budgets.upsertPolicy(f.company.id, { scopeType: "agent", scopeId: f.agent.id, amount: 1 }, "board");
|
||||
for (let i = 0; i < 3; i++) await costService(db).createEvent(f.company.id, { ...f.receipt, costCents: 0.4 });
|
||||
expect((await costService(db).summary(f.company.id)).spendCents).toBe(1.2);
|
||||
expect(await budgets.getInvocationBlock(f.company.id, f.agent.id)).toMatchObject({ scopeType: "agent" });
|
||||
});
|
||||
|
||||
it("conserves project totals when a run touches multiple projects and includes unallocated spend", async () => {
|
||||
const f = await fixture();
|
||||
const [otherProject] = await db.insert(projects).values({ companyId: f.company.id, name: "Other" }).returning();
|
||||
const [otherIssue] = await db.insert(issues).values({ companyId: f.company.id, projectId: otherProject.id, title: "Other" }).returning();
|
||||
await db.insert(activityLog).values([f.issue, otherIssue].map((issue) => ({ companyId: f.company.id, actorType: "agent", actorId: f.agent.id, action: "issue.updated", entityType: "issue", entityId: issue.id, runId: f.run.id })));
|
||||
await costService(db).createEvent(f.company.id, { ...f.receipt, heartbeatRunId: f.run.id, projectId: f.project.id });
|
||||
await costService(db).createEvent(f.company.id, { ...f.receipt, costCents: 30 });
|
||||
const rows = await costService(db).byProject(f.company.id);
|
||||
expect(rows.reduce((sum, row) => sum + row.costCents, 0)).toBe(130);
|
||||
expect(rows.find((row) => row.projectId === f.project.id)?.costCents).toBe(100);
|
||||
expect(rows.find((row) => row.projectId === null)?.costCents).toBe(30);
|
||||
});
|
||||
|
||||
it("does not enforce inactive policies, and checks both monthly and lifetime limits", async () => {
|
||||
const f = await fixture(); const budgets = budgetService(db);
|
||||
await costService(db).createEvent(f.company.id, f.receipt);
|
||||
await budgets.upsertPolicy(f.company.id, { scopeType: "agent", scopeId: f.agent.id, amount: 1, isActive: false }, "board");
|
||||
expect(await budgets.getInvocationBlock(f.company.id, f.agent.id)).toBeNull();
|
||||
await budgets.upsertPolicy(f.company.id, { scopeType: "agent", scopeId: f.agent.id, amount: 1000, windowKind: "calendar_month_utc" }, "board");
|
||||
await budgets.upsertPolicy(f.company.id, { scopeType: "agent", scopeId: f.agent.id, amount: 50, windowKind: "lifetime" }, "board");
|
||||
await budgets.upsertPolicy(f.company.id, { scopeType: "agent", scopeId: f.agent.id, amount: 2000, windowKind: "calendar_month_utc" }, "board");
|
||||
expect(await budgets.getInvocationBlock(f.company.id, f.agent.id)).toMatchObject({ scopeType: "agent" });
|
||||
});
|
||||
|
||||
it("creates one approval under concurrent evaluation and opens a new incident after a raised budget is reached", async () => {
|
||||
const f = await fixture(); const budgets = budgetService(db);
|
||||
await budgets.upsertPolicy(f.company.id, { scopeType: "agent", scopeId: f.agent.id, amount: 100 }, "board");
|
||||
const event = await costService(db).createEvent(f.company.id, f.receipt);
|
||||
await Promise.all(Array.from({ length: 8 }, () => budgets.evaluateCostEvent(event)));
|
||||
expect(await db.select().from(approvals).where(eq(approvals.companyId, f.company.id))).toHaveLength(1);
|
||||
await budgets.upsertPolicy(f.company.id, { scopeType: "agent", scopeId: f.agent.id, amount: 150 }, "board");
|
||||
await costService(db).createEvent(f.company.id, f.receipt);
|
||||
expect(await db.select().from(budgetIncidents).where(and(eq(budgetIncidents.companyId, f.company.id), eq(budgetIncidents.thresholdType, "hard"), eq(budgetIncidents.status, "open")))).toHaveLength(1);
|
||||
expect(await db.select().from(approvals).where(eq(approvals.companyId, f.company.id))).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("commits accounting despite cancellation delivery failure and retries delivery safely", async () => {
|
||||
const f = await fixture();
|
||||
await budgetService(db).upsertPolicy(f.company.id, { scopeType: "agent", scopeId: f.agent.id, amount: 100 }, "board");
|
||||
const cancel = vi.fn().mockRejectedValueOnce(new Error("injected cancellation transport failure")).mockResolvedValue(undefined);
|
||||
const costs = costService(db, { cancelWorkForScope: cancel });
|
||||
const receipt = { ...f.receipt, idempotencyKey: "retry-after-cancel" };
|
||||
await expect(costs.createEvent(f.company.id, receipt)).resolves.toBeTruthy();
|
||||
await costs.createEvent(f.company.id, receipt);
|
||||
expect(cancel).toHaveBeenCalledTimes(2);
|
||||
expect((await costs.summary(f.company.id)).spendCents).toBe(100);
|
||||
expect(await budgetService(db).getInvocationBlock(f.company.id, f.agent.id)).toMatchObject({ scopeType: "agent" });
|
||||
});
|
||||
|
||||
it("recovers terminal run accounting exactly once across simultaneous live and recovery workers", async () => {
|
||||
const f = await fixture();
|
||||
await db.update(heartbeatRuns).set({ status: "timed_out", finishedAt: new Date(), costAccountingPending: true,
|
||||
usageJson: { inputTokens: 20, cachedInputTokens: 100, outputTokens: 10, costUsd: 0.004, provider: "test", model: "test", billingType: "metered_api", ledgerScope: { issueId: f.issue.id, projectId: f.project.id } },
|
||||
}).where(eq(heartbeatRuns.id, f.run.id));
|
||||
await Promise.all([accountRunCost(db, f.run.id), accountRunCost(db, f.run.id), reconcileRunCosts(db)]);
|
||||
const [totals] = await db.select().from(agentRuntimeState).where(eq(agentRuntimeState.agentId, f.agent.id));
|
||||
expect(totals).toMatchObject({ totalInputTokens: 20, totalCachedInputTokens: 100, totalOutputTokens: 10, totalCostCents: 0.4 });
|
||||
const events = await db.select().from(costEvents).where(eq(costEvents.heartbeatRunId, f.run.id));
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toMatchObject({ costCents: 0.4, projectId: f.project.id });
|
||||
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.run.id));
|
||||
expect(run.costAccountingPending).toBe(false);
|
||||
expect(run.costAccountedAt).toBeInstanceOf(Date);
|
||||
// Late terminal metadata may re-arm the pending flag. The durable
|
||||
// acknowledgement, not the delivery flag, owns exactly-once totals.
|
||||
await db.update(heartbeatRuns).set({ costAccountingPending: true }).where(eq(heartbeatRuns.id, f.run.id));
|
||||
await accountRunCost(db, f.run.id);
|
||||
expect((await db.select().from(agentRuntimeState).where(eq(agentRuntimeState.agentId, f.agent.id)))[0].totalCostCents).toBe(0.4);
|
||||
});
|
||||
|
||||
it("rolls back the ledger and counters on accounting failure, retaining a retryable run", async () => {
|
||||
const f = await fixture(); const other = await fixture();
|
||||
await db.update(heartbeatRuns).set({ status: "failed", costAccountingPending: true, usageJson: {
|
||||
inputTokens: 10, costUsd: 1, provider: "test", model: "test", ledgerScope: { projectId: other.project.id },
|
||||
} }).where(eq(heartbeatRuns.id, f.run.id));
|
||||
await expect(accountRunCost(db, f.run.id)).rejects.toThrow(/company/);
|
||||
expect(await db.select().from(costEvents).where(eq(costEvents.companyId, f.company.id))).toHaveLength(0);
|
||||
expect(await db.select().from(agentRuntimeState).where(eq(agentRuntimeState.agentId, f.agent.id))).toHaveLength(0);
|
||||
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.run.id)))[0].costAccountingPending).toBe(true);
|
||||
});
|
||||
|
||||
it("rolls back a receipt, counters, and budget incidents if a later runtime write fails", async () => {
|
||||
const f = await fixture();
|
||||
await budgetService(db).upsertPolicy(f.company.id, { scopeType: "agent", scopeId: f.agent.id, amount: 50 }, "board");
|
||||
await db.update(heartbeatRuns).set({ status: "failed", finishedAt: new Date(), costAccountingPending: true, usageJson: {
|
||||
inputTokens: 10, costUsd: 1, provider: "test", billingType: "metered_api", model: "test",
|
||||
} }).where(eq(heartbeatRuns.id, f.run.id));
|
||||
await db.execute(sql`create function reject_runtime_accounting() returns trigger language plpgsql as $$ begin raise exception 'injected runtime write failure'; end $$`);
|
||||
await db.execute(sql`create trigger reject_runtime_accounting before insert on agent_runtime_state for each row execute function reject_runtime_accounting()`);
|
||||
try {
|
||||
await expect(accountRunCost(db, f.run.id)).rejects.toThrow();
|
||||
expect(await db.select().from(costEvents).where(eq(costEvents.companyId, f.company.id))).toHaveLength(0);
|
||||
expect(await db.select().from(budgetIncidents).where(eq(budgetIncidents.companyId, f.company.id))).toHaveLength(0);
|
||||
expect(await db.select().from(approvals).where(eq(approvals.companyId, f.company.id))).toHaveLength(0);
|
||||
expect((await db.select().from(agents).where(eq(agents.id, f.agent.id)))[0]).toMatchObject({ spentMonthlyCents: 0, status: "active" });
|
||||
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.run.id)))[0]).toMatchObject({ costAccountingPending: true, costAccountedAt: null });
|
||||
} finally {
|
||||
await db.execute(sql`drop trigger reject_runtime_accounting on agent_runtime_state`);
|
||||
await db.execute(sql`drop function reject_runtime_accounting()`);
|
||||
}
|
||||
await accountRunCost(db, f.run.id);
|
||||
expect((await costService(db).summary(f.company.id)).spendCents).toBe(100);
|
||||
});
|
||||
|
||||
it("releases an expired monthly budget pause while preserving manual pauses", async () => {
|
||||
const f = await fixture(); const budgets = budgetService(db);
|
||||
await budgets.upsertPolicy(f.company.id, { scopeType: "agent", scopeId: f.agent.id, amount: 50 }, "board");
|
||||
const event = await costService(db).createEvent(f.company.id, f.receipt);
|
||||
const now = new Date();
|
||||
await db.update(costEvents).set({ occurredAt: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 0)) }).where(eq(costEvents.id, event.id));
|
||||
expect(await budgets.getInvocationBlock(f.company.id, f.agent.id)).toBeNull();
|
||||
expect((await db.select().from(agents).where(eq(agents.id, f.agent.id)))[0]).toMatchObject({ status: "idle", pauseReason: null });
|
||||
await db.update(agents).set({ status: "paused", pauseReason: "manual" }).where(eq(agents.id, f.agent.id));
|
||||
await budgets.upsertPolicy(f.company.id, { scopeType: "agent", scopeId: f.agent.id, amount: 200 }, "board");
|
||||
expect(await budgets.getInvocationBlock(f.company.id, f.agent.id)).toBeNull();
|
||||
expect((await db.select().from(agents).where(eq(agents.id, f.agent.id)))[0]).toMatchObject({ status: "paused", pauseReason: "manual" });
|
||||
});
|
||||
|
||||
it("uses live observed spend for incident resolution and synchronizes company budgets", async () => {
|
||||
const f = await fixture(); const budgets = budgetService(db);
|
||||
await budgets.upsertPolicy(f.company.id, { scopeType: "company", scopeId: f.company.id, amount: 50 }, "board");
|
||||
await costService(db).createEvent(f.company.id, f.receipt);
|
||||
const [incident] = await db.select().from(budgetIncidents).where(and(eq(budgetIncidents.companyId, f.company.id), eq(budgetIncidents.thresholdType, "hard")));
|
||||
await costService(db).createEvent(f.company.id, { ...f.receipt, costCents: 25 });
|
||||
await expect(budgets.resolveIncident(f.company.id, incident.id, { action: "raise_budget_and_resume", amount: 125 }, "board")).rejects.toThrow("New budget must exceed current observed spend");
|
||||
await budgets.resolveIncident(f.company.id, incident.id, { action: "raise_budget_and_resume", amount: 175 }, "board");
|
||||
expect((await db.select().from(companies).where(eq(companies.id, f.company.id)))[0]).toMatchObject({ budgetMonthlyCents: 175, status: "active", pauseReason: null });
|
||||
});
|
||||
|
||||
it("exposes missing prices and blocks strict budgets until explicitly allowed", async () => {
|
||||
const f = await fixture(); const budgets = budgetService(db);
|
||||
await budgets.upsertPolicy(f.company.id, { scopeType: "agent", scopeId: f.agent.id, amount: 100 }, "board");
|
||||
await costService(db).createEvent(f.company.id, { ...f.receipt, inputTokens: 500, costCents: 0, costStatus: "unpriced" });
|
||||
expect(await costService(db).summary(f.company.id)).toMatchObject({ spendCents: 0, unpricedEventCount: 1, pricingComplete: false });
|
||||
expect(await budgets.getInvocationBlock(f.company.id, f.agent.id)).toMatchObject({ reason: "Agent cannot start work because recorded usage has no reliable price." });
|
||||
await budgets.upsertPolicy(f.company.id, { scopeType: "agent", scopeId: f.agent.id, amount: 100, unpricedUsagePolicy: "allow" }, "board");
|
||||
expect(await budgets.getInvocationBlock(f.company.id, f.agent.id)).toBeNull();
|
||||
expect((await budgets.overview(f.company.id)).policies[0]).toMatchObject({ unpricedEventCount: 1, unpricedUsagePolicy: "allow" });
|
||||
});
|
||||
|
||||
it("keeps subscription-included usage outside monetary pricing gaps", async () => {
|
||||
const f = await fixture(); const budgets = budgetService(db);
|
||||
await budgets.upsertPolicy(f.company.id, { scopeType: "agent", scopeId: f.agent.id, amount: 100 }, "board");
|
||||
await costService(db).createEvent(f.company.id, { ...f.receipt, inputTokens: 500, costCents: 0, costStatus: "unpriced", billingType: "subscription_included" });
|
||||
expect(await budgets.getInvocationBlock(f.company.id, f.agent.id)).toBeNull();
|
||||
expect(await costService(db).summary(f.company.id)).toMatchObject({ pricingComplete: true });
|
||||
});
|
||||
|
||||
it("defaults reports to the UTC month and excludes future events from rolling spend", async () => {
|
||||
const f = await fixture(); const costs = costService(db); const now = new Date();
|
||||
await costs.createEvent(f.company.id, { ...f.receipt, occurredAt: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1)) });
|
||||
await costs.createEvent(f.company.id, { ...f.receipt, occurredAt: new Date(now.getTime() + 86_400_000) });
|
||||
expect((await costs.summary(f.company.id)).spendCents).toBe(0);
|
||||
expect((await costs.summary(f.company.id, { allTime: true })).spendCents).toBe(200);
|
||||
expect(await costs.windowSpend(f.company.id)).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps finance currencies separate and deduplicates invoice-line retries", async () => {
|
||||
const f = await fixture(); const finance = financeService(db);
|
||||
const usd = { biller: "vendor", eventKind: "platform_fee", amountCents: 100, currency: "usd", occurredAt: new Date(), idempotencyKey: "invoice:line:1", metadataJson: { a: 1, b: 2 } };
|
||||
await Promise.all(Array.from({ length: 6 }, () => finance.createEvent(f.company.id, usd)));
|
||||
await finance.createEvent(f.company.id, { ...usd, metadataJson: { b: 2, a: 1 } });
|
||||
await finance.createEvent(f.company.id, { ...usd, currency: "EUR", idempotencyKey: "invoice:line:2" });
|
||||
const summary = await finance.summary(f.company.id);
|
||||
expect(summary).toMatchObject({ currency: "USD", debitCents: 100, netCents: 100 });
|
||||
expect(summary.currencies).toEqual(expect.arrayContaining([expect.objectContaining({ currency: "USD", netCents: 100 }), expect.objectContaining({ currency: "EUR", netCents: 100 })]));
|
||||
expect(await finance.byBiller(f.company.id)).toHaveLength(2);
|
||||
expect(await finance.byKind(f.company.id)).toHaveLength(2);
|
||||
expect(await db.select().from(financeEvents).where(eq(financeEvents.companyId, f.company.id))).toHaveLength(2);
|
||||
await expect(finance.createEvent(f.company.id, { ...usd, amountCents: 200 })).rejects.toThrow(/different/);
|
||||
});
|
||||
|
||||
it("synchronizes budgets through generic agent and company updates", async () => {
|
||||
const f = await fixture();
|
||||
const { agentService, companyService } = services.value;
|
||||
await agentService(db).update(f.agent.id, { budgetMonthlyCents: 50 });
|
||||
await companyService(db).update(f.company.id, { budgetMonthlyCents: 75 });
|
||||
await costService(db).createEvent(f.company.id, f.receipt);
|
||||
const policies = await budgetService(db).listPolicies(f.company.id);
|
||||
expect(policies).toEqual(expect.arrayContaining([expect.objectContaining({ scopeType: "agent", amount: 50 }), expect.objectContaining({ scopeType: "company", amount: 75 })]));
|
||||
expect(await budgetService(db).getInvocationBlock(f.company.id, f.agent.id)).toMatchObject({ scopeType: "company" });
|
||||
});
|
||||
|
||||
it("conserves both spend and tokens across complete per-model receipts", async () => {
|
||||
const f = await fixture();
|
||||
await db.update(heartbeatRuns).set({ status: "succeeded", finishedAt: new Date(), costAccountingPending: true, usageJson: {
|
||||
provider: "anthropic", billingType: "metered_api", model: "mixed", costUsd: 0.007,
|
||||
inputTokens: 30, outputTokens: 4, cachedInputTokens: 100,
|
||||
usageByModel: [
|
||||
{ model: "large", costUsd: 0.005, usage: { inputTokens: 20, outputTokens: 3, cachedInputTokens: 100 } },
|
||||
{ model: "small", costUsd: 0.002, usage: { inputTokens: 10, outputTokens: 1 } },
|
||||
],
|
||||
} }).where(eq(heartbeatRuns.id, f.run.id));
|
||||
await accountRunCost(db, f.run.id);
|
||||
const rows = await costService(db).byProvider(f.company.id);
|
||||
expect(rows).toEqual(expect.arrayContaining([expect.objectContaining({ model: "large", costCents: 0.5, inputTokens: 20 }), expect.objectContaining({ model: "small", costCents: 0.2, inputTokens: 10 })]));
|
||||
expect((await costService(db).summary(f.company.id)).spendCents).toBe(0.7);
|
||||
const [totals] = await db.select().from(agentRuntimeState).where(eq(agentRuntimeState.agentId, f.agent.id));
|
||||
expect(totals).toMatchObject({ totalInputTokens: 30, totalCachedInputTokens: 100, totalOutputTokens: 4, totalCostCents: 0.7 });
|
||||
});
|
||||
it("blocks admission while a terminal run awaits accounting, even under an allow-unpriced policy", async () => {
|
||||
const f = await fixture(); const budgets = budgetService(db);
|
||||
await budgets.upsertPolicy(f.company.id, { scopeType: "agent", scopeId: f.agent.id, amount: 1000, unpricedUsagePolicy: "allow" }, "board");
|
||||
await db.update(heartbeatRuns).set({ status: "failed", costAccountingPending: true }).where(eq(heartbeatRuns.id, f.run.id));
|
||||
expect(await budgets.getInvocationBlock(f.company.id, f.agent.id)).toMatchObject({ reason: expect.stringContaining("await accounting") });
|
||||
expect(await costService(db).summary(f.company.id)).toMatchObject({ pricingComplete: false, pendingRunCount: 1 });
|
||||
await accountRunCost(db, f.run.id);
|
||||
expect(await costService(db).summary(f.company.id)).toMatchObject({ pricingComplete: false, pendingRunCount: 0, unpricedEventCount: 1 });
|
||||
expect(await budgets.getInvocationBlock(f.company.id, f.agent.id)).toBeNull();
|
||||
});
|
||||
|
||||
it("waits for a late provider receipt after cancellation instead of acknowledging a zero charge", async () => {
|
||||
const f = await fixture();
|
||||
await db.update(heartbeatRuns).set({ status: "cancelled", costAccountingPending: true, usageJson: { accountingReceiptReady: false } }).where(eq(heartbeatRuns.id, f.run.id));
|
||||
expect(await accountRunCost(db, f.run.id)).toBe(false);
|
||||
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.run.id)))[0]).toMatchObject({ costAccountingPending: true, costAccountedAt: null });
|
||||
expect(await db.select().from(costEvents).where(eq(costEvents.heartbeatRunId, f.run.id))).toHaveLength(0);
|
||||
await db.update(heartbeatRuns).set({ usageJson: { accountingReceiptReady: true, costUsd: 0.25, inputTokens: 10 } }).where(eq(heartbeatRuns.id, f.run.id));
|
||||
await Promise.all([accountRunCost(db, f.run.id), accountRunCost(db, f.run.id)]);
|
||||
expect((await db.select().from(costEvents).where(eq(costEvents.heartbeatRunId, f.run.id)))[0]).toMatchObject({ costCents: 25, inputTokens: 10 });
|
||||
expect((await db.select().from(agentRuntimeState).where(eq(agentRuntimeState.agentId, f.agent.id)))[0].totalCostCents).toBe(25);
|
||||
});
|
||||
|
||||
it("acknowledges proven pre-provider failures without inventing a charge", async () => {
|
||||
const f = await fixture();
|
||||
await db.update(heartbeatRuns).set({ status: "failed", costAccountingPending: true,
|
||||
resultJson: { executionRecovery: { providerWorkStarted: false } },
|
||||
}).where(eq(heartbeatRuns.id, f.run.id));
|
||||
await accountRunCost(db, f.run.id);
|
||||
expect(await db.select().from(costEvents).where(eq(costEvents.companyId, f.company.id))).toHaveLength(0);
|
||||
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.run.id)))[0]).toMatchObject({ costAccountingPending: false, costAccountedAt: expect.any(Date) });
|
||||
});
|
||||
|
||||
it("records spend after attribution targets are deleted", async () => {
|
||||
const f = await fixture();
|
||||
await db.update(heartbeatRuns).set({ status: "failed", costAccountingPending: true, usageJson: {
|
||||
costUsd: 0.01, ledgerScope: { projectId: f.project.id, issueId: f.issue.id },
|
||||
} }).where(eq(heartbeatRuns.id, f.run.id));
|
||||
await db.delete(issues).where(eq(issues.id, f.issue.id));
|
||||
await db.delete(projects).where(eq(projects.id, f.project.id));
|
||||
await accountRunCost(db, f.run.id);
|
||||
expect((await db.select().from(costEvents).where(eq(costEvents.companyId, f.company.id)))[0]).toMatchObject({ costCents: 1, issueId: null, projectId: null });
|
||||
});
|
||||
|
||||
it("preserves dismissed incidents and warning incidents during admission checks", async () => {
|
||||
const f = await fixture(); const budgets = budgetService(db);
|
||||
await budgets.upsertPolicy(f.company.id, { scopeType: "agent", scopeId: f.agent.id, amount: 100 }, "board");
|
||||
await costService(db).createEvent(f.company.id, { ...f.receipt, costCents: 80 });
|
||||
for (let i = 0; i < 3; i++) expect(await budgets.getInvocationBlock(f.company.id, f.agent.id)).toBeNull();
|
||||
expect((await budgets.overview(f.company.id)).activeIncidents).toHaveLength(1);
|
||||
await costService(db).createEvent(f.company.id, { ...f.receipt, costCents: 20 });
|
||||
const [incident] = await db.select().from(budgetIncidents).where(and(eq(budgetIncidents.companyId, f.company.id), eq(budgetIncidents.thresholdType, "hard")));
|
||||
await budgets.resolveIncident(f.company.id, incident.id, { action: "keep_paused" }, "board");
|
||||
for (let i = 0; i < 3; i++) expect(await budgets.getInvocationBlock(f.company.id, f.agent.id)).toBeTruthy();
|
||||
expect(await db.select().from(approvals).where(eq(approvals.companyId, f.company.id))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not revive terminated agents or archived companies during budget reconciliation", async () => {
|
||||
const f = await fixture(); const budgets = budgetService(db);
|
||||
await db.update(agents).set({ status: "terminated", pauseReason: "budget" }).where(eq(agents.id, f.agent.id));
|
||||
await db.update(companies).set({ status: "archived", pauseReason: "budget" }).where(eq(companies.id, f.company.id));
|
||||
for (const [scopeType, scopeId] of [["agent", f.agent.id], ["company", f.company.id]] as const) {
|
||||
await budgets.upsertPolicy(f.company.id, { scopeType, scopeId, amount: 1000 }, "board");
|
||||
}
|
||||
await costService(db).createEvent(f.company.id, { ...f.receipt, costCents: 2000 });
|
||||
expect((await db.select().from(agents).where(eq(agents.id, f.agent.id)))[0].status).toBe("terminated");
|
||||
expect((await db.select().from(companies).where(eq(companies.id, f.company.id)))[0].status).toBe("archived");
|
||||
});
|
||||
|
||||
it.each(["agent", "company", "project"] as const)("limits delayed %s budget cancellation to work that preceded the policy check", async (scopeType) => {
|
||||
const f = await fixture();
|
||||
const { heartbeatService } = await import("../services/heartbeat.js");
|
||||
const heartbeat = heartbeatService(db);
|
||||
const scopeId = scopeType === "agent" ? f.agent.id : scopeType === "company" ? f.company.id : f.project.id;
|
||||
const cutoff = new Date();
|
||||
await db.update(heartbeatRuns).set({ status: "queued", createdAt: new Date(cutoff.getTime() - 1000), contextSnapshot: { projectId: f.project.id } }).where(eq(heartbeatRuns.id, f.run.id));
|
||||
const [newRun] = await db.insert(heartbeatRuns).values({ companyId: f.company.id, agentId: f.agent.id, invocationSource: "on_demand", status: "scheduled_retry",
|
||||
scheduledRetryAt: new Date(cutoff.getTime() + 60000), createdAt: new Date(cutoff.getTime() + 1000), contextSnapshot: { projectId: f.project.id },
|
||||
}).returning();
|
||||
await heartbeat.cancelBudgetScopeWork({ companyId: f.company.id, scopeType, scopeId, createdBefore: cutoff });
|
||||
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.run.id)))[0].status).toBe("cancelled");
|
||||
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, newRun.id)))[0].status).toBe("scheduled_retry");
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { agents, companies, createDb } from "@paperclipai/db";
|
||||
import { costRoutes } from "../routes/costs.js";
|
||||
import { errorHandler } from "../middleware/error-handler.js";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
(support.supported ? describe : describe.skip)("accounting operator authorization", () => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
let companyId: string, foreignId: string, agentId: string;
|
||||
beforeAll(async () => {
|
||||
database = await startEmbeddedPostgresTestDatabase("accounting-authz-"); db = createDb(database.connectionString);
|
||||
const rows = await db.insert(companies).values([{ name: "A", issuePrefix: "AUTH_A" }, { name: "B", issuePrefix: "AUTH_B" }]).returning();
|
||||
companyId = rows[0].id; foreignId = rows[1].id;
|
||||
agentId = (await db.insert(agents).values({ companyId, name: "Worker", role: "engineer", adapterType: "process" }).returning())[0].id;
|
||||
},30_000);
|
||||
afterAll(async () => { await database?.cleanup(); });
|
||||
function app(actor: Record<string, unknown>) {
|
||||
const app = express(); app.use(express.json()); app.use((req,_res,next) => { req.actor = actor as typeof req.actor; next(); });
|
||||
app.use("/api", costRoutes(db)); app.use(errorHandler); return app;
|
||||
}
|
||||
const operations = [
|
||||
["get", "health", undefined], ["get", "inspect", undefined], ["get", "invoices", undefined],
|
||||
["get", `invoices/${randomUUID()}`, undefined], ["get", `events/${randomUUID()}/adjustments`, undefined],
|
||||
["post", "repair", { fingerprint: "a".repeat(64), reason: "test" }],
|
||||
["post", "provider-costs/import", {}], ["post", "retry", { runId: randomUUID() }], ["post", "invoices", {}], ["post", `events/${randomUUID()}/adjustments`, {}],
|
||||
] as const;
|
||||
it.each(operations)("denies agents for %s %s before reading or validating operator payloads", async (method, route, body) => {
|
||||
const response = await request(app({ type: "agent", companyId, agentId }))[method](`/api/companies/${companyId}/accounting/${route}`).send(body);
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
it.each(operations)("denies nonmembers for %s %s", async (method, route, body) => {
|
||||
const response = await request(app({ type: "board", source: "session", userId: "operator", companyIds: [foreignId] }))[method](`/api/companies/${companyId}/accounting/${route}`).send(body);
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
it("allows operator inspection and rejects viewer mutations", async () => {
|
||||
const board = { type: "board", source: "session", userId: "operator", companyIds: [companyId], memberships: [{ companyId, status: "active", membershipRole: "viewer" }] };
|
||||
const response = await request(app(board)).get(`/api/companies/${companyId}/accounting/inspect`);
|
||||
expect(response.status).toBe(200); expect(response.body.findings).toEqual([]);
|
||||
expect((await request(app(board)).post(`/api/companies/${companyId}/accounting/repair`).send({ fingerprint: response.body.fingerprint, reason: "test" })).status).toBe(403);
|
||||
});
|
||||
it("validates correction and repair payloads before mutating", async () => {
|
||||
const board = app({ type: "board", source: "local_implicit", userId: "operator" });
|
||||
expect((await request(board).post(`/api/companies/${companyId}/accounting/repair`).send({ fingerprint: "old", reason: "" })).status).toBe(400);
|
||||
expect((await request(board).post(`/api/companies/${companyId}/accounting/events/${randomUUID()}/adjustments`).send({ idempotencyKey: "x", expectedCents: "0", correctedCents: "-0.000000001", reason: "Invalid", pricing: { source: "operator" } })).status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -221,6 +221,21 @@ describe("cost routes", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("requires an explicit all-time request and rejects conflicting ranges", () => {
|
||||
const { parseCostDateRange } = loadCostParsers();
|
||||
expect(parseCostDateRange({})).toBeUndefined();
|
||||
expect(parseCostDateRange({ period: "all" })).toEqual({ allTime: true });
|
||||
for (const input of [
|
||||
{ from: "2026-09-02", to: "2026-09-01" },
|
||||
{ from: ["2026-09-01"] },
|
||||
{ from: "2026-02-30" },
|
||||
{ from: "" },
|
||||
{ from: "09/01/2026" },
|
||||
{ period: "all", from: "2026-09-01" },
|
||||
{ period: "unknown" },
|
||||
]) expect(() => parseCostDateRange(input)).toThrow();
|
||||
});
|
||||
|
||||
it("returns 400 for an invalid 'from' date string", async () => {
|
||||
const { parseCostDateRange } = loadCostParsers();
|
||||
expect(() => parseCostDateRange({ from: "not-a-date" })).toThrow(/invalid 'from' date/i);
|
||||
@@ -270,7 +285,7 @@ describe("cost routes", () => {
|
||||
|
||||
it("returns 400 for invalid finance event list limits", async () => {
|
||||
const { parseCostLimit } = loadCostParsers();
|
||||
expect(() => parseCostLimit({ limit: "0" })).toThrow(/invalid 'limit'/i);
|
||||
for (const limit of ["0", "25garbage", "1.5", ["25"], "501"]) expect(() => parseCostLimit({ limit })).toThrow(/invalid 'limit'/i);
|
||||
});
|
||||
|
||||
it("accepts valid finance event list limits", async () => {
|
||||
@@ -352,7 +367,8 @@ describe("cost routes", () => {
|
||||
});
|
||||
|
||||
it("allows authorized board users to update an agent budget and budget policy", async () => {
|
||||
mockAgentService.update.mockResolvedValueOnce({
|
||||
mockAgentService.getById.mockResolvedValueOnce({ id: "agent-1", companyId: "company-1", name: "Budget Agent", budgetMonthlyCents: 100, spentMonthlyCents: 0 });
|
||||
mockAgentService.getById.mockResolvedValueOnce({
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Budget Agent",
|
||||
@@ -373,7 +389,9 @@ describe("cost routes", () => {
|
||||
.send({ budgetMonthlyCents: 2500 });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockAgentService.update).toHaveBeenCalledWith("agent-1", { budgetMonthlyCents: 2500 });
|
||||
expect(res.body.budgetMonthlyCents).toBe(2500);
|
||||
expect(mockAgentService.update).not.toHaveBeenCalled();
|
||||
expect(mockBudgetService.upsertPolicy).toHaveBeenCalledTimes(1);
|
||||
expect(mockBudgetService.upsertPolicy).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
{
|
||||
@@ -384,19 +402,8 @@ describe("cost routes", () => {
|
||||
},
|
||||
"board-user",
|
||||
);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
companyId: "company-1",
|
||||
actorType: "user",
|
||||
actorId: "board-user",
|
||||
agentId: null,
|
||||
action: "agent.budget_updated",
|
||||
entityType: "agent",
|
||||
entityId: "agent-1",
|
||||
details: { budgetMonthlyCents: 2500 },
|
||||
}),
|
||||
);
|
||||
// Policy mutation owns the atomic entity update and activity log.
|
||||
expect(mockLogActivity).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -692,6 +699,7 @@ describeEmbeddedPostgres("cost and finance aggregate overflow handling", () => {
|
||||
issueCount: 3,
|
||||
includeDescendants: true,
|
||||
costCents: 600,
|
||||
costCentsExact: "600.0000000",
|
||||
inputTokens: 60,
|
||||
cachedInputTokens: 6,
|
||||
outputTokens: 12,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// Dedicated real-process fault fixture. Only invoked with a throwaway test DB.
|
||||
import { createDb } from "@paperclipai/db";
|
||||
import { costService } from "../../services/costs.js";
|
||||
import { createRunUsageRecorder } from "../../services/usage-receipts.js";
|
||||
const [mode, encoded] = process.argv.slice(2);
|
||||
const input = JSON.parse(encoded);
|
||||
if (!process.env.PAPERCLIP_ACCOUNTING_TEST_DATABASE) throw new Error("Explicit test database required");
|
||||
const db = createDb(process.env.PAPERCLIP_ACCOUNTING_TEST_DATABASE);
|
||||
try {
|
||||
if (mode === "receipt") {
|
||||
const recorder = await createRunUsageRecorder(db, { companyId: input.companyId, runId: input.runId, adapterType: "process" }, input.directory);
|
||||
// The receipt is fsynced before persistUsageReceipt enters a transaction.
|
||||
// Freeze at this boundary, so the parent can SIGKILL us before any DB row.
|
||||
db.transaction = async () => { process.stdout.write("RECEIPT_DURABLE\n"); return new Promise(() => {}); };
|
||||
await recorder.capture({ costUsdExact: "0.012345678", usage: { inputTokens: 7, cachedInputTokens: 11, outputTokens: 3 }, complete: true, billingType: "metered_api" });
|
||||
} else {
|
||||
await Promise.all(Array.from({ length: 4 }, () => costService(db).createEvent(input.companyId, { ...input.receipt, occurredAt: new Date(input.receipt.occurredAt) })));
|
||||
process.stdout.write("WRITES_COMMITTED\n");
|
||||
}
|
||||
} finally { await db.$client.end(); }
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
normalizeAdapterRunUsage,
|
||||
resolveCacheAdjustedCostUsd,
|
||||
resolveLedgerCostStatus,
|
||||
} from "../services/heartbeat.js";
|
||||
@@ -35,6 +36,13 @@ describe("heartbeat cost accounting", () => {
|
||||
})).toBe("unpriced");
|
||||
});
|
||||
|
||||
it("distinguishes missing receipts, reported zeroes, and included subscription usage", () => {
|
||||
const noTokens = { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 };
|
||||
expect(resolveLedgerCostStatus({ ...noTokens, costUsd: null })).toBe("unpriced");
|
||||
expect(resolveLedgerCostStatus({ ...noTokens, costUsd: 0 })).toBe("reported");
|
||||
expect(resolveLedgerCostStatus({ ...noTokens, costUsd: null, billingType: "subscription_included" })).toBe("reported");
|
||||
});
|
||||
|
||||
it("marks reported CLI cost as priced", () => {
|
||||
expect(resolveLedgerCostStatus({
|
||||
costUsd: 1.25,
|
||||
@@ -86,3 +94,17 @@ describe("heartbeat cost accounting", () => {
|
||||
})).toBe(1.5);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("adapter usage basis", () => {
|
||||
const prior = { inputTokens: 100, cachedInputTokens: 500, outputTokens: 20 };
|
||||
it.each([undefined, null, "per_run"] as const)("keeps equal consecutive run usage when the basis is %s", (basis) => {
|
||||
expect(normalizeAdapterRunUsage(prior, prior, basis)).toEqual(prior);
|
||||
});
|
||||
it("subtracts session totals only when explicitly declared", () => {
|
||||
expect(normalizeAdapterRunUsage({ inputTokens: 110, cachedInputTokens: 700, outputTokens: 25 }, prior, "session_cumulative"))
|
||||
.toEqual({ inputTokens: 10, cachedInputTokens: 200, outputTokens: 5 });
|
||||
expect(normalizeAdapterRunUsage({ inputTokens: 5, cachedInputTokens: 10, outputTokens: 2 }, prior, "session_cumulative"))
|
||||
.toEqual({ inputTokens: 5, cachedInputTokens: 10, outputTokens: 2 });
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { agents, companies, createDb, heartbeatRuns } from "@paperclipai/db";
|
||||
import { costEvents, agents, companies, createDb, heartbeatRuns } from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
@@ -26,6 +26,7 @@ describeEmbeddedPostgres("heartbeat list", () => {
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(costEvents);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
|
||||
@@ -31,7 +31,9 @@ import {
|
||||
agentWakeupRequests,
|
||||
approvals,
|
||||
authUsers,
|
||||
budgetIncidents,
|
||||
budgetPolicies,
|
||||
budgetReservations,
|
||||
chatActions,
|
||||
chatConversations,
|
||||
chatDeliveries,
|
||||
@@ -581,6 +583,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
await db.delete(issueTreeHolds);
|
||||
await db.delete(agentTaskSessions);
|
||||
await db.delete(issueApprovals);
|
||||
await db.delete(budgetIncidents);
|
||||
await db.delete(approvals);
|
||||
await db.update(issues).set({ lastStatusDecisionId: null });
|
||||
await db.delete(statusDecisionEffects);
|
||||
@@ -7550,6 +7553,23 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("releases the dispatch reservation after a proven pre-provider adapter failure", async () => {
|
||||
mockAdapterExecute.mockResolvedValueOnce({
|
||||
exitCode: 1, signal: null, timedOut: false,
|
||||
errorMessage: "Provider startup failed", provider: "test", model: "test-model",
|
||||
executionRecovery: { kind: "bootstrap", providerWorkStarted: false },
|
||||
});
|
||||
const { runId } = await seedRunFixture({ runtimeMode: "legacy", agentStatus: "idle", runStatus: "queued", includeIssue: false });
|
||||
const heartbeat = heartbeatService(db);
|
||||
await heartbeat.resumeQueuedRuns();
|
||||
await heartbeat.drainActiveRunExecutions();
|
||||
const run = await heartbeat.getRun(runId);
|
||||
expect(run).toMatchObject({ status: "failed", costAccountingPending: false });
|
||||
expect(run?.costAccountedAt).not.toBeNull();
|
||||
expect((await db.select().from(budgetReservations).where(eq(budgetReservations.runId, runId)))[0]).toMatchObject({ state: "released" });
|
||||
expect(await db.select().from(costEvents).where(eq(costEvents.heartbeatRunId, runId))).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(["single Stop", "agent pause"] as const)(
|
||||
"fences adapter registration while an earlier no-owner %s waits to commit",
|
||||
async (operation) => {
|
||||
@@ -7674,6 +7694,10 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
releaseAdapter();
|
||||
await heartbeat.drainActiveRunExecutions();
|
||||
const settledRun = await heartbeat.getRun(runId);
|
||||
expect(settledRun?.costAccountingPending).toBe(false);
|
||||
expect(settledRun?.costAccountedAt).not.toBeNull();
|
||||
expect((await db.select().from(budgetReservations).where(eq(budgetReservations.runId, runId)))[0]).toMatchObject({ state: "released" });
|
||||
expect(await db.select().from(costEvents).where(eq(costEvents.heartbeatRunId, runId))).toEqual([]);
|
||||
expect({
|
||||
status: settledRun?.status,
|
||||
errorCode: settledRun?.errorCode,
|
||||
@@ -11065,6 +11089,9 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
await waitForRunToSettle(heartbeat, sourceRun.id, 5_000);
|
||||
}
|
||||
expect(sourceRun?.id).not.toBe(runId);
|
||||
// Durable accounting is bookkeeping, not evidence that the agent acted
|
||||
// on its plan. A receipt must not suppress plan-only recovery.
|
||||
expect(await db.select().from(costEvents).where(eq(costEvents.heartbeatRunId, String(sourceRunId)))).toHaveLength(1);
|
||||
expect(sourceRun?.livenessState).toBe("plan_only");
|
||||
if (withLegacyReview) {
|
||||
expect(await db.select().from(issues).where(eq(issues.id, legacyReviewId))).toEqual(legacyReviewBefore);
|
||||
|
||||
@@ -742,6 +742,36 @@ describe("openapi routes", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("documents exact money, reviewed correction inputs, and board-only accounting access", () => {
|
||||
const { spec } = loadSpecRoutes();
|
||||
const base = "/api/companies/{companyId}/accounting/";
|
||||
for (const [suffix, methods] of [
|
||||
["health", ["get"]], ["inspect", ["get"]], ["repair", ["post"]], ["retry", ["post"]],
|
||||
["invoices", ["get", "post"]], ["invoices/{invoiceId}", ["get"]], ["events/{eventId}/adjustments", ["get", "post"]],
|
||||
] as const) {
|
||||
for (const method of methods) {
|
||||
const operation = spec.paths[`${base}${suffix}`][method];
|
||||
expect(operation["x-paperclip-authorization"]).toEqual({ actor: "board" });
|
||||
expect(operation.security).toEqual([{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }]);
|
||||
expect(operation.responses["403"]).toBeDefined();
|
||||
}
|
||||
}
|
||||
const adjustment = spec.paths[`${base}events/{eventId}/adjustments`].post;
|
||||
expect(adjustment.responses["201"]).toBeDefined();
|
||||
expect(adjustment.responses["409"]).toBeDefined();
|
||||
expect(adjustment.responses["422"]).toBeDefined();
|
||||
expect(adjustment.requestBody.content["application/json"].schema.required).toEqual(expect.arrayContaining([
|
||||
"idempotencyKey", "expectedCents", "correctedCents", "reason", "pricing",
|
||||
]));
|
||||
for (const [endpoint, field] of [["cost-events", "costCents"], ["finance-events", "amountCents"]]) {
|
||||
const operation = spec.paths[`/api/companies/{companyId}/${endpoint}`].post;
|
||||
expect(operation.requestBody.content["application/json"].schema.properties[field]).toMatchObject({ oneOf: [{ type: "string" }, { type: "number" }] });
|
||||
expect(operation.responses["409"]).toBeDefined();
|
||||
}
|
||||
const parameters = spec.paths["/api/companies/{companyId}/costs/summary"].get.parameters;
|
||||
expect(parameters).toEqual(expect.arrayContaining([expect.objectContaining({ name: "period", in: "query", schema: expect.objectContaining({ enum: ["month", "all"] }) })]));
|
||||
});
|
||||
|
||||
it("documents board-only repository discovery and selection", () => {
|
||||
const { spec } = loadSpecRoutes();
|
||||
const discovery = spec.paths["/api/companies/{companyId}/project-repositories"].get;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
agentInstructionHeads,
|
||||
agents,
|
||||
approvals,
|
||||
budgetPolicies,
|
||||
companies,
|
||||
createDb,
|
||||
pluginEntities,
|
||||
@@ -106,6 +107,7 @@ describeEmbeddedPostgres("plugin-managed agents", () => {
|
||||
await db.delete(pluginManagedResources);
|
||||
await db.delete(pluginCompanySettings);
|
||||
await db.delete(approvals);
|
||||
await db.delete(budgetPolicies);
|
||||
await db.delete(agents);
|
||||
await db.delete(plugins);
|
||||
await db.delete(companies);
|
||||
@@ -497,6 +499,7 @@ describeEmbeddedPostgres("plugin-managed agents", () => {
|
||||
const relinked = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
|
||||
expect(relinked.status).toBe("relinked");
|
||||
expect(relinked.agentId).toBe(agentId);
|
||||
expect(relinked.agent?.urlKey).toBe("renamed-wiki-agent");
|
||||
|
||||
const [binding] = await db.select().from(pluginEntities);
|
||||
expect(binding?.data).toMatchObject({ agentId });
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fetchProviderDailyCosts } from "../services/provider-billing-import.js";
|
||||
import type { ImportProviderCosts } from "@paperclipai/shared";
|
||||
|
||||
const input: ImportProviderCosts = {
|
||||
provider: "openai",
|
||||
secretId: "00000000-0000-4000-8000-000000000001",
|
||||
accountId: "org_test",
|
||||
scopeIds: ["proj_test"],
|
||||
from: "2026-09-01",
|
||||
to: "2026-09-02",
|
||||
};
|
||||
const start = Date.parse(input.from) / 1000;
|
||||
function page(value = 0.06) {
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
start_time: start,
|
||||
end_time: start + 86400,
|
||||
results: [
|
||||
{ project_id: "proj_test", amount: { currency: "usd", value } },
|
||||
],
|
||||
},
|
||||
],
|
||||
has_more: false,
|
||||
next_page: null,
|
||||
};
|
||||
}
|
||||
const response = (body: unknown) =>
|
||||
new Response(JSON.stringify(body), { status: 200 });
|
||||
describe("provider cost report ingestion", () => {
|
||||
it("uses fixed endpoints, admin identity and scoped daily amounts in the right currency units", async () => {
|
||||
const fetcher = vi.fn(async () => response(page()));
|
||||
expect(
|
||||
await fetchProviderDailyCosts(input, "private-admin", fetcher),
|
||||
).toEqual([
|
||||
{ day: "2026-09-01", scopeId: "proj_test", amountCents: "6.0000000" },
|
||||
]);
|
||||
const [url, options] = fetcher.mock.calls[0] as unknown as [
|
||||
URL,
|
||||
RequestInit,
|
||||
];
|
||||
expect(url.origin).toBe("https://api.openai.com");
|
||||
expect(url.searchParams.get("group_by[]")).toBe("project_id");
|
||||
expect(options.redirect).toBe("error");
|
||||
expect(options.headers).toMatchObject({
|
||||
"OpenAI-Organization": "org_test",
|
||||
});
|
||||
});
|
||||
it("verifies Anthropic organization identity and treats its amount as cents", async () => {
|
||||
const fetcher = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(response({ id: "org_test" }))
|
||||
.mockResolvedValueOnce(
|
||||
response({
|
||||
data: [
|
||||
{
|
||||
starting_at: "2026-09-01T00:00:00Z",
|
||||
ending_at: "2026-09-02T00:00:00Z",
|
||||
results: [
|
||||
{
|
||||
workspace_id: "proj_test",
|
||||
amount: "123.78912",
|
||||
currency: "USD",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
has_more: false,
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
(
|
||||
await fetchProviderDailyCosts(
|
||||
{ ...input, provider: "anthropic" },
|
||||
"private-admin",
|
||||
fetcher,
|
||||
)
|
||||
)[0].amountCents,
|
||||
).toBe("123.7891200");
|
||||
await expect(
|
||||
fetchProviderDailyCosts(
|
||||
{ ...input, provider: "anthropic" },
|
||||
"private-admin",
|
||||
vi.fn(async () => response({ id: "other" })),
|
||||
),
|
||||
).rejects.toThrow("different provider organization");
|
||||
});
|
||||
it("excludes unrelated projects and supports an explicit empty daily bucket", async () => {
|
||||
const body = page();
|
||||
body.data[0].results[0].project_id = "other-company-project";
|
||||
expect(
|
||||
(
|
||||
await fetchProviderDailyCosts(
|
||||
input,
|
||||
"private",
|
||||
vi.fn(async () => response(body)),
|
||||
)
|
||||
)[0].amountCents,
|
||||
).toBe("0.0000000");
|
||||
});
|
||||
it.each([
|
||||
"missing-day",
|
||||
"missing-group",
|
||||
"overlap",
|
||||
"currency",
|
||||
"negative",
|
||||
"period",
|
||||
"cursor",
|
||||
"shape",
|
||||
])(
|
||||
"rejects %s rather than importing partial or invented totals",
|
||||
async (kind) => {
|
||||
const body: any = page();
|
||||
if (kind === "missing-day") body.data = [];
|
||||
if (kind === "missing-group") delete body.data[0].results[0].project_id;
|
||||
if (kind === "overlap") body.data.push(body.data[0]);
|
||||
if (kind === "currency") body.data[0].results[0].amount.currency = "EUR";
|
||||
if (kind === "negative") body.data[0].results[0].amount.value = -1;
|
||||
if (kind === "period") body.data[0].end_time++;
|
||||
if (kind === "cursor") body.has_more = true;
|
||||
if (kind === "shape") body.data[0].results[0].amount = {};
|
||||
await expect(
|
||||
fetchProviderDailyCosts(
|
||||
input,
|
||||
"private",
|
||||
vi.fn(async () => response(body)),
|
||||
),
|
||||
).rejects.toThrow();
|
||||
},
|
||||
);
|
||||
it("does not expose upstream errors or credentials", async () => {
|
||||
await expect(
|
||||
fetchProviderDailyCosts(
|
||||
input,
|
||||
"private-secret",
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response("private-secret raw diagnostics", { status: 403 }),
|
||||
),
|
||||
),
|
||||
).rejects.toThrow("Check the admin credential");
|
||||
});
|
||||
it("bounds response size and does not follow redirects", async () => {
|
||||
await expect(
|
||||
fetchProviderDailyCosts(
|
||||
input,
|
||||
"private",
|
||||
vi.fn(async () => new Response("x".repeat(2_000_001))),
|
||||
),
|
||||
).rejects.toThrow("too large");
|
||||
});
|
||||
it("collects all pages before returning and rejects a repeated cursor", async () => {
|
||||
const first = { ...page(), has_more: true, next_page: "next" };
|
||||
const second = page(0.01);
|
||||
second.data[0].start_time += 86400;
|
||||
second.data[0].end_time += 86400;
|
||||
const fetcher = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(response(first))
|
||||
.mockResolvedValueOnce(response(second));
|
||||
expect(
|
||||
await fetchProviderDailyCosts(
|
||||
{ ...input, to: "2026-09-03" },
|
||||
"private",
|
||||
fetcher,
|
||||
),
|
||||
).toHaveLength(2);
|
||||
const empty = { data: [], has_more: true, next_page: "same" };
|
||||
await expect(
|
||||
fetchProviderDailyCosts(
|
||||
input,
|
||||
"private",
|
||||
vi.fn(async () => response(empty)),
|
||||
),
|
||||
).rejects.toThrow("cursor");
|
||||
});
|
||||
it("preserves a valid report if closing its already-consumed response stream fails", async () => {
|
||||
let sent = false;
|
||||
const reader = {
|
||||
read: async () =>
|
||||
sent
|
||||
? { done: true }
|
||||
: ((sent = true),
|
||||
{ done: false, value: Buffer.from(JSON.stringify(page())) }),
|
||||
cancel: async () => {
|
||||
throw new Error("closed transport");
|
||||
},
|
||||
};
|
||||
const fetcher = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValue({
|
||||
ok: true,
|
||||
body: { getReader: () => reader },
|
||||
} as unknown as Response);
|
||||
expect(
|
||||
(await fetchProviderDailyCosts(input, "private", fetcher))[0].amountCents,
|
||||
).toBe("6.0000000");
|
||||
});
|
||||
it("includes unallocated costs only when the default scope was explicitly selected", async () => {
|
||||
const body = page();
|
||||
(body.data[0].results[0] as { project_id: string | null }).project_id =
|
||||
null;
|
||||
expect(
|
||||
await fetchProviderDailyCosts(
|
||||
{ ...input, scopeIds: ["default"] },
|
||||
"private",
|
||||
vi.fn(async () => response(body)),
|
||||
),
|
||||
).toEqual([
|
||||
{ day: "2026-09-01", scopeId: "default", amountCents: "6.0000000" },
|
||||
]);
|
||||
});
|
||||
it("rejects a provider amount with the other provider's unit/shape and missing period boundaries", async () => {
|
||||
const wrong: any = page();
|
||||
wrong.data[0].results[0].amount = "6";
|
||||
wrong.data[0].results[0].currency = "USD";
|
||||
await expect(
|
||||
fetchProviderDailyCosts(
|
||||
input,
|
||||
"private",
|
||||
vi.fn(async () => response(wrong)),
|
||||
),
|
||||
).rejects.toThrow("invalid billing amount");
|
||||
const missing: any = page();
|
||||
delete missing.data[0].start_time;
|
||||
delete missing.data[0].end_time;
|
||||
await expect(
|
||||
fetchProviderDailyCosts(
|
||||
input,
|
||||
"private",
|
||||
vi.fn(async () => response(missing)),
|
||||
),
|
||||
).rejects.toThrow("unexpected billing period");
|
||||
});
|
||||
});
|
||||
@@ -57,6 +57,7 @@ const {
|
||||
reason: null,
|
||||
}));
|
||||
const heartbeatServiceMock = {
|
||||
reconcileCostAccounting: vi.fn(async () => ({ scanned: 0, accounted: 0 })),
|
||||
resolveSchedulingSuppression: resolveHeartbeatSchedulingSuppressionMock,
|
||||
recoverNativeRunsAfterRestart: vi.fn(async () => ({
|
||||
restartKind: "hard",
|
||||
@@ -559,11 +560,13 @@ describe("startServer feedback export wiring", () => {
|
||||
}) as typeof setInterval);
|
||||
try {
|
||||
await startServer();
|
||||
expect(heartbeatServiceMock.reconcileCostAccounting).toHaveBeenCalledTimes(1);
|
||||
expect(heartbeatServiceMock.sweepStaleIssueLocks).toHaveBeenCalledTimes(1);
|
||||
expect(intervalCallback).not.toBeNull();
|
||||
intervalCallback?.();
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(heartbeatServiceMock.sweepStaleIssueLocks).toHaveBeenCalledTimes(2);
|
||||
expect(heartbeatServiceMock.reconcileCostAccounting).toHaveBeenCalledTimes(2);
|
||||
expect(retiredDetector).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
delete (runtime as Partial<typeof runtime>).reconcileProductivityReviews;
|
||||
@@ -660,6 +663,7 @@ describe("startServer feedback export wiring", () => {
|
||||
// reaped at startup and on the interval.
|
||||
expect(heartbeatServiceFactoryMock).toHaveBeenCalledTimes(1);
|
||||
expect(heartbeatServiceMock.sweepPendingCleanupLeases).toHaveBeenCalled();
|
||||
expect(heartbeatServiceMock.reconcileCostAccounting).toHaveBeenCalledTimes(1);
|
||||
expect(intervalCallback).not.toBeNull();
|
||||
intervalCallback?.();
|
||||
await Promise.resolve();
|
||||
@@ -667,12 +671,33 @@ describe("startServer feedback export wiring", () => {
|
||||
|
||||
expect(externalObjectsServiceMock.refreshDueObjectsForActiveCompanies).toHaveBeenCalledTimes(1);
|
||||
expect(routineServiceMock.tickScheduledTriggers).not.toHaveBeenCalled();
|
||||
expect(heartbeatServiceMock.reconcileCostAccounting).toHaveBeenCalledTimes(2);
|
||||
expect(environmentCustomImagesServiceMock.cleanupExpiredSetupSessions).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
setIntervalSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([false, true])("retries failed accounting recovery with heartbeat scheduling enabled: %s", async (enabled) => {
|
||||
loadConfigMock.mockReturnValue(buildTestConfig({ heartbeatSchedulerEnabled: enabled }));
|
||||
heartbeatServiceMock.reconcileCostAccounting.mockRejectedValueOnce(new Error("temporary ledger outage"));
|
||||
let tick: (() => void) | undefined;
|
||||
const timer = vi.spyOn(globalThis, "setInterval").mockImplementation(((callback: () => void) => {
|
||||
tick = callback;
|
||||
return 1 as unknown as ReturnType<typeof setInterval>;
|
||||
}) as typeof setInterval);
|
||||
try {
|
||||
expect((await startServer()).server).toBe(fakeServer);
|
||||
expect(heartbeatServiceMock.reconcileCostAccounting).toHaveBeenCalledTimes(1);
|
||||
expect(tick).toBeDefined();
|
||||
tick?.();
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(heartbeatServiceMock.reconcileCostAccounting).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
timer.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not replay hot-restart adoption when the orphan reaper retries", async () => {
|
||||
loadConfigMock.mockReturnValue(buildTestConfig({
|
||||
heartbeatSchedulerEnabled: true,
|
||||
|
||||
+9
-4
@@ -876,6 +876,7 @@ async function startServerWithDatabaseTeardown(
|
||||
const heartbeat = config.heartbeatSchedulerEnabled
|
||||
? heartbeatService(db as any, { pluginWorkerManager })
|
||||
: null;
|
||||
const accountingHeartbeat = heartbeat ?? heartbeatService(db as any, { pluginWorkerManager });
|
||||
const decisionServiceOptions = {
|
||||
wakeOriginAgent: createDecisionWakeOriginAgent(heartbeat?.wakeup ?? null),
|
||||
};
|
||||
@@ -1223,8 +1224,7 @@ async function startServerWithDatabaseTeardown(
|
||||
// so a just-failed lease does not draw a retry on every tick. The startup
|
||||
// sweep passes zero, so a restart retries a stranded orphan at once.
|
||||
const ENVIRONMENT_LEASE_CLEANUP_SWEEP_BACKOFF_MS = 5 * 60 * 1000;
|
||||
const environmentLeaseCleanupHeartbeat =
|
||||
heartbeat ?? heartbeatService(db as any, { pluginWorkerManager });
|
||||
const environmentLeaseCleanupHeartbeat = accountingHeartbeat;
|
||||
const chatCompletionDeliveries = chatCompletionDeliveryService(db as any, environmentLeaseCleanupHeartbeat);
|
||||
// Activity publication happens after the status transaction commits. This is
|
||||
// a best-effort fast path; the durable outbox and sweeps remain authoritative.
|
||||
@@ -1442,6 +1442,7 @@ async function startServerWithDatabaseTeardown(
|
||||
},
|
||||
"worktree run-execution cutoff state",
|
||||
);
|
||||
await accountingHeartbeat.reconcileCostAccounting().catch((err) => logger.error({ err }, "Cost accounting recovery failed; pending receipts will retry"));
|
||||
const heartbeatSchedulingSuppression = await heartbeat.resolveSchedulingSuppression();
|
||||
|
||||
// Reap orphaned runs before timer ticks start so wakeups cannot coalesce
|
||||
@@ -1457,8 +1458,7 @@ async function startServerWithDatabaseTeardown(
|
||||
// workers before cleanup or retry admission, including unmanaged installs.
|
||||
await app.locals.bundledPluginsStartup;
|
||||
try {
|
||||
const nativeRecovery =
|
||||
await heartbeat.recoverNativeRunsAfterRestart();
|
||||
const nativeRecovery = await heartbeat.recoverNativeRunsAfterRestart();
|
||||
if (nativeRecovery.dispositions.length > 0) {
|
||||
logger.info(
|
||||
{
|
||||
@@ -1756,6 +1756,9 @@ async function startServerWithDatabaseTeardown(
|
||||
}));
|
||||
|
||||
trackHeartbeatSchedulerWork(chatCompletionDeliveries.sweepPending().catch((err) => logger.error({ err }, "chat completion delivery failed")));
|
||||
|
||||
trackHeartbeatSchedulerWork(accountingHeartbeat.reconcileCostAccounting().catch((err) => logger.error({ err }, "Cost accounting recovery failed")));
|
||||
|
||||
trackHeartbeatSchedulerWork(connectionDeliveries.sweepPending().catch((err) => logger.error({ err }, "connection continuation delivery failed")));
|
||||
trackHeartbeatSchedulerWork(app.locals.toolGateway.sweepActionReviews().catch((err: unknown) => logger.error({ err }, "tool review recovery failed")));
|
||||
trackHeartbeatSchedulerWork(app.locals.toolGateway.cleanupExpiredSessions().catch((err: unknown) => logger.error({ err }, "gateway token cleanup failed")));
|
||||
@@ -1832,8 +1835,10 @@ async function startServerWithDatabaseTeardown(
|
||||
// is still required. A failed acquire can leak a paid provider sandbox, so
|
||||
// this path retries the teardown at startup and on the interval, exactly as
|
||||
// the enabled path does.
|
||||
await accountingHeartbeat.reconcileCostAccounting().catch((err) => logger.error({ err }, "Cost accounting recovery failed; pending receipts will retry"));
|
||||
await runEnvironmentLeaseCleanupSweep(0);
|
||||
startHeartbeatSchedulerInterval(() => {
|
||||
trackHeartbeatSchedulerWork(accountingHeartbeat.reconcileCostAccounting().catch((err) => logger.error({ err }, "Cost accounting recovery failed")));
|
||||
scheduleExternalObjectRefreshSweep(new Date());
|
||||
scheduleEnvironmentLeaseCleanupSweep();
|
||||
scheduleGitHubConnectionEventPoll();
|
||||
|
||||
+59
-74
@@ -1,4 +1,10 @@
|
||||
import { importProviderDailyCosts } from "../services/provider-billing-import.js";
|
||||
import { importProviderCostsSchema } from "@paperclipai/shared";
|
||||
import { accountingIntegrityService } from "../services/accounting-integrity.js";
|
||||
import { billingReconciliationService } from "../services/billing-reconciliation.js";
|
||||
import { adjustCostSchema, importBillingInvoiceSchema, repairAccountingSchema, retryAccountingSchema } from "@paperclipai/shared";
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
createCostEventSchema,
|
||||
@@ -18,28 +24,37 @@ import {
|
||||
issueService,
|
||||
heartbeatService,
|
||||
accessService,
|
||||
logActivity,
|
||||
} from "../services/index.js";
|
||||
import { assertBoard, assertCompanyAccess, getAccessibleResource, getActorInfo } from "./authz.js";
|
||||
import { fetchCompanyQuotaWindows } from "../services/quota-windows.js";
|
||||
import { badRequest } from "../errors.js";
|
||||
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
|
||||
|
||||
const reportDateSchema = z.union([z.iso.datetime({ offset: true }), z.iso.date()]);
|
||||
|
||||
export function parseCostDateRange(query: Record<string, unknown>) {
|
||||
if (query.period !== undefined && query.period !== "all" && query.period !== "month") throw badRequest("invalid 'period'");
|
||||
if (query.period === "all") {
|
||||
if (query.from !== undefined || query.to !== undefined) throw badRequest("all-time period cannot have date bounds");
|
||||
return { allTime: true };
|
||||
}
|
||||
if (query.from !== undefined && !reportDateSchema.safeParse(query.from).success) throw badRequest("invalid 'from' date");
|
||||
if (query.to !== undefined && !reportDateSchema.safeParse(query.to).success) throw badRequest("invalid 'to' date");
|
||||
const fromRaw = query.from as string | undefined;
|
||||
const toRaw = query.to as string | undefined;
|
||||
const from = fromRaw ? new Date(fromRaw) : undefined;
|
||||
const to = toRaw ? new Date(toRaw) : undefined;
|
||||
if (from && isNaN(from.getTime())) throw badRequest("invalid 'from' date");
|
||||
if (to && isNaN(to.getTime())) throw badRequest("invalid 'to' date");
|
||||
if (from && to && from > to) throw badRequest("from must not be after to");
|
||||
return (from || to) ? { from, to } : undefined;
|
||||
}
|
||||
|
||||
export function parseCostLimit(query: Record<string, unknown>) {
|
||||
const raw = Array.isArray(query.limit) ? query.limit[0] : query.limit;
|
||||
const raw = query.limit;
|
||||
if (raw == null || raw === "") return 100;
|
||||
const limit = typeof raw === "number" ? raw : Number.parseInt(String(raw), 10);
|
||||
if (!Number.isFinite(limit) || limit <= 0 || limit > 500) {
|
||||
const limit = typeof raw === "number" || typeof raw === "string" ? Number(raw) : Number.NaN;
|
||||
if (!Number.isInteger(limit) || limit <= 0 || limit > 500) {
|
||||
throw badRequest("invalid 'limit' value");
|
||||
}
|
||||
return limit;
|
||||
@@ -120,22 +135,11 @@ export function costRoutes(
|
||||
return;
|
||||
}
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const event = await costs.createEvent(companyId, {
|
||||
...req.body,
|
||||
occurredAt: new Date(req.body.occurredAt),
|
||||
});
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
action: "cost.reported",
|
||||
entityType: "cost_event",
|
||||
entityId: event.id,
|
||||
details: { costCents: event.costCents, model: event.model },
|
||||
});
|
||||
}, { actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId });
|
||||
|
||||
res.status(201).json(event);
|
||||
});
|
||||
@@ -145,27 +149,11 @@ export function costRoutes(
|
||||
assertCompanyAccess(req, companyId);
|
||||
assertBoard(req);
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const event = await finance.createEvent(companyId, {
|
||||
...req.body,
|
||||
occurredAt: new Date(req.body.occurredAt),
|
||||
});
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
action: "finance_event.reported",
|
||||
entityType: "finance_event",
|
||||
entityId: event.id,
|
||||
details: {
|
||||
amountCents: event.amountCents,
|
||||
biller: event.biller,
|
||||
eventKind: event.eventKind,
|
||||
direction: event.direction,
|
||||
},
|
||||
});
|
||||
}, { actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId });
|
||||
|
||||
res.status(201).json(event);
|
||||
});
|
||||
@@ -331,22 +319,6 @@ export function costRoutes(
|
||||
assertBoard(req);
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const company = await companies.update(companyId, { budgetMonthlyCents: req.body.budgetMonthlyCents });
|
||||
if (!company) {
|
||||
res.status(404).json({ error: "Company not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "company.budget_updated",
|
||||
entityType: "company",
|
||||
entityId: companyId,
|
||||
details: { budgetMonthlyCents: req.body.budgetMonthlyCents },
|
||||
});
|
||||
|
||||
await budgets.upsertPolicy(
|
||||
companyId,
|
||||
{
|
||||
@@ -358,7 +330,7 @@ export function costRoutes(
|
||||
req.actor.userId ?? "board",
|
||||
);
|
||||
|
||||
res.json(company);
|
||||
res.json(await companies.getById(companyId));
|
||||
});
|
||||
|
||||
router.patch("/agents/:agentId/budgets", validate(updateBudgetSchema), async (req, res) => {
|
||||
@@ -368,37 +340,50 @@ export function costRoutes(
|
||||
|
||||
assertBoard(req);
|
||||
|
||||
const updated = await agents.update(agentId, { budgetMonthlyCents: req.body.budgetMonthlyCents });
|
||||
if (!updated) {
|
||||
res.status(404).json({ error: "Agent not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId: updated.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
action: "agent.budget_updated",
|
||||
entityType: "agent",
|
||||
entityId: updated.id,
|
||||
details: { budgetMonthlyCents: updated.budgetMonthlyCents },
|
||||
});
|
||||
|
||||
await budgets.upsertPolicy(
|
||||
updated.companyId,
|
||||
agent.companyId,
|
||||
{
|
||||
scopeType: "agent",
|
||||
scopeId: updated.id,
|
||||
amount: updated.budgetMonthlyCents,
|
||||
scopeId: agent.id,
|
||||
amount: req.body.budgetMonthlyCents,
|
||||
windowKind: "calendar_month_utc",
|
||||
},
|
||||
req.actor.type === "board" ? req.actor.userId ?? "board" : null,
|
||||
);
|
||||
|
||||
res.json(updated);
|
||||
res.json(await agents.getById(agentId));
|
||||
});
|
||||
|
||||
const integrity = accountingIntegrityService(db, budgetHooks);
|
||||
const billing = billingReconciliationService(db, budgetHooks);
|
||||
// Operational accounting evidence and repairs are board-only. These payloads
|
||||
// can contain company-wide billing identifiers even for an agent's own run.
|
||||
router.use("/companies/:companyId/accounting", (req, _res, next) => {
|
||||
assertCompanyAccess(req, req.params.companyId as string); assertBoard(req); next();
|
||||
});
|
||||
router.post("/companies/:companyId/accounting/provider-costs/import", validate(importProviderCostsSchema), async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
assertBoard(req);
|
||||
const result = await importProviderDailyCosts(db, companyId, req.body, getActorInfo(req).actorId);
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/accounting/health", async (req, res) => res.json(await integrity.health(req.params.companyId as string)));
|
||||
router.get("/companies/:companyId/accounting/inspect", async (req, res) => res.json(await integrity.inspect(req.params.companyId as string)));
|
||||
router.post("/companies/:companyId/accounting/repair", validate(repairAccountingSchema), async (req, res) =>
|
||||
res.json(await integrity.repair(req.params.companyId as string, req.body.fingerprint, req.body.reason, getActorInfo(req).actorId)));
|
||||
router.post("/companies/:companyId/accounting/retry", validate(retryAccountingSchema), async (req, res) =>
|
||||
res.json(await integrity.retry(req.params.companyId as string, req.body.runId, getActorInfo(req).actorId)));
|
||||
router.get("/companies/:companyId/accounting/invoices", async (req, res) => res.json(await billing.list(req.params.companyId as string)));
|
||||
router.post("/companies/:companyId/accounting/invoices", validate(importBillingInvoiceSchema), async (req, res) =>
|
||||
res.status(201).json(await billing.importInvoice(req.params.companyId as string, req.body, getActorInfo(req).actorId)));
|
||||
router.get("/companies/:companyId/accounting/invoices/:invoiceId", async (req, res) =>
|
||||
res.json(await billing.reconcile(req.params.companyId as string, req.params.invoiceId as string)));
|
||||
router.get("/companies/:companyId/accounting/events/:eventId/adjustments", async (req, res) =>
|
||||
res.json(await billing.adjustments(req.params.companyId as string, req.params.eventId as string)));
|
||||
router.post("/companies/:companyId/accounting/events/:eventId/adjustments", validate(adjustCostSchema), async (req, res) =>
|
||||
res.status(201).json(await billing.adjust(req.params.companyId as string, req.params.eventId as string, req.body, getActorInfo(req).actorId)));
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -124,6 +124,11 @@ import {
|
||||
updateBudgetSchema,
|
||||
upsertBudgetPolicySchema,
|
||||
resolveBudgetIncidentSchema,
|
||||
repairAccountingSchema,
|
||||
retryAccountingSchema,
|
||||
importBillingInvoiceSchema,
|
||||
importProviderCostsSchema,
|
||||
adjustCostSchema,
|
||||
// Sidebar
|
||||
upsertSidebarOrderPreferenceSchema,
|
||||
// Announcements
|
||||
@@ -1320,6 +1325,7 @@ const PUBLIC_OPERATIONS = new Set([
|
||||
]);
|
||||
|
||||
const BOARD_ONLY_PREFIXES = [
|
||||
"/api/companies/{companyId}/accounting/",
|
||||
"/api/announcements/",
|
||||
"/api/auth/",
|
||||
"/api/admin/",
|
||||
@@ -5250,6 +5256,12 @@ registry.registerPath({
|
||||
|
||||
// ─── Costs ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const costReportQuerySchema = z.object({
|
||||
period: z.enum(["month", "all"]).optional().describe("Defaults to the current UTC month. All-time cannot be combined with date bounds."),
|
||||
from: z.union([z.iso.datetime({ offset: true }), z.iso.date()]).optional().describe("Inclusive start bound."),
|
||||
to: z.union([z.iso.datetime({ offset: true }), z.iso.date()]).optional().describe("Inclusive end bound; must not precede from."),
|
||||
});
|
||||
|
||||
const costSummaryPaths = [
|
||||
"summary",
|
||||
"by-agent",
|
||||
@@ -5271,7 +5283,12 @@ for (const segment of costSummaryPaths) {
|
||||
path: `/api/companies/{companyId}/costs/${segment}`,
|
||||
tags: ["costs"],
|
||||
summary: `Cost report: ${segment}`,
|
||||
request: { params: z.object({ companyId: z.string() }) },
|
||||
request: {
|
||||
params: z.object({ companyId: z.string() }),
|
||||
...(!["window-spend", "quota-windows"].includes(segment) ? {
|
||||
query: segment === "finance-events" ? costReportQuerySchema.extend({ limit: z.coerce.number().int().min(1).max(500).optional() }) : costReportQuerySchema,
|
||||
} : {}),
|
||||
},
|
||||
responses: { 200: r.ok(), 401: r.unauthorized },
|
||||
});
|
||||
}
|
||||
@@ -5281,11 +5298,12 @@ registry.registerPath({
|
||||
path: "/api/companies/{companyId}/cost-events",
|
||||
tags: ["costs"],
|
||||
summary: "Record a cost event",
|
||||
description: "Amounts are USD cents, including fractional cents. Decimal strings preserve exact nanodollar precision. Reuse the idempotency key and original receipt on retries; conflicting reuse returns 409.",
|
||||
request: {
|
||||
params: z.object({ companyId: z.string() }),
|
||||
body: jsonBody(createCostEventSchema),
|
||||
},
|
||||
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized },
|
||||
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 409: r.conflict },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
@@ -5293,13 +5311,47 @@ registry.registerPath({
|
||||
path: "/api/companies/{companyId}/finance-events",
|
||||
tags: ["costs"],
|
||||
summary: "Record a finance event",
|
||||
description: "Amounts accept exact decimal cents in the recorded currency. Credits use a nonnegative amount with direction credit. No currency conversion is inferred. Receipt-key conflicts return 409.",
|
||||
request: {
|
||||
params: z.object({ companyId: z.string() }),
|
||||
body: jsonBody(createFinanceEventSchema),
|
||||
},
|
||||
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized },
|
||||
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 409: r.conflict },
|
||||
});
|
||||
|
||||
for (const [suffix, summary] of [
|
||||
["health", "Inspect pending receipts, unpriced charges, cancellation backlog, and held reservations"],
|
||||
["inspect", "Independently compare ledger evidence and stored totals"],
|
||||
["invoices", "List imported provider invoices"],
|
||||
["invoices/{invoiceId}", "Review exact invoice matches and unresolved differences"],
|
||||
["events/{eventId}/adjustments", "Read append-only correction history and prior pricing evidence"],
|
||||
] as const) {
|
||||
registry.registerPath({
|
||||
method: "get", path: `/api/companies/{companyId}/accounting/${suffix}`, tags: ["costs"], summary,
|
||||
description: "Company-scoped board access is required. Board viewers may inspect; agents cannot access operator accounting evidence.",
|
||||
request: { params: z.object({ companyId: z.string(),
|
||||
...(suffix.includes("invoiceId") ? { invoiceId: z.string() } : {}),
|
||||
...(suffix.includes("eventId") ? { eventId: z.string() } : {}),
|
||||
}) },
|
||||
responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
|
||||
});
|
||||
}
|
||||
|
||||
for (const operation of [
|
||||
{ suffix: "repair", schema: repairAccountingSchema, status: 200, summary: "Repair reviewed ledger projections", description: "Requires an audit reason and the current inspection fingerprint. A stale inspection returns 409. Missing evidence is never invented." },
|
||||
{ suffix: "retry", schema: retryAccountingSchema, status: 200, summary: "Retry accounting for a company-owned run", description: "Retries accounting only. Provider work is never re-executed; incomplete evidence remains pending." },
|
||||
{ suffix: "provider-costs/import", schema: importProviderCostsSchema, status: 200, summary: "Import a scoped provider cost report", description: "Reads up to 31 completed UTC days from OpenAI or Anthropic with a company admin credential. Requires explicit provider account and project/workspace identifiers. All pages validate before writing. Reimports append only the difference; run costs are not repriced. Report totals remain separate from invoices." },
|
||||
{ suffix: "invoices", schema: importBillingInvoiceSchema, status: 201, summary: "Import normalized invoice evidence", description: "Idempotent by company, biller, and external invoice ID. Conflicting reuse returns 409. Import atomically creates finance timeline events and does not reprice run costs." },
|
||||
{ suffix: "events/{eventId}/adjustments", schema: adjustCostSchema, status: 201, summary: "Apply a reviewed cost correction", description: "Requires the expected exact cents, an idempotency key, reason, and pricing evidence. Original amounts and pricing revisions remain in history. Stale reviews return 409; ambiguous or contradictory invoice evidence returns 422." },
|
||||
] as const) {
|
||||
registry.registerPath({
|
||||
method: "post", path: `/api/companies/{companyId}/accounting/${operation.suffix}`, tags: ["costs"],
|
||||
summary: operation.summary, description: `${operation.description} Company-scoped board write access is required; viewers and agents cannot mutate accounting.`,
|
||||
request: { params: z.object({ companyId: z.string(), ...(operation.suffix.includes("eventId") ? { eventId: z.string() } : {}) }), body: jsonBody(operation.schema) },
|
||||
responses: { [operation.status]: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict, 422: r.unprocessable },
|
||||
});
|
||||
}
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/api/companies/{companyId}/budgets/policies",
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { agents, agentRuntimeState, companies, heartbeatRuns, type Db } from "@paperclipai/db";
|
||||
import { normalizeCents, usdToCents, type AccountingFinding, type AccountingHealth, type AccountingInspection } from "@paperclipai/shared";
|
||||
import { conflict, notFound } from "../errors.js";
|
||||
import { receiptFingerprint } from "./receipt-fingerprint.js";
|
||||
import { withAccountingTransaction } from "./accounting-transaction.js";
|
||||
import { logActivity } from "./activity-log.js";
|
||||
import { accountRunCost } from "./run-cost-accounting.js";
|
||||
import type { BudgetServiceHooks } from "./budgets.js";
|
||||
|
||||
/** Independent aggregation: deliberately does not reuse writer projections. */
|
||||
async function inspect(tx: Db, companyId: string): Promise<AccountingInspection> {
|
||||
const findings: AccountingFinding[] = [];
|
||||
const monthly = await tx.execute<{ kind: "company_projection" | "agent_projection"; id: string; actual: string; expected: string }>(sql`
|
||||
with month_events as (select agent_id, cost_cents from cost_events where company_id = ${companyId}
|
||||
and occurred_at >= date_trunc('month', now() at time zone 'UTC') at time zone 'UTC'
|
||||
and occurred_at < (date_trunc('month', now() at time zone 'UTC') + interval '1 month') at time zone 'UTC')
|
||||
select 'company_projection' as kind, id, spent_monthly_cents::text as actual,
|
||||
(select coalesce(sum(cost_cents),0)::text from month_events) as expected from companies where id = ${companyId}
|
||||
union all select 'agent_projection', a.id, a.spent_monthly_cents::text,
|
||||
coalesce((select sum(e.cost_cents) from month_events e where e.agent_id = a.id),0)::text from agents a where a.company_id = ${companyId}`);
|
||||
for (const row of monthly) if (normalizeCents(row.actual) !== normalizeCents(row.expected)) findings.push({ kind: row.kind, entityId: row.id, repairable: true,
|
||||
actual: { cents: normalizeCents(row.actual) }, expected: { cents: normalizeCents(row.expected) } });
|
||||
const runtime = await tx.execute<{ id: string; baseline: string | null; actual: Record<string, string>; expected: Record<string, string> }>(sql`
|
||||
with totals as (select r.agent_id, sum(e.cost_cents) as cents, sum(e.input_tokens) as input, sum(e.cached_input_tokens) as cached, sum(e.output_tokens) as output
|
||||
from heartbeat_runs r join cost_events e on e.heartbeat_run_id = r.id and e.company_id = r.company_id
|
||||
where r.company_id = ${companyId} and r.accounting_projection_version = 'v2' and r.cost_accounted_at is not null group by r.agent_id)
|
||||
select a.agent_id as id, b.agent_id as baseline,
|
||||
jsonb_build_object('cents', a.total_cost_cents::text, 'input', a.total_input_tokens::text, 'cached', a.total_cached_input_tokens::text, 'output', a.total_output_tokens::text) as actual,
|
||||
jsonb_build_object('cents', (coalesce(b.cost_cents,0) + coalesce(t.cents,0))::text, 'input', (coalesce(b.input_tokens,0) + coalesce(t.input,0))::text,
|
||||
'cached', (coalesce(b.cached_input_tokens,0) + coalesce(t.cached,0))::text, 'output', (coalesce(b.output_tokens,0) + coalesce(t.output,0))::text) as expected
|
||||
from agent_runtime_state a left join accounting_runtime_baselines b on b.agent_id = a.agent_id and b.company_id = a.company_id
|
||||
left join totals t on t.agent_id = a.agent_id where a.company_id = ${companyId} order by a.agent_id`);
|
||||
for (const row of runtime) {
|
||||
if (!row.baseline) { findings.push({ kind: "legacy_runtime", entityId: row.id, repairable: false, actual: row.actual, expected: {} }); continue; }
|
||||
row.actual.cents = normalizeCents(row.actual.cents); row.expected.cents = normalizeCents(row.expected.cents);
|
||||
if (Object.keys(row.expected).some(key => row.actual[key] !== row.expected[key])) findings.push({ kind: "runtime_projection", entityId: row.id, repairable: true, actual: row.actual, expected: row.expected });
|
||||
}
|
||||
const incomplete = await tx.execute<{ id: string; acknowledged: boolean; events: number }>(sql`
|
||||
select r.id, r.cost_accounted_at is not null as acknowledged, count(e.id)::int as events from heartbeat_runs r
|
||||
left join cost_events e on e.heartbeat_run_id = r.id and e.company_id = r.company_id
|
||||
where r.company_id = ${companyId} and r.status in ('succeeded','failed','timed_out','cancelled')
|
||||
and (r.cost_accounting_pending or r.accounting_projection_version = 'v2')
|
||||
and r.result_json->'executionRecovery'->>'providerWorkStarted' is distinct from 'false'
|
||||
and r.usage_json->>'accountingProviderWorkStarted' is distinct from 'false'
|
||||
group by r.id having r.cost_accounted_at is null or count(e.id) = 0 order by r.id`);
|
||||
for (const row of incomplete) findings.push({ kind: row.acknowledged ? "missing_receipt" : "missing_acknowledgement", entityId: row.id, repairable: false,
|
||||
actual: { acknowledged: String(row.acknowledged), events: String(row.events) }, expected: { acknowledged: "true", events: ">=1" } });
|
||||
// Verify new run receipts independently of the projections. Corrections
|
||||
// change effective valuation, while reported_cost_cents preserves the
|
||||
// provider amount this acknowledgement originally consumed.
|
||||
const receipts = await tx.execute<{ id: string; usage: Record<string, unknown>; actual: Record<string, string> }>(sql`
|
||||
select r.id, r.usage_json as usage, jsonb_build_object('cents', sum(coalesce(e.reported_cost_cents,e.cost_cents))::text,
|
||||
'input', sum(e.input_tokens)::text, 'cached', sum(e.cached_input_tokens)::text, 'output', sum(e.output_tokens)::text) as actual
|
||||
from heartbeat_runs r join cost_events e on e.heartbeat_run_id = r.id and e.company_id = r.company_id
|
||||
where r.company_id = ${companyId} and r.accounting_projection_version = 'v2' and r.cost_accounted_at is not null group by r.id order by r.id`);
|
||||
for (const row of receipts) {
|
||||
const usage = row.usage ?? {};
|
||||
const cost = usage.cacheAdjustedCostUsd ?? usage.costUsdExact ?? usage.costUsd ?? 0;
|
||||
let cents: string;
|
||||
try { cents = usage.billingType === "subscription_included" ? "0.0000000" : usdToCents(String(cost)); }
|
||||
catch { cents = "invalid receipt"; }
|
||||
const expected = { cents, input: String(usage.inputTokens ?? 0), cached: String(usage.cachedInputTokens ?? 0), output: String(usage.outputTokens ?? 0) };
|
||||
row.actual.cents = normalizeCents(row.actual.cents);
|
||||
if (Object.entries(expected).some(([key,value]) => row.actual[key] !== value)) findings.push({ kind: "receipt_mismatch", entityId: row.id, repairable: false, actual: row.actual, expected });
|
||||
}
|
||||
findings.sort((a,b) => a.kind.localeCompare(b.kind) || a.entityId.localeCompare(b.entityId));
|
||||
return { companyId, findings, fingerprint: receiptFingerprint({ companyId, findings }), checkedAt: new Date().toISOString() };
|
||||
}
|
||||
|
||||
export function accountingIntegrityService(db: Db, hooks: BudgetServiceHooks = {}) {
|
||||
return {
|
||||
inspect: (companyId: string) => withAccountingTransaction(db, companyId, tx => inspect(tx, companyId)),
|
||||
repair: (companyId: string, fingerprint: string, reason: string, actorId: string) => withAccountingTransaction(db, companyId, async (tx, publications) => {
|
||||
if (!reason.trim()) throw conflict("A repair reason is required");
|
||||
const before = await inspect(tx, companyId);
|
||||
if (before.fingerprint !== fingerprint) throw conflict("Accounting changed since inspection; inspect again before repairing");
|
||||
for (const finding of before.findings.filter(f => f.repairable)) {
|
||||
if (finding.kind === "company_projection") await tx.update(companies).set({ spentMonthlyCents: sql`${finding.expected.cents}::numeric`, updatedAt: new Date() }).where(eq(companies.id, companyId));
|
||||
if (finding.kind === "agent_projection") await tx.update(agents).set({ spentMonthlyCents: sql`${finding.expected.cents}::numeric`, updatedAt: new Date() }).where(and(eq(agents.id, finding.entityId), eq(agents.companyId, companyId)));
|
||||
if (finding.kind === "runtime_projection") await tx.update(agentRuntimeState).set({ totalCostCents: sql`${finding.expected.cents}::numeric`,
|
||||
totalInputTokens: sql`${finding.expected.input}::bigint`, totalCachedInputTokens: sql`${finding.expected.cached}::bigint`, totalOutputTokens: sql`${finding.expected.output}::bigint`, updatedAt: new Date(),
|
||||
}).where(and(eq(agentRuntimeState.agentId, finding.entityId), eq(agentRuntimeState.companyId, companyId)));
|
||||
}
|
||||
await logActivity(tx, { companyId, actorType: "user", actorId, action: "accounting.projections_repaired", entityType: "company", entityId: companyId,
|
||||
details: { fingerprint, reason, repairs: before.findings.filter(f => f.repairable) } }, publications);
|
||||
return inspect(tx, companyId);
|
||||
}),
|
||||
retry: async (companyId: string, runId: string, actorId: string) => {
|
||||
const [run] = await db.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, runId)));
|
||||
if (!run) throw notFound("Accounting run not found");
|
||||
await logActivity(db, { companyId, actorType: "user", actorId, action: "accounting.retry_requested", entityType: "heartbeat_run", entityId: runId });
|
||||
try { return { accounted: await accountRunCost(db, runId, hooks) }; }
|
||||
catch (error) {
|
||||
await db.update(heartbeatRuns).set({ accountingLastAttemptAt: new Date(), accountingAttemptCount: sql`${heartbeatRuns.accountingAttemptCount} + 1`,
|
||||
accountingLastError: error instanceof Error ? error.message.slice(0,1000) : "Accounting retry failed",
|
||||
}).where(and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, companyId)));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
health: (companyId: string): Promise<AccountingHealth> => withAccountingTransaction(db, companyId, async tx => {
|
||||
const [counts] = await tx.execute<{ pending: number; unpriced: number; oldest: string | null; cancellations: number; reserved: string }>(sql`
|
||||
select (select count(*)::int from heartbeat_runs where company_id = ${companyId} and cost_accounting_pending and status in ('succeeded','failed','timed_out','cancelled')) as pending,
|
||||
(select count(*)::int from cost_events where company_id = ${companyId} and cost_status = 'unpriced' and billing_type <> 'subscription_included') as unpriced,
|
||||
(select min(coalesce(finished_at,created_at))::text from heartbeat_runs where company_id = ${companyId} and cost_accounting_pending and status in ('succeeded','failed','timed_out','cancelled')) as oldest,
|
||||
(select count(*)::int from budget_policies where company_id = ${companyId} and enforcement_version > enforcement_delivered_version) as cancellations,
|
||||
(select coalesce(sum(amount_cents),0)::text from budget_reservations where company_id = ${companyId} and state = 'held') as reserved`);
|
||||
const pending = await tx.execute<{ runId: string; agentId: string; state: "waiting_for_receipt" | "retryable"; lastError: string | null; attempts: number; since: string; lastAttemptAt: string | null }>(sql`
|
||||
select id as "runId", agent_id as "agentId", case when usage_json->>'accountingReceiptReady' = 'false'
|
||||
and usage_json->>'accountingProviderWorkStarted' is distinct from 'false'
|
||||
and result_json->'executionRecovery'->>'providerWorkStarted' is distinct from 'false' then 'waiting_for_receipt' else 'retryable' end as state,
|
||||
accounting_last_error as "lastError", accounting_attempt_count as attempts, coalesce(finished_at,created_at)::text as since, accounting_last_attempt_at::text as "lastAttemptAt"
|
||||
from heartbeat_runs where company_id = ${companyId} and cost_accounting_pending and status in ('succeeded','failed','timed_out','cancelled') order by coalesce(finished_at,created_at),id limit 100`);
|
||||
const unpriced = await tx.execute<{ runId: string | null; costEventId: string; agentId: string; since: string }>(sql`
|
||||
select heartbeat_run_id as "runId", id as "costEventId", agent_id as "agentId", occurred_at::text as since from cost_events
|
||||
where company_id = ${companyId} and cost_status = 'unpriced' and billing_type <> 'subscription_included' order by occurred_at,id limit 100`);
|
||||
return { companyId, pendingRunCount: counts.pending, unpricedEventCount: counts.unpriced, oldestPendingAt: counts.oldest, pendingCancellationCount: counts.cancellations,
|
||||
heldReservationCents: normalizeCents(counts.reserved), items: [...pending, ...unpriced.map(row => ({ ...row, state: "unpriced" as const, lastError: null, attempts: 0, lastAttemptAt: null }))] };
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { companies, type Db } from "@paperclipai/db";
|
||||
import { publishActivity, type ActivityPublication } from "./activity-log.js";
|
||||
import { notFound } from "../errors.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
|
||||
/** Serialize ledger and budget mutations in one company. Always acquire this
|
||||
* before locking a run or a policy, so concurrent writers use one lock order.
|
||||
* NO KEY UPDATE permits FK KEY SHARE locks held by recovery callers. */
|
||||
export async function withAccountingTransaction<T>(db: Db, companyId: string, work: (tx: Db, publications: ActivityPublication[]) => Promise<T>): Promise<T> {
|
||||
const publications: ActivityPublication[] = [];
|
||||
const result = await db.transaction(async (transaction) => {
|
||||
const tx = transaction as unknown as Db;
|
||||
const [company] = await tx.select({ id: companies.id }).from(companies)
|
||||
.where(eq(companies.id, companyId)).for("no key update");
|
||||
if (!company) throw notFound("Company not found");
|
||||
return work(tx, publications);
|
||||
});
|
||||
publishAccountingActivities(companyId, publications);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Database history is durable even when the live subscriber channel fails. */
|
||||
export function publishAccountingActivities(companyId: string, publications: ActivityPublication[]) {
|
||||
for (const publication of publications) {
|
||||
try { publishActivity(publication); }
|
||||
catch (error) { logger.warn({ err: error, companyId }, "Accounting committed; live activity publication failed"); }
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import { budgetServiceInTransaction } from "./budgets.js";
|
||||
import { withAccountingTransaction } from "./accounting-transaction.js";
|
||||
import type { ActivityPublication } from "./activity-log.js";
|
||||
import { agentAppearanceSchema, randomAgentAppearance, resolveAgentAppearance, agentAvatarUrl } from "@paperclipai/shared";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { and, desc, eq, gte, inArray, lt, ne, or, sql } from "drizzle-orm";
|
||||
@@ -787,7 +790,7 @@ export function agentService(db: Db) {
|
||||
const beforeConfig = shouldRecordRevision ? buildConfigSnapshot(existing) : null;
|
||||
|
||||
type AgentUpdateResult = Awaited<ReturnType<typeof getById>>;
|
||||
const applyUpdate = async (txDb: Db): Promise<AgentUpdateResult> => {
|
||||
const applyUpdate = async (txDb: Db, publications: ActivityPublication[] = []): Promise<AgentUpdateResult> => {
|
||||
const updated = await txDb
|
||||
.update(agents)
|
||||
.set({ ...normalizedPatch, updatedAt: new Date() })
|
||||
@@ -826,6 +829,11 @@ export function agentService(db: Db) {
|
||||
);
|
||||
}
|
||||
|
||||
if (normalizedPatch.budgetMonthlyCents !== undefined) {
|
||||
await budgetServiceInTransaction(txDb, publications).upsertPolicy(existing.companyId, {
|
||||
scopeType: "agent", scopeId: id, amount: normalizedPatch.budgetMonthlyCents, windowKind: "calendar_month_utc",
|
||||
}, options?.recordRevision?.createdByUserId ?? null);
|
||||
}
|
||||
const normalizedUpdated = await agentService(txDb).getById(updated.id);
|
||||
if (!normalizedUpdated) {
|
||||
throw notFound("Agent not found");
|
||||
@@ -852,6 +860,8 @@ export function agentService(db: Db) {
|
||||
return normalizedUpdated;
|
||||
};
|
||||
|
||||
if (normalizedPatch.budgetMonthlyCents !== undefined) return withAccountingTransaction(db, existing.companyId, applyUpdate);
|
||||
|
||||
const transaction = (db as unknown as {
|
||||
transaction?: (callback: (tx: unknown) => Promise<AgentUpdateResult>) => Promise<AgentUpdateResult>;
|
||||
}).transaction;
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { createFinanceEventInTransaction } from "./finance.js";
|
||||
import { and, desc, eq, sql } from "drizzle-orm";
|
||||
import { agentRuntimeState, costEvents, costAdjustments, billingInvoices, billingInvoiceLines, heartbeatRuns, type Db } from "@paperclipai/db";
|
||||
import { adjustCostSchema, importBillingInvoiceSchema, normalizeCents, subtractCents, type AdjustCost, type ImportBillingInvoice } from "@paperclipai/shared";
|
||||
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||
import { withAccountingTransaction } from "./accounting-transaction.js";
|
||||
import { receiptFingerprint } from "./receipt-fingerprint.js";
|
||||
import { logActivity } from "./activity-log.js";
|
||||
import { updateMonthlySpendProjections } from "./costs.js";
|
||||
import { budgetServiceInTransaction, deliverBudgetEnforcement, type BudgetServiceHooks } from "./budgets.js";
|
||||
|
||||
export function billingReconciliationService(db: Db, hooks: BudgetServiceHooks = {}) {
|
||||
return {
|
||||
importInvoice: (companyId: string, raw: ImportBillingInvoice, actorId: string) => withAccountingTransaction(db, companyId, async (tx, publications) => {
|
||||
const input = importBillingInvoiceSchema.parse(raw);
|
||||
// Line order in an export is not part of invoice identity.
|
||||
input.lines.sort((a,b) => a.externalId.localeCompare(b.externalId));
|
||||
const receiptHash = receiptFingerprint(input);
|
||||
const [existing] = await tx.select().from(billingInvoices).where(and(eq(billingInvoices.companyId, companyId), eq(billingInvoices.biller, input.biller), eq(billingInvoices.externalId, input.externalId)));
|
||||
if (existing) {
|
||||
if (existing.receiptHash !== receiptHash) throw conflict("Invoice identifier already has different contents");
|
||||
return existing;
|
||||
}
|
||||
// Validate explicit references, even on non-USD invoices that we cannot
|
||||
// compare with the USD cost ledger. Never infer cross-company matches.
|
||||
for (const line of input.lines) {
|
||||
if (line.costEventId) {
|
||||
const [event] = await tx.select({ id: costEvents.id }).from(costEvents).where(and(eq(costEvents.companyId, companyId), eq(costEvents.id, line.costEventId)));
|
||||
if (!event) throw notFound("Invoice cost event not found");
|
||||
}
|
||||
if (line.runId) {
|
||||
const [run] = await tx.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, line.runId)));
|
||||
if (!run) throw notFound("Invoice run not found");
|
||||
}
|
||||
}
|
||||
const [invoice] = await tx.insert(billingInvoices).values({ companyId, biller: input.biller, externalId: input.externalId, currency: input.currency, receiptHash, importedBy: actorId }).returning();
|
||||
await tx.insert(billingInvoiceLines).values(input.lines.map(line => ({ ...line, companyId, invoiceId: invoice.id, occurredAt: new Date(line.occurredAt), pricing: line.pricing ?? {} })));
|
||||
for (const line of input.lines) {
|
||||
await createFinanceEventInTransaction(tx, publications, companyId, {
|
||||
idempotencyKey: `invoice:${invoice.id}:${receiptFingerprint(line.externalId)}`,
|
||||
biller: input.biller, externalInvoiceId: input.externalId,
|
||||
eventKind: line.kind === "inference" ? "inference_charge" : line.kind === "credit" ? "credit_refund" : "platform_fee",
|
||||
direction: line.kind === "credit" ? "credit" : "debit",
|
||||
amountCents: line.amountCents, currency: input.currency, occurredAt: new Date(line.occurredAt),
|
||||
description: `Invoice ${input.externalId} · ${line.externalId}`.slice(0, 500),
|
||||
costEventId: line.costEventId, heartbeatRunId: line.runId,
|
||||
metadataJson: { source: "provider_invoice", invoiceId: invoice.id, externalLineId: line.externalId },
|
||||
}, { actorType: "user", actorId });
|
||||
}
|
||||
await logActivity(tx, { companyId, actorType: "user", actorId, action: "accounting.invoice_imported", entityType: "billing_invoice", entityId: invoice.id,
|
||||
details: { biller: invoice.biller, externalId: invoice.externalId, currency: invoice.currency, lineCount: input.lines.length, receiptHash } }, publications);
|
||||
return invoice;
|
||||
}),
|
||||
list: (companyId: string) => db.select().from(billingInvoices).where(eq(billingInvoices.companyId, companyId)).orderBy(desc(billingInvoices.createdAt)).limit(100),
|
||||
reconcile: (companyId: string, invoiceId: string) => withAccountingTransaction(db, companyId, async tx => {
|
||||
const [invoice] = await tx.select().from(billingInvoices).where(and(eq(billingInvoices.companyId, companyId), eq(billingInvoices.id, invoiceId)));
|
||||
if (!invoice) throw notFound("Invoice not found");
|
||||
const lines = await tx.select().from(billingInvoiceLines).where(and(eq(billingInvoiceLines.companyId, companyId), eq(billingInvoiceLines.invoiceId, invoiceId))).orderBy(billingInvoiceLines.externalId);
|
||||
const rows = [];
|
||||
for (const line of lines) {
|
||||
const candidates = invoice.currency !== "USD" || line.kind !== "inference" || !(line.costEventId || line.runId || line.providerRequestId) ? [] : await tx.select({
|
||||
id: costEvents.id, cents: sql<string>`${costEvents.costCents}::text`, status: costEvents.costStatus,
|
||||
}).from(costEvents).where(and(eq(costEvents.companyId, companyId), eq(costEvents.biller, invoice.biller),
|
||||
line.costEventId ? eq(costEvents.id, line.costEventId) : undefined,
|
||||
line.runId ? eq(costEvents.heartbeatRunId, line.runId) : undefined,
|
||||
line.providerRequestId ? eq(costEvents.providerRequestId, line.providerRequestId) : undefined,
|
||||
line.model ? eq(costEvents.model, line.model) : undefined)).limit(2);
|
||||
const match = candidates.length === 1 ? candidates[0] : null;
|
||||
const differenceCents = match ? subtractCents(line.amountCents, match.cents) : null;
|
||||
const status = invoice.currency !== "USD" ? "unsupported_currency" : line.kind !== "inference" ? "non_inference"
|
||||
: candidates.length > 1 ? "ambiguous" : !match ? "unmatched" : differenceCents === "0.0000000" && match.status !== "unpriced" ? "matched" : "difference";
|
||||
rows.push({ ...line, amountCents: normalizeCents(line.amountCents), status, matchedEventId: match?.id ?? null,
|
||||
recordedCents: match ? normalizeCents(match.cents) : null, differenceCents });
|
||||
}
|
||||
// Multiple invoice lines cannot independently revalue the same charge.
|
||||
const counts = new Map<string, number>();
|
||||
for (const row of rows) if (row.matchedEventId) counts.set(row.matchedEventId, (counts.get(row.matchedEventId) ?? 0) + 1);
|
||||
for (const row of rows) if (row.matchedEventId && counts.get(row.matchedEventId)! > 1) row.status = "ambiguous";
|
||||
return { invoice, lines: rows };
|
||||
}),
|
||||
adjustments: (companyId: string, eventId: string) => db.select().from(costAdjustments).where(and(eq(costAdjustments.companyId, companyId), eq(costAdjustments.costEventId, eventId))).orderBy(desc(costAdjustments.createdAt)),
|
||||
adjust: async (companyId: string, eventId: string, raw: AdjustCost, actorId: string) => {
|
||||
const input = adjustCostSchema.parse(raw);
|
||||
const result = await withAccountingTransaction(db, companyId, async (tx, publications) => {
|
||||
const hash = receiptFingerprint({ eventId, ...input });
|
||||
const [existing] = await tx.select().from(costAdjustments).where(and(eq(costAdjustments.companyId, companyId), eq(costAdjustments.idempotencyKey, input.idempotencyKey)));
|
||||
if (existing) {
|
||||
if (existing.receiptHash !== hash) throw conflict("Correction key already has different contents");
|
||||
return existing;
|
||||
}
|
||||
const [event] = await tx.select({ event: costEvents, exact: sql<string>`${costEvents.costCents}::text` }).from(costEvents)
|
||||
.where(and(eq(costEvents.companyId, companyId), eq(costEvents.id, eventId))).for("update");
|
||||
if (!event) throw notFound("Cost event not found");
|
||||
if (normalizeCents(event.exact) !== input.expectedCents) throw conflict("Charge changed since review; review it again");
|
||||
if (input.invoiceLineId) {
|
||||
const reportLine = await tx.select({ line: billingInvoiceLines, invoice: billingInvoices }).from(billingInvoiceLines)
|
||||
.innerJoin(billingInvoices, eq(billingInvoices.id, billingInvoiceLines.invoiceId)).where(and(eq(billingInvoiceLines.id, input.invoiceLineId), eq(billingInvoiceLines.companyId, companyId), eq(billingInvoices.companyId, companyId)));
|
||||
const row = reportLine[0];
|
||||
if (!row) throw notFound("Invoice line not found");
|
||||
if (row.invoice.currency !== "USD" || row.line.kind !== "inference" || row.invoice.biller !== event.event.biller
|
||||
|| normalizeCents(row.line.amountCents) !== input.correctedCents
|
||||
|| row.line.costEventId && row.line.costEventId !== eventId
|
||||
|| row.line.runId && row.line.runId !== event.event.heartbeatRunId
|
||||
|| row.line.providerRequestId && row.line.providerRequestId !== event.event.providerRequestId
|
||||
|| row.line.model && row.line.model !== event.event.model) throw unprocessable("Invoice line does not support this correction");
|
||||
const review = await billingReconciliationService(tx).reconcile(companyId, row.invoice.id);
|
||||
const reviewedLine = review.lines.find(line => line.id === input.invoiceLineId);
|
||||
if (!reviewedLine || !["matched", "difference"].includes(reviewedLine.status) || reviewedLine.matchedEventId !== eventId)
|
||||
throw unprocessable("Invoice match is ambiguous or unverified; review an explicit correction without attaching this line");
|
||||
const [used] = await tx.select().from(costAdjustments).where(and(eq(costAdjustments.companyId, companyId), eq(costAdjustments.invoiceLineId, input.invoiceLineId)));
|
||||
if (used) throw conflict("Invoice line has already been applied");
|
||||
}
|
||||
const [adjustment] = await tx.insert(costAdjustments).values({ companyId, costEventId: eventId, invoiceLineId: input.invoiceLineId,
|
||||
idempotencyKey: input.idempotencyKey, receiptHash: hash, previousCents: input.expectedCents, correctedCents: input.correctedCents,
|
||||
previousStatus: event.event.costStatus, previousPricing: event.event.pricingProvenance ?? {},
|
||||
reason: input.reason, actorId, pricing: input.pricing }).returning();
|
||||
// Original provider value and fingerprint remain immutable. cost_cents
|
||||
// is the effective valuation, backed by this append-only correction.
|
||||
const [updated] = await tx.update(costEvents).set({ costCents: sql`${input.correctedCents}::numeric`,
|
||||
reportedCostCents: event.event.reportedCostCents ?? normalizeCents(event.exact), costStatus: "reported", pricingProvenance: input.pricing,
|
||||
}).where(eq(costEvents.id, eventId)).returning();
|
||||
await updateMonthlySpendProjections(tx, companyId, updated.agentId, subtractCents(input.correctedCents, input.expectedCents), updated.occurredAt);
|
||||
if (updated.heartbeatRunId) {
|
||||
const [run] = await tx.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, updated.heartbeatRunId), eq(heartbeatRuns.companyId, companyId)));
|
||||
if (run?.costAccountedAt && run.accountingProjectionVersion === "v2") await tx.update(agentRuntimeState)
|
||||
.set({ totalCostCents: sql`${agentRuntimeState.totalCostCents} + ${subtractCents(input.correctedCents, input.expectedCents)}::numeric`, updatedAt: new Date() })
|
||||
.where(and(eq(agentRuntimeState.agentId, updated.agentId), eq(agentRuntimeState.companyId, companyId)));
|
||||
}
|
||||
const budgets = budgetServiceInTransaction(tx, publications);
|
||||
await budgets.evaluateCostEvent(updated);
|
||||
await budgets.getInvocationBlock(companyId, updated.agentId, { projectId: updated.projectId });
|
||||
await logActivity(tx, { companyId, actorType: "user", actorId, action: "accounting.charge_corrected", entityType: "cost_event", entityId: eventId,
|
||||
details: { adjustmentId: adjustment.id, previousCents: input.expectedCents, correctedCents: input.correctedCents, reason: input.reason, pricing: input.pricing } }, publications);
|
||||
return adjustment;
|
||||
});
|
||||
await deliverBudgetEnforcement(db, hooks, companyId);
|
||||
return result;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { budgetPolicies, budgetReservations, heartbeatRuns, type Db } from "@paperclipai/db";
|
||||
import { centsToUnits, unitsToCents } from "@paperclipai/shared";
|
||||
import { conflict, notFound } from "../errors.js";
|
||||
import { withAccountingTransaction } from "./accounting-transaction.js";
|
||||
import { budgetServiceInTransaction, computeObservedSpend } from "./budgets.js";
|
||||
|
||||
/** Reserve before dispatch, under the same company lock as charges and policy
|
||||
* changes. Estimates constrain admission; they cannot cap a provider's bill.
|
||||
* A reservation survives timeouts and restarts until accounting proves closure. */
|
||||
export async function reserveRunBudget(db: Db, companyId: string, runId: string, projectId: string | null, ledgerScope: Record<string, unknown> = {}) {
|
||||
return withAccountingTransaction(db, companyId, async (tx, publications) => {
|
||||
const [run] = await tx.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, companyId))).for("update");
|
||||
if (!run) throw notFound("Run not found");
|
||||
if (run.costAccountedAt || !["queued", "running"].includes(run.status)) throw conflict("Run can no longer start provider work");
|
||||
const [existing] = await tx.select().from(budgetReservations).where(and(eq(budgetReservations.companyId, companyId), eq(budgetReservations.runId, runId)));
|
||||
if (existing) throw conflict("Provider dispatch has already reserved this run");
|
||||
const block = await budgetServiceInTransaction(tx, publications).getInvocationBlock(companyId, run.agentId, { projectId });
|
||||
if (block) throw conflict(block.reason);
|
||||
const candidates = await tx.select().from(budgetPolicies).where(and(eq(budgetPolicies.companyId, companyId), eq(budgetPolicies.isActive, true), eq(budgetPolicies.hardStopEnabled, true)));
|
||||
const policies = candidates.filter(p => p.metric === "billed_cents" && p.amount > 0 && (
|
||||
p.scopeType === "company" && p.scopeId === companyId || p.scopeType === "agent" && p.scopeId === run.agentId || p.scopeType === "project" && p.scopeId === projectId));
|
||||
const amount = policies.reduce((max, policy) => { const next = centsToUnits(policy.reservationCents); return next > max ? next : max; }, 0n);
|
||||
for (const policy of policies) {
|
||||
const [held] = await tx.select({ amount: sql<string>`coalesce(sum(${budgetReservations.amountCents}), 0)::text` }).from(budgetReservations).where(and(
|
||||
eq(budgetReservations.companyId, companyId), eq(budgetReservations.state, "held"),
|
||||
policy.scopeType === "agent" ? eq(budgetReservations.agentId, run.agentId) : undefined,
|
||||
policy.scopeType === "project" ? eq(budgetReservations.projectId, projectId!) : undefined,
|
||||
));
|
||||
const observed = await computeObservedSpend(tx, policy);
|
||||
// Even zero-estimate runs cannot enter a scope whose available capacity
|
||||
// is completely reserved. Outstanding reservations carry across months.
|
||||
const committed = centsToUnits(observed.totalExact) + centsToUnits(held.amount);
|
||||
const limit = centsToUnits(policy.amount);
|
||||
if (committed >= limit || committed + amount > limit) throw conflict("Available budget is reserved by unfinished runs");
|
||||
}
|
||||
// A zero-valued row also fences duplicate dispatch when estimates are off.
|
||||
const [reservation] = await tx.insert(budgetReservations).values({ companyId, runId, agentId: run.agentId, projectId,
|
||||
amountCents: unitsToCents(amount), providerStartedAt: new Date() }).returning();
|
||||
await tx.update(heartbeatRuns).set({ costAccountingPending: true,
|
||||
usageJson: sql`coalesce(${heartbeatRuns.usageJson}, '{}'::jsonb) || ${JSON.stringify({ accountingReceiptReady: false, ledgerScope: { ...(run.usageJson?.ledgerScope as object ?? {}), ...ledgerScope, projectId } })}::jsonb`,
|
||||
}).where(eq(heartbeatRuns.id, runId));
|
||||
return reservation;
|
||||
});
|
||||
}
|
||||
+219
-182
@@ -1,4 +1,5 @@
|
||||
import { and, desc, eq, gte, inArray, lt, ne, sql } from "drizzle-orm";
|
||||
import { compareCents, normalizeCents } from "@paperclipai/shared";
|
||||
import { and, desc, eq, gte, inArray, lt, ne, sql, or, isNull } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
agents,
|
||||
@@ -7,6 +8,7 @@ import {
|
||||
budgetPolicies,
|
||||
companies,
|
||||
costEvents,
|
||||
heartbeatRuns,
|
||||
projects,
|
||||
} from "@paperclipai/db";
|
||||
import type {
|
||||
@@ -23,7 +25,9 @@ import type {
|
||||
BudgetWindowKind,
|
||||
} from "@paperclipai/shared";
|
||||
import { notFound, unprocessable } from "../errors.js";
|
||||
import { logActivity } from "./activity-log.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { withAccountingTransaction } from "./accounting-transaction.js";
|
||||
import { logActivity, type LogActivityInput, type ActivityPublication } from "./activity-log.js";
|
||||
|
||||
type ScopeRecord = {
|
||||
companyId: string;
|
||||
@@ -39,6 +43,8 @@ export type BudgetEnforcementScope = {
|
||||
companyId: string;
|
||||
scopeType: BudgetScopeType;
|
||||
scopeId: string;
|
||||
/** Snapshot taken under the company lock, before a later budget grant can admit new work. */
|
||||
createdBefore?: Date;
|
||||
};
|
||||
|
||||
export type BudgetServiceHooks = {
|
||||
@@ -64,13 +70,13 @@ function resolveWindow(windowKind: BudgetWindowKind, now = new Date()) {
|
||||
}
|
||||
|
||||
function budgetStatusFromObserved(
|
||||
observedAmount: number,
|
||||
observedAmount: string,
|
||||
amount: number,
|
||||
warnPercent: number,
|
||||
): BudgetPolicySummary["status"] {
|
||||
if (amount <= 0) return "ok";
|
||||
if (observedAmount >= amount) return "hard_stop";
|
||||
if (observedAmount >= Math.ceil((amount * warnPercent) / 100)) return "warning";
|
||||
if (compareCents(observedAmount, amount) >= 0) return "hard_stop";
|
||||
if (compareCents(observedAmount, Math.ceil((amount * warnPercent) / 100)) >= 0) return "warning";
|
||||
return "ok";
|
||||
}
|
||||
|
||||
@@ -140,11 +146,11 @@ async function resolveScopeRecord(db: Db, scopeType: BudgetScopeType, scopeId: s
|
||||
};
|
||||
}
|
||||
|
||||
async function computeObservedAmount(
|
||||
export async function computeObservedSpend(
|
||||
db: Db,
|
||||
policy: Pick<PolicyRow, "companyId" | "scopeType" | "scopeId" | "windowKind" | "metric">,
|
||||
) {
|
||||
if (policy.metric !== "billed_cents") return 0;
|
||||
if (policy.metric !== "billed_cents") return { total: 0, totalExact: "0.0000000", unpricedEventCount: 0, pendingRunCount: 0 };
|
||||
|
||||
const conditions = [eq(costEvents.companyId, policy.companyId)];
|
||||
if (policy.scopeType === "agent") conditions.push(eq(costEvents.agentId, policy.scopeId));
|
||||
@@ -157,12 +163,36 @@ async function computeObservedAmount(
|
||||
|
||||
const [row] = await db
|
||||
.select({
|
||||
total: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::double precision`,
|
||||
unpricedEventCount: sql<number>`count(*) filter (where ${costEvents.costStatus} = 'unpriced' and ${costEvents.billingType} <> 'subscription_included')::int`,
|
||||
total: sql<string>`coalesce(sum(${costEvents.costCents}), 0)::text`,
|
||||
})
|
||||
.from(costEvents)
|
||||
.where(and(...conditions));
|
||||
|
||||
return Number(row?.total ?? 0);
|
||||
const pendingConditions = [eq(heartbeatRuns.companyId, policy.companyId), eq(heartbeatRuns.costAccountingPending, true),
|
||||
inArray(heartbeatRuns.status, ["succeeded", "failed", "timed_out", "cancelled"])];
|
||||
if (policy.scopeType === "agent") pendingConditions.push(eq(heartbeatRuns.agentId, policy.scopeId));
|
||||
if (policy.scopeType === "project") pendingConditions.push(sql`${heartbeatRuns.usageJson}->'ledgerScope'->>'projectId' = ${policy.scopeId}`);
|
||||
if (policy.windowKind === "calendar_month_utc") {
|
||||
pendingConditions.push(sql`coalesce(${heartbeatRuns.finishedAt}, ${heartbeatRuns.createdAt}) >= ${start.toISOString()}::timestamptz`, sql`coalesce(${heartbeatRuns.finishedAt}, ${heartbeatRuns.createdAt}) < ${end.toISOString()}::timestamptz`);
|
||||
}
|
||||
const [pending] = await db.select({ count: sql<number>`count(*)::int` }).from(heartbeatRuns).where(and(...pendingConditions));
|
||||
return { total: Number(row?.total ?? 0), totalExact: normalizeCents(row?.total ?? 0), unpricedEventCount: Number(row?.unpricedEventCount ?? 0), pendingRunCount: pending?.count ?? 0 };
|
||||
}
|
||||
|
||||
async function computeObservedAmount(db: Db, policy: PolicyRow) {
|
||||
return (await computeObservedSpend(db, policy)).total;
|
||||
}
|
||||
async function reached(db: Db, policy: PolicyRow, threshold: number) {
|
||||
return compareCents((await computeObservedSpend(db, policy)).totalExact, threshold) >= 0;
|
||||
}
|
||||
function observedBlocks(policy: PolicyRow, observed: Awaited<ReturnType<typeof computeObservedSpend>>) {
|
||||
if (!policy.isActive || !policy.hardStopEnabled || policy.amount <= 0) return false;
|
||||
return compareCents(observed.totalExact, policy.amount) >= 0 || observed.pendingRunCount > 0 || (policy.unpricedUsagePolicy !== "allow" && observed.unpricedEventCount > 0);
|
||||
}
|
||||
async function policyBlocks(db: Db, policy: PolicyRow) {
|
||||
if (!policy.isActive || !policy.hardStopEnabled || policy.amount <= 0) return false;
|
||||
return observedBlocks(policy, await computeObservedSpend(db, policy));
|
||||
}
|
||||
|
||||
function buildApprovalPayload(input: {
|
||||
@@ -170,6 +200,7 @@ function buildApprovalPayload(input: {
|
||||
scopeName: string;
|
||||
thresholdType: BudgetThresholdType;
|
||||
amountObserved: number;
|
||||
amountObservedExact: string;
|
||||
windowStart: Date;
|
||||
windowEnd: Date;
|
||||
}) {
|
||||
@@ -182,11 +213,12 @@ function buildApprovalPayload(input: {
|
||||
thresholdType: input.thresholdType,
|
||||
budgetAmount: input.policy.amount,
|
||||
observedAmount: input.amountObserved,
|
||||
observedAmountExact: input.amountObservedExact,
|
||||
warnPercent: input.policy.warnPercent,
|
||||
windowStart: input.windowStart.toISOString(),
|
||||
windowEnd: input.windowEnd.toISOString(),
|
||||
policyId: input.policy.id,
|
||||
guidance: "Raise the budget and resume the scope, or keep the scope paused.",
|
||||
guidance: "Resolve pending or unpriced accounting, raise the budget, or keep the scope paused.",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -210,7 +242,9 @@ async function markApprovalStatus(
|
||||
.where(eq(approvals.id, approvalId));
|
||||
}
|
||||
|
||||
export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
|
||||
/** Internal: caller must hold the company accounting transaction lock. */
|
||||
export function budgetServiceInTransaction(db: Db, publications: ActivityPublication[] = []) {
|
||||
const recordActivity = (input: LogActivityInput) => logActivity(db, input, publications);
|
||||
async function pauseScopeForBudget(policy: PolicyRow) {
|
||||
const now = new Date();
|
||||
if (policy.scopeType === "agent") {
|
||||
@@ -234,7 +268,7 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
|
||||
pausedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(projects.id, policy.scopeId));
|
||||
.where(and(eq(projects.id, policy.scopeId), or(isNull(projects.pausedAt), eq(projects.pauseReason, "budget"))));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -246,19 +280,24 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
|
||||
pausedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(companies.id, policy.scopeId));
|
||||
.where(and(eq(companies.id, policy.scopeId), or(eq(companies.status, "active"), and(eq(companies.status, "paused"), eq(companies.pauseReason, "budget")))));
|
||||
}
|
||||
|
||||
async function pauseAndCancelScopeForBudget(policy: PolicyRow) {
|
||||
await pauseScopeForBudget(policy);
|
||||
await hooks.cancelWorkForScope?.({
|
||||
companyId: policy.companyId,
|
||||
scopeType: policy.scopeType as BudgetScopeType,
|
||||
scopeId: policy.scopeId,
|
||||
});
|
||||
await db.update(budgetPolicies).set({
|
||||
enforcementVersion: sql`${budgetPolicies.enforcementVersion} + 1`,
|
||||
}).where(eq(budgetPolicies.id, policy.id));
|
||||
}
|
||||
|
||||
async function resumeScopeFromBudget(policy: PolicyRow) {
|
||||
const policies = await db.select().from(budgetPolicies).where(and(
|
||||
eq(budgetPolicies.companyId, policy.companyId), eq(budgetPolicies.scopeType, policy.scopeType),
|
||||
eq(budgetPolicies.scopeId, policy.scopeId), eq(budgetPolicies.isActive, true), eq(budgetPolicies.hardStopEnabled, true),
|
||||
));
|
||||
for (const candidate of policies) {
|
||||
if (await policyBlocks(db, candidate)) return;
|
||||
}
|
||||
const now = new Date();
|
||||
if (policy.scopeType === "agent") {
|
||||
await db
|
||||
@@ -269,7 +308,7 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
|
||||
pausedAt: null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(and(eq(agents.id, policy.scopeId), eq(agents.pauseReason, "budget")));
|
||||
.where(and(eq(agents.id, policy.scopeId), eq(agents.status, "paused"), eq(agents.pauseReason, "budget")));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -293,7 +332,7 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
|
||||
pausedAt: null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(and(eq(companies.id, policy.scopeId), eq(companies.pauseReason, "budget")));
|
||||
.where(and(eq(companies.id, policy.scopeId), eq(companies.status, "paused"), eq(companies.pauseReason, "budget")));
|
||||
}
|
||||
|
||||
async function getPolicyRow(policyId: string) {
|
||||
@@ -316,7 +355,7 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
|
||||
|
||||
async function buildPolicySummary(policy: PolicyRow): Promise<BudgetPolicySummary> {
|
||||
const scope = await resolveScopeRecord(db, policy.scopeType as BudgetScopeType, policy.scopeId);
|
||||
const observedAmount = await computeObservedAmount(db, policy);
|
||||
const { total: observedAmount, totalExact: observedAmountExact, unpricedEventCount, pendingRunCount } = await computeObservedSpend(db, policy);
|
||||
const { start, end } = resolveWindow(policy.windowKind as BudgetWindowKind);
|
||||
const amount = policy.isActive ? policy.amount : 0;
|
||||
const utilizationPercent =
|
||||
@@ -331,14 +370,19 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
|
||||
windowKind: policy.windowKind as BudgetWindowKind,
|
||||
amount,
|
||||
observedAmount,
|
||||
observedAmountExact,
|
||||
reservationCents: policy.reservationCents,
|
||||
unpricedEventCount,
|
||||
pendingRunCount,
|
||||
unpricedUsagePolicy: policy.unpricedUsagePolicy as "block" | "allow",
|
||||
remainingAmount: amount > 0 ? Math.max(0, amount - observedAmount) : 0,
|
||||
utilizationPercent,
|
||||
warnPercent: policy.warnPercent,
|
||||
hardStopEnabled: policy.hardStopEnabled,
|
||||
notifyEnabled: policy.notifyEnabled,
|
||||
isActive: policy.isActive,
|
||||
status: policy.isActive
|
||||
? budgetStatusFromObserved(observedAmount, amount, policy.warnPercent)
|
||||
status: await policyBlocks(db, policy) ? "hard_stop" : policy.isActive
|
||||
? budgetStatusFromObserved(observedAmountExact, amount, policy.warnPercent)
|
||||
: "ok",
|
||||
paused: scope.paused,
|
||||
pauseReason: scope.pauseReason,
|
||||
@@ -361,18 +405,21 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
|
||||
eq(budgetIncidents.policyId, policy.id),
|
||||
eq(budgetIncidents.windowStart, start),
|
||||
eq(budgetIncidents.thresholdType, thresholdType),
|
||||
ne(budgetIncidents.status, "dismissed"),
|
||||
or(eq(budgetIncidents.status, "open"), and(eq(budgetIncidents.status, "dismissed"), eq(budgetIncidents.amountLimit, policy.amount))),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(budgetIncidents.createdAt))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (existing) return { incident: existing, created: false };
|
||||
|
||||
const amountObservedExact = (await computeObservedSpend(db, policy)).totalExact;
|
||||
const scope = await resolveScopeRecord(db, policy.scopeType as BudgetScopeType, policy.scopeId);
|
||||
const payload = buildApprovalPayload({
|
||||
policy,
|
||||
scopeName: normalizeScopeName(policy.scopeType as BudgetScopeType, scope.name),
|
||||
thresholdType,
|
||||
amountObserved,
|
||||
amountObservedExact,
|
||||
windowStart: start,
|
||||
windowEnd: end,
|
||||
});
|
||||
@@ -405,7 +452,7 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
|
||||
windowEnd: end,
|
||||
thresholdType,
|
||||
amountLimit: policy.amount,
|
||||
amountObserved,
|
||||
amountObserved: sql`${amountObservedExact}::numeric`,
|
||||
status: "open",
|
||||
approvalId: approval?.id ?? null,
|
||||
})
|
||||
@@ -435,11 +482,14 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
|
||||
policyId: string,
|
||||
approvalStatus: "approved" | "rejected" | null,
|
||||
decidedByUserId: string | null,
|
||||
thresholdType?: BudgetThresholdType,
|
||||
) {
|
||||
const condition = and(eq(budgetIncidents.policyId, policyId), eq(budgetIncidents.status, "open"),
|
||||
thresholdType ? eq(budgetIncidents.thresholdType, thresholdType) : undefined);
|
||||
const openRows = await db
|
||||
.select()
|
||||
.from(budgetIncidents)
|
||||
.where(and(eq(budgetIncidents.policyId, policyId), eq(budgetIncidents.status, "open")));
|
||||
.where(condition);
|
||||
|
||||
await db
|
||||
.update(budgetIncidents)
|
||||
@@ -448,7 +498,7 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
|
||||
resolvedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(budgetIncidents.policyId, policyId), eq(budgetIncidents.status, "open")));
|
||||
.where(condition);
|
||||
|
||||
if (!approvalStatus || !decidedByUserId) return;
|
||||
for (const row of openRows) {
|
||||
@@ -494,12 +544,64 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
async function reconcileScope(companyId: string, scopeType: BudgetScopeType, scopeId: string) {
|
||||
const scope = await resolveScopeRecord(db, scopeType, scopeId);
|
||||
if (scope.companyId !== companyId) throw notFound("Budget scope not found");
|
||||
const policies = await db.select().from(budgetPolicies).where(and(
|
||||
eq(budgetPolicies.companyId, companyId), eq(budgetPolicies.scopeType, scopeType), eq(budgetPolicies.scopeId, scopeId),
|
||||
));
|
||||
for (const policy of policies) {
|
||||
const observed = await computeObservedAmount(db, policy);
|
||||
if (await policyBlocks(db, policy)) {
|
||||
await createIncidentIfNeeded(policy, "hard", observed);
|
||||
await pauseAndCancelScopeForBudget(policy);
|
||||
const title = scopeType === "company" ? "Company" : scopeType === "agent" ? "Agent" : "Project";
|
||||
if ((await computeObservedSpend(db, policy)).pendingRunCount > 0) {
|
||||
return { scopeType, scopeId, scopeName: scope.name, reason: `${title} cannot start work while completed runs await accounting.` };
|
||||
}
|
||||
if (policy.unpricedUsagePolicy !== "allow" && (await computeObservedSpend(db, policy)).unpricedEventCount > 0) {
|
||||
return { scopeType, scopeId, scopeName: scope.name, reason: `${title} cannot start work because recorded usage has no reliable price.` };
|
||||
}
|
||||
return { scopeType, scopeId, scopeName: scope.name, reason: scopeType === "project"
|
||||
? "Project cannot start work because its budget hard-stop is still exceeded."
|
||||
: `${title} is paused because its budget hard-stop was reached.` };
|
||||
}
|
||||
// A closed UTC window, inactive policy, or budget raise releases only
|
||||
// budget-owned pauses. Manual pauses remain operator decisions.
|
||||
await resolveOpenIncidentsForPolicy(policy.id, "approved", "budget_service", "hard");
|
||||
if (!policy.isActive || !policy.notifyEnabled || !(await reached(db, policy, Math.ceil(policy.amount * policy.warnPercent / 100)))) {
|
||||
await resolveOpenIncidentsForPolicy(policy.id, "approved", "budget_service", "soft");
|
||||
}
|
||||
}
|
||||
// Agent status has its own admission gate and error code. Preserve a
|
||||
// manual pause without misclassifying it as a budget failure.
|
||||
if (scopeType !== "agent" && scope.paused && scope.pauseReason !== "budget") {
|
||||
return { scopeType, scopeId, scopeName: scope.name, reason: `${scopeType === "company" ? "Company" : "Project"} is paused and cannot start new work.` };
|
||||
}
|
||||
if (scope.paused && scope.pauseReason === "budget" && !policies.length) {
|
||||
return { scopeType, scopeId, scopeName: scope.name, reason: "Budget pause requires a policy or an explicit operator resume." };
|
||||
}
|
||||
if (scope.pauseReason === "budget" && policies[0]) await resumeScopeFromBudget(policies[0]);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
reconcileCompanyScopes: async (companyId: string) => {
|
||||
const policies = await listPolicyRows(companyId);
|
||||
const seen = new Set<string>();
|
||||
for (const policy of policies) {
|
||||
const key = `${policy.scopeType}:${policy.scopeId}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
await reconcileScope(companyId, policy.scopeType as BudgetScopeType, policy.scopeId);
|
||||
}
|
||||
},
|
||||
listPolicies: async (companyId: string): Promise<BudgetPolicy[]> => {
|
||||
const rows = await listPolicyRows(companyId);
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
scopeType: row.scopeType as BudgetScopeType,
|
||||
unpricedUsagePolicy: row.unpricedUsagePolicy as "block" | "allow",
|
||||
metric: row.metric as BudgetMetric,
|
||||
windowKind: row.windowKind as BudgetWindowKind,
|
||||
}));
|
||||
@@ -515,6 +617,7 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
|
||||
throw unprocessable("Budget scope does not belong to company");
|
||||
}
|
||||
|
||||
if (input.reservationCents !== undefined && compareCents(input.reservationCents, 0) < 0) throw unprocessable("Reservation cannot be negative");
|
||||
const metric = input.metric ?? "billed_cents";
|
||||
const windowKind = input.windowKind ?? (input.scopeType === "project" ? "lifetime" : "calendar_month_utc");
|
||||
const amount = Math.max(0, Math.floor(input.amount));
|
||||
@@ -539,9 +642,11 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
|
||||
.update(budgetPolicies)
|
||||
.set({
|
||||
amount,
|
||||
reservationCents: normalizeCents(input.reservationCents ?? existing.reservationCents ?? 0),
|
||||
warnPercent: input.warnPercent ?? existing.warnPercent,
|
||||
hardStopEnabled: input.hardStopEnabled ?? existing.hardStopEnabled,
|
||||
notifyEnabled: input.notifyEnabled ?? existing.notifyEnabled,
|
||||
unpricedUsagePolicy: input.unpricedUsagePolicy ?? existing.unpricedUsagePolicy,
|
||||
isActive: nextIsActive,
|
||||
updatedByUserId: actorUserId,
|
||||
updatedAt: now,
|
||||
@@ -558,9 +663,11 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
|
||||
metric,
|
||||
windowKind,
|
||||
amount,
|
||||
reservationCents: normalizeCents(input.reservationCents ?? 0),
|
||||
warnPercent: input.warnPercent ?? 80,
|
||||
hardStopEnabled: input.hardStopEnabled ?? true,
|
||||
notifyEnabled: input.notifyEnabled ?? true,
|
||||
unpricedUsagePolicy: input.unpricedUsagePolicy ?? "block",
|
||||
isActive: nextIsActive,
|
||||
createdByUserId: actorUserId,
|
||||
updatedByUserId: actorUserId,
|
||||
@@ -588,17 +695,22 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
|
||||
.where(eq(agents.id, input.scopeId));
|
||||
}
|
||||
|
||||
if (amount > 0) {
|
||||
if (row.isActive && amount > 0) {
|
||||
const observedAmount = await computeObservedAmount(db, row);
|
||||
if (observedAmount < amount) {
|
||||
if (!(await policyBlocks(db, row))) {
|
||||
await resumeScopeFromBudget(row);
|
||||
await resolveOpenIncidentsForPolicy(row.id, actorUserId ? "approved" : null, actorUserId);
|
||||
await resolveOpenIncidentsForPolicy(row.id, "approved", actorUserId ?? "budget_service", "hard");
|
||||
if (row.notifyEnabled && (await reached(db, row, Math.ceil(row.amount * row.warnPercent / 100)))) {
|
||||
await createIncidentIfNeeded(row, "soft", observedAmount);
|
||||
} else {
|
||||
await resolveOpenIncidentsForPolicy(row.id, null, null, "soft");
|
||||
}
|
||||
} else {
|
||||
const softThreshold = Math.ceil((row.amount * row.warnPercent) / 100);
|
||||
if (row.notifyEnabled && observedAmount >= softThreshold) {
|
||||
if (row.notifyEnabled && (await reached(db, row, softThreshold)) && (!row.hardStopEnabled || !(await reached(db, row, row.amount)))) {
|
||||
await createIncidentIfNeeded(row, "soft", observedAmount);
|
||||
}
|
||||
if (row.hardStopEnabled && observedAmount >= row.amount) {
|
||||
if (await policyBlocks(db, row)) {
|
||||
await resolveOpenSoftIncidents(row.id);
|
||||
await createIncidentIfNeeded(row, "hard", observedAmount);
|
||||
await pauseAndCancelScopeForBudget(row);
|
||||
@@ -606,10 +718,10 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
|
||||
}
|
||||
} else {
|
||||
await resumeScopeFromBudget(row);
|
||||
await resolveOpenIncidentsForPolicy(row.id, actorUserId ? "approved" : null, actorUserId);
|
||||
await resolveOpenIncidentsForPolicy(row.id, "approved", actorUserId ?? "budget_service");
|
||||
}
|
||||
|
||||
await logActivity(db, {
|
||||
await recordActivity({
|
||||
companyId,
|
||||
actorType: "user",
|
||||
actorId: actorUserId ?? "board",
|
||||
@@ -640,8 +752,8 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
|
||||
companyId,
|
||||
policies,
|
||||
activeIncidents,
|
||||
pausedAgentCount: policies.filter((policy) => policy.scopeType === "agent" && policy.paused).length,
|
||||
pausedProjectCount: policies.filter((policy) => policy.scopeType === "project" && policy.paused).length,
|
||||
pausedAgentCount: new Set(policies.filter((policy) => policy.scopeType === "agent" && policy.paused).map((policy) => policy.scopeId)).size,
|
||||
pausedProjectCount: new Set(policies.filter((policy) => policy.scopeType === "project" && policy.paused).map((policy) => policy.scopeId)).size,
|
||||
pendingApprovalCount: activeIncidents.filter((incident) => incident.approvalStatus === "pending").length,
|
||||
};
|
||||
},
|
||||
@@ -667,13 +779,14 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
|
||||
|
||||
for (const policy of relevantPolicies) {
|
||||
if (policy.metric !== "billed_cents" || policy.amount <= 0) continue;
|
||||
const observedAmount = await computeObservedAmount(db, policy);
|
||||
const observed = await computeObservedSpend(db, policy);
|
||||
const observedAmount = observed.total;
|
||||
const softThreshold = Math.ceil((policy.amount * policy.warnPercent) / 100);
|
||||
|
||||
if (policy.notifyEnabled && observedAmount >= softThreshold) {
|
||||
if (policy.notifyEnabled && compareCents(observed.totalExact, softThreshold) >= 0 && (!policy.hardStopEnabled || compareCents(observed.totalExact, policy.amount) < 0)) {
|
||||
const softIncident = await createIncidentIfNeeded(policy, "soft", observedAmount);
|
||||
if (softIncident?.created) {
|
||||
await logActivity(db, {
|
||||
await recordActivity({
|
||||
companyId: policy.companyId,
|
||||
actorType: "system",
|
||||
actorId: "budget_service",
|
||||
@@ -690,12 +803,12 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
if (policy.hardStopEnabled && observedAmount >= policy.amount) {
|
||||
if (observedBlocks(policy, observed)) {
|
||||
await resolveOpenSoftIncidents(policy.id);
|
||||
const hardIncident = await createIncidentIfNeeded(policy, "hard", observedAmount);
|
||||
await pauseAndCancelScopeForBudget(policy);
|
||||
if (hardIncident?.created) {
|
||||
await logActivity(db, {
|
||||
await recordActivity({
|
||||
companyId: policy.companyId,
|
||||
actorType: "system",
|
||||
actorId: "budget_service",
|
||||
@@ -720,147 +833,18 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
|
||||
agentId: string,
|
||||
context?: { issueId?: string | null; projectId?: string | null },
|
||||
) => {
|
||||
const agent = await db
|
||||
.select({
|
||||
status: agents.status,
|
||||
pauseReason: agents.pauseReason,
|
||||
companyId: agents.companyId,
|
||||
name: agents.name,
|
||||
})
|
||||
.from(agents)
|
||||
.where(eq(agents.id, agentId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!agent || agent.companyId !== companyId) throw notFound("Agent not found");
|
||||
|
||||
const company = await db
|
||||
.select({
|
||||
status: companies.status,
|
||||
pauseReason: companies.pauseReason,
|
||||
name: companies.name,
|
||||
})
|
||||
.from(companies)
|
||||
.where(eq(companies.id, companyId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!company) throw notFound("Company not found");
|
||||
if (company.status === "paused") {
|
||||
return {
|
||||
scopeType: "company" as const,
|
||||
scopeId: companyId,
|
||||
scopeName: company.name,
|
||||
reason:
|
||||
company.pauseReason === "budget"
|
||||
? "Company is paused because its budget hard-stop was reached."
|
||||
: "Company is paused and cannot start new work.",
|
||||
};
|
||||
const scopes: Array<{ scopeType: BudgetScopeType; scopeId: string }> = [
|
||||
{ scopeType: "company", scopeId: companyId }, { scopeType: "agent", scopeId: agentId },
|
||||
...(context?.projectId ? [{ scopeType: "project" as const, scopeId: context.projectId }] : []),
|
||||
];
|
||||
for (const { scopeType, scopeId } of scopes) {
|
||||
if ((await resolveScopeRecord(db, scopeType, scopeId)).companyId !== companyId) throw notFound("Budget scope not found");
|
||||
}
|
||||
|
||||
const companyPolicy = await db
|
||||
.select()
|
||||
.from(budgetPolicies)
|
||||
.where(
|
||||
and(
|
||||
eq(budgetPolicies.companyId, companyId),
|
||||
eq(budgetPolicies.scopeType, "company"),
|
||||
eq(budgetPolicies.scopeId, companyId),
|
||||
eq(budgetPolicies.isActive, true),
|
||||
eq(budgetPolicies.metric, "billed_cents"),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (companyPolicy && companyPolicy.hardStopEnabled && companyPolicy.amount > 0) {
|
||||
const observed = await computeObservedAmount(db, companyPolicy);
|
||||
if (observed >= companyPolicy.amount) {
|
||||
return {
|
||||
scopeType: "company" as const,
|
||||
scopeId: companyId,
|
||||
scopeName: company.name,
|
||||
reason: "Company cannot start new work because its budget hard-stop is exceeded.",
|
||||
};
|
||||
}
|
||||
for (const { scopeType, scopeId } of scopes) {
|
||||
const block = await reconcileScope(companyId, scopeType, scopeId);
|
||||
if (block) return block;
|
||||
}
|
||||
|
||||
if (agent.status === "paused" && agent.pauseReason === "budget") {
|
||||
return {
|
||||
scopeType: "agent" as const,
|
||||
scopeId: agentId,
|
||||
scopeName: agent.name,
|
||||
reason: "Agent is paused because its budget hard-stop was reached.",
|
||||
};
|
||||
}
|
||||
|
||||
const agentPolicy = await db
|
||||
.select()
|
||||
.from(budgetPolicies)
|
||||
.where(
|
||||
and(
|
||||
eq(budgetPolicies.companyId, companyId),
|
||||
eq(budgetPolicies.scopeType, "agent"),
|
||||
eq(budgetPolicies.scopeId, agentId),
|
||||
eq(budgetPolicies.isActive, true),
|
||||
eq(budgetPolicies.metric, "billed_cents"),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (agentPolicy && agentPolicy.hardStopEnabled && agentPolicy.amount > 0) {
|
||||
const observed = await computeObservedAmount(db, agentPolicy);
|
||||
if (observed >= agentPolicy.amount) {
|
||||
return {
|
||||
scopeType: "agent" as const,
|
||||
scopeId: agentId,
|
||||
scopeName: agent.name,
|
||||
reason: "Agent cannot start because its budget hard-stop is still exceeded.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const candidateProjectId = context?.projectId ?? null;
|
||||
if (!candidateProjectId) return null;
|
||||
|
||||
const project = await db
|
||||
.select({
|
||||
id: projects.id,
|
||||
name: projects.name,
|
||||
companyId: projects.companyId,
|
||||
pauseReason: projects.pauseReason,
|
||||
pausedAt: projects.pausedAt,
|
||||
})
|
||||
.from(projects)
|
||||
.where(eq(projects.id, candidateProjectId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!project || project.companyId !== companyId) return null;
|
||||
const projectPolicy = await db
|
||||
.select()
|
||||
.from(budgetPolicies)
|
||||
.where(
|
||||
and(
|
||||
eq(budgetPolicies.companyId, companyId),
|
||||
eq(budgetPolicies.scopeType, "project"),
|
||||
eq(budgetPolicies.scopeId, project.id),
|
||||
eq(budgetPolicies.isActive, true),
|
||||
eq(budgetPolicies.metric, "billed_cents"),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (projectPolicy && projectPolicy.hardStopEnabled && projectPolicy.amount > 0) {
|
||||
const observed = await computeObservedAmount(db, projectPolicy);
|
||||
if (observed >= projectPolicy.amount) {
|
||||
return {
|
||||
scopeType: "project" as const,
|
||||
scopeId: project.id,
|
||||
scopeName: project.name,
|
||||
reason: "Project cannot start work because its budget hard-stop is still exceeded.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!project.pausedAt || project.pauseReason !== "budget") return null;
|
||||
return {
|
||||
scopeType: "project" as const,
|
||||
scopeId: project.id,
|
||||
scopeName: project.name,
|
||||
reason: "Project is paused because its budget hard-stop was reached.",
|
||||
};
|
||||
return null;
|
||||
},
|
||||
|
||||
resolveIncident: async (
|
||||
@@ -881,7 +865,11 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
|
||||
if (input.action === "raise_budget_and_resume") {
|
||||
const nextAmount = Math.max(0, Math.floor(input.amount ?? 0));
|
||||
const currentObserved = await computeObservedAmount(db, policy);
|
||||
if (nextAmount <= currentObserved) {
|
||||
if (policy.unpricedUsagePolicy !== "allow" && (await computeObservedSpend(db, policy)).unpricedEventCount > 0) {
|
||||
throw unprocessable("Resolve unpriced usage or explicitly allow it in the budget policy before resuming");
|
||||
}
|
||||
if ((await computeObservedSpend(db, policy)).pendingRunCount > 0) throw unprocessable("Completed runs must finish accounting before resuming");
|
||||
if (await reached(db, policy, nextAmount)) {
|
||||
throw unprocessable("New budget must exceed current observed spend");
|
||||
}
|
||||
|
||||
@@ -933,7 +921,7 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
|
||||
await markApprovalStatus(db, incident.approvalId ?? null, "rejected", input.decisionNote, actorUserId);
|
||||
}
|
||||
|
||||
await logActivity(db, {
|
||||
await recordActivity({
|
||||
companyId: incident.companyId,
|
||||
actorType: "user",
|
||||
actorId: actorUserId,
|
||||
@@ -958,3 +946,52 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Cancellation is an at-least-once external effect. A failed delivery never
|
||||
* rolls back committed spend, and its version remains pending for recovery. */
|
||||
export async function deliverBudgetEnforcement(db: Db, hooks: BudgetServiceHooks, companyId?: string) {
|
||||
if (!hooks.cancelWorkForScope) return;
|
||||
const pending = await db.select().from(budgetPolicies).where(and(
|
||||
sql`${budgetPolicies.enforcementVersion} > ${budgetPolicies.enforcementDeliveredVersion}`,
|
||||
companyId ? eq(budgetPolicies.companyId, companyId) : undefined,
|
||||
));
|
||||
for (const policy of pending) {
|
||||
try {
|
||||
const cancellation = await withAccountingTransaction(db, policy.companyId, async (tx) => {
|
||||
const [current] = await tx.select().from(budgetPolicies).where(eq(budgetPolicies.id, policy.id));
|
||||
if (!current || current.enforcementVersion !== policy.enforcementVersion || !(await policyBlocks(tx, current))) return null;
|
||||
return { companyId: policy.companyId, scopeType: policy.scopeType as BudgetScopeType, scopeId: policy.scopeId, createdBefore: new Date() };
|
||||
});
|
||||
if (cancellation) await hooks.cancelWorkForScope(cancellation);
|
||||
await db.update(budgetPolicies).set({ enforcementDeliveredVersion: policy.enforcementVersion })
|
||||
.where(and(eq(budgetPolicies.id, policy.id), eq(budgetPolicies.enforcementVersion, policy.enforcementVersion)));
|
||||
} catch (error) {
|
||||
logger.warn({ err: error, policyId: policy.id }, "Budget cancellation delivery pending; accounting is committed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
|
||||
const reads = budgetServiceInTransaction(db);
|
||||
async function mutate<T>(companyId: string, work: (service: ReturnType<typeof budgetServiceInTransaction>) => Promise<T>) {
|
||||
const result = await withAccountingTransaction(db, companyId, (tx, publications) => work(budgetServiceInTransaction(tx, publications)));
|
||||
await deliverBudgetEnforcement(db, hooks, companyId);
|
||||
return result;
|
||||
}
|
||||
return {
|
||||
...reads,
|
||||
deliverPendingEnforcement: (companyId?: string) => deliverBudgetEnforcement(db, hooks, companyId),
|
||||
reconcilePolicies: async () => {
|
||||
const rows = await db.selectDistinct({ companyId: budgetPolicies.companyId }).from(budgetPolicies);
|
||||
for (const row of rows) await mutate(row.companyId, (service) => service.reconcileCompanyScopes(row.companyId));
|
||||
},
|
||||
upsertPolicy: (companyId: string, input: BudgetPolicyUpsertInput, actorUserId: string | null) =>
|
||||
mutate(companyId, (service) => service.upsertPolicy(companyId, input, actorUserId)),
|
||||
evaluateCostEvent: (event: typeof costEvents.$inferSelect) =>
|
||||
mutate(event.companyId, (service) => service.evaluateCostEvent(event)),
|
||||
getInvocationBlock: (companyId: string, agentId: string, context?: { issueId?: string | null; projectId?: string | null }) =>
|
||||
mutate(companyId, (service) => service.getInvocationBlock(companyId, agentId, context)),
|
||||
resolveIncident: (companyId: string, incidentId: string, input: BudgetIncidentResolutionInput, actorUserId: string) =>
|
||||
mutate(companyId, (service) => service.resolveIncident(companyId, incidentId, input, actorUserId)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { centsToUsd, unitsToCents, usdToUnits } from "@paperclipai/shared";
|
||||
import type { AdapterUsageCheckpoint } from "@paperclipai/adapter-utils";
|
||||
|
||||
// Immutable, reviewed rate card. Existing receipts retain their selected rates.
|
||||
// https://developers.openai.com/api/docs/pricing (retrieved 2026-09-30)
|
||||
const VERSION = "openai-standard-2026-09-30";
|
||||
const RATES: Record<string, readonly [string, string, string, string]> = {
|
||||
"gpt-6-astra": ["10", "1", "12.5", "50"],
|
||||
"gpt-6-sol": ["2", "0.2", "2.5", "10"],
|
||||
"gpt-6.1-sol": ["2", "0.1", "2.5", "10"],
|
||||
"gpt-6-luna": ["0.1", "0.01", "0.125", "0.5"],
|
||||
"gpt-5.6-sol": ["4", "0.4", "5", "20"],
|
||||
};
|
||||
|
||||
/** Price new, direct-API receipts only. A token-price estimate is not an invoice.
|
||||
* Run totals cannot establish per-request context length or cache-write splits;
|
||||
* preserve those assumptions instead of inferring a long request from a long run. */
|
||||
export function priceCodexReceipt(
|
||||
receipt: AdapterUsageCheckpoint,
|
||||
): AdapterUsageCheckpoint {
|
||||
if (
|
||||
receipt.costUsd != null ||
|
||||
receipt.costUsdExact != null ||
|
||||
receipt.cacheAdjustedCostUsd != null ||
|
||||
receipt.provider !== "openai" ||
|
||||
receipt.biller !== "openai" ||
|
||||
!["api", "metered_api"].includes(receipt.billingType ?? "") ||
|
||||
receipt.usageBasis !== "per_run" ||
|
||||
!receipt.usage ||
|
||||
receipt.usageByModel?.length
|
||||
)
|
||||
return receipt;
|
||||
const model = receipt.model ?? "";
|
||||
const rates = Object.hasOwn(RATES, model) ? RATES[model] : undefined;
|
||||
if (!rates) return receipt;
|
||||
const usage = receipt.usage;
|
||||
const counts = [
|
||||
usage.inputTokens - (usage.cacheWriteTokens ?? 0),
|
||||
usage.cachedInputTokens ?? 0,
|
||||
usage.cacheWriteTokens ?? 0,
|
||||
usage.outputTokens,
|
||||
];
|
||||
if (counts.some((n) => !Number.isSafeInteger(n) || n < 0)) return receipt;
|
||||
const context = receipt.pricingContext;
|
||||
if (
|
||||
context?.serviceTier &&
|
||||
!["standard", "default", "priority", "fast", "flex", "batch"].includes(
|
||||
context.serviceTier,
|
||||
)
|
||||
)
|
||||
return receipt;
|
||||
const tierNumerator =
|
||||
context?.serviceTier === "priority" || context?.serviceTier === "fast"
|
||||
? 2n
|
||||
: 1n;
|
||||
const tierDenominator =
|
||||
context?.serviceTier === "flex" || context?.serviceTier === "batch"
|
||||
? 2n
|
||||
: 1n;
|
||||
const units = counts.reduce((total, count, index) => {
|
||||
const longNumerator =
|
||||
context?.contextTier === "long" ? (index === 3 ? 3n : 4n) : 2n;
|
||||
return (
|
||||
total +
|
||||
BigInt(count) * usdToUnits(rates[index]) * tierNumerator * longNumerator
|
||||
);
|
||||
}, 0n);
|
||||
const divisor = 1_000_000n * tierDenominator * 2n;
|
||||
const costCents = unitsToCents((units + divisor / 2n) / divisor);
|
||||
const assumptions = [
|
||||
!context?.serviceTier
|
||||
? "standard processing assumed"
|
||||
: `${context.serviceTier} processing`,
|
||||
!context?.contextTier
|
||||
? "short per-request context assumed"
|
||||
: `${context.contextTier} per-request context`,
|
||||
usage.cacheWriteTokens === undefined
|
||||
? "cache writes not separately reported"
|
||||
: "cache writes reported",
|
||||
];
|
||||
return {
|
||||
...receipt,
|
||||
costUsdExact: centsToUsd(costCents),
|
||||
costStatus: "estimated",
|
||||
pricingProvenance: {
|
||||
source: "rate_card",
|
||||
version: VERSION,
|
||||
evidence: `https://developers.openai.com/api/docs/pricing; ${assumptions.join("; ")}`,
|
||||
inputCentsPerMillion: unitsToCents(usdToUnits(rates[0])),
|
||||
cachedInputCentsPerMillion: unitsToCents(usdToUnits(rates[1])),
|
||||
cacheWriteCentsPerMillion: unitsToCents(usdToUnits(rates[2])),
|
||||
outputCentsPerMillion: unitsToCents(usdToUnits(rates[3])),
|
||||
serviceTier: context?.serviceTier ?? "standard",
|
||||
contextTier: context?.contextTier ?? "short",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { publishAccountingActivities } from "./accounting-transaction.js";
|
||||
import { budgetServiceInTransaction } from "./budgets.js";
|
||||
import { and, count, eq, gte, inArray, isNull, lt, notInArray, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
@@ -47,7 +49,7 @@ import {
|
||||
} from "./issue-prefix.js";
|
||||
import { environmentService } from "./environments.js";
|
||||
import { heartbeatService } from "./heartbeat.js";
|
||||
import { logActivity } from "./activity-log.js";
|
||||
import { logActivity, type ActivityPublication } from "./activity-log.js";
|
||||
import { builtInAgentService } from "./built-in-agents.js";
|
||||
|
||||
|
||||
@@ -332,12 +334,16 @@ export function companyService(db: Db) {
|
||||
data: Partial<typeof companies.$inferInsert> & { logoAssetId?: string | null },
|
||||
actor: CompanyActivityActor = SYSTEM_COMPANY_ACTOR,
|
||||
) => {
|
||||
const budgetPublications: ActivityPublication[] = [];
|
||||
const result = await db.transaction(async (tx) => {
|
||||
const existing = await getCompanyQuery(tx)
|
||||
.where(eq(companies.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!existing) return null;
|
||||
|
||||
if (data.budgetMonthlyCents !== undefined) {
|
||||
await tx.select({ id: companies.id }).from(companies).where(eq(companies.id, id)).for("no key update");
|
||||
}
|
||||
const { logoAssetId, ...companyPatch } = data;
|
||||
const willReactivate = existing.status !== "active" && companyPatch.status === "active";
|
||||
const willArchive = existing.status !== "archived" && companyPatch.status === "archived";
|
||||
@@ -431,6 +437,13 @@ export function companyService(db: Db) {
|
||||
await tx.delete(assets).where(eq(assets.id, existing.logoAssetId));
|
||||
}
|
||||
|
||||
if (data.budgetMonthlyCents !== undefined) {
|
||||
await budgetServiceInTransaction(tx as unknown as Db, budgetPublications).upsertPolicy(id, {
|
||||
scopeType: "company", scopeId: id, amount: data.budgetMonthlyCents, windowKind: "calendar_month_utc",
|
||||
}, actor.actorType === "user" ? actor.actorId : null);
|
||||
const [budgetUpdated] = await tx.select().from(companies).where(eq(companies.id, id));
|
||||
Object.assign(updated, budgetUpdated);
|
||||
}
|
||||
const [hydrated] = await hydrateCompanySpend([{
|
||||
...updated,
|
||||
logoAssetId: logoAssetId === undefined ? existing.logoAssetId : logoAssetId,
|
||||
@@ -448,6 +461,7 @@ export function companyService(db: Db) {
|
||||
};
|
||||
});
|
||||
if (!result) return null;
|
||||
publishAccountingActivities(id, budgetPublications);
|
||||
// Post-commit, fire-and-forget, and BEFORE any finalization that
|
||||
// could throw: a Cloud-pinned primary company that crossed the
|
||||
// archived boundary (either direction) rings the harness so the
|
||||
|
||||
@@ -6598,7 +6598,7 @@ export function companySkillService(db: Db) {
|
||||
const rows = await db
|
||||
.select({
|
||||
issueId: costEvents.issueId,
|
||||
costCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
|
||||
costCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::double precision`,
|
||||
inputTokens: sql<number>`coalesce(sum(${costEvents.inputTokens}), 0)::int`,
|
||||
cachedInputTokens: sql<number>`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`,
|
||||
outputTokens: sql<number>`coalesce(sum(${costEvents.outputTokens}), 0)::int`,
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export interface CostDateRange {
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
allTime?: boolean;
|
||||
}
|
||||
|
||||
/** Default reports match the UTC month used by monetary budgets. Explicit
|
||||
* bounds preserve the existing inclusive API; all-time must be requested. */
|
||||
export function resolveCostDateRange(range?: CostDateRange, now = new Date()): CostDateRange {
|
||||
if (range?.allTime || range?.from || range?.to) return range;
|
||||
return { from: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)), to: now };
|
||||
}
|
||||
+138
-66
@@ -1,16 +1,17 @@
|
||||
import { agentAvatarUrl, resolveAgentAppearance } from "@paperclipai/shared";
|
||||
import { and, desc, eq, gte, isNotNull, isNull, lt, lte, sql } from "drizzle-orm";
|
||||
import { receiptFingerprint } from "./receipt-fingerprint.js";
|
||||
import { agentAvatarUrl, resolveAgentAppearance, createCostEventSchema, normalizeCents, type MoneyInput } from "@paperclipai/shared";
|
||||
import { and, desc, eq, gte, inArray, isNotNull, isNull, lt, lte, sql } from "drizzle-orm";
|
||||
import { alias } from "drizzle-orm/pg-core";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { activityLog, agents, companies, costEvents, heartbeatRuns, issues, projects } from "@paperclipai/db";
|
||||
import { notFound, unprocessable } from "../errors.js";
|
||||
import { budgetService, type BudgetServiceHooks } from "./budgets.js";
|
||||
import { activityLog, agents, companies, costEvents, heartbeatRuns, issues, projects, goals } from "@paperclipai/db";
|
||||
import { notFound, unprocessable, conflict } from "../errors.js";
|
||||
import { budgetService, budgetServiceInTransaction, type BudgetServiceHooks } from "./budgets.js";
|
||||
import { logActivity, type LogActivityInput, type ActivityPublication } from "./activity-log.js";
|
||||
import { withAccountingTransaction } from "./accounting-transaction.js";
|
||||
import { visibleIssueCondition } from "./issue-visibility.js";
|
||||
|
||||
export interface CostDateRange {
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
}
|
||||
import { resolveCostDateRange, type CostDateRange } from "./cost-date-range.js";
|
||||
export type { CostDateRange } from "./cost-date-range.js";
|
||||
|
||||
const METERED_BILLING_TYPE = "metered_api";
|
||||
const SUBSCRIPTION_BILLING_TYPES = ["subscription_included", "subscription_overage"] as const;
|
||||
@@ -28,6 +29,26 @@ function currentUtcMonthWindow(now = new Date()) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Increment an initialized month under the company lock. On upgrade or UTC
|
||||
* rollover, initialize from the ledger in the same transaction exactly once.
|
||||
* Reports, budgets and the integrity checker continue reading the ledger. */
|
||||
export async function updateMonthlySpendProjections(db: Db, companyId: string, agentId: string, amountCents: MoneyInput, occurredAt: Date) {
|
||||
const { start, end } = currentUtcMonthWindow();
|
||||
const month = start.toISOString().slice(0, 10);
|
||||
const delta = occurredAt >= start && occurredAt < end ? normalizeCents(amountCents) : "0.0000000";
|
||||
for (const table of [companies, agents] as const) {
|
||||
const id = table === companies ? companyId : agentId;
|
||||
const companyCondition = table === companies ? eq(companies.id, companyId) : eq(agents.companyId, companyId);
|
||||
await db.update(table).set({
|
||||
spentMonthlyCents: sql`case when ${table.spendMonthUtc} = ${month} then ${table.spentMonthlyCents} + ${delta}::numeric else (
|
||||
select coalesce(sum(${costEvents.costCents}),0) from ${costEvents} where ${costEvents.companyId} = ${companyId}
|
||||
and ${costEvents.occurredAt} >= ${start.toISOString()}::timestamptz and ${costEvents.occurredAt} < ${end.toISOString()}::timestamptz
|
||||
${table === agents ? sql`and ${costEvents.agentId} = ${agentId}` : sql``}) end`,
|
||||
spendMonthUtc: month, updatedAt: new Date(),
|
||||
}).where(and(eq(table.id, id), companyCondition));
|
||||
}
|
||||
}
|
||||
|
||||
export async function getMonthlySpendTotal(
|
||||
db: Db,
|
||||
scope: { companyId: string; agentId?: string | null },
|
||||
@@ -50,56 +71,69 @@ export async function getMonthlySpendTotal(
|
||||
return Number(row?.total ?? 0);
|
||||
}
|
||||
|
||||
export async function createCostEventInTransaction(db: Db, companyId: string, data: Omit<typeof costEvents.$inferInsert, "companyId" | "receiptHash" | "costCents"> & { costCents: MoneyInput }, publications: ActivityPublication[] = [], actor?: Pick<LogActivityInput, "actorType" | "actorId" | "agentId">) {
|
||||
const parsed = createCostEventSchema.safeParse({ ...data, occurredAt: data.occurredAt.toISOString() });
|
||||
if (!parsed.success) throw unprocessable("Invalid cost receipt", parsed.error.flatten());
|
||||
const values = {
|
||||
...parsed.data,
|
||||
issueId: parsed.data.issueId ?? null,
|
||||
projectId: parsed.data.projectId ?? null,
|
||||
goalId: parsed.data.goalId ?? null,
|
||||
heartbeatRunId: parsed.data.heartbeatRunId ?? null,
|
||||
billingCode: parsed.data.billingCode ?? null,
|
||||
idempotencyKey: parsed.data.idempotencyKey ?? null,
|
||||
costCents: normalizeCents(parsed.data.costCents),
|
||||
occurredAt: new Date(parsed.data.occurredAt),
|
||||
};
|
||||
const receiptHash = receiptFingerprint(values);
|
||||
// Older receipts hashed the numeric API value. Retain replay compatibility
|
||||
// only when the decimal value survives that representation exactly.
|
||||
let legacyHash: string | null = null;
|
||||
try {
|
||||
if (normalizeCents(Number(values.costCents)) === values.costCents) legacyHash = receiptFingerprint({ ...values, costCents: Number(values.costCents) });
|
||||
} catch { /* The legacy number may round beyond the decimal storage limit. */ }
|
||||
if (values.idempotencyKey) {
|
||||
const [existing] = await db.select().from(costEvents).where(and(
|
||||
eq(costEvents.companyId, companyId), eq(costEvents.idempotencyKey, values.idempotencyKey),
|
||||
));
|
||||
if (existing) {
|
||||
if (existing.receiptHash !== receiptHash && existing.receiptHash !== legacyHash) throw conflict("Idempotency key already used for a different cost receipt");
|
||||
const [exact] = await db.select({ costCents: sql<string>`${costEvents.costCents}::text` }).from(costEvents).where(eq(costEvents.id, existing.id));
|
||||
return { ...existing, costCentsExact: normalizeCents(exact.costCents) };
|
||||
}
|
||||
}
|
||||
const links = [
|
||||
[agents, values.agentId, "Agent"], [issues, values.issueId, "Issue"],
|
||||
[projects, values.projectId, "Project"], [goals, values.goalId, "Goal"],
|
||||
[heartbeatRuns, values.heartbeatRunId, "Heartbeat run"],
|
||||
] as const;
|
||||
for (const [table, id, label] of links) {
|
||||
if (!id) continue;
|
||||
const [row] = await db.select({ companyId: table.companyId }).from(table).where(eq(table.id, id));
|
||||
if (!row) throw notFound(`${label} not found`);
|
||||
if (row.companyId !== companyId) throw unprocessable(`${label} does not belong to company`);
|
||||
}
|
||||
if (values.heartbeatRunId) {
|
||||
const [run] = await db.select({ agentId: heartbeatRuns.agentId }).from(heartbeatRuns).where(eq(heartbeatRuns.id, values.heartbeatRunId));
|
||||
if (run.agentId !== values.agentId) throw unprocessable("Heartbeat run does not belong to agent");
|
||||
}
|
||||
const [event] = await db.insert(costEvents).values({ ...values, costCents: sql`${values.costCents}::numeric`, reportedCostCents: values.costCents, id: data.id, companyId, receiptHash }).returning();
|
||||
await updateMonthlySpendProjections(db, companyId, event.agentId, values.costCents, event.occurredAt);
|
||||
await budgetServiceInTransaction(db, publications).evaluateCostEvent(event);
|
||||
await logActivity(db, {
|
||||
companyId, actorType: actor?.actorType ?? "system", actorId: actor?.actorId ?? "cost_accounting", agentId: actor?.agentId ?? event.agentId,
|
||||
runId: event.heartbeatRunId, action: "cost.reported", entityType: "cost_event", entityId: event.id,
|
||||
details: { costCents: event.costCents, costCentsExact: values.costCents, model: event.model, costStatus: event.costStatus },
|
||||
}, publications);
|
||||
return { ...event, costCentsExact: values.costCents };
|
||||
}
|
||||
|
||||
export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
|
||||
const budgets = budgetService(db, budgetHooks);
|
||||
return {
|
||||
createEvent: async (companyId: string, data: Omit<typeof costEvents.$inferInsert, "companyId">) => {
|
||||
const agent = await db
|
||||
.select()
|
||||
.from(agents)
|
||||
.where(eq(agents.id, data.agentId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!agent) throw notFound("Agent not found");
|
||||
if (agent.companyId !== companyId) {
|
||||
throw unprocessable("Agent does not belong to company");
|
||||
}
|
||||
|
||||
const event = await db
|
||||
.insert(costEvents)
|
||||
.values({
|
||||
...data,
|
||||
companyId,
|
||||
biller: data.biller ?? data.provider,
|
||||
billingType: data.billingType ?? "unknown",
|
||||
cachedInputTokens: data.cachedInputTokens ?? 0,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
const [agentMonthSpend, companyMonthSpend] = await Promise.all([
|
||||
getMonthlySpendTotal(db, { companyId, agentId: event.agentId }),
|
||||
getMonthlySpendTotal(db, { companyId }),
|
||||
]);
|
||||
|
||||
await db
|
||||
.update(agents)
|
||||
.set({
|
||||
spentMonthlyCents: agentMonthSpend,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(agents.id, event.agentId));
|
||||
|
||||
await db
|
||||
.update(companies)
|
||||
.set({
|
||||
spentMonthlyCents: companyMonthSpend,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(companies.id, companyId));
|
||||
|
||||
await budgets.evaluateCostEvent(event);
|
||||
|
||||
createEvent: async (companyId: string, data: Omit<typeof costEvents.$inferInsert, "companyId" | "receiptHash" | "costCents"> & { costCents: MoneyInput }, actor?: Pick<LogActivityInput, "actorType" | "actorId" | "agentId">) => {
|
||||
const event = await withAccountingTransaction(db, companyId, (tx, publications) => createCostEventInTransaction(tx, companyId, data, publications, actor));
|
||||
await budgets.deliverPendingEnforcement(companyId);
|
||||
return event;
|
||||
},
|
||||
|
||||
@@ -112,17 +146,28 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
|
||||
|
||||
if (!company) throw notFound("Company not found");
|
||||
|
||||
range = resolveCostDateRange(range);
|
||||
const conditions: ReturnType<typeof eq>[] = [eq(costEvents.companyId, companyId)];
|
||||
if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from));
|
||||
if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to));
|
||||
|
||||
const [{ total }] = await db
|
||||
const [{ total, totalExact, eventCount, unpricedEventCount, estimatedEventCount }] = await db
|
||||
.select({
|
||||
eventCount: sql<number>`count(*)::int`,
|
||||
estimatedEventCount: sql<number>`count(*) filter (where ${costEvents.costStatus} = 'estimated')::int`,
|
||||
unpricedEventCount: sql<number>`count(*) filter (where ${costEvents.costStatus} = 'unpriced' and ${costEvents.billingType} <> 'subscription_included')::int`,
|
||||
total: sumAsNumber(costEvents.costCents),
|
||||
totalExact: sql<string>`coalesce(sum(${costEvents.costCents}), 0)::text`,
|
||||
})
|
||||
.from(costEvents)
|
||||
.where(and(...conditions));
|
||||
|
||||
const pendingConditions = [eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.costAccountingPending, true),
|
||||
inArray(heartbeatRuns.status, ["succeeded", "failed", "timed_out", "cancelled"])];
|
||||
if (range?.from) pendingConditions.push(sql`coalesce(${heartbeatRuns.finishedAt}, ${heartbeatRuns.createdAt}) >= ${range.from.toISOString()}::timestamptz`);
|
||||
if (range?.to) pendingConditions.push(sql`coalesce(${heartbeatRuns.finishedAt}, ${heartbeatRuns.createdAt}) <= ${range.to.toISOString()}::timestamptz`);
|
||||
const [pending] = await db.select({ count: sql<number>`count(*)::int` }).from(heartbeatRuns).where(and(...pendingConditions));
|
||||
const pendingRunCount = pending?.count ?? 0;
|
||||
const spendCents = Number(total);
|
||||
const utilization =
|
||||
company.budgetMonthlyCents > 0
|
||||
@@ -131,7 +176,13 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
|
||||
|
||||
return {
|
||||
companyId,
|
||||
eventCount,
|
||||
unpricedEventCount,
|
||||
estimatedEventCount,
|
||||
pendingRunCount,
|
||||
pricingComplete: unpricedEventCount === 0 && pendingRunCount === 0,
|
||||
spendCents,
|
||||
spendCentsExact: normalizeCents(totalExact),
|
||||
budgetCents: company.budgetMonthlyCents,
|
||||
utilizationPercent: Number(utilization.toFixed(2)),
|
||||
};
|
||||
@@ -238,6 +289,7 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
|
||||
.select({
|
||||
issueCount: sql<number>`count(distinct ${issues.id})::int`,
|
||||
costCents: sumAsNumber(costEvents.costCents),
|
||||
costCentsExact: sql<string>`coalesce(sum(${costEvents.costCents}), 0)::text`,
|
||||
inputTokens: sumAsNumber(costEvents.inputTokens),
|
||||
cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens),
|
||||
outputTokens: sumAsNumber(costEvents.outputTokens),
|
||||
@@ -270,6 +322,7 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
|
||||
issueCount: Number(costRow?.issueCount ?? 0),
|
||||
includeDescendants: true,
|
||||
costCents: Number(costRow?.costCents ?? 0),
|
||||
costCentsExact: normalizeCents(costRow?.costCentsExact ?? 0),
|
||||
inputTokens: Number(costRow?.inputTokens ?? 0),
|
||||
cachedInputTokens: Number(costRow?.cachedInputTokens ?? 0),
|
||||
outputTokens: Number(costRow?.outputTokens ?? 0),
|
||||
@@ -279,6 +332,7 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
|
||||
},
|
||||
|
||||
byAgent: async (companyId: string, range?: CostDateRange) => {
|
||||
range = resolveCostDateRange(range);
|
||||
const conditions: ReturnType<typeof eq>[] = [eq(costEvents.companyId, companyId)];
|
||||
if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from));
|
||||
if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to));
|
||||
@@ -289,7 +343,10 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
|
||||
agentName: agents.name,
|
||||
agentAppearance: agents.appearance,
|
||||
agentStatus: agents.status,
|
||||
eventCount: sql<number>`count(*)::int`,
|
||||
estimatedEventCount: sql<number>`count(*) filter (where ${costEvents.costStatus} = 'estimated')::int`,
|
||||
costCents: sumAsNumber(costEvents.costCents),
|
||||
costCentsExact: sql<string>`coalesce(sum(${costEvents.costCents}), 0)::text`,
|
||||
inputTokens: sumAsNumber(costEvents.inputTokens),
|
||||
cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens),
|
||||
outputTokens: sumAsNumber(costEvents.outputTokens),
|
||||
@@ -316,6 +373,7 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
|
||||
},
|
||||
|
||||
byProvider: async (companyId: string, range?: CostDateRange) => {
|
||||
range = resolveCostDateRange(range);
|
||||
const conditions: ReturnType<typeof eq>[] = [eq(costEvents.companyId, companyId)];
|
||||
if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from));
|
||||
if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to));
|
||||
@@ -327,6 +385,7 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
|
||||
billingType: costEvents.billingType,
|
||||
model: costEvents.model,
|
||||
costCents: sumAsNumber(costEvents.costCents),
|
||||
costCentsExact: sql<string>`coalesce(sum(${costEvents.costCents}), 0)::text`,
|
||||
inputTokens: sumAsNumber(costEvents.inputTokens),
|
||||
cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens),
|
||||
outputTokens: sumAsNumber(costEvents.outputTokens),
|
||||
@@ -348,6 +407,7 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
|
||||
},
|
||||
|
||||
byBiller: async (companyId: string, range?: CostDateRange) => {
|
||||
range = resolveCostDateRange(range);
|
||||
const conditions: ReturnType<typeof eq>[] = [eq(costEvents.companyId, companyId)];
|
||||
if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from));
|
||||
if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to));
|
||||
@@ -356,6 +416,7 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
|
||||
.select({
|
||||
biller: costEvents.biller,
|
||||
costCents: sumAsNumber(costEvents.costCents),
|
||||
costCentsExact: sql<string>`coalesce(sum(${costEvents.costCents}), 0)::text`,
|
||||
inputTokens: sumAsNumber(costEvents.inputTokens),
|
||||
cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens),
|
||||
outputTokens: sumAsNumber(costEvents.outputTokens),
|
||||
@@ -390,14 +451,16 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
|
||||
{ label: "7d", hours: 168 },
|
||||
] as const;
|
||||
|
||||
const now = new Date();
|
||||
const results = await Promise.all(
|
||||
windows.map(async ({ label, hours }) => {
|
||||
const since = new Date(Date.now() - hours * 60 * 60 * 1000);
|
||||
const since = new Date(now.getTime() - hours * 60 * 60 * 1000);
|
||||
const rows = await db
|
||||
.select({
|
||||
provider: costEvents.provider,
|
||||
biller: sql<string>`case when count(distinct ${costEvents.biller}) = 1 then min(${costEvents.biller}) else 'mixed' end`,
|
||||
costCents: sumAsNumber(costEvents.costCents),
|
||||
costCentsExact: sql<string>`coalesce(sum(${costEvents.costCents}), 0)::text`,
|
||||
inputTokens: sumAsNumber(costEvents.inputTokens),
|
||||
cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens),
|
||||
outputTokens: sumAsNumber(costEvents.outputTokens),
|
||||
@@ -407,6 +470,7 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
|
||||
and(
|
||||
eq(costEvents.companyId, companyId),
|
||||
gte(costEvents.occurredAt, since),
|
||||
lte(costEvents.occurredAt, now),
|
||||
),
|
||||
)
|
||||
.groupBy(costEvents.provider)
|
||||
@@ -418,6 +482,7 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
|
||||
window: label as string,
|
||||
windowHours: hours,
|
||||
costCents: row.costCents,
|
||||
costCentsExact: normalizeCents(row.costCentsExact),
|
||||
inputTokens: row.inputTokens,
|
||||
cachedInputTokens: row.cachedInputTokens,
|
||||
outputTokens: row.outputTokens,
|
||||
@@ -429,6 +494,7 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
|
||||
},
|
||||
|
||||
byAgentModel: async (companyId: string, range?: CostDateRange) => {
|
||||
range = resolveCostDateRange(range);
|
||||
const conditions: ReturnType<typeof eq>[] = [eq(costEvents.companyId, companyId)];
|
||||
if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from));
|
||||
if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to));
|
||||
@@ -446,7 +512,10 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
|
||||
biller: costEvents.biller,
|
||||
billingType: costEvents.billingType,
|
||||
model: costEvents.model,
|
||||
eventCount: sql<number>`count(*)::int`,
|
||||
estimatedEventCount: sql<number>`count(*) filter (where ${costEvents.costStatus} = 'estimated')::int`,
|
||||
costCents: sumAsNumber(costEvents.costCents),
|
||||
costCentsExact: sql<string>`coalesce(sum(${costEvents.costCents}), 0)::text`,
|
||||
inputTokens: sumAsNumber(costEvents.inputTokens),
|
||||
cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens),
|
||||
outputTokens: sumAsNumber(costEvents.outputTokens),
|
||||
@@ -473,9 +542,9 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
|
||||
byProject: async (companyId: string, range?: CostDateRange) => {
|
||||
const issueIdAsText = sql<string>`${issues.id}::text`;
|
||||
const runProjectLinks = db
|
||||
.selectDistinctOn([activityLog.runId, issues.projectId], {
|
||||
.select({
|
||||
runId: activityLog.runId,
|
||||
projectId: issues.projectId,
|
||||
projectId: sql<string>`min(${issues.projectId}::text)::uuid`.as("linked_project_id"),
|
||||
})
|
||||
.from(activityLog)
|
||||
.innerJoin(
|
||||
@@ -493,10 +562,12 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
|
||||
isNotNull(issues.projectId),
|
||||
),
|
||||
)
|
||||
.orderBy(activityLog.runId, issues.projectId, desc(activityLog.createdAt))
|
||||
.groupBy(activityLog.runId)
|
||||
.having(sql`count(distinct ${issues.projectId}) = 1`)
|
||||
.as("run_project_links");
|
||||
|
||||
const effectiveProjectId = sql<string | null>`coalesce(${costEvents.projectId}, ${runProjectLinks.projectId})`;
|
||||
const effectiveProjectId = sql<string | null>`coalesce(${costEvents.projectId}, case when ${costEvents.receiptHash} is null then ${runProjectLinks.projectId} end)`;
|
||||
range = resolveCostDateRange(range);
|
||||
const conditions: ReturnType<typeof eq>[] = [eq(costEvents.companyId, companyId)];
|
||||
if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from));
|
||||
if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to));
|
||||
@@ -505,18 +576,19 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
|
||||
|
||||
return db
|
||||
.select({
|
||||
projectId: effectiveProjectId,
|
||||
projectId: projects.id,
|
||||
projectName: projects.name,
|
||||
costCents: costCentsExpr,
|
||||
costCentsExact: sql<string>`coalesce(sum(${costEvents.costCents}), 0)::text`,
|
||||
inputTokens: sumAsNumber(costEvents.inputTokens),
|
||||
cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens),
|
||||
outputTokens: sumAsNumber(costEvents.outputTokens),
|
||||
})
|
||||
.from(costEvents)
|
||||
.leftJoin(runProjectLinks, eq(costEvents.heartbeatRunId, runProjectLinks.runId))
|
||||
.innerJoin(projects, sql`${projects.id} = ${effectiveProjectId}`)
|
||||
.where(and(...conditions, sql`${effectiveProjectId} is not null`))
|
||||
.groupBy(effectiveProjectId, projects.name)
|
||||
.leftJoin(projects, and(eq(projects.companyId, companyId), sql`${projects.id} = ${effectiveProjectId}`))
|
||||
.where(and(...conditions))
|
||||
.groupBy(projects.id, projects.name)
|
||||
.orderBy(desc(costCentsExpr));
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, eq, gte, sql } from "drizzle-orm";
|
||||
import { and, eq, gte, lte, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agents, approvals, companies, costEvents, heartbeatRuns, issues } from "@paperclipai/db";
|
||||
import { notFound } from "../errors.js";
|
||||
@@ -96,6 +96,7 @@ export function dashboardService(db: Db) {
|
||||
and(
|
||||
eq(costEvents.companyId, companyId),
|
||||
gte(costEvents.occurredAt, monthStart),
|
||||
lte(costEvents.occurredAt, now),
|
||||
),
|
||||
));
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { and, desc, eq, gte, lte, sql } from "drizzle-orm";
|
||||
import { and, desc, eq, getTableColumns, gte, lte, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agents, costEvents, financeEvents, goals, heartbeatRuns, issues, projects } from "@paperclipai/db";
|
||||
import { notFound, unprocessable } from "../errors.js";
|
||||
import { notFound, unprocessable, conflict } from "../errors.js";
|
||||
|
||||
export interface FinanceDateRange {
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
}
|
||||
import { resolveCostDateRange, type CostDateRange as FinanceDateRange } from "./cost-date-range.js";
|
||||
import { createFinanceEventSchema, normalizeCents, subtractCents, type MoneyInput } from "@paperclipai/shared";
|
||||
import { receiptFingerprint } from "./receipt-fingerprint.js";
|
||||
import { withAccountingTransaction } from "./accounting-transaction.js";
|
||||
import { logActivity, type LogActivityInput, type ActivityPublication } from "./activity-log.js";
|
||||
export type { CostDateRange as FinanceDateRange } from "./cost-date-range.js";
|
||||
|
||||
async function assertBelongsToCompany(
|
||||
db: Db,
|
||||
@@ -28,19 +30,29 @@ async function assertBelongsToCompany(
|
||||
}
|
||||
|
||||
function rangeConditions(companyId: string, range?: FinanceDateRange) {
|
||||
range = resolveCostDateRange(range);
|
||||
const conditions: ReturnType<typeof eq>[] = [eq(financeEvents.companyId, companyId)];
|
||||
if (range?.from) conditions.push(gte(financeEvents.occurredAt, range.from));
|
||||
if (range?.to) conditions.push(lte(financeEvents.occurredAt, range.to));
|
||||
return conditions;
|
||||
}
|
||||
|
||||
export function financeService(db: Db) {
|
||||
const debitExpr = sql<number>`coalesce(sum(case when ${financeEvents.direction} = 'debit' then ${financeEvents.amountCents} else 0 end), 0)::double precision`;
|
||||
const creditExpr = sql<number>`coalesce(sum(case when ${financeEvents.direction} = 'credit' then ${financeEvents.amountCents} else 0 end), 0)::double precision`;
|
||||
const estimatedDebitExpr = sql<number>`coalesce(sum(case when ${financeEvents.direction} = 'debit' and ${financeEvents.estimated} = true then ${financeEvents.amountCents} else 0 end), 0)::double precision`;
|
||||
|
||||
return {
|
||||
createEvent: async (companyId: string, data: Omit<typeof financeEvents.$inferInsert, "companyId">) => {
|
||||
export async function createFinanceEventInTransaction(tx: Db, publications: ActivityPublication[], companyId: string, data: Omit<typeof financeEvents.$inferInsert, "companyId" | "receiptHash" | "amountCents"> & { amountCents: MoneyInput }, actor?: Pick<LogActivityInput, "actorType" | "actorId" | "agentId">) {
|
||||
const db = tx;
|
||||
const parsed = createFinanceEventSchema.safeParse({ ...data, occurredAt: data.occurredAt.toISOString() });
|
||||
if (!parsed.success) throw unprocessable("Invalid finance receipt", parsed.error.flatten());
|
||||
const values = { ...parsed.data, amountCents: normalizeCents(parsed.data.amountCents), occurredAt: new Date(parsed.data.occurredAt) };
|
||||
const receiptHash = receiptFingerprint(values);
|
||||
let legacyHash: string | null = null;
|
||||
try { if (normalizeCents(Number(values.amountCents)) === values.amountCents) legacyHash = receiptFingerprint({ ...values, amountCents: Number(values.amountCents) }); } catch { /* Decimal exceeds the legacy number range. */ }
|
||||
if (values.idempotencyKey) {
|
||||
const [existing] = await db.select().from(financeEvents).where(and(eq(financeEvents.companyId, companyId), eq(financeEvents.idempotencyKey, values.idempotencyKey)));
|
||||
if (existing) {
|
||||
if (existing.receiptHash !== receiptHash && existing.receiptHash !== legacyHash) throw conflict("Idempotency key already used for a different finance receipt");
|
||||
const [exact] = await db.select({ amount: sql<string>`${financeEvents.amountCents}::text` }).from(financeEvents).where(eq(financeEvents.id, existing.id));
|
||||
return { ...existing, amountCentsExact: normalizeCents(exact.amount) };
|
||||
}
|
||||
}
|
||||
if (data.agentId) await assertBelongsToCompany(db, agents, data.agentId, companyId, "Agent");
|
||||
if (data.issueId) await assertBelongsToCompany(db, issues, data.issueId, companyId, "Issue");
|
||||
if (data.projectId) await assertBelongsToCompany(db, projects, data.projectId, companyId, "Project");
|
||||
@@ -51,35 +63,60 @@ export function financeService(db: Db) {
|
||||
const event = await db
|
||||
.insert(financeEvents)
|
||||
.values({
|
||||
...data,
|
||||
companyId,
|
||||
currency: data.currency ?? "USD",
|
||||
direction: data.direction ?? "debit",
|
||||
estimated: data.estimated ?? false,
|
||||
...values, amountCents: sql`${values.amountCents}::numeric`, companyId, receiptHash, id: data.id,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
return event;
|
||||
},
|
||||
await logActivity(db, {
|
||||
companyId, actorType: actor?.actorType ?? "system", actorId: actor?.actorId ?? "finance_accounting", agentId: actor?.agentId,
|
||||
action: "finance_event.reported", entityType: "finance_event", entityId: event.id,
|
||||
details: { amountCents: event.amountCents, amountCentsExact: values.amountCents, currency: event.currency, biller: event.biller, eventKind: event.eventKind, direction: event.direction },
|
||||
}, publications);
|
||||
return { ...event, amountCentsExact: values.amountCents };
|
||||
}
|
||||
|
||||
export function financeService(db: Db) {
|
||||
const debitExpr = sql<string>`coalesce(sum(case when ${financeEvents.direction} = 'debit' and ${financeEvents.metadataJson}->>'source' is distinct from 'provider_cost_report' then ${financeEvents.amountCents} else 0 end), 0)`;
|
||||
const creditExpr = sql<string>`coalesce(sum(case when ${financeEvents.direction} = 'credit' and ${financeEvents.metadataJson}->>'source' is distinct from 'provider_cost_report' then ${financeEvents.amountCents} else 0 end), 0)`;
|
||||
const estimatedDebitExpr = sql<string>`coalesce(sum(case when ${financeEvents.direction} = 'debit' and ${financeEvents.estimated} = true and ${financeEvents.metadataJson}->>'source' is distinct from 'provider_cost_report' then ${financeEvents.amountCents} else 0 end), 0)`;
|
||||
|
||||
return {
|
||||
createEvent: async (companyId: string, data: Omit<typeof financeEvents.$inferInsert, "companyId" | "receiptHash" | "amountCents"> & { amountCents: MoneyInput }, actor?: Pick<LogActivityInput, "actorType" | "actorId" | "agentId">) => withAccountingTransaction(db, companyId, (tx, publications) => createFinanceEventInTransaction(tx, publications, companyId, data, actor)),
|
||||
|
||||
summary: async (companyId: string, range?: FinanceDateRange) => {
|
||||
const conditions = rangeConditions(companyId, range);
|
||||
const [row] = await db
|
||||
const rows = await db
|
||||
.select({
|
||||
debitCents: debitExpr,
|
||||
creditCents: creditExpr,
|
||||
estimatedDebitCents: estimatedDebitExpr,
|
||||
currency: financeEvents.currency,
|
||||
providerReportedCentsExact: sql<string>`coalesce(sum(case when ${financeEvents.metadataJson}->>'source' = 'provider_cost_report' then case when ${financeEvents.direction} = 'credit' then -${financeEvents.amountCents} else ${financeEvents.amountCents} end else 0 end), 0)::text`,
|
||||
debitCents: sql<number>`${debitExpr}::double precision`,
|
||||
debitCentsExact: sql<string>`${debitExpr}::text`,
|
||||
creditCents: sql<number>`${creditExpr}::double precision`,
|
||||
creditCentsExact: sql<string>`${creditExpr}::text`,
|
||||
estimatedDebitCents: sql<number>`${estimatedDebitExpr}::double precision`,
|
||||
estimatedDebitCentsExact: sql<string>`${estimatedDebitExpr}::text`,
|
||||
eventCount: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(financeEvents)
|
||||
.where(and(...conditions));
|
||||
.where(and(...conditions))
|
||||
.groupBy(financeEvents.currency)
|
||||
.orderBy(financeEvents.currency);
|
||||
const row = rows.find((row) => row.currency === "USD");
|
||||
|
||||
return {
|
||||
companyId,
|
||||
currency: "USD" as const,
|
||||
providerReportedCentsExact: normalizeCents(row?.providerReportedCentsExact ?? 0),
|
||||
providerReportedCents: Number(row?.providerReportedCentsExact ?? 0),
|
||||
currencies: rows.map((row) => ({ ...row, netCents: Number(subtractCents(row.debitCentsExact, row.creditCentsExact)), netCentsExact: subtractCents(row.debitCentsExact, row.creditCentsExact) })),
|
||||
debitCents: Number(row?.debitCents ?? 0),
|
||||
creditCents: Number(row?.creditCents ?? 0),
|
||||
netCents: Number(row?.debitCents ?? 0) - Number(row?.creditCents ?? 0),
|
||||
netCents: Number(subtractCents(row?.debitCentsExact ?? 0, row?.creditCentsExact ?? 0)),
|
||||
netCentsExact: subtractCents(row?.debitCentsExact ?? 0, row?.creditCentsExact ?? 0),
|
||||
debitCentsExact: normalizeCents(row?.debitCentsExact ?? 0),
|
||||
creditCentsExact: normalizeCents(row?.creditCentsExact ?? 0),
|
||||
estimatedDebitCentsExact: normalizeCents(row?.estimatedDebitCentsExact ?? 0),
|
||||
estimatedDebitCents: Number(row?.estimatedDebitCents ?? 0),
|
||||
eventCount: Number(row?.eventCount ?? 0),
|
||||
};
|
||||
@@ -87,44 +124,56 @@ export function financeService(db: Db) {
|
||||
|
||||
byBiller: async (companyId: string, range?: FinanceDateRange) => {
|
||||
const conditions = rangeConditions(companyId, range);
|
||||
conditions.push(sql`${financeEvents.metadataJson}->>'source' is distinct from 'provider_cost_report'`);
|
||||
return db
|
||||
.select({
|
||||
currency: financeEvents.currency,
|
||||
biller: financeEvents.biller,
|
||||
debitCents: debitExpr,
|
||||
creditCents: creditExpr,
|
||||
estimatedDebitCents: estimatedDebitExpr,
|
||||
debitCents: sql<number>`${debitExpr}::double precision`,
|
||||
debitCentsExact: sql<string>`${debitExpr}::text`,
|
||||
creditCents: sql<number>`${creditExpr}::double precision`,
|
||||
creditCentsExact: sql<string>`${creditExpr}::text`,
|
||||
estimatedDebitCents: sql<number>`${estimatedDebitExpr}::double precision`,
|
||||
estimatedDebitCentsExact: sql<string>`${estimatedDebitExpr}::text`,
|
||||
eventCount: sql<number>`count(*)::int`,
|
||||
kindCount: sql<number>`count(distinct ${financeEvents.eventKind})::int`,
|
||||
netCents: sql<number>`(${debitExpr} - ${creditExpr})::double precision`,
|
||||
netCentsExact: sql<string>`(${debitExpr} - ${creditExpr})::text`,
|
||||
})
|
||||
.from(financeEvents)
|
||||
.where(and(...conditions))
|
||||
.groupBy(financeEvents.biller)
|
||||
.groupBy(financeEvents.biller, financeEvents.currency)
|
||||
.orderBy(desc(sql`(${debitExpr} - ${creditExpr})::double precision`), financeEvents.biller);
|
||||
},
|
||||
|
||||
byKind: async (companyId: string, range?: FinanceDateRange) => {
|
||||
const conditions = rangeConditions(companyId, range);
|
||||
conditions.push(sql`${financeEvents.metadataJson}->>'source' is distinct from 'provider_cost_report'`);
|
||||
return db
|
||||
.select({
|
||||
currency: financeEvents.currency,
|
||||
eventKind: financeEvents.eventKind,
|
||||
debitCents: debitExpr,
|
||||
creditCents: creditExpr,
|
||||
estimatedDebitCents: estimatedDebitExpr,
|
||||
debitCents: sql<number>`${debitExpr}::double precision`,
|
||||
debitCentsExact: sql<string>`${debitExpr}::text`,
|
||||
creditCents: sql<number>`${creditExpr}::double precision`,
|
||||
creditCentsExact: sql<string>`${creditExpr}::text`,
|
||||
estimatedDebitCents: sql<number>`${estimatedDebitExpr}::double precision`,
|
||||
estimatedDebitCentsExact: sql<string>`${estimatedDebitExpr}::text`,
|
||||
eventCount: sql<number>`count(*)::int`,
|
||||
billerCount: sql<number>`count(distinct ${financeEvents.biller})::int`,
|
||||
netCents: sql<number>`(${debitExpr} - ${creditExpr})::double precision`,
|
||||
netCentsExact: sql<string>`(${debitExpr} - ${creditExpr})::text`,
|
||||
})
|
||||
.from(financeEvents)
|
||||
.where(and(...conditions))
|
||||
.groupBy(financeEvents.eventKind)
|
||||
.groupBy(financeEvents.eventKind, financeEvents.currency)
|
||||
.orderBy(desc(sql`(${debitExpr} - ${creditExpr})::double precision`), financeEvents.eventKind);
|
||||
},
|
||||
|
||||
list: async (companyId: string, range?: FinanceDateRange, limit: number = 100) => {
|
||||
const conditions = rangeConditions(companyId, range);
|
||||
return db
|
||||
.select()
|
||||
.select({ ...getTableColumns(financeEvents), amountCentsExact: sql<string>`${financeEvents.amountCents}::text` })
|
||||
.from(financeEvents)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(financeEvents.occurredAt), desc(financeEvents.createdAt))
|
||||
|
||||
+148
-152
@@ -14,6 +14,9 @@ import {
|
||||
type NativeWorkspaceFinalizationOwnership,
|
||||
} from "./native-runtime/native-workspace-finalization-ownership.js";
|
||||
import { hasStopOnlyCleanup, settleStopOnlyCleanup } from "./sandbox-stop-and-retain.js";
|
||||
import { reserveRunBudget } from "./budget-reservations.js";
|
||||
import { accountRunCost, reconcileRunCosts } from "./run-cost-accounting.js";
|
||||
import { createRunUsageRecorder, replayUsageReceipts } from "./usage-receipts.js";
|
||||
import { applyWorkspaceRestoreFailure } from "@paperclipai/adapter-utils/workspace-restore-result";
|
||||
import { hasWorkspaceRestoreFailure } from "@paperclipai/shared";
|
||||
import { externalConversationStateSql, nonIdleSlackIssueCondition } from "./slack-conversation-state.js";
|
||||
@@ -306,7 +309,6 @@ import {
|
||||
appendWithByteCap,
|
||||
MAX_EXCERPT_BYTES,
|
||||
} from "../adapters/utils.js";
|
||||
import { costService } from "./costs.js";
|
||||
import {
|
||||
authorizeChatConversationForBoundRun,
|
||||
isExternalChatWaitAuthorizationContention,
|
||||
@@ -678,6 +680,7 @@ const HEARTBEAT_MAX_CONCURRENT_RUNS_MAX = 50;
|
||||
const LIVENESS_BOOKKEEPING_ACTIVITY_ACTIONS = [
|
||||
"environment.lease_acquired",
|
||||
"environment.lease_released",
|
||||
"cost.reported",
|
||||
];
|
||||
const DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext";
|
||||
const EXTERNAL_ATTACHMENT_OMISSIONS_KEY = "externalAttachmentOmissions";
|
||||
@@ -5309,21 +5312,23 @@ function resolveLedgerBiller(result: AdapterExecutionResult): string {
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeBilledCostCents(
|
||||
export function normalizeBilledCostCents(
|
||||
costUsd: number | null | undefined,
|
||||
billingType: BillingType,
|
||||
): number {
|
||||
if (billingType === "subscription_included") return 0;
|
||||
if (typeof costUsd !== "number" || !Number.isFinite(costUsd)) return 0;
|
||||
return Math.max(0, Math.round(costUsd * 100));
|
||||
return Math.max(0, Number((costUsd * 100).toFixed(7)));
|
||||
}
|
||||
|
||||
export function resolveLedgerCostStatus(input: {
|
||||
costUsd: number | null | undefined;
|
||||
billingType?: BillingType;
|
||||
inputTokens: number;
|
||||
cachedInputTokens: number;
|
||||
outputTokens: number;
|
||||
}): CostStatus {
|
||||
if (input.billingType === "subscription_included") return "reported";
|
||||
// A paused turn can have neither a token receipt nor a cost receipt. Zero
|
||||
// normalized counters do not establish that its billed cost was zero.
|
||||
return typeof input.costUsd === "number" &&
|
||||
@@ -5529,12 +5534,13 @@ function readRawUsageTotals(usageJson: unknown): UsageTotals | null {
|
||||
};
|
||||
}
|
||||
|
||||
function deriveNormalizedUsageDelta(
|
||||
export function normalizeAdapterRunUsage(
|
||||
current: UsageTotals | null,
|
||||
previous: UsageTotals | null,
|
||||
usageBasis?: "per_run" | "session_cumulative" | null,
|
||||
): UsageTotals | null {
|
||||
if (!current) return null;
|
||||
if (!previous) return { ...current };
|
||||
if (!previous || usageBasis !== "session_cumulative") return { ...current };
|
||||
|
||||
const inputTokens =
|
||||
current.inputTokens >= previous.inputTokens
|
||||
@@ -12107,7 +12113,7 @@ export function heartbeatService(
|
||||
// Adapters that declare per-run usage (e.g. the ACPX lane reports each
|
||||
// turn's tokens, not session totals) must not be session-delta'd, or
|
||||
// consecutive runs would be undercounted.
|
||||
if (!sessionId || !rawUsage || usageBasis === "per_run") {
|
||||
if (!sessionId || !rawUsage || usageBasis !== "session_cumulative") {
|
||||
return {
|
||||
normalizedUsage: rawUsage,
|
||||
previousRawUsage: null as UsageTotals | null,
|
||||
@@ -12120,7 +12126,7 @@ export function heartbeatService(
|
||||
});
|
||||
const previousRawUsage = readRawUsageTotals(previousRun?.usageJson);
|
||||
return {
|
||||
normalizedUsage: deriveNormalizedUsageDelta(rawUsage, previousRawUsage),
|
||||
normalizedUsage: normalizeAdapterRunUsage(rawUsage, previousRawUsage, usageBasis),
|
||||
previousRawUsage,
|
||||
derivedFromSessionTotals: previousRawUsage !== null,
|
||||
};
|
||||
@@ -12880,6 +12886,10 @@ export function heartbeatService(
|
||||
.where(eq(heartbeatRuns.id, runId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
// Preserve the receipt-source fence when finalization enriches usage. A
|
||||
// late spool replay must still be able to complete an unfinished receipt.
|
||||
if (patch?.usageJson && previousStatus?.usageJson) patch = { ...patch, usageJson: { ...previousStatus.usageJson, ...patch.usageJson } };
|
||||
|
||||
// Cancelling a queued run that never acquired provider execution is
|
||||
// positive bootstrap evidence. It must not hold unrelated queued messages.
|
||||
if (previousStatus && (status === "cancelled" || status === "interrupted")) {
|
||||
@@ -12973,6 +12983,10 @@ export function heartbeatService(
|
||||
patch = { ...patch, resultJson: cancellationResultJson(previousStatus, status, patch?.resultJson, patch?.errorCode, patch?.error) };
|
||||
}
|
||||
|
||||
// Preserve the receipt-source fence when finalization enriches usage. A
|
||||
// late spool replay must still be able to complete an unfinished receipt.
|
||||
if (patch?.usageJson && previousStatus?.usageJson) patch = { ...patch, usageJson: { ...previousStatus.usageJson, ...patch.usageJson } };
|
||||
|
||||
// Cancelling a queued run that never acquired provider execution is
|
||||
// positive bootstrap evidence. It must not hold unrelated queued messages.
|
||||
if (
|
||||
@@ -16856,7 +16870,7 @@ export function heartbeatService(
|
||||
if (checkCostCap && policy.maxDailyCostCents !== null) {
|
||||
const [row] = await client
|
||||
.select({
|
||||
total: sql<number>`coalesce(sum(${costEvents.costCents})::bigint, 0)`,
|
||||
total: sql<number>`coalesce(sum(${costEvents.costCents})::double precision, 0)`,
|
||||
})
|
||||
.from(costEvents)
|
||||
.where(
|
||||
@@ -19924,32 +19938,7 @@ export function heartbeatService(
|
||||
normalizedUsage?: UsageTotals | null,
|
||||
) {
|
||||
await ensureRuntimeState(agent);
|
||||
const usage = normalizedUsage ?? normalizeUsageTotals(result.usage);
|
||||
const inputTokens = usage?.inputTokens ?? 0;
|
||||
const outputTokens = usage?.outputTokens ?? 0;
|
||||
const cachedInputTokens = usage?.cachedInputTokens ?? 0;
|
||||
const billingType = normalizeLedgerBillingType(result.billingType);
|
||||
const billedCostUsd = resolveCacheAdjustedCostUsd(result);
|
||||
const additionalCostCents = normalizeBilledCostCents(
|
||||
billedCostUsd,
|
||||
billingType,
|
||||
);
|
||||
const hasTokenUsage =
|
||||
inputTokens > 0 || outputTokens > 0 || cachedInputTokens > 0;
|
||||
const costStatus = resolveLedgerCostStatus({
|
||||
costUsd: billedCostUsd,
|
||||
inputTokens,
|
||||
cachedInputTokens,
|
||||
outputTokens,
|
||||
});
|
||||
const provider = result.provider ?? "unknown";
|
||||
const biller = resolveLedgerBiller(result);
|
||||
const ledgerScope = await resolveLedgerScopeForRun(
|
||||
db,
|
||||
agent.companyId,
|
||||
run,
|
||||
);
|
||||
|
||||
await accountRunCost(db, run.id, budgetHooks);
|
||||
await db
|
||||
.update(agentRuntimeState)
|
||||
.set({
|
||||
@@ -19958,34 +19947,10 @@ export function heartbeatService(
|
||||
lastRunId: run.id,
|
||||
lastRunStatus: run.status,
|
||||
lastError: run.error ?? null,
|
||||
totalInputTokens: sql`${agentRuntimeState.totalInputTokens} + ${inputTokens}`,
|
||||
totalOutputTokens: sql`${agentRuntimeState.totalOutputTokens} + ${outputTokens}`,
|
||||
totalCachedInputTokens: sql`${agentRuntimeState.totalCachedInputTokens} + ${cachedInputTokens}`,
|
||||
totalCostCents: sql`${agentRuntimeState.totalCostCents} + ${additionalCostCents}`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(agentRuntimeState.agentId, agent.id));
|
||||
|
||||
if (additionalCostCents > 0 || hasTokenUsage) {
|
||||
const costs = costService(db, budgetHooks);
|
||||
await costs.createEvent(agent.companyId, {
|
||||
heartbeatRunId: run.id,
|
||||
agentId: agent.id,
|
||||
issueId: ledgerScope.issueId,
|
||||
projectId: ledgerScope.projectId,
|
||||
billingCode: ledgerScope.billingCode,
|
||||
provider,
|
||||
biller,
|
||||
billingType,
|
||||
costStatus,
|
||||
model: result.model ?? "unknown",
|
||||
inputTokens,
|
||||
cachedInputTokens,
|
||||
outputTokens,
|
||||
costCents: additionalCostCents,
|
||||
occurredAt: new Date(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// A 403 from claimQueuedRun comes from the run's own persisted identity
|
||||
@@ -20968,6 +20933,13 @@ export function heartbeatService(
|
||||
issueContext.executionWorkspacePreference,
|
||||
}
|
||||
: null;
|
||||
const storedLedgerScope = parseObject(parseObject(run.usageJson).ledgerScope);
|
||||
const runLedgerScope = Object.keys(storedLedgerScope).length > 0
|
||||
? storedLedgerScope
|
||||
: await resolveLedgerScopeForRun(db, agent.companyId, run);
|
||||
await db.update(heartbeatRuns).set({
|
||||
usageJson: sql`coalesce(${heartbeatRuns.usageJson}, '{}'::jsonb) || ${JSON.stringify({ ledgerScope: runLedgerScope })}::jsonb`,
|
||||
}).where(eq(heartbeatRuns.id, run.id));
|
||||
const continuationSummary = issueRef && !isConversation(issueContext)
|
||||
? await getIssueContinuationSummaryDocument(db, issueRef.id)
|
||||
: null;
|
||||
@@ -24601,6 +24573,7 @@ export function heartbeatService(
|
||||
adapterFinalizeOutcome = status;
|
||||
};
|
||||
|
||||
const usageRecorder = await createRunUsageRecorder(db, { companyId: run.companyId, runId: run.id, adapterType: agent.adapterType });
|
||||
let adapterResult: AdapterExecutionResult;
|
||||
const runGoalControlRequestId = readNonEmptyString(
|
||||
context.goalControlRequestId,
|
||||
@@ -24683,8 +24656,9 @@ export function heartbeatService(
|
||||
);
|
||||
const guardedDispatch =
|
||||
await dispatchResolvedInteractionContinuationWithAtomicGate(
|
||||
(markDispatchStarted) =>
|
||||
executePaperclipNativeSession({
|
||||
async (markDispatchStarted) => {
|
||||
await reserveRunBudget(db, run.companyId, run.id, readNonEmptyString(runLedgerScope.projectId), runLedgerScope);
|
||||
return executePaperclipNativeSession({
|
||||
db,
|
||||
execution: nativeExecution,
|
||||
getFreshSessionHandoff: getNativeFreshSessionHandoff,
|
||||
@@ -24741,11 +24715,14 @@ export function heartbeatService(
|
||||
hasChanges: () => instructionCopies.hasChanges({ companyId: agent.companyId, runId: run.id, target: executionTarget }),
|
||||
collectStopped: collectStoppedInstructions,
|
||||
} : undefined,
|
||||
|
||||
onUsage: async receipt => { await usageRecorder.capture(receipt); },
|
||||
preparationSpans: nativeRunnerPreparationSpans,
|
||||
// Bootstrap with executable/home discovery while keeping
|
||||
// configured provider values and the server-selected
|
||||
// workspace boundary authoritative.
|
||||
managedGitHub: !useHostGitHub && githubSelection.configured,
|
||||
billingIdentity: managedAiRuntime ? { provider: managedAiRuntime.attribution.provider, biller: managedAiRuntime.attribution.provider, billingType: managedAiRuntime.attribution.method === "subscription" ? "subscription_included" : "metered_api" } : undefined,
|
||||
managedAiCredentialIdentity: managedAiRuntime?.identity,
|
||||
managedAiCredentialHome: managedAiRuntime ? String((managedAiRuntime.config.env as Record<string, unknown>).CODEX_HOME) : undefined,
|
||||
runnerEnvironment: {
|
||||
@@ -24817,7 +24794,8 @@ export function heartbeatService(
|
||||
markDispatchStarted();
|
||||
await persistRunProcessMetadata(run.id, meta);
|
||||
},
|
||||
}),
|
||||
});
|
||||
},
|
||||
);
|
||||
if (!guardedDispatch.dispatched) return;
|
||||
nativeDispatchStarted = true;
|
||||
@@ -24906,7 +24884,8 @@ export function heartbeatService(
|
||||
}
|
||||
const guardedDispatch =
|
||||
await dispatchResolvedInteractionContinuationWithAtomicGate(
|
||||
(markDispatchStarted) => {
|
||||
async (markDispatchStarted) => {
|
||||
await reserveRunBudget(db, run.companyId, run.id, readNonEmptyString(runLedgerScope.projectId), runLedgerScope);
|
||||
legacyAdapterEntered = true;
|
||||
return adapter.execute({
|
||||
getFreshSessionHandoff,
|
||||
@@ -24932,6 +24911,7 @@ export function heartbeatService(
|
||||
onLog,
|
||||
onMeta: onAdapterMeta,
|
||||
onEvent: onAdapterEvent,
|
||||
onUsage: async receipt => { await usageRecorder.capture(receipt); },
|
||||
startupTraceContext: getStartupTraceContext(),
|
||||
onRuntimeProgress: async (progress) => {
|
||||
await recordCurrentHeartbeatRunRuntimeProgress(
|
||||
@@ -24990,6 +24970,17 @@ export function heartbeatService(
|
||||
adapterResult = await guardedDispatch.resultPromise;
|
||||
}
|
||||
if (instructionSave) adapterResult.resultJson = { ...adapterResult.resultJson, instructionSave };
|
||||
|
||||
if (parseObject(adapterResult.executionRecovery).providerWorkStarted !== false) {
|
||||
const captured = await usageRecorder.complete(adapterResult);
|
||||
adapterResult = { ...adapterResult, ...captured, usageComplete: captured.complete };
|
||||
} else {
|
||||
// Stop may already own the terminal result. Preserve its metadata
|
||||
// while durably recording the proof needed to release admission.
|
||||
await db.update(heartbeatRuns).set({ costAccountingPending: true,
|
||||
usageJson: sql`coalesce(${heartbeatRuns.usageJson}, '{}'::jsonb) || '{"accountingProviderWorkStarted":false}'::jsonb`,
|
||||
}).where(and(eq(heartbeatRuns.id, run.id), isNull(heartbeatRuns.costAccountedAt)));
|
||||
}
|
||||
adapterResult = applyWorkspaceRestoreFailure(adapterResult);
|
||||
// A returned result can include a failed restore. Keep the workspace
|
||||
// barrier closed until required files have been restored.
|
||||
@@ -25425,64 +25416,66 @@ export function heartbeatService(
|
||||
? "timed_out"
|
||||
: "failed";
|
||||
|
||||
const cacheAdjustedCostUsd = resolveCacheAdjustedCostUsd(adapterResult);
|
||||
const usageJson =
|
||||
normalizedUsage ||
|
||||
adapterResult.costUsd != null ||
|
||||
cacheAdjustedCostUsd != null
|
||||
? ({
|
||||
...(normalizedUsage ?? {}),
|
||||
...(rawUsage
|
||||
? {
|
||||
rawInputTokens: rawUsage.inputTokens,
|
||||
rawCachedInputTokens: rawUsage.cachedInputTokens,
|
||||
rawOutputTokens: rawUsage.outputTokens,
|
||||
}
|
||||
: {}),
|
||||
...(sessionUsageResolution.derivedFromSessionTotals
|
||||
? { usageSource: "session_delta" }
|
||||
: adapterResult.usageBasis === "per_run"
|
||||
? { usageSource: "per_run" }
|
||||
: {}),
|
||||
...((nextSessionState.displayId ??
|
||||
nextSessionState.legacySessionId)
|
||||
? {
|
||||
persistedSessionId:
|
||||
nextSessionState.displayId ??
|
||||
nextSessionState.legacySessionId,
|
||||
}
|
||||
: {}),
|
||||
sessionReused:
|
||||
runtimeForAdapter.sessionId != null ||
|
||||
runtimeForAdapter.sessionDisplayId != null,
|
||||
taskSessionReused: taskSessionForRun != null,
|
||||
freshSession:
|
||||
runtimeForAdapter.sessionId == null &&
|
||||
runtimeForAdapter.sessionDisplayId == null,
|
||||
sessionRotated: sessionCompaction.rotate,
|
||||
sessionRotationReason: sessionCompaction.reason,
|
||||
configFreshness: configFreshnessResultMetadata,
|
||||
provider:
|
||||
readNonEmptyString(adapterResult.provider) ?? "unknown",
|
||||
biller: resolveLedgerBiller(adapterResult),
|
||||
model: readNonEmptyString(adapterResult.model) ?? "unknown",
|
||||
...(adapterResult.costUsd != null
|
||||
? { costUsd: adapterResult.costUsd }
|
||||
: {}),
|
||||
...(cacheAdjustedCostUsd != null
|
||||
? { cacheAdjustedCostUsd }
|
||||
: {}),
|
||||
costStatus: resolveLedgerCostStatus({
|
||||
costUsd: cacheAdjustedCostUsd,
|
||||
inputTokens: normalizedUsage?.inputTokens ?? 0,
|
||||
cachedInputTokens: normalizedUsage?.cachedInputTokens ?? 0,
|
||||
outputTokens: normalizedUsage?.outputTokens ?? 0,
|
||||
}),
|
||||
billingType: normalizeLedgerBillingType(
|
||||
adapterResult.billingType,
|
||||
),
|
||||
} as Record<string, unknown>)
|
||||
: null;
|
||||
const cacheAdjustedCostUsd = adapterResult.costUsdExact != null && adapterResult.cacheAdjustedCostUsd == null
|
||||
? null : resolveCacheAdjustedCostUsd(adapterResult);
|
||||
const usageJson: Record<string, unknown> = {
|
||||
accountingReceiptReady: adapterResult.usageComplete !== false,
|
||||
costUsdExact: adapterResult.costUsdExact ?? null,
|
||||
providerRequestId: adapterResult.providerRequestId ?? null,
|
||||
...(normalizedUsage ?? {}),
|
||||
...(adapterResult.usageByModel ? { usageByModel: adapterResult.usageByModel } : {}),
|
||||
...(rawUsage
|
||||
? {
|
||||
rawInputTokens: rawUsage.inputTokens,
|
||||
rawCachedInputTokens: rawUsage.cachedInputTokens,
|
||||
rawOutputTokens: rawUsage.outputTokens,
|
||||
}
|
||||
: {}),
|
||||
...(sessionUsageResolution.derivedFromSessionTotals
|
||||
? { usageSource: "session_delta" }
|
||||
: adapterResult.usageBasis === "per_run"
|
||||
? { usageSource: "per_run" }
|
||||
: {}),
|
||||
...((nextSessionState.displayId ??
|
||||
nextSessionState.legacySessionId)
|
||||
? {
|
||||
persistedSessionId:
|
||||
nextSessionState.displayId ??
|
||||
nextSessionState.legacySessionId,
|
||||
}
|
||||
: {}),
|
||||
sessionReused:
|
||||
runtimeForAdapter.sessionId != null ||
|
||||
runtimeForAdapter.sessionDisplayId != null,
|
||||
taskSessionReused: taskSessionForRun != null,
|
||||
freshSession:
|
||||
runtimeForAdapter.sessionId == null &&
|
||||
runtimeForAdapter.sessionDisplayId == null,
|
||||
sessionRotated: sessionCompaction.rotate,
|
||||
sessionRotationReason: sessionCompaction.reason,
|
||||
configFreshness: configFreshnessResultMetadata,
|
||||
provider:
|
||||
readNonEmptyString(adapterResult.provider) ?? "unknown",
|
||||
biller: resolveLedgerBiller(adapterResult),
|
||||
model: readNonEmptyString(adapterResult.model) ?? "unknown",
|
||||
...(adapterResult.costUsd != null
|
||||
? { costUsd: adapterResult.costUsd }
|
||||
: {}),
|
||||
...(cacheAdjustedCostUsd != null
|
||||
? { cacheAdjustedCostUsd }
|
||||
: {}),
|
||||
pricingProvenance: adapterResult.pricingProvenance,
|
||||
costStatus: adapterResult.costStatus ?? resolveLedgerCostStatus({
|
||||
costUsd: cacheAdjustedCostUsd ?? (adapterResult.costUsdExact != null ? Number(adapterResult.costUsdExact) : null),
|
||||
billingType: normalizeLedgerBillingType(adapterResult.billingType),
|
||||
inputTokens: normalizedUsage?.inputTokens ?? 0,
|
||||
cachedInputTokens: normalizedUsage?.cachedInputTokens ?? 0,
|
||||
outputTokens: normalizedUsage?.outputTokens ?? 0,
|
||||
}),
|
||||
billingType: normalizeLedgerBillingType(
|
||||
adapterResult.billingType,
|
||||
),
|
||||
};
|
||||
|
||||
const persistedResultJson = cancellationResultJson(latestRun ?? run, outcome, mergeHeartbeatRunResultJson(
|
||||
mergeRunStopMetadataForAgent(agent, outcome, {
|
||||
@@ -25506,13 +25499,17 @@ export function heartbeatService(
|
||||
adapterResult.summary ?? null,
|
||||
), runErrorCode, runErrorMessage);
|
||||
|
||||
const ledgerScope = runLedgerScope;
|
||||
const finalRunPatch: Partial<typeof heartbeatRuns.$inferInsert> = {
|
||||
// Accounting must acknowledge even a proven pre-provider failure:
|
||||
// that transaction releases the reservation without creating a charge.
|
||||
costAccountingPending: true,
|
||||
finishedAt: new Date(),
|
||||
error: runErrorMessage,
|
||||
errorCode: runErrorCode,
|
||||
exitCode: adapterResult.exitCode,
|
||||
signal: adapterResult.signal,
|
||||
usageJson,
|
||||
usageJson: { ...usageJson, ledgerScope },
|
||||
resultJson: persistedResultJson,
|
||||
sessionIdAfter:
|
||||
nextSessionState.displayId ?? nextSessionState.legacySessionId,
|
||||
@@ -25550,6 +25547,8 @@ export function heartbeatService(
|
||||
.set({
|
||||
...finalRunPatch,
|
||||
resultJson: cancellationResultJson(persistedRunWrite.run, status, finalRunPatch.resultJson, runErrorCode, runErrorMessage),
|
||||
|
||||
usageJson: { ...parseObject(persistedRunWrite.run.usageJson), ...parseObject(finalRunPatch.usageJson) },
|
||||
finishedAt:
|
||||
persistedRunWrite.run.finishedAt ?? finalRunPatch.finishedAt,
|
||||
updatedAt: new Date(),
|
||||
@@ -25564,6 +25563,9 @@ export function heartbeatService(
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
if (!persistedRun) {
|
||||
await accountRunCost(db, run.id, budgetHooks).catch((err) => {
|
||||
logger.error({ err, runId: run.id }, "Late run accounting queued for recovery");
|
||||
});
|
||||
logger.info(
|
||||
{
|
||||
runId: run.id,
|
||||
@@ -25576,6 +25578,10 @@ export function heartbeatService(
|
||||
}
|
||||
}
|
||||
if (persistedRun) {
|
||||
// Accounting recovery is independent of workspace or issue finalization.
|
||||
await accountRunCost(db, persistedRun.id, budgetHooks).catch((err) => {
|
||||
logger.error({ err, runId: run.id }, "Run accounting queued for recovery");
|
||||
});
|
||||
persistedRun =
|
||||
(await classifyAndPersistRunLiveness(
|
||||
persistedRun,
|
||||
@@ -29290,7 +29296,7 @@ export function heartbeatService(
|
||||
return { scanned: candidates.length, dispatched, recovered, deferred };
|
||||
}
|
||||
|
||||
async function listProjectScopedRunIds(companyId: string, projectId: string) {
|
||||
async function listProjectScopedRunIds(companyId: string, projectId: string, createdBefore?: Date) {
|
||||
const runIssueId = sql<
|
||||
string | null
|
||||
>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`;
|
||||
@@ -29311,6 +29317,7 @@ export function heartbeatService(
|
||||
.where(
|
||||
and(
|
||||
eq(heartbeatRuns.companyId, companyId),
|
||||
createdBefore ? lt(heartbeatRuns.createdAt, createdBefore) : undefined,
|
||||
inArray(heartbeatRuns.status, [
|
||||
...CANCELLABLE_HEARTBEAT_RUN_STATUSES,
|
||||
]),
|
||||
@@ -29413,7 +29420,12 @@ export function heartbeatService(
|
||||
error: "Cancelled due to budget pause",
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(inArray(agentWakeupRequests.id, wakeupIds));
|
||||
.where(and(
|
||||
inArray(agentWakeupRequests.id, wakeupIds),
|
||||
inArray(agentWakeupRequests.status, ["queued", "deferred_issue_execution"]),
|
||||
isNull(agentWakeupRequests.runId),
|
||||
scope.createdBefore ? lt(agentWakeupRequests.createdAt, scope.createdBefore) : undefined,
|
||||
));
|
||||
|
||||
return wakeupIds.length;
|
||||
}
|
||||
@@ -29883,35 +29895,15 @@ export function heartbeatService(
|
||||
}
|
||||
|
||||
async function cancelBudgetScopeWork(scope: BudgetEnforcementScope) {
|
||||
if (scope.scopeType === "agent") {
|
||||
await cancelActiveForAgentInternal(
|
||||
scope.scopeId,
|
||||
"Cancelled due to budget pause",
|
||||
);
|
||||
await cancelPendingWakeupsForBudgetScope(scope);
|
||||
return;
|
||||
}
|
||||
|
||||
const runIds =
|
||||
scope.scopeType === "company"
|
||||
? await db
|
||||
.select({ id: heartbeatRuns.id })
|
||||
.from(heartbeatRuns)
|
||||
.where(
|
||||
and(
|
||||
eq(heartbeatRuns.companyId, scope.companyId),
|
||||
inArray(heartbeatRuns.status, [
|
||||
...CANCELLABLE_HEARTBEAT_RUN_STATUSES,
|
||||
]),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows.map((row) => row.id))
|
||||
: await listProjectScopedRunIds(scope.companyId, scope.scopeId);
|
||||
|
||||
for (const runId of runIds) {
|
||||
await cancelRunInternal(runId, "Cancelled due to budget pause");
|
||||
}
|
||||
|
||||
const runIds = scope.scopeType === "project"
|
||||
? await listProjectScopedRunIds(scope.companyId, scope.scopeId, scope.createdBefore)
|
||||
: await db.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and(
|
||||
eq(heartbeatRuns.companyId, scope.companyId),
|
||||
scope.scopeType === "agent" ? eq(heartbeatRuns.agentId, scope.scopeId) : undefined,
|
||||
scope.createdBefore ? lt(heartbeatRuns.createdAt, scope.createdBefore) : undefined,
|
||||
inArray(heartbeatRuns.status, [...CANCELLABLE_HEARTBEAT_RUN_STATUSES]),
|
||||
)).then((rows) => rows.map((row) => row.id));
|
||||
for (const runId of runIds) await cancelRunInternal(runId, "Cancelled due to budget pause");
|
||||
await cancelPendingWakeupsForBudgetScope(scope);
|
||||
}
|
||||
|
||||
@@ -30278,6 +30270,10 @@ export function heartbeatService(
|
||||
scanSilentActiveRuns,
|
||||
|
||||
reconcileTaskWatchdogs,
|
||||
reconcileCostAccounting: async () => {
|
||||
await replayUsageReceipts(db);
|
||||
return reconcileRunCosts(db, budgetHooks);
|
||||
},
|
||||
|
||||
buildRunOutputSilence,
|
||||
|
||||
|
||||
@@ -305,6 +305,7 @@ import {
|
||||
nativeUsageCostUsd,
|
||||
nativeUsageBiller,
|
||||
normalizeNativeUsage,
|
||||
resolveNativeBilling,
|
||||
parseRemoteRunnerProcessIdentity,
|
||||
REMOTE_RUNNER_CHILD_LAUNCH_SCRIPT,
|
||||
verifyRemoteRunnerReattachment,
|
||||
@@ -985,6 +986,35 @@ describe("native provider usage normalization", () => {
|
||||
expect(nativeUsageCostUsd(usage, provider)).toBeUndefined();
|
||||
expect(normalizeNativeUsage(usage)).toMatchObject({ inputTokens: 100, outputTokens: 12 });
|
||||
});
|
||||
|
||||
it("retains cache writes and avoids counting Codex cache hits twice", () => {
|
||||
expect(normalizeNativeUsage({ runDelta: { inputTokens: 120, cacheReadTokens: 100, cacheWriteTokens: 5, outputTokens: 10 } }, { inputIncludesCacheReads: true }))
|
||||
.toEqual({ inputTokens: 20, cachedInputTokens: 100, cacheWriteTokens: 5, outputTokens: 10 });
|
||||
});
|
||||
it("does not treat a protocol-default price as free usage", () => {
|
||||
expect(nativeUsageCostUsd({ runDelta: { inputTokens: 20, outputTokens: 10, providerCostUsd: 0 } })).toBeUndefined();
|
||||
});
|
||||
it("reports the actual runtime billing identity", () => {
|
||||
expect(resolveNativeBilling({ kind: "opencode", model: "openrouter/deepseek/test", permissionMode: "allow" }))
|
||||
.toEqual({ provider: "deepseek", biller: "openrouter", billingType: "unknown" });
|
||||
expect(resolveNativeBilling({ kind: "acpx", agent: "claude", model: "claude-test", permissionMode: "approve-all",
|
||||
profile: { driverKind: "acpx_runtime", protocolVersion: 1, acpxVersion: "0.13.1", agent: "claude", agentProfileVersion: 1,
|
||||
agentServerPackage: "@zed-industries/claude-agent-acp", agentServerVersion: "1", agentRuntimePackage: null,
|
||||
agentRuntimeVersion: null, commandDigest: "fixture" },
|
||||
}, { ANTHROPIC_API_KEY: "test" }))
|
||||
.toEqual({ provider: "anthropic", biller: "anthropic", billingType: "metered_api" });
|
||||
});
|
||||
|
||||
it("prefers the adjusted turn price to cumulative session prices", () => {
|
||||
expect(nativeUsageCostUsd({ providerCostUsd: 10, runDelta: { inputTokens: 10, providerCostUsd: 2, cacheAdjustedCostUsd: 1 } })).toBe(1);
|
||||
});
|
||||
it.each(["OPENAI_BASE_URL", "OPENAI_API_BASE", "OPENAI_API_BASE_URL"])("does not price native or managed proxy traffic as direct OpenAI using %s", key => {
|
||||
const provider = { kind: "codex", model: "gpt-6-astra" } as Parameters<typeof resolveNativeBilling>[0];
|
||||
expect(resolveNativeBilling(provider, { OPENAI_API_KEY: "fixture", [key]: "https://proxy.example/v1" }).biller).toBe("unknown");
|
||||
expect(resolveNativeBilling(provider, { [key]: "https://proxy.example/v1" }, { provider: "openai", biller: "openai", billingType: "metered_api" }).biller).toBe("unknown");
|
||||
expect(resolveNativeBilling(provider, {}, { provider: "openai", biller: "openai", billingType: "metered_api" })).toMatchObject({ biller: "openai", billingType: "metered_api" });
|
||||
});
|
||||
|
||||
it("reads remote runner run-delta tokens and provider cost", () => {
|
||||
const usage = {
|
||||
total: {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { readLocalAiCredentialFile } from "../local-ai-credential-file.js";
|
||||
import { prepareGrokRunnerCredentials } from "./grok-runner-credentials.js";
|
||||
import { copyBackGrokAuth } from "@paperclipai/adapter-grok-local/server";
|
||||
|
||||
import { inferOpenAiCompatibleBiller, type AdapterUsageCheckpoint } from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
isSupportedRemoteCodexVersion,
|
||||
parseCodexCliVersion,
|
||||
@@ -7256,6 +7257,7 @@ export async function executePaperclipNativeSession(input: {
|
||||
collectStopped: () => Promise<void>;
|
||||
};
|
||||
/** Persist task-level continuity before a durable goal can outlive this run. */
|
||||
onUsage?: (receipt: AdapterUsageCheckpoint) => Promise<void>;
|
||||
onGoalCheckpoint?: (snapshot: PersistedNativeSession) => Promise<void>;
|
||||
sessionGoalControl?: NativeSessionGoalControl | null;
|
||||
resumeSessionGoalHeartbeat?: boolean;
|
||||
@@ -7267,6 +7269,7 @@ export async function executePaperclipNativeSession(input: {
|
||||
/** Private grant materialization; never a user-configured host path. */
|
||||
managedAiCredentialHome?: string;
|
||||
managedAiCredentialIdentity?: string;
|
||||
billingIdentity?: Pick<AdapterExecutionResult, "provider" | "biller" | "billingType">;
|
||||
runnerExecutionTarget?: AdapterExecutionTarget | null;
|
||||
/** Resolved per-run authorization; not an independent instance setting. */
|
||||
runnerIngressAuthorized?: boolean;
|
||||
@@ -8597,6 +8600,20 @@ async function executePaperclipNativeSessionWithinScope(
|
||||
},
|
||||
{ parentName: "task.run" },
|
||||
);
|
||||
// Persist provider accounting before any workspace/issue finalization. A
|
||||
// detached controller or failed finalizer must not lose a completed turn.
|
||||
const accountingUsage = normalizeNativeUsage(native.usage, { inputIncludesCacheReads: input.execution.provider.kind === "codex" });
|
||||
const accountingCost = nativeUsageCostUsd(native.usage, input.execution.provider);
|
||||
const billing = resolveNativeBilling(input.execution.provider, input.runnerEnvironment, input.billingIdentity);
|
||||
await input.onUsage?.({ usage: accountingUsage, ...billing, complete: true, usageBasis: "per_run",
|
||||
model: input.execution.provider.model ?? "unknown", costUsd: accountingCost ?? null });
|
||||
if (!input.onUsage) await input.db.update(heartbeatRuns).set({
|
||||
costAccountingPending: true,
|
||||
usageJson: sql`coalesce(${heartbeatRuns.usageJson}, '{}'::jsonb) || ${JSON.stringify({
|
||||
...accountingUsage, ...billing, accountingReceiptReady: true, model: input.execution.provider.model ?? "unknown",
|
||||
costUsd: accountingCost ?? null, usageSource: "per_run",
|
||||
})}::jsonb`,
|
||||
}).where(and(eq(heartbeatRuns.id, input.execution.binding.runId), eq(heartbeatRuns.companyId, input.execution.binding.companyId), isNull(heartbeatRuns.costAccountedAt)));
|
||||
try {
|
||||
await completeManagedNativeCredentialTurn(managedCredentialSession);
|
||||
} catch {
|
||||
@@ -9219,9 +9236,9 @@ async function executePaperclipNativeSessionWithinScope(
|
||||
summary: native.result.summary,
|
||||
sessionId: native.normalizedSessionId,
|
||||
sessionDisplayId: native.providerSessionId ?? native.normalizedSessionId,
|
||||
provider: nativeUsageBiller(input.execution.provider),
|
||||
...resolveNativeBilling(input.execution.provider, input.runnerEnvironment, input.billingIdentity),
|
||||
model: input.execution.provider.model,
|
||||
usage: normalizeNativeUsage(native.usage),
|
||||
usage: normalizeNativeUsage(native.usage, { inputIncludesCacheReads: input.execution.provider.kind === "codex" }),
|
||||
costUsd: nativeUsageCostUsd(native.usage, input.execution.provider),
|
||||
usageBasis: "per_run",
|
||||
nativeFinalization: finalization,
|
||||
@@ -9297,18 +9314,14 @@ export function nativeUsageCostUsd(
|
||||
if (provider?.kind === "acpx" && ["cursor", "copilot", "pi"].includes(provider.agent)) return undefined;
|
||||
if (!usage) return undefined;
|
||||
const measurement = nativeUsageMeasurement(usage);
|
||||
const direct =
|
||||
numericUsageField(usage, [
|
||||
"providerCostUsd",
|
||||
"cacheAdjustedCostUsd",
|
||||
"costUsd",
|
||||
]) ??
|
||||
numericUsageField(measurement, [
|
||||
"providerCostUsd",
|
||||
"cacheAdjustedCostUsd",
|
||||
"costUsd",
|
||||
]);
|
||||
if (direct !== undefined) return direct;
|
||||
const direct = numericUsageField(measurement, ["cacheAdjustedCostUsd", "providerCostUsd", "costUsd"])
|
||||
?? numericUsageField(usage, ["cacheAdjustedCostUsd", "providerCostUsd", "costUsd"]);
|
||||
// PRP currently defaults an absent providerCostUsd to zero. That zero is
|
||||
// not proof of a free request when the runtime observed token consumption.
|
||||
if (direct !== undefined && direct > 0) return direct;
|
||||
if (direct === 0 && (numericUsageField(measurement, ["inputTokens"]) ?? 0) === 0
|
||||
&& (numericUsageField(measurement, ["outputTokens"]) ?? 0) === 0
|
||||
&& (numericUsageField(measurement, ["cacheReadTokens", "cachedInputTokens"]) ?? 0) === 0) return 0;
|
||||
const cost = record(usage.cost);
|
||||
const currency =
|
||||
typeof cost.currency === "string" ? cost.currency.toUpperCase() : "USD";
|
||||
@@ -9316,7 +9329,34 @@ export function nativeUsageCostUsd(
|
||||
return numericUsageField(cost, ["amount", "total"]);
|
||||
}
|
||||
|
||||
export function normalizeNativeUsage(usage: Record<string, unknown> | null) {
|
||||
/** Billing identity follows the selected runtime and resolved credentials.
|
||||
* Unknown authentication remains unknown; a provider name does not prove billing mode. */
|
||||
export function resolveNativeBilling(provider: NativeExecutionInput["provider"], env: NodeJS.ProcessEnv = {}, identity?: Pick<AdapterExecutionResult, "provider" | "biller" | "billingType">): Pick<AdapterExecutionResult, "provider" | "biller" | "billingType"> {
|
||||
if (identity) return identity.provider === "openai" && identity.billingType === "metered_api"
|
||||
? { ...identity, biller: inferOpenAiCompatibleBiller(env, identity.biller ?? "unknown") }
|
||||
: identity;
|
||||
if (provider.kind === "claude_managed") return { provider: "anthropic", biller: "anthropic", billingType: "metered_api" };
|
||||
if (provider.kind === "aws_agentcore") return { provider: "aws_agentcore", biller: "aws_agentcore", billingType: "metered_api" };
|
||||
if (provider.kind === "acpx" && ["cursor", "copilot"].includes(provider.agent)) {
|
||||
const biller = nativeUsageBiller(provider);
|
||||
return { provider: biller, biller, billingType: "unknown" };
|
||||
}
|
||||
if (provider.kind === "acpx" && provider.agent === "claude") {
|
||||
if (env.CLAUDE_CODE_USE_BEDROCK === "1") return { provider: "anthropic", biller: "aws_bedrock", billingType: "metered_api" };
|
||||
return { provider: "anthropic", biller: "anthropic", billingType: env.ANTHROPIC_API_KEY ? "metered_api" : env.CLAUDE_CODE_OAUTH_TOKEN ? "subscription_included" : "unknown" };
|
||||
}
|
||||
if (provider.kind === "opencode" || (provider.kind === "acpx" && provider.agent === "pi")) {
|
||||
const parts = (provider.model ?? "").split("/");
|
||||
const biller = parts.length > 1 ? parts[0] : provider.kind === "acpx" ? nativeUsageBiller(provider) : "unknown";
|
||||
const modelProvider = biller === "openrouter" && parts.length > 2 ? parts[1] : biller;
|
||||
return { provider: modelProvider, biller, billingType: "unknown" };
|
||||
}
|
||||
if (provider.kind !== "codex" && !(provider.kind === "acpx" && provider.agent === "codex"))
|
||||
return { provider: "unknown", biller: "unknown", billingType: "unknown" };
|
||||
return { provider: "openai", biller: env.OPENAI_API_KEY ? inferOpenAiCompatibleBiller(env, "openai") : "unknown", billingType: env.OPENAI_API_KEY ? "metered_api" : "unknown" };
|
||||
}
|
||||
|
||||
export function normalizeNativeUsage(usage: Record<string, unknown> | null, options: { inputIncludesCacheReads?: boolean } = {}) {
|
||||
if (!usage) return undefined;
|
||||
const measurement = nativeUsageMeasurement(usage);
|
||||
const cache = record(measurement.cache);
|
||||
@@ -9327,13 +9367,13 @@ export function normalizeNativeUsage(usage: Record<string, unknown> | null) {
|
||||
"cacheReadTokens",
|
||||
"cachedReadTokens",
|
||||
]) ?? numericUsageField(cache, ["read"]);
|
||||
const cacheWriteTokens = numericUsageField(measurement, ["cacheWriteTokens", "cacheCreationInputTokens", "cachedWriteTokens"]) ?? numericUsageField(cache, ["write"]);
|
||||
return {
|
||||
inputTokens:
|
||||
numericUsageField(measurement, [
|
||||
"inputTokens",
|
||||
"input",
|
||||
"promptTokens",
|
||||
]) ?? 0,
|
||||
...(cacheWriteTokens === undefined ? {} : { cacheWriteTokens }),
|
||||
inputTokens: Math.max(0,
|
||||
(numericUsageField(measurement, ["inputTokens", "input", "promptTokens"]) ?? 0)
|
||||
- (options.inputIncludesCacheReads ? cachedInputTokens ?? 0 : 0)
|
||||
) + (options.inputIncludesCacheReads ? 0 : cacheWriteTokens ?? 0),
|
||||
outputTokens:
|
||||
numericUsageField(measurement, [
|
||||
"outputTokens",
|
||||
|
||||
@@ -337,7 +337,7 @@ export function pluginManagedAgentService(
|
||||
// and its canonical history. Transfer only its proven stable-key marker to
|
||||
// the replacement installation, atomically with the new scoped bindings.
|
||||
await upsertBinding(companyId, declaration, agentId, {}, adapterType, tx);
|
||||
const [relinked] = await tx.update(agents).set({
|
||||
await tx.update(agents).set({
|
||||
metadata: managedMetadata(options.pluginId, options.pluginKey, declaration, agent.metadata),
|
||||
updatedAt: new Date(),
|
||||
}).where(and(eq(agents.id, agentId), eq(agents.companyId, companyId))).returning();
|
||||
@@ -346,6 +346,8 @@ export function pluginManagedAgentService(
|
||||
action: "plugin.managed_agent.relinked", entityType: "agent", entityId: agentId,
|
||||
details: { sourcePluginKey: options.pluginKey, managedResourceKey: declaration.agentKey, previousPluginId },
|
||||
});
|
||||
const relinked = await agentService(tx as unknown as Db).getById(agentId);
|
||||
if (!relinked) throw notFound("Managed agent not found after relink");
|
||||
return relinked as Agent;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
import { z } from "zod";
|
||||
import { and, eq, gte, inArray, lt } from "drizzle-orm";
|
||||
import {
|
||||
companySecrets,
|
||||
providerBillingSnapshots,
|
||||
type Db,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
centsToUnits,
|
||||
usdToUnits,
|
||||
unitsToCents,
|
||||
importProviderCostsSchema,
|
||||
type ImportProviderCosts,
|
||||
} from "@paperclipai/shared";
|
||||
import { secretService } from "./secrets.js";
|
||||
import { withAccountingTransaction } from "./accounting-transaction.js";
|
||||
import { createFinanceEventInTransaction } from "./finance.js";
|
||||
import { forbidden, unprocessable } from "../errors.js";
|
||||
|
||||
const pageSchema = z.object({
|
||||
data: z
|
||||
.array(
|
||||
z.object({
|
||||
start_time: z.number().int().optional(),
|
||||
end_time: z.number().int().optional(),
|
||||
starting_at: z.iso.datetime({ offset: true }).optional(),
|
||||
ending_at: z.iso.datetime({ offset: true }).optional(),
|
||||
results: z
|
||||
.array(
|
||||
z.object({
|
||||
project_id: z.string().nullable().optional(),
|
||||
workspace_id: z.string().nullable().optional(),
|
||||
amount: z.union([
|
||||
z.string(),
|
||||
z.object({ value: z.number().finite(), currency: z.string() }),
|
||||
]),
|
||||
currency: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.max(5000),
|
||||
}),
|
||||
)
|
||||
.max(31),
|
||||
has_more: z.boolean(),
|
||||
next_page: z.string().max(4000).nullable().optional(),
|
||||
});
|
||||
export interface DailyProviderCost {
|
||||
day: string;
|
||||
scopeId: string;
|
||||
amountCents: string;
|
||||
}
|
||||
|
||||
async function boundedJson(response: Response) {
|
||||
if (!response.ok || !response.body)
|
||||
throw unprocessable(
|
||||
"Provider billing is unavailable. Check the admin credential and selected account.",
|
||||
);
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let size = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const next = await reader.read();
|
||||
if (next.done) break;
|
||||
size += next.value.byteLength;
|
||||
if (size > 2_000_000)
|
||||
throw unprocessable(
|
||||
"Provider billing response is too large; use a shorter period.",
|
||||
);
|
||||
chunks.push(next.value);
|
||||
}
|
||||
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||
} finally {
|
||||
await reader.cancel().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
/** Complete every page before writing. Group only by project/workspace so a
|
||||
* provider's line-item changes cannot manufacture duplicate charges. */
|
||||
export async function fetchProviderDailyCosts(
|
||||
input: ImportProviderCosts,
|
||||
credential: string,
|
||||
fetcher = fetch,
|
||||
): Promise<DailyProviderCost[]> {
|
||||
const parsed = importProviderCostsSchema.parse(input);
|
||||
const from = Date.parse(parsed.from),
|
||||
to = Date.parse(parsed.to);
|
||||
const base = new URL(
|
||||
parsed.provider === "openai"
|
||||
? "https://api.openai.com/v1/organization/costs"
|
||||
: "https://api.anthropic.com/v1/organizations/cost_report",
|
||||
);
|
||||
base.searchParams.set(
|
||||
parsed.provider === "openai" ? "start_time" : "starting_at",
|
||||
parsed.provider === "openai"
|
||||
? String(from / 1000)
|
||||
: new Date(from).toISOString(),
|
||||
);
|
||||
base.searchParams.set(
|
||||
parsed.provider === "openai" ? "end_time" : "ending_at",
|
||||
parsed.provider === "openai"
|
||||
? String(to / 1000)
|
||||
: new Date(to).toISOString(),
|
||||
);
|
||||
base.searchParams.set("bucket_width", "1d");
|
||||
base.searchParams.set("limit", "31");
|
||||
base.searchParams.append(
|
||||
"group_by[]",
|
||||
parsed.provider === "openai" ? "project_id" : "workspace_id",
|
||||
);
|
||||
const headers: Record<string, string> =
|
||||
parsed.provider === "openai"
|
||||
? {
|
||||
Authorization: `Bearer ${credential}`,
|
||||
"OpenAI-Organization": parsed.accountId,
|
||||
}
|
||||
: { "x-api-key": credential, "anthropic-version": "2023-06-01" };
|
||||
headers["User-Agent"] = "Paperclip/1.0 (https://paperclip.ing)";
|
||||
const totals = new Map<string, bigint>();
|
||||
const seen = new Set<string>();
|
||||
const days = new Set<number>();
|
||||
// Missing requested scopes in an otherwise complete daily report mean zero.
|
||||
for (let time = from; time < to; time += 86400000)
|
||||
for (const scope of parsed.scopeIds)
|
||||
totals.set(`${new Date(time).toISOString().slice(0, 10)}:${scope}`, 0n);
|
||||
const cursors = new Set<string>();
|
||||
const signal = AbortSignal.timeout(30_000);
|
||||
if (parsed.provider === "anthropic") {
|
||||
const identity = z.object({ id: z.string() }).parse(
|
||||
await boundedJson(
|
||||
await fetcher("https://api.anthropic.com/v1/organizations/me", {
|
||||
headers,
|
||||
signal,
|
||||
redirect: "error",
|
||||
}),
|
||||
),
|
||||
);
|
||||
if (identity.id !== parsed.accountId)
|
||||
throw unprocessable(
|
||||
"The admin credential belongs to a different provider organization.",
|
||||
);
|
||||
}
|
||||
for (let page = 0; page < 100; page++) {
|
||||
const response = await fetcher(base, {
|
||||
headers,
|
||||
signal,
|
||||
redirect: "error",
|
||||
});
|
||||
const body = pageSchema.parse(await boundedJson(response));
|
||||
for (const bucket of body.data) {
|
||||
const start =
|
||||
parsed.provider === "openai"
|
||||
? (bucket.start_time ?? NaN) * 1000
|
||||
: Date.parse(bucket.starting_at ?? "");
|
||||
const end =
|
||||
parsed.provider === "openai"
|
||||
? (bucket.end_time ?? NaN) * 1000
|
||||
: Date.parse(bucket.ending_at ?? "");
|
||||
if (
|
||||
!Number.isFinite(start) ||
|
||||
start < from ||
|
||||
end > to ||
|
||||
end - start !== 86400000 ||
|
||||
start % 86400000 !== 0
|
||||
)
|
||||
throw unprocessable("Provider returned an unexpected billing period.");
|
||||
days.add(start);
|
||||
for (const result of bucket.results) {
|
||||
const scopeField = parsed.provider === "openai" ? "project_id" : "workspace_id";
|
||||
if (!Object.hasOwn(result, scopeField)) {
|
||||
throw unprocessable("Provider report is missing the requested project/workspace grouping.");
|
||||
}
|
||||
const scope =
|
||||
(parsed.provider === "openai"
|
||||
? result.project_id
|
||||
: result.workspace_id) ?? "default";
|
||||
if (!parsed.scopeIds.includes(scope)) continue;
|
||||
const identity = `${new Date(start).toISOString().slice(0, 10)}:${scope}`;
|
||||
if (seen.has(identity))
|
||||
throw unprocessable("Provider returned overlapping billing buckets.");
|
||||
seen.add(identity);
|
||||
const currency =
|
||||
typeof result.amount === "object"
|
||||
? result.amount.currency
|
||||
: result.currency;
|
||||
if (currency?.toUpperCase() !== "USD")
|
||||
throw unprocessable("Only USD provider cost reports are supported.");
|
||||
const units =
|
||||
parsed.provider === "openai" && typeof result.amount === "object"
|
||||
? usdToUnits(result.amount.value)
|
||||
: parsed.provider === "anthropic" &&
|
||||
typeof result.amount === "string"
|
||||
? centsToUnits(result.amount)
|
||||
: -1n;
|
||||
if (units < 0n)
|
||||
throw unprocessable("Provider returned an invalid billing amount.");
|
||||
totals.set(identity, units);
|
||||
}
|
||||
}
|
||||
if (!body.has_more) {
|
||||
if (days.size !== (to - from) / 86400000)
|
||||
throw unprocessable(
|
||||
"Provider report is incomplete; no financial events were changed.",
|
||||
);
|
||||
return [...totals].map(([key, value]) => ({
|
||||
day: key.slice(0, 10),
|
||||
scopeId: key.slice(11),
|
||||
amountCents: unitsToCents(value),
|
||||
}));
|
||||
}
|
||||
if (!body.next_page || cursors.has(body.next_page))
|
||||
throw unprocessable("Provider returned an invalid billing cursor.");
|
||||
cursors.add(body.next_page);
|
||||
base.searchParams.set("page", body.next_page);
|
||||
}
|
||||
throw unprocessable("Provider billing report exceeded the page limit.");
|
||||
}
|
||||
|
||||
export async function importProviderDailyCosts(
|
||||
db: Db,
|
||||
companyId: string,
|
||||
raw: ImportProviderCosts,
|
||||
actorId: string,
|
||||
fetcher = fetch,
|
||||
) {
|
||||
const input = importProviderCostsSchema.parse(raw);
|
||||
const [secret] = await db
|
||||
.select()
|
||||
.from(companySecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(companySecrets.id, input.secretId),
|
||||
eq(companySecrets.companyId, companyId),
|
||||
),
|
||||
);
|
||||
if (!secret || secret.scope !== "company")
|
||||
throw forbidden("Select a company billing credential.");
|
||||
const credential = await secretService(db).resolveSecretValue(
|
||||
companyId,
|
||||
secret.id,
|
||||
"latest",
|
||||
{
|
||||
accessContext: {
|
||||
consumerType: "system",
|
||||
consumerId: "provider-billing-import",
|
||||
actorType: "user",
|
||||
actorId,
|
||||
},
|
||||
},
|
||||
);
|
||||
// Compare database revisions, not application clocks: concurrent importers
|
||||
// can run on different hosts or start in the same millisecond.
|
||||
const snapshots = await db
|
||||
.select()
|
||||
.from(providerBillingSnapshots)
|
||||
.where(
|
||||
and(
|
||||
eq(providerBillingSnapshots.companyId, companyId),
|
||||
eq(providerBillingSnapshots.provider, input.provider),
|
||||
eq(providerBillingSnapshots.accountId, input.accountId),
|
||||
inArray(providerBillingSnapshots.scopeId, input.scopeIds),
|
||||
gte(providerBillingSnapshots.day, input.from),
|
||||
lt(providerBillingSnapshots.day, input.to),
|
||||
),
|
||||
);
|
||||
const expectedRevisions = new Map(
|
||||
snapshots.map((row) => [`${row.day}:${row.scopeId}`, row.revision]),
|
||||
);
|
||||
const daily = await fetchProviderDailyCosts(input, credential, fetcher);
|
||||
return applyProviderDailyCosts(
|
||||
db,
|
||||
companyId,
|
||||
input,
|
||||
daily,
|
||||
actorId,
|
||||
expectedRevisions,
|
||||
);
|
||||
}
|
||||
|
||||
export async function applyProviderDailyCosts(
|
||||
db: Db,
|
||||
companyId: string,
|
||||
input: ImportProviderCosts,
|
||||
daily: DailyProviderCost[],
|
||||
actorId: string,
|
||||
expectedRevisions?: ReadonlyMap<string, number>,
|
||||
) {
|
||||
return withAccountingTransaction(db, companyId, async (tx, publications) => {
|
||||
let eventsCreated = 0;
|
||||
for (const day of daily) {
|
||||
const where = and(
|
||||
eq(providerBillingSnapshots.companyId, companyId),
|
||||
eq(providerBillingSnapshots.provider, input.provider),
|
||||
eq(providerBillingSnapshots.accountId, input.accountId),
|
||||
eq(providerBillingSnapshots.scopeId, day.scopeId),
|
||||
eq(providerBillingSnapshots.day, day.day),
|
||||
);
|
||||
const [previous] = await tx
|
||||
.select()
|
||||
.from(providerBillingSnapshots)
|
||||
.where(where);
|
||||
const delta =
|
||||
centsToUnits(day.amountCents) -
|
||||
centsToUnits(previous?.amountCents ?? "0");
|
||||
if (previous && delta === 0n) continue;
|
||||
if (
|
||||
expectedRevisions &&
|
||||
(previous?.revision ?? 0) !==
|
||||
(expectedRevisions.get(`${day.day}:${day.scopeId}`) ?? 0)
|
||||
)
|
||||
throw unprocessable(
|
||||
"A newer provider report was imported. Fetch the report again.",
|
||||
);
|
||||
const revision = (previous?.revision ?? 0) + 1;
|
||||
const [snapshot] = previous
|
||||
? await tx
|
||||
.update(providerBillingSnapshots)
|
||||
.set({
|
||||
amountCents: day.amountCents,
|
||||
revision,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(where)
|
||||
.returning()
|
||||
: await tx
|
||||
.insert(providerBillingSnapshots)
|
||||
.values({
|
||||
companyId,
|
||||
provider: input.provider,
|
||||
accountId: input.accountId,
|
||||
...day,
|
||||
revision,
|
||||
})
|
||||
.returning();
|
||||
if (!delta) continue;
|
||||
await createFinanceEventInTransaction(
|
||||
tx,
|
||||
publications,
|
||||
companyId,
|
||||
{
|
||||
idempotencyKey: `provider-report:${snapshot.id}:${revision}`,
|
||||
biller: input.provider,
|
||||
eventKind: "inference_charge",
|
||||
direction: delta < 0n ? "credit" : "debit",
|
||||
currency: "USD",
|
||||
amountCents: unitsToCents(delta < 0n ? -delta : delta),
|
||||
occurredAt: new Date(`${day.day}T00:00:00.000Z`),
|
||||
description:
|
||||
`${input.provider} cost report · ${day.scopeId} · ${day.day}${revision > 1 ? " (correction)" : ""}`.slice(
|
||||
0,
|
||||
500,
|
||||
),
|
||||
metadataJson: {
|
||||
source: "provider_cost_report",
|
||||
accountId: input.accountId,
|
||||
scopeId: day.scopeId,
|
||||
revision,
|
||||
totalCents: day.amountCents,
|
||||
},
|
||||
},
|
||||
{ actorType: "user", actorId },
|
||||
);
|
||||
eventsCreated++;
|
||||
}
|
||||
return { daysRead: daily.length, eventsCreated };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
function canonical(value: unknown): unknown {
|
||||
if (value instanceof Date) return value.toISOString();
|
||||
if (Array.isArray(value)) return value.map(canonical);
|
||||
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => [key, canonical(item)]));
|
||||
return value;
|
||||
}
|
||||
|
||||
export function receiptFingerprint(value: unknown): string {
|
||||
return createHash("sha256").update(JSON.stringify(canonical(value))).digest("hex");
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { usdToUnits, unitsToCents, centsToUnits } from "@paperclipai/shared";
|
||||
import { and, asc, eq, inArray, sql } from "drizzle-orm";
|
||||
import { accountingRuntimeBaselines, budgetReservations, agentRuntimeState, agents, heartbeatRuns, issues, projects, type Db } from "@paperclipai/db";
|
||||
import { withAccountingTransaction } from "./accounting-transaction.js";
|
||||
import { createCostEventInTransaction } from "./costs.js";
|
||||
import { budgetService, deliverBudgetEnforcement, type BudgetServiceHooks } from "./budgets.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
|
||||
const terminalStatuses = ["succeeded", "failed", "timed_out", "cancelled"];
|
||||
const object = (value: unknown): Record<string, unknown> => value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
const text = (value: unknown) => typeof value === "string" && value.length > 0 ? value : null;
|
||||
const amount = (value: unknown) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
|
||||
|
||||
/** The run's persisted usage is a durable receipt. Ledger, runtime totals, and
|
||||
* acknowledgement commit together, including after a restart or failed run. */
|
||||
export async function accountRunCost(db: Db, runId: string, hooks: BudgetServiceHooks = {}) {
|
||||
const [identity] = await db.select({ companyId: heartbeatRuns.companyId }).from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
|
||||
if (!identity) return false;
|
||||
const accounted = await withAccountingTransaction(db, identity.companyId, async (tx, publications) => {
|
||||
const [run] = await tx.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)).for("update");
|
||||
if (!run?.costAccountingPending || !terminalStatuses.includes(run.status)) return false;
|
||||
if (run.costAccountedAt) {
|
||||
await tx.update(heartbeatRuns).set({ costAccountingPending: false }).where(eq(heartbeatRuns.id, run.id));
|
||||
return false;
|
||||
}
|
||||
const preProviderFailure = object(object(run.resultJson).executionRecovery).providerWorkStarted === false
|
||||
|| object(run.usageJson).accountingProviderWorkStarted === false;
|
||||
// A stop can mark the run terminal before the provider's shutdown receipt
|
||||
// arrives. Keep the debt pending instead of acknowledging an invented zero
|
||||
// and permanently discarding that late receipt.
|
||||
if (!preProviderFailure && object(run.usageJson).accountingReceiptReady === false) return false;
|
||||
// Acknowledgement is part of the same transaction. Budget checks inside
|
||||
// this transaction must not mistake this receipt for missing accounting.
|
||||
await tx.update(heartbeatRuns).set({ costAccountingPending: false, costAccountedAt: new Date() }).where(eq(heartbeatRuns.id, run.id));
|
||||
await tx.update(budgetReservations).set({ state: preProviderFailure ? "released" : "settled", settledAt: new Date() })
|
||||
.where(and(eq(budgetReservations.companyId, run.companyId), eq(budgetReservations.runId, run.id), eq(budgetReservations.state, "held")));
|
||||
if (preProviderFailure) return true;
|
||||
const usage = object(run.usageJson);
|
||||
const scope = object(usage.ledgerScope);
|
||||
// Deleted attribution targets become unallocated; a deletion must never
|
||||
// prevent recording provider spend. Foreign-company references still fail.
|
||||
for (const [table, key] of [[issues, "issueId"], [projects, "projectId"]] as const) {
|
||||
const id = text(scope[key]);
|
||||
if (id) {
|
||||
const [linked] = await tx.select({ id: table.id }).from(table).where(eq(table.id, id));
|
||||
if (!linked) scope[key] = null;
|
||||
}
|
||||
}
|
||||
const costUsd = amount(usage.cacheAdjustedCostUsd) ?? text(usage.costUsdExact) ?? amount(usage.costUsd);
|
||||
const billingType = text(usage.billingType) ?? "unknown";
|
||||
const costUnits = billingType === "subscription_included" ? 0n : usdToUnits(costUsd ?? 0);
|
||||
const costCents = unitsToCents(costUnits);
|
||||
const receipt = {
|
||||
idempotencyKey: `heartbeat:${run.id}:final`,
|
||||
agentId: run.agentId, heartbeatRunId: run.id,
|
||||
issueId: text(scope.issueId), projectId: text(scope.projectId), billingCode: text(scope.billingCode),
|
||||
provider: text(usage.provider) ?? "unknown", biller: text(usage.biller) ?? text(usage.provider) ?? "unknown",
|
||||
providerRequestId: text(usage.providerRequestId),
|
||||
pricingProvenance: object(usage.pricingProvenance),
|
||||
model: text(usage.model) ?? "unknown", billingType,
|
||||
costStatus: costUsd === null && billingType !== "subscription_included" ? "unpriced" : usage.costStatus === "estimated" ? "estimated" : "reported",
|
||||
inputTokens: amount(usage.inputTokens) ?? 0, cachedInputTokens: amount(usage.cachedInputTokens) ?? 0, outputTokens: amount(usage.outputTokens) ?? 0,
|
||||
costCents, occurredAt: run.finishedAt ?? run.createdAt,
|
||||
};
|
||||
const parts = Array.isArray(usage.usageByModel) ? usage.usageByModel.map(object) : [];
|
||||
const tokensMatch = ["inputTokens", "cachedInputTokens", "outputTokens"].every((key) =>
|
||||
parts.reduce((sum, part) => sum + (amount(object(part.usage)[key]) ?? 0), 0) === (amount(usage[key]) ?? 0));
|
||||
const costMatches = costUsd !== null && parts.reduce((sum, part) => sum + usdToUnits(amount(part.costUsd) ?? 0), 0n) === usdToUnits(costUsd);
|
||||
const validParts = parts.length > 0 && tokensMatch && costMatches
|
||||
&& parts.every((part) => text(part.model) && amount(part.costUsd) !== null)
|
||||
&& new Set(parts.map((part) => part.model)).size === parts.length;
|
||||
const events = [];
|
||||
if (validParts) {
|
||||
let allocated = 0n;
|
||||
for (const [index, part] of parts.entries()) {
|
||||
const partUsage = object(part.usage);
|
||||
const remainder = costUnits - allocated;
|
||||
const reported = usdToUnits(amount(part.costUsd) ?? 0);
|
||||
const partUnits = billingType === "subscription_included" ? 0n : index === parts.length - 1 ? remainder : reported < remainder ? reported : remainder;
|
||||
allocated += partUnits;
|
||||
const partCents = unitsToCents(partUnits);
|
||||
events.push(await createCostEventInTransaction(tx, run.companyId, {
|
||||
...receipt, idempotencyKey: `heartbeat:${run.id}:model:${index}`, model: text(part.model)!,
|
||||
inputTokens: amount(partUsage.inputTokens) ?? 0, cachedInputTokens: amount(partUsage.cachedInputTokens) ?? 0,
|
||||
outputTokens: amount(partUsage.outputTokens) ?? 0, costCents: partCents,
|
||||
}, publications));
|
||||
}
|
||||
} else {
|
||||
events.push(await createCostEventInTransaction(tx, run.companyId, receipt, publications));
|
||||
}
|
||||
const event = events.reduce((total, part) => ({
|
||||
inputTokens: total.inputTokens + part.inputTokens, cachedInputTokens: total.cachedInputTokens + part.cachedInputTokens,
|
||||
outputTokens: total.outputTokens + part.outputTokens, costCents: total.costCents + centsToUnits(part.costCentsExact),
|
||||
}), { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, costCents: 0n });
|
||||
const [agent] = await tx.select({ adapterType: agents.adapterType }).from(agents).where(eq(agents.id, run.agentId));
|
||||
await tx.insert(agentRuntimeState).values({ agentId: run.agentId, companyId: run.companyId, adapterType: agent.adapterType }).onConflictDoNothing();
|
||||
await tx.execute(sql`insert into accounting_runtime_baselines (agent_id,company_id,cost_cents,input_tokens,cached_input_tokens,output_tokens)
|
||||
select agent_id,company_id,total_cost_cents,total_input_tokens,total_cached_input_tokens,total_output_tokens
|
||||
from agent_runtime_state where agent_id = ${run.agentId} on conflict do nothing`);
|
||||
await tx.update(agentRuntimeState).set({
|
||||
totalInputTokens: sql`${agentRuntimeState.totalInputTokens} + ${event.inputTokens}`,
|
||||
totalCachedInputTokens: sql`${agentRuntimeState.totalCachedInputTokens} + ${event.cachedInputTokens}`,
|
||||
totalOutputTokens: sql`${agentRuntimeState.totalOutputTokens} + ${event.outputTokens}`,
|
||||
totalCostCents: sql`${agentRuntimeState.totalCostCents} + ${unitsToCents(event.costCents)}::numeric`,
|
||||
updatedAt: new Date(),
|
||||
}).where(eq(agentRuntimeState.agentId, run.agentId));
|
||||
await tx.update(heartbeatRuns).set({ costAccountingPending: false, costAccountedAt: new Date(), accountingProjectionVersion: "v2", accountingLastError: null, accountingLastAttemptAt: new Date() }).where(eq(heartbeatRuns.id, run.id));
|
||||
return true;
|
||||
});
|
||||
await deliverBudgetEnforcement(db, hooks, identity.companyId);
|
||||
return accounted;
|
||||
}
|
||||
|
||||
export async function reconcileRunCosts(db: Db, hooks: BudgetServiceHooks = {}) {
|
||||
const pending = await db.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and(
|
||||
eq(heartbeatRuns.costAccountingPending, true), inArray(heartbeatRuns.status, terminalStatuses),
|
||||
sql`(${heartbeatRuns.usageJson}->>'accountingReceiptReady' is distinct from 'false' or ${heartbeatRuns.resultJson}->'executionRecovery'->>'providerWorkStarted' = 'false' or ${heartbeatRuns.usageJson}->>'accountingProviderWorkStarted' = 'false')`,
|
||||
)).orderBy(asc(heartbeatRuns.updatedAt), asc(heartbeatRuns.id)).limit(100);
|
||||
let accounted = 0;
|
||||
for (const run of pending) {
|
||||
try { if (await accountRunCost(db, run.id, hooks)) accounted++; }
|
||||
catch (error) {
|
||||
// Move failed attempts behind other pending work so a poisoned receipt
|
||||
// cannot monopolize the bounded batch on every recovery tick.
|
||||
await db.update(heartbeatRuns).set({ updatedAt: new Date(), accountingLastAttemptAt: new Date(), accountingAttemptCount: sql`${heartbeatRuns.accountingAttemptCount} + 1`, accountingLastError: error instanceof Error ? error.message.slice(0, 1000) : "Accounting failed" }).where(eq(heartbeatRuns.id, run.id));
|
||||
logger.error({ err: error, runId: run.id }, "Run accounting remains pending for retry");
|
||||
}
|
||||
}
|
||||
await budgetService(db, hooks).reconcilePolicies();
|
||||
return { scanned: pending.length, accounted };
|
||||
}
|
||||
@@ -165,6 +165,18 @@ async function setTreeMode(directory: string, readonly: boolean): Promise<void>
|
||||
if (readonly) await fs.chmod(directory, 0o555);
|
||||
}
|
||||
|
||||
// Darwin requires a directory being renamed to be writable by its owner.
|
||||
// Change only the directory itself, while the publication lock is held; the
|
||||
// contents remain read-only. Restore the mode even when publication fails.
|
||||
async function renameCacheDirectory(source: string, destination: string) {
|
||||
const stat = await fs.lstat(source);
|
||||
const directory = stat.isDirectory() && !stat.isSymbolicLink();
|
||||
if (directory) await fs.chmod(source, 0o700);
|
||||
let moved = false;
|
||||
try { await fs.rename(source, destination); moved = true; }
|
||||
finally { if (directory) await fs.chmod(moved ? destination : source, stat.mode & 0o777); }
|
||||
}
|
||||
|
||||
async function removeTree(directory: string): Promise<void> {
|
||||
await setTreeMode(directory, false);
|
||||
await fs.rm(directory, { recursive: true, force: true });
|
||||
@@ -202,9 +214,9 @@ export async function resolveRuntimeSkillCache(
|
||||
if (!await matches(spec, staging)) throw new Error("Runtime skill cache validation failed");
|
||||
// Lifecycle mutations can update the DB while this builder owns the filesystem lock.
|
||||
if (!await stillInstalled()) throw new Error("Skill was renamed or removed during preparation");
|
||||
await fs.rename(spec.entry, path.join(spec.root, `.invalid-${spec.fingerprint}-${randomUUID()}`))
|
||||
await renameCacheDirectory(spec.entry, path.join(spec.root, `.invalid-${spec.fingerprint}-${randomUUID()}`))
|
||||
.catch((error: NodeJS.ErrnoException) => { if (error.code !== "ENOENT") throw error; });
|
||||
await fs.rename(staging, spec.entry);
|
||||
await renameCacheDirectory(staging, spec.entry);
|
||||
return path.join(spec.entry, "files");
|
||||
} finally { await removeTree(staging); }
|
||||
});
|
||||
|
||||
@@ -877,7 +877,7 @@ export function statusCardService(
|
||||
? await tx.select({
|
||||
inputTokens: sql<number>`coalesce(sum(${costEvents.inputTokens}), 0)::int`,
|
||||
outputTokens: sql<number>`coalesce(sum(${costEvents.outputTokens}), 0)::int`,
|
||||
costCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
|
||||
costCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::double precision`,
|
||||
}).from(costEvents).where(eq(costEvents.heartbeatRunId, actor.runId))
|
||||
: [];
|
||||
const existingUpdate = await tx.select().from(statusCardUpdates)
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { priceCodexReceipt } from "./codex-pricing.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { z } from "zod";
|
||||
import { and, eq, isNull, sql } from "drizzle-orm";
|
||||
import { heartbeatRuns, runUsageReceipts, type Db } from "@paperclipai/db";
|
||||
import type { AdapterExecutionResult, AdapterUsageCheckpoint } from "@paperclipai/adapter-utils";
|
||||
import { pricingProvenanceSchema, usdToUnits } from "@paperclipai/shared";
|
||||
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
|
||||
import { conflict, notFound } from "../errors.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { receiptFingerprint } from "./receipt-fingerprint.js";
|
||||
import { withAccountingTransaction } from "./accounting-transaction.js";
|
||||
|
||||
const token = z.number().int().nonnegative().max(2_147_483_647);
|
||||
const price = z.number().finite().nonnegative().nullable().optional();
|
||||
const usage = z.object({ inputTokens: token, outputTokens: token, cachedInputTokens: token.optional(), cacheWriteTokens: token.optional() });
|
||||
const checkpointSchema = z.object({
|
||||
attemptId: z.string().uuid().optional(), usage: usage.optional(),
|
||||
usageByModel: z.array(z.object({ model: z.string().min(1).max(250), usage, costUsd: z.number().finite().nonnegative() })).max(500).optional(),
|
||||
usageBasis: z.enum(["per_run", "session_cumulative"]).nullable().optional(),
|
||||
provider: z.string().max(250).nullable().optional(), biller: z.string().max(250).nullable().optional(),
|
||||
model: z.string().max(250).nullable().optional(), billingType: z.string().max(100).nullable().optional(),
|
||||
costUsd: price, cacheAdjustedCostUsd: price,
|
||||
costStatus: z.enum(["reported", "estimated", "unpriced"]).optional(),
|
||||
pricingContext: z.object({ serviceTier: z.string().max(50).optional(), contextTier: z.enum(["short", "long"]).optional() }).optional(),
|
||||
pricingProvenance: pricingProvenanceSchema.optional(),
|
||||
costUsdExact: z.string().max(160).refine(value => { try { return !value.startsWith("-") && usdToUnits(value) >= 0n; } catch { return false; } }, "Invalid exact USD amount").nullable().optional(),
|
||||
providerRequestId: z.string().max(250).nullable().optional(), complete: z.boolean(),
|
||||
});
|
||||
const envelopeSchema = z.object({
|
||||
schema: z.literal("paperclip/accounting-receipt/v1"), id: z.string().uuid(), companyId: z.string().uuid(),
|
||||
runId: z.string().uuid(), sourceId: z.string().uuid(), sequence: z.number().int().positive(), receivedAt: z.iso.datetime(),
|
||||
adapterType: z.string().max(100), receipt: checkpointSchema,
|
||||
});
|
||||
export type UsageReceiptEnvelope = z.infer<typeof envelopeSchema>;
|
||||
export const usageReceiptSpoolPath = () => path.join(resolvePaperclipInstanceRoot(), "accounting-receipts");
|
||||
|
||||
async function syncDirectory(directory: string) {
|
||||
if (process.platform === "win32") return;
|
||||
const handle = await fs.open(directory, "r");
|
||||
try { await handle.sync(); } finally { await handle.close(); }
|
||||
}
|
||||
|
||||
export async function spoolUsageReceipt(envelope: UsageReceiptEnvelope, directory = usageReceiptSpoolPath()) {
|
||||
const parsed = envelopeSchema.parse(envelope);
|
||||
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
const target = path.join(directory, `${parsed.id}.json`);
|
||||
const temporary = `${target}.${randomUUID()}.tmp`;
|
||||
const handle = await fs.open(temporary, "wx", 0o600);
|
||||
try {
|
||||
try { await handle.writeFile(JSON.stringify(parsed)); await handle.sync(); }
|
||||
finally { await handle.close(); }
|
||||
await fs.rename(temporary, target); await syncDirectory(directory);
|
||||
} catch (error) { await fs.rm(temporary, { force: true }); throw error; }
|
||||
return target;
|
||||
}
|
||||
|
||||
export async function persistUsageReceipt(db: Db, raw: UsageReceiptEnvelope) {
|
||||
const envelope = envelopeSchema.parse(raw);
|
||||
const hash = receiptFingerprint(envelope);
|
||||
return withAccountingTransaction(db, envelope.companyId, async tx => {
|
||||
const [run] = await tx.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, envelope.runId), eq(heartbeatRuns.companyId, envelope.companyId))).for("update");
|
||||
if (!run) throw notFound("Accounting run not found");
|
||||
const [existing] = await tx.select().from(runUsageReceipts).where(and(eq(runUsageReceipts.companyId, envelope.companyId), eq(runUsageReceipts.runId, envelope.runId), eq(runUsageReceipts.sourceId, envelope.sourceId), eq(runUsageReceipts.sequence, envelope.sequence)));
|
||||
if (existing && existing.receiptHash !== hash) throw conflict("Conflicting usage checkpoint replay");
|
||||
if (!existing) await tx.insert(runUsageReceipts).values({
|
||||
id: envelope.id, companyId: envelope.companyId, runId: envelope.runId, sourceId: envelope.sourceId,
|
||||
sequence: envelope.sequence, receiptHash: hash, receiptJson: envelope, complete: envelope.receipt.complete, receivedAt: new Date(envelope.receivedAt),
|
||||
});
|
||||
const previous = run.usageJson ?? {};
|
||||
// A restarted/replaced controller owns a fresh source. Old spool files
|
||||
// remain auditable but cannot overwrite its snapshots or acknowledged work.
|
||||
if (run.costAccountedAt || previous.accountingReceiptSourceId !== envelope.sourceId || Number(previous.accountingReceiptSequence ?? 0) >= envelope.sequence) return;
|
||||
const receipt = envelope.receipt;
|
||||
const billingType = receipt.billingType === "api" ? "metered_api" : receipt.billingType === "subscription" ? "subscription_included" : receipt.billingType ?? "unknown";
|
||||
await tx.update(heartbeatRuns).set({
|
||||
costAccountingPending: true,
|
||||
accountingLastError: null,
|
||||
usageJson: { ...previous, ...receipt.usage,
|
||||
provider: receipt.provider ?? "unknown", biller: receipt.biller ?? receipt.provider ?? "unknown", model: receipt.model ?? "unknown", billingType,
|
||||
costUsd: receipt.costUsd ?? null, costUsdExact: receipt.costUsdExact ?? null, cacheAdjustedCostUsd: receipt.cacheAdjustedCostUsd ?? null,
|
||||
usageByModel: receipt.usageByModel ?? null, usageSource: receipt.usageBasis ?? "per_run", providerRequestId: receipt.providerRequestId ?? null,
|
||||
accountingReceiptReady: receipt.complete && receipt.usageBasis !== "session_cumulative", accountingReceiptId: envelope.id,
|
||||
accountingReceiptSequence: envelope.sequence, accountingReceiptReceivedAt: envelope.receivedAt,
|
||||
costStatus: receipt.costStatus ?? null,
|
||||
pricingProvenance: receipt.pricingProvenance ?? { source: receipt.costUsd != null || receipt.costUsdExact != null ? "provider_reported" : "unknown", version: "accounting-receipt/v1" },
|
||||
},
|
||||
}).where(and(eq(heartbeatRuns.id, run.id), isNull(heartbeatRuns.costAccountedAt)));
|
||||
});
|
||||
}
|
||||
|
||||
export async function replayUsageReceipts(db: Db, directory = usageReceiptSpoolPath(), limit = 100) {
|
||||
let files: string[];
|
||||
try { files = (await fs.readdir(directory)).filter(name => name.endsWith(".json")).sort(); }
|
||||
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return { replayed: 0, failed: 0 }; throw error; }
|
||||
let replayed = 0, failed = 0;
|
||||
for (const name of files.slice(0, limit)) {
|
||||
const file = path.join(directory, name);
|
||||
try {
|
||||
const info = await fs.lstat(file);
|
||||
if (!info.isFile() || info.size > 1024 * 1024) throw new Error("Invalid accounting spool entry");
|
||||
await persistUsageReceipt(db, envelopeSchema.parse(JSON.parse(await fs.readFile(file, "utf8"))));
|
||||
await fs.rm(file, { force: true }); replayed++;
|
||||
} catch (error) {
|
||||
failed++;
|
||||
// Rotate failures behind fresh entries without discarding the evidence.
|
||||
await fs.rename(file, path.join(directory, `retry-${Date.now()}-${randomUUID()}.json`)).catch(() => undefined);
|
||||
logger.error({ err: error, receiptFile: name }, "Accounting spool receipt remains pending");
|
||||
}
|
||||
}
|
||||
return { replayed, failed };
|
||||
}
|
||||
|
||||
export async function createRunUsageRecorder(db: Db, input: { companyId: string; runId: string; adapterType: string }, directory = usageReceiptSpoolPath()) {
|
||||
const sourceId = randomUUID();
|
||||
// Verify durable storage before starting paid work, including a directory
|
||||
// sync. The database binding fences checkpoints from replaced controllers.
|
||||
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
const probe = await fs.open(path.join(directory, `${sourceId}.probe`), "wx", 0o600);
|
||||
try { await probe.sync(); } finally { await probe.close(); await fs.rm(path.join(directory, `${sourceId}.probe`)); }
|
||||
await syncDirectory(directory);
|
||||
const bound = await db.update(heartbeatRuns).set({ usageJson: sql`coalesce(${heartbeatRuns.usageJson}, '{}'::jsonb) || ${JSON.stringify({ accountingReceiptSourceId: sourceId, accountingReceiptSequence: 0 })}::jsonb` })
|
||||
.where(and(eq(heartbeatRuns.id, input.runId), eq(heartbeatRuns.companyId, input.companyId), isNull(heartbeatRuns.costAccountedAt))).returning({ id: heartbeatRuns.id });
|
||||
if (bound.length !== 1) throw conflict("Run cannot accept a new usage recorder");
|
||||
let sequence = 0;
|
||||
let currentAttempt = "default";
|
||||
const attempts = new Map<string, AdapterUsageCheckpoint>();
|
||||
let chain = Promise.resolve();
|
||||
let captureFailed = false;
|
||||
async function capture(raw: AdapterUsageCheckpoint) {
|
||||
const parsed = checkpointSchema.parse(raw) as AdapterUsageCheckpoint;
|
||||
const attempt = parsed.attemptId ?? currentAttempt;
|
||||
if (!attempts.has(attempt)) currentAttempt = attempt;
|
||||
const previous = attempts.get(attempt);
|
||||
const priced = priceCodexReceipt({ ...parsed, usage: parsed.usage ?? previous?.usage,
|
||||
complete: parsed.complete && !(previous?.usage && !parsed.usage) });
|
||||
priced.pricingProvenance ??= {
|
||||
source: priced.costUsd != null || priced.costUsdExact != null || priced.cacheAdjustedCostUsd != null ? "provider_reported" : "unknown",
|
||||
version: "accounting-receipt/v1",
|
||||
};
|
||||
attempts.set(attempt, priced);
|
||||
const parts = [...attempts.values()];
|
||||
const latest = attempts.get(currentAttempt)!;
|
||||
const receipt: AdapterUsageCheckpoint = { ...latest, complete: latest.complete && !captureFailed, usage: {
|
||||
inputTokens: parts.reduce((sum, item) => sum + (item.usage?.inputTokens ?? 0), 0),
|
||||
cachedInputTokens: parts.reduce((sum, item) => sum + (item.usage?.cachedInputTokens ?? 0), 0),
|
||||
...(parts.some(item => item.usage?.cacheWriteTokens !== undefined) ? { cacheWriteTokens: parts.reduce((sum, item) => sum + (item.usage?.cacheWriteTokens ?? 0), 0) } : {}),
|
||||
outputTokens: parts.reduce((sum, item) => sum + (item.usage?.outputTokens ?? 0), 0),
|
||||
} };
|
||||
if (parts.length > 1) {
|
||||
let total = 0n, priced = true;
|
||||
for (const part of parts) {
|
||||
const cost = part.cacheAdjustedCostUsd ?? part.costUsdExact ?? part.costUsd;
|
||||
if (cost == null) priced = false; else total += usdToUnits(cost);
|
||||
}
|
||||
if (new Set(parts.map(part => part.billingType ?? "unknown")).size > 1) receipt.billingType = "unknown";
|
||||
const decimal = `${total / 1_000_000_000n}.${String(total % 1_000_000_000n).padStart(9, "0")}`;
|
||||
receipt.costUsdExact = priced ? decimal : null;
|
||||
receipt.costUsd = priced ? Number(decimal) : null;
|
||||
receipt.cacheAdjustedCostUsd = null;
|
||||
receipt.usageByModel = undefined;
|
||||
receipt.costStatus = priced && parts.some(part => part.costStatus === "estimated") ? "estimated" : priced ? "reported" : "unpriced";
|
||||
receipt.pricingProvenance = { source: receipt.costStatus === "estimated" ? "rate_card" : "unknown", version: "per-attempt/v1", evidence: "Sum of attempted-run receipts; component prices are retained in the immutable receipt journal." };
|
||||
// Every attempted provider must supply a final receipt before the total
|
||||
// can be considered complete, even if a later attempt succeeded.
|
||||
receipt.complete = !captureFailed && parts.every(part => part.complete);
|
||||
}
|
||||
const envelope: UsageReceiptEnvelope = { schema: "paperclip/accounting-receipt/v1", id: randomUUID(), ...input, sourceId, sequence: ++sequence, receivedAt: new Date().toISOString(), receipt };
|
||||
const file = await spoolUsageReceipt(envelope, directory);
|
||||
try { await persistUsageReceipt(db, envelope); await fs.rm(file, { force: true }); }
|
||||
catch (error) { logger.warn({ err: error, runId: input.runId, receiptId: envelope.id }, "Usage receipt is durable on disk; database replay pending"); }
|
||||
return receipt;
|
||||
}
|
||||
return {
|
||||
capture: (receipt: AdapterUsageCheckpoint) => {
|
||||
const result = chain.then(() => capture(receipt)); chain = result.then(() => undefined, () => { captureFailed = true; }); return result;
|
||||
},
|
||||
complete: async (result: AdapterExecutionResult) => {
|
||||
await chain;
|
||||
return capture({ ...result, complete: result.usageComplete !== false });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "..",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": [
|
||||
"src/__tests__/cost-accounting-*.test.ts",
|
||||
"src/__tests__/codex-pricing.test.ts",
|
||||
"src/__tests__/provider-billing-import.test.ts",
|
||||
"src/__tests__/company-quota-windows.test.ts",
|
||||
"src/__tests__/fixtures/accounting-writer.ts",
|
||||
"src/**/*.d.ts",
|
||||
"vitest.accounting-mutations.config.ts",
|
||||
"vitest.accounting.config.ts",
|
||||
"../tests/e2e/cost-accounting.spec.ts"
|
||||
],
|
||||
"exclude": []
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "vitest/config";
|
||||
import base from "./vitest.config.js";
|
||||
export const mutations = {
|
||||
deduplication: { file: "/services/costs.ts", from: "if (existing) {", to: "if (false && existing) {", test: "deduplicates a retried receipt" },
|
||||
company: { file: "/services/costs.ts", from: "const conditions: ReturnType<typeof eq>[] = [eq(costEvents.companyId, companyId)];", to: "const conditions: ReturnType<typeof eq>[] = [sql`true`];", test: "isolates company reporting" },
|
||||
projection: { file: "/services/costs.ts", from: "+ ${delta}::numeric", to: "+ 0::numeric", test: "conserves agent projections" },
|
||||
threshold: { file: "/services/budgets.ts", from: "compareCents(observed.totalExact, policy.amount) >= 0", to: "compareCents(observed.totalExact, policy.amount) > 0", test: "stops at the exact budget boundary" },
|
||||
};
|
||||
const name = process.env.PAPERCLIP_ACCOUNTING_MUTATION;
|
||||
if (name && !(name in mutations)) throw new Error(`Unknown accounting mutation: ${name}`);
|
||||
const mutation = name ? mutations[name as keyof typeof mutations] : null;
|
||||
export default defineConfig({
|
||||
...base, root: fileURLToPath(new URL(".", import.meta.url)),
|
||||
plugins: [...(base.plugins ?? []), {
|
||||
name: "accounting-mutation-test-only", enforce: "pre",
|
||||
transform(code, id) {
|
||||
if (!mutation || !id.split("?")[0].endsWith(mutation.file)) return;
|
||||
if (!code.includes(mutation.from)) throw new Error(`Mutation anchor missing: ${name}`);
|
||||
// Replace precisely one site in Vite's in-memory module. Workspace source
|
||||
// stays untouched, including while another test process is running.
|
||||
console.error(`ACCOUNTING_MUTATION_APPLIED ${name}`);
|
||||
return code.replace(mutation.from, mutation.to);
|
||||
},
|
||||
}],
|
||||
test: { ...base.test, include: ["src/__tests__/cost-accounting-mutation-targets.test.ts"], coverage: { enabled: false } },
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "vitest/config";
|
||||
import base from "./vitest.config.js";
|
||||
|
||||
export default defineConfig({
|
||||
...base,
|
||||
root: fileURLToPath(new URL(".", import.meta.url)),
|
||||
test: {
|
||||
...base.test,
|
||||
include: [
|
||||
"src/__tests__/cost-accounting-*.test.ts",
|
||||
"src/__tests__/budgets-service.test.ts",
|
||||
"src/__tests__/costs-service.test.ts",
|
||||
"src/__tests__/codex-pricing.test.ts",
|
||||
"src/__tests__/provider-billing-import.test.ts",
|
||||
],
|
||||
coverage: {
|
||||
enabled: true,
|
||||
provider: "v8",
|
||||
reportOnFailure: true,
|
||||
reportsDirectory: "../coverage/accounting",
|
||||
reporter: ["text", "json", "json-summary", "html"],
|
||||
// Every module must retain its own floor; a well-covered helper cannot
|
||||
// hide a regression in ingestion, recovery, finance, or enforcement.
|
||||
thresholds: { perFile: true, lines: 98, branches: 90, functions: 96, statements: 96 },
|
||||
include: [
|
||||
"src/services/accounting-transaction.ts",
|
||||
"src/services/accounting-integrity.ts",
|
||||
"src/services/billing-reconciliation.ts",
|
||||
"src/services/budget-reservations.ts",
|
||||
"src/services/usage-receipts.ts",
|
||||
"src/services/receipt-fingerprint.ts",
|
||||
"src/services/cost-date-range.ts",
|
||||
"src/services/run-cost-accounting.ts",
|
||||
"src/services/costs.ts",
|
||||
"src/services/finance.ts",
|
||||
"src/services/budgets.ts",
|
||||
"src/services/codex-pricing.ts",
|
||||
"src/services/provider-billing-import.ts",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -37,7 +37,6 @@ export default defineConfig({
|
||||
isolate: true,
|
||||
maxConcurrency: 1,
|
||||
maxWorkers: 1,
|
||||
minWorkers: 1,
|
||||
pool: "forks",
|
||||
sequence: {
|
||||
concurrent: false,
|
||||
|
||||
@@ -380,7 +380,7 @@ function runMetrics(run: HeartbeatRun) {
|
||||
output,
|
||||
cached,
|
||||
cost,
|
||||
totalTokens: input + output,
|
||||
totalTokens: input + cached + output,
|
||||
provider,
|
||||
model,
|
||||
};
|
||||
@@ -935,6 +935,8 @@ export function AgentDetail() {
|
||||
metric: "billed_cents",
|
||||
windowKind: "calendar_month_utc",
|
||||
amount: budgetMonthlyCents,
|
||||
unpricedEventCount: 0, pendingRunCount: 0,
|
||||
unpricedUsagePolicy: "block",
|
||||
observedAmount: spentMonthlyCents,
|
||||
remainingAmount: Math.max(0, budgetMonthlyCents - spentMonthlyCents),
|
||||
utilizationPercent:
|
||||
|
||||
@@ -605,6 +605,8 @@ export function ProjectDetail() {
|
||||
metric: "billed_cents",
|
||||
windowKind: "lifetime",
|
||||
amount: 0,
|
||||
unpricedEventCount: 0, pendingRunCount: 0,
|
||||
unpricedUsagePolicy: "block",
|
||||
observedAmount: 0,
|
||||
remainingAmount: 0,
|
||||
utilizationPercent: 0,
|
||||
|
||||
@@ -1118,6 +1118,8 @@ export const storybookApprovals: Approval[] = [
|
||||
windowKind: "calendar_month_utc",
|
||||
metric: "billed_cents",
|
||||
budgetAmount: 120_000,
|
||||
unpricedEventCount: 0, pendingRunCount: 0,
|
||||
unpricedUsagePolicy: "block",
|
||||
observedAmount: 131_400,
|
||||
guidance: "Raise the project budget only after current release smoke checks are green.",
|
||||
},
|
||||
@@ -1162,6 +1164,8 @@ export const storybookBudgetSummaries: BudgetPolicySummary[] = [
|
||||
metric: "billed_cents",
|
||||
windowKind: "calendar_month_utc",
|
||||
amount: 250_000,
|
||||
unpricedEventCount: 0, pendingRunCount: 0,
|
||||
unpricedUsagePolicy: "block",
|
||||
observedAmount: 67_500,
|
||||
remainingAmount: 182_500,
|
||||
utilizationPercent: 27,
|
||||
@@ -1184,6 +1188,8 @@ export const storybookBudgetSummaries: BudgetPolicySummary[] = [
|
||||
metric: "billed_cents",
|
||||
windowKind: "calendar_month_utc",
|
||||
amount: 120_000,
|
||||
unpricedEventCount: 0, pendingRunCount: 0,
|
||||
unpricedUsagePolicy: "block",
|
||||
observedAmount: 103_100,
|
||||
remainingAmount: 16_900,
|
||||
utilizationPercent: 86,
|
||||
@@ -1206,6 +1212,8 @@ export const storybookBudgetSummaries: BudgetPolicySummary[] = [
|
||||
metric: "billed_cents",
|
||||
windowKind: "calendar_month_utc",
|
||||
amount: 40_000,
|
||||
unpricedEventCount: 0, pendingRunCount: 0,
|
||||
unpricedUsagePolicy: "block",
|
||||
observedAmount: 43_200,
|
||||
remainingAmount: 0,
|
||||
utilizationPercent: 108,
|
||||
|
||||
@@ -346,6 +346,7 @@ const billerSpendRows: Array<{
|
||||
const financeBillerRows: FinanceByBiller[] = [
|
||||
{
|
||||
biller: "openai",
|
||||
currency: "USD",
|
||||
debitCents: 74_200,
|
||||
creditCents: 12_000,
|
||||
netCents: 62_200,
|
||||
@@ -355,6 +356,7 @@ const financeBillerRows: FinanceByBiller[] = [
|
||||
},
|
||||
{
|
||||
biller: "aws_bedrock",
|
||||
currency: "USD",
|
||||
debitCents: 45_880,
|
||||
creditCents: 0,
|
||||
netCents: 45_880,
|
||||
@@ -367,6 +369,7 @@ const financeBillerRows: FinanceByBiller[] = [
|
||||
const financeKindRows: FinanceByKind[] = [
|
||||
{
|
||||
eventKind: "inference_charge",
|
||||
currency: "USD",
|
||||
debitCents: 49_820,
|
||||
creditCents: 0,
|
||||
netCents: 49_820,
|
||||
@@ -376,6 +379,7 @@ const financeKindRows: FinanceByKind[] = [
|
||||
},
|
||||
{
|
||||
eventKind: "log_storage_charge",
|
||||
currency: "USD",
|
||||
debitCents: 8_760,
|
||||
creditCents: 0,
|
||||
netCents: 8_760,
|
||||
@@ -385,6 +389,7 @@ const financeKindRows: FinanceByKind[] = [
|
||||
},
|
||||
{
|
||||
eventKind: "provisioned_capacity_charge",
|
||||
currency: "USD",
|
||||
debitCents: 42_900,
|
||||
creditCents: 0,
|
||||
netCents: 42_900,
|
||||
@@ -394,6 +399,7 @@ const financeKindRows: FinanceByKind[] = [
|
||||
},
|
||||
{
|
||||
eventKind: "credit_refund",
|
||||
currency: "USD",
|
||||
debitCents: 0,
|
||||
creditCents: 12_000,
|
||||
netCents: -12_000,
|
||||
@@ -428,6 +434,7 @@ const financeTimelineRows: FinanceEvent[] = [
|
||||
amountCents: 40_000,
|
||||
currency: "USD",
|
||||
estimated: false,
|
||||
idempotencyKey: null,
|
||||
externalInvoiceId: "INV-2026-04-OPENAI-1184",
|
||||
metadataJson: { paymentMethod: "corporate-card" },
|
||||
occurredAt: at(1_260),
|
||||
@@ -457,6 +464,7 @@ const financeTimelineRows: FinanceEvent[] = [
|
||||
amountCents: 42_900,
|
||||
currency: "USD",
|
||||
estimated: true,
|
||||
idempotencyKey: null,
|
||||
externalInvoiceId: "AWS-EST-7713",
|
||||
metadataJson: { purchaseOrder: "PO-STORYBOOK-APR" },
|
||||
occurredAt: at(420),
|
||||
@@ -486,6 +494,7 @@ const financeTimelineRows: FinanceEvent[] = [
|
||||
amountCents: 8_760,
|
||||
currency: "USD",
|
||||
estimated: true,
|
||||
idempotencyKey: null,
|
||||
externalInvoiceId: "CF-APR-2026-0091",
|
||||
metadataJson: null,
|
||||
occurredAt: at(210),
|
||||
@@ -515,6 +524,7 @@ const financeTimelineRows: FinanceEvent[] = [
|
||||
amountCents: 12_000,
|
||||
currency: "USD",
|
||||
estimated: false,
|
||||
idempotencyKey: null,
|
||||
externalInvoiceId: "CR-2026-04-OPENAI-041",
|
||||
metadataJson: null,
|
||||
occurredAt: at(64),
|
||||
|
||||
Reference in new issue
Block a user