Files
member-console/internal/server/billing.go
T

252 lines
10 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
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))
w.Header().Add("Vary", "HX-Request")
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)
keyMode := internalstripe.ModeForKey(stripe.Key)
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
}
// A price id recorded in the environment this key is not in, or one the
// environment check could not fetch, reaches Stripe as resource_missing
// and the member reads "failed to start checkout". It is the same fact
// as an unmapped price for everyone who cares, so it is refused here,
// before any Stripe call, with the same message
// (stripe-environment-stamp D5).
if !internalstripe.MappingReachable(priceMapping.Livemode, priceMapping.SyncStatus, keyMode) {
h.Logger.Info("checkout refused: price mapping is out of reach under the current key",
slog.String("price_id", priceID),
slog.String("stripe_price_id", priceMapping.StripePriceID.String),
slog.String("recorded_mode", internalstripe.RecordedMode(priceMapping.Livemode, priceMapping.SyncStatus)),
slog.String("key_mode", keyMode))
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
switch {
case 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
}
case err != nil:
h.Logger.Error("failed to check customer mapping", slog.Any("error", err))
http.Error(w, "internal error", http.StatusInternalServerError)
return
// A customer id this key cannot reach is treated as no customer at all:
// creating one again in the current environment is what a deployment
// that has moved needs, and the new id replaces the old one on the
// mapping (stripe-environment-stamp D5).
case !internalstripe.MappingReachable(customerMapping.Livemode, customerMapping.SyncStatus, keyMode):
h.Logger.Info("checkout: customer mapping is out of reach under the current key; creating the customer again",
slog.String("billing_account_id", account.BillingAccountID),
slog.String("stripe_customer_id", customerMapping.StripeCustomerID.String),
slog.String("recorded_mode", internalstripe.RecordedMode(customerMapping.Livemode, customerMapping.SyncStatus)),
slog.String("key_mode", keyMode))
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
}
default:
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; 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.
w.Header().Add("Vary", "HX-Request")
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)
}
// The environment stamp comes off the customer Stripe just returned,
// never off the key, so a mapping written here records the environment
// that actually made the id (stripe-environment-stamp D1).
_, err = stripeQ.UpsertCustomerMapping(ctx, internalstripe.UpsertCustomerMappingParams{
BillingAccountID: account.BillingAccountID,
StripeCustomerID: sql.NullString{String: cust.ID, Valid: true},
SyncStatus: "synced",
Livemode: sql.NullBool{Bool: cust.Livemode, Valid: true},
})
if err != nil {
return "", fmt.Errorf("write customer mapping: %w", err)
}
return cust.ID, nil
}