149 lines
3.6 KiB
Go
149 lines
3.6 KiB
Go
|
|
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
|
||
|
|
}
|