Auto-refresh FX rates daily from fawazahmed0/exchange-api
Scheduler checks every 15min; if 24h since last FX refresh, fetches rates for all foreign-currency accounts and updates fxRateToBase. Uses CDN primary + Cloudflare fallback with 10s timeout. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<unknown> {
|
||||
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<number | null> {
|
||||
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<string, unknown>;
|
||||
const rates = data[from] as Record<string, number> | 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<FxUpdateResult> {
|
||||
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<string, string[]>();
|
||||
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 };
|
||||
}
|
||||
@@ -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<string, any>;
|
||||
|
||||
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)');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user