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.
210 lines
7.8 KiB
Go
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
|
|
}
|