This commit is contained in:
mats committed 2026-07-26 13:01:07 +02:00
1 parent 540aa48c44
commit bd81f4ff54
8 files changed
+322 -4

No files matched your search

+101
View File
@@ -0,0 +1,101 @@
// Package region provides a first-class region configuration for the commerce
// platform. It maps country codes to their associated currency, locale, and
// other market-specific settings, replacing the hardcoded getCurrency/getLocale
// helpers that were duplicated across services.
//
// Usage:
//
// cfg, err := region.LoadConfig("data/regions.json")
// currency := cfg.CurrencyOf("no") // "NOK"
// locale := cfg.LocaleOf("no") // "nb-no"
// country := cfg.CountryFromCurrency("NOK") // "no"
package region
import (
"encoding/json"
"fmt"
"os"
"strings"
)
// Region defines the market configuration for a single country.
type Region struct {
CountryCode string `json:"countryCode"` // ISO 3166-1 alpha-2 lowercase, e.g. "se", "no", "dk", "fi"
Currency string `json:"currency"` // ISO 4217, e.g. "SEK", "NOK", "DKK", "EUR"
Locale string `json:"locale"` // IETF BCP 47, e.g. "sv-se", "nb-no", "da-dk", "fi-fi"
}
// Config holds the full set of region definitions.
type Config struct {
Regions []Region `json:"regions"`
}
// LoadConfig reads a region configuration from a JSON file. An absent file
// returns a default Config with only SE configured, matching the legacy
// hardcoded behaviour.
func LoadConfig(path string) (*Config, error) {
f, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
return DefaultConfig(), nil
}
return nil, fmt.Errorf("region: open %s: %w", path, err)
}
defer f.Close()
var cfg Config
if err := json.NewDecoder(f).Decode(&cfg); err != nil {
return nil, fmt.Errorf("region: decode %s: %w", path, err)
}
// Normalise country codes to lowercase.
for i := range cfg.Regions {
cfg.Regions[i].CountryCode = strings.ToLower(cfg.Regions[i].CountryCode)
}
return &cfg, nil
}
// DefaultConfig returns the built-in default with SE and NO configured.
func DefaultConfig() *Config {
return &Config{
Regions: []Region{
{CountryCode: "se", Currency: "SEK", Locale: "sv-se"},
{CountryCode: "no", Currency: "NOK", Locale: "nb-no"},
},
}
}
// CurrencyOf returns the ISO 4217 currency code for the given country.
// Returns "SEK" if the country is not configured.
func (c *Config) CurrencyOf(country string) string {
cc := strings.ToLower(country)
for _, r := range c.Regions {
if r.CountryCode == cc {
return r.Currency
}
}
return "SEK"
}
// LocaleOf returns the IETF BCP 47 locale tag for the given country.
// Returns "sv-se" if the country is not configured.
func (c *Config) LocaleOf(country string) string {
cc := strings.ToLower(country)
for _, r := range c.Regions {
if r.CountryCode == cc {
return r.Locale
}
}
return "sv-se"
}
// CountryFromCurrency is the reverse of CurrencyOf — it returns the country
// code that uses the given currency. Returns "se" for unknown currencies.
func (c *Config) CountryFromCurrency(currency string) string {
curr := strings.ToUpper(currency)
for _, r := range c.Regions {
if strings.EqualFold(r.Currency, curr) {
return r.CountryCode
}
}
return "se"
}
+165
View File
@@ -0,0 +1,165 @@
package region
import (
"os"
"path/filepath"
"testing"
)
func TestDefaultConfig(t *testing.T) {
cfg := DefaultConfig()
if len(cfg.Regions) != 2 {
t.Fatalf("expected 2 default regions, got %d", len(cfg.Regions))
}
if got := cfg.CurrencyOf("se"); got != "SEK" {
t.Errorf("CurrencyOf(se) = %s, want SEK", got)
}
if got := cfg.LocaleOf("se"); got != "sv-se" {
t.Errorf("LocaleOf(se) = %s, want sv-se", got)
}
if got := cfg.CurrencyOf("no"); got != "NOK" {
t.Errorf("CurrencyOf(no) = %s, want NOK", got)
}
if got := cfg.LocaleOf("no"); got != "nb-no" {
t.Errorf("LocaleOf(no) = %s, want nb-no", got)
}
}
func TestCurrencyOf(t *testing.T) {
cfg := DefaultConfig()
tests := []struct {
country string
want string
}{
{"se", "SEK"},
{"SE", "SEK"},
{"no", "NOK"},
{"NO", "NOK"},
{"dk", "SEK"}, // unknown → default
{"fi", "SEK"}, // unknown → default
{"us", "SEK"}, // unknown → default
{"", "SEK"}, // empty → default
}
for _, tt := range tests {
t.Run(tt.country, func(t *testing.T) {
if got := cfg.CurrencyOf(tt.country); got != tt.want {
t.Errorf("CurrencyOf(%q) = %q, want %q", tt.country, got, tt.want)
}
})
}
}
func TestLocaleOf(t *testing.T) {
cfg := DefaultConfig()
tests := []struct {
country string
want string
}{
{"se", "sv-se"},
{"SE", "sv-se"},
{"no", "nb-no"},
{"NO", "nb-no"},
{"dk", "sv-se"}, // unknown → default
{"", "sv-se"}, // empty → default
}
for _, tt := range tests {
t.Run(tt.country, func(t *testing.T) {
if got := cfg.LocaleOf(tt.country); got != tt.want {
t.Errorf("LocaleOf(%q) = %q, want %q", tt.country, got, tt.want)
}
})
}
}
func TestCountryFromCurrency(t *testing.T) {
cfg := DefaultConfig()
tests := []struct {
currency string
want string
}{
{"SEK", "se"},
{"sek", "se"},
{"NOK", "no"},
{"nok", "no"},
{"DKK", "se"}, // unknown → default
{"EUR", "se"}, // unknown → default
{"", "se"}, // empty → default
}
for _, tt := range tests {
t.Run(tt.currency, func(t *testing.T) {
if got := cfg.CountryFromCurrency(tt.currency); got != tt.want {
t.Errorf("CountryFromCurrency(%q) = %q, want %q", tt.currency, got, tt.want)
}
})
}
}
func TestLoadConfig_FileNotFound(t *testing.T) {
cfg, err := LoadConfig("/nonexistent/regions.json")
if err != nil {
t.Fatalf("LoadConfig on missing file should not error: %v", err)
}
if cfg == nil {
t.Fatal("expected non-nil config on missing file")
}
if len(cfg.Regions) != 2 {
t.Errorf("expected 2 default regions, got %d", len(cfg.Regions))
}
}
func TestLoadConfig_CustomFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "regions.json")
content := `{
"regions": [
{"countryCode": "se", "currency": "SEK", "locale": "sv-se"},
{"countryCode": "no", "currency": "NOK", "locale": "nb-no"},
{"countryCode": "dk", "currency": "DKK", "locale": "da-dk"}
]
}`
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("write test file: %v", err)
}
cfg, err := LoadConfig(path)
if err != nil {
t.Fatalf("LoadConfig failed: %v", err)
}
if len(cfg.Regions) != 3 {
t.Fatalf("expected 3 regions, got %d", len(cfg.Regions))
}
if got := cfg.CurrencyOf("dk"); got != "DKK" {
t.Errorf("CurrencyOf(dk) = %s, want DKK", got)
}
if got := cfg.LocaleOf("dk"); got != "da-dk" {
t.Errorf("LocaleOf(dk) = %s, want da-dk", got)
}
if got := cfg.CountryFromCurrency("DKK"); got != "dk" {
t.Errorf("CountryFromCurrency(DKK) = %s, want dk", got)
}
}
func TestLoadConfig_NormalisesCountryCode(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "regions.json")
content := `{"regions": [{"countryCode": "NO", "currency": "NOK", "locale": "nb-no"}]}`
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("write test file: %v", err)
}
cfg, err := LoadConfig(path)
if err != nil {
t.Fatalf("LoadConfig failed: %v", err)
}
if got := cfg.CurrencyOf("NO"); got != "NOK" {
t.Errorf("CurrencyOf(NO) = %s, want NOK", got)
}
if got := cfg.CurrencyOf("no"); got != "NOK" {
t.Errorf("CurrencyOf(no) = %s, want NOK", got)
}
}