Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/providers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
162 changes: 162 additions & 0 deletions packages/providers/src/fx/BcbPtaxProvider.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>) {
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("<html>bad gateway</html>", { 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();
});
});
115 changes: 115 additions & 0 deletions packages/providers/src/fx/BcbPtaxProvider.ts
Original file line number Diff line number Diff line change
@@ -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<string, CachedQuote>();

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<unknown> {
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(),
};
}
1 change: 1 addition & 0 deletions packages/providers/src/fx/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './BcbPtaxProvider';
Loading