Files
galaxy-game/user/internal/service/entitlementsvc/service.go
T
2026-04-10 19:05:02 +02:00

1115 lines
34 KiB
Go

// Package entitlementsvc implements the trusted entitlement lifecycle and
// effective-read use cases owned by User Service.
package entitlementsvc
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/ports"
"galaxy/user/internal/service/shared"
"galaxy/user/internal/telemetry"
)
const (
expiryRepairSource common.Source = "entitlement_expiry_repair"
expiryRepairReasonCode common.ReasonCode = "paid_entitlement_expired"
expiryRepairActorType common.ActorType = "service"
expiryRepairActorID common.ActorID = "user-service"
expiryRepairRetryLimit = 4
)
// ActorInput stores one transport-facing audit actor payload.
type ActorInput struct {
// Type stores the machine-readable actor type.
Type string
// ID stores the optional stable actor identifier.
ID string
}
// GrantInput stores one trusted entitlement-grant command request.
type GrantInput struct {
// UserID identifies the user whose current entitlement must be replaced.
UserID string
// PlanCode stores the paid plan that must become current.
PlanCode string
// Source stores the machine-readable mutation source.
Source string
// ReasonCode stores the machine-readable mutation reason.
ReasonCode string
// Actor stores the audit actor metadata.
Actor ActorInput
// StartsAt stores when the granted paid state becomes effective.
StartsAt string
// EndsAt stores the optional finite paid expiry.
EndsAt string
}
// ExtendInput stores one trusted entitlement-extension command request.
type ExtendInput struct {
// UserID identifies the user whose current finite paid entitlement must be
// extended.
UserID string
// Source stores the machine-readable mutation source.
Source string
// ReasonCode stores the machine-readable mutation reason.
ReasonCode string
// Actor stores the audit actor metadata.
Actor ActorInput
// EndsAt stores the replacement finite paid expiry.
EndsAt string
}
// RevokeInput stores one trusted entitlement-revoke command request.
type RevokeInput struct {
// UserID identifies the user whose current paid entitlement must be
// revoked.
UserID string
// Source stores the machine-readable mutation source.
Source string
// ReasonCode stores the machine-readable mutation reason.
ReasonCode string
// Actor stores the audit actor metadata.
Actor ActorInput
}
// CommandResult stores one trusted entitlement mutation result.
type CommandResult struct {
// UserID identifies the mutated user.
UserID string
// Entitlement stores the refreshed current effective snapshot.
Entitlement entitlement.CurrentSnapshot
}
type effectiveReader interface {
GetByUserID(ctx context.Context, userID common.UserID) (entitlement.CurrentSnapshot, error)
}
// Reader loads the current effective entitlement snapshot and lazily repairs
// expired finite paid states.
type Reader struct {
snapshots ports.EntitlementSnapshotStore
lifecycle ports.EntitlementLifecycleStore
clock ports.Clock
idGenerator ports.IDGenerator
logger *slog.Logger
telemetry *telemetry.Runtime
publisher ports.EntitlementChangedPublisher
}
// NewReader constructs one effective entitlement reader.
func NewReader(
snapshots ports.EntitlementSnapshotStore,
lifecycle ports.EntitlementLifecycleStore,
clock ports.Clock,
idGenerator ports.IDGenerator,
) (*Reader, error) {
return NewReaderWithObservability(snapshots, lifecycle, clock, idGenerator, nil, nil, nil)
}
// NewReaderWithObservability constructs one effective entitlement reader with
// optional observability hooks.
func NewReaderWithObservability(
snapshots ports.EntitlementSnapshotStore,
lifecycle ports.EntitlementLifecycleStore,
clock ports.Clock,
idGenerator ports.IDGenerator,
logger *slog.Logger,
telemetryRuntime *telemetry.Runtime,
publisher ports.EntitlementChangedPublisher,
) (*Reader, error) {
switch {
case snapshots == nil:
return nil, fmt.Errorf("entitlement reader: entitlement snapshot store must not be nil")
case lifecycle == nil:
return nil, fmt.Errorf("entitlement reader: entitlement lifecycle store must not be nil")
case clock == nil:
return nil, fmt.Errorf("entitlement reader: clock must not be nil")
case idGenerator == nil:
return nil, fmt.Errorf("entitlement reader: id generator must not be nil")
default:
return &Reader{
snapshots: snapshots,
lifecycle: lifecycle,
clock: clock,
idGenerator: idGenerator,
logger: logger,
telemetry: telemetryRuntime,
publisher: publisher,
}, nil
}
}
// GetByUserID returns the current effective entitlement snapshot for userID.
// When the stored snapshot is a naturally expired finite paid state, it
// lazily materializes the replacement free state before returning.
func (service *Reader) GetByUserID(ctx context.Context, userID common.UserID) (snapshot entitlement.CurrentSnapshot, err error) {
repairOutcome := ""
userIDString := userID.String()
defer func() {
if repairOutcome == "" {
return
}
if service.telemetry != nil {
service.telemetry.RecordEntitlementMutation(ctx, "expiry_repair", repairOutcome)
}
shared.LogServiceOutcome(service.logger, ctx, "entitlement expiry repair completed", err,
"use_case", "repair_expired_entitlement",
"command", "expiry_repair",
"outcome", repairOutcome,
"user_id", userIDString,
"source", expiryRepairSource.String(),
"reason_code", expiryRepairReasonCode.String(),
"actor_type", expiryRepairActorType.String(),
"actor_id", expiryRepairActorID.String(),
)
}()
if err := userID.Validate(); err != nil {
return entitlement.CurrentSnapshot{}, fmt.Errorf("entitlement reader: %w", err)
}
if ctx == nil {
return entitlement.CurrentSnapshot{}, fmt.Errorf("entitlement reader: nil context")
}
for attempt := 0; attempt < expiryRepairRetryLimit; attempt++ {
currentSnapshot, err := service.snapshots.GetByUserID(ctx, userID)
if err != nil {
return entitlement.CurrentSnapshot{}, err
}
now := service.clock.Now().UTC()
if !currentSnapshot.IsExpiredAt(now) {
return currentSnapshot, nil
}
if repairOutcome == "" {
repairOutcome = "conflict"
}
recordID, err := service.idGenerator.NewEntitlementRecordID()
if err != nil {
repairOutcome = shared.ErrorCodeServiceUnavailable
return entitlement.CurrentSnapshot{}, err
}
freeRecord, freeSnapshot, err := buildExpiryRepairState(currentSnapshot, recordID, now)
if err != nil {
repairOutcome = shared.ErrorCodeInternalError
return entitlement.CurrentSnapshot{}, err
}
err = service.lifecycle.RepairExpired(ctx, ports.RepairExpiredEntitlementInput{
ExpectedExpiredSnapshot: currentSnapshot,
NewRecord: freeRecord,
NewSnapshot: freeSnapshot,
})
switch {
case err == nil:
repairOutcome = "success"
publishEntitlementChanged(ctx, service.publisher, service.telemetry, service.logger, "repair_expired_entitlement", ports.EntitlementChangedOperationExpiredRepaired, freeSnapshot)
return freeSnapshot, nil
case errors.Is(err, ports.ErrConflict):
continue
default:
repairOutcome = shared.ErrorCodeServiceUnavailable
return entitlement.CurrentSnapshot{}, err
}
}
latestSnapshot, err := service.snapshots.GetByUserID(ctx, userID)
if err != nil {
repairOutcome = shared.ErrorCodeServiceUnavailable
return entitlement.CurrentSnapshot{}, err
}
if latestSnapshot.IsExpiredAt(service.clock.Now().UTC()) {
repairOutcome = "conflict"
return entitlement.CurrentSnapshot{}, fmt.Errorf("entitlement reader: expiry repair retry limit exceeded for user %q", userID)
}
return latestSnapshot, nil
}
type commandSupport struct {
accounts ports.UserAccountStore
history ports.EntitlementHistoryStore
reader effectiveReader
lifecycle ports.EntitlementLifecycleStore
clock ports.Clock
idGenerator ports.IDGenerator
}
func newCommandSupport(
accounts ports.UserAccountStore,
history ports.EntitlementHistoryStore,
reader effectiveReader,
lifecycle ports.EntitlementLifecycleStore,
clock ports.Clock,
idGenerator ports.IDGenerator,
) (commandSupport, error) {
switch {
case accounts == nil:
return commandSupport{}, fmt.Errorf("user account store must not be nil")
case history == nil:
return commandSupport{}, fmt.Errorf("entitlement history store must not be nil")
case reader == nil:
return commandSupport{}, fmt.Errorf("effective entitlement reader must not be nil")
case lifecycle == nil:
return commandSupport{}, fmt.Errorf("entitlement lifecycle store must not be nil")
case clock == nil:
return commandSupport{}, fmt.Errorf("clock must not be nil")
case idGenerator == nil:
return commandSupport{}, fmt.Errorf("id generator must not be nil")
default:
return commandSupport{
accounts: accounts,
history: history,
reader: reader,
lifecycle: lifecycle,
clock: clock,
idGenerator: idGenerator,
}, nil
}
}
func (support commandSupport) ensureUserExists(ctx context.Context, userID common.UserID) error {
exists, err := support.accounts.ExistsByUserID(ctx, userID)
switch {
case err != nil:
return shared.ServiceUnavailable(err)
case !exists:
return shared.SubjectNotFound()
default:
return nil
}
}
func (support commandSupport) loadEffectiveSnapshot(
ctx context.Context,
userID common.UserID,
) (entitlement.CurrentSnapshot, error) {
currentSnapshot, err := support.reader.GetByUserID(ctx, userID)
switch {
case err == nil:
return currentSnapshot, nil
case errors.Is(err, ports.ErrNotFound):
return entitlement.CurrentSnapshot{}, shared.InternalError(fmt.Errorf("user %q is missing entitlement snapshot", userID))
default:
return entitlement.CurrentSnapshot{}, shared.ServiceUnavailable(err)
}
}
func (support commandSupport) loadCurrentRecord(
ctx context.Context,
userID common.UserID,
now time.Time,
) (entitlement.PeriodRecord, error) {
historyRecords, err := support.history.ListByUserID(ctx, userID)
if err != nil {
return entitlement.PeriodRecord{}, shared.ServiceUnavailable(err)
}
currentRecord, ok := currentRecordAt(historyRecords, now)
if !ok {
return entitlement.PeriodRecord{}, shared.InternalError(fmt.Errorf("user %q is missing current entitlement history record", userID))
}
return currentRecord, nil
}
// GrantService executes the explicit trusted paid-entitlement grant command.
type GrantService struct {
support commandSupport
logger *slog.Logger
telemetry *telemetry.Runtime
publisher ports.EntitlementChangedPublisher
}
// NewGrantService constructs one entitlement-grant use case.
func NewGrantService(
accounts ports.UserAccountStore,
history ports.EntitlementHistoryStore,
reader effectiveReader,
lifecycle ports.EntitlementLifecycleStore,
clock ports.Clock,
idGenerator ports.IDGenerator,
) (*GrantService, error) {
return NewGrantServiceWithObservability(accounts, history, reader, lifecycle, clock, idGenerator, nil, nil, nil)
}
// NewGrantServiceWithObservability constructs one entitlement-grant use case
// with optional observability hooks.
func NewGrantServiceWithObservability(
accounts ports.UserAccountStore,
history ports.EntitlementHistoryStore,
reader effectiveReader,
lifecycle ports.EntitlementLifecycleStore,
clock ports.Clock,
idGenerator ports.IDGenerator,
logger *slog.Logger,
telemetryRuntime *telemetry.Runtime,
publisher ports.EntitlementChangedPublisher,
) (*GrantService, error) {
support, err := newCommandSupport(accounts, history, reader, lifecycle, clock, idGenerator)
if err != nil {
return nil, fmt.Errorf("entitlement grant service: %w", err)
}
return &GrantService{
support: support,
logger: logger,
telemetry: telemetryRuntime,
publisher: publisher,
}, nil
}
// Execute grants a new current paid entitlement when the current effective
// entitlement is free.
func (service *GrantService) Execute(ctx context.Context, input GrantInput) (result CommandResult, err error) {
outcome := shared.ErrorCodeInternalError
userIDString := strings.TrimSpace(input.UserID)
sourceValue := strings.TrimSpace(input.Source)
reasonCodeValue := strings.TrimSpace(input.ReasonCode)
actorTypeValue := strings.TrimSpace(input.Actor.Type)
actorIDValue := strings.TrimSpace(input.Actor.ID)
defer func() {
if service.telemetry != nil {
service.telemetry.RecordEntitlementMutation(ctx, "grant", outcome)
}
shared.LogServiceOutcome(service.logger, ctx, "entitlement grant completed", err,
"use_case", "grant_entitlement",
"command", "grant",
"outcome", outcome,
"user_id", userIDString,
"source", sourceValue,
"reason_code", reasonCodeValue,
"actor_type", actorTypeValue,
"actor_id", actorIDValue,
)
}()
if ctx == nil {
outcome = shared.ErrorCodeInvalidRequest
return CommandResult{}, shared.InvalidRequest("context must not be nil")
}
userID, err := shared.ParseUserID(input.UserID)
if err != nil {
outcome = shared.MetricOutcome(err)
return CommandResult{}, err
}
userIDString = userID.String()
if err := service.support.ensureUserExists(ctx, userID); err != nil {
outcome = shared.MetricOutcome(err)
return CommandResult{}, err
}
planCode, err := parsePlanCode(input.PlanCode)
if err != nil {
outcome = shared.MetricOutcome(err)
return CommandResult{}, err
}
if planCode == entitlement.PlanCodeFree {
outcome = shared.ErrorCodeInvalidRequest
return CommandResult{}, shared.InvalidRequest("plan_code must not be \"free\" for grant")
}
source, err := parseSource(input.Source)
if err != nil {
outcome = shared.MetricOutcome(err)
return CommandResult{}, err
}
sourceValue = source.String()
reasonCode, err := shared.ParseReasonCode(input.ReasonCode)
if err != nil {
outcome = shared.MetricOutcome(err)
return CommandResult{}, err
}
reasonCodeValue = reasonCode.String()
actor, err := parseActor(input.Actor)
if err != nil {
outcome = shared.MetricOutcome(err)
return CommandResult{}, err
}
actorTypeValue = actor.Type.String()
actorIDValue = actor.ID.String()
startsAt, err := parseTimestamp("starts_at", input.StartsAt)
if err != nil {
outcome = shared.MetricOutcome(err)
return CommandResult{}, err
}
endsAt, err := parseOptionalTimestamp("ends_at", input.EndsAt)
if err != nil {
outcome = shared.MetricOutcome(err)
return CommandResult{}, err
}
now := service.support.clock.Now().UTC()
if startsAt.After(now) {
outcome = shared.ErrorCodeInvalidRequest
return CommandResult{}, shared.InvalidRequest("starts_at must not be in the future")
}
if err := validateGrantBounds(planCode, startsAt, endsAt); err != nil {
outcome = shared.MetricOutcome(err)
return CommandResult{}, err
}
currentSnapshot, err := service.support.loadEffectiveSnapshot(ctx, userID)
if err != nil {
outcome = shared.MetricOutcome(err)
return CommandResult{}, err
}
if currentSnapshot.IsPaid {
outcome = shared.ErrorCodeConflict
return CommandResult{}, shared.Conflict()
}
currentRecord, err := service.support.loadCurrentRecord(ctx, userID, now)
if err != nil {
outcome = shared.MetricOutcome(err)
return CommandResult{}, err
}
if currentRecord.PlanCode != entitlement.PlanCodeFree {
outcome = shared.ErrorCodeInternalError
return CommandResult{}, shared.InternalError(fmt.Errorf("user %q current entitlement record must be free before grant", userID))
}
if startsAt.Before(currentRecord.StartsAt) {
outcome = shared.ErrorCodeInvalidRequest
return CommandResult{}, shared.InvalidRequest("starts_at must not be before the current free entitlement started")
}
recordID, err := service.support.idGenerator.NewEntitlementRecordID()
if err != nil {
outcome = shared.ErrorCodeServiceUnavailable
return CommandResult{}, shared.ServiceUnavailable(err)
}
updatedCurrentRecord := currentRecord
updatedCurrentRecord.ClosedAt = &startsAt
updatedCurrentRecord.ClosedBy = actor
updatedCurrentRecord.ClosedReasonCode = reasonCode
newRecord := entitlement.PeriodRecord{
RecordID: recordID,
UserID: userID,
PlanCode: planCode,
Source: source,
Actor: actor,
ReasonCode: reasonCode,
StartsAt: startsAt,
EndsAt: endsAt,
CreatedAt: now,
}
newSnapshot := entitlement.CurrentSnapshot{
UserID: userID,
PlanCode: planCode,
IsPaid: true,
StartsAt: startsAt,
EndsAt: endsAt,
Source: source,
Actor: actor,
ReasonCode: reasonCode,
UpdatedAt: now,
}
if err := service.support.lifecycle.Grant(ctx, ports.GrantEntitlementInput{
ExpectedCurrentSnapshot: currentSnapshot,
ExpectedCurrentRecord: currentRecord,
UpdatedCurrentRecord: updatedCurrentRecord,
NewRecord: newRecord,
NewSnapshot: newSnapshot,
}); err != nil {
switch {
case errors.Is(err, ports.ErrConflict):
outcome = shared.ErrorCodeConflict
return CommandResult{}, shared.Conflict()
default:
outcome = shared.ErrorCodeServiceUnavailable
return CommandResult{}, shared.ServiceUnavailable(err)
}
}
outcome = "success"
result = CommandResult{UserID: userID.String(), Entitlement: newSnapshot}
publishEntitlementChanged(ctx, service.publisher, service.telemetry, service.logger, "grant_entitlement", ports.EntitlementChangedOperationGranted, newSnapshot)
return result, nil
}
// ExtendService executes the explicit trusted paid-entitlement extend command.
type ExtendService struct {
support commandSupport
logger *slog.Logger
telemetry *telemetry.Runtime
publisher ports.EntitlementChangedPublisher
}
// NewExtendService constructs one entitlement-extend use case.
func NewExtendService(
accounts ports.UserAccountStore,
history ports.EntitlementHistoryStore,
reader effectiveReader,
lifecycle ports.EntitlementLifecycleStore,
clock ports.Clock,
idGenerator ports.IDGenerator,
) (*ExtendService, error) {
return NewExtendServiceWithObservability(accounts, history, reader, lifecycle, clock, idGenerator, nil, nil, nil)
}
// NewExtendServiceWithObservability constructs one entitlement-extend use
// case with optional observability hooks.
func NewExtendServiceWithObservability(
accounts ports.UserAccountStore,
history ports.EntitlementHistoryStore,
reader effectiveReader,
lifecycle ports.EntitlementLifecycleStore,
clock ports.Clock,
idGenerator ports.IDGenerator,
logger *slog.Logger,
telemetryRuntime *telemetry.Runtime,
publisher ports.EntitlementChangedPublisher,
) (*ExtendService, error) {
support, err := newCommandSupport(accounts, history, reader, lifecycle, clock, idGenerator)
if err != nil {
return nil, fmt.Errorf("entitlement extend service: %w", err)
}
return &ExtendService{
support: support,
logger: logger,
telemetry: telemetryRuntime,
publisher: publisher,
}, nil
}
// Execute extends the current finite paid entitlement by appending a new
// history segment and updating the current snapshot.
func (service *ExtendService) Execute(ctx context.Context, input ExtendInput) (result CommandResult, err error) {
outcome := shared.ErrorCodeInternalError
userIDString := strings.TrimSpace(input.UserID)
sourceValue := strings.TrimSpace(input.Source)
reasonCodeValue := strings.TrimSpace(input.ReasonCode)
actorTypeValue := strings.TrimSpace(input.Actor.Type)
actorIDValue := strings.TrimSpace(input.Actor.ID)
defer func() {
if service.telemetry != nil {
service.telemetry.RecordEntitlementMutation(ctx, "extend", outcome)
}
shared.LogServiceOutcome(service.logger, ctx, "entitlement extend completed", err,
"use_case", "extend_entitlement",
"command", "extend",
"outcome", outcome,
"user_id", userIDString,
"source", sourceValue,
"reason_code", reasonCodeValue,
"actor_type", actorTypeValue,
"actor_id", actorIDValue,
)
}()
if ctx == nil {
outcome = shared.ErrorCodeInvalidRequest
return CommandResult{}, shared.InvalidRequest("context must not be nil")
}
userID, err := shared.ParseUserID(input.UserID)
if err != nil {
outcome = shared.MetricOutcome(err)
return CommandResult{}, err
}
userIDString = userID.String()
if err := service.support.ensureUserExists(ctx, userID); err != nil {
outcome = shared.MetricOutcome(err)
return CommandResult{}, err
}
source, err := parseSource(input.Source)
if err != nil {
outcome = shared.MetricOutcome(err)
return CommandResult{}, err
}
sourceValue = source.String()
reasonCode, err := shared.ParseReasonCode(input.ReasonCode)
if err != nil {
outcome = shared.MetricOutcome(err)
return CommandResult{}, err
}
reasonCodeValue = reasonCode.String()
actor, err := parseActor(input.Actor)
if err != nil {
outcome = shared.MetricOutcome(err)
return CommandResult{}, err
}
actorTypeValue = actor.Type.String()
actorIDValue = actor.ID.String()
newEndsAt, err := parseTimestamp("ends_at", input.EndsAt)
if err != nil {
outcome = shared.MetricOutcome(err)
return CommandResult{}, err
}
now := service.support.clock.Now().UTC()
currentSnapshot, err := service.support.loadEffectiveSnapshot(ctx, userID)
if err != nil {
outcome = shared.MetricOutcome(err)
return CommandResult{}, err
}
if !currentSnapshot.IsPaid || currentSnapshot.EndsAt == nil {
outcome = shared.ErrorCodeConflict
return CommandResult{}, shared.Conflict()
}
if !newEndsAt.After(*currentSnapshot.EndsAt) {
outcome = shared.ErrorCodeInvalidRequest
return CommandResult{}, shared.InvalidRequest("ends_at must be after the current paid entitlement ends_at")
}
currentRecord, err := service.support.loadCurrentRecord(ctx, userID, now)
if err != nil {
outcome = shared.MetricOutcome(err)
return CommandResult{}, err
}
if currentRecord.PlanCode != currentSnapshot.PlanCode || currentRecord.EndsAt == nil {
outcome = shared.ErrorCodeInternalError
return CommandResult{}, shared.InternalError(fmt.Errorf("user %q current entitlement record is inconsistent with current snapshot", userID))
}
recordID, err := service.support.idGenerator.NewEntitlementRecordID()
if err != nil {
outcome = shared.ErrorCodeServiceUnavailable
return CommandResult{}, shared.ServiceUnavailable(err)
}
segmentStartsAt := currentSnapshot.EndsAt.UTC()
newRecord := entitlement.PeriodRecord{
RecordID: recordID,
UserID: userID,
PlanCode: currentSnapshot.PlanCode,
Source: source,
Actor: actor,
ReasonCode: reasonCode,
StartsAt: segmentStartsAt,
EndsAt: &newEndsAt,
CreatedAt: now,
}
newSnapshot := entitlement.CurrentSnapshot{
UserID: userID,
PlanCode: currentSnapshot.PlanCode,
IsPaid: true,
StartsAt: currentSnapshot.StartsAt,
EndsAt: &newEndsAt,
Source: source,
Actor: actor,
ReasonCode: reasonCode,
UpdatedAt: now,
}
if err := service.support.lifecycle.Extend(ctx, ports.ExtendEntitlementInput{
ExpectedCurrentSnapshot: currentSnapshot,
NewRecord: newRecord,
NewSnapshot: newSnapshot,
}); err != nil {
switch {
case errors.Is(err, ports.ErrConflict):
outcome = shared.ErrorCodeConflict
return CommandResult{}, shared.Conflict()
default:
outcome = shared.ErrorCodeServiceUnavailable
return CommandResult{}, shared.ServiceUnavailable(err)
}
}
outcome = "success"
result = CommandResult{UserID: userID.String(), Entitlement: newSnapshot}
publishEntitlementChanged(ctx, service.publisher, service.telemetry, service.logger, "extend_entitlement", ports.EntitlementChangedOperationExtended, newSnapshot)
return result, nil
}
// RevokeService executes the explicit trusted paid-entitlement revoke command.
type RevokeService struct {
support commandSupport
logger *slog.Logger
telemetry *telemetry.Runtime
publisher ports.EntitlementChangedPublisher
}
// NewRevokeService constructs one entitlement-revoke use case.
func NewRevokeService(
accounts ports.UserAccountStore,
history ports.EntitlementHistoryStore,
reader effectiveReader,
lifecycle ports.EntitlementLifecycleStore,
clock ports.Clock,
idGenerator ports.IDGenerator,
) (*RevokeService, error) {
return NewRevokeServiceWithObservability(accounts, history, reader, lifecycle, clock, idGenerator, nil, nil, nil)
}
// NewRevokeServiceWithObservability constructs one entitlement-revoke use case
// with optional observability hooks.
func NewRevokeServiceWithObservability(
accounts ports.UserAccountStore,
history ports.EntitlementHistoryStore,
reader effectiveReader,
lifecycle ports.EntitlementLifecycleStore,
clock ports.Clock,
idGenerator ports.IDGenerator,
logger *slog.Logger,
telemetryRuntime *telemetry.Runtime,
publisher ports.EntitlementChangedPublisher,
) (*RevokeService, error) {
support, err := newCommandSupport(accounts, history, reader, lifecycle, clock, idGenerator)
if err != nil {
return nil, fmt.Errorf("entitlement revoke service: %w", err)
}
return &RevokeService{
support: support,
logger: logger,
telemetry: telemetryRuntime,
publisher: publisher,
}, nil
}
// Execute revokes the current paid entitlement and materializes a new free
// state starting at the revoke timestamp.
func (service *RevokeService) Execute(ctx context.Context, input RevokeInput) (result CommandResult, err error) {
outcome := shared.ErrorCodeInternalError
userIDString := strings.TrimSpace(input.UserID)
sourceValue := strings.TrimSpace(input.Source)
reasonCodeValue := strings.TrimSpace(input.ReasonCode)
actorTypeValue := strings.TrimSpace(input.Actor.Type)
actorIDValue := strings.TrimSpace(input.Actor.ID)
defer func() {
if service.telemetry != nil {
service.telemetry.RecordEntitlementMutation(ctx, "revoke", outcome)
}
shared.LogServiceOutcome(service.logger, ctx, "entitlement revoke completed", err,
"use_case", "revoke_entitlement",
"command", "revoke",
"outcome", outcome,
"user_id", userIDString,
"source", sourceValue,
"reason_code", reasonCodeValue,
"actor_type", actorTypeValue,
"actor_id", actorIDValue,
)
}()
if ctx == nil {
outcome = shared.ErrorCodeInvalidRequest
return CommandResult{}, shared.InvalidRequest("context must not be nil")
}
userID, err := shared.ParseUserID(input.UserID)
if err != nil {
outcome = shared.MetricOutcome(err)
return CommandResult{}, err
}
userIDString = userID.String()
if err := service.support.ensureUserExists(ctx, userID); err != nil {
outcome = shared.MetricOutcome(err)
return CommandResult{}, err
}
source, err := parseSource(input.Source)
if err != nil {
outcome = shared.MetricOutcome(err)
return CommandResult{}, err
}
sourceValue = source.String()
reasonCode, err := shared.ParseReasonCode(input.ReasonCode)
if err != nil {
outcome = shared.MetricOutcome(err)
return CommandResult{}, err
}
reasonCodeValue = reasonCode.String()
actor, err := parseActor(input.Actor)
if err != nil {
outcome = shared.MetricOutcome(err)
return CommandResult{}, err
}
actorTypeValue = actor.Type.String()
actorIDValue = actor.ID.String()
now := service.support.clock.Now().UTC()
currentSnapshot, err := service.support.loadEffectiveSnapshot(ctx, userID)
if err != nil {
outcome = shared.MetricOutcome(err)
return CommandResult{}, err
}
if !currentSnapshot.IsPaid {
outcome = shared.ErrorCodeConflict
return CommandResult{}, shared.Conflict()
}
currentRecord, err := service.support.loadCurrentRecord(ctx, userID, now)
if err != nil {
outcome = shared.MetricOutcome(err)
return CommandResult{}, err
}
if currentRecord.PlanCode != currentSnapshot.PlanCode {
outcome = shared.ErrorCodeInternalError
return CommandResult{}, shared.InternalError(fmt.Errorf("user %q current entitlement record is inconsistent with current snapshot", userID))
}
recordID, err := service.support.idGenerator.NewEntitlementRecordID()
if err != nil {
outcome = shared.ErrorCodeServiceUnavailable
return CommandResult{}, shared.ServiceUnavailable(err)
}
updatedCurrentRecord := currentRecord
updatedCurrentRecord.ClosedAt = &now
updatedCurrentRecord.ClosedBy = actor
updatedCurrentRecord.ClosedReasonCode = reasonCode
newRecord := entitlement.PeriodRecord{
RecordID: recordID,
UserID: userID,
PlanCode: entitlement.PlanCodeFree,
Source: source,
Actor: actor,
ReasonCode: reasonCode,
StartsAt: now,
CreatedAt: now,
}
newSnapshot := entitlement.CurrentSnapshot{
UserID: userID,
PlanCode: entitlement.PlanCodeFree,
IsPaid: false,
StartsAt: now,
Source: source,
Actor: actor,
ReasonCode: reasonCode,
UpdatedAt: now,
}
if err := service.support.lifecycle.Revoke(ctx, ports.RevokeEntitlementInput{
ExpectedCurrentSnapshot: currentSnapshot,
ExpectedCurrentRecord: currentRecord,
UpdatedCurrentRecord: updatedCurrentRecord,
NewRecord: newRecord,
NewSnapshot: newSnapshot,
}); err != nil {
switch {
case errors.Is(err, ports.ErrConflict):
outcome = shared.ErrorCodeConflict
return CommandResult{}, shared.Conflict()
default:
outcome = shared.ErrorCodeServiceUnavailable
return CommandResult{}, shared.ServiceUnavailable(err)
}
}
outcome = "success"
result = CommandResult{UserID: userID.String(), Entitlement: newSnapshot}
publishEntitlementChanged(ctx, service.publisher, service.telemetry, service.logger, "revoke_entitlement", ports.EntitlementChangedOperationRevoked, newSnapshot)
return result, nil
}
func buildExpiryRepairState(
expiredSnapshot entitlement.CurrentSnapshot,
recordID entitlement.EntitlementRecordID,
now time.Time,
) (entitlement.PeriodRecord, entitlement.CurrentSnapshot, error) {
if !expiredSnapshot.IsExpiredAt(now) {
return entitlement.PeriodRecord{}, entitlement.CurrentSnapshot{}, fmt.Errorf("expired snapshot repair requires an expired finite paid snapshot")
}
freeStartsAt := expiredSnapshot.EndsAt.UTC()
freeRecord := entitlement.PeriodRecord{
RecordID: recordID,
UserID: expiredSnapshot.UserID,
PlanCode: entitlement.PlanCodeFree,
Source: expiryRepairSource,
Actor: common.ActorRef{Type: expiryRepairActorType, ID: expiryRepairActorID},
ReasonCode: expiryRepairReasonCode,
StartsAt: freeStartsAt,
CreatedAt: now,
}
freeSnapshot := entitlement.CurrentSnapshot{
UserID: expiredSnapshot.UserID,
PlanCode: entitlement.PlanCodeFree,
IsPaid: false,
StartsAt: freeStartsAt,
Source: expiryRepairSource,
Actor: common.ActorRef{Type: expiryRepairActorType, ID: expiryRepairActorID},
ReasonCode: expiryRepairReasonCode,
UpdatedAt: now,
}
if err := freeRecord.Validate(); err != nil {
return entitlement.PeriodRecord{}, entitlement.CurrentSnapshot{}, err
}
if err := freeSnapshot.Validate(); err != nil {
return entitlement.PeriodRecord{}, entitlement.CurrentSnapshot{}, err
}
return freeRecord, freeSnapshot, nil
}
func currentRecordAt(records []entitlement.PeriodRecord, now time.Time) (entitlement.PeriodRecord, bool) {
var (
currentRecord entitlement.PeriodRecord
found bool
)
for _, record := range records {
if !record.IsEffectiveAt(now) {
continue
}
if !found || record.StartsAt.After(currentRecord.StartsAt) ||
(record.StartsAt.Equal(currentRecord.StartsAt) && record.CreatedAt.After(currentRecord.CreatedAt)) {
currentRecord = record
found = true
}
}
return currentRecord, found
}
func parsePlanCode(value string) (entitlement.PlanCode, error) {
planCode := entitlement.PlanCode(shared.NormalizeString(value))
if !planCode.IsKnown() {
return "", shared.InvalidRequest("plan_code is unsupported")
}
return planCode, nil
}
func parseSource(value string) (common.Source, error) {
source := common.Source(shared.NormalizeString(value))
if err := source.Validate(); err != nil {
return "", shared.InvalidRequest(err.Error())
}
return source, nil
}
func parseActor(input ActorInput) (common.ActorRef, error) {
ref := common.ActorRef{
Type: common.ActorType(shared.NormalizeString(input.Type)),
ID: common.ActorID(shared.NormalizeString(input.ID)),
}
if err := ref.Validate(); err != nil {
switch {
case ref.Type.IsZero():
return common.ActorRef{}, shared.InvalidRequest("actor.type must not be empty")
default:
return common.ActorRef{}, shared.InvalidRequest(err.Error())
}
}
return ref, nil
}
func parseTimestamp(fieldName string, value string) (time.Time, error) {
trimmed := shared.NormalizeString(value)
if trimmed == "" {
return time.Time{}, shared.InvalidRequest(fieldName + " must not be empty")
}
parsed, err := time.Parse(time.RFC3339Nano, trimmed)
if err != nil {
return time.Time{}, shared.InvalidRequest(fieldName + " must be a valid RFC 3339 timestamp")
}
return parsed.UTC(), nil
}
func parseOptionalTimestamp(fieldName string, value string) (*time.Time, error) {
trimmed := shared.NormalizeString(value)
if trimmed == "" {
return nil, nil
}
parsed, err := parseTimestamp(fieldName, trimmed)
if err != nil {
return nil, err
}
return &parsed, nil
}
func publishEntitlementChanged(
ctx context.Context,
publisher ports.EntitlementChangedPublisher,
telemetryRuntime *telemetry.Runtime,
logger *slog.Logger,
useCase string,
operation ports.EntitlementChangedOperation,
snapshot entitlement.CurrentSnapshot,
) {
if publisher == nil {
return
}
event := ports.EntitlementChangedEvent{
UserID: snapshot.UserID,
OccurredAt: snapshot.UpdatedAt.UTC(),
Source: snapshot.Source,
Operation: operation,
PlanCode: snapshot.PlanCode,
IsPaid: snapshot.IsPaid,
StartsAt: snapshot.StartsAt,
EndsAt: snapshot.EndsAt,
ReasonCode: snapshot.ReasonCode,
Actor: snapshot.Actor,
UpdatedAt: snapshot.UpdatedAt,
}
if err := publisher.PublishEntitlementChanged(ctx, event); err != nil {
if telemetryRuntime != nil {
telemetryRuntime.RecordEventPublicationFailure(ctx, ports.EntitlementChangedEventType)
}
shared.LogEventPublicationFailure(logger, ctx, ports.EntitlementChangedEventType, err,
"use_case", useCase,
"user_id", snapshot.UserID.String(),
"source", snapshot.Source.String(),
"reason_code", snapshot.ReasonCode.String(),
"actor_type", snapshot.Actor.Type.String(),
"actor_id", snapshot.Actor.ID.String(),
)
}
}
func validateGrantBounds(
planCode entitlement.PlanCode,
startsAt time.Time,
endsAt *time.Time,
) error {
switch {
case planCode.HasFiniteExpiry():
if endsAt == nil {
return shared.InvalidRequest("ends_at must be present for finite paid plans")
}
case planCode == entitlement.PlanCodePaidLifetime:
if endsAt != nil {
return shared.InvalidRequest("ends_at must be empty for paid_lifetime")
}
default:
return shared.InvalidRequest("plan_code is unsupported")
}
if endsAt != nil && !endsAt.After(startsAt) {
return shared.InvalidRequest("ends_at must be after starts_at")
}
return nil
}