mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 22:58:17 +00:00
@@ -0,0 +1,26 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the currency-level DJF switch to `manual_payment_settings`.
|
||||||
|
*
|
||||||
|
* Distinct from `djf_enabled`, which governs only the MANUAL rail: this one
|
||||||
|
* says whether DJF may be used as a payment currency at all — offered on the
|
||||||
|
* booking forms and accepted for online payment. Defaults to `true`, the
|
||||||
|
* behaviour before the switch existed.
|
||||||
|
*/
|
||||||
|
export class AddDjfPaymentsEnabled3890000000000 implements MigrationInterface {
|
||||||
|
name = 'AddDjfPaymentsEnabled3890000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.manual_payment_settings
|
||||||
|
ADD COLUMN IF NOT EXISTS djf_payments_enabled boolean NOT NULL DEFAULT true;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.manual_payment_settings DROP COLUMN IF EXISTS djf_payments_enabled;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,7 +82,7 @@ describe("BillingService.generateInvoice", () => {
|
|||||||
{} as never, // invoiceDocuments
|
{} as never, // invoiceDocuments
|
||||||
{} as never, // files
|
{} as never, // files
|
||||||
{ get: () => undefined } as never, // config
|
{ get: () => undefined } as never, // config
|
||||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"], isCurrencyOffered: async () => true } as never, // manualPaymentSettings
|
||||||
{ directSend: jest.fn() } as never, // notifications
|
{ directSend: jest.fn() } as never, // notifications
|
||||||
{ notify: jest.fn() } as never, // inbox
|
{ notify: jest.fn() } as never, // inbox
|
||||||
);
|
);
|
||||||
@@ -167,7 +167,7 @@ describe("BillingService.issueMemo", () => {
|
|||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{ get: () => undefined } as never,
|
{ get: () => undefined } as never,
|
||||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"], isCurrencyOffered: async () => true } as never, // manualPaymentSettings
|
||||||
{ directSend: jest.fn() } as never, // notifications
|
{ directSend: jest.fn() } as never, // notifications
|
||||||
{ notify: jest.fn() } as never, // inbox
|
{ notify: jest.fn() } as never, // inbox
|
||||||
);
|
);
|
||||||
@@ -304,7 +304,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
|||||||
{} as never, // invoiceDocuments
|
{} as never, // invoiceDocuments
|
||||||
{} as never, // files
|
{} as never, // files
|
||||||
{ get: () => undefined } as never, // config
|
{ get: () => undefined } as never, // config
|
||||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"], isCurrencyOffered: async () => true } as never, // manualPaymentSettings
|
||||||
{ directSend: jest.fn() } as never, // notifications
|
{ directSend: jest.fn() } as never, // notifications
|
||||||
{ notify: jest.fn() } as never, // inbox
|
{ notify: jest.fn() } as never, // inbox
|
||||||
);
|
);
|
||||||
@@ -362,7 +362,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
|||||||
{} as never, // invoiceDocuments
|
{} as never, // invoiceDocuments
|
||||||
{} as never, // files
|
{} as never, // files
|
||||||
{ get: () => undefined } as never, // config
|
{ get: () => undefined } as never, // config
|
||||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"], isCurrencyOffered: async () => true } as never, // manualPaymentSettings
|
||||||
{ directSend: jest.fn() } as never, // notifications
|
{ directSend: jest.fn() } as never, // notifications
|
||||||
{ notify: jest.fn() } as never, // inbox
|
{ notify: jest.fn() } as never, // inbox
|
||||||
);
|
);
|
||||||
@@ -410,7 +410,7 @@ describe("BillingService.settleByPaymentId", () => {
|
|||||||
{} as never, // invoiceDocuments
|
{} as never, // invoiceDocuments
|
||||||
{} as never, // files
|
{} as never, // files
|
||||||
{ get: () => undefined } as never, // config
|
{ get: () => undefined } as never, // config
|
||||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"], isCurrencyOffered: async () => true } as never, // manualPaymentSettings
|
||||||
{ directSend: jest.fn() } as never, // notifications
|
{ directSend: jest.fn() } as never, // notifications
|
||||||
{ notify: jest.fn() } as never, // inbox
|
{ notify: jest.fn() } as never, // inbox
|
||||||
);
|
);
|
||||||
@@ -526,7 +526,7 @@ describe("BillingService.recordPayment", () => {
|
|||||||
{} as never, // invoiceDocuments
|
{} as never, // invoiceDocuments
|
||||||
{} as never, // files
|
{} as never, // files
|
||||||
{ get: () => undefined } as never, // config
|
{ get: () => undefined } as never, // config
|
||||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"], isCurrencyOffered: async () => true } as never, // manualPaymentSettings
|
||||||
{ directSend: jest.fn() } as never, // notifications
|
{ directSend: jest.fn() } as never, // notifications
|
||||||
{ notify: jest.fn() } as never, // inbox
|
{ notify: jest.fn() } as never, // inbox
|
||||||
);
|
);
|
||||||
@@ -646,7 +646,7 @@ describe("BillingService.expirePayable — locked write runs in a transaction",
|
|||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never, // config
|
{} as never, // config
|
||||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"], isCurrencyOffered: async () => true } as never, // manualPaymentSettings
|
||||||
{ directSend: jest.fn() } as never, // notifications
|
{ directSend: jest.fn() } as never, // notifications
|
||||||
{ notify: jest.fn() } as never, // inbox
|
{ notify: jest.fn() } as never, // inbox
|
||||||
);
|
);
|
||||||
@@ -722,7 +722,7 @@ describe("BillingService.issuePayable", () => {
|
|||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never, // config
|
{} as never, // config
|
||||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"], isCurrencyOffered: async () => true } as never, // manualPaymentSettings
|
||||||
{ directSend: jest.fn() } as never, // notifications
|
{ directSend: jest.fn() } as never, // notifications
|
||||||
{ notify: jest.fn() } as never, // inbox
|
{ notify: jest.fn() } as never, // inbox
|
||||||
);
|
);
|
||||||
@@ -816,7 +816,7 @@ describe("BillingService — CAC Bank (OTP debit)", () => {
|
|||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never, // config
|
{} as never, // config
|
||||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"], isCurrencyOffered: async () => true } as never, // manualPaymentSettings
|
||||||
{ directSend: jest.fn() } as never, // notifications
|
{ directSend: jest.fn() } as never, // notifications
|
||||||
{ notify: jest.fn() } as never, // inbox
|
{ notify: jest.fn() } as never, // inbox
|
||||||
);
|
);
|
||||||
@@ -902,7 +902,7 @@ describe("BillingService — CBE bill amounts carry cents, never rounded", () =>
|
|||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never, // config
|
{} as never, // config
|
||||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"], isCurrencyOffered: async () => true } as never, // manualPaymentSettings
|
||||||
{ directSend: jest.fn() } as never, // notifications
|
{ directSend: jest.fn() } as never, // notifications
|
||||||
{ notify: jest.fn() } as never, // inbox
|
{ notify: jest.fn() } as never, // inbox
|
||||||
);
|
);
|
||||||
@@ -978,7 +978,7 @@ describe("BillingService.document", () => {
|
|||||||
? { tin: "0053481357", invoice: { sellerVatNumber: "43256663343256663322" } }
|
? { tin: "0053481357", invoice: { sellerVatNumber: "43256663343256663322" } }
|
||||||
: undefined,
|
: undefined,
|
||||||
} as never, // config
|
} as never, // config
|
||||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"], isCurrencyOffered: async () => true } as never, // manualPaymentSettings
|
||||||
{ directSend: jest.fn() } as never, // notifications
|
{ directSend: jest.fn() } as never, // notifications
|
||||||
{ notify: jest.fn() } as never, // inbox
|
{ notify: jest.fn() } as never, // inbox
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -806,7 +806,7 @@ export class BillingService {
|
|||||||
// settle by hand in a currency whose channel is switched off.
|
// settle by hand in a currency whose channel is switched off.
|
||||||
if (!(await this.manualPaymentSettings.isEnabled(invoice.currency))) {
|
if (!(await this.manualPaymentSettings.isEnabled(invoice.currency))) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Manual payment is disabled for ${invoice.currency ?? "this"} invoices. Enable it in Configuration → Manual payments first.`,
|
`Manual payment is disabled for ${invoice.currency ?? "this"} invoices. Enable it in Configuration → Payments first.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!file) {
|
if (!file) {
|
||||||
@@ -2297,6 +2297,15 @@ export class BillingService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DJF switched off as a payment currency: the online rails (Waafi / CAC
|
||||||
|
// Bank) stop taking it. The invoice itself is untouched — Finance can
|
||||||
|
// still settle it by hand while the DJF manual channel is on.
|
||||||
|
if (!(await this.manualPaymentSettings.isCurrencyOffered(invoice.currency))) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`${invoice.currency} payments are switched off. Enable them in Configuration → Payments first.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// A booking's PREPAID invoice is only payable inside its pay window —
|
// A booking's PREPAID invoice is only payable inside its pay window —
|
||||||
// `dueAt` mirrors booking.paymentDeadline (issuePayable at reserve time).
|
// `dueAt` mirrors booking.paymentDeadline (issuePayable at reserve time).
|
||||||
// Blocking INITIATION here is what makes the deadline real: a payment
|
// Blocking INITIATION here is what makes the deadline real: a payment
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import type { Freight } from '@edr/types';
|
import type { Freight } from '@edr/types';
|
||||||
|
|
||||||
|
import { ManualPaymentSettingsService } from '../payment-settings/manual-payment-settings.service';
|
||||||
import { YardScopeService } from '../rule-engine/services/yard-scope.service';
|
import { YardScopeService } from '../rule-engine/services/yard-scope.service';
|
||||||
import { BookingRequestRepository } from './booking-request.repository';
|
import { BookingRequestRepository } from './booking-request.repository';
|
||||||
import { ContractsService } from './contracts.service';
|
import { ContractsService } from './contracts.service';
|
||||||
@@ -30,6 +31,7 @@ export class BookingRequestService {
|
|||||||
private readonly contractBookingService: ContractBookingService,
|
private readonly contractBookingService: ContractBookingService,
|
||||||
private readonly notifier: ContractNotifierService,
|
private readonly notifier: ContractNotifierService,
|
||||||
private readonly yardScope: YardScopeService,
|
private readonly yardScope: YardScopeService,
|
||||||
|
private readonly paymentSettings: ManualPaymentSettingsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -101,6 +103,17 @@ export class BookingRequestService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The currency picker hides a switched-off currency, but a stale tab must
|
||||||
|
// not be able to raise a shipment nobody can pay for.
|
||||||
|
if (
|
||||||
|
dto.paymentCurrency &&
|
||||||
|
!(await this.paymentSettings.isCurrencyOffered(dto.paymentCurrency))
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`${dto.paymentCurrency.toUpperCase()} is not accepted as a billing currency right now. Pick another currency.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await this.contractBookingService.assertRequestWithinCapacity(contract, {
|
await this.contractBookingService.assertRequestWithinCapacity(contract, {
|
||||||
containers: dto.containers,
|
containers: dto.containers,
|
||||||
bulk: dto.bulk,
|
bulk: dto.bulk,
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { ManualPaymentSettingsService } from "./manual-payment-settings.service";
|
||||||
|
import type { ManualPaymentSetting } from "./entities/manual-payment-setting.entity";
|
||||||
|
|
||||||
|
/** Single-row repository stub: enough for get/update, nothing more. */
|
||||||
|
const repoWith = (row: Partial<ManualPaymentSetting>) => {
|
||||||
|
const stored = { id: "settings-1", ...row } as ManualPaymentSetting;
|
||||||
|
return {
|
||||||
|
findOne: async () => stored,
|
||||||
|
create: (v: Partial<ManualPaymentSetting>) => v as ManualPaymentSetting,
|
||||||
|
save: async (v: ManualPaymentSetting) => v,
|
||||||
|
update: async (_id: string, patch: Partial<ManualPaymentSetting>) => {
|
||||||
|
Object.assign(stored, patch);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const serviceWith = (row: Partial<ManualPaymentSetting>) =>
|
||||||
|
new ManualPaymentSettingsService(repoWith(row) as never);
|
||||||
|
|
||||||
|
describe("DJF currency switch", () => {
|
||||||
|
it("offers DJF, and accepts it, while the switch is on", async () => {
|
||||||
|
const service = serviceWith({ djfPaymentsEnabled: true });
|
||||||
|
|
||||||
|
await expect(service.offeredCurrencies()).resolves.toEqual([
|
||||||
|
"ETB",
|
||||||
|
"USD",
|
||||||
|
"DJF",
|
||||||
|
]);
|
||||||
|
await expect(service.isCurrencyOffered("DJF")).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops DJF from the offered currencies once switched off", async () => {
|
||||||
|
const service = serviceWith({ djfPaymentsEnabled: false });
|
||||||
|
|
||||||
|
await expect(service.offeredCurrencies()).resolves.toEqual(["ETB", "USD"]);
|
||||||
|
await expect(service.isCurrencyOffered("djf")).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never switches off ETB or USD — only DJF has a currency-level switch", async () => {
|
||||||
|
const service = serviceWith({ djfPaymentsEnabled: false });
|
||||||
|
|
||||||
|
await expect(service.isCurrencyOffered("ETB")).resolves.toBe(true);
|
||||||
|
await expect(service.isCurrencyOffered("USD")).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the manual rail alone when the currency switch flips", async () => {
|
||||||
|
const service = serviceWith({ djfEnabled: true, djfPaymentsEnabled: true });
|
||||||
|
|
||||||
|
const updated = await service.update({ djfPaymentsEnabled: false }, "user-1");
|
||||||
|
|
||||||
|
expect(updated.djfPaymentsEnabled).toBe(false);
|
||||||
|
// Existing DJF invoices stay hand-settleable, so nothing is stranded.
|
||||||
|
expect(updated.djfEnabled).toBe(true);
|
||||||
|
await expect(service.isEnabled("DJF")).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,4 +20,12 @@ export class UpdateManualPaymentSettingDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
djfEnabled?: boolean;
|
djfEnabled?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
"Accept DJF as a payment currency at all — booking forms and online payment",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
djfPaymentsEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,17 @@ export class ManualPaymentSetting extends BaseEntity {
|
|||||||
@Column({ name: "djf_enabled", type: "boolean", default: true })
|
@Column({ name: "djf_enabled", type: "boolean", default: true })
|
||||||
djfEnabled!: boolean;
|
djfEnabled!: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether DJF may be used as a payment currency AT ALL — offered on the
|
||||||
|
* booking/shipment forms and accepted for online payment (Waafi / CAC Bank).
|
||||||
|
*
|
||||||
|
* Wider than `djfEnabled`, which only governs the manual rail. Off leaves
|
||||||
|
* existing DJF invoices settleable by hand (while `djfEnabled` is on), so
|
||||||
|
* switching it off strands nothing — it only stops new DJF business.
|
||||||
|
*/
|
||||||
|
@Column({ name: "djf_payments_enabled", type: "boolean", default: true })
|
||||||
|
djfPaymentsEnabled!: boolean;
|
||||||
|
|
||||||
/** IAM user id of the last operator to change either toggle. */
|
/** IAM user id of the last operator to change either toggle. */
|
||||||
@Column({ name: "updated_by_id", type: "uuid", nullable: true })
|
@Column({ name: "updated_by_id", type: "uuid", nullable: true })
|
||||||
updatedById?: string | null;
|
updatedById?: string | null;
|
||||||
|
|||||||
@@ -30,13 +30,26 @@ export class ManualPaymentSettingsController {
|
|||||||
return this.service.get();
|
return this.service.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which currencies may be picked for new bookings and paid online. Read by
|
||||||
|
* the customer portal's booking forms, so it stays open like the other
|
||||||
|
* form-shaping settings reads (file-upload / dropdown settings) — it exposes
|
||||||
|
* nothing beyond what the currency picker already shows.
|
||||||
|
*/
|
||||||
|
@Get("currencies")
|
||||||
|
@ApiOperation({ summary: "Currencies customers may be billed and pay in" })
|
||||||
|
currencies() {
|
||||||
|
return this.service.offeredCurrencies();
|
||||||
|
}
|
||||||
|
|
||||||
@Patch()
|
@Patch()
|
||||||
@BookingStaff([
|
@BookingStaff([
|
||||||
FREIGHT_PERMS.settings.manualPayment.manage,
|
FREIGHT_PERMS.settings.manualPayment.manage,
|
||||||
FREIGHT_PERMS.admin,
|
FREIGHT_PERMS.admin,
|
||||||
])
|
])
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Enable or disable manual invoice settlement for ETB and/or USD",
|
summary:
|
||||||
|
"Enable or disable manual invoice settlement per currency, and whether DJF is accepted at all",
|
||||||
})
|
})
|
||||||
update(
|
update(
|
||||||
@Body() dto: UpdateManualPaymentSettingDto,
|
@Body() dto: UpdateManualPaymentSettingDto,
|
||||||
|
|||||||
@@ -37,7 +37,12 @@ export class ManualPaymentSettingsService {
|
|||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
|
|
||||||
return this.repository.save(
|
return this.repository.save(
|
||||||
this.repository.create({ etbEnabled: false, usdEnabled: true, djfEnabled: true }),
|
this.repository.create({
|
||||||
|
etbEnabled: false,
|
||||||
|
usdEnabled: true,
|
||||||
|
djfEnabled: true,
|
||||||
|
djfPaymentsEnabled: true,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,9 +65,34 @@ export class ManualPaymentSettingsService {
|
|||||||
return setting[field];
|
return setting[field];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Currencies customers may be billed and pay in right now. ETB and USD are
|
||||||
|
* always offered; DJF only while its currency-level switch is on. Read by
|
||||||
|
* the booking forms (portal and backoffice) to decide which options to show.
|
||||||
|
*/
|
||||||
|
async offeredCurrencies(): Promise<ManualPaymentCurrency[]> {
|
||||||
|
const setting = await this.get();
|
||||||
|
return setting.djfPaymentsEnabled ? ["ETB", "USD", "DJF"] : ["ETB", "USD"];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a currency may be used for NEW business and online payment — the
|
||||||
|
* currency-level switch, not the manual rail's {@link isEnabled}. Only DJF
|
||||||
|
* is switchable; ETB and USD have no off switch.
|
||||||
|
*/
|
||||||
|
async isCurrencyOffered(currency: string | null | undefined): Promise<boolean> {
|
||||||
|
if (currency?.toUpperCase() !== "DJF") return true;
|
||||||
|
return (await this.get()).djfPaymentsEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
/** Flip any toggle; an omitted field leaves that currency unchanged. */
|
/** Flip any toggle; an omitted field leaves that currency unchanged. */
|
||||||
async update(
|
async update(
|
||||||
patch: { etbEnabled?: boolean; usdEnabled?: boolean; djfEnabled?: boolean },
|
patch: {
|
||||||
|
etbEnabled?: boolean;
|
||||||
|
usdEnabled?: boolean;
|
||||||
|
djfEnabled?: boolean;
|
||||||
|
djfPaymentsEnabled?: boolean;
|
||||||
|
},
|
||||||
updatedById?: string | null,
|
updatedById?: string | null,
|
||||||
): Promise<ManualPaymentSetting> {
|
): Promise<ManualPaymentSetting> {
|
||||||
const current = await this.get();
|
const current = await this.get();
|
||||||
@@ -70,11 +100,14 @@ export class ManualPaymentSettingsService {
|
|||||||
...(patch.etbEnabled === undefined ? {} : { etbEnabled: patch.etbEnabled }),
|
...(patch.etbEnabled === undefined ? {} : { etbEnabled: patch.etbEnabled }),
|
||||||
...(patch.usdEnabled === undefined ? {} : { usdEnabled: patch.usdEnabled }),
|
...(patch.usdEnabled === undefined ? {} : { usdEnabled: patch.usdEnabled }),
|
||||||
...(patch.djfEnabled === undefined ? {} : { djfEnabled: patch.djfEnabled }),
|
...(patch.djfEnabled === undefined ? {} : { djfEnabled: patch.djfEnabled }),
|
||||||
|
...(patch.djfPaymentsEnabled === undefined
|
||||||
|
? {}
|
||||||
|
: { djfPaymentsEnabled: patch.djfPaymentsEnabled }),
|
||||||
updatedById: updatedById ?? null,
|
updatedById: updatedById ?? null,
|
||||||
});
|
});
|
||||||
const updated = await this.get();
|
const updated = await this.get();
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Manual payment channels set to ETB=${updated.etbEnabled} USD=${updated.usdEnabled} DJF=${updated.djfEnabled} by ${updatedById ?? "unknown user"}`,
|
`Manual payment channels set to ETB=${updated.etbEnabled} USD=${updated.usdEnabled} DJF=${updated.djfEnabled} (DJF accepted as a currency: ${updated.djfPaymentsEnabled}) by ${updatedById ?? "unknown user"}`,
|
||||||
);
|
);
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ import {
|
|||||||
useBookingDetail,
|
useBookingDetail,
|
||||||
useBookingMutations,
|
useBookingMutations,
|
||||||
} from "@/hooks/bookings/useBookings";
|
} from "@/hooks/bookings/useBookings";
|
||||||
|
import {
|
||||||
|
eatDay,
|
||||||
|
exportTrainOption,
|
||||||
|
isExportRailBooking,
|
||||||
|
} from "@/features/bookings/shipmentDay";
|
||||||
|
|
||||||
export interface OperationRescheduleModalProps {
|
export interface OperationRescheduleModalProps {
|
||||||
bookingId: string;
|
bookingId: string;
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ import {
|
|||||||
type ConsolidationCandidate,
|
type ConsolidationCandidate,
|
||||||
} from "@/services/contracts.service";
|
} from "@/services/contracts.service";
|
||||||
import { bookingsService } from "@/services/bookings.service";
|
import { bookingsService } from "@/services/bookings.service";
|
||||||
|
import { usePaymentCurrenciesQuery } from "@/hooks/useManualPaymentSettings";
|
||||||
import {
|
import {
|
||||||
useContractCapacity,
|
useContractCapacity,
|
||||||
useContractDetail,
|
useContractDetail,
|
||||||
@@ -346,6 +347,9 @@ export default function GlCreateBookingForm() {
|
|||||||
// IMPORT bookings pick ETB or USD — starts empty so the choice is
|
// IMPORT bookings pick ETB or USD — starts empty so the choice is
|
||||||
// deliberate (required before pricing). Everything else is forced to ETB.
|
// deliberate (required before pricing). Everything else is forced to ETB.
|
||||||
const [paymentCurrency, setPaymentCurrency] = useState<"USD" | "ETB" | "DJF" | "">("");
|
const [paymentCurrency, setPaymentCurrency] = useState<"USD" | "ETB" | "DJF" | "">("");
|
||||||
|
const { data: offeredCurrencies } = usePaymentCurrenciesQuery();
|
||||||
|
// Undefined while the read is in flight — assume on, the switch is rarely off.
|
||||||
|
const djfOffered = offeredCurrencies ? offeredCurrencies.includes("DJF") : true;
|
||||||
// What the containers carry — captured per booking (moved off the contract).
|
// What the containers carry — captured per booking (moved off the contract).
|
||||||
const [cargoDescription, setCargoDescription] = useState("");
|
const [cargoDescription, setCargoDescription] = useState("");
|
||||||
const [containerLines, setContainerLines] = useState<ContainerLineDraft[]>([]);
|
const [containerLines, setContainerLines] = useState<ContainerLineDraft[]>([]);
|
||||||
@@ -2395,7 +2399,9 @@ export default function GlCreateBookingForm() {
|
|||||||
onChange={setPaymentCurrency}
|
onChange={setPaymentCurrency}
|
||||||
disabled={!isImport || requestCurrencyLocked}
|
disabled={!isImport || requestCurrencyLocked}
|
||||||
allowUsd={isImport}
|
allowUsd={isImport}
|
||||||
allowDjf={isImport}
|
// A currency switched off in Configuration → Payments is not
|
||||||
|
// offered, but one the customer already chose stays visible.
|
||||||
|
allowDjf={isImport && (djfOffered || paymentCurrency === "DJF")}
|
||||||
error={currencyError}
|
error={currencyError}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -624,7 +624,7 @@ export const buildSidebarSections = (
|
|||||||
permission: FREIGHT_PERMS.settings.operationsStandards.view,
|
permission: FREIGHT_PERMS.settings.operationsStandards.view,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Manual payments",
|
label: "Payments",
|
||||||
href: "/dashboard/configuration/manual-payments",
|
href: "/dashboard/configuration/manual-payments",
|
||||||
permission: FREIGHT_PERMS.settings.manualPayment.view,
|
permission: FREIGHT_PERMS.settings.manualPayment.view,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export const URL_CONSTANTS = {
|
|||||||
|
|
||||||
MANUAL_PAYMENT_SETTINGS: {
|
MANUAL_PAYMENT_SETTINGS: {
|
||||||
BASE: "/payment-settings/manual",
|
BASE: "/payment-settings/manual",
|
||||||
|
CURRENCIES: "/payment-settings/manual/currencies",
|
||||||
},
|
},
|
||||||
|
|
||||||
AUDIT_LOGS: {
|
AUDIT_LOGS: {
|
||||||
|
|||||||
@@ -9,6 +9,18 @@ import {
|
|||||||
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
|
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
|
||||||
|
|
||||||
export const MANUAL_PAYMENT_SETTINGS_KEY = ["manualPaymentSettings"];
|
export const MANUAL_PAYMENT_SETTINGS_KEY = ["manualPaymentSettings"];
|
||||||
|
export const PAYMENT_CURRENCIES_KEY = ["paymentCurrencies"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Currencies a booking may be billed in right now. Open read, so forms can
|
||||||
|
* hide a switched-off currency without needing the settings permission.
|
||||||
|
*/
|
||||||
|
export const usePaymentCurrenciesQuery = () =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: PAYMENT_CURRENCIES_KEY,
|
||||||
|
queryFn: () => manualPaymentSettingsService.currencies(),
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
export const useManualPaymentSettingsQuery = () =>
|
export const useManualPaymentSettingsQuery = () =>
|
||||||
useQuery({
|
useQuery({
|
||||||
@@ -25,13 +37,18 @@ export const useUpdateManualPaymentSettings = () => {
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (
|
mutationFn: (
|
||||||
patch: Partial<
|
patch: Partial<
|
||||||
Pick<ManualPaymentSettings, "etbEnabled" | "usdEnabled" | "djfEnabled">
|
Pick<
|
||||||
|
ManualPaymentSettings,
|
||||||
|
"etbEnabled" | "usdEnabled" | "djfEnabled" | "djfPaymentsEnabled"
|
||||||
|
>
|
||||||
>,
|
>,
|
||||||
) => manualPaymentSettingsService.update(patch),
|
) => manualPaymentSettingsService.update(patch),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
queryClient.setQueryData(MANUAL_PAYMENT_SETTINGS_KEY, data);
|
queryClient.setQueryData(MANUAL_PAYMENT_SETTINGS_KEY, data);
|
||||||
// The Manual Payments worklist only lists enabled currencies.
|
// The Manual Payments worklist only lists enabled currencies, and the
|
||||||
|
// booking forms only offer currencies that are switched on.
|
||||||
queryClient.invalidateQueries({ queryKey: ["invoices"] });
|
queryClient.invalidateQueries({ queryKey: ["invoices"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: PAYMENT_CURRENCIES_KEY });
|
||||||
toast.success(
|
toast.success(
|
||||||
t("manualPaymentSettings.updated", "Manual payment settings updated"),
|
t("manualPaymentSettings.updated", "Manual payment settings updated"),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -291,8 +291,8 @@ export default function UsdPaymentsPanel({
|
|||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const canConfirm = hasPermission(user, FREIGHT_PERMS.invoices.confirmOffline);
|
const canConfirm = hasPermission(user, FREIGHT_PERMS.invoices.confirmOffline);
|
||||||
|
|
||||||
// Manual settlement is switched on per currency in Configuration → Manual
|
// Manual settlement is switched on per currency in Configuration →
|
||||||
// payments. FinanceHubPage hides the tab for a disabled currency; this is
|
// Payments. FinanceHubPage hides the tab for a disabled currency; this is
|
||||||
// the fallback for a direct `?tab=` link, and the API refuses regardless.
|
// the fallback for a direct `?tab=` link, and the API refuses regardless.
|
||||||
const { data: manualSettings } = useManualPaymentSettingsQuery();
|
const { data: manualSettings } = useManualPaymentSettingsQuery();
|
||||||
const currencyEnabled = manualSettings
|
const currencyEnabled = manualSettings
|
||||||
@@ -549,7 +549,7 @@ export default function UsdPaymentsPanel({
|
|||||||
onRowClick={(row) => navigate(`/dashboard/invoices/${row.id}`)}
|
onRowClick={(row) => navigate(`/dashboard/invoices/${row.id}`)}
|
||||||
emptyMessage={
|
emptyMessage={
|
||||||
!currencyEnabled
|
!currencyEnabled
|
||||||
? `Manual payment is switched off for ${currency} invoices. Enable it in Configuration → Manual payments.`
|
? `Manual payment is switched off for ${currency} invoices. Enable it in Configuration → Payments.`
|
||||||
: controls.activeCount > 0
|
: controls.activeCount > 0
|
||||||
? "No invoices match these filters."
|
? "No invoices match these filters."
|
||||||
: `No ${currency} invoices awaiting manual payment confirmation.`
|
: `No ${currency} invoices awaiting manual payment confirmation.`
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
import { Badge } from "@/shared/common/ui/badge";
|
import { Badge } from "@/shared/common/ui/badge";
|
||||||
import { Switch } from "@/shared/common/ui/switch";
|
import { Switch } from "@/shared/common/ui/switch";
|
||||||
import { Skeleton } from "@/shared/common/ui/skeleton";
|
import { Skeleton } from "@/shared/common/ui/skeleton";
|
||||||
import { AlertTriangle, Banknote, Landmark } from "lucide-react";
|
import { AlertTriangle, Banknote, Coins, Landmark } from "lucide-react";
|
||||||
|
|
||||||
import { useAuth } from "@/auth/useAuth";
|
import { useAuth } from "@/auth/useAuth";
|
||||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||||
@@ -53,7 +53,8 @@ const CURRENCIES: {
|
|||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Switches the manual (offline) payment channel on or off per currency.
|
* Two levels of switch: whether DJF is accepted as a payment currency at all,
|
||||||
|
* and whether the manual (offline) channel is open, per currency.
|
||||||
*
|
*
|
||||||
* Off means gone, not greyed out: the Manual Payments worklist lists only
|
* Off means gone, not greyed out: the Manual Payments worklist lists only
|
||||||
* enabled currencies, and the API refuses a confirmation in a disabled one —
|
* enabled currencies, and the API refuses a confirmation in a disabled one —
|
||||||
@@ -75,11 +76,12 @@ export default function ManualPaymentSettingsCard() {
|
|||||||
return (
|
return (
|
||||||
<Card className="shadow-lg border-gray-200 dark:border-gray-700">
|
<Card className="shadow-lg border-gray-200 dark:border-gray-700">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Manual payments</CardTitle>
|
<CardTitle>Payments</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Whether Finance staff may mark invoices as paid by hand, from
|
Which currencies customers may be billed in, and whether Finance
|
||||||
Invoices → Manual Payments. Each currency is switched separately.
|
staff may mark invoices as paid by hand from Invoices → Manual
|
||||||
Confirming still requires the payment slip and the booking's pay
|
Payments. Each currency is switched separately. Confirming a manual
|
||||||
|
payment still requires the payment slip and the booking's pay
|
||||||
window to be open.
|
window to be open.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -95,6 +97,38 @@ export default function ManualPaymentSettingsCard() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isLoading || !data ? (
|
||||||
|
<Skeleton className="h-[86px] w-full rounded-md" />
|
||||||
|
) : (
|
||||||
|
<div className="flex items-start justify-between gap-4 rounded-md border p-4 dark:border-gray-700">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Coins className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<p className="font-medium">Accept Djibouti Franc (DJF)</p>
|
||||||
|
<Badge variant={data.djfPaymentsEnabled ? "default" : "secondary"}>
|
||||||
|
{data.djfPaymentsEnabled ? "Enabled" : "Disabled"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Whether DJF is offered as a billing currency on new bookings and
|
||||||
|
shipment requests, and whether DJF invoices can be paid online
|
||||||
|
(Waafi / CAC Bank). Switching this off stops new DJF business —
|
||||||
|
invoices already in DJF stay settleable by hand below.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={data.djfPaymentsEnabled}
|
||||||
|
disabled={!canManage || update.isPending}
|
||||||
|
aria-label="Accept DJF as a payment currency"
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
update.mutate({ djfPaymentsEnabled: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="pt-2 text-sm font-medium">Manual (offline) settlement</p>
|
||||||
|
|
||||||
{isLoading || !data
|
{isLoading || !data
|
||||||
? CURRENCIES.map((c) => (
|
? CURRENCIES.map((c) => (
|
||||||
<Skeleton key={c.code} className="h-[86px] w-full rounded-md" />
|
<Skeleton key={c.code} className="h-[86px] w-full rounded-md" />
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ export interface ManualPaymentSettings {
|
|||||||
etbEnabled: boolean;
|
etbEnabled: boolean;
|
||||||
usdEnabled: boolean;
|
usdEnabled: boolean;
|
||||||
djfEnabled: boolean;
|
djfEnabled: boolean;
|
||||||
|
/**
|
||||||
|
* Wider than `djfEnabled`: whether DJF is accepted as a payment currency at
|
||||||
|
* all — offered on the booking forms and payable online. Off leaves existing
|
||||||
|
* DJF invoices settleable by hand.
|
||||||
|
*/
|
||||||
|
djfPaymentsEnabled: boolean;
|
||||||
updatedById: string | null;
|
updatedById: string | null;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
}
|
}
|
||||||
@@ -24,10 +30,21 @@ export const manualPaymentSettingsService = {
|
|||||||
return unwrap(response.data);
|
return unwrap(response.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Currencies customers may be billed and pay in — open read, no permission. */
|
||||||
|
currencies: async (): Promise<string[]> => {
|
||||||
|
const response = await client.get<ApiResponse<string[]>>(
|
||||||
|
URL_CONSTANTS.MANUAL_PAYMENT_SETTINGS.CURRENCIES,
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
/** Partial: an omitted currency keeps its current setting. */
|
/** Partial: an omitted currency keeps its current setting. */
|
||||||
update: async (
|
update: async (
|
||||||
patch: Partial<
|
patch: Partial<
|
||||||
Pick<ManualPaymentSettings, "etbEnabled" | "usdEnabled" | "djfEnabled">
|
Pick<
|
||||||
|
ManualPaymentSettings,
|
||||||
|
"etbEnabled" | "usdEnabled" | "djfEnabled" | "djfPaymentsEnabled"
|
||||||
|
>
|
||||||
>,
|
>,
|
||||||
): Promise<ManualPaymentSettings> => {
|
): Promise<ManualPaymentSettings> => {
|
||||||
const response = await client.patch<ApiResponse<ManualPaymentSettings>>(
|
const response = await client.patch<ApiResponse<ManualPaymentSettings>>(
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { client } from "@/utils/api";
|
||||||
|
import { unwrap } from "@/utils/endpoint";
|
||||||
|
import type { ApiResponse } from "@/types/apiResponse";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Currencies a booking may be billed and paid in right now — DJF drops out
|
||||||
|
* when staff switch it off in Configuration → Payments. One open GET, so it
|
||||||
|
* is fetched here rather than through a service file of its own.
|
||||||
|
*/
|
||||||
|
export const PAYMENT_CURRENCIES_KEY = ["payment-currencies"] as const;
|
||||||
|
|
||||||
|
export function usePaymentCurrencies() {
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: PAYMENT_CURRENCIES_KEY,
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await client.get<ApiResponse<string[]>>(
|
||||||
|
"/api/payment-settings/manual/currencies",
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
currencies: data,
|
||||||
|
/** In flight or failed → assume on; the switch is off only by exception. */
|
||||||
|
djfEnabled: data ? data.includes("DJF") : true,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import { Check, Landmark, ShieldCheck } from "lucide-react";
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import type { InvoicePaymentFlow } from "@/hooks/useInvoicePayment";
|
import type { InvoicePaymentFlow } from "@/hooks/useInvoicePayment";
|
||||||
|
import { usePaymentCurrencies } from "@/hooks/usePaymentCurrencies";
|
||||||
import { PayerAccountNote } from "@/pages/bookings/payments/PayerAccountNote";
|
import { PayerAccountNote } from "@/pages/bookings/payments/PayerAccountNote";
|
||||||
import type { PaymentMethod } from "@/services/payments.service";
|
import type { PaymentMethod } from "@/services/payments.service";
|
||||||
|
|
||||||
@@ -207,13 +208,21 @@ export function PaymentMethodModal({
|
|||||||
/** CBE bill-reference step, from `useInvoicePayment`. Omit to disable CBE_BILL. */
|
/** CBE bill-reference step, from `useInvoicePayment`. Omit to disable CBE_BILL. */
|
||||||
bill?: InvoicePaymentFlow["bill"];
|
bill?: InvoicePaymentFlow["bill"];
|
||||||
}) {
|
}) {
|
||||||
|
// DJF switched off in Configuration → Payments closes its online rails; the
|
||||||
|
// API refuses the charge too, so offering them would only fail at the gate.
|
||||||
|
const { djfEnabled } = usePaymentCurrencies();
|
||||||
|
const currencyOff =
|
||||||
|
!djfEnabled && currency?.trim().toUpperCase() === "DJF";
|
||||||
const providers = useMemo(
|
const providers = useMemo(
|
||||||
() =>
|
() =>
|
||||||
providersForCurrency(currency).filter(
|
currencyOff
|
||||||
(p) =>
|
? []
|
||||||
(otp || !isOtpMethod(p.method)) && (bill || !isBillMethod(p.method)),
|
: providersForCurrency(currency).filter(
|
||||||
),
|
(p) =>
|
||||||
[currency, otp, bill],
|
(otp || !isOtpMethod(p.method)) &&
|
||||||
|
(bill || !isBillMethod(p.method)),
|
||||||
|
),
|
||||||
|
[currency, otp, bill, currencyOff],
|
||||||
);
|
);
|
||||||
const [method, setMethod] = useState<PaymentMethod>(providers[0]?.method ?? PROVIDERS[0].method);
|
const [method, setMethod] = useState<PaymentMethod>(providers[0]?.method ?? PROVIDERS[0].method);
|
||||||
const [mobile, setMobile] = useState("");
|
const [mobile, setMobile] = useState("");
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
type BookingFormValues,
|
type BookingFormValues,
|
||||||
type PaymentCurrency,
|
type PaymentCurrency,
|
||||||
} from "./schema";
|
} from "./schema";
|
||||||
|
import { usePaymentCurrencies } from "@/hooks/usePaymentCurrencies";
|
||||||
import { OptionFieldError, StepLabel } from "./shared";
|
import { OptionFieldError, StepLabel } from "./shared";
|
||||||
|
|
||||||
const CURRENCY_ICONS: Record<
|
const CURRENCY_ICONS: Record<
|
||||||
@@ -29,10 +30,14 @@ export function PaymentCurrencyField({
|
|||||||
*/
|
*/
|
||||||
allowUsd?: boolean;
|
allowUsd?: boolean;
|
||||||
}) {
|
}) {
|
||||||
// DJF is offered wherever USD is — both are import-shipment-only currencies.
|
// DJF is offered wherever USD is — both are import-shipment-only currencies
|
||||||
const options = allowUsd
|
// — and only while staff keep it switched on in Configuration → Payments.
|
||||||
? PAYMENT_CURRENCY_OPTIONS
|
const { djfEnabled } = usePaymentCurrencies();
|
||||||
: PAYMENT_CURRENCY_OPTIONS.filter((o) => o.value === "ETB");
|
const options = (
|
||||||
|
allowUsd
|
||||||
|
? PAYMENT_CURRENCY_OPTIONS
|
||||||
|
: PAYMENT_CURRENCY_OPTIONS.filter((o) => o.value === "ETB")
|
||||||
|
).filter((o) => o.value !== "DJF" || djfEnabled);
|
||||||
return (
|
return (
|
||||||
<Box mt={24}>
|
<Box mt={24}>
|
||||||
<StepLabel>Payment currency</StepLabel>
|
<StepLabel>Payment currency</StepLabel>
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ import {
|
|||||||
ExportTrainPicker,
|
ExportTrainPicker,
|
||||||
OperationDatePicker,
|
OperationDatePicker,
|
||||||
} from "@edr/ui-common";
|
} from "@edr/ui-common";
|
||||||
|
|
||||||
|
import { usePaymentCurrencies } from "@/hooks/usePaymentCurrencies";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import {
|
import {
|
||||||
contractsService,
|
contractsService,
|
||||||
@@ -1318,6 +1320,7 @@ function ScheduleStep({
|
|||||||
}) {
|
}) {
|
||||||
const contractRouteId = form.watch("contractRouteId");
|
const contractRouteId = form.watch("contractRouteId");
|
||||||
const route = routes.find((r) => r.id === contractRouteId) ?? routes[0];
|
const route = routes.find((r) => r.id === contractRouteId) ?? routes[0];
|
||||||
|
const { djfEnabled } = usePaymentCurrencies();
|
||||||
|
|
||||||
// Read the cargo entered in the previous step so the day list reflects what
|
// Read the cargo entered in the previous step so the day list reflects what
|
||||||
// can actually be shipped (matching wagons + open train capacity).
|
// can actually be shipped (matching wagons + open train capacity).
|
||||||
@@ -1474,7 +1477,8 @@ function ScheduleStep({
|
|||||||
onChange={(v) => field.onChange(v)}
|
onChange={(v) => field.onChange(v)}
|
||||||
error={fieldState.error?.message}
|
error={fieldState.error?.message}
|
||||||
allowUsd={isImport}
|
allowUsd={isImport}
|
||||||
allowDjf={isImport}
|
// Hidden once staff switch DJF off, unless it is already picked.
|
||||||
|
allowDjf={isImport && (djfEnabled || field.value === "DJF")}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
Title,
|
Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { CurrencySelector } from "@edr/ui-common";
|
import { CurrencySelector } from "@edr/ui-common";
|
||||||
|
|
||||||
|
import { usePaymentCurrencies } from "@/hooks/usePaymentCurrencies";
|
||||||
import { AlertCircle, ArrowLeft, CalendarDays, Send } from "lucide-react";
|
import { AlertCircle, ArrowLeft, CalendarDays, Send } from "lucide-react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
@@ -39,6 +41,7 @@ export default function NewShipmentRequestPage() {
|
|||||||
// Starts empty so the billing-currency choice is deliberate — required at
|
// Starts empty so the billing-currency choice is deliberate — required at
|
||||||
// submit. Intercity/export are forced to ETB (server-enforced too).
|
// submit. Intercity/export are forced to ETB (server-enforced too).
|
||||||
const [paymentCurrency, setPaymentCurrency] = useState<"USD" | "ETB" | "DJF" | "">("");
|
const [paymentCurrency, setPaymentCurrency] = useState<"USD" | "ETB" | "DJF" | "">("");
|
||||||
|
const { djfEnabled } = usePaymentCurrencies();
|
||||||
const [currencyError, setCurrencyError] = useState<string | undefined>();
|
const [currencyError, setCurrencyError] = useState<string | undefined>();
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
|
|
||||||
@@ -267,7 +270,7 @@ export default function NewShipmentRequestPage() {
|
|||||||
}}
|
}}
|
||||||
disabled={isIntercity || isExport}
|
disabled={isIntercity || isExport}
|
||||||
allowUsd={!isIntercity && !isExport}
|
allowUsd={!isIntercity && !isExport}
|
||||||
allowDjf={!isIntercity && !isExport}
|
allowDjf={!isIntercity && !isExport && djfEnabled}
|
||||||
error={currencyError}
|
error={currencyError}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
Reference in New Issue
Block a user