Skip to main content

Bilko ADR-018: Market vs Locale

Author: ALAI, 2026

# ADR-018: Market vs Locale Separation

**Status:** Accepted
**Date:** 2026-04-21
**Author:** ALAI, 2026
**Related:** ADR-015 (Four-Jurisdiction Plugin), ADR-017 (RLS Multi-Tenancy)

---

## Context

Bilko serves 4 **tax jurisdictions** (markets) but must support 5+ **locales** (languages/scripts). These are **orthogonal concerns** but were conflated in early prototypes.

**Problem example:**

- A company in **Belgrade, Serbia (RS market)** may want UI in **Serbian Latin** (`sr-Latn`)
- A company in **Banja Luka, Bosnia RS entity (BA-RS market)** also wants UI in **Serbian Latin** (`sr-Latn`)
- Same locale, different markets — different tax rates, different filing authorities

**Old mistake:** Hardcoding `market = locale` (e.g., `if locale == 'sr' then jurisdiction = RS`) breaks when:

1. Diaspora companies (Serbian company in Norway uses `nb` locale, `RS` market)
2. Multilingual jurisdictions (BiH supports `bs`, `sr`, `hr` locales, 2 markets)
3. English-speaking accountants working on local entities

**Goal:** Separate **MarketContext** (tax jurisdiction, currency, CoA, fiscal platform) from **LocaleContext** (UI language, number format, date format).

---

## Decision

### 1. Two Independent Contexts

**MarketContext:**

- **Source of truth:** `Organization.taxJurisdiction` (enum: `RS | HR | BA_FED | BA_RS`)
- **Read from:** JWT claim `org.taxJurisdiction` at request time
- **Controls:** VAT rates, currency, e-invoice adapter, fiscal platform, CoA version, retention policy
- **Injected via:** `CountryPlugin` interface (ADR-015)

**LocaleContext:**

- **Source of truth:** User preference (`User.preferredLocale`) or browser `Accept-Language` header
- **Supported locales:** `{sr-Latn, sr-Cyrl, hr, bs, en}` (Phase 3 Task 3.1)
- **Controls:** UI strings (i18n), number formatting, date formatting, currency symbol display
- **Injected via:** Next.js `next-intl` provider

**Key invariant:** Market and locale are **independent variables**. A user can select any locale regardless of organization market.

### 2. Locale Matrix (Supported Combinations)

| Locale    | Script   | Markets Supporting | Notes                                                          |
| --------- | -------- | ------------------ | -------------------------------------------------------------- |
| `sr-Latn` | Latin    | RS, BA-RS          | Serbian Latin (default for Serbia, common in RS entity)        |
| `sr-Cyrl` | Cyrillic | RS, BA-RS          | Serbian Cyrillic (official in Serbia, less common in practice) |
| `hr`      | Latin    | HR, BA-FED         | Croatian (Croatia + Croat-majority areas of BiH)               |
| `bs`      | Latin    | BA-FED, BA-RS      | Bosnian (official in BiH Federation)                           |
| `en`      | Latin    | All                | English (for international accountants)                        |

**User flow:**

1. User logs in → JWT contains `org.taxJurisdiction` (e.g., `BA_FED`)
2. User selects UI locale → stored in `User.preferredLocale` (e.g., `bs`)
3. Frontend: `MarketContext.value = BA_FED`, `LocaleContext.value = bs`
4. Invoice wizard shows BiH-specific VAT fields (17% flat rate) in Bosnian language

### 3. Routing Decision — Path Prefix, Not Subdomain

**Options evaluated:**

- **Option A (subdomain):** `bilko.rs`, `bilko.hr`, `bilko.ba` → different deployments per market
- **Option B (path prefix):** `bilko.io/rs/...`, `bilko.io/hr/...`, `bilko.io/ba/...` → single deployment

**Decision: Option B (path prefix)**

**Rationale:**

1. **Single deployment** — reduces infra complexity (one Docker image, one DB, one domain cert)
2. **Locale independence** — user can switch locale without changing URL market segment
3. **SEO flexibility** — `/en/pricing` vs `/sr/cene` share same market routing
4. **Cloudflare Transform Rule** injects `X-Market` header from path prefix (Task 4.2)

**URL structure:**

```
bilko.io/             → Landing page (market preselection)
bilko.io/rs/...       → Serbia market
bilko.io/hr/...       → Croatia market
bilko.io/ba-fed/...   → BiH Federation market
bilko.io/ba-rs/...    → BiH RS entity market
bilko.io/en/pricing   → English pricing page (market-agnostic)
```

**Backend ignores `X-Market` header when JWT present** (Kelsey security rule — Task 4.2). Only use path prefix for **unauthenticated** flows (landing pages, marketing).

### 4. Frontend Implementation (Phase 3)

**MarketProvider (Task 3.1):**

```tsx
// apps/web/lib/market-context.tsx
const MarketContext = createContext<TaxJurisdiction | null>(null)

export function MarketProvider({ children }: { children: ReactNode }) {
  const jwt = useJWT() // Read from httpOnly cookie
  const market = jwt?.org?.taxJurisdiction ?? null

  return <MarketContext.Provider value={market}>{children}</MarketContext.Provider>
}

export function useMarket() {
  const market = useContext(MarketContext)
  if (!market) throw new Error('useMarket must be within MarketProvider')
  return market
}
```

**LocaleProvider (next-intl):**

```tsx
// apps/web/app/[locale]/layout.tsx
import { NextIntlClientProvider } from 'next-intl'
import { getMessages } from 'next-intl/server'

export default async function LocaleLayout({
  children,
  params: { locale },
}: {
  children: ReactNode
  params: { locale: string }
}) {
  const messages = await getMessages(locale)

  return (
    <NextIntlClientProvider locale={locale} messages={messages}>
      {children}
    </NextIntlClientProvider>
  )
}
```

**Dynamic component import based on market:**

```tsx
// apps/web/app/[locale]/dashboard/vat-return/page.tsx
import { useMarket } from '@/lib/market-context'
import dynamic from 'next/dynamic'

export default function VATReturnPage() {
  const market = useMarket()

  const VATReturnComponent = dynamic(
    () => import(`@bilko/country-${market.toLowerCase()}/components/VATReturn`),
  )

  return <VATReturnComponent />
}
```

Each jurisdiction package exports market-specific components:

- `@bilko/country-rs/components/VATReturnRS` (Serbia PPPDV form)
- `@bilko/country-hr/components/VATReturnHR` (Croatia PDV-S form)
- `@bilko/country-ba-fed/components/VATReturnBAFED` (BiH FBiH PDV-1 form)
- `@bilko/country-ba-rs/components/VATReturnBARS` (BiH RS PDV form)

### 5. Formatters — Injected from CountryPlugin

**Problem:** Number and date formatting is locale-dependent **and** market-dependent:

- Serbia uses `,` as decimal separator (123.456,78)
- Croatia uses `,` as decimal separator (123.456,78)
- US English uses `.` as decimal separator (123,456.78)

**Solution:** `JurisdictionFormatters` interface (ADR-015) provides formatters per market.

**Kotlin side (CountryPlugin):**

```kotlin
interface JurisdictionFormatters {
  fun formatAmount(amount: BigDecimal, currency: Currency): String
  fun formatDate(date: LocalDate): String
  fun formatTaxRate(rate: BigDecimal): String
}

class PluginRS : CountryPlugin {
  override fun getFormatters() = object : JurisdictionFormatters {
    override fun formatAmount(amount: BigDecimal, currency: Currency) =
      "%,.2f %s".format(Locale("sr", "RS"), amount, currency.currencyCode)
    // Output: "123.456,78 RSD"
  }
}
```

**Frontend side (React):**

```tsx
// packages/ui/components/molecules/AmountDisplay.tsx
import { useMarket } from '@/lib/market-context'

export function AmountDisplay({ amount, currency }: { amount: number; currency: string }) {
  const market = useMarket()
  const formatter = getFormatterForMarket(market) // Fetched from API or config

  return <span>{formatter.formatAmount(amount, currency)}</span>
}
```

**Zero market-specific logic in `packages/ui`** — all formatting logic injected from country plugin.

---

## Consequences

### Positive

1. **True internationalization:** Diaspora companies (e.g., Serbian company in Germany) can use `de` locale with `RS` market
2. **User choice:** Same org can have users in different locales (accountant in English, CEO in Serbian)
3. **Marketing flexibility:** Landing pages can be localized without locking market selection
4. **Clean abstractions:** UI components never contain market conditionals (`if market == RS`)

### Negative

1. **Complexity for users:** Market vs locale distinction may confuse non-technical users ("Why do I choose country twice?")
2. **Translation burden:** 5 locales × 287 UI strings (Task 3.4) = 1,435 translation units
3. **Formatter duplication:** Each market needs formatters for each locale (e.g., `RS + en` vs `RS + sr-Latn`)

### Risks

1. **Locale confusion:** User selects `hr` locale on `RS` market → sees Croatian UI for Serbian taxes. **Mitigation:** Locale picker shows recommended locale per market.
2. **Formatter bugs:** Incorrect decimal separator (`,` vs `.`) can cause accounting errors. **Mitigation:** Unit tests for all formatter combinations.

---

## Implementation Notes

### Market Badge (Visual Cue)

Gold accent badge in top-bar:

```tsx
// packages/ui/components/atoms/MarketBadge.tsx
export function MarketBadge({ market }: { market: TaxJurisdiction }) {
  const labels = {
    RS: 'Srbija',
    HR: 'Hrvatska',
    BA_FED: 'BiH Federacija',
    BA_RS: 'BiH Republika Srpska',
  }

  return <div className="bg-gold/10 text-gold px-3 py-1 rounded-full text-sm">{labels[market]}</div>
}
```

**Design constraint:** Badge is **gold accent** (`#F2C87A`), not market-specific color. Branding remains singular (plum `#8B6BBF`).

### i18n Coverage Audit (Task 3.4)

**Current state (2026-04-10 audit):**

- 287 hardcoded strings in `apps/web/` components
- 0 strings in `messages/{locale}.json`

**Target state (Phase 3 completion):**

```bash
npm run i18n:audit
# Expect: 0 hardcoded strings
```

**Tooling:** ESLint rule `no-hardcoded-strings` (enforced in CI after migration).

---

## References

- **Next.js i18n:** https://next-intl-docs.vercel.app/
- **ISO 639-1 locale codes:** https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes
- **Master plan:** `~/system/specs/bilko-multi-market-architecture-plan.md` (Phase 3 Tasks 3.1–3.4)
- **Related ADRs:**
  - ADR-015: Four-Jurisdiction Plugin Architecture (defines TaxJurisdiction enum)
  - ADR-017: RLS Multi-Tenancy (country_code column, separate concern from locale)

---

## Approval

**Approved:** 2026-04-21 by CEO Alem Basic
**Execution:** Phase 3 Tasks 3.1–3.4 (not yet started — blocked on Phase 1 completion)