mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 16:00:56 +00:00
Merge branch 'dev' into fixes
This commit is contained in:
@@ -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: [
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
CONTRACT_TEMPLATE_CODES,
|
||||
contractTemplateCodeFor,
|
||||
} from './entities/contract-template.entity';
|
||||
import { CONTRACT_TEMPLATE_DEFAULTS } from '../../seed/data/contract-template-defaults';
|
||||
|
||||
describe('contractTemplateCodeFor', () => {
|
||||
it('splits import and export by the customs flag', () => {
|
||||
expect(contractTemplateCodeFor('IMPORT', 'BULK', true)).toBe('IMPORT_BULK_CUSTOMS');
|
||||
expect(contractTemplateCodeFor('IMPORT', 'BULK', false)).toBe('IMPORT_BULK_NO_CUSTOMS');
|
||||
expect(contractTemplateCodeFor('EXPORT', 'CONTAINER', true)).toBe(
|
||||
'EXPORT_CONTAINER_CUSTOMS',
|
||||
);
|
||||
expect(contractTemplateCodeFor('EXPORT', 'CONTAINER', false)).toBe(
|
||||
'EXPORT_CONTAINER_NO_CUSTOMS',
|
||||
);
|
||||
});
|
||||
|
||||
it('never gives intercity a customs variant — it crosses no border', () => {
|
||||
for (const flag of [true, false, null, undefined]) {
|
||||
expect(contractTemplateCodeFor('DOMESTIC', 'BULK', flag)).toBe('INTERCITY_BULK');
|
||||
expect(contractTemplateCodeFor('DOMESTIC', 'CONTAINER', flag)).toBe(
|
||||
'INTERCITY_CONTAINER',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('treats a missing customs flag as no customs on cross-border contracts', () => {
|
||||
expect(contractTemplateCodeFor('IMPORT', 'CONTAINER', null)).toBe(
|
||||
'IMPORT_CONTAINER_NO_CUSTOMS',
|
||||
);
|
||||
expect(contractTemplateCodeFor('IMPORT', 'CONTAINER', undefined)).toBe(
|
||||
'IMPORT_CONTAINER_NO_CUSTOMS',
|
||||
);
|
||||
});
|
||||
|
||||
it('only ever resolves to a code that exists', () => {
|
||||
const directions = ['IMPORT', 'EXPORT', 'DOMESTIC', null];
|
||||
const freights = ['BULK', 'CONTAINER', 'BREAK_BULK', null];
|
||||
for (const d of directions) {
|
||||
for (const f of freights) {
|
||||
for (const c of [true, false]) {
|
||||
expect(CONTRACT_TEMPLATE_CODES).toContain(contractTemplateCodeFor(d, f, c));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('CONTRACT_TEMPLATE_DEFAULTS', () => {
|
||||
it('seeds exactly the ten declared codes, once each', () => {
|
||||
const seeded = CONTRACT_TEMPLATE_DEFAULTS.map((t) => t.code).sort();
|
||||
expect(seeded).toHaveLength(10);
|
||||
expect(seeded).toEqual([...CONTRACT_TEMPLATE_CODES].sort());
|
||||
});
|
||||
|
||||
it('gives every _CUSTOMS template the customs articles and no other one', () => {
|
||||
for (const seed of CONTRACT_TEMPLATE_DEFAULTS) {
|
||||
const hasCustomsArticle = seed.articles.some((a) => a.id === 'customs-clearing');
|
||||
// Note "_NO_CUSTOMS" also ends with "_CUSTOMS" — exclude it explicitly.
|
||||
const isCustomsVariant =
|
||||
seed.code.endsWith('_CUSTOMS') && !seed.code.endsWith('_NO_CUSTOMS');
|
||||
expect(hasCustomsArticle).toBe(isCustomsVariant);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -25,11 +25,19 @@ function seededTemplate(code: string): ContractTemplate {
|
||||
}
|
||||
|
||||
describe("contractTemplateCodeFor", () => {
|
||||
it("maps every direction/freight pair to one of the six codes", () => {
|
||||
expect(contractTemplateCodeFor("IMPORT", "BULK")).toBe("IMPORT_BULK");
|
||||
expect(contractTemplateCodeFor("EXPORT", "CONTAINER")).toBe("EXPORT_CONTAINER");
|
||||
expect(contractTemplateCodeFor("DOMESTIC", "CONTAINER")).toBe("INTERCITY_CONTAINER");
|
||||
expect(contractTemplateCodeFor("DOMESTIC", "BULK")).toBe("INTERCITY_BULK");
|
||||
it("maps every direction/freight/customs triple to one of the ten codes", () => {
|
||||
expect(contractTemplateCodeFor("IMPORT", "BULK", true)).toBe("IMPORT_BULK_CUSTOMS");
|
||||
expect(contractTemplateCodeFor("IMPORT", "BULK", false)).toBe(
|
||||
"IMPORT_BULK_NO_CUSTOMS",
|
||||
);
|
||||
expect(contractTemplateCodeFor("EXPORT", "CONTAINER", true)).toBe(
|
||||
"EXPORT_CONTAINER_CUSTOMS",
|
||||
);
|
||||
// Intercity is domestic — no border, so no customs variant either way.
|
||||
expect(contractTemplateCodeFor("DOMESTIC", "CONTAINER", true)).toBe(
|
||||
"INTERCITY_CONTAINER",
|
||||
);
|
||||
expect(contractTemplateCodeFor("DOMESTIC", "BULK", false)).toBe("INTERCITY_BULK");
|
||||
expect(contractTemplateCodeFor(null, null)).toBe("INTERCITY_CONTAINER");
|
||||
});
|
||||
});
|
||||
@@ -60,7 +68,7 @@ describe("ContractTemplatesService.preview", () => {
|
||||
);
|
||||
|
||||
it("interpolates {{contractYear}} inside seeded article bodies", async () => {
|
||||
const { html } = await service.preview("IMPORT_BULK");
|
||||
const { html } = await service.preview("IMPORT_BULK_CUSTOMS");
|
||||
expect(html).toContain(`August 31, ${new Date().getFullYear()}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,13 +24,22 @@ import {
|
||||
contractTemplateCodeFor,
|
||||
} from "./entities/contract-template.entity";
|
||||
|
||||
/** Registry keys used to derive labels for the mock preview per template code. */
|
||||
/**
|
||||
* Registry keys used to derive labels for the mock preview per template code.
|
||||
* The registry's FORWARDING scope carries the customs/clearing clause pack, so
|
||||
* the `_CUSTOMS` codes preview against it and `_NO_CUSTOMS` against
|
||||
* TRANSPORT_ONLY.
|
||||
*/
|
||||
const PREVIEW_TEMPLATE_KEYS: Record<ContractTemplateCode, string> = {
|
||||
IMPORT_BULK: "IMP_BULK_USD_FORWARDING",
|
||||
EXPORT_BULK: "EXP_BULK_USD_TRANSPORT_ONLY",
|
||||
IMPORT_BULK_CUSTOMS: "IMP_BULK_USD_FORWARDING",
|
||||
IMPORT_BULK_NO_CUSTOMS: "IMP_BULK_USD_TRANSPORT_ONLY",
|
||||
EXPORT_BULK_CUSTOMS: "EXP_BULK_USD_FORWARDING",
|
||||
EXPORT_BULK_NO_CUSTOMS: "EXP_BULK_USD_TRANSPORT_ONLY",
|
||||
INTERCITY_BULK: "DOM_BULK_USD_TRANSPORT_ONLY",
|
||||
IMPORT_CONTAINER: "IMP_CON_USD_TRANSPORT_ONLY",
|
||||
EXPORT_CONTAINER: "EXP_CON_USD_FORWARDING",
|
||||
IMPORT_CONTAINER_CUSTOMS: "IMP_CON_USD_FORWARDING",
|
||||
IMPORT_CONTAINER_NO_CUSTOMS: "IMP_CON_USD_TRANSPORT_ONLY",
|
||||
EXPORT_CONTAINER_CUSTOMS: "EXP_CON_USD_FORWARDING",
|
||||
EXPORT_CONTAINER_NO_CUSTOMS: "EXP_CON_USD_TRANSPORT_ONLY",
|
||||
INTERCITY_CONTAINER: "DOM_CON_USD_TRANSPORT_ONLY",
|
||||
};
|
||||
|
||||
@@ -59,14 +68,19 @@ export class ContractTemplatesService {
|
||||
|
||||
/**
|
||||
* The active template used when generating a contract document for the given
|
||||
* direction/freight pair; null when missing or deactivated (the renderer then
|
||||
* falls back to the built-in generic layout).
|
||||
* direction/freight/customs triple; null when missing or deactivated (the
|
||||
* renderer then falls back to the built-in generic layout).
|
||||
*/
|
||||
async findActiveForContract(
|
||||
tradeDirection?: string | null,
|
||||
freightType?: string | null,
|
||||
customsClearingEnabled?: boolean | null,
|
||||
): Promise<ContractTemplate | null> {
|
||||
const code = contractTemplateCodeFor(tradeDirection, freightType);
|
||||
const code = contractTemplateCodeFor(
|
||||
tradeDirection,
|
||||
freightType,
|
||||
customsClearingEnabled,
|
||||
);
|
||||
const template = await this.repository.findByCode(code);
|
||||
return template?.isActive ? template : null;
|
||||
}
|
||||
|
||||
@@ -2,17 +2,30 @@ import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity, Index } from "typeorm";
|
||||
|
||||
/**
|
||||
* The six canonical contract document templates, one per
|
||||
* (trade direction × freight type) combination. Contracts store DOMESTIC for
|
||||
* intercity movements; the template layer labels those INTERCITY to match the
|
||||
* commercial vocabulary used on the printed documents.
|
||||
* The ten canonical contract document templates. Import and export split by
|
||||
* customs clearing (× freight type = 8); intercity does not, because it is a
|
||||
* purely domestic Ethiopian movement that crosses no border and therefore has
|
||||
* no customs leg at all (× freight type = 2).
|
||||
*
|
||||
* Contracts store DOMESTIC for intercity movements; the template layer labels
|
||||
* those INTERCITY to match the commercial vocabulary used on the printed
|
||||
* documents.
|
||||
*
|
||||
* The `_CUSTOMS` variant is issued when the contract has customs clearing
|
||||
* enabled (the Service Provider clears in Djibouti/Ethiopia on the Client's
|
||||
* behalf); `_NO_CUSTOMS` is the transport-only paper, where the Client handles
|
||||
* its own declarations.
|
||||
*/
|
||||
export const CONTRACT_TEMPLATE_CODES = [
|
||||
"IMPORT_BULK",
|
||||
"EXPORT_BULK",
|
||||
"IMPORT_BULK_CUSTOMS",
|
||||
"IMPORT_BULK_NO_CUSTOMS",
|
||||
"EXPORT_BULK_CUSTOMS",
|
||||
"EXPORT_BULK_NO_CUSTOMS",
|
||||
"INTERCITY_BULK",
|
||||
"IMPORT_CONTAINER",
|
||||
"EXPORT_CONTAINER",
|
||||
"IMPORT_CONTAINER_CUSTOMS",
|
||||
"IMPORT_CONTAINER_NO_CUSTOMS",
|
||||
"EXPORT_CONTAINER_CUSTOMS",
|
||||
"EXPORT_CONTAINER_NO_CUSTOMS",
|
||||
"INTERCITY_CONTAINER",
|
||||
] as const;
|
||||
|
||||
@@ -33,10 +46,19 @@ export interface ContractTemplateArticle {
|
||||
order: number;
|
||||
}
|
||||
|
||||
/** Map a contract's stored direction/freight pair onto a template code. */
|
||||
/**
|
||||
* Map a contract's stored direction/freight/customs triple onto a template
|
||||
* code. `customsClearingEnabled` is treated as false when absent so an older
|
||||
* contract row with a null flag still resolves to a real template rather than
|
||||
* falling through to the generic layout.
|
||||
*
|
||||
* Intercity is domestic and has no customs leg, so it resolves to a single
|
||||
* unsuffixed code regardless of the flag.
|
||||
*/
|
||||
export function contractTemplateCodeFor(
|
||||
tradeDirection?: string | null,
|
||||
freightType?: string | null,
|
||||
customsClearingEnabled?: boolean | null,
|
||||
): ContractTemplateCode {
|
||||
const direction =
|
||||
tradeDirection === "IMPORT"
|
||||
@@ -46,7 +68,11 @@ export function contractTemplateCodeFor(
|
||||
: "INTERCITY";
|
||||
const freight =
|
||||
(freightType ?? "").toUpperCase().includes("BULK") ? "BULK" : "CONTAINER";
|
||||
return `${direction}_${freight}` as ContractTemplateCode;
|
||||
if (direction === "INTERCITY") {
|
||||
return `INTERCITY_${freight}` as ContractTemplateCode;
|
||||
}
|
||||
const customs = customsClearingEnabled ? "CUSTOMS" : "NO_CUSTOMS";
|
||||
return `${direction}_${freight}_${customs}` as ContractTemplateCode;
|
||||
}
|
||||
|
||||
@Entity({ schema: "freight", name: "contract_templates" })
|
||||
|
||||
@@ -423,6 +423,7 @@ export class ContractTransitionService {
|
||||
const active = await this.contractTemplates.findActiveForContract(
|
||||
contract.tradeDirection,
|
||||
contract.freightType,
|
||||
contract.customsClearingEnabled,
|
||||
);
|
||||
if (!active) return null;
|
||||
return {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user