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).
151 lines
5.0 KiB
Go
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
|
|
}
|