Files
member-console/internal/server/member_products.go
T
cgalo5758 8e3c68c6be Make UI surfaces honestly reflect system state
- Add deployment-name branding to titles, mastheads, and OG tags
- Share one grant delivery-state query with lineage across grants
  surfaces
- Show pool status/usage, org owners, and config readiness
- Make billing views projection-aware with recency and sync vocabulary
- Guard FedWiki creation without domains and render route-aware 404s
2026-08-23 01:45:52 -05:00

987 lines
38 KiB
Go

package server
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"html/template"
"io/fs"
"log/slog"
"net/http"
"strings"
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/embeds"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/fulfillment"
internalstripe "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store"
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
"github.com/google/uuid"
)
// MemberProductsHandler handles HTMX partial requests for the member Products page.
type MemberProductsHandler struct {
EntitlementsQ entitlements.Querier
BillingQ billing.Querier
AuthConfig *auth.Config
Logger *slog.Logger
Templates *SafeTemplates
// Database is used to resolve Stripe price mappings when determining
// whether a plan is purchasable. May be nil (e.g. in tests or when Stripe
// is not configured), in which case plans are treated as not purchasable.
Database *sql.DB
}
// MemberProductsConfig holds configuration for the member products handler.
type MemberProductsConfig struct {
EntitlementsQ entitlements.Querier
BillingQ billing.Querier
AuthConfig *auth.Config
Logger *slog.Logger
Database *sql.DB
}
// NewMemberProductsHandler creates a new MemberProductsHandler.
func NewMemberProductsHandler(cfg MemberProductsConfig) (*MemberProductsHandler, error) {
templateSubFS, err := fs.Sub(embeds.Templates, "templates/partials")
if err != nil {
return nil, err
}
tmpl, err := template.New("member").Funcs(template.FuncMap{
"routeURL": web.RouteURL,
}).ParseFS(templateSubFS, "member_*.html")
if err != nil {
return nil, err
}
return &MemberProductsHandler{
EntitlementsQ: cfg.EntitlementsQ,
BillingQ: cfg.BillingQ,
AuthConfig: cfg.AuthConfig,
Logger: cfg.Logger,
Templates: NewSafeTemplates(tmpl, cfg.Logger),
Database: cfg.Database,
}, nil
}
// RegisterRoutes registers member product routes.
func (h *MemberProductsHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /partials/member/entitlements", h.GetEntitlements)
mux.HandleFunc("GET /partials/member/plans", h.GetPlans)
mux.HandleFunc("GET /partials/member/addons", h.GetAddons)
mux.HandleFunc("GET /partials/member/plans/switch/preview", h.GetSwitchPreview)
mux.HandleFunc("POST /partials/member/plans/switch", h.PostSwitch)
mux.HandleFunc("POST /partials/member/plans/cancel", h.PostCancel)
}
// --- View models ---
// EntitlementViewModel represents a single resource entitlement for template rendering.
type EntitlementViewModel struct {
ResourceKey string
// Label is the human-friendly display name from core.resource_keys;
// resolveResourceLabels falls back to the raw ResourceKey when no
// metadata row exists, so templates can always lead with Label.
Label string
// IntegrationName attributes the resource to its owning integration
// (core.providers.display_name via resource_keys.provider); empty for
// platform-owned keys or when metadata is missing.
IntegrationName string
ResourceLimit int64
CurrentUsage int64
}
// BooleanEntitlementViewModel represents a granted boolean entitlement
// (access conferred or not — no limit/usage pair) for template rendering.
// Only granted rows are surfaced; conferred-then-lapsed rows are not
// member-visible.
type BooleanEntitlementViewModel struct {
ResourceKey string
Label string // same fallback contract as EntitlementViewModel.Label
IntegrationName string
}
// resourceLabel is one resource key's display metadata, keyed for lookup by
// resolveResourceLabels.
type resourceLabel struct {
Label string
IntegrationName string
}
// resolveResourceLabels loads the resource-key display map, degrading to an
// empty map on error: labels are presentation garnish, and their lookup
// failing must never take the entitlement view down with it — affected keys
// simply render raw and un-attributed via the labelFor fallback.
func resolveResourceLabels(ctx context.Context, q entitlements.Querier, logger *slog.Logger) map[string]resourceLabel {
labels := make(map[string]resourceLabel)
rows, err := q.ListResourceKeyLabels(ctx)
if err != nil {
logger.Error("failed to load resource key labels", slog.Any("error", err))
return labels
}
for _, row := range rows {
labels[row.ResourceKey] = resourceLabel{
Label: row.DisplayName,
IntegrationName: row.ProviderDisplayName,
}
}
return labels
}
// labelFor resolves a resource key against the display map with the
// raw-key fallback the specs require (a half-registered key must render,
// not error).
func labelFor(labels map[string]resourceLabel, key string) resourceLabel {
if l, ok := labels[key]; ok && l.Label != "" {
return l
}
return resourceLabel{Label: key}
}
// EntitlementSourceViewModel represents an entitlement source for template rendering.
type EntitlementSourceViewModel struct {
Reason string
ProductName string
ValidFrom string
}
// PoolTierViewModel represents a pool's current tier for member display.
type PoolTierViewModel struct {
PoolName string
LadderKey string
TierName string
HasTier bool
}
// EntitlementsData holds data for the member_entitlements.html partial.
type EntitlementsData struct {
// Enrolled is true when the org holds a rung on at least one ladder (a
// pool_provision_ladders attachment), independent of whether that tier's
// entitlement set carries any numeric entitlements. The template shows
// the tier badge and Sources list when Enrolled OR HasEntitlements — a
// boolean/metadata-only plan still enrolls the member and should not
// fall into the "no entitlements" empty state alongside a genuinely
// off-plan member with no numeric entitlements either.
Enrolled bool
HasEntitlements bool
Entitlements []EntitlementViewModel
// BooleanEntitlements lists the pool's granted boolean entitlements as
// "included" rows (no usage meter). Granted booleans count toward
// HasEntitlements: a boolean-only plan is not the no-entitlements empty
// state.
BooleanEntitlements []BooleanEntitlementViewModel
Sources []EntitlementSourceViewModel
PoolTiers []PoolTierViewModel
Error string
}
// TierViewModel represents one plan tier (a rung on a ladder) for rendering.
// "Current" is no longer a binary flag on a flat list — each tier carries its
// Relation to the member's current rung *on this ladder*.
type TierViewModel struct {
ProductID string
Name string
Description string
Rank int32
// Relation is the move this tier represents relative to the current rung on
// its ladder: "current", "upgrade", "downgrade", or "available" (the member
// is not yet on this ladder).
Relation string
EntitlementFeatures []string
MetadataFeatures []string
// PriceID is the tier's active price, submitted to checkout. Empty when the
// tier has no active price.
PriceID string
// Purchasable is true only when the tier has an active price with a synced
// Stripe price mapping.
Purchasable bool
// MoveEnabled is true when the move's billing mechanism is built and usable.
// With plan-switch-mechanics this covers free/default→paid (Checkout),
// paid→paid (switch) and paid→free (cancel); it is false only when a target
// is not purchasable.
MoveEnabled bool
// MoveKind selects the built mechanism and endpoint: "checkout" (free/default
// → first paid), "switch" (paid → another paid rung) or "cancel" (paid →
// free/default rung). Empty for the current rung.
MoveKind string
// MoveLadderID is the plan_ladder_id submitted by switch/cancel moves so the
// endpoint can resolve the member's active subscription on this axis.
MoveLadderID string
// MoveLabel is the control's caption ("Upgrade" / "Downgrade" / "Subscribe").
// Empty for the current rung, which shows no control.
MoveLabel string
// DisabledReason explains why a shown-but-disabled control cannot be used.
// Empty when MoveEnabled or for the current rung.
DisabledReason string
// BillingPriceText is the recurring price the org is actually paying for
// its current subscription on this tier (e.g. "$12.00/month"), sourced
// from the backing subscription's item, not the tier's list price — set
// only when Relation == "current" and the current rung is
// subscription-backed. Empty for a free/default or grant-issued current
// rung, which carries no subscription to report (member-product-discovery
// delta: "the member's current plan shows its cost and renewal").
BillingPriceText string
// BillingRenewsOn is the subscription's current period end, formatted for
// display. Set alongside BillingPriceText.
BillingRenewsOn string
// BillingPending is true when the current rung is subscription-backed
// (the pool_provision names a subscription) but that subscription's
// price or period could not yet be resolved — checkout completed but the
// webhook/eager-reconcile projection has not landed. The template must
// say billing details are still processing rather than rendering as if
// no price or renewal exists.
BillingPending bool
}
// LadderViewModel groups a ladder's tiers (a service axis) for rendering.
type LadderViewModel struct {
Key string
Name string
SortOrder int32
Tiers []TierViewModel
// Enrolled is true when the member holds a rung on this ladder.
Enrolled bool
// CancelsOn, when non-empty, is a formatted date rendered as a "cancels
// on <date>" badge next to the ladder — the member's subscription on
// this ladder has an armed cancellation (period-end or a commitment
// deferred to its boundary) that has not yet taken effect. Empty when
// not enrolled or no cancellation is pending.
CancelsOn string
}
// PlansData holds data for the member_plans.html partial.
type PlansData struct {
Ladders []LadderViewModel
Error string
// Success, when set, renders a confirmation banner above the ladders
// after a switch/cancel move completes — without it a successful
// cancel-at-period-end re-renders pixel-identical to a no-op.
Success string
// Preview, when set, renders a switch confirmation banner above the ladders
// with the proration estimate and Confirm / Keep-current controls.
Preview *SwitchPreviewBanner
}
// SwitchPreviewBanner is the confirmation prompt shown before a paid→paid switch,
// carrying the proration estimate and the target the Confirm button submits.
type SwitchPreviewBanner struct {
LadderID string
PriceID string
TierName string
Available bool
// AmountText is the formatted absolute estimate (e.g. "$12.00"). Empty when
// not Available.
AmountText string
// IsCredit is true when the net proration is a credit to the member.
IsCredit bool
}
// AddonViewModel represents an add-on product for template rendering.
type AddonViewModel struct {
ProductID string
Name string
Description string
// PriceID is the add-on's default price, submitted to checkout. Empty
// when the add-on has no active price.
PriceID string
// Purchasable is true only when the add-on has an active price with a
// synced Stripe price mapping — the same shared price+mapping gate the
// plan catalog and the operator readiness panel use (resolvePurchasable /
// computePriceReadiness), so the control can never disagree with the
// operator's "Purchasable" verdict.
Purchasable bool
}
// AddonsData holds data for the member_addons.html partial.
type AddonsData struct {
Addons []AddonViewModel
Error string
}
// --- Handlers ---
// GetEntitlements handles GET /partials/member/entitlements
func (h *MemberProductsHandler) GetEntitlements(w http.ResponseWriter, r *http.Request) {
session := h.AuthConfig.GetUserSession(r.Context())
if session == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
data := EntitlementsData{}
// Get the org's default pool
pool, err := h.EntitlementsQ.GetDefaultPoolByOrgID(r.Context(), session.OrgID)
if err != nil {
h.Logger.Debug("no default pool found", slog.String("org_id", session.OrgID), slog.Any("error", err))
h.Templates.Render(w, "member_entitlements.html", data)
return
}
// Get all numeric entitlements for the pool
ents, err := h.EntitlementsQ.ListNumericEntitlementsByPoolID(r.Context(), pool.PoolID)
if err != nil {
h.Logger.Error("failed to list entitlements", slog.Any("error", err))
data.Error = "Failed to load entitlements"
h.Templates.Render(w, "member_entitlements.html", data)
return
}
// Get usage records for the pool
usages, err := h.EntitlementsQ.ListNumericEntitlementUsageByPoolID(r.Context(), pool.PoolID)
if err != nil {
h.Logger.Error("failed to list usage", slog.Any("error", err))
data.Error = "Failed to load usage data"
h.Templates.Render(w, "member_entitlements.html", data)
return
}
// Build usage map keyed by entitlement_id
usageMap := make(map[string]int64, len(usages))
for _, u := range usages {
usageMap[u.EntitlementID] = u.CurrentUsage
}
// Display metadata for both numeric and boolean rows below.
labels := resolveResourceLabels(r.Context(), h.EntitlementsQ, h.Logger)
// Build entitlement view models
if len(ents) > 0 {
data.HasEntitlements = true
data.Entitlements = make([]EntitlementViewModel, len(ents))
for i, ent := range ents {
usage := usageMap[ent.EntitlementID]
label := labelFor(labels, ent.ResourceKey)
data.Entitlements[i] = EntitlementViewModel{
ResourceKey: ent.ResourceKey,
Label: label.Label,
IntegrationName: label.IntegrationName,
ResourceLimit: ent.ResourceLimit,
CurrentUsage: usage,
}
}
}
// Granted boolean entitlements render as "included" rows. Best-effort:
// a lookup failure logs and leaves the section empty rather than
// failing the whole view (the numeric section above already rendered).
if bools, err := h.EntitlementsQ.ListBooleanEntitlementsByPoolID(r.Context(), pool.PoolID); err != nil {
h.Logger.Error("failed to list boolean entitlements", slog.Any("error", err))
} else {
for _, b := range bools {
if !b.Granted {
continue // conferred-then-lapsed: not member-visible
}
label := labelFor(labels, b.ResourceKey)
data.BooleanEntitlements = append(data.BooleanEntitlements, BooleanEntitlementViewModel{
ResourceKey: b.ResourceKey,
Label: label.Label,
IntegrationName: label.IntegrationName,
})
}
if len(data.BooleanEntitlements) > 0 {
data.HasEntitlements = true
}
}
// Get grant sources for attribution. The Sources panel is meant to
// answer "where do my current entitlements come from?" — so we use
// the strict delivering-grants query (inner-joins to active
// pool_provisions) rather than ListGrantsByOrgID + status filter.
// The latter conflates grants.status='active' ("never revoked") with
// operational delivery, and would surface superseded historical
// grants that no longer contribute anything. See
// design/entitlements/model.md §pool_provisions for the lifecycle
// split and ListDeliveringGrantsByOrgID for the join.
orgUUID, err := uuid.Parse(session.OrgID)
if err == nil {
grants, err := h.EntitlementsQ.ListDeliveringGrantsByOrgID(r.Context(), uuid.NullUUID{UUID: orgUUID, Valid: true})
if err == nil {
for _, g := range grants {
source := EntitlementSourceViewModel{
Reason: g.GrantReason,
ValidFrom: g.ValidFrom.Format("Jan 2, 2006"),
}
// Every grant is product-backed (Doc 41): resolve its name.
if product, err := h.BillingQ.GetProductByID(r.Context(), g.ProductID); err == nil {
source.ProductName = product.Name
}
data.Sources = append(data.Sources, source)
}
}
}
// Resolve active ladder attachments for each pool. "Current" is a set — one
// rung per enrolled ladder — so we emit one row per active attachment, not
// just the first. A pool with no attachment gets a single off-plan row. We
// list only enrolled axes; discovering unjoined ladders is the catalog's job.
pools, err := h.EntitlementsQ.GetResourcePoolsByOrgID(r.Context(), session.OrgID)
if err == nil {
for _, pool := range pools {
attachments, err := h.EntitlementsQ.GetActiveAttachmentsByPool(r.Context(), pool.PoolID)
if err != nil || len(attachments) == 0 {
data.PoolTiers = append(data.PoolTiers, PoolTierViewModel{PoolName: pool.Name})
continue
}
for _, att := range attachments {
vm := PoolTierViewModel{PoolName: pool.Name, HasTier: true}
if product, err := h.BillingQ.GetProductByID(r.Context(), att.ProductID); err == nil {
vm.TierName = product.Name
}
if ladder, err := h.BillingQ.GetPlanLadderByID(r.Context(), att.PlanLadderID); err == nil {
vm.LadderKey = ladder.LadderKey
}
data.PoolTiers = append(data.PoolTiers, vm)
data.Enrolled = true
}
}
}
h.Templates.Render(w, "member_entitlements.html", data)
}
// GetPlans handles GET /partials/member/plans
func (h *MemberProductsHandler) GetPlans(w http.ResponseWriter, r *http.Request) {
session := h.AuthConfig.GetUserSession(r.Context())
if session == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
h.Templates.Render(w, "member_plans.html", h.buildPlansData(r.Context(), session.OrgID))
}
// buildPlansData assembles the ladder-grouped plans view model for an org. It is
// shared by GetPlans and the switch/cancel action handlers, which re-render the
// catalog after performing a move.
func (h *MemberProductsHandler) buildPlansData(ctx context.Context, orgID string) PlansData {
data := PlansData{}
// Resolved once for every tier's feature lines: the catalog must agree
// with the entitlement view on resource-key display names (raw keys like
// "fedwiki_sites" never render when a display name exists).
labels := resolveResourceLabels(ctx, h.EntitlementsQ, h.Logger)
// Current rung per ladder, from active pool_provision_ladders attachments
// across the org's pools. Keyed by ladder (not a product→bool set) so the
// same product can be current on one axis and a move target on another.
// Source-agnostic: an attachment marks the current rung whether the
// provision is grant-, subscription-, or purchase-linked
// (chk_pool_provisions_source) — the grant-only join missed paid upgrades.
currentByLadder := make(map[string]string) // planLadderID -> productID
// provisionByLadder tracks the backing pool_provisions row for each
// enrolled ladder, so a pending cancellation (finding #22) can be
// resolved against its subscription without a second pool/attachment
// scan.
provisionByLadder := make(map[string]string) // planLadderID -> provisionID
if pools, err := h.EntitlementsQ.GetResourcePoolsByOrgID(ctx, orgID); err == nil {
for _, pool := range pools {
if attachments, err := h.EntitlementsQ.GetActiveAttachmentsByPool(ctx, pool.PoolID); err == nil {
for _, att := range attachments {
currentByLadder[att.PlanLadderID] = att.ProductID
provisionByLadder[att.PlanLadderID] = att.ProvisionID
}
}
}
}
// Public plan products by id, for description/feature/price lookup. A tier is
// rendered only when its product is a public plan, which filters out
// non-public or retired tier rows. Plan-ness is structural (ladder tier
// membership), so we do not read products.display_category here.
planByID := make(map[string]billing.Product)
if planProducts, err := h.BillingQ.ListPublicPlanProducts(ctx); err == nil {
for _, p := range planProducts {
planByID[p.ProductID] = p
}
} else {
h.Logger.Error("failed to list plan products", slog.Any("error", err))
}
ladders, err := h.BillingQ.ListPlanLadders(ctx)
if err != nil {
h.Logger.Error("failed to list plan ladders", slog.Any("error", err))
data.Error = "Failed to load plans"
return data
}
// Ladders arrive ordered by (sort_order, name) from the query. A product
// that is a tier in multiple ladders renders under each, with its
// current/move state computed per (pool, ladder).
for _, ladder := range ladders {
tiers, err := h.BillingQ.ListTiersByLadderWithProducts(ctx, ladder.PlanLadderID)
if err != nil {
h.Logger.Error("failed to list ladder tiers",
slog.String("ladder", ladder.LadderKey), slog.Any("error", err))
continue
}
currentProductID := currentByLadder[ladder.PlanLadderID]
enrolled := currentProductID != ""
// Current rank on this ladder, used to derive move direction.
var currentRank int32
if enrolled {
for _, t := range tiers {
if t.ProductID == currentProductID {
currentRank = t.Rank
break
}
}
}
lvm := LadderViewModel{
Key: ladder.LadderKey,
Name: ladder.Name,
SortOrder: ladder.SortOrder,
Enrolled: enrolled,
}
if enrolled {
lvm.CancelsOn = h.resolvePendingCancelDate(ctx, provisionByLadder[ladder.PlanLadderID])
}
for _, t := range tiers {
isCurrent := enrolled && t.ProductID == currentProductID
product, ok := planByID[t.ProductID]
if !ok {
if !isCurrent {
continue // tier product is not a public plan
}
// The member's current rung was excluded by the published-only
// listing query (the operator drafted or retired it after the
// org enrolled) — fetch it directly so the org keeps seeing
// what it is actually on. Non-current unpublished tiers stay
// hidden by the continue above.
fetched, err := h.BillingQ.GetProductByID(ctx, t.ProductID)
if err != nil {
h.Logger.Error("failed to load current-rung product",
slog.String("product_id", t.ProductID), slog.Any("error", err))
continue
}
product = fetched
}
tier := TierViewModel{
ProductID: t.ProductID,
Name: t.ProductName,
Description: product.Description.String,
Rank: t.Rank,
}
tier.EntitlementFeatures, tier.MetadataFeatures = h.buildPlanFeatures(ctx, product, labels)
switch {
case isCurrent:
tier.Relation = "current"
tier.BillingPriceText, tier.BillingRenewsOn, tier.BillingPending =
h.resolveCurrentPlanBilling(ctx, provisionByLadder[ladder.PlanLadderID])
case !enrolled:
tier.Relation = "available"
case t.Rank > currentRank:
tier.Relation = "upgrade"
default:
tier.Relation = "downgrade"
}
if tier.Relation != "current" {
tier.PriceID, tier.Purchasable = h.resolvePurchasable(ctx, t.ProductID)
tier.MoveLadderID = ladder.PlanLadderID
switch tier.Relation {
case "available":
tier.MoveLabel = "Subscribe"
case "upgrade":
tier.MoveLabel = "Upgrade"
case "downgrade":
tier.MoveLabel = "Downgrade"
}
// Route each move to its built mechanism (plan-switch-mechanics):
// free/default → first paid : Checkout (creates the subscription)
// paid → another paid rung : in-place switch (Stripe proration)
// paid → free/default rung : cancel (rank-0 downgrade)
// Commitment policy (block/fee) is enforced by the endpoints; the
// all-evergreen deployment exercises only the immediate path.
switch {
case tier.Relation == "available" || (tier.Relation == "upgrade" && currentRank == 0):
tier.MoveKind = "checkout"
if tier.Purchasable {
tier.MoveEnabled = true
} else {
tier.DisabledReason = "Not available for purchase yet"
}
case tier.Rank == 0:
// Leaving the paid ladder for its free/default rung.
tier.MoveKind = "cancel"
tier.MoveEnabled = true
default:
// Paid → another paid rung.
tier.MoveKind = "switch"
if tier.Purchasable {
tier.MoveEnabled = true
} else {
tier.DisabledReason = "Not available for purchase yet"
}
}
}
lvm.Tiers = append(lvm.Tiers, tier)
}
if len(lvm.Tiers) == 0 {
continue // no public tiers to show for this ladder
}
data.Ladders = append(data.Ladders, lvm)
}
return data
}
// GetSwitchPreview handles GET /partials/member/plans/switch/preview — it renders
// the plans partial with a confirmation banner carrying the proration estimate
// for the proposed paid→paid switch. The estimate is best-effort; the banner
// still lets the member confirm if Stripe could not produce one.
func (h *MemberProductsHandler) GetSwitchPreview(w http.ResponseWriter, r *http.Request) {
session := h.AuthConfig.GetUserSession(r.Context())
if session == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if h.Database == nil {
http.Error(w, "billing not configured", http.StatusServiceUnavailable)
return
}
ladderID := r.FormValue("ladder_id")
priceID := r.FormValue("price_id")
if ladderID == "" || priceID == "" {
http.Error(w, "ladder_id and price_id are required", http.StatusBadRequest)
return
}
// Mirror the checkout IsActive guard (billing.go) on the switch path: a
// price an operator deactivated after the tab rendered its button must
// not be offered as a switch target, even in preview (finding #28).
price, err := h.BillingQ.GetPrice(r.Context(), priceID)
if err != nil || !price.IsActive {
h.renderPlansAfterMove(w, r.Context(), session.OrgID, fulfillment.ErrPriceInactive, "switch-preview")
return
}
banner := &SwitchPreviewBanner{LadderID: ladderID, PriceID: priceID}
if product, err := h.BillingQ.GetProductByID(r.Context(), price.ProductID); err == nil {
banner.TierName = product.Name
}
preview := fulfillment.PreviewSwitch(r.Context(), h.Database, h.Logger, session.OrgID, ladderID, priceID)
if preview.Available {
banner.Available = true
banner.IsCredit = preview.NetAmount < 0
banner.AmountText = formatMoney(preview.NetAmount, preview.Currency)
}
data := h.buildPlansData(r.Context(), session.OrgID)
data.Preview = banner
h.Templates.Render(w, "member_plans.html", data)
}
// formatMoney renders a smallest-unit amount as a human string, with the
// same thousands grouping as formatCount ("$2,295.00") so member banners and
// the operator overview's money headline read identically. The sign is
// dropped (callers describe credit vs charge in copy); only the magnitude
// shows.
func formatMoney(amount int64, currency string) string {
if amount < 0 {
amount = -amount
}
text := fmt.Sprintf("%s.%02d", formatCount(amount/100), amount%100)
switch cur := strings.ToUpper(currency); cur {
case "", "USD":
return "$" + text
default:
return text + " " + cur
}
}
// PostSwitch handles POST /partials/member/plans/switch — an in-place paid→paid
// move to the target tier's price. On success or a handled error it re-renders
// the plans partial (swapped into #member-plans).
func (h *MemberProductsHandler) PostSwitch(w http.ResponseWriter, r *http.Request) {
session := h.AuthConfig.GetUserSession(r.Context())
if session == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if h.Database == nil {
http.Error(w, "billing not configured", http.StatusServiceUnavailable)
return
}
ladderID := r.FormValue("ladder_id")
priceID := r.FormValue("price_id")
if ladderID == "" || priceID == "" {
http.Error(w, "ladder_id and price_id are required", http.StatusBadRequest)
return
}
err := fulfillment.SwitchPlan(r.Context(), h.Database, h.Logger, session.OrgID, ladderID, priceID, "member:switch")
h.renderPlansAfterMove(w, r.Context(), session.OrgID, err, "switch")
}
// PostCancel handles POST /partials/member/plans/cancel — a paid→free move that
// cancels the member's active subscription on the ladder. timing defaults to
// cancel-at-period-end.
func (h *MemberProductsHandler) PostCancel(w http.ResponseWriter, r *http.Request) {
session := h.AuthConfig.GetUserSession(r.Context())
if session == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if h.Database == nil {
http.Error(w, "billing not configured", http.StatusServiceUnavailable)
return
}
ladderID := r.FormValue("ladder_id")
if ladderID == "" {
http.Error(w, "ladder_id is required", http.StatusBadRequest)
return
}
timing := fulfillment.CancelAtPeriodEnd
if r.FormValue("timing") == "immediate" {
timing = fulfillment.CancelImmediate
}
err := fulfillment.CancelSubscription(r.Context(), h.Database, h.Logger, session.OrgID, ladderID, timing, "member:cancel")
h.renderPlansAfterMove(w, r.Context(), session.OrgID, err, "cancel")
}
// renderPlansAfterMove re-renders the plans partial after a switch/cancel,
// surfacing a friendly banner either way: an error explaining why the move
// could not be completed, or a success confirmation so a completed
// cancel-at-period-end isn't indistinguishable from a no-op (finding #22).
// On success it also fires an entitlements-changed HX-Trigger so the
// Entitlements panel elsewhere on the page refreshes instead of going stale
// (finding #7) — this must happen before Render, which starts the response
// body and locks the header.
func (h *MemberProductsHandler) renderPlansAfterMove(w http.ResponseWriter, ctx context.Context, orgID string, moveErr error, action string) {
data := h.buildPlansData(ctx, orgID)
if moveErr != nil {
h.Logger.Error("plan move failed", slog.String("action", action), slog.Any("error", moveErr))
switch {
case errors.Is(moveErr, fulfillment.ErrCommitmentFee):
data.Error = "This change isn't available under your current commitment terms."
case errors.Is(moveErr, fulfillment.ErrNoActivePaidSubscription):
data.Error = "There's no active subscription to change on this plan."
case errors.Is(moveErr, fulfillment.ErrSameTier):
data.Error = "You're already on that tier."
case errors.Is(moveErr, fulfillment.ErrPriceInactive):
data.Error = "That plan is no longer available. Please refresh the page and choose a current plan."
default:
data.Error = "We couldn't complete that change. Please try again in a moment."
}
} else {
switch action {
case "switch":
// Worded to hold for both the immediate path (the tier's "Current
// plan" badge below already confirms it landed) and the
// commitment-'block' deferred path, which schedules rather than
// applies the switch now — not currently reachable in the
// all-evergreen deployment, but this copy stays true either way.
data.Success = "Your plan change has been submitted."
case "cancel":
// Only the period-end and commitment-boundary-deferred timings are
// reachable from the UI today (the cancel control never sends
// timing=immediate), so this holds without inspecting which path
// CancelSubscription took.
data.Success = "Your cancellation is scheduled — see the plan below for the effective date."
}
w.Header().Set("HX-Trigger", `{"entitlements-changed": true}`)
}
h.Templates.Render(w, "member_plans.html", data)
}
// resolvePendingCancelDate returns a formatted date for an armed cancellation
// on the subscription backing provisionID, or "" when the provision is not
// subscription-backed or carries no pending cancellation. It checks both the
// immediate/period-end path (subscriptions.cancel_at_period_end, synced from
// Stripe via reconcile) and a commitment-deferred cancellation, which is
// recorded only as a scheduled change and never touches Stripe's
// cancel_at_period_end until it fires at the boundary.
func (h *MemberProductsHandler) resolvePendingCancelDate(ctx context.Context, provisionID string) string {
if provisionID == "" {
return ""
}
prov, err := h.EntitlementsQ.GetPoolProvisionByProvisionID(ctx, provisionID)
if err != nil || !prov.SubscriptionID.Valid {
return ""
}
subID := prov.SubscriptionID.UUID.String()
sub, err := h.BillingQ.GetSubscriptionByID(ctx, subID)
if err != nil {
return ""
}
if sub.CancelAtPeriodEnd && sub.CurrentPeriodEnd.Valid {
return sub.CurrentPeriodEnd.Time.Format("Jan 2, 2006")
}
changes, err := h.BillingQ.ListActiveScheduledChangesBySubscription(ctx, subID)
if err != nil {
return ""
}
for _, c := range changes {
if c.ChangeType == "cancellation" {
return c.EffectiveAt.Format("Jan 2, 2006")
}
}
return ""
}
// resolveCurrentPlanBilling resolves the recurring price and renewal date the
// member's organization is actually paying for the current tier on a ladder,
// read from the pool_provision's backing subscription (member-product-
// discovery delta: "the member's current plan shows its cost and renewal").
//
// Empty/false results mean the current tier carries no subscription at all —
// the free/default rung, or a paid tier an operator issued as a grant rather
// than through Stripe — and nothing renders; that is not billing information
// silently omitted, there genuinely is none. pending=true means the tier IS
// subscription-backed (the provision names a subscription_id) but its price
// or period could not be resolved yet: checkout completed and the ladder
// attachment landed, but the subscription's projection has not (still
// processing, or a data point on it did not resolve). The template must say
// so rather than rendering as if no price or renewal exists.
func (h *MemberProductsHandler) resolveCurrentPlanBilling(ctx context.Context, provisionID string) (priceText, renewsOn string, pending bool) {
if provisionID == "" {
return "", "", false
}
prov, err := h.EntitlementsQ.GetPoolProvisionByProvisionID(ctx, provisionID)
if err != nil || !prov.SubscriptionID.Valid {
return "", "", false
}
subID := prov.SubscriptionID.UUID.String()
sub, err := h.BillingQ.GetSubscriptionByID(ctx, subID)
if err != nil {
// The provision names a subscription, but it cannot be read yet.
return "", "", true
}
if !sub.CurrentPeriodEnd.Valid {
return "", "", true
}
renewsOn = sub.CurrentPeriodEnd.Time.Format("Jan 2, 2006")
items, err := h.BillingQ.GetSubscriptionItemsBySubscriptionID(ctx, subID)
if err != nil || len(items) == 0 {
return "", renewsOn, true
}
price, err := h.BillingQ.GetPrice(ctx, items[0].PriceID)
if err != nil {
return "", renewsOn, true
}
priceText = formatMoney(int64(price.UnitAmount), price.Currency)
if price.RecurringInterval.Valid && price.RecurringInterval.String != "" {
priceText += "/" + price.RecurringInterval.String
}
return priceText, renewsOn, false
}
// buildPlanFeatures derives a tier's display features: numeric entitlement
// limits (from the product's entitlement set rules) followed by qualitative
// metadata.features strings. Entitlement-derived features come first.
func (h *MemberProductsHandler) buildPlanFeatures(ctx context.Context, p billing.Product, labels map[string]resourceLabel) (entitlement []string, metadata []string) {
if p.EntitlementSetID.Valid {
if rules, err := h.EntitlementsQ.GetActiveRulesBySetID(ctx, p.EntitlementSetID.UUID.String()); err == nil {
for _, rule := range rules {
if rule.ResourceKey.Valid && rule.ResourceValue.Valid {
// Same display-name resolution as the entitlement view
// (labelFor falls back to the raw key when unregistered).
label := labelFor(labels, rule.ResourceKey.String).Label
if rule.ResourceValue.Int64 == 1 && len(label) > 1 && label[len(label)-1] == 's' {
label = label[:len(label)-1]
}
entitlement = append(entitlement,
fmt.Sprintf("%d %s", rule.ResourceValue.Int64, label))
}
}
}
}
if p.Metadata.Valid {
var meta map[string]interface{}
if err := json.Unmarshal(p.Metadata.RawMessage, &meta); err == nil {
if features, ok := meta["features"]; ok {
if featureList, ok := features.([]interface{}); ok {
for _, f := range featureList {
if s, ok := f.(string); ok {
metadata = append(metadata, s)
}
}
}
}
}
}
return entitlement, metadata
}
// GetAddons handles GET /partials/member/addons
func (h *MemberProductsHandler) GetAddons(w http.ResponseWriter, r *http.Request) {
session := h.AuthConfig.GetUserSession(r.Context())
if session == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
data := AddonsData{}
products, err := h.BillingQ.ListPublicProducts(r.Context())
if err != nil {
h.Logger.Error("failed to list public products", slog.Any("error", err))
data.Error = "Failed to load add-ons"
h.Templates.Render(w, "member_addons.html", data)
return
}
for _, p := range products {
// Add-ons are a storefront grouping (display_category), presentation
// only — never a structural signal (Doc 41 Decision 136).
if !p.DisplayCategory.Valid || p.DisplayCategory.String != displayCategoryAddon {
continue
}
priceID, purchasable := h.resolvePurchasable(r.Context(), p.ProductID)
data.Addons = append(data.Addons, AddonViewModel{
ProductID: p.ProductID,
Name: p.Name,
Description: p.Description.String,
PriceID: priceID,
Purchasable: purchasable,
})
}
h.Templates.Render(w, "member_addons.html", data)
}
// resolvePurchasable returns the product's DEFAULT price ID — the price members
// are offered, per computePriceReadiness (is_default = TRUE, oldest-active
// fallback) — and whether that price is purchasable. A plan is purchasable only
// when it has an active price AND that (default) price has a synced Stripe
// price mapping in this deployment. When Stripe is not configured (no
// Database, or no mapping), the plan is not purchasable and the upgrade
// control renders disabled rather than sending the member to an endpoint that
// would reject the request.
//
// This only covers the price + Stripe-mapping axis, not the product-level
// member gate (evaluateMemberGate: published/active/public) — it does not
// need to, because both callers only ever reach products already filtered to
// that gate: buildPlansData's non-current tiers come from planByID, sourced
// from ListPublicPlanProducts (lifecycle_status = 'published'); the current
// rung is exempted from that filter (its own carve-out) but never calls this
// function, skipping straight past it. GetAddons' add-ons come from
// ListPublicProducts, filtered the same way.
func (h *MemberProductsHandler) resolvePurchasable(ctx context.Context, productID string) (string, bool) {
var stripeQ internalstripe.Querier
if h.Database != nil {
stripeQ = internalstripe.New(h.Database)
}
r := computePriceReadiness(ctx, h.BillingQ, stripeQ, stripeQ != nil, productID)
return r.PriceID, r.HasActivePrice && r.StripeMapped
}