Last active 2 days ago

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

pb-gist revised this gist 2 days ago. Go to revision

1 file changed, 383 insertions

charge-overrides.md(file created)

@@ -0,0 +1,383 @@
1 + # Charge Overrides — Design Summary
2 +
3 + Admin-facing feature allowing a charge produced by the billing engine to be
4 + replaced with a stated price over a stated interval. Status: design review,
5 + pre-implementation. Stack: Elixir backend, React frontend, PostgreSQL.
6 +
7 + Visual companion (diagrams + interactive timeline demo): published separately
8 + as a web page. This document is the text equivalent.
9 +
10 + ---
11 +
12 + ## 1. Domain model
13 +
14 + What's sold is modelled as a tree:
15 +
16 + - **Service** — the root.
17 + - **Resource** — children of the service (ports, VXCs, etc.).
18 +
19 + When a service is billed, the run reads the service definition, the lifecycle,
20 + and other metadata, and emits **1..N charges**. A charge has a start, an end,
21 + and a price. Most charges are a base price (resource X costs Y); some appear
22 + dynamically, such as an overage fee.
23 +
24 + The mapping resource → charge is **not** one-to-one. A single resource can
25 + produce several charges in the same month (e.g. a base charge and an overage).
26 +
27 + ### Billing model context
28 +
29 + Billing is **window-agnostic and fully regenerated on every call**. Any start
30 + and end can be used and the math holds. Usage is derived by parsing the audit
31 + log to determine what was ordered / created / connected / deleted and when;
32 + those results are cached and continuously updated by a separate process, then
33 + used to generate reports on the fly.
34 +
35 + Consequence: **changing the code changes past reports too.** Monthly reports
36 + are stamped at month end, but any past report can be regenerated at any time.
37 + This is deliberate and is the reason the edit window (§7) is a policy choice
38 + rather than a technical constraint.
39 +
40 + ---
41 +
42 + ## 2. Charge identity
43 +
44 + Charge IDs are generated on the fly and are **stable forever across all runs**.
45 +
46 + The ID is a stable hash: the fields making up the charge's composite primary
47 + key are concatenated, hashed, and bytes are taken to form a UUID. The key is
48 + **immutable** — service ID, resource ID, start, charge type, etc. Nothing
49 + mutable participates.
50 +
51 + Two classes of charge follow from this:
52 +
53 + | Class | Example | Behaviour |
54 + |---|---|---|
55 + | **Continuous** | Port 10G base price | Can run many months. Same ID on every run, forever. |
56 + | **Period-scoped** | Overage fee | Period is part of the key, so each month is a new ID. |
57 +
58 + An override on a period-scoped charge therefore only ever applies to that one
59 + month. An override on a continuous charge keeps matching as long as the charge
60 + keeps being generated.
61 +
62 + **Important:** because the ID is a one-way hash, a stored override row cannot
63 + be resolved back to a charge by joining. There is no charge table. See §5.
64 +
65 + ---
66 +
67 + ## 3. Charge override vs. price override
68 +
69 + These are routinely confused. They are different features at different points
70 + in the pipeline.
71 +
72 + **Price override** (a *later* feature, not in this scope)
73 + : Sets the base price of one or more parts of the service. That price is an
74 + *input*. It flows into further calculation — discounts and promos still
75 + apply on top — and the engine still produces a charge list from it.
76 +
77 + **Charge override** (this feature)
78 + : Replaces the charge itself, after all computation is done. Within its
79 + interval it discards whatever the earlier stages produced and states a
80 + price. No discounts, no promos — just "set the price".
81 +
82 + If a service carries e.g. a 20% surcharge, the arithmetic will no longer
83 + reconcile once an override is applied. **That is the intent, not a defect.**
84 +
85 + Tax is not applicable in this system. If it were, tax would be its own charge
86 + and would need its own override.
87 +
88 + ---
89 +
90 + ## 4. Engine integration
91 +
92 + There is already a **pricing modification engine**. It runs a pipeline of
93 + modification rules. Rules have:
94 +
95 + - **timeframes**
96 + - **matchers**
97 + - **actions**
98 + - a **matching type**: `first` or `stacked`
99 +
100 + The algorithm applies the first matching rule, takes that chunk out of the
101 + charge interval, and loops — until either the charge interval is fully covered
102 + by rules, or the rules run out. Whatever remains stays as computed. This is how
103 + a charge ends up "sliced and diced": one price plus a promo starting at
104 + activation time yields two slices of the original charge.
105 +
106 + **A charge override is just another rule**, injected as the *final* stage:
107 +
108 + ```
109 + matcher: charge_id = <the stable hash>
110 + timeframe: the interval the admin entered
111 + action: set absolute price
112 + matching type: first
113 + ```
114 +
115 + No new engine capability is required. Because the matcher is the charge ID and
116 + the timeframe decides what's actually touched, a charge already split three ways
117 + by a promo needs **no special handling** — the rule matches all three chunks,
118 + but the timeframe only intersects the ones it overlaps, and the carve-out loop
119 + splits them correctly.
120 +
121 + ### Worked example
122 +
123 + Charge `#a3f9`, Port 10G base, $1,200.00 for March (31 days ⇒ $38.7097/day).
124 + A promo of −20% applies over `[Mar 10, Mar 25)`.
125 +
126 + Before — as computed, Σ **$1,083.88**:
127 +
128 + | Interval | Kind | Amount |
129 + |---|---|---|
130 + | `[Mar 1, Mar 10)` | base | $348.39 |
131 + | `[Mar 10, Mar 25)` | promo −20% | $464.52 |
132 + | `[Mar 25, Apr 1)` | base | $270.97 |
133 +
134 + Now apply an override: `charge_id = #a3f9`, `[Mar 10, Mar 21)`, price `0.00`.
135 +
136 + After — Σ **$743.23**:
137 +
138 + | Interval | Kind | Amount |
139 + |---|---|---|
140 + | `[Mar 1, Mar 10)` | base | $348.39 |
141 + | `[Mar 10, Mar 21)` | **override** | **$0.00** |
142 + | `[Mar 21, Mar 25)` | promo −20% | $123.87 |
143 + | `[Mar 25, Apr 1)` | base | $270.97 |
144 +
145 + The override split the promotional chunk; the four leftover days stayed on the
146 + promotional rate.
147 +
148 + ### Loading
149 +
150 + Load overrides **once per pricing run, scoped by service and billing window** —
151 + not per charge. Per-charge lookup is an N+1 in the hot path.
152 +
153 + ### A free property
154 +
155 + Overrides on the same charge **cannot overlap** — the database refuses to store
156 + them (§5). Therefore ordering *within* the override stage is irrelevant, and
157 + "which override wins?" is a question that never has to be answered.
158 +
159 + ---
160 +
161 + ## 5. Data model
162 +
163 + ```sql
164 + -- requires btree_gist
165 + CREATE TABLE charge_overrides (
166 + id uuid PRIMARY KEY,
167 + charge_id uuid NOT NULL, -- the stable composite hash
168 + service_id uuid NOT NULL, -- for listing/filtering; not derivable from the hash
169 + period tstzrange NOT NULL, -- upper unbounded = open-ended
170 + amount numeric(19,4) NOT NULL, -- absolute, service currency
171 + notes text,
172 + created_by uuid NOT NULL,
173 + inserted_at timestamptz NOT NULL,
174 + updated_at timestamptz NOT NULL,
175 +
176 + -- display snapshot: written once, read only for rendering, NEVER used in pricing
177 + resource_id uuid,
178 + charge_type text,
179 + label text,
180 +
181 + CONSTRAINT amount_non_negative CHECK (amount >= 0),
182 + CONSTRAINT period_non_empty CHECK (NOT isempty(period)),
183 +
184 + CONSTRAINT no_overlap EXCLUDE USING gist (
185 + charge_id WITH =, period WITH &&
186 + )
187 + );
188 +
189 + CREATE INDEX ON charge_overrides (service_id);
190 + ```
191 +
192 + ### Why the exclusion constraint
193 +
194 + An application-level overlap check reads existing overrides, decides there's no
195 + conflict, then writes — and two admins hitting save simultaneously both pass.
196 + The exclusion constraint makes overlap **physically unrepresentable**, and it
197 + understands unbounded upper ranges natively, so an open-ended override is
198 + covered by the same rule as a closed one with no special-casing.
199 +
200 + The upper bound on `amount` lives in **application config**, not a `CHECK` —
201 + it's a policy number that will want tuning.
202 +
203 + ### Why the display snapshot columns
204 +
205 + A hash cannot be reversed into a resource name, and there is no charge table to
206 + join against. Without a snapshot, the overrides index has nothing to render but
207 + a bare UUID for any override whose charge is no longer being generated — and
208 + there's no way to distinguish an orphan from a typo. Written once at creation,
209 + read only for display, never consulted during pricing.
210 +
211 + ### No foreign key to the charge
212 +
213 + Not possible; the charge is computed, never stored. This is safe because the
214 + key is immutable, so the ID is stable forever.
215 +
216 + ---
217 +
218 + ## 6. Validation — two layers, deliberately disagreeing
219 +
220 + | At save time (UX) | At pricing time (correctness) |
221 + |---|---|
222 + | 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 |
223 + | No overlap with an existing override on that charge | Guaranteed by the exclusion constraint; nothing to check |
224 + | Amount within `[0, cap]` | Guaranteed by `CHECK`; nothing to check |
225 + | Start falls in the previous or current billing month | Not re-checked — an override legal when written stays legal |
226 +
227 + Save-time containment is a **snapshot for a good error message**. Pricing-time
228 + intersection is the **real invariant**.
229 +
230 + Drift is expected, not exceptional: the charge range is recomputed every run, so
231 + an override written against a longer range can end up hanging off the end (e.g.
232 + the resource was deleted). Pricing takes the intersection and moves on — no
233 + error, no orphan handling. **Do not "fix" this mismatch later; it is intended.**
234 +
235 + ---
236 +
237 + ## 7. Lifecycle and edit window
238 +
239 + An override is editable when it is **open-ended**, **or** its period intersects
240 + the **previous or current billing month** — and its service has not been
241 + deleted.
242 +
243 + | Override | Period | Edit | Delete | Why |
244 + |---|---|---|---|---|
245 + | Open-ended | `[Mar 1, ∞)` | yes | yes | Still applying to every future run; must remain closeable |
246 + | Current month | `[Aug 1, Aug 15)` | yes | yes | Inside the window |
247 + | Previous month | `[Jul 3, Jul 20)` | yes | yes | Inside the window |
248 + | Older, closed | `[Apr 1, Apr 30)` | no | no | Locked |
249 + | Service deleted | any | no | no | Nothing left to price |
250 +
251 + Editing an old override is *harmless* under the regenerate-everything model —
252 + it just wouldn't accomplish anything anyone is looking at. Locking is a policy
253 + choice to avoid silently changing settled reports.
254 +
255 + ### Creation is narrower
256 +
257 + An override can only be created against a charge the admin can actually see,
258 + and charges are only browsable for the **previous and current month**.
259 +
260 + **Future-dated overrides are not possible.** You cannot know what next month's
261 + charge looks like — a promo applied in the meantime would change it. An
262 + open-ended override *does* reach into the future, but only by extending forward
263 + from a charge that already exists.
264 +
265 + ---
266 +
267 + ## 8. Permissions and audit
268 +
269 + - Admin users gated by specific permissions/roles.
270 + - Audit log for traceability.
271 + - **No approval workflow in v1** — no maker–checker, no threshold escalation.
272 +
273 + ---
274 +
275 + ## 9. Admin UI
276 +
277 + Charge overrides and price overrides share most of the flow. The **shell is
278 + designed for both; only the charge-override half is built now.**
279 +
280 + Section name: "Charge Overrides" or "Billing Overrides".
281 +
282 + ### Entry points
283 +
284 + 1. **Overrides index** — all overrides, with search, filter and sort (by
285 + service, resource, charge type, date range, author). Clicking a row goes to
286 + that service's page.
287 + 2. **ID search** — the admin enters a service ID or a resource ID. A service ID
288 + resolves to exactly one service; a resource ID may resolve to several.
289 +
290 + Both lead to the **service page**.
291 +
292 + ### Service page
293 +
294 + 1. Service header (identity, status, lifecycle).
295 + 2. **Billing month selector** — previous or current month only. (Billing range
296 + is always a month for this org.)
297 + 3. **Charges for that month** — see the open decision in §11.
298 + 4. Select a charge → **create override** panel: start, end (or empty for open),
299 + amount, short notes.
300 + 5. **This service's existing overrides**, listed at the bottom.
301 +
302 + On save the override joins the service's override list.
303 +
304 + The service page is a container hosting N override sections. Search, month
305 + selector and page frame are shared; the charges list is charge-override-specific
306 + and a prices list drops in beside it later for price overrides.
307 +
308 + ---
309 +
310 + ## 10. Phase 2 — month timeline (designed, not built)
311 +
312 + Show the charge's month as a horizontal strip: overridden spans filled,
313 + remaining space open/hatched.
314 +
315 + - Click a **filled** span → edit that override.
316 + - Click **empty** space → create flow, pre-populated with that gap's full range.
317 +
318 + Not strictly necessary and explicitly **out of v1 scope**, but it explains the
319 + feature faster than prose does and makes multi-override charges readable. The
320 + data model already supports it; nothing extra is required to add it later.
321 +
322 + ---
323 +
324 + ## 11. Open decisions
325 +
326 + ### 11.1 Charge list shape — *leaning A*
327 +
328 + **Option A** — one row per charge, slices nested beneath it:
329 +
330 + ```
331 + Port 10G — base #a3f9 Mar 1 – Apr 1 $1,083.88
332 + base Mar 1 – Mar 10 $348.39
333 + promo −20% Mar 10 – Mar 25 $464.52
334 + base Mar 25 – Apr 1 $270.97
335 + Port 10G — overage #7c21 Mar 1 – Apr 1 $73.40
336 + ```
337 +
338 + **Option B** — one row per slice (closer to the invoice).
339 +
340 + A matches the override model: you target a charge, then a span within it, and
341 + one override may cover several slices. B invites the admin to think a *slice* is
342 + the thing being overridden, and cannot express "override across two slices"
343 + without breaking the row metaphor.
344 +
345 + ### 11.2 Where override events are audited — *leaning separate admin trail*
346 +
347 + The existing audit log is parsed to reconstruct usage, and that derived data is
348 + what reports are built from. An override is **not a usage event**; putting it in
349 + that log risks it being read as one, and the blast radius is every report. A
350 + dedicated admin trail keeps traceability without touching the pipeline that
351 + generates the numbers.
352 +
353 + ### 11.3 Search behaviour on unambiguous match — *leaning auto-advance*
354 +
355 + Service ID → skip the one-row result list, go straight to the service page.
356 + Resource ID → always show the list, even with a single hit, so the admin can see
357 + which service they landed on.
358 +
359 + ---
360 +
361 + ## 12. Not in scope
362 +
363 + - **Price overrides** — next feature; shell is designed for it, nothing built.
364 + - **Negative amounts** — zero *is* allowed and is a primary use case (comping a
365 + charge). Negative is a credit: different feature, different approvals.
366 + - **Upper bound** — there will probably be a cap; it lives in config.
367 + - **Currency selection** — the incoming currency is the assumed currency. No
368 + conversion, no currency field.
369 + - **Tax** — not applicable in this system.
370 + - **Approval workflow** — role-gating and audit only in v1.
371 + - **Bulk operations** — one override at a time.
372 + - **The timeline visualisation** — designed and demonstrated, not built.
373 +
374 + ---
375 +
376 + ## 13. Implementation notes
377 +
378 + - Intervals are **half-open** `[start, end)` throughout. Use `tstzrange`.
379 + - `btree_gist` extension is required for the exclusion constraint.
380 + - The override rule is constructed at pricing time from DB rows; it is not
381 + stored as a rule row in the engine's rule table.
382 + - Amount is an **absolute** figure for its span, not a rate to be prorated.
383 + (Proration applies to the *remainder* of the charge, not to the override.)
Newer Older