diff --git a/packages/providers/index.ts b/packages/providers/index.ts index 99ae2ad..538ae5c 100644 --- a/packages/providers/index.ts +++ b/packages/providers/index.ts @@ -2,6 +2,7 @@ export * from './src/services'; export * from './src/services/ServerManagerFactory'; export * from './src/cloud-providers'; +export * from './src/fx'; export * from './src/cost-providers/DefaultCostProvider'; export * from './src/queue'; export * from './src/repository'; diff --git a/packages/providers/src/fx/BcbPtaxProvider.test.ts b/packages/providers/src/fx/BcbPtaxProvider.test.ts new file mode 100644 index 0000000..9a31884 --- /dev/null +++ b/packages/providers/src/fx/BcbPtaxProvider.test.ts @@ -0,0 +1,162 @@ +import { HttpResponse, http } from "msw"; +import { setupServer } from "msw/node"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { toBrlCents } from "@tf2qs/core"; +import { BcbPtaxProvider } from "./BcbPtaxProvider"; + +describe("BcbPtaxProvider", () => { + const server = setupServer(); + + function makeSut(overrides: { cacheTtlMs?: number } = {}) { + const requests: string[] = []; + const sut = new BcbPtaxProvider({ cacheTtlMs: overrides.cacheTtlMs }); + return { sut, requests }; + } + + function usePtaxHandler(requests: string[], quotesByDay: Record) { + server.use( + http.get(/olinda\.bcb\.gov\.br.*CotacaoDolarDia/, ({ request }) => { + requests.push(request.url); + const day = (new URL(request.url).searchParams.get("@dataCotacao") ?? "").replace(/'/g, ""); + const rate = day ? quotesByDay[day] : undefined; + if (rate === undefined) { + return HttpResponse.json({ value: [] }); + } + return HttpResponse.json({ value: [{ cotacaoVenda: rate, dataHoraCotacao: "2026-10-06 13:00:00.000" }] }); + }) + ); + } + + beforeAll(() => server.listen({ onUnhandledRequest: "error" })); + afterEach(() => server.resetHandlers()); + afterAll(() => server.close()); + + it("Given a PTAX quote for the date When converting Then returns the venda rate", async () => { + // Given + const { sut, requests } = makeSut(); + usePtaxHandler(requests, { "10-06-2026": 5.4321 }); + + // When + const result = await sut.getBrlPerUsd({ date: new Date("2026-10-06T12:00:00.000Z") }); + + // Then + expect(result.rate).toBe(5.4321); + expect(result.source).toBe("PTAX"); + expect(result.quotedAt).toBeInstanceOf(Date); + expect(toBrlCents({ amount: 10, fxRate: result.rate })).toBe(5432); + expect(requests).toHaveLength(1); + }); + + it("Given no quote on the date When converting Then falls back to the previous business day", async () => { + // Given + const { sut, requests } = makeSut(); + usePtaxHandler(requests, { "10-05-2026": 5.5 }); + + // When + const result = await sut.getBrlPerUsd({ date: new Date("2026-10-06T12:00:00.000Z") }); + + // Then + expect(result.rate).toBe(5.5); + expect(result.source).toBe("PTAX"); + expect(requests).toHaveLength(2); + expect(requests[0]).toContain("10-06-2026"); + expect(requests[1]).toContain("10-05-2026"); + }); + + it("Given no quotes for seven days When converting Then throws", async () => { + // Given + const { sut, requests } = makeSut(); + usePtaxHandler(requests, {}); + + // When + const attempt = sut.getBrlPerUsd({ date: new Date("2026-10-06T12:00:00.000Z") }); + + // Then + await expect(attempt).rejects.toThrow("last 7 days"); + expect(requests).toHaveLength(7); + }); + + it("Given a cached quote When converting again Then performs no new request", async () => { + // Given + const { sut, requests } = makeSut(); + usePtaxHandler(requests, { "10-06-2026": 5.4321 }); + await sut.getBrlPerUsd({ date: new Date("2026-10-06T12:00:00.000Z") }); + + // When + const result = await sut.getBrlPerUsd({ date: new Date("2026-10-06T18:00:00.000Z") }); + + // Then + expect(result.rate).toBe(5.4321); + expect(requests).toHaveLength(1); + }); + + it("Given a zero cache TTL When converting twice Then requests both times", async () => { + // Given + const { sut, requests } = makeSut({ cacheTtlMs: 0 }); + usePtaxHandler(requests, { "10-06-2026": 5.4321 }); + + // When + await sut.getBrlPerUsd({ date: new Date("2026-10-06T12:00:00.000Z") }); + await sut.getBrlPerUsd({ date: new Date("2026-10-06T12:00:00.000Z") }); + + // Then + expect(requests).toHaveLength(2); + }); + + it("Given a PTAX server error When converting Then throws a descriptive error", async () => { + // Given + const { sut } = makeSut(); + server.use( + http.get(/olinda\.bcb\.gov\.br.*CotacaoDolarDia/, () => new HttpResponse(null, { status: 500 })) + ); + + // When + const attempt = sut.getBrlPerUsd({ date: new Date("2026-10-06T12:00:00.000Z") }); + + // Then + await expect(attempt).rejects.toThrow("status 500"); + }); + + it("Given a requested date When converting Then requests the closing bulletin with a MM-DD-YYYY literal", async () => { + // Given + const { sut, requests } = makeSut(); + usePtaxHandler(requests, { "10-06-2026": 5.4321 }); + + // When + await sut.getBrlPerUsd({ date: new Date("2026-10-06T12:00:00.000Z") }); + + // Then + const requestedUrl = decodeURIComponent(requests[0]); + expect(requestedUrl).toContain("CotacaoDolarDia(dataCotacao=@dataCotacao)"); + expect(requestedUrl).toContain("@dataCotacao='10-06-2026'"); + expect(requestedUrl).not.toContain("2026-10-06"); + }); + + it("Given a non-JSON PTAX body When converting Then throws", async () => { + // Given + const { sut } = makeSut(); + server.use( + http.get(/olinda\.bcb\.gov\.br.*CotacaoDolarDia/, () => new HttpResponse("bad gateway", { status: 200, headers: { "Content-Type": "text/html" } })) + ); + + // When + const attempt = sut.getBrlPerUsd({ date: new Date("2026-10-06T12:00:00.000Z") }); + + // Then + await expect(attempt).rejects.toThrow("not valid JSON"); + }); + + it("Given a rejected PTAX request When converting Then throws", async () => { + // Given + const { sut } = makeSut(); + server.use( + http.get(/olinda\.bcb\.gov\.br.*CotacaoDolarDia/, () => HttpResponse.error()) + ); + + // When + const attempt = sut.getBrlPerUsd({ date: new Date("2026-10-06T12:00:00.000Z") }); + + // Then + await expect(attempt).rejects.toThrow(); + }); +}); diff --git a/packages/providers/src/fx/BcbPtaxProvider.ts b/packages/providers/src/fx/BcbPtaxProvider.ts new file mode 100644 index 0000000..cf15f16 --- /dev/null +++ b/packages/providers/src/fx/BcbPtaxProvider.ts @@ -0,0 +1,115 @@ +import { ExchangeRateProvider } from "@tf2qs/core"; + +type BcbPtaxProviderDependencies = { + fetchFn?: typeof fetch; + cacheTtlMs?: number; +}; + +type CachedQuote = { + rate: number; + quotedAt: Date; + expiresAt: number; +}; + +const PTAX_BASE_URL = 'https://olinda.bcb.gov.br/olinda/servico/PTAX/versao/v1/odata'; +const MAX_LOOKUP_DAYS = 7; +const DEFAULT_CACHE_TTL_MS = 60 * 60 * 1000; + +export class BcbPtaxProvider implements ExchangeRateProvider { + private readonly cache = new Map(); + + constructor(private readonly dependencies: BcbPtaxProviderDependencies = {}) {} + + async getBrlPerUsd(params: { date: Date }): Promise<{ rate: number; source: 'PTAX'; quotedAt: Date }> { + const { date } = params; + const cacheKey = toIsoDay({ date }); + const cached = this.cache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) { + return { rate: cached.rate, source: 'PTAX', quotedAt: cached.quotedAt }; + } + + const quote = await this.fetchRecentQuote({ date }); + + const ttlMs = this.dependencies.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS; + this.cache.set(cacheKey, { rate: quote.rate, quotedAt: quote.quotedAt, expiresAt: Date.now() + ttlMs }); + + return { rate: quote.rate, source: 'PTAX', quotedAt: quote.quotedAt }; + } + + private async fetchRecentQuote(params: { date: Date }): Promise<{ rate: number; quotedAt: Date }> { + const { date } = params; + const fetchFn = this.dependencies.fetchFn ?? fetch; + + for (let daysBack = 0; daysBack < MAX_LOOKUP_DAYS; daysBack += 1) { + const candidate = stepBackDays({ date, daysBack }); + const response = await fetchFn(buildQuoteUrl({ date: candidate }), { signal: AbortSignal.timeout(20000) }); + if (!response.ok) { + throw new Error(`BCB PTAX request failed with status ${response.status}`); + } + const quote = extractQuote({ body: await readJson({ response }) }); + if (quote) { + return quote; + } + } + + throw new Error('BCB PTAX has no USD quote for the last 7 days'); + } +} + +type DateParams = { + date: Date; +}; + +function toIsoDay(params: DateParams): string { + const { date } = params; + return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, '0')}-${String(date.getUTCDate()).padStart(2, '0')}`; +} + +function toPtaxDay(params: DateParams): string { + const { date } = params; + return `${String(date.getUTCMonth() + 1).padStart(2, '0')}-${String(date.getUTCDate()).padStart(2, '0')}-${date.getUTCFullYear()}`; +} + +function stepBackDays(params: DateParams & { daysBack: number }): Date { + const { date, daysBack } = params; + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate() - daysBack)); +} + +function buildQuoteUrl(params: DateParams): string { + const { date } = params; + return `${PTAX_BASE_URL}/CotacaoDolarDia(dataCotacao=@dataCotacao)` + + `?@dataCotacao='${toPtaxDay({ date })}'&$format=json&$select=cotacaoVenda,dataHoraCotacao`; +} + +type ReadJsonParams = { + response: Response; +}; + +async function readJson(params: ReadJsonParams): Promise { + const { response } = params; + try { + return await response.json(); + } catch { + throw new Error('BCB PTAX response is not valid JSON'); + } +} + +type ExtractQuoteParams = { + body: unknown; +}; + +function extractQuote(params: ExtractQuoteParams): { rate: number; quotedAt: Date } | null { + const { body } = params; + const values = (body as { value?: unknown })?.value; + if (!Array.isArray(values) || values.length === 0) { + return null; + } + const first = values[0] as { cotacaoVenda?: unknown; dataHoraCotacao?: unknown }; + if (typeof first.cotacaoVenda !== 'number' || !Number.isFinite(first.cotacaoVenda)) { + throw new Error('BCB PTAX response is missing cotacaoVenda'); + } + return { + rate: first.cotacaoVenda, + quotedAt: typeof first.dataHoraCotacao === 'string' ? new Date(first.dataHoraCotacao) : new Date(), + }; +} diff --git a/packages/providers/src/fx/index.ts b/packages/providers/src/fx/index.ts new file mode 100644 index 0000000..4760a42 --- /dev/null +++ b/packages/providers/src/fx/index.ts @@ -0,0 +1 @@ +export * from './BcbPtaxProvider';