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