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

607 lines
25 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package server
import (
"context"
"database/sql"
"strings"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
internalstripe "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store"
)
// PriceReadiness captures whether a product's pricing is wired up for purchase:
// it has an active price, and that price is mapped to a live Stripe price in
// this deployment. SyncPending distinguishes "a Stripe sync was enqueued but
// the mapping has not landed yet" from "never mapped", so the operator UI can
// show an in-flight state rather than a hard failure.
//
// This is the single definition of the price + Stripe-mapping purchasability
// gate, shared by the member catalog (MemberProductsHandler.resolvePurchasable)
// and the operator product readiness panel so the two can never disagree about
// whether a product can be bought. A member is purchasable on this axis exactly
// when HasActivePrice && StripeMapped.
type PriceReadiness struct {
HasActivePrice bool
PriceID string
StripeMapped bool
SyncPending bool
// StripeConfigured reports whether Stripe is wired for this deployment
// (the Stripe API key and webhook secret are both set). When false, "not
// mapped" means "Stripe is off", not "needs syncing"; the panel renders an
// explanatory state, not an action.
StripeConfigured bool
// Livemode, VerifiedAt and SyncStatus are the mapping's environment
// stamp (stripe-environment-stamp D2), carried as read so the readiness
// panel can say which environment holds the id and whether the check
// has reached it. They are meaningless unless StripeMapped is true.
// StripeMapped still means only "a Stripe id is recorded": whether that
// id can be reached under the current key is a question about the key,
// which this struct is deliberately ignorant of.
Livemode sql.NullBool
VerifiedAt sql.NullTime
SyncStatus string
}
// computePriceReadiness resolves the product's tracked price and its Stripe
// mapping state. stripeQ may be nil (Stripe not configured), in which case the
// price is never considered mapped. The tracked price is the product's DEFAULT
// price (is_default = TRUE, mirroring Stripe's default_price); if no active
// price is marked default; a defensive case the migration backfill and
// CreatePrice's first-price-is-default rule should prevent; it falls back to
// the oldest active price, preserving the pre-multi-price behaviour.
func computePriceReadiness(ctx context.Context, billingQ billing.Querier, stripeQ internalstripe.Querier, stripeConfigured bool, productID string) PriceReadiness {
var r PriceReadiness
r.StripeConfigured = stripeConfigured
prices, err := billingQ.ListPricesByProduct(ctx, productID)
if err != nil || len(prices) == 0 {
return r
}
r.HasActivePrice = true
tracked := prices[0]
for _, p := range prices {
if p.IsDefault {
tracked = p
break
}
}
r.PriceID = tracked.PriceID
if !stripeConfigured || stripeQ == nil {
return r
}
mapping, err := stripeQ.GetPriceMappingByPriceID(ctx, r.PriceID)
if err != nil {
return r
}
r.Livemode, r.VerifiedAt, r.SyncStatus = mapping.Livemode, mapping.VerifiedAt, mapping.SyncStatus
switch {
case mapping.StripePriceID.Valid:
r.StripeMapped = true
case mapping.SyncStatus == "pending":
r.SyncPending = true
}
return r
}
// priceReadinessFromBatches is computePriceReadiness's sibling for the
// products list (design D4): it derives the same PriceReadiness from the
// page's batch reads instead of two per-product queries. prices is one
// product's active prices (a ListPricesByProductIDs group, already ordered
// oldest-first); mappingsByPriceID is the page's price-mapping batch
// (ListPriceMappingsByPriceIDs), keyed by price_id. The tracked-price
// selection mirrors computePriceReadiness exactly: the DEFAULT active
// price, or the oldest active price when none is marked default.
func priceReadinessFromBatches(stripeConfigured bool, prices []billing.Price, mappingsByPriceID map[string]internalstripe.PriceMapping) PriceReadiness {
var r PriceReadiness
r.StripeConfigured = stripeConfigured
if len(prices) == 0 {
return r
}
r.HasActivePrice = true
tracked := prices[0]
for _, p := range prices {
if p.IsDefault {
tracked = p
break
}
}
r.PriceID = tracked.PriceID
if !stripeConfigured {
return r
}
mapping, ok := mappingsByPriceID[r.PriceID]
if !ok {
return r
}
r.Livemode, r.VerifiedAt, r.SyncStatus = mapping.Livemode, mapping.VerifiedAt, mapping.SyncStatus
switch {
case mapping.StripePriceID.Valid:
r.StripeMapped = true
case mapping.SyncStatus == "pending":
r.SyncPending = true
}
return r
}
// ProductReadinessRow is one precondition line in the operator purchasability
// panel. State is "met", "unmet", or "pending"; Detail carries remediation
// guidance shown when the precondition is not met.
type ProductReadinessRow struct {
Label string
State string
Detail string
// Href, when set, links the row to the surface that resolves it.
// Currently set only by the provider-configuration leg (product-
// management "Readiness includes the delivering provider's
// configuration"), which links to the unconfigured provider's settings
// page.
Href string
// MissingName, when set, is how this row names itself in the verdict's
// "Incomplete; missing: ..." list. It differs from Label where the
// label is a noun for the row and the verdict wants the thing that is
// absent ("entitlement set", "rules", "published"). Empty means the
// verdict uses Label.
MissingName string
// DetailAlways renders Detail even when State is "met". Set only by
// the Payment processing row, whose met detail names the Stripe mode
// the sync reaches ("Synced, live" / "Synced, test"); every other
// row's detail is remediation and has nothing to say once met.
DetailAlways bool
}
// providerDisplayName resolves a provider key to its registered display
// name (the same registry the Integrations table and settings pages read),
// falling back to the raw key when the provider is not a registered
// integration on this deployment.
func providerDisplayName(configs []IntegrationConfigInfo, providerKey string) string {
for _, c := range configs {
if c.Key == providerKey && c.DisplayName != "" {
return c.DisplayName
}
}
return providerKey
}
// resolveProviderReadinessRows is the provider-configuration leg (product-
// management "Readiness includes the delivering provider's configuration",
// ACC-3): one row per distinct provider (core.resource_keys.provider)
// referenced by the entitlement set's active rules, in first-referenced
// order. A resource key with no provider (platform-owned/pooled) contributes
// no row. Reuses configurationReadiness — the same required-key resolution
// the Integrations list, the setup checklist, and the landing System panel
// already read — so this leg can never disagree with those surfaces about
// whether a provider is configured. setup_state.go's integrations step
// derives from this same leg (design D12) so the checklist and this panel
// never disagree either.
func resolveProviderReadinessRows(rules []entitlements.EntitlementSetRule, resourceKeys map[string]entitlements.ResourceKey, configs []IntegrationConfigInfo) []ProductReadinessRow {
seen := make(map[string]bool)
var providers []string
for _, rule := range rules {
if !rule.ResourceKey.Valid {
continue
}
rk, ok := resourceKeys[rule.ResourceKey.String]
if !ok || !rk.Provider.Valid || rk.Provider.String == "" {
continue
}
if !seen[rk.Provider.String] {
seen[rk.Provider.String] = true
providers = append(providers, rk.Provider.String)
}
}
rows := make([]ProductReadinessRow, 0, len(providers))
for _, provider := range providers {
name := providerDisplayName(configs, provider)
row := ProductReadinessRow{Label: name}
configured, missing := configurationReadiness(configs, provider)
if configured {
row.State = "met"
} else {
row.State = "unmet"
row.Href = "/operator/integrations/" + provider + "/settings"
detail := name + " is not configured for this deployment, so it cannot deliver what this product's entitlement set grants"
if len(missing) > 0 {
detail += "; missing " + strings.Join(missing, ", ")
}
detail += "."
row.Detail = detail
}
rows = append(rows, row)
}
return rows
}
// ProductReadinessVM is the view model for the operator product purchasability
// readiness panel. It reads core.product_shape (set presence + billing shape)
// composed with the shared price+mapping gate (PriceReadiness). Readiness is
// set_present with at least one active rule AND (is_public ⇒ the product is
// priced and Stripe-mapped), and is_active either way; an unlisted wrap
// product (is_public=false) needs neither price nor listing, so those rows
// report "n/a" or "unlisted" (Doc 41 §5.4, design D2/D3).
type ProductReadinessVM struct {
Purchasable bool
Verdict string
Missing []string
Rows []ProductReadinessRow
// StripeConfigured mirrors PriceReadiness.StripeConfigured for the template.
StripeConfigured bool
// CanSyncStripe is true when the operator can act on the Stripe-mapped
// precondition now: the product is public, Stripe is configured, there is an
// active price, and it is neither already mapped nor a sync already pending
// or failed. Drives the "Sync to Stripe" button.
CanSyncStripe bool
// CanRetryStripe is true when the last Stripe sync terminally failed
// (dead-lettered) and can be re-driven. Drives the "Retry" button.
CanRetryStripe bool
// SyncLabel is the Sync control's label: "Sync to Stripe" for a price
// that was never synced, and "Create in live" / "Create in test" for one
// whose recorded id the current key cannot reach, where the press
// creates the object again rather than registering it for the first
// time (stripe-environment-stamp D5).
SyncLabel string
// SyncPending mirrors the in-flight sync state at the top level so the
// template can emit live-update polling attributes only while a sync is
// actually in flight.
SyncPending bool
// CatalogQualifier, when non-empty, is a short qualifier the template
// renders next to a Purchasable verdict for an is_public product the
// member catalog does not render (no ladder tier, no addon category).
// Empty whenever the product is findable, not is_public, or not
// purchasable; the qualifier only ever attaches to "Purchasable".
CatalogQualifier string
}
// memberGate is the product-level member-visibility gate, decomposed into its
// three independent legs so the operator readiness panel can report which one
// is unmet while member paths only need the aggregate (OK).
type memberGate struct {
Published bool
Active bool
Public bool
}
// OK reports whether the product clears every leg of the member gate: this is
// the product-level predicate for "can a member see or buy this"; published,
// active, and public. It says nothing about pricing or Stripe mapping (see
// PriceReadiness for that axis).
func (g memberGate) OK() bool {
return g.Published && g.Active && g.Public
}
// evaluateMemberGate computes the product-level member gate. This is the
// single definition shared by the member catalog paths (checkout's product
// load, the current-rung carve-out in buildPlansData) and the operator
// readiness panel (whose "Published", "Active" and "Listed" rows are this
// gate's three legs), so the surfaces can never
// disagree about what "publishable for members" means.
func evaluateMemberGate(product billing.Product) memberGate {
return memberGate{
Published: product.LifecycleStatus == "published",
Active: product.IsActive,
Public: product.IsPublic,
}
}
// readinessInputs is buildProductReadinessVM's whole input, as plain values
// (design D4): the function reads nothing itself, so the detail page and the
// list can fill it from two different sources — the detail page from its own
// three per-product reads (computePriceReadiness, GetProductShape, the set's
// rules), the list from the page's batch reads — without either duplicating
// the verdict's definition.
type readinessInputs struct {
product billing.Product
shape billing.CoreProductShape
price PriceReadiness
providers []ProductReadinessRow
// activeRules is how many active rules the product's entitlement set
// holds. product_shape reports only that a set is attached, and a set
// with no active rule confers nothing, so the count is the second half
// of the entitlement-set precondition (design D2). Zero with a set
// present is "No rules"; it is not read at all when no set is present.
activeRules int
// stripeMode is the deployment's Stripe mode, "test", "live" or "" when
// unknown, as a plain value: the readiness computation reads no
// configuration of its own, so the detail page and the list can both
// name the account the sync reaches without this function knowing where
// the mode comes from.
stripeMode string
}
// syncedPaymentDetail names what the surface where syncing is triggered
// knows about the recorded Stripe id: which environment it lives in, and
// whether a key in stripeMode can reach it (design D3, and
// stripe-environment-stamp D5). met is false for the two states where the
// id is out of reach, which makes the Payment processing row unmet and
// the verdict name it.
//
// An unknown mode names nothing rather than guessing, and reaches
// everything: a deployment whose key the console could not classify has no
// environment to compare a stamp against.
func syncedPaymentDetail(pr PriceReadiness, stripeMode string) (detail string, met bool) {
if stripeMode == "" {
return "Synced", true
}
other := internalstripe.OtherMode(stripeMode)
switch {
case pr.SyncStatus == internalstripe.SyncStatusStale:
// The check asked Stripe for this id under the current key and was
// answered resource_missing, so the key's environment is the one
// the id is not in; the recorded flag may agree and still be no
// help, since two Stripe environments both report livemode false.
return "Not found in " + stripeMode + " mode", false
case !internalstripe.MappingAgrees(pr.Livemode, stripeMode):
return "Synced in " + other + ", key is " + stripeMode, false
case !pr.Livemode.Valid || !pr.VerifiedAt.Valid:
return "Synced, unverified", true
}
return "Synced, " + stripeMode, true
}
// buildProductReadinessVM assembles the readiness panel for one product from
// in's product_shape row and price/Stripe state. syncFailed and syncError
// carry the Stripe sync-failure state: a detail-page-only concern (D4) the
// list never loads, so list callers always pass false and "".
func buildProductReadinessVM(in readinessInputs, syncFailed bool, syncError string) ProductReadinessVM {
product, shape, pr, providerRows := in.product, in.shape, in.price, in.providers
gate := evaluateMemberGate(product)
published := gate.Published
isPublic := product.IsPublic
setPresent, _ := shape.SetPresent.(bool)
hasRules := in.activeRules > 0
priced := shape.BillingShape != "unpriced"
// stripeReachable is the Payment processing row's answer to the second
// half of the mapping question: a recorded id the current key can
// actually reach (stripe-environment-stamp D5). It starts false and is
// set by the mapped branch below, so an unmapped, pending or failed
// price never counts as reachable; the purchasable verdict reads it
// beside pr.StripeMapped.
stripeReachable := false
var vm ProductReadinessVM
state := func(met bool) string {
if met {
return "met"
}
return "unmet"
}
publishedRow := ProductReadinessRow{
Label: "Published",
State: state(published),
MissingName: "published",
Detail: "Publish this product so it can be sold.",
}
// is_active applies to every product: the grant form offers only active
// products and the catalog shows only active products, so an inactive
// product is offered nowhere whether it is listed or not (design D3).
activeRow := ProductReadinessRow{
Label: "Active",
State: state(gate.Active),
MissingName: "active",
Detail: "An inactive product is offered by neither the member catalog nor the grant form.",
}
// Listed is reported for every product, but an unlisted product is not
// unmet: unlisted is a working state, not a gap (design D3/D4).
listedRow := ProductReadinessRow{Label: "Listed"}
if isPublic {
listedRow.State = "met"
} else {
listedRow.State = "unlisted"
}
// ACC-29: the row is labeled "Entitlement set", not "Entitlement set
// present" — the old label read as an assertion, so the verdict's
// "missing: Entitlement set present" mis-stated what was missing (an
// entitlement set, not the fact of its presence). Design D2 splits the
// precondition in two: a set must be attached AND hold an active rule,
// since a rule-less set confers nothing.
setRow := ProductReadinessRow{Label: "Entitlement set"}
switch {
case !setPresent:
setRow.State = "unmet"
setRow.MissingName = "entitlement set"
setRow.Detail = "Assign an entitlement set; a product with none confers nothing."
case !hasRules:
setRow.State = "no_rules"
setRow.MissingName = "rules"
setRow.Detail = "Add a rule to the entitlement set; a set with none confers nothing."
default:
setRow.State = "met"
}
// Price + payment processing apply only to listed products.
priceRow := ProductReadinessRow{Label: "Active price"}
// MissingName is lowercase like every other row's, so the verdict reads
// "Incomplete; missing: payment processing" (stripe-environment-stamp
// delta, product-management) rather than restating the row's label.
stripeRow := ProductReadinessRow{Label: "Payment processing", MissingName: "payment processing"}
if !isPublic {
priceRow.State = "n/a"
priceRow.Detail = "Unlisted product; no price required."
stripeRow.State = "n/a"
stripeRow.Detail = "Unlisted product; not sold, so no payment processing."
} else {
priceRow.State = state(priced && pr.HasActivePrice)
priceRow.Detail = "Add an active price on the Prices view."
switch {
case pr.StripeMapped:
detail, met := syncedPaymentDetail(pr, in.stripeMode)
stripeRow.State = state(met)
stripeRow.Detail = detail
stripeRow.DetailAlways = true
stripeReachable = met
case syncFailed:
stripeRow.State = "failed"
stripeRow.Detail = "The last Stripe sync failed"
if syncError != "" {
stripeRow.Detail += ": " + syncError
}
stripeRow.Detail += ". Use Retry to run it again."
case pr.SyncPending:
stripeRow.State = "pending"
stripeRow.Detail = "The price is syncing to Stripe; this clears once the sync lands."
vm.SyncPending = true
default:
stripeRow.State = "unmet"
switch {
case !pr.StripeConfigured:
stripeRow.Detail = "Stripe is not configured for this deployment, so payment processing is unavailable. Set the Stripe API key to enable purchases."
case !pr.HasActivePrice:
stripeRow.Detail = "Add an active price, then use Sync to Stripe to register it for payment."
default:
stripeRow.Detail = "The active price is not registered with Stripe yet; use Sync to Stripe so members can be charged."
}
}
}
// Causal order (maintainer, 2026-08-31): what the product confers, its
// lifecycle, whether it is offered, then the commerce chain, then the
// provider(s) that deliver it, then the catalog-visibility outcome.
vm.Rows = append(vm.Rows, setRow, publishedRow, activeRow, listedRow, priceRow, stripeRow)
vm.Rows = append(vm.Rows, providerRows...)
vm.StripeConfigured = pr.StripeConfigured
// A mapped price whose id the key cannot reach offers the control
// again: pressing it creates the object in the environment the key is
// in and the new mapping replaces the unreachable id
// (stripe-environment-stamp D5).
unreachable := pr.StripeMapped && !stripeReachable
vm.CanSyncStripe = isPublic && pr.StripeConfigured && pr.HasActivePrice && (!pr.StripeMapped || unreachable) && !pr.SyncPending && !syncFailed
vm.CanRetryStripe = isPublic && pr.StripeConfigured && pr.HasActivePrice && !pr.StripeMapped && syncFailed
vm.SyncLabel = "Sync to Stripe"
if unreachable && in.stripeMode != "" {
// The label carries the fact a confirm modal would otherwise have
// to state, in the control itself: this press creates a new object,
// and names where. No modal, because creating in the current
// environment destroys nothing (design D5).
vm.SyncLabel = "Create in " + in.stripeMode
}
// Missing is computed from the purchasability preconditions only, before
// the member-catalog-visibility row below is appended: that row is a
// reported diagnostic, never a purchasability precondition (Decision 6),
// so it must never contribute to "missing" or to the Purchasable verdict.
// "unlisted" joins "met" and "n/a" as a state that is not a gap: an
// unlisted product is deliberately out of the catalog (design D3).
for _, row := range vm.Rows {
if row.State == "met" || row.State == "n/a" || row.State == "unlisted" {
continue
}
name := row.MissingName
if name == "" {
name = row.Label
}
vm.Missing = append(vm.Missing, name)
}
// product-management "Readiness includes the delivering provider's
// configuration": an unresolved provider blocks the verdict the same way
// an unresolved price does — a product that nothing can deliver is not
// Purchasable regardless of visibility.
providersOK := true
for _, row := range providerRows {
if row.State != "met" {
providersOK = false
break
}
}
if isPublic {
// stripeReachable as well as StripeMapped: an id recorded in the
// environment the key is not in, or one the check could not fetch,
// is an id no member checkout can use, and checkout refuses it
// (stripe-environment-stamp D5).
vm.Purchasable = published && gate.Active && setPresent && hasRules && priced && pr.StripeMapped && stripeReachable && providersOK
} else {
vm.Purchasable = published && gate.Active && setPresent && hasRules && providersOK
}
// Member catalog visibility: reported for is_public products only; the
// member catalog renders exactly two sections, plan-ladder tiers and
// display_category='addon' add-ons (member_products.go), so a product
// outside both is purchasable but has no catalog path a member can find.
// This never flips the verdict; it only earns the verdict a qualifier so
// "Purchasable" can never be misread as "findable" (product-management
// delta, Requirement: readiness panel surfaces catalog visibility).
catalogRow := ProductReadinessRow{Label: "Member catalog visibility"}
findable := shape.LadderCount > 0 || (product.DisplayCategory.Valid && product.DisplayCategory.String == displayCategoryAddon)
switch {
case !isPublic:
catalogRow.State = "n/a"
catalogRow.Detail = "Unlisted product; never shown in the member catalog."
case findable:
catalogRow.State = "shown"
catalogRow.Detail = "This product is a plan-ladder tier or is tagged display_category=addon, so the member catalog renders it; a product that is not a tier is listed only while it has an active, synced, recurring price."
default:
catalogRow.State = "hidden"
catalogRow.Detail = "This product is on no plan ladder and is not tagged display_category=addon, so the member catalog does not render it; members have no path to find it."
}
vm.Rows = append(vm.Rows, catalogRow)
// Verdict precedence (design D3): what the product confers and whether
// it is published come first, because neither "Inactive" nor
// "Purchasable" says anything true about a product that confers
// nothing. Then is_active, which is a deliberate state rather than a
// gap: an inactive product is offered by neither the catalog nor the
// grant form, and "Purchasable" or "Ready to grant" would both be
// false of it.
switch {
case !setPresent || !hasRules || !published:
vm.Verdict = "Incomplete; missing: " + strings.Join(vm.Missing, ", ")
case !gate.Active:
vm.Verdict = "Inactive"
case vm.Purchasable && isPublic:
vm.Verdict = "Purchasable"
if !findable {
vm.Verdict += "; not shown in the member catalog"
vm.CatalogQualifier = "Not shown in the member catalog"
}
case vm.Purchasable:
vm.Verdict = "Ready to grant"
default:
vm.Verdict = "Incomplete; missing: " + strings.Join(vm.Missing, ", ")
}
return vm
}
// productListStatus derives the products list's Status cell state and note
// from the readiness verdict (product-management "The Status column is the
// verdict", design D1). For draft and retired products the lifecycle word
// itself is the state and there is no note — an editorial state the
// operator chose, which the verdict would only restate as "missing:
// Published". For a published product the state is the verdict itself
// (purchasable, ready_to_grant, or incomplete) and the note is the muted
// qualifier or the first unmet leg, named exactly as the readiness panel's
// own rows name it so the list and the panel can never disagree.
func productListStatus(lifecycleStatus string, isPublic bool, vm ProductReadinessVM) (state string, note string) {
if lifecycleStatus != "published" {
return lifecycleStatus, ""
}
switch {
// The list states the panel's verdict, so an inactive product reads
// Inactive here too rather than "Incomplete; missing: Active".
case vm.Verdict == "Inactive":
return "inactive", ""
case vm.Purchasable && isPublic:
return "purchasable", vm.CatalogQualifier
case vm.Purchasable:
return "ready_to_grant", ""
default:
if len(vm.Missing) > 0 {
return "incomplete", "Missing: " + vm.Missing[0]
}
return "incomplete", ""
}
}