From 56697d8fc52f65765375f5c1a4268a0511ad4b73 Mon Sep 17 00:00:00 2001 From: Marshal Date: Tue, 4 Aug 2026 11:22:47 +0000 Subject: [PATCH] implement exchange settings management and fallback rate handling --- apps/edr-freight-api/src/app.module.ts | 2 + apps/edr-freight-api/src/config/app.config.ts | 7 +- .../3240000000000-CreateExchangeSettings.ts | 45 ++++ .../src/modules/bookings/bookings.module.ts | 10 +- .../src/modules/contracts/contracts.module.ts | 9 +- .../dto/update-exchange-setting.dto.ts | 13 ++ .../entities/exchange-setting.entity.ts | 47 ++++ .../exchange-module-options.ts | 27 +++ .../exchange-settings.controller.ts | 68 ++++++ .../exchange-settings.module.ts | 20 ++ .../exchange-settings.service.ts | 95 ++++++++ .../modules/warehouses/warehouses.module.ts | 9 +- .../ruleEngine/RuleEngineFormDialog.tsx | 26 ++- .../backoffice/src/constants/URLS.ts | 4 + .../src/hooks/useExchangeSettings.ts | 34 +++ .../backoffice/src/pages/SettingsPage.tsx | 3 + .../settings/ExchangeRateSettingsCard.tsx | 163 ++++++++++++++ .../src/services/exchangeSettings.service.ts | 40 ++++ .../src/services/exchange/cbe.provider.ts | 203 ++++++++++++++---- .../src/services/exchange/exchange.options.ts | 48 ++++- .../src/services/exchange/exchange.service.ts | 10 +- .../api-common/src/services/exchange/index.ts | 7 +- 22 files changed, 805 insertions(+), 85 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3240000000000-CreateExchangeSettings.ts create mode 100644 apps/edr-freight-api/src/modules/exchange-settings/dto/update-exchange-setting.dto.ts create mode 100644 apps/edr-freight-api/src/modules/exchange-settings/entities/exchange-setting.entity.ts create mode 100644 apps/edr-freight-api/src/modules/exchange-settings/exchange-module-options.ts create mode 100644 apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts create mode 100644 apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.module.ts create mode 100644 apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.service.ts create mode 100644 apps/edr-freight-web/backoffice/src/hooks/useExchangeSettings.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/settings/ExchangeRateSettingsCard.tsx create mode 100644 apps/edr-freight-web/backoffice/src/services/exchangeSettings.service.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 337518b2e..0aee0f740 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -46,6 +46,7 @@ import { NotificationInboxModule } from "./modules/notification-inbox/notificati import { SupportChatModule } from "./modules/support-chat/support-chat.module"; import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module"; import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module"; +import { ExchangeSettingsModule } from "./modules/exchange-settings/exchange-settings.module"; import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module"; import { OtpModule } from "./modules/otp/otp.module"; import { HealthModule } from "./modules/health/health.module"; @@ -192,6 +193,7 @@ import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middlewar SupportChatModule, FileUploadSettingsModule, DropdownSettingsModule, + ExchangeSettingsModule, ContractTemplatesModule, OtpModule, HealthModule, diff --git a/apps/edr-freight-api/src/config/app.config.ts b/apps/edr-freight-api/src/config/app.config.ts index 050b07145..8e03ac3f0 100644 --- a/apps/edr-freight-api/src/config/app.config.ts +++ b/apps/edr-freight-api/src/config/app.config.ts @@ -24,12 +24,13 @@ export default registerAs("app", () => ({ }, // Consumed by @edr/api-common ExchangeModule.forRootAsync (see bookings.module.ts). cbeExchange: { - /** ethio.forex CBET page — scraped for USD buying/selling rates. */ + /** CBE daily-exchange-rates JSON — USD `transactionalSelling` is used. */ scrapeUrl: process.env.CBE_EXCHANGE_SCRAPE_URL ?? process.env.CBE_EXCHANGE_API_URL ?? - "https://ethio.forex/bank/CBET", - fallbackRate: numberFromEnv("CBE_EXCHANGE_FALLBACK_RATE", 130), + "https://combanketh.et/cbeapi/daily-exchange-rates/?_limit=1&_sort=Date%3ADESC", + // No fallback env var: the fallback lives in freight.exchange_settings, + // maintained by the backoffice and by write-back on every successful fetch. cacheTtlMs: numberFromEnv("CBE_EXCHANGE_CACHE_TTL_MS", 3_600_000), }, })); diff --git a/apps/edr-freight-api/src/migrations/3240000000000-CreateExchangeSettings.ts b/apps/edr-freight-api/src/migrations/3240000000000-CreateExchangeSettings.ts new file mode 100644 index 000000000..68ad4f49b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3240000000000-CreateExchangeSettings.ts @@ -0,0 +1,45 @@ +import { MigrationInterface, QueryRunner, Table } from 'typeorm'; + +/** + * Single-row store for the USD→ETB fallback used when the CBE exchange-rate + * endpoint is unreachable. The live CBE rate always wins; every successful + * fetch overwrites this row, so it holds the last known good rate rather than + * a constant that drifts. Operators can also set it by hand during an outage. + * + * Seeded with the CBE USD transactional selling rate on 2026-08-04, so the + * fallback is usable before the first successful fetch. + */ +export class CreateExchangeSettings3240000000000 implements MigrationInterface { + name = 'CreateExchangeSettings3240000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'exchange_settings', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' }, + { name: 'fallback_rate', type: 'numeric', precision: 18, scale: 6 }, + // AUTO when written by the CBE sync, MANUAL when set in the backoffice. + { name: 'fallback_source', type: 'varchar', length: '16', default: "'AUTO'" }, + { name: 'last_synced_at', type: 'timestamptz', isNullable: true }, + // IAM user id (iam.users) — no FK, iam schema is externally owned. + { name: 'updated_by_id', type: 'uuid', isNullable: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + + await queryRunner.query(` + INSERT INTO freight.exchange_settings (fallback_rate, fallback_source) + VALUES (162.416500, 'AUTO') + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('freight.exchange_settings', true); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 2063bb01d..84354d860 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -1,8 +1,8 @@ import { Module, forwardRef } from "@nestjs/common"; import { UserTradeAccessModule } from "../user-trade-access/user-trade-access.module"; -import { ConfigService } from "@nestjs/config"; import { TypeOrmModule } from "@nestjs/typeorm"; -import { ExchangeModule, ExchangeOptions } from "@edr/api-common"; + +import { registerExchangeModule } from "../exchange-settings/exchange-module-options"; // import { CustomersModule } from '../customers/customers.module'; import { CompaniesModule } from '../companies/companies.module'; @@ -86,11 +86,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; RuleEngineModule, FileUploadSettingsModule, SignaturesModule, - ExchangeModule.forRootAsync({ - inject: [ConfigService], - useFactory: (config: ConfigService): ExchangeOptions => - config.get("app.cbeExchange") ?? {}, - }), + registerExchangeModule(), ], controllers: [BookingsController], providers: [ diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts index 0e60462a4..2a07b02e5 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts @@ -1,9 +1,8 @@ import { Module, forwardRef } from '@nestjs/common'; import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module'; -import { ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { ExchangeModule, ExchangeOptions } from '@edr/api-common'; +import { registerExchangeModule } from '../exchange-settings/exchange-module-options'; import { BillingModule } from '../billing/billing.module'; import { CompaniesModule } from '../companies/companies.module'; import { FilesModule } from '../files/files.module'; @@ -102,11 +101,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum // by ContractBookingService.createUnderContract. forwardRef because // TrainSchedulingModule already imports ContractsModule. forwardRef(() => TrainSchedulingModule), - ExchangeModule.forRootAsync({ - inject: [ConfigService], - useFactory: (config: ConfigService): ExchangeOptions => - config.get('app.cbeExchange') ?? {}, - }), + registerExchangeModule(), ], controllers: [ContractsController, GlExchangeController], providers: [ diff --git a/apps/edr-freight-api/src/modules/exchange-settings/dto/update-exchange-setting.dto.ts b/apps/edr-freight-api/src/modules/exchange-settings/dto/update-exchange-setting.dto.ts new file mode 100644 index 000000000..98e87007c --- /dev/null +++ b/apps/edr-freight-api/src/modules/exchange-settings/dto/update-exchange-setting.dto.ts @@ -0,0 +1,13 @@ +import { IsNumber, Max, Min } from "class-validator"; + +/** + * Operator-set USD→ETB fallback. Bounded well outside any plausible published + * rate but far short of a fat-fingered magnitude error — this value multiplies + * real invoice amounts whenever CBE is unreachable. + */ +export class UpdateExchangeSettingDto { + @IsNumber({ maxDecimalPlaces: 6 }) + @Min(1) + @Max(10_000) + fallbackRate!: number; +} diff --git a/apps/edr-freight-api/src/modules/exchange-settings/entities/exchange-setting.entity.ts b/apps/edr-freight-api/src/modules/exchange-settings/entities/exchange-setting.entity.ts new file mode 100644 index 000000000..1e1f4ad66 --- /dev/null +++ b/apps/edr-freight-api/src/modules/exchange-settings/entities/exchange-setting.entity.ts @@ -0,0 +1,47 @@ +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity } from "typeorm"; + +/** + * Whether the stored fallback rate was written by the automatic sync (after a + * successful CBE fetch) or typed in by an operator in the backoffice. + */ +export type ExchangeFallbackSource = "AUTO" | "MANUAL"; + +/** + * Single-row table holding the USD→ETB fallback used when the CBE endpoint is + * unreachable. The live CBE rate always wins; this is only consulted on + * failure, and is overwritten by every successful fetch so it tracks the last + * known good rate. + */ +@Entity({ schema: "freight", name: "exchange_settings" }) +export class ExchangeSetting extends BaseEntity { + /** USD→ETB rate served while the CBE endpoint is failing. */ + @Column({ + name: "fallback_rate", + type: "numeric", + precision: 18, + scale: 6, + transformer: { + to: (value: number) => value, + from: (value: string | null) => (value === null ? null : Number(value)), + }, + }) + fallbackRate!: number; + + /** `AUTO` when written by the sync, `MANUAL` when set in the backoffice. */ + @Column({ + name: "fallback_source", + type: "varchar", + length: 16, + default: "AUTO", + }) + fallbackSource!: ExchangeFallbackSource; + + /** When the fallback last changed — i.e. the last successful CBE fetch. */ + @Column({ name: "last_synced_at", type: "timestamptz", nullable: true }) + lastSyncedAt?: Date | null; + + /** IAM user id of the last operator to set the rate manually. */ + @Column({ name: "updated_by_id", type: "uuid", nullable: true }) + updatedById?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/exchange-settings/exchange-module-options.ts b/apps/edr-freight-api/src/modules/exchange-settings/exchange-module-options.ts new file mode 100644 index 000000000..fb126f969 --- /dev/null +++ b/apps/edr-freight-api/src/modules/exchange-settings/exchange-module-options.ts @@ -0,0 +1,27 @@ +import { ExchangeModule, ExchangeOptions } from "@edr/api-common"; +import { ConfigService } from "@nestjs/config"; +import { DynamicModule } from "@nestjs/common"; + +import { ExchangeSettingsService } from "./exchange-settings.service"; + +/** + * The app's single `ExchangeModule` registration shape: CBE endpoint config + * from `app.cbeExchange`, with the DB-backed fallback wired in. + * + * `ExchangeModule` is registered per-feature-module (bookings, contracts, + * warehouses), so this keeps the three call sites identical rather than + * letting their options drift apart. + */ +export function registerExchangeModule(): DynamicModule { + return ExchangeModule.forRootAsync({ + inject: [ConfigService, ExchangeSettingsService], + useFactory: ( + config: ConfigService, + settings: ExchangeSettingsService, + ): ExchangeOptions => ({ + ...(config.get("app.cbeExchange") ?? {}), + loadFallbackRate: () => settings.loadFallbackRate(), + saveFallbackRate: (rate: number) => settings.saveFallbackRate(rate), + }), + }); +} diff --git a/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts new file mode 100644 index 000000000..782a5b6a0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts @@ -0,0 +1,68 @@ +import { Body, Controller, Get, Patch } from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; +import { CurrentUser, ExchangeService } from "@edr/api-common"; +import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; + +import { FreightAdmin } from "../../common/booking-guards"; +import { UpdateExchangeSettingDto } from "./dto/update-exchange-setting.dto"; +import { ExchangeSettingsService } from "./exchange-settings.service"; + +@ApiTags("exchange-settings") +@ApiBearerAuth() +@Controller("exchange-settings") +export class ExchangeSettingsController { + constructor( + private readonly service: ExchangeSettingsService, + private readonly exchangeService: ExchangeService, + ) {} + + @Get() + @FreightAdmin() + @ApiOperation({ + summary: "Current USD→ETB fallback rate and CBE feed health", + }) + async get() { + const [setting, status] = [ + await this.service.get(), + this.exchangeService.getProviderStatus(), + ]; + + return { + fallbackRate: setting.fallbackRate, + fallbackSource: setting.fallbackSource, + lastSyncedAt: setting.lastSyncedAt, + updatedById: setting.updatedById, + feed: { + rate: status.rate, + source: status.source, + lastSuccessAt: status.lastSuccessAt + ? new Date(status.lastSuccessAt).toISOString() + : null, + lastError: status.lastError, + }, + }; + } + + @Patch() + @FreightAdmin() + @ApiOperation({ + summary: + "Set the USD→ETB fallback by hand (used only while CBE is unreachable)", + }) + async update( + @Body() dto: UpdateExchangeSettingDto, + @CurrentUser() user: TCurrentUser, + ) { + const updated = await this.service.setManualRate( + dto.fallbackRate, + user?.id ?? null, + ); + + return { + fallbackRate: updated.fallbackRate, + fallbackSource: updated.fallbackSource, + lastSyncedAt: updated.lastSyncedAt, + updatedById: updated.updatedById, + }; + } +} diff --git a/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.module.ts b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.module.ts new file mode 100644 index 000000000..3b536534a --- /dev/null +++ b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.module.ts @@ -0,0 +1,20 @@ +import { Global, Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; + +import { ExchangeSetting } from "./entities/exchange-setting.entity"; +import { ExchangeSettingsController } from "./exchange-settings.controller"; +import { ExchangeSettingsService } from "./exchange-settings.service"; + +/** + * Global so the several `ExchangeModule.forRootAsync` registrations (bookings, + * contracts, warehouses) can inject {@link ExchangeSettingsService} into their + * options factory without each importing this module. + */ +@Global() +@Module({ + imports: [TypeOrmModule.forFeature([ExchangeSetting])], + controllers: [ExchangeSettingsController], + providers: [ExchangeSettingsService], + exports: [ExchangeSettingsService], +}) +export class ExchangeSettingsModule {} diff --git a/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.service.ts b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.service.ts new file mode 100644 index 000000000..4cccdf8ea --- /dev/null +++ b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.service.ts @@ -0,0 +1,95 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { ExchangeSetting } from "./entities/exchange-setting.entity"; + +/** + * Rate used before the row exists and before the first successful CBE fetch — + * the CBE USD transactional selling rate on 2026-08-04. + */ +const SEED_FALLBACK_RATE = 162.4165; + +/** + * Owns the single `exchange_settings` row: the USD→ETB fallback used when the + * CBE endpoint is unreachable. + * + * The live CBE rate is always preferred. This value is only read on failure, + * and every successful fetch overwrites it, so it tracks the last known good + * rate rather than drifting into a stale constant. + */ +@Injectable() +export class ExchangeSettingsService { + private readonly logger = new Logger(ExchangeSettingsService.name); + + constructor( + @InjectRepository(ExchangeSetting) + private readonly repository: Repository, + ) {} + + /** The settings row, created at the seed rate on first access. */ + async get(): Promise { + const existing = await this.repository.findOne({ where: {} }); + if (existing) return existing; + + return this.repository.save( + this.repository.create({ + fallbackRate: SEED_FALLBACK_RATE, + fallbackSource: "AUTO", + lastSyncedAt: null, + }), + ); + } + + /** + * Reads the stored fallback for the exchange provider. Returns `null` on any + * failure so the provider falls through to its own static default rather + * than propagating a database error into a pricing call. + */ + async loadFallbackRate(): Promise { + try { + const { fallbackRate } = await this.get(); + return Number.isFinite(fallbackRate) && fallbackRate > 0 + ? fallbackRate + : null; + } catch (err) { + this.logger.warn( + `Could not read stored exchange fallback: ${(err as Error).message}`, + ); + return null; + } + } + + /** + * Records a freshly fetched live rate as the new fallback. Marked `AUTO`, + * overwriting a manual entry — a manual rate is a stopgap for while CBE is + * down, so a working CBE feed takes precedence again. + */ + async saveFallbackRate(rate: number): Promise { + const current = await this.get(); + await this.repository.update(current.id, { + fallbackRate: rate, + fallbackSource: "AUTO", + lastSyncedAt: new Date(), + updatedById: null, + }); + this.logger.log(`Exchange fallback synced from CBE: ${rate} ETB/USD`); + } + + /** Operator sets the fallback by hand, e.g. during a prolonged CBE outage. */ + async setManualRate( + rate: number, + updatedById?: string | null, + ): Promise { + const current = await this.get(); + await this.repository.update(current.id, { + fallbackRate: rate, + fallbackSource: "MANUAL", + updatedById: updatedById ?? null, + }); + this.logger.warn( + `Exchange fallback set manually to ${rate} ETB/USD by ${updatedById ?? "unknown user"}`, + ); + return this.get(); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts index 4011bc14f..d91ac2baf 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts @@ -1,8 +1,7 @@ import { Module, forwardRef } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { ExchangeModule, ExchangeOptions } from '@edr/api-common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { registerExchangeModule } from '../exchange-settings/exchange-module-options'; import { BillingModule } from '../billing/billing.module'; import { DocumentsModule } from '../billing/documents/documents.module'; import { FilesModule } from '../files/files.module'; @@ -78,11 +77,7 @@ import { WarehousesService } from './warehouses.service'; NotificationsModule, NotificationInboxModule, SignaturesModule, - ExchangeModule.forRootAsync({ - inject: [ConfigService], - useFactory: (config: ConfigService): ExchangeOptions => - config.get('app.cbeExchange') ?? {}, - }), + registerExchangeModule(), ], controllers: [ WarehousesController, diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx index 01bb5ceea..49aeb17cd 100644 --- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx @@ -153,11 +153,13 @@ const RuleEngineFormDialog = ({ buildInitialValues(fields, initialRecord), ); const [position, setPosition] = useState(RULE_ENGINE_POSITION_END); + const [fieldErrors, setFieldErrors] = useState>({}); useEffect(() => { if (open) { setValues(buildInitialValues(fields, initialRecord)); setPosition(RULE_ENGINE_POSITION_END); + setFieldErrors({}); } }, [open, fields, initialRecord]); @@ -185,6 +187,9 @@ const RuleEngineFormDialog = ({ const formRows = useMemo(() => buildFormRows(visibleFields), [visibleFields]); const setField = (name: string, value: unknown) => { + setFieldErrors((current) => + current[name] ? { ...current, [name]: "" } : current, + ); setValues((current) => { const next = { ...current, [name]: value }; // Changing what a rate applies to (or its surcharge trigger) can invalidate @@ -223,6 +228,10 @@ const RuleEngineFormDialog = ({ const handleSubmit = (event: React.FormEvent) => { event.preventDefault(); const payload: Record = {}; + // Required selects that are empty block the submit and mark themselves, + // rather than posting an incomplete payload for the API to reject. + setFieldErrors({}); + let blocked = false; for (const field of visibleFields) { // Derived fields always submit their computed value — never stale state. @@ -241,7 +250,16 @@ const RuleEngineFormDialog = ({ field.type === "select" && (raw === "" || raw === RULE_ENGINE_SELECT_NONE) ) { + // A required select left empty must not silently submit nothing — the + // API rejects the payload with a message that reads as if the admin + // skipped a field they never saw cleared (e.g. yards reset by a trade + // direction change). Surface it on the field instead. if (!field.required) continue; + setFieldErrors((current) => ({ + ...current, + [field.name]: `${field.label} is required.`, + })); + blocked = true; } else if (raw === "" || raw === undefined) { if (!field.required) continue; payload[field.name] = raw; @@ -254,6 +272,8 @@ const RuleEngineFormDialog = ({ payload.code = String(payload.code).toUpperCase(); } + if (blocked) return; + if (!initialRecord && positionOptions && position !== RULE_ENGINE_POSITION_END) { payload.insertAfterId = position; } @@ -340,10 +360,10 @@ const RuleEngineFormDialog = ({ value={resolveSelectValue(field, values)} onChange={(v) => setField(field.name, v === RULE_ENGINE_SELECT_NONE ? "" : v)} disabled={selectOptionsLoading} - // Native required blocks submit while a mandatory select is empty — - // without it the form posts and the API 400s (e.g. a container - // customs/lashing rate with no container type picked). + // Mantine's Select is not a native input, so `required` only marks it + // visually — handleSubmit is what actually blocks an empty one. required={field.required} + error={fieldErrors[field.name] || undefined} data={options .filter((opt) => opt.value !== "") .map((opt) => ({ diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 3855a5cde..e4f12038b 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -53,6 +53,10 @@ export const URL_CONSTANTS = { NOTIFICATIONS: "/settings/notifications", }, + EXCHANGE_SETTINGS: { + BASE: "/exchange-settings", + }, + DROPDOWN_SETTINGS: { BASE: "/dropdown-settings", BY_ID: (id: string) => `/api/dropdown-settings/${id}`, diff --git a/apps/edr-freight-web/backoffice/src/hooks/useExchangeSettings.ts b/apps/edr-freight-web/backoffice/src/hooks/useExchangeSettings.ts new file mode 100644 index 000000000..d5fca3c6e --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/hooks/useExchangeSettings.ts @@ -0,0 +1,34 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; + +import { exchangeSettingsService } from "@/services/exchangeSettings.service"; +import { useErrorHandler } from "@/shared/hooks/useErrorHandler"; + +const QUERY_KEY = ["exchangeSettings"]; + +export const useExchangeSettingsQuery = () => + useQuery({ + queryKey: QUERY_KEY, + queryFn: () => exchangeSettingsService.get(), + // Feed health is only interesting while it is being looked at. + staleTime: 30_000, + refetchOnWindowFocus: true, + }); + +export const useSetExchangeFallbackRate = () => { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + const { handleError } = useErrorHandler(t); + + return useMutation({ + mutationFn: (rate: number) => exchangeSettingsService.setFallbackRate(rate), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: QUERY_KEY }); + toast.success( + t("exchangeSettings.updated", "Fallback exchange rate updated"), + ); + }, + onError: handleError, + }); +}; diff --git a/apps/edr-freight-web/backoffice/src/pages/SettingsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/SettingsPage.tsx index 8f4281c14..d005064b0 100644 --- a/apps/edr-freight-web/backoffice/src/pages/SettingsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/SettingsPage.tsx @@ -33,6 +33,7 @@ import { AlertDialogTitle, } from "@/shared/common/ui/alert-dialog"; import { toast } from "sonner"; +import ExchangeRateSettingsCard from "./settings/ExchangeRateSettingsCard"; export default function SettingsPage() { const [createDialogOpen, setCreateDialogOpen] = useState(false); @@ -77,6 +78,8 @@ export default function SettingsPage() { return (
+ + diff --git a/apps/edr-freight-web/backoffice/src/pages/settings/ExchangeRateSettingsCard.tsx b/apps/edr-freight-web/backoffice/src/pages/settings/ExchangeRateSettingsCard.tsx new file mode 100644 index 000000000..47d3e8f89 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/settings/ExchangeRateSettingsCard.tsx @@ -0,0 +1,163 @@ +import { useState } from "react"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/shared/common/ui/card"; +import { Input } from "@/shared/common/ui/input"; +import { Button } from "@/shared/common/ui/button"; +import { AlertTriangle, CheckCircle2, RefreshCw, Save } from "lucide-react"; + +import { + useExchangeSettingsQuery, + useSetExchangeFallbackRate, +} from "@/hooks/useExchangeSettings"; +import type { ExchangeRateSource } from "@/services/exchangeSettings.service"; + +/** Feed health, phrased for an operator rather than a developer. */ +function feedLabel(source: ExchangeRateSource | null): { + live: boolean; + text: string; +} { + switch (source) { + case "live": + return { live: true, text: "CBE reachable — using the live rate" }; + case "cache": + return { live: true, text: "Using the rate cached from CBE" }; + case "stored": + return { + live: false, + text: "CBE unreachable — using the fallback rate below", + }; + case "default": + return { + live: false, + text: "CBE unreachable and no rate stored — using the built-in default", + }; + default: + return { live: true, text: "No rate requested yet since the last restart" }; + } +} + +const formatTime = (value: string | null) => + value ? new Date(value).toLocaleString() : "never"; + +/** + * USD→ETB fallback used when the CBE exchange-rate endpoint is unreachable. + * The live CBE rate always wins; every successful fetch overwrites the stored + * value, so it tracks the last known good rate on its own. Editing here is for + * a prolonged outage — the next successful CBE fetch replaces it. + */ +export default function ExchangeRateSettingsCard() { + const { data, isLoading, refetch, isFetching } = useExchangeSettingsQuery(); + const setRate = useSetExchangeFallbackRate(); + const [draft, setDraft] = useState(""); + + const value = draft !== "" ? draft : (data?.fallbackRate?.toString() ?? ""); + const parsed = Number(value); + const invalid = !Number.isFinite(parsed) || parsed < 1 || parsed > 10_000; + const dirty = draft !== "" && parsed !== data?.fallbackRate; + + const feed = feedLabel(data?.feed?.source ?? null); + + const handleSave = async () => { + if (invalid) return; + await setRate.mutateAsync(parsed); + setDraft(""); + }; + + return ( + + +
+
+ Exchange rate (USD → ETB) + + Rates come from the Commercial Bank of Ethiopia. The fallback + below is used only when CBE cannot be reached, and is refreshed + automatically after every successful update. + +
+ +
+
+ + +
+ {feed.live ? ( + + ) : ( + + )} +
+

{feed.text}

+ {data?.feed?.rate != null && ( +

Rate in use: {data.feed.rate} ETB per USD

+ )} +

+ Last successful update: {formatTime(data?.feed?.lastSuccessAt ?? null)} +

+ {data?.feed?.lastError && ( +

Last error: {data.feed.lastError}

+ )} +
+
+ +
+ +
+ setDraft(e.target.value)} + /> + +
+ {invalid && draft !== "" && ( +

+ Enter a rate between 1 and 10,000. +

+ )} +

+ {data?.fallbackSource === "MANUAL" + ? "Set manually. The next successful CBE update will replace it." + : `Synced automatically from CBE (${formatTime( + data?.lastSyncedAt ?? null, + )}).`} +

+
+
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/services/exchangeSettings.service.ts b/apps/edr-freight-web/backoffice/src/services/exchangeSettings.service.ts new file mode 100644 index 000000000..2bb975b91 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/exchangeSettings.service.ts @@ -0,0 +1,40 @@ +import { api as client } from "../auth/http"; +import { unwrap } from "@/utils/endpoint"; +import { URL_CONSTANTS } from "@/constants/URLS"; +import type { ApiResponse } from "@/types/apiResponse"; + +const BASE = URL_CONSTANTS.EXCHANGE_SETTINGS.BASE; + +/** Where the rate the API last served came from. */ +export type ExchangeRateSource = "live" | "cache" | "stored" | "default"; + +/** Health of the CBE exchange-rate feed. */ +export interface ExchangeFeedStatus { + rate: number | null; + source: ExchangeRateSource | null; + lastSuccessAt: string | null; + lastError: string | null; +} + +export interface ExchangeSettings { + fallbackRate: number; + /** `AUTO` when synced from CBE, `MANUAL` when set here. */ + fallbackSource: "AUTO" | "MANUAL"; + lastSyncedAt: string | null; + updatedById: string | null; + feed?: ExchangeFeedStatus; +} + +export const exchangeSettingsService = { + get: async (): Promise => { + const response = await client.get>(BASE); + return unwrap(response.data); + }, + + setFallbackRate: async (fallbackRate: number): Promise => { + const response = await client.patch>(BASE, { + fallbackRate, + }); + return unwrap(response.data); + }, +}; diff --git a/packages/api-common/src/services/exchange/cbe.provider.ts b/packages/api-common/src/services/exchange/cbe.provider.ts index 489b1a4e3..3aa4fadba 100644 --- a/packages/api-common/src/services/exchange/cbe.provider.ts +++ b/packages/api-common/src/services/exchange/cbe.provider.ts @@ -1,30 +1,62 @@ import { Logger } from "@nestjs/common"; -import { EXCHANGE_DEFAULTS, ExchangeOptions } from "./exchange.options"; +import { + EXCHANGE_DEFAULTS, + ExchangeOptions, + ResolvedExchangeOptions, +} from "./exchange.options"; import { CurrencyPair, ExchangeRateProvider, } from "./exchange.types"; -/** Matches USD buying/selling embedded in ethio.forex CBET page HTML (after entity unescape). */ -const USD_RATE_REGEX = - /currency_code":\[0,"USD"\],"currency_name":\[0,"US DOLLAR"\],"buying":\[0,([\d.]+)\],"selling":\[0,([\d.]+)\]/; +/** One currency's rates within a daily record returned by the CBE endpoint. */ +interface CbeExchangeRateEntry { + transactionalSelling?: number | string | null; + transactionalBuying?: number | string | null; + currency?: { CurrencyCode?: string | null } | null; +} + +/** A single day's record from the CBE `daily-exchange-rates` endpoint. */ +interface CbeDailyRecord { + Date?: string | null; + ExchangeRate?: CbeExchangeRateEntry[] | null; +} + +/** Where the most recently served rate came from. */ +export type CbeRateSource = "live" | "cache" | "stored" | "default"; + +/** Health of the CBE feed, for operator-facing status displays. */ +export interface CbeProviderStatus { + /** The rate most recently served, whatever its source. */ + rate: number | null; + /** Where that rate came from. `live` means the API answered. */ + source: CbeRateSource | null; + /** Epoch ms of the last successful live fetch, or `null` if never. */ + lastSuccessAt: number | null; + /** Message from the most recent failed fetch, cleared on success. */ + lastError: string | null; +} /** - * Central Bank of Ethiopia (CBE) rate provider. + * Commercial Bank of Ethiopia (CBE) rate provider. * - * Sources a single canonical direction — **USD→ETB** (selling rate) — by - * scraping ethio.forex, caching the result, and falling back to a configured - * rate when the scrape fails. The inverse (ETB→USD) is derived by - * {@link ExchangeService}, so this provider only ever reports USD→ETB. + * Sources a single canonical direction — **USD→ETB** (transactional selling + * rate) — from CBE's public `daily-exchange-rates` JSON endpoint, caching the + * result and falling back to a configured rate when the fetch fails. The + * inverse (ETB→USD) is derived by {@link ExchangeService}, so this provider + * only ever reports USD→ETB. */ export class CbeExchangeProvider implements ExchangeRateProvider { readonly name = "CBE"; private readonly logger = new Logger(CbeExchangeProvider.name); - private readonly options: Required; + private readonly options: ResolvedExchangeOptions; private cachedRate: number | null = null; private cacheExpiresAt = 0; + private lastSuccessAt: number | null = null; + private lastError: string | null = null; + private lastSource: CbeRateSource | null = null; constructor(options: ExchangeOptions) { this.options = { ...EXCHANGE_DEFAULTS, ...stripUndefined(options) }; @@ -38,15 +70,29 @@ export class CbeExchangeProvider implements ExchangeRateProvider { return this.getUsdToEtbRate(); } + /** Health of the CBE feed — what was served last, and whether it is failing. */ + getStatus(): CbeProviderStatus { + return { + rate: this.cachedRate, + source: this.lastSource, + lastSuccessAt: this.lastSuccessAt, + lastError: this.lastError, + }; + } + /** - * Returns the current CBE USD→ETB **selling** rate scraped from ethio.forex. - * Cached for `cacheTtlMs`; on failure reuses the last cached rate, else - * returns `fallbackRate`. + * Returns the current CBE USD→ETB **transactional selling** rate. + * + * Cached for `cacheTtlMs`. On a successful fetch the rate is written back via + * `saveFallbackRate`, so the stored fallback is never more than one good + * fetch stale. On failure the chain is: cached rate → `loadFallbackRate()` + * → static `fallbackRate`. */ private async getUsdToEtbRate(): Promise { const now = Date.now(); if (this.cachedRate !== null && now < this.cacheExpiresAt) { + this.lastSource = "cache"; return this.cachedRate; } @@ -56,68 +102,133 @@ export class CbeExchangeProvider implements ExchangeRateProvider { try { const response = await fetch(scrapeUrl, { signal: AbortSignal.timeout(requestTimeoutMs), - headers: { "User-Agent": "Mozilla/5.0" }, + headers: { Accept: "application/json", "User-Agent": "Mozilla/5.0" }, }); if (!response.ok) { - throw new Error(`CBE scrape responded with status ${response.status}`); + throw new Error(`CBE rates responded with status ${response.status}`); } - const html = await response.text(); - const rates = this.parseScrapedRates(html); + const payload = (await response.json()) as unknown; + const day = this.latestRecord(payload); - if (!rates) { - throw new Error("USD rate not found in ethio.forex page HTML"); + if (!day) { + throw new Error("CBE rates payload contained no daily record"); } - const rate = rates.selling; - if (!Number.isFinite(rate) || rate <= 0) { - throw new Error(`Invalid selling rate parsed: ${rate}`); + const rate = this.parseUsdRate(day); + + if (rate === null) { + throw new Error( + `USD transactionalSelling not found in CBE record for ${day.Date ?? "unknown date"}`, + ); } + const previous = this.cachedRate; this.cachedRate = rate; this.cacheExpiresAt = now + cacheTtlMs; + this.lastSuccessAt = now; + this.lastError = null; + this.lastSource = "live"; this.logger.log( - `CBE USD→ETB rate refreshed from ethio.forex — buying=${rates.buying} selling=${rate}`, - ); - return rate; - } catch (err) { - this.logger.error( - `Failed to scrape CBE exchange rate — using fallback ${fallbackRate} ETB/USD. Error: ${(err as Error).message}`, + `CBE USD→ETB rate refreshed — transactionalSelling=${rate} (date=${day.Date ?? "unknown"})`, ); + // Persist as the new fallback so a later outage reuses the last good + // rate. Skipped when unchanged, to avoid pointless writes and audit noise. + if (rate !== previous) { + await this.persistFallback(rate); + } + + return rate; + } catch (err) { + const message = (err as Error).message; + this.lastError = message; + this.logger.error(`Failed to fetch CBE exchange rate. Error: ${message}`); + if (this.cachedRate !== null) { + this.lastSource = "cache"; this.logger.warn( `Using previously cached CBE rate: ${this.cachedRate}`, ); return this.cachedRate; } + const stored = await this.loadStoredFallback(); + if (stored !== null) { + this.lastSource = "stored"; + this.logger.warn(`Using stored fallback CBE rate: ${stored}`); + return stored; + } + + this.lastSource = "default"; + this.logger.warn(`Using default fallback CBE rate: ${fallbackRate}`); return fallbackRate; } } - private parseScrapedRates( - html: string, - ): { buying: number; selling: number } | null { - const decoded = this.unescapeHtml(html); - const match = USD_RATE_REGEX.exec(decoded); - if (!match) return null; + /** + * Writes a freshly fetched rate back as the stored fallback. Failures are + * logged and swallowed: persisting the fallback is housekeeping, and must + * never fail the pricing call that triggered it. + */ + private async persistFallback(rate: number): Promise { + const { saveFallbackRate } = this.options; + if (!saveFallbackRate) return; - const buying = Number(match[1]); - const selling = Number(match[2]); - if (!Number.isFinite(buying) || !Number.isFinite(selling)) return null; - - return { buying, selling }; + try { + await saveFallbackRate(rate); + } catch (err) { + this.logger.warn( + `Failed to persist CBE fallback rate ${rate}: ${(err as Error).message}`, + ); + } } - private unescapeHtml(html: string): string { - return html - .replace(/"/g, '"') - .replace(/"/g, '"') - .replace(/&/g, "&") - .replace(/</g, "<") - .replace(/>/g, ">"); + /** + * Reads the persisted fallback. Returns `null` — falling through to the + * static default — when unconfigured, unusable, or itself failing. + */ + private async loadStoredFallback(): Promise { + const { loadFallbackRate } = this.options; + if (!loadFallbackRate) return null; + + try { + const stored = await loadFallbackRate(); + const rate = Number(stored); + return Number.isFinite(rate) && rate > 0 ? rate : null; + } catch (err) { + this.logger.warn( + `Failed to load stored CBE fallback rate: ${(err as Error).message}`, + ); + return null; + } + } + + /** + * The endpoint returns an array of daily records (one when `_limit=1`), but + * tolerate a bare object in case the shape changes. + */ + private latestRecord(payload: unknown): CbeDailyRecord | null { + const record = Array.isArray(payload) ? payload[0] : payload; + return record && typeof record === "object" + ? (record as CbeDailyRecord) + : null; + } + + /** + * Pulls USD `transactionalSelling` out of a daily record. Returns `null` when + * the entry is missing or the value isn't a usable positive number — CBE + * publishes `0`/`null` for currencies it isn't quoting that day. + */ + private parseUsdRate(day: CbeDailyRecord): number | null { + const usd = day.ExchangeRate?.find( + (entry) => entry?.currency?.CurrencyCode === "USD", + ); + if (!usd) return null; + + const rate = Number(usd.transactionalSelling); + return Number.isFinite(rate) && rate > 0 ? rate : null; } } diff --git a/packages/api-common/src/services/exchange/exchange.options.ts b/packages/api-common/src/services/exchange/exchange.options.ts index e009b5f99..6ccf83489 100644 --- a/packages/api-common/src/services/exchange/exchange.options.ts +++ b/packages/api-common/src/services/exchange/exchange.options.ts @@ -4,18 +4,39 @@ export const EXCHANGE_OPTIONS = Symbol("EXCHANGE_OPTIONS"); /** Configuration for the {@link ExchangeService} and its CBE provider. */ export interface ExchangeOptions { /** - * ethio.forex CBET page scraped for USD buying/selling rates. - * @default 'https://ethio.forex/bank/CBET' + * CBE daily-exchange-rates JSON endpoint. Returns an array of daily records; + * `_limit=1&_sort=Date%3ADESC` narrows it to the most recent day. + * @default 'https://combanketh.et/cbeapi/daily-exchange-rates/?_limit=1&_sort=Date%3ADESC' */ scrapeUrl?: string; /** - * Base USD→ETB rate used when scraping fails and no previously cached rate - * exists. The ETB→USD direction is derived as its inverse. - * @default 130 + * Last-resort USD→ETB rate, used only when the fetch fails, no cached rate + * exists, and {@link loadFallbackRate} supplies nothing. The ETB→USD + * direction is derived as its inverse. + * @default 162 */ fallbackRate?: number; + /** + * Reads the persisted fallback rate — the last known good CBE rate, or one + * set by an operator. Consulted only when the live fetch fails and no cached + * rate is available; a `null` result falls through to {@link fallbackRate}. + * + * Optional: omit it and the provider uses the static `fallbackRate` alone. + */ + loadFallbackRate?: () => Promise; + + /** + * Persists a freshly fetched live rate as the new fallback, so the stored + * value is never more than one successful fetch stale. Called after every + * successful fetch that produced a changed rate. + * + * Failures here are logged and swallowed — persisting the fallback must + * never break the pricing call that triggered it. + */ + saveFallbackRate?: (rate: number) => Promise; + /** * How long a successfully fetched rate is cached, in milliseconds. * @default 3_600_000 (1 hour) @@ -23,16 +44,23 @@ export interface ExchangeOptions { cacheTtlMs?: number; /** - * Timeout for the scrape HTTP request, in milliseconds. + * Timeout for the rate HTTP request, in milliseconds. * @default 8_000 */ requestTimeoutMs?: number; } -/** Defaults applied to any unset {@link ExchangeOptions} field. */ -export const EXCHANGE_DEFAULTS: Required = { - scrapeUrl: "https://ethio.forex/bank/CBET", - fallbackRate: 130, +/** The scalar options, all resolved — the callbacks stay genuinely optional. */ +export type ResolvedExchangeOptions = Required< + Omit +> & + Pick; + +/** Defaults applied to any unset scalar {@link ExchangeOptions} field. */ +export const EXCHANGE_DEFAULTS: ResolvedExchangeOptions = { + scrapeUrl: + "https://combanketh.et/cbeapi/daily-exchange-rates/?_limit=1&_sort=Date%3ADESC", + fallbackRate: 162, cacheTtlMs: 3_600_000, requestTimeoutMs: 8_000, }; diff --git a/packages/api-common/src/services/exchange/exchange.service.ts b/packages/api-common/src/services/exchange/exchange.service.ts index c1efdd923..135bafb6d 100644 --- a/packages/api-common/src/services/exchange/exchange.service.ts +++ b/packages/api-common/src/services/exchange/exchange.service.ts @@ -1,6 +1,6 @@ import { Inject, Injectable } from "@nestjs/common"; -import { CbeExchangeProvider } from "./cbe.provider"; +import { CbeExchangeProvider, CbeProviderStatus } from "./cbe.provider"; import { EXCHANGE_OPTIONS, ExchangeOptions } from "./exchange.options"; import { CurrencyCode } from "./exchange.types"; @@ -47,6 +47,14 @@ export class ExchangeService { ); } + /** + * Health of the underlying rate feed — what was served last and whether it + * is currently failing. For operator-facing status displays. + */ + getProviderStatus(): CbeProviderStatus { + return this.provider.getStatus(); + } + /** Converts `amount` from one currency to another using {@link getRate}. */ async convert( amount: number, diff --git a/packages/api-common/src/services/exchange/index.ts b/packages/api-common/src/services/exchange/index.ts index c5e9c960a..f2f88891a 100644 --- a/packages/api-common/src/services/exchange/index.ts +++ b/packages/api-common/src/services/exchange/index.ts @@ -1,8 +1,13 @@ export { ExchangeService } from "./exchange.service"; export { ExchangeModule } from "./exchange.module"; export { CbeExchangeProvider } from "./cbe.provider"; +export type { CbeProviderStatus, CbeRateSource } from "./cbe.provider"; export { EXCHANGE_OPTIONS, EXCHANGE_DEFAULTS } from "./exchange.options"; -export type { ExchangeOptions, ExchangeAsyncOptions } from "./exchange.options"; +export type { + ExchangeOptions, + ExchangeAsyncOptions, + ResolvedExchangeOptions, +} from "./exchange.options"; export type { CurrencyCode, CurrencyPair,