feat: user service

This commit is contained in:
Ilia Denisov
2026-04-10 19:05:02 +02:00
committed by GitHub
parent 710bad712e
commit 23ffcb7535
140 changed files with 33418 additions and 952 deletions
@@ -0,0 +1,752 @@
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)