implement exchange settings management and fallback rate handling

This commit is contained in:
Marshal
2026-08-04 11:22:47 +00:00
parent 7c78a815eb
commit 56697d8fc5
22 changed files with 805 additions and 85 deletions

View File

@@ -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: [

View File

@@ -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: [

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 { 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,