Files
enkly-ai/services/gateway/internal/handlers/billing.go
enkly 79ce8dc4c0
Some checks failed
Go CI Pipeline / build_and_test (push) Failing after 22m0s
Init Project
2026-10-30 18:06:55 +01:00

283 lines
8.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package handlers (billing.go) replaces internal/handlers/billing.go.
// The Stripe webhook keeps its exact public URL (POST /webhooks/stripe)
// so nothing needs to change in the Stripe dashboard — but this handler
// now just reads the raw body and forwards it untouched to billing's
// IngestStripeWebhook RPC, which holds STRIPE_WEBHOOK_SECRET and does
// the actual signature verification; the gateway itself never sees
// Stripe's signing secret.
package handlers
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"time"
billingv1 "enkly/gen/billing/v1"
)
const maxWebhookBodyBytes = 1 << 20
type planView struct {
Plan string
Name string
PriceEUR int
DailyMessageLimit int
IsCurrent bool
Action string
Popular bool
Multiplier string
Checkoutable bool
UnavailableReason string
}
type subscriptionData struct {
CSRFToken string
UserEmail string
Plans []planView
CurrentName string
Status string
CancelAtPeriodEnd bool
CurrentPeriodEnd time.Time
UsedToday int
DailyLimit int
Unlimited bool
UsagePercent int
NearLimit bool
AtLimit bool
HasBillingPortal bool
Message string
Error string
}
func planSlug(p billingv1.Plan) string {
switch p {
case billingv1.Plan_PLAN_FREE:
return "free"
case billingv1.Plan_PLAN_BASIC:
return "basic"
case billingv1.Plan_PLAN_PRO:
return "pro"
case billingv1.Plan_PLAN_PREMIUM:
return "premium"
default:
return "free"
}
}
func planRank(p billingv1.Plan) int {
switch p {
case billingv1.Plan_PLAN_FREE:
return 0
case billingv1.Plan_PLAN_BASIC:
return 1
case billingv1.Plan_PLAN_PRO:
return 2
case billingv1.Plan_PLAN_PREMIUM:
return 3
default:
return 0
}
}
func (h *Handlers) buildSubscriptionData(ctx context.Context, w http.ResponseWriter, r *http.Request) (subscriptionData, error) {
user := userFrom(r)
sub, err := h.Billing.GetOrCreateSubscription(ctx, &billingv1.GetOrCreateSubscriptionRequest{UserId: user.GetId()})
if err != nil {
return subscriptionData{}, err
}
catalog, err := h.Billing.GetPlanCatalog(ctx, &billingv1.GetPlanCatalogRequest{})
if err != nil {
return subscriptionData{}, err
}
curRank := planRank(sub.GetPlan())
var freeLimit int32
for _, p := range catalog.GetPlans() {
if p.GetPlan() == billingv1.Plan_PLAN_FREE {
freeLimit = p.GetDailyMessageLimit()
}
}
plans := make([]planView, 0, len(catalog.GetPlans()))
for _, p := range catalog.GetPlans() {
var reason string
if !p.GetAvailable() {
reason = "Stripe isn't configured on this server yet (missing STRIPE_SECRET_KEY)."
}
action := "Choose " + p.GetName()
switch rk := planRank(p.GetPlan()); {
case p.GetPlan() == billingv1.Plan_PLAN_FREE:
action = "Downgrade to Free"
case rk > curRank:
action = "Upgrade to " + p.GetName()
case rk < curRank:
action = "Switch to " + p.GetName()
}
mult := ""
switch {
case p.GetPlan() == billingv1.Plan_PLAN_FREE:
case p.GetDailyMessageLimit() == 0:
mult = "Unlimited messages — no daily cap"
case freeLimit > 0:
mult = fmt.Sprintf("%d× more messages than Free", p.GetDailyMessageLimit()/freeLimit)
}
plans = append(plans, planView{
Plan: planSlug(p.GetPlan()), Name: p.GetName(), PriceEUR: int(p.GetPriceEur()),
DailyMessageLimit: int(p.GetDailyMessageLimit()), IsCurrent: p.GetPlan() == sub.GetPlan(),
Action: action, Popular: p.GetPlan() == billingv1.Plan_PLAN_PRO, Multiplier: mult,
Checkoutable: p.GetAvailable(), UnavailableReason: reason,
})
}
data := subscriptionData{
CSRFToken: h.csrfToken(w, r), UserEmail: user.GetEmail(), Plans: plans,
CurrentName: planDisplayName(sub.GetPlan()), Status: sub.GetStatus(),
CancelAtPeriodEnd: sub.GetCancelAtPeriodEnd(), HasBillingPortal: sub.GetHasBillingAccount(),
}
if sub.GetCurrentPeriodEnd() != nil {
data.CurrentPeriodEnd = sub.GetCurrentPeriodEnd().AsTime()
}
var limit int
for _, p := range catalog.GetPlans() {
if p.GetPlan() == sub.GetPlan() {
limit = int(p.GetDailyMessageLimit())
}
}
data.DailyLimit = limit
data.Unlimited = limit == 0
if usage, err := h.Billing.GetDailyUsage(ctx, &billingv1.GetDailyUsageRequest{UserId: user.GetId()}); err != nil {
h.Logger.Warn("load daily usage for subscription page", "err", err, "user_id", user.GetId())
} else {
data.UsedToday = int(usage.GetUsed())
if limit > 0 {
data.UsagePercent = min(100, data.UsedToday*100/limit)
data.NearLimit = data.UsedToday*100 >= limit*80
data.AtLimit = data.UsedToday >= limit
}
}
return data, nil
}
func (h *Handlers) SubscriptionPage(w http.ResponseWriter, r *http.Request) {
d, err := h.buildSubscriptionData(r.Context(), w, r)
if err != nil {
h.serverError(w, "load subscription", err)
return
}
switch r.URL.Query().Get("msg") {
case "success":
d.Message = "Payment received — thank you! Your plan is now active."
case "cancelled":
d.Message = "Checkout was cancelled — you weren't charged."
}
h.render(w, http.StatusOK, "subscription", d)
}
func (h *Handlers) subscriptionError(w http.ResponseWriter, r *http.Request, status int, msg string) {
d, err := h.buildSubscriptionData(r.Context(), w, r)
if err != nil {
h.serverError(w, "load subscription", err)
return
}
d.Error = msg
h.render(w, status, "subscription", d)
}
func (h *Handlers) SubscriptionSuccess(w http.ResponseWriter, r *http.Request) {
if id := r.URL.Query().Get("session_id"); id != "" {
if _, err := h.Billing.SyncCheckoutSession(r.Context(), &billingv1.SyncCheckoutSessionRequest{
UserId: userFrom(r).GetId(), StripeSessionId: id,
}); err != nil {
h.Logger.Warn("sync checkout session", "err", err, "session_id", id)
}
}
http.Redirect(w, r, "/subscription?msg=success", http.StatusSeeOther)
}
func (h *Handlers) SubscriptionCancel(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/subscription?msg=cancelled", http.StatusSeeOther)
}
func (h *Handlers) CreateCheckout(w http.ResponseWriter, r *http.Request) {
user := userFrom(r)
ctx := r.Context()
planStr := r.PostFormValue("plan")
if planStr == "free" {
h.BillingPortal(w, r)
return
}
resp, err := h.Billing.CreateCheckoutSession(ctx, &billingv1.CreateCheckoutSessionRequest{
UserId: user.GetId(), Email: user.GetEmail(), Plan: parsePlanSlug(planStr),
SuccessUrl: h.subscriptionURL("/subscription/success") + "?session_id={CHECKOUT_SESSION_ID}",
CancelUrl: h.subscriptionURL("/subscription/cancel"),
})
if err != nil {
h.Logger.Error("create checkout session", "err", err, "user_id", user.GetId(), "plan", planStr)
h.subscriptionError(w, r, http.StatusBadGateway, fmt.Sprintf("Couldn't start checkout: %s", err))
return
}
http.Redirect(w, r, resp.GetCheckoutUrl(), http.StatusSeeOther)
}
func (h *Handlers) BillingPortal(w http.ResponseWriter, r *http.Request) {
user := userFrom(r)
resp, err := h.Billing.CreateBillingPortalSession(r.Context(), &billingv1.CreateBillingPortalSessionRequest{
UserId: user.GetId(), ReturnUrl: h.subscriptionURL("/subscription"),
})
if err != nil {
h.Logger.Error("create billing portal session", "err", err, "user_id", user.GetId())
h.subscriptionError(w, r, http.StatusBadGateway, fmt.Sprintf("Couldn't open the billing portal: %s", err))
return
}
http.Redirect(w, r, resp.GetPortalUrl(), http.StatusSeeOther)
}
func (h *Handlers) subscriptionURL(path string) string {
return strings.TrimRight(h.BaseURL, "/") + path
}
func parsePlanSlug(s string) billingv1.Plan {
switch s {
case "basic":
return billingv1.Plan_PLAN_BASIC
case "pro":
return billingv1.Plan_PLAN_PRO
case "premium":
return billingv1.Plan_PLAN_PREMIUM
default:
return billingv1.Plan_PLAN_FREE
}
}
// StripeWebhook (POST /webhooks/stripe) — see package doc above.
func (h *Handlers) StripeWebhook(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxWebhookBodyBytes))
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
_, err = h.Billing.IngestStripeWebhook(r.Context(), &billingv1.IngestStripeWebhookRequest{
Payload: body, SignatureHeader: r.Header.Get("Stripe-Signature"),
})
if err != nil {
h.Logger.Warn("stripe webhook rejected", "err", err)
http.Error(w, "invalid signature", http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
}