ADR-130: Usage prices belong to the plan, and a price edit applies from each subscriber's next period
Date: 2026-10-23
Status: Accepted (product owner, 2026-10-23). PR-0b (#1367) shipped the plan step of the lock order (section 8). PR-1 (#1373) shipped the one rater. PR-2 (#1376) shipped section 2, one aggregation per meter; #1379 amended it on 2026-10-24 (held rows do not fix the aggregation). PR-3 (#1381) shipped section 3, price shapes and units. PR-4 (#1386) shipped sections 1, 4 to 11 and 14 to 16: prices on the plan, the edit rule for usage prices, overrides, test clocks, the rest of the lock order, windows, customer usage, currency, base_bill_timing and the API; recipes installed through a parser that turned the old YAML into plan prices. PR-4b (#1388) amended section 4 step 3 on 2026-10-26: each plan of a period is priced where the subscription first joined it in the period. PR-5 (#1389) shipped the recipe part of section 15: a recipe file holds only meters and plans with their prices, and an install creates no dunning policy or webhook endpoint. PR-6 (#1390) shipped section 12, with D07 folded in: the answer that writes an event carries a billing_status when Hardcount finds a reason the event is not billed, what Hardcount found then and not a promise (ADR-120 amendment 2026-10-27), and one unbilled counter. PR-7, the base fee next-period rule, was cut from v0.9.0 on 2026-10-27 (section 5): the base fee stays locked while a live subscription uses the plan. Not built yet: the polish of PR-8, before the v0.9.0 tag. Each rule below names the PR that brings it.
Summary
Section titled “Summary”Today a usage price sits on the meter, so every plan that uses a meter bills the same rate. After this change each plan has its own usage prices: Pro can pay $3 and Enterprise $2 per million input tokens, from the same usage events. The meter only counts.
A price is a row on a plan. It names a meter, the events it takes (for
example model = claude-opus-4.5, token_type = input) and the rate. When
several prices could take an event, the one that names the most labels
wins. Two prices that could both take one event with the same number of
labels are refused when you save the second.
Editing a price never changes a period that has already started. The one exception is measured in milliseconds: a period that starts while the edit is being saved gets the edit (section 7). On a plan no subscription has ever used, the edit applies at once. On a used plan, each subscriber pays the new price from their next billing period. This holds in each subscriber’s own time, so a test-clock customer gets the edit from its next simulated period. To keep old customers on an old price, put new customers on a new plan. A customer’s negotiated price replaces one plan price for that customer. Prices take the plan’s currency.
Usage on a yearly plan is billed once a year, at the year-end invoice. The base fee stays locked while a live subscription uses the plan (PR-7, which would have moved it to the next-period rule, was cut on 2026-10-27). Recipes create meters and plans only; they no longer change dunning or webhook settings.
Words used here
Section titled “Words used here”| Word | Meaning |
|---|---|
| meter | What Hardcount counts from usage events, such as tokens, and how: sum, count, max or last value. |
| plan | What a customer subscribes to: a currency, a billing interval, a base fee and, after this ADR, its usage prices. |
| price | One row on a plan: a meter, a dimension_match and a rate. It has a stable id. |
dimension_match |
The labels an event must carry for a price to take it, such as {"model": "claude-opus-4.5", "token_type": "input"}. Labels are an event’s dimensions. Values are strings. {} matches every event of the meter. |
| specificity | The number of keys in a dimension_match. The matching price with the most keys wins. |
| billing period | The dates one subscription invoice covers: a month or a year. A period normally starts at midnight in the account’s billing timezone. |
| pin | The instant a plan’s prices are read at for one subscription’s billing period: the period’s start, or the first instant in it the subscription was on the plan (section 4 step 3). |
| window | A stretch of a billing period during which a subscription was on one plan. A mid-period plan change splits the period into two windows. |
| used plan | A plan that a subscription has ever referenced, canceled ones included. |
| version | Hardcount’s internal record of a price’s content from one instant on. A removal is a version that says “removed from here on” (a tombstone). Versions never appear in the API. |
| test clock | A pretend clock for test customers. Its time is simulated time; real time is wall time. |
| override | A negotiated price for one customer. It replaces the rate of one plan price for that customer. |
Context
Section titled “Context”How pricing works today (origin/main c234a364):
- A usage price is a rating rule with versions (
rating_rule_versions). A meter points at one by default (meters.rating_rule_version_id). Meter pricing rules (meter_pricing_rules) add more rules, each with apriority, adimension_matchand its own aggregation mode. - A plan lists meters (
plans.meter_ids). Every plan that lists a meter bills that meter’s rules. So Pro and Enterprise cannot charge different token rates without a second event name. - A period is priced by the rule versions in force at its start
(ADR-070). The meter’s own pricing (its
rules, default price and aggregation) is read at billing time, so editing
it repriced the open period. Since D05 (#1362) those edits are refused
with 409
meter_in_usewhile a draft, trialing or active subscription uses the meter. - A plan’s base fee, billing timing and meters are locked while a
subscription that is not canceled uses the plan
(ADR-034;
internal/pricing/service.go:637-647, a 422validation_erroron paramplan). So a plan has two edit rules: rule versions apply next period, the base fee is locked. - Rule versions are stamped with wall time, and so are overrides: nothing on
the override write path binds the customer’s test clock
(
internal/pricing/handler.go:115passes the request context;internal/pricing/override.go:259-316). A test clock compares its simulated period start with those wall stamps. A clock moved ahead of wall time gets an edit in the simulated period already open; a clock in the past waits for wall time. ADR-070 decision 4 and the dashboard (web-v2/src/pages/Pricing.tsx:724,web-v2/src/components/PriceOverridesCard.tsx:384) say so.
What other billing engines do. In all four open-source engines checked, a plan owns its usage prices (code read 2026-10-22):
- Lago: a charge belongs to a plan, unique per
(plan_id, code)(lago-api2e52dfc,db/structure.sql:8911). - Flexprice: a price has an
entity_type, andPLANis one of them (flexprice1c23b81,internal/types/price.go:39,internal/api/dto/price.go:20). - OpenMeter: rate cards sit in a plan phase (openmeter
f04b561,openmeter/productcatalog/phase.go:91-95). - Kill Bill: usage sections sit in a plan phase (killbill
3548b02,catalog/src/main/java/org/killbill/billing/catalog/DefaultPlanPhase.java:70-72).
Why now. The strategy amendment of 2026-10-23 moved this change into v0.9.0, before the first tag, because Hardcount has no known users or partners. Changing the pricing contract now costs almost nothing; after the tag it is a break. v0.9.0 is also a clean break: there is no upgrade from Hardcount 0.8.x (decided 2026-10-22).
Decision
Section titled “Decision”1. Prices are rows on the plan (PR-4)
Section titled “1. Prices are rows on the plan (PR-4)”- New tables, each with
tenant_id,livemodeand row-level security:plan_prices: the identity of a price, unique on(tenant_id, plan_id, meter_id, dimension_match). Rows are never deleted.plan_price_versions: a price’s content over time (valid_from, a tombstone flag, the shape fields).plan_price_version_clocks: when a version takes effect on one test clock (section 7).
- Removed:
rating_rule_versions,meter_pricing_rules,meters.rating_rule_version_idandplans.meter_ids. With them go the meter’s default price,priority, the rule key, the rule lifecycle and every rule currency. - A plan’s meters are the meters it has a price row for. A meter only counts.
dimension_matchvalues are strings, at most 16 keys, and keys and values at most 100 characters.
2. A meter has one aggregation (PR-2)
Section titled “2. A meter has one aggregation (PR-2)”aggregationis one ofsum,count,max,last.lastis the latest event by timestamp, then by id, on every path. Today two readers break that tie differently (internal/usage/postgres.go:481-486uses the id,:739does not).last_everis dropped, and so is the per-rule aggregation mode.keyis renamedevent_name, the name ingest already sends.- The aggregation cannot change once the meter has events: 409
meter_has_usage. Events are rows inusage_eventsfor the meter. The meter gainshas_usageso the dashboard can show this before an edit.nameandunitchange at once.
Amendment (2026-10-24, the D08 PR-2 follow-up): held rows
(usage_held_events) do not fix the aggregation; only rows in
usage_events do. This replaces the earlier rule that a held row counts.
- Why: a held row has never been billed. Under the earlier rule, a meter
made with the wrong aggregation after its event name was held could never
be fixed: there is no meter delete, and
event_namecannot change. The only way out was to discard the rows, which are then never billed. - Why no billed number changes: a release writes
usage_events, and each insert’s foreign key takes the meter rowFOR KEY SHARE, the lock an ingest takes. The aggregation PATCH takes the meterFOR UPDATEbefore it reads whether the meter has events. So a PATCH and a release run one after the other: the PATCH sees the released events and refuses, or the release waits and its events are counted under the new aggregation. - The release’s counts (
in_open_periodand the rest) read the meter’s aggregation on the release’s own transaction, after its inserts, so they follow the aggregation its events are counted under. - Ingest does the same since 2026-10-25, at every door: single, batch, backfill and the LiteLLM call. The after_close flag, its counter and its WARN read the aggregation on the ingest’s own transaction, after its insert. Before, they read it before the write. A change committing in between could make them follow the old aggregation. Billed amounts were not affected.
- A hold takes no meter lock any more, which also removes the race #1376 listed between a hold and a meter created during it.
3. Price shapes and units (PR-3)
Section titled “3. Price shapes and units (PR-3)”per_unit:{unit_amount_cents}. It replacesflat.graduated:{tiers: [{up_to, unit_amount_cents}]}.up_tostrictly increases, and only the last tier hasup_to: null.package:{package_size, package_amount_cents}, billed asceil(quantity / package_size) × package_amount_cents. A part package bills as a whole one. There is no overage field.- Every money field named
*_centsis in the currency’s minor unit. Rates are decimal strings with at most 12 decimal places. Invoice amounts stay whole cents, rounded once per line. - Provider costs follow the same unit rule:
unit_cost_centsreplacescost_per_tokenin dollars, and API cost fields become*_cost_cents.
4. How an event finds its price (PR-4)
Section titled “4. How an event finds its price (PR-4)”- The one live subscription that covers the customer and the meter owns
the event.
rejectMeterOverlapkeeps that to one (internal/subscription/service.go:608). - The window holding the event’s timestamp gives the plan
(
billing_intervals). - The pin is the start of the billing period being priced. It is the same for every reader of that period: the close, an immediate cancel, a spend threshold invoice, the preview, the live balance and customer usage. The pin is read in the subscription’s own time: wall time, or its test clock’s time.
- Each of the plan’s prices for that meter takes its latest version in force at the pin. A price with no version then, or whose version is a tombstone, takes no part.
- The candidates are the prices whose
dimension_matchthe event’s dimensions contain. - The candidate with the most keys wins.
{}is the catch-all with 0 keys. - No candidate: the event is stored and not billed (
no_price, section 12). - An override for the customer and the winning price, in force at the pin, replaces that price’s rate.
Amendment (2026-10-26, D08 PR-4b, #1388): a subscriber pays the plan as it stands when they join it. The product owner decided this on the open item of #1386. Step 3 changes; the other steps stay.
- The pin is per plan and billing period: the later of the period start and the first instant in the period at which the subscription had an item on the plan. Every window of the plan in the period is priced there, with the overrides in force there.
- A subscription on the plan when the period starts is priced at the period
start, as before. One that joins the plan in the period is priced where it
joined:
- a new subscription at its start (
started_at); after a trial, at the trial end, where its first paid period starts; - an immediate swap onto the plan at the swap;
- an added item at the add. An add on a period’s first day opens its
billing interval at the period start (ADR-012), so the add’s instant is
read from the item’s
created_at. An item counts as added when itscreated_atis after the subscription’s; items created with the subscription share itscreated_at.
- a new subscription at its start (
- A subscription that leaves the plan and joins it again in the period keeps the first pin, so it is never repriced inside a period. A removed item and an item added back, or a swap off the plan and back, are priced at the first join for the whole period. A change made in between applies from the next period.
- A quantity change is not a join. A swap from a plan with another billing interval starts a period at the swap, and a scheduled swap applies at a period start, so for both the pin is the period start.
- The usage a window covers does not change. A first period still starts at midnight and bills usage from then (ADR-012); that usage is priced at the subscription’s start. Neither another subscription’s coverage end (a usage floor) nor a threshold invoice’s watermark moves a pin. A trial end is a pin, as above: the first paid period starts there.
- So, on a used plan, a subscription that starts after a change pays it from its first period: the changed rate, a price added earlier, and not a price removed earlier. An override created before the subscription joins the plan applies from its first period. A change or override made after it joined applies from its next period.
- Windows are keyed by plan and meter, as before this amendment. The windows
of one plan in a period share its pin, so where they touch they are rated
as one: a
maxorlastmeter bills one peak and graduated tiers run on. Time with a gap between is rated as separate windows, each with its own peak and tiers, as before. - One meter’s usage at one instant is in one window. The meter-overlap guard keeps two live items of a subscription off one meter. But an add on a period’s first day bills from the period start (ADR-012), so its window can reach back over an item that left the meter earlier that day. That time stays with the item that joined its plan first, the item on the meter then; the added item keeps the rest. Across two plans this ends a double bill that was older than this amendment.
- Every reader takes the pins from one function. They are the close, an immediate cancel and the cross-interval swap, the threshold invoice and its item caps, and the previews, the trial-end preview too. They are also the live balance, customer usage, the cost dashboard and margin. An estimate over a chosen window uses the current period’s pins (section 10). Ingest reads each current plan at the same pin; it reads the subscription’s billing intervals only when an item joined its plan in the period, since only then can an earlier join exist. Credit notes work from invoice lines.
- On a test clock the pin is in simulated time. A price version or an override written at clock time T takes effect at T + 1 µs (sections 6 and 7), so a subscription created, an immediate swap or an item added while the clock still reads T gets the earlier prices. Advance the clock first.
- Every pin is a stored instant (
started_at,trial_end_at, a billing interval’s start, an item’screated_atandplan_changed_at), so all readers agree on it. A subscription create, an immediate swap and an item add hold their plansFOR KEY SHARE, so a price write on those plans commits before or after them, never during. Section 7’s commit straddle now reaches a join in the milliseconds before a write commits, as it reached a period start. - No schema change: every instant was already stored. The billing interval
read also returns its item’s
created_at. - This is the “second pin rule” the panel dropped (panel record). It came back because a period-start pin billed a new subscriber the old price for a whole first period.
- History: the first version of this amendment, in the same PR, priced each window at its own item’s join. Three review rounds each found a money bug in it (a re-added item billed twice, a meter’s peak and tiers split, usage moved across a real price change), and each fix added logic. One pin per plan and period replaced it: a plan has one set of prices in a period.
Ties are refused when a price is saved. Under a lock on the plan row,
a new or changed price is compared with the prices on the same plan and
meter that take part from now on. Two prices with the same number of keys
and no shared key with different values could both take one event: 409
price_conflict, naming the other price. An identical dimension_match is
409 price_exists while that price takes part; a removed one is added
back (section 6). So the winner for any event is unique: two matching
candidates agree on every shared key, and equal key counts were refused.
A removed price and a later tying price are never in force at one pin,
because versions on a plan take effect in the order their writes commit
(section 7). On a plan that is never-used again after a teardown, a write
first puts every price of the plan at its latest version from the
beginning of time (section 5), so a price removed while the plan was used
is removed at every pin there too.
The check is D05’s compatibility clause without the priority term
(internal/pricing/postgres.go:1064-1088).
5. The edit rule (PR-4)
Section titled “5. The edit rule (PR-4)”A plan is used once any subscription item ever named it, as its plan
or its pending plan, deleted items included, or any billing_intervals row
names it.
| What | Never-used plan | Used plan |
|---|---|---|
| Usage price add, edit, remove | Applies at once. The price keeps one version, from the beginning of time; the write replaces every earlier version and its clock stamps. Every other price of the plan keeps only its latest version, also from the beginning of time, with no clock stamps. | Applies from each subscriber’s next period. The write appends a version that takes effect when it commits (section 7); a removal appends a tombstone and ends the price’s overrides (section 6). No period already open changes, except one that starts while the write is saving (section 7). A subscription that starts later the same day gets it from its second period, and one that moves to the plan mid-period gets it from its next period (Risks). Amended 2026-10-26 (section 4): a subscription that first joins the plan in a period after the write, by starting on it, a swap onto it or an added item, gets it in that period. |
| Base fee | Editable. | Refused while a live subscription uses the plan (ADR-034). To change it, create a new plan and move subscribers to it. |
base_bill_timing |
Editable, but not to in_advance at a base of 0 (section 14). |
Locked. |
code, currency, billing_interval |
Fixed. | Fixed. |
name, description, tax_code, status |
At once. | At once. |
Meter aggregation, event_name |
Until the meter has events. | Until the meter has events. |
Meter name, unit |
At once. | At once. |
| Override create, replace, delete | From the customer’s next period. | From the customer’s next period. |
- Every price write answers with the price and
effective: "now"or"next_period". - “Used” is conservative on purpose. A plan whose subscribers all canceled still waits a period per edit. A test-clock teardown deletes that clock’s subscriptions, so their plan can become never-used again.
- A price added to a used plan takes part from the next period. Until
then the plan’s other prices bill its usage as before. A less specific
price that matches, such as
{}, bills it this period. With no matching price the usage isno_price. If the meter is new to the plan, the plan does not price that meter at the pin, so the usage isnot_on_plan(section 12). There is no exception for a price that only prices unpriced usage (D05 allowed one): it would make an ingest flag false later. - A PATCH can set the base fee to 0 (PR-4). Today it cannot: the update
tests
> 0(internal/pricing/service.go:637,:673). - History stays internal: versions are kept once a plan is used, and every price write records an audit row with the old and new content in the same transaction as the write (ADR-090).
PR-7 cut (main session, 2026-10-27). PR-7 would have moved the base fee to the next-period rule, at the same pin. It was cut from v0.9.0 before any code, so the base-fee row above is ADR-034’s lock, as before.
- The site-set was larger than this ADR listed. Besides about 30 billing,
proration and preview readers, the MRR analytics read the plan’s base
fee directly (
internal/analytics/overview.go,mrr_movement.go). A base edit that waits for each subscriber’s own next period writes no subscription change, so MRR would either move before billing does, or rewrite past MRR, or need a new movement source. That is a new MRR rule, not a detail of this one. - The lock is the common model: a Stripe price’s amount cannot be edited; a new price is created and subscriptions move to it.
- It can return in a later release, with the MRR rule decided first.
6. Overrides (PR-4)
Section titled “6. Overrides (PR-4)”- An override is keyed by (customer, price) and takes the price’s shape
plus a
reason. Usage prices only; a negotiated base fee is a new plan. - It follows its price across content edits.
- Its window is
[created_at, deactivated_at). A create, replace or delete stamps it after the write’s locks (section 8), in the customer’s own time: wall time read withclock_timestamp(), or the customer’s clock time plus 1 microsecond. Notnow(): that is the transaction’s start, so a write that waited for a lock across a period start would change the period already open. - Removing the price ends it. In the removal’s transaction, each open
override of the price is closed at the instant the tombstone takes
effect for its customer. For a customer without a test clock that is
the tombstone’s
valid_from; for a clock customer, the tombstone’s stamp for its clock (section 7). So at every pin an override is open exactly while its price takes part, and the open period keeps the negotiated rate. On a never-used plan the removal applies at once, so it empties every window of the price’s overrides. - Adding back a removed price (the same plan, meter and
dimension_match) appends a version to the same row, so the price keeps its id. Its old overrides do not come back. - An override write takes the plan row
FOR KEY SHAREand then the price row, before it reads the customer’s clock (section 8). So it waits for a removal in progress. An override write on a removed price is refused with 409invalid_state. - A plan swap does not carry it: the new plan’s price is another row.
7. Test clocks: every price resolves in the subscription’s own time (PR-4)
Section titled “7. Test clocks: every price resolves in the subscription’s own time (PR-4)”- On a used plan, a version’s
valid_fromis wall time read withclock_timestamp()after the plan lock, and at least 1 microsecond after the plan’s latestvalid_from. So versions on a plan take effect in the order their writes commit. Postgresnow()is not used: it is the transaction’s start, so of two racing edits the one saved last could get the earlier time and never apply. - The wall time is read before the commit, so a period that starts in the
milliseconds between the two gets the new version. A reader that priced
that period in those milliseconds saw the old one. This is the same
straddle as section 12’s
no_priceexception. A clock’s time cannot move in between, because the write holds the clockFOR SHARE. - On a never-used plan,
valid_fromis the beginning of time. - A write to a used test-mode plan also writes, for every test clock of
the account, the clock’s current time plus 1 microsecond
(
plan_price_version_clocks). It reads the clocksFOR SHARE, before the plan lock (section 8), which orders the write against a clock advance (MarkAdvancing,internal/testclock/postgres.go:228-231). The extra microsecond keeps the old price for a period that opens exactly at the clock’s time. Two writes at one clock time get equal stamps; the latervalid_fromwins. - A write to a never-used plan writes no stamps, so its version is in force at every pin on every clock. A clock customer’s first period starts at midnight, before the clock’s time; a stamp would leave that period with no price. It deletes the stamps of the versions it replaces.
- Every price write and override write reads a clock
FOR SHAREbefore it writes or deletes that clock’s stamp rows or an override of its customer (section 8). - A subscription on clock C compares its simulated pin with C’s stamp. A version with no stamp for C counts as always in force: it was written before C existed, or on a never-used plan.
- One stamp per clock is needed because a clock keeps only its current time; its time at a past wall instant cannot be recovered.
- Override windows use the same times (section 6).
- The test-clock caveat of ADR-070 decision 4 is withdrawn, and the dashboard’s three “applies to the current simulated period” warnings go.
- Teardown locks its clock row first (section 8) and deletes the clock’s stamp rows. The plan, its prices and versions stay, as plans and meters do today (ADR-086).
8. One lock order (PR-0b shipped; PR-4)
Section titled “8. One lock order (PR-0b shipped; PR-4)”Subscription writers, price writes and override writes take rows in this order. Once one holds a row of one step, it takes no row of an earlier step. A test-clock teardown is one exception, and the clock reads of a price write and of a subscription create are the others (below).
- the subscription row (ADR-115);
- plan rows in ascending id, in one statement:
FOR KEY SHAREfor subscription writers and override writes,FOR UPDATEfor writers of plan content; - price rows,
FOR KEY SHAREonly; no path locks a price row exclusively; - meter rows, ascending;
- test clocks, ascending,
FOR SHARE; - the rest a money path writes: override rows, the tenant’s number row
(
tenant_settings, which numbers invoices and credit notes) and invoice rows.
So an override write takes its plan and its price before it reads the customer’s clock, and a writer takes its plans before it numbers an invoice or a credit note. Until PR-4, D05’s meter edit takes its meter and then plans; no writer takes a plan and then a meter before PR-4, so this makes no cycle.
A test-clock teardown is outside this order. It deletes bottom-up for
foreign keys: invoice rows before the subscription row, override rows
before the clock row (internal/testclock/postgres.go:150-221). From PR-4,
its first lock is its clock row, FOR UPDATE. A price write or override
write reads that clock FOR SHARE before it touches a row teardown
deletes: the clock’s stamp rows or an override of its customer (section
7). So one of the two waits for the other at the clock row, and they make
no cycle. Teardown takes no plan or price row. Without the clock lock
first, a removal and a teardown deadlock: the removal holds the clock and
waits for an override row, and the teardown holds that override row and
waits to delete the clock. A real-Postgres model aborted one with 40P01;
with the clock lock first, both committed. An override write on the
clock’s customer deadlocks with a teardown the same way, and the clock lock
first fixes it too. A never-used write that deleted the clock’s stamps
without reading the clock deadlocked even with a clock-first teardown;
reading the clock first fixed it.
A subscription create for a clock customer is the third writer that meets
a teardown (amended 2026-10-26, third review of #1386). Its insert’s
foreign keys lock the customer row and then the clock row. A create that
inserted while a teardown held the clock waited there holding the
customer, and the teardown then waited to delete that customer: a
real-Postgres test aborted one with 40P01. So a create reads its clock
FOR KEY SHARE before the insert, waits at the clock holding nothing the
teardown deletes, and answers 404 when the teardown took the customer.
A price write reads the test clocks before its plan (amended
2026-10-25, review of #1386). A teardown holds its clock FOR UPDATE while
it waits to delete a subscription row. A subscription writer holding that
row next takes its plans FOR KEY SHARE. A price write that held the plan
FOR UPDATE while it waited for the clock closed a three-party cycle: a
real-Postgres test aborted the teardown with 40P01. So a price write
takes the clocks FOR SHARE first, then its plan. This is safe because
every transaction that locks a clock row exclusively (a teardown, a clock
state change) takes the clock first and holds no plan, and FOR SHARE
does not conflict with an override write’s FOR SHARE read of its
customer’s clock.
PR-0b (#1367, merged as c234a364) brought step 2 to every subscription
writer. It fixed a deadlock it reproduced on main. D05’s meter edit takes
plans in ascending id. An immediate swap from plan A to plan B took B and
then A through foreign keys. With id(A) < id(B), Postgres aborted one of
them with 40P01. It also found a cycle through the number row: an
activation holds plan B and waits for the number row. So a swap takes its
plans before it numbers its refund credit note
(internal/subscription/postgres.go:2084-2097). PR-4 deletes D05’s plan
lock with the rule tables.
9. Windows and the spend threshold invoice (PR-4)
Section titled “9. Windows and the spend threshold invoice (PR-4)”- Usage windows are per (plan, meter), not per meter. After a mid-period swap between two plans that price one meter, each window is priced by its own plan, as two lines. Tiers restart in each window and each line rounds on its own, as ADR-066 already does for separate windows.
- Stripe’s flexible billing mode prices a mid-cycle metered price swap the same way. Stripe’s docs: “When you swap a metered price mid-cycle, we preserve the rate that was active when the usage occurred: Pre-swap usage is rated at the old price … Post-swap usage is rated at the new price” (manage usage-based billing setup, read 2026-10-23). That is: each side of the swap is priced by the price it was on.
- A spend threshold invoice prices the close’s windows. Today it prices
one flat window with the current items’ plans
(
internal/billing/threshold_scan.go:509-510), which would price pre-swap usage at the new plan’s rates. - A graduated price’s ladder continues across a threshold invoice keyed by price id (ADR-066 amendment 2026-10-11). Peak and last-value deferral keys on (meter, price): with one aggregation per meter, “not additive” is a property of the meter.
10. Customer usage, the cost dashboard and margin (PR-4)
Section titled “10. Customer usage, the cost dashboard and margin (PR-4)”- Current period: exact. Each subscription is priced by the close’s own rater over its windows, floors and ladder, for the whole period: usage a threshold invoice already billed is counted, as the period’s daily chart counts it. The live balance’s unbilled usage is the part the close will still bill (amended 2026-10-25, second review of #1386). With two subscriptions on different billing days, the response’s period runs from the earliest start to the latest end of their periods, and each meter’s daily chart is read over its own subscription’s period (amended 2026-10-25, review of #1386).
- An explicit
?from=&to=window: an estimate, labelled so. Each subscription’s windows are clipped to it and priced at its current period’s pin. Past periods are not priced at their own pins. Nothing bills from this read. (Amended 2026-10-26: each plan is priced at its pin of the current period, section 4.) - Margin reads the explicit-window estimate and attributes revenue by the
winning price’s
dimension_match.model. - The public cost dashboard reads the current period: exact.
- Rows are
meters[].prices[]withplan_id, because two plans can price one meter differently.
11. Currency (PR-4)
Section titled “11. Currency (PR-4)”A price and an override have no currency; they take the plan’s. The invoice currency order is unchanged (billing profile, then plan). The currency guards that compared rule currencies go: G1, G2, G2b, the override currency clause and the recipe meter probe of ADR-100. G3 (the customer currency pin), G5 to G8 and ADR-129 stay.
12. Ingest says when an event matched no price (PR-6)
Section titled “12. Ingest says when an event matched no price (PR-6)”Built in #1390 (notes of 2026-10-27, from the code):
-
The contract changed (main session, 2026-10-27).
billing_statusis what Hardcount found when the event arrived. A status that is present was true then; no status is not a promise that the event will be billed. This replaces “omitted when an open period will bill the event” below. -
not_on_planandoutside_subscriptionshipped with it (D07). The contract and why, the rule, the precedence and where a present status can be wrong are in the ADR-120 amendment of 2026-10-27. -
An open-period event is checked against the plan stretches the close bills by, not only the current items’ plans: a plan swapped away from and an item removed in the period keep their stretches. So ingest reads every active subscription’s billing intervals.
-
The prices are not read lazily: every event in an open period needs its price set to tell billed from
no_price, so they come in the one statement that read the plans’ meters at their pins, before the write. -
The straddle exception below can make a
not_on_planflag wrong too: a price write that adds a meter’s first price to the plan. -
Inside a stretch an event is checked against the close’s own usage windows (trial end, other subscriptions’ coverage ends, one window per instant), and a price set whose pin had not passed when read draws no flag: a price saved now is in force there (review round 1).
-
An event before the current period, past the billing start, is
after_closewhatever the current plans price: they are not the plans that held that period. -
The floor of
after_closeis where the close began to bill (the first billing interval’s start, at midnight of the first day), not the start instant. An event at or after the current period’s end is not checked: its own period’s prices are not known while the close has not run, which can be more than one period late (review round 3). -
An event at or after a trial’s end while the subscription is still trialing is not checked (review round 4). One subscription’s unchecked time wins over another’s flag.
-
/batchreturnsresults[i]per event and a count per status. -
The ingest answer’s
billing_statusis omitted when an open period will bill the event. Otherwise it is one of:after_close: shipped (ADR-120);not_on_planandoutside_subscription: the D07 change, sequenced with PR-6;no_price: the subscription’s plan prices the meter at the pin, but no price matches the event’s dimensions. New here.
-
/batchand the LiteLLM callback count each status. Held-usage release gainsno_pricebesidenot_on_plan. -
One counter,
hardcount_usage_unbilled_total{reason, origin}, counts every status. It replaces ADR-120’shardcount_usage_after_close_total{origin}, which becomesreason="after_close". -
Ingest pins at the open period’s start, and at its end for an event in the grace after it. (Amended 2026-10-26, section 4: an event in the open period is checked against each current plan at its pin of the period.)
-
Matching runs in Go. With string values, per-key equality is the same as Postgres
@>; a real-Postgres test checks the two agree. -
A stated exception to “a flag is never false” (ADR-120): a price write whose transaction spans a period start, and commits after an ingest read, can make one
no_priceflag wrong. ADR-120 states one more: a subscription change that commits between the read and the write. Closing it needs a lock on the plan per ingested event, at the 1,000 to 12,000 events per second measured in scale. Rejected.
13. Yearly plans
Section titled “13. Yearly plans”Usage on a yearly plan is billed once a year, at the year-end invoice, as
today: a subscription has one billing period, and the close reads usage
over it (internal/billing/engine.go:2789, :2899).
- A spend threshold (
amount_gte) bills early when usage runs high. - For monthly usage beside a yearly fee, use two subscriptions: a yearly plan with no prices and a monthly plan with the usage prices. Their meters are disjoint, so the overlap guard allows it. The pricing guide says so (PR-8).
- Not now: a usage interval of its own per plan. One period per subscription is assumed by the closer, the threshold watermark, the after_close rule, credit expiry and test clocks. Trigger: a partner, or two evaluators, selling a yearly base fee with monthly usage on one subscription.
14. base_bill_timing (PR-4)
Section titled “14. base_bill_timing (PR-4)”base_bill_timing is required when base_amount_cents is above 0, and the
422 names both values. With a base of 0 it may be left out and is stored
in_arrears. A write that would leave a plan in_advance with a base of
0 is refused: a create, a base edit to 0 on an in_advance plan, and a
base_bill_timing edit to in_advance on a plan whose base is 0. Today
the field defaults to in_arrears (internal/pricing/service.go:544-547). An in_advance plan moves the cycle
invoice’s header to the next period even at base 0
(internal/billing/engine.go:3350-3371), so a usage-only plan must be
in_arrears.
15. Recipes (PR-4, PR-5)
Section titled “15. Recipes (PR-4, PR-5)”- PR-4 (#1386): the recipe parser turned the old YAML into plan prices, so install kept working. PR-5 (#1389) removed that step.
- PR-5: the YAML holds only
metersandplanswith theirprices.rating_rules,pricing_rules,priorityand per-pricecurrencygo. - An apply always creates a new plan with a free code, as today
(ADR-085). From PR-4 it reuses a meter
by
event_nameonly when the aggregation matches, read inside the apply’s transaction (before PR-4 it was read on its own connection). The plan’s currency is the recipe’s. - PR-5: recipes no longer create a dunning policy or a webhook endpoint.
Their
dunningandwebhooksections are removed, and the parser refuses them.
16. API (PR-4)
Section titled “16. API (PR-4)”POST /v1/planstakesprices[]and creates the plan and its prices in one transaction.GET /v1/plans/{id}returnsprices[]andused./v1/plans/{plan_id}/prices: GET, POST, PATCH/{id}(content only; meter anddimension_matchare the identity) and DELETE/{id}. A POST that names a removed price’s meter anddimension_matchadds it back under the same id./v1/price-overrideskeys onprice_id.- Invoice and preview lines carry
price_idanddimension_match.pricing_modeisper_unit,graduatedorpackage. - Removed:
/v1/rating-rules,/v1/meters/{meter_id}/pricing-rules,/v1/meter-pricing-rules,Meter.rating_rule_version_id,Plan.meter_ids,rule_keyand themeter_in_usecode.
17. No data migration
Section titled “17. No data migration”There is no upgrade from Velox 0.8.x, so this ADR moves no data, replays no old invoices and keeps no list of old rows to refuse. Each migration is plain DDL with a working Down. One that drops an old pricing table first checks it is empty, and stops with “install fresh” if not. PR-1’s new rater is proved inside one build: the old rating loops and the new rater run over the existing billing fixtures and must give identical lines.
18. No index for the meter-has-events check
Section titled “18. No index for the meter-has-events check”The aggregation lock asks whether usage_events holds any row for the
meter (section 2; held rows stopped counting on 2026-10-24).
No index on usage_events starts with (tenant_id, meter_id).
The nearest are (tenant_id, customer_id, meter_id, timestamp) and
(tenant_id, timestamp) (baseline 00180, :3048, :3062). No index is
added now: the edit is rare. Revisit if a large account reports a slow
meter PATCH.
Why this design
Section titled “Why this design”- Money. One pin per period means no edit reprices usage already recorded. The close, the threshold invoice, the preview and the live balance share one rater (PR-1), so a preview equals the invoice.
- One edit rule for usage. Usage prices and overrides apply from the next period on a used plan. The base fee stays locked (PR-7 was cut, section 5).
- Fewer concepts. One noun, price. No priority, no meter default, no rule currency, no rule versions in the API, no per-rule aggregation.
- Stable ids. A price keeps its id across edits, so an integration stores one id. Versions stay internal.
- A deterministic winner. Refusing ties at write time means the winner for an event never depends on which price is older.
Alternatives considered
Section titled “Alternatives considered”- A. Immutable prices: a new price id per rate change, as Stripe does. Stripe says “you can’t change a price’s amount in the API. Instead, we recommend creating a new price for the new amount, switch to the new price’s ID, then update the old price to be inactive” (manage prices, read 2026-10-23). That is: Stripe never edits a price, and moving subscriptions to the new price is the integrator’s job. Hardcount moves every subscriber at their next period by itself, so copying Stripe’s identity rule would not give Stripe’s behaviour. Rejected: two identities and a replace verb buy no money safety beyond internal versions. The design that proposed it also had the money breaks in the panel record.
- B. Keep the meter’s default price as a fallback under per-plan
prices. The smallest change. Rejected: two ways to price one meter, and
prioritystays. - C. Edit in place and refresh open drafts, as Lago does. Lago updates
an existing charge by id (lago-api
2e52dfc,app/services/plans/update_service.rb:250-256) and flags draft invoices of the plan’s subscriptions for refresh (:84,:101-125). Rejected: an edit then reaches usage already recorded in the open period. - D. Break ties by age, as Lago does. Lago picks the filter with the
most keys, then the fewest allowed values, then the oldest
(
app/models/charge_filter.rb:81-90, picked withmin_byinapp/services/events/billing_period_filters/event_matching_service.rb:19). Rejected: the winner then changes without notice when a price is removed and added again.
Panel record
Section titled “Panel record”Two designs were written and attacked by one adversarial pass: “plan-rows” (prices as rows on the plan) and “immutable-prices” (a new price per rate change). Plan-rows was chosen with six changes. How each rejected design or part breaks:
Immutable-prices (rejected):
- Pin at the close’s commit time. It pinned a period at
prices_as_of, the wall time the close committed, which is after the grace. An edit at 00:30 on the 1st, before a 01:00 close, would reprice the 30 minutes of usage already recorded. - An overlap lock that did not hold. Its check locked other plans in
FindMeterConflicts, which opens its own transaction (internal/subscription/postgres.go:2601-2609). The lock ended before the subscription insert, so the race stayed open. - A flat threshold window. It kept the threshold invoice’s one flat window priced with the current plans, so after a swap pre-swap usage would bill at the new plan’s rates.
- More contract. Two identities (price id and slot), a
replaceverb with its own 409, derivedreplaced_byfields and a new subscription column. - Two edit rules. The base fee stayed locked while usage prices moved next period. Hardcount has this today too, since PR-7 was cut (section 5).
Parts of plan-rows that were dropped:
- A second pin rule. The first period pinned at
max(period start, activated_at). Two pin rules for one engine, and a different version than the period start for a first period. - “Used” as live users only. A plan whose only subscriber canceled
would have its history rewritten in place, so
invoice_line_items.price_idwould point at terms other than those billed. - Closing overrides at wall now on removal. For a test-clock customer ahead of wall time, that ends the override before its open period’s pin, so the open period loses the negotiated price. A removal closes them at the instant its tombstone takes effect for each customer instead (section 6).
- Numeric dimension values. Normalized as float64 in Go but compared exactly in Postgres, so the ingest flag and the close could disagree on large numbers. Values are strings only.
- Recipes adopting a plan by code, with a 409 on any difference. It contradicts ADR-085’s fresh plan per apply and adds an error path.
- A
plan_metersview with a coverage end. Approximate: after a removal it could under-bill. A plan’s meters are every meter it has ever priced. - One large stack. The store, engine and API changes had to merge together. The PR order now ships each step alone.
Kept as a stated residual: adding a price for a new meter to a used plan is check-then-act against a subscription created on another plan at the same moment (Risks).
The breaks that concerned moving 0.8.x data went with the clean break.
Consequences
Section titled “Consequences”Positive
Section titled “Positive”- Two plans can charge different rates for the same meter. Grandfathering an old price is a new plan.
- One edit rule, shown on the plan page before any edit.
- Removed:
priority, the meter’s default price, rule currencies, rule versions in the API, the per-rule aggregation andlast_ever, the package overage field, and the currency guards that compared rules. - The close, cancel, preview and live balance share one rater instead of separate rating loops.
- Test-clock customers see price edits as live customers do.
- The ingest answer says when usage matched no price.
Risks / open items
Section titled “Risks / open items”- Test clocks cost one stamp row per clock per test-mode price write. An account with 1,000 clocks writes 1,000 rows per edit. Test mode only.
- The commit straddle, measured in milliseconds. A price write’s wall
time is read before it commits. A period that starts in between gets the
write (section 7), and one ingest
no_priceflag can be wrong (section 12). - A test-clock teardown is outside the lock order. It takes its clock row first, which orders it against price writes, override writes and subscription creates on the clock (section 8).
- Price add against a concurrent subscription create is check-then-act.
A price for a meter new to a used plan runs the overlap check for every
customer on the plan (409
meter_overlap). A writer that puts a plan on an item checks again on its own transaction, after it holds the planFOR KEY SHARE, so a price and an item on the same plan are ordered by the plan lock, used or never-used (amended 2026-10-25, second review of #1386). A subscription created on another plan at the same moment is not ordered with it. The same class as two concurrent subscription creates today; it joins ADR-100’s deferred locks. Trigger: the first account with several operators. - Resolved 2026-10-26 (section 4 amendment): the two items below no longer hold. A new subscriber, a swap onto the plan and an added item pay the plan as it stands when they join it, and an override created before the join applies to it.
- A new subscriber later on the day of an edit pays the old price for the first period, because the pin is the period start, a midnight. The same holds for an add and a removal: that first period does not bill a price added that day, and still bills a price removed that day. It holds for an override too: one created on the day the customer’s subscription starts, before or after it, applies from its second period, and the first period bills the price’s rate (third review of #1386). The pricing guide, the API reference, the plan page and the customer’s override dialog say so. Pinning a first period at the subscription’s start instead was dropped in the panel (a second pin rule); it is an open owner decision from the review of #1386.
- A subscriber who moves to a plan mid-period (an immediate swap, or an
added item) is priced, for the rest of that period, by the plan’s prices
at the subscriber’s period start, because every window of a period uses
the period’s pin (section 4 step 3). A rate edited earlier in the period
bills at the old rate, a price removed earlier still bills, and a meter
the plan first priced earlier in the period bills nothing that period,
with no unpriced warning or counter: the plan did not price the meter at
the pin. An immediate swap from a plan with another billing interval is
the exception: it starts a new period at the swap, pinned there, so the
mover pays the plan’s prices as they are at the swap (third review of
#1386). The pricing guide, the API reference and the plan page say so.
Pinning each window at its own start would fix this and the same-day
case together; it is part of the same open owner decision (second review
of #1386).
(A spend threshold with
reset_billing_cycle=trueused to start a period at the invoice’s own instant. Removed 2026-10-23 in its own PR (#1371): a threshold invoice no longer starts a period, so PR-4 carries no reset re-anchor and no reset base.) - After a mid-period swap between two plans that price one meter, the period bills two lines, and tiers restart in the second window.
- The threshold invoice becomes window-aware: one line per plan window instead of one flat line.
- Overrides do not follow a swap. The operator creates the override again on the new plan’s price.
- A raw model-name price needs one more key than the price it refines,
because priority is gone. For example
{model, model_raw, token_type}beats the recipe’s{model, token_type};{model_raw, token_type}beside it is 409price_conflict(PR-5). - Customer usage over an explicit window is an estimate at each subscription’s current pin. Display only.
- The meter-has-events check can scan an account’s time index (section 18).
- PR-7 was cut (2026-10-27). The base fee stays locked while a live subscription uses the plan, as in ADR-034 (section 5).
- Monthly usage on a yearly plan needs two subscriptions until a usage interval per plan ships (section 13).
- No upgrade path from 0.8.x. No 0.8.x install is known.
ADRs this changes
Section titled “ADRs this changes”None of these changes is built yet, except where a row says so. Each ADR’s status line links here, and a banner under it names the part that changes and the PR that changes it.
| ADR | What ADR-130 changes |
|---|---|
| 031 | base_bill_timing no longer defaults to in_arrears when the base fee is above 0; in_advance with a base of 0 is refused (PR-4). |
| 034 | The meter-set lock goes with meter_ids (PR-4). The base-fee lock stays: PR-7, which would have made it next-period edits, was cut on 2026-10-27. The base_bill_timing lock holds once a plan is used (PR-4). |
| 044 | Decision 4: token prices are plan prices on the tokens meter, most specific wins, no priority (PR-4). |
| 045 | package_amount_cents becomes a decimal too, flat becomes per_unit and overage goes (PR-3). Rates take at most 12 decimal places: built in #1378, ahead of PR-3. |
| 066 | Decision 4’s rule-bucket granularity becomes the meter (PR-2). The ladder seed keys on price id, so the ambiguous-config failure goes (PR-4). The package clamp goes (PR-3). The fire prices per plan window (PR-4). |
| 070 | Decision 1: overrides key on the price, not rule_key. Decision 4’s earliest-version fallback and test-clock caveat go. Decision 5’s currency guard goes. The 2026-10-23 meter-in-use amendment is replaced. Customer usage over an explicit window is priced at each subscription’s current pin (section 10). All in PR-4. The period-start pin stays. Amended 2026-10-26 (PR-4b): a plan joined inside a period is priced where the subscription first joined it in that period (section 4). |
| 085 | Rule adoption, the rule conformance check, the currency taken from adopted rules and the 2026-10-23 amendment go (PR-4); recipes no longer create a dunning policy or webhook endpoint (PR-5). |
| 096 | The plan-attach guard and the meter-in-use guard that replaced the last-price guard go: a plan’s meters are its prices’ meters (PR-4). The WARN and counter stay; ingest adds no_price (PR-6). |
| 100 | G1, G2, G2b, the override currency clause and G4’s recipe probe go; prices take the plan’s currency (PR-4). |
| 120 | The after_close rule reads the meter’s aggregation instead of rule buckets and drops the last_ever case (PR-2, PR-4). no_price adds a stated exception to “a flag is never false” (PR-6). The after_close counter becomes hardcount_usage_unbilled_total{reason="after_close"} (PR-6). |
| 123 | A recipe no longer creates a dunning policy, so only an operator’s first enabled policy takes over the seeded default (PR-5). |
References
Section titled “References”- ADR-031, ADR-034, ADR-044, ADR-045, ADR-066, ADR-070, ADR-085, ADR-086, ADR-096, ADR-100, ADR-101, ADR-115, ADR-120, ADR-123, ADR-129.
- PR-0b: gethardcount/hardcount#1367, merged as
c234a364(the lock order). - Migrations: PR-2 is
00208_meter_one_aggregation.sqlwith its CHECK validated in00209; PR-3 is00210_price_shapes.sqlwith its CHECKs validated in00211; one each for PR-4 and PR-7, numbered when each PR opens. - Design of record: velox-ops
design-plan-pricing-2026-10-23.md(sections 4.18 for the PR split, 2 for the panel). - Stripe, read 2026-10-23:
Price object
(
unit_amount_decimal: “a decimal string with at most 12 decimal places”), manage prices (package pricing: “Purchases are rounded up by default”), manage usage-based billing setup. - Peer code read 2026-10-22: lago-api
2e52dfc, flexprice1c23b81, openmeterf04b561, killbill3548b02. Package rounds up in Lago (app/services/charge_models/package_service.rb:12) and OpenMeter (openmeter/billing/rating/service/rate/package.go:55-63). An open last tier isnullin OpenMeter (openmeter/productcatalog/price.go:723-725). OpenMeter’s meter update takes no aggregation (openmeter/meter/service.go:213-221).base_bill_timingdefaults differ: Lagopay_in_advance DEFAULT false(db/structure.sql:4056), Flexpriceinvoice_cadencerequired (internal/api/dto/price.go:30), OpenMeterDefaultPaymentTerm = InAdvancePaymentTerm(openmeter/productcatalog/price.go:17).