Documentation
¶
Overview ¶
Provider cost rates + per-customer margin (ADR-079). COST — what the OPERATOR pays their LLM providers — is a separate ledger from PRICE (what the customer is billed); nothing here touches invoices or rating. The per-event COGS stamp itself lives in store.Ingest (single funnel); this file is the operator surface: rate CRUD + the margin report.
Index ¶
- Constants
- type Aggregate
- type AuditEmitter
- type CostDashboardAssembler
- type CostDashboardMeter
- type CostDashboardPeriod
- type CostDashboardProjection
- type CostDashboardRule
- type CostDashboardSubscription
- type CostDashboardTotal
- type CustomerCostByModel
- type CustomerLookup
- type CustomerResolver
- type CustomerTokenLookup
- type CustomerUsageBucket
- type CustomerUsageHandler
- type CustomerUsageMeter
- type CustomerUsagePeriod
- type CustomerUsagePeriodOut
- type CustomerUsageResult
- type CustomerUsageRule
- type CustomerUsageService
- type CustomerUsageSubscription
- type CustomerUsageTotal
- type DailyBucketRow
- type Handler
- type IngestInput
- type ListFilter
- type MarginAssembler
- type MarginModelRow
- type MarginReport
- type MeterResolver
- type MeterTotal
- type PostgresStore
- func (s *PostgresStore) Aggregate(ctx context.Context, filter ListFilter) (Aggregate, error)
- func (s *PostgresStore) AggregateByPricingRules(ctx context.Context, tenantID, customerID, meterID string, ...) ([]domain.RuleAggregation, error)
- func (s *PostgresStore) AggregateDailyBuckets(ctx context.Context, tenantID, customerID string, meterIDs []string, ...) ([]DailyBucketRow, error)
- func (s *PostgresStore) AggregateForBillingPeriod(ctx context.Context, tenantID, customerID string, meterIDs []string, ...) (map[string]decimal.Decimal, error)
- func (s *PostgresStore) AggregateForBillingPeriodByAgg(ctx context.Context, tenantID, customerID string, meters map[string]string, ...) (map[string]decimal.Decimal, error)
- func (s *PostgresStore) CustomerProviderCost(ctx context.Context, tenantID, customerID string, from, to time.Time) (byModel []CustomerCostByModel, unresolvedEvents int64, err error)
- func (s *PostgresStore) DeleteProviderCostRate(ctx context.Context, tenantID, id string) error
- func (s *PostgresStore) DeleteProviderCostRateAudited(ctx context.Context, tenantID, id string, ...) error
- func (s *PostgresStore) GetByIdempotencyKey(ctx context.Context, tenantID, key string) (domain.UsageEvent, error)
- func (s *PostgresStore) Ingest(ctx context.Context, tenantID string, event domain.UsageEvent) (domain.UsageEvent, error)
- func (s *PostgresStore) IngestAudited(ctx context.Context, tenantID string, event domain.UsageEvent, ...) (domain.UsageEvent, error)
- func (s *PostgresStore) IngestBatch(ctx context.Context, tenantID string, events []domain.UsageEvent) (inserted, deduped int, err error)
- func (s *PostgresStore) List(ctx context.Context, filter ListFilter) ([]domain.UsageEvent, int, error)
- func (s *PostgresStore) ListProviderCostRates(ctx context.Context, tenantID string) ([]domain.ProviderCostRate, error)
- func (s *PostgresStore) StreamForExport(ctx context.Context, tenantID string, from, to time.Time, ...) error
- func (s *PostgresStore) UpsertProviderCostRate(ctx context.Context, tenantID string, r domain.ProviderCostRate) (domain.ProviderCostRate, error)
- func (s *PostgresStore) UpsertProviderCostRateAudited(ctx context.Context, tenantID string, r domain.ProviderCostRate, ...) (domain.ProviderCostRate, error)
- type PricingReader
- type ProviderCostHandler
- type Service
- func (s *Service) Aggregate(ctx context.Context, filter ListFilter) (Aggregate, error)
- func (s *Service) AggregateByPricingRules(ctx context.Context, tenantID, customerID, meterID string, ...) ([]domain.RuleAggregation, error)
- func (s *Service) AggregateDailyBuckets(ctx context.Context, tenantID, customerID string, meterIDs []string, ...) ([]DailyBucketRow, error)
- func (s *Service) AggregateForBillingPeriod(ctx context.Context, tenantID, customerID string, meterIDs []string, ...) (map[string]decimal.Decimal, error)
- func (s *Service) Backfill(ctx context.Context, tenantID string, input IngestInput) (domain.UsageEvent, error)
- func (s *Service) BatchIngest(ctx context.Context, tenantID string, events []IngestInput) (int, int, []error)
- func (s *Service) GetByIdempotencyKey(ctx context.Context, tenantID, key string) (domain.UsageEvent, error)
- func (s *Service) GetSummary(ctx context.Context, tenantID, customerID string, from, to time.Time) (UsageSummary, error)
- func (s *Service) Ingest(ctx context.Context, tenantID string, input IngestInput) (domain.UsageEvent, error)
- func (s *Service) List(ctx context.Context, filter ListFilter) ([]domain.UsageEvent, int, error)
- func (s *Service) SetAuditLogger(a AuditEmitter)
- func (s *Service) SetResolver(r clock.Resolver)
- type Store
- type SubscriptionLister
- type UsageSummary
Constants ¶
const MaxCustomerUsageWindow = 365 * 24 * time.Hour
MaxCustomerUsageWindow caps explicit-window queries at one year so the LATERAL JOIN cost stays bounded — see docs/design-customer-usage.md.
const MaxDimensionKeys = 16
MaxDimensionKeys caps the size of the JSONB dimensions map on each usage event. Dimensions feed pricing-rule dispatch via @> subset matches at finalize time; bounding the per-event JSONB size protects the GIN index from pathological tenants and matches the equivalent cap on meter_pricing_rules.dimension_match (16 keys).
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type Aggregate ¶
type Aggregate struct {
TotalEvents int `json:"total_events"`
TotalUnits decimal.Decimal `json:"total_units"`
ActiveMeters int `json:"active_meters"`
ActiveCustomers int `json:"active_customers"`
ByMeter []MeterTotal `json:"by_meter"`
}
Aggregate is the response shape of GET /v1/usage-events/aggregate. It powers the stat cards + "Usage by Meter" breakdown on the dashboard's /usage page so they reflect server-side filtered totals rather than reductions over the current page of events.
TotalUnits is decimal-string-encoded (NUMERIC(38,12) per ADR-005) so fractional GPU-hours and partial tokens round-trip without loss.
type AuditEmitter ¶ added in v0.2.0
AuditEmitter is the narrow in-tx audit seam (ADR-090). Provider-cost rate CRUD has no service layer — the handler is the layer that knows intent, so it builds the audit.Entry and the store threads the closure onto its own transaction: rate write and audit row commit or roll back together.
type CostDashboardAssembler ¶
type CostDashboardAssembler struct {
// contains filtered or unexported fields
}
CostDashboardAssembler builds the sanitized projection served by GET /v1/public/cost-dashboard/{token}. Two responsibilities:
- resolve the public token to a customer (via the customer store)
- compose the usage view (via CustomerUsageService) and sanitize it into the public envelope
Lives in the usage package because the heavy lifting is the usage math; the customer-package handler imports it via the narrow CostDashboardService interface (returns `any` to keep the customer package free of usage-domain types).
func NewCostDashboardAssembler ¶
func NewCostDashboardAssembler(customers CustomerTokenLookup, usageSvc *CustomerUsageService, subs SubscriptionLister) *CostDashboardAssembler
NewCostDashboardAssembler wires the public-projection assembler. All three deps are required.
func (*CostDashboardAssembler) GetByToken ¶
GetByToken resolves the token, composes the usage view, sanitizes, and returns the public projection. Returns errs.ErrNotFound when the token doesn't match a customer — the handler surfaces this as 401 (anti-enumeration).
Empty-state contract: when the customer has no active subscription (no_subscription branch), the response carries empty arrays + a `billing_period.source = "no_subscription"` instead of a 5xx so the embed widget can render a clean empty state.
type CostDashboardMeter ¶
type CostDashboardMeter struct {
MeterKey string `json:"meter_key"`
MeterName string `json:"meter_name"`
Unit string `json:"unit"`
Currency string `json:"currency"`
TotalQuantity string `json:"total_quantity"`
TotalAmountCents int64 `json:"total_amount_cents"`
Rules []CostDashboardRule `json:"rules"`
}
type CostDashboardPeriod ¶
type CostDashboardProjection ¶
type CostDashboardProjection struct {
CustomerID string `json:"customer_id"`
TenantID string `json:"tenant_id"`
BillingPeriod CostDashboardPeriod `json:"billing_period"`
Subscriptions []CostDashboardSubscription `json:"subscriptions"`
Usage []CostDashboardMeter `json:"usage"`
Totals []CostDashboardTotal `json:"totals"`
ProjectedTotalCents int64 `json:"projected_total_cents"`
// Livemode marks whether this dashboard reflects real-money usage; the
// embed shows a "Test mode" banner when false. Shipped in the JSON
// contract pre-outreach so partner UIs never face a mid-pilot schema
// addition (ADR-032 consumer-ready JSON).
Livemode bool `json:"livemode"`
}
CostDashboardProjection is the wire shape returned from GET /v1/public/cost-dashboard/{token}. Slice fields default to non- nil so the wire emits "[]" not "null" — embed widgets iterate without null guards.
What's deliberately ABSENT (sanitization contract):
- email, display_name, external_id, metadata (customer PII)
- billing_profile (legal name, address, tax_id)
- warnings (operator-facing tech messages from CustomerUsageService)
- plan_id (internal identifier; plan_name is enough for display)
- rating_rule_version_id (internal identifier)
What's PRESENT:
- customer_id + tenant_id (caller already has the token, so these are not secrets)
- billing_period { start, end, source }
- subscriptions (id + plan_name + currency + period only)
- usage[] (meter + per-rule breakdown for multi-dim)
- totals[] (per-currency rollup)
- projected_total_cents (sum across currencies — Stripe-style single-number summary the widget surfaces as the headline)
type CostDashboardRule ¶
type CostDashboardTotal ¶
type CustomerCostByModel ¶
CustomerCostByModel aggregates the stamped COGS for one customer's window: per-model cost plus the honesty counters (ADR-079 D7 — 'unresolved' = token events that carried costable dims but matched no rate: the actionable signal; 'not_applicable' events are excluded so they can't drown it).
type CustomerLookup ¶
type CustomerLookup interface {
Get(ctx context.Context, tenantID, id string) (domain.Customer, error)
}
CustomerLookup is the narrow surface CustomerUsageService needs from customer.Store. Returns errs.ErrNotFound for cross-tenant IDs (RLS hides the row); the handler propagates that as 404 customer_not_found.
type CustomerResolver ¶
type CustomerResolver interface {
GetByExternalID(ctx context.Context, tenantID, externalID string) (domain.Customer, error)
}
CustomerResolver looks up a customer by external ID.
type CustomerTokenLookup ¶
type CustomerTokenLookup interface {
GetByCostDashboardToken(ctx context.Context, token string) (domain.Customer, error)
}
CustomerTokenLookup is the narrow surface the assembler uses to resolve the cost-dashboard token to a customer. RLS-bypass implementation lives in customer.PostgresStore.GetByCostDashboardToken (the token IS the credential — no tenant context yet).
type CustomerUsageBucket ¶
type CustomerUsageBucket struct {
BucketStart time.Time `json:"bucket_start"`
PerMeter map[string]decimal.Decimal `json:"per_meter"`
}
CustomerUsageBucket is one UTC-day cell of the time-series. PerMeter is keyed by meter_id with the day's total quantity (decimal so the NUMERIC(38,12) storage precision round-trips). Days with no events are still included with PerMeter empty / zeroed so the chart renders continuous time without client-side gap-filling.
type CustomerUsageHandler ¶
type CustomerUsageHandler struct {
// contains filtered or unexported fields
}
CustomerUsageHandler exposes GET /v1/customers/{id}/usage. Kept distinct from the existing usage.Handler (which owns /v1/usage-events ingest+list) because this surface composes across customer/subscription/pricing — the dependency set is materially different.
func NewCustomerUsageHandler ¶
func NewCustomerUsageHandler(svc *CustomerUsageService) *CustomerUsageHandler
NewCustomerUsageHandler wires a handler around a CustomerUsageService.
func (*CustomerUsageHandler) CustomerUsageRoutes ¶
func (h *CustomerUsageHandler) CustomerUsageRoutes(requireRead func(http.Handler) http.Handler) chi.Router
CustomerUsageRoutes returns the sub-router. Mount at /v1/customers/{id}/usage with the requireRead guard (auth.PermUsageRead); the customer ID is read from chi.URLParam(r, "id") after the sibling-mount, mirroring the /customers/{id}/coupon precedent.
type CustomerUsageMeter ¶
type CustomerUsageMeter struct {
MeterID string `json:"meter_id"`
MeterKey string `json:"meter_key"`
MeterName string `json:"meter_name"`
Unit string `json:"unit"`
Currency string `json:"currency"`
TotalQuantity decimal.Decimal `json:"total_quantity"`
TotalAmountCents int64 `json:"total_amount_cents"`
Rules []CustomerUsageRule `json:"rules"`
}
CustomerUsageMeter is one meter on the customer's plan(s) with its rolled-up usage and cost over the queried window. Rules carries the per-rule breakdown for multi-dim meters; for a flat single-rule meter the slice is length 1 with no DimensionMatch.
type CustomerUsagePeriod ¶
CustomerUsagePeriod is the input window. Both From and To zero → default to the customer's current billing cycle. Partial bounds (one zero, one non-zero) are rejected with a 400 by resolvePeriod.
type CustomerUsagePeriodOut ¶
type CustomerUsagePeriodOut struct {
From time.Time `json:"from"`
To time.Time `json:"to"`
Source string `json:"source"`
}
CustomerUsagePeriodOut tells the client which window the response covers and whether the server inferred it from the current cycle or honored an explicit ?from=&to=.
type CustomerUsageResult ¶
type CustomerUsageResult struct {
CustomerID string `json:"customer_id"`
Period CustomerUsagePeriodOut `json:"period"`
Subscriptions []CustomerUsageSubscription `json:"subscriptions"`
Meters []CustomerUsageMeter `json:"meters"`
Totals []CustomerUsageTotal `json:"totals"`
Warnings []string `json:"warnings"`
// Buckets is the daily-grain time series powering the customer-usage
// chart. One entry per UTC day in [period.from, period.to), missing
// days zero-filled so chart consumers don't have to gap-fill. Each
// bucket carries per-meter quantities — the frontend stacks them or
// flattens depending on the meter cardinality. Sums match Meters[].
Buckets []CustomerUsageBucket `json:"buckets"`
}
CustomerUsageResult is the response shape for GET /v1/customers/{id}/usage. Snake-case JSON keys, struct-tag enforced. Slices default to non-nil so the wire emits "[]" not "null" — clients can iterate without null guards.
type CustomerUsageRule ¶
type CustomerUsageRule struct {
RatingRuleVersionID string `json:"rating_rule_version_id"`
RuleKey string `json:"rule_key"`
DimensionMatch map[string]any `json:"dimension_match,omitempty"`
Quantity decimal.Decimal `json:"quantity"`
AmountCents int64 `json:"amount_cents"`
// UnitAmountDecimal is the per-unit price to display (decimal cents): the
// configured nominal rate for flat rules, else the effective amount÷qty —
// the SAME value the invoice line shows (ADR-054, via
// domain.DisplayUnitAmountDecimalFor). Rendered with the decimal-aware rate
// formatter so a sub-cent rate never collapses to $0.00.
UnitAmountDecimal *string `json:"unit_amount_decimal,omitempty"`
// Unmatched marks the bucket of events that matched NO pricing rule
// on a meter with no default binding — usage the engine bills
// NOTHING for (same resolution order as the cycle). Surfaced as a
// first-class row so a mislabeled dimension value (the most likely
// integration error on a matrix-priced meter) is visible to the
// operator instead of leaking revenue behind a server-log WARN.
// Excluded from the meter's totals (they mirror the invoice) and
// filtered out of the public cost-dashboard projection —
// pricing-config problems are operator information.
Unmatched bool `json:"unmatched,omitempty"`
}
CustomerUsageRule is one row of the priority+claim resolution: events claimed by a single pricing rule, rolled up into a quantity and rated through pricing.ComputeAmountCents. DimensionMatch echoes the meter pricing rule's match expression (the canonical pricing identity).
type CustomerUsageService ¶
type CustomerUsageService struct {
// contains filtered or unexported fields
}
CustomerUsageService composes the per-domain reads behind GET /v1/customers/{id}/usage. See docs/design-customer-usage.md.
The hard work — dimension-match-aware aggregation in usage.AggregateByPricingRules — already exists. This service is a composition: customer existence, subscription set, period resolution, per-meter rating, totals roll-up, warnings collection.
func NewCustomerUsageService ¶
func NewCustomerUsageService( usageSvc *Service, customers CustomerLookup, subscriptions SubscriptionLister, pricing PricingReader, ) *CustomerUsageService
NewCustomerUsageService wires the read-side composition. All four collaborators are required.
func (*CustomerUsageService) Get ¶
func (s *CustomerUsageService) Get(ctx context.Context, tenantID, customerID string, period CustomerUsagePeriod) (CustomerUsageResult, error)
Get composes the customer-usage view. Order:
- Customer existence (RLS makes cross-tenant IDs return ErrNotFound).
- Active+trialing subscriptions for the customer.
- Period resolution (current cycle from primary sub, or explicit ?from=&to= validated for partial bounds, ordering, 1-year cap).
- Walk the meter union across subscribed plans, calling usage.AggregateByPricingRules per meter then ComputeAmountCents per rule. Same code path the cycle scan uses → dashboard math == invoice math.
- Per-currency totals roll-up, warnings collection, subscription summary.
type CustomerUsageSubscription ¶
type CustomerUsageSubscription struct {
ID string `json:"id"`
PlanID string `json:"plan_id"`
PlanName string `json:"plan_name"`
Currency string `json:"currency"`
CurrentPeriodStart time.Time `json:"current_period_start"`
CurrentPeriodEnd time.Time `json:"current_period_end"`
}
CustomerUsageSubscription summarises one of the customer's subscriptions that overlapped the queried window. Plan info is denormalised so the dashboard renders "Plan: AI API Pro · cycle Apr 1 → May 1" without a follow-up call.
type CustomerUsageTotal ¶
type CustomerUsageTotal struct {
Currency string `json:"currency"`
AmountCents int64 `json:"amount_cents"`
}
CustomerUsageTotal is one currency's roll-up across all meters. We always emit a list (one entry per distinct currency) even when there's only one currency — consistent shape lets clients read totals[0] without branching, and lines up with /v1/* "always lists" convention.
type DailyBucketRow ¶
DailyBucketRow is one (bucket_start, meter) cell from the bucket aggregation. Storage-shape only; the service composes these into the gap-filled DailyBucket presentation type before serving.
type Handler ¶
type Handler struct {
// contains filtered or unexported fields
}
func NewHandler ¶
func NewHandler(svc *Service, customers CustomerResolver, meters MeterResolver) *Handler
func (*Handler) Backfill ¶
func (h *Handler) Backfill(w http.ResponseWriter, r *http.Request)
Backfill is exported so the router can mount it behind PermUsageWrite — the /usage-events subtree uses PermUsageRead for historical reasons, but backfill is a sensitive ledger operation and should not be reachable by read-only keys.
func (*Handler) SummaryRoutes ¶
SummaryHandler handles the usage summary HTTP endpoint.
type IngestInput ¶
type IngestInput struct {
CustomerID string `json:"customer_id"`
MeterID string `json:"meter_id"`
Quantity decimal.Decimal `json:"quantity,omitempty"`
Dimensions map[string]any `json:"dimensions,omitempty"`
IdempotencyKey string `json:"idempotency_key,omitempty"`
Timestamp *time.Time `json:"timestamp,omitempty"`
// ObservedCostMicros is the provider's OWN reported cost for this one
// event, in micro-dollars — the ADR-079 D4 fast-follow. When set it wins
// over rate-table inference and stamps provider_cost_source='observed';
// when nil the table path is unchanged.
//
// The caller must have already applied D4's rule, because this layer
// cannot re-derive it: only a PER-HALF figure may arrive here
// (input_cost onto the input event, output_cost onto the output event).
// A whole-call figure must never reach this field — it would be stamped
// onto each half and multi-count COGS, which is exactly why phase 1
// stamped nothing at all.
ObservedCostMicros *int64 `json:"observed_cost_micros,omitempty"`
}
IngestInput is the internal service input — uses resolved internal IDs only. The handler is responsible for resolving external identifiers before calling this.
type ListFilter ¶
type ListFilter struct {
TenantID string
CustomerID string
MeterID string
From *time.Time
To *time.Time
// Dimensions filters events whose properties JSONB CONTAINS every
// pair (`properties @> $1` — multi-key containment is AND; served by
// the GIN index from migration 0062). Values are typed: the handler
// parses "cached=true" / "attempt=2" as JSON literals and everything
// else as strings, matching how ingest stored them. Pre-2026-07-05
// the dashboard SENT this param and the server silently dropped it —
// unfiltered data rendered as filtered.
Dimensions map[string]any
Limit int
// Offset-based pagination (legacy path). Mutually exclusive with
// AfterTimestamp+AfterID — the cursor path takes precedence when
// both are provided.
Offset int
// Cursor-based pagination (2026-05-29). Seek-method query:
// WHERE (timestamp, id) < (AfterTimestamp, AfterID). Both must
// be set together; the handler rejects partial cursor sets.
// Stable across concurrent inserts at the table's head — offset
// pagination page-skewed reliably on usage_events whenever a
// cycle close fired between operator pages (per-API-call write
// volume = highest of any Velox table).
AfterTimestamp time.Time
AfterID string
}
type MarginAssembler ¶
type MarginAssembler struct {
// contains filtered or unexported fields
}
MarginAssembler joins stamped COGS with rated usage revenue.
func NewMarginAssembler ¶
func NewMarginAssembler(store *PostgresStore, usageSvc *CustomerUsageService) *MarginAssembler
func (*MarginAssembler) Get ¶
func (a *MarginAssembler) Get(ctx context.Context, tenantID, customerID string, from, to time.Time) (MarginReport, error)
type MarginModelRow ¶
type MarginModelRow struct {
Model string `json:"model"`
CostMicros int64 `json:"cost_micros"`
RevenueCents int64 `json:"revenue_cents,omitempty"`
Attributed bool `json:"attributed"`
}
MarginModelRow is one per-model line of the margin report. RevenueCents and MarginBps are set ONLY when the model's revenue is honestly attributable (a pricing rule pins `model` in dimension_match); otherwise Attributed=false and only cost renders — never a heuristic allocation (ADR-079 D6).
type MarginReport ¶
type MarginReport struct {
CustomerID string `json:"customer_id"`
From time.Time `json:"from"`
To time.Time `json:"to"`
// Headline (always correct): total rated usage revenue vs total
// stamped provider cost for the window. Rated USAGE revenue — base
// fees, credits, and taxes are not in this number (it is a usage
// unit-economics view, not GAAP margin; the UI copy says so).
RevenueCents int64 `json:"revenue_cents"`
CostMicros int64 `json:"cost_micros"`
// MarginBps = (revenue − cost) / revenue in basis points; omitted
// when revenue is 0.
MarginBps *int64 `json:"margin_bps,omitempty"`
ByModel []MarginModelRow `json:"by_model"`
UnattributedRevenueCents int64 `json:"unattributed_revenue_cents"`
UnresolvedEvents int64 `json:"unresolved_events"`
CacheWriteExcluded bool `json:"cache_write_excluded"`
}
type MeterResolver ¶
type MeterResolver interface {
GetMeterByKey(ctx context.Context, tenantID, key string) (domain.Meter, error)
}
MeterResolver looks up a meter by key.
type MeterTotal ¶
MeterTotal is the per-meter row in Aggregate.ByMeter — one entry per distinct meter_id matching the filter, sorted by Total DESC so the dashboard's horizontal-bar breakdown can render in priority order.
type PostgresStore ¶
type PostgresStore struct {
// contains filtered or unexported fields
}
func NewPostgresStore ¶
func NewPostgresStore(db *postgres.DB) *PostgresStore
func (*PostgresStore) Aggregate ¶
func (s *PostgresStore) Aggregate(ctx context.Context, filter ListFilter) (Aggregate, error)
Aggregate returns the totals + per-meter breakdown that powers the /usage page's stat cards + "Usage by Meter" card. It honours the same filter as List (customer / meter / from / to) but ignores limit/offset — the whole point is to surface filtered totals, not page slices.
SQL strategy: one row-of-totals query for COUNT/SUM/distinct-counts, plus a GROUP BY meter_id query for the per-meter breakdown. Both run inside the same tenant tx so RLS scopes them identically. SUM is decimal-precision (NUMERIC(38,12)) so 0.5 + 0.5 + 0.0001 round-trips to "1.0001" without floating-point drift.
func (*PostgresStore) AggregateByPricingRules ¶
func (s *PostgresStore) AggregateByPricingRules( ctx context.Context, tenantID, customerID, meterID string, defaultMode domain.AggregationMode, from, to time.Time, ) ([]domain.RuleAggregation, error)
AggregateByPricingRules implements the priority+claim resolution path described in docs/design-multi-dim-meters.md. Strategy:
- Rank meter_pricing_rules by (priority DESC, created_at ASC, id ASC) so the ROW_NUMBER is fully deterministic. The id tiebreaker covers the corner case where two rules share the same priority AND were inserted within the same created_at tick (bulk import, same-txn bootstrap, clock-resolution collisions) — without it, Postgres is free to order them differently across servers, which would make billing non-reproducible across replicas.
- LEFT JOIN LATERAL each in-period event against the ranked rules, keeping only the top-priority rule whose dimension_match is a subset of the event's properties. NULL rule means unclaimed.
- Aggregate per rule. The CASE inside the SELECT is safe because every event in a given (rule_id) group shares the rule's mode — we GROUP BY (rule_id, mode, rrv) so the CASE evaluates a constant within each group.
last_ever needs a separate query because it ignores the period bounds. We run it only if any of the meter's rules is last_ever, then merge.
func (*PostgresStore) AggregateDailyBuckets ¶
func (s *PostgresStore) AggregateDailyBuckets(ctx context.Context, tenantID, customerID string, meterIDs []string, from, to time.Time) ([]DailyBucketRow, error)
AggregateDailyBuckets — see Store interface for contract. UTC-day granularity matches every reference platform (Datadog, OpenAI, AWS Cost Explorer); finer grain (hour) lives in a future bucket-grain param when an operator needs it. NULL meter_ids → empty result with no DB roundtrip. The result is unsorted; the service fills gaps and sorts by (bucket_start, meter_id) before serving.
func (*PostgresStore) AggregateForBillingPeriod ¶
func (*PostgresStore) AggregateForBillingPeriodByAgg ¶
func (*PostgresStore) CustomerProviderCost ¶
func (s *PostgresStore) CustomerProviderCost(ctx context.Context, tenantID, customerID string, from, to time.Time) (byModel []CustomerCostByModel, unresolvedEvents int64, err error)
func (*PostgresStore) DeleteProviderCostRate ¶
func (s *PostgresStore) DeleteProviderCostRate(ctx context.Context, tenantID, id string) error
DeleteProviderCostRate removes a rate row. Stamped events keep their snapshot (documented — deletion never rewrites history).
func (*PostgresStore) DeleteProviderCostRateAudited ¶ added in v0.2.0
func (s *PostgresStore) DeleteProviderCostRateAudited( ctx context.Context, tenantID, id string, emit func(tx *sql.Tx, deleted domain.ProviderCostRate) error, ) error
DeleteProviderCostRateAudited is DeleteProviderCostRate with an in-tx audit emission hook (ADR-090 shared fate). Two properties matter here:
- emit runs ONLY on a row that actually vanished. DELETE … RETURNING yields a row iff exactly one was removed; a miss is sql.ErrNoRows → errs.ErrNotFound with no emission, so deleting a nonexistent rate can never fabricate a "deleted" record.
- emit receives the DELETED row, read inside the tx. The row is gone afterwards, so the audit entry is the only surviving description of what the operator removed — an id alone would be unresolvable forever.
func (*PostgresStore) GetByIdempotencyKey ¶
func (s *PostgresStore) GetByIdempotencyKey(ctx context.Context, tenantID, key string) (domain.UsageEvent, error)
GetByIdempotencyKey fetches the event a replayed key originally wrote. Backs replay-as-success on the public ingest door: instead of a bare 409, the handler returns the original row (Stripe idempotency shape). Livemode scoping rides the RLS session like every other reader.
func (*PostgresStore) Ingest ¶
func (s *PostgresStore) Ingest(ctx context.Context, tenantID string, event domain.UsageEvent) (domain.UsageEvent, error)
func (*PostgresStore) IngestAudited ¶ added in v0.2.0
func (s *PostgresStore) IngestAudited(ctx context.Context, tenantID string, event domain.UsageEvent, emit func(tx *sql.Tx, out domain.UsageEvent) error) (domain.UsageEvent, error)
IngestAudited is Ingest with an in-tx audit emission hook (ADR-090).
Live ingest (POST /v1/usage-events, /batch, the LiteLLM spend callback) passes nil: it is machine metering at up to ~1000/s, usage_events IS the record, and a row per event would double the write volume of the hottest path for no forensic gain. BACKFILL is the exception and passes an emitter: an operator inserting BACKDATED usage is changing what a customer will be billed for a period that may already have closed — an operator action on the money path, not machine telemetry. Sharing the ingest tx means a backfilled event that cannot be recorded is not ingested at all.
func (*PostgresStore) IngestBatch ¶
func (s *PostgresStore) IngestBatch(ctx context.Context, tenantID string, events []domain.UsageEvent) (inserted, deduped int, err error)
IngestBatch writes every event in ONE transaction — all-or-nothing. Pre-fix, BatchIngest looped one tx per event: a mid-batch abort (client timeout, connection drop, crash) left a COMMITTED PREFIX, and the standard client response — retry the whole batch — re-ingested that prefix. Events without idempotency keys have no dedup line of defense, so the retry double-billed every prefix event. Atomic batches make the keyless retry safe: either the response arrived (don't retry) or nothing committed (retry is a clean first write).
Keyed duplicates (replay of a fully-committed batch, or the same key twice within one batch) are counted in deduped, not errors — matching the LiteLLM door's replay contract.
func (*PostgresStore) List ¶
func (s *PostgresStore) List(ctx context.Context, filter ListFilter) ([]domain.UsageEvent, int, error)
List paginates usage events ordered by (timestamp DESC, id DESC). Returns events + total (for the legacy offset path; 0 when cursor path used). Supports two mutually-exclusive paging shapes:
- Cursor (preferred, 2026-05-29): filter.AfterTimestamp + filter.AfterID set → seek-method query `WHERE (timestamp, id) < (after_ts, after_id)`. Skips the COUNT query, fetches limit+1 to detect hasMore via the next handler call. Stable across concurrent inserts at the table's head — usage_events is the highest-write table in Velox, so offset-based pagination page-skewed reliably whenever a cycle close fired between operator pages.
- Offset (legacy): COUNT + LIMIT/OFFSET. Kept for the dashboard paths that still use offset+total for "Page 1 of N" UX.
id as the tiebreaker keeps ordering deterministic when many events share a microsecond — common for batched ingestion.
func (*PostgresStore) ListProviderCostRates ¶
func (s *PostgresStore) ListProviderCostRates(ctx context.Context, tenantID string) ([]domain.ProviderCostRate, error)
ListProviderCostRates returns the tenant's current rates (mode-scoped via RLS), ordered for the dashboard table.
func (*PostgresStore) StreamForExport ¶ added in v0.2.0
func (s *PostgresStore) StreamForExport(ctx context.Context, tenantID string, from, to time.Time, fn func(domain.UsageEvent) error) error
StreamForExport walks EVERY usage event in the [from,to] window in ONE snapshot transaction. See customer.PostgresStore.StreamForExport for the general reason (issue #475); usage is the case that made it urgent. This table is the hottest write path in the product, and the export is given a five-minute timeout PRECISELY because it streams a lot of rows — which is exactly the window in which concurrent ingest is guaranteed. Under the old LIMIT/OFFSET-per-transaction paging, every event ingested mid-export shifted the newest-first window and duplicated the row on a page boundary. A duplicated usage row in a finance CSV reads as usage that did not happen.
The SELECT carries `origin`, which List's does not — so the `origin` column in usage-events.csv was always empty, and an operator could not tell an operator BACKFILL from a metered API event in the artifact they reconcile with. The export's columns are its own contract.
func (*PostgresStore) UpsertProviderCostRate ¶
func (s *PostgresStore) UpsertProviderCostRate(ctx context.Context, tenantID string, r domain.ProviderCostRate) (domain.ProviderCostRate, error)
UpsertProviderCostRate creates or edits-in-place the rate for a key (current-rate semantics, ADR-079 D1: the per-event stamp is the history; editing a rate only affects FUTURE events).
func (*PostgresStore) UpsertProviderCostRateAudited ¶ added in v0.2.0
func (s *PostgresStore) UpsertProviderCostRateAudited( ctx context.Context, tenantID string, r domain.ProviderCostRate, emit func(tx *sql.Tx, out domain.ProviderCostRate) error, ) (domain.ProviderCostRate, error)
UpsertProviderCostRateAudited is UpsertProviderCostRate with an in-tx audit emission hook: the rate write and its audit row commit or roll back together (ADR-090 shared fate). The store owns the transaction and exposes it to the closure; the caller (the handler — this surface has no service) owns row content. emit sees the PERSISTED rate, so the audit row carries the store-assigned id and the values as they actually landed. nil emit = unaudited upsert (unit-test / non-request callers).
The emission is unconditional on success by construction: an INSERT … ON CONFLICT DO UPDATE … RETURNING that scans a row always wrote one (a same-values re-PUT still bumps updated_at — a real mutation), so there is no zero-row arm to fabricate evidence for.
type PricingReader ¶
type PricingReader interface {
GetPlan(ctx context.Context, tenantID, id string) (domain.Plan, error)
GetMeter(ctx context.Context, tenantID, id string) (domain.Meter, error)
GetRatingRule(ctx context.Context, tenantID, id string) (domain.RatingRuleVersion, error)
// GetRuleByKeyAsOf + GetOverrideByKeyAsOf: the ADR-070 resolution
// pair. The customer-usage view must price with the SAME rule the
// cycle close will bill — version and override in force at the
// period open — or the running-spend number is wrong for exactly
// the negotiated-rate customers the spend-cap wedge targets.
GetRuleByKeyAsOf(ctx context.Context, tenantID, ruleKey string, asOf time.Time) (domain.RatingRuleVersion, error)
GetOverrideByKeyAsOf(ctx context.Context, tenantID, customerID, ruleKey string, asOf time.Time) (domain.CustomerPriceOverride, error)
ListMeterPricingRulesByMeter(ctx context.Context, tenantID, meterID string) ([]domain.MeterPricingRule, error)
}
PricingReader resolves plan / meter / rating-rule references when assembling the response. Mirrors the surface pricing.Service exposes — listed here so the customer-usage code owns no cross-domain state.
type ProviderCostHandler ¶
type ProviderCostHandler struct {
// contains filtered or unexported fields
}
ProviderCostHandler is the operator surface: rate CRUD + margin report. Operator-auth only — COGS never renders on customer-facing pages.
func NewProviderCostHandler ¶
func NewProviderCostHandler(store *PostgresStore, margin *MarginAssembler) *ProviderCostHandler
func (*ProviderCostHandler) Margin ¶
func (h *ProviderCostHandler) Margin(w http.ResponseWriter, r *http.Request)
Margin serves GET /v1/customers/{id}/margin?from&to (operator auth; mounted from the router next to the other customer subresources).
func (*ProviderCostHandler) Routes ¶
func (h *ProviderCostHandler) Routes() chi.Router
func (*ProviderCostHandler) SetAuditLogger ¶ added in v0.2.0
func (h *ProviderCostHandler) SetAuditLogger(a AuditEmitter)
SetAuditLogger wires in-tx audit emission for the rate mutations (ADR-090). There is no provider-cost service, so the handler builds the entry — it is the layer that knows the operator's intent — and hands it to the store's …Audited variants, which run it on the write's own transaction. A nil emitter skips emission (keeps handler unit tests fake-friendly); the composition root's audit.MustWired check is what makes a forgotten wiring line fail loudly at boot instead of silently un-auditing the routes.
type Service ¶
type Service struct {
// contains filtered or unexported fields
}
func NewService ¶
func (*Service) Aggregate ¶
Aggregate returns server-side totals + per-meter breakdown matching the filter. Used by GET /v1/usage-events/aggregate so the /usage dashboard's stat cards reflect filtered totals rather than a reduce over the current page of paginated events. Limit/Offset on the filter are intentionally ignored — the whole point is the unbounded total.
func (*Service) AggregateByPricingRules ¶
func (s *Service) AggregateByPricingRules( ctx context.Context, tenantID, customerID, meterID string, defaultMode domain.AggregationMode, from, to time.Time, ) ([]domain.RuleAggregation, error)
AggregateByPricingRules resolves a single (customer, meter, period) into per-rule aggregations using the priority+claim algorithm. The defaultMode applies to events that match no rule; it must be one of the four period-bounded modes (sum, count, max, last_during_period) — last_ever as a meter-default is rejected because it would silently break the "current state" semantics for unclaimed events.
See docs/design-multi-dim-meters.md for the resolution semantics; this method is the runtime entry point that billing-finalize will call.
func (*Service) AggregateDailyBuckets ¶
func (s *Service) AggregateDailyBuckets(ctx context.Context, tenantID, customerID string, meterIDs []string, from, to time.Time) ([]DailyBucketRow, error)
AggregateDailyBuckets delegates to the store. Exposed on the Service so CustomerUsageService can fetch chart data without holding the Store directly. See Store interface for contract.
func (*Service) AggregateForBillingPeriod ¶
func (*Service) Backfill ¶
func (s *Service) Backfill(ctx context.Context, tenantID string, input IngestInput) (domain.UsageEvent, error)
Backfill ingests a historical usage event. Requires a non-nil timestamp strictly in the past; rejects missing / future / equal-to-now values so operators can't accidentally double-post a live event through the audit path. The row is tagged origin='backfill'.
Billing semantics: backfilled events participate in aggregation for any period whose [start, end) contains the event's timestamp. Finalized invoices are immutable (they reference billed_entries, not live aggregations), so backfill into closed periods is safe — it changes the audit ledger without rewriting history.
func (*Service) BatchIngest ¶
func (s *Service) BatchIngest(ctx context.Context, tenantID string, events []IngestInput) (int, int, []error)
BatchIngest validates every event, then writes them ALL in one store transaction — all-or-nothing. Pre-fix each event committed in its own tx: a mid-batch abort (client timeout, dropped connection) left a committed prefix, and the standard retry-the-batch response re-ingested that prefix — double-billed usage for every event without an idempotency key. Now either the whole batch lands or none of it does, so a keyless retry is always a clean first write.
Returns (inserted, deduped, errs). Validation failures are collected for EVERY failing index (a bare "quantity too large" across a 500-event batch was undebuggable) and abort before any write. Idempotency replays count as deduped — success, not failure — matching the LiteLLM door.
func (*Service) GetByIdempotencyKey ¶
func (s *Service) GetByIdempotencyKey(ctx context.Context, tenantID, key string) (domain.UsageEvent, error)
GetByIdempotencyKey fetches the event a replayed key originally wrote — backs the public door's replay-as-success response (200 + original row + Idempotent-Replayed header instead of a bare 409).
func (*Service) GetSummary ¶
func (s *Service) GetSummary(ctx context.Context, tenantID, customerID string, from, to time.Time) (UsageSummary, error)
GetSummary aggregates usage for a customer in the current billing period.
Aggregation is done server-side via store.Aggregate (COUNT(*) + GROUP BY meter_id SUM) over the full filtered set. The previous List(Limit:10000)+Go-reduce undercounted: List clamps the limit to 1000, so any customer with >1000 events in the window reported a truncated total — wrong on both the per-meter quantities and the event count.
func (*Service) Ingest ¶
func (s *Service) Ingest(ctx context.Context, tenantID string, input IngestInput) (domain.UsageEvent, error)
func (*Service) List ¶
func (s *Service) List(ctx context.Context, filter ListFilter) ([]domain.UsageEvent, int, error)
func (*Service) SetAuditLogger ¶ added in v0.2.0
func (s *Service) SetAuditLogger(a AuditEmitter)
SetAuditLogger wires in-tx audit emission for BACKFILL. Live ingest stays unaudited by design (machine metering; usage_events is the record) — but an operator inserting BACKDATED usage is changing what a customer gets billed for a period that may already have closed, so that action is recorded.
func (*Service) SetResolver ¶
SetResolver wires the unified clock.Resolver (implemented by *billing.Engine). Ingest timestamps default to and gate against the CUSTOMER's effective now — a test clock's frozen_time when the customer is pinned — so usage ingestion works in simulated time: on a clock advanced into the (wall-clock) future, events without a timestamp land at frozen_time inside the simulated current period, and sim-timestamped events pass the future-skew gate that wall-clock comparison would wrongly reject. Optional: nil keeps wall-clock gating (narrow tests).
type Store ¶
type Store interface {
Ingest(ctx context.Context, tenantID string, event domain.UsageEvent) (domain.UsageEvent, error)
// IngestAudited runs the caller-supplied audit emission on the ingest's
// own transaction (ADR-090). Only BACKFILL passes an emitter — live
// machine ingest passes nil (see PostgresStore.IngestAudited).
IngestAudited(ctx context.Context, tenantID string, event domain.UsageEvent, emit func(tx *sql.Tx, out domain.UsageEvent) error) (domain.UsageEvent, error)
// IngestBatch writes every event in ONE transaction — all-or-nothing,
// so a client retry after a mid-batch abort never re-ingests a
// committed prefix. Idempotency replays are counted in deduped, not
// errors. Returns (inserted, deduped, err).
IngestBatch(ctx context.Context, tenantID string, events []domain.UsageEvent) (int, int, error)
// GetByIdempotencyKey returns the event a replayed key originally
// wrote (errs.ErrNotFound when absent). Backs replay-as-success on
// the public ingest door.
GetByIdempotencyKey(ctx context.Context, tenantID, key string) (domain.UsageEvent, error)
List(ctx context.Context, filter ListFilter) ([]domain.UsageEvent, int, error)
Aggregate(ctx context.Context, filter ListFilter) (Aggregate, error)
AggregateForBillingPeriod(ctx context.Context, tenantID, customerID string, meterIDs []string, from, to time.Time) (map[string]decimal.Decimal, error)
AggregateForBillingPeriodByAgg(ctx context.Context, tenantID, customerID string, meters map[string]string, from, to time.Time) (map[string]decimal.Decimal, error)
// AggregateByPricingRules walks meter_pricing_rules in priority-DESC
// order and claims each in-period event to its top-priority matching
// rule (JSONB superset on properties), then aggregates per rule using
// the rule's aggregation_mode. Events that match no rule are returned
// as an unclaimed entry (RuleID=="") aggregated with defaultMode.
//
// last_ever rules ignore the period bounds and pick the latest event
// across all time (for "current state" billing like seat counts).
AggregateByPricingRules(ctx context.Context, tenantID, customerID, meterID string, defaultMode domain.AggregationMode, from, to time.Time) ([]domain.RuleAggregation, error)
// AggregateDailyBuckets returns events bucketed to UTC-day granularity
// over [from, to) for the given customer × meter set. One row per
// (bucket_start, meter_id) — the service fills missing buckets with
// zero so chart consumers get continuous time. Sums to the same
// per-meter total as AggregateForBillingPeriod over the same window;
// powers the daily-bar-chart on the customer-usage view (matches
// Datadog / OpenAI / AWS Cost Explorer's primary visual primitive).
AggregateDailyBuckets(ctx context.Context, tenantID, customerID string, meterIDs []string, from, to time.Time) ([]DailyBucketRow, error)
}
type SubscriptionLister ¶
type SubscriptionLister interface {
List(ctx context.Context, filter subscription.ListFilter) ([]domain.Subscription, int, error)
}
SubscriptionLister returns the customer's subscriptions hydrated with their items so we can collect the meter union across subscribed plans.
type UsageSummary ¶
type UsageSummary struct {
CustomerID string `json:"customer_id"`
PeriodFrom time.Time `json:"period_from"`
PeriodTo time.Time `json:"period_to"`
Meters map[string]decimal.Decimal `json:"meters"` // meter_id -> total quantity
TotalEvents int `json:"total_events"`
}
UsageSummary represents aggregated usage for a customer in a period.