mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 07:15:45 +00:00
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.
118 lines
4.2 KiB
TypeScript
118 lines
4.2 KiB
TypeScript
import { ContractBookingService } from './contract-booking.service';
|
|
|
|
/**
|
|
* OPERATION_CHANGES_REQUESTED resubmit with restated cargo. Operations can ask
|
|
* for the cargo itself to change, so a completion payload that restates
|
|
* containers must cancel the unpaid invoice, wipe the persisted cargo and
|
|
* re-run the fresh-completion path (re-persist, re-price, re-invoice). A
|
|
* payload without cargo keeps the day-only resubmit behavior.
|
|
*/
|
|
describe('ContractBookingService — changes-requested resubmit restating cargo', () => {
|
|
const CONTRACT = {
|
|
id: 'c-1',
|
|
reference: 'CTR-1',
|
|
contractKind: 'GENERAL',
|
|
freightType: 'CONTAINER',
|
|
tradeDirection: 'IMPORT',
|
|
customsClearingEnabled: false,
|
|
contractValidUntil: null,
|
|
cargoScope: [],
|
|
};
|
|
|
|
const bookingWithCargo = () => ({
|
|
id: 'b-1',
|
|
contractId: 'c-1',
|
|
reference: 'BKG-1',
|
|
status: 'OPERATION_CHANGES_REQUESTED',
|
|
bookingContainers: [{ containerSize: '20FT', quantity: 4 }],
|
|
cargoTotalWeightVgm: 80,
|
|
originYardId: 'y-o',
|
|
destinationYardId: 'y-d',
|
|
});
|
|
|
|
function makeService() {
|
|
const bookingsRepository = {
|
|
findByIdWithFiles: jest.fn().mockResolvedValue(bookingWithCargo()),
|
|
deleteContainers: jest.fn().mockResolvedValue(undefined),
|
|
update: jest.fn().mockResolvedValue(undefined),
|
|
};
|
|
const invoiceService = {
|
|
cancelUnpaidInvoiceForBooking: jest.fn().mockResolvedValue(undefined),
|
|
};
|
|
const trainSchedulingService = {
|
|
assertBookingWindowOpen: jest.fn().mockResolvedValue(undefined),
|
|
};
|
|
const contractsRepository = {
|
|
findByIdWithRelations: jest.fn().mockResolvedValue(CONTRACT),
|
|
};
|
|
const service = new ContractBookingService(
|
|
contractsRepository as never,
|
|
bookingsRepository as never,
|
|
{} as never, // bookingPricingService
|
|
{} as never, // consolidationService
|
|
{} as never, // containerTypesService
|
|
{} as never, // ruleEngineService
|
|
{} as never, // milestoneService
|
|
invoiceService as never,
|
|
{} as never, // bookingNotifier
|
|
{} as never, // dataSource
|
|
trainSchedulingService as never,
|
|
{} as never, // bookingBatchService
|
|
{} as never, // bookingTransitionService
|
|
{} as never, // consolidationApprovalService
|
|
{ assertCurrencyAllowed: jest.fn() } as never, // exchangeSettings
|
|
);
|
|
return { service, bookingsRepository, invoiceService };
|
|
}
|
|
|
|
// Both paths dead-end into a downstream private assert we replace with a
|
|
// sentinel — which path threw tells us which branch the resubmit took.
|
|
const SENTINEL = new Error('reached-branch');
|
|
|
|
it('restated cargo cancels the invoice, wipes cargo and re-runs fresh completion', async () => {
|
|
const { service, bookingsRepository, invoiceService } = makeService();
|
|
// First gate inside the fresh-completion (!hasCargo) path.
|
|
jest
|
|
.spyOn(
|
|
service as never as { assertWithinQuantityCap: () => Promise<void> },
|
|
'assertWithinQuantityCap',
|
|
)
|
|
.mockRejectedValue(SENTINEL);
|
|
|
|
await expect(
|
|
service.completeUnderContract('c-1', 'b-1', {
|
|
scheduledDate: new Date().toISOString(),
|
|
containers: [{ containerSize: '20FT', quantity: 2 }],
|
|
} as never),
|
|
).rejects.toBe(SENTINEL);
|
|
|
|
expect(invoiceService.cancelUnpaidInvoiceForBooking).toHaveBeenCalledWith('b-1');
|
|
expect(bookingsRepository.deleteContainers).toHaveBeenCalledWith('b-1');
|
|
expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', {
|
|
cargoTotalWeightVgm: 0,
|
|
});
|
|
});
|
|
|
|
it('a day-only resubmit keeps the persisted cargo and invoice untouched', async () => {
|
|
const { service, bookingsRepository, invoiceService } = makeService();
|
|
// First call inside the day-only (hasCargo) resubmit path.
|
|
jest
|
|
.spyOn(
|
|
service as never as {
|
|
assertPersistedContainersAvailable: () => Promise<void>;
|
|
},
|
|
'assertPersistedContainersAvailable',
|
|
)
|
|
.mockRejectedValue(SENTINEL);
|
|
|
|
await expect(
|
|
service.completeUnderContract('c-1', 'b-1', {
|
|
scheduledDate: new Date().toISOString(),
|
|
} as never),
|
|
).rejects.toBe(SENTINEL);
|
|
|
|
expect(invoiceService.cancelUnpaidInvoiceForBooking).not.toHaveBeenCalled();
|
|
expect(bookingsRepository.deleteContainers).not.toHaveBeenCalled();
|
|
});
|
|
});
|