Files
member-console/internal/server/member_products.go
T

1216 lines
50 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
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/forms"
"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
// StripeMode is the environment derived from the API key at boot
// (server.Config.StripeMode): the purchasability gate refuses a price
// whose mapping the key cannot reach (stripe-environment-stamp D5).
StripeMode string
// 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
// StripeMode is the environment derived from the API key at boot; see
// MemberProductsHandler.StripeMode.
StripeMode string
}
// 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,
"helpIcon": helpIcon,
}).ParseFS(templateSubFS, "member_*.html")
if err != nil {
return nil, err
}
if tmpl, err = web.ParseUIPartials(tmpl); 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,
StripeMode: cfg.StripeMode,
}, 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
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
// PriceText is this tier's own list price and interval (e.g. "$10.00
// USD/month"), set whenever Purchasable — a non-current tier states its
// cost too, not only the current one (member-product-discovery: "Every
// plan card states its cost"). Empty for the current tier (which shows
// BillingPriceText instead) and for a tier that is not purchasable.
PriceText string
// NoPriceTier is true when the tier carries no price at all (typically
// the free rank-0 rung); its card reads "Included" rather than a price
// or a move control's disabled reason.
NoPriceTier 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
}
// DisabledControl builds the shared disabledControl part's data (design
// D10, ACC-8) for a tier whose move is shown but not enabled: the member
// surface's visible-reason variant (AsVisible), not the operator surface's
// tooltip-on-wrapper idiom. The id is unique per ladder+product on the page,
// matching the scoping the checkout-error container beside it already uses.
func (t TierViewModel) DisabledControl() DisabledControl {
return NewDisabledControl(
"move-disabled-"+t.MoveLadderID+"-"+t.ProductID,
t.MoveLabel,
"btn btn-secondary btn-sm w-100 mt-auto opacity-50",
t.DisabledReason,
).AsVisible()
}
// LadderViewModel groups a ladder's tiers (a service axis) for rendering.
type LadderViewModel struct {
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 the member.plan.move declaration as a
// switch confirmation banner above the ladders, with the proration
// estimate as its Message and Confirm / Keep-current as its commit and
// way out (spec form-library; design D9, D10).
Preview *forms.FormView
}
// planMovePreviewMessage builds the switch confirmation's prose: the target
// tier named up front, then the proration estimate (or its unavailable
// fallback), then the estimate caveat when a real number is shown. TierName
// is the one piece here that is not the console's own copy, so it alone is
// escaped.
func planMovePreviewMessage(tierName string, available, isCredit bool, amountText string) template.HTML {
msg := "<p class=\"mb-2\"><strong>Switch to " + template.HTMLEscapeString(tierName) + "?</strong></p>"
switch {
case available && isCredit:
msg += "<p class=\"mb-1\">You'll receive an estimated credit of " + template.HTMLEscapeString(amountText) +
" for the unused part of your current plan, then be billed the new rate going forward.</p>" +
"<p class=\"text-muted small mb-0\">This is an estimate; the invoice Stripe issues is the final amount.</p>"
case available:
msg += "<p class=\"mb-1\">You'll be charged an estimated " + template.HTMLEscapeString(amountText) +
" now for the prorated difference, then the new rate going forward.</p>" +
"<p class=\"text-muted small mb-0\">This is an estimate; the invoice Stripe issues is the final amount.</p>"
default:
msg += "<p class=\"mb-0\">You'll be charged or credited the prorated difference for the change, then billed the new rate going forward.</p>"
}
return template.HTML(msg)
}
// AddonViewModel represents one product in the member catalog's Extras
// bucket for template rendering — every published, public product that is
// not a tier on a plan ladder (member-product-discovery delta, Doc 41
// Decision 136: display_category is presentation-only and never gates
// membership).
type AddonViewModel struct {
ProductID string
Name string
Description string
// DisplayCategory is the product's presentation label, rendered as a
// small neutral grouping badge when present. It carries no behavioral
// weight: it never includes or excludes a product from Extras. Empty
// when the product carries no label.
DisplayCategory string
// PriceID is the product's default price, submitted to checkout: an
// active, RECURRING price with a synced Stripe price mapping. A product
// without one is not listed at all (buildAddonsData); see
// resolvePurchasableRecurring for why one-time prices do not qualify
// even though they pass the shared plan/add-on gate
// (resolvePurchasable / computePriceReadiness).
PriceID string
}
// AddonsData holds data for the member_addons.html partial (the non-plan
// catalog section).
type AddonsData struct {
Addons []AddonViewModel
// HasPlans reports whether plan sections render above this section on
// the catalog page. The section's heading adapts to it (maintainer
// decision 2026-08-23): "More products" beneath plan sections, plain
// "Products" when no plans exist and this section IS the catalog — a
// plan-less deployment must not read its whole catalog as extras.
HasPlans bool
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
}
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.Name), 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{
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, tier.PriceText, tier.NoPriceTier = h.resolveTierPrice(ctx, t.ProductID)
// design D5: a free rank-0 tier is conferred, never bought. When
// the org holds nothing on this ladder (Relation == "available")
// it gets no move control at all — no MoveKind, no MoveLabel, no
// DisabledReason, MoveEnabled stays false. An org on a paid rung
// falls through to the default case below, which still routes the
// rank-0 tier to today's cancel control (Relation == "downgrade").
freeRungNotHeld := tier.Rank == 0 && tier.NoPriceTier && tier.Relation == "available"
if !freeRungNotHeld {
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
}
// GET, not a submission of planMoveForm: htmx sends hx-vals on a GET as
// the query string, which is where this reads them (never
// planMoveForm.Parse, whose non-Search Kind reads r.PostForm).
query := r.URL.Query()
ladderID := query.Get("ladder_id")
priceID := query.Get("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
}
var tierName string
if product, err := h.BillingQ.GetProductByID(r.Context(), price.ProductID); err == nil {
tierName = product.Name
}
var available, isCredit bool
var amountText string
preview := fulfillment.PreviewSwitch(r.Context(), h.Database, h.Logger, session.OrgID, ladderID, priceID)
if preview.Available {
available = true
isCredit = preview.NetAmount < 0
amountText = formatMoney(preview.NetAmount, preview.Currency)
}
values := forms.NewValues()
values.Set("ladder_id", ladderID)
values.Set("price_id", priceID)
view := forms.Render(planMoveForm, forms.Binding{
Mode: forms.ModeRecord,
Values: values,
Message: planMovePreviewMessage(tierName, available, isCredit, amountText),
})
data := h.buildPlansData(r.Context(), session.OrgID)
data.Preview = &view
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
}
values, _ := planMoveForm.Parse(r)
ladderID := values.String("ladder_id")
priceID := values.String("price_id")
if ladderID == "" || priceID == "" {
http.Error(w, "ladder_id and price_id are required", http.StatusBadRequest)
return
}
// The same member gate checkout applies (published, active, public),
// checked here because SwitchPlan verifies the price and the ladder but
// not the product's publication state; without it a member who knows a
// draft tier's price id can move onto it (2026-09 audit finding 12).
if err := h.switchTargetOpenToMembers(r.Context(), priceID); err != nil {
h.renderPlansAfterMove(w, r.Context(), session.OrgID, err, "switch")
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")
}
// errPlanNotOpenToMembers is a switch whose target product fails the member
// gate. It renders as the unavailable-plan message, the same one a retired
// price gets, since to the member the two are one fact.
var errPlanNotOpenToMembers = errors.New("switch: target product is not published for members")
// switchTargetOpenToMembers holds a switch's target price to the member
// gate (evaluateMemberGate: published, active, public), the predicate the
// catalog lists by and checkout refuses on. It reads the price and its
// product and nothing else, so a refused switch touches no subscription.
func (h *MemberProductsHandler) switchTargetOpenToMembers(ctx context.Context, priceID string) error {
price, err := h.BillingQ.GetPrice(ctx, priceID)
if err != nil {
return fmt.Errorf("switch: get target price %s: %w", priceID, err)
}
product, err := h.BillingQ.GetProductByID(ctx, price.ProductID)
if err != nil {
return fmt.Errorf("switch: get target product %s: %w", price.ProductID, err)
}
if !evaluateMemberGate(product).OK() {
h.Logger.Info("switch refused: product not published for members",
slog.String("product_id", product.ProductID), slog.String("lifecycle_status", product.LifecycleStatus))
return errPlanNotOpenToMembers
}
return nil
}
// 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 plan."
case errors.Is(moveErr, fulfillment.ErrPriceInactive), errors.Is(moveErr, errPlanNotOpenToMembers),
errors.Is(moveErr, fulfillment.ErrPriceUnreachable):
data.Error = "That plan is no longer available. Refresh the page and choose a current plan."
default:
data.Error = "We couldn't complete that change. 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 — the Extras bucket.
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
}
h.Templates.Render(w, "member_addons.html", h.buildAddonsData(r.Context()))
}
// buildAddonsData assembles the Extras bucket view model: every published,
// public product that is not a tier on a plan ladder (member-product-
// discovery delta). Membership is structural — the same plan-ness signal
// buildPlansData uses (presence in plan_ladder_tiers) — never
// display_category, which is presentation-only grouping metadata (Doc 41
// Decision 136) rendered as a badge but never gating inclusion. Extracted
// from GetAddons (mirroring buildPlansData) so tests can exercise it without
// standing up a session.
func (h *MemberProductsHandler) buildAddonsData(ctx context.Context) AddonsData {
data := AddonsData{}
products, err := h.BillingQ.ListPublicProducts(ctx)
if err != nil {
h.Logger.Error("failed to list public products", slog.Any("error", err))
data.Error = "Failed to load extras"
return data
}
// Tier product IDs, so a product that is a tier on a plan ladder renders
// only in its own plan section — never duplicated here, regardless of
// its display_category label (member-product-discovery delta, scenario
// "A tier is not duplicated into Extras"). ListPublicPlanProducts is the
// same structural query buildPlansData uses to resolve plan-ness
// (presence in core.plan_ladder_tiers), so the two catalogs can never
// disagree about which products are tiers.
tierIDs := make(map[string]bool)
if planProducts, err := h.BillingQ.ListPublicPlanProducts(ctx); err == nil {
for _, p := range planProducts {
tierIDs[p.ProductID] = true
}
} else {
h.Logger.Error("failed to list plan products for tier exclusion", slog.Any("error", err))
}
// Same query decides the heading: plan sections render exactly when
// public tier products exist.
data.HasPlans = len(tierIDs) > 0
for _, p := range products {
if tierIDs[p.ProductID] {
continue // renders in its plan ladder section instead
}
priceID, purchasable := h.resolvePurchasableRecurring(ctx, p.ProductID)
if !purchasable {
// A catalog offers what can be bought (maintainer, 2026-09-19;
// record-table-actions D4): a public product with no active,
// synced, recurring price is not listed, rather than listed with
// "Not available for purchase" where its Add would be. The
// operator's readiness panel reports the missing price.
continue
}
data.Addons = append(data.Addons, AddonViewModel{
ProductID: p.ProductID,
Name: p.Name,
Description: p.Description.String,
DisplayCategory: p.DisplayCategory.String,
PriceID: priceID,
})
}
return 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)
// A mapping the current key cannot reach (recorded in the other
// environment, or marked stale by the environment check) is not
// purchasable either: offering it would send the member to a checkout
// the gate in HandleCheckout refuses (stripe-environment-stamp D5).
return r.PriceID, r.HasActivePrice && r.StripeMapped && internalstripe.MappingReachable(r.Livemode, r.SyncStatus, h.StripeMode)
}
// resolveTierPrice extends resolvePurchasable's readiness check with the
// tier's own displayable cost (member-product-discovery: "Every plan card
// states its cost"). noPrice is true when the tier carries no price row at
// all (the free rank-0 rung reads "Included"); priceText is set only when
// the tier is purchasable (a priced-but-unsynced tier shows neither, per
// the purchasability gate — the disabled move control already names why).
func (h *MemberProductsHandler) resolveTierPrice(ctx context.Context, productID string) (priceID string, purchasable bool, priceText string, noPrice bool) {
var stripeQ internalstripe.Querier
if h.Database != nil {
stripeQ = internalstripe.New(h.Database)
}
r := computePriceReadiness(ctx, h.BillingQ, stripeQ, stripeQ != nil, productID)
priceID = r.PriceID
// The same reachability question resolvePurchasable asks: a tier whose
// mapping the key cannot reach renders its move control disabled
// rather than offering a checkout the gate refuses.
purchasable = r.HasActivePrice && r.StripeMapped && internalstripe.MappingReachable(r.Livemode, r.SyncStatus, h.StripeMode)
if !r.HasActivePrice {
noPrice = true
return priceID, purchasable, priceText, noPrice
}
if !purchasable {
return priceID, purchasable, priceText, noPrice
}
price, err := h.BillingQ.GetPrice(ctx, priceID)
if err != nil {
h.Logger.Error("failed to load tier price", slog.String("price_id", priceID), slog.Any("error", err))
return priceID, purchasable, priceText, noPrice
}
priceText = formatMoney(int64(price.UnitAmount), price.Currency)
if price.RecurringInterval.Valid && price.RecurringInterval.String != "" {
priceText += "/" + price.RecurringInterval.String
}
return priceID, purchasable, priceText, noPrice
}
// resolvePurchasableRecurring extends resolvePurchasable with a recurring
// check for the Extras bucket: a product's default price must be recurring
// (a non-empty recurring_interval), not one-time, to be purchasable there.
// One-time-purchase delivery is the schema's reserved, unimplemented arm —
// nothing creates a purchase-linked pool_provision from a one-time Stripe
// charge — so offering a one-time-priced product a checkout control would
// send the member to a purchase that can never deliver. Plan tiers never
// call this: every rung's price is recurring by construction (a
// subscription ladder), so buildPlansData keeps using the plain
// resolvePurchasable.
func (h *MemberProductsHandler) resolvePurchasableRecurring(ctx context.Context, productID string) (string, bool) {
priceID, purchasable := h.resolvePurchasable(ctx, productID)
if !purchasable {
return priceID, false
}
price, err := h.BillingQ.GetPrice(ctx, priceID)
if err != nil {
h.Logger.Error("failed to load price for recurring check", slog.String("price_id", priceID), slog.Any("error", err))
return priceID, false
}
return priceID, isRecurringPrice(price)
}
// isRecurringPrice reports whether a price carries a recurring interval
// (e.g. "month", "year") rather than being a one-time charge. A one-time
// price stores recurring_interval NULL (00001_init.sql: the column is a
// bare nullable VARCHAR, no enumerated "one_time" literal) — the same
// non-empty check already used to decide whether to append "/interval" when
// formatting a subscription's billing price (resolveCurrentPlanBilling).
func isRecurringPrice(price billing.Price) bool {
return price.RecurringInterval.Valid && price.RecurringInterval.String != ""
}