package server import ( "context" "database/sql" "errors" "fmt" "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/web" ) // PlanLadderViewModel represents a plan ladder for operator template rendering. type PlanLadderViewModel struct { PlanLadderID string LadderKey string Name string Description string IsActive bool TierCount int ActiveAttachmentCount int64 } // 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. type PlanLaddersData struct { Ladders []PlanLadderViewModel FieldErrors web.FieldErrors Success string Error string } // PlanLadderEditData holds data for the plan ladder edit form. type PlanLadderEditData struct { Ladder PlanLadderViewModel FieldErrors web.FieldErrors 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 FieldErrors web.FieldErrors 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 } // 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 TypePreviews []TierReorderTypePreview NeedsDisposition bool // some type's outgoing-default bucket is non-empty } // PlanLadderDetailData is the body data for the addressable per-ladder composite // page (operator_plan_ladder_detail.html). It co-locates the edit form, the // tier/rank manager, and this ladder's structural validation on one URL; the // composite template renders the edit and tiers sub-sections via their existing // partials. type PlanLadderDetailData struct { Edit PlanLadderEditData Tiers PlanLadderTiersData Validation PlanLadderValidationData Error string } // PlanLadderValidationData holds data for the structural invariant validation view. type PlanLadderValidationData struct { OrphanProducts []ProductOption MultiActivePools []MultiActivePoolViewModel MalformedRankLadders []MalformedRankLadderViewModel Success string Error string } // MalformedRankLadderViewModel is a ladder whose tier ranks are not // 0-based contiguous (0..N-1). The schema only enforces per-ladder rank // uniqueness, but rank 0 is load-bearing — org-type default resolution // joins on rank = 0 and the member catalog branches on it — and every UI // authoring path (append at MAX+1, reorder/removal renumber) maintains // 0..N-1, so a gap or nonzero base means data written outside the UI. type MalformedRankLadderViewModel struct { LadderID string Name string MinRank int64 MaxRank int64 TierCount int64 } // MultiActivePoolViewModel represents a pool with multiple active attachments on the same ladder. type MultiActivePoolViewModel struct { PoolID string LadderID string LadderKey string ActiveCount int64 } // CreatePlanLadder handles POST /partials/operator/plan-ladders func (h *OperatorPartialsHandler) CreatePlanLadder(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { h.renderPlanLaddersPage(w, r, "", "Invalid request") return } ladderKey := r.FormValue("ladder_key") name := r.FormValue("name") description := r.FormValue("description") errs := web.New() if ladderKey == "" { errs.Set("ladder_key", "Slug is required.") } if name == "" { errs.Set("name", "Display name is required.") } if errs.Any() { h.renderPlanLaddersFormErrors(w, r, errs) return } _, err := h.BillingQ.CreatePlanLadder(r.Context(), billing.CreatePlanLadderParams{ LadderKey: ladderKey, Name: name, Description: sql.NullString{String: description, Valid: description != ""}, IsActive: true, }) if err != nil { if fe, ok := web.FieldErrorsFromDB(err, web.ConstraintMessages{ "plan_ladders_ladder_key_key": {Field: "ladder_key", Message: "A ladder with this key already exists — pick a different key."}, }); ok { h.renderPlanLaddersFormErrors(w, r, fe) return } h.Logger.Error("failed to create plan ladder", slog.Any("error", err)) h.renderPlanLaddersPage(w, r, "", "Failed to create 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 created successfully.", "") } // UpdatePlanLadder handles PUT /partials/operator/plan-ladders/{ladderID} func (h *OperatorPartialsHandler) UpdatePlanLadder(w http.ResponseWriter, r *http.Request) { ladderID := r.PathValue("ladderID") if err := r.ParseForm(); err != nil { h.renderPlanLadderEditPage(w, r, ladderID, "", "Invalid request") return } name := r.FormValue("name") description := r.FormValue("description") errs := web.New() if name == "" { errs.Set("name", "Display name is required.") } if errs.Any() { h.renderPlanLadderEditFormErrors(w, r, ladderID, errs) return } // 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)) h.renderPlanLadderEditPage(w, r, ladderID, "", "Plan ladder not found.") 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 { h.Logger.Error("failed to update plan ladder", slog.Any("error", err)) h.renderPlanLadderEditPage(w, r, ladderID, "", "Failed to update plan ladder — see server logs.") 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 attachments") return } if count > 0 { h.renderPlanLaddersPage(w, r, "", "Cannot delete ladder: "+strconv.FormatInt(count, 10)+" active attachment(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 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 } if err := r.ParseForm(); err != nil { h.renderPlanLadderTiersPage(w, r, ladderID, "", "Invalid request") return } productID := r.FormValue("product_id") errs := web.New() if productID == "" { errs.Set("product_id", "Product is required.") } if errs.Any() { h.renderPlanLadderTiersFormErrors(w, r, ladderID, errs) return } // 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.Set("product_id", "Product not found.") h.renderPlanLadderTiersFormErrors(w, r, ladderID, errs) return } if product.LifecycleStatus != "published" { errs.Set("product_id", "Only published products can be tiers. This product is "+product.LifecycleStatus+".") h.renderPlanLadderTiersFormErrors(w, r, ladderID, 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 := h.Database.BeginTx(ctx, 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 { h.renderPlanLadderTiersFormErrors(w, r, ladderID, fe) 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.Set("product_id", "Cannot add this tier — a rung is already held: "+err.Error()) h.renderPlanLadderTiersFormErrors(w, r, ladderID, 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. 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, } } if pending.RankZeroChanged { 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 } } } 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 } pending, errMsg := h.buildPendingTierReorder(r.Context(), ladderID, r.Form["product"]) 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 repeated `product` form values 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 } if err := r.ParseForm(); err != nil { h.renderPlanLadderTiersPage(w, r, ladderID, "", "Invalid reorder request.") return } disposition := r.FormValue("bucket2_disposition") if disposition != "" && disposition != "grandfather" && disposition != "migrate" { h.renderPlanLadderTiersPage(w, r, ladderID, "", "Invalid disposition for organizations on the outgoing default.") return } ctx := r.Context() pending, errMsg := h.buildPendingTierReorder(ctx, ladderID, r.Form["product"]) 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. if pending.NeedsDisposition && disposition == "" { outgoing := 0 for _, tp := range pending.TypePreviews { outgoing += len(tp.Preview.OutgoingDefault) } h.renderPlanLadderTiersPending(w, r, ladderID, pending, fmt.Sprintf("%d organization(s) hold the outgoing rank-0 default. Choose whether to grandfather or migrate them before committing.", outgoing)) return } tx, err := h.Database.BeginTx(ctx, 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. Please 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). ladderKey := ladderID if ladder, err := h.BillingQ.GetPlanLadderByID(ctx, ladderID); err == nil { ladderKey = ladder.LadderKey } actor := entitlements.Actor{ ActorType: "operator", ActorID: uuid.NullUUID{UUID: uuid.MustParse(session.PersonID), Valid: true}, Reason: fmt.Sprintf("plan-ladder tier reorder (%s)", ladderKey), } 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) — %s", f.OrgName, f.OrgSlug, 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 attachments") 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 is the ladder's only remaining tier, and "+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 := h.Database.BeginTx(ctx, 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, Slug: row.Slug} 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 for _, ref := range h.orgTypesDefaultingToLadder(ctx, ladderID) { pending.PromotedForTypes = append(pending.PromotedForTypes, ref.DisplayName) } } 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, Validation: h.loadLadderScopedValidation(r.Context(), ladderID), Error: errMsg, }) } // 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 } if err := r.ParseForm(); err != nil { h.renderPlanLadderTiersPage(w, r, ladderID, "", "Invalid removal request.") return } disposition := r.FormValue("default_disposition") if disposition != "" && disposition != "keep" && disposition != "migrate" { h.renderPlanLadderTiersPage(w, r, ladderID, "", "Invalid disposition for default-sourced holders.") return } ctx := r.Context() productName := productID if prod, err := h.BillingQ.GetProductByID(ctx, productID); err == nil { productName = prod.Name } ladderKey := ladderID if ladder, err := h.BillingQ.GetPlanLadderByID(ctx, ladderID); err == nil { ladderKey = ladder.LadderKey } actor := entitlements.Actor{ ActorType: "operator", ActorID: uuid.NullUUID{UUID: uuid.MustParse(session.PersonID), Valid: true}, Reason: fmt.Sprintf("tier removal (%s from %s)", productName, ladderKey), } tx, err := h.Database.BeginTx(ctx, nil) if err != nil { h.Logger.Error("failed to begin tier removal tx", slog.Any("error", err)) h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to remove the tier — nothing was changed.") 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, "", "Failed to remove the tier — nothing was changed.") 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 } h.renderPlanLadderTiersRemoval(w, r, ladderID, pending, fmt.Sprintf( "%d organization(s) hold this tier through the org type default. Choose whether to keep or migrate them before committing.", defaultHolders)) 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, "", "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, "", "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, "", "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, "", "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, "", "Failed to remove the tier — nothing was changed.") 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, "") } // GetPlanLadderValidation handles GET /partials/operator/plan-ladders/validation func (h *OperatorPartialsHandler) GetPlanLadderValidation(w http.ResponseWriter, r *http.Request) { h.renderPlanLadderValidationPage(w, r, "", "") } // GetPlanLadderValidationPage handles GET /operator/plan-ladders/validation — // the addressable, bookmarkable full-page structural-validation view, rendered // inside the operator.html shell. The ladders-list "Validate Structure" and // topology "View detail" links point here (a boosted full-page nav) so a new // tab or refresh lands on a styled, addressable page instead of the shell-less // fragment the bare partial endpoint returns (audit finding #43). func (h *OperatorPartialsHandler) GetPlanLadderValidationPage(w http.ResponseWriter, r *http.Request) { page := h.buildOperatorPageData(r) page.IAPosition = "catalog:plan-ladders:validation" page.ActiveCapability = "plan-ladders" page.BodyTemplate = "operator_plan_ladder_validation.html" page.BodyData = h.computePlanLadderValidation(r.Context()) h.Templates.Render(w, "operator.html", page) } func (h *OperatorPartialsHandler) renderPlanLaddersPage(w http.ResponseWriter, r *http.Request, success string, errMsg string) { fireSuccessToast(w, success) 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." } // 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 } data.Ladders = make([]PlanLadderViewModel, len(ladders)) for i, l := range ladders { 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, LadderKey: l.LadderKey, Name: l.Name, Description: desc, IsActive: l.IsActive, TierCount: len(tiers), ActiveAttachmentCount: attachments, } } return data } // renderPlanLaddersFormErrors re-renders the ladders list page (with inline // create-ladder form) with 422 + FieldErrors populated. func (h *OperatorPartialsHandler) renderPlanLaddersFormErrors(w http.ResponseWriter, r *http.Request, errs web.FieldErrors) { w.WriteHeader(http.StatusUnprocessableEntity) data := h.loadPlanLaddersPageData(r, "", "") data.FieldErrors = errs h.Templates.Render(w, "operator_plan_ladders.html", 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. func (h *OperatorPartialsHandler) renderPlanLadderTiersFormErrors(w http.ResponseWriter, r *http.Request, ladderID string, errs web.FieldErrors) { 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.FieldErrors = errs h.Templates.Render(w, "operator_plan_ladder_detail.html", PlanLadderDetailData{ Edit: edit, Tiers: tiers, Validation: h.loadLadderScopedValidation(r.Context(), ladderID), }) } // renderPlanLadderEditFormErrors re-renders the edit form with 422 + // FieldErrors populated. Convention per docs/operator-ux-conventions.md §6+§8. func (h *OperatorPartialsHandler) renderPlanLadderEditFormErrors(w http.ResponseWriter, r *http.Request, ladderID string, errs web.FieldErrors) { w.WriteHeader(http.StatusUnprocessableEntity) edit, ok := h.loadPlanLadderEditData(r, ladderID) if !ok { h.renderPlanLaddersPage(w, r, "", "Plan ladder not found") return } edit.FieldErrors = errs h.Templates.Render(w, "operator_plan_ladder_detail.html", PlanLadderDetailData{ Edit: edit, Tiers: h.loadPlanLadderTiersData(r, ladderID), Validation: h.loadLadderScopedValidation(r.Context(), 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 } return PlanLadderEditData{ Ladder: PlanLadderViewModel{ PlanLadderID: ladder.PlanLadderID, LadderKey: ladder.LadderKey, Name: ladder.Name, Description: desc, IsActive: ladder.IsActive, }, }, 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 := r.PathValue("ladderID") 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. Please 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) 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), Validation: h.loadLadderScopedValidation(r.Context(), 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) 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), Validation: h.loadLadderScopedValidation(r.Context(), 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) } // loadLadderScopedValidation filters the global structural-invariant checks to // the violations that concern this one ladder (non-plan tiers attached here, and // any multi-active pool attachments on this ladder). Orphan products are global // (they belong to no ladder), so they are intentionally excluded from a ladder // page. Reuses computePlanLadderValidation so the checks never diverge. func (h *OperatorPartialsHandler) loadLadderScopedValidation(ctx context.Context, ladderID string) PlanLadderValidationData { all := h.computePlanLadderValidation(ctx) scoped := PlanLadderValidationData{} for _, p := range all.MultiActivePools { if p.LadderID == ladderID { scoped.MultiActivePools = append(scoped.MultiActivePools, p) } } return scoped } // 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, Validation: h.loadLadderScopedValidation(r.Context(), ladderID), Error: errMsg, }) } // 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, LadderKey: ladder.LadderKey, 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, } } } // Load available products: any active, published product not already in this // ladder (Doc 41: display_category never gates tier membership). products, err := h.BillingQ.ListActiveProducts(r.Context()) if err != nil { h.Logger.Error("failed to list products", slog.Any("error", err)) } else { existingTiers, _ := h.BillingQ.ListTiersByLadder(r.Context(), ladderID) existingProductIDs := make(map[string]bool) for _, t := range existingTiers { existingProductIDs[t.ProductID] = true } for _, p := range products { if p.LifecycleStatus != "published" { continue // skip non-published products } if existingProductIDs[p.ProductID] { continue // skip already-attached products } data.AvailableProducts = append(data.AvailableProducts, ProductOption{ ProductID: p.ProductID, Name: p.Name, }) } } return data } func (h *OperatorPartialsHandler) renderPlanLadderValidationPage(w http.ResponseWriter, r *http.Request, success string, errMsg string) { fireSuccessToast(w, success) if errMsg != "" { h.Templates.Render(w, "operator_plan_ladder_validation.html", PlanLadderValidationData{Error: errMsg}) return } h.Templates.Render(w, "operator_plan_ladder_validation.html", h.computePlanLadderValidation(r.Context())) } // computePlanLadderValidation runs the three structural-invariant checks // (orphan plan products, non-plan tiers, multi-active attachments). It is the // single source of truth shared by the validation page // (renderPlanLadderValidationPage) and the topology overview's health strip // (loadPlanTopologyData), so the two never disagree. func (h *OperatorPartialsHandler) computePlanLadderValidation(ctx context.Context) PlanLadderValidationData { var data PlanLadderValidationData // Orphan products: published products on no ladder. Doc 41 dissolved the // plan/non-plan distinction (display_category is presentation-only), so this // is now purely informational — an off-ladder product is legitimate. products, err := h.BillingQ.ListAllProducts(ctx) if err != nil { h.Logger.Error("failed to list products", slog.Any("error", err)) } else { for _, p := range products { if p.LifecycleStatus == "published" { ladders, _ := h.BillingQ.ListLaddersByProduct(ctx, p.ProductID) if len(ladders) == 0 { data.OrphanProducts = append(data.OrphanProducts, ProductOption{ ProductID: p.ProductID, Name: p.Name, }) } } } } // Multi-active attachments: pools with multiple active attachments on the // same ladder. Impossible under the GiST exclusion, checked defensively. rows, err := h.Database.QueryContext(ctx, ` SELECT pool_id, plan_ladder_id, COUNT(*) as cnt FROM core.pool_provision_ladders WHERE status = 'active' GROUP BY pool_id, plan_ladder_id HAVING COUNT(*) > 1 `) if err != nil { h.Logger.Error("failed to query multi-active attachments", slog.Any("error", err)) } else { defer rows.Close() for rows.Next() { var poolID, planLadderID string var cnt int64 if err := rows.Scan(&poolID, &planLadderID, &cnt); err == nil { ladder, _ := h.BillingQ.GetPlanLadderByID(ctx, planLadderID) data.MultiActivePools = append(data.MultiActivePools, MultiActivePoolViewModel{ PoolID: poolID, LadderID: planLadderID, LadderKey: ladder.LadderKey, ActiveCount: cnt, }) } } rows.Close() } // Malformed rank sequences: ladders whose tier ranks are not 0-based // contiguous. Impossible via the UI (append at MAX+1, reorder/removal // renumber); rank 0 is load-bearing (org-type default resolution joins // on rank = 0, member catalog branches on it), so surface any drift // here instead of letting a defaultless ladder read as mysteriously // broken elsewhere (2026-07-23 finding). rankRows, err := h.Database.QueryContext(ctx, ` SELECT l.plan_ladder_id, l.name, MIN(t.rank), MAX(t.rank), COUNT(*) FROM core.plan_ladder_tiers t JOIN core.plan_ladders l ON l.plan_ladder_id = t.plan_ladder_id GROUP BY l.plan_ladder_id, l.name HAVING MIN(t.rank) <> 0 OR MAX(t.rank) <> COUNT(*) - 1 ORDER BY l.name, l.plan_ladder_id `) if err != nil { h.Logger.Error("failed to query malformed rank ladders", slog.Any("error", err)) } else { defer rankRows.Close() for rankRows.Next() { var vm MalformedRankLadderViewModel if err := rankRows.Scan(&vm.LadderID, &vm.Name, &vm.MinRank, &vm.MaxRank, &vm.TierCount); err == nil { data.MalformedRankLadders = append(data.MalformedRankLadders, vm) } } rankRows.Close() } return data }