feat: user service
This commit is contained in:
@@ -0,0 +1,325 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package entitlement
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/common"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPeriodRecordValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
startsAt := time.Unix(1_775_240_000, 0).UTC()
|
||||
endsAt := startsAt.Add(30 * 24 * time.Hour)
|
||||
createdAt := startsAt.Add(-time.Hour)
|
||||
closedAt := startsAt.Add(12 * time.Hour)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
record PeriodRecord
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid open record",
|
||||
record: PeriodRecord{
|
||||
RecordID: EntitlementRecordID("entitlement-123"),
|
||||
UserID: common.UserID("user-123"),
|
||||
PlanCode: PlanCodePaidMonthly,
|
||||
Source: common.Source("admin"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
ReasonCode: common.ReasonCode("manual_grant"),
|
||||
StartsAt: startsAt,
|
||||
EndsAt: &endsAt,
|
||||
CreatedAt: createdAt,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "valid closed record",
|
||||
record: PeriodRecord{
|
||||
RecordID: EntitlementRecordID("entitlement-123"),
|
||||
UserID: common.UserID("user-123"),
|
||||
PlanCode: PlanCodePaidMonthly,
|
||||
Source: common.Source("admin"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
ReasonCode: common.ReasonCode("manual_grant"),
|
||||
StartsAt: startsAt,
|
||||
EndsAt: &endsAt,
|
||||
CreatedAt: createdAt,
|
||||
ClosedAt: &closedAt,
|
||||
ClosedBy: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-2")},
|
||||
ClosedReasonCode: common.ReasonCode("manual_revoke"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "close metadata without closed at",
|
||||
record: PeriodRecord{
|
||||
RecordID: EntitlementRecordID("entitlement-123"),
|
||||
UserID: common.UserID("user-123"),
|
||||
PlanCode: PlanCodePaidMonthly,
|
||||
Source: common.Source("admin"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
ReasonCode: common.ReasonCode("manual_grant"),
|
||||
StartsAt: startsAt,
|
||||
CreatedAt: createdAt,
|
||||
ClosedReasonCode: common.ReasonCode("manual_revoke"),
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := tt.record.Validate()
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentSnapshotValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
startsAt := time.Unix(1_775_240_000, 0).UTC()
|
||||
endsAt := startsAt.Add(30 * 24 * time.Hour)
|
||||
updatedAt := startsAt.Add(2 * time.Hour)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
record CurrentSnapshot
|
||||
wantErr bool
|
||||
wantFinite bool
|
||||
}{
|
||||
{
|
||||
name: "valid finite paid snapshot",
|
||||
record: CurrentSnapshot{
|
||||
UserID: common.UserID("user-123"),
|
||||
PlanCode: PlanCodePaidMonthly,
|
||||
IsPaid: true,
|
||||
StartsAt: startsAt,
|
||||
EndsAt: &endsAt,
|
||||
Source: common.Source("admin"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
ReasonCode: common.ReasonCode("manual_grant"),
|
||||
UpdatedAt: updatedAt,
|
||||
},
|
||||
wantFinite: true,
|
||||
},
|
||||
{
|
||||
name: "valid free snapshot",
|
||||
record: CurrentSnapshot{
|
||||
UserID: common.UserID("user-123"),
|
||||
PlanCode: PlanCodeFree,
|
||||
IsPaid: false,
|
||||
StartsAt: startsAt,
|
||||
Source: common.Source("system"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("service")},
|
||||
ReasonCode: common.ReasonCode("default_free_plan"),
|
||||
UpdatedAt: updatedAt,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "paid flag mismatch",
|
||||
record: CurrentSnapshot{
|
||||
UserID: common.UserID("user-123"),
|
||||
PlanCode: PlanCodeFree,
|
||||
IsPaid: true,
|
||||
StartsAt: startsAt,
|
||||
Source: common.Source("system"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("service")},
|
||||
ReasonCode: common.ReasonCode("default_free_plan"),
|
||||
UpdatedAt: updatedAt,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := tt.record.Validate()
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.wantFinite, tt.record.HasFiniteExpiry())
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user