Files
cgalo5758 efe3f1528d Restrict member surfaces to published products
Add lifecycle_status = 'published' to the public-catalog queries
(plans and add-ons listings) and reject checkout before any Stripe
call unless the product behind the price clears the shared member
gate (published + active + public). The currently-enrolled ladder
rung stays renderable even if its product is later drafted or
retired, fetched directly so members keep seeing what they are on.

Introduce a single evaluateMemberGate definition shared by the
catalog paths and the operator readiness panel so the surfaces
cannot disagree about what is publishable for members.
2026-08-22 12:58:05 -05:00

210 lines
7.8 KiB
Go

package server
import (
"context"
"database/sql"
"fmt"
"log/slog"
"net/http"
"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/fulfillment"
internalstripe "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store"
stripe "github.com/stripe/stripe-go/v81"
"github.com/stripe/stripe-go/v81/checkout/session"
stripecustomer "github.com/stripe/stripe-go/v81/customer"
)
// BillingCheckoutHandler handles subscription checkout requests.
type BillingCheckoutHandler struct {
Database *sql.DB
BillingQ billing.Querier
AuthConfig *auth.Config
Logger *slog.Logger
BaseURL string
}
// HandleCheckout creates a Stripe Checkout Session and redirects the member.
func (h *BillingCheckoutHandler) HandleCheckout(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Get authenticated user session
userSession := h.AuthConfig.GetUserSession(ctx)
if userSession == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// Parse price_id from form
priceID := r.FormValue("price_id")
if priceID == "" {
http.Error(w, "price_id is required", http.StatusBadRequest)
return
}
// Validate price is active
price, err := h.BillingQ.GetPrice(ctx, priceID)
if err != nil {
h.Logger.Error("failed to get price", slog.String("price_id", priceID), slog.Any("error", err))
http.Error(w, "price not found", http.StatusNotFound)
return
}
if !price.IsActive {
http.Error(w, "price is not active", http.StatusBadRequest)
return
}
// Reject before any Stripe call unless the product behind the price
// clears the shared member gate (published + active + public) — the
// same predicate the member catalog queries and buildProductReadinessVM
// use. A price can outlive its product's publication state, so this is
// checked independently of the listing queries' filter.
product, err := h.BillingQ.GetProductByID(ctx, price.ProductID)
if err != nil {
h.Logger.Error("failed to get product for checkout", slog.String("product_id", price.ProductID), slog.Any("error", err))
http.Error(w, "product not found", http.StatusBadRequest)
return
}
if !evaluateMemberGate(product).OK() {
h.Logger.Info("checkout refused: product not published for members",
slog.String("product_id", product.ProductID), slog.String("lifecycle_status", product.LifecycleStatus))
http.Error(w, "this plan is not currently available", http.StatusBadRequest)
return
}
// Already-subscribed guard: a stale tab left open after the member
// subscribed elsewhere (or in another tab) must not create a second
// concurrent Stripe subscription on the same ladder axis. Only plan
// prices tier into a ladder; add-ons resolve zero ladders and skip this
// check entirely (finding #10).
if ladders, lerr := h.BillingQ.ListLaddersByProduct(ctx, price.ProductID); lerr != nil {
h.Logger.Error("failed to list ladders for product", slog.String("product_id", price.ProductID), slog.Any("error", lerr))
} else {
for _, ladder := range ladders {
active, aerr := fulfillment.HasActiveSubscriptionOnLadder(ctx, h.Database, userSession.OrgID, ladder.PlanLadderID)
if aerr != nil {
h.Logger.Error("failed to check active subscription on ladder",
slog.String("ladder_id", ladder.PlanLadderID), slog.Any("error", aerr))
continue
}
if active {
h.Logger.Info("checkout refused: org already subscribed on ladder",
slog.String("org_id", userSession.OrgID), slog.String("ladder_id", ladder.PlanLadderID))
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Redirect", "/products")
w.WriteHeader(http.StatusOK)
return
}
http.Redirect(w, r, "/products", http.StatusSeeOther)
return
}
}
}
// Resolve billing account for the user's org
billingAccounts, err := h.BillingQ.ListBillingAccountsByOrgID(ctx, userSession.OrgID)
if err != nil || len(billingAccounts) == 0 {
h.Logger.Error("no billing account found", slog.String("org_id", userSession.OrgID))
http.Error(w, "no billing account found", http.StatusBadRequest)
return
}
account := billingAccounts[0]
// Ensure Stripe price mapping exists
stripeQ := internalstripe.New(h.Database)
priceMapping, err := stripeQ.GetPriceMappingByPriceID(ctx, priceID)
if err != nil || !priceMapping.StripePriceID.Valid {
h.Logger.Error("stripe price mapping not found", slog.String("price_id", priceID))
http.Error(w, "price not yet available in Stripe", http.StatusBadRequest)
return
}
// Ensure Stripe customer exists (create synchronously if missing)
customerMapping, err := stripeQ.GetCustomerMappingByBillingAccountID(ctx, account.BillingAccountID)
var stripeCustomerID string
if err == sql.ErrNoRows {
// Create Stripe customer synchronously
stripeCustomerID, err = h.createStripeCustomer(ctx, account, stripeQ)
if err != nil {
h.Logger.Error("failed to create stripe customer", slog.Any("error", err))
http.Error(w, "failed to set up billing", http.StatusInternalServerError)
return
}
} else if err != nil {
h.Logger.Error("failed to check customer mapping", slog.Any("error", err))
http.Error(w, "internal error", http.StatusInternalServerError)
return
} else {
if !customerMapping.StripeCustomerID.Valid || customerMapping.SyncStatus != "synced" {
h.Logger.Error("customer mapping not synced", slog.String("billing_account_id", account.BillingAccountID))
http.Error(w, "billing setup in progress, please try again shortly", http.StatusServiceUnavailable)
return
}
stripeCustomerID = customerMapping.StripeCustomerID.String
}
// Create Stripe Checkout Session
params := &stripe.CheckoutSessionParams{
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
Customer: stripe.String(stripeCustomerID),
LineItems: []*stripe.CheckoutSessionLineItemParams{
{
Price: stripe.String(priceMapping.StripePriceID.String),
Quantity: stripe.Int64(1),
},
},
SuccessURL: stripe.String(fmt.Sprintf("%s/?checkout=success", h.BaseURL)),
CancelURL: stripe.String(fmt.Sprintf("%s/?checkout=cancel", h.BaseURL)),
Metadata: map[string]string{
"billing_account_id": account.BillingAccountID,
},
}
s, err := session.New(params)
if err != nil {
h.Logger.Error("failed to create checkout session", slog.Any("error", err))
http.Error(w, "failed to start checkout", http.StatusInternalServerError)
return
}
// The upgrade control submits via HTMX (so the request carries the page's
// X-CSRF-Token header and a same-origin fetch Origin — a native top-level
// form POST is sent with Origin: null under this app's no-referrer policy
// and is rejected by CSRF). HTMX cannot follow a cross-origin 303 to
// Stripe via fetch, so signal a full-page client redirect with HX-Redirect.
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Redirect", s.URL)
w.WriteHeader(http.StatusOK)
return
}
http.Redirect(w, r, s.URL, http.StatusSeeOther)
}
// createStripeCustomer creates a Stripe Customer synchronously and writes the mapping.
func (h *BillingCheckoutHandler) createStripeCustomer(ctx context.Context, account billing.Account, stripeQ *internalstripe.Queries) (string, error) {
params := &stripe.CustomerParams{
Metadata: map[string]string{
"billing_account_id": account.BillingAccountID,
"org_id": account.OrgID,
},
}
cust, err := stripecustomer.New(params)
if err != nil {
return "", fmt.Errorf("create stripe customer: %w", err)
}
_, err = stripeQ.UpsertCustomerMapping(ctx, internalstripe.UpsertCustomerMappingParams{
BillingAccountID: account.BillingAccountID,
StripeCustomerID: sql.NullString{String: cust.ID, Valid: true},
SyncStatus: "synced",
})
if err != nil {
return "", fmt.Errorf("write customer mapping: %w", err)
}
return cust.ID, nil
}