mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
implement exchange settings management and fallback rate handling
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
await queryRunner.dropTable('freight.exchange_settings', true);
|
||||
}
|
||||
}
|
||||
@@ -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<ExchangeOptions>("app.cbeExchange") ?? {},
|
||||
}),
|
||||
registerExchangeModule(),
|
||||
],
|
||||
controllers: [BookingsController],
|
||||
providers: [
|
||||
|
||||
@@ -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<ExchangeOptions>('app.cbeExchange') ?? {},
|
||||
}),
|
||||
registerExchangeModule(),
|
||||
],
|
||||
controllers: [ContractsController, GlExchangeController],
|
||||
providers: [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<ExchangeOptions>("app.cbeExchange") ?? {}),
|
||||
loadFallbackRate: () => settings.loadFallbackRate(),
|
||||
saveFallbackRate: (rate: number) => settings.saveFallbackRate(rate),
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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<ExchangeSetting>,
|
||||
) {}
|
||||
|
||||
/** The settings row, created at the seed rate on first access. */
|
||||
async get(): Promise<ExchangeSetting> {
|
||||
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<number | null> {
|
||||
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<void> {
|
||||
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<ExchangeSetting> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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<ExchangeOptions>('app.cbeExchange') ?? {},
|
||||
}),
|
||||
registerExchangeModule(),
|
||||
],
|
||||
controllers: [
|
||||
WarehousesController,
|
||||
|
||||
@@ -153,11 +153,13 @@ const RuleEngineFormDialog = ({
|
||||
buildInitialValues(fields, initialRecord),
|
||||
);
|
||||
const [position, setPosition] = useState(RULE_ENGINE_POSITION_END);
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||
|
||||
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<string, unknown> = {};
|
||||
// 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) => ({
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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 (
|
||||
<div className="p-6 space-y-6">
|
||||
<ExchangeRateSettingsCard />
|
||||
|
||||
<Card className="shadow-lg border-gray-200 dark:border-gray-700">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl font-semibold">
|
||||
|
||||
@@ -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<string>("");
|
||||
|
||||
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 (
|
||||
<Card className="shadow-lg border-gray-200 dark:border-gray-700">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>Exchange rate (USD → ETB)</CardTitle>
|
||||
<CardDescription>
|
||||
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.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
<div
|
||||
className={`flex items-start gap-2 rounded-md border p-3 text-sm ${
|
||||
feed.live
|
||||
? "border-green-200 bg-green-50 text-green-900 dark:border-green-900 dark:bg-green-950 dark:text-green-100"
|
||||
: "border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100"
|
||||
}`}
|
||||
>
|
||||
{feed.live ? (
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
) : (
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">{feed.text}</p>
|
||||
{data?.feed?.rate != null && (
|
||||
<p>Rate in use: {data.feed.rate} ETB per USD</p>
|
||||
)}
|
||||
<p className="opacity-80">
|
||||
Last successful update: {formatTime(data?.feed?.lastSuccessAt ?? null)}
|
||||
</p>
|
||||
{data?.feed?.lastError && (
|
||||
<p className="opacity-80">Last error: {data.feed.lastError}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="fallback-rate">
|
||||
Fallback rate (ETB per USD)
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="fallback-rate"
|
||||
type="number"
|
||||
step="0.0001"
|
||||
min={1}
|
||||
max={10000}
|
||||
className="max-w-[220px]"
|
||||
disabled={isLoading}
|
||||
value={value}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!dirty || invalid || setRate.isPending}
|
||||
>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
{invalid && draft !== "" && (
|
||||
<p className="text-sm text-red-600">
|
||||
Enter a rate between 1 and 10,000.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{data?.fallbackSource === "MANUAL"
|
||||
? "Set manually. The next successful CBE update will replace it."
|
||||
: `Synced automatically from CBE (${formatTime(
|
||||
data?.lastSyncedAt ?? null,
|
||||
)}).`}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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<ExchangeSettings> => {
|
||||
const response = await client.get<ApiResponse<ExchangeSettings>>(BASE);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
setFallbackRate: async (fallbackRate: number): Promise<ExchangeSettings> => {
|
||||
const response = await client.patch<ApiResponse<ExchangeSettings>>(BASE, {
|
||||
fallbackRate,
|
||||
});
|
||||
return unwrap(response.data);
|
||||
},
|
||||
};
|
||||
@@ -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<ExchangeOptions>;
|
||||
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<number> {
|
||||
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<void> {
|
||||
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<number | null> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<number | null>;
|
||||
|
||||
/**
|
||||
* 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<void>;
|
||||
|
||||
/**
|
||||
* 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<ExchangeOptions> = {
|
||||
scrapeUrl: "https://ethio.forex/bank/CBET",
|
||||
fallbackRate: 130,
|
||||
/** The scalar options, all resolved — the callbacks stay genuinely optional. */
|
||||
export type ResolvedExchangeOptions = Required<
|
||||
Omit<ExchangeOptions, "loadFallbackRate" | "saveFallbackRate">
|
||||
> &
|
||||
Pick<ExchangeOptions, "loadFallbackRate" | "saveFallbackRate">;
|
||||
|
||||
/** 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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user