Merge pull request #1107 from Tria-plc/freight_feature/usermanagement

Freight feature/usermanagement
This commit is contained in:
marshal
2026-08-04 14:40:41 +03:00
committed by GitHub
25 changed files with 878 additions and 93 deletions

View File

@@ -46,6 +46,7 @@ import { NotificationInboxModule } from "./modules/notification-inbox/notificati
import { SupportChatModule } from "./modules/support-chat/support-chat.module"; import { SupportChatModule } from "./modules/support-chat/support-chat.module";
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module"; import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-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 { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module";
import { OtpModule } from "./modules/otp/otp.module"; import { OtpModule } from "./modules/otp/otp.module";
import { HealthModule } from "./modules/health/health.module"; import { HealthModule } from "./modules/health/health.module";
@@ -192,6 +193,7 @@ import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middlewar
SupportChatModule, SupportChatModule,
FileUploadSettingsModule, FileUploadSettingsModule,
DropdownSettingsModule, DropdownSettingsModule,
ExchangeSettingsModule,
ContractTemplatesModule, ContractTemplatesModule,
OtpModule, OtpModule,
HealthModule, HealthModule,

View File

@@ -24,12 +24,13 @@ export default registerAs("app", () => ({
}, },
// Consumed by @edr/api-common ExchangeModule.forRootAsync (see bookings.module.ts). // Consumed by @edr/api-common ExchangeModule.forRootAsync (see bookings.module.ts).
cbeExchange: { cbeExchange: {
/** ethio.forex CBET page — scraped for USD buying/selling rates. */ /** CBE daily-exchange-rates JSON — USD `transactionalSelling` is used. */
scrapeUrl: scrapeUrl:
process.env.CBE_EXCHANGE_SCRAPE_URL ?? process.env.CBE_EXCHANGE_SCRAPE_URL ??
process.env.CBE_EXCHANGE_API_URL ?? process.env.CBE_EXCHANGE_API_URL ??
"https://ethio.forex/bank/CBET", "https://combanketh.et/cbeapi/daily-exchange-rates/?_limit=1&_sort=Date%3ADESC",
fallbackRate: numberFromEnv("CBE_EXCHANGE_FALLBACK_RATE", 130), // 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), cacheTtlMs: numberFromEnv("CBE_EXCHANGE_CACHE_TTL_MS", 3_600_000),
}, },
})); }));

View File

@@ -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);
}
}

View File

@@ -1,8 +1,8 @@
import { Module, forwardRef } from "@nestjs/common"; import { Module, forwardRef } from "@nestjs/common";
import { UserTradeAccessModule } from "../user-trade-access/user-trade-access.module"; import { UserTradeAccessModule } from "../user-trade-access/user-trade-access.module";
import { ConfigService } from "@nestjs/config";
import { TypeOrmModule } from "@nestjs/typeorm"; 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 { CustomersModule } from '../customers/customers.module';
import { CompaniesModule } from '../companies/companies.module'; import { CompaniesModule } from '../companies/companies.module';
@@ -86,11 +86,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
RuleEngineModule, RuleEngineModule,
FileUploadSettingsModule, FileUploadSettingsModule,
SignaturesModule, SignaturesModule,
ExchangeModule.forRootAsync({ registerExchangeModule(),
inject: [ConfigService],
useFactory: (config: ConfigService): ExchangeOptions =>
config.get<ExchangeOptions>("app.cbeExchange") ?? {},
}),
], ],
controllers: [BookingsController], controllers: [BookingsController],
providers: [ providers: [

View File

@@ -1,9 +1,8 @@
import { Module, forwardRef } from '@nestjs/common'; import { Module, forwardRef } from '@nestjs/common';
import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module'; import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module';
import { ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm'; 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 { BillingModule } from '../billing/billing.module';
import { CompaniesModule } from '../companies/companies.module'; import { CompaniesModule } from '../companies/companies.module';
import { FilesModule } from '../files/files.module'; import { FilesModule } from '../files/files.module';
@@ -102,11 +101,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
// by ContractBookingService.createUnderContract. forwardRef because // by ContractBookingService.createUnderContract. forwardRef because
// TrainSchedulingModule already imports ContractsModule. // TrainSchedulingModule already imports ContractsModule.
forwardRef(() => TrainSchedulingModule), forwardRef(() => TrainSchedulingModule),
ExchangeModule.forRootAsync({ registerExchangeModule(),
inject: [ConfigService],
useFactory: (config: ConfigService): ExchangeOptions =>
config.get<ExchangeOptions>('app.cbeExchange') ?? {},
}),
], ],
controllers: [ContractsController, GlExchangeController], controllers: [ContractsController, GlExchangeController],
providers: [ providers: [

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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),
}),
});
}

View File

@@ -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,
};
}
}

View File

@@ -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 {}

View File

@@ -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();
}
}

View File

@@ -1,8 +1,7 @@
import { Module, forwardRef } from '@nestjs/common'; import { Module, forwardRef } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ExchangeModule, ExchangeOptions } from '@edr/api-common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { registerExchangeModule } from '../exchange-settings/exchange-module-options';
import { BillingModule } from '../billing/billing.module'; import { BillingModule } from '../billing/billing.module';
import { DocumentsModule } from '../billing/documents/documents.module'; import { DocumentsModule } from '../billing/documents/documents.module';
import { FilesModule } from '../files/files.module'; import { FilesModule } from '../files/files.module';
@@ -78,11 +77,7 @@ import { WarehousesService } from './warehouses.service';
NotificationsModule, NotificationsModule,
NotificationInboxModule, NotificationInboxModule,
SignaturesModule, SignaturesModule,
ExchangeModule.forRootAsync({ registerExchangeModule(),
inject: [ConfigService],
useFactory: (config: ConfigService): ExchangeOptions =>
config.get<ExchangeOptions>('app.cbeExchange') ?? {},
}),
], ],
controllers: [ controllers: [
WarehousesController, WarehousesController,

View File

@@ -153,11 +153,13 @@ const RuleEngineFormDialog = ({
buildInitialValues(fields, initialRecord), buildInitialValues(fields, initialRecord),
); );
const [position, setPosition] = useState(RULE_ENGINE_POSITION_END); const [position, setPosition] = useState(RULE_ENGINE_POSITION_END);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setValues(buildInitialValues(fields, initialRecord)); setValues(buildInitialValues(fields, initialRecord));
setPosition(RULE_ENGINE_POSITION_END); setPosition(RULE_ENGINE_POSITION_END);
setFieldErrors({});
} }
}, [open, fields, initialRecord]); }, [open, fields, initialRecord]);
@@ -185,6 +187,9 @@ const RuleEngineFormDialog = ({
const formRows = useMemo(() => buildFormRows(visibleFields), [visibleFields]); const formRows = useMemo(() => buildFormRows(visibleFields), [visibleFields]);
const setField = (name: string, value: unknown) => { const setField = (name: string, value: unknown) => {
setFieldErrors((current) =>
current[name] ? { ...current, [name]: "" } : current,
);
setValues((current) => { setValues((current) => {
const next = { ...current, [name]: value }; const next = { ...current, [name]: value };
// Changing what a rate applies to (or its surcharge trigger) can invalidate // Changing what a rate applies to (or its surcharge trigger) can invalidate
@@ -223,6 +228,10 @@ const RuleEngineFormDialog = ({
const handleSubmit = (event: React.FormEvent) => { const handleSubmit = (event: React.FormEvent) => {
event.preventDefault(); event.preventDefault();
const payload: Record<string, unknown> = {}; 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) { for (const field of visibleFields) {
// Derived fields always submit their computed value — never stale state. // Derived fields always submit their computed value — never stale state.
@@ -241,7 +250,16 @@ const RuleEngineFormDialog = ({
field.type === "select" && field.type === "select" &&
(raw === "" || raw === RULE_ENGINE_SELECT_NONE) (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; if (!field.required) continue;
setFieldErrors((current) => ({
...current,
[field.name]: `${field.label} is required.`,
}));
blocked = true;
} else if (raw === "" || raw === undefined) { } else if (raw === "" || raw === undefined) {
if (!field.required) continue; if (!field.required) continue;
payload[field.name] = raw; payload[field.name] = raw;
@@ -254,6 +272,8 @@ const RuleEngineFormDialog = ({
payload.code = String(payload.code).toUpperCase(); payload.code = String(payload.code).toUpperCase();
} }
if (blocked) return;
if (!initialRecord && positionOptions && position !== RULE_ENGINE_POSITION_END) { if (!initialRecord && positionOptions && position !== RULE_ENGINE_POSITION_END) {
payload.insertAfterId = position; payload.insertAfterId = position;
} }
@@ -340,10 +360,10 @@ const RuleEngineFormDialog = ({
value={resolveSelectValue(field, values)} value={resolveSelectValue(field, values)}
onChange={(v) => setField(field.name, v === RULE_ENGINE_SELECT_NONE ? "" : v)} onChange={(v) => setField(field.name, v === RULE_ENGINE_SELECT_NONE ? "" : v)}
disabled={selectOptionsLoading} disabled={selectOptionsLoading}
// Native required blocks submit while a mandatory select is empty — // Mantine's Select is not a native input, so `required` only marks it
// without it the form posts and the API 400s (e.g. a container // visually — handleSubmit is what actually blocks an empty one.
// customs/lashing rate with no container type picked).
required={field.required} required={field.required}
error={fieldErrors[field.name] || undefined}
data={options data={options
.filter((opt) => opt.value !== "") .filter((opt) => opt.value !== "")
.map((opt) => ({ .map((opt) => ({

View File

@@ -53,6 +53,10 @@ export const URL_CONSTANTS = {
NOTIFICATIONS: "/settings/notifications", NOTIFICATIONS: "/settings/notifications",
}, },
EXCHANGE_SETTINGS: {
BASE: "/exchange-settings",
},
DROPDOWN_SETTINGS: { DROPDOWN_SETTINGS: {
BASE: "/dropdown-settings", BASE: "/dropdown-settings",
BY_ID: (id: string) => `/api/dropdown-settings/${id}`, BY_ID: (id: string) => `/api/dropdown-settings/${id}`,

View File

@@ -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,
});
};

View File

@@ -33,6 +33,7 @@ import {
AlertDialogTitle, AlertDialogTitle,
} from "@/shared/common/ui/alert-dialog"; } from "@/shared/common/ui/alert-dialog";
import { toast } from "sonner"; import { toast } from "sonner";
import ExchangeRateSettingsCard from "./settings/ExchangeRateSettingsCard";
export default function SettingsPage() { export default function SettingsPage() {
const [createDialogOpen, setCreateDialogOpen] = useState(false); const [createDialogOpen, setCreateDialogOpen] = useState(false);
@@ -77,6 +78,8 @@ export default function SettingsPage() {
return ( return (
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
<ExchangeRateSettingsCard />
<Card className="shadow-lg border-gray-200 dark:border-gray-700"> <Card className="shadow-lg border-gray-200 dark:border-gray-700">
<CardHeader> <CardHeader>
<CardTitle className="text-xl font-semibold"> <CardTitle className="text-xl font-semibold">

View File

@@ -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>
);
}

View File

@@ -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);
},
};

View File

@@ -265,8 +265,10 @@ function NewShipmentBookingForm({
// still flip it per shipment. // still flip it per shipment.
withReturn: contract.equipmentReturn === "WITH_RETURN", withReturn: contract.equipmentReturn === "WITH_RETURN",
// The contract quotes USD; the customer bills this shipment in the // The contract quotes USD; the customer bills this shipment in the
// currency they pick here. Intercity is always ETB. // currency they pick here. Intercity is always ETB, so it is preset;
paymentCurrency: contract.tradeDirection === "DOMESTIC" ? "ETB" : "USD", // everything else starts empty so the customer picks deliberately
// instead of silently inheriting USD.
paymentCurrency: contract.tradeDirection === "DOMESTIC" ? "ETB" : "",
}, },
resolver: zodResolver( resolver: zodResolver(
createShipmentFormSchema({ createShipmentFormSchema({
@@ -340,7 +342,11 @@ function NewShipmentBookingForm({
...(values.contractRouteId ...(values.contractRouteId
? { contractRouteId: values.contractRouteId } ? { contractRouteId: values.contractRouteId }
: {}), : {}),
paymentCurrency: values.paymentCurrency, // Validation guarantees a currency by here; the guard keeps an empty
// value out of the payload rather than tripping the API's @IsIn check.
...(values.paymentCurrency
? { paymentCurrency: values.paymentCurrency }
: {}),
// Intercity bookings carry no date — staff assign a passing train later. // Intercity bookings carry no date — staff assign a passing train later.
...(values.scheduledDate ...(values.scheduledDate
? { scheduledDate: new Date(values.scheduledDate).toISOString() } ? { scheduledDate: new Date(values.scheduledDate).toISOString() }
@@ -1131,23 +1137,32 @@ function ScheduleStep({
<Controller <Controller
name="paymentCurrency" name="paymentCurrency"
control={form.control} control={form.control}
render={({ field }) => ( render={({ field, fieldState }) => (
<Box mb="lg"> <Box mb="lg">
<StepLabel>Billing currency *</StepLabel> <StepLabel>Billing currency *</StepLabel>
<Text fz={12.5} c="dimmed" mt={4} mb={10}> <Text fz={12.5} c="dimmed" mt={4} mb={10}>
Your contract is quoted in USD. Pick the currency this shipment is Your contract is quoted in USD. Pick the currency this shipment is
invoiced in the total is converted for you. invoiced in the total is converted for you.
</Text> </Text>
{/* Rendered unselected until the customer chooses: SegmentedControl
highlights whatever value it is given, so passing a fallback
here would look like a made choice. */}
<SegmentedControl <SegmentedControl
value={field.value ?? "USD"} value={field.value || ""}
onChange={(v) => field.onChange(v)} onChange={(v) => field.onChange(v)}
data={[ data={[
{ label: "Select…", value: "", disabled: true },
{ label: "USD", value: "USD" }, { label: "USD", value: "USD" },
{ label: "ETB", value: "ETB" }, { label: "ETB", value: "ETB" },
]} ]}
color="edr-green" color="edr-green"
radius={10} radius={10}
/> />
{fieldState.error && (
<Text fz={12.5} c="red.6" mt={6}>
{fieldState.error.message}
</Text>
)}
</Box> </Box>
)} )}
/> />

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { createShipmentFormSchema, initialShipmentFormValues } from "./schema";
const schema = createShipmentFormSchema({
isContainer: false,
isHazardous: false,
isReefer: false,
requiresDate: false,
});
const values = (over: Record<string, unknown> = {}) => ({
...initialShipmentFormValues,
cargoWeightTons: "10",
...over,
});
const currencyIssues = (input: Record<string, unknown>) => {
const result = schema.safeParse(input);
return result.success
? []
: result.error.issues.filter((i) => i.path[0] === "paymentCurrency");
};
describe("paymentCurrency validation", () => {
it("defaults to empty rather than silently picking USD", () => {
expect(initialShipmentFormValues.paymentCurrency ?? "").toBe("");
});
it("rejects a submit with no currency chosen", () => {
const issues = currencyIssues(values({ paymentCurrency: "" }));
expect(issues).toHaveLength(1);
expect(issues[0].message).toBe("Select the billing currency for this shipment.");
});
it("accepts either currency once chosen", () => {
expect(currencyIssues(values({ paymentCurrency: "USD" }))).toHaveLength(0);
expect(currencyIssues(values({ paymentCurrency: "ETB" }))).toHaveLength(0);
});
});

View File

@@ -76,8 +76,9 @@ const shipmentFormBase = z.object({
// EXPORT rail: the specific train picked for the shipment day (schedule id). // EXPORT rail: the specific train picked for the shipment day (schedule id).
trainScheduleId: z.string().default(""), trainScheduleId: z.string().default(""),
// The contract quotes in USD; the customer picks the billing currency for // The contract quotes in USD; the customer picks the billing currency for
// THIS shipment. Intercity is forced to ETB (server-enforced too). // THIS shipment. Starts empty so the choice is deliberate — validated as
paymentCurrency: z.enum(["USD", "ETB"]).default("USD"), // required below. Intercity is forced to ETB (server-enforced too).
paymentCurrency: z.enum(["USD", "ETB", ""]).default(""),
// Container contracts only: return the empty container(s) to EDR after // Container contracts only: return the empty container(s) to EDR after
// unloading. Seeded from the contract's equipment return; bulk ignores it. // unloading. Seeded from the contract's equipment return; bulk ignores it.
withReturn: z.boolean().default(false), withReturn: z.boolean().default(false),
@@ -101,6 +102,15 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) {
}); });
} }
// No default currency — the customer must pick one before submitting.
if (!data.paymentCurrency) {
refineCtx.addIssue({
code: "custom",
path: ["paymentCurrency"],
message: "Select the billing currency for this shipment.",
});
}
if (ctx.isContainer) { if (ctx.isContainer) {
// Containerized cargo must say WHAT is inside — required per booking. // Containerized cargo must say WHAT is inside — required per booking.
if (!data.cargoDescription.trim()) { if (!data.cargoDescription.trim()) {
@@ -311,6 +321,6 @@ export const shipmentStepFields: Record<
"bulkReeferQuantity", "bulkReeferQuantity",
"withReturn", "withReturn",
], ],
2: ["scheduledDate"], 2: ["paymentCurrency", "scheduledDate"],
3: ["notes"], 3: ["notes"],
}; };

View File

@@ -1,30 +1,62 @@
import { Logger } from "@nestjs/common"; import { Logger } from "@nestjs/common";
import { EXCHANGE_DEFAULTS, ExchangeOptions } from "./exchange.options"; import {
EXCHANGE_DEFAULTS,
ExchangeOptions,
ResolvedExchangeOptions,
} from "./exchange.options";
import { import {
CurrencyPair, CurrencyPair,
ExchangeRateProvider, ExchangeRateProvider,
} from "./exchange.types"; } from "./exchange.types";
/** Matches USD buying/selling embedded in ethio.forex CBET page HTML (after entity unescape). */ /** One currency's rates within a daily record returned by the CBE endpoint. */
const USD_RATE_REGEX = interface CbeExchangeRateEntry {
/currency_code":\[0,"USD"\],"currency_name":\[0,"US DOLLAR"\],"buying":\[0,([\d.]+)\],"selling":\[0,([\d.]+)\]/; 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 * Sources a single canonical direction — **USD→ETB** (transactional selling
* scraping ethio.forex, caching the result, and falling back to a configured * rate) — from CBE's public `daily-exchange-rates` JSON endpoint, caching the
* rate when the scrape fails. The inverse (ETB→USD) is derived by * result and falling back to a configured rate when the fetch fails. The
* {@link ExchangeService}, so this provider only ever reports USD→ETB. * inverse (ETB→USD) is derived by {@link ExchangeService}, so this provider
* only ever reports USD→ETB.
*/ */
export class CbeExchangeProvider implements ExchangeRateProvider { export class CbeExchangeProvider implements ExchangeRateProvider {
readonly name = "CBE"; readonly name = "CBE";
private readonly logger = new Logger(CbeExchangeProvider.name); private readonly logger = new Logger(CbeExchangeProvider.name);
private readonly options: Required<ExchangeOptions>; private readonly options: ResolvedExchangeOptions;
private cachedRate: number | null = null; private cachedRate: number | null = null;
private cacheExpiresAt = 0; private cacheExpiresAt = 0;
private lastSuccessAt: number | null = null;
private lastError: string | null = null;
private lastSource: CbeRateSource | null = null;
constructor(options: ExchangeOptions) { constructor(options: ExchangeOptions) {
this.options = { ...EXCHANGE_DEFAULTS, ...stripUndefined(options) }; this.options = { ...EXCHANGE_DEFAULTS, ...stripUndefined(options) };
@@ -38,15 +70,29 @@ export class CbeExchangeProvider implements ExchangeRateProvider {
return this.getUsdToEtbRate(); 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. * Returns the current CBE USD→ETB **transactional selling** rate.
* Cached for `cacheTtlMs`; on failure reuses the last cached rate, else *
* returns `fallbackRate`. * 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> { private async getUsdToEtbRate(): Promise<number> {
const now = Date.now(); const now = Date.now();
if (this.cachedRate !== null && now < this.cacheExpiresAt) { if (this.cachedRate !== null && now < this.cacheExpiresAt) {
this.lastSource = "cache";
return this.cachedRate; return this.cachedRate;
} }
@@ -56,68 +102,133 @@ export class CbeExchangeProvider implements ExchangeRateProvider {
try { try {
const response = await fetch(scrapeUrl, { const response = await fetch(scrapeUrl, {
signal: AbortSignal.timeout(requestTimeoutMs), signal: AbortSignal.timeout(requestTimeoutMs),
headers: { "User-Agent": "Mozilla/5.0" }, headers: { Accept: "application/json", "User-Agent": "Mozilla/5.0" },
}); });
if (!response.ok) { 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 payload = (await response.json()) as unknown;
const rates = this.parseScrapedRates(html); const day = this.latestRecord(payload);
if (!rates) { if (!day) {
throw new Error("USD rate not found in ethio.forex page HTML"); throw new Error("CBE rates payload contained no daily record");
} }
const rate = rates.selling; const rate = this.parseUsdRate(day);
if (!Number.isFinite(rate) || rate <= 0) {
throw new Error(`Invalid selling rate parsed: ${rate}`); 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.cachedRate = rate;
this.cacheExpiresAt = now + cacheTtlMs; this.cacheExpiresAt = now + cacheTtlMs;
this.lastSuccessAt = now;
this.lastError = null;
this.lastSource = "live";
this.logger.log( this.logger.log(
`CBE USD→ETB rate refreshed from ethio.forex — buying=${rates.buying} selling=${rate}`, `CBE USD→ETB rate refreshed — transactionalSelling=${rate} (date=${day.Date ?? "unknown"})`,
);
return rate;
} catch (err) {
this.logger.error(
`Failed to scrape CBE exchange rate — using fallback ${fallbackRate} ETB/USD. Error: ${(err as Error).message}`,
); );
// 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) { if (this.cachedRate !== null) {
this.lastSource = "cache";
this.logger.warn( this.logger.warn(
`Using previously cached CBE rate: ${this.cachedRate}`, `Using previously cached CBE rate: ${this.cachedRate}`,
); );
return 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; return fallbackRate;
} }
} }
private parseScrapedRates( /**
html: string, * Writes a freshly fetched rate back as the stored fallback. Failures are
): { buying: number; selling: number } | null { * logged and swallowed: persisting the fallback is housekeeping, and must
const decoded = this.unescapeHtml(html); * never fail the pricing call that triggered it.
const match = USD_RATE_REGEX.exec(decoded); */
if (!match) return null; private async persistFallback(rate: number): Promise<void> {
const { saveFallbackRate } = this.options;
if (!saveFallbackRate) return;
const buying = Number(match[1]); try {
const selling = Number(match[2]); await saveFallbackRate(rate);
if (!Number.isFinite(buying) || !Number.isFinite(selling)) return null; } catch (err) {
this.logger.warn(
return { buying, selling }; `Failed to persist CBE fallback rate ${rate}: ${(err as Error).message}`,
);
}
} }
private unescapeHtml(html: string): string { /**
return html * Reads the persisted fallback. Returns `null` — falling through to the
.replace(/&quot;/g, '"') * static default — when unconfigured, unusable, or itself failing.
.replace(/&#34;/g, '"') */
.replace(/&amp;/g, "&") private async loadStoredFallback(): Promise<number | null> {
.replace(/&lt;/g, "<") const { loadFallbackRate } = this.options;
.replace(/&gt;/g, ">"); 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;
} }
} }

View File

@@ -4,18 +4,39 @@ export const EXCHANGE_OPTIONS = Symbol("EXCHANGE_OPTIONS");
/** Configuration for the {@link ExchangeService} and its CBE provider. */ /** Configuration for the {@link ExchangeService} and its CBE provider. */
export interface ExchangeOptions { export interface ExchangeOptions {
/** /**
* ethio.forex CBET page scraped for USD buying/selling rates. * CBE daily-exchange-rates JSON endpoint. Returns an array of daily records;
* @default 'https://ethio.forex/bank/CBET' * `_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; scrapeUrl?: string;
/** /**
* Base USD→ETB rate used when scraping fails and no previously cached rate * Last-resort USD→ETB rate, used only when the fetch fails, no cached rate
* exists. The ETB→USD direction is derived as its inverse. * exists, and {@link loadFallbackRate} supplies nothing. The ETB→USD
* @default 130 * direction is derived as its inverse.
* @default 162
*/ */
fallbackRate?: number; 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. * How long a successfully fetched rate is cached, in milliseconds.
* @default 3_600_000 (1 hour) * @default 3_600_000 (1 hour)
@@ -23,16 +44,23 @@ export interface ExchangeOptions {
cacheTtlMs?: number; cacheTtlMs?: number;
/** /**
* Timeout for the scrape HTTP request, in milliseconds. * Timeout for the rate HTTP request, in milliseconds.
* @default 8_000 * @default 8_000
*/ */
requestTimeoutMs?: number; requestTimeoutMs?: number;
} }
/** Defaults applied to any unset {@link ExchangeOptions} field. */ /** The scalar options, all resolved — the callbacks stay genuinely optional. */
export const EXCHANGE_DEFAULTS: Required<ExchangeOptions> = { export type ResolvedExchangeOptions = Required<
scrapeUrl: "https://ethio.forex/bank/CBET", Omit<ExchangeOptions, "loadFallbackRate" | "saveFallbackRate">
fallbackRate: 130, > &
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, cacheTtlMs: 3_600_000,
requestTimeoutMs: 8_000, requestTimeoutMs: 8_000,
}; };

View File

@@ -1,6 +1,6 @@
import { Inject, Injectable } from "@nestjs/common"; 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 { EXCHANGE_OPTIONS, ExchangeOptions } from "./exchange.options";
import { CurrencyCode } from "./exchange.types"; 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}. */ /** Converts `amount` from one currency to another using {@link getRate}. */
async convert( async convert(
amount: number, amount: number,

View File

@@ -1,8 +1,13 @@
export { ExchangeService } from "./exchange.service"; export { ExchangeService } from "./exchange.service";
export { ExchangeModule } from "./exchange.module"; export { ExchangeModule } from "./exchange.module";
export { CbeExchangeProvider } from "./cbe.provider"; export { CbeExchangeProvider } from "./cbe.provider";
export type { CbeProviderStatus, CbeRateSource } from "./cbe.provider";
export { EXCHANGE_OPTIONS, EXCHANGE_DEFAULTS } from "./exchange.options"; 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 { export type {
CurrencyCode, CurrencyCode,
CurrencyPair, CurrencyPair,