Catalog CRUD
Catalog CRUD
Every catalog resource has routable list/detail pages and full create/edit/delete authoring,
gated by billing.permission:catalog:manage (writes) and catalog:read (reads). Reads go
through a read model (ProductReport, PlanReport, MeterReport, plus the existing
CatalogReport); writes go through an authoring service (ProductAuthoring, PlanAuthoring,
PlanPriceAuthoring, PlanEntitlementAuthoring, PlanCreditGrantAuthoring, MeterAuthoring)
so the controllers stay thin. Every destructive control carries the Wave 1 confirm dialog
and a server-side guard — the dialog is UX only, never the enforcement.
Archive vs hard-delete, per resource
The rule: archive (soft-deactivate) where referential integrity or history matters;
hard-delete only a never-referenced draft. A guard that refuses a hard-delete raises
CatalogActionDenied, which the controller catches and flashes back — the referenced row
survives.
| Resource | Hard-delete allowed when… | Otherwise | Guard |
|---|---|---|---|
| Product | it groups zero plans | archive (archived_at) |
refuse delete while any plan references it |
| Plan | no subscription (serving or historical) references it | archive (active = false → engine Legacy) |
refuse delete while any subscriber is on it; cascades prices/tiers/entitlements/grants when safe |
| Plan price | no serving subscriber bills in that currency on a live-or-legacy plan | (no soft state — the engine resolves exactly one version per currency) | refuse removal that would strip a grandfathered currency (the currency lock) |
| Entitlement | always (revert to deny-by-default) | — | none needed; removing a bucket denies that meter for the plan |
| Credit grant | always (idempotent, time-keyed) | — | none needed; nothing further vests, already-vested credits stand |
| Meter | no entitlement references it and no recorded usage | archive (archived_at) |
refuse delete while referenced or with usage history |
Products and meters gain a nullable archived_at; plans reuse the existing active flag
(archived = legacy). Archived products/meters are hidden from the "new plan" / "new
entitlement" pickers but still resolve for everything already pointing at them.
Grandfathering and the currency lock stay intact
- Plan edits are metadata only — name, interval, product, active. A plan's money lives in
the versioned per-currency
PlanPriceauthoring, which the engine resolves per subscriber, so editing a plan through this surface can never reprice an existing subscriber. - Interval is
monthoryearonly — the two cadences the billing engine can represent and renew (BillingIntervalcarries just Monthly and Yearly).weekandquarterare refused by the authoring guard (server-side, not just the form): they were previously billed on a monthly cadence, so a quarter over-charged 3× and a week under-charged. A genuine sub-monthly or quarterly cadence needs an engine feature, not an app workaround; any legacy plan stored onweek/quarteris normalized tomonthby migration. - Archiving a plan flips it to
Legacy(a valid transition source, closed to new signups); current subscribers keep their plan and their grandfathered price untouched. - Removing a price version is refused while a serving subscriber's org is billed in that currency, so the one-way currency lock the engine finalized on first invoice is never broken out from under them. An unused currency's price removes cleanly.
- Meter aggregation is engine-facing: the console-authored
aggregationflows throughPlanEntitlement::toMeterPolicy()into the resolvedMeterPolicy, so the billable-quantity the engine computes matches what the operator chose (defaultsum, unchanged for existing meters).