// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial // SPDX-FileCopyrightText: 2025-2026 Christian Galo package server import ( "context" "database/sql" "errors" "fmt" "html/template" "log/slog" "net/http" "sort" "strconv" "strings" "github.com/google/uuid" "git.coopcloud.tech/wiki-cafe/member-console/internal/billing" "git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements" "git.coopcloud.tech/wiki-cafe/member-console/internal/forms" "git.coopcloud.tech/wiki-cafe/member-console/internal/web" ) // PlanLadderViewModel represents a plan ladder for operator template rendering. type PlanLadderViewModel struct { PlanLadderID string Name string Description string IsActive bool // Key is the ladder's declarative address (entity-keys §5), shown // read-only on the detail page when set and omitted entirely when NULL. // The create form does not collect one, so most ladders have none; the // ones that do were named by a seed or a configuration file, and an // operator needs to see the string those name before renaming anything. Key string TierCount int ActiveAttachmentCount int64 } // DeleteControl builds the disabled "Delete" control and its reason for a // ladder that still has organizations holding a position on it (design D10, // ACC-8: the one shared disabled-control idiom, `ui_disabled_control.html`, // instead of a hand-rolled `` wrapper). Call only from the // ActiveAttachmentCount > 0 branch; the zero-attachment branch renders its // own enabled delete button. ID is scoped by PlanLadderID so a page listing // several ladders never collides on the aria-describedby target. func (d PlanLadderViewModel) DeleteControl() DisabledControl { return NewDisabledControl( "delete-ladder-reason-"+d.PlanLadderID, "Delete", "btn btn-outline-danger btn-sm", fmt.Sprintf("%d organization(s) hold a position; deletion is blocked until none remain.", d.ActiveAttachmentCount), ) } // PlanLadderTierViewModel represents a tier within a ladder. type PlanLadderTierViewModel struct { PlanLadderID string ProductID string ProductName string Rank int32 HasActiveAttachments bool // ActiveAttachmentCount is the live-holder count backing // HasActiveAttachments, rendered inline beside the Remove control so its // consequence is stated before any confirmation step. ActiveAttachmentCount int64 } // PlanLaddersData holds data for the plan ladders list partial. It carries // no FieldErrors: creation moved to its own page (design D20, round 4; // PlanLadderNewData), so no form on this page can fail validation. type PlanLaddersData struct { Ladders []PlanLadderViewModel Success string Error string // Topology is the cross-ladder map (grid + structural-health strip) // rendered at the top of the page (ux-ia-naming 2.2: plan ladders opens // map-first). Hydrated by the same loader the formerly-standalone // /operator/plan-topology page used (loadPlanTopologyData) so every // state — issues / healthy / nothing-to-validate — behaves unchanged at // its new home. See operator_plan_ladders.html, which includes // operator_plan_topology.html as a partial against this field. Topology PlanTopologyData // LaddersNav is the ladder list's operator-list-scale governance (design // D8 / plan-ladder-management "The ladder list is a scaffolded // section"): search over name, true-total paging. No paged SQL query // exists for plan ladders (a small catalog list, unlike persons or // organizations), so loadPlanLaddersPageData filters and pages the // already-loaded set in Go. LaddersNav ListNav } // LaddersListHeader titles the ladder list section beneath the topology // map. Defined here rather than anatomy.go (lane A's file) because it is a // new section, not a change to an existing one. func (d PlanLaddersData) LaddersListHeader() SectionHeader { h := SectionHeader{ Title: "Ladders", // design D20 (round 4) "record creation gets its own page": the // action is this page's one filled call to action, a link to // /operator/plan-ladders/new. Action: &Link{Label: "New plan ladder", URL: "/operator/plan-ladders/new", Filled: true}, } if d.LaddersNav.Total > 0 { h.Count = countLabel(int(d.LaddersNav.Total), "ladder", "ladders") } return h } // PlanLadderEditData holds data for the plan ladder edit form. type PlanLadderEditData struct { Ladder PlanLadderViewModel // Form is the ladder declaration rendered on its edit side, bound to // this record or to a refused submission of it (form-library). Form forms.FormView Success string Error string } // ProductOption represents a product for dropdown selection on the plan ladders page. type ProductOption struct { ProductID string Name string } // PlanLadderTiersData holds data for the tier management partial. type PlanLadderTiersData struct { Ladder PlanLadderViewModel Tiers []PlanLadderTierViewModel AvailableProducts []ProductOption // TierForm is the tier-add declaration (operator.plan-ladder.tier. // add), bound to whatever is currently chosen: empty on a fresh // visit, the submitted value on a refusal (design D9). Its own // HasErrors() reopens the Add tier panel on a refusal (design D20). TierForm forms.FormView Success string Error string // DefaultForOrgTypeNames is the display names of org types whose configured // default plan ladder is this one. When non-empty, new organizations of // those types are provisioned onto this ladder's rank-0 tier — so the // rank-0 row is badged accordingly. Empty means this ladder is no org // type's default, and rank 0 is only the ladder's base tier (provisions // no one). See orgTypesDefaultingToLadder. DefaultForOrgTypeNames []string // Pending is the server-rendered preview of a dropped-but-uncommitted tier // order; nil when no reorder is in flight. See PendingTierReorder. Pending *PendingTierReorder // RemovalPending is the server-rendered preview of removing a tier that // has live holders; nil when no removal is in flight. The card renders one // pending state at a time. See PendingTierRemoval. RemovalPending *PendingTierRemoval } // PendingTierRemoval is the server-rendered preview of removing a tier with // live holders — nothing is written until the operator commits. Other-source // positions always align-shrink (junction ends, delivery continues); // default-sourced holders require a disposition (keep | migrate), because a // kept off-ladder default cannot block the floor and would later stack with // the current type default — the preview says so instead of letting the // operator discover it months later. type PendingTierRemoval struct { ProductID string ProductName string DefaultSourced []OrgRef OtherSource []OrgSourceRef NeedsDisposition bool // PromotedName is set when the removed tier is rank 0 with a successor: // the product promoted to the new rank 0. PromotedForTypes names the org // types whose new-org default that promotion changes (empty when the // ladder is no type's default). PromotedName string PromotedForTypes []string // RuleLessWarning names the promoted rank-0 product when its entitlement // set carries no active rule (plan-ladder-management "A ladder preview // that promotes a rank-0 product names a set with no active rule"). It // sits beside the promoted-rank-0 note rather than replacing it, and // disables nothing under any disposition. RuleLessWarning template.HTML // Form is the removal preview's commit form (operator.plan-ladder. // tier.remove.commit), the preview idiom's own render (design D10; // finding FA-13). Form forms.FormView } // orgTypeRef names an org type (key + display name) whose default plan ladder // is the ladder under edit: the tier view badges rank 0 with the display // names, and the reorder preview/commit classify each type's population by // key. type orgTypeRef struct { OrgType string DisplayName string } // TierReorderTypePreview pairs one org type whose default is this ladder with // its default-change classification against the pending rank-0 product. type TierReorderTypePreview struct { OrgType string DisplayName string Preview *OrgTypeChangePreview } // PendingTierReorder is the server-rendered preview of a dropped tier order — // the drop itself writes nothing. Rank 0 is what an org type default resolves // to at use-time, so when the pending order changes rank 0 of a ladder that is // some org type's default, TypePreviews classifies each type's population // (same classifier as the org-types card) and the commit requires a bucket-2 // disposition (NeedsDisposition). Pending state lives in the rendered card // (the commit form re-submits OrderIDs), never browser-only. type PendingTierReorder struct { OrderIDs []string // pending product order, rank 0 first Order []PlanLadderTierViewModel // OrderIDs hydrated with names/flags and pending ranks Stale bool // submission reconciled against a changed ladder RankZeroChanged bool OldRankZeroName string NewRankZeroName string // RuleLessWarning names the product the pending order moves to rank 0 // when its entitlement set carries no active rule. A warning only: the // commit stays enabled. RuleLessWarning template.HTML TypePreviews []TierReorderTypePreview NeedsDisposition bool // some type's outgoing-default bucket is non-empty // Form is the reorder preview's commit form (operator.plan-ladder. // tier.reorder.commit), the preview idiom's own render (design D10; // finding FA-13). Form forms.FormView } // PlanLadderDetailData is the body data for the addressable per-ladder composite // page (operator_plan_ladder_detail.html). It co-locates the edit form and the // tier/rank manager on one URL; the composite template renders the edit and // tiers sub-sections via their existing partials. The structural-validation // view that once shared this page (and the entitlements-module query behind // its one live check) was retired in acceptance-fixes round 2 (design D8, // plan-ladder-management "Structural invariant validation view"): the check // it ran is impossible under the database's exclusion constraint, so the // query stays unexposed in internal/entitlements/queries/pool_provision_ladders.sql // (CountMultiActiveLadderAttachments) instead of backing a page here. type PlanLadderDetailData struct { Edit PlanLadderEditData Tiers PlanLadderTiersData Error string } // CreatePlanLadder handles POST /partials/operator/plan-ladders, reading // the body through the ladder declaration and never through r.FormValue. func (h *OperatorPartialsHandler) CreatePlanLadder(w http.ResponseWriter, r *http.Request) { values, errs := planLadderForm.ParseSide(r, forms.CreateOnly, nil) if errs.Any() { h.renderPlanLadderNewRefusal(w, r, values, errs) return } // The form collects the display name once: a ladder carries no key // (entity-keys; migration 00014 dropped the column). name := values.String("name") description := values.String("description") ladder, err := h.BillingQ.CreatePlanLadder(r.Context(), billing.CreatePlanLadderParams{ Name: name, Description: sql.NullString{String: description, Valid: description != ""}, IsActive: true, }) if err != nil { if fe, ok := web.FieldErrorsFromDB(err, web.ConstraintMessages{ "uq_plan_ladders_name_ci": {Field: "name", Message: "A plan ladder with this name already exists."}, }); ok { for field, msg := range fe { errs.Field(field, msg) } h.renderPlanLadderNewRefusal(w, r, values, errs) return } h.Logger.Error("failed to create plan ladder", slog.Any("error", err)) errs.Form("Failed to create the plan ladder. Details are in the server logs.") h.renderPlanLadderNewRefusal(w, r, values, errs) return } // design D20 "Create flows: the create page, then land on the record": // success is a full navigation to the new ladder's page, which renders // the "Plan ladder created." notice for flash=created — the same // mechanism as the existing flash=missing bounce // (loadPlanLaddersPageData) and the product/entitlement-set composites' // own create-then-land landings (redirectToRecord, operator_products.go). redirectToRecord(w, r, "/operator/plan-ladders/"+ladder.PlanLadderID+"?flash=created") } // UpdatePlanLadder handles PUT /partials/operator/plan-ladders/{ladderID}, // reading the body through the same declaration the create page posts // through, on its edit side. func (h *OperatorPartialsHandler) UpdatePlanLadder(w http.ResponseWriter, r *http.Request) { ladderID := r.PathValue("ladderID") values, errs := planLadderForm.ParseSide(r, forms.EditOnly, nil) if errs.Any() { h.renderPlanLadderEditRefusal(w, r, ladderID, values, errs) return } name := values.String("name") description := values.String("description") // The Active flag is not operator-editable: ladder retirement isn't wired // up (an inactive ladder still sells and provisions), so the edit form no // longer renders the toggle and the stored value is preserved verbatim. ladder, err := h.BillingQ.GetPlanLadderByID(r.Context(), ladderID) if err != nil { h.Logger.Error("failed to load plan ladder", slog.Any("error", err)) errs.Form("Plan ladder not found.") h.renderPlanLadderEditRefusal(w, r, ladderID, values, errs) return } // Ladder display order (sort_order) is deliberately not editable here — it is // adjusted via the move-left/move-right controls on the topology overview, // where sibling order is visible. UpdatePlanLadder no longer touches it. _, err = h.BillingQ.UpdatePlanLadder(r.Context(), billing.UpdatePlanLadderParams{ PlanLadderID: ladderID, Name: name, Description: sql.NullString{String: description, Valid: description != ""}, IsActive: ladder.IsActive, }) if err != nil { if fe, ok := web.FieldErrorsFromDB(err, web.ConstraintMessages{ "uq_plan_ladders_name_ci": {Field: "name", Message: "A plan ladder with this name already exists."}, }); ok { for field, msg := range fe { errs.Field(field, msg) } h.renderPlanLadderEditRefusal(w, r, ladderID, values, errs) return } h.Logger.Error("failed to update plan ladder", slog.Any("error", err)) errs.Form("Failed to update the plan ladder. Details are in the server logs.") h.renderPlanLadderEditRefusal(w, r, ladderID, values, errs) return } // Stay on the ladder's composite detail page (the boosted nav already set // the URL); re-render in place reflecting the saved changes. h.renderPlanLadderDetailBody(w, r, ladderID, "Plan ladder updated successfully.", "") } // DeletePlanLadder handles DELETE /partials/operator/plan-ladders/{ladderID} func (h *OperatorPartialsHandler) DeletePlanLadder(w http.ResponseWriter, r *http.Request) { ladderID := r.PathValue("ladderID") // Guard: reject deletion if any active pool_provision_ladders exist count, err := h.EntitlementsQ.CountActiveAttachmentsByLadder(r.Context(), ladderID) if err != nil { h.Logger.Error("failed to count active attachments", slog.Any("error", err)) h.renderPlanLaddersPage(w, r, "", "Failed to check active positions") return } if count > 0 { h.renderPlanLaddersPage(w, r, "", "Cannot delete ladder: "+strconv.FormatInt(count, 10)+" active position(s) exist") return } err = h.BillingQ.DeletePlanLadder(r.Context(), ladderID) if err != nil { if _, ok := web.FieldErrorsFromDB(err, nil); ok { h.renderPlanLaddersPage(w, r, "", "Cannot delete this ladder: something still references it (a tier, or an org type using it as its default). Remove those references first.") return } h.Logger.Error("failed to delete plan ladder", slog.Any("error", err)) h.renderPlanLaddersPage(w, r, "", "Failed to delete plan ladder; see server logs.") return } // Signal dependent tabs (Org Types) to re-fetch — they show plan ladder dropdowns h.renderPlanLaddersPage(w, r, "Plan ladder deleted successfully.", "") } // CreatePlanLadderTier handles POST /partials/operator/plan-ladders/{ladderID}/tiers, // reading the body through the tier-add declaration. func (h *OperatorPartialsHandler) CreatePlanLadderTier(w http.ResponseWriter, r *http.Request) { ladderID := r.PathValue("ladderID") session := h.AuthConfig.GetUserSession(r.Context()) if session == nil { h.renderPlanLadderTiersPage(w, r, ladderID, "", "Unauthorized") return } products := h.availableTierProducts(r.Context(), ladderID) values, errs := planLadderTierAddForm.ParseWith(r, planLadderTierAddFormOptions(products)) if errs.Any() { h.renderPlanLadderTierAddRefusal(w, r, ladderID, values, errs) return } productID := values.String("product_id") // Any published product may be a tier (Doc 41: display_category is // presentation-only and never gates ladder membership). product, err := h.BillingQ.GetProductByID(r.Context(), productID) if err != nil { errs.Field("product_id", "Product not found.") h.renderPlanLadderTierAddRefusal(w, r, ladderID, values, errs) return } if product.LifecycleStatus != "published" { errs.Field("product_id", "Only published products can be tiers. This product is "+product.LifecycleStatus+".") h.renderPlanLadderTierAddRefusal(w, r, ladderID, values, errs) return } // Tier creation and the shape-alignment of every live provision of this // product run in one tx: adding the tier grew the product's conferral shape, // so existing deliveries must gain the new rung atomically. ctx := r.Context() tx, err := entitlements.BeginMaterializing(ctx, h.Database, nil) if err != nil { h.Logger.Error("failed to begin add-tier tx", slog.Any("error", err)) h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to add tier; see server logs.") return } defer tx.Rollback() bq := billing.New(tx) entQ := entitlements.New(tx) // Rank self-assigns to MAX+1 inside the INSERT — new tiers append at the // bottom; the operator drags them into position (that is also the only way // the rank-0 provisioning default changes, which is the right affordance). if _, err = bq.CreatePlanLadderTier(ctx, billing.CreatePlanLadderTierParams{ PlanLadderID: ladderID, ProductID: productID, }); err != nil { if fe, ok := web.FieldErrorsFromDB(err, web.ConstraintMessages{ "plan_ladder_tiers_pkey": {Field: "product_id", Message: "This product is already a tier in this ladder."}, "plan_ladder_tiers_plan_ladder_id_rank_key": {Field: "product_id", Message: "Another change collided with this one; try again."}, }); ok { for field, msg := range fe { errs.Field(field, msg) } h.renderPlanLadderTierAddRefusal(w, r, ladderID, values, errs) return } h.Logger.Error("failed to create plan ladder tier", slog.Any("error", err)) h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to add tier; see server logs.") return } // Align every live provision of the product to the grown shape; CF003 names // the provision already holding the new rung and blocks the add. provisions, err := entQ.GetLivePoolProvisionsByProductID(ctx, productID) if err != nil { h.Logger.Error("failed to list live provisions for tier product", slog.Any("error", err)) h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to add tier; see server logs.") return } for _, p := range provisions { // The operator-actor transition CHECK requires actor_id; omitting it // made every provision-bearing induction roll back (found 2026-07-12 // by the tier-reorder walkthrough). if _, err := entQ.AlignConferralShape(ctx, entitlements.AlignConferralShapeParams{ ProvisionID: p.ProvisionID, ActorType: "operator", ActorID: uuid.NullUUID{UUID: uuid.MustParse(session.PersonID), Valid: true}, Reason: sql.NullString{String: "tier added to ladder " + ladderID, Valid: true}, }); err != nil { if errors.Is(err, entitlements.ErrConferralShapeCollision) { errs.Field("product_id", "Cannot add this tier; a rung is already held: "+err.Error()) h.renderPlanLadderTierAddRefusal(w, r, ladderID, values, errs) return } h.Logger.Error("failed to align provision to new tier shape", slog.Any("error", err), slog.String("provision_id", p.ProvisionID)) h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to add tier; see server logs.") return } if err := entitlements.MaterializePoolEntitlements(ctx, entQ, p.PoolID); err != nil { h.Logger.Error("failed to materialize after tier align", slog.Any("error", err)) h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to add tier; see server logs.") return } } if err := tx.Commit(); err != nil { h.Logger.Error("failed to commit add-tier tx", slog.Any("error", err)) h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to add tier; see server logs.") return } // Signal dependent tabs (Org Types) to re-fetch — they show plan ladder dropdowns h.renderPlanLadderTiersPage(w, r, ladderID, "Tier added at the bottom; drag it into position.", "") } // reconcileTierOrder merges a submitted product order with the ladder's // current tiers: submitted products that are no longer tiers are dropped, and // tiers absent from the submission are appended in their current relative // order (tiers arrive rank ASC), so the result always covers the whole ladder // and renumbering can never collide with an untouched tier that still holds a // target rank (audit finding #42). Returns the merged order, the product // currently at rank 0, and whether the submission was stale (something was // dropped or appended). Shared by the reorder preview and commit so the order // the operator saw is the order the commit gates on. // splitCommaList splits the reorder-commit form's comma-joined "order" // field back into its product ids: a single Hidden field carrying a list, // rather than repeated same-named hidden inputs, which the library's Field // model does not represent (design D6, "a multi-valued control does not // exist"). A blank field splits to no ids, not one empty one. func splitCommaList(joined string) []string { if joined == "" { return nil } return strings.Split(joined, ",") } func reconcileTierOrder(tiers []billing.PlanLadderTier, submitted []string) (final []string, prevTop string, stale bool) { valid := make(map[string]bool, len(tiers)) for _, t := range tiers { valid[t.ProductID] = true if t.Rank == 0 { prevTop = t.ProductID } } taken := make(map[string]bool, len(submitted)) final = make([]string, 0, len(tiers)) for _, pid := range submitted { if valid[pid] && !taken[pid] { taken[pid] = true final = append(final, pid) } } stale = len(final) != len(submitted) // a submitted product was not a valid tier for _, t := range tiers { if !taken[t.ProductID] { final = append(final, t.ProductID) stale = true } } return final, prevTop, stale } // buildPendingTierReorder reconciles a submitted product order into a fully // hydrated preview: pending rows with pending ranks, whether rank 0 changes, // and — when it does on a ladder that is some org type's default — each // affected type's default-change classification with the pending rank-0 // product as candidate. Read-only. A (nil, "") return means there was nothing // to preview (empty submission or a fully-stale one); callers re-render the // persisted order. func (h *OperatorPartialsHandler) buildPendingTierReorder(ctx context.Context, ladderID string, submitted []string) (*PendingTierReorder, string) { if len(submitted) == 0 { return nil, "" } tiers, err := h.BillingQ.ListTiersByLadder(ctx, ladderID) if err != nil { h.Logger.Error("failed to list tiers for reorder preview", slog.Any("error", err)) return nil, "Failed to load tiers for the reorder preview." } final, prevTop, stale := reconcileTierOrder(tiers, submitted) if len(final) == 0 { return nil, "" } withProducts, err := h.BillingQ.ListTiersByLadderWithProducts(ctx, ladderID) if err != nil { h.Logger.Error("failed to load tier products for reorder preview", slog.Any("error", err)) return nil, "Failed to load tiers for the reorder preview." } names := make(map[string]string, len(withProducts)) for _, t := range withProducts { names[t.ProductID] = t.ProductName } pending := &PendingTierReorder{ OrderIDs: final, Stale: stale, RankZeroChanged: final[0] != prevTop, OldRankZeroName: names[prevTop], NewRankZeroName: names[final[0]], } pending.Order = make([]PlanLadderTierViewModel, len(final)) for i, pid := range final { count, _ := h.EntitlementsQ.CountActiveAttachmentsByTier(ctx, entitlements.CountActiveAttachmentsByTierParams{ PlanLadderID: ladderID, ProductID: pid, }) pending.Order[i] = PlanLadderTierViewModel{ PlanLadderID: ladderID, ProductID: pid, ProductName: names[pid], Rank: int32(i), HasActiveAttachments: count > 0, ActiveAttachmentCount: count, } } // design D8: display order is rank ascending (rank 0 first), the same // order this loop already built pending.Order in — no reversal needed. // OrderIDs stays canonical (rank-0-first) since the pending preview's // commit form re-submits it verbatim. if pending.RankZeroChanged { pending.RuleLessWarning = h.ruleLessWarning(ctx, final[0]) for _, ref := range h.orgTypesDefaultingToLadder(ctx, ladderID) { preview, err := h.buildOrgTypeChangePreview(ctx, ref.OrgType, ladderID, final[0]) if err != nil { h.Logger.Error("failed to classify for reorder preview", slog.Any("error", err), slog.String("org_type", ref.OrgType)) return nil, "Failed to classify affected organizations. Details are in the server logs." } pending.TypePreviews = append(pending.TypePreviews, TierReorderTypePreview{ OrgType: ref.OrgType, DisplayName: ref.DisplayName, Preview: preview, }) if len(preview.OutgoingDefault) > 0 { pending.NeedsDisposition = true } } } ladderURL, err := web.RouteURL("/operator/plan-ladders/{ladderID}", ladderID) if err != nil { ladderURL = "/operator/plan-ladders" } pending.Form = planLadderTierReorderPreview(ladderID, ladderURL, planLadderTierReorderValues(pending.OrderIDs, pending.NeedsDisposition, ""), nil, h.Templates.Fragment("operator_plan_ladder_tier_reorder_message.html", pending)) return pending, "" } // PreviewPlanLadderTiersReorder handles // POST /partials/operator/plan-ladders/{ladderID}/tiers/reorder/preview — the // drop half of the two-step reorder. It writes nothing: the submitted order is // reconciled and re-rendered as pending state (server-rendered, so no swap can // silently discard it) together with the consequences of the pending rank 0, // and the card offers Commit/Discard. A second drag on the previewed card // simply re-previews. func (h *OperatorPartialsHandler) PreviewPlanLadderTiersReorder(w http.ResponseWriter, r *http.Request) { ladderID := r.PathValue("ladderID") if err := r.ParseForm(); err != nil { h.renderPlanLadderTiersPage(w, r, ladderID, "", "Invalid reorder request.") return } // design D8: the sortable tbody renders rank ascending (rank 0 first), // the same order the reorder pipeline works in, so a drop's raw DOM // order (collected via hx-include="this") arrives already canonical. submitted := r.Form["product"] pending, errMsg := h.buildPendingTierReorder(r.Context(), ladderID, submitted) if errMsg != "" { h.renderPlanLadderTiersPage(w, r, ladderID, "", errMsg) return } h.renderPlanLadderTiersPending(w, r, ladderID, pending, "") } // ReorderPlanLadderTiers handles POST /partials/operator/plan-ladders/{ladderID}/tiers/reorder // — the commit half of the two-step reorder (the drop itself only previews; // see PreviewPlanLadderTiersReorder). The request carries the pending // product-ID order as the declaration operator.plan-ladder.tier.reorder. // commit's single comma-joined `order` field (design D6: the form library // has no repeated-name control; splitCommaList reverses the join) plus, // when the pending rank 0 differs on a ladder that is some org type's // default and organizations hold the outgoing rank-0 through // default-sourced positions, a required `bucket2_disposition` // (`grandfather` | `migrate`). Enactment mirrors the // org-type default-change commit: validation gate (nothing written, not even // ranks), then renumber (the moment the effective default changes), then // per-pool dispositions with in-tx re-derivation. A tier's rank is display // order plus the rank-0 provisioning default — member provisions bind by // product_id (pool_provision_ladders), so reordering never re-plans a member. func (h *OperatorPartialsHandler) ReorderPlanLadderTiers(w http.ResponseWriter, r *http.Request) { ladderID := r.PathValue("ladderID") session := h.AuthConfig.GetUserSession(r.Context()) if session == nil { h.renderPlanLadderTiersPage(w, r, ladderID, "", "Unauthorized") return } values, errs := planLadderTierReorderForm.Parse(r) submitted := splitCommaList(values.String("order")) disposition := values.String("bucket2_disposition") ctx := r.Context() pending, errMsg := h.buildPendingTierReorder(ctx, ladderID, submitted) if errMsg != "" { h.renderPlanLadderTiersPage(w, r, ladderID, "", errMsg) return } if pending == nil { h.renderPlanLadderTiersPage(w, r, ladderID, "", "") return } // Validation gate: an outgoing-default population anywhere requires an // explicit disposition. Rejecting here writes nothing — not even ranks. // pending.NeedsDisposition is freshly re-derived from the current // database state, never the submitted needs_disposition marker (that // marker only decides whether the radio rendered; a stale marker // re-previews here rather than trusting an out-of-date "not needed"). if pending.NeedsDisposition && disposition == "" && !errs.Has("bucket2_disposition") { outgoing := 0 for _, tp := range pending.TypePreviews { outgoing += len(tp.Preview.OutgoingDefault) } errs.Field("bucket2_disposition", fmt.Sprintf("Choose whether to grandfather or migrate the %d organization(s) on the outgoing default.", outgoing)) } if errs.Any() { h.renderPlanLadderTiersReorderRefusal(w, r, ladderID, pending, planLadderTierReorderValues(pending.OrderIDs, pending.NeedsDisposition, disposition), errs) return } tx, err := entitlements.BeginMaterializing(ctx, h.Database, nil) if err != nil { h.Logger.Error("failed to begin tier reorder tx", slog.Any("error", err)) h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to reorder tiers.") return } defer tx.Rollback() qtx := billing.New(tx) if err := renumberTiers(ctx, qtx, ladderID, pending.OrderIDs); err != nil { h.Logger.Error("failed to renumber tiers", slog.Any("error", err)) // Renumbering the whole ladder removes the stale-collision failure mode; // a residual error here rolls back and re-renders the true current order, // so tell the operator their page was out of date instead of emitting an // opaque generic failure (audit finding #42). h.renderPlanLadderTiersPage(w, r, ladderID, "", "This page was out of date; showing the current tier order. Try again.") return } if err := tx.Commit(); err != nil { h.Logger.Error("failed to commit tier reorder tx", slog.Any("error", err)) h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to reorder tiers.") return } msg := "Tier order updated." if pending.Stale { msg = "Tier order saved. The page was out of date, so this reflects the current order." } if pending.RankZeroChanged && len(pending.TypePreviews) > 0 { // The effective default changed: enact per-pool dispositions for every // affected type's orgs, exactly like the org-type default-change commit // (shared enactOrgDisposition — per-pool serializable transactions, // in-tx bucket re-derivation, per-org failure isolation). // The reason is composed here and stored on the transition rows, so // it is a write-time snapshot: it records the ladder's name as it // read at the moment of the reorder, and a later rename does not // rewrite history. The ladder UUID stands in when the row cannot be // loaded. ladderLabel := ladderID if ladder, err := h.BillingQ.GetPlanLadderByID(ctx, ladderID); err == nil { ladderLabel = ladder.Name } actor := entitlements.Actor{ ActorType: "operator", ActorID: uuid.NullUUID{UUID: uuid.MustParse(session.PersonID), Valid: true}, Reason: fmt.Sprintf("plan-ladder tier reorder (%s)", ladderLabel), } result := &OrgTypeChangeResult{} var typeNames []string for _, tp := range pending.TypePreviews { typeNames = append(typeNames, tp.DisplayName) orgs, err := h.OrgQ.ListOrganizationsByType(ctx, tp.OrgType) if err != nil { h.Logger.Error("failed to list orgs for reorder enactment", slog.Any("error", err), slog.String("org_type", tp.OrgType)) h.renderPlanLadderTiersPage(w, r, ladderID, "", "Tier order saved, but affected organizations could not be listed for enactment. Re-drop the same order and commit again.") return } for _, org := range orgs { h.enactOrgDisposition(ctx, org, disposition, pending.OrderIDs[0], session.PersonID, actor, result) } } msg = fmt.Sprintf("%s is now the default plan for new %s organizations: %s.", pending.NewRankZeroName, strings.Join(typeNames, ", "), result.Summary()) if len(result.Failures) > 0 { parts := make([]string, 0, len(result.Failures)) for _, f := range result.Failures { parts = append(parts, fmt.Sprintf("%s: %s", f.OrgName, f.Reason)) } h.renderPlanLadderTiersPage(w, r, ladderID, "", msg+" Failed organizations were skipped; fix the cause, re-drop the same order, and commit again: "+strings.Join(parts, "; ")) return } } else if pending.RankZeroChanged { msg = fmt.Sprintf("%s is now this ladder's rank-0 tier. The ladder is not any org type's default, so no organizations were affected.", pending.NewRankZeroName) } h.renderPlanLadderTiersPage(w, r, ladderID, msg, "") } // renumberTiers assigns each product in `ordered` a contiguous rank 0..N-1 by // position, in two phases inside the caller's transaction: UNIQUE // (plan_ladder_id, rank) is non-deferrable, so a single-pass assignment can // transiently collide (e.g. setting one tier to 0 while another still holds 0). // Phase 1 parks every tier at a unique negative rank (disjoint from any // non-negative existing value); phase 2 assigns the final contiguous ranks. // Shared by drag-and-drop reorder and renumber-on-delete. func renumberTiers(ctx context.Context, qtx *billing.Queries, ladderID string, ordered []string) error { for i, pid := range ordered { if _, err := qtx.UpdateTierRank(ctx, billing.UpdateTierRankParams{ PlanLadderID: ladderID, ProductID: pid, Rank: int32(-(i + 1)), }); err != nil { return fmt.Errorf("park tier rank: %w", err) } } for i, pid := range ordered { if _, err := qtx.UpdateTierRank(ctx, billing.UpdateTierRankParams{ PlanLadderID: ladderID, ProductID: pid, Rank: int32(i), }); err != nil { return fmt.Errorf("set tier rank: %w", err) } } return nil } // DeletePlanLadderTier handles DELETE /partials/operator/plan-ladders/{ladderID}/tiers/{productID} func (h *OperatorPartialsHandler) DeletePlanLadderTier(w http.ResponseWriter, r *http.Request) { ladderID := r.PathValue("ladderID") productID := r.PathValue("productID") // A tier with live holders is removed via the preview → commit flow // (CommitPlanLadderTierRemoval), never this plain delete — this DELETE is // only reachable with holders from a stale page, so re-render with the // current state (whose Remove button now posts the preview). count, err := h.EntitlementsQ.CountActiveAttachmentsByTier(r.Context(), entitlements.CountActiveAttachmentsByTierParams{ PlanLadderID: ladderID, ProductID: productID, }) if err != nil { h.Logger.Error("failed to count active tier attachments", slog.Any("error", err)) h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to check active positions") return } if count > 0 { h.renderPlanLadderTiersPage(w, r, ladderID, "", "This tier has "+strconv.FormatInt(count, 10)+" live holder(s); Remove now previews the reconciled removal before committing.") return } // Refuse deleting a live default ladder's only remaining tier: new // signups land on this ladder's rank-0 tier, so emptying it would leave // every org type that defaults to it without a starting plan. Emptying a // ladder no org type defaults to stays legal — the 2026-07-03 // remediation explicitly rejected a blanket last-tier guard. tiers, err := h.BillingQ.ListTiersByLadder(r.Context(), ladderID) if err != nil { h.Logger.Error("failed to list tiers for delete guard", slog.Any("error", err)) h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to check the ladder's tiers") return } if len(tiers) == 1 && tiers[0].ProductID == productID { if refs := h.orgTypesDefaultingToLadder(r.Context(), ladderID); len(refs) > 0 { names := make([]string, len(refs)) for i, ref := range refs { names[i] = ref.DisplayName } h.renderPlanLadderTiersPage(w, r, ladderID, "", "This ladder's only remaining tier; "+strings.Join(names, ", ")+" defaults new signups to it. Deleting it would leave those signups without a starting plan, so the removal is refused.") return } } // Delete + renumber the remaining tiers to contiguous 0..N-1 in one tx, so // ranks never gap and the ladder always has a rank-0 provisioning default // (deleting the base tier promotes the next tier — the confirm dialog says so). ctx := r.Context() tx, err := entitlements.BeginMaterializing(ctx, h.Database, nil) if err != nil { h.Logger.Error("failed to begin tier delete tx", slog.Any("error", err)) h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to remove tier; see server logs.") return } defer tx.Rollback() qtx := billing.New(tx) if err := qtx.DeleteTier(ctx, billing.DeleteTierParams{ PlanLadderID: ladderID, ProductID: productID, }); err != nil { h.Logger.Error("failed to delete tier", slog.Any("error", err)) h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to remove tier; see server logs.") return } remaining, err := qtx.ListTiersByLadder(ctx, ladderID) if err != nil { h.Logger.Error("failed to list remaining tiers", slog.Any("error", err)) h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to remove tier; see server logs.") return } ordered := make([]string, len(remaining)) for i, t := range remaining { ordered[i] = t.ProductID } if err := renumberTiers(ctx, qtx, ladderID, ordered); err != nil { h.Logger.Error("failed to renumber tiers after delete", slog.Any("error", err)) h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to remove tier; see server logs.") return } if err := tx.Commit(); err != nil { h.Logger.Error("failed to commit tier delete tx", slog.Any("error", err)) h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to remove tier; see server logs.") return } // Signal dependent tabs (Org Types) to re-fetch — they show plan ladder dropdowns h.renderPlanLadderTiersPage(w, r, ladderID, "Tier removed; remaining tiers renumbered.", "") } // buildTierRemovalPreview classifies the live holders of (ladder, product) // for the removal preview: default-sourced positions (disposition required) // vs other-source positions (align-shrunk, named, never force-ended), plus // the promoted-rank-0 note when removing the top tier. Read-only. A (nil, "") // return means the tier has no live holders — callers fall back to the plain // modal-confirmed delete. func (h *OperatorPartialsHandler) buildTierRemovalPreview(ctx context.Context, ladderID, productID string) (*PendingTierRemoval, string) { rows, err := h.EntitlementsQ.ListLiveTierHoldersByLadderProduct(ctx, entitlements.ListLiveTierHoldersByLadderProductParams{ PlanLadderID: ladderID, ProductID: productID, }) if err != nil { h.Logger.Error("failed to list tier holders for removal preview", slog.Any("error", err)) return nil, "Failed to load the tier's holders. Details are in the server logs." } if len(rows) == 0 { return nil, "" } pending := &PendingTierRemoval{ProductID: productID, ProductName: productID} if prod, err := h.BillingQ.GetProductByID(ctx, productID); err == nil { pending.ProductName = prod.Name } seenDefault := make(map[string]bool) seenOther := make(map[string]bool) for _, row := range rows { ref := OrgRef{OrgID: row.OrgID, Name: row.Name} if row.GrantReason.Valid && row.GrantReason.String == "default" { if !seenDefault[row.OrgID] { seenDefault[row.OrgID] = true pending.DefaultSourced = append(pending.DefaultSourced, ref) } continue } if !seenOther[row.OrgID] { seenOther[row.OrgID] = true pending.OtherSource = append(pending.OtherSource, OrgSourceRef{ OrgRef: ref, Source: otherSourceLabel([]entitlements.GetLivePlanAttachmentsWithSourceByPoolRow{{ ProductID: row.ProductID, GrantID: row.GrantID, SubscriptionID: row.SubscriptionID, PurchaseID: row.PurchaseID, GrantReason: row.GrantReason, }}), }) } } pending.NeedsDisposition = len(pending.DefaultSourced) > 0 // Promoted-rank-0 note: removing the top tier of a ladder makes the next // tier the resolution target for new-org provisioning. if tiers, err := h.BillingQ.ListTiersByLadderWithProducts(ctx, ladderID); err == nil && len(tiers) > 1 && tiers[0].ProductID == productID && tiers[0].Rank == 0 { pending.PromotedName = tiers[1].ProductName pending.RuleLessWarning = h.ruleLessWarning(ctx, tiers[1].ProductID) for _, ref := range h.orgTypesDefaultingToLadder(ctx, ladderID) { pending.PromotedForTypes = append(pending.PromotedForTypes, ref.DisplayName) } } ladderURL, err := web.RouteURL("/operator/plan-ladders/{ladderID}", ladderID) if err != nil { ladderURL = "/operator/plan-ladders" } pending.Form = planLadderTierRemovalPreview(ladderID, productID, ladderURL, planLadderTierRemovalValues(pending.NeedsDisposition, ""), nil, h.Templates.Fragment("operator_plan_ladder_tier_removal_message.html", pending)) return pending, "" } // renderPlanLadderTiersRemoval re-renders the ladder composite with a pending // tier removal attached to the tier card. No toast — nothing has happened yet. func (h *OperatorPartialsHandler) renderPlanLadderTiersRemoval(w http.ResponseWriter, r *http.Request, ladderID string, pending *PendingTierRemoval, errMsg string) { edit, ok := h.loadPlanLadderEditData(r, ladderID) if !ok { h.renderPlanLaddersPage(w, r, "", "Plan ladder not found") return } tiersData := h.loadPlanLadderTiersData(r, ladderID) tiersData.RemovalPending = pending h.Templates.Render(w, "operator_plan_ladder_detail.html", PlanLadderDetailData{ Edit: edit, Tiers: tiersData, Error: errMsg, }) } // renderPlanLadderTiersRemovalRefusal re-renders the composite at 422 with // the removal preview's commit form in submission mode: the chosen // disposition carried back and the refusal under its field (design D9, // plan-ladder-management "A refused commit keeps the chosen dispositions"). func (h *OperatorPartialsHandler) renderPlanLadderTiersRemovalRefusal(w http.ResponseWriter, r *http.Request, ladderID string, pending *PendingTierRemoval, values forms.Values, errs *forms.Errors) { w.WriteHeader(http.StatusUnprocessableEntity) edit, ok := h.loadPlanLadderEditData(r, ladderID) if !ok { h.renderPlanLaddersPage(w, r, "", "Plan ladder not found") return } ladderURL, err := web.RouteURL("/operator/plan-ladders/{ladderID}", ladderID) if err != nil { ladderURL = "/operator/plan-ladders" } pending.Form = planLadderTierRemovalPreview(ladderID, pending.ProductID, ladderURL, values, errs, h.Templates.Fragment("operator_plan_ladder_tier_removal_message.html", pending)) tiersData := h.loadPlanLadderTiersData(r, ladderID) tiersData.RemovalPending = pending h.Templates.Render(w, "operator_plan_ladder_detail.html", PlanLadderDetailData{ Edit: edit, Tiers: tiersData, }) } // PreviewPlanLadderTierRemoval handles // POST /partials/operator/plan-ladders/{ladderID}/tiers/{productID}/remove/preview // — the first half of removing a tier with live holders. Writes nothing: the // tier card re-renders with the affected orgs classified by position source, // disposition radios when default-sourced holders exist, and Commit / Discard. func (h *OperatorPartialsHandler) PreviewPlanLadderTierRemoval(w http.ResponseWriter, r *http.Request) { ladderID := r.PathValue("ladderID") productID := r.PathValue("productID") pending, errMsg := h.buildTierRemovalPreview(r.Context(), ladderID, productID) if errMsg != "" { h.renderPlanLadderTiersPage(w, r, ladderID, "", errMsg) return } if pending == nil { // The holders vanished since the page rendered: the plain delete flow // applies again. h.renderPlanLadderTiersPage(w, r, ladderID, "", "This tier no longer has live holders; use Remove directly.") return } h.renderPlanLadderTiersRemoval(w, r, ladderID, pending, "") } // CommitPlanLadderTierRemoval handles // POST /partials/operator/plan-ladders/{ladderID}/tiers/{productID}/remove — // the commit half of removing a tier with live holders. Atomic by design // (design D3): tier deletion, renumbering, and every holder reconciliation // run in ONE transaction with affected pools locked in deterministic order — // once the tier row is gone there is no entry point to re-run a // half-reconciled removal, and a shape-mismatched provision would block // future same-product confers, so a failure rolls back everything including // the deletion. Other-source positions align-shrink (junction ends, delivery // continues). Default-sourced positions follow the required disposition: // keep = align-shrink (the preview carried the floor caveat); migrate = // end + floor-guarded restoration, resolved after renumbering, so removing a // default ladder's rank 0 migrates holders onto the promoted tier. func (h *OperatorPartialsHandler) CommitPlanLadderTierRemoval(w http.ResponseWriter, r *http.Request) { ladderID := r.PathValue("ladderID") productID := r.PathValue("productID") session := h.AuthConfig.GetUserSession(r.Context()) if session == nil { h.renderPlanLadderTiersPage(w, r, ladderID, "", "Unauthorized") return } values, parseErrs := planLadderTierRemoveForm.Parse(r) disposition := values.String("default_disposition") if parseErrs.Has("default_disposition") { pending, errMsg := h.buildTierRemovalPreview(r.Context(), ladderID, productID) if errMsg != "" || pending == nil { h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to remove the tier; nothing was changed.") return } h.renderPlanLadderTiersRemovalRefusal(w, r, ladderID, pending, planLadderTierRemovalValues(pending.NeedsDisposition, disposition), parseErrs) return } ctx := r.Context() productName := productID if prod, err := h.BillingQ.GetProductByID(ctx, productID); err == nil { productName = prod.Name } // Composed here and stored on the transition rows: a write-time snapshot // of the ladder's name as it read at the moment of the removal. The // ladder UUID stands in when the row cannot be loaded. ladderLabel := ladderID if ladder, err := h.BillingQ.GetPlanLadderByID(ctx, ladderID); err == nil { ladderLabel = ladder.Name } actor := entitlements.Actor{ ActorType: "operator", ActorID: uuid.NullUUID{UUID: uuid.MustParse(session.PersonID), Valid: true}, Reason: fmt.Sprintf("tier removal (%s from %s)", productName, ladderLabel), } tx, err := entitlements.BeginMaterializing(ctx, h.Database, nil) if err != nil { h.Logger.Error("failed to begin tier removal tx", slog.Any("error", err)) h.renderPlanLadderTiersPage(w, r, ladderID, "", tierRemovalRefusal(err)) return } defer tx.Rollback() qtx := entitlements.New(tx) bq := billing.New(tx) // Lock affected pools in deterministic order (concurrent per-pool commits // of the default-change flows lock single pools; sorted acquisition avoids // deadlocking against them), then re-derive the population from the locked // state — the preview was a projection. rows, err := qtx.ListLiveTierHoldersByLadderProduct(ctx, entitlements.ListLiveTierHoldersByLadderProductParams{ PlanLadderID: ladderID, ProductID: productID, }) if err != nil { h.Logger.Error("failed to list tier holders for removal", slog.Any("error", err)) h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to remove the tier; nothing was changed.") return } var pools []string seenPool := make(map[string]bool) for _, row := range rows { if !seenPool[row.PoolID] { seenPool[row.PoolID] = true pools = append(pools, row.PoolID) } } sort.Strings(pools) for _, poolID := range pools { if _, err := tx.ExecContext(ctx, "SELECT 1 FROM core.resource_pools WHERE pool_id = $1 FOR UPDATE", poolID); err != nil { h.renderPlanLadderTiersPage(w, r, ladderID, "", tierRemovalRefusal(err)) return } } rows, err = qtx.ListLiveTierHoldersByLadderProduct(ctx, entitlements.ListLiveTierHoldersByLadderProductParams{ PlanLadderID: ladderID, ProductID: productID, }) if err != nil { h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to remove the tier; nothing was changed.") return } type poolWork struct { incumbents []defaultIncumbent // default-sourced positions (disposition applies) aligns []string // provision IDs to align-shrink } work := make(map[string]*poolWork) defaultHolders := 0 for _, row := range rows { pw := work[row.PoolID] if pw == nil { pw = &poolWork{} work[row.PoolID] = pw } if row.GrantReason.Valid && row.GrantReason.String == "default" { defaultHolders++ pw.incumbents = append(pw.incumbents, defaultIncumbent{ ProvisionID: row.ProvisionID, GrantID: row.GrantID.UUID.String(), ProductID: row.ProductID, }) continue } pw.aligns = append(pw.aligns, row.ProvisionID) } // Validation gate: default-sourced holders require an explicit // disposition. Rejecting here writes nothing — the tier stays. if defaultHolders > 0 && disposition == "" { pending, _ := h.buildTierRemovalPreview(ctx, ladderID, productID) if pending == nil { h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to remove the tier; nothing was changed.") return } errs := forms.NewErrors() errs.Field("default_disposition", fmt.Sprintf( "Choose whether to keep or migrate the %d organization(s) that hold this tier through the org type default.", defaultHolders)) h.renderPlanLadderTiersRemovalRefusal(w, r, ladderID, pending, planLadderTierRemovalValues(pending.NeedsDisposition, disposition), errs) return } // Delete + renumber first: alignment matches the product's CURRENT shape, // so the shape must already be shrunk, and the floor restoration below // must resolve rank 0 against the promoted order. if err := bq.DeleteTier(ctx, billing.DeleteTierParams{PlanLadderID: ladderID, ProductID: productID}); err != nil { h.Logger.Error("failed to delete tier", slog.Any("error", err)) h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to remove the tier; nothing was changed.") return } remaining, err := bq.ListTiersByLadder(ctx, ladderID) if err != nil { h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to remove the tier; nothing was changed.") return } if len(remaining) > 0 { ordered := make([]string, len(remaining)) for i, t := range remaining { ordered[i] = t.ProductID } if err := renumberTiers(ctx, bq, ladderID, ordered); err != nil { h.Logger.Error("failed to renumber tiers after removal", slog.Any("error", err)) h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to remove the tier; nothing was changed.") return } } // Enact per pool: end incumbents first (migrate), then align every // remaining position of the product, then restore — so a pool that still // holds another live position after alignment is correctly not re-floored. ended, migrated := 0, 0 for _, poolID := range pools { pw := work[poolID] if pw == nil { continue } aligns := pw.aligns if len(pw.incumbents) > 0 && disposition == "migrate" { if err := endDefaultIncumbents(ctx, qtx, pw.incumbents, actor); err != nil { h.Logger.Error("tier removal: migrate end failed", slog.String("pool_id", poolID), slog.Any("error", err)) h.renderPlanLadderTiersPage(w, r, ladderID, "", tierRemovalRefusalOr(err, "Removal failed and nothing was changed: could not end a default-sourced position. Details are in the server logs.")) return } migrated++ } else { for _, inc := range pw.incumbents { aligns = append(aligns, inc.ProvisionID) // keep: align-shrink like any other holder } } for _, provisionID := range aligns { if _, err := qtx.AlignConferralShape(ctx, entitlements.AlignConferralShapeParams{ ProvisionID: provisionID, ActorType: "operator", ActorID: actor.ActorID, Reason: sql.NullString{String: actor.Reason, Valid: true}, }); err != nil { h.Logger.Error("tier removal: align failed", slog.String("provision_id", provisionID), slog.Any("error", err)) h.renderPlanLadderTiersPage(w, r, ladderID, "", tierRemovalRefusalOr(err, "Removal failed and nothing was changed: could not reconcile a holder's position. Details are in the server logs.")) return } ended++ } if len(pw.incumbents) > 0 && disposition == "migrate" { if _, err := entitlements.ReapplyDefaultsIfVacant(ctx, tx, poolID, actor); err != nil { h.Logger.Error("tier removal: migrate restore failed", slog.String("pool_id", poolID), slog.Any("error", err)) h.renderPlanLadderTiersPage(w, r, ladderID, "", tierRemovalRefusalOr(err, "Removal failed and nothing was changed: could not apply the current default. Details are in the server logs.")) return } } if err := entitlements.MaterializePoolEntitlements(ctx, qtx, poolID); err != nil { h.Logger.Error("tier removal: materialize failed", slog.String("pool_id", poolID), slog.Any("error", err)) h.renderPlanLadderTiersPage(w, r, ladderID, "", tierRemovalRefusalOr(err, "Removal failed and nothing was changed: could not materialize entitlements. Details are in the server logs.")) return } } if err := tx.Commit(); err != nil { h.Logger.Error("failed to commit tier removal tx", slog.Any("error", err)) h.renderPlanLadderTiersPage(w, r, ladderID, "", tierRemovalRefusal(err)) return } parts := []string{} if ended > 0 { parts = append(parts, fmt.Sprintf("%d position(s) on this ladder ended; delivery continues", ended)) } if migrated > 0 { parts = append(parts, fmt.Sprintf("%d organization(s) migrated to the current default", migrated)) } msg := fmt.Sprintf("%s removed from the ladder: %s.", productName, strings.Join(parts, "; ")) h.renderPlanLadderTiersPage(w, r, ladderID, msg, "") } func (h *OperatorPartialsHandler) renderPlanLaddersPage(w http.ResponseWriter, r *http.Request, success string, errMsg string) { fireSuccessToast(w, success) // The in-body banner alone is invisible when the mutation was triggered // from a scrolled-down row (maintainer 2026-08-23: a ladder delete gave // "no feedback"); the toast is the visible half. fireErrorToast(w, errMsg) data := h.loadPlanLaddersPageData(r, "", errMsg) h.Templates.Render(w, "operator_plan_ladders.html", data) } // loadPlanLaddersPageData hydrates the plan-ladders listing. Shared by the // legacy partial endpoint (renderPlanLaddersPage) and the new MPA page // handler (GetPlanLaddersPage). func (h *OperatorPartialsHandler) loadPlanLaddersPageData(r *http.Request, success string, errMsg string) PlanLaddersData { data := PlanLaddersData{ Success: success, Error: errMsg, } // A stale-detail GET redirects here with ?flash=missing; surface it as the // page banner (finding #53) without clobbering an explicit mutation-guard // errMsg. if data.Error == "" && r.URL.Query().Get("flash") == "missing" { data.Error = "That plan ladder no longer exists; it may have been deleted." } // ux-ia-naming 2.2: plan ladders opens map-first. Loaded independently of // (and before) the list below, mirroring finding #54's always-hydrate // rule — a failure in one section never blanks the other; each renders // its own error state. data.Topology = h.loadPlanTopologyData(r) // Finding #54: always hydrate the table and create form, even when a banner // is set, so an unrecognized error never wipes the whole management surface. ladders, err := h.BillingQ.ListPlanLadders(r.Context()) if err != nil { h.Logger.Error("failed to list plan ladders", slog.Any("error", err)) if data.Error == "" { data.Error = "Failed to load plan ladders" } return data } // design D8 / operator-list-scale: search over name, true-total paging. // No paged SQL query backs plan ladders (a small catalog list), so // filtering and windowing run in Go over the already-loaded set; the // per-ladder tier/attachment counts are then computed only for the rows // on the current page. params := ParseListParams(r, "") // Ladders has no status facet. filtered := ladders if params.Q != "" { q := strings.ToLower(params.Q) filtered = make([]billing.PlanLadder, 0, len(ladders)) for _, l := range ladders { if strings.Contains(strings.ToLower(l.Name), q) { filtered = append(filtered, l) } } } page, total, _ := FetchPage(¶ms, func(limit, offset int32) ([]billing.PlanLadder, int64, error) { lo := int(offset) if lo > len(filtered) { lo = len(filtered) } hi := lo + int(limit) if hi > len(filtered) { hi = len(filtered) } return filtered[lo:hi], int64(len(filtered)), nil }) data.Ladders = make([]PlanLadderViewModel, len(page)) for i, l := range page { tiers, _ := h.BillingQ.ListTiersByLadder(r.Context(), l.PlanLadderID) attachments, _ := h.EntitlementsQ.CountActiveAttachmentsByLadder(r.Context(), l.PlanLadderID) desc := "" if l.Description.Valid { desc = l.Description.String } data.Ladders[i] = PlanLadderViewModel{ PlanLadderID: l.PlanLadderID, Name: l.Name, Description: desc, IsActive: l.IsActive, TierCount: len(tiers), ActiveAttachmentCount: attachments, } } data.LaddersNav = ListNav{ BasePath: "/operator/plan-ladders", SearchPlaceholder: "Search ladders by name", Q: params.Q, Page: params.Page, Total: total, } return data } // renderPlanLadderTiersFormErrors re-renders the tiers page (with inline // add-tier + reorder-rank forms) with 422 + FieldErrors populated. The // load logic mirrors renderPlanLadderTiersPage's hydration since we need // to write the 422 header before any body bytes. // renderPlanLadderTierAddRefusal re-renders the composite at 422 with the // tier-add declaration in submission mode (design D9). func (h *OperatorPartialsHandler) renderPlanLadderTierAddRefusal(w http.ResponseWriter, r *http.Request, ladderID string, values forms.Values, errs *forms.Errors) { w.WriteHeader(http.StatusUnprocessableEntity) edit, ok := h.loadPlanLadderEditData(r, ladderID) if !ok { h.renderPlanLaddersPage(w, r, "", "Plan ladder not found") return } tiers := h.loadPlanLadderTiersData(r, ladderID) tiers.TierForm = planLadderTierAddFormView(ladderID, values, errs, tiers.AvailableProducts, h.ruleLessWarning(r.Context(), values.String("product_id"))) h.Templates.Render(w, "operator_plan_ladder_detail.html", PlanLadderDetailData{ Edit: edit, Tiers: tiers, }) } // renderPlanLadderEditRefusal re-renders the composite at 422 with the // ladder declaration in submission mode (design D9). func (h *OperatorPartialsHandler) renderPlanLadderEditRefusal(w http.ResponseWriter, r *http.Request, ladderID string, values forms.Values, errs *forms.Errors) { w.WriteHeader(http.StatusUnprocessableEntity) edit, ok := h.loadPlanLadderEditData(r, ladderID) if !ok { h.renderPlanLaddersPage(w, r, "", "Plan ladder not found") return } edit.Form = planLadderEditForm(ladderID, values, errs) h.Templates.Render(w, "operator_plan_ladder_detail.html", PlanLadderDetailData{ Edit: edit, Tiers: h.loadPlanLadderTiersData(r, ladderID), }) } // loadPlanLadderEditData hydrates the edit-form view model for one ladder. // ok=false means the ladder does not exist. func (h *OperatorPartialsHandler) loadPlanLadderEditData(r *http.Request, ladderID string) (PlanLadderEditData, bool) { data, found, err := h.loadPlanLadderEditDataResult(r, ladderID) return data, found && err == nil } // loadPlanLadderEditDataResult is loadPlanLadderEditData with the not-found vs. // transient-DB-error distinction preserved: found=false with err=nil means the // ladder does not exist; err != nil means the load itself failed. The full-page // GET (GetPlanLadderDetailPage) uses this to redirect on the former and return a // full-shell 500 on the latter, rather than conflating the two (finding #53). func (h *OperatorPartialsHandler) loadPlanLadderEditDataResult(r *http.Request, ladderID string) (PlanLadderEditData, bool, error) { ladder, err := h.BillingQ.GetPlanLadderByID(r.Context(), ladderID) if errors.Is(err, sql.ErrNoRows) { return PlanLadderEditData{}, false, nil } if err != nil { h.Logger.Error("failed to get plan ladder", slog.Any("error", err)) return PlanLadderEditData{}, false, err } desc := "" if ladder.Description.Valid { desc = ladder.Description.String } vm := PlanLadderViewModel{ PlanLadderID: ladder.PlanLadderID, Name: ladder.Name, Description: desc, IsActive: ladder.IsActive, Key: ladder.Key.String, } return PlanLadderEditData{ Ladder: vm, // The edit form is the ladder declaration bound to this record: // the same field list the create page renders unbound (design D5). Form: planLadderEditForm(ladderID, planLadderEditValues(vm), nil), }, true, nil } // GetPlanLadderDetailPage handles GET /operator/plan-ladders/{ladderID} — the // addressable, bookmarkable per-ladder composite. Renders the operator.html // shell with the edit form, tier/rank manager, and this ladder's structural // validation co-located on one URL. Unknown ladderID degrades to the ladders // browse page with an error. func (h *OperatorPartialsHandler) GetPlanLadderDetailPage(w http.ResponseWriter, r *http.Request) { ladderID, ok := h.pathUUID(w, r, "ladderID") if !ok { return } edit, found, err := h.loadPlanLadderEditDataResult(r, ladderID) if err != nil { // Transient load failure — keep the operator inside the shell (nav, CSS) // with a 500 instead of mislabeling it "not found" (finding #53). errPage := h.buildOperatorPageData(r) errPage.IAPosition = "catalog:plan-ladders" errPage.ActiveCapability = "plan-ladders" errPage.BodyTemplate = "operator_plan_ladders.html" errPage.BodyData = h.loadPlanLaddersPageData(r, "", "Could not load this plan ladder right now. Try again.") w.WriteHeader(http.StatusInternalServerError) h.Templates.Render(w, "operator.html", errPage) return } if !found { // Stale/unknown ID — normalize the URL back to the browse route and carry // a flash so the operator understands the bounce (finding #53). http.Redirect(w, r, "/operator/plan-ladders?flash=missing", http.StatusSeeOther) return } page := h.buildOperatorPageData(r) // design D20, round 2 ("a success toast, not a banner"): a fresh // ?flash=created landing shows a success toast. if r.URL.Query().Get("flash") == "created" { page.FlashSuccess = "Plan ladder created." } page.IAPosition = "catalog:plan-ladders:" + ladderID page.ActiveCapability = "plan-ladders" page.BodyTemplate = "operator_plan_ladder_detail.html" page.BodyData = PlanLadderDetailData{ Edit: edit, Tiers: h.loadPlanLadderTiersData(r, ladderID), } h.Templates.Render(w, "operator.html", page) } // renderPlanLadderDetailBody re-renders the composite ladder detail body into // #operator-body after an in-page mutation. The operator stays on // /operator/plan-ladders/{ladderID}; unknown ladderID degrades to the browse page. func (h *OperatorPartialsHandler) renderPlanLadderDetailBody(w http.ResponseWriter, r *http.Request, ladderID string, success string, errMsg string) { fireSuccessToast(w, success) fireErrorToast(w, errMsg) edit, ok := h.loadPlanLadderEditData(r, ladderID) if !ok { h.renderPlanLaddersPage(w, r, "", "Plan ladder not found") return } // Finding #41: set only the composite-level Error so the alert renders once // at page top; setting edit.Error too stacked an identical banner inside the // Edit card, visually blaming the wrong form. h.Templates.Render(w, "operator_plan_ladder_detail.html", PlanLadderDetailData{ Edit: edit, Tiers: h.loadPlanLadderTiersData(r, ladderID), Error: errMsg, }) } // renderPlanLadderEditPage re-renders the ladder composite in place. Retained as // a thin alias so the mutation call sites (UpdatePlanLadder) keep working; the // edit form now lives on the composite detail page. func (h *OperatorPartialsHandler) renderPlanLadderEditPage(w http.ResponseWriter, r *http.Request, ladderID string, success string, errMsg string) { h.renderPlanLadderDetailBody(w, r, ladderID, success, errMsg) } // renderPlanLadderTiersPage re-renders the ladder composite in place. Retained // as a thin alias so the tier mutation call sites (add/reorder/remove) keep // working; the tier manager now lives on the composite detail page. func (h *OperatorPartialsHandler) renderPlanLadderTiersPage(w http.ResponseWriter, r *http.Request, ladderID string, success string, errMsg string) { h.renderPlanLadderDetailBody(w, r, ladderID, success, errMsg) } // renderPlanLadderTiersPending re-renders the ladder composite with a pending // (uncommitted) tier reorder attached to the tier card: pending rows and ranks, // consequence panel, Commit/Discard. No toast — nothing has happened yet. A nil // pending falls back to the persisted order. func (h *OperatorPartialsHandler) renderPlanLadderTiersPending(w http.ResponseWriter, r *http.Request, ladderID string, pending *PendingTierReorder, errMsg string) { edit, ok := h.loadPlanLadderEditData(r, ladderID) if !ok { h.renderPlanLaddersPage(w, r, "", "Plan ladder not found") return } tiersData := h.loadPlanLadderTiersData(r, ladderID) tiersData.Pending = pending h.Templates.Render(w, "operator_plan_ladder_detail.html", PlanLadderDetailData{ Edit: edit, Tiers: tiersData, Error: errMsg, }) } // renderPlanLadderTiersReorderRefusal re-renders the composite at 422 with // the reorder preview's commit form in submission mode: the chosen // disposition carried back and the refusal under its field (design D9, // plan-ladder-management "A refused commit keeps the chosen dispositions"). func (h *OperatorPartialsHandler) renderPlanLadderTiersReorderRefusal(w http.ResponseWriter, r *http.Request, ladderID string, pending *PendingTierReorder, values forms.Values, errs *forms.Errors) { w.WriteHeader(http.StatusUnprocessableEntity) edit, ok := h.loadPlanLadderEditData(r, ladderID) if !ok { h.renderPlanLaddersPage(w, r, "", "Plan ladder not found") return } ladderURL, err := web.RouteURL("/operator/plan-ladders/{ladderID}", ladderID) if err != nil { ladderURL = "/operator/plan-ladders" } pending.Form = planLadderTierReorderPreview(ladderID, ladderURL, values, errs, h.Templates.Fragment("operator_plan_ladder_tier_reorder_message.html", pending)) tiersData := h.loadPlanLadderTiersData(r, ladderID) tiersData.Pending = pending h.Templates.Render(w, "operator_plan_ladder_detail.html", PlanLadderDetailData{ Edit: edit, Tiers: tiersData, }) } // loadPlanLadderTiersData hydrates the tier-management view model for one ladder: // the existing tiers (with active-attachment flags) and the products still // available to add as tiers. Best-effort — a query failure yields a partial // result rather than aborting, since this feeds the composite detail page where // the ladder's existence is already established. // orgTypesDefaultingToLadder returns the org types (key + display name) whose // configured default plan ladder is ladderID. New organizations of those types // are auto-provisioned onto this ladder's rank-0 tier; for any ladder that is // no org type's default, rank 0 is merely the base tier and provisions no one. // Best-effort: a query failure logs and returns nil (the badge simply doesn't // render; the reorder preview treats the ladder as no type's default) rather // than failing the whole tier view. func (h *OperatorPartialsHandler) orgTypesDefaultingToLadder(ctx context.Context, ladderID string) []orgTypeRef { lid, err := uuid.Parse(ladderID) if err != nil { return nil } orgTypes, err := h.OrgQ.ListOrgTypes(ctx) if err != nil { h.Logger.Error("failed to list org types for default-ladder badge", slog.Any("error", err)) return nil } var refs []orgTypeRef for _, ot := range orgTypes { if ot.DefaultPlanLadderID.Valid && ot.DefaultPlanLadderID.UUID == lid { refs = append(refs, orgTypeRef{OrgType: ot.OrgType, DisplayName: ot.DisplayName}) } } return refs } func (h *OperatorPartialsHandler) loadPlanLadderTiersData(r *http.Request, ladderID string) PlanLadderTiersData { data := PlanLadderTiersData{} if ladder, err := h.BillingQ.GetPlanLadderByID(r.Context(), ladderID); err == nil { data.Ladder = PlanLadderViewModel{ PlanLadderID: ladder.PlanLadderID, Name: ladder.Name, IsActive: ladder.IsActive, } } for _, ref := range h.orgTypesDefaultingToLadder(r.Context(), ladderID) { data.DefaultForOrgTypeNames = append(data.DefaultForOrgTypeNames, ref.DisplayName) } // Load tiers with product info tiers, err := h.BillingQ.ListTiersByLadderWithProducts(r.Context(), ladderID) if err != nil { h.Logger.Error("failed to list tiers", slog.Any("error", err)) } else { data.Tiers = make([]PlanLadderTierViewModel, len(tiers)) for i, t := range tiers { count, _ := h.EntitlementsQ.CountActiveAttachmentsByTier(r.Context(), entitlements.CountActiveAttachmentsByTierParams{ PlanLadderID: ladderID, ProductID: t.ProductID, }) data.Tiers[i] = PlanLadderTierViewModel{ PlanLadderID: ladderID, ProductID: t.ProductID, ProductName: t.ProductName, Rank: t.Rank, HasActiveAttachments: count > 0, ActiveAttachmentCount: count, } } // design D8: the tiers table renders ranks ascending (rank 0 first), // matching the topology grid; ListTiersByLadderWithProducts already // returns rank ASC, so no reordering is needed for display. } data.AvailableProducts = h.availableTierProducts(r.Context(), ladderID) // The panel's fresh (unrefused) state: closed, nothing chosen. A // refusal's caller (renderPlanLadderTierAddRefusal) overwrites this // with the submission bound back. data.TierForm = planLadderTierAddFormView(ladderID, forms.NewValues(), nil, data.AvailableProducts, "") return data } // availableTierProducts lists the products still available to add as a // tier of ladderID: any active, published product not already a tier of // this ladder (Doc 41: display_category never gates tier membership). // Shared by the composite's initial render and the tier-add form's option // set, so a submitted product id is checked against the same list the // control offered. func (h *OperatorPartialsHandler) availableTierProducts(ctx context.Context, ladderID string) []ProductOption { products, err := h.BillingQ.ListActiveProducts(ctx) if err != nil { h.Logger.Error("failed to list products", slog.Any("error", err)) return nil } existingTiers, _ := h.BillingQ.ListTiersByLadder(ctx, ladderID) existingProductIDs := make(map[string]bool, len(existingTiers)) for _, t := range existingTiers { existingProductIDs[t.ProductID] = true } var out []ProductOption for _, p := range products { if p.LifecycleStatus != "published" { continue // skip non-published products } if existingProductIDs[p.ProductID] { continue // skip already-attached products } out = append(out, ProductOption{ProductID: p.ProductID, Name: p.Name}) } return out } // The structural-invariant validation view that once lived here (orphan // products, multi-active attachments, malformed rank sequences) was retired // in acceptance-fixes round 2 (design D8, plan-ladder-management "Structural // invariant validation view"): the one check tied to a real database // guarantee — a pool holding two active positions on one ladder — is // impossible under the exclusion constraint, so its query stays unexposed as // entitlements.CountMultiActiveLadderAttachments // (internal/entitlements/queries/pool_provision_ladders.sql) instead of // backing a page or health strip here. // tierRemovalRefusal is the copy for a tier removal that did not commit: a // lock wait past the timeout or a broken deadlock is a change in progress // and retryable; anything else is the plain failure. func tierRemovalRefusal(err error) string { return tierRemovalRefusalOr(err, "Failed to remove the tier; nothing was changed.") } // tierRemovalRefusalOr keeps the caller's message unless the error is lock // contention, which renders as the retryable refusal. func tierRemovalRefusalOr(err error, message string) string { if entitlements.IsLockContention(err) { return "Another entitlement set change is in progress. Try again." } return message }