'use strict'; // 원/달러 환율: 날짜별 캐시(data/fx.json). 자동 모드는 frankfurter(ECB 기준) → open.er-api 순으로 조회하고 스프레드를 더해 "구입 가격"을 근사한다. const fs = require('fs'); const path = require('path'); const https = require('https'); const settings = require('./settings'); const DATA_ROOT = process.env.DATA_ROOT || path.join(__dirname, '..', 'data'); const FILE = path.join(DATA_ROOT, 'fx.json'); function loadCache() { try { return JSON.parse(fs.readFileSync(FILE, 'utf8')); } catch (_) { return {}; } } function saveCache(c) { fs.mkdirSync(DATA_ROOT, { recursive: true }); fs.writeFileSync(FILE, JSON.stringify(c, null, 1)); } function getJson(url) { return new Promise((resolve, reject) => { const req = https.get(url, { timeout: 8000, headers: { 'User-Agent': 'WebHostingAgent/1.0' } }, (res) => { if ([301, 302, 307, 308].includes(res.statusCode) && res.headers.location) { res.resume(); return resolve(getJson(new URL(res.headers.location, url).toString())); } let d = ''; res.on('data', (c) => { d += c; }); res.on('end', () => { try { resolve(JSON.parse(d)); } catch (e) { reject(new Error('응답 파싱 실패')); } }); }); req.on('timeout', () => req.destroy(new Error('시간 초과'))); req.on('error', reject); }); } /** KST 기준 날짜 문자열 (YYYY-MM-DD) */ function kstDate(d = new Date()) { return new Date(new Date(d).getTime() + 9 * 3600 * 1000).toISOString().slice(0, 10); } async function fetchMid(date) { const today = kstDate(); try { const j = await getJson(`https://api.frankfurter.dev/v1/${date === today ? 'latest' : date}?base=USD&symbols=KRW`); if (j && j.rates && j.rates.KRW) return { mid: j.rates.KRW, source: `ECB(frankfurter) ${j.date}` }; } catch (_) {} if (date === today) { try { const j = await getJson('https://open.er-api.com/v6/latest/USD'); if (j && j.rates && j.rates.KRW) return { mid: j.rates.KRW, source: `open.er-api ${String(j.time_last_update_utc || '').slice(0, 16)}` }; } catch (_) {} } throw new Error('환율 조회 실패'); } /** 해당 날짜의 USD 구입가(원). 반환 {date, rate, mid, spreadPct, source} */ async function getRate(date) { const s = settings.load(); date = date || kstDate(); if (s.fx.mode === 'manual' && s.fx.manualRate > 0) return { date, rate: s.fx.manualRate, mid: s.fx.manualRate, spreadPct: 0, source: '관리자 입력' }; const cache = loadCache(); let entry = cache[date]; const today = kstDate(); if (!entry || (date === today && Date.now() - Date.parse(entry.fetchedAt || 0) > 6 * 3600 * 1000)) { try { const m = await fetchMid(date); entry = { mid: m.mid, source: m.source, fetchedAt: new Date().toISOString() }; cache[date] = entry; saveCache(cache); } catch (e) { if (!entry) { const prev = Object.keys(cache).filter((k) => k < date).sort().pop(); if (prev) entry = { ...cache[prev], source: `${cache[prev].source} (${prev} 값 대체)` }; else if (s.fx.manualRate > 0) entry = { mid: s.fx.manualRate, source: '관리자 입력(조회 실패 대체)', fetchedAt: null }; else throw e; } } } const rate = Math.round(entry.mid * (1 + (s.fx.spreadPct || 0) / 100) * 100) / 100; return { date, rate, mid: entry.mid, spreadPct: s.fx.spreadPct || 0, source: entry.source }; } /** * v1.4.0 다른 통화 1단위 = 몇 USD 인지 (도메인 가격은 EUR 등으로 온다). 하루 한 번 조회해 fx.json 에 'cross:<통화>:<날짜>' 로 둔다. * 조회에 실패하면 가장 최근 값을 쓰고, 그것도 없으면 오류(가격을 모르고 청구하지 않게). */ async function usdPer(currency, date) { const cur = String(currency || 'USD').toUpperCase(); if (cur === 'USD') return 1; if (!/^[A-Z]{3}$/.test(cur)) throw new Error('통화 코드가 올바르지 않습니다'); date = date || kstDate(); const key = `cross:${cur}:${date}`; const cache = loadCache(); if (cache[key]) return cache[key].v; let v = null; try { const j = await getJson(`https://api.frankfurter.dev/v1/latest?base=${cur}&symbols=USD`); if (j && j.rates && j.rates.USD) v = j.rates.USD; } catch (_) {} if (!v) { try { const j = await getJson(`https://open.er-api.com/v6/latest/${cur}`); if (j && j.rates && j.rates.USD) v = j.rates.USD; } catch (_) {} } if (!v) { const prev = Object.keys(cache).filter((k) => k.startsWith(`cross:${cur}:`)).sort().pop(); if (prev) return cache[prev].v; throw new Error(`${cur} 환율 조회 실패`); } cache[key] = { v, fetchedAt: new Date().toISOString() }; saveCache(cache); return v; } /** 금액(통화) → 원화 (USD 구입가 기준 — 스프레드 포함) */ async function toKrw(amount, currency, date) { const usd = Number(amount) * await usdPer(currency, date); const r = await getRate(date); return { krw: usd * r.rate, usd, rate: r.rate }; } module.exports = { getRate, kstDate, loadCache, usdPer, toKrw };