feat: add toggle to DJF

This commit is contained in:
ghost2023
2026-09-06 16:35:14 +03:00
parent aa96bb4086
commit eab36a2526
21 changed files with 337 additions and 41 deletions

View File

@@ -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;
`);
}
}

View File

@@ -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
); );

View File

@@ -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

View File

@@ -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,

View File

@@ -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);
});
});

View File

@@ -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;
} }

View File

@@ -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;

View File

@@ -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,

View File

@@ -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;
} }

View File

@@ -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[]>([]);
@@ -2359,7 +2363,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>

View File

@@ -612,7 +612,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,
}, },

View File

@@ -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: {

View File

@@ -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"),
); );

View File

@@ -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.`

View File

@@ -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&apos;s pay Payments. Each currency is switched separately. Confirming a manual
payment still requires the payment slip and the booking&apos;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" />

View File

@@ -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>>(

View File

@@ -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,
};
}

View File

@@ -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("");

View File

@@ -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>

View File

@@ -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>
)} )}

View File

@@ -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>