// 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 } // No billing provider: AI is unlimited and not tied to a plan, so report // that instead of an empty ledger the dashboard would render as "0 left". if h.CreditService.Unmetered() { c.JSON(http.StatusOK, gin.H{ "unlimited": true, "balance": 0, "monthly_balance": 0, "purchased_balance": 0, "monthly_allowance": 0, "total_purchased": 0, "monthly_reset_at": nil, "next_reset_at": nil, "packs": []credits.CreditPack{}, }) 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{ "unlimited": false, "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 }