diff --git a/src/lib/server/fx/index.ts b/src/lib/server/fx/index.ts new file mode 100644 index 0000000..9cd96ff --- /dev/null +++ b/src/lib/server/fx/index.ts @@ -0,0 +1,112 @@ +import { db } from '$lib/server/db/index.js'; +import { companies, companyAccounts } from '$lib/server/db/schema.js'; +import { and, eq, isNull, ne } from 'drizzle-orm'; + +const CDN_URL = 'https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies'; +const FALLBACK_URL = 'https://latest.currency-api.pages.dev/v1/currencies'; + +async function fetchJson(url: string): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 10_000); + try { + const res = await fetch(url, { signal: controller.signal }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return await res.json(); + } finally { + clearTimeout(timer); + } +} + +export async function fetchRate( + fromCurrency: string, + toCurrency: string +): Promise { + const from = fromCurrency.toLowerCase(); + const to = toCurrency.toLowerCase(); + if (from === to) return 1; + + for (const base of [CDN_URL, FALLBACK_URL]) { + try { + const data = (await fetchJson(`${base}/${from}.min.json`)) as Record; + const rates = data[from] as Record | undefined; + if (rates && typeof rates[to] === 'number') { + return rates[to]; + } + } catch { + continue; + } + } + return null; +} + +export interface FxUpdateResult { + updated: number; + errors: string[]; +} + +export async function updateAllFxRates(): Promise { + let updated = 0; + const errors: string[] = []; + + // Get all companies with their base currency + const companyList = await db + .select({ id: companies.id, currency: companies.currency }) + .from(companies) + .where(isNull(companies.deletedAt)); + + for (const company of companyList) { + const baseCurrency = company.currency; + + // Find accounts in foreign currencies + const foreignAccounts = await db + .select({ + id: companyAccounts.id, + currency: companyAccounts.currency + }) + .from(companyAccounts) + .where( + and( + eq(companyAccounts.companyId, company.id), + isNull(companyAccounts.deletedAt), + ne(companyAccounts.currency, baseCurrency) + ) + ); + + if (foreignAccounts.length === 0) continue; + + // Group by currency to minimize API calls + const byCurrency = new Map(); + for (const acct of foreignAccounts) { + const ids = byCurrency.get(acct.currency) ?? []; + ids.push(acct.id); + byCurrency.set(acct.currency, ids); + } + + for (const [foreignCurrency, accountIds] of byCurrency) { + try { + const rate = await fetchRate(foreignCurrency, baseCurrency); + if (rate === null) { + errors.push(`No rate for ${foreignCurrency}→${baseCurrency}`); + continue; + } + + for (const id of accountIds) { + await db + .update(companyAccounts) + .set({ + fxRateToBase: rate.toFixed(8), + updatedAt: new Date() + }) + .where(eq(companyAccounts.id, id)); + updated++; + } + } catch (err) { + errors.push( + `${foreignCurrency}→${baseCurrency}: ${err instanceof Error ? err.message : String(err)}` + ); + } + } + } + + return { updated, errors }; +} diff --git a/src/lib/server/recurring-bills/scheduler.ts b/src/lib/server/recurring-bills/scheduler.ts index 25a1c7f..7ba6ad7 100644 --- a/src/lib/server/recurring-bills/scheduler.ts +++ b/src/lib/server/recurring-bills/scheduler.ts @@ -1,16 +1,20 @@ import { postBillsDue } from './poster.js'; +import { updateAllFxRates } from '$lib/server/fx/index.js'; const INTERVAL_MS = 15 * 60 * 1000; -const GUARD_KEY = '__b4lRecurringBillsScheduler'; +const FX_INTERVAL_MS = 24 * 60 * 60 * 1000; +const GUARD_KEY = '__b4lScheduler'; +const FX_LAST_KEY = '__b4lFxLastRefresh'; // eslint-disable-next-line @typescript-eslint/no-explicit-any -type GlobalWithGuard = typeof globalThis & { [GUARD_KEY]?: NodeJS.Timeout }; +type GlobalAny = typeof globalThis & Record; export function startScheduler(): void { - const g = globalThis as GlobalWithGuard; + const g = globalThis as GlobalAny; if (g[GUARD_KEY]) return; g[GUARD_KEY] = setInterval(async () => { + // ── Recurring bills (every 15min) ── try { const result = await postBillsDue(); if (result.postedCount > 0 || result.errors.length > 0 || result.skippedCount > 0) { @@ -19,7 +23,21 @@ export function startScheduler(): void { } catch (err) { console.error('[scheduler] recurring bills tick error:', err); } + + // ── FX rate refresh (daily) ── + const lastFx: number = g[FX_LAST_KEY] ?? 0; + if (Date.now() - lastFx > FX_INTERVAL_MS) { + try { + const result = await updateAllFxRates(); + g[FX_LAST_KEY] = Date.now(); + if (result.updated > 0 || result.errors.length > 0) { + console.log('[scheduler] FX rates refreshed:', result); + } + } catch (err) { + console.error('[scheduler] FX refresh error:', err); + } + } }, INTERVAL_MS); - console.log('[scheduler] recurring bills started (interval: 15min)'); + console.log('[scheduler] started (bills: 15min, FX: daily)'); }