Files
member-console/internal/server/billing.go
Christian Galo f23a84999c sqlc: standardize generated type names across all modules
Drop redundant schema prefixes from all sqlc-generated Go types. Since
each module generates into its own package, the package already provides
the namespace — billing.Account is unambiguous without
billing.BillingAccount.

Changes:
- Add rename: blocks to all 6 sqlc.yaml files mapping schema-prefixed
  names to clean idiomatic names (e.g. BillingBillingAccount → Account,
  IdentityPerson → Person, OrganizationOrganization → Organization)
- Rename billing.billing_accounts → billing.accounts (table name
  repeated
  the schema; the schema already provides that context)
- Rename integration.integration_outbox → integration.outbox (same
  reason)
- Regenerate all sqlc output across billing, identity, organization,
  entitlements, stripe, and fedwiki modules
- Update all calling code (server, workflows, provisioning, tests) to
  use
  the new names
- Add internal/db/sqlc_schemas.sql — sqlc-only schema declarations so
  every module can resolve schema-qualified names without including the
  full db migrations
- Update docs/database-management.md with the naming convention and
  standard sqlc.yaml template

Convention going forward: table names must not repeat the schema name;
generated types carry no schema prefix; the Go package provides the
namespace (like http.Request, not http.HttpRequest).
2026-04-05 02:35:36 -05:00

151 lines
5.0 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"
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
}