Gross weight (tare + load): ${num(totals.tare + totals.load)} T
+
${money(totalAmount)}
+
+
+
+
+
+ ${
+ pendingWagons
+ ? `The cargo listed above is accepted for carriage under booking ${esc(booking.reference)}.
+ Wagon identity and seal numbers are filled in when the booking is marshalled onto a train.`
+ : `The wagons listed above are accepted for carriage under booking ${esc(booking.reference)}.
+ Wagon identity, container and seal numbers must be verified against the physical consist
+ before the sheet is signed.`
+ }
+
+
+
+
Signed by — EDR operations / date
+
Signed by — customer or agent / date
+
Signed by — marshalling yard / date
+
+
+`;
+ }
+
/** Resolve trade direction from yard countries; reject client mismatch. */
/**
* An intercity corridor is valid when both yards are Ethiopian and at least
@@ -602,6 +909,24 @@ export class BookingsService {
return result.booking;
}
+ /**
+ * A company with an open unpaid hold (SELECTED_FOR_BATCH — wagons reserved,
+ * pay window running) may not take more capacity until it pays or the hold
+ * dies: otherwise one customer can lock a train's wagons over and over
+ * without ever paying. EXPIRED / CANCELLED holds free the lock.
+ */
+ async assertNoUnpaidHold(companyId?: string | null): Promise {
+ if (!companyId) return;
+ const holds =
+ await this.bookingsRepository.countUnpaidHoldsForCompany(companyId);
+ if (holds > 0) {
+ throw new ConflictException(
+ 'You already have a booking waiting for payment. Pay it or cancel it ' +
+ 'before making a new booking.',
+ );
+ }
+ }
+
/** Create a new freight booking. */
async create(
dto: CreateBookingDto,
@@ -664,6 +989,10 @@ export class BookingsService {
companyId = company.id;
}
+ // Government bookings allocate without paying, so the unpaid-hold lock
+ // only applies to commercial companies.
+ if (!isGovernment) await this.assertNoUnpaidHold(companyId);
+
if (dto.trainScheduleId) {
// Staff manual pin: the schedule must be OPEN and on the same route.
const schedule = await this.dataSource
@@ -857,6 +1186,9 @@ export class BookingsService {
cargoFreeText: dto.cargoFreeText,
shippingLineId: dto.shippingLineId,
cargoTotalWeightVgm: dto.cargoTotalWeightVgm,
+ // Break-bulk actual tonnage (PER_ITEM cargo); meaningless outside BULK.
+ bulkTotalWeightTons:
+ dto.freightType === 'BULK' ? (dto.bulkTotalWeightTons ?? null) : null,
isHazardous: dto.isHazardous ?? false,
// Bulk reefer is the customer's toggle; container reefer is derived from
// the container type at pricing time, so the booking-level flag stays off
@@ -903,6 +1235,7 @@ export class BookingsService {
vgmPerUnitTons: c.vgmPerUnitTons,
hazardousQuantity: c.hazardousQuantity,
reeferQuantity: c.reeferQuantity,
+ containerNumbers: c.containerNumbers,
weightResult: ruleResult.containerWeightResults[i],
})),
);
@@ -957,6 +1290,21 @@ export class BookingsService {
warnings.push(...consolidation.messages);
}
+ // Government bookings pass every customer step at creation: the server
+ // expedites them to PAID/Eligible, generates the contract (signable at any
+ // time) and queues priority placement. Best-effort — the booking row is
+ // already inserted, so a late failure must not 500 the whole create; the
+ // idempotent expedite endpoint remains the retry path.
+ if (isGovernment) {
+ try {
+ full = await this.governmentExpedite(booking.id, userId ?? 'system');
+ } catch (err) {
+ warnings.push(
+ `Government expedite incomplete — retry via the expedite action: ${(err as Error).message}`,
+ );
+ }
+ }
+
return { booking: full, warnings };
}
@@ -1048,6 +1396,11 @@ export class BookingsService {
...dto,
freightType,
cargoTypeId: freightType === 'BULK' ? cargoTypeId : null,
+ // Break-bulk actual tonnage; cleared when the booking leaves BULK.
+ bulkTotalWeightTons:
+ freightType === 'BULK'
+ ? (dto.bulkTotalWeightTons ?? existing.bulkTotalWeightTons ?? null)
+ : null,
// Booking-level reefer is only meaningful for bulk; container reefer is
// derived from the container type at pricing time.
isReefer:
@@ -1800,13 +2153,22 @@ export class BookingsService {
return false;
}
- /** Staff expedite: mark a government booking PAID and ready for scheduling (no commercial hold). */
+ /**
+ * Expedite a government booking past every customer step: PAID + Eligible
+ * (no commercial hold, no payment), contract generated server-side (signable
+ * at any time), and the (route, day) fill kicked immediately so it grabs a
+ * seat on any open train — government-first, preempting commercial cargo if
+ * the day is full. Runs automatically at creation; the endpoint remains as a
+ * no-op-safe retry for older bookings.
+ */
async governmentExpedite(id: string, staffUserId: string): Promise {
const booking = await this.findById(id);
if (!booking.isGovernment) {
throw new BadRequestException('Only government bookings can be expedited');
}
- const blocked = ['PAID', 'IN_TRANSIT', 'ARRIVED', 'COMPLETED', 'CANCELLED', 'REJECTED'];
+ // Idempotent: create() already expedites — a repeat call changes nothing.
+ if (booking.status === 'PAID') return booking;
+ const blocked = ['IN_TRANSIT', 'ARRIVED', 'COMPLETED', 'CANCELLED', 'REJECTED'];
if (blocked.includes(booking.status)) {
throw new BadRequestException(`Cannot expedite booking in status ${booking.status}`);
}
@@ -1818,12 +2180,22 @@ export class BookingsService {
holdStartedAt: null,
holdExpiresAt: null,
});
+ await this.bookingContractService.generateContractForGovernment(id);
await this.bookingsRepository.createReviewNote(
id,
`Government booking expedited to PAID by staff (${staffUserId})`,
'STAFF_NOTE',
staffUserId,
);
+ // Priority placement: run the day-level fill now instead of waiting for a
+ // batch tick — the pool sorts government first and preempts if needed.
+ if (booking.scheduledDate) {
+ this.bookingBatchService.enqueueRouteDayProcessing(
+ booking.originYardId,
+ booking.destinationYardId,
+ eatDay(booking.scheduledDate),
+ );
+ }
return this.findById(id);
}
diff --git a/apps/edr-freight-api/src/modules/bookings/carriage-acceptance-price-split.spec.ts b/apps/edr-freight-api/src/modules/bookings/carriage-acceptance-price-split.spec.ts
new file mode 100644
index 000000000..195e0cab0
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/bookings/carriage-acceptance-price-split.spec.ts
@@ -0,0 +1,26 @@
+import { BookingsService } from './bookings.service';
+
+// The split is a pure helper on the prototype (never touches `this`), so it can be
+// exercised without constructing the service and its dependency graph.
+const split = (total: number, weights: number[]): number[] =>
+ (
+ BookingsService.prototype as unknown as {
+ splitAmountAcrossWagons(total: number, weights: number[]): number[];
+ }
+ ).splitAmountAcrossWagons(total, weights);
+
+describe('carriage acceptance sheet — price split', () => {
+ it('splits proportionally to allocated weight', () => {
+ expect(split(100, [30, 10])).toEqual([75, 25]);
+ });
+
+ it('splits equally when no weights are recorded', () => {
+ expect(split(90, [0, 0, 0])).toEqual([30, 30, 30]);
+ });
+
+ it('always sums back to the booking total despite rounding', () => {
+ const shares = split(100, [1, 1, 1]);
+ expect(shares.reduce((a, b) => a + b, 0)).toBe(100);
+ expect(shares).toEqual([33.33, 33.33, 33.34]);
+ });
+});
diff --git a/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts b/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts
index 81e5c833a..e38715ae9 100644
--- a/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts
+++ b/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts
@@ -11,10 +11,8 @@ describe('clearance.util — clearanceSettingCode', () => {
expect(clearanceSettingCode('IMPORT', 'CONTAINER', true)).toBe(
'clearance_import_container_with_customs',
);
- // Non-customs bookings self-clear with the same document set a ONE_TIME
- // self-clear contract uses.
expect(clearanceSettingCode('IMPORT', 'CONTAINER', false)).toBe(
- 'contract_clearance_selfclear_import_container',
+ 'clearance_import_container_without_customs',
);
});
@@ -23,7 +21,7 @@ describe('clearance.util — clearanceSettingCode', () => {
'clearance_export_bulk_with_customs',
);
expect(clearanceSettingCode('EXPORT', 'BULK', false)).toBe(
- 'contract_clearance_selfclear_export_bulk',
+ 'clearance_export_bulk_without_customs',
);
});
diff --git a/apps/edr-freight-api/src/modules/bookings/clearance.util.ts b/apps/edr-freight-api/src/modules/bookings/clearance.util.ts
index 242cee9d3..e28917715 100644
--- a/apps/edr-freight-api/src/modules/bookings/clearance.util.ts
+++ b/apps/edr-freight-api/src/modules/bookings/clearance.util.ts
@@ -39,12 +39,11 @@ export function clearanceSettingCode(
const op = operationFor(tradeDirection);
if (!op) return null;
const freight = freightFor(freightType);
- // Non-customs (Path A) bookings self-clear: the customer proves his own
- // clearance with the SAME smaller document set a ONE_TIME self-clear
- // contract uses (customs declaration, release permit, …) — not the
- // GL-oriented booking sets.
+ // 4 import + 4 export cases (bulk/container × with/without customs) — each
+ // booking resolves to its own clearance_{op}_{freight}_{with|without}_customs
+ // set, independent of any contract-level clearance codes.
if (!includesCustoms) {
- return `contract_clearance_selfclear_${op}_${freight}`;
+ return `clearance_${op}_${freight}_without_customs`;
}
return `clearance_${op}_${freight}_with_customs`;
}
diff --git a/apps/edr-freight-api/src/modules/bookings/dto/contract-view.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/contract-view.dto.ts
index 919652af6..0dceadf43 100644
--- a/apps/edr-freight-api/src/modules/bookings/dto/contract-view.dto.ts
+++ b/apps/edr-freight-api/src/modules/bookings/dto/contract-view.dto.ts
@@ -20,6 +20,9 @@ export class SavedSignatureViewDto {
@ApiPropertyOptional()
signatureImageUrl?: string | null;
+
+ @ApiPropertyOptional()
+ stampImageUrl?: string | null;
}
export class ContractViewDto {
diff --git a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts
index c4d971f51..a9aca53dd 100644
--- a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts
+++ b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts
@@ -74,6 +74,17 @@ export class CreateBookingContainerDto {
@Min(0)
@Transform(({ value }) => Number(value ?? 0))
reeferQuantity?: number;
+
+ @ApiPropertyOptional({
+ description:
+ 'Physical container numbers for this line (each becomes a booking_container_unit; extras beyond `quantity` are ignored)',
+ type: [String],
+ })
+ @IsOptional()
+ @IsArray()
+ @IsString({ each: true })
+ @MaxLength(64, { each: true })
+ containerNumbers?: string[];
}
/**
@@ -325,6 +336,21 @@ export class CreateBookingDto {
@Transform(({ value }) => Number(value))
cargoTotalWeightVgm!: number;
+ /**
+ * Break-bulk only: actual total cargo weight in tons when the bulk cargo
+ * type is PER_ITEM — `cargoTotalWeightVgm` then carries the item count.
+ * Omit for PER_TON bulk and container freight.
+ */
+ @ApiPropertyOptional({
+ minimum: 0,
+ description: 'Break-bulk (PER_ITEM) total weight in tons; cargoTotalWeightVgm holds the item count',
+ })
+ @IsOptional()
+ @IsNumber()
+ @Min(0)
+ @Transform(({ value }) => (value == null ? undefined : Number(value)))
+ bulkTotalWeightTons?: number;
+
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
diff --git a/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts
index f27b375bb..63ad5b9f4 100644
--- a/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts
+++ b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts
@@ -5,6 +5,7 @@ import {
IsInt,
IsOptional,
IsString,
+ IsUUID,
Max,
Min,
MinLength,
@@ -93,6 +94,17 @@ export class RequestOperationDto {
})
@IsDateString()
scheduledDate!: string;
+
+ @ApiPropertyOptional({
+ description:
+ 'EXPORT rail only: the specific train (schedule id) the customer picked ' +
+ 'from GET /bookings/:id/export-trains. The reserve path locks onto this ' +
+ 'train instead of earliest-first; 409 if it no longer fits. Ignored for ' +
+ 'import/domestic/road bookings.',
+ })
+ @IsOptional()
+ @IsUUID()
+ trainScheduleId?: string;
}
export class OperationReviewDto {
diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts
index 3d0603f5f..3d339fa19 100644
--- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts
+++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts
@@ -365,6 +365,15 @@ export class Booking extends BaseEntity {
@Column({ name: 'cargo_total_weight_vgm', type: 'numeric', precision: 12, scale: 3 })
cargoTotalWeightVgm!: number;
+ /**
+ * Break-bulk only: actual total cargo weight in tons when the bulk cargo
+ * type is PER_ITEM (`cargoTotalWeightVgm` then carries the item COUNT).
+ * Null for PER_TON bulk and all CONTAINER bookings. Wagon allocation uses
+ * weight ÷ count to size indivisible items per wagon.
+ */
+ @Column({ name: 'bulk_total_weight_tons', type: 'numeric', precision: 12, scale: 3, nullable: true })
+ bulkTotalWeightTons?: number | null;
+
@Column({ name: 'is_hazardous', type: 'boolean', default: false })
isHazardous!: boolean;
@@ -499,6 +508,18 @@ export class Booking extends BaseEntity {
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
trainScheduleId?: string | null;
+ /**
+ * EXPORT only: the specific train the customer picked at day-commit.
+ * pickExportSchedule reserves on this train (409 if it no longer fits)
+ * instead of falling back to earliest-departure-first. NULL = no preference.
+ */
+ @Column({ name: 'requested_train_schedule_id', type: 'uuid', nullable: true })
+ requestedTrainScheduleId?: string | null;
+
+ /** Stamped when the one pre-deadline pay reminder went out (tick dedup). */
+ @Column({ name: 'payment_reminder_sent_at', type: 'timestamptz', nullable: true })
+ paymentReminderSentAt?: Date | null;
+
// ── Per-booking journey (segment corridor bookings) ────────────────────────
// A booking rides only its own origin→destination leg of the train's route,
// so dispatch/arrival are per-booking facts, not train facts. Clearance gates
diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts
index 8e3be4d1a..ee75460c9 100644
--- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts
+++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts
@@ -35,6 +35,10 @@ import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto";
import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto";
import { CreateCompanyProfileDto } from "./dto/create-company-profile.dto";
+import {
+ CompanyIdentityStateDto,
+ CompleteIdentityVerificationDto,
+} from "./dto/complete-identity-verification.dto";
import { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto";
import { StartOnboardingDto } from "./dto/start-onboarding.dto";
import { DashboardQueryDto } from "./dto/dashboard-query.dto";
@@ -188,9 +192,20 @@ export class CompaniesController {
@Post("fetch-etrade-info")
@ApiOperation({ summary: "Fetch company info from eTrade by TIN" })
async fetchETradeInfo(
+ @CurrentUser() user: CurrentIamUser,
@Body() dto: FetchETradeDto,
): Promise {
- const data = await this.companiesService.fetchETradeData(dto.tin);
+ // Best-effort: a first-run onboarding draft may not exist yet, in which
+ // case there is no company to exclude and `tinTaken` checks every row —
+ // the correct behaviour for a brand-new lookup.
+ const companyId = await this.companiesService
+ .getCompanyInfoByUserId(user.id)
+ .then(({ company }) => company.id)
+ .catch(() => undefined);
+ const data = await this.companiesService.fetchETradeData(
+ dto.tin,
+ companyId,
+ );
return new ETradeResponseDto(data);
}
@@ -378,6 +393,32 @@ export class CompaniesController {
return this.companiesService.removePoaDelegationLetter(user.id, fileId);
}
+ @Post("identity/fayda/complete")
+ @ApiOperation({
+ summary:
+ "Bind a completed Fayda verification to the company's owner or Power of Attorney. " +
+ "Start the flow with POST /fayda/verification/start (platform=PORTAL), then post the returned code+state here. " +
+ "The verified name, phone, email and address are written from the Fayda payload; on an approved company the change is staged for backoffice review.",
+ })
+ async completeIdentityVerification(
+ @CurrentUser() user: CurrentIamUser,
+ @Body() dto: CompleteIdentityVerificationDto,
+ ): Promise {
+ return this.companiesService.completeIdentityVerification(user.id, dto);
+ }
+
+ @Delete("identity/fayda/poa")
+ @ApiOperation({
+ summary:
+ "Remove the company's Power of Attorney — the verified identity, its details and the delegation paper together. " +
+ "Refused while the company holds a freight forwarder role, which cannot operate without a representative.",
+ })
+ async removePoaIdentity(
+ @CurrentUser() user: CurrentIamUser,
+ ): Promise {
+ return this.companiesService.removePoaIdentity(user.id);
+ }
+
@Patch("onboarding-step")
@ApiOperation({ summary: "Persist the user's current onboarding wizard step" })
@HttpCode(HttpStatus.NO_CONTENT)
diff --git a/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts
new file mode 100644
index 000000000..c13f79f5e
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts
@@ -0,0 +1,458 @@
+import { BadRequestException } from "@nestjs/common";
+
+import { CompaniesService } from "./companies.service";
+import { CompanyNationality, CompanyStatus } from "./entities/company.entity";
+import { ProfileType } from "./entities/company-profile.entity";
+import { POA_DELEGATION_FILE_KEY } from "../file-upload-settings/poa-delegation.constants";
+
+/**
+ * A person's identity is proved through Fayda: name, email, phone and address
+ * come from the verified payload, not typed. Fayda's userinfo carries no
+ * national ID number, so none is collected or derived here.
+ *
+ * Only the OWNER's credential varies by nationality:
+ * - Ethiopian company: the owner is verified through Fayda.
+ * - Foreign company: Fayda is an Ethiopian national ID, so the owner instead
+ * supplies a typed passport number — required on its own, whether or not the
+ * owner also completes a (purely optional) Fayda verification.
+ *
+ * The PoA does not vary. A representative acts for the company inside Ethiopia
+ * whoever owns it, so a PoA is always an Ethiopian holding a Fayda ID: once one
+ * is named, both nationalities must verify them, and their details come from
+ * the verified payload rather than the form.
+ *
+ * The owner is NOT the general manager — GM is a separate, plain typed role
+ * the portal offers a "same as owner" copy for, but it is never itself
+ * Fayda-verified or gated on.
+ */
+
+interface Ctx {
+ attributes: Record;
+ files: { id: string; code: string; reviewStatus?: string | null }[];
+ profileTypes: ProfileType[];
+ status: CompanyStatus;
+ nationality: CompanyNationality;
+ verification: Record;
+}
+
+const OWNER_VERIFIED = {
+ ownerFaydaSub: "owner-sub",
+ ownerFaydaVerifiedAt: "2026-07-01T00:00:00.000Z",
+ ownerName: "Abebe Bikila",
+};
+
+const POA_VERIFIED = {
+ poaFaydaSub: "poa-sub",
+ poaFaydaVerifiedAt: "2026-07-02T00:00:00.000Z",
+ poaName: "Tirunesh Dibaba",
+ poaEmail: "tirunesh@example.com",
+ poaPhone: "+251911000000",
+};
+
+const paper = () => ({
+ id: "file-1",
+ code: POA_DELEGATION_FILE_KEY,
+ reviewStatus: null,
+});
+
+function makeService(overrides: Partial = {}) {
+ const ctx: Ctx = {
+ attributes: {},
+ files: [],
+ profileTypes: [ProfileType.importer],
+ status: CompanyStatus.Pending,
+ nationality: CompanyNationality.Ethiopian,
+ verification: {
+ purpose: "VERIFY",
+ verified: true,
+ sub: "new-sub",
+ fullName: "Haile Gebrselassie",
+ email: "haile@example.com",
+ phoneNumber: "+251922000000",
+ address: "Addis Ababa",
+ birthdate: "1973-04-18",
+ gender: "Male",
+ },
+ ...overrides,
+ };
+
+ const company = () => ({
+ id: "company-1",
+ status: ctx.status,
+ nationality: ctx.nationality,
+ attributes: ctx.attributes,
+ companyProfiles: ctx.profileTypes.map((type, i) => ({
+ id: `profile-${i}`,
+ type,
+ })),
+ type: "customer",
+ });
+
+ const deps = {
+ companiesRepo: {
+ findById: jest.fn(async () => company()),
+ update: jest.fn(async (_id: string, patch: Record) => {
+ if (patch.attributes)
+ ctx.attributes = patch.attributes as Record;
+ return company();
+ }),
+ findByTin: jest.fn(async () => null),
+ },
+ companyProfilesRepo: {
+ findByCompanyId: jest.fn(async () =>
+ ctx.profileTypes.map((type, i) => ({ id: `profile-${i}`, type })),
+ ),
+ findByType: jest.fn(async (_id: string, type: ProfileType) =>
+ ctx.profileTypes.includes(type) ? { id: "existing", type } : null,
+ ),
+ create: jest.fn(async (row: Record) => ({
+ id: "new",
+ ...row,
+ })),
+ },
+ changeRequestRepo: {
+ findPendingByCompanyId: jest.fn(async () => null),
+ findLatestOpenByCompanyId: jest.fn(async () => null),
+ findByCompanyId: jest.fn(async () => []),
+ create: jest.fn(async (row: Record) => ({
+ id: "cr-1",
+ ...row,
+ })),
+ update: jest.fn(async () => ({ id: "cr-1" })),
+ },
+ profilesRepo: {
+ findByCompanyId: jest.fn(async () => []),
+ findByUserId: jest.fn(async () => ({
+ id: "external-1",
+ companyId: "company-1",
+ company: company(),
+ onboardingCompleted: false,
+ })),
+ },
+ filesService: {
+ findByResource: jest.fn(async () => ctx.files),
+ findById: jest.fn(async () => null),
+ remove: jest.fn(async () => undefined),
+ },
+ companyNotifier: { changeRequestSubmitted: jest.fn() },
+ verifayda: {
+ completeVerification: jest.fn(async () => ctx.verification),
+ },
+ };
+
+ const service = new CompaniesService(
+ deps.companiesRepo as never,
+ deps.companyProfilesRepo as never,
+ deps.changeRequestRepo as never,
+ deps.profilesRepo as never,
+ {} as never,
+ deps.filesService as never,
+ {} as never,
+ {} as never,
+ deps.companyNotifier as never,
+ {} as never,
+ deps.verifayda as never,
+ );
+
+ jest
+ .spyOn(service, "getCompanyInfoByUserId")
+ .mockImplementation(
+ async () =>
+ ({ profile: { id: "external-1" }, company: company() }) as never,
+ );
+
+ return { service, ctx, deps, company };
+}
+
+describe("Fayda identity verification binds a person to the company", () => {
+ it("writes the verified identity", async () => {
+ const { service, ctx } = makeService();
+
+ const state = await service.completeIdentityVerification("user-1", {
+ subject: "owner",
+ code: "c",
+ state: "s",
+ });
+
+ expect(ctx.attributes.ownerFaydaSub).toBe("new-sub");
+ expect(ctx.attributes.ownerName).toBe("Haile Gebrselassie");
+ expect(state.owner.verified).toBe(true);
+ });
+
+ it("fills every PoA detail from the payload, address included", async () => {
+ const { service, ctx } = makeService();
+
+ await service.completeIdentityVerification("user-1", {
+ subject: "poa",
+ code: "c",
+ state: "s",
+ });
+
+ expect(ctx.attributes.poaName).toBe("Haile Gebrselassie");
+ expect(ctx.attributes.poaEmail).toBe("haile@example.com");
+ expect(ctx.attributes.poaPhone).toBe("+251922000000");
+ expect(ctx.attributes.poaAddress).toBe("Addis Ababa");
+ });
+
+ it("verifies successfully even though Fayda returns no national ID number", async () => {
+ // Fayda's userinfo carries no FAN/FIN claim at all — this must be the
+ // normal, successful path, not an error.
+ const { service } = makeService({
+ verification: {
+ purpose: "VERIFY",
+ verified: true,
+ sub: "x",
+ fullName: "No Fan Here",
+ },
+ });
+
+ const state = await service.completeIdentityVerification("user-1", {
+ subject: "owner",
+ code: "c",
+ state: "s",
+ });
+
+ expect(state.owner.verified).toBe(true);
+ });
+
+ it("refuses to make one identity both owner and PoA", async () => {
+ const { service } = makeService({
+ attributes: { ownerFaydaSub: "same-person" },
+ verification: {
+ purpose: "VERIFY",
+ verified: true,
+ sub: "same-person",
+ fullName: "Abebe Bikila",
+ },
+ });
+
+ await expect(
+ service.completeIdentityVerification("user-1", {
+ subject: "poa",
+ code: "c",
+ state: "s",
+ }),
+ ).rejects.toBeInstanceOf(BadRequestException);
+ });
+
+ it("stages an owner re-verification for review on an approved company", async () => {
+ // The owner is the live company's identity proof, so re-verifying one is
+ // exactly what the backoffice review exists for: it must not rewrite the
+ // row directly.
+ const { service, ctx, deps } = makeService({
+ status: CompanyStatus.Active,
+ });
+
+ await service.completeIdentityVerification("user-1", {
+ subject: "owner",
+ code: "c",
+ state: "s",
+ });
+
+ expect(deps.changeRequestRepo.create).toHaveBeenCalled();
+ expect(ctx.attributes.ownerFaydaSub).toBeUndefined();
+ });
+
+ it("applies a PoA verification live on an approved company", async () => {
+ // The PoA is personnel the company names for itself — the delegation paper
+ // is what a reviewer actually judges — so it does not go to review.
+ const { service, ctx, deps } = makeService({
+ status: CompanyStatus.Active,
+ });
+
+ await service.completeIdentityVerification("user-1", {
+ subject: "poa",
+ code: "c",
+ state: "s",
+ });
+
+ expect(deps.changeRequestRepo.create).not.toHaveBeenCalled();
+ expect(ctx.attributes.poaFaydaSub).toBe("new-sub");
+ });
+
+ it("refuses to rename a verified person by hand", async () => {
+ const { service } = makeService({
+ attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED },
+ files: [paper()],
+ });
+
+ await expect(
+ service.updateProfile("user-1", { poaName: "Someone Else" } as never),
+ ).rejects.toBeInstanceOf(BadRequestException);
+ });
+
+ it("never locks or gates the general manager — it is not the verified subject", async () => {
+ // GM is a plain typed role; the portal offers a "same as owner" copy, but
+ // the backend must not treat it as identity-owned or require it verified.
+ const { service } = makeService({
+ attributes: { ...OWNER_VERIFIED },
+ });
+
+ await expect(
+ service.updateProfile("user-1", {
+ generalManagerName: "Someone Else",
+ generalManagerEmail: "someone@example.com",
+ generalManagerPhone: "+251911223344",
+ } as never),
+ ).resolves.toBeDefined();
+ });
+});
+
+describe("Ethiopian companies verify with Fayda; foreign companies verify identity by passport", () => {
+ // The company is applying for the forwarder role, so it must not already
+ // hold it — createCompanyProfileForUser short-circuits on an existing profile
+ // and would never reach the gate.
+ const applyingForFf = {
+ profileTypes: [ProfileType.importer],
+ attributes: { ...POA_VERIFIED },
+ files: [paper()],
+ };
+
+ it("blocks the forwarder role while the owner is unverified", async () => {
+ const { service } = makeService(applyingForFf);
+
+ await expect(
+ service.createCompanyProfileForUser(
+ "user-1",
+ ProfileType.freightForwarder,
+ ),
+ ).rejects.toBeInstanceOf(BadRequestException);
+ });
+
+ it("blocks the forwarder role while the PoA is unverified", async () => {
+ const { service } = makeService({
+ profileTypes: [ProfileType.importer],
+ attributes: {
+ ...OWNER_VERIFIED,
+ poaName: "Tirunesh Dibaba",
+ poaEmail: "t@example.com",
+ poaPhone: "+251911000000",
+ },
+ files: [paper()],
+ });
+
+ await expect(
+ service.createCompanyProfileForUser(
+ "user-1",
+ ProfileType.freightForwarder,
+ ),
+ ).rejects.toBeInstanceOf(BadRequestException);
+ });
+
+ it("grants the forwarder role once owner and PoA are both verified", async () => {
+ const { service } = makeService({
+ profileTypes: [ProfileType.importer],
+ attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED },
+ files: [paper()],
+ });
+
+ await expect(
+ service.createCompanyProfileForUser(
+ "user-1",
+ ProfileType.freightForwarder,
+ ),
+ ).resolves.toBeDefined();
+ });
+
+ it("never asks a foreign company for Fayda, verified or not", async () => {
+ const { service } = makeService({
+ nationality: CompanyNationality.Foreign,
+ });
+
+ const state = await service.completeIdentityVerification("user-1", {
+ subject: "owner",
+ code: "c",
+ state: "s",
+ });
+
+ // Still lets the owner verify — a foreign owner verifying is allowed, just
+ // never required — but the passport is the thing that actually gates it.
+ expect(state.owner.verified).toBe(true);
+ expect(state.faydaRequired).toBe(false);
+ expect(state.passportRequired).toBe(true);
+ });
+
+ it("blocks the forwarder role for a foreign company with no owner passport", async () => {
+ const { service } = makeService({
+ profileTypes: [ProfileType.importer],
+ nationality: CompanyNationality.Foreign,
+ attributes: {
+ poaName: "Jean Dupont",
+ poaEmail: "jean@example.com",
+ poaPhone: "+33100000000",
+ },
+ });
+
+ await expect(
+ service.createCompanyProfileForUser(
+ "user-1",
+ ProfileType.freightForwarder,
+ ),
+ ).rejects.toBeInstanceOf(BadRequestException);
+ });
+
+ it("grants the forwarder role to a foreign company whose owner has a passport and whose PoA is Fayda-verified", async () => {
+ const { service } = makeService({
+ profileTypes: [ProfileType.importer],
+ nationality: CompanyNationality.Foreign,
+ attributes: {
+ ownerPassportNumber: "P1234567",
+ ...POA_VERIFIED,
+ },
+ files: [paper()],
+ });
+
+ await expect(
+ service.createCompanyProfileForUser(
+ "user-1",
+ ProfileType.freightForwarder,
+ ),
+ ).resolves.toBeDefined();
+ });
+
+ it("still requires a Fayda-verified PoA from a foreign company", async () => {
+ // The owner's credential is nationality-specific; the representative's is
+ // not. A PoA acts for the company inside Ethiopia whoever owns it, so a
+ // typed foreign name is not a representative the platform can accept.
+ const { service } = makeService({
+ profileTypes: [ProfileType.importer],
+ nationality: CompanyNationality.Foreign,
+ attributes: {
+ ownerPassportNumber: "P1234567",
+ poaName: "Jean Dupont",
+ poaEmail: "jean@example.com",
+ poaPhone: "+33100000000",
+ },
+ files: [paper()],
+ });
+
+ await expect(
+ service.createCompanyProfileForUser(
+ "user-1",
+ ProfileType.freightForwarder,
+ ),
+ ).rejects.toBeInstanceOf(BadRequestException);
+ });
+
+ it("still requires the passport for a foreign owner who chose to verify with Fayda too", async () => {
+ // Verifying is optional for a foreign owner, but it does not waive the
+ // passport requirement — the two are independent credentials.
+ const { service } = makeService({
+ profileTypes: [ProfileType.importer],
+ nationality: CompanyNationality.Foreign,
+ attributes: {
+ ...OWNER_VERIFIED,
+ poaName: "Jean Dupont",
+ poaEmail: "jean@example.com",
+ poaPhone: "+33100000000",
+ },
+ });
+
+ await expect(
+ service.createCompanyProfileForUser(
+ "user-1",
+ ProfileType.freightForwarder,
+ ),
+ ).rejects.toBeInstanceOf(BadRequestException);
+ });
+});
diff --git a/apps/edr-freight-api/src/modules/companies/companies.module.ts b/apps/edr-freight-api/src/modules/companies/companies.module.ts
index 73826689a..450222657 100644
--- a/apps/edr-freight-api/src/modules/companies/companies.module.ts
+++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts
@@ -20,6 +20,7 @@ import { CompanyProfileRepository } from "./company-profile.repository";
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
import { ETradeService } from "./services/etrade.service";
import { CompanyNotifierService } from "./company-notifier.service";
+import { VerifaydaModule } from "../verifayda/verifayda.module";
@Module({
imports: [
@@ -38,6 +39,8 @@ import { CompanyNotifierService } from "./company-notifier.service";
// imports this module back for portal recipient targeting, hence forwardRef.
NotificationsModule,
forwardRef(() => NotificationInboxModule),
+ // Fayda identity verification for the company's owner and PoA.
+ VerifaydaModule,
],
controllers: [CompaniesController],
providers: [
diff --git a/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts
new file mode 100644
index 000000000..27c42e581
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts
@@ -0,0 +1,245 @@
+import { BadRequestException } from "@nestjs/common";
+
+import { CompaniesService } from "./companies.service";
+import { CompanyStatus } from "./entities/company.entity";
+import { ProfileType } from "./entities/company-profile.entity";
+import { POA_DELEGATION_FILE_KEY } from "../file-upload-settings/poa-delegation.constants";
+
+/**
+ * EDRFREIGHT-358: a company that names a Power of Attorney must have the DARS
+ * delegation paper on file. The rule used to live only in the onboarding
+ * wizard's completion check, so every other write that could break the pairing
+ * — saving PoA details, deleting the paper, picking up the forwarder role —
+ * went unguarded. These cover those writes.
+ */
+
+interface Ctx {
+ attributes: Record;
+ files: { id: string; code: string; reviewStatus?: string | null }[];
+ profileTypes: ProfileType[];
+ status: CompanyStatus;
+ pendingSnapshot: Record | null;
+}
+
+const POA = { poaName: "Abebe", poaEmail: "a@b.com", poaPhone: "+251911000000" };
+
+/**
+ * The forwarder role is gated on Fayda-verified identities as well as on the
+ * delegation paper. These tests are about the paper, so they run against a
+ * company whose identities are already verified — the identity rule itself is
+ * covered in companies.fayda-identity.spec.ts.
+ */
+const VERIFIED_IDENTITIES = {
+ ownerFaydaSub: "owner-sub",
+ poaFaydaSub: "poa-sub",
+};
+
+function makeService(overrides: Partial = {}) {
+ const ctx: Ctx = {
+ attributes: {},
+ files: [],
+ profileTypes: [ProfileType.importer],
+ status: CompanyStatus.Pending,
+ pendingSnapshot: null,
+ ...overrides,
+ };
+
+ const company = () => ({
+ id: "company-1",
+ status: ctx.status,
+ attributes: ctx.attributes,
+ companyProfiles: ctx.profileTypes.map((type, i) => ({
+ id: `profile-${i}`,
+ type,
+ })),
+ type: "customer",
+ });
+
+ const deps = {
+ companiesRepo: {
+ findById: jest.fn(async () => company()),
+ update: jest.fn(async (_id: string, patch: Record) => {
+ ctx.attributes = (patch.attributes ??
+ ctx.attributes) as Record;
+ return company();
+ }),
+ findByTin: jest.fn(async () => null),
+ },
+ companyProfilesRepo: {
+ findByCompanyId: jest.fn(async () =>
+ ctx.profileTypes.map((type, i) => ({ id: `profile-${i}`, type })),
+ ),
+ findByType: jest.fn(async (_id: string, type: ProfileType) =>
+ ctx.profileTypes.includes(type) ? { id: "existing", type } : null,
+ ),
+ create: jest.fn(async (row: Record) => ({
+ id: "new",
+ ...row,
+ })),
+ },
+ changeRequestRepo: {
+ findPendingByCompanyId: jest.fn(async () =>
+ ctx.pendingSnapshot ? { id: "cr-1", snapshot: ctx.pendingSnapshot } : null,
+ ),
+ findLatestOpenByCompanyId: jest.fn(async () =>
+ ctx.pendingSnapshot ? { id: "cr-1", snapshot: ctx.pendingSnapshot } : null,
+ ),
+ findByCompanyId: jest.fn(async () => []),
+ create: jest.fn(async (row: Record) => ({
+ id: "cr-1",
+ ...row,
+ })),
+ update: jest.fn(async () => ({ id: "cr-1" })),
+ },
+ profilesRepo: {
+ findByCompanyId: jest.fn(async () => []),
+ findByUserId: jest.fn(async () => ({
+ id: "external-1",
+ companyId: "company-1",
+ company: company(),
+ onboardingCompleted: false,
+ })),
+ },
+ filesService: {
+ findByResource: jest.fn(async () => ctx.files),
+ findById: jest.fn(async (id: string) =>
+ ctx.files.find((f) => f.id === id)
+ ? {
+ ...ctx.files.find((f) => f.id === id),
+ resource: "companies",
+ resourceId: "company-1",
+ name: "dars.pdf",
+ }
+ : null,
+ ),
+ remove: jest.fn(async () => undefined),
+ },
+ companyNotifier: { changeRequestSubmitted: jest.fn() },
+ };
+
+ const service = new CompaniesService(
+ deps.companiesRepo as never,
+ deps.companyProfilesRepo as never,
+ deps.changeRequestRepo as never,
+ deps.profilesRepo as never,
+ {} as never,
+ deps.filesService as never,
+ {} as never,
+ {} as never,
+ deps.companyNotifier as never,
+ {} as never,
+ {} as never,
+ );
+
+ // getCompanyInfoByUserId does its own lookups; the stubs above are enough for
+ // the PoA paths, so short-circuit it rather than mock the whole graph.
+ jest
+ .spyOn(service, "getCompanyInfoByUserId")
+ .mockImplementation(
+ async () =>
+ ({ profile: { id: "external-1" }, company: company() }) as never,
+ );
+
+ return { service, ctx, deps };
+}
+
+const paper = (reviewStatus: string | null = null) => ({
+ id: "file-1",
+ code: POA_DELEGATION_FILE_KEY,
+ reviewStatus,
+});
+
+describe("PoA delegation paper is enforced wherever PoA state changes", () => {
+ it("rejects PoA details saved with no paper on file", async () => {
+ const { service } = makeService();
+
+ await expect(
+ service.updateProfile("user-1", POA as never),
+ ).rejects.toBeInstanceOf(BadRequestException);
+ });
+
+ it("accepts PoA details once the paper is on file", async () => {
+ const { service } = makeService({ files: [paper()] });
+
+ await expect(
+ service.updateProfile("user-1", POA as never),
+ ).resolves.toBeDefined();
+ });
+
+ it("rejects a paper the reviewer sent back for correction", async () => {
+ const { service } = makeService({ files: [paper("change_requested")] });
+
+ await expect(
+ service.updateProfile("user-1", POA as never),
+ ).rejects.toBeInstanceOf(BadRequestException);
+ });
+
+ it("leaves edits that don't touch the PoA alone", async () => {
+ // A company carrying legacy details must not be locked out of every other
+ // field until it produces a paper.
+ const { service } = makeService({ attributes: { ...POA }, files: [] });
+
+ await expect(
+ service.updateProfile("user-1", { companyEmail: "x@y.com" } as never),
+ ).resolves.toBeDefined();
+ });
+
+ it("refuses to remove the paper while the PoA is still named", async () => {
+ const { service } = makeService({
+ attributes: { ...POA },
+ files: [paper()],
+ });
+
+ await expect(
+ service.removePoaDelegationLetter("user-1", "file-1"),
+ ).rejects.toBeInstanceOf(BadRequestException);
+ });
+
+ it("allows removing the paper once the PoA has been cleared", async () => {
+ const { service } = makeService({ attributes: {}, files: [paper()] });
+
+ await expect(
+ service.removePoaDelegationLetter("user-1", "file-1"),
+ ).resolves.toBeDefined();
+ });
+
+ it("judges the removal against a staged clear, not the live row", async () => {
+ // An Active company's edits are staged for review rather than written, so
+ // the live attributes still carry the PoA the customer just cleared.
+ const { service } = makeService({
+ status: CompanyStatus.Active,
+ attributes: { ...POA },
+ pendingSnapshot: { poaName: "", poaEmail: "", poaPhone: "" },
+ files: [paper()],
+ });
+
+ await expect(
+ service.removePoaDelegationLetter("user-1", "file-1"),
+ ).resolves.toBeDefined();
+ });
+
+ it("refuses the forwarder role to a company with no PoA", async () => {
+ const { service } = makeService({ attributes: { ...VERIFIED_IDENTITIES } });
+
+ await expect(
+ service.createCompanyProfileForUser(
+ "user-1",
+ ProfileType.freightForwarder,
+ ),
+ ).rejects.toBeInstanceOf(BadRequestException);
+ });
+
+ it("grants the forwarder role once PoA details and paper are both in place", async () => {
+ const { service } = makeService({
+ attributes: { ...POA, ...VERIFIED_IDENTITIES },
+ files: [paper()],
+ });
+
+ await expect(
+ service.createCompanyProfileForUser(
+ "user-1",
+ ProfileType.freightForwarder,
+ ),
+ ).resolves.toBeDefined();
+ });
+});
diff --git a/apps/edr-freight-api/src/modules/companies/companies.repository.ts b/apps/edr-freight-api/src/modules/companies/companies.repository.ts
index db8db0d2e..092ebc2c4 100644
--- a/apps/edr-freight-api/src/modules/companies/companies.repository.ts
+++ b/apps/edr-freight-api/src/modules/companies/companies.repository.ts
@@ -75,8 +75,14 @@ export class CompaniesRepository extends BaseRepository {
.getMany();
}
- async existsByTin(tin: string): Promise {
- const count = await this.repository.count({ where: { tin } as any });
+ async existsByTin(tin: string, excludeCompanyId?: string): Promise {
+ const qb = this.repository
+ .createQueryBuilder('company')
+ .where('company.tin = :tin', { tin });
+ if (excludeCompanyId) {
+ qb.andWhere('company.id != :excludeCompanyId', { excludeCompanyId });
+ }
+ const count = await qb.getCount();
return count > 0;
}
diff --git a/apps/edr-freight-api/src/modules/companies/companies.role-deselect.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.role-deselect.spec.ts
new file mode 100644
index 000000000..f22123f61
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/companies/companies.role-deselect.spec.ts
@@ -0,0 +1,113 @@
+import { CompaniesService } from "./companies.service";
+import { CompanyType } from "./entities/company.entity";
+import { ProfileStatus, ProfileType } from "./entities/company-profile.entity";
+
+/**
+ * EDRFREIGHT-416: onboarding asked for a deselected role's documents.
+ *
+ * Re-running role selection used to only ADD operational profiles, so a role
+ * the user unticked on the way back left its company_profile row behind — and
+ * every role-driven requirement (business license, forwarder PoA) is derived
+ * from those rows. startOnboarding now reconciles both directions.
+ */
+
+interface ExistingProfile {
+ id: string;
+ type: ProfileType;
+ status: ProfileStatus;
+}
+
+function makeService(existing: ExistingProfile[]) {
+ const companyProfilesRepo = {
+ findByCompanyId: jest.fn(async () => existing),
+ create: jest.fn(async (row: Record) => ({
+ id: "new",
+ ...row,
+ })),
+ softDelete: jest.fn(async () => undefined),
+ };
+ const companiesRepo = { update: jest.fn(async () => null) };
+ const profilesRepo = {
+ findByUserId: jest.fn(async () => ({
+ id: "external-1",
+ companyId: "company-1",
+ company: { id: "company-1" },
+ })),
+ };
+
+ const service = new CompaniesService(
+ companiesRepo as never,
+ companyProfilesRepo as never,
+ {} as never,
+ profilesRepo as never,
+ {} as never,
+ {} as never,
+ {} as never,
+ {} as never,
+ {} as never,
+ {} as never,
+ {} as never,
+ );
+
+ jest
+ .spyOn(service, "getCompanyInfoByUserId")
+ .mockImplementation(
+ async () =>
+ ({ profile: { id: "external-1" }, company: { id: "company-1" } }) as never,
+ );
+
+ return { service, companyProfilesRepo };
+}
+
+const identity = { userId: "user-1", firstName: "Abebe", lastName: "K" };
+
+const start = (service: CompaniesService, roles: ProfileType[]) =>
+ service.startOnboarding(identity as never, CompanyType.Customer, roles);
+
+describe("re-running role selection reconciles the operational profiles", () => {
+ it("drops the profile for a role the user deselected", async () => {
+ const { service, companyProfilesRepo } = makeService([
+ { id: "p-imp", type: ProfileType.importer, status: ProfileStatus.Pending },
+ {
+ id: "p-ff",
+ type: ProfileType.freightForwarder,
+ status: ProfileStatus.Pending,
+ },
+ ]);
+
+ await start(service, [ProfileType.importer]);
+
+ expect(companyProfilesRepo.softDelete).toHaveBeenCalledWith("p-ff");
+ expect(companyProfilesRepo.softDelete).toHaveBeenCalledTimes(1);
+ expect(companyProfilesRepo.create).not.toHaveBeenCalled();
+ });
+
+ it("keeps an already-approved profile even when it is unticked", async () => {
+ const { service, companyProfilesRepo } = makeService([
+ { id: "p-imp", type: ProfileType.importer, status: ProfileStatus.Pending },
+ {
+ id: "p-exp",
+ type: ProfileType.exporter,
+ status: ProfileStatus.Active,
+ },
+ ]);
+
+ await start(service, [ProfileType.importer]);
+
+ expect(companyProfilesRepo.softDelete).not.toHaveBeenCalled();
+ });
+
+ it("still adds a newly-picked role", async () => {
+ const { service, companyProfilesRepo } = makeService([
+ { id: "p-imp", type: ProfileType.importer, status: ProfileStatus.Pending },
+ ]);
+
+ await start(service, [ProfileType.importer, ProfileType.exporter]);
+
+ expect(companyProfilesRepo.softDelete).not.toHaveBeenCalled();
+ expect(companyProfilesRepo.create).toHaveBeenCalledTimes(1);
+ expect(companyProfilesRepo.create).toHaveBeenCalledWith(
+ expect.objectContaining({ type: ProfileType.exporter }),
+ );
+ });
+});
diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts
index 6650dfca5..c198d149f 100644
--- a/apps/edr-freight-api/src/modules/companies/companies.service.ts
+++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts
@@ -17,10 +17,23 @@ import {
import { FilesService } from "../files/files.service";
import { FileRecord } from "../files/entities/file.entity";
import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service";
+import {
+ POA_DELEGATION_FILE_KEY,
+ POA_DELEGATION_LABEL,
+ POA_DELEGATION_PENDING_CODE,
+} from "../file-upload-settings/poa-delegation.constants";
+import { VerifaydaService } from "../verifayda/verifayda.service";
+import {
+ buildCompanyIdentityState,
+ CompanyIdentityStateDto,
+ CompleteIdentityVerificationDto,
+ IdentitySubject,
+} from "./dto/complete-identity-verification.dto";
import { ETradeService } from "./services/etrade.service";
import { CompanyNotifierService } from "./company-notifier.service";
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
import { normalizeE164 } from "../../common/validators/is-phone-number.validator";
+import type { CompanyRegistrationData } from "@edr/types";
import { CreateCompanyDto } from "./dto/create-company.dto";
import { UpdateCompanyDto } from "./dto/update-company.dto";
import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
@@ -58,10 +71,6 @@ const LICENSE_CODE = "business_license";
/** Code for a license file staged in an open change request (not yet live). */
const LICENSE_PENDING_CODE = "business_license_pending";
-/** Mirrors the field seeded in seed/file-upload-settings.seeder.ts. */
-const POA_DELEGATION_FILE_KEY = "poa_delegation_letter";
-/** Code for a PoA letter staged in an open change request (not yet live). */
-const POA_DELEGATION_PENDING_CODE = "poa_delegation_letter_pending";
/** FileRecord resource that company-level documents are stored under. */
const COMPANY_RESOURCE = "companies";
/** company.attributes keys that together mean "a PoA was entered". */
@@ -72,6 +81,28 @@ const POA_ATTRIBUTES = [
"poaLocation",
"poaAddress",
] as const;
+/**
+ * Personnel an approved company maintains itself: its contact person, its
+ * general manager and its Power of Attorney. These name who to talk to, not
+ * what the company is allowed to do, so freezing the settings page until a
+ * reviewer gets to a new phone number costs more than it protects. They write
+ * straight to the live row even for an active company.
+ *
+ * The PoA's *delegation letter* is deliberately not here — the paper is the
+ * thing that actually evidences the delegation, so it still goes through
+ * review (see `uploadPoaDelegationLetter`), as does the owner's own identity.
+ */
+const SELF_SERVICE_ATTRIBUTES: readonly string[] = [
+ "contactPersonName",
+ "contactPersonPosition",
+ "contactPersonEmail",
+ "contactPersonPhone",
+ "contactVerifiedPhone",
+ "generalManagerName",
+ "generalManagerEmail",
+ "generalManagerPhone",
+ ...POA_ATTRIBUTES,
+];
/** Mandatory once the company operates as a freight forwarder. */
const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [
{ key: "poaName", label: "PoA name" },
@@ -79,6 +110,62 @@ const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [
{ key: "poaPhone", label: "PoA phone" },
];
+/**
+ * `attributes` key prefix per verifiable person. The owner is NOT the general
+ * manager — GM is a plain typed role (the portal offers a "same as owner" copy
+ * once the owner is verified), while the owner is who this verification
+ * actually proves. They're very often the same human; that's what the copy is
+ * for.
+ */
+const IDENTITY_PREFIX: Record = {
+ owner: "owner",
+ poa: "poa",
+};
+
+const IDENTITY_LABEL: Record = {
+ owner: "owner",
+ poa: "Power of Attorney",
+};
+
+/**
+ * Identity fields a Fayda verification owns outright, per person. Once verified
+ * these can no longer be typed — the government IdP is the source, so an edit
+ * that disagrees with it is either a mistake or an attempt to launder the
+ * guarantee away. The GM fields are deliberately absent: GM is never itself
+ * Fayda-verified, so it stays freely editable regardless of the owner's state.
+ */
+const IDENTITY_OWNED_FIELDS: Record = {
+ owner: ["ownerName", "ownerEmail", "ownerPhone", "ownerAddress"],
+ poa: ["poaName", "poaEmail", "poaPhone", "poaAddress"],
+};
+
+/**
+ * `UpdateProfileDto` fields eTrade is the sole source of truth for. A request
+ * touching any of these must be re-checked against a fresh eTrade lookup —
+ * see `assertEtradeFieldsAuthentic`.
+ */
+const ETRADE_SOURCED_FIELDS = [
+ "companyName",
+ "tin",
+ "licenceNumber",
+ "statusDescription",
+ "dateRegistered",
+ "renewedFrom",
+ "renewalDate",
+ "renewedTo",
+ "region",
+ "zone",
+ "woreda",
+ "kebele",
+ "houseNo",
+ "etradePhone",
+] as const satisfies readonly (keyof UpdateProfileDto)[];
+
+/** The attributes a verification writes, for one person. */
+interface VerifiedIdentityAttributes {
+ [key: string]: unknown;
+}
+
export interface UserIdentity {
userId: string;
firstName: string;
@@ -100,6 +187,7 @@ export class CompaniesService {
private readonly etradeService: ETradeService,
private readonly companyNotifier: CompanyNotifierService,
private readonly dataSource: DataSource,
+ private readonly verifaydaService: VerifaydaService,
) { }
/**
@@ -255,8 +343,9 @@ export class CompaniesService {
* chosen operational role(s) up front, so every subsequent wizard step can
* save incrementally (PATCH /profile, /onboarding-step) against existing rows.
*
- * Idempotent: if the user already has a profile, returns it unchanged (only
- * adding any newly-chosen roles). The draft company carries a placeholder TIN
+ * Idempotent: if the user already has a profile, returns it unchanged, with
+ * the operational profiles reconciled against the roles just chosen (added
+ * and — for still-pending ones — removed). The draft company carries a placeholder TIN
* (the real one is filled on the Company Information step) and stays
* status=pending / onboardingCompleted=false until the wizard finishes.
*/
@@ -271,7 +360,7 @@ export class CompaniesService {
const existing = await this.profilesRepo.findByUserId(identity.userId);
if (existing) {
const companyId = existing.company?.id ?? existing.companyId;
- await this.ensureCompanyProfiles(companyId, companyType, roles);
+ await this.syncCompanyProfiles(companyId, companyType, roles);
if (nationality) {
await this.companiesRepo.update(companyId, { nationality });
}
@@ -302,25 +391,44 @@ export class CompaniesService {
onboardingCompleted: false,
});
- await this.ensureCompanyProfiles(company.id, companyType, chosenTypes);
+ await this.syncCompanyProfiles(company.id, companyType, chosenTypes);
return this.getCompanyInfoByUserId(identity.userId);
}
- /** Create any of the requested operational profiles that don't exist yet. */
- private async ensureCompanyProfiles(
+ /**
+ * Reconcile the company's operational profiles with the roles the user has
+ * selected: create the missing ones, drop the ones they deselected.
+ *
+ * Dropping matters because every role-driven onboarding requirement — the
+ * per-profile business license, the freight-forwarder PoA rule, the license
+ * cards in the wizard — is derived from these rows. A row left behind after
+ * the user went back and unticked a role keeps asking for that role's
+ * documents (EDRFREIGHT-416). Only still-pending profiles are removed: an
+ * approved one is live (it can carry bookings and contracts) and re-running
+ * role selection must never delete it.
+ */
+ private async syncCompanyProfiles(
companyId: string,
companyType: CompanyType,
roles: ProfileType[],
): Promise {
const allowedTypes = this.getProfileTypeForCompanyType(companyType);
- for (const type of roles) {
- if (!allowedTypes.includes(type)) continue;
- const existing = await this.companyProfilesRepo.findByType(
- companyId,
- type,
- );
- if (existing) continue;
+ const chosen = roles.filter((t) => allowedTypes.includes(t));
+ const existing = await this.companyProfilesRepo.findByCompanyId(companyId);
+
+ for (const profile of existing) {
+ if (chosen.includes(profile.type)) continue;
+ if (profile.status !== ProfileStatus.Pending) continue;
+ // The license files uploaded against this profile go with it: they are
+ // only ever read per company_profile id, so a soft-deleted profile
+ // leaves nothing behind to prompt for. Re-picking the role creates a
+ // fresh profile the user uploads against again.
+ await this.companyProfilesRepo.softDelete(profile.id);
+ }
+
+ for (const type of chosen) {
+ if (existing.some((p) => p.type === type)) continue;
// No reference yet — minted on backoffice approval (setCompanyProfileStatus).
await this.companyProfilesRepo.create({
companyId,
@@ -599,7 +707,9 @@ export class CompaniesService {
*/
private mapProfileDtoToCompanyUpdates(
company: Company,
- dto: Partial,
+ dto: Partial & {
+ faydaIdentity?: VerifiedIdentityAttributes;
+ },
): Record {
const companyUpdates: Record = {};
const attrUpdates: Record = { ...(company.attributes ?? {}) };
@@ -617,7 +727,6 @@ export class CompaniesService {
if (dto.tin !== undefined && dto.tin !== company.tin)
companyUpdates.tin = dto.tin;
if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber;
- if (dto.fanNumber !== undefined) companyUpdates.fanNumber = dto.fanNumber;
if (dto.contactPersonName !== undefined)
attrUpdates.contactPersonName = dto.contactPersonName;
@@ -661,6 +770,72 @@ export class CompaniesService {
if (dto.etradePhone !== undefined)
companyUpdates.etradePhone = normalizeE164(dto.etradePhone);
+ // A plain typed field — never Fayda-verified, so no lock ever applies to
+ // it. Independent of the owner's verification: still required for a
+ // foreign company even if the owner also verifies with Fayda.
+ if (dto.ownerPassportNumber !== undefined)
+ attrUpdates.ownerPassportNumber = dto.ownerPassportNumber;
+
+ // A verified identity overwrites the person's details. `faydaIdentity`
+ // never comes off the wire — the global validation pipe runs with
+ // forbidNonWhitelisted, so a client that sends it is rejected outright; it
+ // only reaches here from completeIdentityVerification, directly or through
+ // a staged snapshot.
+ if (dto.faydaIdentity) {
+ Object.assign(attrUpdates, dto.faydaIdentity);
+ }
+
+ // companyEmail/companyPhone are the Company-column mirrors of the owner's
+ // verified contact details (the portal derives and submits them, it never
+ // lets the customer type them once verified) — lock them the same way
+ // ownerEmail/ownerPhone themselves are locked below, once there is a
+ // verified owner to lock them to.
+ if (attrUpdates.ownerFaydaSub) {
+ if (
+ dto.companyEmail !== undefined &&
+ dto.companyEmail !== attrUpdates.ownerEmail
+ ) {
+ throw new BadRequestException(
+ "companyEmail is set by the owner's Fayda verification and cannot be edited. Re-verify to change it.",
+ );
+ }
+ if (
+ dto.companyPhone !== undefined &&
+ normalizeE164(dto.companyPhone) !==
+ normalizeE164(String(attrUpdates.ownerPhone ?? ""))
+ ) {
+ throw new BadRequestException(
+ "companyPhone is set by the owner's Fayda verification and cannot be edited. Re-verify to change it.",
+ );
+ }
+ }
+
+ // Renaming a Fayda-verified person by hand would launder the guarantee
+ // away, so the fields the verification owns are refused once it exists.
+ for (const subject of ["owner", "poa"] as IdentitySubject[]) {
+ if (!attrUpdates[`${IDENTITY_PREFIX[subject]}FaydaSub`]) continue;
+ for (const field of IDENTITY_OWNED_FIELDS[subject]) {
+ const incoming = (dto as Record)[field];
+ if (incoming === undefined) continue;
+ // The verification itself is allowed to write them; anything else is
+ // compared against what is already stored, not against the value this
+ // same call just copied into the patch. Phones are compared normalized:
+ // a form that re-renders +251911000000 as 0911000000 is echoing the
+ // stored value back, not trying to change it.
+ if (dto.faydaIdentity && field in dto.faydaIdentity) continue;
+ const stored = company.attributes?.[field];
+ const same = field.endsWith("Phone")
+ ? normalizeE164(String(incoming)) ===
+ normalizeE164(String(stored ?? ""))
+ : incoming === stored;
+ if (!same) {
+ throw new BadRequestException(
+ `${field} is set by the Fayda verification of this company's ${IDENTITY_LABEL[subject]} and cannot be edited. Re-verify to change it.`,
+ );
+ }
+ }
+ }
+
companyUpdates.attributes = attrUpdates;
return companyUpdates;
}
@@ -691,10 +866,11 @@ export class CompaniesService {
*
* - Company not yet approved (onboarding) → write straight to the Company row,
* as before. The company/role pending→approve gate already covers first-run.
- * - Company already `active` → do NOT touch the live Company. Stage the edit in
- * a pending change request (merging into any open one) so a backoffice
- * reviewer can approve (apply) or reject (with a note). This locks the
- * customer until the review resolves.
+ * - Company already `active` → personnel details (`SELF_SERVICE_ATTRIBUTES`)
+ * still write straight through; everything else does NOT touch the live
+ * Company but is staged in a pending change request (merging into any open
+ * one) so a backoffice reviewer can approve (apply) or reject (with a
+ * note). Only the staged half locks the customer until the review resolves.
*/
async updateProfile(
userId: string,
@@ -702,6 +878,21 @@ export class CompaniesService {
): Promise {
const { profile, company } = await this.getCompanyInfoByUserId(userId);
+ await this.assertEtradeFieldsAuthentic(company, dto);
+
+ // Naming (or renaming) a Power of Attorney is one of the writes that can
+ // leave the company with a representative and nothing evidencing them, so
+ // it is gated here. Edits that don't touch the PoA are left alone — a
+ // company carrying legacy details must not be locked out of every other
+ // field until it produces a paper.
+ if (POA_ATTRIBUTES.some((k) => dto[k] !== undefined)) {
+ const attributes = this.mapProfileDtoToCompanyUpdates(company, dto)
+ .attributes as Record;
+ await this.assertPoaDelegationSatisfied(company.id, attributes, {
+ requirePoa: await this.isFreightForwarder(company.id),
+ });
+ }
+
if (company.status !== CompanyStatus.Active) {
await this.assertTinAvailable(company, dto.tin);
const companyUpdates = this.mapProfileDtoToCompanyUpdates(company, dto);
@@ -714,9 +905,37 @@ export class CompaniesService {
return new ProfileResponseDto(profile, updated);
}
- // Approved company: stage the change for review, leaving the live row intact.
+ // Approved company: personnel details apply immediately, the rest is staged
+ // for review with the live row left intact.
await this.assertTinAvailable(company, dto.tin);
const fields = this.pickDefined(dto);
+ const selfService: Record = {};
+ const staged: Record = {};
+ for (const [key, value] of Object.entries(fields)) {
+ if (SELF_SERVICE_ATTRIBUTES.includes(key)) selfService[key] = value;
+ else staged[key] = value;
+ }
+
+ let live = company;
+ if (Object.keys(selfService).length > 0) {
+ live =
+ (await this.companiesRepo.update(
+ company.id,
+ this.mapProfileDtoToCompanyUpdates(company, selfService),
+ )) ?? company;
+ live.companyProfiles = company.companyProfiles;
+ }
+
+ if (Object.keys(staged).length === 0) {
+ // Nothing a reviewer needs to see. Any request already open (a document
+ // upload, an owner verification) still surfaces so its banner survives —
+ // it just no longer gains fields it was never asked to review.
+ return new ProfileResponseDto(
+ profile,
+ live,
+ await this.changeRequestRepo.findLatestOpenByCompanyId(company.id),
+ );
+ }
const existing = await this.changeRequestRepo.findPendingByCompanyId(
company.id,
@@ -726,7 +945,7 @@ export class CompaniesService {
if (existing) {
request =
(await this.changeRequestRepo.update(existing.id, {
- snapshot: { ...(existing.snapshot ?? {}), ...fields },
+ snapshot: { ...(existing.snapshot ?? {}), ...staged },
submittedBy: userId,
submittedAt: now,
note: null,
@@ -742,7 +961,7 @@ export class CompaniesService {
);
request = await this.changeRequestRepo.create({
companyId: company.id,
- snapshot: fields,
+ snapshot: staged,
status: ChangeRequestStatus.Pending,
submittedBy: userId,
submittedAt: now,
@@ -754,8 +973,9 @@ export class CompaniesService {
);
}
- // Live company is unchanged; surface the pending state for the settings page.
- return new ProfileResponseDto(profile, company, request);
+ // Only the personnel half (if any) landed; surface the pending state for
+ // the settings page.
+ return new ProfileResponseDto(profile, live, request);
}
/** List a company's change requests, newest first (backoffice review). */
@@ -1127,11 +1347,24 @@ export class CompaniesService {
// blacklist skip all this — staff must always be able to act against a bad
// account.
return this.dataSource.transaction(async (manager) => {
- await manager.findOne(Company, {
+ const company = await manager.findOne(Company, {
where: { id: existing.companyId },
lock: { mode: "pessimistic_write" },
});
+ // Putting a forwarder into service without a Power of Attorney backed by
+ // a DARS paper is the thing EDRFREIGHT-358 forbids, so the approval is
+ // the last place it has to be checked — the role may have been applied
+ // for before the paper was withdrawn.
+ if (company && existing.type === ProfileType.freightForwarder) {
+ this.assertIdentityVerified(company, { requirePoa: true });
+ await this.assertPoaDelegationSatisfied(
+ company.id,
+ company.attributes,
+ { requirePoa: true },
+ );
+ }
+
const [companyDocs, profileDocs] = await Promise.all([
this.filesService.findWithOpenChangeRequest(
[existing.companyId],
@@ -1382,6 +1615,18 @@ export class CompaniesService {
);
if (existing) continue;
+ // A forwarder signs on other companies' behalf, so it cannot be taken on
+ // without a Power of Attorney and its DARS paper — checked here so the
+ // customer is told at the point of asking, not at review.
+ if (type === ProfileType.freightForwarder) {
+ this.assertIdentityVerified(company, { requirePoa: true });
+ await this.assertPoaDelegationSatisfied(
+ companyId,
+ await this.effectivePoaAttributes(company),
+ { requirePoa: true },
+ );
+ }
+
// Self-service role adds start Pending and carry no reference — a reference
// is minted only when a backoffice reviewer approves the role.
await this.companyProfilesRepo.create({
@@ -1419,6 +1664,14 @@ export class CompaniesService {
}
let created = await this.companyProfilesRepo.findByType(companyId, type);
+ if (!created && type === ProfileType.freightForwarder) {
+ this.assertIdentityVerified(company, { requirePoa: true });
+ await this.assertPoaDelegationSatisfied(
+ companyId,
+ await this.effectivePoaAttributes(company),
+ { requirePoa: true },
+ );
+ }
if (!created) {
// New self-service roles start Pending (awaiting backoffice approval) and
// carry no reference until approved.
@@ -1453,11 +1706,17 @@ export class CompaniesService {
userId: string,
): Promise {
const { profile, company } = await this.getCompanyInfoByUserId(userId);
+ const identity = this.getCompanyIdentityState(company);
- // 1. Required company-information fields.
- const missingInfo = this.REQUIRED_COMPANY_INFO.filter(
- (f) => !f.get(company),
- ).map((f) => ({ key: f.key, label: f.label }));
+ // 1. Required company-information fields. The FAN is never one of them —
+ // Fayda verification doesn't produce a FAN, so it's never collected as
+ // part of onboarding at all (see the identity block below).
+ const requiredInfo = this.REQUIRED_COMPANY_INFO.filter(
+ (f) => f.key !== "fanNumber",
+ );
+ const missingInfo = requiredInfo
+ .filter((f) => !f.get(company))
+ .map((f) => ({ key: f.key, label: f.label }));
// 2. Nationality-based company documents + which are already uploaded.
const documentSettingCode = this.documentSettingCodeFor(company.nationality);
@@ -1504,26 +1763,26 @@ export class CompaniesService {
// 4. Power of Attorney. Optional in general, but a freight forwarder acts on
// other companies' behalf so its PoA is mandatory. Either way, a PoA that
- // has been entered must be evidenced by the delegation letter.
+ // has been entered must be evidenced by the DARS delegation paper — a legal
+ // requirement, so unlike the documents above it does not depend on the
+ // upload set carrying a field for it (see poa-delegation.constants.ts).
const poaRequired = (company.companyProfiles ?? []).some(
(p) => p.type === ProfileType.freightForwarder,
);
const poaProvided = POA_ATTRIBUTES.some((k) =>
(company.attributes?.[k] as string | undefined)?.trim(),
);
- const missingPoaFields = poaRequired
- ? REQUIRED_POA_FIELDS.filter(
- (f) => !(company.attributes?.[f.key] as string | undefined)?.trim(),
- )
- : [];
- // Only gate on the letter once the document set actually carries the field.
- const delegationField = (setting?.fields ?? []).find(
- (f) => f.fileKey === POA_DELEGATION_FILE_KEY,
- );
- const missingDelegation =
- Boolean(delegationField) &&
- (poaRequired || poaProvided) &&
- !uploadedCodes.has(POA_DELEGATION_FILE_KEY);
+ // No company types its PoA details — they arrive from the Fayda
+ // verification whatever the nationality — so reporting them as missing
+ // fields would ask for something no form offers. The identity block below
+ // reports "verify your PoA" instead.
+ const missingPoaFields: typeof REQUIRED_POA_FIELDS = [];
+ const delegation = await this.getPoaDelegationState(company.id);
+ const delegationDue = poaRequired || poaProvided;
+ const missingDelegation = delegationDue && !delegation.onFile;
+ // A paper the reviewer sent back is not evidence — the customer has to
+ // replace it before the application counts as complete.
+ const flaggedDelegation = delegationDue && delegation.flagged;
const outstanding = [
...missingInfo.map((f) => `Add your ${f.label.toLowerCase()}`),
@@ -1534,29 +1793,54 @@ export class CompaniesService {
),
...missingPoaFields.map((f) => `Add your ${f.label.toLowerCase()}`),
...(missingDelegation
- ? ["Upload the delegation letter for your Power of Attorney"]
+ ? [`Upload the ${POA_DELEGATION_LABEL} for your Power of Attorney`]
+ : []),
+ ...(flaggedDelegation
+ ? [`Re-upload your ${POA_DELEGATION_LABEL} — EDR asked for a correction`]
+ : []),
+ ...(identity.faydaRequired && !identity.owner.verified
+ ? ["Verify the company owner's identity with Fayda"]
+ : []),
+ ...((poaRequired || poaProvided) && !identity.poa.verified
+ ? ["Verify your Power of Attorney's identity with Fayda"]
+ : []),
+ ...(identity.passportRequired && !identity.owner.passportNumber
+ ? ["Add the company owner's passport number"]
: []),
];
// Progress spans every required item the user has to satisfy: company-info
// fields, required documents, one license per operational profile, and the
- // PoA details/letter whenever those are mandatory.
+ // PoA details/paper whenever those are mandatory.
const requiredDocCount = documents.filter((d) => d.isRequired).length;
- const poaItemCount =
- (poaRequired ? REQUIRED_POA_FIELDS.length : 0) +
- (delegationField && (poaRequired || poaProvided) ? 1 : 0);
+ const poaItemCount = delegationDue ? 1 : 0;
+ // One item per identity credential the company has to prove: the owner
+ // always (Fayda for Ethiopian, passport for foreign), plus the PoA once
+ // there is one — that one is Fayda whatever the nationality.
+ const ownerCredentialDue =
+ identity.faydaRequired || identity.passportRequired;
+ const ownerCredentialProven = identity.faydaRequired
+ ? identity.owner.verified
+ : Boolean(identity.owner.passportNumber);
+ const identityItemCount =
+ (ownerCredentialDue ? 1 : 0) + (delegationDue ? 1 : 0);
+ const missingIdentityCount =
+ (ownerCredentialDue && !ownerCredentialProven ? 1 : 0) +
+ (delegationDue && !identity.poa.verified ? 1 : 0);
const total =
- this.REQUIRED_COMPANY_INFO.length +
+ requiredInfo.length +
requiredDocCount +
licenseProfiles.length +
- poaItemCount;
+ poaItemCount +
+ identityItemCount;
const completed =
total -
(missingInfo.length +
missingDocs.length +
missingLicenses.length +
missingPoaFields.length +
- (missingDelegation ? 1 : 0));
+ (missingDelegation || flaggedDelegation ? 1 : 0) +
+ missingIdentityCount);
return new OnboardingRequirementsResponseDto({
documentSettingCode,
@@ -1567,10 +1851,15 @@ export class CompaniesService {
poa: {
required: poaRequired,
provided: poaProvided,
- delegationLetterUploaded: uploadedCodes.has(POA_DELEGATION_FILE_KEY),
+ delegationLetterUploaded: delegation.onFile,
+ delegationLetterFlagged: delegation.flagged,
missingFields: missingPoaFields,
- complete: missingPoaFields.length === 0 && !missingDelegation,
+ complete:
+ missingPoaFields.length === 0 &&
+ !missingDelegation &&
+ !flaggedDelegation,
},
+ identity,
progress: { completed, total },
isComplete: outstanding.length === 0,
onboardingCompleted: profile.onboardingCompleted,
@@ -2044,15 +2333,352 @@ export class CompaniesService {
}
// ---------------------------------------------------------------------------
- // Power of Attorney delegation letter
+ // Power of Attorney delegation paper (DARS)
//
// A company-level document that follows the same staged-review model as the
// business license: on an approved (Active) company an upload lands under the
- // pending code and the live letter is flagged for removal, so the reviewer
+ // pending code and the live paper is flagged for removal, so the reviewer
// sees both and approval swaps them atomically. During onboarding it goes live.
// ---------------------------------------------------------------------------
- /** The company's PoA letter(s), with each file's review status resolved. */
+ /**
+ * What the company has on file towards its DARS delegation paper. A paper
+ * staged for review counts as "on file" — it is the customer's whole
+ * obligation discharged; whether it is good enough is the reviewer's call,
+ * recorded as `flagged`.
+ */
+ private async getPoaDelegationState(
+ companyId: string,
+ ignoreFileIds: string[] = [],
+ ): Promise<{ onFile: boolean; flagged: boolean }> {
+ const records = (
+ await this.filesService.findByResource(companyId, COMPANY_RESOURCE)
+ ).filter(
+ (r) =>
+ (r.code === POA_DELEGATION_FILE_KEY ||
+ r.code === POA_DELEGATION_PENDING_CODE) &&
+ !ignoreFileIds.includes(r.id),
+ );
+ return {
+ onFile: records.length > 0,
+ flagged: records.some((r) => r.reviewStatus === "change_requested"),
+ };
+ }
+
+ /**
+ * The rule behind EDRFREIGHT-358: a company that names a Power of Attorney
+ * must evidence it with a DARS delegation paper, and a freight forwarder —
+ * which signs on other companies' behalf — must have both, verified.
+ *
+ * This is enforced at every write that can break the pairing (PoA details
+ * saved, paper removed, forwarder role applied for or approved) rather than
+ * only at onboarding submission, which is what let a company that finished
+ * onboarding as an importer pick up the forwarder role with neither.
+ *
+ * `attributes` is the state being written, which is not always the state on
+ * the row yet — a staged change request carries it, and a removal has to be
+ * judged against the files that would survive it (`ignoreFileIds`).
+ */
+ private async assertPoaDelegationSatisfied(
+ companyId: string,
+ attributes: Record | null | undefined,
+ opts: { requirePoa: boolean; ignoreFileIds?: string[] },
+ ): Promise {
+ const read = (key: string) =>
+ (attributes?.[key] as string | undefined)?.trim();
+ const poaProvided = POA_ATTRIBUTES.some((k) => read(k));
+ if (!opts.requirePoa && !poaProvided) return;
+
+ if (opts.requirePoa) {
+ const missing = REQUIRED_POA_FIELDS.filter((f) => !read(f.key));
+ if (missing.length > 0) {
+ throw new BadRequestException(
+ `A freight forwarder acts on other companies' behalf, so a Power of Attorney is required. ` +
+ `Add the ${missing.map((f) => f.label.toLowerCase()).join(", ")} first.`,
+ );
+ }
+ }
+
+ const { onFile, flagged } = await this.getPoaDelegationState(
+ companyId,
+ opts.ignoreFileIds,
+ );
+ if (!onFile) {
+ throw new BadRequestException(
+ `Upload the ${POA_DELEGATION_LABEL} for the Power of Attorney` +
+ (opts.requirePoa ? " — it is required for freight forwarders." : "."),
+ );
+ }
+ if (flagged) {
+ throw new BadRequestException(
+ `The ${POA_DELEGATION_LABEL} on file needs to be corrected. ` +
+ `Re-upload it before continuing.`,
+ );
+ }
+ }
+
+ /** Does this company operate as a freight forwarder? */
+ private async isFreightForwarder(companyId: string): Promise {
+ const profiles = await this.companyProfilesRepo.findByCompanyId(companyId);
+ return profiles.some((p) => p.type === ProfileType.freightForwarder);
+ }
+
+ // ---------------------------------------------------------------------------
+ // Fayda identity verification (owner / PoA)
+ //
+ // A completed VeriFayda verification proves a person's name, phone, email
+ // and address — Fayda's userinfo carries no national ID number, so none of
+ // that is collected here. For an Ethiopian company both the owner and its
+ // PoA (once named) must be verified before the company can trade. Fayda is
+ // an Ethiopian national ID system, so a foreign company's owner proves
+ // identity with a typed passport number instead — required on its own
+ // terms, not waived by an owner who happens to verify with Fayda too.
+ // ---------------------------------------------------------------------------
+
+ /**
+ * Verification state for both people, plus whether it is mandatory here.
+ * `complete` answers the gate question directly so the portal, the onboarding
+ * requirements and the assertions below all read the same verdict — the
+ * derivation itself is shared with ProfileResponseDto.
+ */
+ getCompanyIdentityState(company: Company): CompanyIdentityStateDto {
+ return buildCompanyIdentityState(company);
+ }
+
+ /**
+ * Complete a Fayda verification and bind the identity to one of the company's
+ * people. The portal starts the flow through the shared
+ * `POST /fayda/verification/start` and only tells us which person it was for
+ * here, at completion — so the verifayda module stays generic and its session
+ * table needs no company-specific column.
+ */
+ async completeIdentityVerification(
+ userId: string,
+ dto: CompleteIdentityVerificationDto,
+ ): Promise {
+ const { company } = await this.getCompanyInfoByUserId(userId);
+ const prefix = IDENTITY_PREFIX[dto.subject];
+
+ const result = await this.verifaydaService.completeVerification({
+ code: dto.code,
+ state: dto.state,
+ });
+ if (!result.verified || !result.sub) {
+ throw new BadRequestException(
+ "Fayda could not verify this identity. Start the verification again.",
+ );
+ }
+
+ // The owner delegating power of attorney to themselves is not a
+ // delegation — it would let one identity satisfy both halves of the check.
+ const other: IdentitySubject = dto.subject === "poa" ? "owner" : "poa";
+ const otherSub = company.attributes?.[`${IDENTITY_PREFIX[other]}FaydaSub`];
+ if (otherSub && otherSub === result.sub) {
+ throw new BadRequestException(
+ `This identity is already registered as the company's ${IDENTITY_LABEL[other]}. The Power of Attorney must be a different person from the owner.`,
+ );
+ }
+
+ const now = new Date().toISOString();
+ const identity: VerifiedIdentityAttributes = {
+ [`${prefix}FaydaSub`]: result.sub,
+ [`${prefix}FaydaVerifiedAt`]: now,
+ [`${prefix}Birthdate`]: result.birthdate ?? null,
+ [`${prefix}Gender`]: result.gender ?? null,
+ // The verified payload owns the person's details from here on.
+ ...(result.fullName ? { [`${prefix}Name`]: result.fullName } : {}),
+ ...(result.email ? { [`${prefix}Email`]: result.email } : {}),
+ ...(result.phoneNumber ? { [`${prefix}Phone`]: result.phoneNumber } : {}),
+ ...(result.address ? { [`${prefix}Address`]: result.address } : {}),
+ };
+
+ // An approved company's *owner* is its identity proof, so re-verifying one
+ // is staged for backoffice review rather than quietly rewriting a live
+ // record. The PoA is personnel — the company names its own representative,
+ // and the delegation letter backing them is what the reviewer sees — so a
+ // PoA verification lands live, matching the typed PoA fields in
+ // `SELF_SERVICE_ATTRIBUTES`.
+ if (company.status === CompanyStatus.Active && dto.subject !== "poa") {
+ await this.stageIdentityChange(company, userId, identity);
+ return this.getCompanyIdentityState(company);
+ }
+
+ const updated = await this.companiesRepo.update(company.id, {
+ attributes: { ...(company.attributes ?? {}), ...identity },
+ });
+ if (!updated)
+ throw new NotFoundException(`Company ${company.id} not found`);
+ updated.companyProfiles = company.companyProfiles;
+ return this.getCompanyIdentityState(updated);
+ }
+
+ /**
+ * Drop the Power of Attorney entirely — the verified identity, the details it
+ * wrote and the delegation paper together.
+ *
+ * Only the PoA can go: a company always has an owner, and a freight forwarder
+ * always has a representative. Once a PoA is Fayda-verified its
+ * fields are locked, so blanking the form is no longer a way out — without
+ * this the customer would be stuck with a representative they cannot remove.
+ */
+ async removePoaIdentity(userId: string): Promise {
+ const { company } = await this.getCompanyInfoByUserId(userId);
+ if (
+ (company.companyProfiles ?? []).some(
+ (p) => p.type === ProfileType.freightForwarder,
+ )
+ ) {
+ throw new BadRequestException(
+ "A freight forwarder must have a Power of Attorney. Remove the freight forwarder role first.",
+ );
+ }
+
+ const cleared: Record = {};
+ for (const key of [
+ ...POA_ATTRIBUTES,
+ "poaFaydaSub",
+ "poaFaydaVerifiedAt",
+ "poaBirthdate",
+ "poaGender",
+ ]) {
+ cleared[key] = null;
+ }
+ const attributes = { ...(company.attributes ?? {}), ...cleared };
+
+ // The paper evidences a representative who no longer exists.
+ const records = await this.filesService.findByResource(
+ company.id,
+ COMPANY_RESOURCE,
+ );
+ for (const r of records) {
+ if (
+ r.code === POA_DELEGATION_FILE_KEY ||
+ r.code === POA_DELEGATION_PENDING_CODE
+ ) {
+ await this.filesService.remove(r.id);
+ await this.withdrawDocumentIntent(company.id, r.id);
+ }
+ }
+
+ const updated = await this.companiesRepo.update(company.id, { attributes });
+ if (!updated)
+ throw new NotFoundException(`Company ${company.id} not found`);
+ updated.companyProfiles = company.companyProfiles;
+ return this.getCompanyIdentityState(updated);
+ }
+
+ /** Stage a verified identity onto the company's pending change request. */
+ private async stageIdentityChange(
+ company: Company,
+ userId: string,
+ identity: VerifiedIdentityAttributes,
+ ): Promise {
+ const existing = await this.changeRequestRepo.findPendingByCompanyId(
+ company.id,
+ );
+ const now = new Date();
+ const snapshot = {
+ ...(existing?.snapshot ?? {}),
+ faydaIdentity: {
+ ...(((existing?.snapshot ?? {}) as Record)
+ .faydaIdentity ?? {}),
+ ...identity,
+ },
+ };
+ if (existing) {
+ await this.changeRequestRepo.update(existing.id, {
+ snapshot,
+ submittedBy: userId,
+ submittedAt: now,
+ note: null,
+ });
+ this.companyNotifier.changeRequestSubmitted(company, existing.id, false);
+ return;
+ }
+ const history = await this.changeRequestRepo.findByCompanyId(company.id);
+ const resubmitted = history.some(
+ (r) => r.status === ChangeRequestStatus.Rejected,
+ );
+ const request = await this.changeRequestRepo.create({
+ companyId: company.id,
+ snapshot,
+ status: ChangeRequestStatus.Pending,
+ submittedBy: userId,
+ submittedAt: now,
+ });
+ this.companyNotifier.changeRequestSubmitted(
+ company,
+ request.id,
+ resubmitted,
+ );
+ }
+
+ /**
+ * The gate: an Ethiopian company's owner must be Fayda-verified, and so must
+ * its Power of Attorney once it has one; a foreign company's owner must carry
+ * a passport number instead. Called from the same places as
+ * `assertPoaDelegationSatisfied` — the two rules describe the same moment
+ * (who may act for this company, and on what evidence) and drifting them
+ * apart is how one of them ends up unenforced.
+ */
+ private assertIdentityVerified(
+ company: Company,
+ opts: { requirePoa: boolean },
+ ): void {
+ const state = buildCompanyIdentityState(company);
+
+ // Only the owner's credential is nationality-specific: Fayda for an
+ // Ethiopian company, a typed passport number for a foreign one.
+ if (state.passportRequired) {
+ if (!state.owner.passportNumber) {
+ throw new BadRequestException(
+ "Add the company owner's passport number before continuing.",
+ );
+ }
+ } else if (!state.owner.verified) {
+ throw new BadRequestException(
+ "Verify the company owner's identity with Fayda before continuing.",
+ );
+ }
+
+ // The representative is not. A PoA acts for the company inside Ethiopia
+ // whoever owns it, so they are always an Ethiopian holding a Fayda ID —
+ // a foreign company nominates one rather than typing a name.
+ const poaNamed = POA_ATTRIBUTES.some((k) =>
+ (company.attributes?.[k] as string | undefined)?.trim(),
+ );
+ if (!opts.requirePoa && !poaNamed) return;
+
+ if (!state.poa.verified) {
+ throw new BadRequestException(
+ opts.requirePoa
+ ? "Verify your Power of Attorney with Fayda — a freight forwarder cannot operate without one."
+ : "Verify the Power of Attorney you named with Fayda, or remove the representative.",
+ );
+ }
+ }
+
+ /**
+ * The PoA details the company is heading for: its live attributes with any
+ * pending change-request snapshot laid over them. An Active company's edits
+ * are staged rather than written, so the live row on its own would judge the
+ * customer against details they have already asked to change.
+ */
+ private async effectivePoaAttributes(
+ company: Company,
+ ): Promise> {
+ const pending = await this.changeRequestRepo.findPendingByCompanyId(
+ company.id,
+ );
+ const snapshot = (pending?.snapshot ?? {}) as Record;
+ const staged: Record = {};
+ for (const key of POA_ATTRIBUTES) {
+ if (key in snapshot) staged[key] = snapshot[key];
+ }
+ return { ...(company.attributes ?? {}), ...staged };
+ }
+
+ /** The company's PoA paper(s), with each file's review status resolved. */
async listPoaDelegationFiles(
userId: string,
): Promise {
@@ -2149,6 +2775,18 @@ export class CompaniesService {
throw new NotFoundException(`Delegation letter ${fileId} not found`);
}
+ // Taking the paper away is the other half of the pairing: allowed only once
+ // the representative it evidences is gone too (which, for an Active
+ // company, means the clearing edit is already staged).
+ await this.assertPoaDelegationSatisfied(
+ company.id,
+ await this.effectivePoaAttributes(company),
+ {
+ requirePoa: await this.isFreightForwarder(company.id),
+ ignoreFileIds: [fileId],
+ },
+ );
+
if (record.code === POA_DELEGATION_PENDING_CODE) {
await this.filesService.remove(fileId);
await this.withdrawDocumentIntent(company.id, fileId);
@@ -2328,7 +2966,10 @@ export class CompaniesService {
return match?.id ?? null;
}
- async fetchETradeData(tin: string) {
+ /** Resolve a TIN's live eTrade registration data. Throws when eTrade has no matching business licence. */
+ private async resolveEtradeRegistration(
+ tin: string,
+ ): Promise {
const { businessInfo, companyInfo } =
await this.etradeService.resolveCompanyData(tin);
if (!businessInfo) {
@@ -2336,11 +2977,71 @@ export class CompaniesService {
"We couldn't find a business license for this TIN with eTrade. Please double-check the number and try again.",
);
}
- const registrationData = this.etradeService.extractRegistrationData(
- businessInfo,
- companyInfo,
+ return this.etradeService.extractRegistrationData(businessInfo, companyInfo);
+ }
+
+ async fetchETradeData(tin: string, excludeCompanyId?: string) {
+ const registrationData = await this.resolveEtradeRegistration(tin);
+ const tinTaken = await this.companiesRepo.existsByTin(
+ tin,
+ excludeCompanyId,
);
- const tinTaken = await this.companiesRepo.existsByTin(tin);
return { ...registrationData, tinTaken };
}
+
+ /**
+ * An eTrade-sourced field can only ever hold what a fresh eTrade lookup for
+ * this TIN actually returns — the portal never lets the customer type these
+ * once eTrade has supplied them, so a mismatch here means either stale
+ * client state or a hand-crafted request, and either way the write is
+ * refused rather than silently trusting it.
+ */
+ private async assertEtradeFieldsAuthentic(
+ company: Company,
+ dto: UpdateProfileDto,
+ ): Promise {
+ const touched = ETRADE_SOURCED_FIELDS.some(
+ (key) => dto[key] !== undefined,
+ );
+ if (!touched) return;
+
+ const tin = dto.tin ?? company.tin;
+ const registration = await this.resolveEtradeRegistration(tin);
+ const expected: Partial> = {
+ companyName: registration.companyName,
+ licenceNumber: registration.licenceNumber,
+ statusDescription: registration.statusDescription,
+ dateRegistered: registration.dateRegistered,
+ renewedFrom: registration.renewedFrom,
+ renewalDate: registration.renewalDate,
+ renewedTo: registration.renewedTo,
+ region: registration.region,
+ zone: registration.zone,
+ woreda: registration.woreda,
+ kebele: registration.kebele,
+ houseNo: registration.houseNo,
+ etradePhone:
+ registration.managerPhone ||
+ registration.regularPhone ||
+ registration.mobilePhone,
+ };
+
+ for (const key of ETRADE_SOURCED_FIELDS) {
+ const submitted = dto[key];
+ if (submitted === undefined) continue;
+ const source = expected[key];
+ // eTrade left this field blank — the onboarding/settings card falls back
+ // to letting the customer type it directly, so nothing to check against.
+ if (!source) continue;
+ const same =
+ key === "etradePhone"
+ ? normalizeE164(String(submitted)) === normalizeE164(source)
+ : submitted === source;
+ if (!same) {
+ throw new BadRequestException(
+ `${key} doesn't match eTrade's current record for this TIN. Re-verify with eTrade to pick up the latest details.`,
+ );
+ }
+ }
+ }
}
diff --git a/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts
new file mode 100644
index 000000000..a9988cd28
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts
@@ -0,0 +1,157 @@
+import { ApiProperty } from "@nestjs/swagger";
+import { IsIn, IsString, IsNotEmpty } from "class-validator";
+
+import { Company, CompanyNationality } from "../entities/company.entity";
+import { ProfileType } from "../entities/company-profile.entity";
+
+/**
+ * The two people a company is verified through — its owner and its Power of
+ * Attorney. "Owner" is not the same as the General Manager: a company's GM is
+ * a plain typed role (with a "same as owner" copy the portal offers), while
+ * the owner is the person this verification proves. They're very often the
+ * same human, which is exactly what the copy is for.
+ */
+export const IDENTITY_SUBJECTS = ["owner", "poa"] as const;
+export type IdentitySubject = (typeof IDENTITY_SUBJECTS)[number];
+
+export class CompleteIdentityVerificationDto {
+ @ApiProperty({
+ enum: IDENTITY_SUBJECTS,
+ description: "Which of the company's people this verification is for.",
+ })
+ @IsIn(IDENTITY_SUBJECTS)
+ subject!: IdentitySubject;
+
+ @ApiProperty({ description: "Authorization code from the Fayda redirect." })
+ @IsString()
+ @IsNotEmpty()
+ code!: string;
+
+ @ApiProperty({ description: "CSRF state from the Fayda redirect." })
+ @IsString()
+ @IsNotEmpty()
+ state!: string;
+}
+
+/** One person's verification state, as reported back to the portal. */
+export class IdentityVerificationStateDto {
+ @ApiProperty() verified!: boolean;
+ @ApiProperty({ nullable: true }) name!: string | null;
+ @ApiProperty({ nullable: true }) phone!: string | null;
+ @ApiProperty({ nullable: true }) email!: string | null;
+ @ApiProperty({ nullable: true }) address!: string | null;
+ @ApiProperty({ nullable: true }) verifiedAt!: string | null;
+ @ApiProperty({ nullable: true }) birthdate!: string | null;
+ @ApiProperty({ nullable: true }) gender!: string | null;
+}
+
+export class OwnerIdentityStateDto extends IdentityVerificationStateDto {
+ @ApiProperty({
+ nullable: true,
+ description:
+ "Typed passport number — the foreign-company identity credential. Independent of Fayda: never written by a verification, and still required even if the owner also verifies.",
+ })
+ passportNumber!: string | null;
+}
+
+export class CompanyIdentityStateDto {
+ @ApiProperty({
+ description:
+ "True when Fayda verification of the owner (and PoA, once named) is mandatory — Ethiopian companies only.",
+ })
+ faydaRequired!: boolean;
+
+ @ApiProperty({
+ description:
+ "True when the owner's passport number is mandatory — foreign companies only. Independent of faydaRequired: a foreign owner may verify with Fayda too, but the passport is still required.",
+ })
+ passportRequired!: boolean;
+
+ @ApiProperty({ type: OwnerIdentityStateDto })
+ owner!: OwnerIdentityStateDto;
+
+ @ApiProperty({ type: IdentityVerificationStateDto })
+ poa!: IdentityVerificationStateDto;
+
+ @ApiProperty({
+ description:
+ "False while a mandatory requirement (Fayda for Ethiopian, passport for foreign) is still outstanding.",
+ })
+ complete!: boolean;
+}
+
+/** `attributes` key prefix per person. */
+const PREFIX: Record = {
+ owner: "owner",
+ poa: "poa",
+};
+
+/** company.attributes keys that together mean "a PoA was entered". */
+const POA_KEYS = [
+ "poaName",
+ "poaPhone",
+ "poaEmail",
+ "poaLocation",
+ "poaAddress",
+] as const;
+
+function stateFor(
+ attrs: Record,
+ subject: IdentitySubject,
+): IdentityVerificationStateDto {
+ const p = PREFIX[subject];
+ const read = (key: string) => (attrs[key] as string | undefined) ?? null;
+ return {
+ verified: Boolean(read(`${p}FaydaSub`)),
+ name: read(`${p}Name`),
+ phone: read(`${p}Phone`),
+ email: read(`${p}Email`),
+ address: read(`${p}Address`),
+ verifiedAt: read(`${p}FaydaVerifiedAt`),
+ birthdate: read(`${p}Birthdate`),
+ gender: read(`${p}Gender`),
+ };
+}
+
+/**
+ * Derive both people's verification state from the company row.
+ *
+ * Pure and shared: `CompaniesService` gates on it and `ProfileResponseDto`
+ * renders from it, so the settings page and the onboarding wizard can never
+ * disagree with the rule the API actually enforces.
+ */
+export function buildCompanyIdentityState(
+ company: Company,
+): CompanyIdentityStateDto {
+ const attrs = company.attributes ?? {};
+ const read = (key: string) => (attrs[key] as string | undefined) ?? null;
+
+ // Fayda is an Ethiopian national ID — a foreign company's owner may not hold
+ // one, so a typed passport number is the mandatory credential there instead.
+ // The two are mutually exclusive by nationality but independently tracked,
+ // since a foreign owner verifying with Fayda doesn't waive the passport.
+ const foreign = company.nationality === CompanyNationality.Foreign;
+ const faydaRequired = !foreign;
+ const passportRequired = foreign;
+
+ const owner: OwnerIdentityStateDto = {
+ ...stateFor(attrs, "owner"),
+ passportNumber: read("ownerPassportNumber"),
+ };
+ const poa = stateFor(attrs, "poa");
+ const poaDue =
+ (company.companyProfiles ?? []).some(
+ (p) => p.type === ProfileType.freightForwarder,
+ ) || POA_KEYS.some((k) => (attrs[k] as string | undefined)?.trim());
+
+ // Only the *owner's* credential is nationality-specific. A Power of Attorney
+ // acts for the company inside Ethiopia whoever owns it, so the PoA is always
+ // proven with Fayda — a foreign company nominates a representative who holds
+ // one rather than typing a name nothing backs.
+ const ownerProven = faydaRequired
+ ? owner.verified
+ : !passportRequired || Boolean(owner.passportNumber);
+ const complete = ownerProven && (!poaDue || poa.verified);
+
+ return { faydaRequired, passportRequired, owner, poa, complete };
+}
diff --git a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts
index da908a177..a8d2f24a2 100644
--- a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts
+++ b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts
@@ -8,6 +8,8 @@
* truth the wizard uses to auto-finish.
*/
+import { CompanyIdentityStateDto } from "./complete-identity-verification.dto";
+
export interface OnboardingInfoField {
key: string;
label: string;
@@ -40,11 +42,13 @@ export interface OnboardingPoaState {
required: boolean;
/** True once any PoA detail has been entered. */
provided: boolean;
- /** True when the delegation letter is stored for the company. */
+ /** True when the DARS delegation paper is stored for the company. */
delegationLetterUploaded: boolean;
+ /** True when a reviewer sent the paper back for correction. */
+ delegationLetterFlagged: boolean;
/** PoA details still missing (only populated when `required`). */
missingFields: OnboardingInfoField[];
- /** False while the PoA step still owes details or a delegation letter. */
+ /** False while the PoA step still owes details or an uncorrected paper. */
complete: boolean;
}
@@ -68,6 +72,13 @@ export class OnboardingRequirementsResponseDto {
/** Power of Attorney state, so the wizard needn't re-derive the rule. */
poa: OnboardingPoaState;
+ /**
+ * Fayda verification state for the company's people. `required` is false for
+ * a foreign company, which is never gated on it — the portal renders the
+ * typed personnel forms in that case and the verify panels otherwise.
+ */
+ identity: CompanyIdentityStateDto;
+
/** Overall setup progress across fields + documents + licenses. */
progress: { completed: number; total: number };
@@ -87,6 +98,7 @@ export class OnboardingRequirementsResponseDto {
this.documents = init.documents;
this.licenseProfiles = init.licenseProfiles;
this.poa = init.poa;
+ this.identity = init.identity;
this.progress = init.progress;
this.isComplete = init.isComplete;
this.onboardingCompleted = init.onboardingCompleted;
diff --git a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts
index 89ab954e7..6072268dc 100644
--- a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts
+++ b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts
@@ -1,3 +1,7 @@
+import {
+ buildCompanyIdentityState,
+ CompanyIdentityStateDto,
+} from "./complete-identity-verification.dto";
import { Company } from '../entities/company.entity';
import { ExternalProfile } from '../entities/external-profile.entity';
import {
@@ -52,6 +56,16 @@ export class ProfileResponseDto {
profileId: string;
+ /**
+ * Fayda verification state for the company's owner and PoA — not the general
+ * manager, which is a separate typed role. The settings tabs and the
+ * onboarding wizard render from `identity.faydaRequired` /
+ * `identity.passportRequired`: an Ethiopian company verifies the owner (and
+ * PoA) instead of typing their details; a foreign one requires a typed
+ * passport number instead.
+ */
+ identity: CompanyIdentityStateDto;
+
/**
* Open profile-edit review, if any. `reviewStatus === "pending"` locks the
* settings page; `"rejected"` surfaces the note and prefills the (declined)
@@ -124,5 +138,6 @@ export class ProfileResponseDto {
: null;
this.reviewNote = openReview?.note ?? null;
this.pendingChanges = openReview?.snapshot ?? null;
+ this.identity = buildCompanyIdentityState(company);
}
}
diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts
index a05812558..60f9f9a34 100644
--- a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts
+++ b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts
@@ -9,6 +9,10 @@ import {
ProfileLicenseFileView,
} from '../entities/company-profile.entity';
import { ResponseExternalProfileDto } from './response-external-profile.dto';
+import {
+ buildCompanyIdentityState,
+ CompanyIdentityStateDto,
+} from './complete-identity-verification.dto';
export class ResponseCompanyProfileDto {
id: string;
@@ -69,6 +73,28 @@ export class ResponseCompanyDto {
* external profiles weren't loaded.
*/
onboardingCompleted?: boolean;
+
+ // eTrade-sourced registration record — populated by the onboarding TIN
+ // lookup, locked/read-only on the portal from the moment it's fetched.
+ licenceNumber?: string | null;
+ statusDescription?: string | null;
+ dateRegistered?: string | null;
+ renewedFrom?: string | null;
+ renewalDate?: string | null;
+ renewedTo?: string | null;
+ region?: string | null;
+ zone?: string | null;
+ woreda?: string | null;
+ kebele?: string | null;
+ houseNo?: string | null;
+
+ /**
+ * Owner/PoA Fayda verification state, shared with the portal
+ * (`buildCompanyIdentityState`) so backoffice never re-derives — or
+ * disagrees with — the rule the API actually enforces.
+ */
+ identity: CompanyIdentityStateDto;
+
createdAt: Date;
updatedAt: Date;
@@ -95,6 +121,18 @@ export class ResponseCompanyDto {
? company.profiles.length === 0 ||
company.profiles.some((p) => p.onboardingCompleted)
: undefined;
+ this.licenceNumber = company.licenceNumber;
+ this.statusDescription = company.statusDescription;
+ this.dateRegistered = company.dateRegistered;
+ this.renewedFrom = company.renewedFrom;
+ this.renewalDate = company.renewalDate;
+ this.renewedTo = company.renewedTo;
+ this.region = company.region;
+ this.zone = company.zone;
+ this.woreda = company.woreda;
+ this.kebele = company.kebele;
+ this.houseNo = company.houseNo;
+ this.identity = buildCompanyIdentityState(company);
this.createdAt = company.createdAt;
this.updatedAt = company.updatedAt;
}
diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts
index ba3e27aeb..9f7d1ed39 100644
--- a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts
+++ b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts
@@ -44,10 +44,11 @@ export class UpdateProfileDto {
@MaxLength(50)
vatNumber?: string;
- @IsOptional()
- @IsString()
- @MaxLength(16)
- fanNumber?: string;
+ // `fanNumber` is deliberately absent: the FAN is the Fayda number of the
+ // company's PoA (or its general manager), so it is derived from a completed
+ // Fayda verification rather than typed. The global validation pipe runs with
+ // forbidNonWhitelisted, so a client that still sends it gets a 400 telling it
+ // so — see CompaniesService.completeIdentityVerification.
@IsOptional()
@IsString()
@@ -110,6 +111,16 @@ export class UpdateProfileDto {
@IsString()
poaAddress?: string;
+ /**
+ * The owner's passport number — the identity credential for a foreign
+ * company, since Fayda is an Ethiopian national ID. Plain typed field, never
+ * written or locked by a Fayda verification: still required even if the
+ * owner also verifies.
+ */
+ @IsOptional()
+ @IsString()
+ ownerPassportNumber?: string;
+
@IsOptional()
@IsString()
@MaxLength(100)
diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts
index c567f71ce..7eb87bf86 100644
--- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts
+++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts
@@ -109,6 +109,23 @@ export class ContractBookingService {
private readonly bookingTransitionService: BookingTransitionService,
) {}
+ /**
+ * Mirrors BookingsService.assertNoUnpaidHold for the contract booking paths:
+ * a company sitting on an unpaid hold (SELECTED_FOR_BATCH) books nothing new
+ * until it pays or the hold dies.
+ */
+ private async assertNoUnpaidHold(companyId?: string | null): Promise {
+ if (!companyId) return;
+ const holds =
+ await this.bookingsRepository.countUnpaidHoldsForCompany(companyId);
+ if (holds > 0) {
+ throw new ConflictException(
+ 'You already have a booking waiting for payment. Pay it or cancel it ' +
+ 'before making a new booking.',
+ );
+ }
+ }
+
async createUnderContract(
contractId: string,
dto: CreateBookingUnderContractDto,
@@ -169,6 +186,8 @@ export class ContractBookingService {
// remainder; the customer cannot start any other booking on the contract.
// If the remainder splits again the same rule repeats until the cap is
// exhausted and the contract completes.
+ await this.assertNoUnpaidHold(contract.companyId);
+
if (contract.contractKind === 'ONE_TIME') {
if (await this.hasSplitBooking(contractId)) {
await this.assertExactRemainder(contract, dto);
@@ -455,6 +474,7 @@ export class ContractBookingService {
);
}
}
+ await this.assertNoUnpaidHold(contract.companyId);
const route = await this.resolveRoute(contract, dto.contractRouteId);
@@ -846,6 +866,7 @@ export class ContractBookingService {
const completed = await this.bookingTransitionService.requestOperation(
booking.id,
dto.scheduledDate,
+ dto.trainScheduleId ?? null,
);
return { booking: completed, warnings };
}
diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts
index 512ce5288..e5dd120ee 100644
--- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts
+++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts
@@ -26,30 +26,22 @@ function freightFor(freightType: string): Freight {
/**
* The customer-input clearance setting code, or null when no gate applies.
*
- * - Path B (customs bundled): the customer uploads the documents GL needs to do
- * the clearance work → `contract_clearance_{op}_{freight}`.
- * - Path A (no customs): the customer clears the cargo himself and uploads his
- * own (smaller) clearance proof set → `contract_clearance_selfclear_{op}_{freight}`,
- * reviewed by Operations rather than GL.
+ * Contract-level IMPORT/EXPORT clearance has been removed — clearance is
+ * collected per booking instead (see bookings/clearance.util.ts), so this
+ * always returns null for IMPORT/EXPORT now.
*
* DOMESTIC/intercity has no border, but a ONE_TIME intercity contract still
* collects the admin-configured intercity document set after both signatures
- * (ops-reviewed, like Path A). GENERAL intercity contracts skip the contract
- * gate and collect the same set per booking instead.
+ * (ops-reviewed). GENERAL intercity contracts skip the contract gate and
+ * collect the same set per booking instead.
*/
export function contractClearanceSettingCode(
tradeDirection: string,
- freightType: string,
- includesCustoms: boolean,
+ _freightType: string,
+ _includesCustoms: boolean,
): string | null {
if (tradeDirection === 'DOMESTIC') return INTERCITY_DOCUMENTS_SETTING_CODE;
- const op = operationFor(tradeDirection);
- if (!op) return null;
- const freight = freightFor(freightType);
- if (!includesCustoms) {
- return `contract_clearance_selfclear_${op}_${freight}`;
- }
- return `contract_clearance_${op}_${freight}`;
+ return null;
}
/** The GL-output (customs output) setting code, keyed on op + freight. */
diff --git a/apps/edr-freight-api/src/modules/contracts/contract-duplicate-guard.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-duplicate-guard.spec.ts
index 1290b2e90..f5e2f694c 100644
--- a/apps/edr-freight-api/src/modules/contracts/contract-duplicate-guard.spec.ts
+++ b/apps/edr-freight-api/src/modules/contracts/contract-duplicate-guard.spec.ts
@@ -42,6 +42,7 @@ describe('ContractsService duplicate guard', () => {
{} as never,
{} as never,
{} as never,
+ { buildBreakdown: async () => ({ lineItems: [] }) } as never,
);
return (
service as unknown as {
diff --git a/apps/edr-freight-api/src/modules/contracts/contract-pricing.lane-scope.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-pricing.lane-scope.spec.ts
new file mode 100644
index 000000000..96a8a26ac
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/contracts/contract-pricing.lane-scope.spec.ts
@@ -0,0 +1,122 @@
+import { UnprocessableEntityException } from '@nestjs/common';
+
+import { ContractPricingService } from './contract-pricing.service';
+import type { Contract } from './entities/contract.entity';
+import type { Rate } from '../rule-engine/entities/rate.entity';
+
+const CT20 = 'ct-20';
+const CT40 = 'ct-40';
+const DCT = 'yard-dct';
+const SEBETA = 'yard-sebeta';
+const GMP = 'yard-gmp';
+
+const rate = (over: Partial): Rate =>
+ ({
+ rateType: 'CONTAINER_IMPORT',
+ currency: 'USD',
+ rateValue: 1000,
+ rateUnit: 'PER_CONTAINER',
+ containerTypeId: null,
+ cargoTypeId: null,
+ originYardId: DCT,
+ destinationYardId: SEBETA,
+ ...over,
+ }) as Rate;
+
+const contract = (over: Partial): Contract =>
+ ({
+ freightType: 'CONTAINER',
+ tradeDirection: 'IMPORT',
+ paymentCurrency: 'USD',
+ customsClearingEnabled: false,
+ isHazardous: false,
+ isReefer: false,
+ routes: [{ originYardId: DCT, destinationYardId: SEBETA, sortOrder: 0 }],
+ cargoScope: [{ containerSize: '20ft' }],
+ ...over,
+ }) as Contract;
+
+const service = (liveRates: Rate[]): ContractPricingService =>
+ new ContractPricingService(
+ {} as never,
+ { findLiveRates: async () => liveRates } as never,
+ {
+ findAll: async () => ({
+ items: [
+ { id: CT20, sizeFt: 20 },
+ { id: CT40, sizeFt: 40 },
+ ],
+ }),
+ } as never,
+ { getRate: async () => 1 } as never,
+ );
+
+describe('contract base freight is priced on the contract lane only', () => {
+ it('prices from the contract route, never another lane (CTR-2026-00065)', async () => {
+ const breakdown = await service([
+ // Same size, other lane — the leak that priced DCT → Sebeta at GMP rates.
+ rate({ containerTypeId: CT20, destinationYardId: GMP, rateValue: 1690 }),
+ rate({ containerTypeId: CT20, rateValue: 750 }),
+ ]).buildBreakdown(contract({}));
+ expect(breakdown.lineItems).toEqual([
+ expect.objectContaining({ code: 'CONTAINER_20FT', unitPrice: 750 }),
+ ]);
+ });
+
+ it('blocks the contract when its lane has no container rate', async () => {
+ await expect(
+ service([
+ rate({ containerTypeId: CT20, destinationYardId: GMP, rateValue: 1690 }),
+ ]).buildBreakdown(contract({})),
+ ).rejects.toThrow(UnprocessableEntityException);
+ });
+
+ it('freezes the OVERWEIGHT_PER_TON surcharge on export contracts only', async () => {
+ const overweight = rate({
+ rateType: 'OVERWEIGHT_PER_TON',
+ trigger: 'OVERWEIGHT',
+ rateUnit: 'PER_TON',
+ rateValue: 25,
+ originYardId: null,
+ destinationYardId: null,
+ } as Partial);
+
+ const exported = await service([
+ rate({ rateType: 'CONTAINER_EXPORT', containerTypeId: CT20, rateValue: 900 }),
+ overweight,
+ ]).buildBreakdown(contract({ tradeDirection: 'EXPORT' }));
+ expect(exported.lineItems).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ code: 'OVERWEIGHT_PER_TON', unitPrice: 25 }),
+ ]),
+ );
+
+ // Import derives overweight from the route's base freight — never frozen.
+ const imported = await service([
+ rate({ containerTypeId: CT20, rateValue: 750 }),
+ overweight,
+ ]).buildBreakdown(contract({}));
+ expect(
+ imported.lineItems.some((li) => li.code === 'OVERWEIGHT_PER_TON'),
+ ).toBe(false);
+ });
+
+ it('blocks bulk contracts too instead of borrowing an arbitrary rate', async () => {
+ const bulk = contract({ freightType: 'BULK', cargoScope: [] });
+ await expect(
+ service([
+ rate({
+ rateType: 'BULK_IMPORT',
+ rateUnit: 'PER_TON',
+ destinationYardId: GMP,
+ }),
+ ]).buildBreakdown(bulk),
+ ).rejects.toThrow(UnprocessableEntityException);
+ const priced = await service([
+ rate({ rateType: 'BULK_IMPORT', rateUnit: 'PER_TON', rateValue: 32 }),
+ ]).buildBreakdown(bulk);
+ expect(priced.lineItems).toEqual([
+ expect.objectContaining({ code: 'BULK_FREIGHT', unitPrice: 32 }),
+ ]);
+ });
+});
diff --git a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts
index a4e4b43b5..d54b0b544 100644
--- a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts
+++ b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts
@@ -84,6 +84,26 @@ export class ContractPricingService {
const lineItems: ContractUnitRateLineItem[] = [];
const baseType = this.baseRateType(contract);
+ // Base rail freight is quoted per route (CK_rates_yard_scope) — only rates
+ // on the contract's own lane may price it. Matching without the yard filter
+ // is how a DCT → Sebeta contract froze DCT → GMP (Indode) prices, and the
+ // frozen snapshot then bills bookings that the route-scoped booking lookup
+ // would have hard-blocked (CTR-2026-00065).
+ // ponytail: multi-route contracts price the first lane (same as customs
+ // clearance below); per-lane pricing needs per-route breakdowns.
+ const route = [...(contract.routes ?? [])].sort(
+ (a, b) => a.sortOrder - b.sortOrder,
+ )[0];
+ const onLane = route
+ ? liveRates.filter(
+ (r) =>
+ r.rateType === baseType &&
+ r.currency === 'USD' &&
+ r.originYardId === route.originYardId &&
+ r.destinationYardId === route.destinationYardId,
+ )
+ : [];
+
if (contract.freightType === 'CONTAINER') {
const sizes = (contract.cargoScope ?? [])
.map((c) => c.containerSize)
@@ -97,17 +117,14 @@ export class ContractPricingService {
const matchedTypes = containerTypes.filter((ct) => ct.sizeFt === sizeFt);
const matchedIds = new Set(matchedTypes.map((ct) => ct.id));
const rate =
- liveRates.find(
- (r) =>
- r.rateType === baseType &&
- r.currency === 'USD' &&
- r.containerTypeId &&
- matchedIds.has(r.containerTypeId),
- ) ??
- liveRates.find(
- (r) => r.rateType === baseType && r.currency === 'USD' && !r.containerTypeId,
+ onLane.find(
+ (r) => r.containerTypeId && matchedIds.has(r.containerTypeId),
+ ) ?? onLane.find((r) => !r.containerTypeId);
+ if (!rate || Number(rate.rateValue) <= 0) {
+ throw new UnprocessableEntityException(
+ `No rail freight rate is configured for ${size} containers on this direction and route — the contract cannot be priced. Ask the rates team to set a live ${baseType} rate for this container type and origin → destination.`,
);
- if (!rate) continue;
+ }
lineItems.push({
code: `CONTAINER_${size.toUpperCase()}`,
label: `${size} container`,
@@ -120,25 +137,26 @@ export class ContractPricingService {
const cargoScope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId);
// Freeze the rate for the contract's own commodity when one is configured
// — a per-item machinery rate and a per-ton wheat rate live side by side.
- const bulkRates = liveRates.filter(
- (r) => r.rateType === baseType && r.currency === 'USD',
- );
+ // No arbitrary-rate fallback: another commodity's rate must never price
+ // this contract.
const bulkRate =
(cargoScope?.cargoTypeId
- ? bulkRates.find((r) => r.cargoTypeId === cargoScope.cargoTypeId)
+ ? onLane.find((r) => r.cargoTypeId === cargoScope.cargoTypeId)
: undefined) ??
- bulkRates.find((r) => !r.cargoTypeId) ??
- bulkRates[0] ??
+ onLane.find((r) => !r.cargoTypeId) ??
null;
- if (bulkRate) {
- lineItems.push({
- code: 'BULK_FREIGHT',
- label: cargoScope?.cargoType?.cargoTypeName ?? 'Bulk cargo',
- unit: toContractUnit(bulkRate.rateUnit),
- unitPrice: convert(Number(bulkRate.rateValue)),
- cargoTypeCode: cargoScope?.cargoType?.code ?? null,
- });
+ if (!bulkRate || Number(bulkRate.rateValue) <= 0) {
+ throw new UnprocessableEntityException(
+ 'No bulk rail freight rate is configured for this cargo type on this direction and route — the contract cannot be priced. Ask the rates team to set a live rate for this commodity and origin → destination.',
+ );
}
+ lineItems.push({
+ code: 'BULK_FREIGHT',
+ label: cargoScope?.cargoType?.cargoTypeName ?? 'Bulk cargo',
+ unit: toContractUnit(bulkRate.rateUnit),
+ unitPrice: convert(Number(bulkRate.rateValue)),
+ cargoTypeCode: cargoScope?.cargoType?.code ?? null,
+ });
}
// First / last mile trucking unit rates — shown when the contract carries
@@ -201,6 +219,26 @@ export class ContractPricingService {
});
}
}
+ // Overweight surcharge — EXPORT contracts freeze the OVERWEIGHT_PER_TON
+ // rate so booking pricing bills the contract's price on excess tons
+ // (frozenRateByCode wins over the live rate). Always included, no toggle:
+ // overweight is system-detected at booking, never customer-opted. IMPORT
+ // never reads this snapshot — its overweight price derives from the
+ // route's base container freight (see RuleEngineService).
+ if (contract.tradeDirection === 'EXPORT') {
+ const overweight = liveRates.find(
+ (r) => r.trigger === 'OVERWEIGHT' && r.currency === 'USD',
+ );
+ if (overweight && Number(overweight.rateValue) > 0) {
+ lineItems.push({
+ code: 'OVERWEIGHT_PER_TON',
+ label: 'Overweight surcharge (per excess ton)',
+ unit: toContractUnit(overweight.rateUnit),
+ unitPrice: convert(Number(overweight.rateValue)),
+ conditionalOn: 'is_overweight',
+ });
+ }
+ }
// Lashing / cargo securing — BULK only, shown when the contract's commodity
// needs lashing (cargoType.hasLashing). The commodity-scoped rate for the
// contract's direction wins over the commodity-wide catch-all; billed at
@@ -241,9 +279,6 @@ export class ContractPricingService {
// one display line per contract size that has a configured rate. A size
// with no rate shows nothing here and hard-blocks at booking time.
// ponytail: bookings bill the live route rate, not a frozen snapshot.
- const route = [...(contract.routes ?? [])].sort(
- (a, b) => a.sortOrder - b.sortOrder,
- )[0];
const onLeg = route
? liveRates.filter(
(r) =>
@@ -291,9 +326,6 @@ export class ContractPricingService {
if (contract.customsClearingEnabled) {
// Strict, no route-less fallback.
// ponytail: multi-route contracts bill the first lane's fee; per-lane fees need per-route snapshots.
- const route = [...(contract.routes ?? [])].sort(
- (a, b) => a.sortOrder - b.sortOrder,
- )[0];
const onLeg = route
? liveRates.filter(
(r) =>
diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts
index 24aa2b597..5c0b5e29e 100644
--- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts
+++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts
@@ -28,6 +28,7 @@ import { ContractCargoScope } from './entities/contract-cargo-scope.entity';
import { isEffectivelyExpired } from './utils/contract-expiry.util';
import { diffContractFields } from './contract-document-diff.util';
import { ContractDocumentHistoryService } from './contract-document-history.service';
+import { ContractPricingService } from './contract-pricing.service';
import { FileRecord } from '../files/entities/file.entity';
/** Paginated contract list: flat `total` (backoffice) + `meta` block (portal). */
@@ -113,6 +114,7 @@ export class ContractsService {
private readonly filesService: FilesService,
private readonly minioService: MinioService,
private readonly documentHistory: ContractDocumentHistoryService,
+ private readonly pricingService: ContractPricingService,
) {}
/** Generate a unique contract reference number (CTR-YYYY-NNNNN). */
@@ -331,6 +333,33 @@ export class ContractsService {
);
}
+ // Price the contract BEFORE anything persists: a lane with no configured
+ // rate 422s here and the wizard shows its blocking modal — with no orphan
+ // DRAFT row left behind for the customer to trip over on retry. The probe
+ // carries exactly the fields buildBreakdown prices from; relation-only
+ // niceties (cargoType labels) are absent, which only affects display
+ // lines, never the missing-rate gates.
+ await this.pricingService.buildBreakdown({
+ tradeDirection: dto.tradeDirection,
+ freightType: dto.freightType,
+ paymentCurrency: 'USD',
+ customsClearingEnabled: includesCustoms,
+ isHazardous: dto.isHazardous ?? false,
+ isReefer: dto.isReefer ?? false,
+ equipmentReturn: dto.equipmentReturn ?? null,
+ firstMilePickupAddress: dto.firstMilePickupAddress ?? null,
+ lastMileDeliveryAddress: dto.lastMileDeliveryAddress ?? null,
+ routes: (dto.routes ?? []).map((r, i) => ({
+ originYardId: r.originYardId,
+ destinationYardId: r.destinationYardId,
+ sortOrder: r.sortOrder ?? i,
+ })),
+ cargoScope: (dto.cargoScope ?? []).map((c) => ({
+ containerSize: c.containerSize ?? null,
+ cargoTypeId: c.cargoTypeId ?? null,
+ })),
+ } as unknown as Contract);
+
// An explicit reference is caller-chosen — a collision there is a real
// conflict and should surface. Auto-generated references retry past a
// concurrent insert that grabbed the same sequence number.
diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts
index 2ab616c00..8c1bf763d 100644
--- a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts
+++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts
@@ -175,6 +175,16 @@ export class CreateBookingUnderContractDto {
@IsDateString()
scheduledDate?: string;
+ @ApiPropertyOptional({
+ description:
+ 'EXPORT rail only: the specific train (schedule id) picked from ' +
+ 'GET /bookings/:id/export-trains for the shipment day. The reserve path ' +
+ 'locks onto this train; 409 when it no longer fits. Ignored otherwise.',
+ })
+ @IsOptional()
+ @IsUUID()
+ trainScheduleId?: string;
+
@ApiPropertyOptional({
enum: SHIPMENT_EQUIPMENT_RETURNS,
description:
diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.service.ts b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.service.ts
index 947bb5ffb..9651d4f37 100644
--- a/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.service.ts
+++ b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.service.ts
@@ -15,6 +15,11 @@ import {
FILE_UPLOAD_SETTINGS_REPOSITORY,
IFileUploadSettingsRepository,
} from "./interfaces/file-upload-settings.repository.interface";
+import {
+ COMPANY_ONBOARDING_CODE_PREFIX,
+ POA_DELEGATION_FILE_KEY,
+ poaDelegationField,
+} from "./poa-delegation.constants";
@Injectable()
export class FileUploadSettingsService {
@@ -40,6 +45,22 @@ export class FileUploadSettingsService {
async getByCode(code: string): Promise {
const setting = await this.repository.findByCode(code);
if (!setting) throw new NotFoundException(`Setting "${code}" not found`);
+ return this.withPoaDelegationField(setting);
+ }
+
+ /**
+ * Company onboarding sets always carry the DARS delegation paper, whether or
+ * not anyone configured a row for it — see poa-delegation.constants.ts. Every
+ * consumer (the portal's PoA step, the onboarding gate) reads the set through
+ * here, so this is the single place the field can be guaranteed.
+ */
+ private withPoaDelegationField(setting: FileUploadSetting): FileUploadSetting {
+ if (!setting.code.startsWith(COMPANY_ONBOARDING_CODE_PREFIX)) return setting;
+ const fields = setting.fields ?? [];
+ if (fields.some((f) => f.fileKey === POA_DELEGATION_FILE_KEY)) return setting;
+
+ const lastOrder = fields.reduce((max, f) => Math.max(max, f.displayOrder), 0);
+ setting.fields = [...fields, poaDelegationField(lastOrder + 1)];
return setting;
}
diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/poa-delegation.constants.ts b/apps/edr-freight-api/src/modules/file-upload-settings/poa-delegation.constants.ts
new file mode 100644
index 000000000..9085cc018
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/file-upload-settings/poa-delegation.constants.ts
@@ -0,0 +1,52 @@
+import { FileUploadField } from "./entities/file-upload-field.entity";
+
+/**
+ * The DARS delegation paper — the document that evidences a company's Power of
+ * Attorney (EDRFREIGHT-358).
+ *
+ * Every other onboarding document is admin-managed: the rows in
+ * `file_upload_fields` are edited from the backoffice file-settings editor and
+ * the seeder deliberately inserts none. This one is different — a company that
+ * names a PoA must produce a delegation paper authenticated by the Documents
+ * Authentication and Registration Service, and that is a legal requirement
+ * rather than a configuration choice. So the field is defined here in code and
+ * injected into the company onboarding sets on read: no row to forget to seed,
+ * and deleting one in the editor cannot silently switch the requirement off.
+ */
+
+/** FileRecord `code` (and upload field key) of the live delegation paper. */
+export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter";
+
+/** Code for a delegation paper staged in an open change request (not yet live). */
+export const POA_DELEGATION_PENDING_CODE = "poa_delegation_letter_pending";
+
+/** Customer-facing name of the document, used by the API and both web apps. */
+export const POA_DELEGATION_LABEL = "DARS Delegation Paper";
+
+/** Prefix of the setting codes the field is injected into. */
+export const COMPANY_ONBOARDING_CODE_PREFIX = "company_onboarding_documents_";
+
+const POA_DELEGATION_HELP =
+ "Delegation paper issued by the Documents Authentication and Registration " +
+ "Service (DARS) delegating the representative named above. Upload the " +
+ "authenticated copy — a plain letter is not accepted.";
+
+/**
+ * The field descriptor. `isRequired` stays false because the paper is only due
+ * once a PoA has actually been named (or the company operates as a freight
+ * forwarder) — a rule that spans form fields as well as files, so it is
+ * enforced in CompaniesService rather than by this flag.
+ */
+export function poaDelegationField(displayOrder: number): FileUploadField {
+ return {
+ fileKey: POA_DELEGATION_FILE_KEY,
+ fileLabel: POA_DELEGATION_LABEL,
+ helpText: POA_DELEGATION_HELP,
+ isRequired: false,
+ isMultiple: false,
+ maxFiles: 1,
+ allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
+ maxSizeMb: 10,
+ displayOrder,
+ } as FileUploadField;
+}
diff --git a/apps/edr-freight-api/src/modules/last-mile/dto/set-warehouse-gate-times.dto.ts b/apps/edr-freight-api/src/modules/last-mile/dto/set-warehouse-gate-times.dto.ts
new file mode 100644
index 000000000..ba980dfdc
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/last-mile/dto/set-warehouse-gate-times.dto.ts
@@ -0,0 +1,22 @@
+import { Type } from 'class-transformer';
+import { IsArray, IsDateString, IsOptional, IsUUID, ValidateNested } from 'class-validator';
+
+export class TruckWarehouseGateTimeInput {
+ @IsUUID()
+ vehicleId!: string;
+
+ @IsOptional()
+ @IsDateString()
+ arrivedAt?: string | null;
+
+ @IsOptional()
+ @IsDateString()
+ departedAt?: string | null;
+}
+
+export class SetWarehouseGateTimesDto {
+ @IsArray()
+ @ValidateNested({ each: true })
+ @Type(() => TruckWarehouseGateTimeInput)
+ trucks!: TruckWarehouseGateTimeInput[];
+}
diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts
index e8fa57cdc..d7e2ba1ff 100644
--- a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts
+++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts
@@ -24,6 +24,7 @@ import { CreateLastMileDto } from './dto/create-last-mile.dto';
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
import { SetVehiclesDto } from './dto/set-vehicles.dto';
import { SetDetentionTimesDto } from './dto/set-detention-times.dto';
+import { SetWarehouseGateTimesDto } from './dto/set-warehouse-gate-times.dto';
import { SetDistancesDto } from './dto/set-distances.dto';
import { RecordProofOfDeliveryDto } from './dto/record-proof-of-delivery.dto';
import { LastMileStatus } from './entities/last-mile.entity';
@@ -144,6 +145,18 @@ export class LastMileController {
return this.lastMileService.setDetentionTimes(id, dto.trucks);
}
+ @Post(':id/warehouse-gate-times')
+ @BookingStaff(FREIGHT_PERMS.lastMile.update)
+ @ApiOperation({
+ summary: 'Set each truck\'s warehouse gate arrival/departure times',
+ })
+ async setWarehouseGateTimes(
+ @Param('id', ParseUUIDPipe) id: string,
+ @Body() dto: SetWarehouseGateTimesDto,
+ ) {
+ return this.lastMileService.setWarehouseGateTimes(id, dto.trucks);
+ }
+
@Post(':id/proof-of-delivery')
@BookingStaff(FREIGHT_PERMS.lastMile.update)
@UseInterceptors(AnyFilesInterceptor())
diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts
index 6db55b66d..af6c920fc 100644
--- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts
+++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts
@@ -913,6 +913,41 @@ export class LastMileService {
return this.findById(id);
}
+ async setWarehouseGateTimes(
+ id: string,
+ trucks: Array<{
+ vehicleId: string;
+ arrivedAt?: string | null;
+ departedAt?: string | null;
+ }>,
+ ): Promise {
+ await this.findById(id);
+
+ const invoices = await this.billing.findBySourceIds('last_mile', [id]);
+ if (invoices.length) {
+ throw new BadRequestException(
+ 'Warehouse gate times cannot be changed after the invoice is generated',
+ );
+ }
+
+ for (const t of trucks) {
+ const arrived = t.arrivedAt ? new Date(t.arrivedAt) : null;
+ const departed = t.departedAt ? new Date(t.departedAt) : null;
+ if (arrived && departed && departed.getTime() < arrived.getTime()) {
+ throw new BadRequestException(
+ 'A truck cannot depart before it arrived — check the warehouse gate times',
+ );
+ }
+ await this.dataSource.manager.update(
+ LastMileVehicleAssignment,
+ { lastMileId: id, vehicleId: t.vehicleId },
+ { arrivedAt: arrived, departedAt: departed },
+ );
+ }
+
+ return this.findById(id);
+ }
+
async setDistances(
id: string,
distances: Array<{ vehicleId: string; distanceKm: number }>,
diff --git a/apps/edr-freight-api/src/modules/payment/payment-client.service.ts b/apps/edr-freight-api/src/modules/payment/payment-client.service.ts
index 4c2ebe971..c0fc6fc09 100644
--- a/apps/edr-freight-api/src/modules/payment/payment-client.service.ts
+++ b/apps/edr-freight-api/src/modules/payment/payment-client.service.ts
@@ -1,4 +1,9 @@
-import { BadGatewayException, Injectable, Logger } from "@nestjs/common";
+import {
+ BadGatewayException,
+ BadRequestException,
+ Injectable,
+ Logger,
+} from "@nestjs/common";
import { HttpService } from "@nestjs/axios";
import { AxiosError } from "axios";
import { firstValueFrom } from "rxjs";
@@ -31,6 +36,24 @@ export class PaymentClientService {
return this.call("POST", "/payments/initiate", request);
}
+ /**
+ * POST /payments/reconcile — settlement check for a domain order
+ * (reconcile-before-cancel). Live-queries every non-failed intent at the
+ * provider and registers any late capture found (flips it to SUCCEEDED and
+ * emits payment.succeeded). `unverifiable: true` = could not confirm
+ * "not paid" — the caller must NOT cancel/expire the order.
+ */
+ async reconcileReference(
+ referenceType: PaymentReferenceType,
+ referenceId: string,
+ ): Promise<{ paid: boolean; unverifiable: boolean }> {
+ return this.call("POST", "/payments/reconcile", {
+ service: PaymentService.FREIGHT,
+ referenceType,
+ referenceId,
+ });
+ }
+
/** GET /payments/intents?… — active intent by domain reference; null when none exists. */
async getIntentByReference(
referenceType: PaymentReferenceType,
@@ -49,6 +72,33 @@ export class PaymentClientService {
}
}
+ /**
+ * POST /payments/intents/:id/confirm — submit an OTP for a COLLECT_OTP provider
+ * (CAC Bank). A wrong/expired OTP comes back as 400 from the payment service;
+ * surface that as a BadRequest (retryable) rather than a 502, so the payer can
+ * re-enter the code.
+ */
+ async confirmOtp(intentId: string, otp: string): Promise {
+ try {
+ return await this.call(
+ "POST",
+ `/payments/intents/${intentId}/confirm`,
+ { otp },
+ );
+ } catch (err) {
+ // `call` re-throws raw 404s and masks every other 4xx as BadGateway; an
+ // unknown intent or a bad OTP is client-fixable, so translate both to 400.
+ if (err instanceof AxiosError && err.response?.status === 404) {
+ throw new BadRequestException("PaymentIntent not found");
+ }
+ if (err instanceof BadGatewayException) {
+ const detail = err.message.replace(/^Payment service error: /, "");
+ throw new BadRequestException(detail);
+ }
+ throw err;
+ }
+ }
+
private async call(method: "GET" | "POST", path: string, body?: unknown): Promise {
const url = `${this.baseUrl}${path}`;
try {
diff --git a/apps/edr-freight-api/src/modules/payment/payment.module.ts b/apps/edr-freight-api/src/modules/payment/payment.module.ts
index 05267746d..961aa32bd 100644
--- a/apps/edr-freight-api/src/modules/payment/payment.module.ts
+++ b/apps/edr-freight-api/src/modules/payment/payment.module.ts
@@ -56,7 +56,13 @@ function rabbitMQImport(): DynamicModule[] {
@Module({
imports: [
- HttpModule.register({ timeout: 10_000 }),
+ // CAC Bank's initiate SMSes an OTP and routinely takes >10s, so the old
+ // 10s cap 502'd every CAC charge while the bank was still working —
+ // orphaning an intent the payer had already been texted about. Matches
+ // the passenger API's budget.
+ HttpModule.register({
+ timeout: Number(process.env.PAYMENT_API_HTTP_TIMEOUT_MS) || 60_000,
+ }),
ConfigModule,
forwardRef(() => BillingModule),
// forwardRef(() => TrainSchedulingModule),
diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.spec.ts b/apps/edr-freight-api/src/modules/payment/payment.service.spec.ts
new file mode 100644
index 000000000..ed0f8da58
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/payment/payment.service.spec.ts
@@ -0,0 +1,190 @@
+import { BadRequestException, NotFoundException } from "@nestjs/common";
+import { of, throwError } from "rxjs";
+import { AxiosError, AxiosHeaders } from "axios";
+import { PaymentReferenceType, ProviderPaymentStatus } from "@edr/types";
+
+import { PaymentClientService } from "./payment-client.service";
+import { PaymentService } from "./payment.service";
+
+/** Local intent projection row (the invoice's `paymentId` points at this). */
+function localIntent(overrides: Record = {}) {
+ return {
+ id: "intent-1",
+ refId: "booking-1",
+ referenceType: PaymentReferenceType.SHIPMENT,
+ status: "action-required",
+ method: "cac-bank",
+ merchantOrderId: "EDR_INV_1",
+ clientAction: { type: "COLLECT_OTP", providerOrderId: "471583397" },
+ ...overrides,
+ };
+}
+
+function makeRepo(rows: Record[]) {
+ const store = [...rows];
+ return {
+ findOneBy: jest.fn((where: Record) =>
+ Promise.resolve(
+ store.find((r) =>
+ Object.entries(where).every(([k, v]) => r[k] === v),
+ ) ?? null,
+ ),
+ ),
+ update: jest.fn((where: { id: string }, data: Record) => {
+ const row = store.find((r) => r.id === where.id);
+ if (row) Object.assign(row, data);
+ return Promise.resolve(undefined);
+ }),
+ };
+}
+
+describe("PaymentService.confirmOtp", () => {
+ const build = (
+ client: Partial,
+ rows = [localIntent()],
+ ) => {
+ const repo = makeRepo(rows);
+ const billing = { settleByPaymentId: jest.fn().mockResolvedValue(null) };
+ const service = new PaymentService(
+ repo as never,
+ client as never,
+ billing as never,
+ );
+ return { service, repo, billing };
+ };
+
+ it("settles the local intent and tells billing to settle the invoice on SUCCEEDED", async () => {
+ const paidAt = "2026-07-31T10:00:00.000Z";
+ const { service, repo, billing } = build({
+ getIntentByReference: jest
+ .fn()
+ .mockResolvedValue({ intentId: "gw-1", status: "REQUIRES_ACTION" }),
+ confirmOtp: jest.fn().mockResolvedValue({
+ intentId: "gw-1",
+ status: ProviderPaymentStatus.SUCCEEDED,
+ providerTxnId: "11709363209530624",
+ paidAt,
+ }),
+ });
+
+ const result = await service.confirmOtp("intent-1", "8280");
+
+ expect(repo.update).toHaveBeenCalledWith(
+ { id: "intent-1" },
+ expect.objectContaining({
+ status: "success",
+ transactionId: "11709363209530624",
+ }),
+ );
+ // Billing settles the invoice linked by this intent id.
+ expect(billing.settleByPaymentId).toHaveBeenCalledWith(
+ "intent-1",
+ "11709363209530624",
+ new Date(paidAt),
+ );
+ expect(result.status).toBe(ProviderPaymentStatus.SUCCEEDED);
+ });
+
+ it("forwards the OTP against the GATEWAY intent id, not the local one", async () => {
+ const confirmOtp = jest
+ .fn()
+ .mockResolvedValue({ status: ProviderPaymentStatus.REQUIRES_ACTION });
+ const { service } = build({
+ getIntentByReference: jest.fn().mockResolvedValue({ intentId: "gw-1" }),
+ confirmOtp,
+ });
+
+ await service.confirmOtp("intent-1", "8280");
+
+ expect(confirmOtp).toHaveBeenCalledWith("gw-1", "8280");
+ });
+
+ it("leaves the intent open and does not settle when the OTP is not accepted", async () => {
+ const { service, repo, billing } = build({
+ getIntentByReference: jest.fn().mockResolvedValue({ intentId: "gw-1" }),
+ confirmOtp: jest.fn().mockResolvedValue({
+ status: ProviderPaymentStatus.REQUIRES_ACTION,
+ failureMessage: "OTP confirmation failed",
+ }),
+ });
+
+ const result = await service.confirmOtp("intent-1", "0000");
+
+ expect(billing.settleByPaymentId).not.toHaveBeenCalled();
+ expect(repo.update).toHaveBeenCalledWith(
+ { id: "intent-1" },
+ expect.objectContaining({ status: "action-required" }),
+ );
+ expect(result.status).toBe(ProviderPaymentStatus.REQUIRES_ACTION);
+ });
+
+ it("404s when the gateway has no active intent for the reference", async () => {
+ const { service } = build({
+ getIntentByReference: jest.fn().mockResolvedValue(null),
+ confirmOtp: jest.fn(),
+ });
+
+ await expect(service.confirmOtp("intent-1", "8280")).rejects.toBeInstanceOf(
+ NotFoundException,
+ );
+ });
+});
+
+describe("PaymentClientService.confirmOtp", () => {
+ const axiosErr = (status: number, message: string) =>
+ new AxiosError(
+ `Request failed with status code ${status}`,
+ undefined,
+ undefined,
+ undefined,
+ {
+ status,
+ statusText: "",
+ data: { message },
+ headers: new AxiosHeaders(),
+ config: { headers: new AxiosHeaders() },
+ },
+ );
+
+ const build = (request: jest.Mock) =>
+ new PaymentClientService({ request } as never);
+
+ it("posts the OTP to the payment service intent-confirm route", async () => {
+ const request = jest
+ .fn()
+ .mockReturnValue(of({ data: { intentId: "gw-1", status: "SUCCEEDED" } }));
+
+ const result = await build(request).confirmOtp("gw-1", "8280");
+
+ expect(request).toHaveBeenCalledWith(
+ expect.objectContaining({
+ method: "POST",
+ url: expect.stringContaining("/payments/intents/gw-1/confirm"),
+ data: { otp: "8280" },
+ }),
+ );
+ expect(result.status).toBe("SUCCEEDED");
+ });
+
+ it("maps a rejected OTP (400) to BadRequest so the payer can retry", async () => {
+ const request = jest
+ .fn()
+ .mockReturnValue(
+ throwError(() => axiosErr(400, "OTP confirmation failed")),
+ );
+
+ await expect(build(request).confirmOtp("gw-1", "0000")).rejects.toBeInstanceOf(
+ BadRequestException,
+ );
+ });
+
+ it("maps an unknown intent (404) to BadRequest rather than a gateway error", async () => {
+ const request = jest
+ .fn()
+ .mockReturnValue(throwError(() => axiosErr(404, "PaymentIntent not found")));
+
+ await expect(build(request).confirmOtp("nope", "8280")).rejects.toBeInstanceOf(
+ BadRequestException,
+ );
+ });
+});
diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts
index efc4ac926..0f628e39c 100644
--- a/apps/edr-freight-api/src/modules/payment/payment.service.ts
+++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts
@@ -193,6 +193,30 @@ export class PaymentService {
* marked paid WITHOUT emitting — the caller (billing) settles inline after it
* has stored the intent id, avoiding a settle-before-correlation race.
*/
+ /**
+ * Reconcile-before-cancel: ask the payment service whether ANY intent for
+ * this shipment actually settled at the provider (bank/gateway). A late
+ * capture found there is registered as SUCCEEDED and emits payment.succeeded,
+ * which drives the normal paid flow. A network/provider error reports
+ * `unverifiable` — the caller must not expire the order on unknown.
+ */
+ async reconcileShipment(
+ referenceId: string,
+ ): Promise<{ paid: boolean; unverifiable: boolean }> {
+ try {
+ const result = await this.paymentClient.reconcileReference(
+ PaymentReferenceType.SHIPMENT,
+ referenceId,
+ );
+ return { paid: result.paid, unverifiable: result.unverifiable };
+ } catch (err) {
+ this.logger.warn(
+ `Reconcile for shipment ${referenceId} failed: ${(err as Error).message}`,
+ );
+ return { paid: false, unverifiable: true };
+ }
+ }
+
async initiate(input: InitiateIntentInput): Promise {
try {
const isCbeBill = input.method === ProviderMethod.CBE_BILL;
@@ -364,6 +388,53 @@ export class PaymentService {
return this.formatIntentStatus(refreshed ?? local);
}
+ /**
+ * Submit an OTP for a COLLECT_OTP provider (CAC Bank). Keyed by the LOCAL intent
+ * id (the invoice's `paymentId`) so the right invoice settles even when several
+ * invoices share a domain reference. The active gateway intent is looked up by
+ * reference, the OTP is forwarded, and the projection is refreshed. On success
+ * billing settles the linked invoice (idempotent — the outbox path converges too).
+ * A wrong/expired OTP bubbles up as a 400 and leaves the intent open for retry.
+ */
+ async confirmOtp(intentId: string, otp: string): Promise {
+ const local = await this.paymentRepo.findOneBy({ id: intentId });
+ if (!local) throw new NotFoundException("PaymentIntent not found");
+
+ const snapshot = await this.paymentClient.getIntentByReference(
+ (local.referenceType as PaymentReferenceType) ??
+ PaymentReferenceType.SHIPMENT,
+ local.refId,
+ );
+ if (!snapshot) {
+ throw new NotFoundException("No active payment to confirm");
+ }
+
+ const confirmed = await this.paymentClient.confirmOtp(
+ snapshot.intentId,
+ otp,
+ );
+
+ if (confirmed.status === ProviderPaymentStatus.SUCCEEDED) {
+ await this.markIntentSucceeded(local.id, {
+ providerTxnId: confirmed.providerTxnId,
+ paidAt: confirmed.paidAt ? new Date(confirmed.paidAt) : undefined,
+ notify: true,
+ });
+ } else {
+ await this.paymentRepo.update(
+ { id: local.id },
+ {
+ status: this.toLocalStatus(confirmed.status),
+ failerCode: confirmed.failureCode ?? undefined,
+ failureMessage: confirmed.failureMessage ?? undefined,
+ },
+ );
+ }
+
+ const refreshed = await this.paymentRepo.findOneBy({ id: local.id });
+ return this.formatIntentStatus(refreshed ?? local);
+ }
+
/**
* Mark a gateway intent paid and (by default) notify billing to settle the
* linked invoice. Idempotent — no-op when already success. Pass `notify: false`
diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/yards.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/yards.repository.interface.ts
index 75baca5de..104603c6d 100644
--- a/apps/edr-freight-api/src/modules/rule-engine/interfaces/yards.repository.interface.ts
+++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/yards.repository.interface.ts
@@ -6,6 +6,7 @@ import { Yard } from '../entities/yard.entity';
export interface IYardsRepository {
findById(id: string): Promise;
findByCode(code: string): Promise;
+ findByLabelInsensitive(label: string): Promise;
findAll(options?: FindManyOptions): Promise;
findAndCount(options?: FindManyOptions): Promise<[Yard[], number]>;
findPaged(query: ListYardsQueryDto): Promise>;
diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/yards.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/yards.repository.ts
index 1cb74d9ce..5db5b72ae 100644
--- a/apps/edr-freight-api/src/modules/rule-engine/repositories/yards.repository.ts
+++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/yards.repository.ts
@@ -22,6 +22,15 @@ export class YardsRepository implements IYardsRepository {
return this.repo.findOne({ where: { code } });
}
+ /** Case/whitespace-insensitive label lookup — backs the duplicate-yard guard. */
+ findByLabelInsensitive(label: string): Promise {
+ return this.repo
+ .createQueryBuilder('yard')
+ .where('LOWER(TRIM(yard.label)) = LOWER(TRIM(:label))', { label })
+ .andWhere('yard.deleted_at IS NULL')
+ .getOne();
+ }
+
findAll(options?: FindManyOptions): Promise {
return this.repo.find(options);
}
diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yards.duplicate-label.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yards.duplicate-label.spec.ts
new file mode 100644
index 000000000..8affab199
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/rule-engine/services/yards.duplicate-label.spec.ts
@@ -0,0 +1,36 @@
+import { ConflictException } from '@nestjs/common';
+
+import { YardsService } from './yards.service';
+import type { Yard } from '../entities/yard.entity';
+
+const sebeta = { id: 'yard-1', code: 'LEGACY_DEST', label: 'Sebeta' } as Yard;
+
+const service = (): YardsService =>
+ new YardsService(
+ {
+ findById: async (id: string) => ({ ...sebeta, id }),
+ findByCode: async () => null,
+ findByLabelInsensitive: async (label: string) =>
+ label.trim().toLowerCase() === 'sebeta' ? sebeta : null,
+ create: async (d: Partial) => d as Yard,
+ update: async (_id: string, d: Partial) => d as Yard,
+ } as never,
+ { resolveCreateOrder: async () => 1 } as never,
+ );
+
+describe('duplicate yard labels are rejected', () => {
+ it('blocks create even when the generated code differs (Sebeta vs LEGACY_DEST)', async () => {
+ await expect(
+ service().create({ label: ' sebeta ', country: 'ET' } as never),
+ ).rejects.toThrow(ConflictException);
+ });
+
+ it('blocks renaming a yard onto another yard label, allows renaming itself', async () => {
+ await expect(
+ service().update('yard-2', { label: 'SEBETA' } as never),
+ ).rejects.toThrow(ConflictException);
+ await expect(
+ service().update('yard-1', { label: 'Sebeta' } as never),
+ ).resolves.toBeTruthy();
+ });
+});
diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts
index b698b456c..7dd29ded1 100644
--- a/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts
+++ b/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts
@@ -31,6 +31,9 @@ export class YardsService {
/** Create a yard. */
async create(dto: CreateYardDto): Promise {
+ // Label check first: the code check alone let "sebeta" in next to "Sebeta"
+ // when the existing yard's code didn't match its label (LEGACY_DEST).
+ await this.assertLabelAvailable(dto.label);
const code = generateCode(dto.label).slice(0, 40);
const existing = await this.repository.findByCode(code);
if (existing) throw new ConflictException(`Yard with label "${dto.label}" conflicts with existing code "${code}"`);
@@ -53,11 +56,20 @@ export class YardsService {
/** Update a yard. */
async update(id: string, dto: UpdateYardDto): Promise {
await this.findById(id);
+ if (dto.label !== undefined) await this.assertLabelAvailable(dto.label, id);
const updated = await this.repository.update(id, dto);
if (!updated) throw new NotFoundException(`Yard ${id} not found`);
return updated;
}
+ /** No two active yards may share a label (case/whitespace-insensitive). */
+ private async assertLabelAvailable(label: string, exceptId?: string): Promise {
+ const dupe = await this.repository.findByLabelInsensitive(label);
+ if (dupe && dupe.id !== exceptId) {
+ throw new ConflictException(`A yard named "${dupe.label}" already exists`);
+ }
+ }
+
/**
* Soft-delete a yard. The unique `code` (and the label) get a `@`
* suffix first — e.g. SEBETA → SEBETA@1755612345678 — so a new yard with the
diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts
index e84eb6124..fefbbc868 100644
--- a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts
+++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts
@@ -139,6 +139,17 @@ export class SchedulingRescheduleService {
actorUserId?: string,
) {
const plan = await this.previewReschedule(scheduleId, dto);
+ // Gov bookings may never be pushed off a train. Checked here (not only in
+ // unassignBooking) because the displacement loop below swallows unassign
+ // errors and force-detaches the booking anyway.
+ const govDisplaced = plan.displaced.filter((b) => b.isGovernment);
+ if (govDisplaced.length) {
+ throw new BadRequestException(
+ `Government bookings cannot be removed from a train: ${govDisplaced
+ .map((b) => b.reference)
+ .join(', ')}`,
+ );
+ }
const expectedDisplaced = new Set(plan.displaced.map((b) => b.id));
const providedDisplaced = new Set(dto.displacedBookingIds);
if (
diff --git a/apps/edr-freight-api/src/modules/signatures/dto/save-signature.dto.ts b/apps/edr-freight-api/src/modules/signatures/dto/save-signature.dto.ts
index a5ae63100..a1f245e23 100644
--- a/apps/edr-freight-api/src/modules/signatures/dto/save-signature.dto.ts
+++ b/apps/edr-freight-api/src/modules/signatures/dto/save-signature.dto.ts
@@ -1,5 +1,5 @@
-import { ApiProperty } from '@nestjs/swagger';
-import { IsString, MinLength } from 'class-validator';
+import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+import { IsOptional, IsString, MinLength } from 'class-validator';
export class SaveSignatureDto {
@ApiProperty()
@@ -13,6 +13,15 @@ export class SaveSignatureDto {
@IsString()
@MinLength(20)
signatureImageBase64!: string;
+
+ @ApiPropertyOptional({
+ description:
+ 'Company stamp/seal image as base64 (with or without data URL prefix). Omit to keep the existing saved stamp.',
+ })
+ @IsOptional()
+ @IsString()
+ @MinLength(20)
+ stampImageBase64?: string;
}
export class SavedSignatureDto {
@@ -21,4 +30,7 @@ export class SavedSignatureDto {
@ApiProperty({ nullable: true })
signatureImageUrl!: string | null;
+
+ @ApiProperty({ nullable: true })
+ stampImageUrl!: string | null;
}
diff --git a/apps/edr-freight-api/src/modules/signatures/entities/saved-signature.entity.ts b/apps/edr-freight-api/src/modules/signatures/entities/saved-signature.entity.ts
index 08263cf7d..3814eb017 100644
--- a/apps/edr-freight-api/src/modules/signatures/entities/saved-signature.entity.ts
+++ b/apps/edr-freight-api/src/modules/signatures/entities/saved-signature.entity.ts
@@ -22,4 +22,11 @@ export class SavedSignature extends BaseEntity {
@ManyToOne(() => FileRecord, { nullable: true })
@JoinColumn({ name: 'signature_file_id' })
signatureFile?: FileRecord | null;
+
+ @Column({ name: 'stamp_file_id', type: 'uuid', nullable: true })
+ stampFileId?: string | null;
+
+ @ManyToOne(() => FileRecord, { nullable: true })
+ @JoinColumn({ name: 'stamp_file_id' })
+ stampFile?: FileRecord | null;
}
diff --git a/apps/edr-freight-api/src/modules/signatures/signatures.controller.ts b/apps/edr-freight-api/src/modules/signatures/signatures.controller.ts
index d112edef3..24dd0aa5c 100644
--- a/apps/edr-freight-api/src/modules/signatures/signatures.controller.ts
+++ b/apps/edr-freight-api/src/modules/signatures/signatures.controller.ts
@@ -33,6 +33,7 @@ export class SignaturesController {
userId,
signerDisplayName: dto.signerDisplayName,
signatureImageBase64: dto.signatureImageBase64,
+ stampImageBase64: dto.stampImageBase64,
});
return this.signaturesService.getForUser(userId);
}
diff --git a/apps/edr-freight-api/src/modules/signatures/signatures.repository.ts b/apps/edr-freight-api/src/modules/signatures/signatures.repository.ts
index 70c04ad7b..de8007d65 100644
--- a/apps/edr-freight-api/src/modules/signatures/signatures.repository.ts
+++ b/apps/edr-freight-api/src/modules/signatures/signatures.repository.ts
@@ -16,7 +16,7 @@ export class SignaturesRepository extends BaseRepository {
findByUserId(userId: string): Promise {
return this.repository.findOne({
where: { userId } as never,
- relations: ['signatureFile'],
+ relations: ['signatureFile', 'stampFile'],
});
}
diff --git a/apps/edr-freight-api/src/modules/signatures/signatures.service.ts b/apps/edr-freight-api/src/modules/signatures/signatures.service.ts
index 7137ab6a5..836888dab 100644
--- a/apps/edr-freight-api/src/modules/signatures/signatures.service.ts
+++ b/apps/edr-freight-api/src/modules/signatures/signatures.service.ts
@@ -13,6 +13,8 @@ export interface UpsertSignatureInput {
userId: string;
signerDisplayName: string;
signatureImageBase64: string;
+ /** Optional company stamp/seal; omitted = keep the existing saved stamp. */
+ stampImageBase64?: string;
}
@Injectable()
@@ -31,15 +33,65 @@ export class SignaturesService {
return {
signerDisplayName: saved.signerDisplayName,
signatureImageUrl: await this.inlineImageUrl(saved.signatureFile?.url),
+ stampImageUrl: await this.inlineImageUrl(saved.stampFile?.url),
};
}
- /** Insert or update the user's reusable signature, storing the image in MinIO. */
+ /** Insert or update the user's reusable signature (and optional stamp), storing the images in MinIO. */
async upsertForUser(input: UpsertSignatureInput): Promise {
- const buffer = this.decodeSignatureImage(input.signatureImageBase64);
- const file: Express.Multer.File = {
- fieldname: 'signature',
- originalname: `signature-${input.userId}.png`,
+ // Capture the previously referenced files so we can remove them only AFTER
+ // the saved_signatures row is repointed — deleting first would violate the
+ // FK constraint (saved_signatures.*_file_id -> files.id).
+ const existing = await this.signaturesRepository.findByUserId(input.userId);
+ const previousFileId = existing?.signatureFileId ?? null;
+ const previousStampFileId = existing?.stampFileId ?? null;
+
+ const fileRecord = await this.filesService.upload({
+ resourceId: input.userId,
+ resource: 'saved_signatures',
+ code: 'signature',
+ file: this.toUploadFile('signature', input.userId, input.signatureImageBase64),
+ });
+
+ const stampRecord = input.stampImageBase64
+ ? await this.filesService.upload({
+ resourceId: input.userId,
+ resource: 'saved_signatures',
+ code: 'stamp',
+ file: this.toUploadFile('stamp', input.userId, input.stampImageBase64),
+ })
+ : null;
+
+ const saved = await this.signaturesRepository.upsert({
+ userId: input.userId,
+ signerDisplayName: input.signerDisplayName,
+ signatureFileId: fileRecord.id,
+ // Omitted stamp keeps whatever was saved before.
+ ...(stampRecord ? { stampFileId: stampRecord.id } : {}),
+ });
+
+ const staleIds = [
+ previousFileId !== fileRecord.id ? previousFileId : null,
+ stampRecord && previousStampFileId !== stampRecord.id
+ ? previousStampFileId
+ : null,
+ ].filter((id): id is string => Boolean(id));
+ if (staleIds.length) {
+ await this.dataSource.getRepository(FileRecord).delete(staleIds);
+ }
+
+ return saved;
+ }
+
+ private toUploadFile(
+ kind: 'signature' | 'stamp',
+ userId: string,
+ base64: string,
+ ): Express.Multer.File {
+ const buffer = this.decodeSignatureImage(base64);
+ return {
+ fieldname: kind,
+ originalname: `${kind}-${userId}.png`,
encoding: '7bit',
mimetype: 'image/png',
size: buffer.length,
@@ -49,33 +101,6 @@ export class SignaturesService {
filename: '',
path: '',
};
-
- // Capture the previously referenced file so we can remove it only AFTER the
- // saved_signatures row is repointed — deleting it first would violate the
- // FK constraint (saved_signatures.signature_file_id -> files.id).
- const existing = await this.signaturesRepository.findByUserId(input.userId);
- const previousFileId = existing?.signatureFileId ?? null;
-
- const fileRecord = await this.filesService.upload({
- resourceId: input.userId,
- resource: 'saved_signatures',
- code: 'signature',
- file,
- });
-
- const saved = await this.signaturesRepository.upsert({
- userId: input.userId,
- signerDisplayName: input.signerDisplayName,
- signatureFileId: fileRecord.id,
- });
-
- if (previousFileId && previousFileId !== fileRecord.id) {
- await this.dataSource
- .getRepository(FileRecord)
- .delete({ id: previousFileId });
- }
-
- return saved;
}
private async inlineImageUrl(
diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/schedule-wagon-adjustment-log.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/schedule-wagon-adjustment-log.entity.ts
index 485d29452..56459870c 100644
--- a/apps/edr-freight-api/src/modules/train-schedules/entities/schedule-wagon-adjustment-log.entity.ts
+++ b/apps/edr-freight-api/src/modules/train-schedules/entities/schedule-wagon-adjustment-log.entity.ts
@@ -1,15 +1,17 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm';
-export const WAGON_ADJUSTMENT_ACTIONS = ['ADD', 'REMOVE'] as const;
+export const WAGON_ADJUSTMENT_ACTIONS = ['ADD', 'REMOVE', 'SWITCH'] as const;
export type WagonAdjustmentAction = (typeof WAGON_ADJUSTMENT_ACTIONS)[number];
/**
* History row for a consist adjustment made from a schedule: staff coupled a
- * wagon onto (ADD) or detached one from (REMOVE) the schedule's built train —
- * e.g. trimming free wagons whose tare pushed gross weight over the
- * locomotives' pull limit. Plain columns (no FK relations) so the history
- * survives the wagon or train being deleted later.
+ * wagon onto (ADD), detached one from (REMOVE), or swapped the physical wagon
+ * under a loaded slot (SWITCH — wagonNumber reads "OLD → NEW") on the
+ * schedule's built train. `yardId` records WHERE it happened: the origin yard
+ * before departure, or the mid-route stop the train was standing at. Plain
+ * columns (no FK relations) so the history survives the wagon or train being
+ * deleted later.
*/
@Entity({ schema: 'freight', name: 'schedule_wagon_adjustment_logs' })
@Index(['trainScheduleId'])
@@ -33,6 +35,9 @@ export class ScheduleWagonAdjustmentLog extends BaseEntity {
@Column({ name: 'adjusted_by_user_id', type: 'uuid', nullable: true })
adjustedByUserId!: string | null;
+ @Column({ name: 'yard_id', type: 'uuid', nullable: true })
+ yardId!: string | null;
+
@Column({ name: 'occurred_at', type: 'timestamptz', default: () => 'now()' })
occurredAt!: Date;
}
diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts
index 2b9968f04..0581f6000 100644
--- a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts
+++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts
@@ -153,6 +153,14 @@ export class TrainSchedule extends BaseEntity {
@Column({ name: 'rule_reopen_delay_minutes', type: 'int', nullable: true })
ruleReopenDelayMinutes?: number | null;
+ /**
+ * Per-schedule pay-window override (minutes). NULL = use the live global
+ * value for the schedule's direction. Unlike the other rule_* snapshots this
+ * is only written by an explicit staff override, never stamped at creation.
+ */
+ @Column({ name: 'rule_payment_window_minutes', type: 'int', nullable: true })
+ rulePaymentWindowMinutes?: number | null;
+
@Column({ name: 'rule_import_window_lead_days', type: 'int', nullable: true })
ruleImportWindowLeadDays?: number | null;
diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts
index 294fb93f1..b0cbeb886 100644
--- a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts
+++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts
@@ -39,7 +39,11 @@ export class TrainSchedulesRepository extends BaseRepository {
physicalWagon: true,
allocations: {
booking: { company: true, bookingContainers: { containerType: true } },
- containerItems: true,
+ // Both size sources loaded: the item's own container_type_id FK
+ // (always set for a manually-entered item) and the booking-line
+ // fallback via bookingContainer.containerType — the marshalling
+ // document's 40ft/20ft tally reads whichever is present.
+ containerItems: { containerType: true, bookingContainer: { containerType: true } },
},
},
},
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts
index 62ed50c0a..d43cff60b 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts
@@ -9,6 +9,9 @@
export const BATCH_TIMEZONE = 'Africa/Addis_Ababa';
+/** How long before the pay deadline the one reminder notification goes out. */
+export const PAYMENT_REMINDER_LEAD_MS = 10 * 60_000;
+
/** Fallback wagons-per-booking when a booking has no computed `wagonsRequired`. */
export const DEFAULT_WAGONS_PER_BOOKING = 1;
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts
index a402e6237..1a1a75275 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts
@@ -109,6 +109,7 @@ describe('BookingBatchService — PAID reconcile', () => {
windowDurationHours: 3,
docReviewMinutes: 30,
paymentWindowMinutes: 60,
+ exportPaymentWindowMinutes: 60,
}),
};
@@ -150,6 +151,8 @@ describe('BookingBatchService — PAID reconcile', () => {
{
issuePayable: jest.fn().mockResolvedValue(null),
expirePayable: jest.fn().mockResolvedValue(undefined),
+ // Gateway reconcile-before-expire: default = verifiably unpaid.
+ reconcilePayable: jest.fn().mockResolvedValue({ paid: false, unverifiable: false }),
} as never,
{ emitPhase: jest.fn() } as never,
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never,
@@ -708,7 +711,13 @@ describe('BookingBatchService — PAID reconcile', () => {
notifier as never,
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
trainSchedulingService as never,
- { issuePayable: jest.fn(), expirePayable: jest.fn() } as never,
+ {
+ issuePayable: jest.fn(),
+ expirePayable: jest.fn(),
+ reconcilePayable: jest
+ .fn()
+ .mockResolvedValue({ paid: false, unverifiable: false }),
+ } as never,
{ emitPhase: jest.fn() } as never,
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never,
undefined,
@@ -731,7 +740,13 @@ describe('BookingBatchService — PAID reconcile', () => {
notifier as never,
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
trainSchedulingService as never,
- { issuePayable: jest.fn(), expirePayable: jest.fn() } as never,
+ {
+ issuePayable: jest.fn(),
+ expirePayable: jest.fn(),
+ reconcilePayable: jest
+ .fn()
+ .mockResolvedValue({ paid: false, unverifiable: false }),
+ } as never,
{ emitPhase: jest.fn() } as never,
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never,
undefined,
@@ -762,7 +777,13 @@ describe('BookingBatchService — PAID reconcile', () => {
notifier as never,
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
trainSchedulingService as never,
- { issuePayable: jest.fn(), expirePayable: jest.fn() } as never,
+ {
+ issuePayable: jest.fn(),
+ expirePayable: jest.fn(),
+ reconcilePayable: jest
+ .fn()
+ .mockResolvedValue({ paid: false, unverifiable: false }),
+ } as never,
{ emitPhase: jest.fn() } as never,
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never,
undefined,
@@ -1015,7 +1036,7 @@ describe('BookingBatchService — PAID reconcile', () => {
...(waiting as unknown as Record),
status: 'SELECTED_FOR_BATCH',
trainScheduleId: exportScheduleId,
- paymentDeadline: new Date(Date.now() - 1_000),
+ paymentDeadline: new Date(Date.now() - 60_000),
originYardId: 'yard-a',
destinationYardId: 'yard-b',
priorityScore: 0,
@@ -1307,10 +1328,9 @@ describe('BookingBatchService — built-train wagon capacity', () => {
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false);
});
- it('is FULL for the trade direction once the border edge is sold out, even with home legs free', async () => {
- // Export b→c holds every wagon of the border crossing: no further export
- // can board anywhere (they all must ride that edge), so the window closes —
- // while intercity keeps booking the free a→b leg through the per-leg budget.
+ it('is NOT full when the border edge is sold out but a home leg still has room', async () => {
+ // FULL is corridor-wide now: b→dj holds every wagon, but a→b is empty, so
+ // sub-corridor bookings can still sell that leg — the window stays open.
const { service } = buildService({
physicalWagons: 2,
routeStops: ['yard-a', 'yard-b', 'yard-dj'],
@@ -1324,6 +1344,23 @@ describe('BookingBatchService — built-train wagon capacity', () => {
reservedBooking('b2', { origin: 'yard-b', dest: 'yard-dj' }),
],
});
+ await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false);
+ });
+
+ it('is FULL once every leg of the corridor is sold out', async () => {
+ const { service } = buildService({
+ physicalWagons: 2,
+ routeStops: ['yard-a', 'yard-b', 'yard-dj'],
+ yardCountries: {
+ 'yard-a': 'ETHIOPIA',
+ 'yard-b': 'ETHIOPIA',
+ 'yard-dj': 'DJIBOUTI',
+ },
+ reserved: [
+ reservedBooking('b1', { origin: 'yard-a', dest: 'yard-dj' }),
+ reservedBooking('b2', { origin: 'yard-a', dest: 'yard-dj' }),
+ ],
+ });
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(true);
});
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts
index 41c8023f0..5ce75abd2 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts
@@ -27,6 +27,8 @@ import { BookingPricingService } from '../bookings/booking-pricing.service';
import { formatRouteLabel } from '../routes/entities/route.entity';
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
import { Yard } from '../rule-engine/entities/yard.entity';
+import { CargoType } from '../rule-engine/entities/cargo-type.entity';
+import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
@@ -61,11 +63,13 @@ import {
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
DEFAULT_CONTAINER_WAGON_TARE_TONS,
DEFAULT_WAGONS_PER_BOOKING,
+ PAYMENT_REMINDER_LEAD_MS,
} from "./booking-batch.constants";
import {
LocomotiveLimits,
WagonTypeDimensions,
bookingCargoTons,
+ bulkItemWagonsRequired,
bookingGrossWeightTons,
deriveTrainCapacityFromLocomotive,
sizePartialOfferWagons,
@@ -115,6 +119,36 @@ export interface ExportSpaceReport {
fullMessage: string | null;
}
+/**
+ * One export train the customer can pick for a shipment day: live free-wagon
+ * space measured against THE BOOKING'S allowed wagon types (so the per-type
+ * list doubles as "what cargo this train can take for you"). Unpaid holds
+ * count as taken; lapsed holds free up via the lazy-expiry capacity filter.
+ */
+export interface ExportTrainOption {
+ scheduleId: string;
+ /** Schedule's train number (falls back to the built train's number). */
+ trainNumber: string | null;
+ /** Built train's name/code, when the schedule runs a Train Builder train. */
+ trainName: string | null;
+ departure: Date;
+ /** Booking cutoff for this train (windowClosesAt), null on legacy rows. */
+ bookingClosesAt: Date | null;
+ /** Whether the export FCFS window is open for booking right now. */
+ isOpen: boolean;
+ /** Best bookable wagons across the booking's allowed types. */
+ freeWagons: number;
+ /** Wagons this booking needs — `fits` = freeWagons >= neededWagons. */
+ neededWagons: number;
+ fits: boolean;
+ byWagonType: Array<{
+ wagonTypeId: string | null;
+ code: string | null;
+ name: string | null;
+ freeWagons: number;
+ }>;
+}
+
/** A day-level pool key: all trains on this route departing on this EAT day. */
interface RouteDayGroup {
originYardId: string;
@@ -267,11 +301,20 @@ export interface BatchBoardSchedule {
/** Train length used by allocated bookings (from wagon-type dimensions). */
allocatedLengthMeters: number;
maxLengthMeters: number | null;
- /** Weight committed on the train (allocated + selected-for-batch). */
+ /**
+ * Weight committed on the train (allocated + selected-for-batch). On a
+ * multi-stop corridor this is the HEAVIEST single edge, not the sum —
+ * disjoint legs (intercity + export) never ride together, so summing
+ * them over-reports the train against the pull limit.
+ */
usedWeightTons: number;
maxWeightTons: number | null;
/** Wagon-slot cap for the train (locomotive/wagon-type derived). */
maxWagons: number | null;
+ /** Physical consist length of the built train (Train Builder), null without one. */
+ trainLengthMeters: number | null;
+ /** Committed gross weight per corridor edge, in stop order; null on 2-stop routes. */
+ legUsage: Array<{ from: string; to: string; usedWeightTons: number }> | null;
};
counts: {
allocated: number;
@@ -431,6 +474,9 @@ export class BookingBatchService implements OnModuleInit {
group.destinationYardId,
group.day,
);
+ // Backstop: PAID bookings stranded without a schedule (hold expired before
+ // the payment landed) get re-placed onto whatever fits today.
+ await this.rescueStrandedPaidForDay(group.day);
for (const scheduleId of scheduleIds) {
await this.settleDueReservations(scheduleId);
await this.reconcilePaidUnlinked(scheduleId);
@@ -491,17 +537,26 @@ export class BookingBatchService implements OnModuleInit {
});
if (!booking) return;
if (!booking.trainScheduleId) {
- // A paid booking with no train is money taken and nothing boarding —
- // scream so staff pin it to a schedule manually (batch board / assign).
+ // A paid booking with no train is money taken and nothing boarding. The
+ // hold was expired before the payment landed (webhook lag beat the
+ // reconcile, or the stranding predates it) — try to re-place it on a
+ // fitting same-day train before falling back to a manual-assign scream.
if (booking.paymentStatus === "PAID" || booking.status === "PAID") {
- this.logger.error(
- `PAID booking ${booking.reference ?? bookingId} has no train_schedule_id — ` +
- `its reservation was likely expired before the payment landed. ` +
- `Assign it to a schedule manually from the batch board.`,
- );
+ const rescuedScheduleId = await this.replaceStrandedPaidBooking(booking);
+ if (!rescuedScheduleId) {
+ this.logger.error(
+ `PAID booking ${booking.reference ?? bookingId} has no train_schedule_id — ` +
+ `its reservation was likely expired before the payment landed and no ` +
+ `same-day train fits it. Assign it to a schedule manually from the batch board.`,
+ );
+ return;
+ }
+ booking.trainScheduleId = rescuedScheduleId;
+ } else {
+ return;
}
- return;
}
+ if (!booking.trainScheduleId) return; // unreachable — narrows the rescue path for TS
const isBatchPaid =
booking.status === "SELECTED_FOR_BATCH" ||
@@ -559,6 +614,26 @@ export class BookingBatchService implements OnModuleInit {
const linked =
await this.trainScheduleBookingsRepository.existsForBooking(bookingId);
+ // Intercity is allocated MANUALLY: payment secures the ride, staff then
+ // place it on whichever same-route train suits (intercity panel). Unpin
+ // from the train it reserved against — that train may be the wrong one by
+ // the time it departs — and return it to the waiting pool as PAID.
+ if (!linked && booking.tradeDirection === "DOMESTIC" && !booking.isGovernment) {
+ await this.dataSource.getRepository(Booking).update(bookingId, {
+ trainScheduleId: null,
+ schedulingStatus: "ELIGIBLE",
+ paymentDeadline: null,
+ } as never);
+ this.logger.log(
+ `[BATCH] intercity ${booking.reference ?? bookingId} PAID — awaiting manual placement by staff`,
+ );
+ void this.completeTrackingMilestones(bookingId, [
+ "FREIGHT_PAYMENT_PENDING",
+ "FREIGHT_PAYMENT_SETTLED",
+ ]);
+ this.notifyBoardChanged(booking.trainScheduleId, "intercity_paid_unplaced");
+ return;
+ }
if (!linked) {
if (await this.holdIfWagonShort(booking.trainScheduleId, booking)) return;
await this.allocate(booking.trainScheduleId, booking, "paid");
@@ -616,6 +691,69 @@ export class BookingBatchService implements OnModuleInit {
await this.ensurePaidBookingAllocated(bookingId);
}
+ /**
+ * Day-level backstop for stranded PAID bookings: reconcilePaidUnlinked is
+ * keyed on train_schedule_id, so a booking whose hold was expired (schedule
+ * cleared) before its payment landed never re-enters it. Sweep the day's
+ * PAID-but-unscheduled bookings through ensurePaidBookingAllocated, which
+ * re-places them on a fitting train.
+ */
+ private async rescueStrandedPaidForDay(day: string): Promise {
+ const stranded: Array<{ id: string }> = await this.dataSource.query(
+ `SELECT id FROM freight.bookings
+ WHERE deleted_at IS NULL
+ AND train_schedule_id IS NULL
+ AND (payment_status = 'PAID' OR status = 'PAID')
+ AND scheduled_date IS NOT NULL
+ AND DATE(scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = $1`,
+ [day],
+ );
+ for (const { id } of stranded) {
+ await this.ensurePaidBookingAllocated(id).catch((err) =>
+ this.logger.error(
+ `Stranded-PAID rescue failed for booking ${id}: ${(err as Error).message}`,
+ ),
+ );
+ }
+ }
+
+ /**
+ * Re-place a PAID booking whose hold was expired before the payment landed
+ * (trainScheduleId already cleared). Picks the earliest same-day train that
+ * still fits the booking's whole need on ITS OWN leg and pins the booking to
+ * it. Returns the schedule id, or null when no train fits (manual assign).
+ */
+ private async replaceStrandedPaidBooking(
+ booking: Booking,
+ ): Promise {
+ if (!booking.scheduledDate) return null;
+ // The booking loaded by ensurePaidBookingAllocated carries no cargo
+ // relations; needFor/fittingTrainsForDay derive the wagon need from them.
+ const full = await this.dataSource.getRepository(Booking).findOne({
+ where: { id: booking.id },
+ relations: {
+ bookingContainers: { containerType: true },
+ cargoType: true,
+ },
+ });
+ if (!full) return null;
+ const day = eatDay(new Date(booking.scheduledDate));
+ const direction = booking.tradeDirection === "EXPORT" ? "EXPORT" : "IMPORT";
+ const wagonDims = await this.loadWagonDims();
+ const need = this.needFor(full, wagonDims);
+ const fitting = await this.fittingTrainsForDay(full, day, direction);
+ const target = fitting.find((t) => t.freeWagons >= need.wagons);
+ if (!target) return null;
+ await this.dataSource
+ .getRepository(Booking)
+ .update(booking.id, { trainScheduleId: target.scheduleId });
+ this.logger.warn(
+ `[BATCH] re-placed stranded PAID booking ${booking.reference ?? booking.id} ` +
+ `onto schedule ${target.scheduleId} — its hold expired before the payment landed`,
+ );
+ return target.scheduleId;
+ }
+
/** Open partial-capacity offer summary for booking detail payloads (null when none). */
async getOpenOfferSummary(bookingId: string): Promise<{
offeredWagons: number;
@@ -663,12 +801,16 @@ export class BookingBatchService implements OnModuleInit {
{ status: TrainScheduleStatusEnum.Scheduled },
],
});
+ // A customer-picked train narrows the scan to that ONE schedule: export
+ // FCFS honors the pick or fails loudly (exportFullMessage names it).
+ const requestedId = booking.requestedTrainScheduleId ?? null;
const candidates = corridor
.filter(
(s) =>
s.scheduledDepartureDate != null &&
eatDay(s.scheduledDepartureDate) === day &&
- this.isFillable(s),
+ this.isFillable(s) &&
+ (!requestedId || s.id === requestedId),
)
.sort(
(a, b) =>
@@ -755,13 +897,18 @@ export class BookingBatchService implements OnModuleInit {
/** Customer-facing "train is full" copy carrying the bookable leftover. */
private exportFullMessage(booking: Booking, report: ExportSpaceReport): string {
+ const picked = Boolean(booking.requestedTrainScheduleId);
if (!report.trainsForDay || !report.corridorMatched) {
- return 'No export train is accepting bookings for this day';
+ return picked
+ ? 'The selected train is no longer accepting bookings — pick another train or day.'
+ : 'No export train is accepting bookings for this day';
}
const best = report.bestAvailable;
- const base =
- 'Not enough train space — an export booking must ride a single train whole, ' +
- 'and no open train on this day can carry it. ';
+ const base = picked
+ ? 'Not enough space left on the selected train — an export booking must ' +
+ 'ride one train whole. '
+ : 'Not enough train space — an export booking must ride a single train whole, ' +
+ 'and no open train on this day can carry it. ';
if (!best || best.wagons <= 0) {
return base + 'No capacity is left on this day — pick another shipment day.';
}
@@ -864,6 +1011,162 @@ export class BookingBatchService implements OnModuleInit {
return out;
}
+ /**
+ * The export train picker: every export train on the booking's corridor/day
+ * with its live space, measured per allowed wagon type so the customer sees
+ * what each train can still take for THEIR cargo. Includes full/not-yet-open
+ * trains (freeWagons 0 / isOpen false) so the UI can show them disabled —
+ * the request-time gate (exportSpaceReport) stays the enforcement point.
+ */
+ async exportTrainOptionsForDay(
+ booking: Booking,
+ day: string,
+ overrides?: {
+ /** Cargo the customer is entering on a form (bare contract instance —
+ * nothing persisted yet): container types drive the per-type space. */
+ containerTypeIds?: string[];
+ /** Size labels ("20ft"/"40ft") when the form has no type ids. */
+ containerSizes?: string[];
+ /** Bulk counterparts of the container inputs. */
+ cargoTypeId?: string;
+ cargoTypeCode?: string;
+ /** Needed wagons estimate from the form (drives the `fits` flag). */
+ wagons?: number;
+ },
+ ): Promise {
+ const sizeFts = (overrides?.containerSizes ?? [])
+ .map((s) => parseInt(s, 10))
+ .filter((n) => Number.isFinite(n) && n > 0);
+ if (overrides?.containerTypeIds?.length || sizeFts.length) {
+ const types = await this.dataSource.getRepository(ContainerType).find({
+ where: overrides?.containerTypeIds?.length
+ ? { id: In(overrides.containerTypeIds) }
+ : { sizeFt: In(sizeFts) },
+ relations: { wagonTypes: true },
+ });
+ booking = {
+ ...booking,
+ freightType: "CONTAINER",
+ bookingContainers: types.map((ct) => ({ containerType: ct })),
+ } as Booking;
+ } else if (overrides?.cargoTypeId || overrides?.cargoTypeCode) {
+ const cargoType = await this.dataSource.getRepository(CargoType).findOne({
+ where: overrides.cargoTypeId
+ ? { id: overrides.cargoTypeId }
+ : { code: overrides.cargoTypeCode },
+ relations: { wagonTypes: true },
+ });
+ booking = {
+ ...booking,
+ freightType: "BULK",
+ cargoType: cargoType ?? undefined,
+ } as Booking;
+ }
+ if (overrides?.wagons && overrides.wagons > 0) {
+ booking = { ...booking, wagonsRequired: overrides.wagons } as Booking;
+ }
+ const corridor = await this.trainSchedulesRepository.findAll({
+ where: [
+ { status: TrainScheduleStatusEnum.Draft },
+ { status: TrainScheduleStatusEnum.Scheduled },
+ ],
+ });
+ const candidates = corridor
+ .filter(
+ (s) =>
+ s.scheduledDepartureDate != null &&
+ eatDay(s.scheduledDepartureDate) === day &&
+ s.direction === 'EXPORT',
+ )
+ .sort(
+ (a, b) =>
+ a.scheduledDepartureDate!.getTime() -
+ b.scheduledDepartureDate!.getTime(),
+ );
+
+ const wagonDims = await this.loadWagonDims();
+ const allowed = this.allowedDimsWithTypes(booking, wagonDims);
+ const neededWagons = this.wagonsFor(booking, wagonDims);
+ const typeIds = allowed
+ .map((a) => a.wagonTypeId)
+ .filter((id): id is string => Boolean(id));
+ const types = typeIds.length
+ ? await this.dataSource
+ .getRepository(WagonType)
+ .find({ where: { id: In(typeIds) } })
+ : [];
+ const typeById = new Map(types.map((t) => [t.id, t]));
+
+ const out: ExportTrainOption[] = [];
+ for (const candidate of candidates) {
+ const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
+ candidate.id,
+ );
+ const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
+ if (!schedule || !locomotive) continue;
+ const limits = await this.capacityLimits(locomotive);
+ const budget = await this.remainingBudget(schedule, limits, wagonDims);
+ const leg = budget.legOf(booking.originYardId, booking.destinationYardId);
+ if (!leg) continue; // this train's route doesn't carry the booking's leg
+ const room = budget.remainingFor(leg);
+ // The abstract budget can't tell wagon types apart — cap each type's free
+ // count with the PHYSICAL wagons of that type the train (or yard pool)
+ // actually holds on this leg, and on a built train hide types the consist
+ // doesn't carry at all. Otherwise a 47×NW5 train advertised "PW2: 47 free".
+ const stock = await this.trainSchedulingService.wagonStockForSchedule(
+ schedule.id,
+ schedule.originStationId,
+ budget.stops,
+ );
+ const ledger = new WagonStockLedger(
+ stock.remainingByTypeId,
+ Math.max(1, budget.stops.length - 1),
+ );
+ const byWagonType = allowed
+ .filter(
+ ({ wagonTypeId }) =>
+ stock.mode !== 'TRAIN' ||
+ !wagonTypeId ||
+ (stock.remainingByTypeId.get(wagonTypeId) ?? 0) > 0,
+ )
+ .map(({ wagonTypeId, dims }) => {
+ const type = wagonTypeId ? typeById.get(wagonTypeId) : undefined;
+ const roomWagons = this.bookableWithin(room, dims).wagons;
+ const physical = wagonTypeId
+ ? ledger.availableFor([wagonTypeId], leg)
+ : roomWagons;
+ return {
+ wagonTypeId,
+ code: type?.code ?? null,
+ name: type?.name ?? null,
+ freeWagons: Math.min(roomWagons, physical),
+ };
+ });
+ const freeWagons = byWagonType.reduce(
+ (best, t) => Math.max(best, t.freeWagons),
+ 0,
+ );
+ const builtTrain = schedule.trainSet?.train;
+ out.push({
+ scheduleId: schedule.id,
+ trainNumber:
+ schedule.trainNumber ??
+ builtTrain?.exportTrainNumber ??
+ builtTrain?.trainNumber ??
+ null,
+ trainName: builtTrain?.trainName ?? builtTrain?.code ?? null,
+ departure: schedule.scheduledDepartureDate!,
+ bookingClosesAt: schedule.windowClosesAt ?? null,
+ isOpen: this.isFillable(schedule),
+ freeWagons,
+ neededWagons,
+ fits: freeWagons >= neededWagons,
+ byWagonType,
+ });
+ }
+ return out;
+ }
+
/**
* Advisory free-wagon count for an IMPORT/DOMESTIC booking on a given day,
* summed across every train on the booking's corridor that day. Unlike the
@@ -1192,7 +1495,9 @@ export class BookingBatchService implements OnModuleInit {
const [schedules, total] = await this.trainSchedulesRepository.findAndCount({
where,
relations: {
- trainSet: { locomotive: true, train: true },
+ // locomotives (plural) too — the caps SUM the whole set's pull; the
+ // single legacy column alone under-reports a two-loco train by half.
+ trainSet: { locomotive: true, locomotives: { locomotive: true }, train: true },
originStation: true,
destinationStation: true,
// Yards supply the route's display name for `routeName` below;
@@ -1255,7 +1560,21 @@ export class BookingBatchService implements OnModuleInit {
};
});
- board.push(this.buildScheduleSummary(s, items));
+ board.push(
+ this.buildScheduleSummary(
+ s,
+ items,
+ new Map(
+ bookings.map((b) => [
+ b.id,
+ {
+ originYardId: b.originYardId ?? null,
+ destinationYardId: b.destinationYardId ?? null,
+ },
+ ]),
+ ),
+ ),
+ );
}
return { items: board, meta: buildPaginationMeta(total, page, pageSize) };
@@ -1286,6 +1605,40 @@ export class BookingBatchService implements OnModuleInit {
(s.scheduleBookings ?? []).map((l) => l.bookingId),
);
const bookings = await this.bookingsRepository.findAllBySchedule(s.id);
+ // Under day-level pooling a booking is only pinned to a schedule by
+ // reserve() — until then its train_schedule_id is NULL and the query above
+ // misses it. Merge in the corridor-day candidates so staff see the whole
+ // waiting pool (the 7 that lost the batch), not just the winners. These are
+ // display-only candidates: they are excluded from the capacity meters below.
+ const pinnedIds = new Set(bookings.map((b) => b.id));
+ // Corridor stops drive both the day-pool candidate merge and the per-leg
+ // capacity meters below; a failed lookup degrades to whole-route math.
+ let stops: string[] = [];
+ try {
+ stops = await this.stopsForSchedule(s);
+ } catch (err) {
+ this.logger.warn(
+ `Stop lookup failed for schedule ${s.id}: ${(err as Error).message}`,
+ );
+ }
+ if (s.scheduledDepartureDate && stops.length) {
+ try {
+ const candidates =
+ await this.bookingsRepository.findBatchPoolByCorridorDay(
+ stops,
+ eatDay(s.scheduledDepartureDate),
+ );
+ for (const b of candidates) {
+ if (!pinnedIds.has(b.id)) bookings.push(b);
+ }
+ } catch (err) {
+ // The board must still render the pinned bookings.
+ this.logger.warn(
+ `Corridor-day candidate merge failed for schedule ${s.id}: ` +
+ `${(err as Error).message}`,
+ );
+ }
+ }
let allocationPreview: Awaited<
ReturnType
@@ -1398,6 +1751,18 @@ export class BookingBatchService implements OnModuleInit {
const windowBookings = items.filter((i) => i.fullyExecutedAt);
const pendingBookings = items.filter((i) => !i.fullyExecutedAt);
+ const stopLabels =
+ stops.length > 2 ? await this.yardLabels(stops) : new Map();
+ const yardsByBookingId = new Map(
+ bookings.map((b) => [
+ b.id,
+ {
+ originYardId: b.originYardId ?? null,
+ destinationYardId: b.destinationYardId ?? null,
+ },
+ ]),
+ );
+
return {
scheduleId: s.id,
scheduleReference: s.reference ?? null,
@@ -1437,7 +1802,19 @@ export class BookingBatchService implements OnModuleInit {
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
}
: null,
- capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null),
+ // Capacity holds come from bookings actually pinned to this train —
+ // unpinned day-pool candidates are shown in the lists but hold nothing.
+ capacity: this.computeBoardCapacity(
+ items.filter((i) => pinnedIds.has(i.id)),
+ loco,
+ s.maxWagons ?? null,
+ {
+ stops,
+ labelByYardId: stopLabels,
+ yardsByBookingId,
+ trainLengthMeters: this.builtTrainLengthOf(s),
+ },
+ ),
counts: {
allocated: items.filter((i) => i.state === "ALLOCATED").length,
selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH")
@@ -1477,6 +1854,7 @@ export class BookingBatchService implements OnModuleInit {
*/
private computeBoardCapacity(
items: Array<{
+ id: string;
state: BatchBoardBookingState;
wagons: number;
weightTons: number;
@@ -1484,6 +1862,16 @@ export class BookingBatchService implements OnModuleInit {
}>,
loco: LocomotiveLimits | null,
maxWagons: number | null,
+ legCtx?: {
+ /** Ordered corridor stop yard ids; per-leg math needs 3+ stops. */
+ stops: string[];
+ labelByYardId: Map;
+ yardsByBookingId: Map<
+ string,
+ { originYardId: string | null; destinationYardId: string | null }
+ >;
+ trainLengthMeters: number | null;
+ },
): BatchBoardSchedule["capacity"] {
const allocated = items.filter((i) => i.state === "ALLOCATED");
// Every booking still targeting this train holds gross weight — including
@@ -1501,23 +1889,128 @@ export class BookingBatchService implements OnModuleInit {
: null;
const round2 = (value: number) => Math.round(value * 100) / 100;
+ // Per-leg committed usage: a booking holds capacity only on the edges it
+ // rides, so every meter compares the HEAVIEST single edge against its cap
+ // — weight, wagons and length alike. Whole-route bookings (or yards
+ // missing from the stop list) load every edge — never under-reported.
+ const stops = legCtx?.stops ?? [];
+ let usedWeightTons = round2(
+ committed.reduce((sum, i) => sum + i.weightTons, 0),
+ );
+ let allocatedWagons = allocated.reduce((sum, i) => sum + i.wagons, 0);
+ let allocatedLengthMeters = round2(
+ allocated.reduce((sum, i) => sum + i.lengthMeters, 0),
+ );
+ let legUsage: BatchBoardSchedule["capacity"]["legUsage"] = null;
+ if (legCtx && stops.length > 2) {
+ const stopIndex = new Map(stops.map((yardId, i) => [yardId, i]));
+ const edgeCount = stops.length - 1;
+ const legOf = (bookingId: string): { from: number; to: number } => {
+ const yards = legCtx.yardsByBookingId.get(bookingId);
+ const from = yards?.originYardId
+ ? stopIndex.get(yards.originYardId)
+ : undefined;
+ const to = yards?.destinationYardId
+ ? stopIndex.get(yards.destinationYardId)
+ : undefined;
+ return from != null && to != null && from < to
+ ? { from, to }
+ : { from: 0, to: edgeCount };
+ };
+ const weightEdges = new Array(edgeCount).fill(0);
+ for (const item of committed) {
+ const leg = legOf(item.id);
+ for (let e = leg.from; e < leg.to; e += 1) weightEdges[e] += item.weightTons;
+ }
+ const wagonEdges = new Array(edgeCount).fill(0);
+ const lengthEdges = new Array(edgeCount).fill(0);
+ for (const item of allocated) {
+ const leg = legOf(item.id);
+ for (let e = leg.from; e < leg.to; e += 1) {
+ wagonEdges[e] += item.wagons;
+ lengthEdges[e] += item.lengthMeters;
+ }
+ }
+ const label = (yardId: string) =>
+ legCtx.labelByYardId.get(yardId) ?? yardId;
+ legUsage = weightEdges.map((weight, i) => ({
+ from: label(stops[i]),
+ to: label(stops[i + 1]),
+ usedWeightTons: round2(weight),
+ }));
+ usedWeightTons = round2(Math.max(0, ...weightEdges));
+ allocatedWagons = Math.max(0, ...wagonEdges);
+ allocatedLengthMeters = round2(Math.max(0, ...lengthEdges));
+ }
+
return {
- allocatedWagons: allocated.reduce((sum, i) => sum + i.wagons, 0),
- allocatedLengthMeters: round2(
- allocated.reduce((sum, i) => sum + i.lengthMeters, 0),
- ),
+ allocatedWagons,
+ allocatedLengthMeters,
maxLengthMeters: caps ? caps.maxLengthMeters : null,
- usedWeightTons: round2(committed.reduce((sum, i) => sum + i.weightTons, 0)),
+ usedWeightTons,
maxWeightTons: caps ? caps.maxWeightTons : null,
maxWagons: maxWagons ?? null,
+ trainLengthMeters: legCtx?.trainLengthMeters ?? null,
+ legUsage,
};
}
+ /** Built consist's physical length (what Train Builder shows), null without a built train. */
+ private builtTrainLengthOf(s: TrainSchedule): number | null {
+ const raw = s.trainSet?.totalLengthMeters;
+ const value = raw != null ? Number(raw) : NaN;
+ return Number.isFinite(value) && value > 0 ? value : null;
+ }
+
+ /** Yard display labels for corridor stops (falls back to the yard id). */
+ private async yardLabels(yardIds: string[]): Promise
)}
+ {saved?.stampImageUrl && (
+
+
+
+
+
Company stamp
+
+ )}
@@ -126,6 +145,11 @@ export function MySignatureCard() {
/>
+
@@ -429,7 +524,89 @@ function LimitGauge({
);
}
-function WagonRow({
+/** Coupled row: trim checkbox (reason-badged when blocked) + switch picker. */
+function CoupledWagonRow({
+ wagon,
+ checked,
+ editable,
+ switchValue,
+ switchOptions,
+ onToggleRemove,
+ onSwitch,
+}: {
+ wagon: ConsistWagon;
+ checked: boolean;
+ editable: boolean;
+ switchValue: string | null;
+ switchOptions: Array<{ value: string; label: string }>;
+ onToggleRemove: (id: string, checked: boolean) => void;
+ onSwitch: (fromId: string, toId: string | null) => void;
+}) {
+ const badge = wagon.loaded ? "Loaded" : !wagon.removable ? "Pinned" : null;
+ const checkbox = (
+ onToggleRemove(wagon.id, e.currentTarget.checked)}
+ aria-label={`Trim wagon ${wagon.wagonNumber}`}
+ />
+ );
+ return (
+
+ {wagon.blockReason ? (
+
+ {checkbox}
+
+ ) : (
+ checkbox
+ )}
+
+
+ {wagon.wagonNumber}
+
+
+ {wagon.wagonType
+ ? `${wagon.wagonType.code} · ${wagon.wagonType.tareWeightTons}T tare · ${wagon.wagonType.lengthMeters}m`
+ : "Unknown type"}
+
+
+ {badge ? (
+
+ {badge}
+
+ ) : null}
+ {editable && wagon.switchable && switchOptions.length ? (
+ }
+ data={switchOptions}
+ value={switchValue}
+ onChange={(toId) => onSwitch(wagon.id, toId)}
+ clearable
+ searchable
+ disabled={checked}
+ aria-label={`Switch wagon ${wagon.wagonNumber}`}
+ />
+ ) : null}
+
+ );
+}
+
+function AddableWagonRow({
wagon,
checked,
disabled,
@@ -471,7 +648,7 @@ function WagonRow({
{badge ? (
-
+
{badge}
) : null}
diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/AllocateBookingWizard.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/AllocateBookingWizard.tsx
index ec4e1b5bc..12ef228df 100644
--- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/AllocateBookingWizard.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/AllocateBookingWizard.tsx
@@ -574,6 +574,11 @@ export function AllocateBookingWizard({
id: b.id,
reference: b.reference ?? b.id.slice(0, 8),
weightTons: b.weightTons,
+ isGovernment: b.isGovernment,
+ wagonsRequired: b.wagonsRequired,
+ contractReference: b.contractReference,
+ origin: b.origin,
+ destination: b.destination,
}))}
eligibleItems={eligibleQuery.data?.items ?? []}
eligibleLoading={eligibleQuery.isLoading}
@@ -739,6 +744,7 @@ export function AllocateBookingWizard({
{displayWagonPlan.length || assignedSchedule?.trainSet?.wagons?.length ? (
setForm((f) => f && { ...f, paymentWindowMinutes: v })
}
min={1}
- disabled={isExport}
/>
{!isExport ? (
diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/EligibleBookingsPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/EligibleBookingsPanel.tsx
index 68337a971..27122d68c 100644
--- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/EligibleBookingsPanel.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/EligibleBookingsPanel.tsx
@@ -1,5 +1,5 @@
import { useMemo } from "react";
-import { ArrowRight, Package } from "lucide-react";
+import { ArrowRight, Landmark, Package } from "lucide-react";
import {
Accordion,
Badge,
@@ -21,11 +21,13 @@ function EligibleBookingRow({
freightType,
selected,
onToggle,
+ onSwitch,
}: {
booking: EligibleContainerBooking;
freightType?: FreightType;
selected: boolean;
onToggle: () => void;
+ onSwitch?: (booking: EligibleContainerBooking) => void;
}) {
const resolvedFreightType = booking.freightType ?? freightType;
const isBulk = resolvedFreightType === "BULK";
@@ -61,6 +63,26 @@ function EligibleBookingRow({
{booking.schedulingStatus}
) : null}
+ {booking.isGovernment ? (
+ }
+ >
+ Government
+
+ ) : null}
+ {booking.isGovernment && onSwitch ? (
+
+ ) : null}
{booking.customer}
@@ -102,6 +124,7 @@ export function EligibleBookingsPanel({
onSelectionChange,
assignedIds = [],
freightType,
+ onSwitch,
}: {
items: EligibleContainerBooking[];
isLoading?: boolean;
@@ -109,6 +132,7 @@ export function EligibleBookingsPanel({
onSelectionChange: (ids: string[]) => void;
assignedIds?: string[];
freightType?: FreightType;
+ onSwitch?: (booking: EligibleContainerBooking) => void;
}) {
const assignedSet = useMemo(() => new Set(assignedIds), [assignedIds]);
@@ -230,6 +254,7 @@ export function EligibleBookingsPanel({
freightType={freightType}
selected={selectedIds.includes(booking.id)}
onToggle={() => toggle(booking.id)}
+ onSwitch={onSwitch}
/>
))}
diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/LegCapacityPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/LegCapacityPanel.tsx
new file mode 100644
index 000000000..c98a020a5
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/LegCapacityPanel.tsx
@@ -0,0 +1,387 @@
+import { Fragment, useMemo, useState } from "react";
+import {
+ Alert,
+ Badge,
+ Box,
+ Collapse,
+ Group,
+ Paper,
+ Progress,
+ Stack,
+ Table,
+ Text,
+ Tooltip,
+} from "@mantine/core";
+import { ChevronDown, ChevronRight, Info, Train, Weight } from "lucide-react";
+
+import type { TrainScheduleDetail } from "@/types/trainScheduling";
+
+/**
+ * Per-leg capacity workspace tab, computed from the BOOKINGS themselves: every
+ * adjacent leg (A→B, B→C, …) lists each booking riding it with the booking's
+ * own wagon count and gross weight, and totals both. A through booking (A→C)
+ * appears on every leg it rides — so leg totals are what each leg actually
+ * hauls, independent of how the consist slots were stamped.
+ */
+
+interface Stop {
+ yardId: string;
+ label: string;
+}
+
+interface LegBookingUsage {
+ bookingId: string;
+ reference: string;
+ wagons: number;
+ grossTons: number;
+ /** The booking's own origin → destination, so a sub-leg booking reads as such. */
+ route?: string | null;
+}
+
+interface EdgeUsage {
+ edge: number;
+ from: Stop;
+ to: Stop;
+ wagons: number;
+ grossTons: number;
+ bookings: LegBookingUsage[];
+}
+
+const round1 = (n: number) => Math.round(n * 10) / 10;
+
+function utilizationColor(used: number, cap: number | null): string {
+ if (cap == null || cap <= 0) return "gray";
+ const pct = used / cap;
+ if (pct > 1) return "red";
+ if (pct >= 0.9) return "orange";
+ if (pct >= 0.75) return "yellow";
+ return "teal";
+}
+
+function UsageCell({
+ used,
+ cap,
+ unit,
+}: {
+ used: number;
+ cap: number | null;
+ unit: string;
+}) {
+ const color = utilizationColor(used, cap);
+ const pct = cap ? Math.min(100, (used / cap) * 100) : 0;
+ return (
+
+ cap ? "red.7" : undefined}>
+ {round1(used)}
+ {cap != null ? ` / ${round1(cap)}` : ""} {unit}
+
+ {cap != null ? : null}
+
+ );
+}
+
+export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail }) {
+ const stops: Stop[] = schedule.stops ?? [];
+ const weightCap = schedule.maxGrossWeightTons ?? null;
+ const wagonCap = schedule.maxWagons ?? null;
+ const [expandedEdge, setExpandedEdge] = useState(null);
+
+ const edges: EdgeUsage[] = useMemo(() => {
+ if (stops.length < 2) return [];
+ const indexOf = new Map(stops.map((s, i) => [s.yardId, i]));
+ const lastIdx = stops.length - 1;
+ // A booking rides origin→destination; unknown/off-corridor yards fall back
+ // to the schedule's own endpoints (through cargo).
+ const spans = (schedule.bookings ?? []).map((b) => {
+ const from = (b.originYardId ? indexOf.get(b.originYardId) : undefined) ?? 0;
+ const toRaw = b.destinationYardId ? indexOf.get(b.destinationYardId) : lastIdx;
+ const to = toRaw != null && toRaw > from ? toRaw : lastIdx;
+ return { b, from, to };
+ });
+ return stops.slice(0, -1).map((from, edge) => {
+ const riding = spans.filter((s) => s.from <= edge && edge < s.to);
+ const bookings: LegBookingUsage[] = riding
+ .map(({ b }) => ({
+ bookingId: b.id,
+ reference: b.reference ?? b.id,
+ wagons: Number(b.wagonsRequired) || 0,
+ // The booking's OWN weight (cargo VGM/bulk tons) — no wagon tare, so
+ // each row shows exactly what the customer booked and the leg total
+ // is the plain sum of its rows.
+ grossTons: round1(Number(b.cargoWeightTons ?? b.weightTons) || 0),
+ route: b.origin && b.destination ? `${b.origin} → ${b.destination}` : null,
+ }))
+ .sort((a, b) => b.grossTons - a.grossTons);
+ return {
+ edge,
+ from,
+ to: stops[edge + 1],
+ wagons: bookings.reduce((sum, b) => sum + b.wagons, 0),
+ grossTons: round1(bookings.reduce((sum, b) => sum + b.grossTons, 0)),
+ bookings,
+ };
+ });
+ }, [stops, schedule.bookings]);
+
+ if (stops.length < 2) {
+ return (
+ }>
+ This schedule has no corridor stops to break into legs.
+
+ );
+ }
+
+ const legStatus = (e: EdgeUsage) => {
+ if (weightCap != null && e.grossTons > weightCap)
+ return Overweight;
+ const wagonsFree = wagonCap != null ? wagonCap - e.wagons : null;
+ const tonsFree = weightCap != null ? round1(weightCap - e.grossTons) : null;
+ if ((wagonsFree != null && wagonsFree <= 0) || (tonsFree != null && tonsFree <= 0))
+ return Full;
+ return (
+
+ {tonsFree != null ? `${tonsFree}T free` : "Available"}
+ {wagonsFree != null ? ` · ${wagonsFree} wagons` : ""}
+
+ );
+ };
+
+ // Availability for a span = the tightest leg it rides.
+ const spanAvailability = (from: number, to: number) => {
+ const slice = edges.slice(from, to);
+ const wagonsUsed = Math.max(...slice.map((e) => e.wagons));
+ const tonsUsed = Math.max(...slice.map((e) => e.grossTons));
+ const binding = slice.reduce((worst, e) => (e.grossTons > worst.grossTons ? e : worst));
+ return {
+ wagonsFree: wagonCap != null ? wagonCap - wagonsUsed : null,
+ tonsFree: weightCap != null ? round1(weightCap - tonsUsed) : null,
+ tonsUsed,
+ binding,
+ };
+ };
+
+ return (
+
+
+
+
+ Per-leg utilization
+
+ Each adjacent leg lists every booking riding it with its own booked
+ cargo weight — a through booking counts on all its legs, and the
+ leg total is the plain sum of its rows. Checked against the train
+ limits{weightCap != null ? ` (${weightCap}T pull` : ""}
+ {weightCap != null && wagonCap != null ? `, ${wagonCap} wagons` : ""}
+ {weightCap != null ? ")" : ""}.
+
+
+
+
+
+
+
+
+ {stops.length > 2 ? (
+
+
+
+ Availability by origin → destination
+
+ Every bookable pair along the corridor. Room for a pair is the
+ tightest leg it rides — hover a cell to see which leg binds.
+
+
+
+
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/PriorityTrackingTab.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/PriorityTrackingTab.tsx
index 8b5343f83..867782e84 100644
--- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/PriorityTrackingTab.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/PriorityTrackingTab.tsx
@@ -1,4 +1,4 @@
-import { memo, useMemo, useState } from "react";
+import { memo, useMemo, useState, type ReactNode } from "react";
import {
Box,
Group,
@@ -248,6 +248,96 @@ function RankedCard({
);
}
+/**
+ * Consecutive-run grouping of an already-ranked lane by boarding class:
+ * government first, then each booking-window cycle (windowCycleNo is 0-based,
+ * so cycle 0 renders as "1st cycle window"). rankBookings sorts gov → cycle
+ * asc, so consecutive runs are exactly the cycle groups.
+ */
+type CycleGroup = {
+ key: string;
+ color: string;
+ label: string;
+ sub: string | null;
+ items: BatchBoardBookingDetail[];
+};
+
+const CYCLE_COLORS = ["indigo", "cyan", "teal"];
+
+const ordinal = (n: number) =>
+ n === 1 ? "1st" : n === 2 ? "2nd" : n === 3 ? "3rd" : `${n}th`;
+
+function groupMeta(b: BatchBoardBookingDetail): Omit {
+ if (b.isGovernment)
+ return { key: "gov", color: "grape", label: "Government", sub: "boards first" };
+ if (b.windowCycleNo == null)
+ return {
+ key: "none",
+ color: "gray",
+ label: "No cycle yet",
+ sub: "contract not signed",
+ };
+ const n = b.windowCycleNo + 1;
+ return {
+ key: `c${b.windowCycleNo}`,
+ color: CYCLE_COLORS[b.windowCycleNo % CYCLE_COLORS.length],
+ label: `${ordinal(n)} cycle window`,
+ sub: n === 1 ? "booked in the first window" : "boards after earlier cycles",
+ };
+}
+
+function groupByCycle(items: BatchBoardBookingDetail[]): CycleGroup[] {
+ const groups: CycleGroup[] = [];
+ for (const b of items) {
+ const meta = groupMeta(b);
+ const last = groups[groups.length - 1];
+ if (last && last.key === meta.key) last.items.push(b);
+ else groups.push({ ...meta, items: [b] });
+ }
+ return groups;
+}
+
+/** Tinted wrapper card holding one cycle's ranked bookings. */
+function CycleSection({
+ group,
+ children,
+}: {
+ group: CycleGroup;
+ children: ReactNode;
+}) {
+ const wagons = group.items.reduce((s, b) => s + b.wagons, 0);
+ return (
+
+
+
+ {group.key === "gov" ? : }
+
+
+ {group.label}
+
+ {group.sub ? (
+
+ — {group.sub}
+
+ ) : null}
+
+ {group.items.length} booking{group.items.length === 1 ? "" : "s"} ·{" "}
+ {wagons}w
+
+
+ {children}
+
+ );
+}
+
/** The capacity cut line drawn between "in the batch" and "waiting list". */
function CapacityDivider({ used, max }: { used: number; max: number | null }) {
const full = max != null && used >= max;
@@ -482,19 +572,23 @@ export const PriorityTrackingTab = memo(function PriorityTrackingTab({
- {lanes.inBatch.map((b) => {
- rankNo += 1;
- return (
-
- );
- })}
+ {groupByCycle(lanes.inBatch).map((g) => (
+
+ {g.items.map((b) => {
+ rankNo += 1;
+ return (
+
+ );
+ })}
+
+ ))}
) : null}
@@ -514,19 +608,23 @@ export const PriorityTrackingTab = memo(function PriorityTrackingTab({
- {lanes.waiting.map((b) => {
- rankNo += 1;
- return (
-
- );
- })}
+ {groupByCycle(lanes.waiting).map((g) => (
+
+ {g.items.map((b) => {
+ rankNo += 1;
+ return (
+
+ );
+ })}
+
+ ))}
) : null}
diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleBookingsStep.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleBookingsStep.tsx
index 9241a03ea..fec3d78e9 100644
--- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleBookingsStep.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleBookingsStep.tsx
@@ -7,7 +7,7 @@ import {
Tabs,
Text,
} from "@mantine/core";
-import { ArrowRight, Package, Train } from "lucide-react";
+import { ArrowRight, FileText, Landmark, MapPin, Package, Train } from "lucide-react";
import type { EligibleContainerBooking, FreightType } from "@/types/trainScheduling";
@@ -17,6 +17,11 @@ export type AssignedBookingRow = {
id: string;
reference: string;
weightTons?: number;
+ isGovernment?: boolean;
+ wagonsRequired?: number | null;
+ contractReference?: string | null;
+ origin?: string | null;
+ destination?: string | null;
};
export function ScheduleBookingsStep({
@@ -29,6 +34,7 @@ export function ScheduleBookingsStep({
freightType,
canRemove,
onRemove,
+ onSwitch,
}: {
assignedBookings: AssignedBookingRow[];
eligibleItems: EligibleContainerBooking[];
@@ -39,6 +45,7 @@ export function ScheduleBookingsStep({
freightType?: FreightType;
canRemove?: boolean;
onRemove?: (bookingId: string) => void;
+ onSwitch?: (booking: EligibleContainerBooking) => void;
}) {
return (
@@ -91,7 +98,47 @@ export function ScheduleBookingsStep({
{booking.weightTons}T
) : null}
+ {booking.wagonsRequired != null ? (
+ }
+ >
+ {booking.wagonsRequired} wagon{booking.wagonsRequired === 1 ? "" : "s"}
+
+ ) : null}
+ {booking.isGovernment ? (
+ }
+ >
+ Government
+
+ ) : null}
+ {booking.contractReference || booking.origin || booking.destination ? (
+
+ {booking.contractReference ? (
+
+
+
+ {booking.contractReference}
+
+
+ ) : null}
+ {booking.origin || booking.destination ? (
+
+
+
+ {booking.origin ?? "?"} → {booking.destination ?? "?"}
+
+
+ ) : null}
+
+ ) : null}
Assigned to this consist
@@ -130,6 +177,7 @@ export function ScheduleBookingsStep({
onSelectionChange={onSelectionChange}
assignedIds={assignedIds}
freightType={freightType}
+ onSwitch={onSwitch}
/>
diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleHistoryPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleHistoryPanel.tsx
new file mode 100644
index 000000000..729c7a5d4
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleHistoryPanel.tsx
@@ -0,0 +1,137 @@
+import {
+ Badge,
+ Group,
+ Paper,
+ Stack,
+ Text,
+ ThemeIcon,
+ Timeline,
+} from "@mantine/core";
+import { useQuery } from "@tanstack/react-query";
+import {
+ ArrowLeftRight,
+ History,
+ MapPin,
+ Minus,
+ PackageCheck,
+ PackageMinus,
+ PackageOpen,
+ Plus,
+ User,
+} from "lucide-react";
+
+import { api } from "@/services/api";
+import type { ScheduleHistoryEntry } from "@/services/trainBuilder.service";
+
+const ACTION_META: Record<
+ ScheduleHistoryEntry["action"],
+ { label: string; color: string; icon: typeof Plus }
+> = {
+ ADD: { label: "Wagon coupled", color: "edr-green", icon: Plus },
+ REMOVE: { label: "Wagon trimmed", color: "red", icon: Minus },
+ SWITCH: { label: "Wagon switched", color: "blue", icon: ArrowLeftRight },
+ BOOKING_REMOVED: { label: "Booking removed", color: "orange", icon: PackageMinus },
+ BOOKING_LOADED: { label: "Booking loaded", color: "edr-green", icon: PackageCheck },
+ BOOKING_UNLOADED: { label: "Booking unloaded", color: "blue", icon: PackageOpen },
+};
+
+/**
+ * "History" tab: every change made to the train after it was scheduled —
+ * wagons coupled/trimmed/switched (with the stop where it happened) and
+ * bookings removed from the composition — newest first.
+ */
+export default function ScheduleHistoryPanel({ scheduleId }: { scheduleId: string }) {
+ const historyQuery = useQuery(
+ api.trainScheduling.scheduleHistory.queryOptions({
+ input: { scheduleId },
+ enabled: Boolean(scheduleId),
+ }),
+ );
+ const entries = historyQuery.data ?? [];
+
+ return (
+
+
+
+
+
+
+
+
+ Change history
+
+
+ Wagons coupled, trimmed or switched, bookings loaded/unloaded per
+ yard, and bookings removed — after this train was scheduled,
+ newest first.
+
+
+
+
+ {historyQuery.isLoading ? (
+
+ Loading history…
+
+ ) : entries.length === 0 ? (
+
+ No changes recorded yet — the consist and composition are as
+ scheduled.
+
+ ) : (
+
+ {entries.map((entry) => {
+ const meta = ACTION_META[entry.action] ?? ACTION_META.ADD;
+ const Icon = meta.icon;
+ return (
+ }
+ color={meta.color}
+ title={
+
+
+ {meta.label}
+
+ {entry.subject ? (
+
+ {entry.subject}
+
+ ) : null}
+
+ }
+ >
+
+
+ {new Date(entry.occurredAt).toLocaleString()}
+
+ {entry.yardLabel ? (
+
+
+
+ at {entry.yardLabel}
+
+
+ ) : null}
+ {entry.actor ? (
+
+
+
+ {entry.actor}
+
+
+ ) : null}
+
+ {entry.note ? (
+
+ {entry.note}
+
+ ) : null}
+
+ );
+ })}
+
+ )}
+
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx
index 58ff55912..de954742e 100644
--- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx
@@ -22,10 +22,13 @@ import {
ArrowRight,
CheckCircle2,
Inbox,
+ Landmark,
+ MapPin,
PackageCheck,
- PackageX,
+ PackageOpen,
// Repeat, // used by the hidden Move (reassign) button
Train,
+ TrainFront,
Weight,
X,
} from "lucide-react";
@@ -39,6 +42,7 @@ import type {
EligibleContainerBooking,
FreightType,
TrainScheduleDetail,
+ YardWorkBookingRow,
} from "@/types/trainScheduling";
interface ScheduleWorkspacePanelProps {
@@ -68,41 +72,85 @@ function apiErrorMessage(error: unknown, fallback: string): string {
*/
function phaseCountdown(
schedule: TrainScheduleDetail,
-): { label: string; deadline: string } | null {
+): { label: string; deadline: string; expiredText: string } | null {
switch (schedule.windowPhase) {
+ case "PRE_WINDOW":
+ return schedule.windowOpensAt
+ ? {
+ label: "Booking window opens in",
+ deadline: schedule.windowOpensAt,
+ expiredText: "Booking opening now…",
+ }
+ : null;
case "OPEN":
return schedule.windowClosesAt
- ? { label: "Booking window closes in", deadline: schedule.windowClosesAt }
+ ? {
+ label: "Booking window closes in",
+ deadline: schedule.windowClosesAt,
+ expiredText: "Document review starting…",
+ }
: null;
case "DOC_REVIEW":
return schedule.docReviewEndsAt
- ? { label: "Document review ends in", deadline: schedule.docReviewEndsAt }
+ ? {
+ label: "Document review ends in",
+ deadline: schedule.docReviewEndsAt,
+ expiredText: "Payment starting…",
+ }
: null;
case "PAYMENT":
return schedule.paymentPhaseEndsAt
- ? { label: "Payment window ends in", deadline: schedule.paymentPhaseEndsAt }
+ ? {
+ label: "Payment window ends in",
+ deadline: schedule.paymentPhaseEndsAt,
+ expiredText: "Payment window closing…",
+ }
: null;
default:
return null;
}
}
-/** GROSS weight already on this train (each booking's cargo + wagon tare) —
- * compared against the locomotive pull limit, which is a gross ceiling. */
+/**
+ * GROSS weight the locomotives actually haul: the HEAVIEST LEG, never the
+ * whole-route sum — disjoint legs (Mojo→Dire + Dire→Doraleh) are pulled one
+ * at a time, so summing every booking over-reports a multi-stop train.
+ * Prefers the API's consist-derived heaviestLeg; before allocation it falls
+ * back to a per-leg max over the bookings (same span math as the header strip).
+ */
function usedWeight(schedule: TrainScheduleDetail): number {
- return (schedule.bookings ?? []).reduce(
- (sum, b) => sum + (Number(b.weightTons) || 0),
- 0,
- );
+ const consist = schedule.trainSet?.heaviestLeg?.grossWeightTons;
+ if (consist != null) return Number(consist) || 0;
+
+ const bookings = schedule.bookings ?? [];
+ const stops = schedule.stops ?? [];
+ if (stops.length <= 2) {
+ return bookings.reduce((sum, b) => sum + (Number(b.weightTons) || 0), 0);
+ }
+ const indexOf = new Map(stops.map((s, i) => [s.yardId, i]));
+ const lastIdx = stops.length - 1;
+ let heaviest = 0;
+ for (let edge = 0; edge < lastIdx; edge += 1) {
+ let legTons = 0;
+ for (const b of bookings) {
+ const from = (b.originYardId ? indexOf.get(b.originYardId) : undefined) ?? 0;
+ const toRaw = b.destinationYardId ? indexOf.get(b.destinationYardId) : lastIdx;
+ const to = toRaw != null && toRaw > from ? toRaw : lastIdx;
+ if (from <= edge && edge < to) legTons += Number(b.weightTons) || 0;
+ }
+ heaviest = Math.max(heaviest, legTons);
+ }
+ return heaviest;
}
/**
- * Pull capacity of the set = the WEAKEST locomotive's max pull weight (0 when
- * unknown). The API caps at the weakest loco, not the sum of all locos — a
- * consist can only pull as hard as its weakest engine. Both sides of this meter
- * are gross: `usedWeight` sums per-booking gross (cargo + wagon tare).
+ * Pull capacity of the set. Locomotive pull weights ADD UP (they haul
+ * together), so prefer the API's maxGrossWeightTons — the combined set limit
+ * incl. overage tolerance, the same ceiling the validator holds each leg to —
+ * and fall back to summing the locos' own limits.
*/
function pullCapacity(schedule: TrainScheduleDetail): number {
+ if (schedule.maxGrossWeightTons != null) return Number(schedule.maxGrossWeightTons) || 0;
const set = schedule.trainSet;
if (!set) return 0;
const locos =
@@ -111,8 +159,7 @@ function pullCapacity(schedule: TrainScheduleDetail): number {
: set.locomotive
? [set.locomotive]
: [];
- if (locos.length === 0) return 0;
- return Math.min(...locos.map((l) => Number(l.maxPullWeightTons) || 0));
+ return locos.reduce((sum, l) => sum + (Number(l.maxPullWeightTons) || 0), 0);
}
export function ScheduleWorkspacePanel({
@@ -128,6 +175,9 @@ export function ScheduleWorkspacePanel({
const locked = ["DISPATCHED", "ARRIVED"].includes(schedule.status);
const canManage = ["DRAFT", "SCHEDULED"].includes(schedule.status);
+ // Loading follows the train: it keeps working AFTER dispatch, per yard, as
+ // checkpoints are logged — only add/remove is closed once the train rolls.
+ const canWork = ["DRAFT", "SCHEDULED", "DISPATCHED"].includes(schedule.status);
// Pool = accepted, ready-to-pay bookings on THIS train's route+day that are not
// yet linked to any schedule (same filter the auto-batch uses).
@@ -157,14 +207,83 @@ export function ScheduleWorkspacePanel({
const onTrain = schedule.bookings ?? [];
+ // ── Corridor position: which yard the train currently stands at ───────────
+ // The journey worklist knows the train's latest checkpoint AND per-booking
+ // load/unload eligibility — the same server rules that gate the mutations.
+ const yardWorkQuery = useQuery(
+ api.trainScheduling.yardWork.queryOptions({
+ input: { scheduleId: schedule.id },
+ refetchInterval: 60_000,
+ }),
+ );
+ const trainAtYardId = yardWorkQuery.data?.trainAtYardId ?? null;
+ const journeyById = useMemo(() => {
+ const map = new Map();
+ for (const yard of yardWorkQuery.data?.yards ?? []) {
+ for (const row of [...yard.toLoad, ...yard.toUnload]) map.set(row.id, row);
+ }
+ return map;
+ }, [yardWorkQuery.data]);
+
+ // Ordered corridor (origin → stops → destination). Falls back to the two
+ // endpoints when the schedule has no stops recorded.
+ const stations = useMemo(() => {
+ const stops = schedule.stops ?? [];
+ if (stops.length) return stops;
+ return [
+ { yardId: schedule.originStation?.id ?? "origin", label: schedule.originStation?.label ?? "Origin" },
+ {
+ yardId: schedule.destinationStation?.id ?? "destination",
+ label: schedule.destinationStation?.label ?? "Destination",
+ },
+ ];
+ }, [schedule.stops, schedule.originStation, schedule.destinationStation]);
+ const stationIdx = useMemo(
+ () => new Map(stations.map((s, i) => [s.yardId, i])),
+ [stations],
+ );
+ const trainIdx = trainAtYardId != null ? (stationIdx.get(trainAtYardId) ?? null) : null;
+ const trainAtLabel =
+ trainIdx != null ? stations[trainIdx]?.label : null;
+
+ // On-train bookings grouped by BOARDING yard, in corridor order. A booking
+ // whose origin is off this corridor (through cargo on legacy data) groups
+ // under the train's own origin.
+ const corridorGroups = useMemo(() => {
+ const groups = new Map();
+ for (const b of onTrain) {
+ const yardId =
+ b.originYardId && stationIdx.has(b.originYardId)
+ ? b.originYardId
+ : (stations[0]?.yardId ?? "origin");
+ let group = groups.get(yardId);
+ if (!group) {
+ group = {
+ yardId,
+ label:
+ stations[stationIdx.get(yardId) ?? 0]?.label ?? b.origin ?? "Origin",
+ rows: [],
+ };
+ groups.set(yardId, group);
+ }
+ group.rows.push(b);
+ }
+ return [...groups.values()].sort(
+ (a, b) => (stationIdx.get(a.yardId) ?? 0) - (stationIdx.get(b.yardId) ?? 0),
+ );
+ }, [onTrain, stationIdx, stations]);
+
// ── Mutations (reuse the existing endpoints) ───────────────────────────────
const assign = useMutation(api.trainScheduling.assignBookings.mutationOptions());
const assignUnassigned = useMutation(
api.trainScheduling.assignUnassignedBooking.mutationOptions(),
);
const unassign = useMutation(api.trainScheduling.unassignBooking.mutationOptions());
- const setLoading = useMutation(
- api.trainScheduling.setLoadingStatus.mutationOptions(),
+ const loadJourney = useMutation(
+ api.trainScheduling.loadScheduleBooking.mutationOptions(),
+ );
+ const unloadJourney = useMutation(
+ api.trainScheduling.unloadScheduleBooking.mutationOptions(),
);
const moveSchedule = useMutation(
api.trainScheduling.moveBookingSchedule.mutationOptions(),
@@ -273,26 +392,42 @@ export function ScheduleWorkspacePanel({
);
};
- const toggleLoaded = (
- bookingId: string,
- ref: string,
- next: "LOADED" | "UNLOADED",
- ) => {
- setLoading
- .mutateAsync({ id: schedule.id, bookingIds: [bookingId], loadingStatus: next })
+ // Journey load/unload — the server checks the train's recorded position, so
+ // a stale UI can never load cargo at the wrong yard.
+ const doLoad = (bookingId: string, ref: string) => {
+ loadJourney
+ .mutateAsync({ scheduleId: schedule.id, bookingId })
.then(() => {
- toast({
- title:
- next === "LOADED"
- ? `${ref} marked loaded`
- : `${ref} marked unloaded`,
- });
+ toast({ title: `${ref} loaded onto the train` });
onChanged();
+ void yardWorkQuery.refetch();
})
.catch((error) =>
toast({
- title: "Could not update loading status",
- description: apiErrorMessage(error, "Please try again."),
+ title: "Could not load cargo",
+ description: apiErrorMessage(error, "Train may not be at the boarding yard."),
+ variant: "destructive",
+ }),
+ );
+ };
+
+ const doUnload = (bookingId: string, ref: string) => {
+ unloadJourney
+ .mutateAsync({ scheduleId: schedule.id, bookingId })
+ .then((result) => {
+ toast({
+ title:
+ result.status === "COMPLETED"
+ ? `${ref} unloaded — booking completed`
+ : `${ref} unloaded — booking arrived`,
+ });
+ onChanged();
+ void yardWorkQuery.refetch();
+ })
+ .catch((error) =>
+ toast({
+ title: "Could not unload cargo",
+ description: apiErrorMessage(error, "Train may not be at the destination yard."),
variant: "destructive",
}),
);
@@ -359,7 +494,8 @@ export function ScheduleWorkspacePanel({
Allocation workspace
- Manually add paid, unassigned bookings, remove, or reassign them
+ Add or remove bookings, then load each one when the train is at
+ its boarding yard
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsColumnDefn.tsx b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsColumnDefn.tsx
index 97c3bfbc6..8bbe7c587 100644
--- a/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsColumnDefn.tsx
+++ b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsColumnDefn.tsx
@@ -32,13 +32,15 @@ export interface AdminRoleInfo {
/**
* all-admins/:id returns users who are org admins of the org OR unit admins of
* one of its units; userRoles carries every role of the user, so match the org
- * explicitly for the org-admin grant.
+ * explicitly for the org-admin grant. The unit-admin grant carries no
+ * organizationId of its own, only a unitId, so orgUnitIds (every unit that
+ * belongs to the selected org) is required to tell a same-org unit-admin
+ * grant apart from a same-user unit-admin grant in a different org.
*/
-// ponytail: unit relation isn't loaded, so a unit_admin grant from another org
-// can't be told apart — acceptable, the server only returns admins of this org.
export function getAdminRoleInfo(
admin: OrgAdminUser,
selectedOrgId: string,
+ orgUnitIds: Set,
): AdminRoleInfo {
const roles = admin.userRoles ?? [];
const isOrgAdmin = roles.some(
@@ -47,7 +49,10 @@ export function getAdminRoleInfo(
r.organizationId === selectedOrgId,
);
const unitRole = roles.find(
- (r) => r.role?.key === UNIT_ADMIN_ROLE_KEY && r.unitId,
+ (r) =>
+ r.role?.key === UNIT_ADMIN_ROLE_KEY &&
+ !!r.unitId &&
+ orgUnitIds.has(r.unitId),
);
return {
isOrgAdmin,
@@ -58,6 +63,7 @@ export function getAdminRoleInfo(
interface ColumnCallbacks {
selectedOrgId: string;
+ orgUnitIds: Set;
localizedName: (name?: { am?: string; en?: string }) => string;
onEdit: (admin: OrgAdminUser) => void;
onResend: (admin: OrgAdminUser) => void;
@@ -67,6 +73,7 @@ interface ColumnCallbacks {
export function getOrgAdminsColumnDefn({
selectedOrgId,
+ orgUnitIds,
localizedName,
onEdit,
onResend,
@@ -118,7 +125,7 @@ export function getOrgAdminsColumnDefn({
id: "role",
header: () => t("orgAdmins.columns.role"),
cell: ({ row }) => {
- const info = getAdminRoleInfo(row.original, selectedOrgId);
+ const info = getAdminRoleInfo(row.original, selectedOrgId, orgUnitIds);
return (
{info.isOrgAdmin && (
@@ -179,7 +186,7 @@ export function getOrgAdminsColumnDefn({
enableHiding: false,
cell: ({ row }) => {
const admin = row.original;
- const roleInfo = getAdminRoleInfo(admin, selectedOrgId);
+ const roleInfo = getAdminRoleInfo(admin, selectedOrgId, orgUnitIds);
return (
diff --git a/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsPage.tsx b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsPage.tsx
index d5844a603..adebd98a5 100644
--- a/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsPage.tsx
@@ -1,7 +1,15 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
-import { Building2, Loader2, Plus, UserPlus, Users2 } from "lucide-react";
+import { Link } from "react-router-dom";
+import {
+ Building2,
+ Loader2,
+ Plus,
+ ShieldCheck,
+ UserPlus,
+ Users2,
+} from "lucide-react";
import { Button } from "@/shared/common/ui/button";
import {
Card,
@@ -23,6 +31,8 @@ import { Badge } from "@/shared/common/ui/badge";
import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable";
import { useLocalizedName } from "@/shared/common/localizedName";
import { OrganizationDto } from "@/shared/dto/organization/organizationDto";
+import { useUnit } from "@/user-management/hooks/useUnit";
+import { UnitDto } from "@/user-management/dto/unit/unitDto";
import {
OrgAdminUser,
useOrgAdmins,
@@ -81,6 +91,22 @@ export default function OrgAdminsPage() {
skip: pageIndex * pageSize,
});
+ const { getList: getUnitList } = useUnit();
+ // A unit-admin grant only carries a unitId, no organizationId — this is the
+ // set that tells "unit_admin of this org" apart from "unit_admin of some
+ // other org the same user also administers" (see getAdminRoleInfo).
+ const { data: orgUnitsResponse } = getUnitList(selectedOrg?.id ?? "", {
+ take: 3000,
+ skip: 0,
+ });
+ const orgUnitIds = useMemo(
+ () =>
+ new Set(
+ (orgUnitsResponse?.data?.items ?? []).map((unit: UnitDto) => unit.id),
+ ),
+ [orgUnitsResponse],
+ );
+
useEffect(() => {
setPageIndex(0);
}, [selectedOrg?.id, pageSize]);
@@ -170,6 +196,7 @@ export default function OrgAdminsPage() {
() =>
getOrgAdminsColumnDefn({
selectedOrgId: selectedOrg?.id ?? "",
+ orgUnitIds,
localizedName: localizedName as (name?: {
am?: string;
en?: string;
@@ -183,19 +210,29 @@ export default function OrgAdminsPage() {
onRemove: (admin, roleInfo) => setRemoveTarget({ admin, roleInfo }),
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
- [selectedOrg?.id],
+ [selectedOrg?.id, orgUnitIds],
);
return (
-
- {t("orgAdmins.title")}
-
-
- {t("orgAdmins.subtitle")}
-
+
+
+
+ {t("orgAdmins.title")}
+
+
+ {t("orgAdmins.subtitle")}
+
+
+
+
{/* Org selector + summary */}
diff --git a/apps/edr-freight-web/backoffice/src/super-admin/services/api/rolePermissionService.ts b/apps/edr-freight-web/backoffice/src/super-admin/services/api/rolePermissionService.ts
new file mode 100644
index 000000000..1d96edc1f
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/super-admin/services/api/rolePermissionService.ts
@@ -0,0 +1,25 @@
+import { withHeaders } from "@/record-management/services/api/withHeaders";
+import axiosInstance from "@/shared/services/axiosInstance";
+import { PermissionListResponse } from "@/user-management/dto/permissions/permissonDto";
+import { AxiosResponse } from "axios";
+
+export interface AssignRolePermissionsPayload {
+ firstId: string;
+ secondIds: string[];
+}
+
+// GET /role-permissions/given-first/{roleId}
+export const getPermissionsByRoleId = async (
+ roleId: string,
+): Promise> =>
+ axiosInstance.get(`/role-permissions/given-first/${roleId}`, {
+ headers: withHeaders(),
+ });
+
+// POST /role-permissions/assign-seconds-for-first
+export const assignPermissionsToRole = async (
+ payload: AssignRolePermissionsPayload,
+): Promise> =>
+ axiosInstance.post("/role-permissions/assign-seconds-for-first", payload, {
+ headers: withHeaders(),
+ });
diff --git a/apps/edr-freight-web/backoffice/src/super-admin/services/api/roleService.ts b/apps/edr-freight-web/backoffice/src/super-admin/services/api/roleService.ts
new file mode 100644
index 000000000..6ede479d8
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/super-admin/services/api/roleService.ts
@@ -0,0 +1,20 @@
+import { withHeaders } from "@/record-management/services/api/withHeaders";
+import axiosInstance from "@/shared/services/axiosInstance";
+import { AxiosResponse } from "axios";
+
+export interface RoleDto {
+ id: string;
+ name: { am: string; en: string };
+ key: string;
+}
+
+export interface RoleListResponse {
+ count: number;
+ items: RoleDto[];
+}
+
+export const getRoles = async (): Promise> =>
+ axiosInstance.get("/roles", {
+ headers: withHeaders(),
+ params: { take: 100 },
+ });
diff --git a/apps/edr-freight-web/backoffice/src/types/customer.ts b/apps/edr-freight-web/backoffice/src/types/customer.ts
index d95084851..7e69b19d7 100644
--- a/apps/edr-freight-web/backoffice/src/types/customer.ts
+++ b/apps/edr-freight-web/backoffice/src/types/customer.ts
@@ -135,6 +135,36 @@ export interface CustomerResetTarget {
phoneIsDomestic: boolean | null;
}
+/** One person's Fayda verification state — mirrors `IdentityVerificationStateDto`. */
+export interface IdentityVerificationState {
+ verified: boolean;
+ name: string | null;
+ phone: string | null;
+ email: string | null;
+ address: string | null;
+ verifiedAt: string | null;
+ birthdate: string | null;
+ gender: string | null;
+}
+
+/** Mirrors `OwnerIdentityStateDto`. */
+export interface OwnerIdentityState extends IdentityVerificationState {
+ passportNumber: string | null;
+}
+
+/**
+ * Owner/PoA Fayda verification, shared with the portal's derivation
+ * (`buildCompanyIdentityState`) so backoffice never re-derives — or
+ * disagrees with — the rule the API actually enforces.
+ */
+export interface CompanyIdentityState {
+ faydaRequired: boolean;
+ passportRequired: boolean;
+ owner: OwnerIdentityState;
+ poa: IdentityVerificationState;
+ complete: boolean;
+}
+
/** Mirrors backend `Company` (+ its `companyProfiles`). */
export interface Company {
id: string;
@@ -161,6 +191,20 @@ export interface Company {
poaAddress?: string | null;
website?: string | null;
attributes?: Record | null;
+ // eTrade-sourced registration record — populated by the onboarding TIN
+ // lookup, locked/read-only on the portal from the moment it's fetched.
+ licenceNumber?: string | null;
+ statusDescription?: string | null;
+ dateRegistered?: string | null;
+ renewedFrom?: string | null;
+ renewalDate?: string | null;
+ renewedTo?: string | null;
+ region?: string | null;
+ zone?: string | null;
+ woreda?: string | null;
+ kebele?: string | null;
+ houseNo?: string | null;
+ identity?: CompanyIdentityState;
companyProfiles: CompanyProfile[];
/**
* Whether the customer submitted their onboarding application. A company row
diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts
index 46e4b6c15..0ff8d1db6 100644
--- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts
+++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts
@@ -38,6 +38,7 @@ export interface EligibleContainerBooking {
status: string;
schedulingStatus?: SchedulingStatus;
priorityScore?: number;
+ isGovernment?: boolean;
}
export interface EligibleContainerBookingsResponse {
@@ -119,7 +120,10 @@ export interface TrainSchedulingGlobalRules {
windowCloseHour: number;
windowDurationHours: number;
docReviewMinutes: number;
+ /** Import/domestic customer pay window, minutes. */
paymentWindowMinutes: number;
+ /** Export customer pay window, minutes — tuned separately from import. */
+ exportPaymentWindowMinutes: number;
/** Minutes before departure the import window closes; null = close at departure. */
importCloseOffsetMinutes: number | null;
/** Minutes before departure the export window closes; null = close at departure. */
@@ -360,12 +364,17 @@ export interface BatchBoardSchedule {
allocatedLengthMeters: number;
/** Train-length cap: locomotive floored by global rules, plus overage tolerance. */
maxLengthMeters: number | null;
- /** GROSS tons on the train — wagon tare + cargo, since the pull limit hauls both. */
+ /** GROSS tons on the train — wagon tare + cargo, since the pull limit hauls both.
+ * On a multi-stop corridor this is the HEAVIEST single edge, not the sum. */
usedWeightTons: number;
/** Pull-weight cap: locomotive floored by global rules, plus overage tolerance. */
maxWeightTons: number | null;
/** Wagon-slot cap for the train, derived from train length and the shortest wagon type. */
maxWagons: number | null;
+ /** Physical consist length of the built train (Train Builder), null without one. */
+ trainLengthMeters: number | null;
+ /** Committed gross weight per corridor edge, in stop order; null on 2-stop routes. */
+ legUsage: Array<{ from: string; to: string; usedWeightTons: number }> | null;
};
counts: {
allocated: number;
@@ -600,6 +609,16 @@ export interface TrainScheduleDetail {
wagonCount: number;
totalWeightTons: number;
totalLengthMeters: number;
+ /**
+ * Usage on the corridor's heaviest edge — what the locomotives actually
+ * haul. Cross-leg wagon sharing makes plain slot sums over-report a
+ * multi-stop train. Null without a train set.
+ */
+ heaviestLeg?: {
+ grossWeightTons: number;
+ lengthMeters: number;
+ loadedWagonCount: number;
+ } | null;
locomotive?: {
id: string;
code: string;
@@ -634,6 +653,9 @@ export interface TrainScheduleDetail {
/** Empty-wagon weight from the wagon type — gross = tare + cargo. */
tareWeightTons?: number | null;
status?: string;
+ /** Corridor span this slot rides; null = the schedule's own endpoint. */
+ boardYardId?: string | null;
+ alightYardId?: string | null;
physicalWagonId?: string | null;
physicalWagonNumber?: string | null;
wagonType?: {
@@ -654,6 +676,8 @@ export interface TrainScheduleDetail {
reference: string | null;
customer: string | null;
weightTons: number;
+ /** Cargo only (VGM/bulk tons) — the booked weight without wagon tare. */
+ cargoWeightTons?: number;
status: string | null;
schedulingStatus?: SchedulingStatus | null;
freightType?: FreightType | string | null;
@@ -663,16 +687,20 @@ export interface TrainScheduleDetail {
destinationYardId?: string | null;
origin?: string | null;
destination?: string | null;
+ contractReference?: string | null;
wagonsRequired?: number | null;
loadedAt?: string | null;
arrivedAt?: string | null;
loadingStatus?: "LOADED" | "UNLOADED";
wagonAssigned?: boolean;
+ isGovernment?: boolean;
}>;
/** Ordered corridor stops (route milestones) — for per-segment occupancy. */
stops?: Array<{ yardId: string; label: string }>;
/** Loco pull ceiling incl. overage tolerance — per-leg gross is held to it. */
maxGrossWeightTons?: number | null;
+ /** Train length ceiling incl. overage tolerance — per-leg length is held to it. */
+ maxLengthMeters?: number | null;
warnings?: string[];
}
diff --git a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/CreatePositionForm.tsx b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/CreatePositionForm.tsx
index f8fc0cfdd..29ac1486f 100644
--- a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/CreatePositionForm.tsx
+++ b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/CreatePositionForm.tsx
@@ -115,6 +115,7 @@ export const CreatePositionForm = ({
});
const selectedOrganizationId = form.watch("organizationId");
+ const selectedUnitId = form.watch("unitId");
const { organizationsResponse, isLoading: isLoadingOrgs } = useOrganizations(
"Org",
@@ -163,36 +164,32 @@ export const CreatePositionForm = ({
enabled: mode === "edit" && !!positionTypeId,
});
- // A position type belongs to a unit, and a unit to an organization — IAM has
- // no organizationId on the type itself and no organization-scoped route, so
- // the picked org narrows the list through its units. isSystem types are the
- // shared "commons" and stay available to every organization.
- const orgUnitIds = useMemo(
- () =>
- new Set(
- (unitsResponse?.data?.items ?? []).map((unit: UnitDto) => unit.id),
- ),
- [unitsResponse],
- );
-
+ // A position type belongs to a single unit — scope copy sources to the
+ // selected unit, same as the "Select Unit" filter on the position list page.
+ // isSystem types are the shared "commons" and stay available everywhere.
const copyFromOptions = useMemo(() => {
- if (!selectedOrganizationId) return [];
+ if (!selectedUnitId) return [];
return positionTypes.filter(
(type: PositionTypeDto) =>
type.id !== positionTypeId &&
- (type.isSystem || (!!type.unitId && orgUnitIds.has(type.unitId))),
+ (type.isSystem || type.unitId === selectedUnitId),
);
- }, [positionTypes, orgUnitIds, selectedOrganizationId, positionTypeId]);
+ }, [positionTypes, selectedUnitId, positionTypeId]);
// Reset the selected unit when the organization changes so a unit from a
- // different org can't be submitted by mistake. The copy source is cleared
- // too — it is scoped to the old organization.
+ // different org can't be submitted by mistake.
useEffect(() => {
if (mode === "edit") return;
form.setValue("unitId", "");
- setCopyFromPositionId("");
}, [selectedOrganizationId, mode, form]);
+ // The copy source is scoped to the selected unit — clear it whenever the
+ // unit changes (including as a side effect of the org reset above) so a
+ // stale selection from a different unit can't be submitted.
+ useEffect(() => {
+ setCopyFromPositionId("");
+ }, [selectedUnitId]);
+
useEffect(() => {
if (mode !== "edit" || !initialValues || !positionTypeId) return;
if (hasLoadedEditData.current) return;
@@ -335,11 +332,13 @@ export const CreatePositionForm = ({
const copyFromPlaceholder = !selectedOrganizationId
? t("contentManagement.selectOrganizationToCopy")
- : isCopying || isLoadingPositionTypes || isLoadingUnits
- ? t("common.loading")
- : isErrorPositionTypes
- ? t("contentManagement.failedToLoadPositionTypes")
- : t("contentManagement.selectPositionToCopy");
+ : !selectedUnitId
+ ? t("contentManagement.selectUnitToCopy")
+ : isCopying || isLoadingPositionTypes || isLoadingUnits
+ ? t("common.loading")
+ : isErrorPositionTypes
+ ? t("contentManagement.failedToLoadPositionTypes")
+ : t("contentManagement.selectPositionToCopy");
return (