151 lines
4.3 KiB
Go
151 lines
4.3 KiB
Go
package user
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// Tier values mirror the closed set documented in
|
|
// `backend/README.md` and enforced by CHECK constraints
|
|
// on `entitlement_records` and `entitlement_snapshots`.
|
|
const (
|
|
TierFree = "free"
|
|
TierMonthly = "monthly"
|
|
TierYearly = "yearly"
|
|
TierPermanent = "permanent"
|
|
)
|
|
|
|
// EntitlementSnapshot is the read-side view of the user's current
|
|
// entitlement. It mirrors the OpenAPI `EntitlementSnapshot` schema.
|
|
type EntitlementSnapshot struct {
|
|
UserID uuid.UUID
|
|
Tier string
|
|
IsPaid bool
|
|
Source string
|
|
Actor ActorRef
|
|
ReasonCode string
|
|
StartsAt time.Time
|
|
EndsAt *time.Time
|
|
MaxRegisteredRaceNames int32
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
// ApplyEntitlementInput carries the admin-supplied parameters of
|
|
// `POST /api/v1/admin/users/{user_id}/entitlements`. StartsAt and
|
|
// EndsAt are optional: when omitted the tier policy supplies sensible
|
|
// defaults (StartsAt = now, EndsAt = now + tier window for monthly /
|
|
// yearly, otherwise NULL).
|
|
type ApplyEntitlementInput struct {
|
|
UserID uuid.UUID
|
|
Tier string
|
|
Source string
|
|
Actor ActorRef
|
|
ReasonCode string
|
|
StartsAt *time.Time
|
|
EndsAt *time.Time
|
|
}
|
|
|
|
// tierBinding is the static policy table that maps tiers to their
|
|
// derived attributes. The implementation keeps the values inline; later stages
|
|
// can move the table to configuration if marketing requirements
|
|
// diverge.
|
|
type tierBinding struct {
|
|
IsPaid bool
|
|
MaxRegisteredRaceNames int32
|
|
DefaultDuration time.Duration // 0 = no expiry
|
|
}
|
|
|
|
var tierPolicy = map[string]tierBinding{
|
|
TierFree: {IsPaid: false, MaxRegisteredRaceNames: 1, DefaultDuration: 0},
|
|
TierMonthly: {IsPaid: true, MaxRegisteredRaceNames: 5, DefaultDuration: 30 * 24 * time.Hour},
|
|
TierYearly: {IsPaid: true, MaxRegisteredRaceNames: 5, DefaultDuration: 365 * 24 * time.Hour},
|
|
TierPermanent: {IsPaid: true, MaxRegisteredRaceNames: 5, DefaultDuration: 0},
|
|
}
|
|
|
|
// defaultFreeSnapshot returns the entitlement snapshot installed for a
|
|
// brand-new account at registration time.
|
|
func defaultFreeSnapshot(userID uuid.UUID, now time.Time) EntitlementSnapshot {
|
|
binding := tierPolicy[TierFree]
|
|
return EntitlementSnapshot{
|
|
UserID: userID,
|
|
Tier: TierFree,
|
|
IsPaid: binding.IsPaid,
|
|
Source: "system",
|
|
Actor: ActorRef{Type: "system"},
|
|
ReasonCode: "",
|
|
StartsAt: now,
|
|
EndsAt: nil,
|
|
MaxRegisteredRaceNames: binding.MaxRegisteredRaceNames,
|
|
UpdatedAt: now,
|
|
}
|
|
}
|
|
|
|
// ApplyEntitlement validates the supplied input, derives missing
|
|
// attributes from the tier policy, persists an immutable record and a
|
|
// fresh snapshot atomically, refreshes the cache, and returns the
|
|
// up-to-date account aggregate.
|
|
//
|
|
// Tier downgrades are accepted: lobby state in the consolidated implementation enforces the
|
|
// "already-registered race names are never revoked on downgrade" rule
|
|
// at the lobby surface; user.Service is unconcerned with that
|
|
// invariant on the entitlement write path.
|
|
func (s *Service) ApplyEntitlement(ctx context.Context, input ApplyEntitlementInput) (Account, error) {
|
|
if input.UserID == uuid.Nil {
|
|
return Account{}, ErrAccountNotFound
|
|
}
|
|
binding, ok := tierPolicy[strings.TrimSpace(input.Tier)]
|
|
if !ok {
|
|
return Account{}, fmt.Errorf("%w: %q", ErrInvalidTier, input.Tier)
|
|
}
|
|
if err := input.Actor.Validate(); err != nil {
|
|
return Account{}, err
|
|
}
|
|
source := strings.TrimSpace(input.Source)
|
|
if source == "" {
|
|
return Account{}, fmt.Errorf("%w: source must be non-empty", ErrInvalidInput)
|
|
}
|
|
|
|
now := s.deps.Now().UTC()
|
|
startsAt := now
|
|
if input.StartsAt != nil {
|
|
startsAt = input.StartsAt.UTC()
|
|
}
|
|
var endsAt *time.Time
|
|
switch {
|
|
case input.EndsAt != nil:
|
|
t := input.EndsAt.UTC()
|
|
endsAt = &t
|
|
case binding.DefaultDuration > 0:
|
|
t := startsAt.Add(binding.DefaultDuration)
|
|
endsAt = &t
|
|
}
|
|
|
|
snapshot := EntitlementSnapshot{
|
|
UserID: input.UserID,
|
|
Tier: input.Tier,
|
|
IsPaid: binding.IsPaid,
|
|
Source: source,
|
|
Actor: input.Actor,
|
|
ReasonCode: input.ReasonCode,
|
|
StartsAt: startsAt,
|
|
EndsAt: endsAt,
|
|
MaxRegisteredRaceNames: binding.MaxRegisteredRaceNames,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
persisted, err := s.deps.Store.ApplyEntitlementTx(ctx, snapshot)
|
|
if err != nil {
|
|
if errors.Is(err, ErrAccountNotFound) {
|
|
return Account{}, err
|
|
}
|
|
return Account{}, fmt.Errorf("user apply entitlement: %w", err)
|
|
}
|
|
s.deps.Cache.Add(persisted)
|
|
return s.GetAccount(ctx, input.UserID)
|
|
}
|