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 { 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)');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user