283 lines
8.2 KiB
Go
283 lines
8.2 KiB
Go
// 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)
|
||
}
|