new-site/api/src/fx.ts
justin f8cd37ac8c Initial commit — Performance West telecom compliance platform
Includes: API (Express/TypeScript), Astro site, Python workers,
document generators, FCC compliance tools, Canada CRTC formation,
Ansible infrastructure, and deployment scripts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 06:54:22 -05:00

118 lines
3.6 KiB
TypeScript

/**
* CAD → USD foreign exchange conversion.
*
* Source: Bank of Canada daily exchange rate (Valet API).
* Caches the rate for 24 hours.
*
* cadToUsdCents(cadCents):
* 1. Fetch current CAD/USD rate (e.g., 1 CAD = 0.73 USD)
* 2. Convert CAD cents to USD cents
* 3. Add 10% buffer to cover fluctuations
* 4. Round UP to the nearest whole dollar (100 cents)
*/
let cachedRate: { rate: number; fetchedAt: number } | null = null;
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
const BUFFER_PCT = 0.10; // 10% buffer on top of spot rate
const FALLBACK_RATE = 0.72; // conservative fallback if API is unreachable
/**
* Fetch the current CAD/USD exchange rate from the Bank of Canada.
* Returns how many USD 1 CAD buys (e.g., 0.73).
*/
async function fetchCadUsdRate(): Promise<number> {
// Bank of Canada Valet API — FXCADUSD is the official daily rate
const url = "https://www.bankofcanada.ca/valet/observations/FXCADUSD/json?recent=1";
try {
const resp = await fetch(url, {
signal: AbortSignal.timeout(8000),
headers: { "Accept": "application/json" },
});
if (!resp.ok) {
throw new Error(`Bank of Canada API returned ${resp.status}`);
}
const data = await resp.json() as {
observations: Array<{ d: string; FXCADUSD: { v: string } }>;
};
const obs = data.observations;
if (!obs || obs.length === 0) {
throw new Error("No observations returned from Bank of Canada");
}
const rate = parseFloat(obs[obs.length - 1].FXCADUSD.v);
if (isNaN(rate) || rate <= 0 || rate > 2) {
throw new Error(`Invalid rate value: ${obs[obs.length - 1].FXCADUSD.v}`);
}
console.log(`[fx] Bank of Canada CAD/USD rate: ${rate} (date: ${obs[obs.length - 1].d})`);
return rate;
} catch (err) {
console.warn(`[fx] Failed to fetch Bank of Canada rate: ${err}. Using fallback ${FALLBACK_RATE}`);
return FALLBACK_RATE;
}
}
/**
* Get the current CAD/USD rate (cached for 24h).
*/
async function getCadUsdRate(): Promise<number> {
const now = Date.now();
if (cachedRate && (now - cachedRate.fetchedAt) < CACHE_TTL_MS) {
return cachedRate.rate;
}
const rate = await fetchCadUsdRate();
cachedRate = { rate, fetchedAt: now };
return rate;
}
/**
* Convert CAD cents to USD cents with 10% buffer, rounded UP to nearest whole dollar.
*
* Example: C$350 (35000 cents) at rate 0.73:
* 35000 * 0.73 = 25550 USD cents
* 25550 * 1.10 = 28105 (with 10% buffer)
* Round up to $282.00 = 28200 cents
*/
export async function cadToUsdCents(cadCents: number): Promise<number> {
const rate = await getCadUsdRate();
const usdCentsRaw = cadCents * rate;
const withBuffer = usdCentsRaw * (1 + BUFFER_PCT);
// Round UP to nearest 100 (whole dollar)
const rounded = Math.ceil(withBuffer / 100) * 100;
return rounded;
}
/**
* Synchronous version using cached rate (returns 0 if not yet fetched).
* Use for display purposes only — call cadToUsdCents() for order pricing.
*/
export function cadToUsdCentsSync(cadCents: number): number {
if (!cachedRate) return 0;
const usdCentsRaw = cadCents * cachedRate.rate;
const withBuffer = usdCentsRaw * (1 + BUFFER_PCT);
return Math.ceil(withBuffer / 100) * 100;
}
/**
* Get the current cached rate info (for display/debugging).
*/
export async function getFxInfo(): Promise<{ rate: number; withBuffer: number; fetchedAt: string }> {
const rate = await getCadUsdRate();
return {
rate,
withBuffer: rate * (1 + BUFFER_PCT),
fetchedAt: cachedRate ? new Date(cachedRate.fetchedAt).toISOString() : "never",
};
}
/**
* Pre-warm the cache at startup.
*/
export async function warmFxCache(): Promise<void> {
await getCadUsdRate();
}