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" internalstripe "git.coopcloud.tech/wiki-cafe/member-console/internal/stripe" 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 } // 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 } 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 }