Files
llink/go/internal/handler/billing.go
Arjun Patel 563c91e7d5 infra: add logging wrapping
Resolves issues with gcp cloud logging quirks such as field names
2026-05-27 15:49:27 -07:00

210 lines
5.9 KiB
Go

package handler
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"github.com/flowy-live/llink/internal/utils/flog"
"github.com/flowy-live/llink/internal/billing"
"github.com/flowy-live/llink/internal/middleware"
"github.com/flowy-live/llink/internal/network"
)
type CreateCheckoutSessionRequest struct {
Cadence string `json:"cadence"`
}
type CheckoutSessionResponse struct {
URL string `json:"url"`
}
type PortalSessionResponse struct {
URL string `json:"url"`
}
// GetNetworkUsage reports today's usage, daily limit (nil on pro), and reset
// time. Open to any network member since the UI surfaces it to every sender.
func (h *Handler) GetNetworkUsage(w http.ResponseWriter, r *http.Request) {
humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
networkID := r.PathValue("id")
if networkID == "" {
http.Error(w, "network id is required", http.StatusBadRequest)
return
}
isMember, err := h.networkSvc.IsMember(r.Context(), networkID, humanId)
if err != nil {
flog.Error("failed to check network membership", "error", err, "network_id", networkID)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if !isMember {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
usage, err := h.billingSvc.GetUsage(r.Context(), networkID)
if err != nil {
flog.Error("failed to get network usage", "error", err, "network_id", networkID)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
writeJSON(w, usage)
}
func (h *Handler) GetNetworkBilling(w http.ResponseWriter, r *http.Request) {
net, _, ok := h.loadNetworkForAdmin(w, r)
if !ok {
return
}
status, err := h.billingSvc.GetStatus(r.Context(), net.ID)
if err != nil {
flog.Error("failed to get billing status", "error", err, "network_id", net.ID)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
writeJSON(w, status)
}
func (h *Handler) CreateCheckoutSession(w http.ResponseWriter, r *http.Request) {
net, adminHumanId, ok := h.loadNetworkForAdmin(w, r)
if !ok {
return
}
var req CreateCheckoutSessionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
cadence := billing.Cadence(req.Cadence)
if !cadence.IsValid() {
http.Error(w, "invalid cadence", http.StatusBadRequest)
return
}
adminHuman, err := h.humanSvc.GetByID(r.Context(), adminHumanId)
if err != nil {
flog.Error("failed to load admin human", "error", err, "human_id", adminHumanId)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
seats, err := h.networkSvc.CountSeats(r.Context(), net.ID)
if err != nil {
flog.Error("failed to count seats", "error", err, "network_id", net.ID)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
url, err := h.billingSvc.CreateCheckoutSession(r.Context(), billing.CheckoutParams{
NetworkID: net.ID,
AdminHumanID: adminHumanId,
AdminEmail: adminHuman.Email,
Cadence: cadence,
Seats: seats,
})
if err != nil {
flog.Error("failed to create checkout session", "error", err, "network_id", net.ID)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
writeJSON(w, CheckoutSessionResponse{URL: url})
}
func (h *Handler) CreatePortalSession(w http.ResponseWriter, r *http.Request) {
net, _, ok := h.loadNetworkForAdmin(w, r)
if !ok {
return
}
url, err := h.billingSvc.CreatePortalSession(r.Context(), net.ID)
if errors.Is(err, billing.ErrNoActiveSubscription) {
http.Error(w, "no active subscription", http.StatusConflict)
return
}
if err != nil {
flog.Error("failed to create portal session", "error", err, "network_id", net.ID)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
writeJSON(w, PortalSessionResponse{URL: url})
}
const maxStripeWebhookBytes = 1 << 20 // 1 MiB
func (h *Handler) HandleStripeWebhook(w http.ResponseWriter, r *http.Request) {
payload, err := io.ReadAll(io.LimitReader(r.Body, maxStripeWebhookBytes))
if err != nil {
flog.Warn("stripe webhook: failed to read body", "error", err)
http.Error(w, "bad request", http.StatusBadRequest)
return
}
signature := r.Header.Get("Stripe-Signature")
if err := h.billingSvc.HandleWebhook(r.Context(), payload, signature); err != nil {
flog.Error("stripe webhook failed", "error", err)
formattedErr := fmt.Errorf("webhook processing failed: %w", err)
http.Error(w, formattedErr.Error(), http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
}
// Resolves {id}, verifies the caller is admin. On failure writes the HTTP
// error and returns ok=false.
func (h *Handler) loadNetworkForAdmin(w http.ResponseWriter, r *http.Request) (*network.Network, string, bool) {
humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return nil, "", false
}
networkID := r.PathValue("id")
if networkID == "" {
http.Error(w, "network id is required", http.StatusBadRequest)
return nil, "", false
}
net, err := h.networkSvc.GetByID(r.Context(), networkID)
if errors.Is(err, network.ErrNotFound) {
http.Error(w, "network not found", http.StatusNotFound)
return nil, "", false
}
if err != nil {
flog.Error("failed to load network", "error", err, "network_id", networkID)
http.Error(w, "internal server error", http.StatusInternalServerError)
return nil, "", false
}
if net.AdminHumanId != humanId {
http.Error(w, "forbidden", http.StatusForbidden)
return nil, "", false
}
return net, humanId, true
}
func writeJSON(w http.ResponseWriter, body any) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(body); err != nil {
flog.Error("failed to write json", "error", err)
}
}