Files
edr-platform/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts
ghost2023 21dc28a708 feat(exchange-settings): one fallback rate per currency
exchange_settings was a single row holding the USD->ETB fallback only.
Restructured to one row per foreign currency (adds a currency column,
migration 3850000000000) so DJF gets its own fallback rate, source and
sync timestamp instead of a parallel column. Service/controller/DTO
follow: get/loadFallbackRate/saveFallbackRate/setManualRate all take a
currency now, GET /exchange-settings returns the list, and
PATCH /exchange-settings/:currency sets one. Per-currency manual-rate
ceiling (USD ~10,000, DJF ~100) replaces the old fixed bound.

Adds a spec exercising the multi-currency CBE parse and the USD<->DJF
pivot against a fixture payload.

Claude-Session: https://claude.ai/code/session_01CZy77vCWhka3pnmVF9NDkL
2026-09-04 11:52:18 +03:00

97 lines
3.3 KiB
TypeScript

import { BadRequestException, Body, Controller, Get, Param, Patch } from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { CURRENCY_CODES, CurrencyCode, CurrentUser } from "@edr/api-common";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { BookingStaff } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { UpdateExchangeSettingDto } from "./dto/update-exchange-setting.dto";
import { ExchangeSettingsService } from "./exchange-settings.service";
/**
* Sane manual-rate ceiling per currency — bounded well outside any plausible
* published rate but far short of a fat-fingered magnitude error. USD trades
* in the hundreds (ETB per USD); DJF trades under 2 (ETB per DJF, since DJF
* itself is worth roughly 1/177th of a USD).
*/
const RATE_BOUNDS: Record<CurrencyCode, number> = {
ETB: 1,
USD: 10_000,
DJF: 100,
};
const FOREIGN_CURRENCIES = CURRENCY_CODES.filter((c) => c !== "ETB");
function assertSupportedCurrency(currency: string): (typeof FOREIGN_CURRENCIES)[number] {
const code = currency?.toUpperCase();
const match = FOREIGN_CURRENCIES.find((c) => c === code);
if (!match) {
throw new BadRequestException(
`Unsupported currency "${currency}" — must be one of ${FOREIGN_CURRENCIES.join(", ")}`,
);
}
return match;
}
@ApiTags("exchange-settings")
@ApiBearerAuth()
@Controller("exchange-settings")
export class ExchangeSettingsController {
constructor(private readonly service: ExchangeSettingsService) {}
@Get()
@BookingStaff([FREIGHT_PERMS.settings.exchangeRate.view, FREIGHT_PERMS.admin])
@ApiOperation({
summary: "Current X→ETB fallback rates and CBE feed health, one entry per currency",
})
async list() {
const settings = await this.service.list();
const byCurrency = new Map(settings.map((s) => [s.currency, s]));
return FOREIGN_CURRENCIES.map((code) => {
const setting = byCurrency.get(code);
return {
currency: code,
fallbackRate: setting?.fallbackRate ?? null,
fallbackSource: setting?.fallbackSource ?? null,
lastSyncedAt: setting?.lastSyncedAt ?? null,
updatedById: setting?.updatedById ?? null,
feed: this.service.getFeedStatus(code),
};
});
}
@Patch(":currency")
@BookingStaff([FREIGHT_PERMS.settings.exchangeRate.manage, FREIGHT_PERMS.admin])
@ApiOperation({
summary:
"Set a currency's X→ETB fallback by hand (used only while CBE is unreachable)",
})
async update(
@Param("currency") currency: string,
@Body() dto: UpdateExchangeSettingDto,
@CurrentUser() user: TCurrentUser,
) {
const code = assertSupportedCurrency(currency);
if (dto.fallbackRate > RATE_BOUNDS[code]) {
throw new BadRequestException(
`Fallback rate ${dto.fallbackRate} is outside the accepted range for ${code} (max ${RATE_BOUNDS[code]})`,
);
}
const updated = await this.service.setManualRate(
code,
dto.fallbackRate,
user?.id ?? null,
);
return {
currency: updated.currency,
fallbackRate: updated.fallbackRate,
fallbackSource: updated.fallbackSource,
lastSyncedAt: updated.lastSyncedAt,
updatedById: updated.updatedById,
};
}
}