usage

package
v0.3.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 25, 2026 License: MIT Imports: 24 Imported by: 0

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

View Source
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.

View Source
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

type AuditEmitter interface {
	LogInTx(ctx context.Context, tx *sql.Tx, e audit.Entry) error
}

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

func (a *CostDashboardAssembler) GetByToken(ctx context.Context, token string) (any, error)

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 CostDashboardPeriod struct {
	Start  time.Time `json:"start"`
	End    time.Time `json:"end"`
	Source string    `json:"source"` // "subscription" | "no_subscription"
}

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 CostDashboardRule struct {
	RuleKey           string         `json:"rule_key"`
	DimensionMatch    map[string]any `json:"dimension_match,omitempty"`
	Quantity          string         `json:"quantity"`
	AmountCents       int64          `json:"amount_cents"`
	UnitAmountDecimal *string        `json:"unit_amount_decimal,omitempty"`
}

type CostDashboardSubscription

type CostDashboardSubscription struct {
	ID                 string    `json:"id"`
	PlanName           string    `json:"plan_name"`
	Currency           string    `json:"currency"`
	CurrentPeriodStart time.Time `json:"current_period_start"`
	CurrentPeriodEnd   time.Time `json:"current_period_end"`
}

type CostDashboardTotal

type CostDashboardTotal struct {
	Currency    string `json:"currency"`
	AmountCents int64  `json:"amount_cents"`
}

type CustomerCostByModel

type CustomerCostByModel struct {
	Model      string
	CostMicros int64
	Events     int64
}

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

type CustomerUsagePeriod struct {
	From time.Time
	To   time.Time
}

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:

  1. Customer existence (RLS makes cross-tenant IDs return ErrNotFound).
  2. Active+trialing subscriptions for the customer.
  3. Period resolution (current cycle from primary sub, or explicit ?from=&to= validated for partial bounds, ordering, 1-year cap).
  4. 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.
  5. 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

type DailyBucketRow struct {
	BucketStart time.Time
	MeterID     string
	Quantity    decimal.Decimal
}

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) Routes

func (h *Handler) Routes() chi.Router

func (*Handler) SummaryRoutes

func (h *Handler) SummaryRoutes() chi.Router

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

type MeterTotal struct {
	MeterID string          `json:"meter_id"`
	Total   decimal.Decimal `json:"total"`
}

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:

  1. 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.
  2. 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.
  3. 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 (s *PostgresStore) AggregateForBillingPeriod(ctx context.Context, tenantID, customerID string, meterIDs []string, from, to time.Time) (map[string]decimal.Decimal, error)

func (*PostgresStore) AggregateForBillingPeriodByAgg

func (s *PostgresStore) AggregateForBillingPeriodByAgg(ctx context.Context, tenantID, customerID string, meters map[string]string, from, to time.Time) (map[string]decimal.Decimal, error)

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

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 NewService(store Store) *Service

func (*Service) Aggregate

func (s *Service) Aggregate(ctx context.Context, filter ListFilter) (Aggregate, error)

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 (s *Service) AggregateForBillingPeriod(ctx context.Context, tenantID, customerID string, meterIDs []string, from, to time.Time) (map[string]decimal.Decimal, error)

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

func (s *Service) SetResolver(r clock.Resolver)

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL