- 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
987 lines
38 KiB
Go
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
|
|
}
|