- 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
287 lines
11 KiB
Go
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
|
|
}
|