mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-02 06:53:38 +00:00
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.
This commit is contained in:
@@ -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<void> {
|
||||
// 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<void> {
|
||||
// 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"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -27,6 +27,7 @@ describe('ContractBookingService — customs booking gate', () => {
|
||||
{} as never, // bookingBatchService
|
||||
{} as never, // bookingTransitionService
|
||||
{} as never, // consolidationApprovalService
|
||||
{ assertCurrencyAllowed: jest.fn() } as never, // exchangeSettings
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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<string> {
|
||||
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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<PaymentCurrency[]> {
|
||||
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<void> {
|
||||
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<ExchangeSetting> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,4 +15,9 @@ export class UpdateManualPaymentSettingDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
usdEnabled?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: "Allow manual settlement of DJF invoices" })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
djfEnabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<PaymentCurrency, keyof ManualPaymentSetting> = {
|
||||
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<ManualPaymentCurrency[]> {
|
||||
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<boolean> {
|
||||
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<ManualPaymentSetting> {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user