Last active 2 days ago

Admin charge overrides: domain model, engine integration, data model, validation, UI. Design review draft.

charge-overrides.md Raw

Charge Overrides — Design Summary

Admin-facing feature allowing a charge produced by the billing engine to be replaced with a stated price over a stated interval. Status: design review, pre-implementation. Stack: Elixir backend, React frontend, PostgreSQL.

Visual companion (diagrams + interactive timeline demo): published separately as a web page. This document is the text equivalent.


1. Domain model

What's sold is modelled as a tree:

  • Service — the root.
  • Resource — children of the service (ports, VXCs, etc.).

When a service is billed, the run reads the service definition, the lifecycle, and other metadata, and emits 1..N charges. A charge has a start, an end, and a price. Most charges are a base price (resource X costs Y); some appear dynamically, such as an overage fee.

The mapping resource → charge is not one-to-one. A single resource can produce several charges in the same month (e.g. a base charge and an overage).

Billing model context

Billing is window-agnostic and fully regenerated on every call. Any start and end can be used and the math holds. Usage is derived by parsing the audit log to determine what was ordered / created / connected / deleted and when; those results are cached and continuously updated by a separate process, then used to generate reports on the fly.

Consequence: changing the code changes past reports too. Monthly reports are stamped at month end, but any past report can be regenerated at any time. This is deliberate and is the reason the edit window (§7) is a policy choice rather than a technical constraint.


2. Charge identity

Charge IDs are generated on the fly and are stable forever across all runs.

The ID is a stable hash: the fields making up the charge's composite primary key are concatenated, hashed, and bytes are taken to form a UUID. The key is immutable — service ID, resource ID, start, charge type, etc. Nothing mutable participates.

Two classes of charge follow from this:

Class Example Behaviour
Continuous Port 10G base price Can run many months. Same ID on every run, forever.
Period-scoped Overage fee Period is part of the key, so each month is a new ID.

An override on a period-scoped charge therefore only ever applies to that one month. An override on a continuous charge keeps matching as long as the charge keeps being generated.

Important: because the ID is a one-way hash, a stored override row cannot be resolved back to a charge by joining. There is no charge table. See §5.


3. Charge override vs. price override

These are routinely confused. They are different features at different points in the pipeline.

Price override (a later feature, not in this scope) : Sets the base price of one or more parts of the service. That price is an input. It flows into further calculation — discounts and promos still apply on top — and the engine still produces a charge list from it.

Charge override (this feature) : Replaces the charge itself, after all computation is done. Within its interval it discards whatever the earlier stages produced and states a price. No discounts, no promos — just "set the price".

If a service carries e.g. a 20% surcharge, the arithmetic will no longer reconcile once an override is applied. That is the intent, not a defect.

Tax is not applicable in this system. If it were, tax would be its own charge and would need its own override.


4. Engine integration

There is already a pricing modification engine. It runs a pipeline of modification rules. Rules have:

  • timeframes
  • matchers
  • actions
  • a matching type: first or stacked

The algorithm applies the first matching rule, takes that chunk out of the charge interval, and loops — until either the charge interval is fully covered by rules, or the rules run out. Whatever remains stays as computed. This is how a charge ends up "sliced and diced": one price plus a promo starting at activation time yields two slices of the original charge.

A charge override is just another rule, injected as the final stage:

matcher:       charge_id = <the stable hash>
timeframe:     the interval the admin entered
action:        set absolute price
matching type: first

No new engine capability is required. Because the matcher is the charge ID and the timeframe decides what's actually touched, a charge already split three ways by a promo needs no special handling — the rule matches all three chunks, but the timeframe only intersects the ones it overlaps, and the carve-out loop splits them correctly.

Worked example

Charge #a3f9, Port 10G base, $1,200.00 for March (31 days ⇒ $38.7097/day). A promo of −20% applies over [Mar 10, Mar 25).

Before — as computed, Σ $1,083.88:

Interval Kind Amount
[Mar 1, Mar 10) base $348.39
[Mar 10, Mar 25) promo −20% $464.52
[Mar 25, Apr 1) base $270.97

Now apply an override: charge_id = #a3f9, [Mar 10, Mar 21), price 0.00.

After — Σ $743.23:

Interval Kind Amount
[Mar 1, Mar 10) base $348.39
[Mar 10, Mar 21) override $0.00
[Mar 21, Mar 25) promo −20% $123.87
[Mar 25, Apr 1) base $270.97

The override split the promotional chunk; the four leftover days stayed on the promotional rate.

Loading

Load overrides once per pricing run, scoped by service and billing window — not per charge. Per-charge lookup is an N+1 in the hot path.

A free property

Overrides on the same charge cannot overlap — the database refuses to store them (§5). Therefore ordering within the override stage is irrelevant, and "which override wins?" is a question that never has to be answered.


5. Data model

-- requires btree_gist
CREATE TABLE charge_overrides (
  id          uuid PRIMARY KEY,
  charge_id   uuid          NOT NULL,   -- the stable composite hash
  service_id  uuid          NOT NULL,   -- for listing/filtering; not derivable from the hash
  period      tstzrange     NOT NULL,   -- upper unbounded = open-ended
  amount      numeric(19,4) NOT NULL,   -- absolute, service currency
  notes       text,
  created_by  uuid          NOT NULL,
  inserted_at timestamptz   NOT NULL,
  updated_at  timestamptz   NOT NULL,

  -- display snapshot: written once, read only for rendering, NEVER used in pricing
  resource_id   uuid,
  charge_type   text,
  label         text,

  CONSTRAINT amount_non_negative CHECK (amount >= 0),
  CONSTRAINT period_non_empty    CHECK (NOT isempty(period)),

  CONSTRAINT no_overlap EXCLUDE USING gist (
    charge_id WITH =, period WITH &&
  )
);

CREATE INDEX ON charge_overrides (service_id);

Why the exclusion constraint

An application-level overlap check reads existing overrides, decides there's no conflict, then writes — and two admins hitting save simultaneously both pass. The exclusion constraint makes overlap physically unrepresentable, and it understands unbounded upper ranges natively, so an open-ended override is covered by the same rule as a closed one with no special-casing.

The upper bound on amount lives in application config, not a CHECK — it's a policy number that will want tuning.

Why the display snapshot columns

A hash cannot be reversed into a resource name, and there is no charge table to join against. Without a snapshot, the overrides index has nothing to render but a bare UUID for any override whose charge is no longer being generated — and there's no way to distinguish an orphan from a typo. Written once at creation, read only for display, never consulted during pricing.

No foreign key to the charge

Not possible; the charge is computed, never stored. This is safe because the key is immutable, so the ID is stable forever.


6. Validation — two layers, deliberately disagreeing

At save time (UX) At pricing time (correctness)
Period is contained by the charge's range as currently computed Period is intersected with the charge's actual range; the overlap applies, the rest is ignored
No overlap with an existing override on that charge Guaranteed by the exclusion constraint; nothing to check
Amount within [0, cap] Guaranteed by CHECK; nothing to check
Start falls in the previous or current billing month Not re-checked — an override legal when written stays legal

Save-time containment is a snapshot for a good error message. Pricing-time intersection is the real invariant.

Drift is expected, not exceptional: the charge range is recomputed every run, so an override written against a longer range can end up hanging off the end (e.g. the resource was deleted). Pricing takes the intersection and moves on — no error, no orphan handling. Do not "fix" this mismatch later; it is intended.


7. Lifecycle and edit window

An override is editable when it is open-ended, or its period intersects the previous or current billing month — and its service has not been deleted.

Override Period Edit Delete Why
Open-ended [Mar 1, ∞) yes yes Still applying to every future run; must remain closeable
Current month [Aug 1, Aug 15) yes yes Inside the window
Previous month [Jul 3, Jul 20) yes yes Inside the window
Older, closed [Apr 1, Apr 30) no no Locked
Service deleted any no no Nothing left to price

Editing an old override is harmless under the regenerate-everything model — it just wouldn't accomplish anything anyone is looking at. Locking is a policy choice to avoid silently changing settled reports.

Creation is narrower

An override can only be created against a charge the admin can actually see, and charges are only browsable for the previous and current month.

Future-dated overrides are not possible. You cannot know what next month's charge looks like — a promo applied in the meantime would change it. An open-ended override does reach into the future, but only by extending forward from a charge that already exists.


8. Permissions and audit

  • Admin users gated by specific permissions/roles.
  • Audit log for traceability.
  • No approval workflow in v1 — no maker–checker, no threshold escalation.

9. Admin UI

Charge overrides and price overrides share most of the flow. The shell is designed for both; only the charge-override half is built now.

Section name: "Charge Overrides" or "Billing Overrides".

Entry points

  1. Overrides index — all overrides, with search, filter and sort (by service, resource, charge type, date range, author). Clicking a row goes to that service's page.
  2. ID search — the admin enters a service ID or a resource ID. A service ID resolves to exactly one service; a resource ID may resolve to several.

Both lead to the service page.

Service page

  1. Service header (identity, status, lifecycle).
  2. Billing month selector — previous or current month only. (Billing range is always a month for this org.)
  3. Charges for that month — see the open decision in §11.
  4. Select a charge → create override panel: start, end (or empty for open), amount, short notes.
  5. This service's existing overrides, listed at the bottom.

On save the override joins the service's override list.

The service page is a container hosting N override sections. Search, month selector and page frame are shared; the charges list is charge-override-specific and a prices list drops in beside it later for price overrides.


10. Phase 2 — month timeline (designed, not built)

Show the charge's month as a horizontal strip: overridden spans filled, remaining space open/hatched.

  • Click a filled span → edit that override.
  • Click empty space → create flow, pre-populated with that gap's full range.

Not strictly necessary and explicitly out of v1 scope, but it explains the feature faster than prose does and makes multi-override charges readable. The data model already supports it; nothing extra is required to add it later.


11. Open decisions

11.1 Charge list shape — leaning A

Option A — one row per charge, slices nested beneath it:

Port 10G — base        #a3f9   Mar 1 – Apr 1   $1,083.88
  base                         Mar 1 – Mar 10    $348.39
  promo −20%                   Mar 10 – Mar 25   $464.52
  base                         Mar 25 – Apr 1    $270.97
Port 10G — overage     #7c21   Mar 1 – Apr 1      $73.40

Option B — one row per slice (closer to the invoice).

A matches the override model: you target a charge, then a span within it, and one override may cover several slices. B invites the admin to think a slice is the thing being overridden, and cannot express "override across two slices" without breaking the row metaphor.

11.2 Where override events are audited — leaning separate admin trail

The existing audit log is parsed to reconstruct usage, and that derived data is what reports are built from. An override is not a usage event; putting it in that log risks it being read as one, and the blast radius is every report. A dedicated admin trail keeps traceability without touching the pipeline that generates the numbers.

11.3 Search behaviour on unambiguous match — leaning auto-advance

Service ID → skip the one-row result list, go straight to the service page. Resource ID → always show the list, even with a single hit, so the admin can see which service they landed on.


12. Not in scope

  • Price overrides — next feature; shell is designed for it, nothing built.
  • Negative amounts — zero is allowed and is a primary use case (comping a charge). Negative is a credit: different feature, different approvals.
  • Upper bound — there will probably be a cap; it lives in config.
  • Currency selection — the incoming currency is the assumed currency. No conversion, no currency field.
  • Tax — not applicable in this system.
  • Approval workflow — role-gating and audit only in v1.
  • Bulk operations — one override at a time.
  • The timeline visualisation — designed and demonstrated, not built.

13. Implementation notes

  • Intervals are half-open [start, end) throughout. Use tstzrange.
  • btree_gist extension is required for the exclusion constraint.
  • The override rule is constructed at pricing time from DB rows; it is not stored as a rule row in the engine's rule table.
  • Amount is an absolute figure for its span, not a rate to be prorated. (Proration applies to the remainder of the charge, not to the override.)