Auto-refresh FX rates daily from fawazahmed0/exchange-api
Deploy to LXC / deploy (push) Successful in 1m56s
Validate / validate (push) Successful in 33s

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:
2026-04-17 16:36:17 +07:00
parent 34b1524d3a
commit 84c8beca15
2 changed files with 134 additions and 4 deletions
+112
View File
@@ -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 };
}
+22 -4
View File
@@ -1,16 +1,20 @@
import { postBillsDue } from './poster.js'; import { postBillsDue } from './poster.js';
import { updateAllFxRates } from '$lib/server/fx/index.js';
const INTERVAL_MS = 15 * 60 * 1000; 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 // 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 { export function startScheduler(): void {
const g = globalThis as GlobalWithGuard; const g = globalThis as GlobalAny;
if (g[GUARD_KEY]) return; if (g[GUARD_KEY]) return;
g[GUARD_KEY] = setInterval(async () => { g[GUARD_KEY] = setInterval(async () => {
// ── Recurring bills (every 15min) ──
try { try {
const result = await postBillsDue(); const result = await postBillsDue();
if (result.postedCount > 0 || result.errors.length > 0 || result.skippedCount > 0) { if (result.postedCount > 0 || result.errors.length > 0 || result.skippedCount > 0) {
@@ -19,7 +23,21 @@ export function startScheduler(): void {
} catch (err) { } catch (err) {
console.error('[scheduler] recurring bills tick error:', 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); }, INTERVAL_MS);
console.log('[scheduler] recurring bills started (interval: 15min)'); console.log('[scheduler] started (bills: 15min, FX: daily)');
} }