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