// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial // SPDX-FileCopyrightText: 2025-2026 Christian Galo package server import ( "database/sql" "errors" "fmt" "log/slog" "net/http" "net/url" "sort" "strconv" "strings" "time" "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" wf "git.coopcloud.tech/wiki-cafe/member-console/internal/workflows/entitlements" "git.coopcloud.tech/wiki-cafe/member-console/internal/workflows/queues" "github.com/google/uuid" "go.temporal.io/sdk/client" ) // maxGrantQuantity caps the CreateNonPlanGrant quantity field. Well under // int32's range so validation rejects the input before the int32(q) cast // can wrap or go negative (finding #31); also just a sane ceiling for a // per-org addon/usage/one-time quantity. const maxGrantQuantity = 1_000_000 // enrollmentListCap bounds the composite's Members list to its most // recent rows (maintainer 2026-08-23; the grants ledger and Tier changes // graduated from this cap to the governed-list pager in the 2026-08-24 // design round). The composite is a summary, not a report, so the list // only needs enough rows to answer "who is in this org right now". const enrollmentListCap = 16 // capEnrollmentList truncates items to the most recent enrollmentListCap // entries, returning the (possibly unchanged) slice, the pre-truncation // count, and whether truncation happened. The template renders the honest // "Showing the latest 16 of N" line only when wasCapped is true, so a // caller must pass items already ordered most-recent-first (see the // ordering note on each call site below -- callers whose backing query // sorts oldest-first must reverse or re-sort before calling this). func capEnrollmentList[T any](items []T) (capped []T, total int, wasCapped bool) { total = len(items) if total <= enrollmentListCap { return items, total, false } return items[:enrollmentListCap], total, true } // pageSliceClamped windows an already-loaded, already-ordered slice to // the params' page (operatorListPageSize rows), clamping a past-the-end // page back to 1 like FetchPage does for SQL loaders. The composite's // embedded lists are org-scoped and modest, so Go-side windowing over the // full set is simpler than teaching each backing query LIMIT/OFFSET. func pageSliceClamped[T any](items []T, p *ListParams) (window []T, total int) { total = len(items) start := int(p.Offset()) if start >= total && p.Page > 1 { p.Page = 1 start = 0 } end := start + int(p.Limit()) if end > total { end = total } if start > total { start = total } return items[start:end], total } // siblingListState collects the request's query params EXCEPT the given // list's own (prefix q/page and, when named, its facet param), for // ListNav.Extra: an embedded list's links then preserve its sibling // lists' state on the shared org-detail URL. func siblingListState(r *http.Request, ownPrefix string, ownFacetParams ...string) url.Values { own := map[string]bool{ownPrefix + "q": true, ownPrefix + "page": true, ownPrefix + "per": true} for _, f := range ownFacetParams { own[f] = true } v := url.Values{} for k, vals := range r.URL.Query() { if own[k] { continue } for _, val := range vals { if val != "" { v.Add(k, val) } } } return v } // poolMissingMessage is the breakage reason shown both where the composite // blocks the Issue Grant form (loadOrgEnrollmentData / the template) and // where IssueGrant refuses a POST that reaches the handler anyway // (ux-honest-surfaces: "a pool-less organization is presented as broken, // not empty" — one wording, so the block and its stated reason can never // drift apart). const poolMissingMessage = "This organization is missing its default resource pool. Every organization should have exactly one; without it, grant delivery is impossible until the pool is repaired." // grantConstraints maps the DB constraints a grant INSERT/UPDATE can hit to // the fields on the composite's grant forms, so violations render as friendly // field-level errors instead of leaking raw driver text (per // docs/operator-ux-conventions.md §4/§6 — constraint names live next to the // forms they belong to). Constraints an operator cannot plausibly trigger are // left to web.FieldErrorsFromDB's per-class fallback. var grantConstraints = web.ConstraintMessages{ "chk_grants_recipient": {Field: "", Message: "A grant must name exactly one recipient."}, "chk_grants_no_self_extend": {Field: "", Message: "A grant cannot extend itself."}, "chk_grants_reason_domain": {Field: "reason", Message: "Choose a valid reason."}, "chk_grants_default_iff_system_authored": {Field: "reason", Message: "The 'default' reason is reserved for system-authored grants."}, "grants_product_id_fkey": {Field: "product_id", Message: "The selected product no longer exists. Refresh the page and choose another."}, } // fieldErrorsBanner flattens translated FieldErrors into a single banner // message for actions without field-level rendering (the revoke buttons have // no form to attach a 422 FieldErrors re-render to). Prefers the form-level // "" message, else any field message — the translator sets exactly one entry. func fieldErrorsBanner(fe web.FieldErrors) string { if msg := fe.Get(""); msg != "" { return msg } for _, msg := range fe { return msg } return "The change was rejected by a data constraint." } // parseGrantValidUntilValue reads the grant forms' shared "valid_until" // field (forms.GrantValidUntil): raw is the value forms.Values carried // (Values.String("valid_until"), never r.FormValue, so the query string // cannot shadow it), and offsetRaw is the companion "valid_until_offset" // parameter, read directly off r.PostForm since it is not itself a // declared field (a JS-injected companion, the way the CSRF token is; // design D6 "Undeclared keys are ignored" by Parse, which is exactly why // this reads it separately). The datetime-local input is a naive // wall-clock time. When the browser supplies valid_until_offset // (Date.getTimezoneOffset() for the picked date, via // static/grant-valid-until-tz.js) the instant is resolved exactly // regardless of the server's timezone; otherwise the server's local zone // is assumed. A time in the past is rejected. Returns a zero NullTime and // "" when the field is empty, or a user-facing message on error. func parseGrantValidUntilValue(raw, offsetRaw string) (sql.NullTime, string) { if raw == "" { return sql.NullTime{}, "" } var ( t time.Time err error ) if offsetRaw != "" { if mins, aerr := strconv.Atoi(offsetRaw); aerr == nil { // getTimezoneOffset() is minutes local is behind UTC: parse the naive // value as UTC, then add the offset to recover the true instant. if t, err = time.Parse("2006-01-02T15:04", raw); err == nil { t = t.Add(time.Duration(mins) * time.Minute) } } else { t, err = time.ParseInLocation("2006-01-02T15:04", raw, time.Local) } } else { t, err = time.ParseInLocation("2006-01-02T15:04", raw, time.Local) } if err != nil { return sql.NullTime{}, "Use the date picker (YYYY-MM-DD HH:MM)." } if t.Before(time.Now()) { return sql.NullTime{}, "Choose a time in the future." } return sql.NullTime{Time: t, Valid: true}, "" } // PoolRungViewModel is one ladder placement of a delivery: which ladder, // at what rank, since when. Rendered as a badge pill under the delivery's // product line. type PoolRungViewModel struct { LadderName string Rank int32 ActivatedAt string } // PoolDeliveryViewModel is one delivery (provision) a pool holds, // grouped: the product renders ONCE with the single Extend control, and // its ladder placements render as sub-pills (maintainer design round // 2026-08-24: the delivery is the act-on unit; rungs are facts about it. // This replaced one-line-per-position, which showed a shared product N // times and needed a "Same delivery" disambiguation marker). type PoolDeliveryViewModel struct { ProvisionID string ProductName string ProductID string // GrantBacked is the precondition for extending: a subscription-backed // delivery renders its Extend control disabled with the reason. GrantBacked bool Rungs []PoolRungViewModel // ExtendForm is this delivery's Extend panel, rendered through // operator.enrollment.grant.extend with Binding.Instance set to // ProvisionID so a page with several open panels carries no duplicate // DOM id (design D10; findings FA-19, FA-28, FA-29, FA-32). Set only // when GrantBacked. ExtendForm forms.FormView } // ExtendControl builds the disabled "Extend tier" control and its reason // for a delivery that is not grant-backed (design D10, ACC-8: the one // shared disabled-control idiom, `ui_disabled_control.html`, instead of a // hand-rolled `` wrapper). Call only from the // not-GrantBacked branch; the GrantBacked branch renders its own enabled // button. ID is scoped by ProvisionID so a page with several deliveries // never collides on the aria-describedby target. func (d PoolDeliveryViewModel) ExtendControl() DisabledControl { return NewDisabledControl( "extend-reason-"+d.ProvisionID, "Extend tier", // The weight the enabled opener carries (design D19, round 4: // panel openers are tertiary), so enabling one never changes it. "btn btn-outline-secondary btn-sm", "Nothing to extend: this delivery is not grant-backed (it may be subscription-billed). Use Issue grant instead.", ) } // PoolEnrollmentViewModel represents a pool's current enrollment state. type PoolEnrollmentViewModel struct { PoolID string PoolName string PoolType string // Status is the pool's core.resource_pools.status value ("active" as // built today — invariant 10, resource-pools card — but rendered // honestly rather than assumed, so a future non-active pool is visibly // distinct instead of silently indistinguishable from a healthy one). Status string // Deliveries lists every delivery (provision) this pool holds, each // grouping its ladder placements. One Extend control and one panel per // delivery; a shared product (a tier on several ladders) is ONE // delivery with several rung pills. Deliveries []PoolDeliveryViewModel // Usage lists this pool's per-resource-key usage counters (used vs // limit), so a member-facing quota refusal is diagnosable from the // console (ux-honest-surfaces: "pool status and usage are visible on // the organization view"). Usage []PoolUsageViewModel HasAttachment bool } // PoolUsageViewModel is one per-resource usage counter row on the // org-detail pools panel. type PoolUsageViewModel struct { ResourceKey string Used int64 Limit int64 } // TransitionHistoryViewModel is one row of the Tier changes table // (formerly "Position history"; maintainer design round 2026-08-24: // operators cannot decode "position" or integer ranks, so rows carry the // ladder key, a humanized verb, and tier NAMES resolved from the ladder's // current shape, with "rank N" as the fallback when a historical rank no // longer exists on the ladder). type TransitionHistoryViewModel struct { TransitionID string // TransitionType is the raw transition_type enum value; ChangeBadge // maps it through the badge map (Started / Upgraded / ...). TransitionType string LadderName string // ChangeLabel is the humanized transition_type (Started / Upgraded / // Downgraded / Transferred / Ended); ChangeTooltip explains the one // verb that needs it (Transferred). ChangeLabel string ChangeTooltip string // ChangeKind overrides the badge kind when the verb is not the // transition_type's own: the transfer an extension causes renders the // "extension" kind (Extended). Empty means the type's kind. ChangeKind string // FromLabel / ToLabel are tier names (best effort), "rank N" when the // rank is not on the ladder's current shape, or the empty-value marker // for the side a Started/Ended row does not have. FromLabel string ToLabel string ActorType string ActorName string // ReasonLabel is the Reason cell's body: a grant-backed row's grant // reason badge label (Manual, Evaluation, ...), or D3's sentence for // the machine reason a writer other than a grant produced, raw when // the map does not know it. ReasonNote is the grant's description, // empty for a non-grant-backed row. ReasonRaw is the transition's own // reason string, always, empty when NULL; it rides as the cell's // tooltip so an operator who needs the grant id or the ladder id can // still read it (design D3, D4). ReasonLabel string ReasonNote string ReasonRaw string EffectiveAt string } // foldSupersessions drops an `end` row exactly when another row in rows (a // single pool's transitions, per ListTransitionsByPool) is core.confer()'s // own successor entry for the same act (design D2): a typed row (initiate, // upgrade, downgrade, transfer) with a non-NULL from_rank, the same // effective_at, the same actor, and the same reason. That pair is one act // at one instant; the typed row already states both sides, so the // incumbent's end is redundant. An expiry's or revocation's restorative // `initiate` has from_rank NULL and a different reason ("... default // restoration"), so it never matches and both of its rows stay. No row is // synthesized and no from side is invented. func foldSupersessions(rows []entitlements.ListTransitionsByPoolRow) []entitlements.ListTransitionsByPoolRow { isSuccessor := func(row entitlements.ListTransitionsByPoolRow) bool { switch row.TransitionType { case "initiate", "upgrade", "downgrade", "transfer": return row.FromRank.Valid default: return false } } folded := make([]entitlements.ListTransitionsByPoolRow, 0, len(rows)) for _, row := range rows { if row.TransitionType != "end" { folded = append(folded, row) continue } hidden := false for _, other := range rows { if other.TransitionID == row.TransitionID { continue } if !isSuccessor(other) { continue } if !other.EffectiveAt.Equal(row.EffectiveAt) { continue } if other.ActorType != row.ActorType || other.ActorID != row.ActorID || other.Reason != row.Reason { continue } hidden = true break } if !hidden { folded = append(folded, row) } } return folded } // humanizeTransitionReason renders a machine-written // pool_provision_transitions.reason value as the sentence design D3 // assigns it, matched by prefix where the writer appends an identifier or // a label. known is false for a reason the map does not recognize; callers // then fall back to the raw string (D3: "a reason the map does not know // renders raw"). func humanizeTransitionReason(raw string) (label string, known bool) { switch { case raw == "operator_revocation": return "Revoked by an operator", true case raw == entitlements.ExtensionTransitionReason: return "Extended by an operator", true case raw == "post-revocation default restoration": return "Default restored after a revocation", true case raw == "post-cancellation default restoration": return "Default restored after a subscription ended", true case raw == "auto-provisioning on org creation": return "Default on organization creation", true case raw == "demoseed: floor personal pool at org-type default": return "Seeded default", true case strings.HasPrefix(raw, "grant-expiration:") && strings.HasSuffix(raw, " default restoration"): return "Default restored after a grant expired", true case strings.HasPrefix(raw, "grant-expiration:"): return "Grant expired", true case strings.HasPrefix(raw, "org-type default change ("): return "Org-type default changed", true case strings.HasPrefix(raw, "tier added to ladder "): return "Tier added to the ladder", true case strings.HasPrefix(raw, "plan-ladder tier reorder ("): return "Ladder tiers reordered", true case strings.HasPrefix(raw, "tier removal ("): return "Tier removed from the ladder", true default: return raw, false } } // transitionChangeLabels maps the transition_type enum to operator // vocabulary. transfer gets a tooltip: same rank, different funding // source (model card invariant: callers never choose the type; confer // derives it from ranks), except the transfer an extension causes, which // is labelled Extended and needs no tooltip. var transitionChangeLabels = map[string]string{ "initiate": "Started", "upgrade": "Upgraded", "downgrade": "Downgraded", "transfer": "Transferred", "end": "Ended", } // TierOption represents a selectable tier for force-transition. type TierOption struct { LadderID string ProductID string ProductName string Rank int32 } // grantReasonDomain is the operator-selectable subset of grants.grant_reason // (the 'default' value is system-authored only and never offered here; the // 'trial' value is retired in favour of 'evaluation' plus valid_until, // plan-enrollment-administration "Time-boxed grant uses 'evaluation', never // 'trial'"). issueGrantReasonOptions (operator_enrollment_forms.go) builds // the Reason select's options from this list, so a value outside it is a // Parse refusal rather than a hand-written check (finding FA-8). var grantReasonDomain = []string{"manual", "evaluation", "promotional", "complimentary", "sponsored", "board_decision", "legacy"} // IssuanceProductOption is one selectable product on the single issuance form // (Doc 41: one form over all published products). Internal wrap products // (is_public=false) render with an "internal" badge; SupersedesSubscription // flags a product whose position would displace a subscription-held rung (the // subscription keeps billing — §11.2 residue), surfaced as a non-blocking // advisory. type IssuanceProductOption struct { ProductID string Name string IsInternal bool DisplayCategory string SupersedesSubscription bool } // BillingSummaryViewModel holds the billing snapshot rendered on the per-org // composite. HasAccount=false signals no billing account exists for the org; // the template renders an empty-state message instead of a summary card. // Subscription and invoice fields are zero-valued when those records don't // exist yet (e.g. an account created but never billed). type BillingSummaryViewModel struct { HasAccount bool BillingAccountID string // AccountStatus is the account's stored status. It renders as its own // row only when it is not the normal active state (design D5, round 5): // a permanent Active badge on every healthy organization is noise. AccountStatus string // SubscriptionCount, SubscriptionState and SubscriptionNote aggregate // every subscription on the account (design D5, round 5). The card names // no product: Pools and Plan and grants already say what the // organization holds, and the per-product detail is one click away on // the subscriptions list. See summarizeSubscriptions. SubscriptionCount int SubscriptionState string SubscriptionNote string HasInvoice bool LatestInvoiceID string // LatestInvoiceNumber is Stripe's customer-facing invoice number // (invoice-numbers D3: "the composite's billing section ... where an // invoice is named"), empty when Stripe has not assigned one yet. LatestInvoiceNumber string LatestInvoiceDate string // InvoiceState is the presentation state the invoices list derives for // the same invoice (invoiceStatusState, including the Overdue // derivation), so the card and the list can never disagree. InvoiceState string // OutstandingBalance is the raw minor-unit amount, kept only so the // template can branch styling on "> 0" without parsing the formatted // string back out; render OutstandingBalanceFormatted for display. OutstandingBalance int64 OutstandingBalanceFormatted string Currency string } // subscriptionStatePrecedence ranks the states the card's aggregate badge // picks from, worst first (design D5, round 5): a past-due subscription is // what an operator must act on, a canceling one is the next standing fact, // then the healthy states. Every other state (canceled, unpaid, paused, // incomplete) ranks below all of these; among equals the newest // subscription wins, since the query orders by created_at descending. var subscriptionStatePrecedence = []string{"past_due", "canceling", "active", "trialing"} func subscriptionStateRank(state string) int { for i, s := range subscriptionStatePrecedence { if s == state { return i } } return len(subscriptionStatePrecedence) } // subscriptionState derives the one state a subscription presents on the // card. cancel_at_period_end is a flag on an otherwise healthy subscription, // so it becomes the "canceling" state only when the stored status has // nothing worse to report. func subscriptionState(status string, cancelAtPeriodEnd bool) string { if cancelAtPeriodEnd && (status == "active" || status == "trialing") { return "canceling" } return status } // summarizeSubscriptions folds every subscription on a billing account into // the three facts the card renders: how many there are, the worst state // across them, and the muted note that follows the count. For a single // subscription the note is its period clause ("renews Jan 1, 2026", or // "ends Jan 1, 2026" when it is canceling); for several it counts the // subscriptions in the worst state, and only when they are not all of them. func summarizeSubscriptions(subs []billing.Subscription) (count int, state string, note string) { if len(subs) == 0 { return 0, "", "" } worst := subscriptionState(subs[0].Status, subs[0].CancelAtPeriodEnd) for _, s := range subs[1:] { if st := subscriptionState(s.Status, s.CancelAtPeriodEnd); subscriptionStateRank(st) < subscriptionStateRank(worst) { worst = st } } if len(subs) == 1 { return 1, worst, subscriptionPeriodNote(worst, subs[0].CurrentPeriodEnd) } inWorst := 0 for _, s := range subs { if subscriptionState(s.Status, s.CancelAtPeriodEnd) == worst { inWorst++ } } if inWorst == len(subs) { return len(subs), worst, "" } return len(subs), worst, fmt.Sprintf("%d %s", inWorst, strings.ToLower(StatusBadge(worst).Label)) } // subscriptionPeriodNote reads the single subscription's period end as the // event it is: a canceling subscription ends on that date, any other live // one renews on it. A subscription that has already ended says nothing. func subscriptionPeriodNote(state string, periodEnd sql.NullTime) string { if !periodEnd.Valid || state == "canceled" { return "" } verb := "renews" if state == "canceling" { verb = "ends" } return verb + " " + periodEnd.Time.Format("Jan 2, 2006") } // MemberRowViewModel represents one enrolled person on the per-org composite's // Members section. The PersonID drives the link to /operator/persons/{personID} // (closes the deferred 3.7 link from operator-mpa-conversion). type MemberRowViewModel struct { PersonID string DisplayName string Email string RoleName string JoinedAt string } // OrgEnrollmentData holds data for the enrollment detail partial. type OrgEnrollmentData struct { OrgID string OrgName string // OrgOwnerPersonID / OrgOwnerName identify the organization's owner // (core.organizations.owner_person_id, NOT NULL) so the org-detail // header never reads as unowned (organization delta, owner visibility // — mirrors the ListOrganizationsWithOwner treatment on the org list). // OrgOwnerPersonID empty means the lookup failed (defensive; the // column itself is never NULL). OrgOwnerPersonID string OrgOwnerName string // OrgKey is the organization's declarative address (entity-keys §5), // shown read-only in the header when set and omitted entirely when NULL. // Personal organizations never have one, and the create path does not // collect one, so the System tenant (`system`) and rows a seed or a // configuration file named are the ordinary cases. OrgKey string Pools []PoolEnrollmentViewModel // PoolMissing is true when the organization has zero resource pool // rows at all -- not merely none active. Every organization gets // exactly one default pool at provisioning (resource-pools card // invariant 3); an org with none cannot receive conferrals. The // composite renders an explicit breakage warning and blocks the Issue // Grant form for this case instead of the neutral "no active pools" // empty state (ux-honest-surfaces: "a pool-less organization is // presented as broken, not empty"). PoolMissing bool // Transitions is the Tier changes table's current page (tc_ params); // TransitionsTotal is the full count and TierChangesNav drives the // shared pager (maintainer design round 2026-08-24: the flat 16-cap // graduated to the governed-list pager). Transitions []TransitionHistoryViewModel TransitionsTotal int TierChangesNav ListNav // EntitlementChanges is the Entitlement changes trail's current page // (ec_ params): the entitlement set rule commits that moved this // organization's pools, newest first, so "why did my limit change" is // answered on the organization's own page // (plan-enrollment-administration "Entitlement changes is the // composite's trail of rule commits"). Every cell is the effect row's // own write-time snapshot, so a rename does not rewrite the trail. EntitlementChanges []EntitlementChangeEffectViewModel EntitlementChangesTotal int EntitlementChangesNav ListNav IssuanceProducts []IssuanceProductOption // all published products for the single issuance form // IssueGrantForm is the Issue grant panel, rendered through // operator.enrollment.grant.issue (spec form-library, form-conventions; // design D9, D10). Bound to the record-like rest state by default; a // refused submission overrides it with the submitted values and errors // (renderIssueGrantRefusal). IssueGrantForm forms.FormView // Grants is the ledger's current page under the Active/History tabs // (tab, q, page params): the Active tab (default) shows only rows // delivering right now, History shows every grant ever; search matches // product name or reason on both (maintainer design round 2026-08-24). // GrantsNav drives the tabs, search, and pager. Grants []GrantViewModel GrantsTotal int GrantsNav ListNav BillingSummary BillingSummaryViewModel // Members is capped to the enrollmentListCap most recently joined rows // (the backing query sorts alphabetically, so loadOrgEnrollmentData // re-sorts by JoinedAt before capping); MembersTotal / MembersCapped // follow the same contract as above. Members []MemberRowViewModel MembersTotal int MembersCapped bool Success string // Error is a load failure (the organization or its pools could not be // read); the template renders it instead of the page, because there is // no page. ActionError is the outcome of an action the operator just // took (a refused revoke, a grant issued whose expiry did not get // scheduled): the page renders as usual with the message above it, // since the operator has to act on the page, not on a bare alert. The // two used to share Error, so a post-success warning wiped the whole // composite (maintainer, 2026-09-06). Error string ActionError string // Per-section errors (finding #52). A query failure in one section used // to silently render as that section's genuine-empty state (no members, // no transitions, no products to issue against) with no indication // anything went wrong. Each is set only on a query failure for that // section and rendered as its own inline alert. ProductsError string MembersError string GrantsError string TransitionsError string EntitlementChangesError string } // EntitlementChangeEffectViewModel is one effect row as the composite's // Entitlement changes trail renders it: the resource, the limit before and // after, the set the rule belongs to, the commit's note, the actor and the // time. Every label is the label recorded with the row. type EntitlementChangeEffectViewModel struct { EffectID string SetID string SetName string ResourceLabel string ResourceKey string LimitLine string Note string ActorName string ActorType string When string } // IssueGrantControl builds the disabled "Issue grant" control and its // reason for the three ways issuance is blocked (design D10, ACC-8: the // one shared disabled-control idiom, `ui_disabled_control.html`, instead // of a hand-rolled `` wrapper). Call only when issuance // is actually blocked (PoolMissing, ProductsError, or no published // products); the unblocked branch renders its own enabled button. func (d OrgEnrollmentData) IssueGrantControl() DisabledControl { reason := "No published products yet. Publish a product to enable issuance." switch { case d.PoolMissing: reason = "Unavailable: this organization is missing its default resource pool." case d.ProductsError != "": reason = "Unavailable: products could not be loaded." } // The weight the enabled opener carries (design D19, round 4: panel // openers are tertiary), so enabling it never changes its weight. return NewDisabledControl("issue-grant-reason", "Issue grant", "btn btn-outline-secondary btn-sm", reason) } // GetOrgEnrollment handles GET /partials/operator/organizations/{orgID}/enrollment func (h *OperatorPartialsHandler) GetOrgEnrollment(w http.ResponseWriter, r *http.Request) { orgID := r.PathValue("orgID") h.renderOrgEnrollmentPage(w, r, orgID, "", "") } // conferralFormErrors maps the four named conferral rejections (design D3) to // field-level form errors for the issuance/extend forms; ok=false when err is // not a conferral domain error. func conferralFormErrors(err error) (web.FieldErrors, bool) { fe := web.New() switch { case errors.Is(err, entitlements.ErrConferralPrecedesIncumbent): fe.Set("valid_until", "This grant's start precedes a position it would supersede.") case errors.Is(err, entitlements.ErrConferralShapeDiverged): fe.Set("product_id", "This product's shape changed since an existing delivery; align it before granting.") case errors.Is(err, entitlements.ErrConferralShapeCollision): fe.Set("product_id", "This grant collides with a rung already held by another delivery.") case errors.Is(err, entitlements.ErrConferralAlreadyEnded): fe.Set("", "That delivery was already ended.") default: return nil, false } return fe, true } // IssueGrant handles POST /partials/operator/organizations/{orgID}/grant/create // — the single issuance form (Doc 41 merges the former plan/non-plan split): // any published product, a quantity, an optional valid_until, a reason from // the operator domain, and a free-text description. The pool-scoped alias // route (.../pools/{poolID}/grant) was removed: the handler never read // poolID (ConferGrant targets the org's default pool behind a multi-pool // refusal guard) and no template posted to it. // The decree is recorded and conferred in one transaction; an already-delivering // product still records the decree as a ledger entry. func (h *OperatorPartialsHandler) IssueGrant(w http.ResponseWriter, r *http.Request) { session := h.AuthConfig.GetUserSession(r.Context()) orgID := r.PathValue("orgID") if session == nil { h.renderOrgEnrollmentPage(w, r, orgID, "", "Unauthorized") return } // Multi-pool refusal guard (finding #32): the form has no pool selector and // ConferGrant targets the org's default pool; refuse rather than silently // target one of several pools. Orgs get exactly one pool today. Loaded // before Parse because the product select's options (below) need the // issuance product list, which needs the pools too. pools, err := h.EntitlementsQ.GetResourcePoolsByOrgID(r.Context(), orgID) if err != nil { h.Logger.Error("failed to list pools for issue-grant pool guard", slog.Any("error", err), slog.String("org_id", orgID)) h.renderOrgEnrollmentPage(w, r, orgID, "", "Failed to load resource pools") return } if len(pools) == 0 { // Defense in depth: the template blocks this form when PoolMissing is // set (see loadOrgEnrollmentData), but a stale render or a direct POST // must not be able to attempt conferral against a pool that doesn't // exist (ux-honest-surfaces: "a pool-less organization is presented // as broken, not empty"). Rendered as a page-level banner/toast // (renderOrgEnrollmentPage), not the form's own 422: the re-rendered // page's PoolMissing gate would hide the issuance form entirely. h.renderOrgEnrollmentPage(w, r, orgID, "", poolMissingMessage) return } products, productsErr := h.loadIssuanceProducts(r, pools) if productsErr != "" { h.renderOrgEnrollmentPage(w, r, orgID, "", productsErr) return } values, errs := issueGrantForm.ParseWith(r, issueGrantFormOptions(products)) if len(pools) > 1 { errs.Form("This organization has more than one resource pool. Issuing a grant from this form isn't supported yet; it targets the default pool only.") } quantity := int32(1) if q, ok := values.Int("quantity"); ok { // Bound against int32 wrap on the cast (finding #31); the field's // own Max carries the ceiling to Parse, so a passing value is // already inside it. quantity = int32(q) } // valid_until is optional and allowed on ANY product now (Doc 41 uniform // bounds), not only trials. var validUntil sql.NullTime if vu, msg := parseGrantValidUntilValue(values.String("valid_until"), r.PostForm.Get("valid_until_offset")); msg != "" { errs.Field("valid_until", msg) } else { validUntil = vu } if errs.Any() { h.renderIssueGrantRefusal(w, r, orgID, values, errs) return } result, err := entitlements.ConferGrant(r.Context(), h.Database, entitlements.ConferGrantInput{ ProductID: values.String("product_id"), OrgID: orgID, GrantedByPersonID: session.PersonID, GrantReason: values.String("reason"), Description: values.String("description"), Quantity: quantity, ValidUntil: validUntil, ActorType: "operator", ActorID: uuid.NullUUID{UUID: uuid.MustParse(session.PersonID), Valid: true}, TransitionReason: values.String("description"), }) if err != nil { if fe, ok := conferralFormErrors(err); ok { applyWebErrors(errs, fe) h.renderIssueGrantRefusal(w, r, orgID, values, errs) return } if fe, ok := web.FieldErrorsFromDB(err, grantConstraints); ok { applyWebErrors(errs, fe) h.renderIssueGrantRefusal(w, r, orgID, values, errs) return } h.Logger.Error("failed to issue grant", slog.Any("error", err)) h.renderOrgEnrollmentPage(w, r, orgID, "", "Failed to issue grant. Details are in the server logs.") return } // valid_until: schedule expiration for ANY bounded grant (Doc 41 uniform // expiry). GrantExpirationWorkflow is the sole enforcement (no sweep), so a // failed schedule must not report plain success (finding #49). if validUntil.Valid { if h.TemporalClient == nil { h.Logger.Warn("no Temporal client configured; grant expiration was not scheduled", slog.String("grant_id", result.Grant.GrantID)) h.renderOrgEnrollmentPage(w, r, orgID, "", "Grant issued, but expiration scheduling FAILED (no scheduler); it will not auto-expire. Revoke it manually to end it.") return } workflowOptions := client.StartWorkflowOptions{ ID: "grant-expiration-" + result.Grant.GrantID, TaskQueue: queues.Main, } if _, err := h.TemporalClient.ExecuteWorkflow(r.Context(), workflowOptions, wf.GrantExpirationWorkflow, wf.GrantExpirationInput{ GrantID: result.Grant.GrantID, ValidUntil: validUntil.Time, }); err != nil { h.Logger.Warn("failed to schedule grant expiration workflow", slog.Any("error", err), slog.String("grant_id", result.Grant.GrantID)) h.renderOrgEnrollmentPage(w, r, orgID, "", "Grant issued, but expiration scheduling FAILED; it will not auto-expire on its own. Retry, or revoke it manually.") return } } if result.Outcome == "noop" { h.renderOrgEnrollmentPage(w, r, orgID, "This product is already delivering; the grant was recorded as a ledger entry.", "") return } h.renderOrgEnrollmentPage(w, r, orgID, "Grant issued successfully.", "") } // ExtendGrant handles POST /partials/operator/organizations/{orgID}/pools/{poolID}/grant/extend // — issues a new grant that extends ONE named position's currently-delivering // grant (same product), then confers it. The form carries the position's // provision_id (maintainer 2026-08-23: a pool can hold positions on several // ladders, so "the pool's grant-backed provision" is not a well-defined // target — the button rides a specific tier and the handler extends exactly // that one). Conferral recognizes the lineage (extends_grant_id) and // supersedes the incumbent as a 'transfer' (extend-as-replace); there is no // separate transition call. func (h *OperatorPartialsHandler) ExtendGrant(w http.ResponseWriter, r *http.Request) { session := h.AuthConfig.GetUserSession(r.Context()) if session == nil { h.renderOrgEnrollmentPage(w, r, r.PathValue("orgID"), "", "Unauthorized") return } orgID := r.PathValue("orgID") poolID := r.PathValue("poolID") values, errs := extendGrantForm.Parse(r) provisionID := values.String("provision_id") description := values.String("description") // Optional valid_until: when set, the new grant expires at that time and an // expiration workflow is scheduled; when omitted the extension is open-ended. var validUntil sql.NullTime if vu, msg := parseGrantValidUntilValue(values.String("valid_until"), r.PostForm.Get("valid_until_offset")); msg != "" { errs.Field("valid_until", msg) } else { validUntil = vu } if errs.Any() { h.renderExtendGrantRefusal(w, r, orgID, poolID, provisionID, values, errs) return } // Multi-pool refusal guard (finding #32), mirroring IssueGrant's: the pool // named in the URL is only safe to trust as "the org's pool" when the org // resolves to exactly one active resource pool. No writes yet (the // transaction below hasn't opened), so a refusal here writes nothing. // Page-level, not the form's own 422 (design D9's "same form" does not // apply here): this and the pool-identity checks below are about WHICH // pool and position, resolved from the URL and server state rather than // a submitted field, and Extend's dense form renders once per grant-backed // delivery, so there is no single instance a URL-level refusal could // scope into (a refusal here may name no delivery on the page at all -- // TestExtendGrant_RefusesPoolFromAnotherOrg's pool carries none). They // are route preconditions, not field errors, and a refusal never // answers 200 (form-conventions "Error responses are designed as swap // content"): each answers 409, the composite's own page banner at a // non-200 status (renderOrgEnrollmentPageRefused mirrors // operator_domains.go's renderDomains(status, ...), the codebase's own // precedent for this shape). pools, err := h.EntitlementsQ.GetResourcePoolsByOrgID(r.Context(), orgID) if err != nil { h.Logger.Error("failed to list pools for extend-grant pool guard", slog.Any("error", err), slog.String("org_id", orgID)) h.renderOrgEnrollmentPage(w, r, orgID, "", "Failed to load resource pools") return } if len(pools) > 1 { h.renderOrgEnrollmentPageRefused(w, r, orgID, http.StatusConflict, "This organization has more than one resource pool. Issuing a grant from this form isn't supported yet; it targets the default pool only.") return } // Verify the URL pool actually belongs to the URL org and is that org's // default pool (D3): the multi-pool refusal above covers the future // multi-pool case, this covers a stale or crafted URL today. pool, err := h.EntitlementsQ.GetResourcePoolByID(r.Context(), poolID) if err != nil { if errors.Is(err, sql.ErrNoRows) { h.renderOrgEnrollmentPageRefused(w, r, orgID, http.StatusConflict, "This pool does not belong to this organization's default pool, so the extension was refused.") return } h.Logger.Error("failed to resolve pool for extend-grant pool guard", slog.Any("error", err), slog.String("pool_id", poolID)) h.renderOrgEnrollmentPage(w, r, orgID, "", "Failed to load the resource pool") return } if pool.OrgID != orgID || pool.PoolType != "default" { h.renderOrgEnrollmentPageRefused(w, r, orgID, http.StatusConflict, "This pool does not belong to this organization's default pool, so the extension was refused.") return } tx, err := entitlements.BeginMaterializing(r.Context(), h.Database, nil) if err != nil { h.Logger.Error("failed to begin transaction", slog.Any("error", err)) h.renderOrgEnrollmentPage(w, r, orgID, "", "Transaction failed") return } defer tx.Rollback() // Serialize concurrent extends on the pool. if _, err := tx.ExecContext(r.Context(), "SELECT 1 FROM core.resource_pools WHERE pool_id = $1 FOR UPDATE", poolID); err != nil { h.Logger.Error("failed to lock pool row", slog.Any("error", err)) h.renderOrgEnrollmentPage(w, r, orgID, "", "Pool lock failed") return } // Target = exactly the provision the form named. Never "the first // grant-backed one": a pool with positions on several ladders (or a // grant-backed non-plan delivery) would make that an arbitrary pick // that can disagree with the tier the button named. The act itself // (resolve, verify, decree, confer, materialize) is the shared // entitlements helper the seed and every other caller run. if provisionID == "" { h.renderOrgEnrollmentPage(w, r, orgID, "", "The extension did not name a position. Reload the page and try again.") return } result, err := entitlements.ExtendGrantTx(r.Context(), tx, entitlements.ExtendGrantInput{ OrgID: orgID, PoolID: poolID, ProvisionID: provisionID, GrantedByPersonID: session.PersonID, Description: description, ValidUntil: validUntil, ActorID: uuid.NullUUID{UUID: uuid.MustParse(session.PersonID), Valid: true}, }) if err != nil { switch { case errors.Is(err, entitlements.ErrPositionNotActive): h.renderOrgEnrollmentPage(w, r, orgID, "", "That position is no longer active on this pool, so the extension was refused. Reload the page to see the current positions.") return case errors.Is(err, entitlements.ErrPositionNotGrantBacked): h.renderOrgEnrollmentPage(w, r, orgID, "", "That position's delivery is not grant-backed (it may be subscription-billed), so there is no grant to extend.") return } if fe, ok := conferralFormErrors(err); ok { applyWebErrors(errs, fe) h.renderExtendGrantRefusal(w, r, orgID, poolID, provisionID, values, errs) return } if fe, ok := web.FieldErrorsFromDB(err, grantConstraints); ok { applyWebErrors(errs, fe) h.renderExtendGrantRefusal(w, r, orgID, poolID, provisionID, values, errs) return } h.Logger.Error("extend grant failed", slog.Any("error", err), slog.String("provision_id", provisionID)) h.renderOrgEnrollmentPage(w, r, orgID, "", "Extend failed. Details are in the server logs.") return } grant := result.Grant if err := tx.Commit(); err != nil { h.Logger.Error("failed to commit transaction", slog.Any("error", err)) h.renderOrgEnrollmentPage(w, r, orgID, "", "Commit failed") return } // Schedule expiration when the extension has a deadline (finding #49). if validUntil.Valid { if h.TemporalClient == nil { h.Logger.Warn("no Temporal client configured; extension expiration was not scheduled", slog.String("grant_id", grant.GrantID)) h.renderOrgEnrollmentPage(w, r, orgID, "", "Extended, but expiration scheduling FAILED (no scheduler); it will not auto-expire.") return } workflowOptions := client.StartWorkflowOptions{ ID: "grant-expiration-" + grant.GrantID, TaskQueue: queues.Main, } if _, err := h.TemporalClient.ExecuteWorkflow(r.Context(), workflowOptions, wf.GrantExpirationWorkflow, wf.GrantExpirationInput{ GrantID: grant.GrantID, ValidUntil: validUntil.Time, }); err != nil { h.Logger.Warn("failed to schedule extension expiration workflow", slog.Any("error", err), slog.String("grant_id", grant.GrantID)) h.renderOrgEnrollmentPage(w, r, orgID, "", "Extended, but expiration scheduling FAILED; it will not auto-expire on its own. Retry, or revoke it manually.") return } } msg := "Delivery extended successfully." if validUntil.Valid { msg = "Delivery extended; new expiration scheduled." } h.renderOrgEnrollmentPage(w, r, orgID, msg, "") } // RevokeGrantAndTransition handles POST /partials/operator/grants/{grantID}/revoke-and-transition // — records the revocation decree, then ends the grant's conferral. end_conferral // succeeds quietly against nothing live, so orphan grants and off-ladder grants // need no special branch. func (h *OperatorPartialsHandler) RevokeGrantAndTransition(w http.ResponseWriter, r *http.Request) { session := h.AuthConfig.GetUserSession(r.Context()) if session == nil { h.renderOrgEnrollmentPage(w, r, r.FormValue("org_id"), "", "Unauthorized") return } grantID := r.PathValue("grantID") orgID := r.FormValue("org_id") if orgID == "" { h.renderOrgEnrollmentPage(w, r, orgID, "", "Organization ID is required") return } if _, err := uuid.Parse(grantID); err != nil { h.renderOrgEnrollmentPage(w, r, orgID, "", "Invalid grant ID") return } // Decree-level guards: a non-active grant is already revoked/expired, and a // system-managed default-reason grant is not operator-revocable (reapply // would just re-mint it). Operators change the default by editing the org // type's default ladder instead. grantRow, err := h.EntitlementsQ.GetGrantByID(r.Context(), grantID) if err != nil { h.Logger.Error("failed to load grant for revoke", slog.Any("error", err)) h.renderOrgEnrollmentPage(w, r, orgID, "", "Grant not found") return } if grantRow.Status != "active" { h.renderOrgEnrollmentPage(w, r, orgID, "", "This grant is not active (already revoked or expired).") return } if grantRow.GrantReason == "default" { h.renderOrgEnrollmentPage(w, r, orgID, "", "This grant is the org-type default. Edit the org type's default ladder to change it.") return } tx, err := entitlements.BeginMaterializing(r.Context(), h.Database, nil) if err != nil { h.Logger.Error("failed to begin transaction", slog.Any("error", err)) h.renderOrgEnrollmentPage(w, r, orgID, "", "Transaction failed") return } defer tx.Rollback() // The act itself (lock, decree, end, restore the default if vacant, // materialize) is the shared entitlements helper the seed runs too. if _, err := entitlements.RevokeGrantTx(r.Context(), tx, entitlements.RevokeGrantInput{ GrantID: grantID, OrgID: orgID, RevokedByPersonID: session.PersonID, }); err != nil { if fe, ok := conferralFormErrors(err); ok { h.renderOrgEnrollmentPage(w, r, orgID, "", fieldErrorsBanner(fe)) return } if fe, ok := web.FieldErrorsFromDB(err, grantConstraints); ok { h.renderOrgEnrollmentPage(w, r, orgID, "", fieldErrorsBanner(fe)) return } h.Logger.Error("failed to revoke grant", slog.Any("error", err), slog.String("grant_id", grantID)) h.renderOrgEnrollmentPage(w, r, orgID, "", "Failed to revoke grant. Details are in the server logs.") return } if err := tx.Commit(); err != nil { h.Logger.Error("failed to commit transaction", slog.Any("error", err)) h.renderOrgEnrollmentPage(w, r, orgID, "", "Commit failed") return } h.renderOrgEnrollmentPage(w, r, orgID, "Grant revoked.", "") } func (h *OperatorPartialsHandler) renderOrgEnrollmentPage(w http.ResponseWriter, r *http.Request, orgID string, success string, errMsg string) { fireSuccessToast(w, success) fireErrorToast(w, errMsg) data := h.loadOrgEnrollmentData(r, orgID, "", errMsg) h.Templates.Render(w, "operator_enrollment.html", data) } // renderOrgEnrollmentPageRefused answers a route-precondition refusal (a // pool or organization identity the URL itself names, resolved against // server state rather than a submitted field) on the composite's own page // banner at status rather than 200: a refusal never answers 200 // (form-conventions "Error responses are designed as swap content"). // Mirrors operator_domains.go's renderDomains(status, ...), the existing // precedent for a banner rendered at a non-200 status; it skips the // explicit error toast renderOrgEnrollmentPage fires for its own 200 // refusals, because error-handler.js's own funnel already toasts any // other 4xx with a body, and firing both would show the toast twice // (docs/operator-ux-conventions.md §4). func (h *OperatorPartialsHandler) renderOrgEnrollmentPageRefused(w http.ResponseWriter, r *http.Request, orgID string, status int, errMsg string) { data := h.loadOrgEnrollmentData(r, orgID, "", errMsg) w.WriteHeader(status) h.Templates.Render(w, "operator_enrollment.html", data) } // applyWebErrors copies a web.FieldErrors set (built by // web.FieldErrorsFromDB and conferralFormErrors, both shared with other // surfaces) onto a forms.Errors set, so a DB-constraint or conferral-domain // refusal renders through the same declared form as a field-validation // refusal (design D9). An empty field name is the form-level slot on both // sides. func applyWebErrors(errs *forms.Errors, fe web.FieldErrors) { for field, msg := range fe { errs.Field(field, msg) } } // renderIssueGrantRefusal writes 422 and re-renders the composite with the // Issue grant panel bound to the submission: every value carried back, each // field's error under its control, and a refusal that belongs to no field // in the form's own slot (design D9). Every other section, including the // Extend panels, renders at rest. func (h *OperatorPartialsHandler) renderIssueGrantRefusal(w http.ResponseWriter, r *http.Request, orgID string, values forms.Values, errs *forms.Errors) { w.WriteHeader(http.StatusUnprocessableEntity) data := h.loadOrgEnrollmentData(r, orgID, "", "") data.IssueGrantForm = issueGrantFormView(orgID, data.IssuanceProducts, forms.ModeSubmission, values, errs, h.ruleLessWarning(r.Context(), values.String("product_id"))) h.Templates.Render(w, "operator_enrollment.html", data) } // renderExtendGrantRefusal writes 422 and re-renders the composite with the // one delivery's Extend panel (named by poolID and provisionID) bound to // the submission; every other panel, including every other open Extend // panel, renders at rest (design D9). func (h *OperatorPartialsHandler) renderExtendGrantRefusal(w http.ResponseWriter, r *http.Request, orgID, poolID, provisionID string, values forms.Values, errs *forms.Errors) { w.WriteHeader(http.StatusUnprocessableEntity) data := h.loadOrgEnrollmentData(r, orgID, "", "") for pi := range data.Pools { if data.Pools[pi].PoolID != poolID { continue } for di := range data.Pools[pi].Deliveries { if data.Pools[pi].Deliveries[di].ProvisionID == provisionID { data.Pools[pi].Deliveries[di].ExtendForm = extendGrantFormView(orgID, poolID, provisionID, forms.ModeSubmission, values, errs) } } } h.Templates.Render(w, "operator_enrollment.html", data) } // loadOrgEnrollmentData hydrates the per-org composite view model. Shared by // the legacy partial endpoint (renderOrgEnrollmentPage) and the new MPA page // handler (GetOrganizationDetailPage); kept here next to the data shape it // produces. Errors are recorded on data.Error so the template renders a // single error banner rather than the caller juggling HTTP statuses. func (h *OperatorPartialsHandler) loadOrgEnrollmentData(r *http.Request, orgID string, success string, errMsg string) OrgEnrollmentData { data := OrgEnrollmentData{ OrgID: orgID, Success: success, ActionError: errMsg, } org, err := h.OrgQ.GetOrganizationByID(r.Context(), orgID) if err != nil { h.Logger.Error("failed to get organization", slog.Any("error", err), slog.String("org_id", orgID)) data.Error = "Organization not found" return data } data.OrgName = org.Name data.OrgKey = org.Key.String data.OrgOwnerPersonID = org.OwnerPersonID if owner, err := h.IdentityQ.GetPersonByID(r.Context(), org.OwnerPersonID); err == nil { data.OrgOwnerName = owner.DisplayName } else { h.Logger.Warn("failed to resolve organization owner", slog.Any("error", err), slog.String("org_id", orgID), slog.String("owner_person_id", org.OwnerPersonID)) } // Load pools for the org. Two reads: the active-only set the transition // history / subscriptionHeldLadders logic below already relied on // (targeting semantics: "which pool would conferral use"), and an // any-status read for the panel's own display, so a pool's actual // status is visible instead of GetResourcePoolsByOrgID's active-only // filter silently hiding it, and so "this org has zero pool rows" (the // pool-less breakage case) is distinguishable from "this org's only // pool exists but isn't active" (ux-honest-surfaces: "pool status and // usage are visible on the organization view"; "a pool-less // organization is presented as broken, not empty"). pools, err := h.EntitlementsQ.GetResourcePoolsByOrgID(r.Context(), orgID) if err != nil { h.Logger.Error("failed to list pools", slog.Any("error", err), slog.String("org_id", orgID)) data.Error = "Failed to load pools" } allPools, err := h.EntitlementsQ.ListResourcePoolsByOrgIDAnyStatus(r.Context(), orgID) if err != nil { h.Logger.Error("failed to list pools (any status)", slog.Any("error", err), slog.String("org_id", orgID)) if data.Error == "" { data.Error = "Failed to load pools" } } else { data.PoolMissing = len(allPools) == 0 } poolVMs := make([]PoolEnrollmentViewModel, len(allPools)) for i, pool := range allPools { vm := PoolEnrollmentViewModel{ PoolID: pool.PoolID, PoolName: pool.Name, PoolType: pool.PoolType, Status: pool.Status, } // Grant-backing per provision, so each position's extendability is // its own fact, never a pool-wide guess. grantBacked := map[string]bool{} if provisions, err := h.EntitlementsQ.GetActivePoolProvisionsByPoolID(r.Context(), pool.PoolID); err == nil { for _, p := range provisions { grantBacked[p.ProvisionID] = p.GrantID.Valid } } // Every active ladder placement this pool holds, grouped by // delivery — a pool can be on several ladders at once, and a shared // product places ONE delivery on several of them. Grouping renders // the product once with its single Extend control; per-ladder facts // become rung pills (maintainer design round 2026-08-24). attachments, err := h.EntitlementsQ.GetActiveAttachmentsByPool(r.Context(), pool.PoolID) if err == nil { deliveryIndex := map[string]int{} for _, att := range attachments { di, ok := deliveryIndex[att.ProvisionID] if !ok { d := PoolDeliveryViewModel{ ProvisionID: att.ProvisionID, ProductID: att.ProductID, GrantBacked: grantBacked[att.ProvisionID], } if product, err := h.BillingQ.GetProductByID(r.Context(), att.ProductID); err == nil { d.ProductName = product.Name } if d.GrantBacked { d.ExtendForm = extendGrantFormView(orgID, pool.PoolID, att.ProvisionID, forms.ModeRecord, extendGrantRestValues(att.ProvisionID), nil) } di = len(vm.Deliveries) deliveryIndex[att.ProvisionID] = di vm.Deliveries = append(vm.Deliveries, d) } rung := PoolRungViewModel{ ActivatedAt: att.ActivatedAt.Format("Jan 2, 2006 3:04 PM"), } if ladder, err := h.BillingQ.GetPlanLadderByID(r.Context(), att.PlanLadderID); err == nil { rung.LadderName = ladder.Name } if tier, err := h.BillingQ.GetTier(r.Context(), billing.GetTierParams{ PlanLadderID: att.PlanLadderID, ProductID: att.ProductID, }); err == nil { rung.Rank = tier.Rank } vm.Deliveries[di].Rungs = append(vm.Deliveries[di].Rungs, rung) } vm.HasAttachment = len(vm.Deliveries) > 0 } if usage, err := h.EntitlementsQ.ListPoolUsageWithLimitsByPoolID(r.Context(), pool.PoolID); err == nil { for _, u := range usage { vm.Usage = append(vm.Usage, PoolUsageViewModel{ ResourceKey: u.ResourceKey, Used: u.CurrentUsage, Limit: u.ResourceLimit, }) } } poolVMs[i] = vm } data.Pools = poolVMs // Load transitions for all pools, resolving names an operator can // read: the ladder's display name, and tier names for the from/to // ranks. Names // resolve against the ladder's CURRENT shape (best effort; a reordered // ladder can have moved products since), so a rank with no current // tier falls back to "rank N" and the section heading's tooltip states // the caveat. Ladder shapes are fetched once each and cached. ladderNames := map[string]string{} ladderTierNames := map[string]map[int32]string{} tierLabel := func(ladderID string, rank sql.NullInt32) string { if !rank.Valid { return "—" } if _, ok := ladderTierNames[ladderID]; !ok { names := map[int32]string{} if tiers, err := h.BillingQ.ListTiersByLadderWithProducts(r.Context(), ladderID); err == nil { for _, t := range tiers { names[t.Rank] = t.ProductName } } ladderTierNames[ladderID] = names } if name, ok := ladderTierNames[ladderID][rank.Int32]; ok { return name } return "rank " + strconv.Itoa(int(rank.Int32)) } for _, pool := range pools { transitions, err := h.EntitlementsQ.ListTransitionsByPool(r.Context(), pool.PoolID) if err != nil { h.Logger.Error("failed to list transitions for pool", slog.Any("error", err), slog.String("pool_id", pool.PoolID)) data.TransitionsError = "Failed to load tier changes for one or more pools." continue } // foldSupersessions runs per pool, before the rows join the // cross-pool list pageSliceClamped windows below, so the total it // reports already reflects the fold (design D2/D6). transitions = foldSupersessions(transitions) for _, trn := range transitions { actorName := "" if trn.ActorID.Valid { if person, err := h.IdentityQ.GetPersonByID(r.Context(), trn.ActorID.UUID.String()); err == nil { actorName = person.DisplayName } } reasonRaw := "" if trn.Reason.Valid { reasonRaw = trn.Reason.String } // The machinery's own reason leads (design D3): an expiry, a // revocation, a restoration or a seed renders its sentence, and // the grant's note rides under it when the provision was // grant-backed, so an Ended row says why it ended and still // shows what the operator wrote when issuing. A row the machine // did not write (Issue grant fills the transition reason with // the operator's note) renders the grant's reason // label with the note under it, the grants ledger's cell (design // D4). The raw string always rides as ReasonRaw for the tooltip. var reasonLabel, reasonNote string grantNote := "" if trn.GrantID.Valid && trn.GrantDescription.Valid { grantNote = trn.GrantDescription.String } if label, ok := humanizeTransitionReason(reasonRaw); ok { reasonLabel = label reasonNote = grantNote } else if trn.GrantID.Valid { reasonLabel = StatusBadge(trn.GrantReason.String).Label reasonNote = grantNote } else { reasonLabel = reasonRaw } if _, ok := ladderNames[trn.PlanLadderID]; !ok { ladderNames[trn.PlanLadderID] = "" if ladder, err := h.BillingQ.GetPlanLadderByID(r.Context(), trn.PlanLadderID); err == nil { ladderNames[trn.PlanLadderID] = ladder.Name } } changeLabel := transitionChangeLabels[trn.TransitionType] if changeLabel == "" { changeLabel = trn.TransitionType } changeTooltip := "" changeKind := "" if trn.TransitionType == "transfer" { if reasonRaw == entitlements.ExtensionTransitionReason { changeLabel = "Extended" changeKind = "extension" } else { changeTooltip = "Same tier, different funding source: another grant or subscription took over this placement." } } data.Transitions = append(data.Transitions, TransitionHistoryViewModel{ TransitionID: trn.TransitionID, TransitionType: trn.TransitionType, LadderName: ladderNames[trn.PlanLadderID], ChangeLabel: changeLabel, ChangeTooltip: changeTooltip, ChangeKind: changeKind, FromLabel: tierLabel(trn.PlanLadderID, trn.FromRank), ToLabel: tierLabel(trn.PlanLadderID, trn.ToRank), ActorType: trn.ActorType, ActorName: actorName, ReasonLabel: reasonLabel, ReasonNote: reasonNote, ReasonRaw: reasonRaw, EffectiveAt: trn.EffectiveAt.Format("Jan 2, 2006 3:04 PM"), }) } } // ListTransitionsByPool sorts effective_at DESC (most recent first); the // loop above appends one pool's block at a time, so this is only a true // global recency order for the common single-active-pool org. The pager // windows the full set (tc_page; maintainer design round 2026-08-24 // replaced the flat 16-cap with the shared pager here). tcParams := ParseListParamsNS(r, "tc_", "") tcParams.PerPage = ParsePerPage(r, "tc_", embeddedListDefaultPerPage) data.Transitions, data.TransitionsTotal = pageSliceClamped(data.Transitions, &tcParams) data.TierChangesNav = ListNav{ BasePath: "/operator/organizations/" + orgID, ParamPrefix: "tc_", Page: tcParams.Page, Total: int64(data.TransitionsTotal), Extra: siblingListState(r, "tc_"), PerPage: tcParams.PerPage, DefaultPerPage: embeddedListDefaultPerPage, PerPageOptions: perPageOptions, Target: "#tier-changes-panel", SyncSelect: "#plan-grants-panel,#entitlement-changes-panel", } h.loadOrgEntitlementChanges(r, &data, orgID) // Single issuance form (Doc 41): every published, entitlement-set-bearing // product is issuable. data.IssuanceProducts, data.ProductsError = h.loadIssuanceProducts(r, pools) data.IssueGrantForm = issueGrantFormView(orgID, data.IssuanceProducts, forms.ModeRecord, forms.NewValues(), nil, "") // Billing summary section. Reads the first billing account for the org // and pulls its subscriptions + latest invoice + outstanding open // balance via three small queries (the LEFT JOIN LATERAL composite query // confused sqlc nullability inference; splitting is simpler and the join // count is dwarfed by the per-pool loops above). The subscription fetch // is plural because the card reports a count and a worst state, which a // single latest row cannot produce (design D5, round 5). accounts, err := h.BillingQ.ListBillingAccountsByOrgID(r.Context(), orgID) if err == nil && len(accounts) > 0 { acct := accounts[0] summary := BillingSummaryViewModel{ HasAccount: true, BillingAccountID: acct.BillingAccountID, AccountStatus: acct.Status, } if subs, err := h.BillingQ.GetSubscriptionsByBillingAccountID(r.Context(), acct.BillingAccountID); err == nil { summary.SubscriptionCount, summary.SubscriptionState, summary.SubscriptionNote = summarizeSubscriptions(subs) } if inv, err := h.BillingQ.GetLatestInvoiceByBillingAccountID(r.Context(), acct.BillingAccountID); err == nil { summary.HasInvoice = true summary.LatestInvoiceID = inv.InvoiceID summary.LatestInvoiceNumber = inv.InvoiceNumber.String summary.LatestInvoiceDate = inv.CreatedAt.Format("Jan 2, 2006") // The same derivation the invoices list renders, Overdue // included, so one invoice never reads two ways (design D5). summary.InvoiceState = invoiceStatusState(inv.Status, inv.AmountDue, inv.AmountPaid, invoiceIsOverdue(inv.Status, inv.DueDate)) summary.Currency = inv.Currency } if bal, err := h.BillingQ.GetOutstandingBalanceByBillingAccountID(r.Context(), acct.BillingAccountID); err == nil { summary.OutstandingBalance = bal } // The balance is always money, so it always reads as money: a zero // balance is "USD 0.00", never a bare 0 (design D5, round 5). bal is // a BIGINT SUM; formatCurrency takes int32 like every other amount // column in this codebase (Stripe's own amounts are int32). A // per-account open balance exceeding ~$21M would truncate here, // out of scope for this display fix (finding #6). An account that has // never been invoiced names no currency, so the code is trimmed off // rather than guessed at (the overview's money tile takes the same // line on an unknowable currency). summary.OutstandingBalanceFormatted = strings.TrimSpace(formatCurrency(int32(summary.OutstandingBalance), summary.Currency)) data.BillingSummary = summary } // Members section. Each row links to /operator/persons/{personID} // (closes the deferred 3.7 task from operator-mpa-conversion). members, err := h.OrgQ.ListActiveMembersByOrgID(r.Context(), orgID) if err != nil { h.Logger.Error("failed to list active members", slog.Any("error", err), slog.String("org_id", orgID)) data.MembersError = "Failed to load members." } else { // ListActiveMembersByOrgID sorts alphabetically by display name, not // by recency, so the "most recent" cap below needs its own sort by // JoinedAt (most recently joined first) rather than relying on // query order the way the grants and transitions lists do. sort.Slice(members, func(i, j int) bool { return members[i].JoinedAt.After(members[j].JoinedAt) }) capped, total, wasCapped := capEnrollmentList(members) data.MembersTotal = total data.MembersCapped = wasCapped data.Members = make([]MemberRowViewModel, len(capped)) for i, m := range capped { data.Members[i] = MemberRowViewModel{ PersonID: m.PersonID, DisplayName: m.DisplayName, Email: m.PrimaryEmail, RoleName: m.RoleName, JoinedAt: m.JoinedAt.Format("Jan 2, 2006"), } } } // Load every grant for the org with derived delivery state, via the // shared ListGrantsWithDelivery query (design decision 1: the grants // index and this composite render from the same derivation so they can // never disagree about what "live" / "superseded" / "inactive" mean — // see buildGrantViewModels). The ledger renders under two tabs // (maintainer design round 2026-08-24): Active (default) shows only // rows delivering right now; History shows every grant ever, with the // supersession audit trail. Search (product name or reason, both tabs) // and the pager window the set Go-side — org-scoped cardinality is // modest, and the delivery state is already derived per row. grantTabOptions := []FacetOption{{Value: "", Label: "Active"}, {Value: "history", Label: "History"}} gParams := ParseListParamsNS(r, "", "tab") gParams.Facet = ValidFacet(gParams.Facet, grantTabOptions) gParams.PerPage = ParsePerPage(r, "", embeddedListDefaultPerPage) orgUUID, err := uuid.Parse(orgID) if err != nil { h.Logger.Error("failed to parse org ID for grants query", slog.Any("error", err), slog.String("org_id", orgID)) data.GrantsError = "Failed to load grants." } else { grants, err := h.EntitlementsQ.ListGrantsWithDelivery(r.Context(), uuid.NullUUID{UUID: orgUUID, Valid: true}) if err != nil { h.Logger.Error("failed to list grants", slog.Any("error", err), slog.String("org_id", orgID)) data.GrantsError = "Failed to load grants." } else { // resolveOrgName=false: this composite is already scoped to one // named organization, so every row's OrgName would just repeat // it. ListGrantsWithDelivery orders live-first, then created_at // DESC (see queries/grants.sql). all := h.buildGrantViewModels(r, grants, false) filtered := make([]GrantViewModel, 0, len(all)) q := strings.ToLower(strings.TrimSpace(gParams.Q)) for _, g := range all { if gParams.Facet != "history" && g.DeliveryState != "live" { continue } if q != "" && !strings.Contains(strings.ToLower(g.ProductName), q) && !strings.Contains(strings.ToLower(g.GrantReason), q) { continue } filtered = append(filtered, g) } data.Grants, data.GrantsTotal = pageSliceClamped(filtered, &gParams) } } data.GrantsNav = ListNav{ BasePath: "/operator/organizations/" + orgID, SearchPlaceholder: "Search product or reason", FacetParam: "tab", FacetOptions: grantTabOptions, Q: gParams.Q, Facet: gParams.Facet, Page: gParams.Page, Total: int64(data.GrantsTotal), Extra: siblingListState(r, "", "tab"), PerPage: gParams.PerPage, DefaultPerPage: embeddedListDefaultPerPage, PerPageOptions: perPageOptions, Target: "#plan-grants-panel", SyncSelect: "#tier-changes-panel,#entitlement-changes-panel", } return data } // subscriptionHeldLadders returns the set of plan ladders currently occupied by // a live subscription-sourced provision on any of the org's pools. The issuance // form uses it to advise (non-blocking) when a grant would supersede a // subscription-held rung — the subscription keeps billing (§11.2 residue). Pure // read; best-effort (per-pool query failures are skipped, not surfaced). // loadIssuanceProducts lists every issuable product for the Issue grant // form's Product select: every published, entitlement-set-bearing product // (Doc 41: one form over all published products), decorated with the // internal and subscription-supersession markers the composite already // showed. pools is the org's resource pools, already loaded by the caller, // since the supersession check reads them. A load failure comes back as its // own message, the ProductsError contract loadOrgEnrollmentData renders. func (h *OperatorPartialsHandler) loadIssuanceProducts(r *http.Request, pools []entitlements.ResourcePool) ([]IssuanceProductOption, string) { subscriptionHeldLadders := h.subscriptionHeldLadders(r, pools) products, err := h.BillingQ.ListActiveProducts(r.Context()) if err != nil { h.Logger.Error("failed to list active products", slog.Any("error", err)) return nil, "Failed to load the product catalog. The issuance form is unavailable until this is resolved." } var out []IssuanceProductOption for _, p := range products { if p.LifecycleStatus != "published" || !p.EntitlementSetID.Valid { continue } opt := IssuanceProductOption{ ProductID: p.ProductID, Name: p.Name, IsInternal: !p.IsPublic, } if p.DisplayCategory.Valid { opt.DisplayCategory = p.DisplayCategory.String } if len(subscriptionHeldLadders) > 0 { if ladders, err := h.BillingQ.ListLaddersByProduct(r.Context(), p.ProductID); err == nil { for _, l := range ladders { if subscriptionHeldLadders[l.PlanLadderID] { opt.SupersedesSubscription = true break } } } } out = append(out, opt) } return out, "" } func (h *OperatorPartialsHandler) subscriptionHeldLadders(r *http.Request, pools []entitlements.ResourcePool) map[string]bool { held := map[string]bool{} for _, pool := range pools { provisions, err := h.EntitlementsQ.GetActivePoolProvisionsByPoolID(r.Context(), pool.PoolID) if err != nil { continue } for _, p := range provisions { if !p.SubscriptionID.Valid { continue } attachments, err := h.EntitlementsQ.GetLadderAttachmentsByProvision(r.Context(), p.ProvisionID) if err != nil { continue } for _, a := range attachments { if a.Status != "ended" { held[a.PlanLadderID] = true } } } } return held } // loadOrgEntitlementChanges fills the composite's Entitlement changes // section: one page of the effect rows that moved this organization's pools, // newest first, under the ec_ prefix so its links cannot clobber the // sections beside it. Best-effort, like the composite's other sections: a // query failure renders the section's own alert and the rest of the page // stands. func (h *OperatorPartialsHandler) loadOrgEntitlementChanges(r *http.Request, data *OrgEnrollmentData, orgID string) { params := ParseListParamsNS(r, "ec_", "") params.PerPage = ParsePerPage(r, "ec_", embeddedListDefaultPerPage) rows, total, err := FetchPage(¶ms, func(limit, offset int32) ([]entitlements.ListEntitlementSetChangeEffectsByOrgRow, int64, error) { page, err := h.EntitlementsQ.ListEntitlementSetChangeEffectsByOrg(r.Context(), entitlements.ListEntitlementSetChangeEffectsByOrgParams{ OrgID: orgID, Limit: limit, Offset: offset, }) if err != nil { return nil, 0, err } count, err := h.EntitlementsQ.CountEntitlementSetChangeEffectsByOrg(r.Context(), orgID) if err != nil { return nil, 0, err } return page, count, nil }) if err != nil { h.Logger.Error("failed to load entitlement changes for organization", slog.Any("error", err), slog.String("org_id", orgID)) data.EntitlementChangesError = "Failed to load entitlement changes." rows, total = nil, 0 } data.EntitlementChangesTotal = int(total) data.EntitlementChanges = make([]EntitlementChangeEffectViewModel, 0, len(rows)) for _, row := range rows { vm := EntitlementChangeEffectViewModel{ EffectID: row.EffectID, SetID: row.SetID, SetName: row.SetName, ResourceLabel: row.ResourceLabel, ResourceKey: row.ResourceKey, LimitLine: effectLimitLine(row), Note: row.Note.String, ActorType: row.ActorType, When: row.CreatedAt.Format("Jan 2, 2006 3:04 PM"), } if row.ActorPersonID.Valid && h.IdentityQ != nil { if person, err := h.IdentityQ.GetPersonByID(r.Context(), row.ActorPersonID.UUID.String()); err == nil { vm.ActorName = person.DisplayName } } data.EntitlementChanges = append(data.EntitlementChanges, vm) } data.EntitlementChangesNav = ListNav{ BasePath: "/operator/organizations/" + orgID, ParamPrefix: "ec_", Page: params.Page, Total: total, Extra: siblingListState(r, "ec_"), PerPage: params.PerPage, DefaultPerPage: embeddedListDefaultPerPage, PerPageOptions: perPageOptions, Target: "#entitlement-changes-panel", SyncSelect: "#plan-grants-panel,#tier-changes-panel", } } // effectLimitLine renders the Limit cell: the numbers a limit moved // between, or the boolean key's own yes/no, in the vocabulary the set's // History already uses. func effectLimitLine(row entitlements.ListEntitlementSetChangeEffectsByOrgRow) string { if row.GrantedBefore.Valid || row.GrantedAfter.Valid { return boolLabel(row.GrantedBefore) + " to " + boolLabel(row.GrantedAfter) } return limitLabel(row.LimitBefore) + " to " + limitLabel(row.LimitAfter) }