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.
1714 lines
73 KiB
Go
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(¶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)
|
|
}
|