Files
member-console/internal/server/operator_enrollment.go
T
cgalo5758 3727ff31d8 Add entitlement set rule change ledger and preview flow
Add an append-only ledger of entitlement set rule changes with per-pool
effect rows, a preview-and-commit rule change flow, and an automatic
drain that settles deferred recomputations. Rules gain a tier reduction
policy, resource keys declare over-limit behavior, and the materializer
now lowers limits when a rule stops applying.
Add entitlement set rule change ledger and preview flow

Add an append-only ledger of entitlement set rule changes with a
preview-and-commit operator flow. Rule writes now go through an enclosed
`core.commit_rule_change` function that files an act row and one
obligation per carrying pool, with a drain workflow settling deferred
recomputations. The preview dry-runs the materializer with a rule
overlay and renders per-pool buckets, reduction-policy disclosures, and
provider over-limit consequences. Materializing transactions take a
shared advisory rendezvous that rule changes hold exclusively, enforced
by a possession assertion. Add History and Entitlement changes surfaces,
a rule-less warning on five product-selection surfaces, and a
`tier_reduction_policy` column that gates FedWiki parking.
2026-09-15 03:53:28 -05:00

1714 lines
73 KiB
Go

// 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 `<span title="...">` 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 `<span title="...">` 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(&params, 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)
}