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) }