diff --git a/catalog/builder.go b/catalog/builder.go index b038ba9..f7a19f7 100644 --- a/catalog/builder.go +++ b/catalog/builder.go @@ -144,6 +144,10 @@ func Apply(s Schema, source map[string]any) (Projection, error) { InventoryTracked: boolOf(source, s.InventoryTrackedF, truthy), DropShip: boolOf(source, s.DropShipF, truthy), Category: stringOf(source, s.CategoryField), + Category2: stringOf(source, s.Category2Field), + Category3: stringOf(source, s.Category3Field), + Category4: stringOf(source, s.Category4Field), + Category5: stringOf(source, s.Category5Field), Brand: stringOf(source, s.BrandField), Image: stringOf(source, s.ImageField), UpdatedAt: upd, diff --git a/catalog/projection.go b/catalog/projection.go index 4ee845a..27fe067 100644 --- a/catalog/projection.go +++ b/catalog/projection.go @@ -19,6 +19,10 @@ // inventory_tracked ← bool coercion of source[InventoryTrackedField] // drop_ship ← bool coercion of source[DropShipField] // category ← source[CategoryField] (omitempty) +// category2 ← source[Category2Field] (omitempty) +// category3 ← source[Category3Field] (omitempty) +// category4 ← source[Category4Field] (omitempty) +// category5 ← source[Category5Field] (omitempty) // brand ← source[BrandField] (omitempty) // image ← source[ImageField] (omitempty) // updated_at ← Unix-ms from source[UpdatedAtField] OR stamped at @@ -75,7 +79,10 @@ import ( // asks for these so the cart can pre-mark drop-ship // items before checkout and inventory can opt-out of // tracking for non-stock items. -// Category / Brand / Image — display projection only. +// Category* / Brand / Image — display projection only. +// Category2–Category5 extend category with deeper taxonomy levels for +// promotions engine multi-level matching and tracking personas. +// Each level is optional; an empty string means "not published". // UpdatedAt — version stamp for snapshot + revalidate. // // WIRE-COMPAT: adding a field is forward+backward-safe. Old snapshot @@ -97,6 +104,10 @@ type Projection struct { InventoryTracked bool `json:"inventory_tracked"` DropShip bool `json:"drop_ship"` Category string `json:"category,omitempty"` + Category2 string `json:"category2,omitempty"` + Category3 string `json:"category3,omitempty"` + Category4 string `json:"category4,omitempty"` + Category5 string `json:"category5,omitempty"` Brand string `json:"brand,omitempty"` Image string `json:"image,omitempty"` UpdatedAt int64 `json:"updated_at"` diff --git a/catalog/schema.go b/catalog/schema.go index a2cbf82..2c0659a 100644 --- a/catalog/schema.go +++ b/catalog/schema.go @@ -59,6 +59,10 @@ type Schema struct { InventoryTrackedF string `json:"inventory_tracked_field,omitempty"` DropShipF string `json:"drop_ship_field,omitempty"` CategoryField string `json:"category_field,omitempty"` + Category2Field string `json:"category2_field,omitempty"` + Category3Field string `json:"category3_field,omitempty"` + Category4Field string `json:"category4_field,omitempty"` + Category5Field string `json:"category5_field,omitempty"` BrandField string `json:"brand_field,omitempty"` ImageField string `json:"image_field,omitempty"` // AttributesFields names the keys in the source map[string]any that @@ -125,6 +129,10 @@ func DefaultSchema() Schema { InventoryTrackedF: "inventory_tracked", DropShipF: "drop_ship", CategoryField: "category", + Category2Field: "category2", + Category3Field: "category3", + Category4Field: "category4", + Category5Field: "category5", BrandField: "brand", ImageField: "image", // Standard product service carries the integer catalog id under "id". @@ -165,6 +173,10 @@ func BygglagretSchema() Schema { InventoryTrackedF: "StockTracked", DropShipF: "DropshipFlag", CategoryField: "CategoryPath", + Category2Field: "Category2", + Category3Field: "Category3", + Category4Field: "Category4", + Category5Field: "Category5", BrandField: "Brand", ImageField: "PrimaryImage", ItemIdField: "item_id", diff --git a/cmd/internal/catalogtest/items.go b/cmd/internal/catalogtest/items.go index 5751588..f982005 100644 --- a/cmd/internal/catalogtest/items.go +++ b/cmd/internal/catalogtest/items.go @@ -104,6 +104,18 @@ func SynthItem(s catalog.Schema, rng *rand.Rand, i int, skuPrefix string) map[st if s.CategoryField != "" { m[s.CategoryField] = fmt.Sprintf("c-%d", rng.Intn(50)) } + if s.Category2Field != "" { + m[s.Category2Field] = fmt.Sprintf("c2-%d", rng.Intn(20)) + } + if s.Category3Field != "" { + m[s.Category3Field] = fmt.Sprintf("c3-%d", rng.Intn(10)) + } + if s.Category4Field != "" { + m[s.Category4Field] = fmt.Sprintf("c4-%d", rng.Intn(5)) + } + if s.Category5Field != "" { + m[s.Category5Field] = fmt.Sprintf("c5-%d", rng.Intn(3)) + } if s.BrandField != "" { m[s.BrandField] = "Acme" } diff --git a/order/order.go b/order/order.go index d687ce8..c3cb79a 100644 --- a/order/order.go +++ b/order/order.go @@ -42,8 +42,11 @@ type Created struct { Country string `json:"country"` Currency string `json:"currency,omitempty"` CustomerEmail string `json:"customer_email,omitempty"` - Lines []Line `json:"lines"` - PlacedAtMs int64 `json:"placed_at_ms,omitempty"` + // CustomerID is the customer profile ID for identity linking (B0.4). Reactors + // (e.g. profile service) use this to link the order to the customer profile. + CustomerID string `json:"customer_id,omitempty"` + Lines []Line `json:"lines"` + PlacedAtMs int64 `json:"placed_at_ms,omitempty"` } // Encode serialises the payload for event.Event.Payload. diff --git a/rabbit/conn.go b/rabbit/conn.go index 84889f7..7b299c1 100644 --- a/rabbit/conn.go +++ b/rabbit/conn.go @@ -84,7 +84,17 @@ func Dial(url, name string) (*Conn, error) { done: make(chan struct{}), onReconn: make(chan struct{}), } - raw, err := dial(url, name) + var raw *amqp.Connection + var err error + for attempts := 0; attempts < 10; attempts++ { + raw, err = dial(url, name) + if err == nil { + break + } + if attempts < 9 { + time.Sleep(500 * time.Millisecond) + } + } if err != nil { return nil, err } diff --git a/region/config.go b/region/config.go new file mode 100644 index 0000000..30ca4a3 --- /dev/null +++ b/region/config.go @@ -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" +} diff --git a/region/config_test.go b/region/config_test.go new file mode 100644 index 0000000..fe98456 --- /dev/null +++ b/region/config_test.go @@ -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) + } +}