mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 11:55:42 +00:00
@@ -172,14 +172,23 @@ EIMS_SELLER_LOCALITY=
|
|||||||
# Tax treatment — REQUIRES FINANCE SIGN-OFF. The application models no tax at all
|
# Tax treatment — REQUIRES FINANCE SIGN-OFF. The application models no tax at all
|
||||||
# (invoice.taxAmount is always 0), so nothing here is defaulted: registration fails
|
# (invoice.taxAmount is always 0), so nothing here is defaulted: registration fails
|
||||||
# locally, naming the missing variables, until these are set.
|
# locally, naming the missing variables, until these are set.
|
||||||
# Required, and deliberately unset: the choice is a tax position, not a default.
|
# Required, and deliberately unset here: the choice is a tax position, not a default.
|
||||||
# MoR's enum (from its own 400): TOT10 TOT2 VAT15 VWHT TWHT VATEX VATWH WHOP2 WTHOI VAT0 VWTH
|
# MoR's enum (from its own 400): TOT10 TOT2 VAT15 VWHT TWHT VATEX VATWH WHOP2 WTHOI VAT0 VWTH
|
||||||
# Pending finance confirmation of VAT0 (zero-rated) vs VATEX (exempt).
|
# Finance confirmed 2026-08-12: VATEX (exempt) for EDR's freight business — set in .env.
|
||||||
EIMS_TAX_CODE=
|
EIMS_TAX_CODE=
|
||||||
EIMS_TAX_RATE_PERCENT=0
|
EIMS_TAX_RATE_PERCENT=0
|
||||||
EIMS_EXCISE_TAX_VALUE=0
|
EIMS_EXCISE_TAX_VALUE=0
|
||||||
EIMS_INCOME_WITHHOLD_VALUE=0
|
EIMS_INCOME_WITHHOLD_VALUE=0
|
||||||
EIMS_TRANSACTION_WITHHOLD_VALUE=0
|
EIMS_TRANSACTION_WITHHOLD_VALUE=0
|
||||||
|
# Per-chargeType override, for an invoice whose lines need different MoR tax treatment (e.g. a
|
||||||
|
# zero-rated freight line next to a taxed accessorial) — IRC-P01 compliance-test material.
|
||||||
|
# A charge type not listed here falls back to EIMS_TAX_CODE / EIMS_TAX_RATE_PERCENT above.
|
||||||
|
# EIMS_TAX_CODE_BY_CHARGE_TYPE and EIMS_TAX_RATE_BY_CHARGE_TYPE must list the same charge types.
|
||||||
|
EIMS_TAX_CODE_BY_CHARGE_TYPE=
|
||||||
|
EIMS_TAX_RATE_BY_CHARGE_TYPE=
|
||||||
|
# Same mechanism; charge types not listed fall back to EIMS_EXCISE_TAX_VALUE / 0 respectively.
|
||||||
|
EIMS_EXCISE_BY_CHARGE_TYPE=
|
||||||
|
EIMS_DISCOUNT_BY_CHARGE_TYPE=
|
||||||
# Document classification and payment presentation.
|
# Document classification and payment presentation.
|
||||||
EIMS_TRANSACTION_TYPE=B2B
|
EIMS_TRANSACTION_TYPE=B2B
|
||||||
# Lowercase constant: MoR's oneOf branches require exactly 'goods' or 'service'.
|
# Lowercase constant: MoR's oneOf branches require exactly 'goods' or 'service'.
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ import { LastMileRequestsModule } from "./modules/last-mile-requests/last-mile-r
|
|||||||
import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module";
|
import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module";
|
||||||
import { ImportOperationsModule } from "./modules/import-operations/import-operations.module";
|
import { ImportOperationsModule } from "./modules/import-operations/import-operations.module";
|
||||||
import { AiModule } from "./modules/ai/ai.module";
|
import { AiModule } from "./modules/ai/ai.module";
|
||||||
|
import { AuditModule } from "./modules/audit/audit.module";
|
||||||
import { RequestLogMiddleware } from "@edr/api-common";
|
import { RequestLogMiddleware } from "@edr/api-common";
|
||||||
import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware";
|
import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware";
|
||||||
import { PositionTypePermissionsCache } from "./common/position-type-permissions.cache";
|
import { PositionTypePermissionsCache } from "./common/position-type-permissions.cache";
|
||||||
@@ -244,6 +245,7 @@ if (!process.env.APPLICATION_NAME) {
|
|||||||
EimsModule,
|
EimsModule,
|
||||||
FleetHistoryModule,
|
FleetHistoryModule,
|
||||||
AiModule,
|
AiModule,
|
||||||
|
AuditModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
EdrOrgSeeder,
|
EdrOrgSeeder,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Logger } from "@nestjs/common";
|
import { Logger } from "@nestjs/common";
|
||||||
|
import type { Repository } from "typeorm";
|
||||||
import {
|
import {
|
||||||
|
BaseRepository,
|
||||||
RequestLogMiddleware,
|
RequestLogMiddleware,
|
||||||
getLogContext,
|
getLogContext,
|
||||||
logCtx,
|
logCtx,
|
||||||
@@ -47,10 +49,13 @@ describe("logCtx", () => {
|
|||||||
|
|
||||||
describe("RequestLogMiddleware", () => {
|
describe("RequestLogMiddleware", () => {
|
||||||
it("emits one canonical JSON line carrying the collected context", () => {
|
it("emits one canonical JSON line carrying the collected context", () => {
|
||||||
|
// Raw stdout, not the Nest logger — the line must be parsable JSON with no
|
||||||
|
// "[Nest] … LOG [request]" prefix in front of it.
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
jest
|
jest.spyOn(process.stdout, "write").mockImplementation((chunk) => {
|
||||||
.spyOn(Logger.prototype, "warn")
|
lines.push(String(chunk));
|
||||||
.mockImplementation((m) => lines.push(String(m)));
|
return true;
|
||||||
|
});
|
||||||
jest.spyOn(Logger.prototype, "log").mockImplementation(() => undefined);
|
jest.spyOn(Logger.prototype, "log").mockImplementation(() => undefined);
|
||||||
|
|
||||||
const listeners: Record<string, () => void> = {};
|
const listeners: Record<string, () => void> = {};
|
||||||
@@ -60,10 +65,40 @@ describe("RequestLogMiddleware", () => {
|
|||||||
originalUrl: "/api/bookings/1/submit?dry=1",
|
originalUrl: "/api/bookings/1/submit?dry=1",
|
||||||
baseUrl: "/api/bookings",
|
baseUrl: "/api/bookings",
|
||||||
route: { path: "/:id/submit" },
|
route: { path: "/:id/submit" },
|
||||||
headers: { "user-agent": "jest", "x-request-id": "req-42" },
|
headers: {
|
||||||
|
"user-agent": "jest",
|
||||||
|
"x-request-id": "req-42",
|
||||||
|
authorization: "Bearer tok",
|
||||||
|
"x-client-app": "freight-backoffice",
|
||||||
|
"current-project-id": "proj-3",
|
||||||
|
},
|
||||||
ip: "10.0.0.1",
|
ip: "10.0.0.1",
|
||||||
query: { dry: "1" },
|
query: { dry: "1" },
|
||||||
user: { id: "u-7" },
|
user: {
|
||||||
|
id: "u-7",
|
||||||
|
sessionId: "sess-9",
|
||||||
|
userType: "STAFF",
|
||||||
|
status: "ACTIVE",
|
||||||
|
username: "nati",
|
||||||
|
email: "nati@example.com",
|
||||||
|
phoneNumber: "0911000000",
|
||||||
|
name: { en: "Nati" },
|
||||||
|
roles: [{ key: "freight_operations" }],
|
||||||
|
permissions: [{ key: "a" }, { key: "b" }],
|
||||||
|
employee: {
|
||||||
|
id: "emp-1",
|
||||||
|
organizationId: "org-1",
|
||||||
|
unitId: "unit-2",
|
||||||
|
position: {
|
||||||
|
id: "pos-5",
|
||||||
|
key: "ops_officer",
|
||||||
|
employeePositionId: "ep-6",
|
||||||
|
isDelegate: true,
|
||||||
|
delegatorId: "pos-1",
|
||||||
|
positionType: { key: "operations" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const res = {
|
const res = {
|
||||||
statusCode: 409,
|
statusCode: 409,
|
||||||
@@ -82,7 +117,11 @@ describe("RequestLogMiddleware", () => {
|
|||||||
listeners.close(); // aborts/close after finish must not double-log
|
listeners.close(); // aborts/close after finish must not double-log
|
||||||
|
|
||||||
expect(lines).toHaveLength(1);
|
expect(lines).toHaveLength(1);
|
||||||
|
expect(lines[0].endsWith("\n")).toBe(true);
|
||||||
|
expect(lines[0].startsWith("{")).toBe(true);
|
||||||
expect(JSON.parse(lines[0])).toMatchObject({
|
expect(JSON.parse(lines[0])).toMatchObject({
|
||||||
|
level: "warn",
|
||||||
|
logger: "request",
|
||||||
type: "http_request",
|
type: "http_request",
|
||||||
requestId: "req-42",
|
requestId: "req-42",
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -96,7 +135,65 @@ describe("RequestLogMiddleware", () => {
|
|||||||
bookingId: "b-1",
|
bookingId: "b-1",
|
||||||
booking: { outcome: "REJECTED" },
|
booking: { outcome: "REJECTED" },
|
||||||
});
|
});
|
||||||
|
expect(JSON.parse(lines[0]).auth).toEqual({
|
||||||
|
authenticated: true,
|
||||||
|
hasBearer: true,
|
||||||
|
clientApp: "freight-backoffice",
|
||||||
|
userId: "u-7",
|
||||||
|
sessionId: "sess-9",
|
||||||
|
userType: "STAFF",
|
||||||
|
userStatus: "ACTIVE",
|
||||||
|
roles: ["freight_operations"],
|
||||||
|
permissionCount: 2,
|
||||||
|
employeeId: "emp-1",
|
||||||
|
organizationId: "org-1",
|
||||||
|
unitId: "unit-2",
|
||||||
|
positionId: "pos-5",
|
||||||
|
positionKey: "ops_officer",
|
||||||
|
positionType: "operations",
|
||||||
|
employeePositionId: "ep-6",
|
||||||
|
isDelegate: true,
|
||||||
|
delegatorId: "pos-1",
|
||||||
|
projectId: "proj-3",
|
||||||
|
});
|
||||||
|
// No personal data reaches the line, whatever the token carried.
|
||||||
|
expect(lines[0]).not.toMatch(/nati|example\.com|0911000000/);
|
||||||
expect(res.setHeader).toHaveBeenCalledWith("x-request-id", "req-42");
|
expect(res.setHeader).toHaveBeenCalledWith("x-request-id", "req-42");
|
||||||
jest.restoreAllMocks();
|
jest.restoreAllMocks();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("BaseRepository write trail", () => {
|
||||||
|
class TestRepo extends BaseRepository<{ id: string; status?: string }> {
|
||||||
|
constructor(repo: Repository<{ id: string; status?: string }>) {
|
||||||
|
super(repo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeormRepo = {
|
||||||
|
metadata: { tableName: "booking" },
|
||||||
|
create: (data: unknown) => data,
|
||||||
|
save: async (data: unknown) => data,
|
||||||
|
update: async () => undefined,
|
||||||
|
findOne: async () => ({ id: "b-1", status: "SUBMITTED" }),
|
||||||
|
softDelete: async () => undefined,
|
||||||
|
delete: async () => undefined,
|
||||||
|
} as unknown as Repository<{ id: string; status?: string }>;
|
||||||
|
|
||||||
|
it("records creates, status changes and deletes without any service opting in", async () => {
|
||||||
|
const ctx = await runWithLogContext({}, async () => {
|
||||||
|
const repo = new TestRepo(typeormRepo);
|
||||||
|
await repo.create({ id: "b-1" });
|
||||||
|
await repo.update("b-1", { status: "SUBMITTED" });
|
||||||
|
await repo.update("b-1", { id: "b-1" }); // no status → no transition entry
|
||||||
|
await repo.softDelete("b-1");
|
||||||
|
return getLogContext();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ctx).toEqual({
|
||||||
|
db: { created: { booking: 1 }, updated: { booking: 2 } },
|
||||||
|
statusChanges: [{ entity: "booking", id: "b-1", status: "SUBMITTED" }],
|
||||||
|
deleted: [{ entity: "booking", id: "b-1" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -89,8 +89,30 @@ export interface EimsInvoiceConfig {
|
|||||||
buyerRegionCodes: Record<string, string>;
|
buyerRegionCodes: Record<string, string>;
|
||||||
/** Same mechanism as `buyerRegionCodes`, for `EIMS_BUYER_WEREDA_CODES` ("Yeka=574"). */
|
/** Same mechanism as `buyerRegionCodes`, for `EIMS_BUYER_WEREDA_CODES` ("Yeka=574"). */
|
||||||
buyerWeredaCodes: Record<string, string>;
|
buyerWeredaCodes: Record<string, string>;
|
||||||
|
/**
|
||||||
|
* Per-`chargeType` tax treatment, e.g. `EIMS_TAX_CODE_BY_CHARGE_TYPE=RAIL_FREIGHT=VAT0` +
|
||||||
|
* `EIMS_TAX_RATE_BY_CHARGE_TYPE=RAIL_FREIGHT=0`. A charge type not listed here falls back to
|
||||||
|
* `taxCode`/`taxRatePercent`. Needed for an invoice whose lines carry different MoR tax
|
||||||
|
* treatment (e.g. zero-rated freight next to a taxed accessorial) — the flat `taxCode` above
|
||||||
|
* cannot express that. Values are raw strings; the context builder parses/validates them.
|
||||||
|
*/
|
||||||
|
taxCodeByChargeType: Record<string, string>;
|
||||||
|
taxRateByChargeType: Record<string, string>;
|
||||||
|
/** Same mechanism, for `EIMS_EXCISE_BY_CHARGE_TYPE` / `EIMS_DISCOUNT_BY_CHARGE_TYPE`. Charge
|
||||||
|
* types not listed fall back to `exciseTaxValue` / 0 respectively. */
|
||||||
|
exciseByChargeType: Record<string, string>;
|
||||||
|
discountByChargeType: Record<string, string>;
|
||||||
cashierName: string | null;
|
cashierName: string | null;
|
||||||
salesPersonName: string | null;
|
salesPersonName: string | null;
|
||||||
|
/**
|
||||||
|
* TEMPORARY / experimental — `EIMS_BUYER_ID_TYPE` + `EIMS_BUYER_ID_NUMBER`, applied to every
|
||||||
|
* buyer regardless of who they are. Only exists to test whether rule 7004 ("Id types should be
|
||||||
|
* one of NID, KID, SID, WID, PST, DLS, MRS") is satisfied by *any* IdType/IdNumber pair, ahead
|
||||||
|
* of MoR's answer on whether it's required for a TIN-only corporate buyer and which value fits.
|
||||||
|
* Wrong for a real, non-self buyer — remove once MoR answers and a real per-buyer field exists.
|
||||||
|
*/
|
||||||
|
buyerIdType: string | null;
|
||||||
|
buyerIdNumber: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const REQUIRED_VARS = [
|
const REQUIRED_VARS = [
|
||||||
@@ -188,8 +210,14 @@ export default registerAs("eims", (): EimsConfig => {
|
|||||||
buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null,
|
buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null,
|
||||||
buyerRegionCodes: parseCodeMap(process.env.EIMS_BUYER_REGION_CODES),
|
buyerRegionCodes: parseCodeMap(process.env.EIMS_BUYER_REGION_CODES),
|
||||||
buyerWeredaCodes: parseCodeMap(process.env.EIMS_BUYER_WEREDA_CODES),
|
buyerWeredaCodes: parseCodeMap(process.env.EIMS_BUYER_WEREDA_CODES),
|
||||||
|
taxCodeByChargeType: parseCodeMap(process.env.EIMS_TAX_CODE_BY_CHARGE_TYPE),
|
||||||
|
taxRateByChargeType: parseCodeMap(process.env.EIMS_TAX_RATE_BY_CHARGE_TYPE),
|
||||||
|
exciseByChargeType: parseCodeMap(process.env.EIMS_EXCISE_BY_CHARGE_TYPE),
|
||||||
|
discountByChargeType: parseCodeMap(process.env.EIMS_DISCOUNT_BY_CHARGE_TYPE),
|
||||||
cashierName: process.env.EIMS_CASHIER_NAME || null,
|
cashierName: process.env.EIMS_CASHIER_NAME || null,
|
||||||
salesPersonName: process.env.EIMS_SALESPERSON_NAME || null,
|
salesPersonName: process.env.EIMS_SALESPERSON_NAME || null,
|
||||||
|
buyerIdType: process.env.EIMS_BUYER_ID_TYPE || null,
|
||||||
|
buyerIdNumber: process.env.EIMS_BUYER_ID_NUMBER || null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ const UNIT_LABELS: Record<string, string> = {
|
|||||||
PER_CONTAINER: 'per container',
|
PER_CONTAINER: 'per container',
|
||||||
PER_KM: 'per km',
|
PER_KM: 'per km',
|
||||||
PER_TON_KM: 'per ton per km',
|
PER_TON_KM: 'per ton per km',
|
||||||
|
PER_LITER: 'per liter',
|
||||||
PER_INVOICE: 'per invoice',
|
PER_INVOICE: 'per invoice',
|
||||||
FLAT: 'flat',
|
FLAT: 'flat',
|
||||||
};
|
};
|
||||||
@@ -66,6 +67,7 @@ const TRIGGER_ROUTE_LABELS: Partial<Record<Rate['trigger'], string>> = {
|
|||||||
DEMURRAGE: 'Demurrage / wagon detention',
|
DEMURRAGE: 'Demurrage / wagon detention',
|
||||||
PIL_EXTRA_FEE: 'PIL shipping line extra fee',
|
PIL_EXTRA_FEE: 'PIL shipping line extra fee',
|
||||||
CUSTOMS_CLEARANCE: 'Customs clearance service',
|
CUSTOMS_CLEARANCE: 'Customs clearance service',
|
||||||
|
FUEL: 'Fuel surcharge',
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -101,6 +103,15 @@ export class ContractRateScheduleBuilder {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fuel is sold per lane + commodity — only lanes matching the contract's
|
||||||
|
// direction belong on its schedule, labeled with their leg.
|
||||||
|
if (rate.trigger === 'FUEL') {
|
||||||
|
if (this.fuelDirectionMatches(rate, direction)) {
|
||||||
|
surcharges.push(this.fuelRow(rate));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Everything left is a trigger-based charge (surcharge / demurrage / customs).
|
// Everything left is a trigger-based charge (surcharge / demurrage / customs).
|
||||||
surcharges.push(this.surchargeRow(rate));
|
surcharges.push(this.surchargeRow(rate));
|
||||||
}
|
}
|
||||||
@@ -176,6 +187,35 @@ export class ContractRateScheduleBuilder {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fuelDirectionMatches(rate: Rate, direction: ContractDirection): boolean {
|
||||||
|
const want =
|
||||||
|
direction === 'IMP' ? 'IMPORT' : direction === 'EXP' ? 'EXPORT' : 'DOMESTIC';
|
||||||
|
return rate.tradeDirection === want;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fuel row — the lane matters, so it rides along in the charge label.
|
||||||
|
* Per-liter collapses to one flat total (base liters × rate value); the
|
||||||
|
* customer only ever sees the final price.
|
||||||
|
*/
|
||||||
|
private fuelRow(rate: Rate): RateScheduleRow {
|
||||||
|
const origin = rate.originYard?.label ?? rate.originYard?.code ?? '—';
|
||||||
|
const destination =
|
||||||
|
rate.destinationYard?.label ?? rate.destinationYard?.code ?? '—';
|
||||||
|
const perLiter = rate.rateUnit === 'PER_LITER';
|
||||||
|
return {
|
||||||
|
route: `Fuel surcharge (${origin} → ${destination})`,
|
||||||
|
cargo: this.cargoLabel(rate),
|
||||||
|
currency: rate.currency,
|
||||||
|
amount: this.formatAmount(
|
||||||
|
perLiter
|
||||||
|
? Number(rate.baseLiters ?? 0) * Number(rate.rateValue)
|
||||||
|
: rate.rateValue,
|
||||||
|
),
|
||||||
|
unit: perLiter ? 'flat' : this.unitLabel(rate.rateUnit),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private surchargeRow(rate: Rate): RateScheduleRow {
|
private surchargeRow(rate: Rate): RateScheduleRow {
|
||||||
return {
|
return {
|
||||||
route: TRIGGER_ROUTE_LABELS[rate.trigger] ?? this.titleCase(rate.trigger),
|
route: TRIGGER_ROUTE_LABELS[rate.trigger] ?? this.titleCase(rate.trigger),
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk contract templates gain trade direction, so the unique key becomes
|
||||||
|
* (cargo type, direction, customs option) instead of (cargo type, customs).
|
||||||
|
*
|
||||||
|
* Intercity is domestic and crosses no border, so it has no customs variant at
|
||||||
|
* all: with_customs stays NULL there, enforced by ck_bulk_intercity_no_customs.
|
||||||
|
* The unique index coalesces that NULL so two intercity templates for the same
|
||||||
|
* cargo type still collide (plain NULLs never do).
|
||||||
|
*
|
||||||
|
* No backfill: staff-created bulk templates are keyed by cargo_type_id and no
|
||||||
|
* such row exists yet — the seeded direction-keyed bulk rows were retired by
|
||||||
|
* 3320000000000 and carry a NULL cargo_type_id. The five system container
|
||||||
|
* templates are untouched: cargo_type_id IS NULL keeps them out of both the
|
||||||
|
* index and the check.
|
||||||
|
*/
|
||||||
|
export class BulkTemplateTradeDirection3420000000000 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.contract_templates
|
||||||
|
ADD COLUMN IF NOT EXISTS trade_direction varchar(20)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(
|
||||||
|
`DROP INDEX IF EXISTS freight.uq_contract_templates_cargo_customs`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_contract_templates_cargo_dir_customs
|
||||||
|
ON freight.contract_templates
|
||||||
|
(cargo_type_id, trade_direction, COALESCE(with_customs, false))
|
||||||
|
WHERE deleted_at IS NULL AND cargo_type_id IS NOT NULL
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.contract_templates
|
||||||
|
DROP CONSTRAINT IF EXISTS ck_bulk_intercity_no_customs
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.contract_templates
|
||||||
|
ADD CONSTRAINT ck_bulk_intercity_no_customs CHECK (
|
||||||
|
cargo_type_id IS NULL
|
||||||
|
OR (
|
||||||
|
trade_direction IN ('IMPORT', 'EXPORT', 'INTERCITY')
|
||||||
|
AND (trade_direction = 'INTERCITY') = (with_customs IS NULL)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.contract_templates
|
||||||
|
DROP CONSTRAINT IF EXISTS ck_bulk_intercity_no_customs
|
||||||
|
`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`DROP INDEX IF EXISTS freight.uq_contract_templates_cargo_dir_customs`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_contract_templates_cargo_customs
|
||||||
|
ON freight.contract_templates (cargo_type_id, with_customs)
|
||||||
|
WHERE deleted_at IS NULL AND cargo_type_id IS NOT NULL
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.contract_templates DROP COLUMN IF EXISTS trade_direction
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fuel surcharge, sold per lane + commodity:
|
||||||
|
*
|
||||||
|
* - cargo_types.has_fuel marks the commodities that incur it (same shape as
|
||||||
|
* has_lashing — the booking's cargo type flag is what fires the charge).
|
||||||
|
* - rates.base_liters carries the liters a PER_LITER fuel rate bills
|
||||||
|
* (price = base_liters × rate_value, once per booking). NULL on every other
|
||||||
|
* rate shape, including PER_WAGON fuel rates (wagons × rate_value).
|
||||||
|
* - CK_rates_yard_scope gains FUEL in its yard-carrying branch: fuel is priced
|
||||||
|
* per origin → destination leg like customs clearance and container return.
|
||||||
|
*/
|
||||||
|
export class FuelSurcharge3430000000000 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.cargo_types
|
||||||
|
ADD COLUMN IF NOT EXISTS has_fuel boolean NOT NULL DEFAULT false
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.rates
|
||||||
|
ADD COLUMN IF NOT EXISTS base_liters numeric(14,4)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope"`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
|
||||||
|
deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR
|
||||||
|
CASE
|
||||||
|
WHEN (trigger = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY'))
|
||||||
|
OR trigger IN ('CUSTOMS_CLEARANCE', 'WITH_RETURN', 'FUEL')
|
||||||
|
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
|
||||||
|
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
|
||||||
|
END
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope"`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
|
||||||
|
deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR
|
||||||
|
CASE
|
||||||
|
WHEN (trigger = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY'))
|
||||||
|
OR trigger IN ('CUSTOMS_CLEARANCE', 'WITH_RETURN')
|
||||||
|
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
|
||||||
|
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
|
||||||
|
END
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.rates DROP COLUMN IF EXISTS base_liters`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.cargo_types DROP COLUMN IF EXISTS has_fuel`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `eims_irn varchar(64)` was sized for a guess; the real value MoR returns is longer. Confirmed
|
||||||
|
* live 2026-08-12 — a genuine `POST /v1/register` acceptance came back as
|
||||||
|
* `test-aeedcbf496f0b63bb035ba3ca1dc674e3c81c980cafeb1c5038421999a23a215` (69 chars: a `test-`
|
||||||
|
* prefix + a 64-hex-char body), which overflowed the column and threw *after* MoR had already
|
||||||
|
* accepted the document — `settleSuccess` never committed, leaving the invoice stuck `SUBMITTING`
|
||||||
|
* and the system-wide reservation stuck in-flight with no block/alert (see
|
||||||
|
* `EimsInvoiceRegistrationService` for the accompanying code fix).
|
||||||
|
*
|
||||||
|
* Widened to `text` rather than a new fixed length: MoR has never documented an IRN format or
|
||||||
|
* length, and a `test-` prefix on a *production* endpoint suggests this may not even be MoR's
|
||||||
|
* real production shape — guessing another fixed bound risks the exact same failure again.
|
||||||
|
*/
|
||||||
|
export class WidenEimsIrn3450000000000 implements MigrationInterface {
|
||||||
|
name = "WidenEimsIrn3450000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.invoices
|
||||||
|
ALTER COLUMN eims_irn TYPE text
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only safe if nothing stored so far exceeds 64 chars — true only until this migration ran. */
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.invoices
|
||||||
|
ALTER COLUMN eims_irn TYPE varchar(64)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/** `DocumentDetails`/register-response field `signedQR`, persisted alongside `eims_irn`. */
|
||||||
|
export class AddEimsSignedQr3460000000000 implements MigrationInterface {
|
||||||
|
name = "AddEimsSignedQr3460000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.invoices
|
||||||
|
ADD COLUMN IF NOT EXISTS eims_signed_qr text
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.invoices
|
||||||
|
DROP COLUMN IF EXISTS eims_signed_qr
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/** Columns for `POST /v1/cancel` — see `EimsCancellationService`. */
|
||||||
|
export class EimsCancellation3470000000000 implements MigrationInterface {
|
||||||
|
name = "EimsCancellation3470000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.invoices
|
||||||
|
ADD COLUMN IF NOT EXISTS eims_cancelled_at timestamptz,
|
||||||
|
ADD COLUMN IF NOT EXISTS eims_cancellation_date varchar(64),
|
||||||
|
ADD COLUMN IF NOT EXISTS eims_cancellation_reason_code varchar(8),
|
||||||
|
ADD COLUMN IF NOT EXISTS eims_cancellation_remark text
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.invoices
|
||||||
|
DROP COLUMN IF EXISTS eims_cancelled_at,
|
||||||
|
DROP COLUMN IF EXISTS eims_cancellation_date,
|
||||||
|
DROP COLUMN IF EXISTS eims_cancellation_reason_code,
|
||||||
|
DROP COLUMN IF EXISTS eims_cancellation_remark
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same bug as `3450000000000-WidenEimsIrn`, same fix: `previous_irn` stores a real MoR IRN too
|
||||||
|
* (fed into the next registration's `ReferenceDetails.PreviousIrn`) and would overflow the same
|
||||||
|
* varchar(64) on the next successful registration.
|
||||||
|
*/
|
||||||
|
export class WidenPreviousIrn3480000000000 implements MigrationInterface {
|
||||||
|
name = "WidenPreviousIrn3480000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.eims_system_state
|
||||||
|
ALTER COLUMN previous_irn TYPE text
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.eims_system_state
|
||||||
|
ALTER COLUMN previous_irn TYPE varchar(64)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/** `freight.eims_receipts` — see `EimsReceipt` entity. */
|
||||||
|
export class EimsReceipts3490000000000 implements MigrationInterface {
|
||||||
|
name = "EimsReceipts3490000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.eims_receipts (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
invoice_id uuid NOT NULL REFERENCES freight.invoices(id),
|
||||||
|
kind varchar(16) NOT NULL,
|
||||||
|
status varchar(20) NOT NULL DEFAULT 'NOT_SUBMITTED',
|
||||||
|
receipt_number varchar(64) NOT NULL,
|
||||||
|
rrn text,
|
||||||
|
qr text,
|
||||||
|
ack_status varchar(8),
|
||||||
|
submitted_at timestamptz,
|
||||||
|
last_error jsonb,
|
||||||
|
request jsonb,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_eims_receipts_invoice_id ON freight.eims_receipts (invoice_id)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.eims_receipts`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -80,6 +80,7 @@ describe("BillingService.generateInvoice", () => {
|
|||||||
{} as never, // companies
|
{} as never, // companies
|
||||||
{} as never, // invoiceDocuments
|
{} as never, // invoiceDocuments
|
||||||
{} as never, // files
|
{} as never, // files
|
||||||
|
{ get: () => undefined } as never, // config
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -142,6 +143,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
|||||||
{} as never, // companies
|
{} as never, // companies
|
||||||
{} as never, // invoiceDocuments
|
{} as never, // invoiceDocuments
|
||||||
{} as never, // files
|
{} as never, // files
|
||||||
|
{ get: () => undefined } as never, // config
|
||||||
);
|
);
|
||||||
|
|
||||||
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
||||||
@@ -196,6 +198,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
|||||||
{} as never, // companies
|
{} as never, // companies
|
||||||
{} as never, // invoiceDocuments
|
{} as never, // invoiceDocuments
|
||||||
{} as never, // files
|
{} as never, // files
|
||||||
|
{ get: () => undefined } as never, // config
|
||||||
);
|
);
|
||||||
|
|
||||||
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
||||||
@@ -240,6 +243,7 @@ describe("BillingService.settleByPaymentId", () => {
|
|||||||
{} as never, // companies
|
{} as never, // companies
|
||||||
{} as never, // invoiceDocuments
|
{} as never, // invoiceDocuments
|
||||||
{} as never, // files
|
{} as never, // files
|
||||||
|
{ get: () => undefined } as never, // config
|
||||||
);
|
);
|
||||||
return { service, mg, events };
|
return { service, mg, events };
|
||||||
}
|
}
|
||||||
@@ -352,6 +356,7 @@ describe("BillingService.recordPayment", () => {
|
|||||||
{} as never, // companies
|
{} as never, // companies
|
||||||
{} as never, // invoiceDocuments
|
{} as never, // invoiceDocuments
|
||||||
{} as never, // files
|
{} as never, // files
|
||||||
|
{ get: () => undefined } as never, // config
|
||||||
);
|
);
|
||||||
return { service, mg, events };
|
return { service, mg, events };
|
||||||
}
|
}
|
||||||
@@ -468,6 +473,7 @@ describe("BillingService.expirePayable — locked write runs in a transaction",
|
|||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
{} as never, // config
|
||||||
);
|
);
|
||||||
return { service, defaultManager, txManager, transaction };
|
return { service, defaultManager, txManager, transaction };
|
||||||
};
|
};
|
||||||
@@ -540,6 +546,7 @@ describe("BillingService.issuePayable", () => {
|
|||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
{} as never, // config
|
||||||
);
|
);
|
||||||
return { service, manager };
|
return { service, manager };
|
||||||
};
|
};
|
||||||
@@ -630,6 +637,7 @@ describe("BillingService — CAC Bank (OTP debit)", () => {
|
|||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
{} as never, // config
|
||||||
);
|
);
|
||||||
return { service, repo };
|
return { service, repo };
|
||||||
};
|
};
|
||||||
@@ -712,6 +720,7 @@ describe("BillingService — CBE bill amounts carry cents, never rounded", () =>
|
|||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
{} as never, // config
|
||||||
);
|
);
|
||||||
return { service, repo };
|
return { service, repo };
|
||||||
};
|
};
|
||||||
@@ -740,3 +749,102 @@ describe("BillingService — CBE bill amounts carry cents, never rounded", () =>
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("BillingService.document", () => {
|
||||||
|
const invoiceRow = (over: Record<string, unknown> = {}) => ({
|
||||||
|
id: "inv-1",
|
||||||
|
invoiceNumber: "INV-20260812-00001",
|
||||||
|
source: "booking",
|
||||||
|
sourceId: "booking-1",
|
||||||
|
status: Freight.InvoiceStatus.Pending,
|
||||||
|
type: "freight",
|
||||||
|
currency: "ETB",
|
||||||
|
subtotalAmount: 100,
|
||||||
|
taxAmount: 0,
|
||||||
|
totalAmount: 100,
|
||||||
|
paidAmount: 0,
|
||||||
|
balanceAmount: 100,
|
||||||
|
issuedAt: new Date(2026, 7, 12),
|
||||||
|
dueAt: new Date(2026, 7, 19),
|
||||||
|
eimsIrn: null,
|
||||||
|
eimsSignedQr: null,
|
||||||
|
company: { name: "ABC Trading PLC", tin: "0999930000", vatNumber: "123475885858" },
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const build = (invoice: Record<string, unknown>) => {
|
||||||
|
const render = jest.fn().mockResolvedValue({ filename: "x.pdf", buffer: Buffer.from("") });
|
||||||
|
const service = new BillingService(
|
||||||
|
{} as never,
|
||||||
|
{ findById: jest.fn().mockResolvedValue(invoice) } as never,
|
||||||
|
{ findAll: jest.fn().mockResolvedValue([]) } as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{ render } as never,
|
||||||
|
{} as never,
|
||||||
|
{
|
||||||
|
get: (key: string) =>
|
||||||
|
key === "eims"
|
||||||
|
? { tin: "0053481357", invoice: { sellerVatNumber: "43256663343256663322" } }
|
||||||
|
: undefined,
|
||||||
|
} as never, // config
|
||||||
|
);
|
||||||
|
return { service, render };
|
||||||
|
};
|
||||||
|
|
||||||
|
it("adds no EIMS IRN row and no QR for an unregistered invoice", async () => {
|
||||||
|
const { service, render } = build(invoiceRow());
|
||||||
|
|
||||||
|
await service.document("inv-1");
|
||||||
|
|
||||||
|
const model = render.mock.calls[0][0];
|
||||||
|
expect(model.summary.find((r: { label: string }) => r.label === "EIMS IRN")).toBeUndefined();
|
||||||
|
expect(model.qrImageUrl).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the buyer's name, TIN and VAT number on every invoice", async () => {
|
||||||
|
const { service, render } = build(invoiceRow());
|
||||||
|
|
||||||
|
await service.document("inv-1");
|
||||||
|
|
||||||
|
const model = render.mock.calls[0][0];
|
||||||
|
expect(model.summary).toContainEqual({ label: "Buyer", value: "ABC Trading PLC" });
|
||||||
|
expect(model.summary).toContainEqual({ label: "Buyer TIN", value: "0999930000" });
|
||||||
|
expect(model.summary).toContainEqual({ label: "Buyer VAT No.", value: "123475885858" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the VAT row when the buyer company has none", async () => {
|
||||||
|
const { service, render } = build(invoiceRow({ company: { name: "Acme", tin: "0011223344" } }));
|
||||||
|
|
||||||
|
await service.document("inv-1");
|
||||||
|
|
||||||
|
const model = render.mock.calls[0][0];
|
||||||
|
expect(model.summary.find((r: { label: string }) => r.label === "Buyer VAT No.")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows EDR's own seller TIN and VAT number from EIMS config", async () => {
|
||||||
|
const { service, render } = build(invoiceRow());
|
||||||
|
|
||||||
|
await service.document("inv-1");
|
||||||
|
|
||||||
|
const model = render.mock.calls[0][0];
|
||||||
|
expect(model.summary).toContainEqual({ label: "Seller TIN", value: "0053481357" });
|
||||||
|
expect(model.summary).toContainEqual({
|
||||||
|
label: "Seller VAT No.",
|
||||||
|
value: "43256663343256663322",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds the EIMS IRN to the summary and renders the QR for a registered invoice", async () => {
|
||||||
|
const { service, render } = build(
|
||||||
|
invoiceRow({ eimsIrn: "IRN-123", eimsSignedQr: "signed-payload" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.document("inv-1");
|
||||||
|
|
||||||
|
const model = render.mock.calls[0][0];
|
||||||
|
expect(model.summary).toContainEqual({ label: "EIMS IRN", value: "IRN-123" });
|
||||||
|
expect(model.qrImageUrl).toBe("data:image/png;base64,signed-payload");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Freight, PaymentReferenceType } from "@edr/types";
|
import { Freight, PaymentReferenceType } from "@edr/types";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
forwardRef,
|
forwardRef,
|
||||||
@@ -8,9 +9,11 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { EventEmitter2 } from "@nestjs/event-emitter";
|
import { EventEmitter2 } from "@nestjs/event-emitter";
|
||||||
|
import { logCtx } from "@edr/api-common";
|
||||||
import { DataSource, EntityManager, In } from "typeorm";
|
import { DataSource, EntityManager, In } from "typeorm";
|
||||||
|
|
||||||
import { Booking } from "../bookings/entities/booking.entity";
|
import { Booking } from "../bookings/entities/booking.entity";
|
||||||
|
import { EimsConfig } from "../../config/eims.config";
|
||||||
import { CompaniesService } from "../companies/companies.service";
|
import { CompaniesService } from "../companies/companies.service";
|
||||||
import { FilesService } from "../files/files.service";
|
import { FilesService } from "../files/files.service";
|
||||||
import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util";
|
import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util";
|
||||||
@@ -160,6 +163,7 @@ export class BillingService {
|
|||||||
private readonly companies: CompaniesService,
|
private readonly companies: CompaniesService,
|
||||||
private readonly invoiceDocuments: InvoiceDocumentService,
|
private readonly invoiceDocuments: InvoiceDocumentService,
|
||||||
private readonly files: FilesService,
|
private readonly files: FilesService,
|
||||||
|
private readonly config: ConfigService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
// ── Reads ──────────────────────────────────────────────────────────────────
|
// ── Reads ──────────────────────────────────────────────────────────────────
|
||||||
@@ -383,7 +387,7 @@ export class BillingService {
|
|||||||
async document(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
async document(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||||
const invoice = await this.findById(id);
|
const invoice = await this.findById(id);
|
||||||
return this.invoiceDocuments.render(
|
return this.invoiceDocuments.render(
|
||||||
this.toDocumentModel(invoice, "INVOICE"),
|
await this.toDocumentModel(invoice, "INVOICE"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,15 +400,24 @@ export class BillingService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return this.invoiceDocuments.render(
|
return this.invoiceDocuments.render(
|
||||||
this.toDocumentModel(invoice, "RECEIPT"),
|
await this.toDocumentModel(invoice, "RECEIPT"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `Invoice.eimsSignedQr` is already a base64 PNG straight from MoR — confirmed against the
|
||||||
|
* Postman collection's `register` response (`signedQR` decodes to a PNG magic-byte header),
|
||||||
|
* not a payload we encode ourselves. Wrapped in a data URL, nothing more.
|
||||||
|
*/
|
||||||
|
private renderEimsQr(signedQr: string): string {
|
||||||
|
return `data:image/png;base64,${signedQr}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Map a global invoice (+ lines) onto the source-agnostic document model. */
|
/** Map a global invoice (+ lines) onto the source-agnostic document model. */
|
||||||
private toDocumentModel(
|
private async toDocumentModel(
|
||||||
invoice: Invoice & { lines: InvoiceLine[] },
|
invoice: Invoice & { lines: InvoiceLine[] },
|
||||||
kind: "INVOICE" | "RECEIPT",
|
kind: "INVOICE" | "RECEIPT",
|
||||||
): InvoiceDocumentModel {
|
): Promise<InvoiceDocumentModel> {
|
||||||
const title = invoice.source
|
const title = invoice.source
|
||||||
? invoice.source.charAt(0).toUpperCase() + invoice.source.slice(1)
|
? invoice.source.charAt(0).toUpperCase() + invoice.source.slice(1)
|
||||||
: "EDR";
|
: "EDR";
|
||||||
@@ -422,6 +435,43 @@ export class BillingService {
|
|||||||
totals.push({ label: "Paid", amount: Number(invoice.paidAmount) });
|
totals.push({ label: "Paid", amount: Number(invoice.paidAmount) });
|
||||||
totals.push({ label: "Balance", amount: Number(invoice.balanceAmount) });
|
totals.push({ label: "Balance", amount: Number(invoice.balanceAmount) });
|
||||||
|
|
||||||
|
const summary: InvoiceDocumentModel["summary"] = [
|
||||||
|
// Buyer identity — was missing entirely; a MoR-registered invoice must show who it was
|
||||||
|
// filed against, not just the seller. VatNumber shown only when the company has one.
|
||||||
|
{ label: "Buyer", value: invoice.company?.name ?? null },
|
||||||
|
{ label: "Buyer TIN", value: invoice.company?.tin ?? null },
|
||||||
|
...(invoice.company?.vatNumber
|
||||||
|
? [{ label: "Buyer VAT No.", value: invoice.company.vatNumber }]
|
||||||
|
: []),
|
||||||
|
{ label: "Status", value: invoice.status },
|
||||||
|
{ label: "Type", value: invoice.type },
|
||||||
|
{ label: "Reference", value: invoice.sourceId },
|
||||||
|
{ label: "Currency", value: invoice.currency },
|
||||||
|
{
|
||||||
|
label: "Issued",
|
||||||
|
value: invoice.issuedAt
|
||||||
|
? new Date(invoice.issuedAt).toLocaleDateString("en-GB")
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Due",
|
||||||
|
value: invoice.dueAt
|
||||||
|
? new Date(invoice.dueAt).toLocaleDateString("en-GB")
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Seller identity — EDR's own legal TIN/VAT live only in EIMS config (nowhere else in this
|
||||||
|
// codebase). Shown only when actually configured, same as the buyer VAT row.
|
||||||
|
const eimsCfg = this.config.get<EimsConfig>("eims");
|
||||||
|
if (eimsCfg?.tin) summary.push({ label: "Seller TIN", value: eimsCfg.tin });
|
||||||
|
if (eimsCfg?.invoice?.sellerVatNumber) {
|
||||||
|
summary.push({ label: "Seller VAT No.", value: eimsCfg.invoice.sellerVatNumber });
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoR EIMS reference — only once actually registered, never a placeholder row.
|
||||||
|
if (invoice.eimsIrn) summary.push({ label: "EIMS IRN", value: invoice.eimsIrn });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
kind,
|
kind,
|
||||||
title,
|
title,
|
||||||
@@ -429,24 +479,7 @@ export class BillingService {
|
|||||||
issuedAt: invoice.issuedAt ?? invoice.createdAt,
|
issuedAt: invoice.issuedAt ?? invoice.createdAt,
|
||||||
status: invoice.status,
|
status: invoice.status,
|
||||||
currency: invoice.currency,
|
currency: invoice.currency,
|
||||||
summary: [
|
summary,
|
||||||
{ label: "Status", value: invoice.status },
|
|
||||||
{ label: "Type", value: invoice.type },
|
|
||||||
{ label: "Reference", value: invoice.sourceId },
|
|
||||||
{ label: "Currency", value: invoice.currency },
|
|
||||||
{
|
|
||||||
label: "Issued",
|
|
||||||
value: invoice.issuedAt
|
|
||||||
? new Date(invoice.issuedAt).toLocaleDateString("en-GB")
|
|
||||||
: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Due",
|
|
||||||
value: invoice.dueAt
|
|
||||||
? new Date(invoice.dueAt).toLocaleDateString("en-GB")
|
|
||||||
: null,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
categoryHeader: "Charge type",
|
categoryHeader: "Charge type",
|
||||||
lines: invoice.lines.map((l) => ({
|
lines: invoice.lines.map((l) => ({
|
||||||
description: l.description ?? l.chargeType,
|
description: l.description ?? l.chargeType,
|
||||||
@@ -457,6 +490,7 @@ export class BillingService {
|
|||||||
currency: l.currency,
|
currency: l.currency,
|
||||||
})),
|
})),
|
||||||
totals,
|
totals,
|
||||||
|
qrImageUrl: invoice.eimsSignedQr ? this.renderEimsQr(invoice.eimsSignedQr) : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -951,6 +985,24 @@ export class BillingService {
|
|||||||
|
|
||||||
await mg.update(Invoice, { id: invoice.id }, { status, ...extra });
|
await mg.update(Invoice, { id: invoice.id }, { status, ...extra });
|
||||||
|
|
||||||
|
// Every invoice status move in the app funnels through here — money
|
||||||
|
// changing state is the single most-asked question in support.
|
||||||
|
logCtx(
|
||||||
|
{
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
invoiceNumber: invoice.invoiceNumber,
|
||||||
|
source: invoice.source,
|
||||||
|
sourceId: invoice.sourceId,
|
||||||
|
from: invoice.status,
|
||||||
|
to: status,
|
||||||
|
event,
|
||||||
|
amount: Number(invoice.totalAmount),
|
||||||
|
currency: invoice.currency,
|
||||||
|
paymentId: extra.paymentId ?? invoice.paymentId ?? undefined,
|
||||||
|
},
|
||||||
|
{ path: "invoiceTransitions", mode: "push" },
|
||||||
|
);
|
||||||
|
|
||||||
const updated = { ...invoice, ...extra, status } as Invoice;
|
const updated = { ...invoice, ...extra, status } as Invoice;
|
||||||
return {
|
return {
|
||||||
result: updated,
|
result: updated,
|
||||||
@@ -1320,6 +1372,21 @@ export class BillingService {
|
|||||||
throw new BadRequestException("Invoice has no outstanding balance.");
|
throw new BadRequestException("Invoice has no outstanding balance.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logCtx(
|
||||||
|
{
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
invoiceNumber: invoice.invoiceNumber,
|
||||||
|
source: invoice.source,
|
||||||
|
sourceId: invoice.sourceId,
|
||||||
|
companyId: invoice.companyId,
|
||||||
|
amountDue,
|
||||||
|
currency: invoice.currency,
|
||||||
|
method: opts.method ?? "TELEBIRR",
|
||||||
|
platform: opts.platform,
|
||||||
|
},
|
||||||
|
{ path: "payment.payInvoice" },
|
||||||
|
);
|
||||||
|
|
||||||
// CAC Bank is an OTP debit — the bank SMSes the code to this number, so it is
|
// CAC Bank is an OTP debit — the bank SMSes the code to this number, so it is
|
||||||
// required up front (the payment service rejects it otherwise, as a 502 here).
|
// required up front (the payment service rejects it otherwise, as a 502 here).
|
||||||
if (
|
if (
|
||||||
@@ -1443,7 +1510,24 @@ export class BillingService {
|
|||||||
// first on DESC, which would hand back an unissued invoice.
|
// first on DESC, which would hand back an unissued invoice.
|
||||||
order: { issuedAt: { direction: "DESC", nulls: "LAST" } },
|
order: { issuedAt: { direction: "DESC", nulls: "LAST" } },
|
||||||
});
|
});
|
||||||
if (!invoice) return null;
|
if (!invoice) {
|
||||||
|
logCtx(
|
||||||
|
{ paymentId, outcome: "no-invoice-for-payment" },
|
||||||
|
{ path: "payment.settleInvoice" },
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
logCtx(
|
||||||
|
{
|
||||||
|
paymentId,
|
||||||
|
providerTxnId,
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
invoiceNumber: invoice.invoiceNumber,
|
||||||
|
invoiceStatus: invoice.status,
|
||||||
|
},
|
||||||
|
{ path: "payment.settleInvoice" },
|
||||||
|
);
|
||||||
|
|
||||||
const settleable: Freight.InvoiceStatus[] = [
|
const settleable: Freight.InvoiceStatus[] = [
|
||||||
...OPEN_STATUSES,
|
...OPEN_STATUSES,
|
||||||
@@ -1453,6 +1537,12 @@ export class BillingService {
|
|||||||
// Already PAID is the ordinary idempotent no-op (redelivery, or settled
|
// Already PAID is the ordinary idempotent no-op (redelivery, or settled
|
||||||
// inline by payInvoice). Anything else means money was captured with
|
// inline by payInvoice). Anything else means money was captured with
|
||||||
// nowhere to land — that needs a person, so say so loudly.
|
// nowhere to land — that needs a person, so say so loudly.
|
||||||
|
logCtx(
|
||||||
|
invoice.status === Freight.InvoiceStatus.Paid
|
||||||
|
? "already-paid"
|
||||||
|
: "captured-with-nowhere-to-land",
|
||||||
|
{ path: "payment.settleInvoice.outcome", mode: "set" },
|
||||||
|
);
|
||||||
if (invoice.status !== Freight.InvoiceStatus.Paid) {
|
if (invoice.status !== Freight.InvoiceStatus.Paid) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Payment ${paymentId} succeeded but invoice ${invoice.invoiceNumber} ` +
|
`Payment ${paymentId} succeeded but invoice ${invoice.invoiceNumber} ` +
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { InvoiceDocumentModel, InvoiceDocumentService } from "./invoice-document.service";
|
||||||
|
|
||||||
|
const model = (over: Partial<InvoiceDocumentModel> = {}): InvoiceDocumentModel => ({
|
||||||
|
kind: "INVOICE",
|
||||||
|
title: "Freight",
|
||||||
|
documentNumber: "INV-20260812-00001",
|
||||||
|
issuedAt: new Date(2026, 7, 12),
|
||||||
|
status: "PENDING",
|
||||||
|
currency: "ETB",
|
||||||
|
summary: [{ label: "Status", value: "PENDING" }],
|
||||||
|
lines: [],
|
||||||
|
totals: [{ label: "Total", amount: 100, grand: true }],
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("InvoiceDocumentService.buildHtml — EIMS QR", () => {
|
||||||
|
const service = new InvoiceDocumentService({} as never, {} as never);
|
||||||
|
|
||||||
|
it("renders no QR block when qrImageUrl is unset", () => {
|
||||||
|
const html = service.buildHtml(model());
|
||||||
|
expect(html).not.toContain('class="qr"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the QR image when qrImageUrl is set", () => {
|
||||||
|
const html = service.buildHtml(model({ qrImageUrl: "data:image/png;base64,QR" }));
|
||||||
|
expect(html).toContain('class="qr"');
|
||||||
|
expect(html).toContain('src="data:image/png;base64,QR"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still shows the IRN text row via the ordinary summary grid", () => {
|
||||||
|
const html = service.buildHtml(
|
||||||
|
model({ summary: [{ label: "EIMS IRN", value: "IRN-123" }] }),
|
||||||
|
);
|
||||||
|
expect(html).toContain("EIMS IRN");
|
||||||
|
expect(html).toContain("IRN-123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("widens the summary's right margin only when a QR is present, to clear the QR block", () => {
|
||||||
|
// "summary-with-qr" also appears in the always-present <style> rule, so the check has to be
|
||||||
|
// the actual div's class attribute, not a bare substring match.
|
||||||
|
expect(service.buildHtml(model())).not.toContain('class="summary summary-with-qr"');
|
||||||
|
expect(service.buildHtml(model({ qrImageUrl: "data:image/png;base64,QR" }))).toContain(
|
||||||
|
'class="summary summary-with-qr"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -62,6 +62,12 @@ export interface InvoiceDocumentModel {
|
|||||||
* explicitly only to override that default for one document.
|
* explicitly only to override that default for one document.
|
||||||
*/
|
*/
|
||||||
stampImageUrl?: string | null;
|
stampImageUrl?: string | null;
|
||||||
|
/**
|
||||||
|
* MoR EIMS verification QR (data URL, pre-rendered by the caller from `Invoice.eimsSignedQr` —
|
||||||
|
* see that column's comment). Set only once an invoice is actually registered; the IRN text
|
||||||
|
* itself goes through the ordinary `summary` rows, not a dedicated field.
|
||||||
|
*/
|
||||||
|
qrImageUrl?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -96,9 +102,11 @@ export class InvoiceDocumentService {
|
|||||||
// summary grid, line-item table, totals) from the model — not a flat
|
// summary grid, line-item table, totals) from the model — not a flat
|
||||||
// plain-text dump — so it still reads as a proper invoice document.
|
// plain-text dump — so it still reads as a proper invoice document.
|
||||||
// ponytail: still draws the plain vector seal, not the uploaded stamp
|
// ponytail: still draws the plain vector seal, not the uploaded stamp
|
||||||
// image — embedding a raster image needs a new PDF XObject primitive
|
// image, and omits the EIMS QR entirely — embedding a raster image
|
||||||
// in styled-pdf.util.ts. Upgrade when the Chromium-less path needs to
|
// needs a new PDF XObject primitive in styled-pdf.util.ts. Upgrade
|
||||||
// carry the real stamp too; today it's a rare degraded fallback.
|
// when the Chromium-less path needs to carry the real stamp/QR too;
|
||||||
|
// today it's a rare degraded fallback. The IRN text itself still
|
||||||
|
// comes through (buildFallbackPdf renders model.summary same as HTML).
|
||||||
fallback: () => this.buildFallbackPdf(resolvedModel),
|
fallback: () => this.buildFallbackPdf(resolvedModel),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -243,6 +251,10 @@ export class InvoiceDocumentService {
|
|||||||
const sealInner = sealMarkup(model.stampImageUrl, sealText);
|
const sealInner = sealMarkup(model.stampImageUrl, sealText);
|
||||||
const sealCssClass = sealClass(model.stampImageUrl);
|
const sealCssClass = sealClass(model.stampImageUrl);
|
||||||
|
|
||||||
|
const qrMarkup = model.qrImageUrl
|
||||||
|
? `<div class="qr"><img src="${esc(model.qrImageUrl)}" alt="EIMS verification QR" /><span>Scan to verify (MoR EIMS)</span></div>`
|
||||||
|
: "";
|
||||||
|
|
||||||
const summaryRows = model.summary
|
const summaryRows = model.summary
|
||||||
.map((row) => `<div><span>${esc(row.label)}</span>${esc(row.value)}</div>`)
|
.map((row) => `<div><span>${esc(row.label)}</span>${esc(row.value)}</div>`)
|
||||||
.join("");
|
.join("");
|
||||||
@@ -281,8 +293,19 @@ export class InvoiceDocumentService {
|
|||||||
.meta strong { display: block; color: #0f172a; font-size: 17px; margin-top: 5px; }
|
.meta strong { display: block; color: #0f172a; font-size: 17px; margin-top: 5px; }
|
||||||
.seal { position: absolute; right: 28px; top: 118px; width: 116px; height: 116px; border: 4px double #0f766e; border-radius: 999px; color: #0f766e; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 18px; transform: rotate(-14deg); opacity: .82; }
|
.seal { position: absolute; right: 28px; top: 118px; width: 116px; height: 116px; border: 4px double #0f766e; border-radius: 999px; color: #0f766e; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 18px; transform: rotate(-14deg); opacity: .82; }
|
||||||
${sealImageCss()}
|
${sealImageCss()}
|
||||||
|
.qr { position: absolute; right: 160px; top: 118px; width: 90px; text-align: center; }
|
||||||
|
.qr img { width: 90px; height: 90px; }
|
||||||
|
.qr span { display: block; font-size: 7px; color: #64748b; margin-top: 3px; }
|
||||||
.summary { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 28px; margin: 24px 150px 16px 0; font-size: 13px; }
|
.summary { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 28px; margin: 24px 150px 16px 0; font-size: 13px; }
|
||||||
.summary div { border-bottom: 1px solid #e2e8f0; padding: 7px 0; }
|
/* The QR block (right:160px, width:90px) sits further inward than the seal alone did — the
|
||||||
|
150px margin above only ever cleared the seal, so a QR-bearing invoice needs more room. */
|
||||||
|
.summary.summary-with-qr { margin-right: 270px; }
|
||||||
|
/* min-width: 0 overrides Grid's default min-width:auto on grid items — without it, a long
|
||||||
|
unbroken value (a 20-digit VAT number) forces its column wider to fit un-wrapped rather than
|
||||||
|
honouring overflow-wrap, which is what actually let text bleed into the seal/QR overlay
|
||||||
|
(confirmed by isolating the two: margin-right alone already positioned the box correctly;
|
||||||
|
the text itself was still escaping the box's own right edge until this was added). */
|
||||||
|
.summary div { border-bottom: 1px solid #e2e8f0; padding: 7px 0; overflow-wrap: break-word; min-width: 0; }
|
||||||
.summary span { color: #64748b; display: block; font-size: 11px; margin-bottom: 3px; }
|
.summary span { color: #64748b; display: block; font-size: 11px; margin-bottom: 3px; }
|
||||||
table { width: 100%; border-collapse: collapse; margin-top: 18px; }
|
table { width: 100%; border-collapse: collapse; margin-top: 18px; }
|
||||||
th { text-align: left; background: #f8fafc; color: #475569; }
|
th { text-align: left; background: #f8fafc; color: #475569; }
|
||||||
@@ -309,7 +332,8 @@ export class InvoiceDocumentService {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="${sealCssClass}">${sealInner}</div>
|
<div class="${sealCssClass}">${sealInner}</div>
|
||||||
<div class="summary">${summaryRows}</div>
|
${qrMarkup}
|
||||||
|
<div class="summary${model.qrImageUrl ? " summary-with-qr" : ""}">${summaryRows}</div>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ const context = (over: Partial<EimsMapperContext> = {}): EimsMapperContext => ({
|
|||||||
salesPersonName: null,
|
salesPersonName: null,
|
||||||
transactionType: "B2B",
|
transactionType: "B2B",
|
||||||
payment: { mode: "CASH", term: "IMMIDIATE" },
|
payment: { mode: "CASH", term: "IMMIDIATE" },
|
||||||
taxForLine: () => ({ code: "VAT15", ratePercent: 15, exciseTaxValue: 0 }),
|
taxForLine: () => ({ code: "VAT15", ratePercent: 15, exciseTaxValue: 0, discount: 0 }),
|
||||||
natureOfSupplies: "Service",
|
natureOfSupplies: "Service",
|
||||||
unitDefault: "PCS",
|
unitDefault: "PCS",
|
||||||
incomeWithholdValue: 0,
|
incomeWithholdValue: 0,
|
||||||
@@ -115,8 +115,8 @@ describe("toEimsInvoice", () => {
|
|||||||
context({
|
context({
|
||||||
taxForLine: (line) =>
|
taxForLine: (line) =>
|
||||||
line.chargeType === "RAIL_FREIGHT"
|
line.chargeType === "RAIL_FREIGHT"
|
||||||
? { code: "VAT15", ratePercent: 15, exciseTaxValue: 0 }
|
? { code: "VAT15", ratePercent: 15, exciseTaxValue: 0, discount: 0 }
|
||||||
: { code: "EXEMPT", ratePercent: 0, exciseTaxValue: 50 },
|
: { code: "EXEMPT", ratePercent: 0, exciseTaxValue: 50, discount: 25 },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -130,6 +130,7 @@ describe("toEimsInvoice", () => {
|
|||||||
TaxCode: "VAT15",
|
TaxCode: "VAT15",
|
||||||
TaxAmount: 1500,
|
TaxAmount: 1500,
|
||||||
ExciseTaxValue: 0,
|
ExciseTaxValue: 0,
|
||||||
|
Discount: 0,
|
||||||
TotalLineAmount: 11500,
|
TotalLineAmount: 11500,
|
||||||
Unit: "PCS",
|
Unit: "PCS",
|
||||||
NatureOfSupplies: "service",
|
NatureOfSupplies: "service",
|
||||||
@@ -141,6 +142,9 @@ describe("toEimsInvoice", () => {
|
|||||||
TaxCode: "EXEMPT",
|
TaxCode: "EXEMPT",
|
||||||
TaxAmount: 0,
|
TaxAmount: 0,
|
||||||
ExciseTaxValue: 50,
|
ExciseTaxValue: 50,
|
||||||
|
// Discount is carried on the line but does not (yet) reduce TotalLineAmount — see the
|
||||||
|
// EimsLineTax.discount comment in eims-invoice.mapper.ts.
|
||||||
|
Discount: 25,
|
||||||
TotalLineAmount: 1050,
|
TotalLineAmount: 1050,
|
||||||
Unit: "CTR",
|
Unit: "CTR",
|
||||||
});
|
});
|
||||||
@@ -187,7 +191,17 @@ describe("toEimsInvoice", () => {
|
|||||||
toEimsInvoice(
|
toEimsInvoice(
|
||||||
invoice(),
|
invoice(),
|
||||||
seller,
|
seller,
|
||||||
context({ taxForLine: () => ({ code: "", ratePercent: 15, exciseTaxValue: 0 }) }),
|
context({ taxForLine: () => ({ code: "", ratePercent: 15, exciseTaxValue: 0, discount: 0 }) }),
|
||||||
|
),
|
||||||
|
).toThrow(/unresolved tax treatment for line 1/);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
toEimsInvoice(
|
||||||
|
invoice(),
|
||||||
|
seller,
|
||||||
|
context({
|
||||||
|
taxForLine: () => ({ code: "VAT15", ratePercent: 15, exciseTaxValue: 0, discount: NaN }),
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
).toThrow(/unresolved tax treatment for line 1/);
|
).toThrow(/unresolved tax treatment for line 1/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -183,6 +183,12 @@ export interface EimsLineTax {
|
|||||||
code: string;
|
code: string;
|
||||||
ratePercent: number;
|
ratePercent: number;
|
||||||
exciseTaxValue: number;
|
exciseTaxValue: number;
|
||||||
|
/**
|
||||||
|
* Line-level `Discount`. Its effect on `TotalLineAmount` has never been observed live (every
|
||||||
|
* prior test ran it at 0), so the total below still sums PreTax + Tax + Excise only — do not
|
||||||
|
* start subtracting this without a confirmed MoR example.
|
||||||
|
*/
|
||||||
|
discount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EimsMapperContext {
|
export interface EimsMapperContext {
|
||||||
@@ -336,7 +342,13 @@ export function toEimsInvoice(
|
|||||||
const ItemList: EimsInvoiceItem[] = invoice.lines.map((line, index) => {
|
const ItemList: EimsInvoiceItem[] = invoice.lines.map((line, index) => {
|
||||||
const lineNumber = index + 1;
|
const lineNumber = index + 1;
|
||||||
const tax = context.taxForLine(line, lineNumber);
|
const tax = context.taxForLine(line, lineNumber);
|
||||||
if (!tax || !tax.code || !Number.isFinite(tax.ratePercent) || !Number.isFinite(tax.exciseTaxValue)) {
|
if (
|
||||||
|
!tax ||
|
||||||
|
!tax.code ||
|
||||||
|
!Number.isFinite(tax.ratePercent) ||
|
||||||
|
!Number.isFinite(tax.exciseTaxValue) ||
|
||||||
|
!Number.isFinite(tax.discount)
|
||||||
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`EIMS mapping: unresolved tax treatment for line ${lineNumber} (${line.chargeType}) ` +
|
`EIMS mapping: unresolved tax treatment for line ${lineNumber} (${line.chargeType}) ` +
|
||||||
`on invoice ${invoice.invoiceNumber}`,
|
`on invoice ${invoice.invoiceNumber}`,
|
||||||
@@ -349,7 +361,7 @@ export function toEimsInvoice(
|
|||||||
const unit = typeof line.metadata?.unit === "string" ? line.metadata.unit : context.unitDefault;
|
const unit = typeof line.metadata?.unit === "string" ? line.metadata.unit : context.unitDefault;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
Discount: 0,
|
Discount: round2(tax.discount),
|
||||||
ExciseTaxValue,
|
ExciseTaxValue,
|
||||||
HarmonizationCode: null,
|
HarmonizationCode: null,
|
||||||
NatureOfSupplies: natureOfSupplies,
|
NatureOfSupplies: natureOfSupplies,
|
||||||
|
|||||||
@@ -111,10 +111,22 @@ export class Invoice extends BaseEntity {
|
|||||||
@Column({ name: "eims_status", type: "varchar", length: 20, default: "NOT_SUBMITTED" })
|
@Column({ name: "eims_status", type: "varchar", length: 20, default: "NOT_SUBMITTED" })
|
||||||
eimsStatus!: EimsInvoiceStatus;
|
eimsStatus!: EimsInvoiceStatus;
|
||||||
|
|
||||||
/** Invoice Reference Number returned by EIMS. Unique across invoices (partial index). */
|
/**
|
||||||
@Column({ name: "eims_irn", type: "varchar", length: 64, nullable: true })
|
* Invoice Reference Number returned by EIMS. Unique across invoices (partial index). `text`,
|
||||||
|
* not a fixed varchar — MoR has never documented an IRN format/length, and a real live value
|
||||||
|
* (a `test-` prefix + 64 hex chars, 69 chars total) already overflowed a prior varchar(64).
|
||||||
|
*/
|
||||||
|
@Column({ name: "eims_irn", type: "text", nullable: true })
|
||||||
eimsIrn?: string | null;
|
eimsIrn?: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `signedQR` from the register response — a base64 PNG image, already rendered by MoR (confirmed
|
||||||
|
* against the Postman collection's saved response: decodes to a PNG magic-byte header). Stored
|
||||||
|
* verbatim; `BillingService.renderEimsQr` only wraps it in a `data:image/png;base64,` URL.
|
||||||
|
*/
|
||||||
|
@Column({ name: "eims_signed_qr", type: "text", nullable: true })
|
||||||
|
eimsSignedQr?: string | null;
|
||||||
|
|
||||||
/** The numeric `DocumentDetails.DocumentNumber` filed for this invoice. */
|
/** The numeric `DocumentDetails.DocumentNumber` filed for this invoice. */
|
||||||
@Column({ name: "eims_document_number", type: "varchar", length: 16, nullable: true })
|
@Column({ name: "eims_document_number", type: "varchar", length: 16, nullable: true })
|
||||||
eimsDocumentNumber?: string | null;
|
eimsDocumentNumber?: string | null;
|
||||||
@@ -133,4 +145,24 @@ export class Invoice extends BaseEntity {
|
|||||||
/** Sanitized last failure: the gateway's own error fields only, never our signed envelope. */
|
/** Sanitized last failure: the gateway's own error fields only, never our signed envelope. */
|
||||||
@Column({ name: "eims_last_error", type: "jsonb", nullable: true })
|
@Column({ name: "eims_last_error", type: "jsonb", nullable: true })
|
||||||
eimsLastError?: EimsInvoiceError | null;
|
eimsLastError?: EimsInvoiceError | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `POST /v1/cancel` — set together, only once `eimsStatus` reaches CANCELLED.
|
||||||
|
* `eimsCancelledAt` is our own server time (same convention as `eimsSubmittedAt`);
|
||||||
|
* `eimsCancellationDate` is MoR's own confirmation string, stored verbatim like `eimsAckDate` —
|
||||||
|
* its format (`"Sun Dec 22 21:55:03 EAT 2024"`, a Java `Date#toString()`) is not reliably
|
||||||
|
* `Date.parse`-able (the `EAT` zone abbreviation is non-standard), so it is never parsed.
|
||||||
|
*/
|
||||||
|
@Column({ name: "eims_cancelled_at", type: "timestamptz", nullable: true })
|
||||||
|
eimsCancelledAt?: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: "eims_cancellation_date", type: "varchar", length: 64, nullable: true })
|
||||||
|
eimsCancellationDate?: string | null;
|
||||||
|
|
||||||
|
/** Numeric string per the collection docs, e.g. "1" (Duplicate), "6" (Calculation Error). */
|
||||||
|
@Column({ name: "eims_cancellation_reason_code", type: "varchar", length: 8, nullable: true })
|
||||||
|
eimsCancellationReasonCode?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: "eims_cancellation_remark", type: "text", nullable: true })
|
||||||
|
eimsCancellationRemark?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { BaseRepository } from '@edr/api-common';
|
import { BaseRepository, logCtx } from '@edr/api-common';
|
||||||
import { SchedulingStatus } from '@edr/types';
|
import { SchedulingStatus } from '@edr/types';
|
||||||
import { ConflictException, Injectable } from '@nestjs/common';
|
import { ConflictException, Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
@@ -631,6 +631,13 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
authorId?: string,
|
authorId?: string,
|
||||||
): Promise<BookingReviewNote> {
|
): Promise<BookingReviewNote> {
|
||||||
const repo = this.dataSource.getRepository(BookingReviewNote);
|
const repo = this.dataSource.getRepository(BookingReviewNote);
|
||||||
|
// Every rejection/cancellation/change-request reason in the booking flow is
|
||||||
|
// written through here — the "why" behind the status change on the same log
|
||||||
|
// line as the status change itself.
|
||||||
|
logCtx(
|
||||||
|
{ bookingId, type, note, authorId },
|
||||||
|
{ path: 'reviewNotes', mode: 'push' },
|
||||||
|
);
|
||||||
return repo.save(
|
return repo.save(
|
||||||
repo.create({ bookingId, note, type, authorId: authorId ?? null }),
|
repo.create({ bookingId, note, type, authorId: authorId ?? null }),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Freight, SchedulingStatus } from '@edr/types';
|
import { Freight, SchedulingStatus } from '@edr/types';
|
||||||
import { insertWithGeneratedReference } from '@edr/api-common';
|
import { insertWithGeneratedReference, logCtx } from '@edr/api-common';
|
||||||
// import { CustomersService } from '../customers/customers.service';
|
// import { CustomersService } from '../customers/customers.service';
|
||||||
import { CompaniesService } from '../companies/companies.service';
|
import { CompaniesService } from '../companies/companies.service';
|
||||||
import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity';
|
import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity';
|
||||||
@@ -2115,6 +2115,21 @@ export class BookingsService {
|
|||||||
throw new NotFoundException(`Booking ${id} not found`);
|
throw new NotFoundException(`Booking ${id} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Nearly every booking flow loads the booking through here, so this one
|
||||||
|
// call puts the human-searchable reference + entry state on the request log
|
||||||
|
// line for all of them. Entry state only — the write trail (statusChanges)
|
||||||
|
// shows where it ended up.
|
||||||
|
logCtx(
|
||||||
|
{
|
||||||
|
id: booking.id,
|
||||||
|
reference: booking.reference,
|
||||||
|
statusAtEntry: booking.status,
|
||||||
|
companyId: booking.companyId,
|
||||||
|
contractId: booking.contractId ?? undefined,
|
||||||
|
},
|
||||||
|
{ path: "booking" },
|
||||||
|
);
|
||||||
|
|
||||||
if (booking.files && booking.files.length > 0) {
|
if (booking.files && booking.files.length > 0) {
|
||||||
booking.files = await Promise.all(
|
booking.files = await Promise.all(
|
||||||
booking.files.map(async (file: FileRecord) => {
|
booking.files.map(async (file: FileRecord) => {
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { ContractTemplatesRepository } from './contract-templates.repository';
|
||||||
|
import { ContractTemplatesService } from './contract-templates.service';
|
||||||
|
import {
|
||||||
|
bulkTemplateCode,
|
||||||
|
bulkTemplateDirectionFor,
|
||||||
|
} from './entities/contract-template.entity';
|
||||||
|
|
||||||
|
describe('bulkTemplateDirectionFor', () => {
|
||||||
|
it('maps the contract-side DOMESTIC onto the template-side INTERCITY', () => {
|
||||||
|
expect(bulkTemplateDirectionFor('DOMESTIC')).toBe('INTERCITY');
|
||||||
|
expect(bulkTemplateDirectionFor('INTERCITY')).toBe('INTERCITY');
|
||||||
|
expect(bulkTemplateDirectionFor(null)).toBe('INTERCITY');
|
||||||
|
expect(bulkTemplateDirectionFor(undefined)).toBe('INTERCITY');
|
||||||
|
expect(bulkTemplateDirectionFor('IMPORT')).toBe('IMPORT');
|
||||||
|
expect(bulkTemplateDirectionFor('EXPORT')).toBe('EXPORT');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bulkTemplateCode', () => {
|
||||||
|
it('gives each direction/customs combination its own code', () => {
|
||||||
|
expect(bulkTemplateCode('STEEL', 'IMPORT', true)).toBe('BULK_IMPORT_STEEL_CUSTOMS');
|
||||||
|
expect(bulkTemplateCode('STEEL', 'IMPORT', false)).toBe(
|
||||||
|
'BULK_IMPORT_STEEL_NO_CUSTOMS',
|
||||||
|
);
|
||||||
|
expect(bulkTemplateCode('STEEL', 'EXPORT', true)).toBe('BULK_EXPORT_STEEL_CUSTOMS');
|
||||||
|
expect(bulkTemplateCode('STEEL', 'EXPORT', false)).toBe(
|
||||||
|
'BULK_EXPORT_STEEL_NO_CUSTOMS',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves intercity unsuffixed — it crosses no border', () => {
|
||||||
|
expect(bulkTemplateCode('STEEL', 'INTERCITY', null)).toBe('BULK_INTERCITY_STEEL');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces 5 distinct codes per cargo type', () => {
|
||||||
|
const codes = [
|
||||||
|
bulkTemplateCode('STEEL', 'IMPORT', true),
|
||||||
|
bulkTemplateCode('STEEL', 'IMPORT', false),
|
||||||
|
bulkTemplateCode('STEEL', 'EXPORT', true),
|
||||||
|
bulkTemplateCode('STEEL', 'EXPORT', false),
|
||||||
|
bulkTemplateCode('STEEL', 'INTERCITY', null),
|
||||||
|
];
|
||||||
|
expect(new Set(codes).size).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ContractTemplatesService bulk create/resolve', () => {
|
||||||
|
function build() {
|
||||||
|
const repository = {
|
||||||
|
findCargoType: jest.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
id: 'cargo-1',
|
||||||
|
code: 'STEEL',
|
||||||
|
cargoTypeName: 'Steel',
|
||||||
|
hasContractTemplate: true,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
findByCargoCombo: jest.fn(() => Promise.resolve(null)),
|
||||||
|
findActiveBulkTemplate: jest.fn(() => Promise.resolve(null)),
|
||||||
|
findByCode: jest.fn(() => Promise.resolve(null)),
|
||||||
|
saveTemplate: jest.fn((template) => Promise.resolve(template)),
|
||||||
|
} as unknown as ContractTemplatesRepository;
|
||||||
|
return {
|
||||||
|
repository,
|
||||||
|
service: new ContractTemplatesService(repository, {} as never),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('stores direction and customs on an import template', async () => {
|
||||||
|
const { service } = build();
|
||||||
|
const created = await service.create({
|
||||||
|
cargoTypeId: 'cargo-1',
|
||||||
|
tradeDirection: 'EXPORT',
|
||||||
|
withCustoms: true,
|
||||||
|
});
|
||||||
|
expect(created.code).toBe('BULK_EXPORT_STEEL_CUSTOMS');
|
||||||
|
expect(created.tradeDirection).toBe('EXPORT');
|
||||||
|
expect(created.withCustoms).toBe(true);
|
||||||
|
expect(created.documentTitle).toBe(
|
||||||
|
'Steel Transportation and Customs Clearance Services',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores a null customs flag for intercity', async () => {
|
||||||
|
const { service } = build();
|
||||||
|
const created = await service.create({
|
||||||
|
cargoTypeId: 'cargo-1',
|
||||||
|
tradeDirection: 'INTERCITY',
|
||||||
|
});
|
||||||
|
expect(created.code).toBe('BULK_INTERCITY_STEEL');
|
||||||
|
expect(created.withCustoms).toBeNull();
|
||||||
|
expect(created.documentTitle).toBe('Steel Transportation Services');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a customs flag on intercity', async () => {
|
||||||
|
const { service } = build();
|
||||||
|
await expect(
|
||||||
|
service.create({
|
||||||
|
cargoTypeId: 'cargo-1',
|
||||||
|
tradeDirection: 'INTERCITY',
|
||||||
|
withCustoms: false,
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires a customs flag on import and export', async () => {
|
||||||
|
const { service } = build();
|
||||||
|
await expect(
|
||||||
|
service.create({ cargoTypeId: 'cargo-1', tradeDirection: 'IMPORT' }),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves a domestic bulk contract to the intercity template, ignoring its customs flag', async () => {
|
||||||
|
const { repository, service } = build();
|
||||||
|
await service.findActiveForContract('DOMESTIC', 'BULK', true, 'cargo-1');
|
||||||
|
expect(repository.findActiveBulkTemplate).toHaveBeenCalledWith(
|
||||||
|
'cargo-1',
|
||||||
|
'INTERCITY',
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves an import bulk contract on direction and customs', async () => {
|
||||||
|
const { repository, service } = build();
|
||||||
|
await service.findActiveForContract('IMPORT', 'BULK', false, 'cargo-1');
|
||||||
|
expect(repository.findActiveBulkTemplate).toHaveBeenCalledWith(
|
||||||
|
'cargo-1',
|
||||||
|
'IMPORT',
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -61,7 +61,7 @@ export class ContractTemplatesController {
|
|||||||
])
|
])
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
"Create a bulk contract template for a (cargo type, customs option) pair",
|
"Create a bulk contract template for a (cargo type, trade direction, customs option) combination",
|
||||||
})
|
})
|
||||||
create(@Body() dto: CreateContractTemplateDto) {
|
create(@Body() dto: CreateContractTemplateDto) {
|
||||||
return this.service.create(dto);
|
return this.service.create(dto);
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { BaseRepository } from "@edr/api-common";
|
import { BaseRepository } from "@edr/api-common";
|
||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import { InjectRepository } from "@nestjs/typeorm";
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
import { Repository } from "typeorm";
|
import { IsNull, Repository } from "typeorm";
|
||||||
|
|
||||||
import { CargoType } from "../rule-engine/entities/cargo-type.entity";
|
import { CargoType } from "../rule-engine/entities/cargo-type.entity";
|
||||||
import { ContractTemplate } from "./entities/contract-template.entity";
|
import {
|
||||||
|
BulkTemplateDirection,
|
||||||
|
ContractTemplate,
|
||||||
|
} from "./entities/contract-template.entity";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ContractTemplatesRepository extends BaseRepository<ContractTemplate> {
|
export class ContractTemplatesRepository extends BaseRepository<ContractTemplate> {
|
||||||
@@ -28,24 +31,35 @@ export class ContractTemplatesRepository extends BaseRepository<ContractTemplate
|
|||||||
|
|
||||||
findByCargoCombo(
|
findByCargoCombo(
|
||||||
cargoTypeId: string,
|
cargoTypeId: string,
|
||||||
withCustoms: boolean,
|
tradeDirection: BulkTemplateDirection,
|
||||||
|
withCustoms: boolean | null,
|
||||||
): Promise<ContractTemplate | null> {
|
): Promise<ContractTemplate | null> {
|
||||||
return this.repository.findOne({ where: { cargoTypeId, withCustoms } });
|
return this.repository.findOne({
|
||||||
|
where: { cargoTypeId, tradeDirection, withCustoms: withCustoms ?? IsNull() },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The active bulk template covering this cargo type: written against the
|
* The active bulk template covering this cargo type: written against the
|
||||||
* cargo type itself or against its parent group (the two are mutually
|
* cargo type itself or against its parent group (the two are mutually
|
||||||
* exclusive, so at most one row matches).
|
* exclusive, so at most one row matches). Intercity templates carry no
|
||||||
|
* customs variant, so they are matched on a null flag.
|
||||||
*/
|
*/
|
||||||
findActiveBulkTemplate(
|
findActiveBulkTemplate(
|
||||||
cargoTypeId: string,
|
cargoTypeId: string,
|
||||||
withCustoms: boolean,
|
tradeDirection: BulkTemplateDirection,
|
||||||
|
withCustoms: boolean | null,
|
||||||
): Promise<ContractTemplate | null> {
|
): Promise<ContractTemplate | null> {
|
||||||
return this.repository
|
return this.repository
|
||||||
.createQueryBuilder("t")
|
.createQueryBuilder("t")
|
||||||
.where("t.is_active = true")
|
.where("t.is_active = true")
|
||||||
.andWhere("t.with_customs = :withCustoms", { withCustoms })
|
.andWhere("t.trade_direction = :tradeDirection", { tradeDirection })
|
||||||
|
.andWhere(
|
||||||
|
withCustoms === null
|
||||||
|
? "t.with_customs IS NULL"
|
||||||
|
: "t.with_customs = :withCustoms",
|
||||||
|
withCustoms === null ? {} : { withCustoms },
|
||||||
|
)
|
||||||
.andWhere(
|
.andWhere(
|
||||||
`(t.cargo_type_id = :cargoTypeId OR t.cargo_type_id = (
|
`(t.cargo_type_id = :cargoTypeId OR t.cargo_type_id = (
|
||||||
SELECT c.parent_group_id FROM freight.cargo_types c
|
SELECT c.parent_group_id FROM freight.cargo_types c
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ import {
|
|||||||
UpdateContractTemplateDto,
|
UpdateContractTemplateDto,
|
||||||
} from "./dto/contract-template.dto";
|
} from "./dto/contract-template.dto";
|
||||||
import {
|
import {
|
||||||
|
BulkTemplateDirection,
|
||||||
|
bulkTemplateCode,
|
||||||
|
bulkTemplateDirectionFor,
|
||||||
CONTRACT_TEMPLATE_CODES,
|
CONTRACT_TEMPLATE_CODES,
|
||||||
ContractTemplate,
|
ContractTemplate,
|
||||||
ContractTemplateArticle,
|
ContractTemplateArticle,
|
||||||
@@ -77,10 +80,13 @@ export class ContractTemplatesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Staff-created bulk template for one (cargo type, customs option) pair.
|
* Staff-created bulk template for one (cargo type, direction, customs
|
||||||
* The cargo type must have hasContractTemplate enabled and the combination
|
* option) triple. The cargo type must have hasContractTemplate enabled and
|
||||||
* must not already exist — the same commodity + customs pairing is edited,
|
* the combination must not already exist — the same commodity + direction +
|
||||||
* never duplicated.
|
* customs pairing is edited, never duplicated.
|
||||||
|
*
|
||||||
|
* Intercity is domestic and crosses no border, so it carries no customs
|
||||||
|
* variant: the flag must be omitted and is stored as null.
|
||||||
*/
|
*/
|
||||||
async create(dto: CreateContractTemplateDto): Promise<ContractTemplate> {
|
async create(dto: CreateContractTemplateDto): Promise<ContractTemplate> {
|
||||||
const cargoType = await this.repository.findCargoType(dto.cargoTypeId);
|
const cargoType = await this.repository.findCargoType(dto.cargoTypeId);
|
||||||
@@ -92,31 +98,46 @@ export class ContractTemplatesService {
|
|||||||
`"${cargoType.cargoTypeName}" does not allow contract templates — enable "has contract template" on the cargo type first`,
|
`"${cargoType.cargoTypeName}" does not allow contract templates — enable "has contract template" on the cargo type first`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const variant = dto.withCustoms ? "with" : "without";
|
|
||||||
|
const direction = dto.tradeDirection;
|
||||||
|
const intercity = direction === "INTERCITY";
|
||||||
|
if (intercity && dto.withCustoms !== undefined) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Intercity contracts are domestic and cross no border — they have no customs clearing variant",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!intercity && dto.withCustoms === undefined) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`A ${direction.toLowerCase()} template must state whether customs clearing is included`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const withCustoms = intercity ? null : Boolean(dto.withCustoms);
|
||||||
|
|
||||||
|
const label = this.comboLabel(cargoType.cargoTypeName, direction, withCustoms);
|
||||||
const existing = await this.repository.findByCargoCombo(
|
const existing = await this.repository.findByCargoCombo(
|
||||||
dto.cargoTypeId,
|
dto.cargoTypeId,
|
||||||
dto.withCustoms,
|
direction,
|
||||||
|
withCustoms,
|
||||||
);
|
);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
throw new ConflictException(
|
throw new ConflictException(
|
||||||
`A "${cargoType.cargoTypeName}" template ${variant} customs clearing already exists — edit that template instead`,
|
`${label} already exists — edit that template instead`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const template = new ContractTemplate();
|
const template = new ContractTemplate();
|
||||||
template.code = `BULK_${cargoType.code}_${dto.withCustoms ? "CUSTOMS" : "NO_CUSTOMS"}`.toUpperCase();
|
template.code = bulkTemplateCode(cargoType.code, direction, withCustoms);
|
||||||
template.name =
|
template.name = dto.name ?? label;
|
||||||
dto.name ??
|
|
||||||
`${cargoType.cargoTypeName} Bulk Contract (${variant} customs clearing)`;
|
|
||||||
template.description = dto.description ?? null;
|
template.description = dto.description ?? null;
|
||||||
template.documentTitle = dto.withCustoms
|
template.documentTitle = withCustoms
|
||||||
? `${cargoType.cargoTypeName} Transportation and Customs Clearance Services`
|
? `${cargoType.cargoTypeName} Transportation and Customs Clearance Services`
|
||||||
: `${cargoType.cargoTypeName} Transportation Services`;
|
: `${cargoType.cargoTypeName} Transportation Services`;
|
||||||
template.whereasClauses = [];
|
template.whereasClauses = [];
|
||||||
template.articles = [];
|
template.articles = [];
|
||||||
template.isActive = true;
|
template.isActive = true;
|
||||||
template.cargoTypeId = cargoType.id;
|
template.cargoTypeId = cargoType.id;
|
||||||
template.withCustoms = dto.withCustoms;
|
template.tradeDirection = direction;
|
||||||
|
template.withCustoms = withCustoms;
|
||||||
template.isSystem = false;
|
template.isSystem = false;
|
||||||
try {
|
try {
|
||||||
return await this.repository.saveTemplate(template);
|
return await this.repository.saveTemplate(template);
|
||||||
@@ -124,13 +145,29 @@ export class ContractTemplatesService {
|
|||||||
// Partial unique index backstop for concurrent creates of the same combo.
|
// Partial unique index backstop for concurrent creates of the same combo.
|
||||||
if ((error as { code?: string })?.code === "23505") {
|
if ((error as { code?: string })?.code === "23505") {
|
||||||
throw new ConflictException(
|
throw new ConflictException(
|
||||||
`A "${cargoType.cargoTypeName}" template ${variant} customs clearing already exists — edit that template instead`,
|
`${label} already exists — edit that template instead`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Human label for one bulk combination, used for names and conflict errors. */
|
||||||
|
private comboLabel(
|
||||||
|
cargoTypeName: string,
|
||||||
|
direction: BulkTemplateDirection,
|
||||||
|
withCustoms: boolean | null,
|
||||||
|
): string {
|
||||||
|
const dir = direction.charAt(0) + direction.slice(1).toLowerCase();
|
||||||
|
const customs =
|
||||||
|
withCustoms === null
|
||||||
|
? ""
|
||||||
|
: withCustoms
|
||||||
|
? ", with customs clearing"
|
||||||
|
: ", without customs clearing";
|
||||||
|
return `${cargoTypeName} Bulk Contract (${dir}${customs})`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Bulk templates only — the five seeded container templates are permanent. */
|
/** Bulk templates only — the five seeded container templates are permanent. */
|
||||||
async remove(code: string): Promise<void> {
|
async remove(code: string): Promise<void> {
|
||||||
const template = await this.getByCode(code);
|
const template = await this.getByCode(code);
|
||||||
@@ -146,9 +183,10 @@ export class ContractTemplatesService {
|
|||||||
* The active template used when generating a contract document. Container
|
* The active template used when generating a contract document. Container
|
||||||
* contracts resolve through the fixed direction/customs codes; bulk contracts
|
* contracts resolve through the fixed direction/customs codes; bulk contracts
|
||||||
* resolve through the staff-created template for the contract's cargo type
|
* resolve through the staff-created template for the contract's cargo type
|
||||||
* (or its parent group) and customs option. Null when nothing matches or the
|
* (or its parent group), trade direction and customs option. A domestic
|
||||||
* match is deactivated (the renderer then falls back to the built-in generic
|
* contract resolves to the intercity template regardless of its customs flag.
|
||||||
* layout).
|
* Null when nothing matches or the match is deactivated (the renderer then
|
||||||
|
* falls back to the built-in generic layout).
|
||||||
*/
|
*/
|
||||||
async findActiveForContract(
|
async findActiveForContract(
|
||||||
tradeDirection?: string | null,
|
tradeDirection?: string | null,
|
||||||
@@ -159,9 +197,11 @@ export class ContractTemplatesService {
|
|||||||
const isBulk = (freightType ?? "").toUpperCase().includes("BULK");
|
const isBulk = (freightType ?? "").toUpperCase().includes("BULK");
|
||||||
if (isBulk) {
|
if (isBulk) {
|
||||||
if (!cargoTypeId) return null;
|
if (!cargoTypeId) return null;
|
||||||
|
const direction = bulkTemplateDirectionFor(tradeDirection);
|
||||||
return this.repository.findActiveBulkTemplate(
|
return this.repository.findActiveBulkTemplate(
|
||||||
cargoTypeId,
|
cargoTypeId,
|
||||||
Boolean(customsClearingEnabled),
|
direction,
|
||||||
|
direction === "INTERCITY" ? null : Boolean(customsClearingEnabled),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const code = contractTemplateCodeFor(
|
const code = contractTemplateCodeFor(
|
||||||
@@ -274,18 +314,31 @@ export class ContractTemplatesService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Registry key the mock preview renders against. Staff-created bulk
|
* Registry key the mock preview renders against. Staff-created bulk
|
||||||
* templates aren't in the fixed code map — they preview against the
|
* templates aren't in the fixed code map — they preview against the bulk
|
||||||
* representative bulk import pack matching their customs option.
|
* pack matching their own direction and customs option.
|
||||||
*/
|
*/
|
||||||
private previewKeyFor(template: ContractTemplate): string {
|
private previewKeyFor(template: ContractTemplate): string {
|
||||||
if (template.cargoTypeId) {
|
if (template.cargoTypeId) {
|
||||||
return template.withCustoms
|
const direction = bulkTemplateDirectionFor(template.tradeDirection);
|
||||||
? "IMP_BULK_USD_FORWARDING"
|
const dir =
|
||||||
: "IMP_BULK_USD_TRANSPORT_ONLY";
|
direction === "IMPORT" ? "IMP" : direction === "EXPORT" ? "EXP" : "DOM";
|
||||||
|
const scope = template.withCustoms ? "FORWARDING" : "TRANSPORT_ONLY";
|
||||||
|
return `${dir}_BULK_USD_${scope}`;
|
||||||
}
|
}
|
||||||
return PREVIEW_TEMPLATE_KEYS[template.code as ContractTemplateCode];
|
return PREVIEW_TEMPLATE_KEYS[template.code as ContractTemplateCode];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preview direction: bulk templates carry it on the row, the fixed container
|
||||||
|
* codes carry it as the code prefix.
|
||||||
|
*/
|
||||||
|
private previewDirectionFor(template: ContractTemplate): BulkTemplateDirection {
|
||||||
|
if (template.cargoTypeId) {
|
||||||
|
return bulkTemplateDirectionFor(template.tradeDirection);
|
||||||
|
}
|
||||||
|
return bulkTemplateDirectionFor(template.code.split("_")[0]);
|
||||||
|
}
|
||||||
|
|
||||||
private buildMockView(
|
private buildMockView(
|
||||||
template: ContractTemplate,
|
template: ContractTemplate,
|
||||||
dynamicTemplate: ContractDynamicTemplateView,
|
dynamicTemplate: ContractDynamicTemplateView,
|
||||||
@@ -294,11 +347,12 @@ export class ContractTemplatesService {
|
|||||||
const previewKey = this.previewKeyFor(template);
|
const previewKey = this.previewKeyFor(template);
|
||||||
const meta = getTemplateMeta(previewKey);
|
const meta = getTemplateMeta(previewKey);
|
||||||
const isBulk = Boolean(template.cargoTypeId) || code.includes("BULK");
|
const isBulk = Boolean(template.cargoTypeId) || code.includes("BULK");
|
||||||
|
const direction = this.previewDirectionFor(template);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
// Representative rate schedule so the admin preview shows the live-rate
|
// Representative rate schedule so the admin preview shows the live-rate
|
||||||
// table shape. Real contracts populate this from freight.rates (LIVE).
|
// table shape. Real contracts populate this from freight.rates (LIVE).
|
||||||
const rateSchedule = this.mockRateSchedule(code, isBulk);
|
const rateSchedule = this.mockRateSchedule(direction, isBulk);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
bookingId: "00000000-0000-0000-0000-000000000000",
|
bookingId: "00000000-0000-0000-0000-000000000000",
|
||||||
@@ -336,11 +390,7 @@ export class ContractTemplatesService {
|
|||||||
schedule: {
|
schedule: {
|
||||||
originLabel: isBulk ? "Nagad Railway Station" : "SGTD Freight Station",
|
originLabel: isBulk ? "Nagad Railway Station" : "SGTD Freight Station",
|
||||||
destinationLabel: "Galaan Multipurpose Port (GMP)",
|
destinationLabel: "Galaan Multipurpose Port (GMP)",
|
||||||
tradeDirection: code.startsWith("IMPORT")
|
tradeDirection: direction === "INTERCITY" ? "DOMESTIC" : direction,
|
||||||
? "IMPORT"
|
|
||||||
: code.startsWith("EXPORT")
|
|
||||||
? "EXPORT"
|
|
||||||
: "DOMESTIC",
|
|
||||||
freightType: isBulk ? "BULK" : "CONTAINER",
|
freightType: isBulk ? "BULK" : "CONTAINER",
|
||||||
serviceType: "Rail transport and customs clearance",
|
serviceType: "Rail transport and customs clearance",
|
||||||
scheduledDate: "—",
|
scheduledDate: "—",
|
||||||
@@ -379,16 +429,14 @@ export class ContractTemplatesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Static, representative rate schedule for the admin preview only. */
|
/** Static, representative rate schedule for the admin preview only. */
|
||||||
private mockRateSchedule(code: string, isBulk: boolean): RateSchedule {
|
private mockRateSchedule(
|
||||||
const dir = code.startsWith("IMPORT")
|
direction: BulkTemplateDirection,
|
||||||
? "import"
|
isBulk: boolean,
|
||||||
: code.startsWith("EXPORT")
|
): RateSchedule {
|
||||||
? "export"
|
|
||||||
: "domestic";
|
|
||||||
const lane =
|
const lane =
|
||||||
dir === "export"
|
direction === "EXPORT"
|
||||||
? "Galaan Multipurpose Port → SGTD"
|
? "Galaan Multipurpose Port → SGTD"
|
||||||
: dir === "domestic"
|
: direction === "INTERCITY"
|
||||||
? "Mojo Dry Port → Dire Dawa"
|
? "Mojo Dry Port → Dire Dawa"
|
||||||
: "Negad → Mojo Dry Port";
|
: "Negad → Mojo Dry Port";
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Type } from "class-transformer";
|
|||||||
import {
|
import {
|
||||||
IsArray,
|
IsArray,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
|
IsIn,
|
||||||
IsInt,
|
IsInt,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
@@ -13,6 +14,11 @@ import {
|
|||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
|
|
||||||
|
import {
|
||||||
|
BULK_TEMPLATE_DIRECTIONS,
|
||||||
|
BulkTemplateDirection,
|
||||||
|
} from "../entities/contract-template.entity";
|
||||||
|
|
||||||
export class CreateContractTemplateDto {
|
export class CreateContractTemplateDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description:
|
description:
|
||||||
@@ -23,10 +29,19 @@ export class CreateContractTemplateDto {
|
|||||||
cargoTypeId!: string;
|
cargoTypeId!: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: "Whether this is the with-customs-clearing variant",
|
description: "Trade direction this template is written for",
|
||||||
|
enum: BULK_TEMPLATE_DIRECTIONS,
|
||||||
})
|
})
|
||||||
|
@IsIn(BULK_TEMPLATE_DIRECTIONS as unknown as string[])
|
||||||
|
tradeDirection!: BulkTemplateDirection;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
"Whether this is the with-customs-clearing variant. Required for IMPORT/EXPORT, rejected for INTERCITY (domestic movements cross no border)",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
withCustoms!: boolean;
|
withCustoms?: boolean;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: "Display name (derived from the cargo type when omitted)" })
|
@ApiPropertyOptional({ description: "Display name (derived from the cargo type when omitted)" })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ import { CargoType } from "../../rule-engine/entities/cargo-type.entity";
|
|||||||
* template). These are system rows: always present, never deletable.
|
* template). These are system rows: always present, never deletable.
|
||||||
*
|
*
|
||||||
* Bulk templates are NOT seeded — staff create them per bulk cargo type
|
* Bulk templates are NOT seeded — staff create them per bulk cargo type
|
||||||
* (`cargoTypeId`) and customs option (`withCustoms`), one template per
|
* (`cargoTypeId`), trade direction (`tradeDirection`) and customs option
|
||||||
* combination. Their codes are generated as BULK_<cargo code>_(NO_)CUSTOMS.
|
* (`withCustoms`), one template per combination. Their codes are generated by
|
||||||
* The retired direction-keyed bulk codes remain listed so old frozen document
|
* `bulkTemplateCode` below. The retired direction-keyed bulk codes remain
|
||||||
* snapshots still label correctly.
|
* listed so old frozen document snapshots still label correctly.
|
||||||
*
|
*
|
||||||
* Contracts store DOMESTIC for intercity movements; the template layer labels
|
* Contracts store DOMESTIC for intercity movements; the template layer labels
|
||||||
* those INTERCITY to match the commercial vocabulary used on the printed
|
* those INTERCITY to match the commercial vocabulary used on the printed
|
||||||
@@ -82,9 +82,41 @@ export function contractTemplateCodeFor(
|
|||||||
return `${direction}_${freight}_${customs}` as ContractTemplateCode;
|
return `${direction}_${freight}_${customs}` as ContractTemplateCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The three directions a bulk template can be written for. */
|
||||||
|
export const BULK_TEMPLATE_DIRECTIONS = ["IMPORT", "EXPORT", "INTERCITY"] as const;
|
||||||
|
|
||||||
|
export type BulkTemplateDirection = (typeof BULK_TEMPLATE_DIRECTIONS)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contracts store DOMESTIC for intercity movements; templates use INTERCITY.
|
||||||
|
* Anything that is not an explicit IMPORT/EXPORT is domestic, matching
|
||||||
|
* `contractTemplateCodeFor`.
|
||||||
|
*/
|
||||||
|
export function bulkTemplateDirectionFor(
|
||||||
|
tradeDirection?: string | null,
|
||||||
|
): BulkTemplateDirection {
|
||||||
|
const value = (tradeDirection ?? "").toUpperCase();
|
||||||
|
return value === "IMPORT" || value === "EXPORT" ? value : "INTERCITY";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generated code for a staff-created bulk template. Intercity gets no customs
|
||||||
|
* suffix — it crosses no border, so the variant does not exist.
|
||||||
|
*/
|
||||||
|
export function bulkTemplateCode(
|
||||||
|
cargoCode: string,
|
||||||
|
direction: BulkTemplateDirection,
|
||||||
|
withCustoms: boolean | null,
|
||||||
|
): string {
|
||||||
|
const suffix =
|
||||||
|
direction === "INTERCITY" ? "" : withCustoms ? "_CUSTOMS" : "_NO_CUSTOMS";
|
||||||
|
return `BULK_${direction}_${cargoCode}${suffix}`.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
@Entity({ schema: "freight", name: "contract_templates" })
|
@Entity({ schema: "freight", name: "contract_templates" })
|
||||||
// Uniqueness lives in partial DB indexes (live rows only): code, and
|
// Uniqueness lives in partial DB indexes (live rows only): code, and
|
||||||
// (cargo_type_id, with_customs) for staff-created bulk templates.
|
// (cargo_type_id, trade_direction, coalesce(with_customs,false)) for
|
||||||
|
// staff-created bulk templates.
|
||||||
@Index(["code"])
|
@Index(["code"])
|
||||||
export class ContractTemplate extends BaseEntity {
|
export class ContractTemplate extends BaseEntity {
|
||||||
@Column({ name: "code", type: "varchar", length: 80 })
|
@Column({ name: "code", type: "varchar", length: 80 })
|
||||||
@@ -118,7 +150,15 @@ export class ContractTemplate extends BaseEntity {
|
|||||||
@JoinColumn({ name: "cargo_type_id" })
|
@JoinColumn({ name: "cargo_type_id" })
|
||||||
cargoType?: CargoType | null;
|
cargoType?: CargoType | null;
|
||||||
|
|
||||||
/** Bulk templates only: whether this is the with-customs-clearing variant. */
|
/** Bulk templates only: IMPORT, EXPORT or INTERCITY. */
|
||||||
|
@Column({ name: "trade_direction", type: "varchar", length: 20, nullable: true })
|
||||||
|
tradeDirection?: BulkTemplateDirection | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk templates only: whether this is the with-customs-clearing variant.
|
||||||
|
* Always null for INTERCITY templates — domestic movements have no customs
|
||||||
|
* leg, so neither variant applies.
|
||||||
|
*/
|
||||||
@Column({ name: "with_customs", type: "boolean", nullable: true })
|
@Column({ name: "with_customs", type: "boolean", nullable: true })
|
||||||
withCustoms?: boolean | null;
|
withCustoms?: boolean | null;
|
||||||
|
|
||||||
|
|||||||
@@ -157,13 +157,9 @@ export class ContractBookingService {
|
|||||||
actorPermissions != null &&
|
actorPermissions != null &&
|
||||||
hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking);
|
hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking);
|
||||||
|
|
||||||
|
await this.assertNotExpired(contract);
|
||||||
const createdByRole = await this.assertGate(contract, isGlActor);
|
const createdByRole = await this.assertGate(contract, isGlActor);
|
||||||
|
|
||||||
// Validity window must still be open.
|
|
||||||
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
|
|
||||||
throw new BadRequestException('Contract validity has expired — no new bookings.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ONE_TIME: a single shipment at a time. The slot frees only if the prior
|
// ONE_TIME: a single shipment at a time. The slot frees only if the prior
|
||||||
// booking reached a terminal state (e.g. payment expired without shipping),
|
// booking reached a terminal state (e.g. payment expired without shipping),
|
||||||
// letting the customer re-book within contract validity (doc §10.4).
|
// letting the customer re-book within contract validity (doc §10.4).
|
||||||
@@ -441,12 +437,9 @@ export class ContractBookingService {
|
|||||||
// The customer initiates his own shipment instance on ONE_TIME contracts
|
// The customer initiates his own shipment instance on ONE_TIME contracts
|
||||||
// (customs or self-clearance); GL may also initiate on a customs contract.
|
// (customs or self-clearance); GL may also initiate on a customs contract.
|
||||||
// GENERAL customs instances come from a shipment request, not from here.
|
// GENERAL customs instances come from a shipment request, not from here.
|
||||||
|
await this.assertNotExpired(contract);
|
||||||
const createdByRole = await this.assertGate(contract, isGlActor, true);
|
const createdByRole = await this.assertGate(contract, isGlActor, true);
|
||||||
|
|
||||||
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
|
|
||||||
throw new BadRequestException('Contract validity has expired — no new bookings.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ONE_TIME carries a single shipment at a time; a bare instance occupies the
|
// ONE_TIME carries a single shipment at a time; a bare instance occupies the
|
||||||
// slot from the moment it is initiated (it is not a terminal status). The
|
// slot from the moment it is initiated (it is not a terminal status). The
|
||||||
// split chain is the one exception — a paid partial frees the slot and
|
// split chain is the one exception — a paid partial frees the slot and
|
||||||
@@ -545,9 +538,7 @@ export class ContractBookingService {
|
|||||||
'Shipment-request initiation applies only to general customs contracts.',
|
'Shipment-request initiation applies only to general customs contracts.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
|
await this.assertNotExpired(contract);
|
||||||
throw new BadRequestException('Contract validity has expired — no new bookings.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const route = await this.resolveRoute(contract, opts.contractRouteId);
|
const route = await this.resolveRoute(contract, opts.contractRouteId);
|
||||||
|
|
||||||
@@ -681,9 +672,11 @@ export class ContractBookingService {
|
|||||||
if (!dto.scheduledDate) {
|
if (!dto.scheduledDate) {
|
||||||
throw new BadRequestException('A binding shipment day is required');
|
throw new BadRequestException('A binding shipment day is required');
|
||||||
}
|
}
|
||||||
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
|
// No expiry gate here on purpose: this booking was already initiated
|
||||||
throw new BadRequestException('Contract validity has expired — no new bookings.');
|
// before the contract lapsed (createUnderContract/initiateUnderContract
|
||||||
}
|
// already checked expiry at start). Finishing an in-flight booking must
|
||||||
|
// proceed even if the contract expires meanwhile — only starting a NEW
|
||||||
|
// booking is blocked (see assertNotExpired).
|
||||||
|
|
||||||
// Completion is booking time: the route's booking window must be open —
|
// Completion is booking time: the route's booking window must be open —
|
||||||
// the same config-driven gate a direct one-time booking passes at create.
|
// the same config-driven gate a direct one-time booking passes at create.
|
||||||
@@ -1019,6 +1012,22 @@ export class ContractBookingService {
|
|||||||
* Returns the role to stamp on the booking, or throws if the caller is not
|
* Returns the role to stamp on the booking, or throws if the caller is not
|
||||||
* allowed to create one for this contract's execution path.
|
* allowed to create one for this contract's execution path.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Blocks starting a NEW booking (create/initiate) once the contract has
|
||||||
|
* lapsed, and lazily flips the stored status to EXPIRED so it doesn't wait
|
||||||
|
* for the nightly sweep. Only for the "start something new" entry points —
|
||||||
|
* a booking already underway (completeUnderContract) must be allowed to
|
||||||
|
* finish even if the contract expires mid-flight.
|
||||||
|
*/
|
||||||
|
private async assertNotExpired(contract: Contract): Promise<void> {
|
||||||
|
if (!isEffectivelyExpired(contract)) return;
|
||||||
|
if (contract.status !== 'EXPIRED') {
|
||||||
|
const flipped = await this.contractsRepository.expireIfLapsed(contract.id);
|
||||||
|
if (flipped) contract.status = 'EXPIRED';
|
||||||
|
}
|
||||||
|
throw new BadRequestException('Contract validity has expired — no new bookings.');
|
||||||
|
}
|
||||||
|
|
||||||
private async assertGate(
|
private async assertGate(
|
||||||
contract: Contract,
|
contract: Contract,
|
||||||
isGlActor: boolean,
|
isGlActor: boolean,
|
||||||
|
|||||||
@@ -11,7 +11,14 @@ import { Contract } from './entities/contract.entity';
|
|||||||
export interface ContractUnitRateLineItem {
|
export interface ContractUnitRateLineItem {
|
||||||
code: string;
|
code: string;
|
||||||
label: string;
|
label: string;
|
||||||
unit: 'per_container' | 'per_wagon' | 'per_ton' | 'per_item' | 'per_km' | 'flat';
|
unit:
|
||||||
|
| 'per_container'
|
||||||
|
| 'per_wagon'
|
||||||
|
| 'per_ton'
|
||||||
|
| 'per_item'
|
||||||
|
| 'per_km'
|
||||||
|
| 'per_liter'
|
||||||
|
| 'flat';
|
||||||
unitPrice: number;
|
unitPrice: number;
|
||||||
containerSize?: string | null;
|
containerSize?: string | null;
|
||||||
conditionalOn?: string | null;
|
conditionalOn?: string | null;
|
||||||
@@ -44,6 +51,8 @@ function toContractUnit(rateUnit: string): ContractUnitRateLineItem['unit'] {
|
|||||||
return 'per_wagon';
|
return 'per_wagon';
|
||||||
case 'PER_CONTAINER':
|
case 'PER_CONTAINER':
|
||||||
return 'per_container';
|
return 'per_container';
|
||||||
|
case 'PER_LITER':
|
||||||
|
return 'per_liter';
|
||||||
default:
|
default:
|
||||||
return 'flat';
|
return 'flat';
|
||||||
}
|
}
|
||||||
@@ -276,6 +285,42 @@ export class ContractPricingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fuel surcharge — shown when the contract's commodity incurs fuel
|
||||||
|
// (cargoType.hasFuel), sold per lane + commodity. Billed at booking on the
|
||||||
|
// frozen/live rate (per wagon × wagons, or per liter × base liters, once);
|
||||||
|
// this line freezes the agreed unit price.
|
||||||
|
{
|
||||||
|
const scope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId);
|
||||||
|
if (scope?.cargoType?.hasFuel && route) {
|
||||||
|
const fuel = liveRates.find(
|
||||||
|
(r) =>
|
||||||
|
r.trigger === 'FUEL' &&
|
||||||
|
r.currency === 'USD' &&
|
||||||
|
r.tradeDirection === contract.tradeDirection &&
|
||||||
|
r.originYardId === route.originYardId &&
|
||||||
|
r.destinationYardId === route.destinationYardId &&
|
||||||
|
r.cargoTypeId === scope.cargoTypeId,
|
||||||
|
);
|
||||||
|
if (fuel && Number(fuel.rateValue) > 0) {
|
||||||
|
// Per-liter collapses to one flat total (base liters × rate value) —
|
||||||
|
// the customer only sees the final price, and booking pricing bills
|
||||||
|
// the same flat figure once (see RuleEngineService.fuelCharges).
|
||||||
|
const perLiter = fuel.rateUnit === 'PER_LITER';
|
||||||
|
const total = perLiter
|
||||||
|
? Number(fuel.baseLiters ?? 0) * Number(fuel.rateValue)
|
||||||
|
: Number(fuel.rateValue);
|
||||||
|
lineItems.push({
|
||||||
|
code: 'FUEL_SURCHARGE',
|
||||||
|
label: `Fuel surcharge (${scope.cargoType.cargoTypeName})`,
|
||||||
|
unit: perLiter ? 'flat' : toContractUnit(fuel.rateUnit),
|
||||||
|
unitPrice: convert(total),
|
||||||
|
cargoTypeCode: scope.cargoType.code ?? null,
|
||||||
|
conditionalOn: 'has_fuel',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Empty-container return service — container contracts only, toggled on the
|
// Empty-container return service — container contracts only, toggled on the
|
||||||
// contract like hazard/reefer. Billed at booking per WITH_RETURN container.
|
// contract like hazard/reefer. Billed at booking per WITH_RETURN container.
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { InjectDataSource } from '@nestjs/typeorm';
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { Readable } from 'stream';
|
import { Readable } from 'stream';
|
||||||
import { insertWithGeneratedReference } from '@edr/api-common';
|
import { insertWithGeneratedReference, logCtx } from '@edr/api-common';
|
||||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||||
|
|
||||||
import { ContractDocumentViewModelBuilder } from '../../contracts/contract-document-view-model.builder';
|
import { ContractDocumentViewModelBuilder } from '../../contracts/contract-document-view-model.builder';
|
||||||
@@ -1084,6 +1084,19 @@ export class ContractTransitionService {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const role = dto.role as ContractSignerRole;
|
const role = dto.role as ContractSignerRole;
|
||||||
|
|
||||||
|
// Both sign() and counterSign() land here — who signed what, and whether the
|
||||||
|
// ink came from the request or the signer's saved profile signature.
|
||||||
|
logCtx(
|
||||||
|
{
|
||||||
|
contractId: contract.id,
|
||||||
|
reference: contract.reference,
|
||||||
|
role,
|
||||||
|
signerUserId: options.signerUserId,
|
||||||
|
usedDrawnImage: Boolean(dto.signatureImageBase64),
|
||||||
|
},
|
||||||
|
{ path: "contractSignatures", mode: "push" },
|
||||||
|
);
|
||||||
|
|
||||||
// Resolve the signature image. The client may send a freshly-drawn image, or
|
// Resolve the signature image. The client may send a freshly-drawn image, or
|
||||||
// omit it to reuse the signer's saved profile signature. Fall back to the
|
// omit it to reuse the signer's saved profile signature. Fall back to the
|
||||||
// saved one whenever no image is supplied.
|
// saved one whenever no image is supplied.
|
||||||
|
|||||||
@@ -140,6 +140,27 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
|||||||
return result.affected ?? 0;
|
return result.affected ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same-row version of expireLapsedContracts, for lazy flips on read/booking
|
||||||
|
* paths — flips this one contract to EXPIRED if it's lapsed and not already
|
||||||
|
* terminal. No-op (returns false) if the contract isn't actually lapsed, so
|
||||||
|
* callers can call this unconditionally without a pre-check.
|
||||||
|
*/
|
||||||
|
async expireIfLapsed(id: string): Promise<boolean> {
|
||||||
|
const result = await this.repository
|
||||||
|
.createQueryBuilder()
|
||||||
|
.update(Contract)
|
||||||
|
.set({ status: 'EXPIRED' })
|
||||||
|
.where('id = :id', { id })
|
||||||
|
.andWhere('deleted_at IS NULL')
|
||||||
|
.andWhere('status NOT IN (:...terminal)', { terminal: TERMINAL_CONTRACT_STATUSES })
|
||||||
|
.andWhere('contract_valid_until IS NOT NULL AND contract_valid_until < :now', {
|
||||||
|
now: new Date(),
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
return (result.affected ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Live contracts whose validity ends between `days` and `days + 1` days from
|
* Live contracts whose validity ends between `days` and `days + 1` days from
|
||||||
* now — the slice the daily expiry-reminder cron warns about. The window is
|
* now — the slice the daily expiry-reminder cron warns about. The window is
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectDataSource } from '@nestjs/typeorm';
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { insertWithGeneratedReference } from '@edr/api-common';
|
import { insertWithGeneratedReference, logCtx } from '@edr/api-common';
|
||||||
|
|
||||||
import { YardCountry } from '@edr/types';
|
import { YardCountry } from '@edr/types';
|
||||||
//
|
//
|
||||||
@@ -811,6 +811,27 @@ export class ContractsService {
|
|||||||
throw new NotFoundException(`Contract ${id} not found`);
|
throw new NotFoundException(`Contract ${id} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lazy expiry flip: the nightly cron only sweeps once a day, so a
|
||||||
|
// contract can be past contract_valid_until for hours before it shows
|
||||||
|
// EXPIRED. Flip it here so the detail page never shows a stale status.
|
||||||
|
if (isEffectivelyExpired(contract) && contract.status !== 'EXPIRED') {
|
||||||
|
const flipped = await this.contractsRepository.expireIfLapsed(id);
|
||||||
|
if (flipped) {
|
||||||
|
contract.status = 'EXPIRED';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Entry state for every contract flow (submit, approve, sign, suspend…) —
|
||||||
|
// see the equivalent in BookingsService.findById.
|
||||||
|
logCtx(
|
||||||
|
{
|
||||||
|
id: contract.id,
|
||||||
|
reference: contract.reference,
|
||||||
|
statusAtEntry: contract.status,
|
||||||
|
companyId: contract.companyId,
|
||||||
|
},
|
||||||
|
{ path: "contract" },
|
||||||
|
);
|
||||||
|
|
||||||
if (contract.files && contract.files.length > 0) {
|
if (contract.files && contract.files.length > 0) {
|
||||||
contract.files = await Promise.all(
|
contract.files = await Promise.all(
|
||||||
contract.files.map(async (file: FileRecord) => {
|
contract.files.map(async (file: FileRecord) => {
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { IsOptional, IsString, Length } from "class-validator";
|
||||||
|
|
||||||
|
/** `POST /v1/cancel` body — `ReasonCode`/`Remark` are the collection's own field names. */
|
||||||
|
export class CancelEimsRegistrationDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Numeric reason code, e.g. "1" (Duplicate), "6" (Calculation Error).',
|
||||||
|
example: "1",
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@Length(1, 8)
|
||||||
|
reasonCode!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "Free-text cancellation note.", example: "Duplicate submission" })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(0, 500)
|
||||||
|
remark?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { IsIn, IsNumber, IsOptional, IsString, Length } from "class-validator";
|
||||||
|
|
||||||
|
import { EIMS_MODE_OF_PAYMENT, EimsModeOfPayment } from "../eims-receipt.types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `POST /v1/receipt/sales` — invoice/amounts/currency/IRN are derived from the invoice; everything
|
||||||
|
* here is what this codebase has no source of truth for, so it is asked of the caller rather than
|
||||||
|
* guessed (payment method, collector, provider references — none of it is modelled on `Invoice`).
|
||||||
|
*/
|
||||||
|
export class RegisterSalesReceiptDto {
|
||||||
|
@ApiProperty({ enum: EIMS_MODE_OF_PAYMENT, description: "MoR's confirmed ModeOfPayment enum." })
|
||||||
|
@IsIn(EIMS_MODE_OF_PAYMENT)
|
||||||
|
modeOfPayment!: EimsModeOfPayment;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Defaults to "Payment received".' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
reason?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "Overrides the amount collected; defaults to the invoice's paidAmount." })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
collectedAmount?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "ETB, USD or CAD. Defaults to the invoice's currency." })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
currency?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "Required when currency is not ETB." })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
exchangeRate?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Defaults to "FULL" if the invoice balance is 0, else "PARTIAL".' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
paymentCoverage?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(0, 200)
|
||||||
|
collectorName?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(0, 200)
|
||||||
|
paymentServiceProvider?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(0, 200)
|
||||||
|
otherPaymentServiceProviderName?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(0, 100)
|
||||||
|
accountNumber?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(0, 100)
|
||||||
|
transactionNumber?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(0, 100)
|
||||||
|
chequeNumber?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(0, 100)
|
||||||
|
cpoNumber?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(0, 100)
|
||||||
|
documentNumber?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { IsNumber, IsOptional, IsString, Length } from "class-validator";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `POST /v1/receipt/withholding`. `type`/`preTaxAmount`/`withholdingAmount` are real money/tax
|
||||||
|
* values this codebase has no computed source for (the same "no tax model" gap the invoice mapper
|
||||||
|
* documents) — required from the caller rather than defaulted or derived.
|
||||||
|
*/
|
||||||
|
export class RegisterWithholdingReceiptDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'WithholdDetail.Type. Only "TWHT" has been observed; not restricted to it.',
|
||||||
|
example: "TWHT",
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@Length(1, 16)
|
||||||
|
type!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
rate?: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Amount the withholding is calculated against." })
|
||||||
|
@IsNumber()
|
||||||
|
preTaxAmount!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "The withheld amount itself." })
|
||||||
|
@IsNumber()
|
||||||
|
withholdingAmount!: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Defaults to "Withholding".' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
reason?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "Required when the invoice currency is not ETB." })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
exchangeRate?: number;
|
||||||
|
}
|
||||||
@@ -10,9 +10,11 @@ export class ResolveEimsRegistrationDto {
|
|||||||
description: "IRN confirmed in the MoR portal. Records the registration and resumes the chain.",
|
description: "IRN confirmed in the MoR portal. Records the registration and resumes the chain.",
|
||||||
example: "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0",
|
example: "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0",
|
||||||
})
|
})
|
||||||
|
// Not capped at 64: eims_irn is `text` now — a real IRN already overflowed a former
|
||||||
|
// varchar(64) (69 chars). This bound is a sanity ceiling, not a confirmed MoR format.
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@Length(1, 64)
|
@Length(1, 500)
|
||||||
irn?: string;
|
irn?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { BadRequestException } from "@nestjs/common";
|
||||||
|
import { DataSource } from "typeorm";
|
||||||
|
|
||||||
|
import { Invoice } from "../billing/entities/invoice.entity";
|
||||||
|
import { NotificationsService } from "../notifications/notifications.service";
|
||||||
|
import { EimsCancellationService } from "./eims-cancellation.service";
|
||||||
|
import { EimsClientService } from "./eims-client.service";
|
||||||
|
import { EimsApiException } from "./eims.errors";
|
||||||
|
import { EimsInvoiceStatus } from "./eims-registration.types";
|
||||||
|
|
||||||
|
const INVOICE_ID = "11111111-1111-4111-8111-111111111111";
|
||||||
|
const IRN = "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0";
|
||||||
|
|
||||||
|
const invoiceRow = (over: Partial<Invoice> = {}): Invoice =>
|
||||||
|
({
|
||||||
|
id: INVOICE_ID,
|
||||||
|
invoiceNumber: "INV-20260807-00042",
|
||||||
|
companyId: "company-1",
|
||||||
|
eimsStatus: EimsInvoiceStatus.Registered,
|
||||||
|
eimsIrn: IRN,
|
||||||
|
eimsCancelledAt: null,
|
||||||
|
eimsCancellationDate: null,
|
||||||
|
eimsCancellationReasonCode: null,
|
||||||
|
eimsCancellationRemark: null,
|
||||||
|
...over,
|
||||||
|
}) as unknown as Invoice;
|
||||||
|
|
||||||
|
/** In-memory stand-in, same shape as the registration spec's FakeDb but with only what cancel needs. */
|
||||||
|
class FakeDb {
|
||||||
|
invoices = new Map<string, Invoice>();
|
||||||
|
companyContact: { phone: string | null; email: string | null } | null = null;
|
||||||
|
|
||||||
|
constructor(invoices: Invoice[]) {
|
||||||
|
for (const inv of invoices) this.invoices.set(inv.id, inv);
|
||||||
|
}
|
||||||
|
|
||||||
|
private manager = {
|
||||||
|
createQueryBuilder: (entity: unknown) => {
|
||||||
|
let id: string | undefined;
|
||||||
|
const builder = {
|
||||||
|
setLock: () => builder,
|
||||||
|
where: (_clause: string, params: Record<string, string>) => {
|
||||||
|
id = params.invoiceId;
|
||||||
|
return builder;
|
||||||
|
},
|
||||||
|
getOne: async () => (entity === Invoice ? (this.invoices.get(id!) ?? null) : null),
|
||||||
|
};
|
||||||
|
return builder;
|
||||||
|
},
|
||||||
|
findOne: async (_entity: unknown, options: { where: { id: string } }) =>
|
||||||
|
this.invoices.get(options.where.id) ?? null,
|
||||||
|
update: async (_entity: unknown, id: string, patch: Record<string, unknown>) => {
|
||||||
|
Object.assign(this.invoices.get(id)!, patch);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
asDataSource(): DataSource {
|
||||||
|
return {
|
||||||
|
manager: this.manager,
|
||||||
|
query: async () => (this.companyContact ? [this.companyContact] : []),
|
||||||
|
transaction: async (body: (m: unknown) => Promise<unknown>) => body(this.manager),
|
||||||
|
} as unknown as DataSource;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const build = (db: FakeDb, postBearer: jest.Mock, directSend: jest.Mock = jest.fn().mockResolvedValue(undefined)) =>
|
||||||
|
new EimsCancellationService(
|
||||||
|
db.asDataSource(),
|
||||||
|
{ postBearer } as unknown as EimsClientService,
|
||||||
|
{ directSend } as unknown as NotificationsService,
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("EimsCancellationService.cancelInvoiceWithEims", () => {
|
||||||
|
it("cancels a registered invoice and persists MoR's confirmation", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow()]);
|
||||||
|
const postBearer = jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ statusCode: 200, message: "Success", body: { cancellationDate: "Sun Dec 22 21:55:03 EAT 2024" } });
|
||||||
|
|
||||||
|
const view = await build(db, postBearer).cancelInvoiceWithEims(INVOICE_ID, "1", "Duplicate");
|
||||||
|
|
||||||
|
expect(postBearer).toHaveBeenCalledWith("/v1/cancel", { Irn: IRN, ReasonCode: "1", Remark: "Duplicate" });
|
||||||
|
expect(view.eimsStatus).toBe(EimsInvoiceStatus.Cancelled);
|
||||||
|
expect(view.eimsCancellationDate).toBe("Sun Dec 22 21:55:03 EAT 2024");
|
||||||
|
expect(view.eimsCancellationReasonCode).toBe("1");
|
||||||
|
expect(view.eimsCancellationRemark).toBe("Duplicate");
|
||||||
|
expect(db.invoices.get(INVOICE_ID)?.eimsCancelledAt).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults Remark to an empty string, matching the collection's request shape", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow()]);
|
||||||
|
const postBearer = jest.fn().mockResolvedValue({ statusCode: 200, body: { cancellationDate: "x" } });
|
||||||
|
|
||||||
|
await build(db, postBearer).cancelInvoiceWithEims(INVOICE_ID, "1");
|
||||||
|
|
||||||
|
expect(postBearer).toHaveBeenCalledWith("/v1/cancel", { Irn: IRN, ReasonCode: "1", Remark: "" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent — an already-cancelled invoice returns unchanged, no HTTP call", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow({ eimsStatus: EimsInvoiceStatus.Cancelled })]);
|
||||||
|
const postBearer = jest.fn();
|
||||||
|
|
||||||
|
const view = await build(db, postBearer).cancelInvoiceWithEims(INVOICE_ID, "1");
|
||||||
|
|
||||||
|
expect(postBearer).not.toHaveBeenCalled();
|
||||||
|
expect(view.eimsStatus).toBe(EimsInvoiceStatus.Cancelled);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to cancel an invoice that was never registered", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow({ eimsStatus: EimsInvoiceStatus.NotSubmitted, eimsIrn: null })]);
|
||||||
|
const postBearer = jest.fn();
|
||||||
|
|
||||||
|
await expect(build(db, postBearer).cancelInvoiceWithEims(INVOICE_ID, "1")).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
expect(postBearer).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates a MoR rejection and leaves the invoice REGISTERED", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow()]);
|
||||||
|
const postBearer = jest
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValue(new EimsApiException("RULE_VALIDATION", "EIMS cancel failed (406)", 406));
|
||||||
|
|
||||||
|
await expect(build(db, postBearer).cancelInvoiceWithEims(INVOICE_ID, "1")).rejects.toBeInstanceOf(
|
||||||
|
EimsApiException,
|
||||||
|
);
|
||||||
|
expect(db.invoices.get(INVOICE_ID)?.eimsStatus).toBe(EimsInvoiceStatus.Registered);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("notifies the buyer company on success, without blocking the result", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow()]);
|
||||||
|
db.companyContact = { phone: "+251911000000", email: "buyer@abc.et" };
|
||||||
|
const directSend = jest.fn().mockResolvedValue(undefined);
|
||||||
|
const postBearer = jest.fn().mockResolvedValue({ statusCode: 200, body: { cancellationDate: "x" } });
|
||||||
|
|
||||||
|
const view = await build(db, postBearer, directSend).cancelInvoiceWithEims(INVOICE_ID, "1");
|
||||||
|
|
||||||
|
expect(view.eimsStatus).toBe(EimsInvoiceStatus.Cancelled);
|
||||||
|
expect(directSend).toHaveBeenCalledWith("sms", "+251911000000", expect.stringContaining("cancelled"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fail cancellation when the buyer notification itself fails", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow()]);
|
||||||
|
db.companyContact = { phone: "+251911000000", email: null };
|
||||||
|
const directSend = jest.fn().mockRejectedValue(new Error("sms provider down"));
|
||||||
|
const postBearer = jest.fn().mockResolvedValue({ statusCode: 200, body: { cancellationDate: "x" } });
|
||||||
|
|
||||||
|
const view = await build(db, postBearer, directSend).cancelInvoiceWithEims(INVOICE_ID, "1");
|
||||||
|
|
||||||
|
expect(view.eimsStatus).toBe(EimsInvoiceStatus.Cancelled);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||||
|
import { InjectDataSource } from "@nestjs/typeorm";
|
||||||
|
import { DataSource, EntityManager } from "typeorm";
|
||||||
|
|
||||||
|
import { Invoice } from "../billing/entities/invoice.entity";
|
||||||
|
import { NotificationsService } from "../notifications/notifications.service";
|
||||||
|
import { sendCompanyChannels } from "../notifications/notify-company.util";
|
||||||
|
import { EimsClientService } from "./eims-client.service";
|
||||||
|
import { EimsCancelRequest, EimsCancelResponse, EimsInvoiceStatus, EimsInvoiceStatusView } from "./eims-registration.types";
|
||||||
|
import { toEimsInvoiceStatusView } from "./eims-invoice-view.util";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `POST /v1/cancel` for one already-registered invoice.
|
||||||
|
*
|
||||||
|
* Simpler than registration: cancellation carries no `InvoiceCounter`/`DocumentNumber`, so none of
|
||||||
|
* `EimsSystemState`'s reservation machinery applies, and — unlike registration — a retried cancel
|
||||||
|
* is safe: the collection's bulk-cancel example shows MoR itself rejects a second cancel with
|
||||||
|
* "IRN already Canceled.", so there is no double-filing risk the way an unacknowledged register
|
||||||
|
* call has. That is what makes the simpler shape below correct: no system-wide block, no in-flight
|
||||||
|
* marker, just a lock-check-unlock before the call and a fresh lock-check-write after it.
|
||||||
|
*
|
||||||
|
* ponytail: the eligibility check (TX1) and the write (TX2) are not one atomic operation, so two
|
||||||
|
* concurrent cancels on the same invoice could both pass TX1 and both call MoR — wasted, but safe,
|
||||||
|
* per the paragraph above. Upgrade to a single locked reservation (like registration's) only if
|
||||||
|
* MoR's cancel endpoint turns out not to be idempotent after all.
|
||||||
|
*
|
||||||
|
* Bearer-authenticated but unsigned (`postBearer`), same as `/v1/verify` — the collection's saved
|
||||||
|
* `/v1/cancel` request carries no `{request,signature,certificate}` envelope.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class EimsCancellationService {
|
||||||
|
private readonly logger = new Logger(EimsCancellationService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectDataSource() private readonly dataSource: DataSource,
|
||||||
|
private readonly client: EimsClientService,
|
||||||
|
private readonly notifications: NotificationsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Idempotent: an already-cancelled invoice returns unchanged, no HTTP call. Refuses an invoice
|
||||||
|
* that was never registered — there is no IRN to cancel.
|
||||||
|
*/
|
||||||
|
async cancelInvoiceWithEims(
|
||||||
|
invoiceId: string,
|
||||||
|
reasonCode: string,
|
||||||
|
remark?: string,
|
||||||
|
): Promise<EimsInvoiceStatusView> {
|
||||||
|
const eligible = await this.dataSource.transaction(async (manager) => {
|
||||||
|
const invoice = await this.lockInvoice(manager, invoiceId);
|
||||||
|
if (invoice.eimsStatus === EimsInvoiceStatus.Cancelled) return null;
|
||||||
|
if (!invoice.eimsIrn) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: "EIMS_NOT_REGISTERED",
|
||||||
|
message: `Invoice ${invoice.invoiceNumber} was never registered with EIMS — nothing to cancel.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return invoice;
|
||||||
|
});
|
||||||
|
if (!eligible) return this.getEimsCancellationStatus(invoiceId);
|
||||||
|
|
||||||
|
const request: EimsCancelRequest = { Irn: eligible.eimsIrn!, ReasonCode: reasonCode, Remark: remark ?? "" };
|
||||||
|
// Outside any transaction — no DB lock is held across the wire.
|
||||||
|
const response = await this.client.postBearer<EimsCancelRequest, EimsCancelResponse>(
|
||||||
|
"/v1/cancel",
|
||||||
|
request,
|
||||||
|
);
|
||||||
|
const cancellationDate = response?.body?.cancellationDate ?? null;
|
||||||
|
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
const invoice = await this.lockInvoice(manager, invoiceId);
|
||||||
|
// Re-checked under lock: a concurrent call may have already recorded this cancellation.
|
||||||
|
if (invoice.eimsStatus === EimsInvoiceStatus.Cancelled) return;
|
||||||
|
await manager.update(Invoice, invoiceId, {
|
||||||
|
eimsStatus: EimsInvoiceStatus.Cancelled,
|
||||||
|
eimsCancelledAt: new Date(),
|
||||||
|
eimsCancellationDate: cancellationDate,
|
||||||
|
eimsCancellationReasonCode: reasonCode,
|
||||||
|
eimsCancellationRemark: remark ?? null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.logger.log(`Invoice ${eligible.invoiceNumber} cancelled with EIMS (IRN ${eligible.eimsIrn})`);
|
||||||
|
|
||||||
|
await this.notifyBuyer(eligible);
|
||||||
|
return this.getEimsCancellationStatus(invoiceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEimsCancellationStatus(invoiceId: string): Promise<EimsInvoiceStatusView> {
|
||||||
|
const invoice = await this.dataSource.manager.findOne(Invoice, { where: { id: invoiceId } });
|
||||||
|
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
||||||
|
return toEimsInvoiceStatusView(invoice);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best-effort — a notification failure must never mask a cancellation that already succeeded. */
|
||||||
|
private async notifyBuyer(invoice: Invoice): Promise<void> {
|
||||||
|
try {
|
||||||
|
await sendCompanyChannels(
|
||||||
|
this.dataSource,
|
||||||
|
this.notifications,
|
||||||
|
invoice.companyId,
|
||||||
|
`Invoice ${invoice.invoiceNumber} has been cancelled with MoR EIMS.`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`EIMS buyer notification failed for invoice ${invoice.id}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async lockInvoice(manager: EntityManager, invoiceId: string): Promise<Invoice> {
|
||||||
|
const invoice = await manager
|
||||||
|
.createQueryBuilder(Invoice, "invoice")
|
||||||
|
.setLock("pessimistic_write")
|
||||||
|
.where("invoice.id = :invoiceId", { invoiceId })
|
||||||
|
.getOne();
|
||||||
|
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
||||||
|
return invoice;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { assertEimsInvoiceConfig, buildEimsContext } from "./eims-invoice-context";
|
||||||
|
import { eimsConfig } from "./eims-test-fixtures";
|
||||||
|
|
||||||
|
const SESSION = { systemNumber: "B0360154BA", systemType: "SYS" };
|
||||||
|
|
||||||
|
describe("assertEimsInvoiceConfig — charge-type overrides", () => {
|
||||||
|
it("passes when EIMS_TAX_CODE_BY_CHARGE_TYPE and _RATE_ list the same charge types", () => {
|
||||||
|
expect(() =>
|
||||||
|
assertEimsInvoiceConfig(
|
||||||
|
eimsConfig({
|
||||||
|
invoice: {
|
||||||
|
...eimsConfig().invoice,
|
||||||
|
taxCodeByChargeType: { RAIL_FREIGHT: "VAT0" },
|
||||||
|
taxRateByChargeType: { RAIL_FREIGHT: "0" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when a charge type has a code but no rate, or a rate but no code", () => {
|
||||||
|
expect(() =>
|
||||||
|
assertEimsInvoiceConfig(
|
||||||
|
eimsConfig({
|
||||||
|
invoice: {
|
||||||
|
...eimsConfig().invoice,
|
||||||
|
taxCodeByChargeType: { RAIL_FREIGHT: "VAT0" },
|
||||||
|
taxRateByChargeType: {},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toThrow(/mismatched: RAIL_FREIGHT/);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
assertEimsInvoiceConfig(
|
||||||
|
eimsConfig({
|
||||||
|
invoice: {
|
||||||
|
...eimsConfig().invoice,
|
||||||
|
taxCodeByChargeType: {},
|
||||||
|
taxRateByChargeType: { RAIL_FREIGHT: "0" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toThrow(/mismatched: RAIL_FREIGHT/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when a rate/excise/discount override is not a number", () => {
|
||||||
|
expect(() =>
|
||||||
|
assertEimsInvoiceConfig(
|
||||||
|
eimsConfig({
|
||||||
|
invoice: {
|
||||||
|
...eimsConfig().invoice,
|
||||||
|
taxCodeByChargeType: { RAIL_FREIGHT: "VAT0" },
|
||||||
|
taxRateByChargeType: { RAIL_FREIGHT: "not-a-number" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toThrow(/EIMS_TAX_RATE_BY_CHARGE_TYPE\[RAIL_FREIGHT\]/);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
assertEimsInvoiceConfig(
|
||||||
|
eimsConfig({ invoice: { ...eimsConfig().invoice, discountByChargeType: { X: "abc" } } }),
|
||||||
|
),
|
||||||
|
).toThrow(/EIMS_DISCOUNT_BY_CHARGE_TYPE\[X\]/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildEimsContext — taxForLine", () => {
|
||||||
|
const input = { documentNumber: "24", invoiceCounter: 7, previousIrn: "", session: SESSION };
|
||||||
|
const line = (chargeType: string) => ({ chargeType, quantity: 1, unitRate: 100, amount: 100 });
|
||||||
|
|
||||||
|
it("uses the per-chargeType override when one is configured", () => {
|
||||||
|
const context = buildEimsContext(
|
||||||
|
eimsConfig({
|
||||||
|
invoice: {
|
||||||
|
...eimsConfig().invoice,
|
||||||
|
taxCode: "VAT15",
|
||||||
|
taxRatePercent: 15,
|
||||||
|
taxCodeByChargeType: { RAIL_FREIGHT: "VAT0" },
|
||||||
|
taxRateByChargeType: { RAIL_FREIGHT: "0" },
|
||||||
|
exciseByChargeType: { RAIL_FREIGHT: "5" },
|
||||||
|
discountByChargeType: { RAIL_FREIGHT: "10" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
input,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(context.taxForLine(line("RAIL_FREIGHT"), 1)).toEqual({
|
||||||
|
code: "VAT0",
|
||||||
|
ratePercent: 0,
|
||||||
|
exciseTaxValue: 5,
|
||||||
|
discount: 10,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the invoice-wide default for a charge type with no override", () => {
|
||||||
|
const context = buildEimsContext(
|
||||||
|
eimsConfig({
|
||||||
|
invoice: {
|
||||||
|
...eimsConfig().invoice,
|
||||||
|
taxCode: "VAT15",
|
||||||
|
taxRatePercent: 15,
|
||||||
|
exciseTaxValue: 0,
|
||||||
|
taxCodeByChargeType: { RAIL_FREIGHT: "VAT0" },
|
||||||
|
taxRateByChargeType: { RAIL_FREIGHT: "0" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
input,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(context.taxForLine(line("HANDLING"), 1)).toEqual({
|
||||||
|
code: "VAT15",
|
||||||
|
ratePercent: 15,
|
||||||
|
exciseTaxValue: 0,
|
||||||
|
discount: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -59,6 +59,47 @@ export function assertEimsInvoiceConfig(config: EimsConfig): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
assertSellerFormats(config.invoice);
|
assertSellerFormats(config.invoice);
|
||||||
|
assertChargeTypeOverrides(config.invoice);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-`chargeType` tax overrides must be internally consistent before anything is filed:
|
||||||
|
* `EIMS_TAX_CODE_BY_CHARGE_TYPE` and `EIMS_TAX_RATE_BY_CHARGE_TYPE` must name the same charge
|
||||||
|
* types (a code with no rate, or vice versa, is a half-finished override), and every rate/excise/
|
||||||
|
* discount value must parse as a number — checked once here rather than once per line at
|
||||||
|
* registration time.
|
||||||
|
*/
|
||||||
|
function assertChargeTypeOverrides(invoice: EimsConfig["invoice"]): void {
|
||||||
|
const codeKeys = Object.keys(invoice.taxCodeByChargeType);
|
||||||
|
const rateKeys = Object.keys(invoice.taxRateByChargeType);
|
||||||
|
const mismatched = [...new Set([...codeKeys, ...rateKeys])].filter(
|
||||||
|
(k) => !(codeKeys.includes(k) && rateKeys.includes(k)),
|
||||||
|
);
|
||||||
|
if (mismatched.length > 0) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: "EIMS_INVOICE_CONFIG_INVALID",
|
||||||
|
message:
|
||||||
|
`EIMS_TAX_CODE_BY_CHARGE_TYPE and EIMS_TAX_RATE_BY_CHARGE_TYPE must list the same charge ` +
|
||||||
|
`types; mismatched: ${mismatched.join(", ")}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const numericMaps: { env: string; map: Record<string, string> }[] = [
|
||||||
|
{ env: "EIMS_TAX_RATE_BY_CHARGE_TYPE", map: invoice.taxRateByChargeType },
|
||||||
|
{ env: "EIMS_EXCISE_BY_CHARGE_TYPE", map: invoice.exciseByChargeType },
|
||||||
|
{ env: "EIMS_DISCOUNT_BY_CHARGE_TYPE", map: invoice.discountByChargeType },
|
||||||
|
];
|
||||||
|
const badNumbers = numericMaps.flatMap(({ env, map }) =>
|
||||||
|
Object.entries(map)
|
||||||
|
.filter(([, value]) => !Number.isFinite(Number(value)))
|
||||||
|
.map(([chargeType]) => `${env}[${chargeType}]`),
|
||||||
|
);
|
||||||
|
if (badNumbers.length > 0) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: "EIMS_INVOICE_CONFIG_INVALID",
|
||||||
|
message: `EIMS charge-type overrides must be numbers: ${badNumbers.join(", ")}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -135,9 +176,25 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E
|
|||||||
salesPersonName: invoice.salesPersonName,
|
salesPersonName: invoice.salesPersonName,
|
||||||
transactionType: invoice.transactionType,
|
transactionType: invoice.transactionType,
|
||||||
payment: { mode: invoice.paymentMode, term: invoice.paymentTerm },
|
payment: { mode: invoice.paymentMode, term: invoice.paymentTerm },
|
||||||
// One treatment for every line today. The mapper resolves tax per line, so a future
|
// Per-`chargeType` override when one is configured (validated symmetric in
|
||||||
// charge-type-specific rule slots in here without touching the mapper.
|
// assertChargeTypeOverrides), else the single invoice-wide default.
|
||||||
taxForLine: (_line: EimsMapperLine) => ({ code: taxCode, ratePercent, exciseTaxValue }),
|
taxForLine: (line: EimsMapperLine) => {
|
||||||
|
const { chargeType } = line;
|
||||||
|
const code = invoice.taxCodeByChargeType[chargeType] ?? taxCode;
|
||||||
|
const rate =
|
||||||
|
chargeType in invoice.taxRateByChargeType
|
||||||
|
? Number(invoice.taxRateByChargeType[chargeType])
|
||||||
|
: ratePercent;
|
||||||
|
const excise =
|
||||||
|
chargeType in invoice.exciseByChargeType
|
||||||
|
? Number(invoice.exciseByChargeType[chargeType])
|
||||||
|
: exciseTaxValue;
|
||||||
|
const discount =
|
||||||
|
chargeType in invoice.discountByChargeType
|
||||||
|
? Number(invoice.discountByChargeType[chargeType])
|
||||||
|
: 0;
|
||||||
|
return { code, ratePercent: rate, exciseTaxValue: excise, discount };
|
||||||
|
},
|
||||||
natureOfSupplies: invoice.natureOfSupplies,
|
natureOfSupplies: invoice.natureOfSupplies,
|
||||||
unitDefault: invoice.unitDefault,
|
unitDefault: invoice.unitDefault,
|
||||||
incomeWithholdValue: invoice.incomeWithholdValue!,
|
incomeWithholdValue: invoice.incomeWithholdValue!,
|
||||||
@@ -145,6 +202,9 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E
|
|||||||
buyerCountryCode: invoice.buyerCountryCode,
|
buyerCountryCode: invoice.buyerCountryCode,
|
||||||
buyerRegionCodes: invoice.buyerRegionCodes,
|
buyerRegionCodes: invoice.buyerRegionCodes,
|
||||||
buyerWeredaCodes: invoice.buyerWeredaCodes,
|
buyerWeredaCodes: invoice.buyerWeredaCodes,
|
||||||
|
// TEMPORARY — see EimsInvoiceConfig.buyerIdType.
|
||||||
|
buyerIdType: invoice.buyerIdType,
|
||||||
|
buyerIdNumber: invoice.buyerIdNumber,
|
||||||
exchangeRate: input.exchangeRate ?? null,
|
exchangeRate: input.exchangeRate ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Invoice } from "../billing/entities/invoice.entity";
|
|||||||
import { EimsInvoiceRequest } from "../billing/eims-invoice.mapper";
|
import { EimsInvoiceRequest } from "../billing/eims-invoice.mapper";
|
||||||
import { eimsInvoiceConfig } from "./eims-test-fixtures";
|
import { eimsInvoiceConfig } from "./eims-test-fixtures";
|
||||||
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
|
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
|
||||||
|
import { NotificationsService } from "../notifications/notifications.service";
|
||||||
import { EimsAuthService } from "./eims-auth.service";
|
import { EimsAuthService } from "./eims-auth.service";
|
||||||
import { EimsClientService } from "./eims-client.service";
|
import { EimsClientService } from "./eims-client.service";
|
||||||
import { EimsApiException } from "./eims.errors";
|
import { EimsApiException } from "./eims.errors";
|
||||||
@@ -85,6 +86,8 @@ class FakeDb {
|
|||||||
state: EimsSystemState | null = null;
|
state: EimsSystemState | null = null;
|
||||||
/** Runs before every transaction body, to simulate a concurrent writer. */
|
/** Runs before every transaction body, to simulate a concurrent writer. */
|
||||||
onTransaction: (() => void) | null = null;
|
onTransaction: (() => void) | null = null;
|
||||||
|
/** `sendCompanyChannels`'s contact lookup, when a test needs it non-empty. */
|
||||||
|
companyContact: { phone: string | null; email: string | null } | null = null;
|
||||||
|
|
||||||
constructor(invoices: Invoice[], state?: Partial<EimsSystemState>) {
|
constructor(invoices: Invoice[], state?: Partial<EimsSystemState>) {
|
||||||
for (const inv of invoices) this.invoices.set(inv.id, inv);
|
for (const inv of invoices) this.invoices.set(inv.id, inv);
|
||||||
@@ -133,10 +136,13 @@ class FakeDb {
|
|||||||
return {
|
return {
|
||||||
manager: this.manager,
|
manager: this.manager,
|
||||||
getRepository: this.manager.getRepository,
|
getRepository: this.manager.getRepository,
|
||||||
query: async (sql: string) =>
|
query: async (sql: string) => {
|
||||||
sql.includes("eims_system_state")
|
if (sql.includes("eims_system_state")) {
|
||||||
? [{ in_flight_invoice_id: this.state?.inFlightInvoiceId ?? null }]
|
return [{ in_flight_invoice_id: this.state?.inFlightInvoiceId ?? null }];
|
||||||
: LINES,
|
}
|
||||||
|
if (sql.includes("freight.companies")) return this.companyContact ? [this.companyContact] : [];
|
||||||
|
return LINES;
|
||||||
|
},
|
||||||
transaction: async (body: (m: unknown) => Promise<unknown>) => {
|
transaction: async (body: (m: unknown) => Promise<unknown>) => {
|
||||||
this.onTransaction?.();
|
this.onTransaction?.();
|
||||||
return body(this.manager);
|
return body(this.manager);
|
||||||
@@ -155,6 +161,7 @@ const build = (
|
|||||||
postBearer: jest.Mock = jest.fn(),
|
postBearer: jest.Mock = jest.fn(),
|
||||||
getSessionContext: jest.Mock | undefined = undefined,
|
getSessionContext: jest.Mock | undefined = undefined,
|
||||||
notify: jest.Mock = jest.fn().mockResolvedValue(undefined),
|
notify: jest.Mock = jest.fn().mockResolvedValue(undefined),
|
||||||
|
directSend: jest.Mock = jest.fn().mockResolvedValue(undefined),
|
||||||
) =>
|
) =>
|
||||||
new EimsInvoiceRegistrationService(
|
new EimsInvoiceRegistrationService(
|
||||||
db.asDataSource(),
|
db.asDataSource(),
|
||||||
@@ -164,6 +171,7 @@ const build = (
|
|||||||
getSessionContext: getSessionContext ?? jest.fn().mockResolvedValue(SESSION),
|
getSessionContext: getSessionContext ?? jest.fn().mockResolvedValue(SESSION),
|
||||||
} as unknown as EimsAuthService,
|
} as unknown as EimsAuthService,
|
||||||
{ notify } as unknown as NotificationInboxService,
|
{ notify } as unknown as NotificationInboxService,
|
||||||
|
{ directSend } as unknown as NotificationsService,
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -196,8 +204,11 @@ const verifyResponse = (over: Record<string, unknown> = {}) => ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const okResponse = (irn = IRN) =>
|
const okResponse = (irn = IRN, over: Record<string, unknown> = {}) => ({
|
||||||
({ statusCode: 200, message: "SUCCESS", body: { irn, ackDate: "2026-08-07T09:05:03Z[Etc/UTC]" } });
|
statusCode: 200,
|
||||||
|
message: "SUCCESS",
|
||||||
|
body: { irn, ackDate: "2026-08-07T09:05:03Z[Etc/UTC]", ...over },
|
||||||
|
});
|
||||||
|
|
||||||
const apiError = (kind: string, status?: number) =>
|
const apiError = (kind: string, status?: number) =>
|
||||||
new EimsApiException(kind as never, `EIMS register failed (${status ?? "-"})`, status);
|
new EimsApiException(kind as never, `EIMS register failed (${status ?? "-"})`, status);
|
||||||
@@ -226,6 +237,51 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("persists signedQR alongside the IRN", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow()]);
|
||||||
|
const postSigned = jest.fn().mockResolvedValue(okResponse(IRN, { signedQR: "signed-qr-payload" }));
|
||||||
|
|
||||||
|
const view = await build(db, postSigned).registerInvoiceWithEims(INVOICE_ID);
|
||||||
|
|
||||||
|
expect(view.eimsSignedQr).toBe("signed-qr-payload");
|
||||||
|
expect(db.invoices.get(INVOICE_ID)?.eimsSignedQr).toBe("signed-qr-payload");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves eimsSignedQr null when the gateway does not return one", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow()]);
|
||||||
|
const postSigned = jest.fn().mockResolvedValue(okResponse());
|
||||||
|
|
||||||
|
const view = await build(db, postSigned).registerInvoiceWithEims(INVOICE_ID);
|
||||||
|
|
||||||
|
expect(view.eimsSignedQr).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("notifies the buyer company on a successful registration, without blocking the result", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow({ companyId: "company-1" } as Partial<Invoice>)]);
|
||||||
|
db.companyContact = { phone: "+251911000000", email: "buyer@abc.et" };
|
||||||
|
const directSend = jest.fn().mockResolvedValue(undefined);
|
||||||
|
const postSigned = jest.fn().mockResolvedValue(okResponse());
|
||||||
|
|
||||||
|
const view = await build(db, postSigned, config(), jest.fn(), undefined, undefined, directSend)
|
||||||
|
.registerInvoiceWithEims(INVOICE_ID);
|
||||||
|
|
||||||
|
expect(view.eimsStatus).toBe(EimsInvoiceStatus.Registered);
|
||||||
|
expect(directSend).toHaveBeenCalledWith("sms", "+251911000000", expect.stringContaining(IRN));
|
||||||
|
expect(directSend).toHaveBeenCalledWith("email", "buyer@abc.et", expect.stringContaining(IRN));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fail registration when the buyer notification itself fails", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow({ companyId: "company-1" } as Partial<Invoice>)]);
|
||||||
|
db.companyContact = { phone: "+251911000000", email: null };
|
||||||
|
const directSend = jest.fn().mockRejectedValue(new Error("sms provider down"));
|
||||||
|
const postSigned = jest.fn().mockResolvedValue(okResponse());
|
||||||
|
|
||||||
|
const view = await build(db, postSigned, config(), jest.fn(), undefined, undefined, directSend)
|
||||||
|
.registerInvoiceWithEims(INVOICE_ID);
|
||||||
|
|
||||||
|
expect(view.eimsStatus).toBe(EimsInvoiceStatus.Registered);
|
||||||
|
});
|
||||||
|
|
||||||
it("sends the exact reserved counter and previous IRN to the mapper", async () => {
|
it("sends the exact reserved counter and previous IRN to the mapper", async () => {
|
||||||
const db = new FakeDb([invoiceRow()], { nextInvoiceCounter: 42, previousIrn: "PRIOR-IRN" });
|
const db = new FakeDb([invoiceRow()], { nextInvoiceCounter: 42, previousIrn: "PRIOR-IRN" });
|
||||||
const postSigned = jest.fn().mockResolvedValue(okResponse());
|
const postSigned = jest.fn().mockResolvedValue(okResponse());
|
||||||
@@ -408,7 +464,7 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
|
|||||||
expect(postSigned).toHaveBeenCalledTimes(1);
|
expect(postSigned).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns the counter after a refusal, but keeps it after an ambiguous result", async () => {
|
it("returns both the counter and the document number after a refusal, keeps both after an ambiguous result", async () => {
|
||||||
const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID })]);
|
const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID })]);
|
||||||
const postSigned = jest
|
const postSigned = jest
|
||||||
.fn()
|
.fn()
|
||||||
@@ -421,14 +477,47 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
|
|||||||
);
|
);
|
||||||
await service.registerInvoiceWithEims(OTHER_INVOICE_ID);
|
await service.registerInvoiceWithEims(OTHER_INVOICE_ID);
|
||||||
|
|
||||||
// The two numbers move differently, because MoR constrains them differently: the counter must
|
// A deterministic rejection rolls both numbers back — MoR's expected-next-value for either
|
||||||
// not skip (it returns), the document number must not repeat (it is burned).
|
// sequence only advances on acceptance (rule 7001 for DocumentNumber, same as InvoiceCounter).
|
||||||
const first = postSigned.mock.calls[0][1] as EimsInvoiceRequest;
|
const first = postSigned.mock.calls[0][1] as EimsInvoiceRequest;
|
||||||
const second = postSigned.mock.calls[1][1] as EimsInvoiceRequest;
|
const second = postSigned.mock.calls[1][1] as EimsInvoiceRequest;
|
||||||
expect(first.SourceSystem.InvoiceCounter).toBe(7);
|
expect(first.SourceSystem.InvoiceCounter).toBe(7);
|
||||||
expect(second.SourceSystem.InvoiceCounter).toBe(7);
|
expect(second.SourceSystem.InvoiceCounter).toBe(7);
|
||||||
expect(first.DocumentDetails.DocumentNumber).toBe("5");
|
expect(first.DocumentDetails.DocumentNumber).toBe("5");
|
||||||
expect(second.DocumentDetails.DocumentNumber).toBe("6");
|
expect(second.DocumentDetails.DocumentNumber).toBe("5");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks and alerts with the real IRN when MoR accepts but persisting it locally fails", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow()]);
|
||||||
|
const notify = jest.fn().mockResolvedValue(undefined);
|
||||||
|
const postSigned = jest.fn().mockResolvedValue(okResponse());
|
||||||
|
|
||||||
|
// 1st/2nd calls are the reserve() updates; the 3rd is settleSuccess's invoice update — the one
|
||||||
|
// that actually failed live (eims_irn too narrow for the real value MoR returned).
|
||||||
|
let call = 0;
|
||||||
|
const manager = (db as unknown as { manager: { update: jest.Mock } }).manager;
|
||||||
|
const realUpdate = manager.update;
|
||||||
|
manager.update = jest.fn(async (entity: unknown, id: string, patch: Record<string, unknown>) => {
|
||||||
|
call++;
|
||||||
|
if (call === 3) throw new Error("value too long for type character varying(64)");
|
||||||
|
return realUpdate(entity, id, patch);
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
build(db, postSigned, config(), jest.fn(), undefined, notify).registerInvoiceWithEims(
|
||||||
|
INVOICE_ID,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/value too long/);
|
||||||
|
|
||||||
|
// The IRN is never lost, even though the normal success path never committed.
|
||||||
|
expect(db.state?.blockedReason).toContain(IRN);
|
||||||
|
expect(db.state?.blockedReason).toContain("ACCEPTED");
|
||||||
|
expect(db.invoices.get(INVOICE_ID)?.eimsStatus).toBe(EimsInvoiceStatus.Submitting);
|
||||||
|
|
||||||
|
expect(notify).toHaveBeenCalledTimes(1);
|
||||||
|
const sent = notify.mock.calls[0][0];
|
||||||
|
expect(sent.priority).toBe("HIGH");
|
||||||
|
expect(sent.body).toContain(IRN);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ import {
|
|||||||
} from "../billing/eims-invoice.mapper";
|
} from "../billing/eims-invoice.mapper";
|
||||||
import { NotificationAudience, NotificationPriority, NotificationType } from "@edr/types";
|
import { NotificationAudience, NotificationPriority, NotificationType } from "@edr/types";
|
||||||
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
|
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
|
||||||
|
import { NotificationsService } from "../notifications/notifications.service";
|
||||||
|
import { sendCompanyChannels } from "../notifications/notify-company.util";
|
||||||
|
import { toEimsInvoiceStatusView } from "./eims-invoice-view.util";
|
||||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||||
import { EimsAuthService } from "./eims-auth.service";
|
import { EimsAuthService } from "./eims-auth.service";
|
||||||
import { EimsClientService } from "./eims-client.service";
|
import { EimsClientService } from "./eims-client.service";
|
||||||
@@ -78,6 +81,7 @@ export class EimsInvoiceRegistrationService {
|
|||||||
private readonly client: EimsClientService,
|
private readonly client: EimsClientService,
|
||||||
private readonly auth: EimsAuthService,
|
private readonly auth: EimsAuthService,
|
||||||
private readonly inbox: NotificationInboxService,
|
private readonly inbox: NotificationInboxService,
|
||||||
|
private readonly notifications: NotificationsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private get cfg(): EimsConfig {
|
private get cfg(): EimsConfig {
|
||||||
@@ -115,17 +119,28 @@ export class EimsInvoiceRegistrationService {
|
|||||||
|
|
||||||
let irn: string;
|
let irn: string;
|
||||||
let ackDate: string | undefined;
|
let ackDate: string | undefined;
|
||||||
|
let signedQR: string | undefined;
|
||||||
try {
|
try {
|
||||||
// Deliberately outside every transaction — no DB lock is held across the wire.
|
// Deliberately outside every transaction — no DB lock is held across the wire.
|
||||||
const result = await this.submit(request);
|
const result = await this.submit(request);
|
||||||
irn = result.irn;
|
irn = result.irn;
|
||||||
ackDate = result.ackDate;
|
ackDate = result.ackDate;
|
||||||
|
signedQR = result.signedQR;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await this.settleFailure(invoiceId, reservation, err);
|
await this.settleFailure(invoiceId, reservation, err);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.settleSuccess(invoiceId, reservation, irn, ackDate);
|
try {
|
||||||
|
await this.settleSuccess(invoiceId, reservation, irn, ackDate, signedQR);
|
||||||
|
} catch (err) {
|
||||||
|
// MoR already accepted this document — unlike settleFailure's targets, this is not
|
||||||
|
// ambiguous, it is a *known* IRN we simply failed to persist (confirmed live 2026-08-12: a
|
||||||
|
// column too narrow for a real IRN). Losing it here would be worse than the persistence
|
||||||
|
// bug itself, so it goes straight into the block reason and the alert, not just a log line.
|
||||||
|
await this.blockOnKnownIrnPersistFailure(invoiceId, reservation, irn, ackDate, err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Invoice ${invoice.invoiceNumber} registered with EIMS (counter ${reservation.invoiceCounter})`,
|
`Invoice ${invoice.invoiceNumber} registered with EIMS (counter ${reservation.invoiceCounter})`,
|
||||||
);
|
);
|
||||||
@@ -373,13 +388,19 @@ export class EimsInvoiceRegistrationService {
|
|||||||
reservation: Reservation,
|
reservation: Reservation,
|
||||||
irn: string,
|
irn: string,
|
||||||
ackDate?: string,
|
ackDate?: string,
|
||||||
|
signedQR?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
let companyId: string | undefined;
|
||||||
|
let invoiceNumber = invoiceId;
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
await this.lockInvoice(manager, invoiceId);
|
const invoice = await this.lockInvoice(manager, invoiceId);
|
||||||
|
companyId = invoice.companyId;
|
||||||
|
invoiceNumber = invoice.invoiceNumber;
|
||||||
await manager.update(Invoice, invoiceId, {
|
await manager.update(Invoice, invoiceId, {
|
||||||
eimsStatus: EimsInvoiceStatus.Registered,
|
eimsStatus: EimsInvoiceStatus.Registered,
|
||||||
eimsIrn: irn,
|
eimsIrn: irn,
|
||||||
eimsAckDate: ackDate ?? null,
|
eimsAckDate: ackDate ?? null,
|
||||||
|
eimsSignedQr: signedQR ?? null,
|
||||||
eimsLastError: null,
|
eimsLastError: null,
|
||||||
});
|
});
|
||||||
await manager.update(EimsSystemState, reservation.stateId, {
|
await manager.update(EimsSystemState, reservation.stateId, {
|
||||||
@@ -390,20 +411,39 @@ export class EimsInvoiceRegistrationService {
|
|||||||
blockedReason: null,
|
blockedReason: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Best-effort, outside the transaction: MoR checklist ADD-N001 wants the buyer notified of a
|
||||||
|
// registration event. Never lets a notification failure mask a filing that already succeeded.
|
||||||
|
if (companyId) {
|
||||||
|
try {
|
||||||
|
await sendCompanyChannels(
|
||||||
|
this.dataSource,
|
||||||
|
this.notifications,
|
||||||
|
companyId,
|
||||||
|
`Invoice ${invoiceNumber} has been registered with MoR EIMS. Reference (IRN): ${irn}`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`EIMS buyer notification failed for invoice ${invoiceId}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TX2b. A deterministic rejection releases the reservation **and returns the counter**; an
|
* TX2b. A deterministic rejection releases the reservation **and returns both numbers**; an
|
||||||
* ambiguous result keeps both and blocks the system number, because `PreviousIrn` is now unknown
|
* ambiguous result keeps both and blocks the system number, because `PreviousIrn` is now unknown
|
||||||
* for every later document.
|
* for every later document.
|
||||||
*
|
*
|
||||||
* The two numbers move differently, because MoR constrains them differently:
|
* Both `InvoiceCounter` and `DocumentNumber` roll back together on a deterministic rejection —
|
||||||
|
* MoR's own expected-next-value only advances on acceptance, for both fields:
|
||||||
*
|
*
|
||||||
* - `InvoiceCounter` must not **skip** — "Invoice counter is not correct. expected : 1". A
|
* - `InvoiceCounter`: "Invoice counter is not correct. expected : 1".
|
||||||
* document MoR definitively refused was never counted there, so ours must not advance either.
|
* - `DocumentNumber`: "Document number error. Document number is not in correct sequence
|
||||||
* - `DocumentNumber` must not **repeat** — the documented rule is "Document number is not
|
* expected : 1" (rule 7001) — confirmed live 2026-08-12. An earlier design burned
|
||||||
* unique". It is therefore spent by the attempt itself and never handed back, even for a
|
* `DocumentNumber` forward on every attempt, reasoning from a separate "Document number is
|
||||||
* refusal.
|
* not unique" rejection; that turned out to describe the same constraint from the other side
|
||||||
|
* (MoR rejects both reuse *and* skipping ahead of its true next value), and burning forward on
|
||||||
|
* every rejection permanently drifted past what MoR would ever accept again — confirmed live
|
||||||
|
* when two rejected self-test attempts deadlocked the sequence until a manual DB reset.
|
||||||
*
|
*
|
||||||
* An ambiguous result keeps both: MoR may have counted and stored the document.
|
* An ambiguous result keeps both: MoR may have counted and stored the document.
|
||||||
*/
|
*/
|
||||||
@@ -434,9 +474,9 @@ export class EimsInvoiceRegistrationService {
|
|||||||
reservation.stateId,
|
reservation.stateId,
|
||||||
deterministic
|
deterministic
|
||||||
? {
|
? {
|
||||||
// Counter returns (MoR never counted a refused document); the document number does
|
// Both return: MoR never counted a refused document against either sequence.
|
||||||
// not (MoR requires it to be unique, so it is burned by the attempt).
|
|
||||||
nextInvoiceCounter: reservation.invoiceCounter,
|
nextInvoiceCounter: reservation.invoiceCounter,
|
||||||
|
nextDocumentNumber: Number(reservation.documentNumber),
|
||||||
inFlightInvoiceId: null,
|
inFlightInvoiceId: null,
|
||||||
inFlightCounter: null,
|
inFlightCounter: null,
|
||||||
inFlightDocumentNumber: null,
|
inFlightDocumentNumber: null,
|
||||||
@@ -455,6 +495,53 @@ export class EimsInvoiceRegistrationService {
|
|||||||
await this.alertStaff(invoiceId, status, lastError, deterministic);
|
await this.alertStaff(invoiceId, status, lastError, deterministic);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MoR accepted the document (a real IRN came back) but recording that locally failed — the
|
||||||
|
* reservation is still held from TX1, so the system-wide block goes on regardless of *why*
|
||||||
|
* `settleSuccess` failed. The IRN and ack date are written straight into `blockedReason` so a
|
||||||
|
* human resolving this never has to dig through logs for the one thing that must not be lost.
|
||||||
|
*/
|
||||||
|
private async blockOnKnownIrnPersistFailure(
|
||||||
|
invoiceId: string,
|
||||||
|
reservation: Reservation,
|
||||||
|
irn: string,
|
||||||
|
ackDate: string | undefined,
|
||||||
|
err: unknown,
|
||||||
|
): Promise<void> {
|
||||||
|
const reason =
|
||||||
|
`Invoice ${invoiceId} was ACCEPTED by EIMS (IRN ${irn}${ackDate ? `, ack ${ackDate}` : ""}) ` +
|
||||||
|
`but recording it locally failed: ${(err as Error)?.message ?? "unknown error"}. Resolve with ` +
|
||||||
|
`POST /invoices/${invoiceId}/eims/resolve using this IRN once the underlying issue is fixed — ` +
|
||||||
|
"do not resubmit, the document already exists at MoR.";
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.dataSource.manager.update(EimsSystemState, reservation.stateId, { blockedReason: reason });
|
||||||
|
} catch (updateErr) {
|
||||||
|
// Even the block itself failed to write — last resort is the log, since there is nothing
|
||||||
|
// left to retry into.
|
||||||
|
this.logger.error(`Could not record EIMS block for invoice ${invoiceId}: ${reason}`, updateErr as Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.error(reason);
|
||||||
|
// Not alertStaff(): its canned copy for a non-deterministic result always says "the IRN is
|
||||||
|
// unknown", which is false here — the whole point of this path is that the IRN *is* known.
|
||||||
|
try {
|
||||||
|
await this.inbox.notify({
|
||||||
|
recipients: { permissionKeys: [FREIGHT_PERMS.invoices.eimsResolve] },
|
||||||
|
audience: NotificationAudience.BACKOFFICE,
|
||||||
|
type: NotificationType.GENERIC,
|
||||||
|
priority: NotificationPriority.HIGH,
|
||||||
|
title: "EIMS accepted an invoice but it was not recorded — all further filing is blocked",
|
||||||
|
body: reason,
|
||||||
|
link: `/dashboard/invoices/${invoiceId}`,
|
||||||
|
data: { invoiceId, eimsStatus: EimsInvoiceStatus.Unknown, irn, action: "EIMS_PERSIST_FAILED" },
|
||||||
|
});
|
||||||
|
} catch (notifyErr) {
|
||||||
|
this.logger.warn(`EIMS staff alert failed for invoice ${invoiceId}: ${(notifyErr as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tell the people who can act about a failed filing.
|
* Tell the people who can act about a failed filing.
|
||||||
*
|
*
|
||||||
@@ -492,7 +579,9 @@ export class EimsInvoiceRegistrationService {
|
|||||||
// ── internals ────────────────────────────────────────────────────────────────────────────────
|
// ── internals ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** A non-empty IRN is the only success signal; anything else is a failed registration. */
|
/** A non-empty IRN is the only success signal; anything else is a failed registration. */
|
||||||
private async submit(request: EimsInvoiceRequest): Promise<{ irn: string; ackDate?: string }> {
|
private async submit(
|
||||||
|
request: EimsInvoiceRequest,
|
||||||
|
): Promise<{ irn: string; ackDate?: string; signedQR?: string }> {
|
||||||
const response = await this.client.postSigned<EimsInvoiceRequest, EimsRegisterResponse>(
|
const response = await this.client.postSigned<EimsInvoiceRequest, EimsRegisterResponse>(
|
||||||
"/v1/register",
|
"/v1/register",
|
||||||
request,
|
request,
|
||||||
@@ -506,7 +595,7 @@ export class EimsInvoiceRegistrationService {
|
|||||||
response?.statusCode,
|
response?.statusCode,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return { irn, ackDate: response.body?.ackDate };
|
return { irn, ackDate: response.body?.ackDate, signedQR: response.body?.signedQR };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async lockInvoice(manager: EntityManager, invoiceId: string): Promise<Invoice> {
|
private async lockInvoice(manager: EntityManager, invoiceId: string): Promise<Invoice> {
|
||||||
@@ -572,17 +661,6 @@ export class EimsInvoiceRegistrationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private toView(invoice: Invoice): EimsInvoiceStatusView {
|
private toView(invoice: Invoice): EimsInvoiceStatusView {
|
||||||
const counter = invoice.eimsInvoiceCounter;
|
return toEimsInvoiceStatusView(invoice);
|
||||||
return {
|
|
||||||
invoiceId: invoice.id,
|
|
||||||
invoiceNumber: invoice.invoiceNumber,
|
|
||||||
eimsStatus: invoice.eimsStatus ?? EimsInvoiceStatus.NotSubmitted,
|
|
||||||
eimsIrn: invoice.eimsIrn ?? null,
|
|
||||||
eimsDocumentNumber: invoice.eimsDocumentNumber ?? null,
|
|
||||||
eimsInvoiceCounter: counter === null || counter === undefined ? null : Number(counter),
|
|
||||||
eimsSubmittedAt: invoice.eimsSubmittedAt ?? null,
|
|
||||||
eimsAckDate: invoice.eimsAckDate ?? null,
|
|
||||||
eimsLastError: invoice.eimsLastError ?? null,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Invoice } from "../billing/entities/invoice.entity";
|
||||||
|
import { EimsInvoiceStatus, EimsInvoiceStatusView } from "./eims-registration.types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `Invoice` → `EimsInvoiceStatusView`, shared by every service that mutates EIMS state on an
|
||||||
|
* invoice (registration, cancellation, receipts) — one place defines what "the current EIMS
|
||||||
|
* status of an invoice" means to a caller.
|
||||||
|
*/
|
||||||
|
export function toEimsInvoiceStatusView(invoice: Invoice): EimsInvoiceStatusView {
|
||||||
|
const counter = invoice.eimsInvoiceCounter;
|
||||||
|
return {
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
invoiceNumber: invoice.invoiceNumber,
|
||||||
|
eimsStatus: invoice.eimsStatus ?? EimsInvoiceStatus.NotSubmitted,
|
||||||
|
eimsIrn: invoice.eimsIrn ?? null,
|
||||||
|
eimsDocumentNumber: invoice.eimsDocumentNumber ?? null,
|
||||||
|
eimsInvoiceCounter: counter === null || counter === undefined ? null : Number(counter),
|
||||||
|
eimsSubmittedAt: invoice.eimsSubmittedAt ?? null,
|
||||||
|
eimsAckDate: invoice.eimsAckDate ?? null,
|
||||||
|
eimsLastError: invoice.eimsLastError ?? null,
|
||||||
|
eimsSignedQr: invoice.eimsSignedQr ?? null,
|
||||||
|
eimsCancelledAt: invoice.eimsCancelledAt ?? null,
|
||||||
|
eimsCancellationDate: invoice.eimsCancellationDate ?? null,
|
||||||
|
eimsCancellationReasonCode: invoice.eimsCancellationReasonCode ?? null,
|
||||||
|
eimsCancellationRemark: invoice.eimsCancellationRemark ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,8 +3,13 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
|||||||
|
|
||||||
import { BookingStaff } from "../../common/booking-guards";
|
import { BookingStaff } from "../../common/booking-guards";
|
||||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||||
|
import { CancelEimsRegistrationDto } from "./dto/cancel-eims-registration.dto";
|
||||||
|
import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto";
|
||||||
|
import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto";
|
||||||
import { ResolveEimsRegistrationDto } from "./dto/resolve-eims-registration.dto";
|
import { ResolveEimsRegistrationDto } from "./dto/resolve-eims-registration.dto";
|
||||||
|
import { EimsCancellationService } from "./eims-cancellation.service";
|
||||||
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
|
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
|
||||||
|
import { EimsReceiptService } from "./eims-receipt.service";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manual EIMS actions on an existing invoice.
|
* Manual EIMS actions on an existing invoice.
|
||||||
@@ -13,10 +18,12 @@ import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.serv
|
|||||||
* normal production path — they exist for controlled testing and exceptional operations. Automatic
|
* normal production path — they exist for controlled testing and exceptional operations. Automatic
|
||||||
* submission after an invoice is issued is a separate phase; nothing here is called by it.
|
* submission after an invoice is issued is a separate phase; nothing here is called by it.
|
||||||
*
|
*
|
||||||
* `eims_register` and `eims_resolve` are intentionally left out of every role preset and assigned
|
* `eims_register`, `eims_resolve`, `eims_cancel` and `eims_receipt_register` are intentionally left
|
||||||
* to named admins instead. They are also separate permissions: resolving clears the system-wide
|
* out of every role preset and assigned to named admins instead. They are also separate
|
||||||
* chain block and can record an IRN against an invoice, which is a supervisor action, not an
|
* permissions: resolving clears the system-wide chain block and can record an IRN against an
|
||||||
* operational one. Only `eims/status` rides on the ordinary `invoices:view`.
|
* invoice, cancelling is its own irreversible-at-MoR action, and filing a receipt is a third —
|
||||||
|
* none follows from the right to register. Only `eims/status` and `eims/receipts` ride on the
|
||||||
|
* ordinary `invoices:view`.
|
||||||
*
|
*
|
||||||
* Filing gets its own permission (`invoices:eims_register`) rather than riding on an existing key:
|
* Filing gets its own permission (`invoices:eims_register`) rather than riding on an existing key:
|
||||||
* registration is irreversible at MoR, so it must not follow from the right to download a PDF.
|
* registration is irreversible at MoR, so it must not follow from the right to download a PDF.
|
||||||
@@ -27,7 +34,11 @@ import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.serv
|
|||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller("invoices")
|
@Controller("invoices")
|
||||||
export class EimsInvoiceController {
|
export class EimsInvoiceController {
|
||||||
constructor(private readonly registration: EimsInvoiceRegistrationService) {}
|
constructor(
|
||||||
|
private readonly registration: EimsInvoiceRegistrationService,
|
||||||
|
private readonly cancellation: EimsCancellationService,
|
||||||
|
private readonly receipts: EimsReceiptService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Post(":id/eims/register")
|
@Post(":id/eims/register")
|
||||||
@BookingStaff(FREIGHT_PERMS.invoices.eimsRegister)
|
@BookingStaff(FREIGHT_PERMS.invoices.eimsRegister)
|
||||||
@@ -65,4 +76,38 @@ export class EimsInvoiceController {
|
|||||||
status(@Param("id", ParseUUIDPipe) id: string) {
|
status(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
return this.registration.getEimsStatus(id);
|
return this.registration.getEimsStatus(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(":id/eims/cancel")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.invoices.eimsCancel)
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Cancel the invoice's registered EIMS document. Idempotent — an already-cancelled invoice is returned unchanged.",
|
||||||
|
})
|
||||||
|
cancel(@Param("id", ParseUUIDPipe) id: string, @Body() dto: CancelEimsRegistrationDto) {
|
||||||
|
return this.cancellation.cancelInvoiceWithEims(id, dto.reasonCode, dto.remark);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":id/eims/receipt/sales")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.invoices.eimsReceiptRegister)
|
||||||
|
@ApiOperation({ summary: "Register a sales receipt with MoR EIMS against a registered invoice" })
|
||||||
|
registerSalesReceipt(@Param("id", ParseUUIDPipe) id: string, @Body() dto: RegisterSalesReceiptDto) {
|
||||||
|
return this.receipts.registerSalesReceipt(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":id/eims/receipt/withholding")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.invoices.eimsReceiptRegister)
|
||||||
|
@ApiOperation({ summary: "Register a withholding receipt with MoR EIMS against a registered invoice" })
|
||||||
|
registerWithholdingReceipt(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: RegisterWithholdingReceiptDto,
|
||||||
|
) {
|
||||||
|
return this.receipts.registerWithholdingReceipt(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(":id/eims/receipts")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.invoices.view)
|
||||||
|
@ApiOperation({ summary: "List every EIMS receipt filed against this invoice, newest first" })
|
||||||
|
listReceipts(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
|
return this.receipts.listReceipts(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import { BadRequestException } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { DataSource } from "typeorm";
|
||||||
|
|
||||||
|
import { EimsConfig } from "../../config/eims.config";
|
||||||
|
import { Invoice } from "../billing/entities/invoice.entity";
|
||||||
|
import { NotificationsService } from "../notifications/notifications.service";
|
||||||
|
import { EimsAuthService } from "./eims-auth.service";
|
||||||
|
import { EimsClientService } from "./eims-client.service";
|
||||||
|
import { EimsApiException } from "./eims.errors";
|
||||||
|
import { EimsReceiptStatus } from "./entities/eims-receipt.entity";
|
||||||
|
import { EimsReceiptService } from "./eims-receipt.service";
|
||||||
|
|
||||||
|
const INVOICE_ID = "11111111-1111-4111-8111-111111111111";
|
||||||
|
const IRN = "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0";
|
||||||
|
const SESSION = { systemNumber: "B0360154BA", systemType: "SYS" };
|
||||||
|
|
||||||
|
const invoiceRow = (over: Partial<Invoice> = {}): Invoice =>
|
||||||
|
({
|
||||||
|
id: INVOICE_ID,
|
||||||
|
invoiceNumber: "INV-20260807-00042",
|
||||||
|
companyId: "company-1",
|
||||||
|
currency: "ETB",
|
||||||
|
totalAmount: "10000.00",
|
||||||
|
paidAmount: "10000.00",
|
||||||
|
balanceAmount: "0.00",
|
||||||
|
eimsIrn: IRN,
|
||||||
|
...over,
|
||||||
|
}) as unknown as Invoice;
|
||||||
|
|
||||||
|
/** In-memory stand-in: Invoice lookups + EimsReceipt save/update/find. */
|
||||||
|
class FakeDb {
|
||||||
|
invoices = new Map<string, Invoice>();
|
||||||
|
receipts = new Map<string, Record<string, unknown>>();
|
||||||
|
companyContact: { phone: string | null; email: string | null } | null = null;
|
||||||
|
private seq = 0;
|
||||||
|
|
||||||
|
constructor(invoices: Invoice[]) {
|
||||||
|
for (const inv of invoices) this.invoices.set(inv.id, inv);
|
||||||
|
}
|
||||||
|
|
||||||
|
private manager = {
|
||||||
|
findOne: async (entity: unknown, options: { where: { id: string } }) =>
|
||||||
|
entity === Invoice
|
||||||
|
? (this.invoices.get(options.where.id) ?? null)
|
||||||
|
: (this.receipts.get(options.where.id) ?? null),
|
||||||
|
find: async (_entity: unknown, options: { where: { invoiceId: string } }) =>
|
||||||
|
[...this.receipts.values()].filter((r) => r.invoiceId === options.where.invoiceId),
|
||||||
|
save: async (_entity: unknown, data: Record<string, unknown>) => {
|
||||||
|
const id = `receipt-${++this.seq}`;
|
||||||
|
const row = { id, ...data };
|
||||||
|
this.receipts.set(id, row);
|
||||||
|
return row;
|
||||||
|
},
|
||||||
|
update: async (_entity: unknown, id: string, patch: Record<string, unknown>) => {
|
||||||
|
Object.assign(this.receipts.get(id)!, patch);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
asDataSource(): DataSource {
|
||||||
|
return {
|
||||||
|
manager: this.manager,
|
||||||
|
query: async () => (this.companyContact ? [this.companyContact] : []),
|
||||||
|
} as unknown as DataSource;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = (): EimsConfig => ({ tin: "0053481357" }) as EimsConfig;
|
||||||
|
|
||||||
|
const build = (
|
||||||
|
db: FakeDb,
|
||||||
|
postBearer: jest.Mock,
|
||||||
|
directSend: jest.Mock = jest.fn().mockResolvedValue(undefined),
|
||||||
|
) =>
|
||||||
|
new EimsReceiptService(
|
||||||
|
db.asDataSource(),
|
||||||
|
{ get: () => config() } as unknown as ConfigService,
|
||||||
|
{ postBearer } as unknown as EimsClientService,
|
||||||
|
{ getSessionContext: jest.fn().mockResolvedValue(SESSION) } as unknown as EimsAuthService,
|
||||||
|
{ directSend } as unknown as NotificationsService,
|
||||||
|
);
|
||||||
|
|
||||||
|
const okResponse = (over: Record<string, unknown> = {}) => ({
|
||||||
|
statusCode: 200,
|
||||||
|
message: "Success",
|
||||||
|
body: { status: "A", rrn: "rrn-value", qr: "iVBORw0KGgo...", ...over },
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("EimsReceiptService.registerSalesReceipt", () => {
|
||||||
|
it("registers a sales receipt and persists the RRN/QR", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow()]);
|
||||||
|
const postBearer = jest.fn().mockResolvedValue(okResponse());
|
||||||
|
|
||||||
|
const receipt = await build(db, postBearer).registerSalesReceipt(INVOICE_ID, {
|
||||||
|
modeOfPayment: "CASH",
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(postBearer).toHaveBeenCalledTimes(1);
|
||||||
|
expect(postBearer.mock.calls[0][0]).toBe("/v1/receipt/sales");
|
||||||
|
const request = postBearer.mock.calls[0][1];
|
||||||
|
expect(request.SellerTIN).toBe("0053481357");
|
||||||
|
expect(request.SourceSystemNumber).toBe("B0360154BA");
|
||||||
|
expect(request.Invoices[0].InvoiceIRN).toBe(IRN);
|
||||||
|
expect(request.TransactionDetails.ModeOfPayment).toBe("CASH");
|
||||||
|
expect(receipt.status).toBe(EimsReceiptStatus.Registered);
|
||||||
|
expect(receipt.rrn).toBe("rrn-value");
|
||||||
|
expect(receipt.qr).toBe("iVBORw0KGgo...");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults PaymentCoverage to FULL when the invoice balance is 0, PARTIAL otherwise", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow({ balanceAmount: 500 })]);
|
||||||
|
const postBearer = jest.fn().mockResolvedValue(okResponse());
|
||||||
|
|
||||||
|
await build(db, postBearer).registerSalesReceipt(INVOICE_ID, { modeOfPayment: "CASH" } as never);
|
||||||
|
|
||||||
|
expect(postBearer.mock.calls[0][1].Invoices[0].PaymentCoverage).toBe("PARTIAL");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unconfirmed receipt currency locally, with zero HTTP calls", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow()]);
|
||||||
|
const postBearer = jest.fn();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
build(db, postBearer).registerSalesReceipt(INVOICE_ID, {
|
||||||
|
modeOfPayment: "CASH",
|
||||||
|
currency: "GBP",
|
||||||
|
} as never),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(postBearer).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to file a receipt against an unregistered invoice", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow({ eimsIrn: null })]);
|
||||||
|
const postBearer = jest.fn();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
build(db, postBearer).registerSalesReceipt(INVOICE_ID, { modeOfPayment: "CASH" } as never),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(postBearer).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks the receipt FAILED on a deterministic rejection and rethrows", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow()]);
|
||||||
|
const postBearer = jest
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValue(new EimsApiException("RULE_VALIDATION", "EIMS receipt failed (406)", 406));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
build(db, postBearer).registerSalesReceipt(INVOICE_ID, { modeOfPayment: "CASH" } as never),
|
||||||
|
).rejects.toBeInstanceOf(EimsApiException);
|
||||||
|
const [receipt] = [...db.receipts.values()];
|
||||||
|
expect(receipt.status).toBe(EimsReceiptStatus.Failed);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks the receipt UNKNOWN on an ambiguous failure (never auto-retried)", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow()]);
|
||||||
|
const postBearer = jest.fn().mockRejectedValue(new EimsApiException("TIMEOUT", "EIMS receipt timed out"));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
build(db, postBearer).registerSalesReceipt(INVOICE_ID, { modeOfPayment: "CASH" } as never),
|
||||||
|
).rejects.toBeInstanceOf(EimsApiException);
|
||||||
|
const [receipt] = [...db.receipts.values()];
|
||||||
|
expect(receipt.status).toBe(EimsReceiptStatus.Unknown);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("notifies the buyer company on success, without blocking the result", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow()]);
|
||||||
|
db.companyContact = { phone: "+251911000000", email: "buyer@abc.et" };
|
||||||
|
const directSend = jest.fn().mockResolvedValue(undefined);
|
||||||
|
const postBearer = jest.fn().mockResolvedValue(okResponse());
|
||||||
|
|
||||||
|
const receipt = await build(db, postBearer, directSend).registerSalesReceipt(INVOICE_ID, {
|
||||||
|
modeOfPayment: "CASH",
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(receipt.status).toBe(EimsReceiptStatus.Registered);
|
||||||
|
expect(directSend).toHaveBeenCalledWith("sms", "+251911000000", expect.stringContaining("sales"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("EimsReceiptService.registerWithholdingReceipt", () => {
|
||||||
|
it("registers a withholding receipt and persists the RRN", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow()]);
|
||||||
|
const postBearer = jest.fn().mockResolvedValue(okResponse());
|
||||||
|
|
||||||
|
const receipt = await build(db, postBearer).registerWithholdingReceipt(INVOICE_ID, {
|
||||||
|
type: "TWHT",
|
||||||
|
preTaxAmount: 6000,
|
||||||
|
withholdingAmount: 120,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(postBearer.mock.calls[0][0]).toBe("/v1/receipt/withholding");
|
||||||
|
const request = postBearer.mock.calls[0][1];
|
||||||
|
expect(request.InvoiceDetail.InvoiceIRN).toBe(IRN);
|
||||||
|
expect(request.WithholdDetail).toMatchObject({ Type: "TWHT", PreTaxAmount: 6000, WithholdingAmount: 120 });
|
||||||
|
expect(receipt.status).toBe(EimsReceiptStatus.Registered);
|
||||||
|
expect(receipt.rrn).toBe("rrn-value");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires an exchangeRate for a non-ETB invoice", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow({ currency: "USD" })]);
|
||||||
|
const postBearer = jest.fn();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
build(db, postBearer).registerWithholdingReceipt(INVOICE_ID, {
|
||||||
|
type: "TWHT",
|
||||||
|
preTaxAmount: 6000,
|
||||||
|
withholdingAmount: 120,
|
||||||
|
} as never),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(postBearer).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("EimsReceiptService.listReceipts", () => {
|
||||||
|
it("returns every receipt filed against the invoice", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow()]);
|
||||||
|
const postBearer = jest.fn().mockResolvedValue(okResponse());
|
||||||
|
const service = build(db, postBearer);
|
||||||
|
|
||||||
|
await service.registerSalesReceipt(INVOICE_ID, { modeOfPayment: "CASH" } as never);
|
||||||
|
await service.registerWithholdingReceipt(INVOICE_ID, {
|
||||||
|
type: "TWHT",
|
||||||
|
preTaxAmount: 100,
|
||||||
|
withholdingAmount: 2,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const list = await service.listReceipts(INVOICE_ID);
|
||||||
|
expect(list).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
259
apps/edr-freight-api/src/modules/eims/eims-receipt.service.ts
Normal file
259
apps/edr-freight-api/src/modules/eims/eims-receipt.service.ts
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { InjectDataSource } from "@nestjs/typeorm";
|
||||||
|
import { DataSource } from "typeorm";
|
||||||
|
import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity.js";
|
||||||
|
|
||||||
|
import { EimsConfig } from "../../config/eims.config";
|
||||||
|
import { Invoice } from "../billing/entities/invoice.entity";
|
||||||
|
import { NotificationsService } from "../notifications/notifications.service";
|
||||||
|
import { sendCompanyChannels } from "../notifications/notify-company.util";
|
||||||
|
import { EimsAuthService } from "./eims-auth.service";
|
||||||
|
import { EimsClientService } from "./eims-client.service";
|
||||||
|
import { EimsApiException } from "./eims.errors";
|
||||||
|
import { EimsReceipt, EimsReceiptKind, EimsReceiptStatus } from "./entities/eims-receipt.entity";
|
||||||
|
import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto";
|
||||||
|
import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto";
|
||||||
|
import {
|
||||||
|
EimsReceiptResponse,
|
||||||
|
EimsSalesReceiptRequest,
|
||||||
|
EimsWithholdReceiptRequest,
|
||||||
|
} from "./eims-receipt.types";
|
||||||
|
import { EimsInvoiceError } from "./eims-registration.types";
|
||||||
|
|
||||||
|
/** `ReceiptCurrency` / withholding `InvoiceDetail.Currency` — confirmed by a live rule error. */
|
||||||
|
const EIMS_RECEIPT_CURRENCIES = ["ETB", "USD", "CAD"] as const;
|
||||||
|
|
||||||
|
/** Failure kinds where MoR gave a complete answer — the receipt definitively did not register. */
|
||||||
|
const DETERMINISTIC_KINDS = new Set(["SCHEMA_VALIDATION", "RULE_VALIDATION", "AUTH", "FORBIDDEN"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `POST /v1/receipt/sales` and `POST /v1/receipt/withholding`, both against an already-registered
|
||||||
|
* invoice.
|
||||||
|
*
|
||||||
|
* No counter/reservation machinery: the collection shows no MoR-enforced ordering on
|
||||||
|
* `ReceiptCounter` the way `InvoiceCounter` has a documented "expected: N" rule, so — unlike
|
||||||
|
* registration — there is no shared sequence to protect. Each attempt is its own `EimsReceipt` row:
|
||||||
|
* created before the HTTP call (so a crash mid-flight leaves an UNKNOWN row instead of nothing),
|
||||||
|
* settled after it. An ambiguous outcome is never auto-retried — receipts have no evidenced
|
||||||
|
* double-submission guard the way `/v1/cancel` does ("IRN already Canceled."), so the same caution
|
||||||
|
* applies as an unacknowledged registration: a human must check the MoR portal first.
|
||||||
|
*
|
||||||
|
* Several request fields have no confirmed source in this codebase (payment method, collector,
|
||||||
|
* withholding rate/amount) and are never guessed — see the two DTOs.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class EimsReceiptService {
|
||||||
|
private readonly logger = new Logger(EimsReceiptService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectDataSource() private readonly dataSource: DataSource,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
private readonly client: EimsClientService,
|
||||||
|
private readonly auth: EimsAuthService,
|
||||||
|
private readonly notifications: NotificationsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private get cfg(): EimsConfig {
|
||||||
|
return this.config.get<EimsConfig>("eims")!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async registerSalesReceipt(invoiceId: string, dto: RegisterSalesReceiptDto): Promise<EimsReceipt> {
|
||||||
|
const invoice = await this.loadRegisteredInvoice(invoiceId);
|
||||||
|
const currency = dto.currency ?? invoice.currency;
|
||||||
|
this.assertReceiptCurrency(currency);
|
||||||
|
|
||||||
|
const session = await this.auth.getSessionContext();
|
||||||
|
const receiptNumber = this.generateReceiptNumber(invoice);
|
||||||
|
const collectedAmount = dto.collectedAmount ?? Number(invoice.paidAmount);
|
||||||
|
const balance = Number(invoice.balanceAmount);
|
||||||
|
|
||||||
|
const request: EimsSalesReceiptRequest = {
|
||||||
|
ReceiptNumber: receiptNumber,
|
||||||
|
ReceiptType: "Sales Receipts",
|
||||||
|
Reason: dto.reason ?? "Payment received",
|
||||||
|
// ISO-8601 UTC — the collection's saved example uses a "+03:00" offset instead; no schema
|
||||||
|
// error for this field was ever observed to confirm which form MoR actually requires.
|
||||||
|
ReceiptDate: new Date().toISOString(),
|
||||||
|
ReceiptCounter: String(Date.now()),
|
||||||
|
ManualReceiptNumber: receiptNumber,
|
||||||
|
SourceSystemType: session.systemType,
|
||||||
|
SourceSystemNumber: session.systemNumber,
|
||||||
|
ReceiptCurrency: currency,
|
||||||
|
ExchangeRate: dto.exchangeRate ?? null,
|
||||||
|
CollectedAmount: collectedAmount,
|
||||||
|
SellerTIN: this.cfg.tin,
|
||||||
|
Invoices: [
|
||||||
|
{
|
||||||
|
InvoiceIRN: invoice.eimsIrn!,
|
||||||
|
PaymentCoverage: dto.paymentCoverage ?? (balance <= 0 ? "FULL" : "PARTIAL"),
|
||||||
|
InvoicePaidAmount: collectedAmount,
|
||||||
|
DiscountAmount: null,
|
||||||
|
RemainingAmount: balance,
|
||||||
|
TotalAmount: Number(invoice.totalAmount),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
TransactionDetails: {
|
||||||
|
ModeOfPayment: dto.modeOfPayment,
|
||||||
|
ChequeNumber: dto.chequeNumber ?? null,
|
||||||
|
CPONumber: dto.cpoNumber ?? null,
|
||||||
|
DocumentNumber: dto.documentNumber ?? null,
|
||||||
|
CollectorName: dto.collectorName ?? null,
|
||||||
|
PaymentServiceProvider: dto.paymentServiceProvider ?? null,
|
||||||
|
OtherPaymentServiceProviderName: dto.otherPaymentServiceProviderName ?? null,
|
||||||
|
AccountNumber: dto.accountNumber ?? null,
|
||||||
|
TransactionNumber: dto.transactionNumber ?? null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.submit(invoice, "SALES", receiptNumber, request, "/v1/receipt/sales");
|
||||||
|
}
|
||||||
|
|
||||||
|
async registerWithholdingReceipt(
|
||||||
|
invoiceId: string,
|
||||||
|
dto: RegisterWithholdingReceiptDto,
|
||||||
|
): Promise<EimsReceipt> {
|
||||||
|
const invoice = await this.loadRegisteredInvoice(invoiceId);
|
||||||
|
if (invoice.currency !== "ETB" && dto.exchangeRate == null) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: "EIMS_EXCHANGE_RATE_REQUIRED",
|
||||||
|
message: `Invoice ${invoice.invoiceNumber} is in ${invoice.currency} and needs an exchangeRate`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await this.auth.getSessionContext();
|
||||||
|
const receiptNumber = this.generateReceiptNumber(invoice);
|
||||||
|
|
||||||
|
const request: EimsWithholdReceiptRequest = {
|
||||||
|
ReceiptNumber: receiptNumber,
|
||||||
|
Reason: dto.reason ?? "Withholding",
|
||||||
|
ReceiptCounter: String(Date.now()),
|
||||||
|
ManualReceiptNumber: receiptNumber,
|
||||||
|
SourceSystemType: session.systemType,
|
||||||
|
SourceSystemNumber: session.systemNumber,
|
||||||
|
InvoiceDetail: {
|
||||||
|
InvoiceIRN: invoice.eimsIrn!,
|
||||||
|
Currency: invoice.currency,
|
||||||
|
ExchangeRate: dto.exchangeRate ?? null,
|
||||||
|
},
|
||||||
|
WithholdDetail: {
|
||||||
|
Type: dto.type,
|
||||||
|
Rate: dto.rate ?? null,
|
||||||
|
PreTaxAmount: dto.preTaxAmount,
|
||||||
|
WithholdingAmount: dto.withholdingAmount,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.submit(invoice, "WITHHOLDING", receiptNumber, request, "/v1/receipt/withholding");
|
||||||
|
}
|
||||||
|
|
||||||
|
async listReceipts(invoiceId: string): Promise<EimsReceipt[]> {
|
||||||
|
return this.dataSource.manager.find(EimsReceipt, {
|
||||||
|
where: { invoiceId },
|
||||||
|
order: { createdAt: "DESC" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── internals ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async submit(
|
||||||
|
invoice: Invoice,
|
||||||
|
kind: EimsReceiptKind,
|
||||||
|
receiptNumber: string,
|
||||||
|
request: EimsSalesReceiptRequest | EimsWithholdReceiptRequest,
|
||||||
|
path: "/v1/receipt/sales" | "/v1/receipt/withholding",
|
||||||
|
): Promise<EimsReceipt> {
|
||||||
|
// Committed before the HTTP call — a crash mid-flight leaves an UNKNOWN row, not nothing.
|
||||||
|
const receipt = await this.dataSource.manager.save(EimsReceipt, {
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
kind,
|
||||||
|
status: EimsReceiptStatus.Submitting,
|
||||||
|
receiptNumber,
|
||||||
|
submittedAt: new Date(),
|
||||||
|
request: request as unknown as Record<string, unknown>,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.client.postBearer<
|
||||||
|
EimsSalesReceiptRequest | EimsWithholdReceiptRequest,
|
||||||
|
EimsReceiptResponse
|
||||||
|
>(path, request);
|
||||||
|
const rrn = response?.body?.rrn;
|
||||||
|
if (!rrn) {
|
||||||
|
throw new EimsApiException(
|
||||||
|
"SCHEMA_VALIDATION",
|
||||||
|
"EIMS receipt registration returned no rrn",
|
||||||
|
response?.statusCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.dataSource.manager.update(EimsReceipt, receipt.id, {
|
||||||
|
status: EimsReceiptStatus.Registered,
|
||||||
|
rrn,
|
||||||
|
qr: response.body?.qr ?? null,
|
||||||
|
ackStatus: response.body?.status ?? null,
|
||||||
|
});
|
||||||
|
this.logger.log(`${kind} receipt ${receiptNumber} registered for invoice ${invoice.invoiceNumber} (RRN ${rrn})`);
|
||||||
|
await this.notifyBuyer(invoice, kind, receiptNumber);
|
||||||
|
} catch (err) {
|
||||||
|
const api = err instanceof EimsApiException ? err : null;
|
||||||
|
const deterministic = api ? DETERMINISTIC_KINDS.has(api.kind) : false;
|
||||||
|
const lastError: EimsInvoiceError = {
|
||||||
|
kind: api?.kind ?? "UNKNOWN",
|
||||||
|
message: (err as Error)?.message ?? "unknown error",
|
||||||
|
httpStatus: api?.httpStatus,
|
||||||
|
details: api?.details,
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await this.dataSource.manager.update(EimsReceipt, receipt.id, {
|
||||||
|
status: deterministic ? EimsReceiptStatus.Failed : EimsReceiptStatus.Unknown,
|
||||||
|
lastError,
|
||||||
|
} as QueryDeepPartialEntity<EimsReceipt>);
|
||||||
|
this.logger.error(
|
||||||
|
`${kind} receipt ${receiptNumber} for invoice ${invoice.invoiceNumber} ${deterministic ? "FAILED" : "UNKNOWN"}: ${lastError.message}`,
|
||||||
|
);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const saved = await this.dataSource.manager.findOne(EimsReceipt, { where: { id: receipt.id } });
|
||||||
|
return saved!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async notifyBuyer(invoice: Invoice, kind: EimsReceiptKind, receiptNumber: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await sendCompanyChannels(
|
||||||
|
this.dataSource,
|
||||||
|
this.notifications,
|
||||||
|
invoice.companyId,
|
||||||
|
`A ${kind === "SALES" ? "sales" : "withholding"} receipt (${receiptNumber}) has been registered ` +
|
||||||
|
`with MoR EIMS for invoice ${invoice.invoiceNumber}.`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`EIMS receipt buyer notification failed for invoice ${invoice.id}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadRegisteredInvoice(invoiceId: string): Promise<Invoice> {
|
||||||
|
const invoice = await this.dataSource.manager.findOne(Invoice, { where: { id: invoiceId } });
|
||||||
|
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
||||||
|
if (!invoice.eimsIrn) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: "EIMS_NOT_REGISTERED",
|
||||||
|
message: `Invoice ${invoice.invoiceNumber} was never registered with EIMS — no IRN to file a receipt against.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return invoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertReceiptCurrency(currency: string): void {
|
||||||
|
if (!EIMS_RECEIPT_CURRENCIES.includes(currency as (typeof EIMS_RECEIPT_CURRENCIES)[number])) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: "EIMS_RECEIPT_CURRENCY_INVALID",
|
||||||
|
message: `EIMS receipt currency must be one of ${EIMS_RECEIPT_CURRENCIES.join(", ")}, got "${currency}"`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateReceiptNumber(invoice: Invoice): string {
|
||||||
|
return `REC-${invoice.invoiceNumber}-${Date.now()}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
97
apps/edr-freight-api/src/modules/eims/eims-receipt.types.ts
Normal file
97
apps/edr-freight-api/src/modules/eims/eims-receipt.types.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* Wire types for `POST /v1/receipt/sales` and `POST /v1/receipt/withholding`, taken verbatim from
|
||||||
|
* the Postman collection's saved requests/responses. Bearer-authenticated but unsigned — no
|
||||||
|
* `{request,signature,certificate}` envelope in the collection's saved bodies, same as `/v1/verify`
|
||||||
|
* and `/v1/cancel`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** One entry of `Invoices[]` on a sales receipt. */
|
||||||
|
export interface EimsSalesReceiptInvoice {
|
||||||
|
InvoiceIRN: string;
|
||||||
|
PaymentCoverage: string;
|
||||||
|
InvoicePaidAmount: number;
|
||||||
|
DiscountAmount: number | null;
|
||||||
|
RemainingAmount: number | null;
|
||||||
|
TotalAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `ModeOfPayment` enum confirmed by a live schema error: exactly these eight values, verbatim
|
||||||
|
* spelling and casing (including the two multi-word ones).
|
||||||
|
*/
|
||||||
|
export const EIMS_MODE_OF_PAYMENT = [
|
||||||
|
"CASH",
|
||||||
|
"CHEQUE",
|
||||||
|
"CPO",
|
||||||
|
"Local Bank Transfer",
|
||||||
|
"SWIFT",
|
||||||
|
"Wire Transfer",
|
||||||
|
"Letter of Credit",
|
||||||
|
"Card",
|
||||||
|
] as const;
|
||||||
|
export type EimsModeOfPayment = (typeof EIMS_MODE_OF_PAYMENT)[number];
|
||||||
|
|
||||||
|
export interface EimsSalesReceiptTransactionDetails {
|
||||||
|
ModeOfPayment: EimsModeOfPayment;
|
||||||
|
ChequeNumber: string | null;
|
||||||
|
CPONumber: string | null;
|
||||||
|
DocumentNumber: string | null;
|
||||||
|
CollectorName: string | null;
|
||||||
|
PaymentServiceProvider: string | null;
|
||||||
|
OtherPaymentServiceProviderName: string | null;
|
||||||
|
AccountNumber: string | null;
|
||||||
|
TransactionNumber: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EimsSalesReceiptRequest {
|
||||||
|
ReceiptNumber: string;
|
||||||
|
ReceiptType: string;
|
||||||
|
Reason: string;
|
||||||
|
ReceiptDate: string;
|
||||||
|
ReceiptCounter: string;
|
||||||
|
ManualReceiptNumber: string;
|
||||||
|
SourceSystemType: string;
|
||||||
|
SourceSystemNumber: string;
|
||||||
|
/** Confirmed enum via a live rule error: "Currency should be ETB, USD, CAD". */
|
||||||
|
ReceiptCurrency: string;
|
||||||
|
ExchangeRate: number | null;
|
||||||
|
CollectedAmount: number;
|
||||||
|
SellerTIN: string;
|
||||||
|
Invoices: EimsSalesReceiptInvoice[];
|
||||||
|
TransactionDetails: EimsSalesReceiptTransactionDetails;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EimsWithholdReceiptRequest {
|
||||||
|
ReceiptNumber: string;
|
||||||
|
Reason: string;
|
||||||
|
ReceiptCounter: string;
|
||||||
|
ManualReceiptNumber: string;
|
||||||
|
SourceSystemType: string;
|
||||||
|
SourceSystemNumber: string;
|
||||||
|
InvoiceDetail: {
|
||||||
|
InvoiceIRN: string;
|
||||||
|
Currency: string;
|
||||||
|
ExchangeRate: number | null;
|
||||||
|
};
|
||||||
|
WithholdDetail: {
|
||||||
|
/** Only "TWHT" has ever been observed; not restricted to it, since no fuller enum is confirmed. */
|
||||||
|
Type: string;
|
||||||
|
Rate: number | null;
|
||||||
|
PreTaxAmount: number;
|
||||||
|
WithholdingAmount: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Success body — identical shape for both receipt endpoints. */
|
||||||
|
export interface EimsReceiptResponseBody {
|
||||||
|
status: string;
|
||||||
|
rrn: string;
|
||||||
|
/** Base64 PNG, same convention as the register response's `signedQR`. */
|
||||||
|
qr: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EimsReceiptResponse {
|
||||||
|
statusCode?: number;
|
||||||
|
message?: string;
|
||||||
|
body?: EimsReceiptResponseBody;
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ export enum EimsInvoiceStatus {
|
|||||||
Registered = "REGISTERED",
|
Registered = "REGISTERED",
|
||||||
Failed = "FAILED",
|
Failed = "FAILED",
|
||||||
Unknown = "UNKNOWN",
|
Unknown = "UNKNOWN",
|
||||||
|
/** Successfully cancelled at MoR via `POST /v1/cancel`. Terminal — never re-registered. */
|
||||||
|
Cancelled = "CANCELLED",
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `body` of a successful `POST /v1/register`, as observed in the collection. */
|
/** `body` of a successful `POST /v1/register`, as observed in the collection. */
|
||||||
@@ -65,6 +67,28 @@ export interface EimsVerifyResponse {
|
|||||||
body?: EimsVerifyResponseBody;
|
body?: EimsVerifyResponseBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `POST /v1/cancel` — bearer-authenticated but unsigned, same shape as `/v1/verify` (no
|
||||||
|
* `{request,signature,certificate}` envelope in the collection's saved request).
|
||||||
|
*/
|
||||||
|
export interface EimsCancelRequest {
|
||||||
|
Irn: string;
|
||||||
|
/** Numeric code as a string, e.g. "1" (Duplicate), "6" (Calculation Error) per the collection docs. */
|
||||||
|
ReasonCode: string;
|
||||||
|
Remark: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Success body: `{ cancellationDate: "Sun Dec 22 21:55:03 EAT 2024" }` — a Java Date#toString(), not ISO. Stored verbatim, like `eimsAckDate`. */
|
||||||
|
export interface EimsCancelResponseBody {
|
||||||
|
cancellationDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EimsCancelResponse {
|
||||||
|
statusCode?: number;
|
||||||
|
message?: string;
|
||||||
|
body?: EimsCancelResponseBody;
|
||||||
|
}
|
||||||
|
|
||||||
/** Persisted failure detail. Carries the gateway's own error fields only — never our envelope. */
|
/** Persisted failure detail. Carries the gateway's own error fields only — never our envelope. */
|
||||||
export interface EimsInvoiceError {
|
export interface EimsInvoiceError {
|
||||||
kind: string;
|
kind: string;
|
||||||
@@ -86,4 +110,10 @@ export interface EimsInvoiceStatusView {
|
|||||||
eimsSubmittedAt: Date | null;
|
eimsSubmittedAt: Date | null;
|
||||||
eimsAckDate: string | null;
|
eimsAckDate: string | null;
|
||||||
eimsLastError: EimsInvoiceError | null;
|
eimsLastError: EimsInvoiceError | null;
|
||||||
|
/** Raw base64 PNG from MoR, already rendered on their side — see `Invoice.eimsSignedQr`. */
|
||||||
|
eimsSignedQr: string | null;
|
||||||
|
eimsCancelledAt: Date | null;
|
||||||
|
eimsCancellationDate: string | null;
|
||||||
|
eimsCancellationReasonCode: string | null;
|
||||||
|
eimsCancellationRemark: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,14 @@ import { EimsSignedRequest } from "./eims.types";
|
|||||||
*
|
*
|
||||||
* 1. compact `JSON.stringify` of the **inner** request object only,
|
* 1. compact `JSON.stringify` of the **inner** request object only,
|
||||||
* 2. those exact UTF-8 bytes,
|
* 2. those exact UTF-8 bytes,
|
||||||
* 3. RSA + SHA-512 (`SHA512withRSA`, PKCS#1 v1.5 — Node's default RSA padding),
|
* 3. RSA + SHA-512 (`SHA512withRSA`, PKCS#1 v1.5 — Node's default RSA padding). Confirmed, not
|
||||||
|
* assumed: MoR's own "Guide to Generating and Using Certificate for E-Invoicing" names
|
||||||
|
* `SHA512withRSA` explicitly, which is PKCS#1v1.5 in Java (PSS would be named
|
||||||
|
* `SHA512withRSAandMGF1`) — the same padding `createSign("RSA-SHA512")` uses by default.
|
||||||
* 4. base64 of the raw signature bytes (256 bytes for an RSA-2048 key),
|
* 4. base64 of the raw signature bytes (256 bytes for an RSA-2048 key),
|
||||||
* 5. base64 of the certificate file's exact bytes.
|
* 5. base64 of the certificate file's exact bytes. Also confirmed by the same guide: its own
|
||||||
|
* worked example certificate is the identical `Subject:`/`Issuer:` header + 3-cert PEM chain
|
||||||
|
* text-file format ours is, base64'd with no re-encoding.
|
||||||
*
|
*
|
||||||
* The outer `{request, signature, certificate}` envelope is never itself signed, and the request
|
* The outer `{request, signature, certificate}` envelope is never itself signed, and the request
|
||||||
* object is never mutated after serialization.
|
* object is never mutated after serialization.
|
||||||
|
|||||||
@@ -35,8 +35,14 @@ export const eimsInvoiceConfig = (over: Partial<EimsInvoiceConfig> = {}): EimsIn
|
|||||||
buyerCountryCode: null,
|
buyerCountryCode: null,
|
||||||
buyerRegionCodes: { "Addis Ababa": "13" },
|
buyerRegionCodes: { "Addis Ababa": "13" },
|
||||||
buyerWeredaCodes: { Yeka: "99" }, // test-only, not a real MoR code
|
buyerWeredaCodes: { Yeka: "99" }, // test-only, not a real MoR code
|
||||||
|
taxCodeByChargeType: {},
|
||||||
|
taxRateByChargeType: {},
|
||||||
|
exciseByChargeType: {},
|
||||||
|
discountByChargeType: {},
|
||||||
cashierName: null,
|
cashierName: null,
|
||||||
salesPersonName: null,
|
salesPersonName: null,
|
||||||
|
buyerIdType: null,
|
||||||
|
buyerIdNumber: null,
|
||||||
...over,
|
...over,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,13 +4,17 @@ import { TypeOrmModule } from "@nestjs/typeorm";
|
|||||||
|
|
||||||
import { Invoice } from "../billing/entities/invoice.entity";
|
import { Invoice } from "../billing/entities/invoice.entity";
|
||||||
import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
|
import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
|
||||||
|
import { NotificationsModule } from "../notifications/notifications.module";
|
||||||
import { EimsAuthService } from "./eims-auth.service";
|
import { EimsAuthService } from "./eims-auth.service";
|
||||||
import { EimsAutoSubmitService } from "./eims-auto-submit.service";
|
import { EimsAutoSubmitService } from "./eims-auto-submit.service";
|
||||||
|
import { EimsCancellationService } from "./eims-cancellation.service";
|
||||||
import { EimsClientService } from "./eims-client.service";
|
import { EimsClientService } from "./eims-client.service";
|
||||||
import { EimsCredentialsProvider } from "./eims-credentials.provider";
|
import { EimsCredentialsProvider } from "./eims-credentials.provider";
|
||||||
import { EimsInvoiceController } from "./eims-invoice.controller";
|
import { EimsInvoiceController } from "./eims-invoice.controller";
|
||||||
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
|
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
|
||||||
|
import { EimsReceiptService } from "./eims-receipt.service";
|
||||||
import { EimsSignerService } from "./eims-signer.service";
|
import { EimsSignerService } from "./eims-signer.service";
|
||||||
|
import { EimsReceipt } from "./entities/eims-receipt.entity";
|
||||||
import { EimsSystemState } from "./entities/eims-system-state.entity";
|
import { EimsSystemState } from "./entities/eims-system-state.entity";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,8 +26,9 @@ import { EimsSystemState } from "./entities/eims-system-state.entity";
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
HttpModule.register({ timeout: Number(process.env.EIMS_HTTP_TIMEOUT_MS) || 30_000 }),
|
HttpModule.register({ timeout: Number(process.env.EIMS_HTTP_TIMEOUT_MS) || 30_000 }),
|
||||||
TypeOrmModule.forFeature([EimsSystemState, Invoice]),
|
TypeOrmModule.forFeature([EimsSystemState, Invoice, EimsReceipt]),
|
||||||
NotificationInboxModule,
|
NotificationInboxModule,
|
||||||
|
NotificationsModule,
|
||||||
],
|
],
|
||||||
controllers: [EimsInvoiceController],
|
controllers: [EimsInvoiceController],
|
||||||
providers: [
|
providers: [
|
||||||
@@ -33,7 +38,15 @@ import { EimsSystemState } from "./entities/eims-system-state.entity";
|
|||||||
EimsClientService,
|
EimsClientService,
|
||||||
EimsInvoiceRegistrationService,
|
EimsInvoiceRegistrationService,
|
||||||
EimsAutoSubmitService,
|
EimsAutoSubmitService,
|
||||||
|
EimsCancellationService,
|
||||||
|
EimsReceiptService,
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
EimsAuthService,
|
||||||
|
EimsClientService,
|
||||||
|
EimsInvoiceRegistrationService,
|
||||||
|
EimsCancellationService,
|
||||||
|
EimsReceiptService,
|
||||||
],
|
],
|
||||||
exports: [EimsAuthService, EimsClientService, EimsInvoiceRegistrationService],
|
|
||||||
})
|
})
|
||||||
export class EimsModule {}
|
export class EimsModule {}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { BaseEntity } from "@edr/api-common";
|
||||||
|
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||||
|
|
||||||
|
import { Invoice } from "../../billing/entities/invoice.entity";
|
||||||
|
import { EimsInvoiceError } from "../eims-registration.types";
|
||||||
|
|
||||||
|
export type EimsReceiptKind = "SALES" | "WITHHOLDING";
|
||||||
|
|
||||||
|
/** Same shape as `EimsInvoiceStatus`, for a receipt instead of an invoice. */
|
||||||
|
export enum EimsReceiptStatus {
|
||||||
|
NotSubmitted = "NOT_SUBMITTED",
|
||||||
|
Submitting = "SUBMITTING",
|
||||||
|
Registered = "REGISTERED",
|
||||||
|
Failed = "FAILED",
|
||||||
|
Unknown = "UNKNOWN",
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One `POST /v1/receipt/sales` or `POST /v1/receipt/withholding` attempt, linked to the invoice it
|
||||||
|
* was filed against. An invoice can have more than one receipt (partial payments), so this is its
|
||||||
|
* own table rather than columns on `Invoice`.
|
||||||
|
*
|
||||||
|
* No counter/sequence reservation the way invoice registration has: the collection's `/v1/receipt/*`
|
||||||
|
* examples show no MoR-enforced ordering rule on `ReceiptCounter` (unlike `InvoiceCounter`'s
|
||||||
|
* documented "expected: N"), so this is a plain row per attempt.
|
||||||
|
*/
|
||||||
|
@Entity({ schema: "freight", name: "eims_receipts" })
|
||||||
|
@Index(["invoiceId"])
|
||||||
|
export class EimsReceipt extends BaseEntity {
|
||||||
|
@Column({ name: "invoice_id", type: "uuid" })
|
||||||
|
invoiceId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Invoice)
|
||||||
|
@JoinColumn({ name: "invoice_id" })
|
||||||
|
invoice?: Invoice;
|
||||||
|
|
||||||
|
@Column({ name: "kind", type: "varchar", length: 16 })
|
||||||
|
kind!: EimsReceiptKind;
|
||||||
|
|
||||||
|
@Column({ name: "status", type: "varchar", length: 20, default: "NOT_SUBMITTED" })
|
||||||
|
status!: EimsReceiptStatus;
|
||||||
|
|
||||||
|
/** Our own generated `ReceiptNumber` / `ManualReceiptNumber` (both sent equal — see the service). */
|
||||||
|
@Column({ name: "receipt_number", type: "varchar", length: 64 })
|
||||||
|
receiptNumber!: string;
|
||||||
|
|
||||||
|
/** MoR's Receipt Reference Number, returned only on success. */
|
||||||
|
@Column({ name: "rrn", type: "text", nullable: true })
|
||||||
|
rrn?: string | null;
|
||||||
|
|
||||||
|
/** Base64 PNG from MoR, same convention as `Invoice.eimsSignedQr` — embed directly. */
|
||||||
|
@Column({ name: "qr", type: "text", nullable: true })
|
||||||
|
qr?: string | null;
|
||||||
|
|
||||||
|
/** MoR's own `"A"` (active) / `"F"` (failed) status marker, stored verbatim. */
|
||||||
|
@Column({ name: "ack_status", type: "varchar", length: 8, nullable: true })
|
||||||
|
ackStatus?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: "submitted_at", type: "timestamptz", nullable: true })
|
||||||
|
submittedAt?: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: "last_error", type: "jsonb", nullable: true })
|
||||||
|
lastError?: EimsInvoiceError | null;
|
||||||
|
|
||||||
|
/** The exact request body sent — receipts are bearer-only/unsigned, so nothing secret is in it. */
|
||||||
|
@Column({ name: "request", type: "jsonb", nullable: true })
|
||||||
|
request?: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
@@ -30,8 +30,11 @@ export class EimsSystemState extends BaseEntity {
|
|||||||
@Column({ name: "in_flight_document_number", type: "bigint", nullable: true })
|
@Column({ name: "in_flight_document_number", type: "bigint", nullable: true })
|
||||||
inFlightDocumentNumber?: number | null;
|
inFlightDocumentNumber?: number | null;
|
||||||
|
|
||||||
/** IRN of the last successful registration; null until the first one succeeds. */
|
/**
|
||||||
@Column({ name: "previous_irn", type: "varchar", length: 64, nullable: true })
|
* IRN of the last successful registration; null until the first one succeeds. `text`, not a
|
||||||
|
* fixed varchar — same reasoning as `Invoice.eimsIrn`: a real IRN already overflowed varchar(64).
|
||||||
|
*/
|
||||||
|
@Column({ name: "previous_irn", type: "text", nullable: true })
|
||||||
previousIrn?: string | null;
|
previousIrn?: string | null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { NotificationsGateway } from "./notifications.gateway";
|
||||||
|
import { WsAuthService } from "./ws-auth.service";
|
||||||
|
|
||||||
|
const gateway = () => new NotificationsGateway({} as WsAuthService);
|
||||||
|
|
||||||
|
describe("NotificationsGateway", () => {
|
||||||
|
it("skips emitNew rather than throwing when no WebSocket server is attached", () => {
|
||||||
|
const g = gateway();
|
||||||
|
expect(() => g.emitNew("user-1", { id: "n-1" } as never, 3)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips emitUnreadCount rather than throwing when no WebSocket server is attached", () => {
|
||||||
|
const g = gateway();
|
||||||
|
expect(() => g.emitUnreadCount("user-1", 3)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pushes to the user's room once a server is attached", () => {
|
||||||
|
const g = gateway();
|
||||||
|
const emit = jest.fn();
|
||||||
|
const to = jest.fn().mockReturnValue({ emit });
|
||||||
|
(g as unknown as { server: { to: typeof to } }).server = { to };
|
||||||
|
|
||||||
|
g.emitNew("user-1", { id: "n-1" } as never, 3);
|
||||||
|
|
||||||
|
expect(to).toHaveBeenCalledWith("user:user-1");
|
||||||
|
expect(emit).toHaveBeenCalledWith("notification:new", { id: "n-1" });
|
||||||
|
expect(emit).toHaveBeenCalledWith("notification:unread-count", 3);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -27,8 +27,10 @@ import { WsAuthService } from "./ws-auth.service";
|
|||||||
export class NotificationsGateway implements OnGatewayConnection {
|
export class NotificationsGateway implements OnGatewayConnection {
|
||||||
private readonly logger = new Logger(NotificationsGateway.name);
|
private readonly logger = new Logger(NotificationsGateway.name);
|
||||||
|
|
||||||
|
// Not `!`-asserted: Nest only wires this once the WS adapter attaches to a running HTTP
|
||||||
|
// listener, which does not happen under `NestFactory.createApplicationContext` — see `skip()`.
|
||||||
@WebSocketServer()
|
@WebSocketServer()
|
||||||
private readonly server!: Server;
|
private readonly server?: Server;
|
||||||
|
|
||||||
constructor(private readonly wsAuth: WsAuthService) {}
|
constructor(private readonly wsAuth: WsAuthService) {}
|
||||||
|
|
||||||
@@ -45,6 +47,7 @@ export class NotificationsGateway implements OnGatewayConnection {
|
|||||||
|
|
||||||
/** Push a freshly-created notification + the new unread count to a user. */
|
/** Push a freshly-created notification + the new unread count to a user. */
|
||||||
emitNew(userId: string, notification: NotificationDto, unreadCount: number): void {
|
emitNew(userId: string, notification: NotificationDto, unreadCount: number): void {
|
||||||
|
if (!this.server) return this.skip("emitNew");
|
||||||
const room = this.server.to(this.room(userId));
|
const room = this.server.to(this.room(userId));
|
||||||
room.emit(NOTIFICATION_WS_EVENTS.NEW, notification);
|
room.emit(NOTIFICATION_WS_EVENTS.NEW, notification);
|
||||||
room.emit(NOTIFICATION_WS_EVENTS.UNREAD_COUNT, unreadCount);
|
room.emit(NOTIFICATION_WS_EVENTS.UNREAD_COUNT, unreadCount);
|
||||||
@@ -52,11 +55,24 @@ export class NotificationsGateway implements OnGatewayConnection {
|
|||||||
|
|
||||||
/** Push only an updated unread count (e.g. after a read on another tab). */
|
/** Push only an updated unread count (e.g. after a read on another tab). */
|
||||||
emitUnreadCount(userId: string, unreadCount: number): void {
|
emitUnreadCount(userId: string, unreadCount: number): void {
|
||||||
|
if (!this.server) return this.skip("emitUnreadCount");
|
||||||
this.server
|
this.server
|
||||||
.to(this.room(userId))
|
.to(this.room(userId))
|
||||||
.emit(NOTIFICATION_WS_EVENTS.UNREAD_COUNT, unreadCount);
|
.emit(NOTIFICATION_WS_EVENTS.UNREAD_COUNT, unreadCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `@WebSocketServer()` only wires `server` once the WS adapter attaches to a running HTTP
|
||||||
|
* listener — never under `NestFactory.createApplicationContext` (scripts, one-off jobs), and not
|
||||||
|
* for the brief window before `app.listen()` completes in a real boot either. The notification row
|
||||||
|
* is already persisted by this point (the caller writes it before pushing), so a missing socket
|
||||||
|
* server just means "no live push this time" — skip it rather than throw and lose the caller's
|
||||||
|
* own result (e.g. an EIMS registration outcome that already succeeded or failed for real).
|
||||||
|
*/
|
||||||
|
private skip(method: string): void {
|
||||||
|
this.logger.debug(`${method}: no WebSocket server attached (non-HTTP context?) — push skipped`);
|
||||||
|
}
|
||||||
|
|
||||||
private room(userId: string): string {
|
private room(userId: string): string {
|
||||||
return `user:${userId}`;
|
return `user:${userId}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// otp.service.ts
|
// otp.service.ts
|
||||||
|
|
||||||
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
|
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
|
||||||
|
import { logCtx } from "@edr/api-common";
|
||||||
import { randomInt } from "node:crypto";
|
import { randomInt } from "node:crypto";
|
||||||
|
|
||||||
import { OtpRepository } from "./otp.repository";
|
import { OtpRepository } from "./otp.repository";
|
||||||
@@ -317,6 +318,13 @@ export class OtpService {
|
|||||||
}`;
|
}`;
|
||||||
if (result === "ok") this.logger.log(line);
|
if (result === "ok") this.logger.log(line);
|
||||||
else this.logger.warn(line);
|
else this.logger.warn(line);
|
||||||
|
|
||||||
|
// Outcome only — the target is a phone number / email address and stays out
|
||||||
|
// of the canonical line. Channels are safe and say which one was used.
|
||||||
|
logCtx(
|
||||||
|
{ mode, result, channels: channelsOf(target) },
|
||||||
|
{ path: "otp.verify", mode: "push" },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
import { HttpService } from "@nestjs/axios";
|
import { HttpService } from "@nestjs/axios";
|
||||||
import { AxiosError } from "axios";
|
import { AxiosError } from "axios";
|
||||||
import { firstValueFrom } from "rxjs";
|
import { firstValueFrom } from "rxjs";
|
||||||
|
import { logCtx } from "@edr/api-common";
|
||||||
import {
|
import {
|
||||||
InitiatePaymentRequest,
|
InitiatePaymentRequest,
|
||||||
PaymentIntentSnapshot,
|
PaymentIntentSnapshot,
|
||||||
@@ -111,6 +112,16 @@ export class PaymentClientService {
|
|||||||
body?: unknown,
|
body?: unknown,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const url = `${this.baseUrl}${path}`;
|
const url = `${this.baseUrl}${path}`;
|
||||||
|
// Every hop to the payment service lands on the request log line: which
|
||||||
|
// call, how slow, and what it answered. A settle that never happened is
|
||||||
|
// almost always one of these coming back 4xx/5xx or timing out.
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const trace = (extra: Record<string, unknown>) =>
|
||||||
|
logCtx(
|
||||||
|
{ method, path, ms: Date.now() - startedAt, ...extra },
|
||||||
|
{ path: "outbound.payment", mode: "push" },
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await firstValueFrom(
|
const response = await firstValueFrom(
|
||||||
this.http.request<T>({
|
this.http.request<T>({
|
||||||
@@ -122,9 +133,11 @@ export class PaymentClientService {
|
|||||||
: {},
|
: {},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
trace({ status: response.status });
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof AxiosError && err.response) {
|
if (err instanceof AxiosError && err.response) {
|
||||||
|
trace({ status: err.response.status });
|
||||||
if (err.response.status === 404) throw err;
|
if (err.response.status === 404) throw err;
|
||||||
const detail =
|
const detail =
|
||||||
(err.response.data as { message?: string | string[] })?.message ??
|
(err.response.data as { message?: string | string[] })?.message ??
|
||||||
@@ -136,6 +149,7 @@ export class PaymentClientService {
|
|||||||
}
|
}
|
||||||
const message =
|
const message =
|
||||||
err instanceof Error && err.message ? err.message : String(err);
|
err instanceof Error && err.message ? err.message : String(err);
|
||||||
|
trace({ unreachable: true, error: message });
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`payment service unreachable (${method} ${path}): ${message}`,
|
`payment service unreachable (${method} ${path}): ${message}`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Logger,
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
|
import { logCtx } from "@edr/api-common";
|
||||||
import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util";
|
import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util";
|
||||||
import { PaymentEntity } from "./entities/payment.entity";
|
import { PaymentEntity } from "./entities/payment.entity";
|
||||||
import { PaymentRepository } from "./payment.repository";
|
import { PaymentRepository } from "./payment.repository";
|
||||||
@@ -214,8 +215,16 @@ export class PaymentService {
|
|||||||
PaymentReferenceType.SHIPMENT,
|
PaymentReferenceType.SHIPMENT,
|
||||||
referenceId,
|
referenceId,
|
||||||
);
|
);
|
||||||
|
logCtx(
|
||||||
|
{ referenceId, paid: result.paid, unverifiable: result.unverifiable },
|
||||||
|
{ path: "payment.reconcile" },
|
||||||
|
);
|
||||||
return { paid: result.paid, unverifiable: result.unverifiable };
|
return { paid: result.paid, unverifiable: result.unverifiable };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
logCtx(
|
||||||
|
{ referenceId, unverifiable: true, error: (err as Error).message },
|
||||||
|
{ path: "payment.reconcile" },
|
||||||
|
);
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Reconcile for shipment ${referenceId} failed: ${(err as Error).message}`,
|
`Reconcile for shipment ${referenceId} failed: ${(err as Error).message}`,
|
||||||
);
|
);
|
||||||
@@ -224,6 +233,18 @@ export class PaymentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async initiate(input: InitiateIntentInput): Promise<InitiateIntentResult> {
|
async initiate(input: InitiateIntentInput): Promise<InitiateIntentResult> {
|
||||||
|
logCtx(
|
||||||
|
{
|
||||||
|
referenceId: input.referenceId,
|
||||||
|
source: input.source,
|
||||||
|
orderRef: input.orderRef,
|
||||||
|
method: input.method,
|
||||||
|
amountMinor: input.amountMinor,
|
||||||
|
currency: input.currency,
|
||||||
|
platform: input.platform,
|
||||||
|
},
|
||||||
|
{ path: "payment.initiate" },
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
const isCbeBill = input.method === ProviderMethod.CBE_BILL;
|
const isCbeBill = input.method === ProviderMethod.CBE_BILL;
|
||||||
// CBE settles ETB only (docs/cbe/CBE_IMPLEMENTATION_PLAN.md D8).
|
// CBE settles ETB only (docs/cbe/CBE_IMPLEMENTATION_PLAN.md D8).
|
||||||
@@ -268,6 +289,17 @@ export class PaymentService {
|
|||||||
|
|
||||||
const intent = await this.upsertIntent(input, snapshot);
|
const intent = await this.upsertIntent(input, snapshot);
|
||||||
|
|
||||||
|
logCtx(
|
||||||
|
{
|
||||||
|
intentId: intent.id,
|
||||||
|
providerStatus: snapshot.status,
|
||||||
|
providerTxnId: snapshot.providerTxnId,
|
||||||
|
merchantOrderId: snapshot.merchantOrderId,
|
||||||
|
immediateSuccess,
|
||||||
|
},
|
||||||
|
{ path: "payment.initiate" },
|
||||||
|
);
|
||||||
|
|
||||||
if (immediateSuccess) {
|
if (immediateSuccess) {
|
||||||
// Settle the projection but DO NOT notify billing — billing settles
|
// Settle the projection but DO NOT notify billing — billing settles
|
||||||
// inline once it has stored intentId on the invoice (see payInvoice),
|
// inline once it has stored intentId on the invoice (see payInvoice),
|
||||||
@@ -428,6 +460,17 @@ export class PaymentService {
|
|||||||
otp,
|
otp,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
logCtx(
|
||||||
|
{
|
||||||
|
intentId: local.id,
|
||||||
|
refId: local.refId,
|
||||||
|
gatewayIntentId: snapshot.intentId,
|
||||||
|
providerStatus: confirmed.status,
|
||||||
|
failureCode: confirmed.failureCode,
|
||||||
|
},
|
||||||
|
{ path: "payment.confirmOtp" },
|
||||||
|
);
|
||||||
|
|
||||||
if (confirmed.status === ProviderPaymentStatus.SUCCEEDED) {
|
if (confirmed.status === ProviderPaymentStatus.SUCCEEDED) {
|
||||||
await this.markIntentSucceeded(local.id, {
|
await this.markIntentSucceeded(local.id, {
|
||||||
providerTxnId: confirmed.providerTxnId,
|
providerTxnId: confirmed.providerTxnId,
|
||||||
@@ -460,6 +503,16 @@ export class PaymentService {
|
|||||||
): Promise<{ alreadyFinalized: boolean }> {
|
): Promise<{ alreadyFinalized: boolean }> {
|
||||||
const intent = await this.paymentRepo.findOneBy({ id: intentId });
|
const intent = await this.paymentRepo.findOneBy({ id: intentId });
|
||||||
if (!intent) throw new NotFoundException("PaymentIntent not found");
|
if (!intent) throw new NotFoundException("PaymentIntent not found");
|
||||||
|
logCtx(
|
||||||
|
{
|
||||||
|
intentId,
|
||||||
|
refId: intent.refId,
|
||||||
|
priorStatus: intent.status,
|
||||||
|
providerTxnId: opts.providerTxnId,
|
||||||
|
notifyBilling: opts.notify !== false,
|
||||||
|
},
|
||||||
|
{ path: "payment.settle" },
|
||||||
|
);
|
||||||
if (intent.status === "success") {
|
if (intent.status === "success") {
|
||||||
// Still notify billing: a prior delivery may have flipped the intent to
|
// Still notify billing: a prior delivery may have flipped the intent to
|
||||||
// success and then died before the invoice settled (the two steps are not
|
// success and then died before the invoice settled (the two steps are not
|
||||||
@@ -471,6 +524,7 @@ export class PaymentService {
|
|||||||
opts.paidAt ?? intent.paidAt ?? undefined,
|
opts.paidAt ?? intent.paidAt ?? undefined,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
logCtx(true, { path: "payment.settle.alreadyFinalized", mode: "set" });
|
||||||
return { alreadyFinalized: true };
|
return { alreadyFinalized: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -507,6 +561,15 @@ export class PaymentService {
|
|||||||
referenceId: string,
|
referenceId: string,
|
||||||
): Promise<{ acknowledged: boolean }> {
|
): Promise<{ acknowledged: boolean }> {
|
||||||
const intent = await this.paymentRepo.findOneBy({ refId: referenceId });
|
const intent = await this.paymentRepo.findOneBy({ refId: referenceId });
|
||||||
|
logCtx(
|
||||||
|
{
|
||||||
|
referenceId,
|
||||||
|
intentId: intent?.id,
|
||||||
|
intentStatus: intent?.status ?? "none",
|
||||||
|
method: intent?.method,
|
||||||
|
},
|
||||||
|
{ path: "payment.successRedirect" },
|
||||||
|
);
|
||||||
if (!intent || intent.method === "cbe-bill") {
|
if (!intent || intent.method === "cbe-bill") {
|
||||||
return { acknowledged: false };
|
return { acknowledged: false };
|
||||||
}
|
}
|
||||||
@@ -533,6 +596,16 @@ export class PaymentService {
|
|||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const intent = await this.paymentRepo.findOneBy({ id: input.intentId });
|
const intent = await this.paymentRepo.findOneBy({ id: input.intentId });
|
||||||
if (!intent) throw new NotFoundException("PaymentIntent not found");
|
if (!intent) throw new NotFoundException("PaymentIntent not found");
|
||||||
|
logCtx(
|
||||||
|
{
|
||||||
|
intentId: intent.id,
|
||||||
|
refId: intent.refId,
|
||||||
|
priorStatus: intent.status,
|
||||||
|
failureCode: input.failureCode,
|
||||||
|
failureMessage: input.failureMessage,
|
||||||
|
},
|
||||||
|
{ path: "payment.failed" },
|
||||||
|
);
|
||||||
if (intent.status === "success" || intent.status === "canceled") return;
|
if (intent.status === "success" || intent.status === "canceled") return;
|
||||||
|
|
||||||
await this.paymentRepo.update(
|
await this.paymentRepo.update(
|
||||||
|
|||||||
@@ -70,6 +70,15 @@ export class CreateCargoTypeDto {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
hasLashing?: boolean;
|
hasLashing?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
default: false,
|
||||||
|
description:
|
||||||
|
'When true, bookings of this cargo type incur the lane-scoped FUEL surcharge.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
hasFuel?: boolean;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
default: false,
|
default: false,
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import {
|
|||||||
RATE_UNITS,
|
RATE_UNITS,
|
||||||
} from '../entities/rate.entity';
|
} from '../entities/rate.entity';
|
||||||
|
|
||||||
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const;
|
// DOMESTIC is accepted only for FUEL rates (an intercity fuel lane).
|
||||||
|
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH', 'DOMESTIC'] as const;
|
||||||
// ETB is accepted only for last-mile rates; the service forces USD elsewhere.
|
// ETB is accepted only for last-mile rates; the service forces USD elsewhere.
|
||||||
const CURRENCIES = ['USD', 'ETB'] as const;
|
const CURRENCIES = ['USD', 'ETB'] as const;
|
||||||
export const INTERCITY_KINDS = ['CONTAINER', 'BULK'] as const;
|
export const INTERCITY_KINDS = ['CONTAINER', 'BULK'] as const;
|
||||||
@@ -94,6 +95,17 @@ export class CreateRateDto {
|
|||||||
@IsIn([...RATE_UNITS])
|
@IsIn([...RATE_UNITS])
|
||||||
rateUnit?: string;
|
rateUnit?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'FUEL rates billed PER_LITER only: liters the surcharge covers — price = baseLiters × rateValue, once per booking. Required there, rejected elsewhere.',
|
||||||
|
minimum: 0,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@Transform(({ value }) => (value === null || value === undefined || value === '' ? undefined : Number(value)))
|
||||||
|
baseLiters?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description:
|
description:
|
||||||
'Distance band start (km, inclusive). Container last-mile rates only (rateUnit = PER_KM).',
|
'Distance band start (km, inclusive). Container last-mile rates only (rateUnit = PER_KM).',
|
||||||
|
|||||||
@@ -82,6 +82,14 @@ export class CargoType extends BaseEntity {
|
|||||||
@Column({ name: 'has_lashing', type: 'boolean', default: false })
|
@Column({ name: 'has_lashing', type: 'boolean', default: false })
|
||||||
hasLashing!: boolean;
|
hasLashing!: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether bookings of this cargo incur the fuel surcharge. Billed off the
|
||||||
|
* lane-scoped FUEL rate for the booking's direction + route + this cargo
|
||||||
|
* type (per liter or per wagon).
|
||||||
|
*/
|
||||||
|
@Column({ name: 'has_fuel', type: 'boolean', default: false })
|
||||||
|
hasFuel!: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether staff may write bulk contract templates against this cargo type.
|
* Whether staff may write bulk contract templates against this cargo type.
|
||||||
* Mutually exclusive between a parent group and its children: if the parent
|
* Mutually exclusive between a parent group and its children: if the parent
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ export function deriveRateType(input: {
|
|||||||
return 'PIL_EXTRA_FEE';
|
return 'PIL_EXTRA_FEE';
|
||||||
case 'CUSTOMS_CLEARANCE':
|
case 'CUSTOMS_CLEARANCE':
|
||||||
return 'CUSTOMS_CLEARANCE';
|
return 'CUSTOMS_CLEARANCE';
|
||||||
|
case 'FUEL':
|
||||||
|
return 'FUEL_SURCHARGE';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,9 @@ function unitsForShape(input: {
|
|||||||
case 'LASHING':
|
case 'LASHING':
|
||||||
// Bulk-only cargo securing — per ton or per wagon.
|
// Bulk-only cargo securing — per ton or per wagon.
|
||||||
return ['PER_TON', 'PER_WAGON'];
|
return ['PER_TON', 'PER_WAGON'];
|
||||||
|
case 'FUEL':
|
||||||
|
// Per wagon (wagons × rate) or per liter (baseLiters × rate, once).
|
||||||
|
return ['PER_WAGON', 'PER_LITER'];
|
||||||
case 'CONSOLIDATION':
|
case 'CONSOLIDATION':
|
||||||
return ['PER_CONTAINER', 'FLAT'];
|
return ['PER_CONTAINER', 'FLAT'];
|
||||||
case 'SHIPPING_LINE':
|
case 'SHIPPING_LINE':
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export const RATE_TYPES = [
|
|||||||
'RETURN_SURCHARGE',
|
'RETURN_SURCHARGE',
|
||||||
'PIL_EXTRA_FEE',
|
'PIL_EXTRA_FEE',
|
||||||
'CUSTOMS_CLEARANCE',
|
'CUSTOMS_CLEARANCE',
|
||||||
|
'FUEL_SURCHARGE',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type RateType = typeof RATE_TYPES[number];
|
export type RateType = typeof RATE_TYPES[number];
|
||||||
@@ -41,6 +42,8 @@ export const RATE_UNITS = [
|
|||||||
'PER_KM',
|
'PER_KM',
|
||||||
// Last-mile bulk: price = tons × km × rateValue.
|
// Last-mile bulk: price = tons × km × rateValue.
|
||||||
'PER_TON_KM',
|
'PER_TON_KM',
|
||||||
|
// Fuel surcharge only: price = baseLiters × rateValue, once per booking.
|
||||||
|
'PER_LITER',
|
||||||
'PER_INVOICE',
|
'PER_INVOICE',
|
||||||
'FLAT',
|
'FLAT',
|
||||||
] as const;
|
] as const;
|
||||||
@@ -92,6 +95,9 @@ export const RATE_TRIGGERS = [
|
|||||||
// Customs clearance service fee — billed up front via a clearance invoice,
|
// Customs clearance service fee — billed up front via a clearance invoice,
|
||||||
// never auto-applied to booking pricing (matchesTrigger returns false).
|
// never auto-applied to booking pricing (matchesTrigger returns false).
|
||||||
'CUSTOMS_CLEARANCE',
|
'CUSTOMS_CLEARANCE',
|
||||||
|
// Fuel surcharge — fires when the booking's cargo type has hasFuel = true,
|
||||||
|
// billed off the lane-scoped rate (direction + route + cargo type).
|
||||||
|
'FUEL',
|
||||||
] as const;
|
] as const;
|
||||||
export type RateTrigger = typeof RATE_TRIGGERS[number];
|
export type RateTrigger = typeof RATE_TRIGGERS[number];
|
||||||
|
|
||||||
@@ -163,6 +169,14 @@ export class Rate extends BaseEntity {
|
|||||||
* containerTypeId): the rate applies when minKm <= km < maxKm (maxKm NULL =
|
* containerTypeId): the rate applies when minKm <= km < maxKm (maxKm NULL =
|
||||||
* open-ended). NULL on every other rate shape.
|
* open-ended). NULL on every other rate shape.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* FUEL rates billed PER_LITER only: the liters the surcharge covers —
|
||||||
|
* price = baseLiters × rateValue, once per booking. NULL on every other
|
||||||
|
* rate shape (a PER_WAGON fuel rate bills wagons × rateValue instead).
|
||||||
|
*/
|
||||||
|
@Column({ name: 'base_liters', type: 'numeric', precision: 14, scale: 4, nullable: true })
|
||||||
|
baseLiters?: number | null;
|
||||||
|
|
||||||
@Column({ name: 'min_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
|
@Column({ name: 'min_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
|
||||||
minKm?: number | null;
|
minKm?: number | null;
|
||||||
|
|
||||||
|
|||||||
@@ -422,3 +422,108 @@ describe('RuleEngineService — lashing (bulk-only, per direction + commodity)',
|
|||||||
expect(lashingMods(result)).toHaveLength(0);
|
expect(lashingMods(result)).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('RuleEngineService — fuel surcharge (per lane + cargo type)', () => {
|
||||||
|
const fuelPerLiter: Rate = {
|
||||||
|
id: 'rate-fuel-liter',
|
||||||
|
rateType: 'FUEL_SURCHARGE',
|
||||||
|
trigger: 'FUEL',
|
||||||
|
rateValue: 2,
|
||||||
|
rateUnit: 'PER_LITER',
|
||||||
|
baseLiters: 100,
|
||||||
|
currency: 'USD',
|
||||||
|
status: 'LIVE',
|
||||||
|
containerTypeId: null,
|
||||||
|
cargoTypeId: 'cargo-steel',
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
originYardId: 'yard-nagad',
|
||||||
|
destinationYardId: 'yard-mojo',
|
||||||
|
} as Rate;
|
||||||
|
|
||||||
|
const buildService = (rates: Rate[], hasFuel = true): RuleEngineService =>
|
||||||
|
new RuleEngineService(
|
||||||
|
{
|
||||||
|
findById: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ hasFuel, hasLashing: false, requiresDirectorApproval: false }),
|
||||||
|
} as never,
|
||||||
|
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||||
|
{ findActiveByContainerTypeId: jest.fn().mockResolvedValue([]) } as never,
|
||||||
|
{ findAllActive: jest.fn().mockResolvedValue([]) } as never,
|
||||||
|
{ findLiveRates: jest.fn().mockResolvedValue(rates) } as never,
|
||||||
|
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
const fuelInput = (
|
||||||
|
overrides: Partial<BookingEvaluationInput> = {},
|
||||||
|
): BookingEvaluationInput => ({
|
||||||
|
serviceTypeId: 'svc-1',
|
||||||
|
paymentCurrency: 'USD',
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
isHazardous: false,
|
||||||
|
cargoTypeId: 'cargo-steel',
|
||||||
|
originYardId: 'yard-nagad',
|
||||||
|
destinationYardId: 'yard-mojo',
|
||||||
|
totalWagons: 0,
|
||||||
|
bulkTons: 100,
|
||||||
|
bulkWagons: 4,
|
||||||
|
containers: [],
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fuelMods = (result: Awaited<ReturnType<RuleEngineService['evaluate']>>) =>
|
||||||
|
result.appliedModifiers.filter((m) => m.surchargeCode === 'FUEL_SURCHARGE');
|
||||||
|
|
||||||
|
it('PER_LITER collapses to one flat total (base liters × rate value), regardless of wagons', async () => {
|
||||||
|
const result = await buildService([fuelPerLiter]).evaluate(fuelInput());
|
||||||
|
const mods = fuelMods(result);
|
||||||
|
expect(mods).toHaveLength(1);
|
||||||
|
// Flat: the customer sees only the total, and a frozen contract snapshot
|
||||||
|
// (also stored flat) multiplies it by quantity 1 — never by the liters.
|
||||||
|
expect(mods[0].triggerValue).toBe(1);
|
||||||
|
expect(mods[0].unitPriceUsd).toBe(200);
|
||||||
|
expect(mods[0].calculatedAmount).toBe(200);
|
||||||
|
expect(mods[0].billingUnit).toBe('FLAT');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PER_WAGON bills the wagons the cargo occupies', async () => {
|
||||||
|
const result = await buildService([
|
||||||
|
{ ...fuelPerLiter, rateUnit: 'PER_WAGON', baseLiters: null, rateValue: 50 } as Rate,
|
||||||
|
]).evaluate(fuelInput());
|
||||||
|
const mods = fuelMods(result);
|
||||||
|
expect(mods[0].triggerValue).toBe(4);
|
||||||
|
expect(mods[0].calculatedAmount).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a rate for another lane, direction or commodity never bills', async () => {
|
||||||
|
for (const wrong of [
|
||||||
|
{ tradeDirection: 'EXPORT' },
|
||||||
|
{ originYardId: 'yard-other' },
|
||||||
|
{ destinationYardId: 'yard-other' },
|
||||||
|
{ cargoTypeId: 'cargo-wheat' },
|
||||||
|
]) {
|
||||||
|
const result = await buildService([{ ...fuelPerLiter, ...wrong } as Rate]).evaluate(
|
||||||
|
fuelInput(),
|
||||||
|
);
|
||||||
|
expect(fuelMods(result)).toHaveLength(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a domestic booking bills the DOMESTIC fuel lane', async () => {
|
||||||
|
const result = await buildService([
|
||||||
|
{ ...fuelPerLiter, tradeDirection: 'DOMESTIC' } as Rate,
|
||||||
|
]).evaluate(fuelInput({ tradeDirection: 'DOMESTIC' }));
|
||||||
|
expect(fuelMods(result)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no fuel charge when the cargo type does not have hasFuel', async () => {
|
||||||
|
const result = await buildService([fuelPerLiter], false).evaluate(fuelInput());
|
||||||
|
expect(fuelMods(result)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no matching lane rate bills nothing (lenient, like lashing)', async () => {
|
||||||
|
const result = await buildService([]).evaluate(fuelInput());
|
||||||
|
expect(fuelMods(result)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -175,6 +175,9 @@ export class RuleEngineService {
|
|||||||
// matchesTrigger can fire the LASHING rate. Falls back to an explicit
|
// matchesTrigger can fire the LASHING rate. Falls back to an explicit
|
||||||
// input flag when no cargo type is set (e.g. container bookings).
|
// input flag when no cargo type is set (e.g. container bookings).
|
||||||
let hasLashing = input.hasLashing === true;
|
let hasLashing = input.hasLashing === true;
|
||||||
|
// Fuel is likewise a cargo-type property (hasFuel), billed off the
|
||||||
|
// lane-scoped FUEL rate — see fuelCharges.
|
||||||
|
let hasFuel = false;
|
||||||
if (input.cargoTypeId) {
|
if (input.cargoTypeId) {
|
||||||
const cargoType = await this.cargoTypesRepo.findById(input.cargoTypeId);
|
const cargoType = await this.cargoTypesRepo.findById(input.cargoTypeId);
|
||||||
if (!cargoType) {
|
if (!cargoType) {
|
||||||
@@ -186,6 +189,9 @@ export class RuleEngineService {
|
|||||||
if (cargoType.hasLashing) {
|
if (cargoType.hasLashing) {
|
||||||
hasLashing = true;
|
hasLashing = true;
|
||||||
}
|
}
|
||||||
|
if (cargoType.hasFuel) {
|
||||||
|
hasFuel = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,6 +334,9 @@ export class RuleEngineService {
|
|||||||
// Lashing is sold per cargo kind + container type — billed by the
|
// Lashing is sold per cargo kind + container type — billed by the
|
||||||
// kind-aware block below, never by this generic loop.
|
// kind-aware block below, never by this generic loop.
|
||||||
if (rate.trigger === 'LASHING') continue;
|
if (rate.trigger === 'LASHING') continue;
|
||||||
|
// Fuel is sold per lane + cargo type — billed by the route-matched
|
||||||
|
// block below, never by this route-agnostic loop.
|
||||||
|
if (rate.trigger === 'FUEL') continue;
|
||||||
const triggered = this.matchesTrigger(rate.trigger, {
|
const triggered = this.matchesTrigger(rate.trigger, {
|
||||||
isHazardous: input.isHazardous,
|
isHazardous: input.isHazardous,
|
||||||
hasReefer,
|
hasReefer,
|
||||||
@@ -442,6 +451,10 @@ export class RuleEngineService {
|
|||||||
appliedModifiers.push(...this.lashingCharges(input, liveRates));
|
appliedModifiers.push(...this.lashingCharges(input, liveRates));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasFuel) {
|
||||||
|
appliedModifiers.push(...this.fuelCharges(input, liveRates));
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
priorityScore,
|
priorityScore,
|
||||||
appliedModifiers,
|
appliedModifiers,
|
||||||
@@ -632,6 +645,56 @@ export class RuleEngineService {
|
|||||||
return modifiers;
|
return modifiers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fuel surcharge — fires when the booking's cargo type has hasFuel = true,
|
||||||
|
* billed off the FUEL rate matching the booking's lane (trade direction +
|
||||||
|
* origin + destination) and cargo type. PER_LITER collapses to one FLAT
|
||||||
|
* amount (baseLiters × rateValue, once per booking) — the customer only ever
|
||||||
|
* sees the total, and the frozen contract snapshot stores that same flat
|
||||||
|
* figure so the snapshot-override math bills it exactly once. PER_WAGON
|
||||||
|
* bills the wagons the cargo occupies. No matching lane rate simply bills
|
||||||
|
* nothing — same leniency as lashing.
|
||||||
|
*/
|
||||||
|
private fuelCharges(
|
||||||
|
input: BookingEvaluationInput,
|
||||||
|
liveRates: Rate[],
|
||||||
|
): AppliedCargoModifier[] {
|
||||||
|
const modifiers: AppliedCargoModifier[] = [];
|
||||||
|
const rate = liveRates.find(
|
||||||
|
(r) =>
|
||||||
|
r.trigger === 'FUEL' &&
|
||||||
|
r.currency === 'USD' &&
|
||||||
|
r.tradeDirection === input.tradeDirection &&
|
||||||
|
r.originYardId === input.originYardId &&
|
||||||
|
r.destinationYardId === input.destinationYardId &&
|
||||||
|
r.cargoTypeId === input.cargoTypeId,
|
||||||
|
);
|
||||||
|
if (!rate) return modifiers;
|
||||||
|
|
||||||
|
const rateValue = Number(rate.rateValue);
|
||||||
|
const wagons = Math.max(
|
||||||
|
0,
|
||||||
|
Number(input.bulkWagons ?? 0) || Number(input.totalWagons ?? 0),
|
||||||
|
);
|
||||||
|
const perLiter = rate.rateUnit === 'PER_LITER';
|
||||||
|
const billedQty = perLiter ? 1 : wagons;
|
||||||
|
const unitPrice = perLiter
|
||||||
|
? Number(rate.baseLiters ?? 0) * rateValue
|
||||||
|
: rateValue;
|
||||||
|
const amount = billedQty * unitPrice;
|
||||||
|
if (!(amount > 0)) return modifiers;
|
||||||
|
modifiers.push({
|
||||||
|
rateId: rate.id,
|
||||||
|
surchargeCode: this.surchargeCode(rate),
|
||||||
|
triggerValue: billedQty,
|
||||||
|
calculatedAmount: amount,
|
||||||
|
currency: rate.currency,
|
||||||
|
unitPriceUsd: unitPrice,
|
||||||
|
billingUnit: perLiter ? 'FLAT' : rate.rateUnit,
|
||||||
|
});
|
||||||
|
return modifiers;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Messages for container lines whose total weight exceeds the hard capacity
|
* Messages for container lines whose total weight exceeds the hard capacity
|
||||||
* ceiling (weight_limit_rules.max_capacity_tons). Non-empty ⇒ the booking
|
* ceiling (weight_limit_rules.max_capacity_tons). Non-empty ⇒ the booking
|
||||||
|
|||||||
@@ -118,6 +118,19 @@ describe('RateChangeRequestsService', () => {
|
|||||||
expect(request.payload).toEqual({ destinationYardId: 'yard-c' });
|
expect(request.payload).toEqual({ destinationYardId: 'yard-c' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('carries baseLiters — a switch to PER_LITER keeps its billing base', async () => {
|
||||||
|
const { service } = build({
|
||||||
|
rate: liveRate({ rateUnit: 'PER_WAGON', baseLiters: null }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = await service.submit({
|
||||||
|
rateId: 'rate-1',
|
||||||
|
update: { rateUnit: 'PER_LITER', baseLiters: 3 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(request.payload).toEqual({ rateUnit: 'PER_LITER', baseLiters: 3 });
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects a no-op — 100 posted against a live 100.0000 is not a change', async () => {
|
it('rejects a no-op — 100 posted against a live 100.0000 is not a change', async () => {
|
||||||
const { service } = build();
|
const { service } = build();
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ const DIFFABLE_FIELDS = [
|
|||||||
// LIVE last-mile rate would diff to "nothing changed".
|
// LIVE last-mile rate would diff to "nothing changed".
|
||||||
'minKm',
|
'minKm',
|
||||||
'maxKm',
|
'maxKm',
|
||||||
|
// PER_LITER fuel surcharge billing base. Missing here, a switch to PER_LITER
|
||||||
|
// dropped the submitted liters and validation failed with "needs a base
|
||||||
|
// liters amount" even though the payload carried one.
|
||||||
|
'baseLiters',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -121,15 +121,16 @@ export class RatesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rates sold per direction + route. Base freight always; customs clearance
|
* Rates sold per direction + route. Base freight always; customs clearance,
|
||||||
* and empty-container return are the surcharges that are too — their fee
|
* empty-container return and fuel are the surcharges that are too — their
|
||||||
* depends on the lane (and, for returns, the container type).
|
* fee depends on the lane (and, for returns, the container type).
|
||||||
*/
|
*/
|
||||||
private isRouteScoped(appliesTo: Rate['appliesTo'], trigger: Rate['trigger']): boolean {
|
private isRouteScoped(appliesTo: Rate['appliesTo'], trigger: Rate['trigger']): boolean {
|
||||||
return (
|
return (
|
||||||
this.isBaseFreight(appliesTo, trigger) ||
|
this.isBaseFreight(appliesTo, trigger) ||
|
||||||
trigger === 'CUSTOMS_CLEARANCE' ||
|
trigger === 'CUSTOMS_CLEARANCE' ||
|
||||||
trigger === 'WITH_RETURN'
|
trigger === 'WITH_RETURN' ||
|
||||||
|
trigger === 'FUEL'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +161,9 @@ export class RatesService {
|
|||||||
appliesTo: Rate['appliesTo'],
|
appliesTo: Rate['appliesTo'],
|
||||||
tradeDirection: string | null,
|
tradeDirection: string | null,
|
||||||
): { origin: YardCountry; destination: YardCountry } {
|
): { origin: YardCountry; destination: YardCountry } {
|
||||||
if (appliesTo === 'INTERCITY') {
|
// DOMESTIC only reaches here on a FUEL rate's intercity lane — it stays
|
||||||
|
// inside Ethiopia exactly like intercity base freight.
|
||||||
|
if (appliesTo === 'INTERCITY' || tradeDirection === 'DOMESTIC') {
|
||||||
return { origin: YardCountry.ETHIOPIA, destination: YardCountry.ETHIOPIA };
|
return { origin: YardCountry.ETHIOPIA, destination: YardCountry.ETHIOPIA };
|
||||||
}
|
}
|
||||||
return tradeDirection === 'EXPORT'
|
return tradeDirection === 'EXPORT'
|
||||||
@@ -291,6 +294,31 @@ export class RatesService {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (trigger === 'FUEL') {
|
||||||
|
// Fuel is sold per lane + commodity: the direction says which countries
|
||||||
|
// the leg spans (DOMESTIC = intercity, inside Ethiopia) and the cargo
|
||||||
|
// type names the commodity — different commodities price differently.
|
||||||
|
if (
|
||||||
|
tradeDirection !== 'IMPORT' &&
|
||||||
|
tradeDirection !== 'EXPORT' &&
|
||||||
|
tradeDirection !== 'DOMESTIC'
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'A fuel rate must say whether it covers IMPORT, EXPORT or DOMESTIC (intercity).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (containerTypeId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'A fuel rate cannot be scoped to a container type.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!cargoTypeId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'A fuel rate must name the cargo type it covers.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (trigger === 'WITH_RETURN') {
|
if (trigger === 'WITH_RETURN') {
|
||||||
// Returning the empty box only exists on imports (the box goes back to
|
// Returning the empty box only exists on imports (the box goes back to
|
||||||
// the port) — export return rates are rejected until the business sells
|
// the port) — export return rates are rejected until the business sells
|
||||||
@@ -502,15 +530,21 @@ export class RatesService {
|
|||||||
: (dto.containerTypeId ?? null);
|
: (dto.containerTypeId ?? null);
|
||||||
const cargoTypeId =
|
const cargoTypeId =
|
||||||
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') ||
|
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') ||
|
||||||
trigger === 'LASHING'
|
trigger === 'LASHING' ||
|
||||||
|
trigger === 'FUEL'
|
||||||
? (dto.cargoTypeId ?? null)
|
? (dto.cargoTypeId ?? null)
|
||||||
: isSurcharge
|
: isSurcharge
|
||||||
? null
|
? null
|
||||||
: (dto.cargoTypeId ?? null);
|
: (dto.cargoTypeId ?? null);
|
||||||
// Intercity never leaves Ethiopia, so it has no trade direction to store —
|
// Intercity never leaves Ethiopia, so it has no trade direction to store —
|
||||||
// its yard pair already says where it runs.
|
// its yard pair already says where it runs. (Fuel is the exception: its
|
||||||
|
// intercity lane is stored as DOMESTIC, since appliesTo = OTHER says
|
||||||
|
// nothing about the direction.)
|
||||||
const tradeDirection =
|
const tradeDirection =
|
||||||
trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN' || trigger === 'LASHING'
|
trigger === 'CUSTOMS_CLEARANCE' ||
|
||||||
|
trigger === 'WITH_RETURN' ||
|
||||||
|
trigger === 'LASHING' ||
|
||||||
|
trigger === 'FUEL'
|
||||||
? (dto.tradeDirection ?? null)
|
? (dto.tradeDirection ?? null)
|
||||||
: isSurcharge || appliesTo === 'INTERCITY'
|
: isSurcharge || appliesTo === 'INTERCITY'
|
||||||
? null
|
? null
|
||||||
@@ -563,6 +597,8 @@ export class RatesService {
|
|||||||
await this.assertNoBandOverlap({ rateUnit, containerTypeId, minKm, maxKm });
|
await this.assertNoBandOverlap({ rateUnit, containerTypeId, minKm, maxKm });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const baseLiters = this.resolveBaseLiters(rateUnit, dto.baseLiters);
|
||||||
|
|
||||||
await this.assertNoDuplicatePattern({
|
await this.assertNoDuplicatePattern({
|
||||||
rateType,
|
rateType,
|
||||||
...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }),
|
...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }),
|
||||||
@@ -588,6 +624,7 @@ export class RatesService {
|
|||||||
currency: appliesTo === 'LAST_MILE' ? (dto.currency ?? 'ETB') : 'USD',
|
currency: appliesTo === 'LAST_MILE' ? (dto.currency ?? 'ETB') : 'USD',
|
||||||
rateValue: dto.rateValue,
|
rateValue: dto.rateValue,
|
||||||
rateUnit,
|
rateUnit,
|
||||||
|
baseLiters,
|
||||||
minKm,
|
minKm,
|
||||||
maxKm,
|
maxKm,
|
||||||
status: 'DRAFT',
|
status: 'DRAFT',
|
||||||
@@ -595,6 +632,25 @@ export class RatesService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The liters a PER_LITER fuel rate bills (price = baseLiters × rateValue,
|
||||||
|
* once per booking). Required there; cleared on every other rate shape —
|
||||||
|
* a PER_WAGON fuel rate bills wagons × rateValue and carries none.
|
||||||
|
*/
|
||||||
|
private resolveBaseLiters(
|
||||||
|
rateUnit: Rate['rateUnit'],
|
||||||
|
baseLiters?: number | null,
|
||||||
|
): number | null {
|
||||||
|
if (rateUnit !== 'PER_LITER') return null;
|
||||||
|
const liters = Number(baseLiters);
|
||||||
|
if (!(liters > 0)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'A per-liter fuel rate needs a base liters amount — the price is base liters × rate value.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return liters;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update a DRAFT rate in place. Nothing prices off a draft, so a direct edit
|
* Update a DRAFT rate in place. Nothing prices off a draft, so a direct edit
|
||||||
* is safe. A LIVE rate cannot take this path — see `applyApprovedUpdate`.
|
* is safe. A LIVE rate cannot take this path — see `applyApprovedUpdate`.
|
||||||
@@ -680,14 +736,18 @@ export class RatesService {
|
|||||||
const keepsCargoType =
|
const keepsCargoType =
|
||||||
!isSurcharge ||
|
!isSurcharge ||
|
||||||
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') ||
|
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') ||
|
||||||
trigger === 'LASHING';
|
trigger === 'LASHING' ||
|
||||||
|
trigger === 'FUEL';
|
||||||
const cargoTypeId = !keepsCargoType
|
const cargoTypeId = !keepsCargoType
|
||||||
? null
|
? null
|
||||||
: dto.cargoTypeId !== undefined
|
: dto.cargoTypeId !== undefined
|
||||||
? dto.cargoTypeId
|
? dto.cargoTypeId
|
||||||
: existing.cargoTypeId;
|
: existing.cargoTypeId;
|
||||||
const tradeDirection =
|
const tradeDirection =
|
||||||
trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN' || trigger === 'LASHING'
|
trigger === 'CUSTOMS_CLEARANCE' ||
|
||||||
|
trigger === 'WITH_RETURN' ||
|
||||||
|
trigger === 'LASHING' ||
|
||||||
|
trigger === 'FUEL'
|
||||||
? dto.tradeDirection !== undefined
|
? dto.tradeDirection !== undefined
|
||||||
? dto.tradeDirection
|
? dto.tradeDirection
|
||||||
: existing.tradeDirection
|
: existing.tradeDirection
|
||||||
@@ -788,6 +848,11 @@ export class RatesService {
|
|||||||
ignoreId: id,
|
ignoreId: id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
updates.baseLiters = this.resolveBaseLiters(
|
||||||
|
rateUnit,
|
||||||
|
dto.baseLiters !== undefined ? dto.baseLiters : existing.baseLiters,
|
||||||
|
);
|
||||||
|
|
||||||
updates.currency =
|
updates.currency =
|
||||||
appliesTo === 'LAST_MILE'
|
appliesTo === 'LAST_MILE'
|
||||||
? (dto.currency ?? existing.currency ?? 'ETB')
|
? (dto.currency ?? existing.currency ?? 'ETB')
|
||||||
|
|||||||
@@ -15,21 +15,21 @@ export class StampSettingsController {
|
|||||||
constructor(private readonly service: StampSettingsService) {}
|
constructor(private readonly service: StampSettingsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@BookingStaff([FREIGHT_PERMS.settings.invoiceStamp.view, FREIGHT_PERMS.admin])
|
@BookingStaff([FREIGHT_PERMS.settings.stamp.view, FREIGHT_PERMS.admin])
|
||||||
@ApiOperation({ summary: "Current company stamp used on invoice/receipt PDFs" })
|
@ApiOperation({ summary: "Current company stamp used on invoice/receipt PDFs" })
|
||||||
get() {
|
get() {
|
||||||
return this.service.getView();
|
return this.service.getView();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put()
|
@Put()
|
||||||
@BookingStaff([FREIGHT_PERMS.settings.invoiceStamp.manage, FREIGHT_PERMS.admin])
|
@BookingStaff([FREIGHT_PERMS.settings.stamp.manage, FREIGHT_PERMS.admin])
|
||||||
@ApiOperation({ summary: "Replace the company stamp" })
|
@ApiOperation({ summary: "Replace the company stamp" })
|
||||||
update(@Body() dto: UpdateStampSettingDto, @CurrentUser() user: TCurrentUser) {
|
update(@Body() dto: UpdateStampSettingDto, @CurrentUser() user: TCurrentUser) {
|
||||||
return this.service.setStamp(dto.stampImageBase64, user?.id ?? null);
|
return this.service.setStamp(dto.stampImageBase64, user?.id ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete()
|
@Delete()
|
||||||
@BookingStaff([FREIGHT_PERMS.settings.invoiceStamp.manage, FREIGHT_PERMS.admin])
|
@BookingStaff([FREIGHT_PERMS.settings.stamp.manage, FREIGHT_PERMS.admin])
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Clear the company stamp (invoices fall back to the plain seal)",
|
summary: "Clear the company stamp (invoices fall back to the plain seal)",
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ import {
|
|||||||
perEdgeConsistUsage,
|
perEdgeConsistUsage,
|
||||||
validateContainerPlacements,
|
validateContainerPlacements,
|
||||||
validateMixedTrainLimitsPerEdge,
|
validateMixedTrainLimitsPerEdge,
|
||||||
|
MAX_TEU_SLOTS_PER_WAGON,
|
||||||
type ContainerPlacementInput,
|
type ContainerPlacementInput,
|
||||||
type WagonPlanSlot,
|
type WagonPlanSlot,
|
||||||
} from '../utils/wagon-plan.util';
|
} from '../utils/wagon-plan.util';
|
||||||
@@ -330,6 +331,8 @@ const DEFAULT_TRAIN_LIMITS: Required<TrainLimitConfig> = {
|
|||||||
interface BookingWindowRow {
|
interface BookingWindowRow {
|
||||||
schedule_id: string;
|
schedule_id: string;
|
||||||
reference: string | null;
|
reference: string | null;
|
||||||
|
/** Operational run number (e.g. 8001 import / 8002 export), typed by staff. */
|
||||||
|
train_number: string | null;
|
||||||
contract_id: string | null;
|
contract_id: string | null;
|
||||||
contract_kind: string | null;
|
contract_kind: string | null;
|
||||||
direction: string | null;
|
direction: string | null;
|
||||||
@@ -2928,6 +2931,10 @@ export class TrainSchedulingService {
|
|||||||
// dispatch pre-check keeps reporting these bookings as unloaded).
|
// dispatch pre-check keeps reporting these bookings as unloaded).
|
||||||
const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId);
|
const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId);
|
||||||
if (wagonAssignedIds.size) {
|
if (wagonAssignedIds.size) {
|
||||||
|
// Re-check the 1×40ft/2×20ft-per-wagon packing rule right before loading
|
||||||
|
// is confirmed — allocation time already enforces it, but a wagon swap or
|
||||||
|
// an edited allocation since then could have broken it unnoticed.
|
||||||
|
await this.assertWagonContainerCapacity(scheduleId);
|
||||||
// Export cargo must be received at the warehouse with a GRN before it can
|
// Export cargo must be received at the warehouse with a GRN before it can
|
||||||
// be confirmed loaded — an allocation is not proof the goods are in hand.
|
// be confirmed loaded — an allocation is not proof the goods are in hand.
|
||||||
if (this.isExportSchedule(schedule)) {
|
if (this.isExportSchedule(schedule)) {
|
||||||
@@ -3395,6 +3402,72 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-check the 1×40ft / 2×20ft-per-wagon packing rule at loading confirmation
|
||||||
|
* time. `validateContainerPlacements` already enforces this the moment a
|
||||||
|
* booking is allocated to a wagon, but nothing re-checks it afterwards — a
|
||||||
|
* wagon swap, an edited allocation, or a container item added out-of-band
|
||||||
|
* between allocation and loading could still leave a wagon over its 2-TEU
|
||||||
|
* capacity undetected until the train is already loaded. This closes that gap
|
||||||
|
* by summing the TEU actually persisted per wagon (40ft = 2 TEU, 20ft = 1 TEU,
|
||||||
|
* same size resolution as the marshalling document) right before loading is
|
||||||
|
* confirmed.
|
||||||
|
*/
|
||||||
|
private async assertWagonContainerCapacity(scheduleId: string): Promise<void> {
|
||||||
|
const rows: Array<{ sequenceNo: number; wagonNumber: string | null; teuUsed: string }> =
|
||||||
|
await this.dataSource.query(
|
||||||
|
`SELECT tsw.sequence_no AS "sequenceNo",
|
||||||
|
pw.wagon_number AS "wagonNumber",
|
||||||
|
SUM(
|
||||||
|
CASE COALESCE(ct.size_ft, bct.size_ft)
|
||||||
|
WHEN 40 THEN 2
|
||||||
|
WHEN 20 THEN 1
|
||||||
|
ELSE
|
||||||
|
CASE
|
||||||
|
WHEN bc.container_size ILIKE '%40%' THEN 2
|
||||||
|
WHEN bc.container_size ILIKE '%20%' THEN 1
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
END
|
||||||
|
) AS "teuUsed"
|
||||||
|
FROM freight.train_set_wagons tsw
|
||||||
|
JOIN freight.wagon_booking_allocations wba
|
||||||
|
ON wba.train_set_wagon_id = tsw.id AND wba.deleted_at IS NULL
|
||||||
|
JOIN freight.wagon_allocation_container_items wci
|
||||||
|
ON wci.wagon_booking_allocation_id = wba.id AND wci.deleted_at IS NULL
|
||||||
|
LEFT JOIN freight.container_types ct ON ct.id = wci.container_type_id
|
||||||
|
LEFT JOIN freight.booking_container bc ON bc.id = wci.booking_container_id
|
||||||
|
LEFT JOIN freight.container_types bct ON bct.id = bc.container_type_id
|
||||||
|
LEFT JOIN freight.wagons pw ON pw.id = tsw.physical_wagon_id
|
||||||
|
WHERE tsw.train_set_id = (
|
||||||
|
SELECT train_set_id FROM freight.train_schedules WHERE id = $1
|
||||||
|
)
|
||||||
|
AND tsw.deleted_at IS NULL
|
||||||
|
GROUP BY tsw.id, tsw.sequence_no, pw.wagon_number
|
||||||
|
HAVING SUM(
|
||||||
|
CASE COALESCE(ct.size_ft, bct.size_ft)
|
||||||
|
WHEN 40 THEN 2
|
||||||
|
WHEN 20 THEN 1
|
||||||
|
ELSE
|
||||||
|
CASE
|
||||||
|
WHEN bc.container_size ILIKE '%40%' THEN 2
|
||||||
|
WHEN bc.container_size ILIKE '%20%' THEN 1
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
END
|
||||||
|
) > $2`,
|
||||||
|
[scheduleId, MAX_TEU_SLOTS_PER_WAGON],
|
||||||
|
);
|
||||||
|
if (rows.length) {
|
||||||
|
const labels = rows
|
||||||
|
.map((r) => `wagon #${r.sequenceNo}${r.wagonNumber ? ` (${r.wagonNumber})` : ''}`)
|
||||||
|
.join(', ');
|
||||||
|
throw new BadRequestException(
|
||||||
|
`These wagons exceed capacity (max 1×40ft or 2×20ft per wagon) — fix the container placement before confirming loading: ${labels}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private buildImportLoadListHtml(loadList: Awaited<ReturnType<TrainSchedulingService['generateImportLoadList']>>): string {
|
private buildImportLoadListHtml(loadList: Awaited<ReturnType<TrainSchedulingService['generateImportLoadList']>>): string {
|
||||||
const esc = (value: unknown) =>
|
const esc = (value: unknown) =>
|
||||||
String(value ?? '-')
|
String(value ?? '-')
|
||||||
@@ -5872,6 +5945,7 @@ export class TrainSchedulingService {
|
|||||||
wagonSlots: schedule.trainSet?.wagons,
|
wagonSlots: schedule.trainSet?.wagons,
|
||||||
storedWagonCount: schedule.trainSet?.wagonCount,
|
storedWagonCount: schedule.trainSet?.wagonCount,
|
||||||
scheduleBookings: schedule.scheduleBookings,
|
scheduleBookings: schedule.scheduleBookings,
|
||||||
|
maxWagons: schedule.maxWagons,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -6847,6 +6921,7 @@ export class TrainSchedulingService {
|
|||||||
`SELECT DISTINCT ON (ts.id)
|
`SELECT DISTINCT ON (ts.id)
|
||||||
ts.id AS schedule_id,
|
ts.id AS schedule_id,
|
||||||
ts.reference AS reference,
|
ts.reference AS reference,
|
||||||
|
ts.train_number,
|
||||||
cr.contract_id AS contract_id,
|
cr.contract_id AS contract_id,
|
||||||
c.contract_kind AS contract_kind,
|
c.contract_kind AS contract_kind,
|
||||||
ts.direction,
|
ts.direction,
|
||||||
@@ -6903,6 +6978,7 @@ export class TrainSchedulingService {
|
|||||||
const rows: Array<BookingWindowRow> = await this.dataSource.query(
|
const rows: Array<BookingWindowRow> = await this.dataSource.query(
|
||||||
`SELECT DISTINCT ts.id AS schedule_id,
|
`SELECT DISTINCT ts.id AS schedule_id,
|
||||||
ts.reference AS reference,
|
ts.reference AS reference,
|
||||||
|
ts.train_number,
|
||||||
cr.contract_id AS contract_id,
|
cr.contract_id AS contract_id,
|
||||||
c.contract_kind AS contract_kind,
|
c.contract_kind AS contract_kind,
|
||||||
ts.direction,
|
ts.direction,
|
||||||
@@ -6948,9 +7024,7 @@ export class TrainSchedulingService {
|
|||||||
*/
|
*/
|
||||||
async listAllBookingWindows() {
|
async listAllBookingWindows() {
|
||||||
const rows: Array<
|
const rows: Array<
|
||||||
Omit<BookingWindowRow, 'contract_id' | 'contract_kind'> & {
|
Omit<BookingWindowRow, 'contract_id' | 'contract_kind'>
|
||||||
train_number: string | null;
|
|
||||||
}
|
|
||||||
> = await this.dataSource.query(
|
> = await this.dataSource.query(
|
||||||
`SELECT ts.id AS schedule_id,
|
`SELECT ts.id AS schedule_id,
|
||||||
ts.reference AS reference,
|
ts.reference AS reference,
|
||||||
@@ -6980,14 +7054,13 @@ export class TrainSchedulingService {
|
|||||||
AND ts.scheduled_departure_date >= now()
|
AND ts.scheduled_departure_date >= now()
|
||||||
ORDER BY ts.scheduled_departure_date ASC NULLS LAST`,
|
ORDER BY ts.scheduled_departure_date ASC NULLS LAST`,
|
||||||
);
|
);
|
||||||
return rows.map((r) => ({
|
return rows.map((r) =>
|
||||||
...this.mapBookingWindowRow({
|
this.mapBookingWindowRow({
|
||||||
...r,
|
...r,
|
||||||
contract_id: null,
|
contract_id: null,
|
||||||
contract_kind: null,
|
contract_kind: null,
|
||||||
}),
|
}),
|
||||||
trainNumber: r.train_number,
|
);
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private mapBookingWindowRow(r: BookingWindowRow) {
|
private mapBookingWindowRow(r: BookingWindowRow) {
|
||||||
@@ -7005,6 +7078,7 @@ export class TrainSchedulingService {
|
|||||||
return {
|
return {
|
||||||
scheduleId: r.schedule_id,
|
scheduleId: r.schedule_id,
|
||||||
reference: r.reference ?? null,
|
reference: r.reference ?? null,
|
||||||
|
trainNumber: r.train_number ?? null,
|
||||||
contractId: r.contract_id,
|
contractId: r.contract_id,
|
||||||
contractKind: r.contract_kind,
|
contractKind: r.contract_kind,
|
||||||
direction: r.direction,
|
direction: r.direction,
|
||||||
@@ -9301,9 +9375,14 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Every schedule the target train is committed to, via its train sets.
|
// Every schedule the target train is committed to, via its train sets.
|
||||||
|
// Locomotives come along: the merged train is pulled by the union of this
|
||||||
|
// schedule's locos and the target train's, so capacity checks need both.
|
||||||
const targetSets = await this.dataSource
|
const targetSets = await this.dataSource
|
||||||
.getRepository(TrainSet)
|
.getRepository(TrainSet)
|
||||||
.find({ where: { trainId: targetTrainId } });
|
.find({
|
||||||
|
where: { trainId: targetTrainId },
|
||||||
|
relations: { locomotives: { locomotive: true }, locomotive: true },
|
||||||
|
});
|
||||||
const targetSetIds = targetSets.map((s) => s.id);
|
const targetSetIds = targetSets.map((s) => s.id);
|
||||||
const targetSchedules = targetSetIds.length
|
const targetSchedules = targetSetIds.length
|
||||||
? await this.dataSource.getRepository(TrainSchedule).find({
|
? await this.dataSource.getRepository(TrainSchedule).find({
|
||||||
@@ -9346,6 +9425,24 @@ export class TrainSchedulingService {
|
|||||||
.getRepository(Wagon)
|
.getRepository(Wagon)
|
||||||
.find({ where: { trainId: targetTrainId }, order: { wagonNumber: 'ASC' } });
|
.find({ where: { trainId: targetTrainId }, order: { wagonNumber: 'ASC' } });
|
||||||
|
|
||||||
|
// EVERY wagon physically on the source train moves with the merge — not
|
||||||
|
// just the ones coupled into this schedule's set. A wagon left behind
|
||||||
|
// would strand on the deactivated train. "Loose" = on the train but not
|
||||||
|
// backing a set slot; it joins the counts and the capacity math.
|
||||||
|
const sourceWagons = sourceTrainId
|
||||||
|
? await this.dataSource
|
||||||
|
.getRepository(Wagon)
|
||||||
|
.find({ where: { trainId: sourceTrainId }, order: { wagonNumber: 'ASC' } })
|
||||||
|
: [];
|
||||||
|
const coupledPhysicalIds = new Set(
|
||||||
|
(schedule.trainSet?.wagons ?? [])
|
||||||
|
.map((w) => w.physicalWagonId)
|
||||||
|
.filter(Boolean),
|
||||||
|
);
|
||||||
|
const looseSourceWagons = sourceWagons.filter(
|
||||||
|
(w) => !coupledPhysicalIds.has(w.id),
|
||||||
|
);
|
||||||
|
|
||||||
const movingBookings = absorbed
|
const movingBookings = absorbed
|
||||||
? await this.dataSource.getRepository(TrainScheduleBooking).find({
|
? await this.dataSource.getRepository(TrainScheduleBooking).find({
|
||||||
where: { trainScheduleId: absorbed.id },
|
where: { trainScheduleId: absorbed.id },
|
||||||
@@ -9357,10 +9454,12 @@ export class TrainSchedulingService {
|
|||||||
schedule,
|
schedule,
|
||||||
sourceTrainId,
|
sourceTrainId,
|
||||||
targetTrain,
|
targetTrain,
|
||||||
|
targetSets,
|
||||||
absorbed,
|
absorbed,
|
||||||
affectedOthers,
|
affectedOthers,
|
||||||
untouched,
|
untouched,
|
||||||
incomingWagons,
|
incomingWagons,
|
||||||
|
looseSourceWagons,
|
||||||
movingBookings,
|
movingBookings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -9382,14 +9481,20 @@ export class TrainSchedulingService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Capacity: the merged consist must fit this schedule's locomotives ────
|
// ── Capacity: the merged consist must fit the merged train's locomotives ─
|
||||||
|
// Existing side = coupled set slots PLUS loose wagons riding the source
|
||||||
|
// train without a slot — they all move, so they all count.
|
||||||
const existingSlots = (schedule.trainSet?.wagons ?? []).map((w) => ({
|
const existingSlots = (schedule.trainSet?.wagons ?? []).map((w) => ({
|
||||||
lengthMeters: Number(w.lengthMeters) || 0,
|
lengthMeters: Number(w.lengthMeters) || 0,
|
||||||
tareWeightTons: Number(w.wagonType?.tareWeightTons) || 0,
|
tareWeightTons: Number(w.wagonType?.tareWeightTons) || 0,
|
||||||
cargoTons: 0,
|
cargoTons: 0,
|
||||||
}));
|
}));
|
||||||
const wagonTypeIds = [
|
const wagonTypeIds = [
|
||||||
...new Set(incomingWagons.map((w) => w.wagonTypeId).filter(Boolean)),
|
...new Set(
|
||||||
|
[...incomingWagons, ...plan.looseSourceWagons]
|
||||||
|
.map((w) => w.wagonTypeId)
|
||||||
|
.filter(Boolean),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
const wagonTypes = wagonTypeIds.length
|
const wagonTypes = wagonTypeIds.length
|
||||||
? await this.dataSource
|
? await this.dataSource
|
||||||
@@ -9397,16 +9502,27 @@ export class TrainSchedulingService {
|
|||||||
.find({ where: { id: In(wagonTypeIds) } })
|
.find({ where: { id: In(wagonTypeIds) } })
|
||||||
: [];
|
: [];
|
||||||
const typeById = new Map(wagonTypes.map((t) => [t.id, t]));
|
const typeById = new Map(wagonTypes.map((t) => [t.id, t]));
|
||||||
const incomingSlots = incomingWagons.map((w) => {
|
const slotFromWagon = (w: Wagon) => {
|
||||||
const t = typeById.get(w.wagonTypeId);
|
const t = typeById.get(w.wagonTypeId);
|
||||||
return {
|
return {
|
||||||
lengthMeters: Number(t?.lengthMeters) || 0,
|
lengthMeters: Number(t?.lengthMeters) || 0,
|
||||||
tareWeightTons: Number(t?.tareWeightTons) || 0,
|
tareWeightTons: Number(t?.tareWeightTons) || 0,
|
||||||
cargoTons: 0,
|
cargoTons: 0,
|
||||||
};
|
};
|
||||||
});
|
};
|
||||||
|
const incomingSlots = incomingWagons.map(slotFromWagon);
|
||||||
|
const looseSlots = plan.looseSourceWagons.map(slotFromWagon);
|
||||||
|
|
||||||
const limits = trainSetLocomotiveLimits(schedule.trainSet);
|
// The merged train is pulled by the union of this schedule's locomotives
|
||||||
|
// and whatever already pulls the target train (its sets keep their locos).
|
||||||
|
// Pull weight adds up across the pool; length stays the tightest cap.
|
||||||
|
const locoPool = [
|
||||||
|
...this.locomotivesOfTrainSet(schedule.trainSet),
|
||||||
|
...plan.targetSets.flatMap((set) => this.locomotivesOfTrainSet(set)),
|
||||||
|
];
|
||||||
|
const limits = combinedLocomotiveLimits([
|
||||||
|
...new Map(locoPool.map((l) => [l.id, l])).values(),
|
||||||
|
]);
|
||||||
if (limits) {
|
if (limits) {
|
||||||
const rules = await this.dataSource
|
const rules = await this.dataSource
|
||||||
.getRepository(TrainSchedulingGlobalRules)
|
.getRepository(TrainSchedulingGlobalRules)
|
||||||
@@ -9415,13 +9531,17 @@ export class TrainSchedulingService {
|
|||||||
maxTrainWeightTons: rules[0]?.maxTrainWeightTons ?? undefined,
|
maxTrainWeightTons: rules[0]?.maxTrainWeightTons ?? undefined,
|
||||||
maxTrainLengthMeters: rules[0]?.maxTrainLengthMeters ?? undefined,
|
maxTrainLengthMeters: rules[0]?.maxTrainLengthMeters ?? undefined,
|
||||||
});
|
});
|
||||||
const merged = [...existingSlots, ...incomingSlots];
|
const merged = [...existingSlots, ...looseSlots, ...incomingSlots];
|
||||||
// maxWagons is the schedule's own slot ceiling; fall back to the consist
|
// Merge is a physical consist move, so only the physical axes gate it:
|
||||||
// size when it is unset so the count axis never blocks spuriously.
|
// can this schedule's locomotives pull the merged weight and length.
|
||||||
|
// `schedule.maxWagons` is the booking-window planning ceiling — using it
|
||||||
|
// as a slot cap here blocked every merge into a bigger train (e.g. a
|
||||||
|
// 3-wagon plan absorbing a 47-wagon train). The commit raises the
|
||||||
|
// ceiling to the merged size instead.
|
||||||
const violations = consistViolations(merged, {
|
const violations = consistViolations(merged, {
|
||||||
maxWeightTons: caps.maxWeightTons,
|
maxWeightTons: caps.maxWeightTons,
|
||||||
maxLengthMeters: caps.maxLengthMeters,
|
maxLengthMeters: caps.maxLengthMeters,
|
||||||
maxWagonSlots: schedule.maxWagons || merged.length,
|
maxWagonSlots: merged.length,
|
||||||
});
|
});
|
||||||
blockers.push(...violations);
|
blockers.push(...violations);
|
||||||
}
|
}
|
||||||
@@ -9481,7 +9601,10 @@ export class TrainSchedulingService {
|
|||||||
const plan = await this.planMerge(scheduleId, targetTrainId);
|
const plan = await this.planMerge(scheduleId, targetTrainId);
|
||||||
const blockers = await this.mergeBlockers(plan);
|
const blockers = await this.mergeBlockers(plan);
|
||||||
|
|
||||||
const existingCount = plan.schedule.trainSet?.wagons?.length ?? 0;
|
// Coupled slots plus loose wagons on the source train — everything moves.
|
||||||
|
const existingCount =
|
||||||
|
(plan.schedule.trainSet?.wagons?.length ?? 0) +
|
||||||
|
plan.looseSourceWagons.length;
|
||||||
return {
|
return {
|
||||||
canMerge: blockers.length === 0,
|
canMerge: blockers.length === 0,
|
||||||
blockers,
|
blockers,
|
||||||
@@ -9555,13 +9678,20 @@ export class TrainSchedulingService {
|
|||||||
trainId: targetTrain.id,
|
trainId: targetTrain.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. The physical wagons follow the train.
|
// 2. The physical wagons follow the train — the target's stay put, and
|
||||||
|
// EVERY wagon on the source train (coupled or loose) moves across so
|
||||||
|
// nothing strands on the deactivated train.
|
||||||
if (incomingWagons.length) {
|
if (incomingWagons.length) {
|
||||||
await manager.getRepository(Wagon).update(
|
await manager.getRepository(Wagon).update(
|
||||||
{ id: In(incomingWagons.map((w) => w.id)) },
|
{ id: In(incomingWagons.map((w) => w.id)) },
|
||||||
{ trainId: targetTrain.id },
|
{ trainId: targetTrain.id },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (sourceTrainId) {
|
||||||
|
await manager
|
||||||
|
.getRepository(Wagon)
|
||||||
|
.update({ trainId: sourceTrainId }, { trainId: targetTrain.id });
|
||||||
|
}
|
||||||
|
|
||||||
// 3. Carry the target's train-set wagon rows into THIS consist, appended
|
// 3. Carry the target's train-set wagon rows into THIS consist, appended
|
||||||
// after the existing wagons. Sequence is provisional — staff reorder
|
// after the existing wagons. Sequence is provisional — staff reorder
|
||||||
@@ -9611,6 +9741,14 @@ export class TrainSchedulingService {
|
|||||||
await manager
|
await manager
|
||||||
.getRepository(TrainSet)
|
.getRepository(TrainSet)
|
||||||
.update(trainSetId, { wagonCount: mergedCount });
|
.update(trainSetId, { wagonCount: mergedCount });
|
||||||
|
|
||||||
|
// 8. Booking capacity follows the consist: raise (never lower) the
|
||||||
|
// planning ceiling so the merged wagons are actually sellable.
|
||||||
|
if (mergedCount > (schedule.maxWagons ?? 0)) {
|
||||||
|
await manager
|
||||||
|
.getRepository(TrainSchedule)
|
||||||
|
.update(schedule.id, { maxWagons: mergedCount });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
|
|||||||
@@ -68,6 +68,23 @@ describe('computeScheduleWagonUsage', () => {
|
|||||||
expect(usage.wagonsRemaining).toBe(0);
|
expect(usage.wagonsRemaining).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('sells against the planned ceiling, not the partially coupled consist', () => {
|
||||||
|
// S-2026-00003: planned 3 wagons, 2 coupled + allocated for a paid booking
|
||||||
|
// that reserved 2 — the list read "2/2 used, 0 bookable" while the detail
|
||||||
|
// page and the booking gate (remainingWagonsForLeg vs maxWagons) both said
|
||||||
|
// 1 wagon was still free. Wagons couple on demand; the ceiling is capacity.
|
||||||
|
const usage = computeScheduleWagonUsage({
|
||||||
|
wagonSlots: Array(2).fill(slot(true)),
|
||||||
|
storedWagonCount: 2,
|
||||||
|
scheduleBookings: [booking(2)],
|
||||||
|
maxWagons: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(usage.wagonsUsed).toBe(2);
|
||||||
|
expect(usage.wagonsTotal).toBe(3);
|
||||||
|
expect(usage.wagonsRemaining).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('falls back to the stored counter when slot rows were not loaded', () => {
|
it('falls back to the stored counter when slot rows were not loaded', () => {
|
||||||
const usage = computeScheduleWagonUsage({
|
const usage = computeScheduleWagonUsage({
|
||||||
wagonSlots: [],
|
wagonSlots: [],
|
||||||
|
|||||||
@@ -20,11 +20,11 @@ export interface ScheduleBookingLike {
|
|||||||
export interface ScheduleWagonUsage {
|
export interface ScheduleWagonUsage {
|
||||||
/** Coupled slots carrying at least one booking allocation. */
|
/** Coupled slots carrying at least one booking allocation. */
|
||||||
wagonsUsed: number;
|
wagonsUsed: number;
|
||||||
/** Coupled consist size — the denominator of `wagonsUsed`. */
|
/** Schedule capacity — the larger of coupled consist and planned ceiling. */
|
||||||
wagonsTotal: number;
|
wagonsTotal: number;
|
||||||
/** Wagons claimed by bookings, including bookings that have not paid. */
|
/** Wagons claimed by bookings, including bookings that have not paid. */
|
||||||
wagonsReserved: number;
|
wagonsReserved: number;
|
||||||
/** Consist minus what bookings have claimed — what is still bookable. */
|
/** Capacity minus what bookings have claimed — what is still bookable. */
|
||||||
wagonsRemaining: number;
|
wagonsRemaining: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +33,8 @@ export function computeScheduleWagonUsage(input: {
|
|||||||
/** Stored counter; used only when the slot rows were not loaded. */
|
/** Stored counter; used only when the slot rows were not loaded. */
|
||||||
storedWagonCount?: number | null;
|
storedWagonCount?: number | null;
|
||||||
scheduleBookings?: ScheduleBookingLike[] | null;
|
scheduleBookings?: ScheduleBookingLike[] | null;
|
||||||
|
/** Planned wagon ceiling (`maxWagons`) — what booking capacity is sold against. */
|
||||||
|
maxWagons?: number | null;
|
||||||
}): ScheduleWagonUsage {
|
}): ScheduleWagonUsage {
|
||||||
const slots = input.wagonSlots ?? [];
|
const slots = input.wagonSlots ?? [];
|
||||||
|
|
||||||
@@ -42,7 +44,14 @@ export function computeScheduleWagonUsage(input: {
|
|||||||
|
|
||||||
// Prefer live slot rows; the stored counter drifts when a consist is edited
|
// Prefer live slot rows; the stored counter drifts when a consist is edited
|
||||||
// without a recompute, which is why the list and detail disagreed on totals.
|
// without a recompute, which is why the list and detail disagreed on totals.
|
||||||
const wagonsTotal = slots.length || (input.storedWagonCount ?? 0);
|
const coupled = slots.length || (input.storedWagonCount ?? 0);
|
||||||
|
|
||||||
|
// Wagons are coupled on demand as bookings are allocated, so a partially
|
||||||
|
// built consist does not cap what is bookable — the planned ceiling does
|
||||||
|
// (remainingWagonsForLeg sells against maxWagons). Without this, a schedule
|
||||||
|
// planned for 3 wagons with 2 coupled+allocated read "2/2 used, 0 bookable"
|
||||||
|
// while its detail page and the booking gate both said 1 wagon was free.
|
||||||
|
const wagonsTotal = Math.max(coupled, input.maxWagons ?? 0);
|
||||||
|
|
||||||
// An unpaid booking still holds its wagons, so reserved space is NOT bookable.
|
// An unpaid booking still holds its wagons, so reserved space is NOT bookable.
|
||||||
const wagonsReserved = (input.scheduleBookings ?? []).reduce(
|
const wagonsReserved = (input.scheduleBookings ?? []).reduce(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable, Logger } from "@nestjs/common";
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { DataSource } from "typeorm";
|
import { DataSource } from "typeorm";
|
||||||
|
|
||||||
|
import { FileUploadField } from "../modules/file-upload-settings/entities/file-upload-field.entity";
|
||||||
import { FileUploadSetting } from "../modules/file-upload-settings/entities/file-upload-setting.entity";
|
import { FileUploadSetting } from "../modules/file-upload-settings/entities/file-upload-setting.entity";
|
||||||
import { poaDelegationField } from "../modules/file-upload-settings/poa-delegation.constants";
|
import { poaDelegationField } from "../modules/file-upload-settings/poa-delegation.constants";
|
||||||
|
|
||||||
@@ -165,10 +166,13 @@ const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [
|
|||||||
* actually produce is a backoffice decision, edited in the file-settings editor.
|
* actually produce is a backoffice decision, edited in the file-settings editor.
|
||||||
*/
|
*/
|
||||||
const COOPERATIVE_ONBOARDING_FIELDS: OnboardingField[] = [
|
const COOPERATIVE_ONBOARDING_FIELDS: OnboardingField[] = [
|
||||||
|
// First on purpose: only the first field of a new set is seeded, and this is
|
||||||
|
// the paper that distinguishes a co-operative from every other company.
|
||||||
{
|
{
|
||||||
fileKey: "tin_certificate",
|
fileKey: "cooperative_registration_certificate",
|
||||||
fileLabel: "TIN Certificate",
|
fileLabel: "Co-operative Union / Farm Registration Certificate",
|
||||||
helpText: "Verified against the TIN registry during registration.",
|
helpText:
|
||||||
|
"Certificate issued by the co-operative promotion agency that registered the union or farm.",
|
||||||
isRequired: true,
|
isRequired: true,
|
||||||
isMultiple: false,
|
isMultiple: false,
|
||||||
maxFiles: 1,
|
maxFiles: 1,
|
||||||
@@ -177,10 +181,9 @@ const COOPERATIVE_ONBOARDING_FIELDS: OnboardingField[] = [
|
|||||||
displayOrder: 1,
|
displayOrder: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
fileKey: "cooperative_registration_certificate",
|
fileKey: "tin_certificate",
|
||||||
fileLabel: "Co-operative Union / Farm Registration Certificate",
|
fileLabel: "TIN Certificate",
|
||||||
helpText:
|
helpText: "Verified against the TIN registry during registration.",
|
||||||
"Certificate issued by the co-operative promotion agency that registered the union or farm.",
|
|
||||||
isRequired: true,
|
isRequired: true,
|
||||||
isMultiple: false,
|
isMultiple: false,
|
||||||
maxFiles: 1,
|
maxFiles: 1,
|
||||||
@@ -662,16 +665,15 @@ export class FileUploadSettingsSeeder {
|
|||||||
async run() {
|
async run() {
|
||||||
const settingRepository = this.dataSource.getRepository(FileUploadSetting);
|
const settingRepository = this.dataSource.getRepository(FileUploadSetting);
|
||||||
|
|
||||||
// Seed only into an empty table: any existing rows (including
|
// Seed per CODE, not "only into an empty table". An existing row is
|
||||||
// soft-deleted ones, which would still conflict on the unique `code`)
|
// admin-managed and never touched — including a soft-deleted one, which
|
||||||
// mean the data is admin-managed, so leave it untouched.
|
// means the set was removed on purpose (and would still conflict on the
|
||||||
const existing = await settingRepository.count({ withDeleted: true });
|
// unique `code`). What the table-wide check got wrong is the other half: a
|
||||||
if (existing > 0) {
|
// set added to this file after the first boot could never reach a database
|
||||||
this.logger.log(
|
// that already held the others, so it existed in code and nowhere else.
|
||||||
`file_upload_settings already has ${existing} rows — skipping seed`,
|
const existingCodes = new Set(
|
||||||
);
|
(await settingRepository.find({ withDeleted: true })).map((s) => s.code),
|
||||||
return;
|
);
|
||||||
}
|
|
||||||
|
|
||||||
const allSettings: Array<
|
const allSettings: Array<
|
||||||
OnboardingDocumentSetting & { description: string }
|
OnboardingDocumentSetting & { description: string }
|
||||||
@@ -701,20 +703,41 @@ export class FileUploadSettingsSeeder {
|
|||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Insert setting rows only — no FileUploadField rows. Fields start empty
|
const missing = allSettings.filter((s) => !existingCodes.has(s.code));
|
||||||
// and are configured from the backoffice file-settings editor; the field
|
if (missing.length === 0) {
|
||||||
// definitions above are kept as reference defaults.
|
this.logger.log("file upload settings up to date — nothing to seed");
|
||||||
await settingRepository.insert(
|
return;
|
||||||
allSettings.map((documentSetting) => ({
|
}
|
||||||
code: documentSetting.code,
|
|
||||||
label: documentSetting.label,
|
const inserted = await settingRepository.save(
|
||||||
description: documentSetting.description,
|
missing.map((documentSetting) =>
|
||||||
entity: documentSetting.entity,
|
settingRepository.create({
|
||||||
})),
|
code: documentSetting.code,
|
||||||
|
label: documentSetting.label,
|
||||||
|
description: documentSetting.description,
|
||||||
|
entity: documentSetting.entity,
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// A brand-new set gets exactly ONE field: its first reference default. The
|
||||||
|
// rest of the list above stays documentation — what a set actually asks for
|
||||||
|
// is a backoffice decision, edited in the file-settings editor. Seeding one
|
||||||
|
// means a set is never born empty (an empty set silently requires nothing),
|
||||||
|
// while leaving the admin a single row to extend rather than a list to prune.
|
||||||
|
const fieldRepository = this.dataSource.getRepository(FileUploadField);
|
||||||
|
const firstFields = inserted.flatMap((setting) => {
|
||||||
|
const reference = missing.find((s) => s.code === setting.code)?.fields[0];
|
||||||
|
return reference
|
||||||
|
? [fieldRepository.create({ ...reference, settingId: setting.id })]
|
||||||
|
: [];
|
||||||
|
});
|
||||||
|
if (firstFields.length > 0) await fieldRepository.save(firstFields);
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Seeded ${allSettings.length} file upload settings with empty fields`,
|
`Seeded ${missing.length} file upload settings (${firstFields.length} with a default field): ${missing
|
||||||
|
.map((s) => s.code)
|
||||||
|
.join(", ")}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -482,6 +482,20 @@ export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
"edr_freight_app:invoices:eims_resolve",
|
"edr_freight_app:invoices:eims_resolve",
|
||||||
"Resolve a blocked MoR EIMS submission",
|
"Resolve a blocked MoR EIMS submission",
|
||||||
),
|
),
|
||||||
|
// Cancellation is a separate irreversible-at-MoR action from registration — its own grant,
|
||||||
|
// same reasoning as eims_register.
|
||||||
|
perm(
|
||||||
|
"d2b00001-0001-4000-8000-000000000008",
|
||||||
|
"edr_freight_app:invoices:eims_cancel",
|
||||||
|
"Cancel a registered invoice with MoR EIMS",
|
||||||
|
),
|
||||||
|
// Covers both sales and withholding receipts — same risk profile (filing a document with
|
||||||
|
// MoR), no reason to split further.
|
||||||
|
perm(
|
||||||
|
"d2b00001-0001-4000-8000-000000000009",
|
||||||
|
"edr_freight_app:invoices:eims_receipt_register",
|
||||||
|
"Register a sales or withholding receipt with MoR EIMS",
|
||||||
|
),
|
||||||
// USD bookings are paid by bank transfer; Finance uploads the slip and settles
|
// USD bookings are paid by bank transfer; Finance uploads the slip and settles
|
||||||
// the invoice. Moves money state, so it is its own grant, not part of view.
|
// the invoice. Moves money state, so it is its own grant, not part of view.
|
||||||
perm(
|
perm(
|
||||||
@@ -1190,22 +1204,28 @@ export const CONFIG_SETTINGS_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
perm(
|
perm(
|
||||||
"b4b00002-0001-4000-8000-000000000001",
|
"b4b00002-0001-4000-8000-000000000001",
|
||||||
"edr_freight_app:settings:stamp:view",
|
"edr_freight_app:settings:stamp:view",
|
||||||
"View stamp settings",
|
"View the company stamp",
|
||||||
),
|
),
|
||||||
perm(
|
perm(
|
||||||
"b4b00002-0001-4000-8000-000000000002",
|
"b4b00002-0001-4000-8000-000000000002",
|
||||||
"edr_freight_app:settings:stamp:manage",
|
"edr_freight_app:settings:stamp:manage",
|
||||||
"Manage stamp settings",
|
"Manage the company stamp",
|
||||||
),
|
),
|
||||||
|
// The per-officer approval teeter (ማህተም) — an individual's own stamp +
|
||||||
|
// signature, not the company seal. It used to ride on settings:stamp:*, which
|
||||||
|
// now gates the ONE company stamp; this key was split out when the two were
|
||||||
|
// untangled. `settings:invoice_stamp:*` retired at the same time: it gated the
|
||||||
|
// company stamp before the fold and is deliberately left orphaned in any DB
|
||||||
|
// that already seeded it (the seeder upserts by key and never deletes).
|
||||||
perm(
|
perm(
|
||||||
"b4b00003-0001-4000-8000-000000000001",
|
"b4b00003-0001-4000-8000-000000000001",
|
||||||
"edr_freight_app:settings:invoice_stamp:view",
|
"edr_freight_app:settings:teeter:view",
|
||||||
"View invoice stamp settings",
|
"View own approval teeter and signature",
|
||||||
),
|
),
|
||||||
perm(
|
perm(
|
||||||
"b4b00003-0001-4000-8000-000000000002",
|
"b4b00003-0001-4000-8000-000000000002",
|
||||||
"edr_freight_app:settings:invoice_stamp:manage",
|
"edr_freight_app:settings:teeter:manage",
|
||||||
"Manage invoice stamp settings",
|
"Manage own approval teeter and signature",
|
||||||
),
|
),
|
||||||
perm(
|
perm(
|
||||||
"b4c00001-0001-4000-8000-000000000001",
|
"b4c00001-0001-4000-8000-000000000001",
|
||||||
@@ -1701,6 +1721,8 @@ export const FREIGHT_PERMS = {
|
|||||||
export: "edr_freight_app:invoices:export",
|
export: "edr_freight_app:invoices:export",
|
||||||
eimsRegister: "edr_freight_app:invoices:eims_register",
|
eimsRegister: "edr_freight_app:invoices:eims_register",
|
||||||
eimsResolve: "edr_freight_app:invoices:eims_resolve",
|
eimsResolve: "edr_freight_app:invoices:eims_resolve",
|
||||||
|
eimsCancel: "edr_freight_app:invoices:eims_cancel",
|
||||||
|
eimsReceiptRegister: "edr_freight_app:invoices:eims_receipt_register",
|
||||||
confirmOffline: "edr_freight_app:invoices:confirm_offline",
|
confirmOffline: "edr_freight_app:invoices:confirm_offline",
|
||||||
},
|
},
|
||||||
firstMile: {
|
firstMile: {
|
||||||
@@ -1907,15 +1929,18 @@ export const FREIGHT_PERMS = {
|
|||||||
view: "edr_freight_app:settings:dropdown:view",
|
view: "edr_freight_app:settings:dropdown:view",
|
||||||
manage: "edr_freight_app:settings:dropdown:manage",
|
manage: "edr_freight_app:settings:dropdown:manage",
|
||||||
},
|
},
|
||||||
|
// The ONE company stamp/seal, applied to every generated document
|
||||||
|
// (invoices, receipts, warehouse papers, the EDR side of contracts).
|
||||||
stamp: {
|
stamp: {
|
||||||
view: "edr_freight_app:settings:stamp:view",
|
view: "edr_freight_app:settings:stamp:view",
|
||||||
manage: "edr_freight_app:settings:stamp:manage",
|
manage: "edr_freight_app:settings:stamp:manage",
|
||||||
},
|
},
|
||||||
// Company stamp/seal image stamped onto invoice/receipt PDFs — separate
|
// The per-officer approval teeter (ማህተም) + signature — genuinely per-person,
|
||||||
// from `stamp` above, which is the per-employee approval-record teeter.
|
// and NOT the company seal above. Retired: `invoiceStamp`, which used to
|
||||||
invoiceStamp: {
|
// gate the company stamp before the two were untangled.
|
||||||
view: "edr_freight_app:settings:invoice_stamp:view",
|
teeter: {
|
||||||
manage: "edr_freight_app:settings:invoice_stamp:manage",
|
view: "edr_freight_app:settings:teeter:view",
|
||||||
|
manage: "edr_freight_app:settings:teeter:manage",
|
||||||
},
|
},
|
||||||
exchangeRate: {
|
exchangeRate: {
|
||||||
view: "edr_freight_app:settings:exchange_rate:view",
|
view: "edr_freight_app:settings:exchange_rate:view",
|
||||||
@@ -2216,10 +2241,10 @@ export const ROLE_PERMISSION_PRESETS = {
|
|||||||
FREIGHT_PERMS.bookings.view,
|
FREIGHT_PERMS.bookings.view,
|
||||||
FREIGHT_PERMS.invoices.view,
|
FREIGHT_PERMS.invoices.view,
|
||||||
FREIGHT_PERMS.invoices.export,
|
FREIGHT_PERMS.invoices.export,
|
||||||
// Deliberately NOT granted here: invoices:eims_register and invoices:eims_resolve.
|
// Deliberately NOT granted here: invoices:eims_register, eims_resolve, eims_cancel,
|
||||||
// Invoices are filed with MoR by the workflow, not by a person, so filing is not a
|
// eims_receipt_register. Invoices are filed with MoR by the workflow, not by a person, so
|
||||||
// Finance job function — the endpoints exist for controlled testing and exceptional
|
// filing is not a Finance job function — the endpoints exist for controlled testing and
|
||||||
// operations, and are assigned to named admins rather than a role preset.
|
// exceptional operations, and are assigned to named admins rather than a role preset.
|
||||||
FREIGHT_PERMS.payments.view,
|
FREIGHT_PERMS.payments.view,
|
||||||
FREIGHT_PERMS.bookings.wagonCancellationView,
|
FREIGHT_PERMS.bookings.wagonCancellationView,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -51,8 +51,7 @@ import { NO_ACCESS_PATH, resolveLandingPath } from "./lib/landing";
|
|||||||
import NoAccessPage from "./pages/NoAccessPage";
|
import NoAccessPage from "./pages/NoAccessPage";
|
||||||
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
|
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
|
||||||
import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage";
|
import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage";
|
||||||
import StampSettings from "./record-management/components/Settings/uploadTeeterandSingature";
|
import CompanyStampSettingsPage from "./pages/settings/CompanyStampSettingsPage";
|
||||||
import InvoiceStampSettingsPage from "./pages/settings/InvoiceStampSettingsPage";
|
|
||||||
import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage";
|
import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage";
|
||||||
import PortalContentPage from "./pages/portal_content/PortalContentPage";
|
import PortalContentPage from "./pages/portal_content/PortalContentPage";
|
||||||
import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage";
|
import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage";
|
||||||
@@ -786,23 +785,27 @@ const App = () => {
|
|||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{/*
|
||||||
|
The ONE company stamp, for every generated document. The per-officer
|
||||||
|
teeter (ማህተም) that used to sit beside it at /dashboard/stamp-settings
|
||||||
|
now lives at /user-management/teeter-and-signature — it is a different
|
||||||
|
thing (an individual's approval stamp), and pairing the two here was
|
||||||
|
the duplication.
|
||||||
|
*/}
|
||||||
<Route
|
<Route
|
||||||
path="stamp-settings"
|
path="stamp-settings"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.settings.stamp.view}>
|
<RequirePermission
|
||||||
<StampSettings />
|
permission={FREIGHT_PERMS.settings.stamp.view}
|
||||||
|
>
|
||||||
|
<CompanyStampSettingsPage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{/* Old URL kept alive so existing links/bookmarks do not 404. */}
|
||||||
<Route
|
<Route
|
||||||
path="invoice-stamp-settings"
|
path="invoice-stamp-settings"
|
||||||
element={
|
element={<Navigate to="/dashboard/stamp-settings" replace />}
|
||||||
<RequirePermission
|
|
||||||
permission={FREIGHT_PERMS.settings.invoiceStamp.view}
|
|
||||||
>
|
|
||||||
<InvoiceStampSettingsPage />
|
|
||||||
</RequirePermission>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="contract-templates"
|
path="contract-templates"
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { Badge, Card, Divider, Group, Stack, Text } from "@mantine/core";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export interface PersonField {
|
||||||
|
label: string;
|
||||||
|
value?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PersonCardProps {
|
||||||
|
/** OWNER / POA / CONTACT PERSON — the person's role, not their name. */
|
||||||
|
title: string;
|
||||||
|
icon?: ReactNode;
|
||||||
|
/**
|
||||||
|
* Identity state. `undefined` = this person has no identity check at all
|
||||||
|
* (contact person), so no badge is rendered rather than a misleading "not
|
||||||
|
* verified" one.
|
||||||
|
*/
|
||||||
|
verified?: boolean;
|
||||||
|
/** Extra pills after the verification badge (e.g. "Verifies for this company"). */
|
||||||
|
badges?: ReactNode;
|
||||||
|
/** Rendered between the header and the fields — alerts, match warnings. */
|
||||||
|
notice?: ReactNode;
|
||||||
|
fields: PersonField[];
|
||||||
|
/** Shown when the API returned nothing for every field. */
|
||||||
|
emptyMessage: string;
|
||||||
|
/** Attachments or anything else that belongs to this person. */
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One person in the customer's people column: owner, power of attorney, contact
|
||||||
|
* person. Empty fields are dropped rather than rendered as "—", so a field the
|
||||||
|
* API stops sending simply disappears instead of leaving a dead row behind.
|
||||||
|
*/
|
||||||
|
export function PersonCard({
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
verified,
|
||||||
|
badges,
|
||||||
|
notice,
|
||||||
|
fields,
|
||||||
|
emptyMessage,
|
||||||
|
children,
|
||||||
|
}: PersonCardProps) {
|
||||||
|
const filled = fields.filter(
|
||||||
|
(f) => f.value != null && String(f.value).trim(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Group gap={8} wrap="wrap">
|
||||||
|
{icon}
|
||||||
|
<Text
|
||||||
|
size="xs"
|
||||||
|
fw={700}
|
||||||
|
c="edr-muted"
|
||||||
|
tt="uppercase"
|
||||||
|
style={{ letterSpacing: "0.06em" }}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{verified !== undefined &&
|
||||||
|
(verified ? (
|
||||||
|
<Badge size="xs" color="edr-green" variant="light">
|
||||||
|
Fayda verified
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge size="xs" color="gray" variant="light">
|
||||||
|
Not verified
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{badges}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{notice}
|
||||||
|
|
||||||
|
{filled.length > 0 ? (
|
||||||
|
<Stack gap="xs">
|
||||||
|
{filled.map((f) => (
|
||||||
|
<Stack key={f.label} gap={0}>
|
||||||
|
<Text size="xs" c="edr-muted">
|
||||||
|
{f.label}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
size="sm"
|
||||||
|
c="edr-text"
|
||||||
|
style={{ wordBreak: "break-word" }}
|
||||||
|
>
|
||||||
|
{f.value}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{emptyMessage}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{children && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PersonCard;
|
||||||
@@ -24,4 +24,9 @@ export {
|
|||||||
type ResetPasswordActionProps,
|
type ResetPasswordActionProps,
|
||||||
} from "./ResetPasswordAction";
|
} from "./ResetPasswordAction";
|
||||||
export { formatBytes, formatDate, formatMoney, humanize } from "./format";
|
export { formatBytes, formatDate, formatMoney, humanize } from "./format";
|
||||||
|
export {
|
||||||
|
PersonCard,
|
||||||
|
type PersonCardProps,
|
||||||
|
type PersonField,
|
||||||
|
} from "./PersonCard";
|
||||||
export { TableCard, type TableCardProps } from "./TableCard";
|
export { TableCard, type TableCardProps } from "./TableCard";
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
PackageOpen,
|
PackageOpen,
|
||||||
Paperclip,
|
Paperclip,
|
||||||
Receipt,
|
Receipt,
|
||||||
|
Stamp,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
Send,
|
Send,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -486,17 +487,14 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[]
|
|||||||
permission: FREIGHT_PERMS.settings.dropdown.view,
|
permission: FREIGHT_PERMS.settings.dropdown.view,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Stamp settings",
|
// One entry, one stamp. The former "Stamp settings" entry here pointed
|
||||||
|
// at the per-officer teeter (ማህተም), not a company seal — it moved to
|
||||||
|
// /user-management/teeter-and-signature.
|
||||||
|
label: "Company stamp",
|
||||||
href: "/dashboard/stamp-settings",
|
href: "/dashboard/stamp-settings",
|
||||||
icon: <FileSignature />,
|
icon: <Stamp />,
|
||||||
permission: FREIGHT_PERMS.settings.stamp.view,
|
permission: FREIGHT_PERMS.settings.stamp.view,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: "Invoice stamp",
|
|
||||||
href: "/dashboard/invoice-stamp-settings",
|
|
||||||
icon: <Receipt />,
|
|
||||||
permission: FREIGHT_PERMS.settings.invoiceStamp.view,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: "Contract templates",
|
label: "Contract templates",
|
||||||
href: "/dashboard/contract-templates",
|
href: "/dashboard/contract-templates",
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Mail, Loader2 } from "lucide-react";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
Input,
|
||||||
|
Label,
|
||||||
|
} from "@edr/ui-common";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lets the signed-in backoffice user change their own email. Goes through
|
||||||
|
* /me/contact/otp + /me/contact rather than the generic (unverified)
|
||||||
|
* /auth/update-profile route, so the new address is proven before it's
|
||||||
|
* written — see account.controller.ts on the API side.
|
||||||
|
*/
|
||||||
|
export function ChangeEmailCard() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const sendOtpMutation = useMutation(
|
||||||
|
api.account.sendContactOtp.mutationOptions(),
|
||||||
|
);
|
||||||
|
const updateContactMutation = useMutation(
|
||||||
|
api.account.updateContact.mutationOptions(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [step, setStep] = useState<"enterEmail" | "enterOtp">("enterEmail");
|
||||||
|
const [newEmail, setNewEmail] = useState("");
|
||||||
|
const [otp, setOtp] = useState("");
|
||||||
|
const [formError, setFormError] = useState("");
|
||||||
|
|
||||||
|
const closeDialog = () => {
|
||||||
|
setOpen(false);
|
||||||
|
setStep("enterEmail");
|
||||||
|
setNewEmail("");
|
||||||
|
setOtp("");
|
||||||
|
setFormError("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendOtp = () => {
|
||||||
|
setFormError("");
|
||||||
|
if (!newEmail.trim()) {
|
||||||
|
setFormError("Enter the new email address.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendOtpMutation.mutate(
|
||||||
|
{ channel: "email", value: newEmail.trim() },
|
||||||
|
{
|
||||||
|
onSuccess: (result) => {
|
||||||
|
toast.success(`Verification code sent to ${result.sentTo}`);
|
||||||
|
setStep("enterOtp");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmOtp = () => {
|
||||||
|
setFormError("");
|
||||||
|
if (!otp.trim()) {
|
||||||
|
setFormError("Enter the verification code.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateContactMutation.mutate(
|
||||||
|
{ channel: "email", value: newEmail.trim(), otp: otp.trim() },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Email updated.");
|
||||||
|
closeDialog();
|
||||||
|
// Refetches the session so the new email shows everywhere — simplest
|
||||||
|
// way to refresh the cached user without a dedicated context method.
|
||||||
|
window.location.reload();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Mail className="size-4" />
|
||||||
|
Email
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{user?.email ? `Current email: ${user.email}` : "Change your account email."}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||||
|
Change email
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<Dialog open={open} onOpenChange={(next) => !next && closeDialog()}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Change email</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{step === "enterEmail"
|
||||||
|
? "We'll send a verification code to the new address."
|
||||||
|
: `Enter the code sent to ${newEmail}.`}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{step === "enterEmail" ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="newEmail">New email</Label>
|
||||||
|
<Input
|
||||||
|
id="newEmail"
|
||||||
|
type="email"
|
||||||
|
value={newEmail}
|
||||||
|
onChange={(e) => setNewEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
{formError && <p className="text-sm text-destructive">{formError}</p>}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="otp">Verification code</Label>
|
||||||
|
<Input
|
||||||
|
id="otp"
|
||||||
|
value={otp}
|
||||||
|
onChange={(e) => setOtp(e.target.value)}
|
||||||
|
/>
|
||||||
|
{formError && <p className="text-sm text-destructive">{formError}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={closeDialog}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
{step === "enterEmail" ? (
|
||||||
|
<Button disabled={sendOtpMutation.isPending} onClick={sendOtp}>
|
||||||
|
{sendOtpMutation.isPending ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Send code"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button disabled={updateContactMutation.isPending} onClick={confirmOtp}>
|
||||||
|
{updateContactMutation.isPending ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Confirm"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { KeyRound, Loader2 } from "lucide-react";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
Input,
|
||||||
|
Label,
|
||||||
|
} from "@edr/ui-common";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lets the signed-in backoffice user change their own password. The account
|
||||||
|
* is logged out on success — the old token was issued under the old
|
||||||
|
* password, and this forces a clean re-login rather than trusting the
|
||||||
|
* server to keep the existing session valid.
|
||||||
|
*/
|
||||||
|
export function ChangePasswordCard() {
|
||||||
|
const { logout } = useAuth();
|
||||||
|
const changePasswordMutation = useMutation(
|
||||||
|
api.account.changePassword.mutationOptions(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [oldPassword, setOldPassword] = useState("");
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const [formError, setFormError] = useState("");
|
||||||
|
|
||||||
|
const closeDialog = () => {
|
||||||
|
setOpen(false);
|
||||||
|
setOldPassword("");
|
||||||
|
setNewPassword("");
|
||||||
|
setConfirmPassword("");
|
||||||
|
setFormError("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
setFormError("");
|
||||||
|
|
||||||
|
if (!oldPassword || !newPassword || !confirmPassword) {
|
||||||
|
setFormError("All fields are required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPassword.length < 8) {
|
||||||
|
setFormError("New password must be at least 8 characters.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPassword === oldPassword) {
|
||||||
|
setFormError("New password must be different from the current one.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setFormError("New password and confirmation do not match.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
changePasswordMutation.mutate(
|
||||||
|
{ oldPassword, newPassword, confirmPassword },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Password changed. Please sign in again.");
|
||||||
|
closeDialog();
|
||||||
|
setTimeout(logout, 1200);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<KeyRound className="size-4" />
|
||||||
|
Password
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Change the password for your account.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||||
|
Change password
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<Dialog open={open} onOpenChange={(next) => !next && closeDialog()}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Change password</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
You'll be signed out and asked to log in again once it's changed.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="oldPassword">Current password</Label>
|
||||||
|
<Input
|
||||||
|
id="oldPassword"
|
||||||
|
type="password"
|
||||||
|
value={oldPassword}
|
||||||
|
onChange={(e) => setOldPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="newPassword">New password</Label>
|
||||||
|
<Input
|
||||||
|
id="newPassword"
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="confirmPassword">Confirm new password</Label>
|
||||||
|
<Input
|
||||||
|
id="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{formError && <p className="text-sm text-destructive">{formError}</p>}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={closeDialog}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button disabled={changePasswordMutation.isPending} onClick={submit}>
|
||||||
|
{changePasswordMutation.isPending ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Change password"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -319,7 +319,7 @@ export const TopBar = () => {
|
|||||||
{t("header.viewProfile")}
|
{t("header.viewProfile")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => navigate("/change-password")}
|
onClick={() => navigate("/dashboard/profile")}
|
||||||
className="cursor-pointer hover:bg-primary-50 dark:hover:bg-primary-900/30 hover:text-primary-700 dark:hover:text-primary-400 py-2.5">
|
className="cursor-pointer hover:bg-primary-50 dark:hover:bg-primary-900/30 hover:text-primary-700 dark:hover:text-primary-400 py-2.5">
|
||||||
<Key className="w-4 h-4 mr-3" />
|
<Key className="w-4 h-4 mr-3" />
|
||||||
{t("header.changePassword")}
|
{t("header.changePassword")}
|
||||||
|
|||||||
@@ -328,15 +328,18 @@ export const FREIGHT_PERMS = {
|
|||||||
view: "edr_freight_app:settings:dropdown:view",
|
view: "edr_freight_app:settings:dropdown:view",
|
||||||
manage: "edr_freight_app:settings:dropdown:manage",
|
manage: "edr_freight_app:settings:dropdown:manage",
|
||||||
},
|
},
|
||||||
|
// The ONE company stamp/seal, applied to every generated document
|
||||||
|
// (invoices, receipts, warehouse papers, the EDR side of contracts).
|
||||||
stamp: {
|
stamp: {
|
||||||
view: "edr_freight_app:settings:stamp:view",
|
view: "edr_freight_app:settings:stamp:view",
|
||||||
manage: "edr_freight_app:settings:stamp:manage",
|
manage: "edr_freight_app:settings:stamp:manage",
|
||||||
},
|
},
|
||||||
// Company stamp/seal image stamped onto invoice/receipt PDFs — separate
|
// The per-officer approval teeter (ማህተም) + signature — genuinely per-person,
|
||||||
// from `stamp` above, which is the per-employee approval-record teeter.
|
// and NOT the company seal above. Retired: `invoiceStamp`, which used to
|
||||||
invoiceStamp: {
|
// gate the company stamp before the two were untangled.
|
||||||
view: "edr_freight_app:settings:invoice_stamp:view",
|
teeter: {
|
||||||
manage: "edr_freight_app:settings:invoice_stamp:manage",
|
view: "edr_freight_app:settings:teeter:view",
|
||||||
|
manage: "edr_freight_app:settings:teeter:manage",
|
||||||
},
|
},
|
||||||
exchangeRate: {
|
exchangeRate: {
|
||||||
view: "edr_freight_app:settings:exchange_rate:view",
|
view: "edr_freight_app:settings:exchange_rate:view",
|
||||||
|
|||||||
@@ -502,7 +502,7 @@ const Header = () => {
|
|||||||
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="flex items-center gap-3 px-3 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-primary-50 dark:hover:bg-primary-900/30 hover:text-primary-700 dark:hover:text-primary-400 rounded-lg cursor-pointer transition-colors"
|
className="flex items-center gap-3 px-3 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-primary-50 dark:hover:bg-primary-900/30 hover:text-primary-700 dark:hover:text-primary-400 rounded-lg cursor-pointer transition-colors"
|
||||||
onClick={() => navigate("/change-password")}
|
onClick={() => navigate("/dashboard/profile")}
|
||||||
>
|
>
|
||||||
<Key className="w-4 h-4 text-primary-600 dark:text-primary-400" />
|
<Key className="w-4 h-4 text-primary-600 dark:text-primary-400" />
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
|
|||||||
@@ -38,7 +38,10 @@ import {
|
|||||||
} from "@/hooks/contract-templates/useContractTemplates";
|
} from "@/hooks/contract-templates/useContractTemplates";
|
||||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||||
import { cargoTypesService } from "@/services/cargo-types.service";
|
import { cargoTypesService } from "@/services/cargo-types.service";
|
||||||
import type { ContractTemplate } from "@/services/contract-templates.service";
|
import type {
|
||||||
|
BulkTemplateDirection,
|
||||||
|
ContractTemplate,
|
||||||
|
} from "@/services/contract-templates.service";
|
||||||
import TemplatePreviewModal from "./TemplatePreviewModal";
|
import TemplatePreviewModal from "./TemplatePreviewModal";
|
||||||
|
|
||||||
const DIRECTION_LABEL: Record<string, string> = {
|
const DIRECTION_LABEL: Record<string, string> = {
|
||||||
@@ -68,6 +71,14 @@ function customsVariant(template: ContractTemplate): boolean | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bulk templates carry the direction on the row; the fixed container codes
|
||||||
|
// carry it as the code prefix.
|
||||||
|
function directionOf(template: ContractTemplate): string {
|
||||||
|
return isBulk(template)
|
||||||
|
? template.tradeDirection ?? "INTERCITY"
|
||||||
|
: template.code.split("_")[0];
|
||||||
|
}
|
||||||
|
|
||||||
function formatUpdated(value: string): string {
|
function formatUpdated(value: string): string {
|
||||||
return new Date(value).toLocaleDateString("en-GB", {
|
return new Date(value).toLocaleDateString("en-GB", {
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
@@ -105,7 +116,7 @@ export default function ContractTemplatesPage() {
|
|||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Contract templates"
|
title="Contract templates"
|
||||||
subtitle="The five container contract documents are built in — one per trade direction and customs-clearing option. Bulk contracts are written per cargo type: create one template per commodity and customs option. Articles are fully editable."
|
subtitle="The five container contract documents are built in — one per trade direction and customs-clearing option. Bulk contracts are written per cargo type: create one template per trade direction, customs option and commodity. Articles are fully editable."
|
||||||
action={
|
action={
|
||||||
canCreate ? (
|
canCreate ? (
|
||||||
<Button
|
<Button
|
||||||
@@ -194,9 +205,13 @@ export default function ContractTemplatesPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Staff pick the customs option first, then a bulk cargo type that has
|
* Staff pick the trade direction, then the customs option, then a bulk cargo
|
||||||
* "has contract template" enabled. One template per combination — the API
|
* type that has "has contract template" enabled. One template per
|
||||||
* rejects duplicates, so an existing pairing must be edited instead.
|
* (direction, customs, cargo type) combination — the API rejects duplicates,
|
||||||
|
* so an existing combination must be edited instead.
|
||||||
|
*
|
||||||
|
* Intercity is domestic and crosses no border, so the customs choice does not
|
||||||
|
* apply there and is hidden.
|
||||||
*/
|
*/
|
||||||
function CreateTemplateModal({
|
function CreateTemplateModal({
|
||||||
opened,
|
opened,
|
||||||
@@ -207,9 +222,11 @@ function CreateTemplateModal({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onCreated: (code: string) => void;
|
onCreated: (code: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const [direction, setDirection] = useState<BulkTemplateDirection>("IMPORT");
|
||||||
const [withCustoms, setWithCustoms] = useState<string>("true");
|
const [withCustoms, setWithCustoms] = useState<string>("true");
|
||||||
const [cargoTypeId, setCargoTypeId] = useState<string | null>(null);
|
const [cargoTypeId, setCargoTypeId] = useState<string | null>(null);
|
||||||
const create = useCreateContractTemplate();
|
const create = useCreateContractTemplate();
|
||||||
|
const intercity = direction === "INTERCITY";
|
||||||
|
|
||||||
const { data: cargoTypes, isLoading } = useQuery({
|
const { data: cargoTypes, isLoading } = useQuery({
|
||||||
queryKey: ["cargo-types", "contract-template-options"],
|
queryKey: ["cargo-types", "contract-template-options"],
|
||||||
@@ -238,19 +255,42 @@ function CreateTemplateModal({
|
|||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<div>
|
<div>
|
||||||
<Text size="sm" fw={500} mb={6}>
|
<Text size="sm" fw={500} mb={6}>
|
||||||
Customs clearing
|
Trade direction
|
||||||
</Text>
|
</Text>
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
fullWidth
|
fullWidth
|
||||||
value={withCustoms}
|
value={direction}
|
||||||
onChange={setWithCustoms}
|
onChange={(value) => setDirection(value as BulkTemplateDirection)}
|
||||||
data={[
|
data={[
|
||||||
{ value: "true", label: "With customs clearing" },
|
{ value: "IMPORT", label: "Import" },
|
||||||
{ value: "false", label: "Without customs clearing" },
|
{ value: "EXPORT", label: "Export" },
|
||||||
|
{ value: "INTERCITY", label: "Intercity" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{intercity ? (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Intercity contracts are domestic and cross no border, so they have
|
||||||
|
no customs clearing variant — one template per cargo type.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<Text size="sm" fw={500} mb={6}>
|
||||||
|
Customs clearing
|
||||||
|
</Text>
|
||||||
|
<SegmentedControl
|
||||||
|
fullWidth
|
||||||
|
value={withCustoms}
|
||||||
|
onChange={setWithCustoms}
|
||||||
|
data={[
|
||||||
|
{ value: "true", label: "With customs clearing" },
|
||||||
|
{ value: "false", label: "Without customs clearing" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
label="Bulk cargo type"
|
label="Bulk cargo type"
|
||||||
description="Only cargo types with “has contract template” enabled are listed"
|
description="Only cargo types with “has contract template” enabled are listed"
|
||||||
@@ -273,7 +313,12 @@ function CreateTemplateModal({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!cargoTypeId) return;
|
if (!cargoTypeId) return;
|
||||||
create.mutate(
|
create.mutate(
|
||||||
{ cargoTypeId, withCustoms: withCustoms === "true" },
|
{
|
||||||
|
cargoTypeId,
|
||||||
|
tradeDirection: direction,
|
||||||
|
// Omitted for intercity — the API rejects the flag there.
|
||||||
|
...(intercity ? {} : { withCustoms: withCustoms === "true" }),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
onSuccess: (template) =>
|
onSuccess: (template) =>
|
||||||
onCreated((template as ContractTemplate).code),
|
onCreated((template as ContractTemplate).code),
|
||||||
@@ -305,10 +350,12 @@ function TemplateCard({
|
|||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
}) {
|
}) {
|
||||||
const bulk = isBulk(template);
|
const bulk = isBulk(template);
|
||||||
const direction = template.code.split("_")[0];
|
const direction = directionOf(template);
|
||||||
const customs = customsVariant(template);
|
const customs = customsVariant(template);
|
||||||
const kicker = bulk
|
const kicker = bulk
|
||||||
? `${template.cargoType?.cargoTypeName ?? "Bulk cargo"} · Bulk`
|
? `${template.cargoType?.cargoTypeName ?? "Bulk cargo"} · ${
|
||||||
|
DIRECTION_LABEL[direction] ?? direction
|
||||||
|
} · Bulk`
|
||||||
: `${DIRECTION_LABEL[direction] ?? direction} · Container`;
|
: `${DIRECTION_LABEL[direction] ?? direction} · Container`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -336,9 +383,8 @@ function TemplateCard({
|
|||||||
style={{
|
style={{
|
||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
background: bulk
|
background:
|
||||||
? "var(--mantine-color-teal-5)"
|
DIRECTION_DOT[direction] ?? "var(--mantine-color-gray-5)",
|
||||||
: DIRECTION_DOT[direction] ?? "var(--mantine-color-gray-5)",
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Text size="xs" fw={600} tt="uppercase" lts="0.06em" c="dimmed">
|
<Text size="xs" fw={600} tt="uppercase" lts="0.06em" c="dimmed">
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
Card,
|
Card,
|
||||||
Center,
|
Center,
|
||||||
Container,
|
Container,
|
||||||
Divider,
|
Grid,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Banknote,
|
Banknote,
|
||||||
|
Contact,
|
||||||
Download,
|
Download,
|
||||||
Eye,
|
Eye,
|
||||||
FileText,
|
FileText,
|
||||||
@@ -32,6 +33,8 @@ import {
|
|||||||
FilePen,
|
FilePen,
|
||||||
Paperclip,
|
Paperclip,
|
||||||
Receipt,
|
Receipt,
|
||||||
|
UserCheck,
|
||||||
|
UserRound,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
@@ -46,6 +49,7 @@ import {
|
|||||||
CompanyTypeBadge,
|
CompanyTypeBadge,
|
||||||
InvoiceStatusBadge,
|
InvoiceStatusBadge,
|
||||||
PaymentStatusBadge,
|
PaymentStatusBadge,
|
||||||
|
PersonCard,
|
||||||
ProfileApprovalActions,
|
ProfileApprovalActions,
|
||||||
ProfileChips,
|
ProfileChips,
|
||||||
ProfileStatusBadge,
|
ProfileStatusBadge,
|
||||||
@@ -259,7 +263,9 @@ export default function CustomerDetailPage() {
|
|||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="gray"
|
color="gray"
|
||||||
aria-label={`View ${f.name}`}
|
aria-label={`View ${f.name}`}
|
||||||
onClick={() => void fetchViewableFile(f.id, f.name).then(view)}
|
onClick={() =>
|
||||||
|
void fetchViewableFile(f.id, f.name).then(view)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Eye size={14} />
|
<Eye size={14} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@@ -268,7 +274,9 @@ export default function CustomerDetailPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
size="xs"
|
size="xs"
|
||||||
lineClamp={1}
|
lineClamp={1}
|
||||||
onClick={() => void fetchViewableFile(f.id, f.name).then(view)}
|
onClick={() =>
|
||||||
|
void fetchViewableFile(f.id, f.name).then(view)
|
||||||
|
}
|
||||||
style={{
|
style={{
|
||||||
maxWidth: 170,
|
maxWidth: 170,
|
||||||
textAlign: "left",
|
textAlign: "left",
|
||||||
@@ -615,7 +623,10 @@ export default function CustomerDetailPage() {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const licenseProfiles = (company?.companyProfiles ?? []).filter(
|
/** Never read `company.companyProfiles` directly — an endpoint that stops
|
||||||
|
* loading the relation would otherwise crash the whole page. */
|
||||||
|
const profiles = company?.companyProfiles ?? [];
|
||||||
|
const licenseProfiles = profiles.filter(
|
||||||
(p) => p.licenseFiles && p.licenseFiles.length > 0,
|
(p) => p.licenseFiles && p.licenseFiles.length > 0,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -629,14 +640,13 @@ export default function CustomerDetailPage() {
|
|||||||
[documents],
|
[documents],
|
||||||
);
|
);
|
||||||
const poaLive = poaDocuments.filter((d) => d.code === POA_DELEGATION_CODE);
|
const poaLive = poaDocuments.filter((d) => d.code === POA_DELEGATION_CODE);
|
||||||
const poaFields = [
|
const hasPoaDetails = [
|
||||||
{ label: "PoA name", value: company?.poaName },
|
company?.poaName,
|
||||||
{ label: "PoA email", value: company?.poaEmail },
|
company?.poaEmail,
|
||||||
{ label: "PoA phone", value: company?.poaPhone },
|
company?.poaPhone,
|
||||||
{ label: "PoA location", value: company?.poaLocation },
|
company?.poaLocation,
|
||||||
{ label: "PoA address", value: company?.poaAddress },
|
company?.poaAddress,
|
||||||
];
|
].some((v) => v?.trim());
|
||||||
const hasPoaDetails = poaFields.some((f) => f.value?.trim());
|
|
||||||
// Shared with the portal (buildCompanyIdentityState) — same derivation, so
|
// Shared with the portal (buildCompanyIdentityState) — same derivation, so
|
||||||
// this page can never disagree with the rule the API actually enforces.
|
// this page can never disagree with the rule the API actually enforces.
|
||||||
const identityState = company?.identity;
|
const identityState = company?.identity;
|
||||||
@@ -645,9 +655,7 @@ export default function CustomerDetailPage() {
|
|||||||
const hasEtradeRecord = Boolean(company?.licenceNumber?.trim());
|
const hasEtradeRecord = Boolean(company?.licenceNumber?.trim());
|
||||||
// A freight forwarder acts on other companies' behalf, so its PoA — details
|
// A freight forwarder acts on other companies' behalf, so its PoA — details
|
||||||
// and DARS delegation paper both — is mandatory rather than optional.
|
// and DARS delegation paper both — is mandatory rather than optional.
|
||||||
const poaMandatory = (company?.companyProfiles ?? []).some(
|
const poaMandatory = profiles.some((p) => p.type === "freight_forwarder");
|
||||||
(p) => p.type === "freight_forwarder",
|
|
||||||
);
|
|
||||||
const delegationMissing =
|
const delegationMissing =
|
||||||
company?.identity?.poaDeclared === "yes" && poaLive.length === 0;
|
company?.identity?.poaDeclared === "yes" && poaLive.length === 0;
|
||||||
|
|
||||||
@@ -685,8 +693,9 @@ export default function CustomerDetailPage() {
|
|||||||
]}
|
]}
|
||||||
backTo="/dashboard/customers"
|
backTo="/dashboard/customers"
|
||||||
title={company.name}
|
title={company.name}
|
||||||
subtitle={`TIN ${company.tin}${company.country ? ` · ${company.country}` : ""
|
subtitle={`TIN ${company.tin}${
|
||||||
}`}
|
company.country ? ` · ${company.country}` : ""
|
||||||
|
}`}
|
||||||
meta={
|
meta={
|
||||||
<Group gap="xs" wrap="nowrap">
|
<Group gap="xs" wrap="nowrap">
|
||||||
<CompanyTypeBadge type={company.type} />
|
<CompanyTypeBadge type={company.type} />
|
||||||
@@ -749,7 +758,7 @@ export default function CustomerDetailPage() {
|
|||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
label: "Profiles",
|
label: "Profiles",
|
||||||
value: company.companyProfiles.length,
|
value: profiles.length,
|
||||||
icon: IdCard,
|
icon: IdCard,
|
||||||
color: "edr-green",
|
color: "edr-green",
|
||||||
},
|
},
|
||||||
@@ -761,9 +770,7 @@ export default function CustomerDetailPage() {
|
|||||||
: "Pending approval",
|
: "Pending approval",
|
||||||
value: stillOnboarding
|
value: stillOnboarding
|
||||||
? "—"
|
? "—"
|
||||||
: company.companyProfiles.filter(
|
: profiles.filter((p) => p.status === "pending").length,
|
||||||
(p) => p.status === "pending",
|
|
||||||
).length,
|
|
||||||
icon: IdCard,
|
icon: IdCard,
|
||||||
color: "yellow",
|
color: "yellow",
|
||||||
},
|
},
|
||||||
@@ -782,407 +789,392 @@ export default function CustomerDetailPage() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Card>
|
<Grid gap="lg" align="flex-start">
|
||||||
<Stack gap="lg">
|
{/* Company facts — the wide column. People live in the narrow one
|
||||||
<Text fw={600} c="edr-text">
|
beside it, so nothing about a person is stated twice. */}
|
||||||
Company information
|
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||||
</Text>
|
<Stack gap="lg">
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
<Card>
|
||||||
<InfoField label="TIN" value={company.tin} />
|
<Stack gap="lg">
|
||||||
<InfoField label="VAT number" value={company.vatNumber} />
|
<Text fw={600} c="edr-text">
|
||||||
<InfoField label="FAN number" value={company.fanNumber} />
|
Company information
|
||||||
<InfoField
|
</Text>
|
||||||
label="Submitted on"
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
|
||||||
value={formatDate(company.createdAt)}
|
<InfoField label="TIN" value={company.tin} />
|
||||||
/>
|
|
||||||
<InfoField
|
|
||||||
label="Approved on"
|
|
||||||
value={
|
|
||||||
company.approvedAt
|
|
||||||
? formatDate(company.approvedAt)
|
|
||||||
: "Not yet approved"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<InfoField
|
|
||||||
label="Owner identity"
|
|
||||||
value={
|
|
||||||
ownerIdentity?.verified
|
|
||||||
? "Fayda verified"
|
|
||||||
: ownerIdentity?.passportNumber
|
|
||||||
? `Passport ${ownerIdentity.passportNumber}`
|
|
||||||
: "Not verified"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<InfoField label="Country" value={company.country} />
|
|
||||||
<InfoField
|
|
||||||
label="Nationality"
|
|
||||||
value={
|
|
||||||
company.nationality
|
|
||||||
? humanize(company.nationality)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{/* Why this company's registration was typed rather than
|
|
||||||
fetched, and why it carries no business licence. */}
|
|
||||||
<InfoField
|
|
||||||
label="Registration"
|
|
||||||
value={
|
|
||||||
company.cooperative
|
|
||||||
? "Co-operative union / farm (no trade licence)"
|
|
||||||
: "eTrade trade licence"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<InfoField label="Address" value={company.address} />
|
|
||||||
<InfoField label="Website" value={company.website} />
|
|
||||||
<InfoField label="Email" value={company.email} />
|
|
||||||
<InfoField label="Phone" value={company.phone} />
|
|
||||||
<Box />
|
|
||||||
<InfoField
|
|
||||||
label="Contact person"
|
|
||||||
value={company.contactPersonName}
|
|
||||||
/>
|
|
||||||
<InfoField
|
|
||||||
label="Contact phone"
|
|
||||||
value={company.contactPersonPhone}
|
|
||||||
/>
|
|
||||||
<Box />
|
|
||||||
<InfoField label="Owner" value={company.ownerName} />
|
|
||||||
<InfoField label="Owner email" value={company.ownerEmail} />
|
|
||||||
<InfoField label="Owner phone" value={company.ownerPhone} />
|
|
||||||
</SimpleGrid>
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<Stack gap="lg">
|
|
||||||
<Group gap="xs" wrap="nowrap" justify="space-between">
|
|
||||||
<Group gap="xs" wrap="nowrap">
|
|
||||||
<Text fw={600} c="edr-text">
|
|
||||||
eTrade registration
|
|
||||||
</Text>
|
|
||||||
{hasEtradeRecord ? (
|
|
||||||
<Badge size="sm" color="edr-green" variant="light">
|
|
||||||
Verified with eTrade
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge size="sm" color="gray" variant="light">
|
|
||||||
No eTrade record
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
{hasEtradeRecord && (
|
|
||||||
<ActionIcon
|
|
||||||
variant="default"
|
|
||||||
aria-label="Download TIN record"
|
|
||||||
onClick={() => downloadTinRecord(company)}
|
|
||||||
>
|
|
||||||
<Download size={16} />
|
|
||||||
</ActionIcon>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
{hasEtradeRecord ? (
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
|
||||||
<InfoField
|
|
||||||
label="License number"
|
|
||||||
value={company.licenceNumber}
|
|
||||||
/>
|
|
||||||
<InfoField label="Status" value={company.statusDescription} />
|
|
||||||
<InfoField
|
|
||||||
label="Date registered"
|
|
||||||
value={company.dateRegistered}
|
|
||||||
/>
|
|
||||||
<InfoField label="Renewed from" value={company.renewedFrom} />
|
|
||||||
<InfoField label="Renewal date" value={company.renewalDate} />
|
|
||||||
<InfoField label="Renewed to" value={company.renewedTo} />
|
|
||||||
<InfoField label="Region" value={company.region} />
|
|
||||||
<InfoField label="Zone" value={company.zone} />
|
|
||||||
<InfoField label="Woreda" value={company.woreda} />
|
|
||||||
<InfoField label="Kebele" value={company.kebele} />
|
|
||||||
<InfoField label="House No" value={company.houseNo} />
|
|
||||||
</SimpleGrid>
|
|
||||||
) : (
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
No eTrade registration record on file for this customer's
|
|
||||||
TIN.
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<Stack gap="lg">
|
|
||||||
<Group gap="xs" wrap="nowrap">
|
|
||||||
<Text fw={600} c="edr-text">
|
|
||||||
Owner identity
|
|
||||||
</Text>
|
|
||||||
{identityState?.subject === "owner" && (
|
|
||||||
<Badge size="sm" color="blue" variant="light">
|
|
||||||
Verifies for this company
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{ownerIdentity?.verified ? (
|
|
||||||
<Badge size="sm" color="edr-green" variant="light">
|
|
||||||
Fayda verified
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge size="sm" color="gray" variant="light">
|
|
||||||
Not verified
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{/* THE check: is the owner the company put forward the person
|
|
||||||
the eTrade licence actually names? Advisory — eTrade and
|
|
||||||
Fayda transliterate Amharic names differently, so this is a
|
|
||||||
prompt to look, not a verdict. */}
|
|
||||||
{identityState?.ownerMatchesEtrade === false ? (
|
|
||||||
<Alert
|
|
||||||
color="amber"
|
|
||||||
variant="light"
|
|
||||||
icon={<AlertTriangle size={16} />}
|
|
||||||
title="Does not match the eTrade licence"
|
|
||||||
>
|
|
||||||
The licence names{" "}
|
|
||||||
<strong>{identityState.etradeManagerName}</strong>, but this
|
|
||||||
company recorded <strong>{company.ownerName}</strong>.
|
|
||||||
</Alert>
|
|
||||||
) : identityState?.ownerMatchesEtrade === true ? (
|
|
||||||
<Badge
|
|
||||||
size="sm"
|
|
||||||
color="edr-green"
|
|
||||||
variant="light"
|
|
||||||
style={{ alignSelf: "flex-start" }}
|
|
||||||
>
|
|
||||||
Matches the eTrade licence
|
|
||||||
</Badge>
|
|
||||||
) : company.cooperative ? (
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
A co-operative union or farm holds no trade licence, so
|
|
||||||
there is no eTrade record to check the owner against.
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
No eTrade manager name on file to compare against.
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{ownerIdentity?.verified ? (
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
|
||||||
<InfoField label="Name" value={ownerIdentity.name} />
|
|
||||||
<InfoField label="Phone" value={ownerIdentity.phone} />
|
|
||||||
<InfoField label="Email" value={ownerIdentity.email} />
|
|
||||||
<InfoField label="Address" value={ownerIdentity.address} />
|
|
||||||
<InfoField
|
|
||||||
label="Verified at"
|
|
||||||
value={formatDate(ownerIdentity.verifiedAt)}
|
|
||||||
/>
|
|
||||||
<InfoField
|
|
||||||
label="Birthdate"
|
|
||||||
value={ownerIdentity.birthdate}
|
|
||||||
/>
|
|
||||||
<InfoField label="Gender" value={ownerIdentity.gender} />
|
|
||||||
<InfoField
|
|
||||||
label="Passport number"
|
|
||||||
value={ownerIdentity.passportNumber}
|
|
||||||
/>
|
|
||||||
</SimpleGrid>
|
|
||||||
) : (
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{ownerIdentity?.passportNumber
|
|
||||||
? `Not Fayda verified — identified by passport ${ownerIdentity.passportNumber}.`
|
|
||||||
: "The company owner has not verified their identity with Fayda."}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<Stack gap="lg">
|
|
||||||
<Group justify="space-between" wrap="nowrap">
|
|
||||||
<Group gap="xs" wrap="nowrap">
|
|
||||||
<Text fw={600} c="edr-text">
|
|
||||||
Power of Attorney
|
|
||||||
</Text>
|
|
||||||
{poaMandatory && (
|
|
||||||
<Badge size="xs" color="blue" variant="light">
|
|
||||||
Required for freight forwarder
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
{delegationMissing ? (
|
|
||||||
<Badge size="sm" color="red" variant="light">
|
|
||||||
DARS delegation paper missing
|
|
||||||
</Badge>
|
|
||||||
) : poaLive.length > 0 ? (
|
|
||||||
<Badge size="sm" color="edr-green" variant="light">
|
|
||||||
DARS delegation paper on file
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge size="sm" color="gray" variant="light">
|
|
||||||
Not provided
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{hasPoaDetails ? (
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
|
||||||
{poaFields.map((f) => (
|
|
||||||
<InfoField
|
|
||||||
key={f.label}
|
|
||||||
label={f.label}
|
|
||||||
value={f.value}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<InfoField
|
|
||||||
label="PoA Fayda"
|
|
||||||
value={
|
|
||||||
poaIdentity?.verified ? "Verified" : "Not verified"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{poaIdentity?.verified && (
|
|
||||||
<>
|
|
||||||
<InfoField
|
<InfoField
|
||||||
label="PoA verified at"
|
label="VAT number"
|
||||||
value={formatDate(poaIdentity.verifiedAt)}
|
value={company.vatNumber}
|
||||||
|
/>
|
||||||
|
<InfoField label="Country" value={company.country} />
|
||||||
|
<InfoField
|
||||||
|
label="Nationality"
|
||||||
|
value={
|
||||||
|
company.nationality
|
||||||
|
? humanize(company.nationality)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{/* Why this company's registration was typed rather
|
||||||
|
than fetched, and why it carries no licence. */}
|
||||||
|
<InfoField
|
||||||
|
label="Registration"
|
||||||
|
value={
|
||||||
|
company.cooperative
|
||||||
|
? "Co-operative union / farm (no trade licence)"
|
||||||
|
: "eTrade trade licence"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InfoField label="Address" value={company.address} />
|
||||||
|
<InfoField label="Email" value={company.email} />
|
||||||
|
<InfoField label="Phone" value={company.phone} />
|
||||||
|
<InfoField label="Website" value={company.website} />
|
||||||
|
<InfoField
|
||||||
|
label="Submitted on"
|
||||||
|
value={formatDate(company.createdAt)}
|
||||||
/>
|
/>
|
||||||
<InfoField
|
<InfoField
|
||||||
label="PoA birthdate"
|
label="Approved on"
|
||||||
value={poaIdentity.birthdate}
|
value={
|
||||||
|
company.approvedAt
|
||||||
|
? formatDate(company.approvedAt)
|
||||||
|
: "Not yet approved"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<InfoField label="PoA gender" value={poaIdentity.gender} />
|
</SimpleGrid>
|
||||||
</>
|
</Stack>
|
||||||
)}
|
</Card>
|
||||||
</SimpleGrid>
|
|
||||||
) : (
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
No Power of Attorney representative recorded for this
|
|
||||||
customer.
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Divider />
|
<Card>
|
||||||
|
<Stack gap="lg">
|
||||||
<Stack gap="sm">
|
<Group gap="xs" wrap="nowrap" justify="space-between">
|
||||||
<Text
|
<Group gap="xs" wrap="nowrap">
|
||||||
size="xs"
|
<Text fw={600} c="edr-text">
|
||||||
fw={600}
|
eTrade registration
|
||||||
c="edr-muted"
|
|
||||||
tt="uppercase"
|
|
||||||
style={{ letterSpacing: "0.04em" }}
|
|
||||||
>
|
|
||||||
DARS delegation paper
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{documentsQuery.isLoading ? (
|
|
||||||
<Group gap="xs">
|
|
||||||
<Loader size="xs" />
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
Loading documents…
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
) : documentsQuery.isError ? (
|
|
||||||
<Group gap="sm">
|
|
||||||
<Text size="sm" c="red">
|
|
||||||
Failed to load documents.
|
|
||||||
</Text>
|
|
||||||
<Anchor
|
|
||||||
component="button"
|
|
||||||
type="button"
|
|
||||||
size="xs"
|
|
||||||
onClick={() => void documentsQuery.refetch()}
|
|
||||||
>
|
|
||||||
Retry
|
|
||||||
</Anchor>
|
|
||||||
</Group>
|
|
||||||
) : poaDocuments.length === 0 ? (
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
No DARS delegation paper uploaded.
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
poaDocuments.map((doc) => (
|
|
||||||
<Group key={doc.id} justify="space-between" wrap="nowrap">
|
|
||||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
|
||||||
<Paperclip
|
|
||||||
size={14}
|
|
||||||
className="shrink-0 text-edr-muted"
|
|
||||||
/>
|
|
||||||
<Anchor
|
|
||||||
component="button"
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
lineClamp={1}
|
|
||||||
onClick={() =>
|
|
||||||
void fetchViewableFile(doc.id, doc.name).then(view)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{doc.name}
|
|
||||||
</Anchor>
|
|
||||||
<Text size="xs" c="dimmed" className="shrink-0">
|
|
||||||
{formatBytes(doc.size)} ·{" "}
|
|
||||||
{formatDate(doc.uploadedAt)}
|
|
||||||
</Text>
|
</Text>
|
||||||
{doc.code === POA_DELEGATION_PENDING_CODE && (
|
{hasEtradeRecord ? (
|
||||||
<Badge
|
<Badge size="sm" color="edr-green" variant="light">
|
||||||
size="xs"
|
Verified with eTrade
|
||||||
color="yellow"
|
</Badge>
|
||||||
variant="light"
|
) : (
|
||||||
className="shrink-0"
|
<Badge size="sm" color="gray" variant="light">
|
||||||
>
|
No eTrade record
|
||||||
Pending approval
|
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap={4} wrap="nowrap">
|
{hasEtradeRecord && (
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="default"
|
||||||
color="gray"
|
aria-label="Download TIN record"
|
||||||
aria-label={`Preview ${doc.name}`}
|
onClick={() => downloadTinRecord(company)}
|
||||||
onClick={() =>
|
|
||||||
void fetchViewableFile(doc.id, doc.name).then(view)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Eye size={16} />
|
|
||||||
</ActionIcon>
|
|
||||||
<ActionIcon
|
|
||||||
component="button"
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
void downloadBookingFile(doc.id, doc.name)
|
|
||||||
}
|
|
||||||
variant="subtle"
|
|
||||||
color="gray"
|
|
||||||
aria-label={`Download ${doc.name}`}
|
|
||||||
>
|
>
|
||||||
<Download size={16} />
|
<Download size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Group>
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
))
|
{hasEtradeRecord ? (
|
||||||
)}
|
<SimpleGrid
|
||||||
</Stack>
|
cols={{ base: 1, sm: 2, lg: 3 }}
|
||||||
</Stack>
|
spacing="lg"
|
||||||
</Card>
|
>
|
||||||
|
<InfoField
|
||||||
|
label="License number"
|
||||||
|
value={company.licenceNumber}
|
||||||
|
/>
|
||||||
|
<InfoField
|
||||||
|
label="Status"
|
||||||
|
value={company.statusDescription}
|
||||||
|
/>
|
||||||
|
<InfoField
|
||||||
|
label="Date registered"
|
||||||
|
value={company.dateRegistered}
|
||||||
|
/>
|
||||||
|
<InfoField
|
||||||
|
label="Renewed from"
|
||||||
|
value={company.renewedFrom}
|
||||||
|
/>
|
||||||
|
<InfoField
|
||||||
|
label="Renewal date"
|
||||||
|
value={company.renewalDate}
|
||||||
|
/>
|
||||||
|
<InfoField
|
||||||
|
label="Renewed to"
|
||||||
|
value={company.renewedTo}
|
||||||
|
/>
|
||||||
|
<InfoField label="Region" value={company.region} />
|
||||||
|
<InfoField label="Zone" value={company.zone} />
|
||||||
|
<InfoField label="Woreda" value={company.woreda} />
|
||||||
|
<InfoField label="Kebele" value={company.kebele} />
|
||||||
|
<InfoField label="House No" value={company.houseNo} />
|
||||||
|
</SimpleGrid>
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
No eTrade registration record on file for this
|
||||||
|
customer's TIN.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text fw={600} c="edr-text">
|
<Text fw={600} c="edr-text">
|
||||||
Role profiles
|
Role profiles
|
||||||
</Text>
|
</Text>
|
||||||
<ProfileChips profiles={company.companyProfiles} />
|
<ProfileChips profiles={profiles} />
|
||||||
</Group>
|
</Group>
|
||||||
<Box style={{ overflowX: "auto" }} w="100%">
|
{/* Narrower than the old full-width layout — the table
|
||||||
<Box miw={1040}>
|
shares the row with the people column now. */}
|
||||||
<DataTable
|
<Box style={{ overflowX: "auto" }} w="100%">
|
||||||
columns={profileColumns}
|
<Box miw={760}>
|
||||||
data={company.companyProfiles}
|
<DataTable
|
||||||
status="success"
|
columns={profileColumns}
|
||||||
emptyMessage="No profiles registered."
|
data={profiles}
|
||||||
containerClassName="border-0 shadow-none bg-transparent"
|
status="success"
|
||||||
/>
|
emptyMessage="No profiles registered."
|
||||||
</Box>
|
containerClassName="border-0 shadow-none bg-transparent"
|
||||||
</Box>
|
/>
|
||||||
</Stack>
|
</Box>
|
||||||
</Card>
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
</Grid.Col>
|
||||||
|
|
||||||
|
{/* People: owner, then power of attorney, then contact person —
|
||||||
|
the order a reviewer checks them in. */}
|
||||||
|
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||||
|
<Stack gap="md">
|
||||||
|
<PersonCard
|
||||||
|
title="Owner"
|
||||||
|
icon={<UserRound size={15} className="text-edr-muted" />}
|
||||||
|
verified={Boolean(ownerIdentity?.verified)}
|
||||||
|
badges={
|
||||||
|
<>
|
||||||
|
{identityState?.subject === "owner" && (
|
||||||
|
<Badge size="xs" color="blue" variant="light">
|
||||||
|
Verifies for this company
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{identityState?.ownerMatchesEtrade === true && (
|
||||||
|
<Badge size="xs" color="edr-green" variant="light">
|
||||||
|
Matches eTrade licence
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
notice={
|
||||||
|
/* THE check: is the owner the company put forward the
|
||||||
|
person the eTrade licence actually names? Advisory —
|
||||||
|
eTrade and Fayda transliterate Amharic names
|
||||||
|
differently, so this is a prompt to look, not a
|
||||||
|
verdict. */
|
||||||
|
identityState?.ownerMatchesEtrade === false ? (
|
||||||
|
<Alert
|
||||||
|
color="amber"
|
||||||
|
variant="light"
|
||||||
|
p="xs"
|
||||||
|
icon={<AlertTriangle size={16} />}
|
||||||
|
title="Does not match the eTrade licence"
|
||||||
|
>
|
||||||
|
<Text size="xs">
|
||||||
|
The licence names{" "}
|
||||||
|
<strong>{identityState.etradeManagerName}</strong>,
|
||||||
|
but this company recorded{" "}
|
||||||
|
<strong>{company.ownerName ?? "nobody"}</strong>.
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
) : !ownerIdentity?.verified &&
|
||||||
|
ownerIdentity?.passportNumber ? (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Identified by passport rather than Fayda.
|
||||||
|
</Text>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
fields={[
|
||||||
|
{
|
||||||
|
label: "Name",
|
||||||
|
value: company.ownerName ?? ownerIdentity?.name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Email",
|
||||||
|
value: company.ownerEmail ?? ownerIdentity?.email,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Phone",
|
||||||
|
value: company.ownerPhone ?? ownerIdentity?.phone,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Passport number",
|
||||||
|
value: ownerIdentity?.passportNumber,
|
||||||
|
},
|
||||||
|
{ label: "Address", value: ownerIdentity?.address },
|
||||||
|
{ label: "Birthdate", value: ownerIdentity?.birthdate },
|
||||||
|
{ label: "Gender", value: ownerIdentity?.gender },
|
||||||
|
{
|
||||||
|
label: "Verified on",
|
||||||
|
value: ownerIdentity?.verifiedAt
|
||||||
|
? formatDate(ownerIdentity.verifiedAt)
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
emptyMessage="No owner recorded for this company."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PersonCard
|
||||||
|
title="Power of attorney"
|
||||||
|
icon={<UserCheck size={15} className="text-edr-muted" />}
|
||||||
|
verified={
|
||||||
|
// No PoA at all → no badge, rather than a "not verified"
|
||||||
|
// that reads as a problem where none exists.
|
||||||
|
hasPoaDetails || identityState?.poaDeclared === "yes"
|
||||||
|
? Boolean(poaIdentity?.verified)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
badges={
|
||||||
|
<>
|
||||||
|
{identityState?.subject === "poa" && (
|
||||||
|
<Badge size="xs" color="blue" variant="light">
|
||||||
|
Verifies for this company
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{poaMandatory && (
|
||||||
|
<Badge size="xs" color="blue" variant="light">
|
||||||
|
Required for freight forwarder
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{delegationMissing && (
|
||||||
|
<Badge size="xs" color="red" variant="light">
|
||||||
|
Delegation paper missing
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
fields={[
|
||||||
|
{ label: "Name", value: company.poaName },
|
||||||
|
{ label: "Email", value: company.poaEmail },
|
||||||
|
{ label: "Phone", value: company.poaPhone },
|
||||||
|
{ label: "Location", value: company.poaLocation },
|
||||||
|
{ label: "Address", value: company.poaAddress },
|
||||||
|
{ label: "Birthdate", value: poaIdentity?.birthdate },
|
||||||
|
{ label: "Gender", value: poaIdentity?.gender },
|
||||||
|
{
|
||||||
|
label: "Verified on",
|
||||||
|
value: poaIdentity?.verifiedAt
|
||||||
|
? formatDate(poaIdentity.verifiedAt)
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
emptyMessage="No representative recorded for this customer."
|
||||||
|
>
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text size="xs" c="edr-muted">
|
||||||
|
DARS delegation paper
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{documentsQuery.isLoading ? (
|
||||||
|
<Group gap="xs">
|
||||||
|
<Loader size="xs" />
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Loading…
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
) : documentsQuery.isError ? (
|
||||||
|
<Group gap="sm">
|
||||||
|
<Text size="sm" c="red">
|
||||||
|
Failed to load documents.
|
||||||
|
</Text>
|
||||||
|
<Anchor
|
||||||
|
component="button"
|
||||||
|
type="button"
|
||||||
|
size="xs"
|
||||||
|
onClick={() => void documentsQuery.refetch()}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</Anchor>
|
||||||
|
</Group>
|
||||||
|
) : poaDocuments.length === 0 ? (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Not uploaded.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
poaDocuments.map((doc) => (
|
||||||
|
<Stack key={doc.id} gap={2}>
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<Paperclip
|
||||||
|
size={13}
|
||||||
|
className="shrink-0 text-edr-muted"
|
||||||
|
/>
|
||||||
|
<Anchor
|
||||||
|
component="button"
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
lineClamp={1}
|
||||||
|
style={{ flex: 1, textAlign: "left" }}
|
||||||
|
onClick={() =>
|
||||||
|
void fetchViewableFile(doc.id, doc.name).then(
|
||||||
|
view,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{doc.name}
|
||||||
|
</Anchor>
|
||||||
|
<ActionIcon
|
||||||
|
size="sm"
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
aria-label={`Preview ${doc.name}`}
|
||||||
|
onClick={() =>
|
||||||
|
void fetchViewableFile(doc.id, doc.name).then(
|
||||||
|
view,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Eye size={15} />
|
||||||
|
</ActionIcon>
|
||||||
|
<ActionIcon
|
||||||
|
size="sm"
|
||||||
|
component="button"
|
||||||
|
type="button"
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
aria-label={`Download ${doc.name}`}
|
||||||
|
onClick={() =>
|
||||||
|
void downloadBookingFile(doc.id, doc.name)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Download size={15} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
<Group gap={6} pl={19} wrap="wrap">
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{formatBytes(doc.size)} ·{" "}
|
||||||
|
{formatDate(doc.uploadedAt)}
|
||||||
|
</Text>
|
||||||
|
{doc.code === POA_DELEGATION_PENDING_CODE && (
|
||||||
|
<Badge size="xs" color="yellow" variant="light">
|
||||||
|
Pending approval
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</PersonCard>
|
||||||
|
|
||||||
|
<PersonCard
|
||||||
|
title="Contact person"
|
||||||
|
icon={<Contact size={15} className="text-edr-muted" />}
|
||||||
|
fields={[
|
||||||
|
{ label: "Name", value: company.contactPersonName },
|
||||||
|
{ label: "Phone", value: company.contactPersonPhone },
|
||||||
|
]}
|
||||||
|
emptyMessage="No contact person recorded."
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
@@ -1198,9 +1190,9 @@ export default function CustomerDetailPage() {
|
|||||||
error={
|
error={
|
||||||
bookingsQuery.isError
|
bookingsQuery.isError
|
||||||
? {
|
? {
|
||||||
message: "Failed to load bookings.",
|
message: "Failed to load bookings.",
|
||||||
onRetry: () => void bookingsQuery.refetch(),
|
onRetry: () => void bookingsQuery.refetch(),
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -1220,9 +1212,9 @@ export default function CustomerDetailPage() {
|
|||||||
error={
|
error={
|
||||||
documentsQuery.isError
|
documentsQuery.isError
|
||||||
? {
|
? {
|
||||||
message: "Failed to load documents.",
|
message: "Failed to load documents.",
|
||||||
onRetry: () => void documentsQuery.refetch(),
|
onRetry: () => void documentsQuery.refetch(),
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -1297,9 +1289,9 @@ export default function CustomerDetailPage() {
|
|||||||
error={
|
error={
|
||||||
paymentsQuery.isError
|
paymentsQuery.isError
|
||||||
? {
|
? {
|
||||||
message: "Failed to load payments.",
|
message: "Failed to load payments.",
|
||||||
onRetry: () => void paymentsQuery.refetch(),
|
onRetry: () => void paymentsQuery.refetch(),
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -1320,9 +1312,9 @@ export default function CustomerDetailPage() {
|
|||||||
error={
|
error={
|
||||||
invoicesQuery.isError
|
invoicesQuery.isError
|
||||||
? {
|
? {
|
||||||
message: "Failed to load invoices.",
|
message: "Failed to load invoices.",
|
||||||
onRetry: () => void invoicesQuery.refetch(),
|
onRetry: () => void invoicesQuery.refetch(),
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
pagination={{
|
pagination={{
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { MySignatureCard } from "@/components/profile/MySignatureCard";
|
import { MySignatureCard } from "@/components/profile/MySignatureCard";
|
||||||
|
import { ChangePasswordCard } from "@/components/profile/ChangePasswordCard";
|
||||||
|
import { ChangeEmailCard } from "@/components/profile/ChangeEmailCard";
|
||||||
|
|
||||||
export default function MyProfilePage() {
|
export default function MyProfilePage() {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-2xl space-y-6 p-4">
|
<div className="mx-auto w-full max-w-2xl space-y-6 p-4">
|
||||||
|
<ChangeEmailCard />
|
||||||
|
<ChangePasswordCard />
|
||||||
<div id="signature">
|
<div id="signature">
|
||||||
<MySignatureCard />
|
<MySignatureCard />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ interface CargoNode extends RuleEngineRecord {
|
|||||||
requiresDirectorApproval?: boolean;
|
requiresDirectorApproval?: boolean;
|
||||||
/** When true, bookings of this cargo type incur the flat LASHING surcharge. */
|
/** When true, bookings of this cargo type incur the flat LASHING surcharge. */
|
||||||
hasLashing?: boolean;
|
hasLashing?: boolean;
|
||||||
|
/** When true, bookings of this cargo type incur the lane-scoped FUEL surcharge. */
|
||||||
|
hasFuel?: boolean;
|
||||||
/** Staff may write bulk contract templates for this cargo type (parent XOR children). */
|
/** Staff may write bulk contract templates for this cargo type (parent XOR children). */
|
||||||
hasContractTemplate?: boolean;
|
hasContractTemplate?: boolean;
|
||||||
/** How this cargo is measured (PER_TON / PER_ITEM); null for groups/unset. */
|
/** How this cargo is measured (PER_TON / PER_ITEM); null for groups/unset. */
|
||||||
@@ -114,6 +116,9 @@ const FORM_FIELDS: FormFieldDef[] = [
|
|||||||
// When on, every booking of this cargo type is charged the flat LASHING
|
// When on, every booking of this cargo type is charged the flat LASHING
|
||||||
// surcharge (a rate with trigger = Lashing).
|
// surcharge (a rate with trigger = Lashing).
|
||||||
{ name: "hasLashing", label: "Charge lashing fee", type: "boolean" },
|
{ name: "hasLashing", label: "Charge lashing fee", type: "boolean" },
|
||||||
|
// When on, bookings of this cargo type incur the fuel surcharge, billed off
|
||||||
|
// the lane-scoped FUEL rate (direction + route + cargo type) under Rates.
|
||||||
|
{ name: "hasFuel", label: "Charge fuel fee", type: "boolean" },
|
||||||
// Lets staff write bulk contract templates for this cargo type. The API
|
// Lets staff write bulk contract templates for this cargo type. The API
|
||||||
// rejects the save when the parent group (or a child) already has it on —
|
// rejects the save when the parent group (or a child) already has it on —
|
||||||
// the template must live on exactly one level.
|
// the template must live on exactly one level.
|
||||||
|
|||||||
@@ -100,19 +100,27 @@ const yardOptionsForLegEnd = (
|
|||||||
} else if (
|
} else if (
|
||||||
appliesTo === "CONTAINER" ||
|
appliesTo === "CONTAINER" ||
|
||||||
appliesTo === "BULK" ||
|
appliesTo === "BULK" ||
|
||||||
// Customs clearance + empty-container return are sold per direction +
|
// Customs clearance, empty-container return and fuel are sold per
|
||||||
// route, so their yard dropdowns narrow exactly like base freight.
|
// direction + route, so their yard dropdowns narrow exactly like base
|
||||||
|
// freight.
|
||||||
(appliesTo === "OTHER" &&
|
(appliesTo === "OTHER" &&
|
||||||
["CUSTOMS_CLEARANCE", "WITH_RETURN"].includes(String(values.trigger ?? "")))
|
["CUSTOMS_CLEARANCE", "WITH_RETURN", "FUEL"].includes(
|
||||||
|
String(values.trigger ?? ""),
|
||||||
|
))
|
||||||
) {
|
) {
|
||||||
const direction = String(values.tradeDirection ?? "");
|
const direction = String(values.tradeDirection ?? "");
|
||||||
// Direction is what decides the countries, so offer nothing until it is set
|
// Direction is what decides the countries, so offer nothing until it is set
|
||||||
// rather than defaulting to one and letting it read as a real choice.
|
// rather than defaulting to one and letting it read as a real choice.
|
||||||
if (direction !== "IMPORT" && direction !== "EXPORT") return [];
|
if (direction === "DOMESTIC") {
|
||||||
const startsInEthiopia = direction === "EXPORT";
|
// A fuel rate's intercity lane — stays inside Ethiopia.
|
||||||
country = (end === "origin" ? startsInEthiopia : !startsInEthiopia)
|
country = "Ethiopia";
|
||||||
? "Ethiopia"
|
} else {
|
||||||
: "Djibouti";
|
if (direction !== "IMPORT" && direction !== "EXPORT") return [];
|
||||||
|
const startsInEthiopia = direction === "EXPORT";
|
||||||
|
country = (end === "origin" ? startsInEthiopia : !startsInEthiopia)
|
||||||
|
? "Ethiopia"
|
||||||
|
: "Djibouti";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!country) return [];
|
if (!country) return [];
|
||||||
return yards
|
return yards
|
||||||
|
|||||||
@@ -181,6 +181,17 @@ const RATE_TRIGGERS = [
|
|||||||
{ label: "Cancellation", value: "CANCELLATION" },
|
{ label: "Cancellation", value: "CANCELLATION" },
|
||||||
{ label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" },
|
{ label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" },
|
||||||
{ label: "Customs clearance service fee (billed with the booking)", value: "CUSTOMS_CLEARANCE" },
|
{ label: "Customs clearance service fee (billed with the booking)", value: "CUSTOMS_CLEARANCE" },
|
||||||
|
{ label: "Fuel (per lane + cargo type)", value: "FUEL" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fuel lanes: import/export like base freight, plus a domestic intercity lane
|
||||||
|
* (stored as DOMESTIC — matches the booking's own trade direction).
|
||||||
|
*/
|
||||||
|
const FUEL_TRADE_DIRECTIONS = [
|
||||||
|
{ label: "Import", value: "IMPORT" },
|
||||||
|
{ label: "Export", value: "EXPORT" },
|
||||||
|
{ label: "Intercity", value: "DOMESTIC" },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -205,7 +216,7 @@ const isBaseFreightRate = (values: Record<string, unknown>) =>
|
|||||||
const isRouteScopedRate = (values: Record<string, unknown>) =>
|
const isRouteScopedRate = (values: Record<string, unknown>) =>
|
||||||
isBaseFreightRate(values) ||
|
isBaseFreightRate(values) ||
|
||||||
(String(values.appliesTo ?? "") === "OTHER" &&
|
(String(values.appliesTo ?? "") === "OTHER" &&
|
||||||
["CUSTOMS_CLEARANCE", "WITH_RETURN"].includes(String(values.trigger ?? "")));
|
["CUSTOMS_CLEARANCE", "WITH_RETURN", "FUEL"].includes(String(values.trigger ?? "")));
|
||||||
|
|
||||||
const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value });
|
const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value });
|
||||||
|
|
||||||
@@ -255,6 +266,9 @@ const unitsForShape = (
|
|||||||
case "LASHING":
|
case "LASHING":
|
||||||
// Bulk-only cargo securing — per ton or per wagon.
|
// Bulk-only cargo securing — per ton or per wagon.
|
||||||
return ["PER_TON", "PER_WAGON"];
|
return ["PER_TON", "PER_WAGON"];
|
||||||
|
case "FUEL":
|
||||||
|
// Per wagon (wagons × rate) or per liter (base liters × rate, once).
|
||||||
|
return ["PER_WAGON", "PER_LITER"];
|
||||||
case "CONSOLIDATION":
|
case "CONSOLIDATION":
|
||||||
case "SHIPPING_LINE":
|
case "SHIPPING_LINE":
|
||||||
case "PIL_EXTRA_FEE":
|
case "PIL_EXTRA_FEE":
|
||||||
@@ -371,6 +385,13 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
},
|
},
|
||||||
{ name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" },
|
{ name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" },
|
||||||
{ name: "hasLashing", label: "Charge lashing fee", type: "boolean" },
|
{ name: "hasLashing", label: "Charge lashing fee", type: "boolean" },
|
||||||
|
{
|
||||||
|
name: "hasFuel",
|
||||||
|
label: "Charge fuel fee",
|
||||||
|
type: "boolean",
|
||||||
|
description:
|
||||||
|
"Bookings of this cargo incur the fuel surcharge (configure the FUEL rate per lane under Rates).",
|
||||||
|
},
|
||||||
{ name: "isActive", label: "Active", type: "boolean" },
|
{ name: "isActive", label: "Active", type: "boolean" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -834,7 +855,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
filters: {
|
filters: {
|
||||||
appliesTo: "OTHER",
|
appliesTo: "OTHER",
|
||||||
trigger:
|
trigger:
|
||||||
"HAZARDOUS,OVERWEIGHT,REEFER,SHIPPING_LINE,CONSOLIDATION,LASHING,CANCELLATION,PIL_EXTRA_FEE",
|
"HAZARDOUS,OVERWEIGHT,REEFER,SHIPPING_LINE,CONSOLIDATION,LASHING,CANCELLATION,PIL_EXTRA_FEE,FUEL",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -886,14 +907,17 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
type: "select",
|
type: "select",
|
||||||
required: true,
|
required: true,
|
||||||
optionsFromValues: (v: Record<string, unknown>) =>
|
optionsFromValues: (v: Record<string, unknown>) =>
|
||||||
String(v.trigger ?? "") === "WITH_RETURN" &&
|
String(v.appliesTo ?? "") === "OTHER" &&
|
||||||
String(v.appliesTo ?? "") === "OTHER"
|
String(v.trigger ?? "") === "WITH_RETURN"
|
||||||
? TRADE_DIRECTIONS.filter((d) => d.value === "IMPORT")
|
? TRADE_DIRECTIONS.filter((d) => d.value === "IMPORT")
|
||||||
: TRADE_DIRECTIONS.filter((d) => d.value !== "BOTH"),
|
: String(v.appliesTo ?? "") === "OTHER" &&
|
||||||
|
String(v.trigger ?? "") === "FUEL"
|
||||||
|
? FUEL_TRADE_DIRECTIONS
|
||||||
|
: TRADE_DIRECTIONS.filter((d) => d.value !== "BOTH"),
|
||||||
showIf: (v) =>
|
showIf: (v) =>
|
||||||
["BULK", "CONTAINER"].includes(String(v.appliesTo ?? "")) ||
|
["BULK", "CONTAINER"].includes(String(v.appliesTo ?? "")) ||
|
||||||
(String(v.appliesTo ?? "") === "OTHER" &&
|
(String(v.appliesTo ?? "") === "OTHER" &&
|
||||||
["CUSTOMS_CLEARANCE", "WITH_RETURN", "LASHING"].includes(
|
["CUSTOMS_CLEARANCE", "WITH_RETURN", "LASHING", "FUEL"].includes(
|
||||||
String(v.trigger ?? ""),
|
String(v.trigger ?? ""),
|
||||||
)),
|
)),
|
||||||
},
|
},
|
||||||
@@ -939,6 +963,18 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
v.trigger === "CUSTOMS_CLEARANCE" &&
|
v.trigger === "CUSTOMS_CLEARANCE" &&
|
||||||
v.cargoKind === "BULK",
|
v.cargoKind === "BULK",
|
||||||
},
|
},
|
||||||
|
// ── Cargo type — a fuel rate names the commodity it covers (different
|
||||||
|
// commodities price differently on the same lane) ──────────────────────
|
||||||
|
{
|
||||||
|
name: "cargoTypeId",
|
||||||
|
label: "Cargo type",
|
||||||
|
type: "select",
|
||||||
|
required: true,
|
||||||
|
placeholder: "Which cargo type this fuel rate covers",
|
||||||
|
description:
|
||||||
|
"Fuel is charged for bookings of this cargo type (needs “Charge fuel fee” enabled on the cargo type).",
|
||||||
|
showIf: (v) => v.appliesTo === "OTHER" && v.trigger === "FUEL",
|
||||||
|
},
|
||||||
// ── Bulk cargo type — lashing is bulk-only; may narrow to one leaf
|
// ── Bulk cargo type — lashing is bulk-only; may narrow to one leaf
|
||||||
// commodity (specific wins over the commodity-wide catch-all) ──────────
|
// commodity (specific wins over the commodity-wide catch-all) ──────────
|
||||||
{
|
{
|
||||||
@@ -1123,6 +1159,20 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
String(v.trigger ?? "") !== "OVERWEIGHT" &&
|
String(v.trigger ?? "") !== "OVERWEIGHT" &&
|
||||||
String(v.appliesTo ?? "") !== "LAST_MILE",
|
String(v.appliesTo ?? "") !== "LAST_MILE",
|
||||||
},
|
},
|
||||||
|
// ── Base liters — per-liter fuel rates only ────────────────────────────
|
||||||
|
{
|
||||||
|
name: "baseLiters",
|
||||||
|
label: "Base (liters)",
|
||||||
|
type: "number",
|
||||||
|
required: true,
|
||||||
|
placeholder: "e.g. 100",
|
||||||
|
description:
|
||||||
|
"Liters the surcharge covers — price = base liters × rate value, charged once per booking.",
|
||||||
|
showIf: (v) =>
|
||||||
|
v.appliesTo === "OTHER" &&
|
||||||
|
v.trigger === "FUEL" &&
|
||||||
|
v.rateUnit === "PER_LITER",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -17,10 +17,16 @@ import {
|
|||||||
} from "@/hooks/useStampSettings";
|
} from "@/hooks/useStampSettings";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The one company stamp/seal stamped onto every generated invoice/receipt
|
* The ONE company stamp/seal, read by every document path server-side via
|
||||||
* PDF (InvoiceDocumentService). Single global image — no per-employee choice.
|
* StampSettingsService: invoices and receipts (InvoiceDocumentService),
|
||||||
|
* warehouse release + handover papers, and the EDR side of contract signature
|
||||||
|
* blocks. Single global image — no per-employee choice, and staff never upload
|
||||||
|
* one when signing.
|
||||||
|
*
|
||||||
|
* Not to be confused with the per-officer teeter (ማህተም) at
|
||||||
|
* /user-management/teeter-and-signature, which is genuinely per-person.
|
||||||
*/
|
*/
|
||||||
export default function InvoiceStampSettingsPage() {
|
export default function CompanyStampSettingsPage() {
|
||||||
const { data, isLoading } = useStampSettingsQuery();
|
const { data, isLoading } = useStampSettingsQuery();
|
||||||
const setStamp = useSetStamp();
|
const setStamp = useSetStamp();
|
||||||
const clearStamp = useClearStamp();
|
const clearStamp = useClearStamp();
|
||||||
@@ -47,11 +53,13 @@ export default function InvoiceStampSettingsPage() {
|
|||||||
<div className="p-4 w-full max-w-screen-sm mx-auto">
|
<div className="p-4 w-full max-w-screen-sm mx-auto">
|
||||||
<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>Invoice stamp</CardTitle>
|
<CardTitle>Company stamp</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Stamped onto every generated invoice and receipt PDF. Replacing it
|
The single EDR seal, applied to every generated document — invoices
|
||||||
here changes it everywhere at once — there is no per-invoice or
|
and receipts, warehouse release and handover papers, and the EDR
|
||||||
per-user choice.
|
side of signed contracts. Replacing it here changes it everywhere at
|
||||||
|
once; there is no per-document, per-invoice or per-employee choice.
|
||||||
|
Staff do not upload their own.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
@@ -1019,14 +1019,11 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The row's wagon chips, matching the detail page's wagon plan: used is slots
|
* The row's wagon chips, matching the detail page's wagon plan: used is slots
|
||||||
* carrying a booking allocation (never the coupled consist size), and remaining
|
* carrying a booking allocation, the denominator is the schedule's capacity
|
||||||
* excludes wagons reserved by bookings that have not paid yet — that space is
|
* (API-computed: the larger of coupled consist and planned `maxWagons`, since
|
||||||
* claimed, so it is not bookable.
|
* wagons are coupled on demand), and remaining excludes wagons reserved by
|
||||||
*
|
* bookings that have not paid yet — that space is claimed, so it is not
|
||||||
* Schedules whose train set has not been built yet have no consist to measure,
|
* bookable.
|
||||||
* so both figures fall back to the schedule's planned `maxWagons` ceiling.
|
|
||||||
* Without that fallback an unbuilt 37-wagon schedule reads "0 bookable" even
|
|
||||||
* though every one of its wagons is still free.
|
|
||||||
*/
|
*/
|
||||||
function WagonChips({ schedule }: { schedule: TrainScheduleListItem }) {
|
function WagonChips({ schedule }: { schedule: TrainScheduleListItem }) {
|
||||||
// Pre-deploy API rows carry only wagonCount; fall back so the chip still
|
// Pre-deploy API rows carry only wagonCount; fall back so the chip still
|
||||||
@@ -1034,17 +1031,7 @@ function WagonChips({ schedule }: { schedule: TrainScheduleListItem }) {
|
|||||||
const total = schedule.wagonsTotal ?? schedule.wagonCount;
|
const total = schedule.wagonsTotal ?? schedule.wagonCount;
|
||||||
const used = schedule.wagonsUsed;
|
const used = schedule.wagonsUsed;
|
||||||
const reserved = schedule.wagonsReserved ?? 0;
|
const reserved = schedule.wagonsReserved ?? 0;
|
||||||
|
const remaining = schedule.wagonsRemaining;
|
||||||
// Until the train set is built there is no consist to measure against, so
|
|
||||||
// `wagonsRemaining` (consist minus claimed) is 0 on every unbuilt schedule —
|
|
||||||
// which reads as "fully booked" when in fact nothing is booked at all. Before
|
|
||||||
// a consist exists, capacity is the planned ceiling minus what bookings have
|
|
||||||
// already claimed.
|
|
||||||
const planCeiling = schedule.maxWagons ?? 0;
|
|
||||||
const remaining =
|
|
||||||
total === 0 && planCeiling > 0
|
|
||||||
? Math.max(0, planCeiling - Math.max(used ?? 0, reserved))
|
|
||||||
: schedule.wagonsRemaining;
|
|
||||||
|
|
||||||
if (used == null) {
|
if (used == null) {
|
||||||
return <MetricChip value={total} label="wgn" subtle />;
|
return <MetricChip value={total} label="wgn" subtle />;
|
||||||
@@ -1052,11 +1039,9 @@ function WagonChips({ schedule }: { schedule: TrainScheduleListItem }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* An unbuilt consist has no "used out of coupled" to show; the plan
|
|
||||||
ceiling is the only meaningful denominator at that point. */}
|
|
||||||
<MetricChip
|
<MetricChip
|
||||||
value={total === 0 && planCeiling > 0 ? `${used}/${planCeiling}` : `${used}/${total}`}
|
value={`${used}/${total}`}
|
||||||
label={total === 0 && planCeiling > 0 ? "wgn planned" : "wgn used"}
|
label={schedule.wagonCount === 0 ? "wgn planned" : "wgn used"}
|
||||||
/>
|
/>
|
||||||
{reserved > used ? (
|
{reserved > used ? (
|
||||||
<MetricChip value={reserved} label="reserved" subtle />
|
<MetricChip value={reserved} label="reserved" subtle />
|
||||||
|
|||||||
@@ -551,7 +551,7 @@ const Top: React.FC<HeaderProps> = ({
|
|||||||
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="flex cursor-pointer items-center rounded-lg px-3 py-2.5 text-sm text-gray-700 transition-colors hover:bg-primary-50 dark:text-gray-200 dark:hover:bg-primary-900/20"
|
className="flex cursor-pointer items-center rounded-lg px-3 py-2.5 text-sm text-gray-700 transition-colors hover:bg-primary-50 dark:text-gray-200 dark:hover:bg-primary-900/20"
|
||||||
onClick={() => navigate("/change-password")}
|
onClick={() => navigate("/dashboard/profile")}
|
||||||
>
|
>
|
||||||
<Key className="mr-2.5 h-4 w-4 text-primary-600 dark:text-primary-400" />
|
<Key className="mr-2.5 h-4 w-4 text-primary-600 dark:text-primary-400" />
|
||||||
<span>{t("header.changePassword")}</span>
|
<span>{t("header.changePassword")}</span>
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { api as client } from "../auth/http";
|
||||||
|
import { unwrap } from "@/utils/endpoint";
|
||||||
|
|
||||||
|
export type ContactChannel = "email" | "phone";
|
||||||
|
|
||||||
|
export interface ChangePasswordPayload {
|
||||||
|
oldPassword: string;
|
||||||
|
newPassword: string;
|
||||||
|
confirmPassword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendContactOtpPayload {
|
||||||
|
channel: ContactChannel;
|
||||||
|
/** The NEW email/phone to verify — the OTP is sent here, not to the current one. */
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateContactPayload extends SendContactOtpPayload {
|
||||||
|
otp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const accountService = {
|
||||||
|
/** PATCH /auth/change-password — generic IAM route, works for any user type. */
|
||||||
|
changePassword: async (payload: ChangePasswordPayload): Promise<void> => {
|
||||||
|
const response = await client.patch("/auth/change-password", payload);
|
||||||
|
unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** POST /me/contact/otp — sends a code to the new email/phone to prove ownership. */
|
||||||
|
sendContactOtp: async (
|
||||||
|
payload: SendContactOtpPayload,
|
||||||
|
): Promise<{ sentTo: string }> => {
|
||||||
|
const response = await client.post<{ sentTo: string }>(
|
||||||
|
"/me/contact/otp",
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** PATCH /me/contact — verifies the OTP and writes the new email/phone. */
|
||||||
|
updateContact: async (
|
||||||
|
payload: UpdateContactPayload,
|
||||||
|
): Promise<{ success: true; value: string }> => {
|
||||||
|
const response = await client.patch<{ success: true; value: string }>(
|
||||||
|
"/me/contact",
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -136,6 +136,12 @@ import type {
|
|||||||
WarehouseZone,
|
WarehouseZone,
|
||||||
} from "@/types/warehouse";
|
} from "@/types/warehouse";
|
||||||
import { endpoint } from "@/utils/endpoint";
|
import { endpoint } from "@/utils/endpoint";
|
||||||
|
import {
|
||||||
|
accountService,
|
||||||
|
type ChangePasswordPayload,
|
||||||
|
type SendContactOtpPayload,
|
||||||
|
type UpdateContactPayload,
|
||||||
|
} from "./account.service";
|
||||||
import {
|
import {
|
||||||
BookingListFilter,
|
BookingListFilter,
|
||||||
bookingsService,
|
bookingsService,
|
||||||
@@ -2182,6 +2188,29 @@ export const api = {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
account: {
|
||||||
|
changePassword: endpoint<ChangePasswordPayload, void>(
|
||||||
|
"me",
|
||||||
|
"change-password",
|
||||||
|
(payload) => accountService.changePassword(payload),
|
||||||
|
),
|
||||||
|
|
||||||
|
sendContactOtp: endpoint<SendContactOtpPayload, { sentTo: string }>(
|
||||||
|
"me",
|
||||||
|
"send-contact-otp",
|
||||||
|
(payload) => accountService.sendContactOtp(payload),
|
||||||
|
),
|
||||||
|
|
||||||
|
updateContact: endpoint<
|
||||||
|
UpdateContactPayload,
|
||||||
|
{ success: true; value: string }
|
||||||
|
>(
|
||||||
|
"me",
|
||||||
|
"update-contact",
|
||||||
|
(payload) => accountService.updateContact(payload),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
signatures: {
|
signatures: {
|
||||||
mySignature: endpoint<void, SavedSignature | null>(
|
mySignature: endpoint<void, SavedSignature | null>(
|
||||||
"me",
|
"me",
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import { api as client } from "../auth/http";
|
|||||||
|
|
||||||
const BASE = "/contract-templates";
|
const BASE = "/contract-templates";
|
||||||
|
|
||||||
|
export const BULK_TEMPLATE_DIRECTIONS = ["IMPORT", "EXPORT", "INTERCITY"] as const;
|
||||||
|
|
||||||
|
export type BulkTemplateDirection = (typeof BULK_TEMPLATE_DIRECTIONS)[number];
|
||||||
|
|
||||||
export interface ContractTemplateArticle {
|
export interface ContractTemplateArticle {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -23,7 +27,12 @@ export interface ContractTemplate {
|
|||||||
/** Bulk templates only: the cargo type this template is written for. */
|
/** Bulk templates only: the cargo type this template is written for. */
|
||||||
cargoTypeId?: string | null;
|
cargoTypeId?: string | null;
|
||||||
cargoType?: { id: string; cargoTypeName: string } | null;
|
cargoType?: { id: string; cargoTypeName: string } | null;
|
||||||
/** Bulk templates only: whether this is the with-customs-clearing variant. */
|
/** Bulk templates only: IMPORT, EXPORT or INTERCITY. */
|
||||||
|
tradeDirection?: BulkTemplateDirection | null;
|
||||||
|
/**
|
||||||
|
* Bulk templates only: whether this is the with-customs-clearing variant.
|
||||||
|
* Null for intercity — domestic movements have no customs leg.
|
||||||
|
*/
|
||||||
withCustoms?: boolean | null;
|
withCustoms?: boolean | null;
|
||||||
/** The five seeded container templates — cannot be deleted. */
|
/** The five seeded container templates — cannot be deleted. */
|
||||||
isSystem: boolean;
|
isSystem: boolean;
|
||||||
@@ -33,7 +42,9 @@ export interface ContractTemplate {
|
|||||||
|
|
||||||
export interface CreateContractTemplatePayload {
|
export interface CreateContractTemplatePayload {
|
||||||
cargoTypeId: string;
|
cargoTypeId: string;
|
||||||
withCustoms: boolean;
|
tradeDirection: BulkTemplateDirection;
|
||||||
|
/** Omitted for INTERCITY — the API rejects the flag there. */
|
||||||
|
withCustoms?: boolean;
|
||||||
name?: string;
|
name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export interface Rate {
|
|||||||
currency: string;
|
currency: string;
|
||||||
rateValue: string;
|
rateValue: string;
|
||||||
rateUnit: string;
|
rateUnit: string;
|
||||||
|
/** PER_LITER fuel rates only: price = baseLiters × rateValue, once per booking. */
|
||||||
|
baseLiters?: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
proposedByStaffId: string;
|
proposedByStaffId: string;
|
||||||
approvedByCeoId: string | null;
|
approvedByCeoId: string | null;
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ export type EimsInvoiceStatus =
|
|||||||
| "SUBMITTING"
|
| "SUBMITTING"
|
||||||
| "REGISTERED"
|
| "REGISTERED"
|
||||||
| "FAILED"
|
| "FAILED"
|
||||||
| "UNKNOWN";
|
| "UNKNOWN"
|
||||||
|
| "CANCELLED";
|
||||||
|
|
||||||
/** Sanitized gateway failure: MoR's own error fields, never our signed envelope. */
|
/** Sanitized gateway failure: MoR's own error fields, never our signed envelope. */
|
||||||
export interface EimsInvoiceError {
|
export interface EimsInvoiceError {
|
||||||
@@ -30,6 +31,28 @@ export interface EimsInvoiceStatusView {
|
|||||||
/** MoR returns a Java ZonedDateTime string, stored verbatim — display as-is. */
|
/** MoR returns a Java ZonedDateTime string, stored verbatim — display as-is. */
|
||||||
eimsAckDate: string | null;
|
eimsAckDate: string | null;
|
||||||
eimsLastError: EimsInvoiceError | null;
|
eimsLastError: EimsInvoiceError | null;
|
||||||
|
/** Base64 PNG from MoR, already rendered on their side — embed as `data:image/png;base64,...`. */
|
||||||
|
eimsSignedQr: string | null;
|
||||||
|
eimsCancelledAt: string | null;
|
||||||
|
/** MoR's own confirmation string (a Java `Date#toString()`), stored verbatim — display as-is. */
|
||||||
|
eimsCancellationDate: string | null;
|
||||||
|
eimsCancellationReasonCode: string | null;
|
||||||
|
eimsCancellationRemark: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One `EimsReceipt` row — mirrors `entities/eims-receipt.entity.ts`. */
|
||||||
|
export interface EimsReceiptView {
|
||||||
|
id: string;
|
||||||
|
invoiceId: string;
|
||||||
|
kind: "SALES" | "WITHHOLDING";
|
||||||
|
status: EimsInvoiceStatus;
|
||||||
|
receiptNumber: string;
|
||||||
|
rrn: string | null;
|
||||||
|
/** Base64 PNG, same convention as `eimsSignedQr`. */
|
||||||
|
qr: string | null;
|
||||||
|
ackStatus: string | null;
|
||||||
|
submittedAt: string | null;
|
||||||
|
lastError: EimsInvoiceError | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `POST /v1/verify` response, echoed back from the gateway. */
|
/** `POST /v1/verify` response, echoed back from the gateway. */
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Navigate, Outlet, Route } from "react-router-dom";
|
|||||||
|
|
||||||
import { useAuth } from "@/auth/useAuth";
|
import { useAuth } from "@/auth/useAuth";
|
||||||
import { NO_ACCESS_PATH, resolveLandingPath } from "@/lib/landing";
|
import { NO_ACCESS_PATH, resolveLandingPath } from "@/lib/landing";
|
||||||
import { isSuperAdmin } from "@/lib/permissions";
|
import { FREIGHT_PERMS, isSuperAdmin } from "@/lib/permissions";
|
||||||
import { WithPermission } from "@/shared/hooks/useHas";
|
import { WithPermission } from "@/shared/hooks/useHas";
|
||||||
import PendingExternalUsers from "@/super-admin/components/externalUsers/PendingExternalUsers";
|
import PendingExternalUsers from "@/super-admin/components/externalUsers/PendingExternalUsers";
|
||||||
import TemplatePage from "@/super-admin/components/templates/components/templates";
|
import TemplatePage from "@/super-admin/components/templates/components/templates";
|
||||||
@@ -44,6 +44,8 @@ import { SidebarProvider } from "@/shared/common/ui/sidebar";
|
|||||||
import { AuthProvider as UmAuthProvider } from "@/shared/context/AuthContext";
|
import { AuthProvider as UmAuthProvider } from "@/shared/context/AuthContext";
|
||||||
import { PermissionProvider } from "@/shared/context/PermissionContext";
|
import { PermissionProvider } from "@/shared/context/PermissionContext";
|
||||||
import UserManagementPage from "@/pages/UserManagementPage";
|
import UserManagementPage from "@/pages/UserManagementPage";
|
||||||
|
import { RequirePermission } from "@/components/auth/RequirePermission";
|
||||||
|
import UploadTeeterAndSignature from "@/record-management/components/Settings/uploadTeeterandSingature";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provider shell for the vendored IAM UI. Feeds its Auth + Permission contexts
|
* Provider shell for the vendored IAM UI. Feeds its Auth + Permission contexts
|
||||||
@@ -138,6 +140,29 @@ export function UserManagementRoutes(): ReactElement {
|
|||||||
path="user-management/position-management"
|
path="user-management/position-management"
|
||||||
element={<PositionManagementPage />}
|
element={<PositionManagementPage />}
|
||||||
/>
|
/>
|
||||||
|
{/*
|
||||||
|
The per-officer teeter (ማህተም) + signature upload. This
|
||||||
|
is NOT the company stamp: it is the individual approval
|
||||||
|
stamp a record officer applies to records, locale-aware
|
||||||
|
(am/en) and genuinely per-person. It used to sit at
|
||||||
|
/dashboard/stamp-settings under Settings, next to the
|
||||||
|
single global company stamp, which read as duplication.
|
||||||
|
|
||||||
|
Gated by settings:teeter:*, split out of settings:stamp:*
|
||||||
|
when the two were untangled — settings:stamp:* now means
|
||||||
|
the company stamp, so anyone who held it for the teeter
|
||||||
|
needs the new key granted.
|
||||||
|
*/}
|
||||||
|
<Route
|
||||||
|
path="user-management/teeter-and-signature"
|
||||||
|
element={
|
||||||
|
<RequirePermission
|
||||||
|
permission={FREIGHT_PERMS.settings.teeter.view}
|
||||||
|
>
|
||||||
|
<UploadTeeterAndSignature />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="user-management/migrated-records-management"
|
path="user-management/migrated-records-management"
|
||||||
element={<MigratedDataManagementPage />}
|
element={<MigratedDataManagementPage />}
|
||||||
|
|||||||
@@ -169,7 +169,8 @@ export default function OnboardingWizardDialog({
|
|||||||
setCooperative(checked);
|
setCooperative(checked);
|
||||||
if (checked) {
|
if (checked) {
|
||||||
setRoles((prev) => prev.filter((r) => r !== "freight_forwarder"));
|
setRoles((prev) => prev.filter((r) => r !== "freight_forwarder"));
|
||||||
setNationality((prev) => (prev === "foreign" ? "ethiopian" : prev));
|
// Ethiopian is then the only answer left, so it is made rather than asked.
|
||||||
|
setNationality("ethiopian");
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
const [documentFiles, setDocumentFiles] = useState<
|
const [documentFiles, setDocumentFiles] = useState<
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user