Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 15 additions & 51 deletions src/lib/exchangeRate.js
Original file line number Diff line number Diff line change
@@ -1,69 +1,33 @@
/**
* Exchange rate utilities — calls /api/exchange-rate (Netlify Function proxy).
*
* The proxy calls api.frankfurter.dev server-side, avoiding browser CORS
* restrictions. Rate convention: 1 unit of `from` = X units of `to`.
*
* ECB publish window:
* Rates update daily around 16:00 CET (22:00 SGT). During this window
* /latest can be unstable. If the call fails without an explicit date,
* we retry once with yesterday's date as a fallback.
* Exchange rate utilities — fetches from frankfurter.dev.
* Always fetches by explicit date so rate reflects when money was spent.
*/

const BASE_URL = 'https://api.frankfurter.dev/v1'

/**
* Fetch exchange rate.
* @param {string} from - Source currency (ISO 4217, e.g. 'MYR')
* @param {string} to - Target currency (ISO 4217, e.g. 'SGD')
* @param {string} [date] - ISO date string (YYYY-MM-DD) for historical rate
* @returns {Promise<{ from, to, rate, date, fetchedAt, usedFallbackDate? }>}
* @param {string} from - Source currency (e.g. 'MYR')
* @param {string} to - Target currency (e.g. 'SGD')
* @param {string} date - ISO date YYYY-MM-DD (the expense date)
* @returns {Promise<{ from, to, rate, date, fetchedAt }>}
*/
export async function fetchExchangeRate(from, to, date) {
if (from === to) {
return {
from,
to,
rate: 1,
date: date || new Date().toISOString().slice(0, 10),
fetchedAt: new Date().toISOString(),
}
return { from, to, rate: 1, date, fetchedAt: new Date().toISOString() }
}

// First attempt
try {
return await _fetchOnce(from, to, date)
} catch (err) {
// Only retry /latest — historical dates don't have publish window issues
if (date) throw err

const yesterday = new Date()
yesterday.setUTCDate(yesterday.getUTCDate() - 1)
const fallbackDate = yesterday.toISOString().slice(0, 10)

const result = await _fetchOnce(from, to, fallbackDate)
return { ...result, usedFallbackDate: true }
}
}

async function _fetchOnce(from, to, date) {
const params = new URLSearchParams({ from, to })
if (date) params.set('date', date)

const res = await fetch(`/api/exchange-rate?${params}`)
const res = await fetch(`${BASE_URL}/${date}?from=${from}&to=${to}`)

Comment on lines 14 to 20
if (!res.ok) {
const body = await res.json().catch(() => ({}))
const err = new Error(body.error || `Exchange rate fetch failed: ${res.status}`)
const err = new Error(`Exchange rate fetch failed: ${res.status}`)
err.status = res.status
Comment on lines +19 to 23
throw err
}

const data = await res.json()
const rate = data.rates?.[to]

return {
from: data.from,
to: data.to,
rate: data.rate,
date: data.date,
fetchedAt: new Date().toISOString(),
}
if (rate == null) throw new Error(`No rate found for ${from} → ${to}`)

return { from, to, rate, date: data.date, fetchedAt: new Date().toISOString() }
}
12 changes: 6 additions & 6 deletions src/pages/AddExpense.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,33 +65,33 @@ export default function AddExpense() {
// Fetch exchange rate when currency or date changes
useEffect(() => {
const groupCurrency = group?.currency || 'SGD'
if (!expenseCurrency || expenseCurrency === groupCurrency || manualRate) return
if (!expenseCurrency || expenseCurrency === groupCurrency || manualRate) {
setRateLoading(false)
return
}

let cancelled = false
async function fetchRate() {
setRateLoading(true)
try {
const isToday = expenseDate === new Date().toISOString().slice(0, 10)
const result = await fetchExchangeRate(
expenseCurrency,
groupCurrency,
isToday ? undefined : expenseDate
expenseDate,
)
if (!cancelled) setExchangeRate(result)
} catch (err) {
const isHistorical = expenseDate !== new Date().toISOString().slice(0, 10)
await log('fx.error', {
from: expenseCurrency,
to: group?.currency || 'SGD',
date: expenseDate,
is_historical: isHistorical,
http_status: err.status ?? null,
error: err.message,
})
console.error('Exchange rate fetch failed:', err)
if (!cancelled) toast.error('Could not fetch exchange rate')
} finally {
if (!cancelled) setRateLoading(false)
setRateLoading(false)
}
}
fetchRate()
Expand Down