feat(freight-api): add DJF as a supported currency

Adds DJF to freight.payments_currency_enum (migration 3860000000000,
alone in its own migration per Postgres's ADD VALUE-in-a-transaction
restriction) and to manual_payment_settings (djf_enabled column,
migration 3870000000000, default true).

Replaces the binary ETB/USD assumptions that would have silently
mispriced or discarded a DJF booking:
- booking-pricing / contract-pricing: usdToEtb scalar -> a rate table
  keyed by source currency (ExchangeService.getRateTable), so a
  contract-frozen rate converts into whatever currency the booking is
  paid in instead of being dropped when neither leg is ETB or USD.
- warehouse-fee / booking-wagon-cancellation: normalizeCurrency no
  longer coerces anything non-ETB to USD.
- additional-charge: convertAmount no longer bails out for a currency
  that isn't literally ETB or USD.
- manual-payment-settings: isEnabled/enabledCurrencies cover DJF.

Widens the three @IsIn(['ETB','USD']) DTO validators, and adds DJF to
the export/report currency filter option lists.

Claude-Session: https://claude.ai/code/session_01CZy77vCWhka3pnmVF9NDkL
This commit is contained in:
ghost2023
2026-09-04 11:52:37 +03:00
parent 21dc28a708
commit 3734ca3897
20 changed files with 183 additions and 75 deletions

View File

@@ -15,4 +15,9 @@ export class UpdateManualPaymentSettingDto {
@IsOptional()
@IsBoolean()
usdEnabled?: boolean;
@ApiPropertyOptional({ description: "Allow manual settlement of DJF invoices" })
@IsOptional()
@IsBoolean()
djfEnabled?: boolean;
}

View File

@@ -20,6 +20,10 @@ export class ManualPaymentSetting extends BaseEntity {
@Column({ name: "usd_enabled", type: "boolean", default: true })
usdEnabled!: boolean;
/** Manual settlement allowed for DJF invoices. */
@Column({ name: "djf_enabled", type: "boolean", default: true })
djfEnabled!: 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

@@ -4,16 +4,23 @@ import { Repository } from "typeorm";
import { ManualPaymentSetting } from "./entities/manual-payment-setting.entity";
/** The two currencies an invoice can be settled by hand in. */
export type ManualPaymentCurrency = "ETB" | "USD";
/** The currencies an invoice can be settled by hand in. */
export type ManualPaymentCurrency = "ETB" | "USD" | "DJF";
const FIELD_BY_CURRENCY: Record<ManualPaymentCurrency, "etbEnabled" | "usdEnabled" | "djfEnabled"> = {
ETB: "etbEnabled",
USD: "usdEnabled",
DJF: "djfEnabled",
};
/**
* Owns the single `manual_payment_settings` row: whether Finance may settle
* invoices by hand, per currency.
*
* Defaults mirror how the platform behaved before the toggles existed — USD
* has always been bank-transfer-only so it starts ON; ETB manual settlement is
* the new capability and starts OFF, so enabling it is a deliberate act.
* and DJF have always been bank-transfer-capable so they start ON; ETB manual
* settlement is the new capability and starts OFF, so enabling it is a
* deliberate act.
*/
@Injectable()
export class ManualPaymentSettingsService {
@@ -30,7 +37,7 @@ export class ManualPaymentSettingsService {
if (existing) return existing;
return this.repository.save(
this.repository.create({ etbEnabled: false, usdEnabled: true }),
this.repository.create({ etbEnabled: false, usdEnabled: true, djfEnabled: true }),
);
}
@@ -40,31 +47,34 @@ export class ManualPaymentSettingsService {
const enabled: ManualPaymentCurrency[] = [];
if (setting.etbEnabled) enabled.push("ETB");
if (setting.usdEnabled) enabled.push("USD");
if (setting.djfEnabled) enabled.push("DJF");
return enabled;
}
/** Whether one currency may be settled by hand right now. */
async isEnabled(currency: string | null | undefined): Promise<boolean> {
const upper = currency?.toUpperCase();
if (upper !== "ETB" && upper !== "USD") return false;
const field = FIELD_BY_CURRENCY[upper as ManualPaymentCurrency];
if (!field) return false;
const setting = await this.get();
return upper === "ETB" ? setting.etbEnabled : setting.usdEnabled;
return setting[field];
}
/** Flip either toggle; an omitted field leaves that currency unchanged. */
/** Flip any toggle; an omitted field leaves that currency unchanged. */
async update(
patch: { etbEnabled?: boolean; usdEnabled?: boolean },
patch: { etbEnabled?: boolean; usdEnabled?: boolean; djfEnabled?: boolean },
updatedById?: string | null,
): Promise<ManualPaymentSetting> {
const current = await this.get();
await this.repository.update(current.id, {
...(patch.etbEnabled === undefined ? {} : { etbEnabled: patch.etbEnabled }),
...(patch.usdEnabled === undefined ? {} : { usdEnabled: patch.usdEnabled }),
...(patch.djfEnabled === undefined ? {} : { djfEnabled: patch.djfEnabled }),
updatedById: updatedById ?? null,
});
const updated = await this.get();
this.logger.warn(
`Manual payment channels set to ETB=${updated.etbEnabled} USD=${updated.usdEnabled} by ${updatedById ?? "unknown user"}`,
`Manual payment channels set to ETB=${updated.etbEnabled} USD=${updated.usdEnabled} DJF=${updated.djfEnabled} by ${updatedById ?? "unknown user"}`,
);
return updated;
}