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, // 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
);

View File

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

View File

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

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

View File

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

View File

@@ -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<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. */
async update(
patch: { etbEnabled?: boolean; usdEnabled?: boolean; djfEnabled?: boolean },
patch: {
etbEnabled?: boolean;
usdEnabled?: boolean;
djfEnabled?: boolean;
djfPaymentsEnabled?: boolean;
},
updatedById?: string | null,
): Promise<ManualPaymentSetting> {
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;
}