diff --git a/apps/edr-freight-api/src/migrations/3790000000000-DjfCurrencySupport.ts b/apps/edr-freight-api/src/migrations/3790000000000-DjfCurrencySupport.ts new file mode 100644 index 000000000..3e2ffa1b1 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3790000000000-DjfCurrencySupport.ts @@ -0,0 +1,46 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Djiboutian Franc as a billing currency. + * + * Every currency column in `freight` is already a `varchar(5)`/`varchar(8)` that fits + * `'DJF'` — except `payments.currency`, which is the schema's one enum-typed currency + * column and would reject the value outright. + * + * The two `djf_enabled` flags are the operator gates: DJF is off for booking/contract + * pricing until Finance turns it on, but manual (bank-transfer) settlement is on from the + * start, mirroring how USD has always behaved — a DJF invoice must be settleable the day + * the first one is raised, not once gateway routing is proven. + */ +export class DjfCurrencySupport3790000000000 implements MigrationInterface { + name = 'DjfCurrencySupport3790000000000'; + + public async up(queryRunner: QueryRunner): Promise { + // PG 12+ permits ADD VALUE inside a transaction (migrationsTransactionMode: 'each') + // provided the new label is not USED in the same transaction — nothing below inserts it. + await queryRunner.query( + `ALTER TYPE "freight"."payments_currency_enum" ADD VALUE IF NOT EXISTS 'DJF'`, + ); + + await queryRunner.query(` + ALTER TABLE "freight"."exchange_settings" + ADD COLUMN IF NOT EXISTS "djf_enabled" boolean NOT NULL DEFAULT false + `); + + await queryRunner.query(` + ALTER TABLE "freight"."manual_payment_settings" + ADD COLUMN IF NOT EXISTS "djf_enabled" boolean NOT NULL DEFAULT true + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // The enum label stays: Postgres cannot drop one, and any row already written with it + // would be orphaned by the attempt. Dropping the flags is enough to disable the feature. + await queryRunner.query( + `ALTER TABLE "freight"."manual_payment_settings" DROP COLUMN IF EXISTS "djf_enabled"`, + ); + await queryRunner.query( + `ALTER TABLE "freight"."exchange_settings" DROP COLUMN IF EXISTS "djf_enabled"`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 71107650f..bf10019ed 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -36,6 +36,7 @@ import { Contract } from '../contracts/entities/contract.entity'; import { BookingBatchService } from '../train-scheduling/booking-batch.service'; import { paymentDrainEndsAtIso } from '../train-scheduling/booking-batch.constants'; import { BookingContractService } from './booking-contract.service'; +import { ExchangeSettingsService } from '../exchange-settings/exchange-settings.service'; import { BookingsRepository } from './bookings.repository'; import { ConsolidationService } from './consolidation.service'; import { VehiclesService } from '../vehicles/vehicles.service'; @@ -162,6 +163,7 @@ export class BookingsService { private readonly bookingContractService: BookingContractService, @Inject(forwardRef(() => BookingBatchService)) private readonly bookingBatchService: BookingBatchService, + private readonly exchangeSettings: ExchangeSettingsService, ) {} async assignCustomerTruck( @@ -1064,6 +1066,10 @@ export class BookingsService { ); } + // The DTO whitelist is static, so it admits every currency the platform knows about; + // whether one is being *offered* right now is a database flag. + await this.exchangeSettings.assertCurrencyAllowed(dto.paymentCurrency); + // let customerId = dto.customerId; // if (!customerId) { // if (!userId) { @@ -1437,6 +1443,10 @@ export class BookingsService { ); } + // Only what the caller is actually changing: a booking already priced in a currency + // that has since been switched off must stay editable in every other respect. + await this.exchangeSettings.assertCurrencyAllowed(dto.paymentCurrency); + const warnings: string[] = []; const freightType = (dto.freightType ?? existing.freightType) as FreightType; let containers = diff --git a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts index a9aca53dd..82058caad 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts @@ -20,11 +20,16 @@ import { } from 'class-validator'; import { BOOKING_STATUSES, BOOKING_TYPES, FREIGHT_TYPES } from '../entities/booking.entity'; import { BookingFreightShapeConstraint } from './validators/booking-freight.validator'; +import { PAYMENT_CURRENCIES as SHARED_PAYMENT_CURRENCIES } from '@edr/types'; const CONTRACT_TYPES = ['NEW', 'RENEWAL'] as const; const EQUIPMENT_RETURNS = ['WITH_RETURN', 'WITHOUT_RETURN', 'NA'] as const; const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const; -const PAYMENT_CURRENCIES = ['ETB', 'USD'] as const; +// Re-exported from @edr/types so this list and the one the exchange service, the portal +// forms and the payment API validate against cannot drift apart — they already had. +// Availability is a separate question: DJF is in the whitelist but gated at runtime by +// ExchangeSettingsService.assertCurrencyAllowed(). +const PAYMENT_CURRENCIES = SHARED_PAYMENT_CURRENCIES; export { BOOKING_STATUSES, diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts index 565164995..671c9fe25 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts @@ -31,6 +31,7 @@ describe('ContractBookingService — quantity-cap completion', () => { {} as never, // bookingBatchService {} as never, // bookingTransitionService {} as never, // consolidationApprovalService + { assertCurrencyAllowed: jest.fn() } as never, // exchangeSettings ); return { service, contractsRepository }; } @@ -158,6 +159,7 @@ describe('ContractBookingService — quantity-cap completion', () => { {} as never, {} as never, {} as never, // consolidationApprovalService + { assertCurrencyAllowed: jest.fn() } as never, // exchangeSettings ); return { service, contractsRepository }; } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts index fb68b3f5c..8ae1e4eb8 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts @@ -65,6 +65,7 @@ describe('ContractBookingService — drawdown consolidation gate', () => { {} as never, // bookingBatchService {} as never, // bookingTransitionService {} as never, // consolidationApprovalService + { assertCurrencyAllowed: jest.fn() } as never, // exchangeSettings ); return { service, diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.initiate-gate.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.initiate-gate.spec.ts index 6155eb583..f43cf4869 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.initiate-gate.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.initiate-gate.spec.ts @@ -27,6 +27,7 @@ describe('ContractBookingService — customs booking gate', () => { {} as never, // bookingBatchService {} as never, // bookingTransitionService {} as never, // consolidationApprovalService + { assertCurrencyAllowed: jest.fn() } as never, // exchangeSettings ); } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.manual-consolidation.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.manual-consolidation.spec.ts index 49e77627f..b0dc4f097 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.manual-consolidation.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.manual-consolidation.spec.ts @@ -47,6 +47,7 @@ describe('ContractBookingService — manual odd-20ft consolidation', () => { // The pairing is parked for approval rather than going straight to // Operations; the gate itself is covered by its own spec. { requestApproval: jest.fn().mockResolvedValue({ id: 'ap-1' }) } as never, + { assertCurrencyAllowed: jest.fn() } as never, // exchangeSettings ); return { service, bookingsRepository, dataSource }; } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.resubmit-cargo.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.resubmit-cargo.spec.ts index 0a892b108..2f697ab10 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.resubmit-cargo.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.resubmit-cargo.spec.ts @@ -60,6 +60,7 @@ describe('ContractBookingService — changes-requested resubmit restating cargo' {} as never, // bookingBatchService {} as never, // bookingTransitionService {} as never, // consolidationApprovalService + { assertCurrencyAllowed: jest.fn() } as never, // exchangeSettings ); return { service, bookingsRepository, invoiceService }; } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index f5e00f503..e1b176bfe 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -52,6 +52,7 @@ import { CreateBookingContainerLineDto, CreateBookingUnderContractDto, } from './dto/create-booking-under-contract.dto'; +import { ExchangeSettingsService } from '../exchange-settings/exchange-settings.service'; // TERMINAL_BOOKING_STATUSES (the statuses that free the ONE_TIME active-booking // slot) lives in contracts.repository.ts — the contract cancel gate needs the @@ -136,6 +137,7 @@ export class ContractBookingService { private readonly bookingTransitionService: BookingTransitionService, @Inject(forwardRef(() => ConsolidationApprovalService)) private readonly consolidationApprovalService: ConsolidationApprovalService, + private readonly exchangeSettings: ExchangeSettingsService, ) {} async createUnderContract( @@ -276,6 +278,12 @@ export class ContractBookingService { } const bulkFields = await this.resolveBulkCargoFields(contract, dto); + // Resolved before the insert: the callback below is sync (it re-runs on a reference + // collision) and this must not be re-checked on every retry. + const paymentCurrency = await this.resolveShipmentCurrencyChecked( + contract, + dto.paymentCurrency, + ); // Denormalize route/direction/freight onto the booking for the scheduling engine. // Retry past a concurrent insert that grabbed the same BK sequence number. @@ -297,7 +305,7 @@ export class ContractBookingService { createdByUserId: user?.id ?? null, scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null, serviceTypeId: contract.serviceTypeId, - paymentCurrency: this.resolveShipmentCurrency(contract, dto.paymentCurrency), + paymentCurrency, contractType: 'NEW', customsClearingEnabled: contract.customsClearingEnabled, customsClearingAgent: contract.customsClearingAgent ?? null, @@ -498,6 +506,7 @@ export class ContractBookingService { } const route = await this.resolveRoute(contract, dto.contractRouteId); + const paymentCurrency = await this.resolveShipmentCurrencyChecked(contract, null); // Bare instance: no cargo, no date, no price. Draws no contract capacity // until the customer completes it after clearance. @@ -519,7 +528,7 @@ export class ContractBookingService { createdByUserId: user?.id ?? null, scheduledDate: null, serviceTypeId: contract.serviceTypeId, - paymentCurrency: this.resolveShipmentCurrency(contract, null), + paymentCurrency, contractType: 'NEW', customsClearingEnabled: contract.customsClearingEnabled, customsClearingAgent: contract.customsClearingAgent ?? null, @@ -585,6 +594,10 @@ export class ContractBookingService { await this.assertNotExpired(contract); const route = await this.resolveRoute(contract, opts.contractRouteId); + const paymentCurrency = await this.resolveShipmentCurrencyChecked( + contract, + opts?.paymentCurrency, + ); // No prepay gate: the clearance service fee is billed on the booking // invoice at completion, so the document step opens immediately. @@ -606,7 +619,7 @@ export class ContractBookingService { createdByUserId: opts.userId ?? null, scheduledDate: null, serviceTypeId: contract.serviceTypeId, - paymentCurrency: this.resolveShipmentCurrency(contract, opts?.paymentCurrency), + paymentCurrency, contractType: 'NEW', customsClearingEnabled: contract.customsClearingEnabled, customsClearingAgent: contract.customsClearingAgent ?? null, @@ -992,7 +1005,7 @@ export class ContractBookingService { // Completion is where the cargo — and therefore the price — is fixed, so // it is also where the billing currency is chosen. A bare instance was // created before the customer had any figure to look at. - paymentCurrency: this.resolveShipmentCurrency( + paymentCurrency: await this.resolveShipmentCurrencyChecked( contract, dto.paymentCurrency ?? booking.paymentCurrency, ), @@ -2137,6 +2150,21 @@ export class ContractBookingService { * currency, which is USD for contracts created under the current rule and the * grandfathered value for older ones. */ + /** + * {@link resolveShipmentCurrency} plus the availability check. + * + * Only the *requested* currency is checked, never the resolved one: switching a currency + * off must stop new choices, not brick shipment creation on contracts already written in + * it. Kept separate from the resolver so that stays pure and unit-testable. + */ + private async resolveShipmentCurrencyChecked( + contract: Contract, + requested?: string | null, + ): Promise { + await this.exchangeSettings.assertCurrencyAllowed(requested); + return this.resolveShipmentCurrency(contract, requested); + } + private resolveShipmentCurrency( contract: Contract, requested?: string | null, @@ -2379,6 +2407,9 @@ export class ContractBookingService { contractId: contract.id, freightType: contract.freightType, tradeDirection: contract.tradeDirection, + // Pure resolver, not the checked one: this is the pricing PREVIEW. It persists + // nothing, the picker already only offers enabled currencies, and the create path + // asserts — so a database round-trip here would only make the preview fail. paymentCurrency: this.resolveShipmentCurrency(contract, dto.paymentCurrency), serviceTypeId: contract.serviceTypeId, cargoTypeId: this.resolveCargoTypeId(contract, dto), diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts index 88ab8beb7..f81a24a4d 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts @@ -20,10 +20,11 @@ import { import { HAZARD_CLASS_VALUES } from '@edr/types'; import { CONTRACT_KINDS } from '../entities/contract.entity'; +import { PAYMENT_CURRENCIES as SHARED_PAYMENT_CURRENCIES } from '@edr/types'; const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const; const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const; -const PAYMENT_CURRENCIES = ['ETB', 'USD'] as const; +const PAYMENT_CURRENCIES = SHARED_PAYMENT_CURRENCIES; // Canonical UPPERCASE — everything downstream (booking gating, pricing // surcharge, GL/portal booking forms) compares contract.equipmentReturn // against 'WITH_RETURN'/'WITHOUT_RETURN'. Lowercase input is normalized. diff --git a/apps/edr-freight-api/src/modules/contracts/dto/filter-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/filter-contract.dto.ts index 1d513b2c0..0c719c4df 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/filter-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/filter-contract.dto.ts @@ -4,10 +4,11 @@ import { IsDateString, IsIn, IsOptional, IsUUID } from 'class-validator'; import { CONTRACT_STATUSES, CONTRACT_KINDS } from '../entities/contract.entity'; import { IdListParam } from '../../../common/dto/id-list.transform'; +import { PAYMENT_CURRENCIES as SHARED_PAYMENT_CURRENCIES } from '@edr/types'; const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const; const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const; -const PAYMENT_CURRENCIES = ['ETB', 'USD'] as const; +const PAYMENT_CURRENCIES = SHARED_PAYMENT_CURRENCIES; export class FilterContractDto { @ApiPropertyOptional({ enum: CONTRACT_STATUSES }) diff --git a/apps/edr-freight-api/src/modules/exchange-settings/dto/update-exchange-setting.dto.ts b/apps/edr-freight-api/src/modules/exchange-settings/dto/update-exchange-setting.dto.ts index 98e87007c..fda50ce50 100644 --- a/apps/edr-freight-api/src/modules/exchange-settings/dto/update-exchange-setting.dto.ts +++ b/apps/edr-freight-api/src/modules/exchange-settings/dto/update-exchange-setting.dto.ts @@ -1,13 +1,23 @@ -import { IsNumber, Max, Min } from "class-validator"; +import { IsBoolean, IsNumber, IsOptional, 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. + * + * Both fields are optional so the two controls on the page move independently: flipping + * the DJF toggle must not require re-submitting (and so re-stamping as MANUAL) a fallback + * rate the operator did not touch. */ export class UpdateExchangeSettingDto { + @IsOptional() @IsNumber({ maxDecimalPlaces: 6 }) @Min(1) @Max(10_000) - fallbackRate!: number; + fallbackRate?: number; + + /** Whether Djiboutian Franc may be chosen as a billing currency. */ + @IsOptional() + @IsBoolean() + djfEnabled?: boolean; } diff --git a/apps/edr-freight-api/src/modules/exchange-settings/entities/exchange-setting.entity.ts b/apps/edr-freight-api/src/modules/exchange-settings/entities/exchange-setting.entity.ts index 1e1f4ad66..bb7958b83 100644 --- a/apps/edr-freight-api/src/modules/exchange-settings/entities/exchange-setting.entity.ts +++ b/apps/edr-freight-api/src/modules/exchange-settings/entities/exchange-setting.entity.ts @@ -41,6 +41,17 @@ export class ExchangeSetting extends BaseEntity { @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; diff --git a/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts index 001fc90d2..0d7ca565b 100644 --- a/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts +++ b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts @@ -3,7 +3,7 @@ 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 } from "../../common/booking-guards"; +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"; @@ -28,30 +28,57 @@ export class ExchangeSettingsController { 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)", + "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, ) { - const updated = await this.service.setManualRate( - dto.fallbackRate, - user?.id ?? null, - ); + // 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, }; } } diff --git a/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.service.ts b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.service.ts index e0b670292..ffd2348a4 100644 --- a/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.service.ts +++ b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.service.ts @@ -1,6 +1,11 @@ -import { Injectable, Logger } from "@nestjs/common"; +import { BadRequestException, Injectable, Logger } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; import { Repository } from "typeorm"; +import { + PAYMENT_CURRENCIES, + PaymentCurrency, + isPaymentCurrency, +} from "@edr/types"; import { ExchangeSetting } from "./entities/exchange-setting.entity"; @@ -68,6 +73,7 @@ export class ExchangeSettingsService { fallbackRate: SEED_FALLBACK_RATE, fallbackSource: "AUTO", lastSyncedAt: null, + djfEnabled: false, }), ); } @@ -138,4 +144,56 @@ export class ExchangeSettingsService { ); return this.get(); } + + /** + * Currencies a booking or contract may currently be billed in. + * + * ETB and USD are permanent — the platform has always offered both. DJF is behind the + * operator toggle, so this is the one place that decides, rather than each form guessing. + */ + async enabledCurrencies(): Promise { + const { djfEnabled } = await this.get(); + return PAYMENT_CURRENCIES.filter((code) => code !== "DJF" || djfEnabled); + } + + /** + * Rejects a currency the platform is not billing in right now. + * + * Called wherever a currency is *chosen* (booking create, contract create, shipment + * request) rather than wherever one is read: class-validator's `@IsIn` cannot see a + * database flag, so the static whitelist admits DJF and this decides whether it is live. + */ + async assertCurrencyAllowed(currency: string | null | undefined): Promise { + const code = currency?.trim().toUpperCase(); + if (!code) return; + + if (!isPaymentCurrency(code)) { + throw new BadRequestException( + `${code} is not a currency this platform bills in`, + ); + } + + const enabled = await this.enabledCurrencies(); + if (!enabled.includes(code)) { + throw new BadRequestException( + `${code} billing is not enabled — an administrator turns it on in Exchange Settings`, + ); + } + } + + /** Operator turns DJF billing on or off. */ + async setDjfEnabled( + enabled: boolean, + updatedById?: string | null, + ): Promise { + const current = await this.get(); + await this.repository.update(current.id, { + djfEnabled: enabled, + updatedById: updatedById ?? null, + }); + this.logger.warn( + `DJF billing ${enabled ? "enabled" : "disabled"} by ${updatedById ?? "unknown user"}`, + ); + return this.get(); + } } diff --git a/apps/edr-freight-api/src/modules/payment-settings/dto/update-manual-payment-setting.dto.ts b/apps/edr-freight-api/src/modules/payment-settings/dto/update-manual-payment-setting.dto.ts index 971de03cb..39d4757e7 100644 --- a/apps/edr-freight-api/src/modules/payment-settings/dto/update-manual-payment-setting.dto.ts +++ b/apps/edr-freight-api/src/modules/payment-settings/dto/update-manual-payment-setting.dto.ts @@ -15,4 +15,9 @@ export class UpdateManualPaymentSettingDto { @IsOptional() @IsBoolean() usdEnabled?: boolean; + + @ApiPropertyOptional({ description: "Allow manual settlement of DJF invoices" }) + @IsOptional() + @IsBoolean() + djfEnabled?: boolean; } diff --git a/apps/edr-freight-api/src/modules/payment-settings/entities/manual-payment-setting.entity.ts b/apps/edr-freight-api/src/modules/payment-settings/entities/manual-payment-setting.entity.ts index a18f97279..ddc37ce62 100644 --- a/apps/edr-freight-api/src/modules/payment-settings/entities/manual-payment-setting.entity.ts +++ b/apps/edr-freight-api/src/modules/payment-settings/entities/manual-payment-setting.entity.ts @@ -5,10 +5,9 @@ import { Column, Entity } from "typeorm"; * Single-row table controlling whether Finance may settle invoices by hand * (bank transfer / counter payment) instead of the customer paying online. * - * Per currency on purpose: the two channels are operationally different — USD - * bookings have always been bank-transfer-only, while ETB normally goes - * through the gateway and manual settlement is the exception. Switching one - * off must not switch off the other. + * Per currency on purpose: the channels are operationally different — USD and DJF + * bookings are bank-transfer-only, while ETB normally goes through the gateway and + * manual settlement is the exception. Switching one off must not switch off the others. */ @Entity({ schema: "freight", name: "manual_payment_settings" }) export class ManualPaymentSetting extends BaseEntity { @@ -20,7 +19,16 @@ export class ManualPaymentSetting extends BaseEntity { @Column({ name: "usd_enabled", type: "boolean", default: true }) usdEnabled!: boolean; - /** IAM user id of the last operator to change either toggle. */ + /** + * Manual settlement allowed for DJF invoices. + * + * On by default. DJF gateway routing is new, so bank transfer has to work the day the + * first DJF invoice is raised — the same reason USD starts on. + */ + @Column({ name: "djf_enabled", type: "boolean", default: true }) + djfEnabled!: boolean; + + /** IAM user id of the last operator to change any toggle. */ @Column({ name: "updated_by_id", type: "uuid", nullable: true }) updatedById?: string | null; } diff --git a/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.controller.ts b/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.controller.ts index 786d1f7ca..8e1b87637 100644 --- a/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.controller.ts +++ b/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.controller.ts @@ -36,7 +36,7 @@ export class ManualPaymentSettingsController { FREIGHT_PERMS.admin, ]) @ApiOperation({ - summary: "Enable or disable manual invoice settlement for ETB and/or USD", + summary: "Enable or disable manual invoice settlement, per currency", }) update( @Body() dto: UpdateManualPaymentSettingDto, diff --git a/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.service.ts b/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.service.ts index efb43fa91..a94068f06 100644 --- a/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.service.ts +++ b/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.service.ts @@ -2,18 +2,30 @@ import { Injectable, Logger } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; import { Repository } from "typeorm"; +import { PAYMENT_CURRENCIES, PaymentCurrency, isPaymentCurrency } from "@edr/types"; + import { ManualPaymentSetting } from "./entities/manual-payment-setting.entity"; -/** The two currencies an invoice can be settled by hand in. */ -export type ManualPaymentCurrency = "ETB" | "USD"; +/** The currencies an invoice can be settled by hand in. */ +export type ManualPaymentCurrency = PaymentCurrency; + +/** + * Currency → its column on the settings row. The toggles are columns rather than rows, so + * this is the one place that knows the mapping; a new currency is a column plus a line here. + */ +const TOGGLE_COLUMN: Record = { + ETB: "etbEnabled", + USD: "usdEnabled", + DJF: "djfEnabled", +}; /** * Owns the single `manual_payment_settings` row: whether Finance may settle * invoices by hand, per currency. * - * Defaults mirror how the platform behaved before the toggles existed — USD - * has always been bank-transfer-only so it starts ON; ETB manual settlement is - * the new capability and starts OFF, so enabling it is a deliberate act. + * Defaults mirror how the platform behaved before the toggles existed — USD (and now DJF) + * is bank-transfer-only so it starts ON; ETB manual settlement is the exception and starts + * OFF, so enabling it is a deliberate act. */ @Injectable() export class ManualPaymentSettingsService { @@ -30,41 +42,44 @@ export class ManualPaymentSettingsService { if (existing) return existing; return this.repository.save( - this.repository.create({ etbEnabled: false, usdEnabled: true }), + this.repository.create({ + etbEnabled: false, + usdEnabled: true, + djfEnabled: true, + }), ); } /** Currencies manual settlement is currently allowed for. */ async enabledCurrencies(): Promise { const setting = await this.get(); - const enabled: ManualPaymentCurrency[] = []; - if (setting.etbEnabled) enabled.push("ETB"); - if (setting.usdEnabled) enabled.push("USD"); - return enabled; + return PAYMENT_CURRENCIES.filter((code) => setting[TOGGLE_COLUMN[code]]); } /** Whether one currency may be settled by hand right now. */ async isEnabled(currency: string | null | undefined): Promise { const upper = currency?.toUpperCase(); - if (upper !== "ETB" && upper !== "USD") return false; + if (!isPaymentCurrency(upper)) return false; const setting = await this.get(); - return upper === "ETB" ? setting.etbEnabled : setting.usdEnabled; + return Boolean(setting[TOGGLE_COLUMN[upper]]); } /** Flip either toggle; an omitted field leaves that currency unchanged. */ async update( - patch: { etbEnabled?: boolean; usdEnabled?: boolean }, + patch: { etbEnabled?: boolean; usdEnabled?: boolean; djfEnabled?: boolean }, updatedById?: string | null, ): Promise { const current = await this.get(); await this.repository.update(current.id, { ...(patch.etbEnabled === undefined ? {} : { etbEnabled: patch.etbEnabled }), ...(patch.usdEnabled === undefined ? {} : { usdEnabled: patch.usdEnabled }), + ...(patch.djfEnabled === undefined ? {} : { djfEnabled: patch.djfEnabled }), updatedById: updatedById ?? null, }); const updated = await this.get(); this.logger.warn( - `Manual payment channels set to ETB=${updated.etbEnabled} USD=${updated.usdEnabled} by ${updatedById ?? "unknown user"}`, + `Manual payment channels set to ETB=${updated.etbEnabled} USD=${updated.usdEnabled} ` + + `DJF=${updated.djfEnabled} by ${updatedById ?? "unknown user"}`, ); return updated; }