252 lines
10 KiB
Go
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
|
|
}
|