1115 lines
34 KiB
Go
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
|
|
}
|