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>
118 lines
3.6 KiB
TypeScript
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();
|
|
}
|