Files
galaxy-game/user/internal/domain/entitlement/model.go
T
2026-04-10 19:05:02 +02:00

326 lines
9.8 KiB
Go

// Package entitlement defines the logical entitlement entities owned by User
// Service.
package entitlement
import (
"fmt"
"strings"
"time"
"galaxy/user/internal/domain/common"
)
// PlanCode identifies one supported entitlement plan.
type PlanCode string
const (
// PlanCodeFree reports the free default entitlement.
PlanCodeFree PlanCode = "free"
// PlanCodePaidMonthly reports a finite monthly paid entitlement.
PlanCodePaidMonthly PlanCode = "paid_monthly"
// PlanCodePaidYearly reports a finite yearly paid entitlement.
PlanCodePaidYearly PlanCode = "paid_yearly"
// PlanCodePaidLifetime reports a non-expiring paid entitlement.
PlanCodePaidLifetime PlanCode = "paid_lifetime"
)
// IsKnown reports whether PlanCode belongs to the frozen v1 catalog.
func (code PlanCode) IsKnown() bool {
switch code {
case PlanCodeFree, PlanCodePaidMonthly, PlanCodePaidYearly, PlanCodePaidLifetime:
return true
default:
return false
}
}
// IsPaid reports whether PlanCode represents a paid entitlement state.
func (code PlanCode) IsPaid() bool {
switch code {
case PlanCodePaidMonthly, PlanCodePaidYearly, PlanCodePaidLifetime:
return true
default:
return false
}
}
// HasFiniteExpiry reports whether PlanCode requires a bounded `ends_at`
// value in the Stage 07 entitlement timeline model.
func (code PlanCode) HasFiniteExpiry() bool {
switch code {
case PlanCodePaidMonthly, PlanCodePaidYearly:
return true
default:
return false
}
}
// EntitlementRecordID identifies one immutable entitlement history record.
type EntitlementRecordID string
// String returns EntitlementRecordID as its stored identifier string.
func (id EntitlementRecordID) String() string {
return string(id)
}
// IsZero reports whether EntitlementRecordID does not contain a usable value.
func (id EntitlementRecordID) IsZero() bool {
return strings.TrimSpace(string(id)) == ""
}
// Validate reports whether EntitlementRecordID is non-empty, normalized, and
// uses the frozen Stage 02 prefix.
func (id EntitlementRecordID) Validate() error {
switch {
case id.IsZero():
return fmt.Errorf("entitlement record id must not be empty")
case strings.TrimSpace(string(id)) != string(id):
return fmt.Errorf("entitlement record id must not contain surrounding whitespace")
case !strings.HasPrefix(string(id), "entitlement-"):
return fmt.Errorf("entitlement record id must start with %q", "entitlement-")
case len(string(id)) == len("entitlement-"):
return fmt.Errorf("entitlement record id must contain opaque data after %q", "entitlement-")
default:
return nil
}
}
// PeriodRecord stores one entitlement-period history record.
type PeriodRecord struct {
// RecordID identifies the immutable history record.
RecordID EntitlementRecordID
// UserID identifies the account that owns the entitlement record.
UserID common.UserID
// PlanCode stores the effective plan for the recorded period.
PlanCode PlanCode
// Source stores the machine-readable mutation source.
Source common.Source
// Actor stores the audit actor metadata captured for the mutation.
Actor common.ActorRef
// ReasonCode stores the machine-readable reason for the mutation.
ReasonCode common.ReasonCode
// StartsAt stores when the period becomes effective.
StartsAt time.Time
// EndsAt stores the optional planned end of the period.
EndsAt *time.Time
// CreatedAt stores when the history record was created.
CreatedAt time.Time
// ClosedAt stores when the period was later closed early by another trusted
// mutation.
ClosedAt *time.Time
// ClosedBy stores optional audit actor metadata for the close mutation.
ClosedBy common.ActorRef
// ClosedReasonCode stores the reason for closing the period early.
ClosedReasonCode common.ReasonCode
}
// Validate reports whether PeriodRecord satisfies the frozen Stage 02
// structural invariants.
func (record PeriodRecord) Validate() error {
if err := record.RecordID.Validate(); err != nil {
return fmt.Errorf("entitlement period record id: %w", err)
}
if err := record.UserID.Validate(); err != nil {
return fmt.Errorf("entitlement period user id: %w", err)
}
if !record.PlanCode.IsKnown() {
return fmt.Errorf("entitlement period plan code %q is unsupported", record.PlanCode)
}
if err := record.Source.Validate(); err != nil {
return fmt.Errorf("entitlement period source: %w", err)
}
if err := record.Actor.Validate(); err != nil {
return fmt.Errorf("entitlement period actor: %w", err)
}
if err := record.ReasonCode.Validate(); err != nil {
return fmt.Errorf("entitlement period reason code: %w", err)
}
if err := common.ValidateTimestamp("entitlement period starts at", record.StartsAt); err != nil {
return err
}
if err := validatePlanBounds("entitlement period", record.PlanCode, record.StartsAt, record.EndsAt); err != nil {
return err
}
if err := common.ValidateTimestamp("entitlement period created at", record.CreatedAt); err != nil {
return err
}
if record.ClosedAt == nil {
if !record.ClosedBy.IsZero() {
return fmt.Errorf("entitlement period closed by must be empty when closed at is absent")
}
if !record.ClosedReasonCode.IsZero() {
return fmt.Errorf("entitlement period closed reason code must be empty when closed at is absent")
}
return nil
}
if record.ClosedAt.Before(record.StartsAt) {
return fmt.Errorf("entitlement period closed at must not be before starts at")
}
if record.EndsAt != nil && record.ClosedAt.After(*record.EndsAt) {
return fmt.Errorf("entitlement period closed at must not be after ends at")
}
if record.ClosedAt.Before(record.CreatedAt) {
return fmt.Errorf("entitlement period closed at must not be before created at")
}
if err := record.ClosedBy.Validate(); err != nil {
return fmt.Errorf("entitlement period closed by: %w", err)
}
if err := record.ClosedReasonCode.Validate(); err != nil {
return fmt.Errorf("entitlement period closed reason code: %w", err)
}
return nil
}
// IsEffectiveAt reports whether PeriodRecord is the currently effective
// segment at the supplied timestamp.
func (record PeriodRecord) IsEffectiveAt(now time.Time) bool {
if record.ClosedAt != nil {
return false
}
if record.StartsAt.After(now) {
return false
}
if record.EndsAt != nil && !record.EndsAt.After(now) {
return false
}
return true
}
// CurrentSnapshot stores the read-optimized current entitlement state of one
// user account.
type CurrentSnapshot struct {
// UserID identifies the account that owns the current entitlement.
UserID common.UserID
// PlanCode stores the current effective plan code.
PlanCode PlanCode
// IsPaid stores the materialized paid/free state used on hot read paths.
IsPaid bool
// StartsAt stores when the current effective state started.
StartsAt time.Time
// EndsAt stores the optional end of the current finite entitlement.
EndsAt *time.Time
// Source stores the machine-readable source of the current state.
Source common.Source
// Actor stores the actor metadata attached to the last successful mutation.
Actor common.ActorRef
// ReasonCode stores the machine-readable reason attached to the last
// successful mutation.
ReasonCode common.ReasonCode
// UpdatedAt stores when the snapshot was last recomputed.
UpdatedAt time.Time
}
// Validate reports whether CurrentSnapshot satisfies the frozen Stage 02
// structural invariants.
func (record CurrentSnapshot) Validate() error {
if err := record.UserID.Validate(); err != nil {
return fmt.Errorf("entitlement snapshot user id: %w", err)
}
if !record.PlanCode.IsKnown() {
return fmt.Errorf("entitlement snapshot plan code %q is unsupported", record.PlanCode)
}
if record.IsPaid != record.PlanCode.IsPaid() {
return fmt.Errorf("entitlement snapshot paid flag must match plan code %q", record.PlanCode)
}
if err := common.ValidateTimestamp("entitlement snapshot starts at", record.StartsAt); err != nil {
return err
}
if err := validatePlanBounds("entitlement snapshot", record.PlanCode, record.StartsAt, record.EndsAt); err != nil {
return err
}
if err := record.Source.Validate(); err != nil {
return fmt.Errorf("entitlement snapshot source: %w", err)
}
if err := record.Actor.Validate(); err != nil {
return fmt.Errorf("entitlement snapshot actor: %w", err)
}
if err := record.ReasonCode.Validate(); err != nil {
return fmt.Errorf("entitlement snapshot reason code: %w", err)
}
if err := common.ValidateTimestamp("entitlement snapshot updated at", record.UpdatedAt); err != nil {
return err
}
return nil
}
// HasFiniteExpiry reports whether CurrentSnapshot participates in the finite
// paid-expiry index.
func (record CurrentSnapshot) HasFiniteExpiry() bool {
return record.IsPaid && record.EndsAt != nil
}
// IsExpiredAt reports whether CurrentSnapshot represents a finite paid state
// that has already reached its stored expiry.
func (record CurrentSnapshot) IsExpiredAt(now time.Time) bool {
return record.HasFiniteExpiry() && !record.EndsAt.After(now)
}
// PaidState identifies the coarse free-versus-paid filter used by admin
// listing.
type PaidState string
const (
// PaidStateFree filters accounts whose current entitlement is free.
PaidStateFree PaidState = "free"
// PaidStatePaid filters accounts whose current entitlement is paid.
PaidStatePaid PaidState = "paid"
)
// IsKnown reports whether PaidState belongs to the frozen Stage 02 filter
// vocabulary.
func (state PaidState) IsKnown() bool {
switch state {
case "", PaidStateFree, PaidStatePaid:
return true
default:
return false
}
}
func validatePlanBounds(
name string,
planCode PlanCode,
startsAt time.Time,
endsAt *time.Time,
) error {
switch {
case planCode.HasFiniteExpiry():
if endsAt == nil {
return fmt.Errorf("%s ends at must be present for plan code %q", name, planCode)
}
if !endsAt.After(startsAt) {
return common.ErrInvertedTimeRange
}
case endsAt != nil:
return fmt.Errorf("%s ends at must be empty for plan code %q", name, planCode)
}
return nil
}