package billing import ( "errors" "fmt" "github.com/stripe/stripe-go/v82" "github.com/stripe/stripe-go/v82/billingportal/session" csession "github.com/stripe/stripe-go/v82/checkout/session" "github.com/stripe/stripe-go/v82/customer" "github.com/stripe/stripe-go/v82/webhook" ) // Client wraps the Stripe SDK with the subset of calls the API needs. // Concentrates env-var reads + price-ID lookups in one place so handlers // stay focused on HTTP, not Stripe plumbing. type Client struct { secretKey string webhookSecret string prices map[Tier]string } // Config is the env-derived configuration the Client needs. type Config struct { SecretKey string WebhookSecret string PriceProMonthly string PriceBusiness string } // NewClient validates required fields and returns a configured client. // Returns (nil, nil) when SecretKey is empty — callers treat that as // "billing disabled" and degrade gracefully (free tier for everyone, no // /billing/* endpoints exposed). func NewClient(cfg Config) (*Client, error) { if cfg.SecretKey == "" { return nil, nil } stripe.Key = cfg.SecretKey c := &Client{ secretKey: cfg.SecretKey, webhookSecret: cfg.WebhookSecret, prices: map[Tier]string{ TierPro: cfg.PriceProMonthly, TierBusiness: cfg.PriceBusiness, }, } return c, nil } // Enabled reports whether the client was constructed with a Stripe key. func (c *Client) Enabled() bool { return c != nil && c.secretKey != "" } // PriceFor returns the Stripe Price ID for a tier or an error if it // hasn't been configured — checkout will fail loudly rather than // silently send the customer to an empty checkout page. func (c *Client) PriceFor(tier Tier) (string, error) { id, ok := c.prices[tier] if !ok || id == "" { return "", fmt.Errorf("billing: no Stripe price configured for tier %q", tier) } return id, nil } // CreateOrGetCustomer returns the Stripe customer id for the given // (user_id, email). If `existingID` is non-empty we trust it; otherwise // we create a new Stripe customer and let the caller persist the id. func (c *Client) CreateOrGetCustomer(userID, email, name, existingID string) (string, error) { if !c.Enabled() { return "", errors.New("billing: disabled") } if existingID != "" { return existingID, nil } params := &stripe.CustomerParams{ Email: stripe.String(email), Name: stripe.String(name), Metadata: map[string]string{"gg_user_id": userID}, } cust, err := customer.New(params) if err != nil { return "", fmt.Errorf("stripe customer create: %w", err) } return cust.ID, nil } // CheckoutSessionParams collects the inputs CreateCheckoutSession needs. // Keeping them in a struct so future fields (coupon codes, trial periods, // referral metadata) drop in without breaking callers. type CheckoutSessionParams struct { CustomerID string PriceID string SuccessURL string CancelURL string } // CreateCheckoutSession returns the URL the frontend redirects the user // to. Subscription mode — recurring billing for Pro/Business. func (c *Client) CreateCheckoutSession(p CheckoutSessionParams) (string, error) { if !c.Enabled() { return "", errors.New("billing: disabled") } params := &stripe.CheckoutSessionParams{ Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)), Customer: stripe.String(p.CustomerID), SuccessURL: stripe.String(p.SuccessURL), CancelURL: stripe.String(p.CancelURL), LineItems: []*stripe.CheckoutSessionLineItemParams{{ Price: stripe.String(p.PriceID), Quantity: stripe.Int64(1), }}, AllowPromotionCodes: stripe.Bool(true), } sess, err := csession.New(params) if err != nil { return "", fmt.Errorf("stripe checkout session: %w", err) } return sess.URL, nil } // CreatePortalSession returns a URL to the Stripe-hosted customer // portal so the user can manage payment methods, cancel, view invoices. func (c *Client) CreatePortalSession(customerID, returnURL string) (string, error) { if !c.Enabled() { return "", errors.New("billing: disabled") } params := &stripe.BillingPortalSessionParams{ Customer: stripe.String(customerID), ReturnURL: stripe.String(returnURL), } sess, err := session.New(params) if err != nil { return "", fmt.Errorf("stripe portal session: %w", err) } return sess.URL, nil } // VerifyWebhook validates the Stripe signature header and returns the // parsed event. Refuses to verify when no webhook secret is configured — // no shared secret means anyone can POST forged events, so the route // should reject everything in that case (the caller does that check). // // We pass IgnoreAPIVersionMismatch because Stripe accounts can be on a // newer API version than the SDK we're built against. Event payloads // are designed to be forward-compatible — the SDK warns about the skew // but the deserialised event is still safe to use. Strict matching // would mean we'd have to upgrade the SDK in lockstep with whatever // Stripe rolls out, defeating the point of having an SDK. func (c *Client) VerifyWebhook(body []byte, sigHeader string) (stripe.Event, error) { if c.webhookSecret == "" { return stripe.Event{}, errors.New("billing: no webhook secret configured") } return webhook.ConstructEventWithOptions(body, sigHeader, c.webhookSecret, webhook.ConstructEventOptions{ IgnoreAPIVersionMismatch: true, }) }