diff --git a/apps/edr-freight-api/src/migrations/3890000000000-AddDjfPaymentsEnabled.ts b/apps/edr-freight-api/src/migrations/3890000000000-AddDjfPaymentsEnabled.ts new file mode 100644 index 000000000..c8eb09978 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3890000000000-AddDjfPaymentsEnabled.ts @@ -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 { + 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 { + await queryRunner.query(` + ALTER TABLE freight.manual_payment_settings DROP COLUMN IF EXISTS djf_payments_enabled; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts index 9a34516aa..647d9717a 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts @@ -82,7 +82,7 @@ describe("BillingService.generateInvoice", () => { {} as never, // invoiceDocuments {} as never, // files { 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 { notify: jest.fn() } as never, // inbox ); @@ -167,7 +167,7 @@ describe("BillingService.issueMemo", () => { {} as never, {} 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 { notify: jest.fn() } as never, // inbox ); @@ -304,7 +304,7 @@ describe("BillingService.markInvoiceAsPaid", () => { {} as never, // invoiceDocuments {} as never, // files { 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 { notify: jest.fn() } as never, // inbox ); @@ -362,7 +362,7 @@ describe("BillingService.markInvoiceAsPaid", () => { {} as never, // invoiceDocuments {} as never, // files { 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 { notify: jest.fn() } as never, // inbox ); @@ -410,7 +410,7 @@ describe("BillingService.settleByPaymentId", () => { {} as never, // invoiceDocuments {} as never, // files { 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 { notify: jest.fn() } as never, // inbox ); @@ -526,7 +526,7 @@ describe("BillingService.recordPayment", () => { {} as never, // invoiceDocuments {} as never, // files { 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 { 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, // 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 { notify: jest.fn() } as never, // inbox ); @@ -722,7 +722,7 @@ describe("BillingService.issuePayable", () => { {} as never, {} as never, {} 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 { notify: jest.fn() } as never, // inbox ); @@ -816,7 +816,7 @@ describe("BillingService — CAC Bank (OTP debit)", () => { {} as never, {} as never, {} 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 { 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, // 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 { notify: jest.fn() } as never, // inbox ); @@ -978,7 +978,7 @@ describe("BillingService.document", () => { ? { tin: "0053481357", invoice: { sellerVatNumber: "43256663343256663322" } } : 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 { notify: jest.fn() } as never, // inbox ); diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index ce8c95f40..42a5baf34 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -806,7 +806,7 @@ export class BillingService { // settle by hand in a currency whose channel is switched off. if (!(await this.manualPaymentSettings.isEnabled(invoice.currency))) { 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) { @@ -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 — // `dueAt` mirrors booking.paymentDeadline (issuePayable at reserve time). // Blocking INITIATION here is what makes the deadline real: a payment diff --git a/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts index a2900174a..d0f9ee7a4 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts @@ -7,6 +7,7 @@ import { } from '@nestjs/common'; 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 { BookingRequestRepository } from './booking-request.repository'; import { ContractsService } from './contracts.service'; @@ -30,6 +31,7 @@ export class BookingRequestService { private readonly contractBookingService: ContractBookingService, private readonly notifier: ContractNotifierService, 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, { containers: dto.containers, bulk: dto.bulk, diff --git a/apps/edr-freight-api/src/modules/payment-settings/djf-currency-switch.spec.ts b/apps/edr-freight-api/src/modules/payment-settings/djf-currency-switch.spec.ts new file mode 100644 index 000000000..d1f1a9baa --- /dev/null +++ b/apps/edr-freight-api/src/modules/payment-settings/djf-currency-switch.spec.ts @@ -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) => { + const stored = { id: "settings-1", ...row } as ManualPaymentSetting; + return { + findOne: async () => stored, + create: (v: Partial) => v as ManualPaymentSetting, + save: async (v: ManualPaymentSetting) => v, + update: async (_id: string, patch: Partial) => { + Object.assign(stored, patch); + }, + }; +}; + +const serviceWith = (row: Partial) => + 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); + }); +}); diff --git a/apps/edr-freight-api/src/modules/payment-settings/dto/update-manual-payment-setting.dto.ts b/apps/edr-freight-api/src/modules/payment-settings/dto/update-manual-payment-setting.dto.ts index 39d4757e7..0672d573e 100644 --- a/apps/edr-freight-api/src/modules/payment-settings/dto/update-manual-payment-setting.dto.ts +++ b/apps/edr-freight-api/src/modules/payment-settings/dto/update-manual-payment-setting.dto.ts @@ -20,4 +20,12 @@ export class UpdateManualPaymentSettingDto { @IsOptional() @IsBoolean() djfEnabled?: boolean; + + @ApiPropertyOptional({ + description: + "Accept DJF as a payment currency at all — booking forms and online payment", + }) + @IsOptional() + @IsBoolean() + djfPaymentsEnabled?: boolean; } diff --git a/apps/edr-freight-api/src/modules/payment-settings/entities/manual-payment-setting.entity.ts b/apps/edr-freight-api/src/modules/payment-settings/entities/manual-payment-setting.entity.ts index 862456241..d201bb07e 100644 --- a/apps/edr-freight-api/src/modules/payment-settings/entities/manual-payment-setting.entity.ts +++ b/apps/edr-freight-api/src/modules/payment-settings/entities/manual-payment-setting.entity.ts @@ -24,6 +24,17 @@ export class ManualPaymentSetting extends BaseEntity { @Column({ name: "djf_enabled", type: "boolean", default: true }) 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. */ @Column({ name: "updated_by_id", type: "uuid", nullable: true }) updatedById?: string | null; diff --git a/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.controller.ts b/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.controller.ts index 786d1f7ca..6b29ee226 100644 --- a/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.controller.ts +++ b/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.controller.ts @@ -30,13 +30,26 @@ export class ManualPaymentSettingsController { 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() @BookingStaff([ FREIGHT_PERMS.settings.manualPayment.manage, FREIGHT_PERMS.admin, ]) @ApiOperation({ - summary: "Enable or disable manual invoice settlement for ETB and/or USD", + summary: + "Enable or disable manual invoice settlement per currency, and whether DJF is accepted at all", }) update( @Body() dto: UpdateManualPaymentSettingDto, diff --git a/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.service.ts b/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.service.ts index dc397cf79..50d05a7f9 100644 --- a/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.service.ts +++ b/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.service.ts @@ -37,7 +37,12 @@ export class ManualPaymentSettingsService { if (existing) return existing; 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]; } + /** + * 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 { + 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 { + if (currency?.toUpperCase() !== "DJF") return true; + return (await this.get()).djfPaymentsEnabled; + } + /** Flip any toggle; an omitted field leaves that currency unchanged. */ async update( - patch: { etbEnabled?: boolean; usdEnabled?: boolean; djfEnabled?: boolean }, + patch: { + etbEnabled?: boolean; + usdEnabled?: boolean; + djfEnabled?: boolean; + djfPaymentsEnabled?: boolean; + }, updatedById?: string | null, ): Promise { const current = await this.get(); @@ -70,11 +100,14 @@ export class ManualPaymentSettingsService { ...(patch.etbEnabled === undefined ? {} : { etbEnabled: patch.etbEnabled }), ...(patch.usdEnabled === undefined ? {} : { usdEnabled: patch.usdEnabled }), ...(patch.djfEnabled === undefined ? {} : { djfEnabled: patch.djfEnabled }), + ...(patch.djfPaymentsEnabled === undefined + ? {} + : { djfPaymentsEnabled: patch.djfPaymentsEnabled }), updatedById: updatedById ?? null, }); const updated = await this.get(); 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; } diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/OperationRescheduleModal.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/OperationRescheduleModal.tsx index 8fa1d5c46..0e31ceff4 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/OperationRescheduleModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/OperationRescheduleModal.tsx @@ -22,6 +22,11 @@ import { useBookingDetail, useBookingMutations, } from "@/hooks/bookings/useBookings"; +import { + eatDay, + exportTrainOption, + isExportRailBooking, +} from "@/features/bookings/shipmentDay"; export interface OperationRescheduleModalProps { bookingId: string; diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index cfcf3ef4f..b8b020148 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -65,6 +65,7 @@ import { type ConsolidationCandidate, } from "@/services/contracts.service"; import { bookingsService } from "@/services/bookings.service"; +import { usePaymentCurrenciesQuery } from "@/hooks/useManualPaymentSettings"; import { useContractCapacity, useContractDetail, @@ -346,6 +347,9 @@ export default function GlCreateBookingForm() { // IMPORT bookings pick ETB or USD — starts empty so the choice is // deliberate (required before pricing). Everything else is forced to ETB. 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). const [cargoDescription, setCargoDescription] = useState(""); const [containerLines, setContainerLines] = useState([]); @@ -2395,7 +2399,9 @@ export default function GlCreateBookingForm() { onChange={setPaymentCurrency} disabled={!isImport || requestCurrencyLocked} 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} /> diff --git a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx index 1f64a6478..61c4c5b7f 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx @@ -624,7 +624,7 @@ export const buildSidebarSections = ( permission: FREIGHT_PERMS.settings.operationsStandards.view, }, { - label: "Manual payments", + label: "Payments", href: "/dashboard/configuration/manual-payments", permission: FREIGHT_PERMS.settings.manualPayment.view, }, diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 5809a7b39..1d69a892a 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -63,6 +63,7 @@ export const URL_CONSTANTS = { MANUAL_PAYMENT_SETTINGS: { BASE: "/payment-settings/manual", + CURRENCIES: "/payment-settings/manual/currencies", }, AUDIT_LOGS: { diff --git a/apps/edr-freight-web/backoffice/src/hooks/useManualPaymentSettings.ts b/apps/edr-freight-web/backoffice/src/hooks/useManualPaymentSettings.ts index cbb5f5996..fddb42240 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/useManualPaymentSettings.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/useManualPaymentSettings.ts @@ -9,6 +9,18 @@ import { import { useErrorHandler } from "@/shared/hooks/useErrorHandler"; 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 = () => useQuery({ @@ -25,13 +37,18 @@ export const useUpdateManualPaymentSettings = () => { return useMutation({ mutationFn: ( patch: Partial< - Pick + Pick< + ManualPaymentSettings, + "etbEnabled" | "usdEnabled" | "djfEnabled" | "djfPaymentsEnabled" + > >, ) => manualPaymentSettingsService.update(patch), onSuccess: (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: PAYMENT_CURRENCIES_KEY }); toast.success( t("manualPaymentSettings.updated", "Manual payment settings updated"), ); diff --git a/apps/edr-freight-web/backoffice/src/pages/invoices/UsdPaymentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/invoices/UsdPaymentsPage.tsx index eeceae91b..92202c4c5 100644 --- a/apps/edr-freight-web/backoffice/src/pages/invoices/UsdPaymentsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/invoices/UsdPaymentsPage.tsx @@ -291,8 +291,8 @@ export default function UsdPaymentsPanel({ const { user } = useAuth(); const canConfirm = hasPermission(user, FREIGHT_PERMS.invoices.confirmOffline); - // Manual settlement is switched on per currency in Configuration → Manual - // payments. FinanceHubPage hides the tab for a disabled currency; this is + // Manual settlement is switched on per currency in Configuration → + // Payments. FinanceHubPage hides the tab for a disabled currency; this is // the fallback for a direct `?tab=` link, and the API refuses regardless. const { data: manualSettings } = useManualPaymentSettingsQuery(); const currencyEnabled = manualSettings @@ -549,7 +549,7 @@ export default function UsdPaymentsPanel({ onRowClick={(row) => navigate(`/dashboard/invoices/${row.id}`)} emptyMessage={ !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 ? "No invoices match these filters." : `No ${currency} invoices awaiting manual payment confirmation.` diff --git a/apps/edr-freight-web/backoffice/src/pages/settings/ManualPaymentSettingsCard.tsx b/apps/edr-freight-web/backoffice/src/pages/settings/ManualPaymentSettingsCard.tsx index d7386259a..0edea429a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/settings/ManualPaymentSettingsCard.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/settings/ManualPaymentSettingsCard.tsx @@ -8,7 +8,7 @@ import { import { Badge } from "@/shared/common/ui/badge"; import { Switch } from "@/shared/common/ui/switch"; 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 { 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 * enabled currencies, and the API refuses a confirmation in a disabled one — @@ -75,11 +76,12 @@ export default function ManualPaymentSettingsCard() { return ( - Manual payments + Payments - Whether Finance staff may mark invoices as paid by hand, from - Invoices → Manual Payments. Each currency is switched separately. - Confirming still requires the payment slip and the booking's pay + Which currencies customers may be billed in, and whether Finance + staff may mark invoices as paid by hand from Invoices → Manual + Payments. Each currency is switched separately. Confirming a manual + payment still requires the payment slip and the booking's pay window to be open. @@ -95,6 +97,38 @@ export default function ManualPaymentSettingsCard() { )} + {isLoading || !data ? ( + + ) : ( +
+
+
+ +

Accept Djibouti Franc (DJF)

+ + {data.djfPaymentsEnabled ? "Enabled" : "Disabled"} + +
+

+ 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. +

+
+ + update.mutate({ djfPaymentsEnabled: checked }) + } + /> +
+ )} + +

Manual (offline) settlement

+ {isLoading || !data ? CURRENCIES.map((c) => ( diff --git a/apps/edr-freight-web/backoffice/src/services/manualPaymentSettings.service.ts b/apps/edr-freight-web/backoffice/src/services/manualPaymentSettings.service.ts index ffe5281ec..d231eac98 100644 --- a/apps/edr-freight-web/backoffice/src/services/manualPaymentSettings.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/manualPaymentSettings.service.ts @@ -14,6 +14,12 @@ export interface ManualPaymentSettings { etbEnabled: boolean; usdEnabled: 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; updatedAt?: string; } @@ -24,10 +30,21 @@ export const manualPaymentSettingsService = { return unwrap(response.data); }, + /** Currencies customers may be billed and pay in — open read, no permission. */ + currencies: async (): Promise => { + const response = await client.get>( + URL_CONSTANTS.MANUAL_PAYMENT_SETTINGS.CURRENCIES, + ); + return unwrap(response.data); + }, + /** Partial: an omitted currency keeps its current setting. */ update: async ( patch: Partial< - Pick + Pick< + ManualPaymentSettings, + "etbEnabled" | "usdEnabled" | "djfEnabled" | "djfPaymentsEnabled" + > >, ): Promise => { const response = await client.patch>( diff --git a/apps/edr-freight-web/portal/src/hooks/usePaymentCurrencies.ts b/apps/edr-freight-web/portal/src/hooks/usePaymentCurrencies.ts new file mode 100644 index 000000000..d09e7201a --- /dev/null +++ b/apps/edr-freight-web/portal/src/hooks/usePaymentCurrencies.ts @@ -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>( + "/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, + }; +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx index 24f817f14..8e8adf99a 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx @@ -13,6 +13,7 @@ import { Check, Landmark, ShieldCheck } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import type { InvoicePaymentFlow } from "@/hooks/useInvoicePayment"; +import { usePaymentCurrencies } from "@/hooks/usePaymentCurrencies"; import { PayerAccountNote } from "@/pages/bookings/payments/PayerAccountNote"; 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. */ 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( () => - providersForCurrency(currency).filter( - (p) => - (otp || !isOtpMethod(p.method)) && (bill || !isBillMethod(p.method)), - ), - [currency, otp, bill], + currencyOff + ? [] + : providersForCurrency(currency).filter( + (p) => + (otp || !isOtpMethod(p.method)) && + (bill || !isBillMethod(p.method)), + ), + [currency, otp, bill, currencyOff], ); const [method, setMethod] = useState(providers[0]?.method ?? PROVIDERS[0].method); const [mobile, setMobile] = useState(""); diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/payment-currency-field.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/payment-currency-field.tsx index 9cee483db..b7461f5ae 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/payment-currency-field.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/payment-currency-field.tsx @@ -7,6 +7,7 @@ import { type BookingFormValues, type PaymentCurrency, } from "./schema"; +import { usePaymentCurrencies } from "@/hooks/usePaymentCurrencies"; import { OptionFieldError, StepLabel } from "./shared"; const CURRENCY_ICONS: Record< @@ -29,10 +30,14 @@ export function PaymentCurrencyField({ */ allowUsd?: boolean; }) { - // DJF is offered wherever USD is — both are import-shipment-only currencies. - const options = allowUsd - ? PAYMENT_CURRENCY_OPTIONS - : PAYMENT_CURRENCY_OPTIONS.filter((o) => o.value === "ETB"); + // DJF is offered wherever USD is — both are import-shipment-only currencies + // — and only while staff keep it switched on in Configuration → Payments. + const { djfEnabled } = usePaymentCurrencies(); + const options = ( + allowUsd + ? PAYMENT_CURRENCY_OPTIONS + : PAYMENT_CURRENCY_OPTIONS.filter((o) => o.value === "ETB") + ).filter((o) => o.value !== "DJF" || djfEnabled); return ( Payment currency diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx index 3cc75e221..8019a76b6 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -56,6 +56,8 @@ import { ExportTrainPicker, OperationDatePicker, } from "@edr/ui-common"; + +import { usePaymentCurrencies } from "@/hooks/usePaymentCurrencies"; import { api } from "@/services/api"; import { contractsService, @@ -1318,6 +1320,7 @@ function ScheduleStep({ }) { const contractRouteId = form.watch("contractRouteId"); 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 // can actually be shipped (matching wagons + open train capacity). @@ -1474,7 +1477,8 @@ function ScheduleStep({ onChange={(v) => field.onChange(v)} error={fieldState.error?.message} allowUsd={isImport} - allowDjf={isImport} + // Hidden once staff switch DJF off, unless it is already picked. + allowDjf={isImport && (djfEnabled || field.value === "DJF")} /> )} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx index 29e7406ef..3d3e6777d 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx @@ -15,6 +15,8 @@ import { Title, } from "@mantine/core"; import { CurrencySelector } from "@edr/ui-common"; + +import { usePaymentCurrencies } from "@/hooks/usePaymentCurrencies"; import { AlertCircle, ArrowLeft, CalendarDays, Send } from "lucide-react"; import toast from "react-hot-toast"; 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 // submit. Intercity/export are forced to ETB (server-enforced too). const [paymentCurrency, setPaymentCurrency] = useState<"USD" | "ETB" | "DJF" | "">(""); + const { djfEnabled } = usePaymentCurrencies(); const [currencyError, setCurrencyError] = useState(); const [notes, setNotes] = useState(""); @@ -267,7 +270,7 @@ export default function NewShipmentRequestPage() { }} disabled={isIntercity || isExport} allowUsd={!isIntercity && !isExport} - allowDjf={!isIntercity && !isExport} + allowDjf={!isIntercity && !isExport && djfEnabled} error={currencyError} />