resolve merge conflict

This commit is contained in:
Marshal
2026-08-01 12:16:12 +00:00
171 changed files with 8253 additions and 1318 deletions

View File

@@ -29,7 +29,12 @@ COPY --from=builder /app/ .
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm deploy --filter="@edr/freight-api" --prod --legacy /deploy
FROM deployer AS migration
WORKDIR /deploy
CMD ["node", "dist/scripts/migrate.js"]
FROM base AS runner
RUN apk add --no-cache libc6-compat
ENV NODE_ENV=production
WORKDIR /app

View File

@@ -27,6 +27,7 @@
"seed:negad-indode-arrived-train": "ts-node -r tsconfig-paths/register src/scripts/seed-negad-indode-arrived-train.ts",
"seed:gate-pass-train-scenarios": "ts-node -r tsconfig-paths/register src/scripts/seed-gate-pass-train-scenarios.ts",
"auto-unload:arrived-import-trains": "ts-node -r tsconfig-paths/register src/scripts/auto-unload-arrived-import-trains.ts",
"backfill:missing-unload-inventory": "ts-node -r tsconfig-paths/register src/scripts/backfill-missing-unload-inventory.ts",
"seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts",
"seed:dropdown-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-dropdown-settings.ts",
"seed:gov-companies": "ts-node -r tsconfig-paths/register src/scripts/seed-gov-companies.ts",
@@ -36,6 +37,7 @@
"iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert",
"iam:migration:show": "pnpm run iam:typeorm:cli migration:show",
"migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts",
"migration:run": "node dist/scripts/migrate.js",
"script": "ts-node -r tsconfig-paths/register src/scripts/main.ts"
},
"dependencies": {
@@ -57,8 +59,8 @@
"@nestjs/swagger": "^11.4.2",
"@nestjs/typeorm": "^11.0.1",
"@nestjs/websockets": "^11.1.27",
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz",
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.12.tgz",
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.6.0.tgz",
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-1.0.0.tgz",
"amqp-connection-manager": "^5.0.0",
"amqplib": "^2.0.1",
"axios": "^1.16.1",

View File

@@ -1,5 +1,6 @@
import { registerAs } from "@nestjs/config";
import { TypeOrmModuleOptions } from "@nestjs/typeorm";
import { DataSourceOptions } from "typeorm";
import { join, dirname } from "path";
import {
DefaultPosition,
@@ -44,8 +45,30 @@ import {
NotificationTemplate,
} from "@tria-plc/iamapi-common";
import { OrganizationSetting } from "@tria-plc/iamapi-common/entities/iam/organization-structure/organization-setting.entity";
import { UnitSetting } from "@tria-plc/iamapi-common/entities/iam/organization-structure/unit-setting.entity";
import { UnitDetail } from "@tria-plc/iamapi-common/entities/iam/organization-structure/unit-detail.entity";
import { OrganizationDetail } from "@tria-plc/iamapi-common/entities/iam/organization-structure/organization-detail.entity";
import { UnitCluster } from "@tria-plc/iamapi-common/entities/iam/organization-structure/unit-cluster.entity";
import { Location } from "@tria-plc/iamapi-common/entities/iam/organization-structure/location.entity";
import { LocationType } from "@tria-plc/iamapi-common/entities/iam/organization-structure/location-type.entity";
import { DelegationTerminationReason } from "@tria-plc/iamapi-common/entities/iam/organization-structure/delegation-termination-reason.entity";
import { EmployeePositionActivePeriod } from "@tria-plc/iamapi-common/entities/iam/organization-structure/employee-position-active-period.entity";
import { UnitConfiguration } from "@tria-plc/iamapi-common/entities/iam/organization-structure/unit-configuration.entity";
import { Site } from "@tria-plc/iamapi-common/entities/iam/site/site.entity";
import { SiteSetting } from "@tria-plc/iamapi-common/entities/iam/site/site-setting.entity";
const iamEntities = [
UnitSetting,
UnitDetail,
OrganizationDetail,
UnitCluster,
Location,
LocationType,
DelegationTerminationReason,
EmployeePositionActivePeriod,
UnitConfiguration,
Site,
SiteSetting,
DefaultPosition,
DefaultUnit,
EmployeePosition,
@@ -95,7 +118,7 @@ const iamMigrationsGlob = join(
);
const freightMigrationsGlob = join(__dirname, "../migrations/*.js");
export default registerAs("database", (): TypeOrmModuleOptions => {
export function buildDataSourceOptions(): DataSourceOptions {
return {
type: "postgres",
host: process.env.DB_HOST ?? "localhost",
@@ -111,17 +134,19 @@ export default registerAs("database", (): TypeOrmModuleOptions => {
// The search_path is instead applied per-connection via a pool `connect`
// handler in app.module.ts (see setPoolSearchPath).
entities: [__dirname + "/../**/*.entity.{ts,js}", ...iamEntities],
autoLoadEntities: true,
migrations: [
// IAM schema + tables must be created before freight migrations
iamMigrationsGlob,
freightMigrationsGlob,
],
migrationsRun: true,
migrationsTransactionMode: "each",
// Schema changes via migrations only (synchronize breaks ITMLS backfill on existing rows).
synchronize: false,
logging:
process.env.TYPEORM_LOGGING === "true" ? true : ["error", "warn"],
};
});
}
export default registerAs("database", (): TypeOrmModuleOptions => ({
...buildDataSourceOptions(),
autoLoadEntities: true,
migrationsRun: false,
}));

View File

@@ -1,21 +1,8 @@
// apps/edr-freight-api/src/data-source.ts
import 'dotenv/config';
import { DataSource } from 'typeorm';
//import { ensurePostgresSchemas } from './utils/ensure-postgres-schemas'; // adjust path if needed
import "dotenv/config";
import { DataSource } from "typeorm";
import { buildDataSourceOptions } from "./config/database.config";
export const AppDataSource = new DataSource({
type: 'postgres',
host: process.env.DB_HOST ?? 'localhost',
port: Number(process.env.DB_PORT ?? 5433),
username: process.env.DB_USER ?? 'postgres',
password: process.env.DB_PASSWORD ?? '',
database: process.env.DB_NAME ?? 'edr_freight',
schema: 'freight', // default schema for entities without an explicit schema
entities: [__dirname + '/**/*.entity{.ts,.js}'],
migrations: [__dirname + '/migrations/*{.ts,.js}'],
synchronize: false,
logging: process.env.TYPEORM_LOGGING === 'true',
});
export const AppDataSource = new DataSource(buildDataSourceOptions());
// Optional: call ensurePostgresSchemas before initializing
// But you can also run it separately.
export default AppDataSource;

View File

@@ -0,0 +1,15 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddCbeBillPaymentMethod3050000000000 implements MigrationInterface {
name = "AddCbeBillPaymentMethod3050000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
// CBE Unified Bill Payment (docs/cbe/CBE_IMPLEMENTATION_PLAN.md §4.3) — lowercase-hyphen
// per the local convention (see 2460000000000-AddCacBankPaymentMethod).
await queryRunner.query(`ALTER TYPE freight.payments_method_enum ADD VALUE IF NOT EXISTS 'cbe-bill';`);
}
public async down(_queryRunner: QueryRunner): Promise<void> {
// PostgreSQL does not support removing enum values directly.
}
}

View File

@@ -0,0 +1,24 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Pending→Active is the only company-level approval event; `updatedAt` can't
* stand in for it since any field edit bumps that too. Nullable — existing
* companies (approved before this column existed) have no recorded moment.
*/
export class AddApprovedAtToCompanies3120000000000 implements MigrationInterface {
name = "AddApprovedAtToCompanies3120000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.companies
ADD COLUMN IF NOT EXISTS approved_at timestamptz`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.companies
DROP COLUMN IF EXISTS approved_at`,
);
}
}

View File

@@ -0,0 +1,48 @@
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
/**
* Append-only audit of company edits made before the company reaches Active
* (the onboarding phase) — that write path has no approval gate and, until
* now, left no trace of what changed (e.g. a phone number or a document).
*/
export class CreateCompanyRevisions3130000000000 implements MigrationInterface {
name = 'CreateCompanyRevisions3130000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
schema: 'freight',
name: 'company_revisions',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' },
{ name: 'company_id', type: 'uuid' },
{ name: 'actor_id', type: 'uuid', isNullable: true },
{ name: 'summary', type: 'varchar', length: '255' },
{ name: 'changes', type: 'jsonb', default: "'[]'::jsonb" },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
foreignKeys: [
{
columnNames: ['company_id'],
referencedSchema: 'freight',
referencedTableName: 'companies',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
},
],
}),
true,
);
await queryRunner.createIndex(
'freight.company_revisions',
new TableIndex({ name: 'idx_company_revisions_company', columnNames: ['company_id'] }),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('freight.company_revisions', true);
}
}

View File

@@ -470,3 +470,78 @@ describe("BillingService.issuePayable", () => {
expect(manager.update).not.toHaveBeenCalled();
});
});
describe("BillingService — CAC Bank (OTP debit)", () => {
const openInvoice = {
id: "inv-1",
status: Freight.InvoiceStatus.Pending,
source: Freight.InvoiceSource.Booking,
sourceId: "booking-1",
type: "PREPAID",
invoiceNumber: "INV-20260101-00001",
currency: "USD",
balanceAmount: 500,
totalAmount: 500,
paymentId: "intent-1",
dueAt: null,
};
const build = (payment: Record<string, unknown>) => {
const repo = {
findOne: jest.fn().mockResolvedValue(openInvoice),
update: jest.fn().mockResolvedValue(undefined),
};
const service = new BillingService(
{ getRepository: () => repo } as never,
{} as never,
{} as never,
makeEvents() as never,
payment as never,
{} as never,
{} as never,
);
return { service, repo };
};
it("rejects a CAC Bank charge with no payer mobile before calling the gateway", async () => {
const initiate = jest.fn();
const { service } = build({ initiate });
await expect(
service.payInvoice("inv-1", { method: "CAC_BANK" }),
).rejects.toThrow(/payerAccount/);
expect(initiate).not.toHaveBeenCalled();
});
it("does not settle an OTP intent at initiate — the payer still has to confirm", async () => {
const handlePaymentEvent = jest.fn();
const { service } = build({
initiate: jest.fn().mockResolvedValue({
intentId: "intent-1",
immediateSuccess: false,
response: {
intentId: "intent-1",
status: "REQUIRES_ACTION",
clientAction: { type: "COLLECT_OTP", providerOrderId: "cac-1" },
},
}),
handlePaymentEvent,
});
await service.payInvoice("inv-1", {
method: "CAC_BANK",
payerAccount: "77123456",
});
expect(handlePaymentEvent).not.toHaveBeenCalled();
});
it("confirms the OTP against the intent stamped on the invoice", async () => {
const confirmOtp = jest.fn().mockResolvedValue({ status: "SUCCEEDED" });
const { service } = build({ confirmOtp });
await service.confirmInvoiceOtp("inv-1", "123456");
expect(confirmOtp).toHaveBeenCalledWith("intent-1", "123456");
});
});

View File

@@ -12,7 +12,7 @@ import { DataSource, EntityManager, In } from "typeorm";
import { CompaniesService } from "../companies/companies.service";
import { PaymentService } from "../payment/payment.service";
import { InitiateResponseDto } from "../payment/payments.dto";
import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto";
import {
InvoiceDocumentModel,
InvoiceDocumentService,
@@ -55,6 +55,27 @@ const OPEN_STATUSES: Freight.InvoiceStatus[] = [
Freight.InvoiceStatus.Overdue,
];
/**
* Why a non-open invoice can no longer be paid, in the vocabulary the payment service's CBE
* bill-query mapper understands. Kept specific: CBE reads this back to the payer at the counter,
* so "cancelled" must not stand in for "already paid" or "refunded".
*/
function closedInvoiceReason(status: Freight.InvoiceStatus): string {
switch (status) {
case Freight.InvoiceStatus.Paid:
return "ALREADY_PAID";
case Freight.InvoiceStatus.Refunded:
return "REFUNDED";
case Freight.InvoiceStatus.Cancelled:
return "CANCELLED";
case Freight.InvoiceStatus.Expired:
return "EXPIRED";
// Draft — issued to nobody yet, so there is nothing honest to say beyond "not payable".
default:
return "NOT_PAYABLE";
}
}
/** A single line to bill on a generated invoice. */
export interface InvoiceLineInput {
chargeType: string;
@@ -352,6 +373,34 @@ export class BillingService {
return this.payInvoice(id, opts);
}
/**
* Submit the CAC Bank OTP for one of the customer's own invoices
* (ownership-checked). Settlement of the invoice happens inside the payment
* service when the OTP succeeds.
*/
async confirmInvoiceOtpForUser(
id: string,
userId: string,
otp: string,
): Promise<IntentStatusDto> {
await this.findByIdForUser(id, userId);
return this.confirmInvoiceOtp(id, otp);
}
/** OTP confirmation by invoice id — the intent is the one stamped at initiate. */
async confirmInvoiceOtp(
invoiceId: string,
otp: string,
): Promise<IntentStatusDto> {
const invoice = await this.dataSource
.getRepository(Invoice)
.findOne({ where: { id: invoiceId } });
if (!invoice?.paymentId) {
throw new NotFoundException("No payment to confirm for this invoice");
}
return this.payment.confirmOtp(invoice.paymentId, otp);
}
/** Sealed invoice PDF for one of the customer's own invoices (ownership-checked). */
async documentForUser(
id: string,
@@ -1006,6 +1055,7 @@ export class BillingService {
): Promise<InitiateResponseDto> {
const invoice = await this.dataSource.getRepository(Invoice).findOne({
where: { id: invoiceId, status: In(OPEN_STATUSES) },
relations: { company: true },
});
if (!invoice) {
throw new NotFoundException(
@@ -1035,6 +1085,17 @@ export class BillingService {
throw new BadRequestException("Invoice has no outstanding balance.");
}
// CAC Bank is an OTP debit — the bank SMSes the code to this number, so it is
// required up front (the payment service rejects it otherwise, as a 502 here).
if (
(opts.method ?? "").toUpperCase() === "CAC_BANK" &&
!opts.payerAccount?.trim()
) {
throw new BadRequestException(
"payerAccount (mobile number) is required for CAC Bank",
);
}
const result = await this.payment.initiate({
referenceId: invoice.sourceId,
source: invoice.source,
@@ -1051,6 +1112,10 @@ export class BillingService {
method: opts.method ?? "TELEBIRR",
platform: opts.platform,
payerAccount: opts.payerAccount,
// CBE_BILL: payer identity + the invoice's own due date as the bill expiry
// (docs/cbe/CBE_IMPLEMENTATION_PLAN.md §6.4).
payerName: invoice.company?.name,
expiresAt: invoice.dueAt?.toISOString(),
returnUrl: opts.returnUrl,
failureUrl: opts.failureUrl,
});
@@ -1062,7 +1127,13 @@ export class BillingService {
// Settlement is driven by the payment API (webhook/outbox → payment.succeeded);
// billing must not simulate it. Kept commented for local demos only.
if (!result.immediateSuccess) {
// An OTP intent (CAC Bank) is NOT paid yet — the payer still has to enter the
// code — so the demo shortcut must never fire for it.
if (
!result.immediateSuccess &&
result.response.clientAction?.type !== "COLLECT_OTP" &&
opts.method !== "CBE_BILL"
) {
await this.payment.handlePaymentEvent({
eventType: "payment.succeeded",
eventId: `demo-${result.intentId}`,
@@ -1107,4 +1178,67 @@ export class BillingService {
paidAt,
});
}
/**
* CBE bill-query (docs/cbe/CBE_IMPLEMENTATION_PLAN.md Phase 4): live still-payable check for
* the invoice behind a payment reference. `referenceId` is the gateway intent's referenceId,
* i.e. the invoice `sourceId`. Read-only; called while a CBE teller/app is waiting.
*/
async billQuery(referenceId: string): Promise<{
stillPayable: boolean;
payerName?: string | null;
currentAmountMinor?: number | null;
currency?: string | null;
reason?: string | null;
paymentReason?: string | null;
}> {
const repo = this.dataSource.getRepository(Invoice);
const open = await repo.findOne({
where: { sourceId: referenceId, status: In(OPEN_STATUSES) },
relations: { company: true },
order: { issuedAt: "DESC" },
});
if (open) {
const balance = Math.round(Number(open.balanceAmount ?? open.totalAmount));
const expired = open.dueAt && open.dueAt.getTime() < Date.now();
return {
stillPayable: balance > 0 && !expired,
payerName: open.company?.name ?? null,
currentAmountMinor: balance,
currency: open.currency,
// CBE shows this beside the amount on the confirmation screen — the invoice number
// the payer is holding, not our internal reference.
paymentReason: `Freight invoice ${open.invoiceNumber}`,
// Settled-in-full wins over past-due: an invoice with nothing left to pay is paid, not
// expired, and that is what the payer at the CBE counter must be told.
reason: balance > 0 ? (expired ? "EXPIRED" : null) : "ALREADY_PAID",
};
}
const latest = await repo.findOne({
where: { sourceId: referenceId },
relations: { company: true },
order: { createdAt: "DESC" },
});
// A bill reference whose invoice no longer exists at all — a data problem, not a
// cancellation the payer did anything to cause.
if (!latest) {
return {
stillPayable: false,
payerName: null,
currentAmountMinor: null,
currency: null,
reason: "NOT_FOUND",
};
}
return {
stillPayable: false,
payerName: latest.company?.name ?? null,
currentAmountMinor: Math.round(Number(latest.totalAmount)),
currency: latest.currency,
paymentReason: `Freight invoice ${latest.invoiceNumber}`,
reason: closedInvoiceReason(latest.status),
};
}
}

View File

@@ -1,5 +1,13 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsIn, IsOptional, IsString } from "class-validator";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsIn, IsNotEmpty, IsOptional, IsString } from "class-validator";
/** OTP submitted for a COLLECT_OTP provider (CAC Bank). */
export class ConfirmOtpDto {
@ApiProperty({ description: "One-time password SMSed by the bank." })
@IsString()
@IsNotEmpty()
otp!: string;
}
/** Gateway options for paying an invoice from the customer portal. */
export class PayInvoiceDto {

View File

@@ -18,7 +18,7 @@ import {
} from "../../common/resolve-auth-user-id";
import { sendPdf } from "./billing.controller";
import { BillingService } from "./billing.service";
import { PayInvoiceDto } from "./dto/pay-invoice.dto";
import { ConfirmOtpDto, PayInvoiceDto } from "./dto/pay-invoice.dto";
/**
* Customer-facing billing endpoints. Unlike {@link BillingController} (admin,
@@ -96,4 +96,20 @@ export class PortalBillingController {
failureUrl: dto.failureUrl,
});
}
@Post("my-invoices/:id/confirm")
@ApiOperation({
summary: "Confirm an OTP-debit payment (CAC Bank) for one of the customer's invoices",
})
confirmOtp(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUserPayload,
@Body() dto: ConfirmOtpDto,
) {
return this.billingService.confirmInvoiceOtpForUser(
id,
resolveAuthUserId(user),
dto.otp,
);
}
}

View File

@@ -464,6 +464,26 @@ export class BookingsController {
res.send(buffer);
}
@Get(':id/carriage-acceptance-sheet')
@ApiOperation({
summary:
'Download the carriage acceptance sheet (one per booking, lists every allocated wagon)',
})
async carriageAcceptanceSheet(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
@Res() res: Response,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
const { filename, buffer } = await this.bookingsService.carriageAcceptanceSheet(id);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(buffer);
}
@Get(':id/customer-trucks')
@ApiOperation({ summary: 'List customer self-haul trucks (multi-truck) for a booking' })
async listCustomerTrucks(

View File

@@ -70,6 +70,29 @@ export interface PaginatedBookings {
};
}
/** One wagon line on the carriage acceptance sheet (raw SQL projection). */
interface CarriageAcceptanceWagonRow {
sequenceNo: number;
wagonType: string | null;
wagonNumber: string | null;
tareWeightTons: string | null;
equatedLength: string | null;
loadCapacityTons: string | null;
allocatedWeightTons: string | null;
trainNumber: string | null;
departureAt: Date | null;
marshalledAt: string | null;
arrivalAt: string | null;
containerNumbers: string | null;
sealNumbers: string | null;
}
/** A received-but-not-yet-marshalled export line, standing in for a wagon row. */
interface CarriageAcceptanceReceivedRow {
allocatedWeightTons: string | null;
containerNumbers: string | null;
}
const URGENT_PRIORITY_THRESHOLD = 1000;
const NEEDS_ACTION_STATUSES = [
'SUBMITTED',
@@ -208,6 +231,284 @@ export class BookingsService {
};
}
/**
* Carriage acceptance sheet — one per booking, listing every wagon the booking
* occupies. Handed to the customer when EDR accepts the cargo (export) and when
* the wagons are allocated before marshalling (import), so it is only available
* once the booking has wagon allocations.
*/
async carriageAcceptanceSheet(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
const booking = await this.findById(bookingId);
let wagons: CarriageAcceptanceWagonRow[] = await this.dataSource.query(
`SELECT tsw.sequence_no AS "sequenceNo",
COALESCE(wt.code, wt.name) AS "wagonType",
w.wagon_number AS "wagonNumber",
wt.tare_weight_tons AS "tareWeightTons",
tsw.length_meters AS "equatedLength",
tsw.capacity_tons AS "loadCapacityTons",
a.allocated_weight_tons AS "allocatedWeightTons",
s.train_number AS "trainNumber",
s.scheduled_departure_date AS "departureAt",
so.label AS "marshalledAt",
sd.label AS "arrivalAt",
string_agg(DISTINCT ci.container_number, ', ') AS "containerNumbers",
string_agg(DISTINCT ci.seal_number, ', ') AS "sealNumbers"
FROM freight.wagon_booking_allocations a
JOIN freight.train_set_wagons tsw
ON tsw.id = a.train_set_wagon_id AND tsw.deleted_at IS NULL
LEFT JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id
LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id
LEFT JOIN freight.train_schedules s
ON s.train_set_id = tsw.train_set_id AND s.deleted_at IS NULL
LEFT JOIN freight.yards so ON so.id = s.origin_station_id
LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id
LEFT JOIN freight.wagon_allocation_container_items ci
ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL
WHERE a.booking_id = $1 AND a.deleted_at IS NULL
GROUP BY tsw.id, a.id, wt.code, wt.name, w.wagon_number, wt.tare_weight_tons,
s.train_number, s.scheduled_departure_date, so.label, sd.label
ORDER BY tsw.sequence_no`,
[bookingId],
);
// Export acceptance happens at the warehouse gate, not at marshalling: EDR
// takes custody of the cargo when it receives it, and the customer is handed
// this sheet then — before the booking is put on a train. So a received
// export booking gets its sheet off the received cargo, wagon columns blank
// until the consist exists. Import keeps the allocation gate: nothing is
// accepted from the customer before the wagons carry it.
//
// Receipt is proven by the warehouse GRN, but the GRN is warehouse paperwork
// and never appears on this sheet — it is only the signal that EDR has taken
// the cargo, which is what the customer's sheet attests to.
const pendingWagons = wagons.length === 0;
if (pendingWagons) {
const receivedLines: CarriageAcceptanceReceivedRow[] =
booking.tradeDirection === 'EXPORT'
? await this.dataSource.query(
`SELECT inv.weight AS "allocatedWeightTons",
c.container_number AS "containerNumbers"
FROM freight.warehouse_inventory inv
LEFT JOIN freight.containers c
ON c.id = inv.container_id AND c.deleted_at IS NULL
WHERE inv.booking_id = $1 AND inv.deleted_at IS NULL
AND COALESCE(NULLIF(TRIM(inv.grn_number), ''), '') <> ''
ORDER BY inv.created_at`,
[bookingId],
)
: [];
if (receivedLines.length === 0) {
throw new BadRequestException(
booking.tradeDirection === 'EXPORT'
? 'This export booking has no GRN yet — receive the cargo at the warehouse before issuing the carriage acceptance sheet'
: 'No wagons are allocated to this booking yet — the carriage acceptance sheet is issued after wagon allocation',
);
}
wagons = receivedLines.map((row, index) => ({
sequenceNo: index + 1,
wagonType: null,
wagonNumber: null,
tareWeightTons: null,
equatedLength: null,
loadCapacityTons: null,
allocatedWeightTons: row.allocatedWeightTons,
trainNumber: null,
departureAt: null,
marshalledAt: null,
arrivalAt: null,
containerNumbers: row.containerNumbers,
sealNumbers: null,
}));
}
const html = this.buildCarriageAcceptanceSheetHtml(booking, wagons, { pendingWagons });
const buffer = await this.pdfRender.htmlToPdfBuffer(html, {
label: 'carriage acceptance sheet',
fallback: (prepared) => buildTabularFallbackPdf(prepared),
});
return {
filename: `carriage-acceptance-${booking.reference.replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
buffer,
};
}
/**
* Split the booking amount across its wagons, proportional to allocated weight
* (equal shares when no weights are recorded). The last row absorbs the rounding
* remainder so the Price column always sums to the Total Amount on the sheet.
*/
private splitAmountAcrossWagons(total: number, weights: number[]): number[] {
const sum = weights.reduce((acc, w) => acc + w, 0);
const shares = weights.map((w) =>
Math.round((sum > 0 ? (total * w) / sum : total / weights.length) * 100) / 100,
);
const drift = Math.round((total - shares.reduce((a, b) => a + b, 0)) * 100) / 100;
shares[shares.length - 1] = Math.round((shares[shares.length - 1] + drift) * 100) / 100;
return shares;
}
private buildCarriageAcceptanceSheetHtml(
booking: Booking,
wagons: CarriageAcceptanceWagonRow[],
{ pendingWagons }: { pendingWagons: boolean },
): string {
const esc = (v: unknown) => this.escapeHtml(String(v ?? '-'));
const num = (v: unknown, digits = 3) => (Number(v) || 0).toFixed(digits);
const money = (v: number) =>
v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const departureStation = booking.originYard?.label ?? booking.originYard?.code ?? '-';
const arrivalStation = booking.destinationYard?.label ?? booking.destinationYard?.code ?? '-';
const cargoName = booking.cargoType?.cargoTypeName ?? booking.cargoFreeText ?? '-';
const currency = booking.paymentCurrency ?? 'ETB';
const totalAmount = Number(booking.adjustedTotalAmount ?? booking.totalAmount) || 0;
const prices = this.splitAmountAcrossWagons(
totalAmount,
wagons.map((w) => Number(w.allocatedWeightTons) || 0),
);
const header = wagons[0];
const sheetDate = header.departureAt ? new Date(header.departureAt) : new Date();
const totals = wagons.reduce(
(acc, w) => ({
tare: acc.tare + (Number(w.tareWeightTons) || 0),
capacity: acc.capacity + (Number(w.loadCapacityTons) || 0),
load: acc.load + (Number(w.allocatedWeightTons) || 0),
length: acc.length + (Number(w.equatedLength) || 0),
}),
{ tare: 0, capacity: 0, load: 0, length: 0 },
);
// A wagon carrying no weight and no container is running empty under this booking.
const fullWagons = wagons.filter(
(w) => (Number(w.allocatedWeightTons) || 0) > 0 || Boolean(w.containerNumbers),
).length;
const rows = wagons
.map(
(w, i) => `<tr>
<td class="num">${i + 1}</td>
<td>${esc(w.wagonType)}</td>
<td>${esc(w.wagonNumber)}</td>
<td class="num">${num(w.tareWeightTons, 2)}</td>
<td class="num">${num(w.equatedLength)}</td>
<td class="num">${num(w.loadCapacityTons)}</td>
<td>${esc(arrivalStation)}</td>
<td>${esc(cargoName)}</td>
<td>${esc(departureStation)}</td>
<td>${esc(w.containerNumbers)}</td>
<td>${esc(w.sealNumbers)}</td>
<td class="num">${money(prices[i])}</td>
</tr>`,
)
.join('');
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Carriage Acceptance Sheet</title>
<style>
@page { size: A4 landscape; margin: 10mm; }
* { box-sizing: border-box; }
body { margin: 0; color: #0f172a; font-family: Arial, sans-serif; }
.top { display: flex; justify-content: space-between; border-bottom: 3px solid #0f766e; padding-bottom: 10px; gap: 24px; }
.brand { font-size: 11px; color: #475569; text-transform: uppercase; letter-spacing: .08em; font-weight: 700; }
h1 { margin: 6px 0 0; font-size: 25px; line-height: 1.05; }
.subtitle { font-size: 11px; color: #475569; margin-top: 4px; }
.meta { text-align: right; font-size: 11px; color: #475569; min-width: 210px; }
.meta strong { display: block; margin-top: 4px; color: #0f172a; font-size: 15px; }
.summary { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin: 14px 0; }
.tile { border: 1px solid #cbd5e1; padding: 8px; min-height: 50px; }
.tile span { display: block; color: #64748b; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 4px; }
.tile strong { font-size: 11px; }
table { width: 100%; border-collapse: collapse; }
th { background: #f8fafc; color: #475569; text-align: left; }
th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; }
.num { text-align: right; }
tfoot td { background: #f8fafc; font-weight: 700; }
.notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; }
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 34px; }
.line { border-top: 1px solid #334155; padding-top: 7px; font-size: 9px; color: #475569; min-height: 34px; }
</style>
</head>
<body>
<div class="top">
<div>
<div class="brand">Ethio-Djibouti Railway S.C.</div>
<h1>Carriage Acceptance Sheet</h1>
<div class="subtitle">Booking ${esc(booking.reference)}${esc(booking.tradeDirection)}</div>
</div>
<div class="meta">
Sheet No.
<strong>CAS-${esc(booking.reference)}</strong>
Generated: ${esc(new Date().toLocaleString('en-GB'))}
</div>
</div>
<div class="summary">
<div class="tile"><span>Marshalled at</span><strong>${esc(header.marshalledAt ?? departureStation)}</strong></div>
<div class="tile"><span>Arrival at</span><strong>${esc(header.arrivalAt ?? arrivalStation)}</strong></div>
<div class="tile"><span>Date and time</span><strong>${esc(sheetDate.toLocaleString('en-GB'))}</strong></div>
<div class="tile"><span>Train No.</span><strong>${esc(header.trainNumber)}</strong></div>
<div class="tile"><span>Customer</span><strong>${esc(booking.company?.name)}</strong></div>
<div class="tile"><span>Cargo</span><strong>${esc(cargoName)}</strong></div>
</div>
<table>
<thead>
<tr>
<th class="num">SN</th>
<th>Type of Wagon</th>
<th>Wagon No.</th>
<th class="num">Tare Weight</th>
<th class="num">Equated Length</th>
<th class="num">Load Capacity</th>
<th>Arrival Station</th>
<th>Cargo Name</th>
<th>Departure Station</th>
<th>Container No.</th>
<th>Seal No.</th>
<th class="num">Price (${esc(currency)})</th>
</tr>
</thead>
<tbody>
${rows}
</tbody>
<tfoot>
<tr>
<td colspan="3">${
pendingWagons
? `Received lines: ${wagons.length} — wagons pending marshalling`
: `Total wagons: ${wagons.length} (full ${fullWagons} / empty ${wagons.length - fullWagons})`
}</td>
<td class="num">${num(totals.tare, 2)}</td>
<td class="num">${num(totals.length)}</td>
<td class="num">${num(totals.capacity)}</td>
<td colspan="5">Gross weight (tare + load): ${num(totals.tare + totals.load)} T</td>
<td class="num">${money(totalAmount)}</td>
</tr>
</tfoot>
</table>
<div class="notice">
${
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.`
}
</div>
<div class="signatures">
<div class="line">Signed by — EDR operations / date</div>
<div class="line">Signed by — customer or agent / date</div>
<div class="line">Signed by — marshalling yard / date</div>
</div>
</body>
</html>`;
}
/** Resolve trade direction from yard countries; reject client mismatch. */
/**
* An intercity corridor is valid when both yards are Ethiopian and at least

View File

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

View File

@@ -62,6 +62,7 @@ import { UpdateCompanyProfileStatusDto } from "./dto/update-company-profile-stat
import { RejectChangeRequestDto } from "./dto/reject-change-request.dto";
import { RequestDocumentChangeDto } from "./dto/request-document-change.dto";
import { ChangeRequestResponseDto } from "./dto/change-request-response.dto";
import { CompanyRevisionResponseDto } from "./dto/company-revision-response.dto";
import { FetchETradeDto } from "./dto/fetch-etrade.dto";
import { ETradeResponseDto } from "./dto/etrade-response.dto";
@@ -685,6 +686,17 @@ export class CompaniesController {
return requests.map((r) => new ChangeRequestResponseDto(r));
}
@Get(":companyId/revisions")
@BookingStaff(FREIGHT_PERMS.customers.view)
@ApiOperation({ summary: "Onboarding-phase edit history (version history)" })
async listCompanyRevisions(
@Param("companyId", ParseUUIDPipe) companyId: string,
): Promise<CompanyRevisionResponseDto[]> {
const revisions =
await this.companiesService.listCompanyRevisions(companyId);
return revisions.map((r) => new CompanyRevisionResponseDto(r));
}
@Post("change-requests/:id/approve")
@BookingStaff(FREIGHT_PERMS.customers.verify)
@ApiOperation({
@@ -719,6 +731,25 @@ export class CompaniesController {
return new ChangeRequestResponseDto(request);
}
@Post("change-requests/:id/request-changes")
@BookingStaff(FREIGHT_PERMS.customers.verify)
@ApiOperation({
summary:
"Ask for specific changes on a pending request without rejecting it (row stays open, next edit appends to it)",
})
async requestChangeRequestChanges(
@CurrentUser() user: CurrentIamUser,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: RejectChangeRequestDto,
): Promise<ChangeRequestResponseDto> {
const request = await this.companiesService.requestChangeRequestChanges(
id,
dto.note,
user.id,
);
return new ChangeRequestResponseDto(request);
}
@Post(":companyId/profiles")
@BookingStaff(FREIGHT_PERMS.customers.update)
@ApiOperation({ summary: "Add a profile (employee) to a company" })

View File

@@ -10,12 +10,17 @@ import { POA_DELEGATION_FILE_KEY } from "../file-upload-settings/poa-delegation.
* come from the verified payload, not typed. Fayda's userinfo carries no
* national ID number, so none is collected or derived here.
*
* - Ethiopian company: the owner (and its PoA, once named) is verified through
* Fayda, and their details can't be edited afterwards.
* 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.
@@ -107,6 +112,7 @@ function makeService(overrides: Partial<Ctx> = {}) {
},
changeRequestRepo: {
findPendingByCompanyId: jest.fn(async () => null),
findLatestOpenByCompanyId: jest.fn(async () => null),
findByCompanyId: jest.fn(async () => []),
create: jest.fn(async (row: Record<string, unknown>) => ({
id: "cr-1",
@@ -114,6 +120,13 @@ function makeService(overrides: Partial<Ctx> = {}) {
})),
update: jest.fn(async () => ({ id: "cr-1" })),
},
revisionRepo: {
create: jest.fn(async (row: Record<string, unknown>) => ({
id: "rev-1",
...row,
})),
findByCompanyId: jest.fn(async () => []),
},
profilesRepo: {
findByCompanyId: jest.fn(async () => []),
findByUserId: jest.fn(async () => ({
@@ -138,6 +151,7 @@ function makeService(overrides: Partial<Ctx> = {}) {
deps.companiesRepo as never,
deps.companyProfilesRepo as never,
deps.changeRequestRepo as never,
deps.revisionRepo as never,
deps.profilesRepo as never,
{} as never,
deps.filesService as never,
@@ -229,9 +243,27 @@ describe("Fayda identity verification binds a person to the company", () => {
).rejects.toBeInstanceOf(BadRequestException);
});
it("stages the change for review on an approved company", async () => {
// Swapping the person who can act for a live company is exactly what the
// backoffice review exists for, so it must not rewrite the row directly.
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,
});
@@ -242,8 +274,8 @@ describe("Fayda identity verification binds a person to the company", () => {
state: "s",
});
expect(deps.changeRequestRepo.create).toHaveBeenCalled();
expect(ctx.attributes.poaFaydaSub).toBeUndefined();
expect(deps.changeRequestRepo.create).not.toHaveBeenCalled();
expect(ctx.attributes.poaFaydaSub).toBe("new-sub");
});
it("refuses to rename a verified person by hand", async () => {
@@ -367,7 +399,29 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi
).rejects.toBeInstanceOf(BadRequestException);
});
it("grants the forwarder role to a foreign company with an owner passport and no Fayda at all", async () => {
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,
@@ -385,7 +439,7 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi
"user-1",
ProfileType.freightForwarder,
),
).resolves.toBeDefined();
).rejects.toBeInstanceOf(BadRequestException);
});
it("still requires the passport for a foreign owner who chose to verify with Fayda too", async () => {

View File

@@ -15,9 +15,11 @@ import { Company } from "./entities/company.entity";
import { ExternalProfile } from "./entities/external-profile.entity";
import { CompanyProfile } from "./entities/company-profile.entity";
import { CompanyChangeRequest } from "./entities/company-change-request.entity";
import { CompanyRevision } from "./entities/company-revision.entity";
import { Booking } from "../bookings/entities/booking.entity";
import { CompanyProfileRepository } from "./company-profile.repository";
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
import { CompanyRevisionRepository } from "./company-revision.repository";
import { ETradeService } from "./services/etrade.service";
import { CompanyNotifierService } from "./company-notifier.service";
import { VerifaydaModule } from "../verifayda/verifayda.module";
@@ -29,6 +31,7 @@ import { VerifaydaModule } from "../verifayda/verifayda.module";
ExternalProfile,
CompanyProfile,
CompanyChangeRequest,
CompanyRevision,
Booking,
]),
HttpModule,
@@ -49,6 +52,7 @@ import { VerifaydaModule } from "../verifayda/verifayda.module";
ExternalProfileRepository,
CompanyProfileRepository,
CompanyChangeRequestRepository,
CompanyRevisionRepository,
CompanyDashboardRepository,
ETradeService,
CompanyNotifierService,

View File

@@ -81,6 +81,9 @@ function makeService(overrides: Partial<Ctx> = {}) {
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<string, unknown>) => ({
id: "cr-1",
@@ -88,6 +91,13 @@ function makeService(overrides: Partial<Ctx> = {}) {
})),
update: jest.fn(async () => ({ id: "cr-1" })),
},
revisionRepo: {
create: jest.fn(async (row: Record<string, unknown>) => ({
id: "rev-1",
...row,
})),
findByCompanyId: jest.fn(async () => []),
},
profilesRepo: {
findByCompanyId: jest.fn(async () => []),
findByUserId: jest.fn(async () => ({
@@ -118,6 +128,7 @@ function makeService(overrides: Partial<Ctx> = {}) {
deps.companiesRepo as never,
deps.companyProfilesRepo as never,
deps.changeRequestRepo as never,
deps.revisionRepo as never,
deps.profilesRepo as never,
{} as never,
deps.filesService as never,

View File

@@ -39,6 +39,7 @@ function makeService(existing: ExistingProfile[]) {
companiesRepo as never,
companyProfilesRepo as never,
{} as never,
{} as never,
profilesRepo as never,
{} as never,
{} as never,

View File

@@ -1,5 +1,6 @@
import {
Injectable,
Logger,
NotFoundException,
ConflictException,
BadRequestException,
@@ -9,6 +10,11 @@ import { DataSource, EntityManager } from "typeorm";
import { CompaniesRepository } from "./companies.repository";
import { CompanyProfileRepository } from "./company-profile.repository";
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
import { CompanyRevisionRepository } from "./company-revision.repository";
import {
diffCompanyUpdate,
summarizeCompanyChanges,
} from "./company-revision-diff.util";
import { ExternalProfileRepository } from "./external-profile.repository";
import {
CompanyDashboardRepository,
@@ -64,6 +70,10 @@ import {
DocumentChangeIntent,
LicenseChangeIntent,
} from "./entities/company-change-request.entity";
import {
CompanyRevision,
CompanyRevisionChange,
} from "./entities/company-revision.entity";
/** FileRecord `resource` + `code` slots for business-license documents. */
const LICENSE_RESOURCE = "company_profiles";
@@ -81,6 +91,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" },
@@ -154,10 +186,13 @@ export interface UserIdentity {
@Injectable()
export class CompaniesService {
private readonly logger = new Logger(CompaniesService.name);
constructor(
private readonly companiesRepo: CompaniesRepository,
private readonly companyProfilesRepo: CompanyProfileRepository,
private readonly changeRequestRepo: CompanyChangeRequestRepository,
private readonly revisionRepo: CompanyRevisionRepository,
private readonly profilesRepo: ExternalProfileRepository,
private readonly dashboardRepo: CompanyDashboardRepository,
private readonly filesService: FilesService,
@@ -660,7 +695,16 @@ export class CompaniesService {
async updateCompany(id: string, dto: UpdateCompanyDto): Promise<Company> {
const before = await this.findCompanyById(id);
const updated = await this.companiesRepo.update(id, dto);
const patch: UpdateCompanyDto & { approvedAt?: Date } = { ...dto };
// Staff can also promote Pending -> Active directly through this generic
// endpoint (not just via the first-profile-approval path), so stamp it here too.
if (
dto.status === CompanyStatus.Active &&
before.status !== CompanyStatus.Active
) {
patch.approvedAt = new Date();
}
const updated = await this.companiesRepo.update(id, patch);
if (!updated) throw new NotFoundException(`Company ${id} not found`);
// Suspending or blacklisting locks the customer out, so they must be told.
@@ -818,6 +862,34 @@ export class CompaniesService {
return companyUpdates;
}
/**
* Append a version-history entry for an onboarding-phase edit (the company
* is not yet Active, so the change went straight to the live row with no
* approval gate to carry a record of it). Best-effort: a no-op patch or a
* failure to write history must never break the edit that triggered it.
*/
private async recordCompanyRevision(
before: Company,
patch: Record<string, any>,
actorId?: string | null,
extraChanges: CompanyRevisionChange[] = [],
): Promise<void> {
try {
const changes = [...diffCompanyUpdate(before, patch), ...extraChanges];
if (changes.length === 0) return;
await this.revisionRepo.create({
companyId: before.id,
actorId: actorId ?? null,
summary: summarizeCompanyChanges(changes),
changes,
});
} catch (err) {
this.logger.error(
`Failed to record company revision for ${before.id}: ${String(err)}`,
);
}
}
/** Reject a TIN already registered to a *different* company. */
private async assertTinAvailable(
company: Company,
@@ -844,10 +916,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,
@@ -879,12 +952,41 @@ export class CompaniesService {
);
if (!updated)
throw new NotFoundException(`Company ${company.id} not found`);
await this.recordCompanyRevision(company, companyUpdates, userId);
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<string, any> = {};
const staged: Record<string, any> = {};
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,
@@ -894,10 +996,14 @@ export class CompaniesService {
if (existing) {
request =
(await this.changeRequestRepo.update(existing.id, {
snapshot: { ...(existing.snapshot ?? {}), ...fields },
// Note is left untouched: if this request was ChangesRequested, the
// reviewer's ask stays visible on the resubmitted (Pending) row —
// clearing it here would hide what was asked for right when the
// reviewer comes back to check whether it was actually addressed.
snapshot: { ...(existing.snapshot ?? {}), ...staged },
submittedBy: userId,
submittedAt: now,
note: null,
status: ChangeRequestStatus.Pending,
})) ?? existing;
this.companyNotifier.changeRequestSubmitted(company, request.id, false);
} else {
@@ -910,7 +1016,7 @@ export class CompaniesService {
);
request = await this.changeRequestRepo.create({
companyId: company.id,
snapshot: fields,
snapshot: staged,
status: ChangeRequestStatus.Pending,
submittedBy: userId,
submittedAt: now,
@@ -922,8 +1028,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). */
@@ -934,6 +1041,53 @@ export class CompaniesService {
return this.changeRequestRepo.findByCompanyId(companyId);
}
/** Onboarding-phase edit history (see {@link recordCompanyRevision}), newest first. */
async listCompanyRevisions(companyId: string): Promise<CompanyRevision[]> {
await this.findCompanyById(companyId);
return this.revisionRepo.findByCompanyId(companyId);
}
/**
* Pair adjacent remove-then-add intents into one before/after revision
* change — that's exactly how a "replace" is staged (see
* `replaceProfileLicenseFile`: `[{op:'remove',...}, {op:'add',...}]`
* pushed together, and later merges only ever append after that pair, so
* adjacency is preserved). A remove or add with no adjacent partner (a pure
* add, or a pure removal) stands alone.
*/
private pairReplaceIntents<
T extends { op: "add" | "remove"; fileId: string; fileName?: string },
>(intents: T[], labelFor: (intent: T) => string): CompanyRevisionChange[] {
const changes: CompanyRevisionChange[] = [];
let i = 0;
while (i < intents.length) {
const current = intents[i];
const next = intents[i + 1];
if (current.op === "remove" && next?.op === "add") {
changes.push({
field: `document:${current.fileId}`,
label: labelFor(next),
from: current.fileName ?? null,
to: next.fileName ?? null,
fromFileId: current.fileId,
toFileId: next.fileId,
});
i += 2;
continue;
}
changes.push({
field: `document:${current.fileId}`,
label: labelFor(current),
from: current.op === "remove" ? (current.fileName ?? null) : null,
to: current.op === "add" ? (current.fileName ?? null) : null,
fromFileId: current.op === "remove" ? current.fileId : null,
toFileId: current.op === "add" ? current.fileId : null,
});
i += 1;
}
return changes;
}
/**
* Approve a pending change request: apply its snapshot to the live Company and
* mark the request approved. Any staged documents are already attached to the
@@ -963,6 +1117,30 @@ export class CompaniesService {
await this.applyLicenseChanges(request);
await this.applyDocumentChanges(request);
// This is the ONLY place post-approval FIELD/license/PoA-document changes
// land on the live row — without this call, everything the #419
// change-request flow does to those is invisible in Version History.
// `documentFileIds` (the general bulk company-documents upload) is
// deliberately NOT re-recorded here — those documents go live immediately
// at upload time and are already recorded there (see
// `uploadCompanyDocuments`); redoing it here would double the entry.
const documentChanges: CompanyRevisionChange[] = [
...this.pairReplaceIntents(
request.documents?.licenseChanges ?? [],
() => "Business license",
),
...this.pairReplaceIntents(
request.documents?.documentChanges ?? [],
(intent) => intent.code,
),
];
await this.recordCompanyRevision(
company,
companyUpdates,
reviewerId,
documentChanges,
);
return (
(await this.changeRequestRepo.update(id, {
status: ChangeRequestStatus.Approved,
@@ -973,12 +1151,62 @@ export class CompaniesService {
);
}
/**
* A fresh upload under a single-file document slot (`isMultiple: false`)
* replaces whatever was there, not adds to it — soft-delete the prior live
* file(s) for that code, and describe each replacement (plus each genuinely
* new upload) as a revision change carrying both file ids, so the reviewer
* can open the previous and current file. Multi-file slots are left alone
* (genuinely additive, no single "the" document to diff against). Unrecognised
* codes (no matching field in the nationality's document setting) are also
* left alone — safer to under-clean than to guess wrong. Independent of the
* change-request review outcome: nothing else in this flow ever retires a
* superseded document, on approve OR reject — these documents go live the
* moment they're uploaded.
*/
private async replaceSingleFileCompanyDocuments(
company: Company,
before: FileRecord[],
uploaded: FileRecord[],
): Promise<CompanyRevisionChange[]> {
const setting = await this.fileUploadSettingsService
.getByCode(this.documentSettingCodeFor(company.nationality))
.catch(() => null);
const fields = setting?.fields ?? [];
const singleFileCodes = new Set(
fields.filter((f) => !f.isMultiple).map((f) => f.fileKey),
);
const labelByCode = new Map(fields.map((f) => [f.fileKey, f.fileLabel]));
const uploadedIds = new Set(uploaded.map((f) => f.id));
const changes: CompanyRevisionChange[] = [];
const toRemove: FileRecord[] = [];
for (const file of uploaded) {
if (!singleFileCodes.has(file.code)) continue;
const prior = before.find(
(f) => f.code === file.code && !uploadedIds.has(f.id),
);
changes.push({
field: `document:${file.code}`,
label: labelByCode.get(file.code) ?? file.code,
from: prior?.name ?? null,
to: file.name,
fromFileId: prior?.id ?? null,
toFileId: file.id,
});
if (prior) toRemove.push(prior);
}
await Promise.all(toRemove.map((f) => this.filesService.remove(f.id)));
return changes;
}
/**
* Upload company documents. For an approved company this also opens/updates a
* pending change request (recording the uploaded file ids) so the upload is
* reviewed and the customer is locked until it clears — consistent with the
* field-edit review. During onboarding (company not yet active) it's a plain
* upload with no review.
* upload with no review. Either way the documents go live immediately, so
* the revision history is recorded right away too, not gated on a decision.
*/
async uploadCompanyDocuments(
companyId: string,
@@ -986,11 +1214,20 @@ export class CompaniesService {
submittedBy?: string,
): Promise<FileRecord[]> {
const company = await this.findCompanyById(companyId);
const before = await this.filesService.findByResource(
companyId,
"companies",
);
const uploaded = await this.filesService.uploadMany(
companyId,
"companies",
files,
);
const documentChanges = await this.replaceSingleFileCompanyDocuments(
company,
before,
uploaded,
);
await this.resolveDocumentChangeRequests(
companyId,
"companies",
@@ -1004,6 +1241,9 @@ export class CompaniesService {
submittedBy,
);
}
if (documentChanges.length > 0) {
await this.recordCompanyRevision(company, {}, submittedBy, documentChanges);
}
return uploaded;
}
@@ -1118,7 +1358,8 @@ export class CompaniesService {
},
submittedBy: submittedBy ?? existing.submittedBy ?? null,
submittedAt: now,
note: null,
// Note left untouched — see the comment in updateProfile's merge branch.
status: ChangeRequestStatus.Pending,
});
if (company) {
this.companyNotifier.changeRequestSubmitted(company, existing.id, false);
@@ -1179,6 +1420,37 @@ export class CompaniesService {
);
}
/**
* Ask for specific fixes without rejecting outright: unlike
* {@link rejectChangeRequest}, staged license/document intents are kept (the
* row stays open), so the customer's next edit is appended to this SAME
* request — via the merge branches in `updateProfile`/`stageDocumentChange`/
* `stageLicenseChange`/`stageDocumentIntent`/`stageIdentityChange` — instead
* of starting a fresh cycle.
*/
async requestChangeRequestChanges(
id: string,
note: string,
reviewerId?: string,
): Promise<CompanyChangeRequest> {
const request = await this.changeRequestRepo.findById(id);
if (!request)
throw new NotFoundException(`Change request ${id} not found`);
if (request.status !== ChangeRequestStatus.Pending) {
throw new BadRequestException(
`Change request ${id} is already ${request.status}`,
);
}
return (
(await this.changeRequestRepo.update(id, {
status: ChangeRequestStatus.ChangesRequested,
note,
reviewedBy: reviewerId ?? null,
reviewedAt: new Date(),
})) ?? request
);
}
async deleteCompany(id: string): Promise<void> {
await this.findCompanyById(id);
await this.companiesRepo.softDelete(id);
@@ -1420,6 +1692,7 @@ export class CompaniesService {
) {
await companyRepo.update(updated.companyId, {
status: CompanyStatus.Active,
approvedAt: new Date(),
});
this.companyNotifier.companyApproved(company);
}
@@ -1720,16 +1993,11 @@ export class CompaniesService {
const poaProvided = POA_ATTRIBUTES.some((k) =>
(company.attributes?.[k] as string | undefined)?.trim(),
);
// An Ethiopian company does not type its PoA details at all — they arrive
// from the Fayda verification — so reporting them as missing fields would
// ask for something the form no longer offers. The identity block below
// 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 =
poaRequired && !identity.faydaRequired
? REQUIRED_POA_FIELDS.filter(
(f) => !(company.attributes?.[f.key] as string | undefined)?.trim(),
)
: [];
const missingPoaFields: typeof REQUIRED_POA_FIELDS = [];
const delegation = await this.getPoaDelegationState(company.id);
const delegationDue = poaRequired || poaProvided;
const missingDelegation = delegationDue && !delegation.onFile;
@@ -1754,9 +2022,7 @@ export class CompaniesService {
...(identity.faydaRequired && !identity.owner.verified
? ["Verify the company owner's identity with Fayda"]
: []),
...(identity.faydaRequired &&
(poaRequired || poaProvided) &&
!identity.poa.verified
...((poaRequired || poaProvided) && !identity.poa.verified
? ["Verify your Power of Attorney's identity with Fayda"]
: []),
...(identity.passportRequired && !identity.owner.passportNumber
@@ -1768,26 +2034,20 @@ export class CompaniesService {
// fields, required documents, one license per operational profile, and the
// PoA details/paper whenever those are mandatory.
const requiredDocCount = documents.filter((d) => d.isRequired).length;
const poaItemCount =
(poaRequired && !identity.faydaRequired
? REQUIRED_POA_FIELDS.length
: 0) + (delegationDue ? 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), the PoA once there
// is one and Fayda is what's mandatory here.
const identityItemCount = identity.faydaRequired
? delegationDue
? 2
: 1
: identity.passportRequired
? 1
: 0;
const missingIdentityCount = identity.faydaRequired
? (identity.owner.verified ? 0 : 1) +
(delegationDue && !identity.poa.verified ? 1 : 0)
: identity.passportRequired && !identity.owner.passportNumber
? 1
: 0;
// 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 =
requiredInfo.length +
requiredDocCount +
@@ -2225,7 +2485,8 @@ export class CompaniesService {
},
submittedBy: submittedBy ?? existing.submittedBy ?? null,
submittedAt: now,
note: null,
// Note left untouched — see the comment in updateProfile's merge branch.
status: ChangeRequestStatus.Pending,
});
} else {
await this.changeRequestRepo.create({
@@ -2453,11 +2714,13 @@ export class CompaniesService {
...(result.address ? { [`${prefix}Address`]: result.address } : {}),
};
// An approved company's profile edits are staged for backoffice review, and
// swapping the person who can act for the company is exactly the kind of
// edit that review exists for — so a verification lands the same way an
// ordinary edit does, rather than quietly rewriting a live record.
if (company.status === CompanyStatus.Active) {
// 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);
}
@@ -2549,7 +2812,8 @@ export class CompaniesService {
snapshot,
submittedBy: userId,
submittedAt: now,
note: null,
// Note left untouched — see the comment in updateProfile's merge branch.
status: ChangeRequestStatus.Pending,
});
this.companyNotifier.changeRequestSubmitted(company, existing.id, false);
return;
@@ -2586,21 +2850,23 @@ export class CompaniesService {
): 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.",
);
}
return;
}
if (!state.owner.verified) {
} 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(),
);
@@ -2827,7 +3093,8 @@ export class CompaniesService {
},
submittedBy: submittedBy ?? existing.submittedBy ?? null,
submittedAt: now,
note: null,
// Note left untouched — see the comment in updateProfile's merge branch.
status: ChangeRequestStatus.Pending,
});
} else {
await this.changeRequestRepo.create({

View File

@@ -1,4 +1,4 @@
import { Repository } from "typeorm";
import { FindOperator, Repository } from "typeorm";
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
import {
@@ -10,6 +10,16 @@ type Row = Pick<CompanyChangeRequest, "id" | "status"> & { createdAt: Date };
const COMPANY_ID = "company-1";
/** Matches a row's status against either a plain value or an `In([...])` operator. */
function statusMatches(
rowStatus: ChangeRequestStatus,
where: ChangeRequestStatus | FindOperator<ChangeRequestStatus> | undefined,
): boolean {
if (where === undefined) return true;
if (where instanceof FindOperator) return where.value.includes(rowStatus);
return rowStatus === where;
}
/**
* Stands in for the TypeORM repository over a fixed set of rows, honouring the
* `where.status` filter and the `createdAt DESC` ordering findOne relies on.
@@ -17,13 +27,17 @@ const COMPANY_ID = "company-1";
function mockRepositoryOver(rows: Row[]) {
return {
findOne: jest.fn(
({ where }: { where: Partial<Row> & { companyId: string } }) =>
({
where,
}: {
where: { companyId: string; status?: Row["status"] | FindOperator<Row["status"]> };
}) =>
Promise.resolve(
rows
.filter(
(row) =>
where.companyId === COMPANY_ID &&
(where.status === undefined || row.status === where.status),
statusMatches(row.status, where.status),
)
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0] ??
null,
@@ -57,6 +71,21 @@ describe("CompanyChangeRequestRepository.findLatestOpenByCompanyId", () => {
expect(result?.id).toBe("pending");
});
it("treats a changes-requested request as open, same as pending", async () => {
const changesRequested: Row = {
id: "changes-requested",
status: ChangeRequestStatus.ChangesRequested,
createdAt: new Date("2026-01-02T00:00:00.000Z"),
};
const result = await subject([
rejected,
changesRequested,
]).findLatestOpenByCompanyId(COMPANY_ID);
expect(result?.id).toBe("changes-requested");
});
it("returns the latest rejected request when nothing is pending", async () => {
const result = await subject([rejected]).findLatestOpenByCompanyId(
COMPANY_ID,

View File

@@ -1,12 +1,18 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { In, Repository } from "typeorm";
import { BaseRepository } from "@edr/api-common";
import {
ChangeRequestStatus,
CompanyChangeRequest,
} from "./entities/company-change-request.entity";
/** Statuses that mean "still open, awaiting the customer's next edit" — Pending and ChangesRequested behave identically here, they just carry a note or not. */
const OPEN_FOR_EDIT_STATUSES = [
ChangeRequestStatus.Pending,
ChangeRequestStatus.ChangesRequested,
];
@Injectable()
export class CompanyChangeRequestRepository extends BaseRepository<CompanyChangeRequest> {
constructor(
@@ -16,12 +22,12 @@ export class CompanyChangeRequestRepository extends BaseRepository<CompanyChange
super(repo);
}
/** The company's current pending request, if any. */
/** The company's current open request (Pending or ChangesRequested), if any — the row the next edit appends to. */
async findPendingByCompanyId(
companyId: string,
): Promise<CompanyChangeRequest | null> {
return this.repository.findOne({
where: { companyId, status: ChangeRequestStatus.Pending },
where: { companyId, status: In(OPEN_FOR_EDIT_STATUSES) },
order: { createdAt: "DESC" },
});
}

View File

@@ -0,0 +1,90 @@
import type { Company } from "./entities/company.entity";
import type { CompanyRevisionChange } from "./entities/company-revision.entity";
/** Human label per audited company field — anything not listed here is skipped (internal/lock fields like `*FaydaSub`). */
export const COMPANY_FIELD_LABELS: Record<string, string> = {
name: "Company name",
phone: "Phone",
email: "Email",
address: "Address",
country: "Country",
tin: "TIN",
vatNumber: "VAT number",
fanNumber: "FAN number",
nationality: "Nationality",
website: "Website",
licenceNumber: "Licence number",
region: "Region",
zone: "Zone",
woreda: "Woreda",
kebele: "Kebele",
houseNo: "House No",
contactPersonName: "Contact person name",
contactPersonPhone: "Contact person phone",
contactPersonEmail: "Contact person email",
contactPersonPosition: "Contact person position",
generalManagerName: "General manager name",
generalManagerPhone: "General manager phone",
generalManagerEmail: "General manager email",
poaName: "PoA name",
poaPhone: "PoA phone",
poaEmail: "PoA email",
poaLocation: "PoA location",
poaAddress: "PoA address",
documents: "Document",
};
function displayValue(value: unknown): string | null {
if (value === null || value === undefined || value === "") return null;
if (typeof value === "boolean") return value ? "Yes" : "No";
return String(value);
}
/**
* Compare the company row before a write against the patch about to be
* applied (the same shape `mapProfileDtoToCompanyUpdates` returns: scalar
* columns plus a merged `attributes` blob). Only fields with a known label
* are reported, so identity-lock bookkeeping (`ownerFaydaSub`, etc.) never
* shows up as noise.
*/
export function diffCompanyUpdate(
before: Company,
patch: Record<string, any>,
): CompanyRevisionChange[] {
const changes: CompanyRevisionChange[] = [];
const { attributes: attrPatch, ...columnPatch } = patch;
for (const [field, nextRaw] of Object.entries(columnPatch)) {
const label = COMPANY_FIELD_LABELS[field];
if (!label) continue;
const next = displayValue(nextRaw);
const previous = displayValue((before as unknown as Record<string, unknown>)[field]);
if (next === previous) continue;
changes.push({ field, label, from: previous, to: next });
}
if (attrPatch) {
const beforeAttrs = before.attributes ?? {};
for (const [field, nextRaw] of Object.entries(attrPatch)) {
const label = COMPANY_FIELD_LABELS[field];
if (!label) continue;
const next = displayValue(nextRaw);
const previous = displayValue(beforeAttrs[field]);
if (next === previous) continue;
changes.push({ field, label, from: previous, to: next });
}
}
return changes;
}
/** Short human summary of a change set, e.g. "phone, address changed". */
export function summarizeCompanyChanges(
changes: CompanyRevisionChange[],
): string {
if (changes.length === 0) return "No changes";
const labels = changes.map((c) => c.label.toLowerCase());
return labels.length <= 3
? `${labels.join(", ")} changed`
: `${labels.length} fields changed`;
}

View File

@@ -0,0 +1,23 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { BaseRepository } from "@edr/api-common";
import { CompanyRevision } from "./entities/company-revision.entity";
@Injectable()
export class CompanyRevisionRepository extends BaseRepository<CompanyRevision> {
constructor(
@InjectRepository(CompanyRevision)
repo: Repository<CompanyRevision>,
) {
super(repo);
}
/** Revision history for a company, newest first. */
async findByCompanyId(companyId: string): Promise<CompanyRevision[]> {
return this.repository.find({
where: { companyId },
order: { createdAt: "DESC" },
});
}
}

View File

@@ -13,9 +13,12 @@ export class CompanyInfoResponseDto {
/**
* Open profile-edit review, if any. Drives the portal-wide lock (pending →
* settings + new-contract/booking creation disabled) and the reapply banner.
* `changes_requested` is the soft variant of `rejected`: same edit-and-resubmit
* call to action, but the customer's edit appends to this SAME request
* instead of starting a fresh one.
*/
review: {
status: 'pending' | 'rejected';
status: 'pending' | 'rejected' | 'changes_requested';
note: string | null;
} | null;
@@ -30,12 +33,13 @@ export class CompanyInfoResponseDto {
const open =
changeRequest &&
(changeRequest.status === ChangeRequestStatus.Pending ||
changeRequest.status === ChangeRequestStatus.Rejected)
changeRequest.status === ChangeRequestStatus.Rejected ||
changeRequest.status === ChangeRequestStatus.ChangesRequested)
? changeRequest
: null;
this.review = open
? {
status: open.status as 'pending' | 'rejected',
status: open.status as 'pending' | 'rejected' | 'changes_requested',
note: open.note ?? null,
}
: null;

View File

@@ -0,0 +1,23 @@
import {
CompanyRevision,
CompanyRevisionChange,
} from "../entities/company-revision.entity";
/** One version-history entry, shown on the backoffice customer detail page. */
export class CompanyRevisionResponseDto {
id: string;
companyId: string;
actorId: string | null;
summary: string;
changes: CompanyRevisionChange[];
createdAt: Date;
constructor(revision: CompanyRevision) {
this.id = revision.id;
this.companyId = revision.companyId;
this.actorId = revision.actorId ?? null;
this.summary = revision.summary;
this.changes = revision.changes ?? [];
this.createdAt = revision.createdAt;
}
}

View File

@@ -144,9 +144,14 @@ export function buildCompanyIdentityState(
(p) => p.type === ProfileType.freightForwarder,
) || POA_KEYS.some((k) => (attrs[k] as string | undefined)?.trim());
const complete = faydaRequired
? owner.verified && (!poaDue || poa.verified)
// 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 };
}

View File

@@ -68,10 +68,12 @@ export class ProfileResponseDto {
/**
* Open profile-edit review, if any. `reviewStatus === "pending"` locks the
* settings page; `"rejected"` surfaces the note and prefills the (declined)
* proposed values from `pendingChanges` so the customer can amend & resubmit.
* settings page; `"rejected"`/`"changes_requested"` both surface the note and
* prefill the proposed values from `pendingChanges` so the customer can amend
* & resubmit — `"changes_requested"` just appends the edit to this same
* request instead of starting a fresh one.
*/
reviewStatus: "pending" | "rejected" | null;
reviewStatus: "pending" | "rejected" | "changes_requested" | null;
reviewNote: string | null;
pendingChanges: Record<string, any> | null;
@@ -127,7 +129,8 @@ export class ProfileResponseDto {
const openReview =
changeRequest &&
(changeRequest.status === ChangeRequestStatus.Pending ||
changeRequest.status === ChangeRequestStatus.Rejected)
changeRequest.status === ChangeRequestStatus.Rejected ||
changeRequest.status === ChangeRequestStatus.ChangesRequested)
? changeRequest
: null;
this.reviewStatus =
@@ -135,7 +138,9 @@ export class ProfileResponseDto {
? "pending"
: openReview?.status === ChangeRequestStatus.Rejected
? "rejected"
: null;
: openReview?.status === ChangeRequestStatus.ChangesRequested
? "changes_requested"
: null;
this.reviewNote = openReview?.note ?? null;
this.pendingChanges = openReview?.snapshot ?? null;
this.identity = buildCompanyIdentityState(company);

View File

@@ -97,6 +97,7 @@ export class ResponseCompanyDto {
createdAt: Date;
updatedAt: Date;
approvedAt: Date | null;
constructor(company: Company) {
this.id = company.id;
@@ -135,5 +136,6 @@ export class ResponseCompanyDto {
this.identity = buildCompanyIdentityState(company);
this.createdAt = company.createdAt;
this.updatedAt = company.updatedAt;
this.approvedAt = company.approvedAt ?? null;
}
}

View File

@@ -5,14 +5,18 @@ import { Company } from "./company.entity";
/**
* Lifecycle of a customer's proposed profile change. Edits made on the portal
* settings page by an already-approved company are staged here (not written to
* the live Company row) until a backoffice reviewer approves — at which point
* the snapshot is applied — or rejects with a note, after which the customer can
* amend and resubmit.
* the live Company row) until a backoffice reviewer resolves it:
* - Approved — the snapshot is applied to the live Company row.
* - Rejected — terminal for this row; the customer's next edit starts a fresh one.
* - ChangesRequested — soft: the row stays open with the reviewer's note attached,
* so the customer's next edit is appended (merged) into this SAME row instead
* of starting a new cycle.
*/
export enum ChangeRequestStatus {
Pending = "pending",
Approved = "approved",
Rejected = "rejected",
ChangesRequested = "changes_requested",
}
/**

View File

@@ -0,0 +1,46 @@
import { BaseEntity } from "@edr/api-common";
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
import { Company } from "./company.entity";
/**
* One recorded field/document change, as shown on the customer's version
* history. A document change carries `fromFileId`/`toFileId` alongside the
* display names, so the reviewer can open the previous and current file —
* not just read that "a document changed."
*/
export interface CompanyRevisionChange {
field: string;
label: string;
from: string | null;
to: string | null;
fromFileId?: string | null;
toFileId?: string | null;
}
/**
* Append-only audit of edits made to a company record BEFORE it reaches
* `Active` (the onboarding phase), where {@link CompaniesService.updateProfile}
* and {@link CompaniesService.uploadCompanyDocuments} write straight to the
* live row with no approval gate — and, until this entity, no trace at all.
* Post-approval edits already get history via `CompanyChangeRequest`; this
* covers the gap before that gate exists.
*/
@Entity({ schema: "freight", name: "company_revisions" })
@Index(["companyId"])
export class CompanyRevision extends BaseEntity {
@Column({ name: "company_id", type: "uuid" })
companyId!: string;
@ManyToOne(() => Company, { onDelete: "CASCADE" })
@JoinColumn({ name: "company_id" })
company?: Company;
@Column({ name: "actor_id", type: "uuid", nullable: true })
actorId?: string | null;
@Column({ name: "summary", type: "varchar", length: 255 })
summary!: string;
@Column({ name: "changes", type: "jsonb", default: () => `'[]'::jsonb` })
changes!: CompanyRevisionChange[];
}

View File

@@ -61,6 +61,10 @@ export class Company extends BaseEntity {
})
status!: CompanyStatus;
/** Set when the company is first promoted Pending → Active. Null for companies approved before this column existed. */
@Column({ name: "approved_at", type: "timestamptz", nullable: true })
approvedAt?: Date | null;
@Column({ name: "tin", type: "varchar", length: 10, unique: true })
tin!: string;

View File

@@ -51,7 +51,11 @@ export class FilesController {
@Query("download") download: string | undefined,
@Res() res: Response,
) {
const record = await this.filesService.findById(fileId);
// Includes soft-deleted records: a superseded document (replaced via a
// single-file document slot, or resolved as part of a license/PoA swap)
// is only reachable by UUID through the change-request/version-history
// diff, where reviewers need to open the "previous" file to compare it.
const record = await this.filesService.findByIdIncludingDeleted(fileId);
// Chat attachments are cross-tenant sensitive and this route has no
// ownership check, so a leaked/guessed UUID would hand one company's file to
@@ -63,7 +67,9 @@ export class FilesController {
);
}
const { stream } = await this.filesService.streamById(fileId);
const { stream } = await this.filesService.streamById(fileId, {
includeDeleted: true,
});
const forceDownload = download === "1" || download === "true";
const disposition = forceDownload ? "attachment" : "inline";

View File

@@ -245,6 +245,23 @@ export class FilesService {
return record;
}
/**
* Same as {@link findById}, but also matches a soft-deleted record — a
* superseded document (replaced via a single-file slot, or a resolved
* license/PoA swap) is exactly this: gone from every live listing, but its
* id is still handed to reviewers in the change-request/version-history
* diff so they can open the "previous" file for comparison. Only the
* preview/download route should use this; every other caller wants the
* default (soft-deleted = not found).
*/
async findByIdIncludingDeleted(id: string): Promise<FileRecord> {
const record = await this.filesRepository.findById(id, {
withDeleted: true,
});
if (!record) throw new NotFoundException(`File ${id} not found`);
return record;
}
/**
* Record a reviewer verdict on one document. `change_requested` keeps the note
* (the customer sees it verbatim); any other verdict clears it, so a stale
@@ -360,8 +377,11 @@ export class FilesService {
async streamById(
id: string,
opts: { includeDeleted?: boolean } = {},
): Promise<{ stream: Readable; record: FileRecord }> {
const record = await this.findById(id);
const record = opts.includeDeleted
? await this.findByIdIncludingDeleted(id)
: await this.findById(id);
const objectName = this.minioService.getObjectNameFromUrl(record.url);
const stream = await this.minioService.getFileStream(objectName);
return { stream, record };

View File

@@ -4,7 +4,7 @@ import { PaymentRefundEntity } from "./payment-refund.entity";
/** Invoice source that owns the intent ('booking', 'demurrage', …) — caller-supplied. */
type PaymentType = string
type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr" | "waafi" | "card" | "dmoney" | "cac-bank"
type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr" | "waafi" | "card" | "dmoney" | "cac-bank" | "cbe-bill"
type Currency = "ETB" | "USD"
export type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded"
@@ -22,7 +22,7 @@ export class PaymentEntity extends BaseEntity {
@Column({ type: "varchar", length: 40, nullable: true, name: "reference_type" })
referenceType?: string;
@Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr", "waafi", "card", "dmoney", "cac-bank"] })
@Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr", "waafi", "card", "dmoney", "cac-bank", "cbe-bill"] })
method!: PaymentMethod
@Column({ type: "enum", enum: ["ETB", "USD"] })

View File

@@ -1,30 +1,42 @@
import {
Body,
Controller,
forwardRef,
HttpCode,
HttpStatus,
Inject,
Logger,
Post,
UseGuards,
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { Public } from "@edr/api-common";
import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payment.dto";
import {
PaymentEventDto,
MarkPaidResponseDto,
BillQueryRequestDto,
BillQueryResponseDto,
} from "./internal-payment.dto";
import { PaymentService } from "./payment.service";
import { BillingService } from "../billing/billing.service";
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
/**
* Consumer side of the payment microservice's outbox relay.
* WARNING: currently unauthenticated — anyone who can reach the API can mark
* payments as paid. Re-add ServiceAuthGuard before exposing beyond a trusted network.
* Consumer side of the payment microservice's outbox relay. Only the payment service may
* call this (shared service token — restored per docs/cbe/CBE_IMPLEMENTATION_PLAN.md R8).
* Idempotent by design — the relay delivers at-least-once, so duplicates must be harmless.
* Becomes a queue consumer via PaymentEventsConsumer when RabbitMQ is available;
* this HTTP endpoint remains as a transport-agnostic fallback.
*/
@ApiTags("Internal Payments")
@Public()
@UseGuards(ServiceAuthGuard)
@Controller("internal/payments")
export class InternalPaymentController {
private readonly logger = new Logger(InternalPaymentController.name);
constructor(private readonly paymentService: PaymentService) { }
constructor(
private readonly paymentService: PaymentService,
@Inject(forwardRef(() => BillingService))
private readonly billingService: BillingService,
) { }
@Post("mark-paid")
@HttpCode(HttpStatus.OK)
@@ -36,4 +48,16 @@ export class InternalPaymentController {
this.logger.log(`Marking payment ${event} as PAID`);
return this.paymentService.handlePaymentEvent(event);
}
@Post("bill-query")
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary:
"Live still-payable check + payer name for a CBE bill (called while CBE is on the line)",
})
async billQuery(
@Body() request: BillQueryRequestDto,
): Promise<BillQueryResponseDto> {
return this.billingService.billQuery(request.referenceId);
}
}

View File

@@ -51,3 +51,30 @@ export class MarkPaidResponseDto {
@ApiPropertyOptional() alreadyFinalized?: boolean;
@ApiPropertyOptional() reason?: string;
}
/**
* CBE bill-query hop (docs/cbe/CBE_IMPLEMENTATION_PLAN.md Phase 4): the payment service asks
* "is this invoice still payable, by whom, for how much" while a CBE channel is on the line.
*/
export class BillQueryRequestDto {
// Typed `string`, not the enum: the @nestjs/swagger CLI plugin resolves an enum-typed
// property to a relative require() into packages/types, which does not exist inside the
// Docker image (only /app is copied) and crashes at boot with MODULE_NOT_FOUND. The
// decorators below still give us enum docs + runtime validation.
@ApiProperty({ enum: PaymentReferenceType })
@IsEnum(PaymentReferenceType)
referenceType!: string;
@ApiProperty() @IsString() referenceId!: string;
}
export class BillQueryResponseDto {
@ApiProperty() stillPayable!: boolean;
@ApiPropertyOptional() payerName?: string | null;
@ApiPropertyOptional() currentAmountMinor?: number | null;
@ApiPropertyOptional() currency?: string | null;
/** When stillPayable=false: "ALREADY_PAID" | "CANCELLED" | "REFUNDED" | "EXPIRED" | "NOT_FOUND" | "NOT_PAYABLE". */
@ApiPropertyOptional() reason?: string | null;
/** What the payer is paying for — CBE renders it beside the amount (Payment_Reason). */
@ApiPropertyOptional() paymentReason?: string | null;
}

View File

@@ -1,12 +1,17 @@
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";
import {
InitiatePaymentRequest,
PaymentIntentSnapshot,
PaymentReferenceType,
PaymentService,
InitiatePaymentRequest,
PaymentIntentSnapshot,
PaymentReferenceType,
PaymentService,
} from "@edr/types";
/**
@@ -16,85 +21,125 @@ import {
*/
@Injectable()
export class PaymentClientService {
private readonly logger = new Logger(PaymentClientService.name);
private readonly baseUrl = (
// process.env.PAYMENT_API_URL ??
"https://paymentcallback.triaplc.com"
// "http://localhost:3003"
).replace(/\/$/, "");
private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? "";
private readonly logger = new Logger(PaymentClientService.name);
private readonly baseUrl = (
process.env.PAYMENT_API_URL ?? "https://paymentcallback.triaplc.com"
)
// "http://localhost:3003"
.replace(/\/$/, "");
private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? "";
constructor(private readonly http: HttpService) { }
constructor(private readonly http: HttpService) { }
/** POST /payments/initiate — idempotent per (service, referenceType, referenceId). */
async initiate(request: InitiatePaymentRequest): Promise<PaymentIntentSnapshot> {
return this.call("POST", "/payments/initiate", request);
/** POST /payments/initiate — idempotent per (service, referenceType, referenceId). */
async initiate(
request: InitiatePaymentRequest,
): Promise<PaymentIntentSnapshot> {
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,
referenceId: string,
): Promise<PaymentIntentSnapshot | null> {
const query = new URLSearchParams({
service: PaymentService.FREIGHT,
referenceType,
referenceId,
});
try {
return await this.call("GET", `/payments/intents?${query.toString()}`);
} catch (err) {
if (err instanceof AxiosError && err.response?.status === 404)
return null;
throw err;
}
}
/**
* 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,
});
/**
* 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<PaymentIntentSnapshot> {
try {
return await this.call<PaymentIntentSnapshot>(
"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;
}
}
/** GET /payments/intents?… — active intent by domain reference; null when none exists. */
async getIntentByReference(
referenceType: PaymentReferenceType,
referenceId: string,
): Promise<PaymentIntentSnapshot | null> {
const query = new URLSearchParams({
service: PaymentService.FREIGHT,
referenceType,
referenceId,
});
try {
return await this.call("GET", `/payments/intents?${query.toString()}`);
} catch (err) {
if (err instanceof AxiosError && err.response?.status === 404) return null;
throw err;
}
}
private async call<T>(method: "GET" | "POST", path: string, body?: unknown): Promise<T> {
const url = `${this.baseUrl}${path}`;
try {
const response = await firstValueFrom(
this.http.request<T>({
method,
url,
data: body,
headers: this.serviceToken
? { "x-service-token": this.serviceToken }
: {},
}),
);
return response.data;
} catch (err) {
if (err instanceof AxiosError && err.response) {
if (err.response.status === 404) throw err;
const detail =
(err.response.data as { message?: string | string[] })?.message ??
err.message;
this.logger.error(
`payment service ${method} ${path}${err.response.status}: ${detail}`,
);
throw new BadGatewayException(`Payment service error: ${detail}`);
}
const message = err instanceof Error && err.message ? err.message : String(err);
this.logger.error(`payment service unreachable (${method} ${path}): ${message}`);
throw new BadGatewayException("Payment service unreachable");
}
private async call<T>(
method: "GET" | "POST",
path: string,
body?: unknown,
): Promise<T> {
const url = `${this.baseUrl}${path}`;
try {
const response = await firstValueFrom(
this.http.request<T>({
method,
url,
data: body,
headers: this.serviceToken
? { "x-service-token": this.serviceToken }
: {},
}),
);
return response.data;
} catch (err) {
if (err instanceof AxiosError && err.response) {
if (err.response.status === 404) throw err;
const detail =
(err.response.data as { message?: string | string[] })?.message ??
err.message;
this.logger.error(
`payment service ${method} ${path}${err.response.status}: ${detail}`,
);
throw new BadGatewayException(`Payment service error: ${detail}`);
}
const message =
err instanceof Error && err.message ? err.message : String(err);
this.logger.error(
`payment service unreachable (${method} ${path}): ${message}`,
);
throw new BadGatewayException("Payment service unreachable");
}
}
}

View File

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

View File

@@ -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<string, unknown> = {}) {
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<string, unknown>[]) {
const store = [...rows];
return {
findOneBy: jest.fn((where: Record<string, unknown>) =>
Promise.resolve(
store.find((r) =>
Object.entries(where).every(([k, v]) => r[k] === v),
) ?? null,
),
),
update: jest.fn((where: { id: string }, data: Record<string, unknown>) => {
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<PaymentClientService>,
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,
);
});
});

View File

@@ -51,6 +51,10 @@ export interface InitiateIntentInput {
payerAccount?: string;
returnUrl?: string;
failureUrl?: string;
/** CBE_BILL: payer full name snapshot (feeds CBE's mandatory Full_Name). */
payerName?: string;
/** CBE_BILL: intent expiry, ISO-8601 — the invoice due date, never a session TTL. */
expiresAt?: string;
}
export interface InitiateIntentResult {
@@ -79,6 +83,7 @@ const PROVIDER_TO_METHOD: Record<string, PaymentEntity["method"]> = {
CARD: "card",
DMONEY: "dmoney",
CAC_BANK: "cac-bank",
CBE_BILL: "cbe-bill",
};
/**
@@ -214,8 +219,13 @@ export class PaymentService {
async initiate(input: InitiateIntentInput): Promise<InitiateIntentResult> {
try {
const isCbeBill = input.method === ProviderMethod.CBE_BILL;
// CBE settles ETB only (docs/cbe/CBE_IMPLEMENTATION_PLAN.md D8).
if (isCbeBill && input.currency?.toUpperCase() !== "ETB") {
throw new BadRequestException(
"CBE bill payment is only available for ETB invoices",
);
}
const snapshot = await this.paymentClient.initiate({
service: PaymentServiceEnum.FREIGHT,
@@ -223,11 +233,15 @@ export class PaymentService {
referenceId: input.referenceId,
orderRef: input.orderRef,
// amountMinor: input.amountMinor,
amountMinor:1,
// CBE_BILL must carry the REAL amount: /cbe/payment verifies what the customer was
// debited against the intent amount, so the 1-birr dev shortcut would break it.
amountMinor: isCbeBill ? input.amountMinor : 1,
currency: input.currency,
provider: input.method as ProviderMethod,
platform: input.platform,
payerAccount: input.payerAccount,
payerName: input.payerName,
expiresAt: input.expiresAt,
returnUrl:
input.returnUrl ?? "https://edrfreight.triaplc.com/payment/success",
failureUrl:
@@ -374,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<IntentStatusDto> {
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`

View File

@@ -60,8 +60,10 @@ export class RefundDto {
}
export class ClientActionDto {
@ApiProperty({ enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP"] })
type!: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP";
@ApiProperty({
enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP", "SHOW_BILL_REFERENCE"],
})
type!: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP" | "SHOW_BILL_REFERENCE";
@ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" })
url?: string;
@@ -80,6 +82,17 @@ export class ClientActionDto {
@ApiPropertyOptional({ description: "Set when type=COLLECT_OTP" })
message?: string;
@ApiPropertyOptional({
description: "Set when type=SHOW_BILL_REFERENCE (CBE bill payment)",
})
billReference?: string;
@ApiPropertyOptional({ description: "Set when type=SHOW_BILL_REFERENCE" })
instructions?: string;
@ApiPropertyOptional({ description: "Set when type=SHOW_BILL_REFERENCE" })
expiresAt?: string;
}
export class InitiateResponseDto {

View File

@@ -158,6 +158,14 @@ export class BookingJourneyService {
this.events.emit('booking.completed', { bookingId });
}
// The cargo is physically off the train at its own yard — mid-corridor or
// final. WarehouseInventoryService picks this up to create the warehouse
// record (import/intercity only; export already has one from receive).
this.events.emit('booking.unloadedAtYard', {
bookingId,
tradeDirection: booking.tradeDirection,
});
// Customer tracking: THIS booking arrived (train may still be rolling).
void this.completeMilestones(booking, [
...(booking.tradeDirection === 'IMPORT'
@@ -319,11 +327,19 @@ export class BookingJourneyService {
RETURNING b.id, b.trade_direction`,
[schedule.id, schedule.destinationStationId, now],
);
// Intercity rows just completed — let a ONE_TIME contract close on delivery.
for (const row of rows) {
// Intercity rows just completed — let a ONE_TIME contract close on delivery.
if (row.trade_direction === 'DOMESTIC') {
this.events.emit('booking.completed', { bookingId: row.id });
}
// Same event the per-booking unloadBooking() path emits — WarehouseInventoryService
// listens for this to auto-create the warehouse_inventory row (import/intercity only,
// it filters EXPORT itself). The bulk SQL update above skipped this entirely, so
// bookings caught by this fallback never left "awaiting unload".
this.events.emit('booking.unloadedAtYard', {
bookingId: row.id,
tradeDirection: row.trade_direction,
});
}
return rows.map((r) => r.id);
}

View File

@@ -162,25 +162,36 @@ describe('applyWagonOrderReversal', () => {
expect(applyWagonOrderReversal(plan, null)).toBe(plan);
});
it('flips the order and renumbers sequenceNo 1..N when the flag is true', () => {
it('flips the position numbers when the flag is true', () => {
const reversed = applyWagonOrderReversal(plan, true);
// Physically-last wagon (was seq 3, wt-c) is now position 1.
expect(reversed.map((s) => s.wagonTypeId)).toEqual(['wt-c', 'wt-b', 'wt-a']);
expect(reversed.map((s) => s.sequenceNo)).toEqual([1, 2, 3]);
// Physically-last wagon (wt-c) is now position 1.
expect(reversed.map((s) => s.sequenceNo)).toEqual([3, 2, 1]);
});
it('keeps each booking with its own wagon — only the position changes', () => {
const reversed = applyWagonOrderReversal(plan, true);
// The booking that was in the last wagon now sits at sequenceNo 1.
expect(reversed[0].sequenceNo).toBe(1);
const atPosition1 = reversed.find((s) => s.sequenceNo === 1);
expect(
(reversed[0].allocations as { bookingId: string }[])[0].bookingId,
(atPosition1?.allocations as { bookingId: string }[])[0].bookingId,
).toBe('BKG-C');
const atPosition3 = reversed.find((s) => s.sequenceNo === 3);
expect(
(reversed[2].allocations as { bookingId: string }[])[0].bookingId,
(atPosition3?.allocations as { bookingId: string }[])[0].bookingId,
).toBe('BKG-A');
});
// The regression that emptied every reversed train's container items: the
// placement generators pair unit k (booking order) with slot k of this array,
// and persistAllocationsAndLoads matches that sequenceNo against the
// allocation's booking. Array order must stay packing order.
it('keeps array order aligned with booking order so placements still match', () => {
const reversed = applyWagonOrderReversal(plan, true);
expect(
reversed.map((s) => (s.allocations as { bookingId: string }[])[0].bookingId),
).toEqual(['BKG-A', 'BKG-B', 'BKG-C']);
});
it('does not mutate the input plan', () => {
applyWagonOrderReversal(plan, true);
expect(plan.map((s) => s.sequenceNo)).toEqual([1, 2, 3]);

View File

@@ -470,15 +470,22 @@ export function planWagonsWithStock(params: {
* sequenceNos, the snapshot re-sorts by them, and the board/allocation views all
* read them — so the stored train order and the schedule order stay identical,
* just reversed. A false/absent flag returns the plan unchanged.
*
* Only the NUMBERS flip — the array itself stays in packing order. Container
* placements are generated by walking the container units in booking order
* against getContainerSlotSequenceNos(plan) in array order, then matched back to
* their allocation by `sequenceNo:bookingId`. Reordering the array here broke
* that pairing on every reversed schedule: unit 1 was handed the number of the
* slot holding the LAST booking, the match missed, and persistAllocationsAndLoads
* silently dropped every container item — which is why a reversed export train
* printed a marshalling doc with no container numbers and 0/0 container counts.
*/
export function applyWagonOrderReversal(
plan: WagonPlanSlot[],
reverse: boolean | null | undefined,
): WagonPlanSlot[] {
if (!reverse) return plan;
return [...plan]
.reverse()
.map((slot, index) => ({ ...slot, sequenceNo: index + 1 }));
return plan.map((slot, index) => ({ ...slot, sequenceNo: plan.length - index }));
}
/** Unbounded stock — used to compute pure demand for availability reporting. */

View File

@@ -0,0 +1,79 @@
import { WarehouseInventoryService } from './warehouse-inventory.service';
/**
* A mid-corridor booking (import destined at an intermediate yard, or any
* DOMESTIC/intercity ride-along) used to have its booking.status flipped by
* the checkpoint-driven unload but never got a warehouse_inventory row — the
* Arrival Queue's unload count never moved and the booking was effectively
* stranded. handleBookingUnloadedAtYard reacts to the 'booking.unloadedAtYard'
* event BookingJourneyService.unloadBooking() emits and creates that row.
*/
function makeService(opts: {
existingInventory?: unknown;
booking?: Record<string, unknown> | null;
}) {
const created: Record<string, unknown>[] = [];
const inventoryRepository = {
findAll: jest.fn().mockResolvedValue(opts.existingInventory ? [opts.existingInventory] : []),
create: jest.fn((row: Record<string, unknown>) => {
created.push(row);
return Promise.resolve({ id: 'new-inv', ...row });
}),
};
const bookingRow =
opts.booking === undefined
? [{ weight: '10', freightType: 'CONTAINER', cargoTypeCode: 'GEN', customer: 'Acme' }]
: opts.booking
? [opts.booking]
: [];
const service = Object.create(WarehouseInventoryService.prototype) as Record<string, unknown>;
service.inventoryRepository = inventoryRepository;
service.dataSource = { query: jest.fn().mockResolvedValue(bookingRow), manager: {} };
service.allocation = { resolveLocation: jest.fn().mockResolvedValue(null) };
service.pickDefaultLocation = jest.fn().mockResolvedValue({ warehouseId: 'w1', yardId: 'y1', zoneId: 'z1' });
service.applyCapacityDelta = jest.fn().mockResolvedValue(undefined);
service.activityLog = { record: jest.fn().mockResolvedValue(undefined) };
service.logger = { warn: jest.fn() };
return { service: service as unknown as WarehouseInventoryService, created };
}
describe('handleBookingUnloadedAtYard', () => {
it('creates an UNLOADED row with an IMPORT GRN for a fresh import booking', async () => {
const { service, created } = makeService({});
await (service as unknown as { handleBookingUnloadedAtYard: (p: unknown) => Promise<void> })
.handleBookingUnloadedAtYard({ bookingId: 'b1', tradeDirection: 'IMPORT' });
expect(created).toHaveLength(1);
expect(created[0]).toMatchObject({ bookingId: 'b1', status: 'UNLOADED' });
expect(created[0].grnNumber).toMatch(/^GRN-IMPORT-/);
});
it('creates one for a DOMESTIC/intercity ride-along too', async () => {
const { service, created } = makeService({});
await (service as unknown as { handleBookingUnloadedAtYard: (p: unknown) => Promise<void> })
.handleBookingUnloadedAtYard({ bookingId: 'b2', tradeDirection: 'DOMESTIC' });
expect(created).toHaveLength(1);
expect(created[0].grnNumber).toMatch(/^GRN-DOMESTIC-/);
});
it('skips EXPORT — its warehouse record already exists from the origin receive', async () => {
const { service, created } = makeService({});
await (service as unknown as { handleBookingUnloadedAtYard: (p: unknown) => Promise<void> })
.handleBookingUnloadedAtYard({ bookingId: 'b3', tradeDirection: 'EXPORT' });
expect(created).toHaveLength(0);
});
it('is idempotent — a booking that already has an inventory row is left alone', async () => {
const { service, created } = makeService({ existingInventory: { id: 'existing' } });
await (service as unknown as { handleBookingUnloadedAtYard: (p: unknown) => Promise<void> })
.handleBookingUnloadedAtYard({ bookingId: 'b4', tradeDirection: 'IMPORT' });
expect(created).toHaveLength(0);
});
});

View File

@@ -95,11 +95,9 @@ export class WarehouseInspectionService {
b.company_id AS "companyId",
b.trade_direction AS "tradeDirection",
b.last_mile_delivery_address AS "lastMileDeliveryAddress",
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
COALESCE(st.includes_last_mile, false) AS "serviceIncludesLastMile"
b.customer_truck_assigned_at AS "customerTruckAssignedAt"
FROM freight.warehouse_inventory inv
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
WHERE inv.id = $1 AND inv.deleted_at IS NULL
LIMIT 1`,
[inventoryId],
@@ -111,8 +109,11 @@ export class WarehouseInspectionService {
readyForPickupAt: new Date(),
});
const hasLastMile =
Boolean(row.lastMileDeliveryAddress?.trim?.()) || Boolean(row.serviceIncludesLastMile);
// service_types.includes_last_mile is NOT read here — every service type
// ships with it true, which made this always true regardless of the
// customer's actual self-haul/EDR-haul choice and permanently dead-coded
// the self-haul nudge below. The delivery address is the real signal.
const hasLastMile = Boolean(row.lastMileDeliveryAddress?.trim?.());
if (row.bookingReference && hasLastMile) {
await this.lastMileService.acceptBooking(row.bookingReference);

View File

@@ -1,5 +1,5 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
import { Cron, CronExpression } from '@nestjs/schedule';
import {
Between,
@@ -1339,8 +1339,11 @@ export class WarehouseInventoryService {
bcu.seal_numbers AS "sealNumbers",
bc.container_quantity AS "containerQuantity",
bc.container_packaging_type AS "containerPackagingType",
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
OR COALESCE(st.includes_last_mile, false)) AS "lastMileRequested",
-- service_types.includes_last_mile/first_mile are NOT read here: every
-- service type ships with both true, so OR-ing them in made this always
-- true regardless of the customer's actual self-haul/EDR-haul choice.
-- The address is the only per-booking record of that choice.
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL) AS "lastMileRequested",
oy.code AS "origin",
dy.code AS "destination",
oy.country AS "originCountry",
@@ -1351,8 +1354,7 @@ export class WarehouseInventoryService {
b.cargo_total_weight_vgm AS "weight",
b.payment_status AS "paymentStatus",
b.status AS "status",
(NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL
OR COALESCE(st.includes_first_mile, false)) AS "hasFirstMile",
(NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL) AS "hasFirstMile",
fm.id AS "firstMileRequestId",
fm.status AS "firstMileStatus",
fm.vehicle_id AS "firstMileVehicleId",
@@ -1386,7 +1388,6 @@ export class WarehouseInventoryService {
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
LEFT JOIN LATERAL (
SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers,
@@ -1494,8 +1495,8 @@ export class WarehouseInventoryService {
bc.container_packaging_type AS "containerPackagingType",
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS "cargoDescription",
oy.country AS "originCountry", dy.country AS "destinationCountry",
(NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL
OR COALESCE(st.includes_first_mile, false)) AS "hasFirstMile",
-- No service_types OR here either — see eligibleBookings above.
(NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL) AS "hasFirstMile",
fm.id AS "firstMileRequestId",
fm.status AS "firstMileStatus",
v.plate_number AS "firstMileTruckPlateNumber",
@@ -1519,14 +1520,12 @@ export class WarehouseInventoryService {
b.customer_truck_container_number AS "customerTruckContainerNumber",
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
b.company_id AS "companyId",
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
OR COALESCE(st.includes_last_mile, false)) AS "hasLastMile"
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL) AS "hasLastMile"
FROM freight.bookings b
LEFT JOIN freight.companies company ON company.id = b.company_id
${primaryContactUserJoin('company')}
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
LEFT JOIN LATERAL (
SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers,
@@ -1682,6 +1681,7 @@ export class WarehouseInventoryService {
for (const pending of pendingNotifications) {
void this.notifyOwnerInventoryReceived(pending.owner);
void this.notifyTruckAssignmentNeeded(pending.booking, pending.bookingId);
if (dto.direction === 'EXPORT') void this.notifyCarriageAcceptanceReady(pending.bookingId);
}
return result;
@@ -1979,11 +1979,10 @@ export class WarehouseInventoryService {
COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber",
ts.train_number AS "trainSchedule",
inv.inspection_status AS "inspectionStatus",
-- No service_types OR here either — see eligibleBookings above.
CASE WHEN NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
OR COALESCE(st.includes_last_mile, false)
THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption",
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
OR COALESCE(st.includes_last_mile, false)) AS "lastMileRequested",
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL) AS "lastMileRequested",
-- Multi-truck self-haul writes plates/drivers to
-- customer_truck_assignments and leaves the booking columns null,
-- so read the assignments first and keep the legacy column as the
@@ -2018,7 +2017,6 @@ export class WarehouseInventoryService {
LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
LEFT JOIN freight.train_schedule_bookings tsb ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL
@@ -2112,6 +2110,108 @@ export class WarehouseInventoryService {
);
}
/**
* A booking alighted from a train at ITS OWN destination yard — emitted by
* BookingJourneyService.unloadBooking() for every direction, whether that
* yard is a mid-corridor stop (checkpoint auto-unload) or the train's final
* yard (manual per-booking unload). That per-booking flow only ever flips
* booking.status; it never creates a warehouse_inventory row, which used to
* strand mid-corridor IMPORT and DOMESTIC/intercity bookings — their status
* read ARRIVED/COMPLETED but the Arrival Queue's unload count never moved
* (nothing else was watching for a mid-corridor arrival). This creates that
* row the moment the cargo is physically off the train.
*
* EXPORT is deliberately skipped: its warehouse_inventory row (and GRN) is
* created at the ORIGIN warehouse receive, before the cargo ever boards —
* see BookingsService.carriageAcceptanceSheet and receive()/bulkReceive()
* above. Creating a second row here would duplicate that receipt.
*
* Idempotent — a booking already unloaded via this listener, a retried
* checkpoint, or the final-yard "Auto Unload" bulk action is left alone.
*/
@OnEvent('booking.unloadedAtYard')
async handleBookingUnloadedAtYard(payload: {
bookingId: string;
tradeDirection: string | null;
}): Promise<void> {
if (payload.tradeDirection !== 'IMPORT' && payload.tradeDirection !== 'DOMESTIC') return;
try {
const existing = (
await this.inventoryRepository.findAll({ where: { bookingId: payload.bookingId } })
)[0];
if (existing) return;
const [booking]: Array<{
weight: string | null;
freightType: string | null;
cargoTypeCode: string | null;
customer: string | null;
}> = await this.dataSource.query(
`SELECT COALESCE(
NULLIF(b.cargo_total_weight_vgm, 0),
(SELECT SUM(bcu.vgm_tons)
FROM freight.booking_container_units bcu
JOIN freight.booking_container bc2
ON bc2.id = bcu.booking_container_id AND bc2.deleted_at IS NULL
WHERE bc2.booking_id = b.id AND bcu.deleted_at IS NULL)
) AS weight,
b.freight_type AS "freightType",
cgt.code AS "cargoTypeCode",
company.name AS customer
FROM freight.bookings b
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
LEFT JOIN freight.companies company ON company.id = b.company_id
WHERE b.id = $1 AND b.deleted_at IS NULL`,
[payload.bookingId],
);
if (!booking) return;
const allocated = await this.allocation.resolveLocation({
freightType: booking.freightType,
tradeDirection: payload.tradeDirection,
cargoTypeCode: booking.cargoTypeCode,
});
const location = allocated ?? (await this.pickDefaultLocation());
if (!location) {
this.logger.warn(
`Checkpoint auto-unload for booking ${payload.bookingId}: no warehouse/yard/zone configured`,
);
return;
}
const now = new Date();
const saved = await this.inventoryRepository.create({
warehouseId: location.warehouseId,
yardId: location.yardId,
zoneId: location.zoneId,
bookingId: payload.bookingId,
quantity: 1,
weight: Number(booking.weight) || 0,
status: 'UNLOADED',
grnNumber: this.generateGrnNumber(payload.tradeDirection, payload.bookingId, now, booking.customer),
arrivedAt: now,
unloadedAt: now,
notes:
(allocated as { rule?: { name: string } | null } | null)?.rule
? `Unloaded → ${(allocated as { path?: string | null }).path}`
: 'Unloaded from arrived train (checkpoint auto-unload)',
});
await this.activityLog.record({
activityType: 'INVENTORY_UNLOADED',
inventoryId: saved.id,
warehouseId: saved.warehouseId,
description: 'Unloaded from arrived train (checkpoint auto-unload)',
});
if (Number(saved.weight) > 0) {
await this.applyCapacityDelta(this.dataSource.manager, location, Number(saved.weight), 0, 0);
}
} catch (err) {
this.logger.warn(
`Checkpoint auto-unload inventory create failed for ${payload.bookingId}: ${(err as Error).message}`,
);
}
}
/**
* Batch 8 — unload all eligible assigned bookings of an ARRIVED import train into UNLOADED state.
* Reuses the allocation + inventory + activity-log plumbing. Does NOT store and does NOT inspect —
@@ -2689,16 +2789,16 @@ export class WarehouseInventoryService {
if (!bookingId) return;
const [booking] = await this.dataSource.query(
`SELECT reference,
last_mile_delivery_address AS "lastMileDeliveryAddress",
COALESCE(st.includes_last_mile, false) AS "serviceIncludesLastMile"
last_mile_delivery_address AS "lastMileDeliveryAddress"
FROM freight.bookings b
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
WHERE b.id = $1 AND b.deleted_at IS NULL
LIMIT 1`,
[bookingId],
);
const hasLastMile =
Boolean(booking?.lastMileDeliveryAddress?.trim?.()) || Boolean(booking?.serviceIncludesLastMile);
// service_types.includes_last_mile is NOT read here — every service type
// ships with it true, so it can't distinguish EDR last-mile from self-haul.
// The delivery address is the only per-booking record of that choice.
const hasLastMile = Boolean(booking?.lastMileDeliveryAddress?.trim?.());
if (!booking?.reference || !hasLastMile) return;
await this.lastMileService.acceptBooking(booking.reference);
}
@@ -2809,6 +2909,10 @@ export class WarehouseInventoryService {
return saved.id;
});
if (dto.bookingId && bookingDirection === 'EXPORT') {
void this.notifyCarriageAcceptanceReady(dto.bookingId);
}
return this.findById(id);
}
@@ -3057,14 +3161,15 @@ export class WarehouseInventoryService {
*/
private async isSelfHaulBooking(bookingId: string, manager?: EntityManager): Promise<boolean> {
const runner = manager ?? this.dataSource;
// service_types.includes_last_mile is NOT read here — every service type
// ships with it true, which made the second disjunct below unreachable and
// this method effectively return true only from an assigned truck.
const [row]: Array<{ ok: number }> = await runner.query(
`SELECT 1 AS ok
FROM freight.bookings b
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
WHERE b.id = $1 AND b.deleted_at IS NULL
AND (b.customer_truck_assigned_at IS NOT NULL
OR (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NULL
AND COALESCE(st.includes_last_mile, false) = false))`,
OR NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NULL)`,
[bookingId],
);
return Boolean(row);
@@ -5978,6 +6083,36 @@ export class WarehouseInventoryService {
return booking ?? {};
}
/**
* Tell the customer their export carriage acceptance sheet is ready to
* download from the portal. Export acceptance happens at the warehouse gate
* (see BookingsService.carriageAcceptanceSheet) — the sheet is generatable
* as soon as the cargo is received, no wagon allocation required, so this
* fires right after receive, not at marshalling.
*/
private async notifyCarriageAcceptanceReady(bookingId: string): Promise<void> {
try {
const [b]: Array<{ companyId: string | null; reference: string }> = await this.dataSource.query(
`SELECT company_id AS "companyId", reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
[bookingId],
);
if (!b?.companyId) return;
const body = `Your carriage acceptance sheet for booking ${b.reference} is ready to download from the portal.`;
await this.inbox.notify({
recipients: { companyId: b.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.DOCUMENT_ACTION,
title: 'Carriage acceptance sheet ready',
body,
link: `/bookings/${bookingId}`,
data: { bookingId, reference: b.reference },
});
await sendCompanyChannels(this.dataSource, this.notifications, b.companyId, body);
} catch (err) {
this.logger.warn(`Carriage acceptance ready notify failed for ${bookingId}: ${(err as Error).message}`);
}
}
private async notifyOwnerInventoryReceived(params: {
phone?: string | null;
ownerName?: string | null;

View File

@@ -0,0 +1,63 @@
import 'reflect-metadata';
import { config } from 'dotenv';
import { resolve } from 'path';
import { NestFactory } from '@nestjs/core';
import { DataSource } from 'typeorm';
config({ path: resolve(__dirname, '../../.env') });
process.env.TYPEORM_LOGGING = 'false';
import { AppModule } from '../app.module';
import { WarehouseInventoryService } from '../modules/warehouses/warehouse-inventory.service';
/**
* One-off backfill for bookings caught by the autoArriveAtFinalYard bug
* (fixed in booking-journey.service.ts): the bulk final-yard arrival used to
* flip booking status to ARRIVED/COMPLETED without ever emitting
* booking.unloadedAtYard, so WarehouseInventoryService never created their
* warehouse_inventory row. Reuses the same idempotent listener the live
* event now calls, so it's safe to re-run.
*/
async function main() {
const app = await NestFactory.createApplicationContext(AppModule, {
logger: ['error', 'warn'],
});
try {
const dataSource = app.get(DataSource);
const inventory = app.get(WarehouseInventoryService);
const bookings: { id: string; tradeDirection: string }[] = await dataSource.query(
`SELECT b.id, b.trade_direction AS "tradeDirection"
FROM freight.bookings b
WHERE b.deleted_at IS NULL
AND b.trade_direction IN ('IMPORT', 'DOMESTIC')
AND b.status IN ('ARRIVED', 'COMPLETED')
AND NOT EXISTS (
SELECT 1 FROM freight.warehouse_inventory wi
WHERE wi.booking_id = b.id AND wi.deleted_at IS NULL
)`,
);
if (bookings.length === 0) {
console.log('No bookings missing their unload inventory row.');
return;
}
console.log(`Backfilling ${bookings.length} booking(s)...`);
for (const booking of bookings) {
await inventory.handleBookingUnloadedAtYard({
bookingId: booking.id,
tradeDirection: booking.tradeDirection,
});
console.log(` - ${booking.id} (${booking.tradeDirection})`);
}
} finally {
await app.close();
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});

View File

@@ -0,0 +1,24 @@
import "dotenv/config";
import { AppDataSource } from "../data-source";
import { ensurePostgresSchemas } from "../config/ensure-postgres-schemas";
import { buildDataSourceOptions } from "../config/database.config";
async function main(): Promise<void> {
await ensurePostgresSchemas(buildDataSourceOptions());
await AppDataSource.initialize();
try {
const applied = await AppDataSource.runMigrations();
for (const migration of applied) {
console.log(`applied: ${migration.name}`);
}
if (applied.length === 0) console.log("no pending migrations");
} finally {
await AppDataSource.destroy();
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});