98 lines
3.9 KiB
Go
98 lines
3.9 KiB
Go
// 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)
|
|
}
|