mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-11 00:05:01 +00:00
Add an organization credit ledger with idempotent consumption, plan credit grants, Anthropic/OpenAI writing providers, and a gated generation endpoint for campaign writing assistance.
204 lines
7.0 KiB
Go
204 lines
7.0 KiB
Go
// Package credits implements the AI writing-assistant credit ledger: balance
|
|
// reads, atomic consumption with idempotency, and grants. Hard billing
|
|
// correctness (no negative balances, no double-charge on retry) lives in the
|
|
// repository's atomic SQL; this service layer adds the abuse caps (a short
|
|
// rolling window plus a daily window) on top.
|
|
package credits
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/redis/go-redis/v9"
|
|
"github.com/warmbly/warmbly/internal/errx"
|
|
"github.com/warmbly/warmbly/internal/infrastructure/cache"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
"github.com/warmbly/warmbly/internal/repository"
|
|
)
|
|
|
|
const (
|
|
// Rolling-window cap: max writing-assistant generations per org in a 5-hour
|
|
// window. Catches burst abuse independent of credit balance.
|
|
WindowShort = 5 * time.Hour
|
|
DefaultShortLimit = 60
|
|
|
|
// Daily cap: max writing-assistant generations per org per UTC day.
|
|
WindowDaily = 24 * time.Hour
|
|
DefaultDailyLimit = 300
|
|
|
|
keyPrefixShort = "credits:cap:5h:"
|
|
keyPrefixDaily = "credits:cap:day:"
|
|
)
|
|
|
|
// ErrInsufficientCredits signals the org has fewer credits than requested. The
|
|
// handler maps this to HTTP 402. Distinct, exported sentinels are used (rather
|
|
// than *errx.Error) because errx has no 402 code; the handler emits the 402
|
|
// envelope itself.
|
|
var ErrInsufficientCredits = errors.New("insufficient credits")
|
|
|
|
// ErrCapExceeded signals the org hit a rolling/daily generation cap. The
|
|
// handler maps this to HTTP 429.
|
|
var ErrCapExceeded = errors.New("generation rate cap exceeded")
|
|
|
|
// CreditService is the application-facing API for AI credits.
|
|
type CreditService interface {
|
|
// GetBalance returns the org's current credit balance (0 if no ledger yet).
|
|
GetBalance(ctx context.Context, orgID uuid.UUID) (int, *errx.Error)
|
|
|
|
// Consume enforces the abuse caps, then atomically debits `amount` credits.
|
|
// On success it returns the resulting balance. idempotencyKey may be empty;
|
|
// when set, a retry with the same key does not double-charge and does not
|
|
// re-count against the caps.
|
|
//
|
|
// Returns ErrInsufficientCredits (→402) or ErrCapExceeded (→429) as
|
|
// sentinel errors the handler maps; any other error is an internal failure.
|
|
Consume(ctx context.Context, orgID uuid.UUID, amount int, reason, model string, tokens int, idempotencyKey string) (int, error)
|
|
|
|
// Grant credits to an org (monthly plan grant or purchase). Returns the
|
|
// resulting balance.
|
|
Grant(ctx context.Context, orgID uuid.UUID, amount int, reason string) (int, *errx.Error)
|
|
|
|
// ListTransactions returns recent ledger transactions, newest first.
|
|
ListTransactions(ctx context.Context, orgID uuid.UUID, limit int) ([]models.CreditTransaction, *errx.Error)
|
|
}
|
|
|
|
type creditService struct {
|
|
repo repository.CreditRepository
|
|
cache *cache.Cache
|
|
shortLimit int
|
|
dailyLimit int
|
|
}
|
|
|
|
func NewService(repo repository.CreditRepository, c *cache.Cache) CreditService {
|
|
return &creditService{
|
|
repo: repo,
|
|
cache: c,
|
|
shortLimit: DefaultShortLimit,
|
|
dailyLimit: DefaultDailyLimit,
|
|
}
|
|
}
|
|
|
|
func (s *creditService) GetBalance(ctx context.Context, orgID uuid.UUID) (int, *errx.Error) {
|
|
ledger, err := s.repo.GetBalance(ctx, orgID)
|
|
if err != nil {
|
|
return 0, errx.New(errx.Internal, "failed to read credit balance")
|
|
}
|
|
if ledger == nil {
|
|
return 0, nil
|
|
}
|
|
return ledger.Balance, nil
|
|
}
|
|
|
|
func (s *creditService) Consume(ctx context.Context, orgID uuid.UUID, amount int, reason, model string, tokens int, idempotencyKey string) (int, error) {
|
|
if amount <= 0 {
|
|
return 0, errors.New("credit amount must be positive")
|
|
}
|
|
|
|
// Enforce the abuse caps first. The repo's Consume then handles the atomic
|
|
// debit and idempotent replay; we only count a cap hit against a *fresh*
|
|
// debit (replayed == false) so a legitimate client retry with the same
|
|
// Idempotency-Key is never penalized against the 5h/daily window.
|
|
if err := s.checkCaps(ctx, orgID, idempotencyKey); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
bal, _, replayed, err := s.repo.Consume(ctx, orgID, amount, reason, model, tokens, idempotencyKey)
|
|
if errors.Is(err, repository.ErrInsufficientCredits) {
|
|
return 0, ErrInsufficientCredits
|
|
}
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
// Persist the cap increment only for fresh debits. checkCaps used a
|
|
// reserve/peek so a replay does not advance the window.
|
|
if !replayed {
|
|
s.commitCaps(ctx, orgID)
|
|
}
|
|
return bal, nil
|
|
}
|
|
|
|
func (s *creditService) Grant(ctx context.Context, orgID uuid.UUID, amount int, reason string) (int, *errx.Error) {
|
|
if amount <= 0 {
|
|
return 0, errx.New(errx.BadRequest, "grant amount must be positive")
|
|
}
|
|
bal, _, err := s.repo.Grant(ctx, orgID, amount, reason)
|
|
if err != nil {
|
|
return 0, errx.New(errx.Internal, "failed to grant credits")
|
|
}
|
|
return bal, nil
|
|
}
|
|
|
|
func (s *creditService) ListTransactions(ctx context.Context, orgID uuid.UUID, limit int) ([]models.CreditTransaction, *errx.Error) {
|
|
txns, err := s.repo.ListTransactions(ctx, orgID, limit)
|
|
if err != nil {
|
|
return nil, errx.New(errx.Internal, "failed to list credit transactions")
|
|
}
|
|
return txns, nil
|
|
}
|
|
|
|
// shortKey / dailyKey are the per-org Redis keys for the two abuse windows.
|
|
func (s *creditService) shortKey(orgID uuid.UUID) string {
|
|
return keyPrefixShort + orgID.String()
|
|
}
|
|
|
|
func (s *creditService) dailyKey(orgID uuid.UUID) string {
|
|
day := time.Now().UTC().Format("2006-01-02")
|
|
return fmt.Sprintf("%s%s:%s", keyPrefixDaily, orgID.String(), day)
|
|
}
|
|
|
|
// checkCaps rejects when the org has already reached the rolling 5h or daily
|
|
// generation cap. It only *reads* the counters (commitCaps does the increment
|
|
// after a successful fresh debit), so an idempotent replay never advances the
|
|
// window. On Redis errors it fails open so a cache outage never blocks
|
|
// legitimate generation. It is a thin wrapper over Redis (the same key shape the
|
|
// rate-limit service uses), not a reimplementation of that service.
|
|
//
|
|
// idempotencyKey is reserved for future per-key suppression; today a replay is
|
|
// distinguished after the fact via the repo's replayed flag.
|
|
func (s *creditService) checkCaps(ctx context.Context, orgID uuid.UUID, _ string) error {
|
|
if s.cache == nil {
|
|
return nil
|
|
}
|
|
if s.atOrOverLimit(ctx, s.shortKey(orgID), s.shortLimit) {
|
|
return ErrCapExceeded
|
|
}
|
|
if s.atOrOverLimit(ctx, s.dailyKey(orgID), s.dailyLimit) {
|
|
return ErrCapExceeded
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *creditService) atOrOverLimit(ctx context.Context, key string, limit int) bool {
|
|
n, err := s.cache.Get(ctx, key).Int()
|
|
if err == redis.Nil {
|
|
return false
|
|
}
|
|
if err != nil {
|
|
// Fail open on cache errors.
|
|
return false
|
|
}
|
|
return n >= limit
|
|
}
|
|
|
|
// commitCaps increments both windows after a successful fresh debit, setting the
|
|
// TTLs on first write. Best-effort: a Redis failure here never fails the
|
|
// already-completed generation.
|
|
func (s *creditService) commitCaps(ctx context.Context, orgID uuid.UUID) {
|
|
if s.cache == nil {
|
|
return
|
|
}
|
|
s.bump(ctx, s.shortKey(orgID), WindowShort)
|
|
s.bump(ctx, s.dailyKey(orgID), WindowDaily)
|
|
}
|
|
|
|
func (s *creditService) bump(ctx context.Context, key string, window time.Duration) {
|
|
pipe := s.cache.Pipeline()
|
|
pipe.Incr(ctx, key)
|
|
pipe.Expire(ctx, key, window)
|
|
_, _ = pipe.Exec(ctx)
|
|
}
|