# 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 = 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 ```sql -- 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.)