feat(prefs): display.currency preference with live exchange rate conversion

- Backend: validate display.currency against 10 supported ISO 4217 codes
  (USD, GBP, EUR, CAD, AUD, JPY, CHF, MXN, BRL, INR); return 400 on
  unsupported code with a clear message listing accepted values
- Frontend: useCurrency composable fetches rates from open.er-api.com
  with 1-hour module-level cache and in-flight deduplication; falls back
  to USD display on network failure
- Preferences store: adds display.currency with localStorage fallback for
  anonymous users and localStorage-to-DB migration for newly logged-in users
- ListingCard: price and market price now convert from USD using live rates,
  showing USD synchronously while rates load then updating reactively
- Settings UI: currency selector dropdown in Appearance section using
  theme-aware CSS classes; available to all users (anon via localStorage,
  logged-in via DB preference)
- Tests: 6 Python tests for the PATCH /api/preferences currency endpoint
  (including ordering-safe fixture using patch.object on _LOCAL_SNIPE_DB);
  14 Vitest tests for convertFromUSD, formatPrice, and formatPriceUSD
This commit is contained in:
pyr0ball 2026-04-20 11:02:59 -07:00
parent d5912080fb
commit dca3c3f50b
6 changed files with 435 additions and 11 deletions

View file

@ -0,0 +1,76 @@
"""Tests for PATCH /api/preferences display.currency validation."""
from __future__ import annotations
import os
from pathlib import Path
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
@pytest.fixture
def client(tmp_path):
"""TestClient with a patched local DB path.
api.cloud_session._LOCAL_SNIPE_DB is set at module import time, so we
cannot rely on setting SNIPE_DB before import when other tests have already
triggered the module load. Patch the module-level variable directly so
the session dependency points at our fresh tmp DB for the duration of this
fixture.
"""
db_path = tmp_path / "snipe.db"
# Ensure the DB is initialised so the Store can create its tables.
import api.cloud_session as _cs
from circuitforge_core.db import get_connection, run_migrations
conn = get_connection(db_path)
run_migrations(conn, Path("app/db/migrations"))
conn.close()
from api.main import app
with patch.object(_cs, "_LOCAL_SNIPE_DB", db_path):
yield TestClient(app, raise_server_exceptions=False)
def test_set_display_currency_valid(client):
"""Accepted ISO 4217 codes are stored and returned."""
for code in ("USD", "GBP", "EUR", "CAD", "AUD", "JPY", "CHF", "MXN", "BRL", "INR"):
resp = client.patch("/api/preferences", json={"path": "display.currency", "value": code})
assert resp.status_code == 200, f"Expected 200 for {code}, got {resp.status_code}: {resp.text}"
data = resp.json()
assert data.get("display", {}).get("currency") == code
def test_set_display_currency_normalises_lowercase(client):
"""Lowercase code is accepted and normalised to uppercase."""
resp = client.patch("/api/preferences", json={"path": "display.currency", "value": "eur"})
assert resp.status_code == 200
assert resp.json()["display"]["currency"] == "EUR"
def test_set_display_currency_unsupported_returns_400(client):
"""Unsupported currency code returns 400 with a clear message."""
resp = client.patch("/api/preferences", json={"path": "display.currency", "value": "XYZ"})
assert resp.status_code == 400
detail = resp.json().get("detail", "")
assert "XYZ" in detail
assert "Supported" in detail or "supported" in detail
def test_set_display_currency_empty_string_returns_400(client):
"""Empty string is not a valid currency code."""
resp = client.patch("/api/preferences", json={"path": "display.currency", "value": ""})
assert resp.status_code == 400
def test_set_display_currency_none_returns_400(client):
"""None is not a valid currency code."""
resp = client.patch("/api/preferences", json={"path": "display.currency", "value": None})
assert resp.status_code == 400
def test_other_preference_paths_unaffected(client):
"""Unrelated preference paths still work normally after currency validation added."""
resp = client.patch("/api/preferences", json={"path": "affiliate.opt_out", "value": True})
assert resp.status_code == 200
assert resp.json().get("affiliate", {}).get("opt_out") is True

View file

@ -0,0 +1,140 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Reset module-level cache and fetch mock between tests
beforeEach(async () => {
vi.restoreAllMocks()
// Reset module-level cache so each test starts clean
const mod = await import('../composables/useCurrency')
mod._resetCacheForTest()
})
const MOCK_RATES: Record<string, number> = {
USD: 1,
GBP: 0.79,
EUR: 0.92,
JPY: 151.5,
CAD: 1.36,
}
function mockFetchSuccess(rates = MOCK_RATES) {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ rates }),
}))
}
function mockFetchFailure() {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')))
}
describe('convertFromUSD', () => {
it('returns the same amount for USD (no conversion)', async () => {
mockFetchSuccess()
const { convertFromUSD } = await import('../composables/useCurrency')
const result = await convertFromUSD(100, 'USD')
expect(result).toBe(100)
// fetch should not be called for USD passthrough
expect(fetch).not.toHaveBeenCalled()
})
it('converts USD to GBP using fetched rates', async () => {
mockFetchSuccess()
const { convertFromUSD, _resetCacheForTest } = await import('../composables/useCurrency')
_resetCacheForTest()
const result = await convertFromUSD(100, 'GBP')
expect(result).toBeCloseTo(79, 1)
})
it('converts USD to JPY using fetched rates', async () => {
mockFetchSuccess()
const { convertFromUSD, _resetCacheForTest } = await import('../composables/useCurrency')
_resetCacheForTest()
const result = await convertFromUSD(10, 'JPY')
expect(result).toBeCloseTo(1515, 1)
})
it('returns the original amount when rates are unavailable (network failure)', async () => {
mockFetchFailure()
const { convertFromUSD, _resetCacheForTest } = await import('../composables/useCurrency')
_resetCacheForTest()
const result = await convertFromUSD(100, 'EUR')
expect(result).toBe(100)
})
it('returns the original amount when the currency code is unknown', async () => {
mockFetchSuccess({ USD: 1, EUR: 0.92 }) // no XYZ rate
const { convertFromUSD, _resetCacheForTest } = await import('../composables/useCurrency')
_resetCacheForTest()
const result = await convertFromUSD(50, 'XYZ')
expect(result).toBe(50)
})
it('only calls fetch once when called concurrently (deduplication)', async () => {
mockFetchSuccess()
const { convertFromUSD, _resetCacheForTest } = await import('../composables/useCurrency')
_resetCacheForTest()
await Promise.all([
convertFromUSD(100, 'GBP'),
convertFromUSD(200, 'EUR'),
convertFromUSD(50, 'CAD'),
])
expect((fetch as ReturnType<typeof vi.fn>).mock.calls.length).toBe(1)
})
})
describe('formatPrice', () => {
it('formats USD amount with dollar sign', async () => {
mockFetchSuccess()
const { formatPrice, _resetCacheForTest } = await import('../composables/useCurrency')
_resetCacheForTest()
const result = await formatPrice(99.99, 'USD')
expect(result).toMatch(/^\$99\.99$|^\$100$/) // Intl rounding may vary
expect(result).toContain('$')
})
it('formats GBP amount with correct symbol', async () => {
mockFetchSuccess()
const { formatPrice, _resetCacheForTest } = await import('../composables/useCurrency')
_resetCacheForTest()
const result = await formatPrice(100, 'GBP')
// GBP 79 — expect pound sign or "GBP" prefix
expect(result).toMatch(/[£]|GBP/)
})
it('formats JPY without decimal places (Intl rounds to zero decimals)', async () => {
mockFetchSuccess()
const { formatPrice, _resetCacheForTest } = await import('../composables/useCurrency')
_resetCacheForTest()
const result = await formatPrice(10, 'JPY')
// 10 * 151.5 = 1515 JPY — no decimal places for JPY
expect(result).toMatch(/¥1,515|JPY.*1,515|¥1515/)
})
it('falls back gracefully on network failure, showing USD', async () => {
mockFetchFailure()
const { formatPrice, _resetCacheForTest } = await import('../composables/useCurrency')
_resetCacheForTest()
// With failed rates, conversion returns original amount and uses Intl with target currency
// This may throw if Intl doesn't know EUR — but the function should not throw
const result = await formatPrice(50, 'EUR')
expect(typeof result).toBe('string')
expect(result.length).toBeGreaterThan(0)
})
})
describe('formatPriceUSD', () => {
it('formats a USD amount synchronously', async () => {
const { formatPriceUSD } = await import('../composables/useCurrency')
const result = formatPriceUSD(1234.5)
// Intl output varies by runtime locale data; check structure not exact string
expect(result).toContain('$')
expect(result).toContain('1,234')
})
it('formats zero as a USD string', async () => {
const { formatPriceUSD } = await import('../composables/useCurrency')
const result = formatPriceUSD(0)
expect(result).toContain('$')
expect(result).toMatch(/\$0/)
})
})

View file

@ -189,15 +189,18 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { RouterLink } from 'vue-router'
import type { Listing, TrustScore, Seller } from '../stores/search'
import { useSearchStore } from '../stores/search'
import { useBlocklistStore } from '../stores/blocklist'
import TrustFeedbackButtons from './TrustFeedbackButtons.vue'
import { useTrustSignalPref } from '../composables/useTrustSignalPref'
import { formatPrice, formatPriceUSD } from '../composables/useCurrency'
import { usePreferencesStore } from '../stores/preferences'
const { enabled: trustSignalEnabled } = useTrustSignalPref()
const prefsStore = usePreferencesStore()
const props = defineProps<{
listing: Listing
@ -379,15 +382,26 @@ const isSteal = computed(() => {
return props.listing.price < props.marketPrice * 0.8
})
const formattedPrice = computed(() => {
const sym = props.listing.currency === 'USD' ? '$' : props.listing.currency + ' '
return `${sym}${props.listing.price.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`
})
// Async price display show USD synchronously while rates load, then update
const formattedPrice = ref(formatPriceUSD(props.listing.price))
const formattedMarket = ref(props.marketPrice ? formatPriceUSD(props.marketPrice) : '')
const formattedMarket = computed(() => {
if (!props.marketPrice) return ''
return `$${props.marketPrice.toLocaleString('en-US', { maximumFractionDigits: 0 })}`
})
async function _updatePrices() {
const currency = prefsStore.displayCurrency
formattedPrice.value = await formatPrice(props.listing.price, currency)
if (props.marketPrice) {
formattedMarket.value = await formatPrice(props.marketPrice, currency)
} else {
formattedMarket.value = ''
}
}
// Update when the listing, marketPrice, or display currency changes
watch(
[() => props.listing.price, () => props.marketPrice, () => prefsStore.displayCurrency],
() => { _updatePrices() },
{ immediate: true },
)
</script>
<style scoped>

View file

@ -0,0 +1,102 @@
/**
* useCurrency live exchange rate conversion from USD to a target display currency.
*
* Rates are fetched lazily on first use from open.er-api.com (free, no key required).
* A module-level cache with a 1-hour TTL prevents redundant network calls.
* On fetch failure the composable falls back silently to USD display.
*/
const ER_API_URL = 'https://open.er-api.com/v6/latest/USD'
const CACHE_TTL_MS = 60 * 60 * 1000 // 1 hour
interface RateCache {
rates: Record<string, number>
fetchedAt: number
}
// Module-level cache shared across all composable instances
let _cache: RateCache | null = null
let _inflight: Promise<Record<string, number>> | null = null
async function _fetchRates(): Promise<Record<string, number>> {
const now = Date.now()
if (_cache && now - _cache.fetchedAt < CACHE_TTL_MS) {
return _cache.rates
}
// Deduplicate concurrent calls — reuse the same in-flight fetch
if (_inflight) {
return _inflight
}
_inflight = (async () => {
try {
const res = await fetch(ER_API_URL)
if (!res.ok) throw new Error(`ER-API responded ${res.status}`)
const data = await res.json()
const rates: Record<string, number> = data.rates ?? {}
_cache = { rates, fetchedAt: Date.now() }
return rates
} catch {
// Return cached stale data if available, otherwise empty object (USD passthrough)
return _cache?.rates ?? {}
} finally {
_inflight = null
}
})()
return _inflight
}
/**
* Convert an amount in USD to the target currency using the latest exchange rates.
* Returns the original amount unchanged if rates are unavailable or the currency is USD.
*/
export async function convertFromUSD(amountUSD: number, targetCurrency: string): Promise<number> {
if (targetCurrency === 'USD') return amountUSD
const rates = await _fetchRates()
const rate = rates[targetCurrency]
if (!rate) return amountUSD
return amountUSD * rate
}
/**
* Format a USD amount as a localized string in the target currency.
* Fetches exchange rates lazily. Falls back to USD display if rates are unavailable.
*
* Returns a plain USD string synchronously on first call while rates load;
* callers should use a ref that updates once the promise resolves.
*/
export async function formatPrice(amountUSD: number, currency: string): Promise<string> {
const converted = await convertFromUSD(amountUSD, currency)
try {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
minimumFractionDigits: 0,
maximumFractionDigits: 2,
}).format(converted)
} catch {
// Fallback if Intl doesn't know the currency code
return `${currency} ${converted.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`
}
}
/**
* Synchronous USD-only formatter for use before rates have loaded.
*/
export function formatPriceUSD(amountUSD: number): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 2,
}).format(amountUSD)
}
// Exported for testing — allows resetting module-level cache between test cases
export function _resetCacheForTest(): void {
_cache = null
_inflight = null
}

View file

@ -12,8 +12,14 @@ export interface UserPreferences {
community?: {
blocklist_share?: boolean
}
display?: {
currency?: string
}
}
const CURRENCY_LS_KEY = 'snipe:currency'
const DEFAULT_CURRENCY = 'USD'
const apiBase = (import.meta.env.VITE_API_BASE as string) ?? ''
export const usePreferencesStore = defineStore('preferences', () => {
@ -26,14 +32,34 @@ export const usePreferencesStore = defineStore('preferences', () => {
const affiliateByokId = computed(() => prefs.value.affiliate?.byok_ids?.ebay ?? '')
const communityBlocklistShare = computed(() => prefs.value.community?.blocklist_share ?? false)
// displayCurrency: DB preference for logged-in users, localStorage for anon users
const displayCurrency = computed((): string => {
return prefs.value.display?.currency ?? DEFAULT_CURRENCY
})
async function load() {
if (!session.isLoggedIn) return
if (!session.isLoggedIn) {
// Anonymous user: read currency from localStorage
const stored = localStorage.getItem(CURRENCY_LS_KEY)
if (stored) {
prefs.value = { ...prefs.value, display: { ...prefs.value.display, currency: stored } }
}
return
}
loading.value = true
error.value = null
try {
const res = await fetch(`${apiBase}/api/preferences`)
if (res.ok) {
prefs.value = await res.json()
const data: UserPreferences = await res.json()
// Migration: if logged in but no DB preference, fall back to localStorage value
if (!data.display?.currency) {
const lsVal = localStorage.getItem(CURRENCY_LS_KEY)
if (lsVal) {
data.display = { ...data.display, currency: lsVal }
}
}
prefs.value = data
}
} catch {
// Non-cloud deploy or network error — preferences unavailable
@ -75,6 +101,18 @@ export const usePreferencesStore = defineStore('preferences', () => {
await setPref('community.blocklist_share', value)
}
async function setDisplayCurrency(code: string) {
const upper = code.toUpperCase()
// Optimistic local update so the UI reacts immediately
prefs.value = { ...prefs.value, display: { ...prefs.value.display, currency: upper } }
if (session.isLoggedIn) {
await setPref('display.currency', upper)
} else {
// Anonymous user: persist to localStorage only
localStorage.setItem(CURRENCY_LS_KEY, upper)
}
}
return {
prefs,
loading,
@ -82,9 +120,11 @@ export const usePreferencesStore = defineStore('preferences', () => {
affiliateOptOut,
affiliateByokId,
communityBlocklistShare,
displayCurrency,
load,
setAffiliateOptOut,
setAffiliateByokId,
setCommunityBlocklistShare,
setDisplayCurrency,
}
})

View file

@ -69,6 +69,28 @@
>{{ opt.label }}</button>
</div>
</div>
<!-- Display currency -->
<div class="settings-toggle">
<div class="settings-toggle-text">
<span class="settings-toggle-label">Display currency</span>
<span class="settings-toggle-desc">
Listing prices are converted from USD using live exchange rates.
Rates update hourly.
</span>
</div>
<select
id="display-currency"
class="settings-select"
:value="prefs.displayCurrency"
aria-label="Select display currency"
@change="prefs.setDisplayCurrency(($event.target as HTMLSelectElement).value)"
>
<option v-for="opt in currencyOptions" :key="opt.code" :value="opt.code">
{{ opt.code }} {{ opt.label }}
</option>
</select>
</div>
</section>
<!-- Affiliate Links only shown to signed-in cloud users -->
@ -166,6 +188,18 @@ const themeOptions: { value: 'system' | 'dark' | 'light'; label: string }[] = [
{ value: 'dark', label: 'Dark' },
{ value: 'light', label: 'Light' },
]
const currencyOptions: { code: string; label: string }[] = [
{ code: 'USD', label: 'US Dollar' },
{ code: 'EUR', label: 'Euro' },
{ code: 'GBP', label: 'British Pound' },
{ code: 'CAD', label: 'Canadian Dollar' },
{ code: 'AUD', label: 'Australian Dollar' },
{ code: 'JPY', label: 'Japanese Yen' },
{ code: 'CHF', label: 'Swiss Franc' },
{ code: 'MXN', label: 'Mexican Peso' },
{ code: 'BRL', label: 'Brazilian Real' },
{ code: 'INR', label: 'Indian Rupee' },
]
const session = useSessionStore()
const prefs = usePreferencesStore()
const { autoRun: llmAutoRun, setAutoRun: setLLMAutoRun } = useLLMQueryBuilder()
@ -346,6 +380,24 @@ function saveByokId() {
margin: 0;
}
.settings-select {
padding: var(--space-2) var(--space-3);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text);
font-size: 0.875rem;
font-family: inherit;
cursor: pointer;
outline: none;
flex-shrink: 0;
transition: border-color 0.15s ease;
}
.settings-select:focus {
border-color: var(--app-primary);
}
.theme-btn-group {
display: flex;
gap: 0;