Files
edr-platform/apps/edr-freight-api/src/modules/exchange-settings/entities/exchange-setting.entity.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

59 lines
2.0 KiB
TypeScript

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;
/**
* Whether Djiboutian Franc may be chosen as a billing currency.
*
* Off by default: DJF pricing goes live only once Finance says so, so enabling it is a
* deliberate act rather than something a deploy switches on. The rate itself is always
* available (CBE quotes DJF in the same payload as USD) — this gates the *offer*, not
* the conversion.
*/
@Column({ name: "djf_enabled", type: "boolean", default: false })
djfEnabled!: boolean;
/** IAM user id of the last operator to set the rate manually. */
@Column({ name: "updated_by_id", type: "uuid", nullable: true })
updatedById?: string | null;
}