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

287 lines
11 KiB
Go

package server
import (
"context"
"strings"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
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
}
// 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
}
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
}
// 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 AND (is_public ⇒ the product is priced and Stripe-mapped); an
// internal wrap product (is_public=false) needs neither price nor visibility,
// so those rows report "n/a" (Doc 41 §5.4).
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
// 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" and "Public & active" rows are this
// gate's Published and Active+Public 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,
}
}
// buildProductReadinessVM assembles the readiness panel for one product from its
// product_shape row and price/Stripe state.
func buildProductReadinessVM(product billing.Product, shape billing.CoreProductShape, pr PriceReadiness, syncFailed bool, syncError string) ProductReadinessVM {
gate := evaluateMemberGate(product)
published := gate.Published
isPublic := product.IsPublic
visible := gate.Active && gate.Public
setPresent, _ := shape.SetPresent.(bool)
priced := shape.BillingShape != "unpriced"
var vm ProductReadinessVM
state := func(met bool) string {
if met {
return "met"
}
return "unmet"
}
vm.Rows = append(vm.Rows, ProductReadinessRow{
Label: "Published",
State: state(published),
Detail: "Publish this product so it can be sold.",
})
// Visibility applies only to public products; a wrap product is
// intentionally never public.
visRow := ProductReadinessRow{Label: "Public & active"}
if isPublic {
visRow.State = state(visible)
visRow.Detail = "Mark the product Active and Public so it appears in the member catalog."
} else {
visRow.State = "n/a"
visRow.Detail = "Internal wrap product — intentionally never public; not shown in the member catalog."
}
vm.Rows = append(vm.Rows, visRow)
vm.Rows = append(vm.Rows, ProductReadinessRow{
Label: "Entitlement set present",
State: state(setPresent),
Detail: "Assign an entitlement set — a product with none confers nothing.",
})
// Price + payment processing apply only to public products.
priceRow := ProductReadinessRow{Label: "Active price"}
stripeRow := ProductReadinessRow{Label: "Payment processing"}
if !isPublic {
priceRow.State = "n/a"
priceRow.Detail = "Internal wrap product — no price required."
stripeRow.State = "n/a"
stripeRow.Detail = "Internal wrap 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:
stripeRow.State = "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."
}
}
}
vm.Rows = append(vm.Rows, priceRow, stripeRow)
vm.StripeConfigured = pr.StripeConfigured
vm.CanSyncStripe = isPublic && pr.StripeConfigured && pr.HasActivePrice && !pr.StripeMapped && !pr.SyncPending && !syncFailed
vm.CanRetryStripe = isPublic && pr.StripeConfigured && pr.HasActivePrice && !pr.StripeMapped && syncFailed
// 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.
for _, row := range vm.Rows {
if row.State != "met" && row.State != "n/a" {
vm.Missing = append(vm.Missing, row.Label)
}
}
if isPublic {
vm.Purchasable = published && visible && setPresent && priced && pr.StripeMapped
} else {
vm.Purchasable = published && setPresent
}
// 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 = "Internal wrap 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."
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)
switch {
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 (internal)"
default:
vm.Verdict = "Incomplete — missing: " + strings.Join(vm.Missing, ", ")
}
return vm
}