feat: backend service
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user