doc/plans/2026-03-14-billing-ledger-and-reporting.md
Paperclip currently stores model spend in cost_events and operational run state in heartbeat_runs.
That split is fine, but the current reporting code tries to infer billing semantics by mixing both tables:
cost_events knows provider, model, tokens, and dollarsheartbeat_runs.usage_json knows some per-run billing metadataheartbeat_runs.usage_json does not currently carry enough normalized billing dimensions to support honest provider-level reportingThis becomes incorrect as soon as a company uses more than one provider, more than one billing channel, or more than one billing mode.
Examples:
The system needs to support:
cost_events becomes the canonical billing and usage ledger for reporting.
heartbeat_runs remains an operational execution log. It may keep mirrored billing metadata for debugging and transcripts, but reporting must not reconstruct billing semantics from heartbeat_runs.usage_json.
We do not need two tables to solve the current PR's problem.
For request-level inference reporting, cost_events is enough if it carries the right dimensions:
That is why the first implementation pass extends cost_events instead of introducing a second table immediately.
However, if Paperclip needs to account for the full billing surface of aggregators and managed AI platforms, then cost_events alone is not enough.
Some charges are not cleanly representable as a single model inference event:
So the decision is:
cost_events as the inference and usage ledgerfinance_events for non-inference financial eventsThis is a deliberate split between:
That separation keeps request reporting honest without forcing us to fake invoice semantics onto rows that were never request-scoped.
The need for this model is not theoretical. It follows directly from the billing systems of providers and aggregators Paperclip needs to support.
Source URLs:
Relevant billing behavior as of March 14, 2026:
Implication for Paperclip:
cost_eventsfinance_eventsbiller=openrouter must remain distinct from provider=anthropic|openai|google|...Source URL:
Relevant billing behavior as of March 14, 2026:
Implication for Paperclip:
biller=cloudflarecost_eventsSource URL:
Relevant billing behavior as of March 14, 2026:
Implication for Paperclip:
cost_eventsfinance_eventsTo keep the system coherent, the table boundary should be explicit.
cost_eventsUse cost_events for request-scoped usage and inference charges:
heartbeat_run_idfinance_eventsUse finance_events for account-scoped or platform-scoped financial events:
These rows may or may not have a related model, provider, or run id.
Trying to force them into cost_events would either create fake request rows or create null-heavy rows that mean something fundamentally different from inference usage.
Every persisted billing event should model four separate axes:
Usage provider
The upstream provider whose model performed the work.
Examples: openai, anthropic, google.
Biller
The system that charged for the usage.
Examples: openai, anthropic, openrouter, cursor, chatgpt.
Billing type The pricing mode applied to the event. Initial canonical values:
metered_apisubscription_includedsubscription_overagecreditsfixedunknownMeasures Usage and billing must both be storable:
input_tokensoutput_tokenscached_input_tokenscost_centsThese dimensions are independent. For example, an event may be:
anthropicopenroutermetered_apiOr:
anthropicanthropicsubscription_included0Extend cost_events with:
heartbeat_run_id uuid null references heartbeat_runs.idbiller text not null default 'unknown'billing_type text not null default 'unknown'cached_input_tokens int not null default 0Keep provider as the upstream usage provider.
Do not overload provider to mean biller.
Add a future finance_events table for account-level financial events with fields along these lines:
company_idoccurred_atevent_kinddirectionbillerprovider nullableexecution_adapter_type nullablepricing_tier nullableregion nullablemodel nullablequantity nullableunit nullableamount_centscurrencyestimatedrelated_cost_event_id nullablerelated_heartbeat_run_id nullableexternal_invoice_id nullablemetadata_json nullableAdd indexes:
(company_id, biller, occurred_at)(company_id, provider, occurred_at)(company_id, heartbeat_run_id) if distinct-run reporting remains commonAdd a shared billing type union and enrich cost types with:
heartbeatRunIdbillerbillingTypecachedInputTokensUpdate reporting response types so the provider breakdown reflects the ledger directly rather than inferred run metadata.
Extend createCostEventSchema to accept:
heartbeatRunIdbillerbillingTypecachedInputTokensDefaults:
biller defaults to providerbillingType defaults to unknowncachedInputTokens defaults to 0Extend adapter execution results so they can report:
billerBackwards compatibility:
api and subscription are treated as legacy aliasesapi -> metered_apisubscription -> subscription_includedFuture adapters may emit the canonical values directly.
OpenRouter support will use:
provider = upstream provider when knownbiller = openrouterbillingType = metered_api unless OpenRouter later exposes another billing modeCloudflare Unified Billing support will use:
provider = upstream provider when knownbiller = cloudflarebillingType = credits or metered_api depending on the normalized request billing contractBedrock support will use:
provider = upstream provider or aws_bedrock depending on adapter shapebiller = aws_bedrockbillingType = request-scoped mode for inference rowsfinance_events for provisioned, training, import, and storage chargesWhen a heartbeat run produces usage or spend:
cost_eventsheartbeat_run_idprovider, biller, billing_type, token fields, and cost_centsThe write path should no longer depend on later inference from heartbeat_runs.
Manual cost event creation remains supported.
These events may have heartbeatRunId = null.
Rules:
provider remains requiredbiller defaults to providerbillingType defaults to unknownRefactor reporting queries to use cost_events only.
summarycost_centsby-agentcost_eventscount(distinct heartbeat_run_id) filtered by billing type for run countsby-providerprovider, modelcost_events.billing_typeheartbeat_runsby-billerbillerwindow-spendcost_eventsKeep current project attribution logic for now, but prefer cost_events.heartbeat_run_id as the join anchor whenever possible.
The long-term board UI should expose:
Migration behavior:
biller = providerbilling_type = 'unknown'cached_input_tokens = 0heartbeat_run_id = nullDo not attempt to backfill historical provider-level subscription attribution from heartbeat_runs.
That data was never stored with the required dimensions.
Add or update tests for:
heartbeatRunId, biller, billingType, and cached tokensbiller=openrouter with non-OpenRouter upstream providersbiller=cloudflare with preserved upstream provider identityfinance_events aggregation handles non-request charges without requiring a model or run idexecutionAdapterType to persisted cost reporting if adapter-level grouping becomes a product requirementfinance_eventsheartbeat_runs as the operational run record