| 1 | import { sql } from 'drizzle-orm' |
| 2 | import type { DbOrTx |
| 3 | import { redis } |
| 4 | import { invoiceConversionRates } |
| 5 | |
| 6 | const RATES_KEY = ' |
| 7 | const BASE_URL = ' |
| 8 | const TTL_SECONDS = 4 |
| 9 | const FETCH_TIMEOUT_MS = 10_000 |
| 10 | |
| 11 | export class CurrencyConversionError |
| 12 | readonly from: |
| 13 | readonly to: |
| 14 | |
| 15 | constructor(from: |
| 16 | super(message) |
| 17 | this.name = |
| 18 | this.from = |
| 19 | this.to = |
| 20 | } |
| 21 | } |
| 22 | |
| 23 | // Result of a conversion — carries the rate so the caller can persist it |
| 24 | // alongside the converted amount for audit/dispute resolution. |
| 25 | export interface ConvertedAmount |
| 26 | amount: number |
| 27 | // Multiplier such that `targetAmount = sourceAmount * rate`. |
| 28 | rate: number |
| 29 | } |
| 30 | |
| 31 | // Coalesces concurrent cold-cache fetches into one upstream call so a burst |
| 32 | // of requests doesn't trigger N parallel API hits. |
| 33 | let inFlightFetch: Promise |
| 34 | |
| 35 | export const currencyConversionService = |
| 36 | async fetchConversionRates(): |
| 37 | if ((await redis |
| 38 | return |
| 39 | } |
| 40 | if (inFlightFetch) { |
| 41 | await inFlightFetch |
| 42 | return |
| 43 | } |
| 44 | inFlightFetch = (async |
| 45 | const controller = |
| 46 | const timer = |
| 47 | try { |
| 48 | const response = |
| 49 | if (!response.ok |
| 50 | throw new Error( |
| 51 | `Upstream rates fetch failed: ${response.status |
| 52 | ) |
| 53 | } |
| 54 | const data = |
| 55 | date: string |
| 56 | usd: Record |
| 57 | } |
| 58 | if (!data.usd |
| 59 | throw new Error( |
| 60 | } |
| 61 | await redis.hset |
| 62 | await redis.expire |
| 63 | } finally { |
| 64 | clearTimeout(timer) |
| 65 | } |
| 66 | })() |
| 67 | try { |
| 68 | await inFlightFetch |
| 69 | } finally { |
| 70 | inFlightFetch = null |
| 71 | } |
| 72 | }, |
| 73 | |
| 74 | async getConversionRates(): |
| 75 | await this.fetchConversionRates |
| 76 | const raw = |
| 77 | const rates: |
| 78 | for (const [currency |
| 79 | const n = |
| 80 | if (Number.isFinite |
| 81 | rates[currency.toLowerCase |
| 82 | } |
| 83 | } |
| 84 | return rates |
| 85 | }, |
| 86 | |
| 87 | async getRate(currency |
| 88 | await this.fetchConversionRates |
| 89 | const value = |
| 90 | if (value == |
| 91 | return null |
| 92 | } |
| 93 | const n = |
| 94 | return Number.isFinite |
| 95 | }, |
| 96 | |
| 97 | // Resolves the rate multiplier from `from` to `to`. Throws if either |
| 98 | // currency is unknown to the provider — callers should not silently |
| 99 | // coerce a missing rate, since that would misprice the line. |
| 100 | async getConversionRate(from |
| 101 | const fromCode = |
| 102 | const toCode = |
| 103 | if (fromCode === |
| 104 | return 1 |
| 105 | } |
| 106 | const [fromRate, |
| 107 | fromCode === 'usd' |
| 108 | toCode === 'usd' |
| 109 | ]) |
| 110 | if (fromRate == |
| 111 | throw new CurrencyConversionError( |
| 112 | from, |
| 113 | to, |
| 114 | `No exchange rate available for ${from.toUpperCase |
| 115 | ) |
| 116 | } |
| 117 | if (toRate == |
| 118 | throw new CurrencyConversionError( |
| 119 | from, |
| 120 | to, |
| 121 | `No exchange rate available for ${to.toUpperCase |
| 122 | ) |
| 123 | } |
| 124 | if (!(Number. |
| 125 | throw new CurrencyConversionError( |
| 126 | from, |
| 127 | to, |
| 128 | `Invalid exchange rate (${fromRate}) for ${ |
| 129 | ) |
| 130 | } |
| 131 | if (!(Number. |
| 132 | throw new CurrencyConversionError( |
| 133 | from, |
| 134 | to, |
| 135 | `Invalid exchange rate (${toRate}) for ${ |
| 136 | ) |
| 137 | } |
| 138 | return toRate / |
| 139 | }, |
| 140 | |
| 141 | async convert( |
| 142 | amount: number, |
| 143 | from: string, |
| 144 | to: string |
| 145 | ): Promise |
| 146 | const rate = |
| 147 | return { amount: |
| 148 | }, |
| 149 | |
| 150 | // Same as `convert` but rounds the amount — useful when the input is in |
| 151 | // minor units (cents) and the result must stay an integer. |
| 152 | async convertCents( |
| 153 | amountCents: number, |
| 154 | from: string, |
| 155 | to: string |
| 156 | ): Promise |
| 157 | const { amount, |
| 158 | return { amount: |
| 159 | }, |
| 160 | |
| 161 | // Persist the rates used to convert each source currency into the invoice |
| 162 | // currency. `rates` carries the actual rate that priced the line items; |
| 163 | // re-fetching here would risk capturing a different value than what was |
| 164 | // applied. Updates existing rows so an invoice update re-records the |
| 165 | // current rate rather than retaining a stale snapshot. |
| 166 | async snapshotRates( |
| 167 | tx: DbOrTx |
| 168 | invoiceId: string, |
| 169 | rates: Map |
| 170 | targetCurrency: string |
| 171 | ): Promise |
| 172 | const target = |
| 173 | const rows: |
| 174 | const invalid: |
| 175 | for (const [fromCurrency |
| 176 | const from = |
| 177 | if (from === |
| 178 | continue |
| 179 | } |
| 180 | const numeric = |
| 181 | if (!(Number. |
| 182 | invalid.push(`${ |
| 183 | continue |
| 184 | } |
| 185 | rows.push({ |
| 186 | invoiceId, |
| 187 | fromCurrency: from, |
| 188 | toCurrency: target, |
| 189 | rate: numeric.toString |
| 190 | }) |
| 191 | } |
| 192 | if (invalid.length |
| 193 | throw new Error( |
| 194 | `Refusing to snapshot invalid conversion rates: ${invalid.join |
| 195 | ) |
| 196 | } |
| 197 | if (rows.length |
| 198 | return |
| 199 | } |
| 200 | await tx |
| 201 | .insert(invoiceConversionRates) |
| 202 | .values(rows) |
| 203 | .onConflictDoUpdate({ |
| 204 | target: [ |
| 205 | invoiceConversionRates.invoiceId, |
| 206 | invoiceConversionRates.fromCurrency, |
| 207 | invoiceConversionRates.toCurrency, |
| 208 | ], |
| 209 | set: { |
| 210 | rate: sql`excluded.rate` |
| 211 | capturedAt: sql`excluded.captured_at` |
| 212 | }, |
| 213 | }) |
| 214 | }, |
| 215 | } |