package userstore import ( "context" "encoding/json" "errors" "fmt" "time" "galaxy/user/internal/domain/common" "galaxy/user/internal/domain/entitlement" "galaxy/user/internal/ports" "github.com/redis/go-redis/v9" ) type entitlementPeriodRecord struct { RecordID string `json:"record_id"` UserID string `json:"user_id"` PlanCode string `json:"plan_code"` Source string `json:"source"` ActorType string `json:"actor_type"` ActorID *string `json:"actor_id,omitempty"` ReasonCode string `json:"reason_code"` StartsAt string `json:"starts_at"` EndsAt *string `json:"ends_at,omitempty"` CreatedAt string `json:"created_at"` ClosedAt *string `json:"closed_at,omitempty"` ClosedByType *string `json:"closed_by_type,omitempty"` ClosedByID *string `json:"closed_by_id,omitempty"` ClosedReasonCode *string `json:"closed_reason_code,omitempty"` } // CreateEntitlementRecord stores one new entitlement history record. func (store *Store) CreateEntitlementRecord(ctx context.Context, record entitlement.PeriodRecord) error { if err := record.Validate(); err != nil { return fmt.Errorf("create entitlement record in redis: %w", err) } payload, err := marshalEntitlementPeriodRecord(record) if err != nil { return fmt.Errorf("create entitlement record in redis: %w", err) } recordKey := store.keyspace.EntitlementRecord(record.RecordID) historyKey := store.keyspace.EntitlementHistory(record.UserID) operationCtx, cancel, err := store.operationContext(ctx, "create entitlement record in redis") if err != nil { return err } defer cancel() watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error { if err := ensureKeyAbsent(operationCtx, tx, recordKey); err != nil { return fmt.Errorf("create entitlement record %q in redis: %w", record.RecordID, err) } _, err := tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error { pipe.Set(operationCtx, recordKey, payload, 0) pipe.ZAdd(operationCtx, historyKey, redis.Z{ Score: float64(record.StartsAt.UTC().UnixMicro()), Member: record.RecordID.String(), }) return nil }) if err != nil { return fmt.Errorf("create entitlement record %q in redis: %w", record.RecordID, err) } return nil }, recordKey, historyKey) switch { case errors.Is(watchErr, redis.TxFailedErr): return fmt.Errorf("create entitlement record %q in redis: %w", record.RecordID, ports.ErrConflict) case watchErr != nil: return watchErr default: return nil } } // GetEntitlementRecordByRecordID returns the entitlement history record // identified by recordID. func (store *Store) GetEntitlementRecordByRecordID( ctx context.Context, recordID entitlement.EntitlementRecordID, ) (entitlement.PeriodRecord, error) { if err := recordID.Validate(); err != nil { return entitlement.PeriodRecord{}, fmt.Errorf("get entitlement record by record id from redis: %w", err) } operationCtx, cancel, err := store.operationContext(ctx, "get entitlement record by record id from redis") if err != nil { return entitlement.PeriodRecord{}, err } defer cancel() record, err := store.loadEntitlementRecord(operationCtx, store.client, recordID) if err != nil { switch { case errors.Is(err, ports.ErrNotFound): return entitlement.PeriodRecord{}, fmt.Errorf("get entitlement record by record id %q from redis: %w", recordID, ports.ErrNotFound) default: return entitlement.PeriodRecord{}, fmt.Errorf("get entitlement record by record id %q from redis: %w", recordID, err) } } return record, nil } // ListEntitlementRecordsByUserID returns every entitlement history record // owned by userID. func (store *Store) ListEntitlementRecordsByUserID( ctx context.Context, userID common.UserID, ) ([]entitlement.PeriodRecord, error) { if err := userID.Validate(); err != nil { return nil, fmt.Errorf("list entitlement records by user id from redis: %w", err) } operationCtx, cancel, err := store.operationContext(ctx, "list entitlement records by user id from redis") if err != nil { return nil, err } defer cancel() recordIDs, err := store.client.ZRange(operationCtx, store.keyspace.EntitlementHistory(userID), 0, -1).Result() if err != nil { return nil, fmt.Errorf("list entitlement records by user id %q from redis: %w", userID, err) } records := make([]entitlement.PeriodRecord, 0, len(recordIDs)) for _, rawRecordID := range recordIDs { record, err := store.loadEntitlementRecord(operationCtx, store.client, entitlement.EntitlementRecordID(rawRecordID)) if err != nil { return nil, fmt.Errorf("list entitlement records by user id %q from redis: %w", userID, err) } records = append(records, record) } return records, nil } // UpdateEntitlementRecord replaces one stored entitlement history record. func (store *Store) UpdateEntitlementRecord(ctx context.Context, record entitlement.PeriodRecord) error { if err := record.Validate(); err != nil { return fmt.Errorf("update entitlement record in redis: %w", err) } payload, err := marshalEntitlementPeriodRecord(record) if err != nil { return fmt.Errorf("update entitlement record in redis: %w", err) } recordKey := store.keyspace.EntitlementRecord(record.RecordID) operationCtx, cancel, err := store.operationContext(ctx, "update entitlement record in redis") if err != nil { return err } defer cancel() watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error { if _, err := store.loadEntitlementRecord(operationCtx, tx, record.RecordID); err != nil { return fmt.Errorf("update entitlement record %q in redis: %w", record.RecordID, err) } _, err := tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error { pipe.Set(operationCtx, recordKey, payload, 0) return nil }) if err != nil { return fmt.Errorf("update entitlement record %q in redis: %w", record.RecordID, err) } return nil }, recordKey) switch { case errors.Is(watchErr, redis.TxFailedErr): return fmt.Errorf("update entitlement record %q in redis: %w", record.RecordID, ports.ErrConflict) case watchErr != nil: return watchErr default: return nil } } // GrantEntitlement atomically closes the current free history record, creates // one paid history record, and replaces the current snapshot. func (store *Store) GrantEntitlement(ctx context.Context, input ports.GrantEntitlementInput) error { if err := input.Validate(); err != nil { return fmt.Errorf("grant entitlement in redis: %w", err) } updatedCurrentRecordPayload, err := marshalEntitlementPeriodRecord(input.UpdatedCurrentRecord) if err != nil { return fmt.Errorf("grant entitlement in redis: %w", err) } newRecordPayload, err := marshalEntitlementPeriodRecord(input.NewRecord) if err != nil { return fmt.Errorf("grant entitlement in redis: %w", err) } newSnapshotPayload, err := marshalEntitlementSnapshotRecord(input.NewSnapshot) if err != nil { return fmt.Errorf("grant entitlement in redis: %w", err) } currentRecordKey := store.keyspace.EntitlementRecord(input.ExpectedCurrentRecord.RecordID) newRecordKey := store.keyspace.EntitlementRecord(input.NewRecord.RecordID) historyKey := store.keyspace.EntitlementHistory(input.NewRecord.UserID) snapshotKey := store.keyspace.EntitlementSnapshot(input.NewSnapshot.UserID) watchedKeys := append( []string{currentRecordKey, newRecordKey, historyKey, snapshotKey}, store.activeSanctionWatchKeys(input.NewSnapshot.UserID)..., ) operationCtx, cancel, err := store.operationContext(ctx, "grant entitlement in redis") if err != nil { return err } defer cancel() watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error { storedSnapshot, err := store.loadEntitlementSnapshot(operationCtx, tx, input.ExpectedCurrentSnapshot.UserID) if err != nil { return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err) } if !equalEntitlementSnapshots(storedSnapshot, input.ExpectedCurrentSnapshot) { return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict) } storedCurrentRecord, err := store.loadEntitlementRecord(operationCtx, tx, input.ExpectedCurrentRecord.RecordID) if err != nil { return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err) } if !equalEntitlementPeriodRecords(storedCurrentRecord, input.ExpectedCurrentRecord) { return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict) } if err := ensureKeyAbsent(operationCtx, tx, newRecordKey); err != nil { return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err) } activeSanctionCodes, err := store.loadActiveSanctionCodeSet(operationCtx, tx, input.NewSnapshot.UserID) if err != nil { return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err) } _, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error { pipe.Set(operationCtx, currentRecordKey, updatedCurrentRecordPayload, 0) pipe.Set(operationCtx, newRecordKey, newRecordPayload, 0) pipe.ZAdd(operationCtx, historyKey, redis.Z{ Score: float64(input.NewRecord.StartsAt.UTC().UnixMicro()), Member: input.NewRecord.RecordID.String(), }) pipe.Set(operationCtx, snapshotKey, newSnapshotPayload, 0) store.syncEntitlementIndexes(pipe, operationCtx, input.NewSnapshot) store.syncEligibilityMarkerIndexes(pipe, operationCtx, input.NewSnapshot.UserID, input.NewSnapshot.IsPaid, activeSanctionCodes) return nil }) if err != nil { return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err) } return nil }, watchedKeys...) switch { case errors.Is(watchErr, redis.TxFailedErr): return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict) case watchErr != nil: return watchErr default: return nil } } // ExtendEntitlement atomically appends one paid history segment and replaces // the current paid snapshot. func (store *Store) ExtendEntitlement(ctx context.Context, input ports.ExtendEntitlementInput) error { if err := input.Validate(); err != nil { return fmt.Errorf("extend entitlement in redis: %w", err) } newRecordPayload, err := marshalEntitlementPeriodRecord(input.NewRecord) if err != nil { return fmt.Errorf("extend entitlement in redis: %w", err) } newSnapshotPayload, err := marshalEntitlementSnapshotRecord(input.NewSnapshot) if err != nil { return fmt.Errorf("extend entitlement in redis: %w", err) } newRecordKey := store.keyspace.EntitlementRecord(input.NewRecord.RecordID) historyKey := store.keyspace.EntitlementHistory(input.NewRecord.UserID) snapshotKey := store.keyspace.EntitlementSnapshot(input.NewSnapshot.UserID) watchedKeys := append( []string{newRecordKey, historyKey, snapshotKey}, store.activeSanctionWatchKeys(input.NewSnapshot.UserID)..., ) operationCtx, cancel, err := store.operationContext(ctx, "extend entitlement in redis") if err != nil { return err } defer cancel() watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error { storedSnapshot, err := store.loadEntitlementSnapshot(operationCtx, tx, input.ExpectedCurrentSnapshot.UserID) if err != nil { return fmt.Errorf("extend entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err) } if !equalEntitlementSnapshots(storedSnapshot, input.ExpectedCurrentSnapshot) { return fmt.Errorf("extend entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict) } if err := ensureKeyAbsent(operationCtx, tx, newRecordKey); err != nil { return fmt.Errorf("extend entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err) } activeSanctionCodes, err := store.loadActiveSanctionCodeSet(operationCtx, tx, input.NewSnapshot.UserID) if err != nil { return fmt.Errorf("extend entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err) } _, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error { pipe.Set(operationCtx, newRecordKey, newRecordPayload, 0) pipe.ZAdd(operationCtx, historyKey, redis.Z{ Score: float64(input.NewRecord.StartsAt.UTC().UnixMicro()), Member: input.NewRecord.RecordID.String(), }) pipe.Set(operationCtx, snapshotKey, newSnapshotPayload, 0) store.syncEntitlementIndexes(pipe, operationCtx, input.NewSnapshot) store.syncEligibilityMarkerIndexes(pipe, operationCtx, input.NewSnapshot.UserID, input.NewSnapshot.IsPaid, activeSanctionCodes) return nil }) if err != nil { return fmt.Errorf("extend entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err) } return nil }, watchedKeys...) switch { case errors.Is(watchErr, redis.TxFailedErr): return fmt.Errorf("extend entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict) case watchErr != nil: return watchErr default: return nil } } // RevokeEntitlement atomically closes the current paid history record, // creates one free history record, and replaces the current snapshot. func (store *Store) RevokeEntitlement(ctx context.Context, input ports.RevokeEntitlementInput) error { if err := input.Validate(); err != nil { return fmt.Errorf("revoke entitlement in redis: %w", err) } updatedCurrentRecordPayload, err := marshalEntitlementPeriodRecord(input.UpdatedCurrentRecord) if err != nil { return fmt.Errorf("revoke entitlement in redis: %w", err) } newRecordPayload, err := marshalEntitlementPeriodRecord(input.NewRecord) if err != nil { return fmt.Errorf("revoke entitlement in redis: %w", err) } newSnapshotPayload, err := marshalEntitlementSnapshotRecord(input.NewSnapshot) if err != nil { return fmt.Errorf("revoke entitlement in redis: %w", err) } currentRecordKey := store.keyspace.EntitlementRecord(input.ExpectedCurrentRecord.RecordID) newRecordKey := store.keyspace.EntitlementRecord(input.NewRecord.RecordID) historyKey := store.keyspace.EntitlementHistory(input.NewRecord.UserID) snapshotKey := store.keyspace.EntitlementSnapshot(input.NewSnapshot.UserID) watchedKeys := append( []string{currentRecordKey, newRecordKey, historyKey, snapshotKey}, store.activeSanctionWatchKeys(input.NewSnapshot.UserID)..., ) operationCtx, cancel, err := store.operationContext(ctx, "revoke entitlement in redis") if err != nil { return err } defer cancel() watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error { storedSnapshot, err := store.loadEntitlementSnapshot(operationCtx, tx, input.ExpectedCurrentSnapshot.UserID) if err != nil { return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err) } if !equalEntitlementSnapshots(storedSnapshot, input.ExpectedCurrentSnapshot) { return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict) } storedCurrentRecord, err := store.loadEntitlementRecord(operationCtx, tx, input.ExpectedCurrentRecord.RecordID) if err != nil { return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err) } if !equalEntitlementPeriodRecords(storedCurrentRecord, input.ExpectedCurrentRecord) { return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict) } if err := ensureKeyAbsent(operationCtx, tx, newRecordKey); err != nil { return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err) } activeSanctionCodes, err := store.loadActiveSanctionCodeSet(operationCtx, tx, input.NewSnapshot.UserID) if err != nil { return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err) } _, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error { pipe.Set(operationCtx, currentRecordKey, updatedCurrentRecordPayload, 0) pipe.Set(operationCtx, newRecordKey, newRecordPayload, 0) pipe.ZAdd(operationCtx, historyKey, redis.Z{ Score: float64(input.NewRecord.StartsAt.UTC().UnixMicro()), Member: input.NewRecord.RecordID.String(), }) pipe.Set(operationCtx, snapshotKey, newSnapshotPayload, 0) store.syncEntitlementIndexes(pipe, operationCtx, input.NewSnapshot) store.syncEligibilityMarkerIndexes(pipe, operationCtx, input.NewSnapshot.UserID, input.NewSnapshot.IsPaid, activeSanctionCodes) return nil }) if err != nil { return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err) } return nil }, watchedKeys...) switch { case errors.Is(watchErr, redis.TxFailedErr): return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict) case watchErr != nil: return watchErr default: return nil } } // RepairExpiredEntitlement atomically replaces one expired finite paid // snapshot with a materialized free state. func (store *Store) RepairExpiredEntitlement(ctx context.Context, input ports.RepairExpiredEntitlementInput) error { if err := input.Validate(); err != nil { return fmt.Errorf("repair expired entitlement in redis: %w", err) } newRecordPayload, err := marshalEntitlementPeriodRecord(input.NewRecord) if err != nil { return fmt.Errorf("repair expired entitlement in redis: %w", err) } newSnapshotPayload, err := marshalEntitlementSnapshotRecord(input.NewSnapshot) if err != nil { return fmt.Errorf("repair expired entitlement in redis: %w", err) } newRecordKey := store.keyspace.EntitlementRecord(input.NewRecord.RecordID) historyKey := store.keyspace.EntitlementHistory(input.NewRecord.UserID) snapshotKey := store.keyspace.EntitlementSnapshot(input.NewSnapshot.UserID) watchedKeys := append( []string{newRecordKey, historyKey, snapshotKey}, store.activeSanctionWatchKeys(input.NewSnapshot.UserID)..., ) operationCtx, cancel, err := store.operationContext(ctx, "repair expired entitlement in redis") if err != nil { return err } defer cancel() watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error { storedSnapshot, err := store.loadEntitlementSnapshot(operationCtx, tx, input.ExpectedExpiredSnapshot.UserID) if err != nil { return fmt.Errorf("repair expired entitlement for user %q in redis: %w", input.ExpectedExpiredSnapshot.UserID, err) } if !equalEntitlementSnapshots(storedSnapshot, input.ExpectedExpiredSnapshot) { return fmt.Errorf("repair expired entitlement for user %q in redis: %w", input.ExpectedExpiredSnapshot.UserID, ports.ErrConflict) } if err := ensureKeyAbsent(operationCtx, tx, newRecordKey); err != nil { return fmt.Errorf("repair expired entitlement for user %q in redis: %w", input.ExpectedExpiredSnapshot.UserID, err) } activeSanctionCodes, err := store.loadActiveSanctionCodeSet(operationCtx, tx, input.NewSnapshot.UserID) if err != nil { return fmt.Errorf("repair expired entitlement for user %q in redis: %w", input.ExpectedExpiredSnapshot.UserID, err) } _, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error { pipe.Set(operationCtx, newRecordKey, newRecordPayload, 0) pipe.ZAdd(operationCtx, historyKey, redis.Z{ Score: float64(input.NewRecord.StartsAt.UTC().UnixMicro()), Member: input.NewRecord.RecordID.String(), }) pipe.Set(operationCtx, snapshotKey, newSnapshotPayload, 0) store.syncEntitlementIndexes(pipe, operationCtx, input.NewSnapshot) store.syncEligibilityMarkerIndexes(pipe, operationCtx, input.NewSnapshot.UserID, input.NewSnapshot.IsPaid, activeSanctionCodes) return nil }) if err != nil { return fmt.Errorf("repair expired entitlement for user %q in redis: %w", input.ExpectedExpiredSnapshot.UserID, err) } return nil }, watchedKeys...) switch { case errors.Is(watchErr, redis.TxFailedErr): return fmt.Errorf("repair expired entitlement for user %q in redis: %w", input.ExpectedExpiredSnapshot.UserID, ports.ErrConflict) case watchErr != nil: return watchErr default: return nil } } func (store *Store) loadEntitlementRecord( ctx context.Context, getter bytesGetter, recordID entitlement.EntitlementRecordID, ) (entitlement.PeriodRecord, error) { payload, err := getter.Get(ctx, store.keyspace.EntitlementRecord(recordID)).Bytes() switch { case errors.Is(err, redis.Nil): return entitlement.PeriodRecord{}, ports.ErrNotFound case err != nil: return entitlement.PeriodRecord{}, err } return decodeEntitlementPeriodRecord(payload) } func marshalEntitlementPeriodRecord(record entitlement.PeriodRecord) ([]byte, error) { encoded := entitlementPeriodRecord{ RecordID: record.RecordID.String(), UserID: record.UserID.String(), PlanCode: string(record.PlanCode), Source: record.Source.String(), ActorType: record.Actor.Type.String(), ReasonCode: record.ReasonCode.String(), StartsAt: record.StartsAt.UTC().Format(time.RFC3339Nano), CreatedAt: record.CreatedAt.UTC().Format(time.RFC3339Nano), } if !record.Actor.ID.IsZero() { value := record.Actor.ID.String() encoded.ActorID = &value } if record.EndsAt != nil { value := record.EndsAt.UTC().Format(time.RFC3339Nano) encoded.EndsAt = &value } if record.ClosedAt != nil { value := record.ClosedAt.UTC().Format(time.RFC3339Nano) encoded.ClosedAt = &value } if !record.ClosedBy.Type.IsZero() { value := record.ClosedBy.Type.String() encoded.ClosedByType = &value } if !record.ClosedBy.ID.IsZero() { value := record.ClosedBy.ID.String() encoded.ClosedByID = &value } if !record.ClosedReasonCode.IsZero() { value := record.ClosedReasonCode.String() encoded.ClosedReasonCode = &value } return json.Marshal(encoded) } func decodeEntitlementPeriodRecord(payload []byte) (entitlement.PeriodRecord, error) { var encoded entitlementPeriodRecord if err := decodeJSONPayload(payload, &encoded); err != nil { return entitlement.PeriodRecord{}, err } startsAt, err := time.Parse(time.RFC3339Nano, encoded.StartsAt) if err != nil { return entitlement.PeriodRecord{}, fmt.Errorf("decode entitlement period record starts_at: %w", err) } createdAt, err := time.Parse(time.RFC3339Nano, encoded.CreatedAt) if err != nil { return entitlement.PeriodRecord{}, fmt.Errorf("decode entitlement period record created_at: %w", err) } record := entitlement.PeriodRecord{ RecordID: entitlement.EntitlementRecordID(encoded.RecordID), UserID: common.UserID(encoded.UserID), PlanCode: entitlement.PlanCode(encoded.PlanCode), Source: common.Source(encoded.Source), Actor: common.ActorRef{Type: common.ActorType(encoded.ActorType)}, ReasonCode: common.ReasonCode(encoded.ReasonCode), StartsAt: startsAt.UTC(), CreatedAt: createdAt.UTC(), } if encoded.ActorID != nil { record.Actor.ID = common.ActorID(*encoded.ActorID) } if encoded.EndsAt != nil { value, err := time.Parse(time.RFC3339Nano, *encoded.EndsAt) if err != nil { return entitlement.PeriodRecord{}, fmt.Errorf("decode entitlement period record ends_at: %w", err) } value = value.UTC() record.EndsAt = &value } if encoded.ClosedAt != nil { value, err := time.Parse(time.RFC3339Nano, *encoded.ClosedAt) if err != nil { return entitlement.PeriodRecord{}, fmt.Errorf("decode entitlement period record closed_at: %w", err) } value = value.UTC() record.ClosedAt = &value } if encoded.ClosedByType != nil { record.ClosedBy.Type = common.ActorType(*encoded.ClosedByType) } if encoded.ClosedByID != nil { record.ClosedBy.ID = common.ActorID(*encoded.ClosedByID) } if encoded.ClosedReasonCode != nil { record.ClosedReasonCode = common.ReasonCode(*encoded.ClosedReasonCode) } if err := record.Validate(); err != nil { return entitlement.PeriodRecord{}, fmt.Errorf("decode entitlement period record: %w", err) } return record, nil } func equalEntitlementSnapshots(left entitlement.CurrentSnapshot, right entitlement.CurrentSnapshot) bool { return left.UserID == right.UserID && left.PlanCode == right.PlanCode && left.IsPaid == right.IsPaid && left.StartsAt.Equal(right.StartsAt) && equalOptionalTime(left.EndsAt, right.EndsAt) && left.Source == right.Source && left.Actor == right.Actor && left.ReasonCode == right.ReasonCode && left.UpdatedAt.Equal(right.UpdatedAt) } func equalEntitlementPeriodRecords(left entitlement.PeriodRecord, right entitlement.PeriodRecord) bool { return left.RecordID == right.RecordID && left.UserID == right.UserID && left.PlanCode == right.PlanCode && left.Source == right.Source && left.Actor == right.Actor && left.ReasonCode == right.ReasonCode && left.StartsAt.Equal(right.StartsAt) && equalOptionalTime(left.EndsAt, right.EndsAt) && left.CreatedAt.Equal(right.CreatedAt) && equalOptionalTime(left.ClosedAt, right.ClosedAt) && left.ClosedBy == right.ClosedBy && left.ClosedReasonCode == right.ClosedReasonCode } func equalOptionalTime(left *time.Time, right *time.Time) bool { switch { case left == nil && right == nil: return true case left == nil || right == nil: return false default: return left.Equal(*right) } } // EntitlementHistoryStore adapts Store to the existing // EntitlementHistoryStore port. type EntitlementHistoryStore struct { store *Store } // EntitlementHistory returns one adapter that exposes the entitlement-history // store port over Store. func (store *Store) EntitlementHistory() *EntitlementHistoryStore { if store == nil { return nil } return &EntitlementHistoryStore{store: store} } // Create stores one new entitlement history record. func (adapter *EntitlementHistoryStore) Create(ctx context.Context, record entitlement.PeriodRecord) error { return adapter.store.CreateEntitlementRecord(ctx, record) } // GetByRecordID returns the entitlement history record identified by recordID. func (adapter *EntitlementHistoryStore) GetByRecordID( ctx context.Context, recordID entitlement.EntitlementRecordID, ) (entitlement.PeriodRecord, error) { return adapter.store.GetEntitlementRecordByRecordID(ctx, recordID) } // ListByUserID returns every entitlement history record owned by userID. func (adapter *EntitlementHistoryStore) ListByUserID( ctx context.Context, userID common.UserID, ) ([]entitlement.PeriodRecord, error) { return adapter.store.ListEntitlementRecordsByUserID(ctx, userID) } // Update replaces one stored entitlement history record. func (adapter *EntitlementHistoryStore) Update(ctx context.Context, record entitlement.PeriodRecord) error { return adapter.store.UpdateEntitlementRecord(ctx, record) } var _ ports.EntitlementHistoryStore = (*EntitlementHistoryStore)(nil) // EntitlementLifecycleStore adapts Store to the existing // EntitlementLifecycleStore port. type EntitlementLifecycleStore struct { store *Store } // EntitlementLifecycle returns one adapter that exposes the atomic // entitlement-lifecycle store port over Store. func (store *Store) EntitlementLifecycle() *EntitlementLifecycleStore { if store == nil { return nil } return &EntitlementLifecycleStore{store: store} } // Grant atomically applies one free-to-paid transition. func (adapter *EntitlementLifecycleStore) Grant(ctx context.Context, input ports.GrantEntitlementInput) error { return adapter.store.GrantEntitlement(ctx, input) } // Extend atomically appends one paid extension segment and updates the current // snapshot. func (adapter *EntitlementLifecycleStore) Extend(ctx context.Context, input ports.ExtendEntitlementInput) error { return adapter.store.ExtendEntitlement(ctx, input) } // Revoke atomically applies one paid-to-free transition. func (adapter *EntitlementLifecycleStore) Revoke(ctx context.Context, input ports.RevokeEntitlementInput) error { return adapter.store.RevokeEntitlement(ctx, input) } // RepairExpired atomically repairs one expired finite paid snapshot. func (adapter *EntitlementLifecycleStore) RepairExpired( ctx context.Context, input ports.RepairExpiredEntitlementInput, ) error { return adapter.store.RepairExpiredEntitlement(ctx, input) } var _ ports.EntitlementLifecycleStore = (*EntitlementLifecycleStore)(nil)