Advanced pricing - overrides, keyed prices, multi-currency
This page covers the patterns layered on top of the core pricing models: how the resolver picks which price to use, how to bill the same product across many dimensions via keyed prices, and how multi-currency conversion runs.
Resolution order
When the engine needs the price for a (product, plan, customer, time) tuple, it scores every candidate and picks the most specific. Highest precedence first:
- Customer-scoped price - a
pricesrow withcustomer_idset to this customer (aCUSTOMER_OVERRIDErow). Phase-item overrides on a phased subscription are justCUSTOMER_OVERRIDErows too; they resolve at this same tier inside the single query below - there is no separate phase-override branch resolved ahead of the cascade. - Plan-scoped price - a row with
plan_idset to this plan. - Keyed price - a row with a non-null
price_key, matched against the event's key. A keyed price beats an unkeyed (set-level / country / dimension) row for the same product. - Country-coded price - a row with
country_codematching the customer's billing country. - Custom-dimension specificity - among rows whose
custom_dimensionsare a subset of the caller's dimensions, the one with the most dimensions matched wins. - Most recent version - among ties, the candidate with the latest
effective_fromon its version timeline (or the version active atat_timefor historical replay).
This single ORDER BY runs in one indexed query - there's no separate "look up override, fall back to base" round-trip. A Price row's kind field (BASE vs CUSTOMER_OVERRIDE) is metadata for tooling; the resolver doesn't gate on it.
The full resolution including FX conversion is auditable on the invoice line. The line records the
price_id,price_version_id,unit_amount, and any conversion adjustment, so a regenerated invoice always reproduces the same total.
Customer price overrides
Override the catalog price for a specific customer - useful for negotiated enterprise rates, loyalty discounts, or per-tenant pricing.
There is no separate /customer-price-overrides endpoint. You create an override by POST /v1/prices with customer_id set (it lands as a CUSTOMER_OVERRIDE row on the prices table); the resolver picks it up automatically because of the cascade above. There is no separate overrides table - overrides and base prices share the one prices table, distinguished only by the kind field.
Code
Overrides:
- can scope to a specific
plan_idtoo - combine fields to express "Acme on the Pro plan" - can be time-bounded via
effective_from/effective_to- useful for promotional windows or contract terms that auto-expire - can carry their own tier structure (different from the catalog price's tiers)
- versioned independently - updating the override tiers creates a new
price_versionso historical invoices replay against the version that was active at issue time
Geo and dimension scoping
Two ways to vary a price by context other than customer:
| Field | Use it for | Example |
|---|---|---|
country_code | Single ISO 3166-1 alpha-2 code matched against the customer's billing country | A reduced rate for DE customers |
custom_dimensions | Arbitrary key/value scope (region, environment, tier) - JSONB containment, caller's dimensions ⊇ price's dimensions | A {"region": "EU"} price matches calls with region=EU in their dimension_vars |
Both are subordinate to customer / plan scoping in the cascade above, so a customer override always beats a country price.
Keyed prices
A product becomes keyed the moment you point its key_set_id at an org-global key set. The key set is the registry that defines which price_key values exist (key, display name, status, tags - never money); a product can subtract keys it doesn't offer via key_set_excluded_keys (subtract-only - a product can never add a key the set doesn't define). Once a product references a key set:
- The valid
price_keydomain for the product is the set's active entries minuskey_set_excluded_keys. - A keyed
Pricecarries aprice_keydrawn from that domain. A single price with a NULLprice_keyandis_set_price=truemay stand in as the set-level fallback the resolver uses when no exact-key price exists. - Every usage event (and reserve) references a
price_keyin the product's domain. - Each price can carry an optional
display_name(M186) - a customer-facing label used on invoices and PDFs in place of the bare key. Per-key display names also live centrally on the key set's entries (key_set_entries.display_name) in the registry. - The
(product_id, price_key)pair is the natural key - two prices with the same key collide.
Code
Key matching and the set-level fallback
Key validity is enforced at the write paths - usage-event ingestion, subscription items, and price creation all check the requested price_key against the product's registry domain (the key set's active entries minus key_set_excluded_keys). A key outside that domain is rejected at write time, so an unknown key never reaches the resolver.
At resolution, a request that carries a price_key matches the exact-key price first; if no exact-key price exists, it falls back to the product's set-level price (the price_key-NULL row with is_set_price=true), when one is defined. A request without a key matches only plain unkeyed rows and never the set price - keyed products always require a key, unkeyed products never see one, so there is no cross-leakage in either direction.
Invoice-line behaviour
How keyed lines roll up onto the invoice is controlled per plan:
expanded/per_key- one invoice line per(product, price_key). The line'sprice_keyandprice_key_display_nameare populated; per-key audit detail is keyed.per_product- one collapsed line per product. The line'sprice_keyis null; per-key detail lives in theatomsarray (returned with?depth=full).
See Invoices for line groupings and the audit-atom shape.
Multi-currency
Two parts: catalog and conversion.
Catalog
Create one Price per (product, plan, currency) combination - same product, side-by-side prices in different currencies:
Code
If a Price row matches the customer's preferred currency exactly, it's used as-is - no conversion runs.
Conversion
When no per-currency price matches, the resolver picks the best price by the cascade above and the post-processor converts it. resolveTargetCurrency selects the target in this order:
req.Currency- caller explicitly demanded a currency (e.g. an invoice template fixed to one).- The customer's
preferred_currency. - The org's default currency.
- Falls back to the price's native currency (no conversion).
Each step is skipped when its candidate equals the price's resolved currency, so conversion only runs when the target genuinely differs.
The FX rate's effective time comes from the request's FXContext (an fx.FXOperationContext carrying the operation kind - invoice_finalize, preview, proration, reserve, refund, wallet_transfer - plus the anchoring moments it pins, e.g. InvoiceIssue). When no FXContext is supplied, the post-processor defaults to an invoice_finalize context and uses req.AtTime as the issue moment for historical-replay determinism; absent that, the converter falls back to live ("now") behaviour.
The full FX policy model - three candidate rates pinned per invoice and the policies that select between them - is in Exchange Rates.
Endpoints
- Prices - single endpoint for base, customer-overrides, plan-scoped, geo, and keyed prices
- Products -
key_set_idandkey_set_excluded_keys(the keyed-pricing opt-in) live here
Related
- Pricing models - VOLUME / STAIRCASE / PACKAGE + per-tier rate expressions
- Products - keyed-product setup, quantity-source ↔ pricing-model compatibility
- Exchange rates - FX policies and the three pinned candidates per invoice
- Currencies & dates -
Moneyenvelope, per-currency scale - Subscriptions - phased subscriptions where phase-item overrides apply