ProofedMesh/services/admin-api/internal/model/rate.go

99 lines
3.9 KiB
Go
Raw Permalink Normal View History

// 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)
}