607 lines
25 KiB
Go
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", ""
|
|
}
|
|
}
|