// Package model defines the Rate value type and its invariants, shared by the // HTTP layer and the storage layer. All amounts are decimal strings matching // the "^-?[0-9]+\\.[0-9]{2}$" pattern used across ProofedMesh schemas; we // deliberately do not reach for float. package model import ( "errors" "fmt" "regexp" "time" ) var ( countryPattern = regexp.MustCompile(`^[A-Z]{2}$`) currencyPattern = regexp.MustCompile(`^[A-Z]{3}$`) amountPattern = regexp.MustCompile(`^[0-9]+\.[0-9]{2}$`) sha256Pattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) ) // Rate mirrors a row in per_diem_rates. Monetary fields are decimal strings // e.g. "28.00". Core business fields are set at creation and are immutable // afterwards (enforced both by the Postgres trigger and by the admin API). type Rate struct { RateID string `json:"rate_id"` Country string `json:"country"` // ISO 3166-1 alpha-2 City *string `json:"city,omitempty"` // optional Currency string `json:"currency"` // ISO 4217 FullDay string `json:"full_day"` PartialDayMin8h string `json:"partial_day_min8h"` ArrivalDay string `json:"arrival_day"` DepartureDay string `json:"departure_day"` ValidFrom time.Time `json:"valid_from"` ValidTo *time.Time `json:"valid_to,omitempty"` SourceRegulation string `json:"source_regulation"` CreatedBy string `json:"created_by"` CreatedAt time.Time `json:"created_at"` RateChangeAttestationRef *string `json:"rate_change_attestation_ref,omitempty"` } // NewRateInput carries the fields a caller may supply when creating a new rate. // RateID, CreatedBy, CreatedAt and the attestation ref are assigned by the API, // not the caller. type NewRateInput struct { Country string `json:"country"` City *string `json:"city,omitempty"` Currency string `json:"currency"` FullDay string `json:"full_day"` PartialDayMin8h string `json:"partial_day_min8h"` ArrivalDay string `json:"arrival_day"` DepartureDay string `json:"departure_day"` ValidFrom time.Time `json:"valid_from"` ValidTo *time.Time `json:"valid_to,omitempty"` SourceRegulation string `json:"source_regulation"` } // Validate checks the invariants that are enforced by the schema and that we // want to fail-fast on in the API layer so the user sees a sensible error // before we hit Postgres. func (in NewRateInput) Validate() error { if !countryPattern.MatchString(in.Country) { return fmt.Errorf("country %q must be ISO 3166-1 alpha-2 (two uppercase letters)", in.Country) } if in.City != nil && (*in.City == "" || len(*in.City) > 100) { return errors.New("city, if set, must be 1..100 characters") } if !currencyPattern.MatchString(in.Currency) { return fmt.Errorf("currency %q must be ISO 4217 (three uppercase letters)", in.Currency) } for _, p := range []struct { name string value string }{ {"full_day", in.FullDay}, {"partial_day_min8h", in.PartialDayMin8h}, {"arrival_day", in.ArrivalDay}, {"departure_day", in.DepartureDay}, } { if !amountPattern.MatchString(p.value) { return fmt.Errorf("%s %q must be a decimal string with two decimals (e.g. '28.00')", p.name, p.value) } } if in.ValidFrom.IsZero() { return errors.New("valid_from is required") } if in.ValidTo != nil && in.ValidTo.Before(in.ValidFrom) { return errors.New("valid_to must not be before valid_from") } if l := len(in.SourceRegulation); l < 1 || l > 500 { return errors.New("source_regulation must be 1..500 characters") } return nil } // IsAttestationRef returns true for a well-formed sha256: content hash. func IsAttestationRef(ref string) bool { return sha256Pattern.MatchString(ref) }