Files
edr-platform/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts
Nathnael f1d1cfb127 feat(billing): accept DJF as a billing currency, behind a toggle
Adds DJF to the currencies a booking or shipment can be billed in, off by
default: going live with it is Finance's call, not a deploy's.

Schema. Every currency column in freight is already a varchar that fits
'DJF' except payments.currency, the schema's one enum-typed currency
column, which would reject the value outright — so the migration adds the
enum label. PG 12+ allows ADD VALUE inside a transaction (migrations run
with transaction mode 'each') as long as the new label is not used in the
same one, and nothing here inserts it. down() drops only the flags:
Postgres cannot remove an enum label, and trying would orphan any row
already written with it.

Two flags, because they answer different questions. exchange_settings
.djf_enabled gates whether DJF is offered at all and starts off.
manual_payment_settings.djf_enabled gates bank-transfer settlement and
starts on — a DJF invoice has to be settleable the day the first one is
raised, which is exactly why USD has always started on.

Enforcement. class-validator cannot see a database flag, so the static
whitelists admit DJF and ExchangeSettingsService.assertCurrencyAllowed()
decides whether it is live. It is called where a currency is chosen —
booking create and update, and shipment creation under a contract — not
where one is read. Only the requested currency is checked, never the
resolved one, so switching DJF off stops new choices instead of bricking
shipment creation on contracts already written in it. Contract creation
needs no check: contracts are always quoted in USD and ignore a
client-supplied currency.

resolveShipmentCurrency stays pure and synchronous; a checked async
wrapper sits beside it, resolved before the insert callbacks (which are
sync, and re-run on a reference collision) rather than inside them.

GET /exchange-settings/currencies is readable by customers as well as
staff, so the portal's picker can offer exactly what the API will accept
instead of a hardcoded pair that fails on submit. PATCH now leaves an
omitted field alone, so flipping the toggle does not re-stamp the fallback
rate as MANUAL as a side effect.
2026-08-29 08:12:52 +00:00

85 lines
2.8 KiB
TypeScript

import { Body, Controller, Get, Patch } from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { CurrentUser } from "@edr/api-common";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { BookingStaff, MixedAudience } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
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) {}
@Get()
@BookingStaff([FREIGHT_PERMS.settings.exchangeRate.view, FREIGHT_PERMS.admin])
@ApiOperation({
summary: "Current USD→ETB fallback rate and CBE feed health",
})
async get() {
const setting = await this.service.get();
const status = this.service.getFeedStatus();
return {
fallbackRate: setting.fallbackRate,
fallbackSource: setting.fallbackSource,
lastSyncedAt: setting.lastSyncedAt,
updatedById: setting.updatedById,
djfEnabled: setting.djfEnabled,
feed: status,
};
}
@Get("currencies")
@MixedAudience([FREIGHT_PERMS.settings.exchangeRate.view, FREIGHT_PERMS.admin])
@ApiOperation({
summary: "Currencies a booking or contract may be billed in right now",
})
async currencies() {
// Customers reach this too — the portal's currency picker must offer exactly what the
// API will accept, or the choice fires and comes back a 400.
return { currencies: await this.service.enabledCurrencies() };
}
@Patch()
@BookingStaff([FREIGHT_PERMS.settings.exchangeRate.manage, FREIGHT_PERMS.admin])
@ApiOperation({
summary:
"Set the USD→ETB fallback by hand (used only while CBE is unreachable) " +
"and/or turn DJF billing on and off",
})
async update(
@Body() dto: UpdateExchangeSettingDto,
@CurrentUser() user: TCurrentUser,
) {
// An omitted field leaves that setting alone — setting the rate stamps it MANUAL, and
// toggling DJF must not do that as a side effect.
let updated = await this.service.get();
if (dto.fallbackRate !== undefined) {
updated = await this.service.setManualRate(
dto.fallbackRate,
user?.id ?? null,
);
}
if (dto.djfEnabled !== undefined) {
updated = await this.service.setDjfEnabled(
dto.djfEnabled,
user?.id ?? null,
);
}
return {
fallbackRate: updated.fallbackRate,
fallbackSource: updated.fallbackSource,
lastSyncedAt: updated.lastSyncedAt,
updatedById: updated.updatedById,
djfEnabled: updated.djfEnabled,
};
}
}