Commit 27d86b4d authored by ThinhNC's avatar ThinhNC

feat(calculator): integrate real-time market exchange rates with live badge and offline fallback

parent c4c60432
This diff is collapsed.
......@@ -2210,6 +2210,12 @@
"thb": "Thai Baht",
"sgd": "Singapore Dollar",
"aud": "Australian Dollar",
"cad": "Canadian Dollar"
"cad": "Canadian Dollar",
"updatingRate": "Updating market rate...",
"liveBadge": "Live Market",
"estimated": "Baseline",
"refreshRate": "Refresh market rate",
"sameCurrencyNote": "Exchange rate for identical currencies"
}
}
......@@ -2311,6 +2311,12 @@
"thb": "Baht Thái",
"sgd": "Đô la Singapore",
"aud": "Đô la Úc",
"cad": "Đô la Canada"
"cad": "Đô la Canada",
"updatingRate": "Đang cập nhật tỷ giá...",
"liveBadge": "Thị trường",
"estimated": "Cơ sở",
"refreshRate": "Cập nhật tỷ giá thị trường",
"sameCurrencyNote": "Tỷ giá giữa cùng một loại tiền tệ"
}
}
......@@ -10,6 +10,8 @@ import {
CategorizeTransactionData,
ExtractReceiptInput,
ExtractReceiptData,
AIExchangeRateInput,
AIExchangeRateData,
AIServiceResponse,
} from "@/types/ai";
......@@ -142,4 +144,20 @@ export const aiAssistantService = {
return handleAIError(error);
}
},
async getExchangeRate(
input: AIExchangeRateInput
): Promise<AIServiceResponse<AIExchangeRateData>> {
try {
const response = await apiClient.post<AIServiceResponse<AIExchangeRateData>>(
"/ai-assistant/exchange-rate",
input,
{ timeout: 30000 }
);
return response.data;
} catch (error) {
return handleAIError(error);
}
},
};
......@@ -26,16 +26,16 @@ export const POPULAR_CURRENCIES: CurrencyItem[] = [
*/
export const DEFAULT_VND_RATES: ExchangeRateMap = {
VND: 1,
USD: 25450,
EUR: 27600,
JPY: 165,
KRW: 19,
CNY: 3550,
GBP: 32800,
THB: 740,
SGD: 19400,
AUD: 16800,
CAD: 18500,
USD: 25922.52,
EUR: 30067.05,
JPY: 168.3,
KRW: 19.3,
CNY: 3864.5,
GBP: 35022.8,
THB: 784.2,
SGD: 20448.9,
AUD: 18591.4,
CAD: 18710.2,
};
const CACHE_KEY = "finwise.currency_rates";
......@@ -132,7 +132,39 @@ export async function fetchLatestRates(): Promise<ExchangeRateMap> {
}
}
// Try fetching USD-based rates from free open API
// 1. Try FloatRates (interbank feed)
try {
const response = await fetch("https://www.floatrates.com/daily/usd.json");
if (response.ok) {
const data = await response.json();
if (data && data.vnd?.rate) {
const usdVnd = parseFloat(String(data.vnd.rate));
if (!isNaN(usdVnd) && usdVnd > 0) {
const newVndRates: ExchangeRateMap = { VND: 1, USD: usdVnd };
for (const [codeLower, info] of Object.entries(data)) {
const code = codeLower.toUpperCase();
const item = info as { rate?: string | number; inverseRate?: string | number };
if (item?.rate) {
const foreignRateAgainstUsd = parseFloat(String(item.rate));
if (!isNaN(foreignRateAgainstUsd) && foreignRateAgainstUsd > 0) {
newVndRates[code] = Math.round((usdVnd / foreignRateAgainstUsd) * 100) / 100;
}
}
}
const merged = { ...DEFAULT_VND_RATES, ...newVndRates };
try {
localStorage.setItem(CACHE_KEY, JSON.stringify(merged));
localStorage.setItem(CACHE_TIME_KEY, String(now));
} catch {}
return merged;
}
}
}
} catch {
// Continue to open.er-api fallback
}
// 2. Try fetching USD-based rates from open exchange API
const response = await fetch("https://open.er-api.com/v6/latest/USD");
if (!response.ok) {
return getCachedRates();
......@@ -148,7 +180,6 @@ export async function fetchLatestRates(): Promise<ExchangeRateMap> {
for (const [code, rateAgainstUsd] of Object.entries(usdRates)) {
const rateNum = Number(rateAgainstUsd);
if (rateNum > 0) {
// 1 USD = rateNum [CODE] => 1 [CODE] = (usdVnd / rateNum) VND
newVndRates[code] = Math.round((usdVnd / rateNum) * 100) / 100;
}
}
......@@ -157,9 +188,7 @@ export async function fetchLatestRates(): Promise<ExchangeRateMap> {
try {
localStorage.setItem(CACHE_KEY, JSON.stringify(merged));
localStorage.setItem(CACHE_TIME_KEY, String(now));
} catch {
// Storage might be full
}
} catch {}
return merged;
}
} catch {
......@@ -168,3 +197,78 @@ export async function fetchLatestRates(): Promise<ExchangeRateMap> {
return getCachedRates();
}
/**
* Fetch real-time live exchange rate for a specific pair from interbank feed (FloatRates / Open Exchange Rates)
* with robust offline fallback to cached rates. No AI tokens consumed.
*/
export async function fetchLiveExchangeRate(
from: string,
to: string
): Promise<{ rate: number; isLive: boolean; note?: string }> {
const fromCode = from.toUpperCase().trim();
const toCode = to.toUpperCase().trim();
if (fromCode === toCode) {
return { rate: 1, isLive: true, note: "Tỷ giá giữa cùng một loại tiền tệ" };
}
// 1. Try FloatRates directly for real-time interbank market rate
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3500);
const res = await fetch(`https://www.floatrates.com/daily/${fromCode.toLowerCase()}.json`, {
signal: controller.signal,
});
clearTimeout(timeout);
if (res.ok) {
const data = await res.json();
const target = data[toCode.toLowerCase()];
if (target?.rate) {
const rate = parseFloat(String(target.rate));
if (!isNaN(rate) && rate > 0) {
return {
rate,
isLive: true,
note: "Tỷ giá thị trường liên ngân hàng (FloatRates)",
};
}
}
}
} catch {
// Ignore network error and continue
}
// 2. Try Open Exchange Rates API
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3500);
const res = await fetch(`https://open.er-api.com/v6/latest/${fromCode}`, {
signal: controller.signal,
});
clearTimeout(timeout);
if (res.ok) {
const data = await res.json();
const targetRate = data.rates?.[toCode];
if (typeof targetRate === "number" && targetRate > 0) {
return {
rate: targetRate,
isLive: true,
note: "Tỷ giá thị trường mở (Open Exchange Rates)",
};
}
}
} catch {
// Ignore network error
}
// 3. Fallback to cached or DEFAULT_VND_RATES
const cachedRates = getCachedRates();
const fallbackRate = getExchangeRate(fromCode, toCode, cachedRates);
return {
rate: fallbackRate,
isLive: false,
note: "Tỷ giá thị trường cơ sở (ngoại tuyến)",
};
}
......@@ -160,3 +160,19 @@ export interface AIRateLimitError {
code: string;
retryAfterSeconds: number;
}
export interface AIExchangeRateInput {
from: string;
to: string;
amount?: number;
}
export interface AIExchangeRateData {
from: string;
to: string;
rate: number;
amount: number;
convertedAmount: number;
formattedRate?: string;
note?: string;
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment