package store import ( "context" "fmt" "sort" "sync" "time" "basicstack.de/proofedmesh/services/admin-api/internal/model" ) // Memory is a thread-safe in-memory implementation of RateStore. It is the // fastest path for tests and the dev loop (`-store=memory`). Production uses // the Postgres implementation. type Memory struct { mu sync.Mutex rates map[string]model.Rate seq int } func NewMemory() *Memory { return &Memory{rates: make(map[string]model.Rate)} } func (m *Memory) List(ctx context.Context, f ListFilter) ([]model.Rate, error) { m.mu.Lock() defer m.mu.Unlock() today := time.Now().UTC().Truncate(24 * time.Hour) out := make([]model.Rate, 0, len(m.rates)) for _, r := range m.rates { if f.Country != "" && r.Country != f.Country { continue } if f.ActiveOnly { if r.ValidTo != nil && r.ValidTo.Before(today) { continue } } out = append(out, r) } sort.Slice(out, func(i, j int) bool { return out[i].RateID < out[j].RateID }) return out, nil } func (m *Memory) Get(ctx context.Context, rateID string) (model.Rate, error) { m.mu.Lock() defer m.mu.Unlock() r, ok := m.rates[rateID] if !ok { return model.Rate{}, ErrNotFound } return r, nil } func (m *Memory) Create(ctx context.Context, in model.NewRateInput, createdBy string, now time.Time) (model.Rate, error) { m.mu.Lock() defer m.mu.Unlock() if err := checkOverlap(m.rates, in); err != nil { return model.Rate{}, err } m.seq++ r := model.Rate{ RateID: fmt.Sprintf("PDR-%d", m.seq), Country: in.Country, City: in.City, Currency: in.Currency, FullDay: in.FullDay, PartialDayMin8h: in.PartialDayMin8h, ArrivalDay: in.ArrivalDay, DepartureDay: in.DepartureDay, ValidFrom: in.ValidFrom, ValidTo: in.ValidTo, SourceRegulation: in.SourceRegulation, CreatedBy: createdBy, CreatedAt: now, } m.rates[r.RateID] = r return r, nil } func (m *Memory) Retire(ctx context.Context, rateID string, validTo time.Time, performedBy string, now time.Time) (model.Rate, error) { m.mu.Lock() defer m.mu.Unlock() r, ok := m.rates[rateID] if !ok { return model.Rate{}, ErrNotFound } today := now.UTC().Truncate(24 * time.Hour) if r.ValidTo != nil && r.ValidTo.Before(today) { return model.Rate{}, ErrAlreadyRetired } if validTo.Before(r.ValidFrom) { return model.Rate{}, fmt.Errorf("retire: valid_to %s is before valid_from %s", validTo.Format("2006-01-02"), r.ValidFrom.Format("2006-01-02")) } cpy := r cpy.ValidTo = &validTo m.rates[rateID] = cpy return cpy, nil } func (m *Memory) AttachAttestation(ctx context.Context, rateID string, attestationRef string) error { m.mu.Lock() defer m.mu.Unlock() r, ok := m.rates[rateID] if !ok { return ErrNotFound } if !model.IsAttestationRef(attestationRef) { return fmt.Errorf("attestation ref %q is not a sha256: content hash", attestationRef) } cpy := r cpy.RateChangeAttestationRef = &attestationRef m.rates[rateID] = cpy return nil } func checkOverlap(existing map[string]model.Rate, in model.NewRateInput) error { cityKey := "" if in.City != nil { cityKey = *in.City } newEnd := time.Date(9999, 12, 31, 0, 0, 0, 0, time.UTC) if in.ValidTo != nil { newEnd = *in.ValidTo } for _, r := range existing { if r.Country != in.Country { continue } rc := "" if r.City != nil { rc = *r.City } if rc != cityKey { continue } rEnd := time.Date(9999, 12, 31, 0, 0, 0, 0, time.UTC) if r.ValidTo != nil { rEnd = *r.ValidTo } // Overlapping ranges? Inclusive on both ends. if !in.ValidFrom.After(rEnd) && !r.ValidFrom.After(newEnd) { return ErrOverlap } } return nil }