From c1b932b64acb528282fc9b78ca89cd9e39d47d6f Mon Sep 17 00:00:00 2001 From: Jonathan Nagayoshi Date: Sat, 10 Oct 2026 23:48:39 -0300 Subject: [PATCH 1/4] feat(ledger): add BCB PTAX exchange-rate provider --- packages/providers/index.ts | 1 + .../src/fx/bcb_ptax_provider.test.ts | 119 ++++++++++++++++++ .../providers/src/fx/bcb_ptax_provider.ts | 115 +++++++++++++++++ packages/providers/src/fx/index.ts | 1 + 4 files changed, 236 insertions(+) create mode 100644 packages/providers/src/fx/bcb_ptax_provider.test.ts create mode 100644 packages/providers/src/fx/bcb_ptax_provider.ts create mode 100644 packages/providers/src/fx/index.ts 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/bcb_ptax_provider.test.ts b/packages/providers/src/fx/bcb_ptax_provider.test.ts new file mode 100644 index 0000000..9490520 --- /dev/null +++ b/packages/providers/src/fx/bcb_ptax_provider.test.ts @@ -0,0 +1,119 @@ +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 "./bcb_ptax_provider"; + +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.*CotacaoMoedaDia/, ({ 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()); + 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.*CotacaoMoedaDia/, () => 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"); + }); +}); diff --git a/packages/providers/src/fx/bcb_ptax_provider.ts b/packages/providers/src/fx/bcb_ptax_provider.ts new file mode 100644 index 0000000..861ad15 --- /dev/null +++ b/packages/providers/src/fx/bcb_ptax_provider.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}/CotacaoMoedaDia(moeda=@moeda,dataCotacao=@dataCotacao)` + + `?@moeda='USD'&@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..26a9542 --- /dev/null +++ b/packages/providers/src/fx/index.ts @@ -0,0 +1 @@ +export * from './bcb_ptax_provider'; From 29c61c02cac34d6a51df7131284b1ec088db390a Mon Sep 17 00:00:00 2001 From: Jonathan Nagayoshi Date: Thu, 15 Oct 2026 09:20:59 -0300 Subject: [PATCH 2/4] refactor(providers): rename the PTAX provider module to PascalCase Follow the repository convention for a module whose main export is a class, and keep the export path in the fx barrel in step. The exported BcbPtaxProvider symbol is unchanged, so consumers are unaffected. --- .../fx/{bcb_ptax_provider.test.ts => BcbPtaxProvider.test.ts} | 2 +- .../src/fx/{bcb_ptax_provider.ts => BcbPtaxProvider.ts} | 0 packages/providers/src/fx/index.ts | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename packages/providers/src/fx/{bcb_ptax_provider.test.ts => BcbPtaxProvider.test.ts} (98%) rename packages/providers/src/fx/{bcb_ptax_provider.ts => BcbPtaxProvider.ts} (100%) diff --git a/packages/providers/src/fx/bcb_ptax_provider.test.ts b/packages/providers/src/fx/BcbPtaxProvider.test.ts similarity index 98% rename from packages/providers/src/fx/bcb_ptax_provider.test.ts rename to packages/providers/src/fx/BcbPtaxProvider.test.ts index 9490520..00dd3ea 100644 --- a/packages/providers/src/fx/bcb_ptax_provider.test.ts +++ b/packages/providers/src/fx/BcbPtaxProvider.test.ts @@ -2,7 +2,7 @@ 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 "./bcb_ptax_provider"; +import { BcbPtaxProvider } from "./BcbPtaxProvider"; describe("BcbPtaxProvider", () => { const server = setupServer(); diff --git a/packages/providers/src/fx/bcb_ptax_provider.ts b/packages/providers/src/fx/BcbPtaxProvider.ts similarity index 100% rename from packages/providers/src/fx/bcb_ptax_provider.ts rename to packages/providers/src/fx/BcbPtaxProvider.ts diff --git a/packages/providers/src/fx/index.ts b/packages/providers/src/fx/index.ts index 26a9542..4760a42 100644 --- a/packages/providers/src/fx/index.ts +++ b/packages/providers/src/fx/index.ts @@ -1 +1 @@ -export * from './bcb_ptax_provider'; +export * from './BcbPtaxProvider'; From 64315b13c1cebb043216354a93a37c9c0096c01a Mon Sep 17 00:00:00 2001 From: Jonathan Nagayoshi Date: Thu, 15 Oct 2026 09:23:14 -0300 Subject: [PATCH 3/4] fix(providers): read the PTAX closing bulletin for USD conversions The provider queried CotacaoMoedaDia, which returns five bulletins for a business day with the opening rate first, and took values[0]. Every USD donation and the bot infrastructure cost were therefore converted at the opening rate while being recorded as the PTAX rate. Query CotacaoDolarDia instead, which returns exactly the closing bulletin row. Verified against the live endpoint for 2026-10-14: the old operation returned five rows starting at 5.04620, the new one returns the single closing row at 5.04410, identical to the last row of the old response. The date literal stays MM-DD-YYYY: the ISO form returns HTTP 200 with an empty value array, which is why the fallback walk never surfaced the problem. --- .../providers/src/fx/BcbPtaxProvider.test.ts | 47 ++++++++++++++++++- packages/providers/src/fx/BcbPtaxProvider.ts | 4 +- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/packages/providers/src/fx/BcbPtaxProvider.test.ts b/packages/providers/src/fx/BcbPtaxProvider.test.ts index 00dd3ea..dc401c9 100644 --- a/packages/providers/src/fx/BcbPtaxProvider.test.ts +++ b/packages/providers/src/fx/BcbPtaxProvider.test.ts @@ -15,7 +15,7 @@ describe("BcbPtaxProvider", () => { function usePtaxHandler(requests: string[], quotesByDay: Record) { server.use( - http.get(/olinda\.bcb\.gov\.br.*CotacaoMoedaDia/, ({ request }) => { + 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; @@ -107,7 +107,7 @@ describe("BcbPtaxProvider", () => { // Given const { sut } = makeSut(); server.use( - http.get(/olinda\.bcb\.gov\.br.*CotacaoMoedaDia/, () => new HttpResponse(null, { status: 500 })) + http.get(/olinda\.bcb\.gov\.br.*CotacaoDolarDia/, () => new HttpResponse(null, { status: 500 })) ); // When @@ -116,4 +116,47 @@ describe("BcbPtaxProvider", () => { // 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 index 861ad15..cf15f16 100644 --- a/packages/providers/src/fx/BcbPtaxProvider.ts +++ b/packages/providers/src/fx/BcbPtaxProvider.ts @@ -77,8 +77,8 @@ function stepBackDays(params: DateParams & { daysBack: number }): Date { function buildQuoteUrl(params: DateParams): string { const { date } = params; - return `${PTAX_BASE_URL}/CotacaoMoedaDia(moeda=@moeda,dataCotacao=@dataCotacao)` + - `?@moeda='USD'&@dataCotacao='${toPtaxDay({ date })}'&$format=json&$select=cotacaoVenda,dataHoraCotacao`; + return `${PTAX_BASE_URL}/CotacaoDolarDia(dataCotacao=@dataCotacao)` + + `?@dataCotacao='${toPtaxDay({ date })}'&$format=json&$select=cotacaoVenda,dataHoraCotacao`; } type ReadJsonParams = { From 4a032eab74b9abbd28c58aa5ef4d9311b720f6de Mon Sep 17 00:00:00 2001 From: Jonathan Nagayoshi Date: Thu, 15 Oct 2026 09:55:17 -0300 Subject: [PATCH 4/4] test(providers): fail the PTAX suite on unhandled requests The msw matchers are URL-specific, so a provider that reverted to the wrong BCB operation would not match any handler and the request would pass through to the live endpoint, letting the suite pass against the real API. Make an unhandled request an error. --- packages/providers/src/fx/BcbPtaxProvider.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/providers/src/fx/BcbPtaxProvider.test.ts b/packages/providers/src/fx/BcbPtaxProvider.test.ts index dc401c9..9a31884 100644 --- a/packages/providers/src/fx/BcbPtaxProvider.test.ts +++ b/packages/providers/src/fx/BcbPtaxProvider.test.ts @@ -27,7 +27,7 @@ describe("BcbPtaxProvider", () => { ); } - beforeAll(() => server.listen()); + beforeAll(() => server.listen({ onUnhandledRequest: "error" })); afterEach(() => server.resetHandlers()); afterAll(() => server.close());