274 lines
7.4 KiB
Go
274 lines
7.4 KiB
Go
package usecase
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
"apps/backend/internal/module/usage/domain"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// KeyResolver decides platform vs byok for a meter.
|
|
type KeyResolver interface {
|
|
// Resolve returns key_mode and whether a usable key exists.
|
|
Resolve(ctx context.Context, uid int64, meter string) (keyMode string, hasKey bool, err error)
|
|
}
|
|
|
|
// StaticResolver for tests / simple wiring.
|
|
type StaticResolver struct {
|
|
// ByUIDMeter: "uid:meter" -> keyMode (platform|byok); empty means no key
|
|
Map map[string]string
|
|
}
|
|
|
|
func (s *StaticResolver) Resolve(_ context.Context, uid int64, meter string) (string, bool, error) {
|
|
if s == nil || s.Map == nil {
|
|
return "", false, nil
|
|
}
|
|
k := s.Map[fmt.Sprintf("%d:%s", uid, meter)]
|
|
if k == "" {
|
|
return "", false, nil
|
|
}
|
|
return k, true, nil
|
|
}
|
|
|
|
type Service struct {
|
|
Repo domain.Repository
|
|
Resolver KeyResolver
|
|
}
|
|
|
|
func New(repo domain.Repository, resolver KeyResolver) *Service {
|
|
return &Service{Repo: repo, Resolver: resolver}
|
|
}
|
|
|
|
// RecordCall after successful external call (or for meter tests).
|
|
func (s *Service) RecordCall(ctx context.Context, uid int64, meter, keyMode, label, source string) (*domain.Event, error) {
|
|
if keyMode != domain.KeyModePlatform && keyMode != domain.KeyModeByok {
|
|
return nil, fmt.Errorf("invalid key_mode")
|
|
}
|
|
credits := 0
|
|
if keyMode == domain.KeyModePlatform {
|
|
credits = domain.DefaultCreditCost[meter]
|
|
if credits == 0 {
|
|
credits = 1
|
|
}
|
|
prefs, _ := s.ensurePrefs(ctx, uid)
|
|
if !prefs.Unlimited {
|
|
sum, err := s.GetSummary(ctx, uid, domain.CurrentMonthKey())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if sum.Platform.CreditsRemaining < credits {
|
|
return nil, domain.ErrQuotaExceeded
|
|
}
|
|
}
|
|
}
|
|
now := domain.NowNano()
|
|
e := &domain.Event{
|
|
ID: uuid.NewString(), UID: uid, Meter: meter, Credits: credits,
|
|
KeyMode: keyMode, Label: label, Source: source,
|
|
MonthKey: domain.MonthKey(now), CreatedAt: now,
|
|
}
|
|
if err := s.Repo.InsertEvent(ctx, e); err != nil {
|
|
return nil, err
|
|
}
|
|
return e, nil
|
|
}
|
|
|
|
// PrepareCall checks quota / key before external call; returns key_mode.
|
|
func (s *Service) PrepareCall(ctx context.Context, uid int64, meter string) (keyMode string, err error) {
|
|
if s.Resolver == nil {
|
|
return "", domain.ErrNoKey
|
|
}
|
|
mode, has, err := s.Resolver.Resolve(ctx, uid, meter)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if !has {
|
|
return "", domain.ErrNoKey
|
|
}
|
|
if mode == domain.KeyModePlatform {
|
|
prefs, _ := s.ensurePrefs(ctx, uid)
|
|
if !prefs.Unlimited {
|
|
sum, err := s.GetSummary(ctx, uid, domain.CurrentMonthKey())
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
cost := domain.DefaultCreditCost[meter]
|
|
if cost == 0 {
|
|
cost = 1
|
|
}
|
|
if sum.Platform.CreditsRemaining < cost {
|
|
return "", domain.ErrQuotaExceeded
|
|
}
|
|
}
|
|
}
|
|
return mode, nil
|
|
}
|
|
|
|
func (s *Service) ensurePrefs(ctx context.Context, uid int64) (*domain.MemberPrefs, error) {
|
|
p, err := s.Repo.GetPrefs(ctx, uid)
|
|
if err != nil || p == nil {
|
|
p = &domain.MemberPrefs{UID: uid, PlanID: domain.PlanFree, UpdatedAt: domain.NowNano()}
|
|
_ = s.Repo.SavePrefs(ctx, p)
|
|
}
|
|
if p.PlanID == "" {
|
|
p.PlanID = domain.PlanFree
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
func (s *Service) GetSummary(ctx context.Context, uid int64, monthKey string) (*domain.MonthSummary, error) {
|
|
if monthKey == "" {
|
|
monthKey = domain.CurrentMonthKey()
|
|
}
|
|
prefs, _ := s.ensurePrefs(ctx, uid)
|
|
plan := domain.Plans[prefs.PlanID]
|
|
if plan.ID == "" {
|
|
plan = domain.Plans[domain.PlanFree]
|
|
}
|
|
events, err := s.Repo.ListEvents(ctx, uid, monthKey, "all", 0)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
platBy := map[string]domain.MeterCount{}
|
|
byokBy := map[string]domain.MeterCount{}
|
|
platUsed, platCalls, byokCalls := 0, 0, 0
|
|
for _, e := range events {
|
|
if e.KeyMode == domain.KeyModePlatform {
|
|
platUsed += e.Credits
|
|
platCalls++
|
|
m := platBy[e.Meter]
|
|
m.Count++
|
|
m.Credits += e.Credits
|
|
platBy[e.Meter] = m
|
|
} else if e.KeyMode == domain.KeyModeByok {
|
|
byokCalls++
|
|
m := byokBy[e.Meter]
|
|
m.Count++
|
|
byokBy[e.Meter] = m
|
|
}
|
|
}
|
|
remaining := plan.MonthlyCredits - platUsed
|
|
if remaining < 0 {
|
|
remaining = 0
|
|
}
|
|
if prefs.Unlimited {
|
|
remaining = plan.MonthlyCredits // display full; not enforced
|
|
}
|
|
return &domain.MonthSummary{
|
|
MonthKey: monthKey, UID: uid, PlanID: prefs.PlanID, Unlimited: prefs.Unlimited,
|
|
Platform: domain.PlatformBlock{
|
|
CreditsUsed: platUsed, CreditsRemaining: remaining, CreditsTotal: plan.MonthlyCredits,
|
|
CallCount: platCalls, ByMeter: platBy,
|
|
},
|
|
Byok: domain.ByokBlock{CallCount: byokCalls, ByMeter: byokBy},
|
|
TotalCredits: plan.MonthlyCredits, RemainingCredits: remaining,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) ListEvents(ctx context.Context, uid int64, monthKey, keyMode string, limit int) ([]*domain.Event, error) {
|
|
if limit <= 0 {
|
|
limit = 100
|
|
}
|
|
return s.Repo.ListEvents(ctx, uid, monthKey, keyMode, limit)
|
|
}
|
|
|
|
func (s *Service) GetPrefs(ctx context.Context, uid int64) (*domain.MemberPrefs, error) {
|
|
return s.ensurePrefs(ctx, uid)
|
|
}
|
|
|
|
func (s *Service) SetPrefs(ctx context.Context, actorUID int64, targetUID int64, isAdmin bool, planID string, unlimited *bool) (*domain.MemberPrefs, error) {
|
|
if !isAdmin && actorUID != targetUID {
|
|
return nil, domain.ErrForbidden
|
|
}
|
|
if !isAdmin && unlimited != nil {
|
|
// members cannot set unlimited
|
|
return nil, domain.ErrForbidden
|
|
}
|
|
p, _ := s.ensurePrefs(ctx, targetUID)
|
|
if planID != "" {
|
|
if _, ok := domain.Plans[planID]; !ok {
|
|
return nil, domain.ErrInvalidPlan
|
|
}
|
|
// member self can only change via purchase
|
|
if !isAdmin && actorUID == targetUID {
|
|
return nil, domain.ErrForbidden
|
|
}
|
|
p.PlanID = planID
|
|
}
|
|
if unlimited != nil && isAdmin {
|
|
p.Unlimited = *unlimited
|
|
}
|
|
p.UpdatedAt = domain.NowNano()
|
|
if err := s.Repo.SavePrefs(ctx, p); err != nil {
|
|
return nil, err
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
func (s *Service) PurchasePlan(ctx context.Context, uid int64, planID, mockRef string) (*domain.Purchase, error) {
|
|
if _, ok := domain.Plans[planID]; !ok {
|
|
return nil, domain.ErrInvalidPlan
|
|
}
|
|
p, _ := s.ensurePrefs(ctx, uid)
|
|
p.PlanID = planID
|
|
p.UpdatedAt = domain.NowNano()
|
|
_ = s.Repo.SavePrefs(ctx, p)
|
|
pur := &domain.Purchase{
|
|
ID: uuid.NewString(), UID: uid, PlanID: planID, MockRef: mockRef, CreatedAt: domain.NowNano(),
|
|
}
|
|
if err := s.Repo.InsertPurchase(ctx, pur); err != nil {
|
|
return nil, err
|
|
}
|
|
return pur, nil
|
|
}
|
|
|
|
func (s *Service) ListMyPurchases(ctx context.Context, uid int64, limit int) ([]*domain.Purchase, error) {
|
|
if limit <= 0 {
|
|
limit = 50
|
|
}
|
|
return s.Repo.ListPurchases(ctx, uid, limit)
|
|
}
|
|
|
|
func (s *Service) TenantSummary(ctx context.Context, monthKey string) (*domain.TenantSummary, error) {
|
|
if monthKey == "" {
|
|
monthKey = domain.CurrentMonthKey()
|
|
}
|
|
events, err := s.Repo.ListEventsAll(ctx, monthKey, 0)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
type acc struct {
|
|
plat int
|
|
byok int
|
|
}
|
|
byUID := map[int64]*acc{}
|
|
platTotal, byokTotal := 0, 0
|
|
for _, e := range events {
|
|
a := byUID[e.UID]
|
|
if a == nil {
|
|
a = &acc{}
|
|
byUID[e.UID] = a
|
|
}
|
|
if e.KeyMode == domain.KeyModePlatform {
|
|
a.plat += e.Credits
|
|
platTotal += e.Credits
|
|
} else if e.KeyMode == domain.KeyModeByok {
|
|
a.byok++
|
|
byokTotal++
|
|
}
|
|
}
|
|
var members []domain.TenantRow
|
|
for uid, a := range byUID {
|
|
prefs, _ := s.ensurePrefs(ctx, uid)
|
|
members = append(members, domain.TenantRow{
|
|
UID: uid, PlanID: prefs.PlanID, Unlimited: prefs.Unlimited,
|
|
PlatformCreditsUsed: a.plat, ByokCallCount: a.byok,
|
|
})
|
|
}
|
|
return &domain.TenantSummary{
|
|
MonthKey: monthKey, PlatformCreditsUsed: platTotal, ByokCallCount: byokTotal, Members: members,
|
|
}, nil
|
|
}
|