mirror of
https://github.com/warmbly/warmbly.git
synced 2026-08-19 00:01:14 +00:00
291 lines
9.0 KiB
Go
291 lines
9.0 KiB
Go
// Billing/credits endpoints: read the org's AI-credit ledger, start a top-up
|
|
// checkout, and page the transaction log. All are JWT-gated on manage_billing
|
|
// (see routes.go). Fulfillment of a purchase happens only in the Stripe
|
|
// webhook, never here.
|
|
package handler
|
|
|
|
import (
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/warmbly/warmbly/internal/api/middleware"
|
|
"github.com/warmbly/warmbly/internal/app/credits"
|
|
"github.com/warmbly/warmbly/internal/errx"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
"github.com/warmbly/warmbly/internal/utils/paging"
|
|
)
|
|
|
|
// GetCreditBalance — GET /subscription/credits
|
|
// Returns both credit pools, the plan's monthly allowance, and the next reset.
|
|
func (h *Handler) GetCreditBalance(c *gin.Context) {
|
|
orgID := middleware.GetOrganizationID(c)
|
|
if orgID == nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
|
|
return
|
|
}
|
|
if h.CreditService == nil {
|
|
errx.JSON(c, errx.New(errx.ServiceUnavailable, "credits are not available"))
|
|
return
|
|
}
|
|
|
|
ledger, xerr := h.CreditService.GetLedger(c.Request.Context(), *orgID)
|
|
if xerr != nil {
|
|
errx.JSON(c, xerr)
|
|
return
|
|
}
|
|
|
|
// Monthly allowance (per-cycle grant) and next reset come from the plan +
|
|
// subscription. Best-effort: an org with no subscription still gets a valid
|
|
// ledger view (allowance 0, no reset date).
|
|
monthlyAllowance := 0
|
|
var nextResetAt *time.Time
|
|
if h.FeatureGateService != nil {
|
|
if status, serr := h.FeatureGateService.GetSubscriptionStatus(c.Request.Context(), *orgID); serr == nil && status != nil && status.Plan != nil {
|
|
monthlyAllowance = status.Plan.MonthlyCredits
|
|
}
|
|
}
|
|
if h.SubscriptionService != nil {
|
|
if sub, serr := h.SubscriptionService.Get(c.Request.Context(), *orgID); serr == nil && sub != nil {
|
|
nextResetAt = sub.CurrentPeriodEnd
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"balance": ledger.Total(),
|
|
"monthly_balance": ledger.Balance,
|
|
"purchased_balance": ledger.PurchasedBalance,
|
|
"monthly_allowance": monthlyAllowance,
|
|
"total_purchased": ledger.TotalPurchased,
|
|
"monthly_reset_at": ledger.MonthResetAt,
|
|
"next_reset_at": nextResetAt,
|
|
"packs": credits.CreditPacks,
|
|
})
|
|
}
|
|
|
|
// CreateCreditCheckoutSession — POST /subscription/credits/checkout {pack}
|
|
// Paid orgs only. Starts a one-time Stripe Checkout for a top-up pack.
|
|
func (h *Handler) CreateCreditCheckoutSession(c *gin.Context) {
|
|
userID := c.GetString("user_id")
|
|
uid, err := uuid.Parse(userID)
|
|
if err != nil {
|
|
errx.JSON(c, errx.New(errx.Unauthorized, "invalid user"))
|
|
return
|
|
}
|
|
orgID := middleware.GetOrganizationID(c)
|
|
if orgID == nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Pack string `json:"pack" binding:"required"`
|
|
SuccessURL string `json:"success_url" binding:"required"`
|
|
CancelURL string `json:"cancel_url" binding:"required"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "invalid request body"))
|
|
return
|
|
}
|
|
|
|
pack := credits.PackByKey(req.Pack)
|
|
if pack == nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "unknown credit pack"))
|
|
return
|
|
}
|
|
|
|
// Top-ups are a paid-org feature.
|
|
paid, xerr := h.FeatureGateService.IsPaidOrganization(c.Request.Context(), *orgID)
|
|
if xerr != nil {
|
|
errx.JSON(c, xerr)
|
|
return
|
|
}
|
|
if !paid {
|
|
errx.JSON(c, errx.New(errx.Forbidden, "Credit top-ups require an active paid plan."))
|
|
return
|
|
}
|
|
|
|
session, xerr := h.StripeService.CreateCreditCheckoutSession(
|
|
c.Request.Context(), uid, *orgID, pack.Key, pack.Credits, req.SuccessURL, req.CancelURL,
|
|
)
|
|
if xerr != nil {
|
|
errx.JSON(c, xerr)
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"session_id": session.ID,
|
|
"checkout_url": session.URL,
|
|
})
|
|
}
|
|
|
|
// ListCreditTransactions — GET /subscription/credits/transactions
|
|
// Keyset-paginated (opaque cursor), newest first. Invalid cursor/limit -> 400.
|
|
func (h *Handler) ListCreditTransactions(c *gin.Context) {
|
|
orgID := middleware.GetOrganizationID(c)
|
|
if orgID == nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
|
|
return
|
|
}
|
|
if h.CreditService == nil {
|
|
errx.JSON(c, errx.New(errx.ServiceUnavailable, "credits are not available"))
|
|
return
|
|
}
|
|
|
|
limit, xerr := parseCreditLimit(c.Query("limit"), 25, 100)
|
|
if xerr != nil {
|
|
errx.JSON(c, xerr)
|
|
return
|
|
}
|
|
|
|
beforeAt, beforeID, xerr := paging.DecodeTimeCursor(c.Query("cursor"))
|
|
if xerr != nil {
|
|
errx.JSON(c, xerr)
|
|
return
|
|
}
|
|
|
|
// Fetch one extra row to know whether a next page exists.
|
|
txns, xerr := h.CreditService.ListTransactionsBefore(c.Request.Context(), *orgID, limit+1, beforeAt, beforeID)
|
|
if xerr != nil {
|
|
errx.JSON(c, xerr)
|
|
return
|
|
}
|
|
|
|
var nextCursor *string
|
|
if len(txns) > limit {
|
|
last := txns[limit-1]
|
|
nextCursor = paging.EncodeTime(last.CreatedAt, last.ID)
|
|
txns = txns[:limit]
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"data": txns,
|
|
"pagination": gin.H{
|
|
"next_cursor": nextCursor,
|
|
"has_more": nextCursor != nil,
|
|
},
|
|
})
|
|
}
|
|
|
|
// GetCreditUsage — GET /subscription/credits/usage?days=30
|
|
// The AI usage overview: spend per window vs limits, a daily series, and
|
|
// breakdowns by feature and model over the requested window (1-90 days).
|
|
func (h *Handler) GetCreditUsage(c *gin.Context) {
|
|
orgID := middleware.GetOrganizationID(c)
|
|
if orgID == nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
|
|
return
|
|
}
|
|
if h.CreditService == nil {
|
|
errx.JSON(c, errx.New(errx.ServiceUnavailable, "credits are not available"))
|
|
return
|
|
}
|
|
days := 30
|
|
if raw := c.Query("days"); raw != "" {
|
|
n, err := strconv.Atoi(raw)
|
|
if err != nil || n < 1 || n > 90 {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "days must be 1-90"))
|
|
return
|
|
}
|
|
days = n
|
|
}
|
|
overview, xerr := h.CreditService.GetUsageOverview(c.Request.Context(), *orgID, days)
|
|
if xerr != nil {
|
|
errx.JSON(c, xerr)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, overview)
|
|
}
|
|
|
|
// GetCreditSettings — GET /subscription/credits/settings
|
|
// The org's AI spend controls (limits, alert threshold, auto top-up).
|
|
func (h *Handler) GetCreditSettings(c *gin.Context) {
|
|
orgID := middleware.GetOrganizationID(c)
|
|
if orgID == nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
|
|
return
|
|
}
|
|
if h.CreditService == nil {
|
|
errx.JSON(c, errx.New(errx.ServiceUnavailable, "credits are not available"))
|
|
return
|
|
}
|
|
cfg, xerr := h.CreditService.GetSpendSettings(c.Request.Context(), *orgID)
|
|
if xerr != nil {
|
|
errx.JSON(c, xerr)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, cfg)
|
|
}
|
|
|
|
// UpdateCreditSettings — PATCH /subscription/credits/settings
|
|
// Saves the spend controls. Absent limit fields disable that limit.
|
|
func (h *Handler) UpdateCreditSettings(c *gin.Context) {
|
|
orgID := middleware.GetOrganizationID(c)
|
|
if orgID == nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
|
|
return
|
|
}
|
|
if h.CreditService == nil {
|
|
errx.JSON(c, errx.New(errx.ServiceUnavailable, "credits are not available"))
|
|
return
|
|
}
|
|
var body struct {
|
|
SpendLimitDaily *int `json:"spend_limit_daily"`
|
|
SpendLimitWeekly *int `json:"spend_limit_weekly"`
|
|
SpendLimitMonthly *int `json:"spend_limit_monthly"`
|
|
MemberLimitDaily *int `json:"member_limit_daily"`
|
|
MemberLimitWeekly *int `json:"member_limit_weekly"`
|
|
MemberLimitMonthly *int `json:"member_limit_monthly"`
|
|
LowBalanceThreshold int `json:"low_balance_threshold"`
|
|
AutoTopupEnabled bool `json:"auto_topup_enabled"`
|
|
AutoTopupPack string `json:"auto_topup_pack"`
|
|
AutoTopupThreshold int `json:"auto_topup_threshold"`
|
|
AutoTopupMaxPerMonth int `json:"auto_topup_max_per_month"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "invalid request body"))
|
|
return
|
|
}
|
|
if body.AutoTopupPack == "" {
|
|
body.AutoTopupPack = credits.CreditPacks[0].Key
|
|
}
|
|
cfg, xerr := h.CreditService.UpdateSpendSettings(c.Request.Context(), *orgID, &models.AISpendSettings{
|
|
SpendLimitDaily: body.SpendLimitDaily,
|
|
SpendLimitWeekly: body.SpendLimitWeekly,
|
|
SpendLimitMonthly: body.SpendLimitMonthly,
|
|
MemberLimitDaily: body.MemberLimitDaily,
|
|
MemberLimitWeekly: body.MemberLimitWeekly,
|
|
MemberLimitMonthly: body.MemberLimitMonthly,
|
|
LowBalanceThreshold: body.LowBalanceThreshold,
|
|
AutoTopupEnabled: body.AutoTopupEnabled,
|
|
AutoTopupPack: body.AutoTopupPack,
|
|
AutoTopupThreshold: body.AutoTopupThreshold,
|
|
AutoTopupMaxPerMonth: body.AutoTopupMaxPerMonth,
|
|
})
|
|
if xerr != nil {
|
|
errx.JSON(c, xerr)
|
|
return
|
|
}
|
|
h.auditOrg(c, models.AuditActionUpdate, models.AuditEntityCreditGrant, nil, nil, map[string]string{
|
|
"kind": "spend_settings",
|
|
})
|
|
c.JSON(http.StatusOK, cfg)
|
|
}
|
|
|
|
// parseCreditLimit parses an optional ?limit into [1, max], defaulting to def.
|
|
// A non-numeric or out-of-range value is a 400 (never silently clamped from
|
|
// garbage), matching the public-API list contract.
|
|
func parseCreditLimit(s string, def, max int) (int, *errx.Error) {
|
|
if s == "" {
|
|
return def, nil
|
|
}
|
|
n, err := strconv.Atoi(s)
|
|
if err != nil || n < 1 || n > max {
|
|
return 0, errx.New(errx.BadRequest, "invalid limit")
|
|
}
|
|
return n, nil
|
|
}
|