Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight_feature/contrat

This commit is contained in:
Marshal
2026-06-29 15:25:53 +00:00
126 changed files with 7251 additions and 2156 deletions

View File

@@ -34,4 +34,4 @@ RUN addgroup --system --gid 1001 nodejs \
COPY --from=deployer --chown=nestjs:nodejs /deploy .
USER nestjs
EXPOSE 3001
CMD ["node", "dist/main.js"]
CMD ["sh", "-c", "pnpm run migrate && node dist/main.js"]

View File

@@ -22,13 +22,16 @@
"seed:import-djibouti-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-import-djibouti-demo.ts",
"seed:approved-first-lastmile-demo-bookings": "ts-node -r tsconfig-paths/register src/scripts/seed-approved-first-lastmile-demo-bookings.ts",
"seed:negad-indode-arrived-train": "ts-node -r tsconfig-paths/register src/scripts/seed-negad-indode-arrived-train.ts",
"auto-unload:arrived-import-trains": "ts-node -r tsconfig-paths/register src/scripts/auto-unload-arrived-import-trains.ts",
"seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts",
"seed:gov-companies": "ts-node -r tsconfig-paths/register src/scripts/seed-gov-companies.ts",
"seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh",
"iam:typeorm:cli": "cross-env MIGRATIONS_DIR=node_modules/@tria-plc/iamapi-common/dist/db/migrations/*.{ts,js} ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -d ./node_modules/@tria-plc/api-common/dist/modules/typeorm/typeorm.config.js",
"iam:migration:run": "pnpm run iam:typeorm:cli migration:run",
"iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert",
"iam:migration:show": "pnpm run iam:typeorm:cli migration:show",
"iam:seed:run": "cross-env APP_MODULE_PATH=./dist/app.module dotenv -- node ./node_modules/@tria-plc/iamapi-common/dist/db/seed.cli.js"
"iam:seed:run": "cross-env APP_MODULE_PATH=./dist/app.module dotenv -- node ./node_modules/@tria-plc/iamapi-common/dist/db/seed.cli.js",
"migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts"
},
"dependencies": {
"@edr/api-common": "workspace:*",

View File

@@ -2,6 +2,7 @@ import { Module, OnApplicationBootstrap } from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
import { ScheduleModule } from "@nestjs/schedule";
import { EventEmitterModule } from "@nestjs/event-emitter";
import { DataSource, DataSourceOptions } from "typeorm";
import { ensurePostgresSchemas } from "./config/ensure-postgres-schemas";
import { IamModule, DataSeeder } from "@tria-plc/iamapi-common";
@@ -53,8 +54,10 @@ import { Batch7TestDataSeeder } from "./seed/batch7-test-data.seeder";
import { Batch8TestDataSeeder } from "./seed/batch8-test-data.seeder";
import { WarehouseDemoSeeder } from "./seed/warehouse-demo.seeder";
import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder";
import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seeder";
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
//New Trains, Wagons, Container and Cargo management modules
import { TrainsModule } from "./modules/trains/trains.module";
@@ -78,7 +81,7 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera
load: [appConfig, databaseConfig, telebirrConfig, rabbitmqConfig],
}),
ScheduleModule.forRoot(),
// EventEmitterModule.forRoot(),
EventEmitterModule.forRoot(),
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService): TypeOrmModuleOptions =>
@@ -143,6 +146,7 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera
FileUploadSettingsSeeder,
FreightPermissionKeyMigrationSeeder,
DemoFreightDataSeeder,
GovCompaniesSeeder,
IndodeFacilitySeeder,
Batch14TestDataSeeder,
Batch5TestDataSeeder,
@@ -150,6 +154,7 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera
Batch8TestDataSeeder,
WarehouseDemoSeeder,
ExportDjiboutiInterchangeDemoSeeder,
MarshallingDemoTrainsSeeder,
ApprovedFirstLastMileDemoBookingsSeeder,
],
})
@@ -168,8 +173,10 @@ export class AppModule implements OnApplicationBootstrap {
private readonly batch8TestDataSeeder: Batch8TestDataSeeder,
private readonly warehouseDemoSeeder: WarehouseDemoSeeder,
private readonly exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder,
private readonly marshallingDemoTrainsSeeder: MarshallingDemoTrainsSeeder,
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
private readonly govCompaniesSeeder: GovCompaniesSeeder,
) { }
async onApplicationBootstrap() {
@@ -187,6 +194,7 @@ export class AppModule implements OnApplicationBootstrap {
await this.batch8TestDataSeeder.run();
await this.warehouseDemoSeeder.run();
await this.exportDjiboutiInterchangeDemoSeeder.run();
await this.marshallingDemoTrainsSeeder.run();
// Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users.
// Each block self-guards on an empty-table check, so this is safe every boot.
// Demo data seeds (DemoBookingsSeeder, PricingDataSeeder,
@@ -195,5 +203,8 @@ export class AppModule implements OnApplicationBootstrap {
// demoFreightDataSeeder now seeds ONLY the 4 staff users (wagons + approval
// rules are disabled inside the seeder). Kept running for the staff users.
await this.demoFreightDataSeeder.run();
// Government entities (with importer/exporter profiles) that government
// bookings bill to. Idempotent — keyed by fixed IDs.
await this.govCompaniesSeeder.run();
}
}

View File

@@ -0,0 +1,51 @@
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
export class AddPostPaymentCompletedColumn1719667261000 implements MigrationInterface {
name = 'AddPostPaymentCompletedColumn1719667261000';
public async up(queryRunner: QueryRunner): Promise<void> {
const firstMileTable = await queryRunner.hasTable('freight.first_mile_deliveries');
if (firstMileTable) {
const hasColumn = await queryRunner.hasColumn('freight.first_mile_deliveries', 'is_post_payment_completed');
if (!hasColumn) {
await queryRunner.addColumn(
'freight.first_mile_deliveries',
new TableColumn({
name: 'is_post_payment_completed',
type: 'boolean',
default: false,
isNullable: false,
})
);
}
}
const lastMileTable = await queryRunner.hasTable('freight.last_mile_deliveries');
if (lastMileTable) {
const hasColumn = await queryRunner.hasColumn('freight.last_mile_deliveries', 'is_post_payment_completed');
if (!hasColumn) {
await queryRunner.addColumn(
'freight.last_mile_deliveries',
new TableColumn({
name: 'is_post_payment_completed',
type: 'boolean',
default: false,
isNullable: false,
})
);
}
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
const lastMileTable = await queryRunner.hasTable('freight.last_mile_deliveries');
if (lastMileTable) {
await queryRunner.dropColumn('freight.last_mile_deliveries', 'is_post_payment_completed');
}
const firstMileTable = await queryRunner.hasTable('freight.first_mile_deliveries');
if (firstMileTable) {
await queryRunner.dropColumn('freight.first_mile_deliveries', 'is_post_payment_completed');
}
}
}

View File

@@ -0,0 +1,101 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Freight billing — `invoices` + `invoice_lines` tables.
*
* Matches:
* - billing/entities/invoice.entity.ts
* - billing/entities/invoice-line.entity.ts
*
* The status enum mirrors `Freight.InvoiceStatus` and uses TypeORM's default
* enum-type name (`<table>_<column>_enum`) so the entity's `type: "enum"`
* column resolves to it without an explicit `enumName`.
*/
export class CreateInvoices1821000000002 implements MigrationInterface {
name = "CreateInvoices1821000000002";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TYPE freight.invoices_status_enum AS ENUM (
'DRAFT',
'PENDING',
'PAID',
'OVERDUE',
'CANCELLED',
'REFUNDED'
);
`);
await queryRunner.query(`
CREATE TABLE freight.invoices (
id uuid NOT NULL DEFAULT uuid_generate_v4(),
invoice_number varchar(64) NOT NULL,
company_id uuid NOT NULL,
company_profile_id uuid NOT NULL,
total_amount numeric(14, 2) NOT NULL,
currency varchar(8) NOT NULL DEFAULT 'ETB',
status freight.invoices_status_enum NOT NULL DEFAULT 'DRAFT',
source varchar(255) NOT NULL,
source_id varchar(255) NOT NULL,
type varchar(255) NOT NULL,
issued_at timestamptz,
payment_id uuid,
due_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT pk_invoices PRIMARY KEY (id),
CONSTRAINT uq_invoices_invoice_number UNIQUE (invoice_number),
CONSTRAINT fk_invoices_company FOREIGN KEY (company_id)
REFERENCES freight.companies (id) ON DELETE RESTRICT,
CONSTRAINT fk_invoices_company_profile FOREIGN KEY (company_profile_id)
REFERENCES freight.company_profiles (id) ON DELETE RESTRICT,
CONSTRAINT fk_invoices_payment FOREIGN KEY (payment_id)
REFERENCES freight.payments (id) ON DELETE SET NULL
);
`);
await queryRunner.query(
`CREATE INDEX idx_invoices_company ON freight.invoices (company_id);`,
);
await queryRunner.query(
`CREATE INDEX idx_invoices_company_profile ON freight.invoices (company_profile_id);`,
);
await queryRunner.query(
`CREATE INDEX idx_invoices_source ON freight.invoices (source, source_id);`,
);
await queryRunner.query(
`CREATE INDEX idx_invoices_status ON freight.invoices (status);`,
);
await queryRunner.query(`
CREATE TABLE freight.invoice_lines (
id uuid NOT NULL DEFAULT uuid_generate_v4(),
invoice_id uuid NOT NULL,
charge_type varchar NOT NULL,
description varchar(255),
quantity numeric(12, 2) NOT NULL DEFAULT 1,
unit_rate numeric(14, 2) NOT NULL DEFAULT 0,
amount numeric(14, 2) NOT NULL,
currency varchar(8) NOT NULL DEFAULT 'ETB',
metadata jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT pk_invoice_lines PRIMARY KEY (id),
CONSTRAINT fk_invoice_lines_invoice FOREIGN KEY (invoice_id)
REFERENCES freight.invoices (id) ON DELETE CASCADE
);
`);
await queryRunner.query(
`CREATE INDEX idx_invoice_lines_invoice ON freight.invoice_lines (invoice_id);`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.invoice_lines;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.invoices;`);
await queryRunner.query(`DROP TYPE IF EXISTS freight.invoices_status_enum;`);
}
}

View File

@@ -0,0 +1,129 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Government bookings now bill to a real seeded government company + an explicit
* importer/exporter profile, instead of carrying a null company + free-text
* institution. This migration:
*
* 1. Adds `companies.kind` (commercial | government).
* 2. Seeds the Ethiopian government entities + their importer/exporter
* profiles (mirrors src/seed/data/gov-companies.data.ts — keep in sync).
* 3. Backfills every booking with a NULL company_id / company_profile_id so
* the NOT NULL constraints below can be applied:
* - NULL company_id → the default government company.
* - NULL company_profile_id → the company's profile matching the booking
* trade direction; else any profile of the company; else the default
* government importer profile.
* 4. Enforces NOT NULL on bookings.company_id and bookings.company_profile_id.
*/
export class AddCompanyKindAndGovBookingLinks1821000000003
implements MigrationInterface
{
name = "AddCompanyKindAndGovBookingLinks1821000000003";
// Mirrors src/seed/data/gov-companies.data.ts
private readonly govCompanies = [
{ id: "0a1b0001-0000-4000-8000-000000000001", name: "Federal Government of Ethiopia", tin: "0000000001", email: "procurement@gov.et", phone: "+251111000001", im: "0b1c0001-0000-4000-8000-000000000001", ex: "0b1c0001-0000-4000-8000-000000000002", imRef: "IM-90001", exRef: "EX-90001" },
{ id: "0a1b0002-0000-4000-8000-000000000002", name: "Ministry of National Defense", tin: "0000000002", email: "logistics@mod.gov.et", phone: "+251111000002", im: "0b1c0002-0000-4000-8000-000000000001", ex: "0b1c0002-0000-4000-8000-000000000002", imRef: "IM-90002", exRef: "EX-90002" },
{ id: "0a1b0003-0000-4000-8000-000000000003", name: "Ethiopian Roads Administration", tin: "0000000003", email: "supply@era.gov.et", phone: "+251111000003", im: "0b1c0003-0000-4000-8000-000000000001", ex: "0b1c0003-0000-4000-8000-000000000002", imRef: "IM-90003", exRef: "EX-90003" },
{ id: "0a1b0004-0000-4000-8000-000000000004", name: "Ministry of Agriculture", tin: "0000000004", email: "imports@moa.gov.et", phone: "+251111000004", im: "0b1c0004-0000-4000-8000-000000000001", ex: "0b1c0004-0000-4000-8000-000000000002", imRef: "IM-90004", exRef: "EX-90004" },
{ id: "0a1b0005-0000-4000-8000-000000000005", name: "Ministry of Trade and Regional Integration", tin: "0000000005", email: "trade@motri.gov.et", phone: "+251111000005", im: "0b1c0005-0000-4000-8000-000000000001", ex: "0b1c0005-0000-4000-8000-000000000002", imRef: "IM-90005", exRef: "EX-90005" },
{ id: "0a1b0006-0000-4000-8000-000000000006", name: "Ethiopian Disaster Risk Management Commission", tin: "0000000006", email: "relief@edrmc.gov.et", phone: "+251111000006", im: "0b1c0006-0000-4000-8000-000000000001", ex: "0b1c0006-0000-4000-8000-000000000002", imRef: "IM-90006", exRef: "EX-90006" },
];
private get defaultCompanyId(): string {
return this.govCompanies[0].id;
}
private get defaultImporterProfileId(): string {
return this.govCompanies[0].im;
}
public async up(queryRunner: QueryRunner): Promise<void> {
// 1. kind column
await queryRunner.query(
`ALTER TABLE "freight"."companies" ADD COLUMN IF NOT EXISTS "kind" varchar(20) NOT NULL DEFAULT 'commercial'`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_companies_kind" ON "freight"."companies" ("kind")`,
);
// 2. seed government companies + importer/exporter profiles (idempotent)
for (const g of this.govCompanies) {
await queryRunner.query(
`INSERT INTO "freight"."companies" ("id", "name", "type", "kind", "status", "tin", "country", "email", "phone")
VALUES ($1, $2, 'customer', 'government', 'active', $3, 'Ethiopia', $4, $5)
ON CONFLICT ("id") DO NOTHING`,
[g.id, g.name, g.tin, g.email, g.phone],
);
await queryRunner.query(
`INSERT INTO "freight"."company_profiles" ("id", "company_id", "type", "reference", "status")
VALUES ($1, $2, 'importer', $3, 'active'), ($4, $2, 'exporter', $5, 'active')
ON CONFLICT ("id") DO NOTHING`,
[g.im, g.id, g.imRef, g.ex, g.exRef],
);
}
// 3a. backfill NULL company_id → default government company
await queryRunner.query(
`UPDATE "freight"."bookings" SET "company_id" = $1 WHERE "company_id" IS NULL`,
[this.defaultCompanyId],
);
// 3b. backfill NULL company_profile_id → profile matching trade direction
await queryRunner.query(
`UPDATE "freight"."bookings" b
SET "company_profile_id" = cp."id"
FROM "freight"."company_profiles" cp
WHERE b."company_profile_id" IS NULL
AND cp."company_id" = b."company_id"
AND cp."deleted_at" IS NULL
AND cp."type" = CASE b."trade_direction"
WHEN 'IMPORT' THEN 'importer'
WHEN 'EXPORT' THEN 'exporter'
ELSE NULL END`,
);
// 3c. fallback → any profile of the booking's company
await queryRunner.query(
`UPDATE "freight"."bookings" b
SET "company_profile_id" = (
SELECT cp."id" FROM "freight"."company_profiles" cp
WHERE cp."company_id" = b."company_id" AND cp."deleted_at" IS NULL
ORDER BY cp."created_at" ASC LIMIT 1)
WHERE b."company_profile_id" IS NULL
AND EXISTS (
SELECT 1 FROM "freight"."company_profiles" cp
WHERE cp."company_id" = b."company_id" AND cp."deleted_at" IS NULL)`,
);
// 3d. final fallback → default government importer profile
await queryRunner.query(
`UPDATE "freight"."bookings" SET "company_profile_id" = $1 WHERE "company_profile_id" IS NULL`,
[this.defaultImporterProfileId],
);
// 4. enforce NOT NULL
await queryRunner.query(
`ALTER TABLE "freight"."bookings" ALTER COLUMN "company_id" SET NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "freight"."bookings" ALTER COLUMN "company_profile_id" SET NOT NULL`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "freight"."bookings" ALTER COLUMN "company_profile_id" DROP NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "freight"."bookings" ALTER COLUMN "company_id" DROP NOT NULL`,
);
await queryRunner.query(
`DROP INDEX IF EXISTS "freight"."IDX_companies_kind"`,
);
await queryRunner.query(
`ALTER TABLE "freight"."companies" DROP COLUMN IF EXISTS "kind"`,
);
// Seeded government rows are intentionally left in place.
}
}

View File

@@ -0,0 +1,47 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Make the payment projection source-agnostic so any domain (not just bookings)
* can own a payment intent.
*
* - `payments.type` enum `('booking')` → `varchar(50)`. It now stores the
* invoice SOURCE (e.g. 'booking', 'demurrage'), supplied by the caller, so a
* new domain no longer needs an enum migration to write its intents.
* - adds `payments.reference_type varchar(40)` — the gateway reference type
* (`PaymentReferenceType`) the intent was opened with, so the reconcile/poll
* path can query the provider without hardcoding it.
*
* Matches payment/entities/payment.entity.ts.
*/
export class MakePaymentsTypeGeneric1821000000004 implements MigrationInterface {
name = "MakePaymentsTypeGeneric1821000000004";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.payments ALTER COLUMN type TYPE varchar(50) USING type::text;`,
);
await queryRunner.query(`DROP TYPE IF EXISTS freight.payments_type_enum;`);
await queryRunner.query(
`ALTER TABLE freight.payments ADD COLUMN reference_type varchar(40);`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.payments DROP COLUMN IF EXISTS reference_type;`,
);
// Restore the single-value enum. Any non-'booking' rows would block the cast;
// collapse them first so the down migration is safe.
await queryRunner.query(
`UPDATE freight.payments SET type = 'booking' WHERE type <> 'booking';`,
);
await queryRunner.query(
`CREATE TYPE freight.payments_type_enum AS ENUM ('booking');`,
);
await queryRunner.query(
`ALTER TABLE freight.payments ALTER COLUMN type TYPE freight.payments_type_enum USING type::freight.payments_type_enum;`,
);
}
}

View File

@@ -8,7 +8,7 @@ import { BillingService } from "./billing.service";
@Controller("billing")
@FreightAdmin()
export class BillingController {
constructor(private readonly billingService: BillingService) {}
constructor(private readonly billingService: BillingService) { }
@Get("invoices")
@ApiOperation({ summary: "List all invoices" })
@@ -16,9 +16,9 @@ export class BillingController {
return this.billingService.findAll();
}
@Get("invoices/booking/:bookingId")
@ApiOperation({ summary: "List invoices for a booking" })
findByBooking(@Param("bookingId", ParseUUIDPipe) bookingId: string) {
return this.billingService.findByBooking(bookingId);
@Get("invoices/:id")
@ApiOperation({ summary: "Get an invoice with its line items" })
findById(@Param("id", ParseUUIDPipe) id: string) {
return this.billingService.findById(id);
}
}

View File

@@ -1,14 +1,24 @@
import { Module } from "@nestjs/common";
import { forwardRef, Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { BillingController } from "./billing.controller";
import { PortalBillingController } from "./portal-billing.controller";
import { BillingService } from "./billing.service";
import { Invoice } from "./entities/invoice.entity";
import { InvoiceLine } from "./entities/invoice-line.entity";
import { InvoiceRepository } from "./invoice.repository";
import { InvoiceLineRepository } from "./invoice-line.repository";
import { PaymentModule } from "../payment/payment.module";
import { CompaniesModule } from "../companies/companies.module";
@Module({
imports: [TypeOrmModule.forFeature([Invoice])],
controllers: [BillingController],
providers: [BillingService],
imports: [
TypeOrmModule.forFeature([Invoice, InvoiceLine]),
forwardRef(() => PaymentModule),
CompaniesModule,
],
controllers: [BillingController, PortalBillingController],
providers: [BillingService, InvoiceRepository, InvoiceLineRepository],
exports: [BillingService],
})
export class BillingModule {}

View File

@@ -0,0 +1,250 @@
import { Freight } from "@edr/types";
import { BillingService } from "./billing.service";
/**
* Minimal in-memory EntityManager stand-in covering the methods
* `generateInvoice` / `markInvoiceAsPaid` call on the transaction manager.
*/
function makeManager(savedLines: unknown[]) {
return {
create: (_entity: unknown, data: Record<string, unknown>) => data,
save: (data: Record<string, unknown>) => {
const row = { id: data.id ?? "gen-1", ...data };
if (data.invoiceId) savedLines.push(row);
return Promise.resolve(row);
},
query: () => Promise.resolve([{ seq: 0 }]),
update: jest.fn().mockResolvedValue(undefined),
findOne: jest.fn().mockResolvedValue(null),
};
}
function makeEvents() {
return { emit: jest.fn() };
}
function generateInput(overrides: Record<string, unknown> = {}) {
return {
source: Freight.InvoiceSource.Booking,
sourceId: "booking-1",
type: "prepaid",
companyId: "company-1",
companyProfileId: "profile-1",
currency: "ETB",
lines: [
{
chargeType: "RAIL_FREIGHT",
description: "Rail freight",
quantity: 2,
unitRate: 500,
amount: 1000,
},
{
chargeType: "HAZARD_SURCHARGE",
description: "Hazard surcharge",
quantity: 2,
unitRate: 250,
amount: 500,
},
],
...overrides,
};
}
describe("BillingService.generateInvoice", () => {
let savedLines: unknown[];
let manager: ReturnType<typeof makeManager>;
let events: ReturnType<typeof makeEvents>;
let dataSource: { transaction: jest.Mock; manager: unknown };
let service: BillingService;
beforeEach(() => {
savedLines = [];
manager = makeManager(savedLines);
events = makeEvents();
dataSource = {
transaction: jest
.fn()
.mockImplementation((cb: (mg: unknown) => unknown) => cb(manager)),
manager,
};
service = new BillingService(
dataSource as never,
{} as never,
{} as never,
events as never,
{} as never, // payment
{} as never, // companies
);
});
it("creates a PENDING invoice with one line per input line", async () => {
const invoice = await service.generateInvoice(generateInput());
expect(invoice.status).toBe(Freight.InvoiceStatus.Pending);
expect(invoice.companyId).toBe("company-1");
expect(invoice.source).toBe("booking");
expect(invoice.sourceId).toBe("booking-1");
expect(invoice.totalAmount).toBe(1500);
expect(invoice.issuedAt).toBeInstanceOf(Date);
expect(invoice.invoiceNumber).toMatch(/^FRT-\d{8}-00001$/);
expect(savedLines).toHaveLength(2);
});
it("sums line amounts when no explicit totalAmount is given", async () => {
const invoice = await service.generateInvoice(
generateInput({ totalAmount: undefined }),
);
expect(invoice.totalAmount).toBe(1500);
});
it("leaves issuedAt null for a DRAFT invoice", async () => {
const invoice = await service.generateInvoice(
generateInput({ status: Freight.InvoiceStatus.Draft }),
);
expect(invoice.status).toBe(Freight.InvoiceStatus.Draft);
expect(invoice.issuedAt).toBeNull();
});
it("enlists in a caller's transaction when a manager is passed", async () => {
await service.generateInvoice(generateInput(), manager as never);
expect(dataSource.transaction).not.toHaveBeenCalled();
expect(savedLines).toHaveLength(2);
});
});
describe("BillingService.markInvoiceAsPaid", () => {
it("marks the invoice PAID, links the payment, and emits ${source}.invoice.paid", async () => {
const open = {
id: "inv-1",
status: Freight.InvoiceStatus.Pending,
source: "booking",
sourceId: "booking-1",
};
const mg = {
findOne: jest.fn().mockResolvedValue(open),
update: jest.fn().mockResolvedValue(undefined),
};
const events = makeEvents();
const service = new BillingService(
{ manager: mg } as never,
{} as never,
{} as never,
events as never,
{} as never, // payment
{} as never, // companies
);
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
expect(mg.update).toHaveBeenCalledWith(
expect.anything(),
{ id: "inv-1" },
{ status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" },
);
expect(events.emit).toHaveBeenCalledWith(
"booking.invoice.paid",
expect.objectContaining({
invoiceId: "inv-1",
status: Freight.InvoiceStatus.Paid,
paymentId: "pay-1",
}),
);
});
it("is a no-op (no event) when the invoice is already paid", async () => {
const paid = {
id: "inv-1",
status: Freight.InvoiceStatus.Paid,
source: "booking",
};
const mg = {
findOne: jest.fn().mockResolvedValue(paid),
update: jest.fn().mockResolvedValue(undefined),
};
const events = makeEvents();
const service = new BillingService(
{ manager: mg } as never,
{} as never,
{} as never,
events as never,
{} as never, // payment
{} as never, // companies
);
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
expect(mg.update).not.toHaveBeenCalled();
expect(events.emit).not.toHaveBeenCalled();
});
});
describe("BillingService.settlePayable", () => {
it("settles the source's open invoice PAID and emits ${source}.invoice.paid", async () => {
const open = {
id: "inv-1",
status: Freight.InvoiceStatus.Pending,
source: Freight.InvoiceSource.Booking,
sourceId: "booking-1",
};
const mg = {
findOne: jest.fn().mockResolvedValue(open),
update: jest.fn().mockResolvedValue(undefined),
};
const events = makeEvents();
const service = new BillingService(
{ manager: mg } as never,
{} as never,
{} as never,
events as never,
{} as never, // payment
{} as never, // companies
);
const settled = await service.settlePayable(
Freight.InvoiceSource.Booking,
"booking-1",
"pay-1",
mg as never,
);
expect(settled?.status).toBe(Freight.InvoiceStatus.Paid);
expect(mg.update).toHaveBeenCalledWith(
expect.anything(),
{ id: "inv-1" },
{ status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" },
);
expect(events.emit).toHaveBeenCalledWith(
"booking.invoice.paid",
expect.anything(),
);
});
it("is a no-op (returns null) when the source has no open invoice", async () => {
const mg = {
findOne: jest.fn().mockResolvedValue(null),
update: jest.fn().mockResolvedValue(undefined),
};
const events = makeEvents();
const service = new BillingService(
{ manager: mg } as never,
{} as never,
{} as never,
events as never,
{} as never, // payment
{} as never, // companies
);
const settled = await service.settlePayable(
Freight.InvoiceSource.Booking,
"booking-1",
"pay-1",
mg as never,
);
expect(settled).toBeNull();
expect(mg.update).not.toHaveBeenCalled();
expect(events.emit).not.toHaveBeenCalled();
});
});

View File

@@ -1,26 +1,542 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { forwardRef, Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { EventEmitter2 } from "@nestjs/event-emitter";
import { Freight, PaymentReferenceType } from "@edr/types";
import { DataSource, EntityManager, In } from "typeorm";
import { Invoice } from "./entities/invoice.entity";
import { InvoiceLine } from "./entities/invoice-line.entity";
import { InvoiceRepository } from "./invoice.repository";
import { InvoiceLineRepository } from "./invoice-line.repository";
import { PaymentService } from "../payment/payment.service";
import { InitiateResponseDto } from "../payment/payments.dto";
import { CompaniesService } from "../companies/companies.service";
/** Options forwarded to the payment gateway when settling an invoice. */
export interface PayInvoiceOptions {
method?: string;
platform?: "web" | "mobile";
payerAccount?: string;
returnUrl?: string;
failureUrl?: string;
}
/** Default invoice payment-term window, in days, used to compute `dueAt`. */
const DEFAULT_DUE_DAYS = 14;
/** Statuses an invoice can still be settled (paid/refunded/cancelled) from. */
const OPEN_STATUSES: Freight.InvoiceStatus[] = [
Freight.InvoiceStatus.Draft,
Freight.InvoiceStatus.Pending,
Freight.InvoiceStatus.Overdue,
];
/** A single line to bill on a generated invoice. */
export interface InvoiceLineInput {
chargeType: string;
description?: string;
/** Units this line bills for; defaults to 1. */
quantity?: number;
/** Price per unit; defaults to 0. */
unitRate?: number;
/** Line total; defaults to `quantity * unitRate`. */
amount?: number;
currency?: string;
metadata?: Record<string, unknown> | null;
}
/** Everything needed to generate an invoice for any source. */
export interface GenerateInvoiceInput {
/** Originating subsystem; namespaces events (`${source}.invoice.<event>`). */
source: Freight.InvoiceSource;
/** Identifier of the source record (e.g. booking id). */
sourceId: string;
/** What the invoice is for (e.g. "prepaid", "credit"). */
type: string;
companyId: string;
companyProfileId: string;
lines: InvoiceLineInput[];
currency?: string;
/** Explicit total; defaults to the sum of line amounts. */
totalAmount?: number;
/** Issue date window; defaults to `DEFAULT_DUE_DAYS` from now. */
dueAt?: Date;
dueInDays?: number;
/**
* Initial status. DRAFT leaves `issuedAt` null; any issued status
* (default PENDING) stamps `issuedAt`.
*/
status?: Freight.InvoiceStatus;
}
/** Payload broadcast on `${source}.invoice.<event>`. */
export interface InvoiceEventPayload {
invoiceId: string;
invoiceNumber: string;
source: Freight.InvoiceSource;
sourceId: string;
type: string;
companyId: string;
companyProfileId: string;
totalAmount: number;
currency: string;
status: Freight.InvoiceStatus;
paymentId?: string | null;
}
@Injectable()
export class BillingService {
private readonly logger = new Logger(BillingService.name);
constructor(
@InjectRepository(Invoice)
private readonly invoicesRepository: Repository<Invoice>,
) {}
private readonly dataSource: DataSource,
private readonly invoices: InvoiceRepository,
private readonly invoiceLines: InvoiceLineRepository,
private readonly events: EventEmitter2,
@Inject(forwardRef(() => PaymentService))
private readonly payment: PaymentService,
private readonly companies: CompaniesService,
) { }
// ── Reads ──────────────────────────────────────────────────────────────────
/** List every invoice (most recent first). */
findAll(): Promise<Invoice[]> {
return this.invoicesRepository.find({ order: { issuedAt: "DESC" } });
return this.invoices.findAll({ order: { issuedAt: "DESC" } });
}
/** List invoices for a given booking. */
findByBooking(bookingId: string): Promise<Invoice[]> {
return this.invoicesRepository.find({
where: { bookingId },
/** Invoice header plus its line items. */
async findById(id: string): Promise<Invoice & { lines: InvoiceLine[] }> {
const invoice = await this.invoices.findById(id);
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
const lines = await this.invoiceLines.findAll({
where: { invoiceId: id },
order: { createdAt: "ASC" },
});
return { ...invoice, lines } as Invoice & { lines: InvoiceLine[] };
}
// ── Customer-scoped reads (portal) ───────────────────────────────────────────
/** Resolve the customer's company id from their IAM user id (null if none). */
async resolveCompanyId(userId: string): Promise<string | null> {
try {
const { company } = await this.companies.getCompanyInfoByUserId(userId);
return company?.id ?? null;
} catch {
return null;
}
}
/** Every invoice billed to a company, newest first, with billing relations. */
findByCompany(companyId: string): Promise<Invoice[]> {
return this.invoices.findAll({
where: { companyId },
relations: { company: true, companyProfile: true },
order: { createdAt: "DESC" },
});
}
/** Invoices for the signed-in customer; empty when they have no company. */
async findForUser(userId: string): Promise<Invoice[]> {
const companyId = await this.resolveCompanyId(userId);
return companyId ? this.findByCompany(companyId) : [];
}
/** Company-scoped invoice detail (+ lines); 404 when not owned by the user. */
async findByIdForUser(
id: string,
userId: string,
): Promise<Invoice & { lines: InvoiceLine[] }> {
const companyId = await this.resolveCompanyId(userId);
const invoice = await this.findById(id);
if (!companyId || invoice.companyId !== companyId) {
throw new NotFoundException(`Invoice ${id} not found`);
}
return invoice;
}
/**
* Initiate gateway payment for one of the customer's own invoices. Verifies
* ownership, then charges whichever open invoice the source currently has
* (see {@link payInvoice}).
*/
async payInvoiceForUser(
id: string,
userId: string,
opts: PayInvoiceOptions = {},
): Promise<InitiateResponseDto> {
const invoice = await this.findByIdForUser(id, userId);
return this.payInvoice(
invoice.source as Freight.InvoiceSource,
invoice.sourceId,
opts,
);
}
// ── Generation ───────────────────────────────────────────────────────────────
/** `FRT-YYYYMMDD-00001` — sequential per day, within the active transaction. */
private async nextInvoiceNumber(mg: EntityManager): Promise<string> {
const now = new Date();
const ymd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}`;
const prefix = `FRT-${ymd}-`;
const [row] = await mg.query(
`SELECT COALESCE(MAX(CAST(split_part(invoice_number, '-', 3) AS int)), 0) AS seq
FROM freight.invoices WHERE invoice_number LIKE $1`,
[`${prefix}%`],
);
const next = Number(row?.seq ?? 0) + 1;
return `${prefix}${String(next).padStart(5, "0")}`;
}
/**
* Generate an invoice for any source (booking, demurrage, manual, …).
*
* Persists the header plus its lines in one transaction and assigns the next
* sequential `invoice_number`. The total defaults to the sum of line amounts
* unless `totalAmount` is given. Issued invoices (default PENDING) stamp
* `issuedAt`; pass `status: DRAFT` to leave it unissued.
*
* Pass `manager` to enlist in a caller's transaction (e.g. when generating an
* invoice as part of a larger booking flow).
*/
async generateInvoice(
input: GenerateInvoiceInput,
manager?: EntityManager,
): Promise<Invoice & { lines: InvoiceLine[] }> {
const run = (mg: EntityManager) => this.createInvoice(input, mg);
return manager ? run(manager) : this.dataSource.transaction(run);
}
private async createInvoice(
input: GenerateInvoiceInput,
mg: EntityManager,
): Promise<Invoice & { lines: InvoiceLine[] }> {
const currency = input.currency ?? "ETB";
const status = input.status ?? Freight.InvoiceStatus.Pending;
const issued = status !== Freight.InvoiceStatus.Draft;
const lines = input.lines.map((l) => {
const quantity = l.quantity ?? 1;
const unitRate = l.unitRate ?? 0;
return {
chargeType: l.chargeType,
description: l.description,
quantity,
unitRate,
amount: l.amount ?? quantity * unitRate,
currency: l.currency ?? currency,
metadata: l.metadata ?? null,
};
});
const totalAmount =
input.totalAmount ?? lines.reduce((sum, l) => sum + Number(l.amount), 0);
const dueAt =
input.dueAt ??
new Date(
Date.now() +
(input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
);
const invoiceNumber = await this.nextInvoiceNumber(mg);
const invoice = await mg.save(
mg.create(Invoice, {
invoiceNumber,
source: input.source,
sourceId: input.sourceId,
type: input.type,
companyId: input.companyId,
companyProfileId: input.companyProfileId,
totalAmount,
currency,
status,
issuedAt: issued ? new Date() : null,
dueAt,
}),
);
const savedLines = await Promise.all(
lines.map((l) =>
mg.save(mg.create(InvoiceLine, { ...l, invoiceId: invoice.id })),
),
);
this.logger.log(
`Generated invoice ${invoice.invoiceNumber} (${invoice.id}) for ${input.source}:${input.sourceId}`,
);
return { ...invoice, lines: savedLines };
}
// ── State transitions ────────────────────────────────────────────────────────
/**
* Mark an invoice paid and link the gateway payment, then emit
* `${source}.invoice.paid`. Full-payment only — no partial settlement.
* No-op when the invoice is already paid. Pass `manager` to enlist in a
* caller's transaction.
*/
async markInvoiceAsPaid(
invoiceId: string,
paymentId: string | null = null,
manager?: EntityManager,
): Promise<Invoice | null> {
return this.transition(
invoiceId,
Freight.InvoiceStatus.Paid,
"paid",
{ paymentId: paymentId ?? undefined },
manager,
);
}
/**
* Mark an invoice refunded and emit `${source}.invoice.refunded`.
* No-op when already refunded.
*/
async markInvoiceAsRefunded(
invoiceId: string,
manager?: EntityManager,
): Promise<Invoice | null> {
return this.transition(
invoiceId,
Freight.InvoiceStatus.Refunded,
"refunded",
{},
manager,
);
}
/**
* Mark an invoice cancelled and emit `${source}.invoice.cancelled`.
* No-op when already cancelled.
*/
async cancelInvoice(
invoiceId: string,
manager?: EntityManager,
): Promise<Invoice | null> {
return this.transition(
invoiceId,
Freight.InvoiceStatus.Cancelled,
"cancelled",
{},
manager,
);
}
/**
* Load the invoice, apply the new status (+ extra columns), then emit
* `${source}.invoice.<event>`. No-op (returns the invoice) when it is already
* in the target status. Throws when the invoice does not exist.
*
* Note: the event fires in-process synchronously. When a `manager` from an
* outer transaction is passed, listeners run before that transaction commits.
*/
private async transition(
invoiceId: string,
status: Freight.InvoiceStatus,
event: string,
extra: { paymentId?: string },
manager?: EntityManager,
): Promise<Invoice | null> {
const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, { where: { id: invoiceId } });
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
if (invoice.status === status) return invoice;
await mg.update(Invoice, { id: invoice.id }, { status, ...extra });
const updated = { ...invoice, ...extra, status } as Invoice;
this.emitInvoiceEvent(event, updated);
return updated;
}
/** Broadcast `${invoice.source}.invoice.<event>` to in-process listeners. */
private emitInvoiceEvent(event: string, invoice: Invoice): void {
const payload: InvoiceEventPayload = {
invoiceId: invoice.id,
invoiceNumber: invoice.invoiceNumber,
source: invoice.source as Freight.InvoiceSource,
sourceId: invoice.sourceId,
type: invoice.type,
companyId: invoice.companyId,
companyProfileId: invoice.companyProfileId,
totalAmount: invoice.totalAmount,
currency: invoice.currency,
status: invoice.status,
paymentId: invoice.paymentId ?? null,
};
this.events.emit(`${invoice.source}.invoice.${event}`, payload);
}
// ── Payment reconciliation (by source) ───────────────────────────────────────
/**
* The invoice a gateway payment should settle for a source record, or null if
* none. This is the billing document of record for "what is owed" — callers
* (e.g. {@link payInvoice}) charge `invoice.totalAmount` against it rather than
* recomputing from the source's own total, so discounts/penalties/adjustments
* carried on the invoice are honored.
*
* Pass `type` to select a specific invoice when a source carries several (e.g.
* a booking's up-front vs final charge); omit it to settle whichever single
* invoice is currently open. Returns the most recent matching open (unpaid,
* non-cancelled) invoice.
*/
findPayable(
source: Freight.InvoiceSource,
sourceId: string,
type?: string,
): Promise<Invoice | null> {
return this.dataSource.getRepository(Invoice).findOne({
where: {
source,
sourceId,
status: In(OPEN_STATUSES),
...(type ? { type } : {}),
},
order: { issuedAt: "DESC" },
});
}
/**
* Settle a source's currently-open invoice as paid and link the gateway
* payment, then emit `${source}.invoice.paid`. Resolves the open invoice then
* delegates to {@link markInvoiceAsPaid}. Full-payment only — no partial
* settlement. No-op (returns null) when the source has no open invoice.
*
* Type-blind by design: settles whichever invoice is due; any per-type reaction
* belongs in the `${source}.invoice.paid` handler, which reads `invoice.type`.
* Pass the caller's transaction `manager` to enlist in its DB transaction.
*
* NOTE: the booking flow settles via {@link payInvoice} + the `payment.succeeded`
* event ({@link settleByPaymentId}); this source-keyed settle is a generic helper
* for callers that settle by source rather than by gateway intent id.
*/
async settlePayable(
source: Freight.InvoiceSource,
sourceId: string,
paymentId: string | null,
manager?: EntityManager,
): Promise<Invoice | null> {
const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, {
where: { source, sourceId, status: In(OPEN_STATUSES) },
order: { issuedAt: "DESC" },
});
if (!invoice) return null;
return this.markInvoiceAsPaid(invoice.id, paymentId, mg);
}
/**
* Refund a source's paid invoice, then emit `${source}.invoice.refunded`.
* Resolves the paid invoice then delegates to {@link markInvoiceAsRefunded}.
* No-op (returns null) when the source has no paid invoice.
*
* Pass the caller's transaction `manager` (e.g. from `payment.service.refund`)
* to enlist in its DB transaction.
*/
async refundPayable(
source: Freight.InvoiceSource,
sourceId: string,
manager?: EntityManager,
): Promise<Invoice | null> {
const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, {
where: { source, sourceId, status: Freight.InvoiceStatus.Paid },
order: { issuedAt: "DESC" },
});
if (!invoice) return null;
return this.markInvoiceAsRefunded(invoice.id, mg);
}
// ── Payment initiation & settlement (the gateway boundary) ───────────────────
/**
* Charge a source's open invoice through the payment gateway. Billing is the
* single place that turns "what is owed" (the invoice) into a payment intent —
* the domain never talks to the payment service directly. Resolves the open
* invoice, opens an intent for `invoice.totalAmount`, records the intent id on
* the invoice (the settlement correlation key), and returns the client action.
*
* When the provider settles synchronously, the invoice is settled inline here —
* after the intent id is stored — so the `payment.succeeded` correlation can
* never fire before the link exists. Throws when the source has no open invoice.
*/
async payInvoice(
source: Freight.InvoiceSource,
sourceId: string,
opts: {
method?: string;
platform?: "web" | "mobile";
payerAccount?: string;
returnUrl?: string;
failureUrl?: string;
} = {},
): Promise<InitiateResponseDto> {
const invoice = await this.findPayable(source, sourceId);
if (!invoice) {
throw new NotFoundException(`No open invoice to charge for ${source}:${sourceId}`);
}
const result = await this.payment.initiate({
referenceId: sourceId,
source: invoice.source,
// Gateway reference type derives from the invoice source by convention
// (source.toUpperCase() ∈ PaymentReferenceType) — no domain word here, and
// the domain never supplies it. New sources add their uppercased value to
// the PaymentReferenceType enum.
referenceType: invoice.source.toUpperCase() as PaymentReferenceType,
orderRef: invoice.invoiceNumber,
amountMinor: Math.round(Number(invoice.totalAmount)),
currency: invoice.currency,
reason: `Payment for invoice ${invoice.invoiceNumber}`,
method: opts.method ?? "TELEBIRR",
platform: opts.platform,
payerAccount: opts.payerAccount,
returnUrl: opts.returnUrl,
failureUrl: opts.failureUrl,
});
// Link the intent to the invoice BEFORE any settlement can correlate against it.
await this.dataSource
.getRepository(Invoice)
.update({ id: invoice.id }, { paymentId: result.intentId });
if (result.immediateSuccess) {
await this.settleByPaymentId(
result.intentId,
result.providerTxnId,
result.paidAt,
);
}
return result.response;
}
/**
* Settle the open invoice linked to a gateway intent id, if any. Called by the
* payment service when an intent succeeds: finds the invoice linked by
* `paymentId`, marks it paid, and emits `${source}.invoice.paid` for the domain
* to advance on. Idempotent — no-op when no open invoice is linked (already
* settled, or settled inline by {@link payInvoice}).
*/
async settleByPaymentId(
paymentId: string,
_providerTxnId?: string,
_paidAt?: Date,
): Promise<Invoice | null> {
const invoice = await this.dataSource.getRepository(Invoice).findOne({
where: { paymentId, status: In(OPEN_STATUSES) },
order: { issuedAt: "DESC" },
});
if (!invoice) return null;
return this.markInvoiceAsPaid(invoice.id, paymentId);
}
}

View File

@@ -0,0 +1,30 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsIn, IsOptional, IsString } from "class-validator";
/** Gateway options for paying an invoice from the customer portal. */
export class PayInvoiceDto {
@ApiPropertyOptional({ description: "Payment method (defaults to TELEBIRR)." })
@IsOptional()
@IsString()
method?: string;
@ApiPropertyOptional({ enum: ["web", "mobile"], default: "web" })
@IsOptional()
@IsIn(["web", "mobile"])
platform?: "web" | "mobile";
@ApiPropertyOptional({ description: "Payer account / phone, for wallet methods." })
@IsOptional()
@IsString()
payerAccount?: string;
@ApiPropertyOptional({ description: "Browser redirect URL on success." })
@IsOptional()
@IsString()
returnUrl?: string;
@ApiPropertyOptional({ description: "Browser redirect URL on failure." })
@IsOptional()
@IsString()
failureUrl?: string;
}

View File

@@ -0,0 +1,43 @@
import { BaseEntity } from "@edr/api-common";
import { Column, Entity, JoinColumn, ManyToOne } from "typeorm";
import { Invoice } from "./invoice.entity";
@Entity({ schema: "freight", name: "invoice_lines" })
export class InvoiceLine extends BaseEntity {
@Column({ name: "invoice_id", type: "uuid", nullable: false })
invoiceId!: string;
@ManyToOne(() => Invoice, { onDelete: "CASCADE" })
@JoinColumn({ name: "invoice_id" })
invoice!: Invoice;
@Column({ name: "charge_type", type: "varchar", nullable: false })
chargeType!: string;
@Column({ name: "description", type: "varchar", length: 255, nullable: true })
description?: string;
/** Units this line bills for (e.g. container count, wagon count, tons). */
@Column({ name: "quantity", type: "numeric", precision: 12, scale: 2, default: 1 })
quantity!: number;
/** Price per unit; `amount` is normally `quantity * unitRate`. */
@Column({ name: "unit_rate", type: "numeric", precision: 14, scale: 2, default: 0 })
unitRate!: number;
@Column({
name: "amount",
type: "numeric",
precision: 14,
scale: 2,
nullable: false,
})
amount!: number;
@Column({ name: "currency", type: "varchar", length: 8, default: "ETB" })
currency!: string;
@Column({ name: "metadata", type: "jsonb", nullable: true })
metadata?: Record<string, unknown> | null;
}

View File

@@ -1,17 +1,35 @@
import { BaseEntity } from "@edr/api-common";
import { Freight } from "@edr/types";
import { Column, Entity } from "typeorm";
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
import { PaymentEntity } from "../../payment/entities/payment.entity";
import { Company } from "../../companies/entities/company.entity";
import { CompanyProfile } from "../../companies/entities/company-profile.entity";
@Entity({schema:"freight", name: "invoices" })
@Entity({ schema: "freight", name: "invoices" })
@Index(["companyId"])
@Index(["companyProfileId"])
export class Invoice extends BaseEntity {
@Column({ name: "booking_id", type: "uuid" })
bookingId!: string;
@Column({ name: "invoice_number", type: "varchar", length: 64, unique: true })
invoiceNumber!: string;
@Column({ name: "amount", type: "numeric", precision: 14, scale: 2 })
amount!: number;
/** The customer (company) this invoice is billed to. */
@Column({ name: "company_id", type: "uuid" })
companyId!: string;
@ManyToOne(() => Company)
@JoinColumn({ name: "company_id" })
company?: Company;
/** The specific company profile (importer/exporter/forwarder/...) billed. */
@Column({ name: "company_profile_id", type: "uuid" })
companyProfileId!: string;
@ManyToOne(() => CompanyProfile)
@JoinColumn({ name: "company_profile_id" })
companyProfile?: CompanyProfile;
@Column({ name: "total_amount", type: "numeric", precision: 14, scale: 2 })
totalAmount!: number;
@Column({ name: "currency", type: "varchar", length: 8, default: "ETB" })
currency!: string;
@@ -19,13 +37,38 @@ export class Invoice extends BaseEntity {
@Column({
name: "status",
type: "enum",
enum: Freight.PaymentStatus,
default: Freight.PaymentStatus.Pending,
enum: Freight.InvoiceStatus,
default: Freight.InvoiceStatus.Draft,
})
status!: Freight.PaymentStatus;
status!: Freight.InvoiceStatus;
@Column({ name: "issued_at", type: "timestamptz" })
issuedAt!: Date;
/** The source of the payment (e.g. booking, customer, etc.). */
@Column({ name: "source", type: "varchar", length: 255, nullable: false })
source!: string;
/** The ID of the source (e.g. booking ID, customer ID, etc.). */
@Column({ name: "source_id", type: "varchar", length: 255, nullable: false })
sourceId!: string;
/** The type of Invoice (e.g. prepaid, credit, etc.). it suppose to answer the question "what is the invoice for?" */
@Column({
type: "varchar",
length: 255,
nullable: false,
})
type!: string;
/** Set when the invoice is actually issued (DRAFT invoices leave this null). */
@Column({ name: "issued_at", type: "timestamptz", nullable: true })
issuedAt?: Date | null;
/** The ID of the payment that generated this invoice. */
@Column({ name: "payment_id", type: "uuid", nullable: true })
paymentId?: string | null;
@ManyToOne(() => PaymentEntity)
@JoinColumn({ name: "payment_id" })
payment?: PaymentEntity;
@Column({ name: "due_at", type: "timestamptz" })
dueAt!: Date;

View File

@@ -0,0 +1,15 @@
import { BaseRepository } from "@edr/api-common";
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { InvoiceLine } from "./entities/invoice-line.entity";
@Injectable()
export class InvoiceLineRepository extends BaseRepository<InvoiceLine> {
constructor(
@InjectRepository(InvoiceLine) repository: Repository<InvoiceLine>,
) {
super(repository);
}
}

View File

@@ -0,0 +1,15 @@
import { BaseRepository } from "@edr/api-common";
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { Invoice } from "./entities/invoice.entity";
@Injectable()
export class InvoiceRepository extends BaseRepository<Invoice> {
constructor(
@InjectRepository(Invoice) repository: Repository<Invoice>,
) {
super(repository);
}
}

View File

@@ -0,0 +1,60 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
} from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { CurrentUser } from "@edr/api-common";
import {
type AuthUserPayload,
resolveAuthUserId,
} from "../../common/resolve-auth-user-id";
import { BillingService } from "./billing.service";
import { PayInvoiceDto } from "./dto/pay-invoice.dto";
/**
* Customer-facing billing endpoints. Unlike {@link BillingController} (admin,
* org-wide), every route here is force-scoped to the signed-in customer's
* company — they only ever see and pay their own invoices.
*/
@ApiTags("billing")
@ApiBearerAuth()
@Controller("billing")
export class PortalBillingController {
constructor(private readonly billingService: BillingService) {}
@Get("my-invoices")
@ApiOperation({ summary: "List the signed-in customer's invoices" })
findMine(@CurrentUser() user: AuthUserPayload) {
return this.billingService.findForUser(resolveAuthUserId(user));
}
@Get("my-invoices/:id")
@ApiOperation({ summary: "Get one of the customer's invoices (+ line items)" })
findMineById(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUserPayload,
) {
return this.billingService.findByIdForUser(id, resolveAuthUserId(user));
}
@Post("my-invoices/:id/pay")
@ApiOperation({ summary: "Initiate payment for one of the customer's invoices" })
pay(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUserPayload,
@Body() dto: PayInvoiceDto,
) {
return this.billingService.payInvoiceForUser(id, resolveAuthUserId(user), {
method: dto.method,
platform: dto.platform ?? "web",
payerAccount: dto.payerAccount,
returnUrl: dto.returnUrl,
failureUrl: dto.failureUrl,
});
}
}

View File

@@ -0,0 +1,201 @@
import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { Freight } from '@edr/types';
import { DataSource } from 'typeorm';
import {
BillingService,
GenerateInvoiceInput,
InvoiceEventPayload,
InvoiceLineInput,
} from '../billing/billing.service';
import { Invoice } from '../billing/entities/invoice.entity';
import { FirstMileService } from '../first-mile/first-mile.service';
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { PriceLineItemDto } from './dto/generate-price-response.dto';
import { BookingsRepository } from './bookings.repository';
import { Booking } from './entities/booking.entity';
/** Snapshot written onto `booking.pricingBreakdown` by the pricing service. */
interface StoredPricingBreakdown {
lineItems?: PriceLineItemDto[];
totalAmount?: number;
currency?: string;
}
/** Round to 2 decimals, avoiding binary float drift. */
const round2 = (n: number): number => Math.round(n * 100) / 100;
/**
* Owns the booking ⇄ invoice mapping — the one place that knows how a booking
* turns into invoices, which type to use, and how it advances when paid. Bookings
* are the billable business entity, so they generate their own invoices directly
* via {@link BillingService} (billing stays source-agnostic). All booking-specific
* type branching lives here, at the two points it belongs: invoice creation and
* settlement (the paid handler).
*/
@Injectable()
export class BookingInvoiceService {
private readonly logger = new Logger(BookingInvoiceService.name);
constructor(
private readonly billing: BillingService,
private readonly bookingsRepository: BookingsRepository,
private readonly dataSource: DataSource,
@Inject(forwardRef(() => FirstMileService))
private readonly firstMile: FirstMileService,
@Inject(forwardRef(() => BookingBatchService))
private readonly bookingBatch: BookingBatchService,
) { }
/**
* Ensure the booking has its invoice, generating one from the snapshotted
* pricing breakdown if absent. Called when a booking reaches a billable state.
* Idempotent — returns the existing open invoice instead of a duplicate.
* Returns `null` (and logs) when the booking is not billable: no company to
* bill (e.g. government bookings whose `companyId` is null, which the invoices
* FK requires), or no priced amount.
*/
async ensureInvoiceForBooking(booking: Booking): Promise<Invoice | null> {
const existing = await this.billing.findPayable(
Freight.InvoiceSource.Booking,
booking.id,
Freight.InvoiceType.Prepaid,
);
if (existing) return existing;
if (!booking.companyId) {
this.logger.warn(
`Skipping invoice for booking ${booking.reference} (${booking.id}): no company to bill.`,
);
return null;
}
const input = this.buildInput(booking);
if (!input) {
this.logger.warn(
`Skipping invoice for booking ${booking.reference} (${booking.id}): no priced amount.`,
);
return null;
}
return this.billing.generateInvoice(input);
}
/**
* React to a booking invoice being paid — the settlement branch point. Per-type
* reactions live here (not in the payment process): each invoice type advances
* the booking its own way. Only PREPAID exists today.
*/
@OnEvent('booking.invoice.paid')
async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
switch (payload.type) {
case Freight.InvoiceType.Prepaid:
await this.advanceBookingOnPayment(payload.sourceId);
break;
default:
this.logger.warn(
`Unhandled booking invoice type "${payload.type}" paid (${payload.invoiceId})`,
);
}
}
/**
* Advance a booking once its prepaid invoice settles — the domain side-effect
* of payment, relocated out of the payment service: the booking becomes PAID
* and is allocated into its batch. Idempotent — no-op when already PAID.
*
* General contracts are a separate aggregate now: their CONTRACT_ACTIVE
* lifecycle and ordering window live in the contracts module, advanced by the
* contract transition/clearance services — not by booking payment. Every
* booking that settles here is a ONE_TIME shipment, so there is no contract
* branch (legacy GENERAL_CONTRACT booking creation now 410s).
*/
private async advanceBookingOnPayment(bookingId: string): Promise<void> {
const booking = await this.bookingsRepository.findById(bookingId);
if (!booking) {
this.logger.warn(`Cannot advance unknown booking ${bookingId} on payment.`);
return;
}
if (booking.paymentStatus === 'PAID') return;
await this.dataSource.transaction(async (mg) => {
await mg.update(
Booking,
{ id: bookingId },
{ paymentStatus: 'PAID', status: 'PAID' },
);
await this.firstMile.acceptBooking(bookingId);
});
try {
await this.bookingBatch.ensurePaidBookingAllocated(bookingId);
} catch (err) {
this.logger.error(
`Error allocating booking after payment: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
/** Map a booking's pricing snapshot into a generic invoice request. */
private buildInput(booking: Booking): GenerateInvoiceInput | null {
const breakdown = (booking.pricingBreakdown ?? {}) as StoredPricingBreakdown;
const currency = breakdown.currency ?? booking.paymentCurrency ?? 'ETB';
const lines: InvoiceLineInput[] = (breakdown.lineItems ?? []).map((l) => ({
chargeType: l.code,
description: l.description,
quantity: l.quantity,
unitRate: l.unitAmount,
amount: l.amount,
currency: l.currency ?? currency,
metadata: l.unit ? { unit: l.unit } : null,
}));
// Fall back to a single freight line when no breakdown was snapshotted.
if (lines.length === 0) {
const amount = Number(booking.totalAmount);
if (!Number.isFinite(amount) || amount <= 0) return null;
lines.push({
chargeType: 'FREIGHT',
description: 'Rail freight',
quantity: 1,
unitRate: amount,
amount,
currency,
});
}
const subtotal = round2(lines.reduce((sum, l) => sum + Number(l.amount), 0));
let totalAmount = subtotal;
// Honor a staff price override: bill the adjusted total, recording the delta
// as an ADJUSTMENT line so the lines still sum to the invoice total.
const adjusted = booking.adjustedTotalAmount;
if (adjusted != null && Number.isFinite(Number(adjusted))) {
const delta = round2(Number(adjusted) - subtotal);
if (delta !== 0) {
lines.push({
chargeType: 'ADJUSTMENT',
description: 'Staff price adjustment',
quantity: 1,
unitRate: delta,
amount: delta,
currency,
});
}
totalAmount = round2(Number(adjusted));
}
return {
source: Freight.InvoiceSource.Booking,
sourceId: booking.id,
type: Freight.InvoiceType.Prepaid,
companyId: booking.companyId,
companyProfileId: booking.companyProfileId,
currency,
lines,
totalAmount,
};
}
}

View File

@@ -0,0 +1,180 @@
import {
Body,
Controller,
Get,
HttpStatus,
Post,
Query,
Res,
} from "@nestjs/common";
import {
ApiTags,
ApiOperation,
ApiQuery,
ApiOkResponse,
ApiProduces,
} from "@nestjs/swagger";
import { Response } from "express";
import { Public } from "@edr/api-common";
import { Freight } from "@edr/types";
import { BillingService } from "../billing/billing.service";
import {
InitiatePaymentDto,
InitiateResponseDto,
PaymentMethodTypeEnum,
PaymentPlatformDto,
} from "../payment/payments.dto";
/**
* Booking-payment entrypoints. This is the ONE place that knows a payment is for a
* booking — it maps the request to {@link Freight.InvoiceSource.Booking} and hands
* off to billing, which resolves the invoice/amount and drives the gateway. Billing
* and payment stay source-agnostic; the booking knowledge lives here, in the domain.
* Routes are unchanged (`/payments/*`) so the portal is unaffected.
*/
@ApiTags("Payment")
@Controller("payments")
export class BookingPaymentController {
constructor(private readonly billing: BillingService) { }
@Post("initiate")
@ApiOperation({
summary: "Initiate payment for a freight booking",
description: "Charges the booking's open invoice through the payment gateway.",
})
@ApiOkResponse({ type: InitiateResponseDto })
initiate(@Body() dto: InitiatePaymentDto): Promise<InitiateResponseDto> {
return this.billing.payInvoice(Freight.InvoiceSource.Booking, dto.bookingId, {
method: dto.method,
platform: dto.platform,
payerAccount: dto.payerAccount,
returnUrl: dto.returnUrl,
failureUrl: dto.failureUrl,
});
}
@Get("checkout")
@Public()
@ApiOperation({
summary: "Browser checkout redirect",
description:
"Charges the booking's invoice and returns an HTML page that auto-redirects to the provider checkout URL. Open directly in a browser tab.",
})
@ApiQuery({ name: "bookingId", required: true })
@ApiQuery({ name: "method", enum: PaymentMethodTypeEnum, required: true })
@ApiQuery({ name: "platform", enum: ["web", "mobile"], required: false })
@ApiProduces("text/html")
async checkout(
@Query("bookingId") bookingId: string,
@Query("method") method: PaymentMethodTypeEnum,
@Query("platform") platform: PaymentPlatformDto = "web",
@Res() res: Response,
) {
if (!bookingId) {
return res
.status(HttpStatus.BAD_REQUEST)
.type("html")
.send(this.buildErrorHtml("Missing required query parameter: bookingId"));
}
if (!method || !Object.values(PaymentMethodTypeEnum).includes(method)) {
return res
.status(HttpStatus.BAD_REQUEST)
.type("html")
.send(this.buildErrorHtml("Missing or invalid query parameter: method"));
}
try {
const result = await this.billing.payInvoice(
Freight.InvoiceSource.Booking,
bookingId,
{ method, platform },
);
const url =
result.clientAction?.type === "REDIRECT" ? result.clientAction.url : undefined;
if (url) {
return res.status(HttpStatus.OK).type("html").send(this.buildRedirectHtml(url));
}
return res
.status(HttpStatus.OK)
.type("html")
.send(this.buildStatusHtml(result.status, result.intentId));
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "An unexpected error occurred";
return res.status(HttpStatus.OK).type("html").send(this.buildErrorHtml(message));
}
}
private buildRedirectHtml(url: string): string {
const escaped = url.replace(/\"/g, "&quot;");
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="refresh" content="0;url=${escaped}">
<title>Redirecting to payment…</title>
<style>
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
.spinner { width: 40px; height: 40px; border: 4px solid #e0e0e0; border-top-color: #1a73e8; border-radius: 50%; animation: spin .8s linear infinite; margin: 0 auto 20px; }
@keyframes spin { to { transform: rotate(360deg); } }
p { color: #555; margin: 0 0 16px; }
a { color: #1a73e8; }
</style>
</head>
<body>
<div class="card">
<div class="spinner"></div>
<p>Redirecting to payment provider…</p>
<p><a href="${escaped}">Click here if you are not redirected</a></p>
</div>
<script>window.location.href = "${escaped}";</script>
</body>
</html>`;
}
private buildStatusHtml(status: string, intentId: string): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Payment status</title>
<style>
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
.status { font-size: 1.1rem; font-weight: 600; color: #333; margin-bottom: 8px; }
small { color: #888; }
</style>
</head>
<body>
<div class="card">
<div class="status">${status}</div>
<small>Intent: ${intentId}</small>
</div>
</body>
</html>`;
}
private buildErrorHtml(message: string): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Payment error</title>
<style>
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
.error { color: #d32f2f; font-weight: 600; margin-bottom: 8px; }
p { color: #555; }
</style>
</head>
<body>
<div class="card">
<div class="error">Payment could not be initiated</div>
<p>${message}</p>
</div>
</body>
</html>`;
}
}

View File

@@ -1,49 +1,37 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Freight } from '@edr/types';
import { BookingsRepository } from './bookings.repository';
import { Booking } from './entities/booking.entity';
import { assertBookingStatus } from './booking-status.util';
import { InAppPaymentReceiptDto } from './dto/pay-booking.dto';
import { PaymentService } from '../payment/payment.service';
import { PaymentStatus } from '../payment/entities/payment.entity';
import { BillingService } from '../billing/billing.service';
import { PaymentMethodTypeEnum } from '../payment/payments.dto';
export interface InAppPaymentReceipt extends InAppPaymentReceiptDto { }
const NON_TERMINAL_STATUSES: PaymentStatus[] = [
"action-required",
"processing",
"success",
];
@Injectable()
export class BookingPaymentService {
constructor(
private readonly bookingsRepository: BookingsRepository,
private readonly paymentService: PaymentService,
private readonly billing: BillingService,
) { }
/**
* Start payment for a booking. The booking never touches the payment gateway
* directly — it charges its invoice through billing, which resolves the amount
* and drives the provider. Returns the provider redirect URL (empty when none).
*/
async pay(bookingId: string): Promise<{ redirectUrl: string }> {
const booking = await this.requireBooking(bookingId);
assertBookingStatus(booking, ['FULLY_EXECUTED', 'SELECTED_FOR_BATCH', 'AWAITING_PAYMENT', '']);
const existing = await this.paymentService.findBookingById(bookingId);
if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) {
if (existing.clientAction) {
const action = existing.clientAction as { type?: string; url?: string };
if (action.type === "REDIRECT" && action.url) {
return { redirectUrl: action.url };
}
}
}
const resp = await this.paymentService.initiatePayment({
bookingId,
const resp = await this.billing.payInvoice(Freight.InvoiceSource.Booking, bookingId, {
method: PaymentMethodTypeEnum.TELEBIRR,
platform: "web",
platform: 'web',
});
const action = resp.clientAction as { type?: string; url?: string } | undefined;
return {
redirectUrl: action?.type === "REDIRECT" ? (action.url ?? "") : "",
redirectUrl: action?.type === 'REDIRECT' ? (action.url ?? '') : '',
};
}

View File

@@ -30,6 +30,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
ruleEngineService as never,
{} as never, // pricingService
{} as never, // contractService
{} as never, // invoiceService
{} as never, // filesService
{} as never, // fileUploadSettingsService
{} as never, // bookingBatchService

View File

@@ -41,6 +41,7 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
{} as never, // ruleEngineService
{} as never, // pricingService
{} as never, // contractService
{} as never, // invoiceService
filesService as never,
fileUploadSettingsService as never,
{} as never, // bookingBatchService
@@ -122,6 +123,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
{} as never,
{} as never,
{} as never,
{} as never, // invoiceService
filesService as never,
fileUploadSettingsService as never,
{} as never,
@@ -189,6 +191,7 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields
{} as never,
{} as never,
{} as never,
{} as never, // invoiceService
filesService as never,
fileUploadSettingsService as never,
{} as never,

View File

@@ -33,6 +33,7 @@ describe('BookingTransitionService — operation review', () => {
{} as never, // ruleEngineService
{} as never, // pricingService
{} as never, // contractService
{} as never, // invoiceService
{} as never, // filesService
{} as never, // fileUploadSettingsService
bookingBatchService as never,

View File

@@ -3,6 +3,7 @@ import {
forwardRef,
Inject,
Injectable,
Logger,
} from '@nestjs/common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
@@ -14,6 +15,7 @@ import { RuleEngineService } from '../rule-engine/rule-engine.service';
import { FilesService } from '../files/files.service';
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
import { BookingContractService } from './booking-contract.service';
import { BookingInvoiceService } from './booking-invoice.service';
import { BookingPricingService } from './booking-pricing.service';
import { BookingsRepository } from './bookings.repository';
import { assertBookingStatus } from './booking-status.util';
@@ -26,11 +28,14 @@ import { BookingsService } from './bookings.service';
@Injectable()
export class BookingTransitionService {
private readonly logger = new Logger(BookingTransitionService.name);
constructor(
private readonly bookingsRepository: BookingsRepository,
private readonly ruleEngineService: RuleEngineService,
private readonly pricingService: BookingPricingService,
private readonly contractService: BookingContractService,
private readonly invoiceService: BookingInvoiceService,
private readonly filesService: FilesService,
private readonly fileUploadSettingsService: FileUploadSettingsService,
@Inject(forwardRef(() => BookingBatchService))
@@ -404,7 +409,22 @@ export class BookingTransitionService {
marketingApprovedAt: new Date(),
lockedAt: new Date(),
} as never);
return this.bookingsService.findById(updated!.id);
const executed = await this.bookingsService.findById(updated!.id);
// Billable state reached — generate the invoice payment will settle.
// Non-blocking: a billing hiccup must not undo the execution.
await this.invoiceService
.ensureInvoiceForBooking(executed)
.catch((err) =>
this.logger.error(
`Failed to generate invoice for booking ${executed.reference}: ${
err instanceof Error ? err.message : String(err)
}`,
),
);
return executed;
}
async startTransit(bookingId: string): Promise<Booking> {

View File

@@ -10,7 +10,11 @@ import { MinioModule } from '../minio/minio.module';
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module';
import { SignaturesModule } from '../signatures/signatures.module';
import { BillingModule } from '../billing/billing.module';
import { FirstMileModule } from '../first-mile/first-mile.module';
import { BookingContractService } from './booking-contract.service';
import { BookingInvoiceService } from './booking-invoice.service';
import { BookingPaymentController } from './booking-payment.controller';
import { BookingPaymentService } from './booking-payment.service';
import { BookingPricingService } from './booking-pricing.service';
import { BookingReferenceDataService } from './booking-reference-data.service';
@@ -33,7 +37,6 @@ import { ContractPricingScheduleBuilder } from '../../contracts/contract-pricing
import { ContractRendererService } from '../../contracts/contract-renderer.service';
import { ContractTemplateResolver } from '../../contracts/contract-template.resolver';
import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
import { PaymentModule } from '../payment/payment.module';
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
@Module({
@@ -48,7 +51,8 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
BookingReviewNote,
BookingContractSignature,
]),
PaymentModule,
BillingModule,
forwardRef(() => FirstMileModule),
forwardRef(() => TrainSchedulingModule),
FilesModule,
MinioModule,
@@ -63,7 +67,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
config.get<ExchangeOptions>('app.cbeExchange') ?? {},
}),
],
controllers: [BookingsController, PayController],
controllers: [BookingsController, PayController, BookingPaymentController],
providers: [
BookingsService,
BookingsRepository,
@@ -72,6 +76,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
BookingPricingService,
BookingTransitionService,
BookingContractService,
BookingInvoiceService,
BookingPaymentService,
ContractTemplateResolver,
ContractViewModelBuilder,
@@ -79,6 +84,6 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
ContractRendererService,
ContractPdfService,
],
exports: [BookingsService, BookingsRepository, BookingPricingService],
exports: [BookingsService, BookingsRepository, BookingPricingService, BookingInvoiceService],
})
export class BookingsModule {}

View File

@@ -12,7 +12,7 @@ import { Freight, SchedulingStatus } from '@edr/types';
// import { CustomersService } from '../customers/customers.service';
import { CompaniesService } from '../companies/companies.service';
import { ProfileType } from '../companies/entities/company-profile.entity';
import { CompanyStatus } from '../companies/entities/company.entity';
import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
import { eatDay } from '../train-scheduling/batch-window.util';
import { FilesService } from '../files/files.service';
@@ -307,10 +307,23 @@ export class BookingsService {
let companyId: string | null | undefined = dto.companyId;
if (isGovernment) {
if (!dto.governmentInstitution?.trim()) {
throw new BadRequestException('governmentInstitution is required for government bookings');
// Government bookings bill to a real seeded government company + an
// explicitly-chosen importer/exporter profile (no more null company +
// free-text institution).
if (!dto.companyId) {
throw new BadRequestException('A government company is required for government bookings');
}
companyId = dto.companyId ?? null;
const govCompany = await this.companiesService.findCompanyById(dto.companyId);
if (govCompany.kind !== CompanyKind.Government) {
throw new BadRequestException('Selected company is not a government entity');
}
if (govCompany.status !== CompanyStatus.Active) {
throw new BadRequestException('Selected government company is not active');
}
if (!dto.companyProfileId) {
throw new BadRequestException('A government company profile is required for government bookings');
}
companyId = govCompany.id;
} else if (!companyId) {
if (!userId) {
throw new BadRequestException(
@@ -383,7 +396,16 @@ export class BookingsService {
// so the customer portal can scope lists/KPIs to the active mode. Best-effort
// for non-government bookings with a resolved company; never blocks creation.
let companyProfileId: string | null = null;
if (!isGovernment && companyId) {
if (dto.companyProfileId && companyId) {
// Explicit profile pin (government booking, or staff booking on behalf):
// must belong to the chosen company and be active.
const profile =
await this.companiesService.getActiveCompanyProfileForBooking(
companyId,
dto.companyProfileId,
);
companyProfileId = profile.id;
} else if (companyId) {
let fallbackType: ProfileType | null = null;
if (userId) {
try {
@@ -413,6 +435,16 @@ export class BookingsService {
}
}
// Every booking must link to a company and a company profile.
if (!companyId) {
throw new BadRequestException('A company is required to create a booking');
}
if (!companyProfileId) {
throw new BadRequestException(
'A company profile is required to create a booking — none could be resolved for this company',
);
}
const needsConsolidation =
dto.freightType === 'CONTAINER'
? await this.needsConsolidation(containers)
@@ -443,10 +475,10 @@ export class BookingsService {
const booking = await this.bookingsRepository.create({
reference,
companyId: companyId ?? null,
companyId,
companyProfileId,
isGovernment,
governmentInstitution: isGovernment ? dto.governmentInstitution!.trim() : null,
governmentInstitution: dto.governmentInstitution?.trim() || null,
trainId: dto.trainId,
trainScheduleId: dto.trainScheduleId ?? null,
contractType: dto.contractType,

View File

@@ -14,7 +14,6 @@ import {
Max,
MaxLength,
Min,
MinLength,
Validate,
ValidateIf,
ValidateNested,
@@ -104,19 +103,31 @@ export class CreateBookingDto {
@Transform(({ value }) => value === 'true' || value === true)
isGovernment?: boolean;
@ApiPropertyOptional({ description: 'Required when isGovernment is true' })
@ValidateIf((o) => o.isGovernment === true)
/** @deprecated Government bookings now bill to a real government company. */
@ApiPropertyOptional({ description: 'Deprecated: free-text institution (superseded by companyId)' })
@IsOptional()
@IsString()
@MinLength(2)
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
governmentInstitution?: string;
@ApiPropertyOptional({ format: 'uuid', description: 'Admin only: target company' })
@ValidateIf((o) => o.isGovernment !== true)
@ApiPropertyOptional({
format: 'uuid',
description:
'Target company. Required for staff/government bookings; resolved from the auth token for customer self-bookings.',
})
@IsOptional()
@IsUUID()
companyId?: string;
@ApiPropertyOptional({
format: 'uuid',
description:
'Explicit company profile (importer/exporter). Required for government bookings; commercial bookings auto-resolve from trade direction.',
})
@IsOptional()
@IsUUID()
companyProfileId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()

View File

@@ -105,8 +105,10 @@ export class Booking extends BaseEntity {
// @JoinColumn({ name: 'customer_id' })
// customer?: Customer;
@Column({ name: 'company_id', type: 'uuid', nullable: true })
companyId?: string | null;
// Every booking is billed to a company — government bookings bill to a seeded
// government company (companies.kind = 'government'). Enforced NOT NULL.
@Column({ name: 'company_id', type: 'uuid' })
companyId!: string;
@ManyToOne(() => Company, { nullable: true })
@JoinColumn({ name: 'company_id' })
@@ -116,11 +118,12 @@ export class Booking extends BaseEntity {
* The operational profile (importer/exporter/forwarder) this booking belongs
* to. Stamped at creation from the booking's trade direction (IMPORT→importer,
* EXPORT→exporter) or the user's active profile for DOMESTIC/forwarder.
* Customer portal lists and dashboard KPIs are scoped by this. Nullable for
* legacy/government/staff-created bookings.
* Customer portal lists and dashboard KPIs are scoped by this. Required:
* commercial bookings resolve it from trade direction / active mode;
* government bookings carry the explicitly-picked government profile.
*/
@Column({ name: 'company_profile_id', type: 'uuid', nullable: true })
companyProfileId?: string | null;
@Column({ name: 'company_profile_id', type: 'uuid' })
companyProfileId!: string;
@ManyToOne(() => CompanyProfile, { nullable: true })
@JoinColumn({ name: 'company_profile_id' })

View File

@@ -38,7 +38,7 @@ export class CompaniesRepository extends BaseRepository<Company> {
async findPaginated(
query: ListCompaniesQueryDto,
): Promise<{ items: Company[]; total: number }> {
const { page = 1, pageSize = 20, search, type, status } = query;
const { page = 1, pageSize = 20, search, type, kind, status } = query;
const qb = this.repository
.createQueryBuilder('company')
@@ -49,6 +49,10 @@ export class CompaniesRepository extends BaseRepository<Company> {
qb.andWhere('company.type = :type', { type });
}
if (kind) {
qb.andWhere('company.kind = :kind', { kind });
}
if (status) {
qb.andWhere('company.status = :status', { status });
}

View File

@@ -337,6 +337,29 @@ export class CompaniesService {
return company;
}
/**
* Validate an explicitly-chosen company profile for a booking: it must belong
* to the booking's company and be Active. Used for government bookings (staff
* pick the profile) and any staff booking that pins a profile directly.
*/
async getActiveCompanyProfileForBooking(
companyId: string,
profileId: string,
): Promise<CompanyProfile> {
const profile = await this.companyProfilesRepo.findById(profileId);
if (!profile || profile.companyId !== companyId) {
throw new BadRequestException(
"Selected company profile does not belong to the chosen company",
);
}
if (profile.status !== ProfileStatus.Active) {
throw new BadRequestException(
"Selected company profile is not active",
);
}
return profile;
}
async getCompanyInfoByUserId(
userId: string,
): Promise<{ profile: ExternalProfile; company: Company }> {

View File

@@ -1,7 +1,7 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsIn, IsInt, IsOptional, IsString, Min } from "class-validator";
import { Transform } from "class-transformer";
import { CompanyStatus, CompanyType } from "../entities/company.entity";
import { CompanyKind, CompanyStatus, CompanyType } from "../entities/company.entity";
export class ListCompaniesQueryDto {
@ApiPropertyOptional({ default: 1 })
@@ -28,6 +28,11 @@ export class ListCompaniesQueryDto {
@IsIn(Object.values(CompanyType))
type?: CompanyType;
@ApiPropertyOptional({ enum: CompanyKind })
@IsOptional()
@IsIn(Object.values(CompanyKind))
kind?: CompanyKind;
@ApiPropertyOptional({ enum: CompanyStatus })
@IsOptional()
@IsIn(Object.values(CompanyStatus))

View File

@@ -10,6 +10,16 @@ export enum CompanyType {
Transporter = "transporter",
}
/**
* Sector of the company — orthogonal to {@link CompanyType} (the trade role).
* Government bookings are billed to a single seeded `GOVERNMENT` company instead
* of carrying a null company + free-text institution.
*/
export enum CompanyKind {
Commercial = "commercial",
Government = "government",
}
export enum CompanyStatus {
Active = "active",
Pending = "pending",
@@ -25,6 +35,7 @@ export enum CompanyNationality {
@Entity({ schema: "freight", name: "companies" })
@Index(["tin"])
@Index(["type"])
@Index(["kind"])
export class Company extends BaseEntity {
@Column({ name: "name", type: "varchar", length: 200 })
name!: string;
@@ -32,6 +43,16 @@ export class Company extends BaseEntity {
@Column({ name: "type", type: "varchar", length: 32, enum: CompanyType })
type!: CompanyType;
/** Commercial customer vs. the seeded government entity. */
@Column({
name: "kind",
type: "varchar",
length: 20,
default: CompanyKind.Commercial,
enum: CompanyKind,
})
kind!: CompanyKind;
@Column({
name: "status",
type: "varchar",

View File

@@ -2,6 +2,7 @@ import {
BadRequestException,
ForbiddenException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { DataSource } from 'typeorm';
@@ -11,6 +12,7 @@ import { BookingContainer } from '../bookings/entities/booking-container.entity'
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
import { BookingsRepository } from '../bookings/bookings.repository';
import { BookingPricingService } from '../bookings/booking-pricing.service';
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
import { RuleEngineService } from '../rule-engine/rule-engine.service';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
@@ -45,6 +47,8 @@ export interface CreateBookingUnderContractResult {
*/
@Injectable()
export class ContractBookingService {
private readonly logger = new Logger(ContractBookingService.name);
constructor(
private readonly contractsRepository: ContractsRepository,
private readonly bookingsRepository: BookingsRepository,
@@ -52,6 +56,7 @@ export class ContractBookingService {
private readonly containerTypesService: ContainerTypesService,
private readonly ruleEngineService: RuleEngineService,
private readonly milestoneService: ClearanceMilestoneService,
private readonly invoiceService: BookingInvoiceService,
private readonly dataSource: DataSource,
) {}
@@ -199,6 +204,22 @@ export class ContractBookingService {
}
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
// Contract bookings are born past the billable gate (the contract is already
// executed), so the invoice is generated here — they never pass through the
// legacy marketingApprove → FULLY_EXECUTED path that invoices direct bookings.
// Idempotent and non-blocking: a billing hiccup must not undo the booking.
// Skips silently when unbillable (no company / no priced amount).
await this.invoiceService
.ensureInvoiceForBooking(result ?? booking)
.catch((err) =>
this.logger.error(
`Failed to generate invoice for contract booking ${booking.reference}: ${
err instanceof Error ? err.message : String(err)
}`,
),
);
return { booking: result ?? booking, warnings };
}

View File

@@ -34,6 +34,9 @@ export class FirstMile extends BaseEntity {
@Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 })
remainingPayment!: number;
// @Column({ type: 'boolean', default: false })
// isPostPaymentCompleted!: boolean;
@Column({ name: 'estimated_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
estimatedKm?: number | null;

View File

@@ -3,7 +3,7 @@ import { FindOptionsWhere } from 'typeorm';
import { BookingsRepository } from '../bookings/bookings.repository';
import { DriversService } from '../drivers/drivers.service';
import { NotificationsService } from '../notifications/notifications.service';
import { SmsClientService } from '../notifications/sms-client.service';
import { VehiclesService } from '../vehicles/vehicles.service';
import { CreateFirstMileDto } from './dto/create-first-mile.dto';
import { UpdateFirstMileDto } from './dto/update-first-mile.dto';
@@ -36,7 +36,7 @@ export class FirstMileService {
private readonly bookingsRepository: BookingsRepository,
private readonly vehiclesService: VehiclesService,
private readonly driversService: DriversService,
private readonly notificationsService: NotificationsService,
private readonly smsClient: SmsClientService,
) {}
/**
@@ -251,16 +251,19 @@ export class FirstMileService {
const booking = (record as FirstMile & { booking?: { reference?: string; firstMilePickupAddress?: string | null; originYard?: { label?: string } | null } }).booking;
await this.notificationsService.notifyDriverVehicleAssignment({
driverPhone: driver.phoneNumber,
driverName: `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(),
vehiclePlateNumber: vehicle.plateNumber ?? vehicleId,
bookingReference: booking?.reference ?? record.bookingId,
pickupAddress: booking?.firstMilePickupAddress,
destinationYard: booking?.originYard?.label,
const driverName = `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim();
const message =
`Dear ${driverName}, you have been assigned to a first-mile pickup. ` +
`Booking: ${booking?.reference ?? record.bookingId}. Vehicle: ${vehicle.plateNumber ?? vehicleId}. ` +
(booking?.firstMilePickupAddress ? `Pickup: ${booking.firstMilePickupAddress}. ` : '') +
(booking?.originYard?.label ? `Destination: ${booking.originYard.label}.` : '');
void this.smsClient.sendSms({
to: driver.phoneNumber,
message,
});
this.logger.log(`SMS sent to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`);
this.logger.log(`SMS queued to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`);
} catch (err) {
this.logger.error(`Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`);
}

View File

@@ -34,6 +34,9 @@ export class LastMile extends BaseEntity {
@Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 })
remainingPayment!: number;
// @Column({ type: 'boolean', default: false })
// isPostPaymentCompleted!: boolean;
@Column({ name: 'estimated_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
estimatedKm?: number | null;

View File

@@ -3,7 +3,7 @@ import { FindOptionsWhere } from 'typeorm';
import { BookingsRepository } from '../bookings/bookings.repository';
import { DriversService } from '../drivers/drivers.service';
import { NotificationsService } from '../notifications/notifications.service';
import { SmsClientService } from '../notifications/sms-client.service';
import { VehiclesService } from '../vehicles/vehicles.service';
import { CreateLastMileDto } from './dto/create-last-mile.dto';
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
@@ -32,12 +32,11 @@ export class LastMileService {
private readonly logger = new Logger(LastMileService.name);
constructor(
private readonly lastMileRepository: LastMileRepository,
private readonly bookingsRepository: BookingsRepository,
private readonly vehiclesService: VehiclesService,
private readonly driversService: DriversService,
private readonly notificationsService: NotificationsService,
private readonly smsClient: SmsClientService,
) {}
async acceptBooking(bookingReference: string): Promise<LastMile | null> {
@@ -185,16 +184,19 @@ export class LastMileService {
};
const booking = (record as LastMile & { booking?: BookingWithYards }).booking;
await this.notificationsService.notifyDriverVehicleAssignment({
driverPhone: driver.phoneNumber,
driverName: `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(),
vehiclePlateNumber: vehicle.plateNumber ?? vehicleId,
bookingReference: booking?.reference ?? record.bookingId,
pickupAddress: booking?.destinationYard?.label,
destinationYard: booking?.lastMileDeliveryAddress,
const driverName = `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim();
const message =
`Dear ${driverName}, you have been assigned to a last-mile delivery. ` +
`Booking: ${booking?.reference ?? record.bookingId}. Vehicle: ${vehicle.plateNumber ?? vehicleId}. ` +
(booking?.destinationYard?.label ? `Pickup: ${booking.destinationYard.label}. ` : '') +
(booking?.lastMileDeliveryAddress ? `Destination: ${booking.lastMileDeliveryAddress}.` : '');
void this.smsClient.sendSms({
to: driver.phoneNumber,
message,
});
this.logger.log(`SMS sent to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`);
this.logger.log(`SMS queued to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`);
} catch (err) {
this.logger.error(`Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`);
}

View File

@@ -2,7 +2,8 @@ import { BaseEntity, Column, CreateDateColumn, Entity, OneToMany, PrimaryGenerat
import { PaymentRefundEntity } from "./payment-refund.entity";
type PaymentType = "booking"
/** 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 Currency = "ETB" | "USD"
export type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded"
@@ -15,9 +16,12 @@ export class PaymentEntity extends BaseEntity {
@Column({ type: 'varchar', length: 255, name: "ref_id" })
refId!: string
@Column({ type: "enum", enum: ["booking"] })
@Column({ type: "varchar", length: 50 })
type!: PaymentType;
@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"] })
method!: PaymentMethod

View File

@@ -1,13 +1,13 @@
import {
Body,
Controller,
Get,
HttpStatus,
Param,
ParseUUIDPipe,
Post,
Query,
Res,
Body,
Post,
} from "@nestjs/common";
import {
ApiTags,
@@ -20,14 +20,7 @@ import { Response } from "express";
import { Public } from "@edr/api-common";
import { BookingView, FreightAdmin } from "../../common/booking-guards";
import { PaymentService } from "./payment.service";
import {
InitiatePaymentDto,
InitiateResponseDto,
IntentStatusDto,
PaymentMethodTypeEnum,
PaymentPlatformDto,
RefundDto,
} from "./payments.dto";
import { IntentStatusDto, RefundDto } from "./payments.dto";
@ApiTags("Payment")
@Controller("payments")
@@ -73,16 +66,6 @@ export class PaymentController {
});
}
@Post("initiate")
@ApiOperation({
summary: "Initiate payment for a freight booking",
description: `Initiates payment via the central payment microservice.\n\n**Supported methods:**\n- TELEBIRR — Ethiopian mobile money\n- CBE_BIRR — Commercial Bank of Ethiopia\n- EBIRR — Electronic payment gateway\n- WAAFI — Djibouti mobile money\n- CARD — Visa/Mastercard\n- DMONEY — Djibouti D-money\n- CAC_BANK — CAC Int Bank (OTP)`,
})
@ApiOkResponse({ type: InitiateResponseDto })
initiatePayment(@Body() dto: InitiatePaymentDto) {
return this.paymentService.initiatePayment(dto);
}
@Get("intents/:bookingId")
@ApiOperation({ summary: "Get payment intent status for a booking" })
@ApiOkResponse({ type: IntentStatusDto })
@@ -97,54 +80,6 @@ export class PaymentController {
return this.paymentService.refund(dto);
}
@Get("checkout")
@Public()
@ApiOperation({
summary: "Browser checkout redirect",
description:
"Initiates payment and returns an HTML page that auto-redirects to the provider checkout URL. Open directly in a browser tab.",
})
@ApiQuery({ name: "bookingId", required: true })
@ApiQuery({ name: "method", enum: PaymentMethodTypeEnum, required: true })
@ApiQuery({ name: "platform", enum: ["web", "mobile"], required: false })
@ApiProduces("text/html")
async checkout(
@Query("bookingId") bookingId: string,
@Query("method") method: PaymentMethodTypeEnum,
@Query("platform") platform: PaymentPlatformDto = "web",
@Res() res: Response,
) {
if (!bookingId) {
return res
.status(HttpStatus.BAD_REQUEST)
.type("html")
.send(this.buildErrorHtml("Missing required query parameter: bookingId"));
}
if (!method || !Object.values(PaymentMethodTypeEnum).includes(method)) {
return res
.status(HttpStatus.BAD_REQUEST)
.type("html")
.send(this.buildErrorHtml("Missing or invalid query parameter: method"));
}
try {
const result = await this.paymentService.initiatePayment({ bookingId, method, platform });
const url =
result.clientAction?.type === "REDIRECT" ? result.clientAction.url : undefined;
if (url) {
return res.status(HttpStatus.OK).type("html").send(this.buildRedirectHtml(url));
}
return res
.status(HttpStatus.OK)
.type("html")
.send(this.buildStatusHtml(result.status, result.intentId));
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "An unexpected error occurred";
return res.status(HttpStatus.OK).type("html").send(this.buildErrorHtml(message));
}
}
@Get("receipt/:orderId")
@Public()
@ApiOperation({ summary: "Generate a payment receipt HTML page" })
@@ -153,76 +88,4 @@ export class PaymentController {
const html = await this.paymentService.genReceiptHtml(orderId);
return res.status(HttpStatus.OK).type("html").send(html);
}
private buildRedirectHtml(url: string): string {
const escaped = url.replace(/\"/g, "&quot;");
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="refresh" content="0;url=${escaped}">
<title>Redirecting to payment…</title>
<style>
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
.spinner { width: 40px; height: 40px; border: 4px solid #e0e0e0; border-top-color: #1a73e8; border-radius: 50%; animation: spin .8s linear infinite; margin: 0 auto 20px; }
@keyframes spin { to { transform: rotate(360deg); } }
p { color: #555; margin: 0 0 16px; }
a { color: #1a73e8; }
</style>
</head>
<body>
<div class="card">
<div class="spinner"></div>
<p>Redirecting to payment provider…</p>
<p><a href="${escaped}">Click here if you are not redirected</a></p>
</div>
<script>window.location.href = "${escaped}";</script>
</body>
</html>`;
}
private buildStatusHtml(status: string, intentId: string): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Payment status</title>
<style>
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
.status { font-size: 1.1rem; font-weight: 600; color: #333; margin-bottom: 8px; }
small { color: #888; }
</style>
</head>
<body>
<div class="card">
<div class="status">${status}</div>
<small>Intent: ${intentId}</small>
</div>
</body>
</html>`;
}
private buildErrorHtml(message: string): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Payment error</title>
<style>
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
.error { color: #d32f2f; font-weight: 600; margin-bottom: 8px; }
p { color: #555; }
</style>
</head>
<body>
<div class="card">
<div class="error">Payment could not be initiated</div>
<p>${message}</p>
</div>
</body>
</html>`;
}
}

View File

@@ -1,4 +1,4 @@
import { DynamicModule, Module, forwardRef } from "@nestjs/common";
import { DynamicModule, forwardRef, Module } from "@nestjs/common";
import { HttpModule } from "@nestjs/axios";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { RabbitMQModule } from "@golevelup/nestjs-rabbitmq";
@@ -12,9 +12,7 @@ import {
} from "@edr/types";
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
import { DropdownSettingsModule } from "../dropdown-settings/dropdown-settings.module";
import { FirstMileModule } from "../first-mile/first-mile.module";
import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module";
import { BillingModule } from "../billing/billing.module";
import { PaymentRefundEntity } from "./entities/payment-refund.entity";
import { PaymentWebhookEventEntity } from "./entities/payment-webhook-event.entity";
import { PaymentEntity } from "./entities/payment.entity";
@@ -58,9 +56,7 @@ function rabbitMQImport(): DynamicModule[] {
imports: [
HttpModule.register({ timeout: 10_000 }),
ConfigModule,
DropdownSettingsModule,
forwardRef(() => FirstMileModule),
forwardRef(() => TrainSchedulingModule),
forwardRef(() => BillingModule),
TypeOrmModule.forFeature([
PaymentEntity,
PaymentWebhookEventEntity,

View File

@@ -11,6 +11,7 @@ import { DataSource } from "typeorm";
import { PaymentEntity } from "./entities/payment.entity";
import { PaymentRepository } from "./payment.repository";
import { PaymentClientService } from "./payment-client.service";
import { BillingService } from "../billing/billing.service";
import * as fs from "fs";
import * as path from "path";
@@ -28,13 +29,44 @@ import {
ProviderMethod,
} from "@edr/types";
import {
InitiatePaymentDto,
InitiateResponseDto,
IntentStatusDto,
PaymentPlatformDto,
RefundDto,
} from "./payments.dto";
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
import { FirstMileService } from "../first-mile/first-mile.service";
/** Everything the gateway needs to open an intent. Amount/currency are supplied by
* the caller (billing) — this service never derives them from a domain record. */
export interface InitiateIntentInput {
/** Opaque domain reference (booking id, …). */
referenceId: string;
/** Invoice source that owns the intent ('booking', …) — stored on the projection. */
source: string;
/** Gateway reference type the intent is opened with (caller's domain decides it). */
referenceType: PaymentReferenceType;
/** Human-readable order ref shown on provider pages. */
orderRef: string;
/** Authoritative amount in minor units, computed by the caller. */
amountMinor: number;
currency: string;
/** Stored on the intent projection for receipts/dashboards. */
reason?: string;
/** Provider/method selector. */
method: ProviderMethod | string;
platform?: PaymentPlatformDto;
payerAccount?: string;
returnUrl?: string;
failureUrl?: string;
}
export interface InitiateIntentResult {
intentId: string;
response: InitiateResponseDto;
/** True when the provider settled the charge synchronously during initiate. */
immediateSuccess: boolean;
providerTxnId?: string;
paidAt?: Date;
}
const STATUS_MAP: Record<string, ProviderPaymentStatus> = {
"action-required": ProviderPaymentStatus.REQUIRES_ACTION,
@@ -45,6 +77,23 @@ const STATUS_MAP: Record<string, ProviderPaymentStatus> = {
"refunded": ProviderPaymentStatus.CANCELLED,
};
const PROVIDER_TO_METHOD: Record<string, PaymentEntity["method"]> = {
TELEBIRR: "telebirr",
CBE_BIRR: "cbe-birr",
EBIRR: "ebirr",
WAAFI: "waafi",
CARD: "card",
DMONEY: "dmoney",
CAC_BANK: "cac-bank",
};
/**
* Pure payment-gateway adapter. Owns intents, provider calls and webhooks — and
* NOTHING domain-specific: it never loads a booking, computes an amount, or
* advances a domain record. On settlement it notifies billing directly
* ({@link BillingService.settleByPaymentId}); billing (and through it, the domain)
* reacts. The billing↔payment pair is a deliberate forwardRef cycle.
*/
@Injectable()
export class PaymentService {
private readonly logger = new Logger(PaymentService.name);
@@ -53,9 +102,8 @@ export class PaymentService {
private readonly datasource: DataSource,
private readonly paymentRepo: PaymentRepository,
private readonly paymentClient: PaymentClientService,
@Inject(forwardRef(() => BookingBatchService))
private readonly bookingBatchService: BookingBatchService,
private readonly firstMileService: FirstMileService,
@Inject(forwardRef(() => BillingService))
private readonly billing: BillingService,
) { }
async getAll(filters: {
@@ -123,7 +171,6 @@ export class PaymentService {
total += row.count;
}
// Sum of successfully collected amounts.
const paidAgg = await this.paymentRepo
.createQueryBuilder("payment")
.select("COALESCE(SUM(payment.amount), 0)", "sum")
@@ -141,69 +188,74 @@ export class PaymentService {
};
}
async initiatePayment(dto: InitiatePaymentDto): Promise<InitiateResponseDto> {
const booking = await this.datasource
.getRepository(Booking)
.findOneBy({ id: dto.bookingId });
if (!booking) throw new NotFoundException("Booking not found");
const amountMinor = Math.round(Number(booking.totalAmount));
/**
* Open a gateway intent for a caller-supplied amount/reference and project it
* locally. Returns the intent id (so billing can correlate the invoice) plus
* the client action. When the provider settles synchronously, the intent is
* marked paid WITHOUT emitting — the caller (billing) settles inline after it
* has stored the intent id, avoiding a settle-before-correlation race.
*/
async initiate(input: InitiateIntentInput): Promise<InitiateIntentResult> {
const snapshot = await this.paymentClient.initiate({
service: PaymentServiceEnum.FREIGHT,
referenceType: PaymentReferenceType.SHIPMENT,
referenceId: booking.id,
orderRef: booking.reference,
amountMinor,
currency: booking.paymentCurrency,
provider: dto.method as unknown as ProviderMethod,
platform: dto.platform,
payerAccount: dto.payerAccount,
returnUrl:'https://edrfreight.triaplc.com/payment/success',
failureUrl: 'https://edrfreight.triaplc.com/payment/failure',
referenceType: input.referenceType,
referenceId: input.referenceId,
orderRef: input.orderRef,
amountMinor: input.amountMinor,
currency: input.currency,
provider: input.method as ProviderMethod,
platform: input.platform,
payerAccount: input.payerAccount,
returnUrl: input.returnUrl ?? "https://edrfreight.triaplc.com/payment/success",
failureUrl: input.failureUrl ?? "https://edrfreight.triaplc.com/payment/failure",
});
await this.datasource.getRepository(Booking).update(
{ id: dto.bookingId },
{ paymentStatus: "PAID", status: "PAID" },
);
const intent = await this.syncIntentProjection(booking.id, booking, snapshot);
const immediateSuccess = snapshot.status === ProviderPaymentStatus.SUCCEEDED;
const paidAt = snapshot.paidAt ? new Date(snapshot.paidAt) : undefined;
if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) {
await this.finalizePaymentSuccess({
intentId: intent.id,
bookingId: booking.id,
const intent = await this.upsertIntent(input, snapshot);
if (immediateSuccess) {
// Settle the projection but DO NOT notify billing — billing settles
// inline once it has stored intentId on the invoice (see payInvoice),
// avoiding a settle-before-correlation race.
await this.markIntentSucceeded(intent.id, {
providerTxnId: snapshot.providerTxnId,
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
paidAt,
notify: false,
});
}
return this.formatIntentResponse(intent);
return {
intentId: intent.id,
// `intent` still reflects the projection status ("processing" on immediate
// success — settlement is applied by the caller, not shown synchronously).
response: this.formatIntentResponse(intent),
immediateSuccess,
providerTxnId: snapshot.providerTxnId,
paidAt,
};
}
private async syncIntentProjection(
bookingId: string,
booking: Booking,
/** Create or update the local intent projection from a provider snapshot. */
private async upsertIntent(
input: InitiateIntentInput,
snapshot: PaymentIntentSnapshot,
): Promise<PaymentEntity> {
const existing = await this.paymentRepo.findOneBy({ refId: bookingId, type: "booking" });
const existing = await this.paymentRepo.findOneBy({
refId: input.referenceId,
});
const PROVIDER_TO_METHOD: Record<string, PaymentEntity["method"]> = {
TELEBIRR: "telebirr",
CBE_BIRR: "cbe-birr",
EBIRR: "ebirr",
WAAFI: "waafi",
CARD: "card",
DMONEY: "dmoney",
CAC_BANK: "cac-bank",
};
const method: PaymentEntity["method"] =
PROVIDER_TO_METHOD[snapshot.provider ?? ""] ?? "telebirr";
const status = snapshot.status === ProviderPaymentStatus.SUCCEEDED
? "processing"
: this.toLocalStatus(snapshot.status);
const status =
snapshot.status === ProviderPaymentStatus.SUCCEEDED
? "processing"
: this.toLocalStatus(snapshot.status);
const clientAction = (snapshot.clientAction ?? undefined) as Record<string, unknown> | undefined;
const clientAction = (snapshot.clientAction ?? undefined) as
| Record<string, unknown>
| undefined;
const data = {
status,
method,
@@ -220,30 +272,37 @@ export class PaymentService {
}
return this.paymentRepo.create({
refId: bookingId,
type: "booking",
amount: booking.totalAmount,
currency: booking.paymentCurrency,
reason: `Payment for booking ${booking.reference}`,
refId: input.referenceId,
type: input.source,
referenceType: input.referenceType,
amount: input.amountMinor,
currency: input.currency as PaymentEntity["currency"],
reason: input.reason ?? `Payment for ${input.orderRef}`,
rawInitiation: snapshot as unknown as Record<string, unknown>,
clientAction: clientAction ?? {},
...data,
} as any);
}
async getIntentByBookingId(bookingId: string): Promise<IntentStatusDto> {
const local = await this.paymentRepo.findOneBy({ refId: bookingId, type: "booking" });
/**
* Reconcile an intent's status with the gateway by reference. Read-only on the
* domain side: it syncs the local projection and, when the provider reports a
* newly-observed success, notifies billing to settle. `referenceId` is opaque
* (the booking id, but this service does not load it).
*/
async getIntentByBookingId(referenceId: string): Promise<IntentStatusDto> {
const local = await this.paymentRepo.findOneBy({ refId: referenceId });
let snapshot: PaymentIntentSnapshot | null = null;
try {
snapshot = await this.paymentClient.getIntentByReference(
PaymentReferenceType.SHIPMENT,
bookingId,
(local?.referenceType as PaymentReferenceType) ?? PaymentReferenceType.SHIPMENT,
referenceId,
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.warn(
`payment service lookup failed for booking ${bookingId}: ${message}; using local intent`,
`payment service lookup failed for reference ${referenceId}: ${message}; using local intent`,
);
}
@@ -251,77 +310,54 @@ export class PaymentService {
if (!local) throw new NotFoundException("PaymentIntent not found");
return this.formatIntentStatus(local);
}
if (!local) throw new NotFoundException("PaymentIntent not found");
const booking = await this.datasource
.getRepository(Booking)
.findOneBy({ id: bookingId });
// Sync local projection with provider-reported status.
const becameSuccess =
snapshot.status === ProviderPaymentStatus.SUCCEEDED && local.status !== "success";
if (!booking) throw new NotFoundException("Booking not found");
const intent = await this.syncIntentProjection(bookingId, booking, snapshot);
if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) {
await this.finalizePaymentSuccess({
intentId: intent.id,
bookingId: booking.id,
if (becameSuccess) {
await this.markIntentSucceeded(local.id, {
providerTxnId: snapshot.providerTxnId,
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
notify: true,
});
} else if (snapshot.status !== ProviderPaymentStatus.SUCCEEDED) {
await this.paymentRepo.update(
{ id: local.id },
{
status: this.toLocalStatus(snapshot.status),
failerCode: snapshot.failureCode ?? undefined,
failureMessage: snapshot.failureMessage ?? undefined,
},
);
}
const refreshed = await this.paymentRepo.findOneBy({ id: intent.id });
return this.formatIntentStatus(refreshed ?? intent);
const refreshed = await this.paymentRepo.findOneBy({ id: local.id });
return this.formatIntentStatus(refreshed ?? local);
}
async refund(dto: RefundDto) {
const intent = await this.paymentRepo.findOneBy({ refId: dto.bookingId, type: "booking" });
if (!intent || intent.status !== "success") {
throw new BadRequestException("No successful payment to refund");
}
/**
* Mark a gateway intent paid and (by default) notify billing to settle the
* linked invoice. Idempotent — no-op when already success. Pass `notify: false`
* when the caller settles inline and will trigger settlement itself.
*/
async markIntentSucceeded(
intentId: string,
opts: { providerTxnId?: string; paidAt?: Date; notify?: boolean } = {},
): Promise<{ alreadyFinalized: boolean }> {
const intent = await this.paymentRepo.findOneBy({ id: intentId });
if (!intent) throw new NotFoundException("PaymentIntent not found");
if (intent.status === "success") return { alreadyFinalized: true };
await this.datasource.transaction(async (mg) => {
await mg.update(PaymentEntity, { id: intent.id }, { status: "refunded", refundedAt: new Date() });
await mg.update(Booking, { id: dto.bookingId }, { paymentStatus: "FAILED", status: "CANCELLED" });
});
const paidAt = opts.paidAt ?? new Date();
await this.paymentRepo.update(
{ id: intent.id },
{ status: "success", paidAt, transactionId: opts.providerTxnId ?? intent.transactionId },
);
return { refunded: true, bookingId: dto.bookingId };
}
async finalizePaymentSuccess(input: {
intentId: string;
bookingId: string;
providerTxnId?: string;
paidAt?: Date;
}): Promise<{ alreadyFinalized: boolean }> {
// const intent = await this.paymentRepo.findOneBy({ id: input.intentId });
// if (!intent) throw new NotFoundException("PaymentIntent not found");
// if (intent.status === "success") return { alreadyFinalized: true };
const paidAt = input.paidAt ?? new Date();
// Every booking is a real shipment now (contracts are a separate aggregate),
// so payment always settles the booking to PAID and enters allocation.
await this.datasource.transaction(async (mg) => {
// await mg.update(
// PaymentEntity,
// // { id: intent.id },
// {id:input.intentId},
// { status: "success", paidAt, transactionId: input.providerTxnId ?? intent.transactionId },
// );
await mg.update(
Booking,
{ id: input.bookingId },
{ paymentStatus: "PAID", status: "PAID" },
);
await this.firstMileService.acceptBooking(input.bookingId);
});
try {
await this.bookingBatchService.ensurePaidBookingAllocated(input.bookingId);
} catch (err) {
this.logger.error(
`Error allocating booking after payment: ${err instanceof Error ? err.message : String(err)}`,
);
if (opts.notify !== false) {
await this.billing.settleByPaymentId(intent.id, opts.providerTxnId, paidAt);
}
return { alreadyFinalized: false };
@@ -340,6 +376,29 @@ export class PaymentService {
{ id: intent.id },
{ status: "failed", failerCode: input.failureCode, failureMessage: input.failureMessage },
);
// Invoice stays open for retry — nothing to settle. Logged only.
this.logger.warn(
`Payment ${intent.id} failed for ${intent.refId}` +
(input.failureMessage ? `: ${input.failureMessage}` : ""),
);
}
async refund(dto: RefundDto) {
const intent = await this.paymentRepo.findOneBy({ refId: dto.bookingId, type: "booking" });
if (!intent || intent.status !== "success") {
throw new BadRequestException("No successful payment to refund");
}
// NOTE: refunding still mutates the booking directly — left intact pending
// the refund redesign. TODO: route refunds through billing.refundPayable +
// a `${source}.invoice.refunded` reaction, like settlement.
await this.datasource.transaction(async (mg) => {
await mg.update(PaymentEntity, { id: intent.id }, { status: "refunded", refundedAt: new Date() });
await mg.update(Booking, { id: dto.bookingId }, { paymentStatus: "FAILED", status: "CANCELLED" });
});
return { refunded: true, bookingId: dto.bookingId };
}
async getActivePaymentByOrderIdAndMethod(orderId: string, method: PaymentEntity["method"]): Promise<PaymentEntity | null> {
@@ -368,7 +427,7 @@ export class PaymentService {
}
findBookingById(id: string) {
return this.paymentRepo.findOneBy({ refId: id, type: "booking" });
return this.paymentRepo.findOneBy({ refId: id });
}
formatIntentResponse(intent: PaymentEntity): InitiateResponseDto {
@@ -403,46 +462,34 @@ export class PaymentService {
failureCode?: string;
failureMessage?: string;
}): Promise<{ processed: boolean; alreadyFinalized?: boolean; reason?: string }> {
const { alreadyFinalized } = await this.finalizePaymentSuccess({
intentId:event.intentId,
bookingId: event.referenceId,
if (event.eventType === "payment.succeeded") {
const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId });
if (!intent) {
return { processed: false, reason: `No local intent for reference ${event.referenceId}` };
}
const { alreadyFinalized } = await this.markIntentSucceeded(intent.id, {
providerTxnId: event.providerTxnId,
paidAt: event.paidAt ? new Date(event.paidAt) : undefined,
notify: true,
});
// console.log(`Payment finalized for booking ${event.referenceId}, intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`);
return { processed: true, alreadyFinalized };
// console.log(`Received payment event: ${JSON.stringify(event)}`);
// if (event.eventType === "payment.succeeded") {
// console.log(`Received payment.succeeded event for booking ${event.referenceId}, intent ${event.intentId}`);
// const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" });
// if (!intent) {
// return { processed: false, reason: `No local intent for booking ${event.referenceId}` };
// }
// console.log(`Processing payment.succeeded event for booking ${event.referenceId}, intent ${intent.id}`);
// const { alreadyFinalized } = await this.finalizePaymentSuccess({
// intentId: intent.id,
// bookingId: event.referenceId,
// providerTxnId: event.providerTxnId,
// paidAt: event.paidAt ? new Date(event.paidAt) : undefined,
// });
// console.log(`Payment finalized for booking ${event.referenceId}, intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`);
// return { processed: true, alreadyFinalized };
// }
}
// if (event.eventType === "payment.failed") {
// const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" });
// if (!intent) {
// return { processed: false, reason: `No local intent for booking ${event.referenceId}` };
// }
// await this.markPaymentFailed({
// intentId: intent.id,
// failureCode: event.failureCode,
// failureMessage: event.failureMessage,
// });
// return { processed: true };
// }
if (event.eventType === "payment.failed") {
const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId });
if (!intent) {
return { processed: false, reason: `No local intent for reference ${event.referenceId}` };
}
await this.markPaymentFailed({
intentId: intent.id,
failureCode: event.failureCode,
failureMessage: event.failureMessage,
});
return { processed: true };
}
// return { processed: false, reason: `Unknown event type: ${event.eventType}` };
return { processed: false, reason: `Unknown event type: ${event.eventType}` };
}
private toLocalStatus(status: ProviderPaymentStatus): PaymentEntity["status"] {

View File

@@ -32,6 +32,9 @@ export interface ImportTrainItemRow {
bookingReference: string | null;
customerId: string | null;
customerName: string | null;
wagonNumber: string | null;
sequenceNo: number | null;
allocatedWeightTons: number | null;
containerNumber: string | null;
cargoType: string | null;
weight: number | null;
@@ -59,6 +62,9 @@ export interface ExportTrainItemRow {
bookingReference: string | null;
customerId: string | null;
customerName: string | null;
wagonNumber: string | null;
sequenceNo: number | null;
allocatedWeightTons: number | null;
itemType: 'CONTAINER' | 'CARGO';
itemId: string | null;
inventoryId: string | null;
@@ -265,6 +271,9 @@ export class SchedulingReadFacade {
b.reference AS "bookingReference",
b.company_id AS "customerId",
company.name AS "customerName",
w.wagon_number AS "wagonNumber",
tsw.sequence_no AS "sequenceNo",
wba.allocated_weight_tons AS "allocatedWeightTons",
(SELECT c.container_number FROM freight.containers c
WHERE c.booking_id = b.id AND c.deleted_at IS NULL
ORDER BY c.container_number LIMIT 1) AS "containerNumber",
@@ -279,11 +288,24 @@ export class SchedulingReadFacade {
FROM freight.train_schedule_bookings tsb
JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id
JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.wagon_booking_allocations wba ON wba.booking_id = b.id
AND wba.deleted_at IS NULL
AND EXISTS (
SELECT 1
FROM freight.train_set_wagons tsw_match
WHERE tsw_match.id = wba.train_set_wagon_id
AND tsw_match.train_set_id = ts.train_set_id
AND tsw_match.deleted_at IS NULL
)
LEFT JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id
AND tsw.train_set_id = ts.train_set_id
AND tsw.deleted_at IS NULL
LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id AND w.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.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL
ORDER BY b.reference ASC NULLS LAST`,
ORDER BY tsw.sequence_no ASC NULLS LAST, b.reference ASC NULLS LAST`,
[scheduleId],
);
return rows;
@@ -401,6 +423,9 @@ export class SchedulingReadFacade {
b.reference,
b.company_id,
company.name AS customer_name,
w.wagon_number,
tsw.sequence_no,
wba.allocated_weight_tons,
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS cargo_type,
b.cargo_total_weight_vgm AS booking_weight,
oy.code AS origin,
@@ -412,6 +437,19 @@ export class SchedulingReadFacade {
FROM freight.train_schedule_bookings tsb
JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id
JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.wagon_booking_allocations wba ON wba.booking_id = b.id
AND wba.deleted_at IS NULL
AND EXISTS (
SELECT 1
FROM freight.train_set_wagons tsw_match
WHERE tsw_match.id = wba.train_set_wagon_id
AND tsw_match.train_set_id = ts.train_set_id
AND tsw_match.deleted_at IS NULL
)
LEFT JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id
AND tsw.train_set_id = ts.train_set_id
AND tsw.deleted_at IS NULL
LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id AND w.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.yards oy ON oy.id = b.origin_yard_id
@@ -423,6 +461,9 @@ export class SchedulingReadFacade {
a.reference AS "bookingReference",
a.company_id AS "customerId",
a.customer_name AS "customerName",
a.wagon_number AS "wagonNumber",
a.sequence_no AS "sequenceNo",
a.allocated_weight_tons AS "allocatedWeightTons",
'CONTAINER' AS "itemType",
c.id AS "itemId",
a.inventory_id AS "inventoryId",
@@ -441,6 +482,9 @@ export class SchedulingReadFacade {
a.reference AS "bookingReference",
a.company_id AS "customerId",
a.customer_name AS "customerName",
a.wagon_number AS "wagonNumber",
a.sequence_no AS "sequenceNo",
a.allocated_weight_tons AS "allocatedWeightTons",
'CARGO' AS "itemType",
cg.id AS "itemId",
a.inventory_id AS "inventoryId",
@@ -460,6 +504,9 @@ export class SchedulingReadFacade {
a.reference AS "bookingReference",
a.company_id AS "customerId",
a.customer_name AS "customerName",
a.wagon_number AS "wagonNumber",
a.sequence_no AS "sequenceNo",
a.allocated_weight_tons AS "allocatedWeightTons",
CASE WHEN a.cargo_type ILIKE '%container%' THEN 'CONTAINER' ELSE 'CARGO' END AS "itemType",
a.inventory_id AS "itemId",
a.inventory_id AS "inventoryId",
@@ -474,7 +521,7 @@ export class SchedulingReadFacade {
FROM assigned a
WHERE NOT EXISTS (SELECT 1 FROM freight.containers c WHERE c.booking_id = a.booking_id AND c.deleted_at IS NULL)
AND NOT EXISTS (SELECT 1 FROM freight.cargoes cg WHERE cg.booking_id = a.booking_id AND cg.deleted_at IS NULL)
ORDER BY "bookingReference" ASC NULLS LAST, "itemType" ASC`,
ORDER BY "sequenceNo" ASC NULLS LAST, "bookingReference" ASC NULLS LAST, "itemType" ASC`,
[scheduleId],
);
return rows;

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res } from '@nestjs/common';
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Request, Res } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express';
@@ -273,6 +273,25 @@ export class WarehouseInventoryController {
return res.send(buffer);
}
@Get(':id/handover-document')
@ApiOperation({ summary: 'View import goods handover document PDF' })
async handoverDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
const { filename, buffer } = await this.inventoryService.handoverDocument(id);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
res.setHeader('Content-Length', buffer.length);
return res.send(buffer);
}
@Post('bookings/:bookingId/approve-delivery')
@ApiOperation({ summary: "Approve delivery using the current customer's saved signature" })
approveDeliveryForBooking(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Request() req: { user?: { id?: string; sub?: string } },
) {
return this.inventoryService.approveDeliveryForBooking(bookingId, req.user?.id ?? req.user?.sub);
}
@Post(':id/deliver')
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) {

View File

@@ -7,6 +7,7 @@ import { InterchangeDocumentsService } from '../interchange-documents/interchang
import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity';
import { LastMileService } from '../last-mile/last-mile.service';
import { NotificationsService } from '../notifications/notifications.service';
import { SignaturesService } from '../signatures/signatures.service';
import { BulkInspectDto } from './dto/bulk-inspect.dto';
import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto';
import { DeliverInventoryDto } from './dto/deliver-inventory.dto';
@@ -50,6 +51,8 @@ const normalizeWagonStatus = (status: string | null | undefined) =>
const isLoadableWagonStatus = (status: string | null | undefined) =>
LOADABLE_WAGON_STATUSES.includes(normalizeWagonStatus(status));
const CUSTOMER_DELIVERY_APPROVAL_PREFIX = 'CUSTOMER_DELIVERY_APPROVAL:';
export interface InventoryInquiryResult {
id: string;
inventoryId: string | null;
@@ -297,6 +300,9 @@ export interface ImportUnloadedRow {
pickupOption: string;
lastMileRequested: boolean;
currentStatus: string;
releaseDate: string | null;
releaseOrderReference: string | null;
deliveredAt: string | null;
}
@Injectable()
@@ -316,6 +322,7 @@ export class WarehouseInventoryService {
private readonly interchangeDocuments: InterchangeDocumentsService,
private readonly lastMileService: LastMileService,
private readonly notifications: NotificationsService,
private readonly signatures: SignaturesService,
) {}
/**
@@ -1020,6 +1027,9 @@ export class WarehouseInventoryService {
THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption",
(b.last_mile_delivery_address IS NOT NULL) AS "lastMileRequested",
inv.status AS "currentStatus",
inv.release_date AS "releaseDate",
inv.release_order_reference AS "releaseOrderReference",
inv.delivered_at AS "deliveredAt",
oy.country AS "originCountry",
dy.country AS "destinationCountry"
FROM freight.warehouse_inventory inv
@@ -1049,7 +1059,16 @@ export class WarehouseInventoryService {
* states), with the columns the inspection screen needs. Read-only.
*/
importUnloadedQueue(): Promise<ImportUnloadedRow[]> {
return this.importQueueByStatuses(['UNLOADED', 'DESTINATION_INSPECTION', 'UNDER_INSPECTION']);
return this.importQueueByStatuses([
'UNLOADED',
'DESTINATION_INSPECTION',
'UNDER_INSPECTION',
'ARRIVED_AT_WAREHOUSE',
'STORED',
'READY_FOR_PICKUP',
'DISPATCHED',
'DELIVERED',
]);
}
/**
@@ -2020,6 +2039,154 @@ export class WarehouseInventoryService {
}
/** Hand import goods to the customer + capture proof of delivery (READY_FOR_PICKUP → DELIVERED). */
async approveDeliveryForBooking(
bookingId: string,
userId?: string,
): Promise<{ bookingId: string; inventoryId: string; approvedAt: string; signerDisplayName: string }> {
if (!userId) {
throw new BadRequestException('Authentication is required to approve delivery');
}
const signature = await this.signatures.getForUser(userId);
if (!signature?.signatureImageUrl) {
throw new BadRequestException('Please save your signature before approving delivery');
}
const [item]: Array<{ id: string; warehouseId: string | null; notes: string | null }> =
await this.dataSource.query(
`SELECT inv.id,
inv.warehouse_id AS "warehouseId",
inv.notes
FROM freight.warehouse_inventory inv
JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
WHERE inv.booking_id = $1
AND inv.deleted_at IS NULL
AND inv.inspection_status = 'PASSED'
ORDER BY inv.updated_at DESC NULLS LAST, inv.created_at DESC
LIMIT 1`,
[bookingId],
);
if (!item) {
throw new BadRequestException('Delivery can be approved after warehouse inspection has passed');
}
const approvedAt = new Date();
const approval = {
approvedAt: approvedAt.toISOString(),
signerDisplayName: signature.signerDisplayName,
signatureImageUrl: signature.signatureImageUrl,
userId,
};
const existingNotes = this.stripCustomerDeliveryApproval(item.notes);
const approvalNote = `${CUSTOMER_DELIVERY_APPROVAL_PREFIX}${JSON.stringify(approval)}`;
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(WarehouseInventory).update(item.id, {
notes: this.appendNote(existingNotes, approvalNote),
});
await this.activityLog.record(
{
activityType: 'INVENTORY_RELEASED',
inventoryId: item.id,
warehouseId: item.warehouseId,
description: `Customer approved delivery as ${signature.signerDisplayName}`,
performedBy: signature.signerDisplayName,
},
manager,
);
});
return {
bookingId,
inventoryId: item.id,
approvedAt: approval.approvedAt,
signerDisplayName: signature.signerDisplayName,
};
}
async handoverDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
const [row] = await this.dataSource.query(
`SELECT inv.id,
inv.booking_id AS "bookingId",
inv.quantity,
inv.weight,
inv.status,
inv.notes,
inv.inspection_status AS "inspectionStatus",
inv.release_date AS "releaseDate",
inv.release_order_reference AS "releaseOrderReference",
COALESCE(inv.unloaded_at, inv.arrived_at, inv.created_at) AS "handoverDate",
b.reference AS "bookingReference",
b.status AS "bookingStatus",
b.freight_type AS "freightType",
b.trade_direction AS "tradeDirection",
company.name AS "customerName",
COALESCE(container.container_number, booking_container.container_number) AS "containerNumber",
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription",
wh.name AS "warehouseName",
wh.code AS "warehouseCode",
yard.name AS "yardName",
yard.code AS "yardCode",
zone.name AS "zoneName",
zone.code AS "zoneCode",
ts.train_number AS "trainSchedule"
FROM freight.warehouse_inventory inv
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id
LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id
LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id
LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
LEFT JOIN freight.booking_container booking_container ON (
booking_container.booking_id = b.id
AND booking_container.deleted_at IS NULL
)
LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL
LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id)
LEFT JOIN freight.train_schedule_bookings tsb ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL
LEFT JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id AND ts.deleted_at IS NULL
WHERE inv.id = $1 AND inv.deleted_at IS NULL
LIMIT 1`,
[id],
);
if (!row) {
throw new NotFoundException(`Inventory item ${id} not found`);
}
if (row.inspectionStatus !== 'PASSED') {
throw new BadRequestException('Handover document is available after inspection has passed');
}
const bookingReference = row.bookingReference || row.bookingId || 'N/A';
const html = this.buildHandoverDocumentHtml({
reference: `HND-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}`,
handedOverAt: new Date(row.handoverDate ?? Date.now()),
bookingReference,
bookingStatus: row.bookingStatus ?? null,
customerName: row.customerName ?? null,
freightType: row.freightType ?? null,
tradeDirection: row.tradeDirection ?? null,
containerNumber: row.containerNumber ?? null,
cargoDescription: row.cargoDescription ?? null,
quantity: Number(row.quantity ?? 0),
weight: Number(row.weight ?? 0),
warehouse: [row.warehouseName, row.warehouseCode].filter(Boolean).join(' / ') || null,
yard: [row.yardName, row.yardCode].filter(Boolean).join(' / ') || null,
zone: [row.zoneName, row.zoneCode].filter(Boolean).join(' / ') || null,
inventoryStatus: row.status ?? null,
inspectionStatus: row.inspectionStatus ?? null,
releaseOrderReference: row.releaseOrderReference ?? null,
releaseDate: row.releaseDate ? new Date(row.releaseDate) : null,
trainSchedule: row.trainSchedule ?? null,
customerApproval: this.extractCustomerDeliveryApproval(row.notes),
});
return {
filename: `handover-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
buffer: await this.releaseDocuments.htmlToPdfBuffer(html),
};
}
async deliver(id: string, dto: DeliverInventoryDto): Promise<WarehouseInventory> {
const item = await this.findById(id);
this.assertTransition(item.status, 'DELIVERED');
@@ -2619,6 +2786,183 @@ export class WarehouseInventoryService {
</html>`;
}
private buildHandoverDocumentHtml(data: {
reference: string;
handedOverAt: Date;
bookingReference: string;
bookingStatus: string | null;
customerName: string | null;
freightType: string | null;
tradeDirection: string | null;
containerNumber: string | null;
cargoDescription: string | null;
quantity: number;
weight: number;
warehouse: string | null;
yard: string | null;
zone: string | null;
inventoryStatus: string | null;
inspectionStatus: string | null;
releaseOrderReference: string | null;
releaseDate: Date | null;
trainSchedule: string | null;
customerApproval: {
approvedAt: string;
signerDisplayName: string;
signatureImageUrl: string;
} | null;
}): string {
const esc = (value: unknown) =>
String(value ?? '-')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const fmt = (date: Date | string | null) => {
if (!date) return '-';
const parsed = date instanceof Date ? date : new Date(date);
if (Number.isNaN(parsed.getTime())) return '-';
return parsed.toLocaleString('en-GB', {
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
};
const rows = [
['Booking Reference', data.bookingReference],
['Customer / Consignee', data.customerName],
['Booking Status', data.bookingStatus],
['Freight Type', data.freightType],
['Trade Direction', data.tradeDirection],
['Train Schedule', data.trainSchedule],
['Container Number', data.containerNumber],
['Cargo / Goods Description', data.cargoDescription],
['Quantity', data.quantity],
['Declared Weight', `${data.weight.toLocaleString()} kg`],
['Warehouse', data.warehouse],
['Yard', data.yard],
['Zone', data.zone],
['Inventory Status', data.inventoryStatus],
['Inspection Status', data.inspectionStatus],
['Release Order', data.releaseOrderReference],
['Release Date', fmt(data.releaseDate)],
];
const approval = data.customerApproval;
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Import Goods Handover Document</title>
<style>
* { box-sizing: border-box; }
@page { size: A4; margin: 12mm 15mm 14mm; }
body { font-family: "Times New Roman", Georgia, serif; color: #061323; margin: 0; background: #fff; }
.top { display: grid; grid-template-columns: 1fr 190px; gap: 26px; border-top: 5px solid #2a2a2a; padding-top: 18px; }
.brand { font-size: 12px; color: #064c27; text-transform: uppercase; letter-spacing: .13em; font-weight: 800; }
h1 { margin: 8px 0 0; max-width: 380px; font-size: 29px; line-height: .98; text-transform: uppercase; letter-spacing: .02em; }
.subtitle { margin-top: 12px; font-size: 11px; color: #3d516a; text-transform: uppercase; letter-spacing: .14em; }
.ref { text-align: right; font-size: 11px; color: #334155; padding-top: 8px; }
.ref strong { display: block; color: #061323; font-size: 18px; margin: 5px 0 8px; letter-spacing: .02em; }
.rule { height: 3px; background: #064c27; margin: 16px 0 22px; }
.notice { width: 78%; margin: 0 0 18px; padding: 13px 18px; background: #f3fff6; border: 1px solid #61d98b; border-left: 5px solid #16743d; font-size: 13px; line-height: 1.45; }
.section-title { margin: 18px 0 8px; font-size: 13px; font-weight: 800; color: #064c27; text-transform: uppercase; letter-spacing: .12em; }
table { width: 100%; border-collapse: collapse; }
th { width: 31%; text-align: left; color: #0f2744; background: #f8fafc; font-weight: 800; }
th, td { border: 1px solid #b9c7d8; padding: 8px 10px; font-size: 12.2px; vertical-align: top; }
.clause { margin-top: 14px; border: 1px solid #b9c7d8; padding: 12px 15px; font-size: 12.2px; line-height: 1.45; }
.signatures { display: grid; grid-template-columns: 1fr 96px 1.45fr; gap: 22px; align-items: start; margin-top: 42px; }
.line { border-top: 1.4px solid #061323; padding-top: 7px; font-size: 10.8px; color: #24384f; min-height: 72px; }
.signature-img { display: block; max-width: 210px; max-height: 58px; margin: 2px 0 6px; object-fit: contain; }
.signature-meta { font-size: 11px; color: #061323; }
.seal { position: relative; width: 96px; height: 96px; margin: -28px auto 0; border: 3px double #17633a; border-radius: 999px; color: #17633a; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 14px; line-height: 1.05; transform: rotate(-17deg); text-transform: uppercase; }
.seal::before { content: ""; position: absolute; width: 78px; height: 78px; border: 1px solid #17633a; border-radius: 999px; }
.seal span { position: relative; }
</style>
</head>
<body>
<div class="top">
<div>
<div class="brand">Ethio-Djibouti Railway S.C.</div>
<h1>Import Goods Handover Document</h1>
<div class="subtitle">EDR to customer warehouse handover</div>
</div>
<div class="ref">
Document No.
<strong>${esc(data.reference)}</strong>
Handover: ${esc(fmt(data.handedOverAt))}
</div>
</div>
<div class="rule"></div>
<div class="notice">
This document confirms EDR handed over the listed import goods to the customer after warehouse inspection passed.
</div>
<div class="section-title">Handover Particulars</div>
<table>
<tbody>
${rows.map(([label, value]) => `<tr><th>${esc(label)}</th><td>${esc(value)}</td></tr>`).join('')}
</tbody>
</table>
<div class="section-title">Goods List</div>
<table>
<tbody>
<tr><th>1. Goods</th><td>${esc(data.cargoDescription || data.containerNumber || data.bookingReference)}</td></tr>
<tr><th>Container</th><td>${esc(data.containerNumber)}</td></tr>
<tr><th>Weight</th><td>${esc(`${data.weight.toLocaleString()} kg`)}</td></tr>
</tbody>
</table>
<div class="section-title">Handover Clause</div>
<div class="clause">
The customer acknowledges receipt of the goods listed above. Warehouse staff shall verify identity, booking reference,
inspection status, and release records before final physical handover.
</div>
<div class="signatures">
<div class="line">Officer in charge name / signature / date</div>
<div class="seal"><span>EDR<br />Warehouse<br />Handover</span></div>
<div class="line">
${approval?.signatureImageUrl ? `<img class="signature-img" src="${esc(approval.signatureImageUrl)}" />` : ''}
<div class="signature-meta">${approval ? esc(approval.signerDisplayName) : 'Customer or driver name / signature / date'}</div>
<div class="signature-meta">${approval ? `Approved: ${esc(fmt(approval.approvedAt))}` : ''}</div>
</div>
</div>
</body>
</html>`;
}
private extractCustomerDeliveryApproval(notes?: string | null): {
approvedAt: string;
signerDisplayName: string;
signatureImageUrl: string;
} | null {
if (!notes) return null;
const line = notes
.split(/\r?\n/)
.find((entry) => entry.startsWith(CUSTOMER_DELIVERY_APPROVAL_PREFIX));
if (!line) return null;
try {
const parsed = JSON.parse(line.slice(CUSTOMER_DELIVERY_APPROVAL_PREFIX.length));
if (!parsed?.approvedAt || !parsed?.signerDisplayName || !parsed?.signatureImageUrl) return null;
return {
approvedAt: String(parsed.approvedAt),
signerDisplayName: String(parsed.signerDisplayName),
signatureImageUrl: String(parsed.signatureImageUrl),
};
} catch {
return null;
}
}
private stripCustomerDeliveryApproval(notes?: string | null): string | null {
if (!notes?.trim()) return null;
const lines = notes
.split(/\r?\n/)
.filter((entry) => !entry.startsWith(CUSTOMER_DELIVERY_APPROVAL_PREFIX));
return lines.join('\n').trim() || null;
}
private assertTransition(from: WarehouseInventoryStatus, to: WarehouseInventoryStatus): void {
if (!WAREHOUSE_INVENTORY_TRANSITIONS[from]?.includes(to)) {
throw new BadRequestException(`Invalid transition ${from}${to}`);

View File

@@ -7,6 +7,7 @@ import { FilesModule } from '../files/files.module';
import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module';
import { LastMileModule } from '../last-mile/last-mile.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { SignaturesModule } from '../signatures/signatures.module';
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity';
import { WarehouseFeeInvoice } from './entities/warehouse-fee-invoice.entity';
@@ -73,6 +74,7 @@ import { WarehousesService } from './warehouses.service';
InterchangeDocumentsModule,
forwardRef(() => LastMileModule),
NotificationsModule,
SignaturesModule,
ExchangeModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService): ExchangeOptions =>

View File

@@ -0,0 +1,85 @@
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 { deriveTradeDirection } from '../common/derive-trade-direction.util';
import { WarehouseInventoryService } from '../modules/warehouses/warehouse-inventory.service';
async function main() {
const app = await NestFactory.createApplicationContext(AppModule, {
logger: ['error', 'warn'],
});
try {
const dataSource = app.get(DataSource);
const inventory = app.get(WarehouseInventoryService);
const schedules: {
id: string;
trainNumber: string | null;
originCountry: string | null;
destinationCountry: string | null;
}[] = await dataSource.query(
`SELECT ts.id,
ts.train_number AS "trainNumber",
oy.country AS "originCountry",
dy.country AS "destinationCountry"
FROM freight.train_schedules ts
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
WHERE ts.status = 'ARRIVED'
AND ts.deleted_at IS NULL
ORDER BY COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) DESC NULLS LAST,
ts.created_at DESC`,
);
const importSchedules = schedules.filter(
(schedule) =>
deriveTradeDirection(
{ country: schedule.originCountry },
{ country: schedule.destinationCountry },
) === 'IMPORT',
);
if (importSchedules.length === 0) {
console.log('No ARRIVED import trains found.');
return;
}
for (const schedule of importSchedules) {
const result = await inventory.autoUnloadArrivedBookings(
schedule.id,
'Demo Auto Unload',
);
console.log(
`${schedule.trainNumber ?? schedule.id}: ${result.unloadedCount} unloaded, ${result.skippedCount} skipped, ${result.failedCount} failed`,
);
for (const item of result.results) {
console.log(` - ${item.bookingId}: ${item.status}${item.reason ? ` (${item.reason})` : ''}`);
}
}
const queueRows = await inventory.importUnloadedQueue();
console.log(`Import Unloaded Queue rows now visible: ${queueRows.length}`);
const byStatus = queueRows.reduce<Record<string, number>>((acc, row) => {
acc[row.currentStatus] = (acc[row.currentStatus] ?? 0) + 1;
return acc;
}, {});
for (const [status, count] of Object.entries(byStatus)) {
console.log(` ${status}: ${count}`);
}
} finally {
await app.close();
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});

View File

@@ -0,0 +1,28 @@
import "reflect-metadata";
import { config } from "dotenv";
import { resolve } from "path";
config({ path: resolve(__dirname, "../../.env") });
import { NestFactory } from "@nestjs/core";
import { AppModule } from "../app.module";
import { GovCompaniesSeeder } from "../seed/gov-companies.seeder";
async function main() {
const app = await NestFactory.createApplicationContext(AppModule, {
logger: ["error", "warn", "log"],
});
try {
const seeder = app.get(GovCompaniesSeeder);
await seeder.run();
console.log("Government companies seeded.");
} finally {
await app.close();
}
}
main().catch((err) => {
console.error("Government companies seed failed:", err);
process.exit(1);
});

View File

@@ -1,16 +1,30 @@
import 'reflect-metadata';
import { config } from 'dotenv';
import { resolve } from 'path';
import { WagonStatus } from '@edr/types';
config({ path: resolve(__dirname, '../../.env') });
import { AppDataSource } from '../data-source';
import { BookingContainer } from '../modules/bookings/entities/booking-container.entity';
import { Booking } from '../modules/bookings/entities/booking.entity';
import { Company, CompanyStatus, CompanyType } from '../modules/companies/entities/company.entity';
import { Locomotive } from '../modules/locomotives/entities/locomotive.entity';
import { ImportDjiboutiOperation } from '../modules/train-scheduling/entities/import-djibouti-operation.entity';
import { ContainerType } from '../modules/rule-engine/entities/container-type.entity';
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
import { Yard } from '../modules/rule-engine/entities/yard.entity';
import { TrainScheduleBooking } from '../modules/train-schedules/entities/train-schedule-booking.entity';
import { WagonAllocationContainerItem } from '../modules/train-schedules/entities/wagon-allocation-container-item.entity';
import { WagonBookingAllocation } from '../modules/train-schedules/entities/wagon-booking-allocation.entity';
import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity';
import { TrainSetWagon } from '../modules/train-sets/entities/train-set-wagon.entity';
import { TrainSet } from '../modules/train-sets/entities/train-set.entity';
import { WagonType } from '../modules/wagon-types/entities/wagon-type.entity';
import { Wagon } from '../modules/wagons/entities/wagon.entity';
const TRAIN_NUMBER = 'NEGAD-INDODE-ARR-01';
const BOOKING_REFS = ['NEGAD-INDODE-BKG-001', 'NEGAD-INDODE-BKG-002', 'NEGAD-INDODE-BKG-003'] as const;
function addHours(date: Date, hours: number): Date {
return new Date(date.getTime() + hours * 60 * 60 * 1000);
@@ -25,18 +39,34 @@ async function main() {
const locomotiveRepo = manager.getRepository(Locomotive);
const trainSetRepo = manager.getRepository(TrainSet);
const scheduleRepo = manager.getRepository(TrainSchedule);
const wagonTypeRepo = manager.getRepository(WagonType);
const wagonRepo = manager.getRepository(Wagon);
const trainSetWagonRepo = manager.getRepository(TrainSetWagon);
const serviceTypeRepo = manager.getRepository(ServiceType);
const containerTypeRepo = manager.getRepository(ContainerType);
const companyRepo = manager.getRepository(Company);
const bookingRepo = manager.getRepository(Booking);
const bookingContainerRepo = manager.getRepository(BookingContainer);
const scheduleBookingRepo = manager.getRepository(TrainScheduleBooking);
const allocationRepo = manager.getRepository(WagonBookingAllocation);
const containerItemRepo = manager.getRepository(WagonAllocationContainerItem);
const importOperationRepo = manager.getRepository(ImportDjiboutiOperation);
const negad =
(await yardRepo.findOne({ where: { code: 'NEGAD' } })) ??
(await yardRepo.save(
yardRepo.create({
code: 'NEGAD',
label: 'Negad',
label: 'Negad / Nagad',
country: 'Djibouti',
isActive: true,
displayOrder: 5,
}),
));
if (negad.label !== 'Negad / Nagad') {
negad.label = 'Negad / Nagad';
await yardRepo.save(negad);
}
const indode =
(await yardRepo.findOne({ where: { code: 'INDODE' } })) ??
@@ -64,6 +94,78 @@ async function main() {
}),
));
const wagonType =
(await wagonTypeRepo.findOne({ where: { code: 'NEGAD-FLAT' } })) ??
(await wagonTypeRepo.save(
wagonTypeRepo.create({
code: 'NEGAD-FLAT',
name: 'Negad Demo Flat Wagon',
capacityTons: 70,
lengthMeters: 14,
maxWagonsPerTrain: 53,
supportedLoadTypes: ['CONTAINER'],
isActive: true,
equatedLengthM: 14,
tareWeightTons: 20,
supportsContainer: true,
maxContainerGrossT: 70,
}),
));
const containerType =
(await containerTypeRepo.findOne({ where: { code: '40FT' } })) ??
(await containerTypeRepo.save(
containerTypeRepo.create({
code: '40FT',
label: '40FT',
sizeFt: 40,
wagonsPerUnit: 1,
isReefer: false,
isOpenTop: false,
isActive: true,
displayOrder: 2,
}),
));
const serviceType =
(await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER' } })) ??
(await serviceTypeRepo.save(
serviceTypeRepo.create({
code: 'RAIL_CONTAINER',
serviceName: 'Rail Container Service',
description: 'Rail container service for demo marshalling',
canBeBookedAlone: true,
includesFirstMile: false,
includesLastMile: false,
includesCustoms: false,
priorityBonusPoints: 0,
isActive: true,
displayOrder: 1,
}),
));
const company =
(await companyRepo.findOne({ where: { tin: 'NEGADIND01' } })) ??
(await companyRepo.save(
companyRepo.create({
name: 'Negad Indode Marshalling Demo Customer',
type: CompanyType.Customer,
status: CompanyStatus.Active,
tin: 'NEGADIND01',
vatNumber: 'NEGADIND01',
fanNumber: 'NEGADINDODE00001',
country: 'Ethiopia',
address: 'Indode Dry Port',
phone: '251900000202',
email: 'negad-indode-demo@edr.local',
contactPersonName: 'Marshalling Demo',
contactPersonPhone: '251900000202',
generalManagerName: 'Demo Manager',
generalManagerEmail: 'negad-indode-demo@edr.local',
generalManagerPhone: '251900000202',
}),
));
const now = new Date();
const departure = addHours(now, -12);
const arrival = now;
@@ -75,7 +177,7 @@ async function main() {
locomotiveId: locomotive.id,
totalWeightTons: 960,
totalLengthMeters: 420,
wagonCount: 18,
wagonCount: BOOKING_REFS.length,
status: 'COMPLETED',
}),
);
@@ -111,9 +213,169 @@ async function main() {
}
const saved = await scheduleRepo.save(schedule);
await trainSetRepo.update(saved.trainSetId, {
totalWeightTons: BOOKING_REFS.length * 28,
totalLengthMeters: BOOKING_REFS.length * 14,
wagonCount: BOOKING_REFS.length,
status: 'COMPLETED',
});
const existingSlots = await trainSetWagonRepo.find({ where: { trainSetId: saved.trainSetId } });
const existingAllocations = existingSlots.length
? await allocationRepo.find({
where: existingSlots.map((slot) => ({ trainSetWagonId: slot.id })),
})
: [];
if (existingAllocations.length) {
await containerItemRepo.delete(
existingAllocations.map((allocation) => ({ wagonBookingAllocationId: allocation.id })),
);
}
if (existingSlots.length) {
await allocationRepo.delete(existingSlots.map((slot) => ({ trainSetWagonId: slot.id })));
await wagonRepo.update(
existingSlots.map((slot) => ({ trainSetWagonId: slot.id })),
{
trainSetWagonId: null,
currentTrainScheduleId: null,
sequenceNumber: null,
status: WagonStatus.Available,
},
);
await trainSetWagonRepo.delete({ trainSetId: saved.trainSetId });
}
for (const [index, reference] of BOOKING_REFS.entries()) {
const sequenceNo = index + 1;
const containerNumber = `NEGADIND${String(sequenceNo).padStart(4, '0')}`;
const weightTons = 26 + sequenceNo;
let booking = await bookingRepo.findOne({ where: { reference } });
if (!booking) {
booking = bookingRepo.create({ reference });
}
Object.assign(booking, {
companyId: company.id,
originYardId: negad.id,
destinationYardId: indode.id,
serviceTypeId: serviceType.id,
status: 'IN_TRANSIT',
paymentStatus: 'PAID',
scheduledDate: departure,
estimatedShipmentDate: departure,
contractType: 'SPOT',
equipmentReturn: 'TERMINAL',
paymentCurrency: 'ETB',
totalAmount: 0,
isGovernment: false,
tradeDirection: 'IMPORT',
freightType: 'CONTAINER',
cargoTypeId: null,
cargoFreeText: `Negad to Indode demo container ${sequenceNo}`,
cargoTotalWeightVgm: weightTons,
priorityScore: 75 - index,
trainScheduleId: saved.id,
schedulingStatus: 'SCHEDULED',
scheduledAt: now,
wagonsRequired: 1,
});
booking = await bookingRepo.save(booking);
await bookingContainerRepo.delete({ bookingId: booking.id });
const bookingContainer = await bookingContainerRepo.save(
bookingContainerRepo.create({
bookingId: booking.id,
containerTypeId: containerType.id,
containerNumber,
quantity: 1,
vgmPerUnitTons: weightTons,
totalVgmTons: weightTons,
wagonsRequired: 1,
weightLimitRuleId: null,
isOverweight: false,
overweightExcessTons: null,
}),
);
await scheduleBookingRepo.upsert(
{ trainScheduleId: saved.id, bookingId: booking.id },
{ conflictPaths: { trainScheduleId: true, bookingId: true } },
);
const wagon = await wagonRepo.save(
wagonRepo.create({
wagonNumber: `NEGAD-INDODE-WGN-${String(sequenceNo).padStart(2, '0')}`,
wagonTypeId: wagonType.id,
trainId: null,
sequenceNumber: sequenceNo,
tareWeight: 20,
maxPayloadWeight: 70,
status: WagonStatus.Assigned,
currentYardId: indode.id,
notes: 'Demo wagon for Negad to Indode marshalling',
trainSetWagonId: null,
currentTrainScheduleId: saved.id,
}),
);
const trainSetWagon = await trainSetWagonRepo.save(
trainSetWagonRepo.create({
trainSetId: saved.trainSetId,
wagonTypeId: wagonType.id,
physicalWagonId: wagon.id,
sequenceNo,
capacityTons: 70,
lengthMeters: 14,
assignedWeightTons: weightTons,
status: 'LOADED',
}),
);
await wagonRepo.update(wagon.id, { trainSetWagonId: trainSetWagon.id });
const allocation = await allocationRepo.save(
allocationRepo.create({
trainSetWagonId: trainSetWagon.id,
bookingId: booking.id,
allocatedWeightTons: weightTons,
loadType: 'CONTAINER',
status: 'LOADED',
confirmedAt: now,
}),
);
await containerItemRepo.save(
containerItemRepo.create({
wagonBookingAllocationId: allocation.id,
bookingContainerId: bookingContainer.id,
containerId: null,
containerNumber,
containerTypeId: containerType.id,
positionOnWagon: 1,
sealNumber: `SEAL-${containerNumber}`,
grossWeightTons: weightTons,
}),
);
}
await importOperationRepo.upsert(
{
trainScheduleId: saved.id,
documents: {},
gatepassGrantedAt: departure,
readyForLoadingAt: departure,
loadedOnTrainAt: departure,
departedFromDjiboutiAt: departure,
loadListGeneratedAt: now,
performedBy: 'Seed Demo',
notes: 'Seeded marshalling data for Negad to Indode arrived train',
},
{ conflictPaths: { trainScheduleId: true } },
);
console.log(`Seeded ARRIVED train ${TRAIN_NUMBER}`);
console.log(`Schedule ID: ${saved.id}`);
console.log(`Route: ${negad.code} -> ${indode.code}`);
console.log(`Marshalling data: ${BOOKING_REFS.length} bookings, wagons and allocations`);
});
} finally {
await dataSource.destroy();

View File

@@ -0,0 +1,136 @@
import {
CompanyKind,
CompanyStatus,
CompanyType,
} from "../../modules/companies/entities/company.entity";
import {
ProfileStatus,
ProfileType,
} from "../../modules/companies/entities/company-profile.entity";
/**
* Canonical list of seeded Ethiopian government entities. Government bookings
* are billed to one of these (with an explicit importer/exporter profile)
* instead of carrying a null company + free-text institution.
*
* IDs are fixed so the seeder is idempotent and the matching migration
* (1821000000003-AddCompanyKindAndGovBookingLinks) can backfill legacy rows to
* the same companies. The migration mirrors these rows in raw SQL — keep both
* in sync when adding new entities.
*/
export const GOV_COMPANY_TYPE = CompanyType.Customer;
export const GOV_COMPANY_KIND = CompanyKind.Government;
export const GOV_COMPANY_STATUS = CompanyStatus.Active;
export const GOV_PROFILE_STATUS = ProfileStatus.Active;
export interface GovProfileSeed {
id: string;
type: ProfileType;
reference: string;
}
export interface GovCompanySeed {
id: string;
name: string;
tin: string;
email: string;
phone: string;
profiles: GovProfileSeed[];
}
const importExport = (
index: number,
importerId: string,
exporterId: string,
): GovProfileSeed[] => [
{
id: importerId,
type: ProfileType.importer,
reference: `IM-9000${index}`,
},
{
id: exporterId,
type: ProfileType.exporter,
reference: `EX-9000${index}`,
},
];
export const GOV_COMPANIES: GovCompanySeed[] = [
{
id: "0a1b0001-0000-4000-8000-000000000001",
name: "Federal Government of Ethiopia",
tin: "0000000001",
email: "procurement@gov.et",
phone: "+251111000001",
profiles: importExport(
1,
"0b1c0001-0000-4000-8000-000000000001",
"0b1c0001-0000-4000-8000-000000000002",
),
},
{
id: "0a1b0002-0000-4000-8000-000000000002",
name: "Ministry of National Defense",
tin: "0000000002",
email: "logistics@mod.gov.et",
phone: "+251111000002",
profiles: importExport(
2,
"0b1c0002-0000-4000-8000-000000000001",
"0b1c0002-0000-4000-8000-000000000002",
),
},
{
id: "0a1b0003-0000-4000-8000-000000000003",
name: "Ethiopian Roads Administration",
tin: "0000000003",
email: "supply@era.gov.et",
phone: "+251111000003",
profiles: importExport(
3,
"0b1c0003-0000-4000-8000-000000000001",
"0b1c0003-0000-4000-8000-000000000002",
),
},
{
id: "0a1b0004-0000-4000-8000-000000000004",
name: "Ministry of Agriculture",
tin: "0000000004",
email: "imports@moa.gov.et",
phone: "+251111000004",
profiles: importExport(
4,
"0b1c0004-0000-4000-8000-000000000001",
"0b1c0004-0000-4000-8000-000000000002",
),
},
{
id: "0a1b0005-0000-4000-8000-000000000005",
name: "Ministry of Trade and Regional Integration",
tin: "0000000005",
email: "trade@motri.gov.et",
phone: "+251111000005",
profiles: importExport(
5,
"0b1c0005-0000-4000-8000-000000000001",
"0b1c0005-0000-4000-8000-000000000002",
),
},
{
id: "0a1b0006-0000-4000-8000-000000000006",
name: "Ethiopian Disaster Risk Management Commission",
tin: "0000000006",
email: "relief@edrmc.gov.et",
phone: "+251111000006",
profiles: importExport(
6,
"0b1c0006-0000-4000-8000-000000000001",
"0b1c0006-0000-4000-8000-000000000002",
),
},
];
/** Fallback entity used to backfill legacy government / null-company bookings. */
export const DEFAULT_GOV_COMPANY = GOV_COMPANIES[0];
export const DEFAULT_GOV_IMPORTER_PROFILE = GOV_COMPANIES[0].profiles[0];

View File

@@ -0,0 +1,72 @@
import { Injectable, Logger } from "@nestjs/common";
import { DataSource } from "typeorm";
import { Company } from "../modules/companies/entities/company.entity";
import { CompanyProfile } from "../modules/companies/entities/company-profile.entity";
import {
GOV_COMPANIES,
GOV_COMPANY_KIND,
GOV_COMPANY_STATUS,
GOV_COMPANY_TYPE,
GOV_PROFILE_STATUS,
} from "./data/gov-companies.data";
/**
* Idempotently seeds the Ethiopian government entities (with importer + exporter
* profiles) that government bookings bill to. Safe to re-run — rows are keyed by
* the fixed IDs in {@link GOV_COMPANIES}; existing rows are left untouched.
*/
@Injectable()
export class GovCompaniesSeeder {
private readonly logger = new Logger(GovCompaniesSeeder.name);
constructor(private readonly dataSource: DataSource) {}
async run(): Promise<void> {
await this.dataSource.transaction(async (manager) => {
const companyRepo = manager.getRepository(Company);
const profileRepo = manager.getRepository(CompanyProfile);
for (const gov of GOV_COMPANIES) {
const existing = await companyRepo.findOne({ where: { id: gov.id } });
if (!existing) {
await companyRepo.save(
companyRepo.create({
id: gov.id,
name: gov.name,
type: GOV_COMPANY_TYPE,
kind: GOV_COMPANY_KIND,
status: GOV_COMPANY_STATUS,
tin: gov.tin,
country: "Ethiopia",
email: gov.email,
phone: gov.phone,
}),
);
this.logger.log(`Created government company: ${gov.name}`);
}
for (const profile of gov.profiles) {
const existingProfile = await profileRepo.findOne({
where: { id: profile.id },
});
if (existingProfile) continue;
await profileRepo.save(
profileRepo.create({
id: profile.id,
companyId: gov.id,
type: profile.type,
reference: profile.reference,
status: GOV_PROFILE_STATUS,
}),
);
this.logger.log(
`Created ${profile.type} profile ${profile.reference} for ${gov.name}`,
);
}
}
});
this.logger.log("Government companies seeded.");
}
}

View File

@@ -0,0 +1,474 @@
import { Injectable, Logger } from '@nestjs/common';
import { WagonStatus } from '@edr/types';
import { DataSource } from 'typeorm';
import { Booking } from '../modules/bookings/entities/booking.entity';
import { Locomotive } from '../modules/locomotives/entities/locomotive.entity';
import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity';
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
import { Yard } from '../modules/rule-engine/entities/yard.entity';
import { ImportDjiboutiOperation } from '../modules/train-scheduling/entities/import-djibouti-operation.entity';
import { WagonAllocationContainerItem } from '../modules/train-schedules/entities/wagon-allocation-container-item.entity';
import { WagonBookingAllocation } from '../modules/train-schedules/entities/wagon-booking-allocation.entity';
import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity';
import { TrainScheduleBooking } from '../modules/train-schedules/entities/train-schedule-booking.entity';
import { TrainSetWagon } from '../modules/train-sets/entities/train-set-wagon.entity';
import { TrainSet } from '../modules/train-sets/entities/train-set.entity';
import { WagonType } from '../modules/wagon-types/entities/wagon-type.entity';
import { Wagon } from '../modules/wagons/entities/wagon.entity';
import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity';
import { WarehouseYard } from '../modules/warehouses/entities/warehouse-yard.entity';
import { WarehouseZone } from '../modules/warehouses/entities/warehouse-zone.entity';
import { Warehouse } from '../modules/warehouses/entities/warehouse.entity';
type DemoDirection = 'IMPORT' | 'EXPORT';
interface DemoTrain {
trainNumber: string;
direction: DemoDirection;
status: 'SCHEDULED' | 'DISPATCHED' | 'ARRIVED';
bookingPrefix: string;
departureOffsetHours: number;
}
const DEMO_TRAINS: DemoTrain[] = [
{
trainNumber: 'MSH-DEMO-IMP-01',
direction: 'IMPORT',
status: 'SCHEDULED',
bookingPrefix: 'MSH-IMP-01',
departureOffsetHours: 6,
},
{
trainNumber: 'MSH-DEMO-IMP-02',
direction: 'IMPORT',
status: 'DISPATCHED',
bookingPrefix: 'MSH-IMP-02',
departureOffsetHours: -3,
},
{
trainNumber: 'MSH-DEMO-IMP-03',
direction: 'IMPORT',
status: 'ARRIVED',
bookingPrefix: 'MSH-IMP-03',
departureOffsetHours: -14,
},
{
trainNumber: 'MSH-DEMO-EXP-01',
direction: 'EXPORT',
status: 'SCHEDULED',
bookingPrefix: 'MSH-EXP-01',
departureOffsetHours: 8,
},
{
trainNumber: 'MSH-DEMO-EXP-02',
direction: 'EXPORT',
status: 'DISPATCHED',
bookingPrefix: 'MSH-EXP-02',
departureOffsetHours: -2,
},
{
trainNumber: 'MSH-DEMO-EXP-03',
direction: 'EXPORT',
status: 'ARRIVED',
bookingPrefix: 'MSH-EXP-03',
departureOffsetHours: -12,
},
];
@Injectable()
export class MarshallingDemoTrainsSeeder {
private readonly logger = new Logger(MarshallingDemoTrainsSeeder.name);
constructor(private readonly dataSource: DataSource) {}
async run(): Promise<void> {
try {
const yardRepo = this.dataSource.getRepository(Yard);
const serviceTypeRepo = this.dataSource.getRepository(ServiceType);
const cargoTypeRepo = this.dataSource.getRepository(CargoType);
const wagonTypeRepo = this.dataSource.getRepository(WagonType);
const warehouseRepo = this.dataSource.getRepository(Warehouse);
const warehouseYardRepo = this.dataSource.getRepository(WarehouseYard);
const warehouseZoneRepo = this.dataSource.getRepository(WarehouseZone);
const djiboutiYard =
(await yardRepo.findOne({ where: { code: 'NAGAD' } })) ??
(await yardRepo.findOne({ where: { code: 'DJIB_PORT' } })) ??
(await yardRepo.findOne({ where: { country: 'Djibouti' } }));
const ethiopiaYard =
(await yardRepo.findOne({ where: { code: 'INDODE' } })) ??
(await yardRepo.findOne({ where: { code: 'MOJO' } })) ??
(await yardRepo.findOne({ where: { country: 'Ethiopia' } }));
const serviceType =
(await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER' } })) ??
(await serviceTypeRepo.findOne({ where: { isActive: true } }));
const cargoType = await cargoTypeRepo.findOne({ where: { isActive: true } });
const wagonType =
(await wagonTypeRepo.findOne({ where: { code: 'NW5' } })) ??
(await wagonTypeRepo.findOne({ where: { supportsContainer: true } })) ??
(await wagonTypeRepo.findOne({ where: { isActive: true } }));
const warehouse = await warehouseRepo.findOne({ where: { code: 'INDODE_OPEN' } });
const warehouseYard = warehouse
? await warehouseYardRepo.findOne({ where: { warehouseId: warehouse.id } })
: null;
const warehouseZone = warehouseYard
? await warehouseZoneRepo.findOne({ where: { yardId: warehouseYard.id } })
: null;
const missing = [
!djiboutiYard ? 'Djibouti yard' : '',
!ethiopiaYard ? 'Ethiopia yard' : '',
!serviceType ? 'service type' : '',
!wagonType ? 'wagon type' : '',
!warehouse ? 'INDODE_OPEN warehouse' : '',
!warehouseYard ? 'warehouse yard' : '',
!warehouseZone ? 'warehouse zone' : '',
].filter(Boolean);
if (missing.length) {
this.logger.warn(`Cannot seed marshalling demo trains, missing: ${missing.join(', ')}`);
return;
}
let seeded = 0;
for (const demo of DEMO_TRAINS) {
const created = await this.seedTrain(demo, {
djiboutiYard: djiboutiYard!,
ethiopiaYard: ethiopiaYard!,
serviceType: serviceType!,
cargoType,
wagonType: wagonType!,
warehouse: warehouse!,
warehouseYard: warehouseYard!,
warehouseZone: warehouseZone!,
});
if (created) seeded += 1;
}
this.logger.log(`Marshalling demo trains ready: ${seeded} new train(s) seeded, 6 total expected`);
} catch (error) {
this.logger.error(
`MarshallingDemoTrainsSeeder failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
private async seedTrain(
demo: DemoTrain,
refs: {
djiboutiYard: Yard;
ethiopiaYard: Yard;
serviceType: ServiceType;
cargoType: CargoType | null;
wagonType: WagonType;
warehouse: Warehouse;
warehouseYard: WarehouseYard;
warehouseZone: WarehouseZone;
},
): Promise<boolean> {
const bookingRepo = this.dataSource.getRepository(Booking);
const trainSetRepo = this.dataSource.getRepository(TrainSet);
const trainSetWagonRepo = this.dataSource.getRepository(TrainSetWagon);
const scheduleRepo = this.dataSource.getRepository(TrainSchedule);
const scheduleBookingRepo = this.dataSource.getRepository(TrainScheduleBooking);
const allocationRepo = this.dataSource.getRepository(WagonBookingAllocation);
const containerItemRepo = this.dataSource.getRepository(WagonAllocationContainerItem);
const existing = await scheduleRepo.findOne({ where: { trainNumber: demo.trainNumber } });
if (existing) {
await this.backfillDispatchQueueInventory(demo, refs);
return false;
}
const now = new Date();
const departure = this.addHours(now, demo.departureOffsetHours);
const arrival = this.addHours(departure, demo.direction === 'IMPORT' ? 12 : 10);
const isDispatched = demo.status === 'DISPATCHED';
const isArrived = demo.status === 'ARRIVED';
const hasDeparted = isDispatched || isArrived;
const originYard = demo.direction === 'IMPORT' ? refs.djiboutiYard : refs.ethiopiaYard;
const destinationYard = demo.direction === 'IMPORT' ? refs.ethiopiaYard : refs.djiboutiYard;
const locomotive = await this.ensureLocomotive(originYard.id);
const bookingWeights = [22.4, 24.8, 18.6, 20.2];
const totalWeight = bookingWeights.reduce((sum, weight) => sum + weight, 0);
const wagonCapacity = Number(refs.wagonType.capacityTons) || 70;
const wagonLength = Number(refs.wagonType.lengthMeters) || 14;
const tareWeight = Number(refs.wagonType.tareWeightTons) || 14;
const trainSet = await trainSetRepo.save(
trainSetRepo.create({
locomotiveId: locomotive.id,
totalWeightTons: totalWeight,
totalLengthMeters: wagonLength * bookingWeights.length,
wagonCount: bookingWeights.length,
status: isArrived ? 'COMPLETED' : isDispatched ? 'DISPATCHED' : 'ASSIGNED',
}),
);
const schedule = await scheduleRepo.save(
scheduleRepo.create({
trainSetId: trainSet.id,
originStationId: originYard.id,
destinationStationId: destinationYard.id,
scheduledDepartureDate: departure,
scheduledArrivalDate: arrival,
actualDepartureAt: hasDeparted ? departure : null,
actualArrivalAt: isArrived ? arrival : null,
status: demo.status as TrainSchedule['status'],
trainNumber: demo.trainNumber,
direction: demo.direction,
maxWagons: 53,
bookingWindowStatus: 'CLOSED',
}),
);
for (const [index, weightTons] of bookingWeights.entries()) {
const sequence = index + 1;
const bookingReference = `${demo.bookingPrefix}-${String(sequence).padStart(3, '0')}`;
const containerNumber = `${demo.direction === 'IMPORT' ? 'IMDU' : 'EXPU'}${demo.trainNumber.slice(-2)}${String(sequence).padStart(3, '0')}`;
const booking = await bookingRepo.save(
bookingRepo.create({
reference: bookingReference,
originYardId: originYard.id,
destinationYardId: destinationYard.id,
serviceTypeId: refs.serviceType.id,
status: hasDeparted ? 'IN_TRANSIT' : 'PAID',
paymentStatus: 'PAID',
scheduledDate: departure,
contractType: 'SPOT',
equipmentReturn: 'TERMINAL',
paymentCurrency: 'ETB',
totalAmount: 0,
isGovernment: false,
tradeDirection: demo.direction,
freightType: sequence % 2 === 0 ? 'BULK' : 'CONTAINER',
cargoTypeId: refs.cargoType?.id ?? null,
cargoFreeText: refs.cargoType ? null : `${demo.direction} marshalling demo goods ${sequence}`,
cargoTotalWeightVgm: weightTons * 1000,
trainScheduleId: schedule.id,
schedulingStatus: isArrived ? 'ARRIVED' : isDispatched ? 'DISPATCHED' : 'SCHEDULED',
scheduledAt: now,
}),
);
await this.ensureDispatchQueueInventory({
booking,
demo,
refs,
weightKg: weightTons * 1000,
now,
});
const physicalWagon = await this.ensureWagon({
wagonNumber: `${demo.trainNumber}-W${String(sequence).padStart(2, '0')}`,
wagonTypeId: refs.wagonType.id,
yardId: originYard.id,
trainScheduleId: schedule.id,
tareWeight,
capacityTons: wagonCapacity,
dispatched: hasDeparted,
});
const trainSetWagon = await trainSetWagonRepo.save(
trainSetWagonRepo.create({
trainSetId: trainSet.id,
wagonTypeId: refs.wagonType.id,
physicalWagonId: physicalWagon.id,
sequenceNo: sequence,
capacityTons: wagonCapacity,
lengthMeters: wagonLength,
assignedWeightTons: weightTons,
status: hasDeparted ? 'DEPARTED' : 'LOADED',
}),
);
await this.dataSource.getRepository(Wagon).update(physicalWagon.id, {
trainSetWagonId: trainSetWagon.id,
});
const allocation = await allocationRepo.save(
allocationRepo.create({
trainSetWagonId: trainSetWagon.id,
bookingId: booking.id,
allocatedWeightTons: weightTons,
loadType: booking.freightType === 'CONTAINER' ? 'CONTAINER' : 'BULK',
status: hasDeparted ? 'DEPARTED' : 'LOADED',
confirmedAt: now,
}),
);
await containerItemRepo.save(
containerItemRepo.create({
wagonBookingAllocationId: allocation.id,
containerNumber,
positionOnWagon: 1,
sealNumber: `SEAL-${demo.trainNumber.slice(-2)}-${sequence}`,
chassisNumber: `CHS-${demo.trainNumber.slice(-2)}-${sequence}`,
grossWeightTons: weightTons,
}),
);
await scheduleBookingRepo.save(
scheduleBookingRepo.create({
trainScheduleId: schedule.id,
bookingId: booking.id,
}),
);
}
if (demo.direction === 'IMPORT') {
await this.seedImportOperation(schedule.id, demo.trainNumber, now, departure, hasDeparted);
}
return true;
}
private async backfillDispatchQueueInventory(
demo: DemoTrain,
refs: {
warehouse: Warehouse;
warehouseYard: WarehouseYard;
warehouseZone: WarehouseZone;
},
): Promise<void> {
const bookingRepo = this.dataSource.getRepository(Booking);
for (let sequence = 1; sequence <= 4; sequence++) {
const reference = `${demo.bookingPrefix}-${String(sequence).padStart(3, '0')}`;
const booking = await bookingRepo.findOne({ where: { reference } });
if (!booking) continue;
await this.ensureDispatchQueueInventory({
booking,
demo,
refs,
weightKg: Number(booking.cargoTotalWeightVgm) || 0,
now: new Date(),
});
}
}
private async ensureDispatchQueueInventory(input: {
booking: Booking;
demo: DemoTrain;
refs: {
warehouse: Warehouse;
warehouseYard: WarehouseYard;
warehouseZone: WarehouseZone;
};
weightKg: number;
now: Date;
}): Promise<void> {
const repo = this.dataSource.getRepository(WarehouseInventory);
const existing = await repo.findOne({ where: { bookingId: input.booking.id } });
if (existing) return;
const exportDispatch = input.demo.direction === 'EXPORT';
const arrivedAt = this.addHours(input.now, -8);
const inspectedAt = this.addHours(input.now, -6);
const readyAt = this.addHours(input.now, -4);
const loadedAt = this.addHours(input.now, -2);
await repo.save(
repo.create({
warehouseId: input.refs.warehouse.id,
yardId: input.refs.warehouseYard.id,
zoneId: input.refs.warehouseZone.id,
bookingId: input.booking.id,
quantity: 1,
weight: input.weightKg,
status: exportDispatch ? 'LOADED' : 'READY_FOR_PICKUP',
inspectionStatus: 'PASSED',
arrivedAt,
unloadedAt: exportDispatch ? null : arrivedAt,
inspectedAt,
readyForLoadingAt: exportDispatch ? readyAt : null,
loadedAt: exportDispatch ? loadedAt : null,
readyForPickupAt: exportDispatch ? null : readyAt,
notes: `[MSH-DEMO] ${input.demo.trainNumber} dispatch queue test item`,
}),
);
}
private async ensureLocomotive(currentYardId: string): Promise<Locomotive> {
const repo = this.dataSource.getRepository(Locomotive);
const existing = await repo.findOne({ where: { code: 'MSH-DEMO-LOCO' } });
if (existing) return existing;
return repo.save(
repo.create({
code: 'MSH-DEMO-LOCO',
name: 'Marshalling Demo Locomotive',
locomotiveType: 'DIESEL',
maxPullWeightTons: 4200,
maxTrainLengthMeters: 760,
status: 'AVAILABLE',
currentYardId,
}),
);
}
private async ensureWagon(input: {
wagonNumber: string;
wagonTypeId: string;
yardId: string;
trainScheduleId: string;
tareWeight: number;
capacityTons: number;
dispatched: boolean;
}): Promise<Wagon> {
const repo = this.dataSource.getRepository(Wagon);
const existing = await repo.findOne({ where: { wagonNumber: input.wagonNumber } });
if (existing) return existing;
return repo.save(
repo.create({
wagonNumber: input.wagonNumber,
wagonTypeId: input.wagonTypeId,
currentYardId: input.yardId,
currentTrainScheduleId: input.trainScheduleId,
tareWeight: input.tareWeight,
maxPayloadWeight: input.capacityTons,
status: WagonStatus.Assigned,
notes: 'Marshalling demo seed wagon',
}),
);
}
private async seedImportOperation(
trainScheduleId: string,
trainNumber: string,
now: Date,
departure: Date,
dispatched: boolean,
): Promise<void> {
const repo = this.dataSource.getRepository(ImportDjiboutiOperation);
await repo.save(
repo.create({
trainScheduleId,
documents: {
DELIVERY_ORDER: this.documentRecord(trainNumber, 'DELIVERY_ORDER', now),
PORT_INVOICE: this.documentRecord(trainNumber, 'PORT_INVOICE', now),
DJIBOUTI_T1: this.documentRecord(trainNumber, 'DJIBOUTI_T1', now),
ETHIOPIA_T1: this.documentRecord(trainNumber, 'ETHIOPIA_T1', now),
RAILWAY_BILL: this.documentRecord(trainNumber, 'RAILWAY_BILL', now),
},
gatepassGrantedAt: now,
readyForLoadingAt: now,
loadedOnTrainAt: now,
departedFromDjiboutiAt: dispatched ? departure : null,
performedBy: 'Marshalling Demo Seeder',
notes: '[MSH-DEMO] Import train ready for marshalling document and dispatch workflow',
}),
);
}
private documentRecord(trainNumber: string, type: string, now: Date) {
return {
reference: `${type}-${trainNumber}`,
uploadedAt: now.toISOString(),
uploadedBy: 'Marshalling Demo Seeder',
notes: 'Seeded document for import marshalling workflow',
};
}
private addHours(date: Date, hours: number): Date {
return new Date(date.getTime() + hours * 60 * 60 * 1000);
}
}

View File

@@ -73,6 +73,8 @@ import TrainDetailPage from "./pages/trains/TrainDetailPage";
import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage";
import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage";
import ExportDjiboutiUnloadingQueuePage from "./pages/warehouses/ExportDjiboutiUnloadingQueuePage";
import ExportWarehouseFlowPage from "./pages/warehouses/ExportWarehouseFlowPage";
import ImportWarehouseFlowPage from "./pages/warehouses/ImportWarehouseFlowPage";
import InterchangeDocumentsPage from "./pages/warehouses/InterchangeDocumentsPage";
import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage";
import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage";
@@ -210,6 +212,85 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
// },
],
},
{
title: "Port & Terminal",
items: [
{
label: "Import Operations",
href: "/dashboard/import-warehouse",
icon: <PackageOpen />,
children: [
{
label: "Import Overview",
href: "/dashboard/import-warehouse",
icon: <PackageOpen />,
},
{
label: "Arrival Queue",
href: "/dashboard/arrival-queue",
icon: <PackageOpen />,
},
{
label: "Dispatch Queue",
href: "/dashboard/dispatch-queue",
icon: <Send />,
},
{
label: "Terminal Inventory",
href: "/dashboard/warehouse-inventory",
icon: <Package />,
},
{
label: "Inventory Inquiry",
href: "/dashboard/inventory-inquiry",
icon: <Boxes />,
},
],
},
{
label: "Export Operations",
href: "/dashboard/export-warehouse",
icon: <Truck />,
children: [
{
label: "Export Overview",
href: "/dashboard/export-warehouse",
icon: <Truck />,
},
{
label: "Loading Queue",
href: "/dashboard/loading-queue",
icon: <Truck />,
},
{
label: "Loaded Inventory",
href: "/dashboard/loaded-inventory",
icon: <PackageCheck />,
},
{
label: "Dispatch Queue",
href: "/dashboard/dispatch-queue",
icon: <Send />,
},
{
label: "Djibouti Unloading",
href: "/dashboard/export-djibouti-unloading",
icon: <PackageOpen />,
},
{
label: "Interchange Documents",
href: "/dashboard/interchange-documents",
icon: <FileText />,
},
{
label: "Terminal Inventory",
href: "/dashboard/warehouse-inventory",
icon: <Package />,
},
],
},
],
},
{
title: "Warehouse Management",
items: [
@@ -223,46 +304,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
href: "/dashboard/warehouses",
icon: <Container />,
},
{
label: "Inventory",
href: "/dashboard/warehouse-inventory",
icon: <Package />,
},
{
label: "Arrival Queue",
href: "/dashboard/arrival-queue",
icon: <PackageOpen />,
},
{
label: "Loading Queue",
href: "/dashboard/loading-queue",
icon: <Truck />,
},
{
label: "Loaded Inventory",
href: "/dashboard/loaded-inventory",
icon: <PackageCheck />,
},
{
label: "Dispatch Queue",
href: "/dashboard/dispatch-queue",
icon: <Send />,
},
{
label: "Djibouti Unloading",
href: "/dashboard/export-djibouti-unloading",
icon: <PackageOpen />,
},
{
label: "Interchange Documents",
href: "/dashboard/interchange-documents",
icon: <FileText />,
},
{
label: "Inventory Inquiry",
href: "/dashboard/inventory-inquiry",
icon: <Boxes />,
},
{
label: "Allocation & Fees",
href: "/dashboard/warehouse-rules",
@@ -487,6 +528,8 @@ const App = () => {
<Route path="warehouses" element={<WarehouseListPage />} />
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
<Route path="warehouse-inventory" element={<WarehouseInventoryPage />} />
<Route path="import-warehouse" element={<ImportWarehouseFlowPage />} />
<Route path="export-warehouse" element={<ExportWarehouseFlowPage />} />
<Route path="arrival-queue" element={<ArrivalQueuePage />} />
<Route path="loading-queue" element={<LoadingQueuePage />} />
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />

View File

@@ -120,6 +120,26 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
}
};
const openHandoverDocument = async (item: WarehouseInventoryItem) => {
setBusyId(item.id);
const pdfWindow = window.open('', '_blank');
try {
const response = await warehouseService.downloadHandoverDocument(item.id);
const filename = `handover-${item.booking?.reference ?? item.bookingId ?? item.id}.pdf`;
const opened = openPdfBlob(response.data, filename, pdfWindow);
toast({ title: opened ? 'Handover document opened' : 'Handover document downloaded' });
} catch (error) {
pdfWindow?.close();
toast({
variant: 'destructive',
title: 'Handover document failed',
description: extractErrorMessage(error),
});
} finally {
setBusyId(null);
}
};
const acceptLastMile = async (item: WarehouseInventoryItem) => {
const reference = item.booking?.reference;
if (!reference) {
@@ -227,6 +247,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
onInspect={setInspectItem}
onFeePreview={setFeeItem}
onReleaseDocument={downloadReleaseDocument}
onHandoverDocument={openHandoverDocument}
onLastMile={onLastMile ? acceptLastMile : undefined}
selectedIds={selected}
onToggleSelect={toggleSelect}

View File

@@ -1,5 +1,6 @@
import { Fragment, useEffect, useMemo, useState } from 'react';
import {
ActionIcon,
Alert,
Badge,
Button,
@@ -8,6 +9,7 @@ import {
Loader,
Modal,
NumberInput,
ScrollArea,
Select,
Stack,
Table,
@@ -15,28 +17,58 @@ import {
Text,
Textarea,
TextInput,
Tooltip,
} from '@mantine/core';
import { ChevronDown, ChevronRight, ClipboardCheck, Info, PackageSearch, Train, Truck } from 'lucide-react';
import {
ChevronDown,
ChevronRight,
ClipboardCheck,
Eye,
FileText,
History,
Info,
PackageCheck,
PackageOpen,
PackageSearch,
Send,
Search,
Train,
Truck,
} from 'lucide-react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { api } from '@/services/api';
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
import { useToast } from '@/hooks/use-toast';
import { useInventoryInquiry } from '@/hooks/useWarehouses';
import { firstMileService } from '@/services/first-mile.service';
import { warehouseService } from '@/services/warehouse.service';
import type {
EligibleBooking,
InventoryInquiryFilter,
InventoryInquiryResult,
ImportTrain,
ImportTrainItem,
ImportUnloadedItem,
ReadyToLoadRow,
ReceiveInventoryPayload,
TruckEntrancePayload,
WarehouseInventoryItem,
} from '@/types/warehouse';
import { BookingSelect } from './BookingSelect';
import { DeliverInventoryModal } from './DeliverInventoryModal';
import { FeePreviewModal } from './FeePreviewModal';
import { InspectionReportModal } from './InspectionReportModal';
import { InventoryDetailModal } from './InventoryDetailModal';
import { InventoryHistoryModal } from './InventoryHistoryModal';
import { InventoryWorkbench } from './InventoryWorkbench';
import { extractErrorMessage, formatDate, formatNumber } from './options';
import { InventoryInquiryDetailModal } from './InventoryInquiryDetailModal';
import { ReleaseOrderModal } from './ReleaseOrderModal';
import { WarehouseInquiryTable } from './WarehouseInquiryTable';
import { extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions } from './options';
import { openPdfBlob } from './pdf';
import '@/components/overview/overview.css';
interface ReceiveInventoryModalProps {
opened: boolean;
@@ -608,6 +640,7 @@ function EligibleTab({
const [statusTab, setStatusTab] = useState('ALL');
const [truckOpen, setTruckOpen] = useState(false);
const [pendingReceiveIds, setPendingReceiveIds] = useState<string[]>([]);
const [receivedAt, setReceivedAt] = useState<string | null>(null);
const [truckForm, setTruckForm] = useState<TruckEntranceFormState>(emptyTruckEntrance());
const [lockedTruckFields, setLockedTruckFields] = useState<LockedTruckEntranceFields>({});
const [packagingFreightType, setPackagingFreightType] = useState<PackagingFreightType>('MIXED');
@@ -717,6 +750,7 @@ function EligibleTab({
setSelected(new Set());
setTruckOpen(false);
setPendingReceiveIds([]);
setReceivedAt(null);
setLockedTruckFields({});
setPackagingFreightType('MIXED');
onChanged?.();
@@ -749,6 +783,7 @@ function EligibleTab({
}
const { form, lockedFields, packagingFreightType: nextPackagingFreightType } = truckEntranceFromBookings(selectedRows);
setPendingReceiveIds(filteredIds);
setReceivedAt(new Date().toISOString());
setTruckForm(form);
setLockedTruckFields(lockedFields);
setPackagingFreightType(nextPackagingFreightType);
@@ -807,15 +842,18 @@ function EligibleTab({
)}
<Button
size="compact-sm"
variant="default"
color={direction === 'EXPORT' ? 'edr-green' : undefined}
variant={direction === 'EXPORT' ? 'filled' : 'default'}
leftSection={direction === 'EXPORT' ? <Truck size={14} /> : undefined}
disabled={!locationReady || selectableRows.length === 0}
loading={bulkReceive.isPending}
onClick={() => openTruckReceive(selectableRows.map((r) => r.id))}
onClick={() => openTruckReceive(selected.size > 0 ? [...selected] : selectableRows.map((r) => r.id))}
>
Receive All Eligible
{direction === 'EXPORT' ? 'Receive to Warehouse' : 'Receive All to Warehouse'}
</Button>
<Button
size="compact-sm"
variant="default"
disabled={!locationReady || selected.size === 0}
loading={bulkReceive.isPending}
onClick={() => openTruckReceive([...selected])}
@@ -859,7 +897,7 @@ function EligibleTab({
<Table.Th>Origin</Table.Th>
<Table.Th>Destination</Table.Th>
<Table.Th>Route</Table.Th>
<Table.Th>Container #</Table.Th>
<Table.Th>Container / Cargo Items</Table.Th>
<Table.Th>Cargo Type</Table.Th>
<Table.Th>Weight</Table.Th>
<Table.Th>Payment</Table.Th>
@@ -899,7 +937,17 @@ function EligibleTab({
<Table.Td>
{r.origin || r.destination ? `${r.origin ?? '?'}${r.destination ?? '?'}` : '—'}
</Table.Td>
<Table.Td></Table.Td>
<Table.Td>
<Stack gap={2}>
<Text size="sm">{r.containerNumber ?? r.cargoDescription ?? r.cargo ?? '—'}</Text>
<Text size="xs" c="dimmed">
{[
r.containerQuantity != null ? `${r.containerQuantity} unit(s)` : null,
r.containerPackagingType,
].filter(Boolean).join(' / ') || 'Item details from booking'}
</Text>
</Stack>
</Table.Td>
<Table.Td>{r.cargo ?? '—'}</Table.Td>
<Table.Td>{formatNumber(Number(r.weight))}</Table.Td>
<Table.Td>
@@ -952,7 +1000,7 @@ function EligibleTab({
loading={bulkReceive.isPending}
onClick={() => openTruckReceive([r.id])}
>
{canReceive ? 'Receive' : 'Await First Mile'}
{canReceive ? 'Receive to Warehouse' : 'Await First Mile'}
</Button>
)}
</Table.Td>
@@ -967,7 +1015,7 @@ function EligibleTab({
<Modal
opened={truckOpen}
onClose={() => setTruckOpen(false)}
title="Export Truck Arrival / First Mile Receive Form"
title="Receive to Warehouse"
centered
size="lg"
>
@@ -979,6 +1027,52 @@ function EligibleTab({
: 'Register the customer or third-party truck and driver before export receiving and GRN.'}
</Text>
</Alert>
<Table.ScrollContainer minWidth={900}>
<Table withTableBorder highlightOnHover verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Booking</Table.Th>
<Table.Th>Customer</Table.Th>
<Table.Th>TIN / Phone</Table.Th>
<Table.Th>Container / Cargo</Table.Th>
<Table.Th>Qty / Package</Table.Th>
<Table.Th>Weight</Table.Th>
<Table.Th>Received at</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{pendingReceiveRows.map((booking) => (
<Table.Tr key={booking.id}>
<Table.Td>
<Text size="sm" fw={600}>{booking.reference}</Text>
<Text size="xs" c="dimmed">{booking.id.slice(0, 8)}...</Text>
</Table.Td>
<Table.Td>{booking.customer ?? '-'}</Table.Td>
<Table.Td>
<Stack gap={0}>
<Text size="xs">{booking.customerTin ?? '-'}</Text>
<Text size="xs" c="dimmed">{booking.customerPhone ?? '-'}</Text>
</Stack>
</Table.Td>
<Table.Td>
<Stack gap={0}>
<Text size="xs">{booking.containerNumber ?? booking.cargoDescription ?? booking.cargo ?? '-'}</Text>
<Text size="xs" c="dimmed">{booking.freightType ?? '-'}</Text>
</Stack>
</Table.Td>
<Table.Td>
{[
booking.containerQuantity != null ? `${booking.containerQuantity} unit(s)` : null,
booking.containerPackagingType,
].filter(Boolean).join(' / ') || '-'}
</Table.Td>
<Table.Td>{formatNumber(Number(booking.weight))}</Table.Td>
<Table.Td>{formatDate(receivedAt)}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
<TruckEntranceFields
value={truckForm}
onChange={setTruckForm}
@@ -1084,7 +1178,7 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
<Table.Th>Booking ID</Table.Th>
<Table.Th>Customer ID</Table.Th>
<Table.Th>Customer Name</Table.Th>
<Table.Th>Container #</Table.Th>
<Table.Th>Container / Cargo Items</Table.Th>
<Table.Th>Cargo Type</Table.Th>
<Table.Th>Weight</Table.Th>
<Table.Th>Route</Table.Th>
@@ -1466,11 +1560,11 @@ function LoadedExportTab({
}
/** Assigned bookings/items for an arrived import train (read-only detail view). */
function ImportTrainDetailTable({ scheduleId }: { scheduleId: string }) {
function ImportTrainDetailTable({ train }: { train: ImportTrain }) {
const { data: items = [], isLoading } = useQuery(
api.warehouses.importTrainItems.queryOptions({
input: { scheduleId },
enabled: Boolean(scheduleId),
input: { scheduleId: train.scheduleId },
enabled: Boolean(train.scheduleId),
}),
);
@@ -1493,6 +1587,7 @@ function ImportTrainDetailTable({ scheduleId }: { scheduleId: string }) {
<Table withTableBorder verticalSpacing="xs" fz="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Wagon</Table.Th>
<Table.Th>Booking ID</Table.Th>
<Table.Th>Booking Ref</Table.Th>
<Table.Th>Customer ID</Table.Th>
@@ -1509,7 +1604,12 @@ function ImportTrainDetailTable({ scheduleId }: { scheduleId: string }) {
</Table.Thead>
<Table.Tbody>
{items.map((it: ImportTrainItem) => (
<Table.Tr key={it.bookingId}>
<Table.Tr key={`${it.bookingId}-${it.sequenceNo ?? 'wagon'}`}>
<Table.Td>
<Text size="xs" fw={600}>
{it.sequenceNo ? `#${it.sequenceNo}` : '-'} {it.wagonNumber ?? ''}
</Text>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">{it.bookingId.slice(0, 8)}</Text>
</Table.Td>
@@ -1656,8 +1756,8 @@ function ImportArriveQueueTab({
<Table.Td ta="center">{t.totalCargoes}</Table.Td>
<Table.Td>
<Stack gap={2}>
<Badge color={fullyUnloaded ? 'green' : 'indigo'} variant="light" size="sm">
{fullyUnloaded ? 'UNLOADED' : t.status}
<Badge color="indigo" variant="light" size="sm">
{t.status}
</Badge>
<Text size="xs" c="dimmed">
{Math.max(unloadedBookings, 0)}/{t.totalBookings} unloaded
@@ -1690,7 +1790,7 @@ function ImportArriveQueueTab({
{isOpen && (
<Table.Tr>
<Table.Td colSpan={11} bg="var(--mantine-color-gray-0)">
<ImportTrainDetailTable scheduleId={t.scheduleId} />
<ImportTrainDetailTable train={t} />
</Table.Td>
</Table.Tr>
)}
@@ -1712,14 +1812,24 @@ function ImportArriveQueueTab({
*/
function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
const { toast } = useToast();
const qc = useQueryClient();
const { data: rows = [], isLoading } = useQuery(
api.warehouses.importUnloadedQueue.queryOptions({ enabled }),
);
const inspectMutation = useMutation(
api.warehouses.bulkMarkInspected.mutationOptions(),
);
const storeMutation = useMutation(api.warehouses.store.mutationOptions());
const readyMutation = useMutation(api.warehouses.markReadyForPickup.mutationOptions());
const dispatchMutation = useMutation(api.warehouses.dispatch.mutationOptions());
const [selected, setSelected] = useState<Set<string>>(new Set());
const [inspectId, setInspectId] = useState<string | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const [viewItem, setViewItem] = useState<WarehouseInventoryItem | null>(null);
const [historyItem, setHistoryItem] = useState<WarehouseInventoryItem | null>(null);
const [feeItem, setFeeItem] = useState<WarehouseInventoryItem | null>(null);
const [releaseItem, setReleaseItem] = useState<WarehouseInventoryItem | null>(null);
const [deliverItem, setDeliverItem] = useState<WarehouseInventoryItem | null>(null);
const allSelected = rows.length > 0 && selected.size === rows.length;
const someSelected = selected.size > 0 && !allSelected;
@@ -1744,11 +1854,62 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
description: r.skippedCount ? `${r.skippedCount} skipped` : undefined,
});
setSelected(new Set());
void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
} catch (error) {
toast({ variant: 'destructive', title: 'Mark inspected failed', description: extractErrorMessage(error) });
}
};
const toInventoryItem = (row: ImportUnloadedItem): WarehouseInventoryItem =>
({
id: row.id,
bookingId: row.bookingId,
quantity: 1,
weight: Number(row.weight) || 0,
status: row.currentStatus,
arrivedAt: row.arrivalTime,
unloadedAt: row.arrivalTime,
inspectionStatus: row.inspectionStatus,
releaseDate: row.releaseDate,
releaseOrderReference: row.releaseOrderReference,
deliveredAt: row.deliveredAt,
booking: row.bookingId
? {
id: row.bookingId,
reference: row.bookingReference ?? row.bookingId,
tradeDirection: 'IMPORT',
lastMileDeliveryAddress: row.lastMileRequested ? row.pickupOption : null,
}
: null,
}) as unknown as WarehouseInventoryItem;
const runRowAction = async (row: ImportUnloadedItem, label: string, fn: () => Promise<unknown>) => {
setBusyId(row.id);
try {
await fn();
toast({ title: label });
void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
} catch (error) {
toast({ variant: 'destructive', title: 'Action failed', description: extractErrorMessage(error) });
} finally {
setBusyId(null);
}
};
const openHandoverDocument = async (row: ImportUnloadedItem) => {
setBusyId(row.id);
const pdfWindow = window.open('', '_blank');
try {
const response = await warehouseService.downloadHandoverDocument(row.id);
openPdfBlob(response.data, `handover-${row.bookingReference ?? row.id}.pdf`, pdfWindow);
} catch (error) {
pdfWindow?.close();
toast({ variant: 'destructive', title: 'Handover document failed', description: extractErrorMessage(error) });
} finally {
setBusyId(null);
}
};
return (
<Stack gap="sm" mt="sm">
<Group justify="space-between">
@@ -1852,9 +2013,92 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
<Badge color="indigo" variant="light" size="sm">{r.currentStatus}</Badge>
</Table.Td>
<Table.Td ta="right">
<Button size="compact-xs" variant="light" color="orange" onClick={() => setInspectId(r.id)}>
Inspect / Report
</Button>
<Group gap="xs" justify="flex-end" wrap="nowrap">
<Tooltip label="View details" withArrow>
<ActionIcon variant="subtle" color="gray" onClick={() => setViewItem(toInventoryItem(r))}>
<Eye size={16} />
</ActionIcon>
</Tooltip>
{r.currentStatus === 'UNLOADED' && (
<Button
size="compact-xs"
variant="light"
color="blue"
loading={busyId === r.id}
onClick={() => runRowAction(r, 'Inventory stored', () => storeMutation.mutateAsync(r.id))}
>
Store
</Button>
)}
{['UNLOADED', 'STORED'].includes(r.currentStatus) && r.inspectionStatus === 'PASSED' && (
<Button
size="compact-xs"
variant="light"
color="orange"
loading={busyId === r.id}
onClick={() => runRowAction(r, 'Ready for pickup', () => readyMutation.mutateAsync(r.id))}
>
Ready Pickup
</Button>
)}
{r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && (
<>
<Button
size="compact-xs"
variant="light"
color="yellow"
onClick={() => setReleaseItem(toInventoryItem(r))}
>
Truck Arrival
</Button>
</>
)}
{r.currentStatus === 'READY_FOR_PICKUP' && (
<Button
size="compact-xs"
variant="light"
color="green"
loading={busyId === r.id}
onClick={() => runRowAction(r, 'Inventory dispatched', () => dispatchMutation.mutateAsync(r.id))}
>
Dispatch
</Button>
)}
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
<Button
size="compact-xs"
variant="light"
color="green"
onClick={() => setDeliverItem(toInventoryItem(r))}
>
Deliver
</Button>
)}
{r.inspectionStatus === 'PASSED' && (
<Button
size="compact-xs"
variant="light"
color="teal"
leftSection={<FileText size={14} />}
onClick={() => openHandoverDocument(r)}
>
Handover
</Button>
)}
<Button size="compact-xs" variant="light" color="orange" onClick={() => setInspectId(r.id)}>
Inspect / Report
</Button>
<Tooltip label="Storage / fee preview" withArrow>
<ActionIcon variant="subtle" color="teal" onClick={() => setFeeItem(toInventoryItem(r))}>
<PackageCheck size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="History" withArrow>
<ActionIcon variant="subtle" color="gray" onClick={() => setHistoryItem(toInventoryItem(r))}>
<History size={16} />
</ActionIcon>
</Tooltip>
</Group>
</Table.Td>
</Table.Tr>
))}
@@ -1868,6 +2112,15 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
onClose={() => setInspectId(null)}
inventoryId={inspectId}
/>
<InventoryDetailModal opened={Boolean(viewItem)} onClose={() => setViewItem(null)} item={viewItem} />
<InventoryHistoryModal opened={Boolean(historyItem)} onClose={() => setHistoryItem(null)} item={historyItem} />
<FeePreviewModal
opened={Boolean(feeItem)}
onClose={() => setFeeItem(null)}
inventoryId={feeItem?.id ?? null}
/>
<ReleaseOrderModal opened={Boolean(releaseItem)} onClose={() => setReleaseItem(null)} item={releaseItem} />
<DeliverInventoryModal opened={Boolean(deliverItem)} onClose={() => setDeliverItem(null)} item={deliverItem} />
</Stack>
);
}
@@ -1905,20 +2158,340 @@ function ImportDispatchQueueTab({ enabled }: { enabled: boolean }) {
);
}
/** New bulk Receive: Import / Export tabs with eligible PAID bookings. */
function BulkReceiveModal({ opened, onClose, onReceived }: ReceiveInventoryModalProps) {
const [location, setLocation] = useState<Location>({ warehouseId: '', yardId: '', zoneId: '' });
const [tab, setTab] = useState<'IMPORT' | 'EXPORT'>('IMPORT');
type WarehouseFlowDirection = 'IMPORT' | 'EXPORT' | 'BOTH';
type ImportWarehouseTab = 'arrive-queue' | 'unloaded-queue' | 'dispatch-queue' | 'locate-booking';
type ExportWarehouseTab = 'receive-queue' | 'received' | 'ready-to-load' | 'loaded' | 'dispatch-queue' | 'locate-booking';
useEffect(() => {
if (opened) setLocation({ warehouseId: '', yardId: '', zoneId: '' });
}, [opened]);
interface WarehouseQueueTab<TValue extends string> {
value: TValue;
label: string;
icon: React.ReactNode;
count?: number;
}
interface WarehouseFlowWorkbenchProps {
direction?: WarehouseFlowDirection;
enabled?: boolean;
onChanged?: () => void;
}
function WarehouseQueueTabs<TValue extends string>({
value,
onChange,
tabs,
}: {
value: TValue;
onChange: (value: TValue) => void;
tabs: WarehouseQueueTab<TValue>[];
}) {
return (
<Tabs
value={value}
onChange={(next) => onChange((next as TValue) ?? value)}
variant="pills"
color="edr-green"
keepMounted={false}
classNames={{ list: 'ov-tablist', tab: 'ov-tab' }}
>
<ScrollArea type="auto" scrollbarSize={6} offsetScrollbars="x">
<Tabs.List style={{ flexWrap: 'nowrap', width: 'max-content' }}>
{tabs.map((tab) => {
const active = value === tab.value;
return (
<Tabs.Tab
key={tab.value}
value={tab.value}
leftSection={tab.icon}
rightSection={
tab.count !== undefined ? (
<Badge
size="sm"
radius="sm"
variant={active ? 'white' : 'light'}
color={active ? 'edr-green' : 'gray'}
styles={
active
? { root: { background: 'rgba(255,255,255,0.9)', color: '#15805f' } }
: undefined
}
>
{tab.count}
</Badge>
) : undefined
}
>
{tab.label}
</Tabs.Tab>
);
})}
</Tabs.List>
</ScrollArea>
</Tabs>
);
}
function LocateBookingTab({ enabled }: { enabled: boolean }) {
const [draft, setDraft] = useState<InventoryInquiryFilter>({});
const [applied, setApplied] = useState<InventoryInquiryFilter>({});
const [viewResult, setViewResult] = useState<InventoryInquiryResult | null>(null);
const hasSearch = Boolean(
applied.bookingReference ||
applied.containerNumber ||
applied.goodsName ||
applied.cargoType ||
applied.status,
);
const { data: results = [], isFetching } = useInventoryInquiry(applied, enabled && hasSearch);
const normalizeDraft = (): InventoryInquiryFilter => ({
bookingReference: draft.bookingReference?.trim() || undefined,
containerNumber: draft.containerNumber?.trim() || undefined,
goodsName: draft.goodsName?.trim() || undefined,
cargoType: draft.cargoType?.trim() || undefined,
status: draft.status,
});
const runSearch = () => setApplied(normalizeDraft());
const reset = () => {
setDraft({});
setApplied({});
};
return (
<Modal opened={opened} onClose={onClose} title="Receive at warehouse" centered size="80rem">
<Stack gap="md">
<LocationSelects value={location} onChange={setLocation} />
<Stack gap="md" mt="sm">
<Group gap="sm" wrap="wrap">
<TextInput
label="Booking reference"
placeholder="e.g. BK-2026-000051"
value={draft.bookingReference ?? ''}
onChange={(e) => setDraft((filter) => ({ ...filter, bookingReference: e.currentTarget.value || undefined }))}
onKeyDown={(e) => {
if (e.key === 'Enter') runSearch();
}}
w={230}
/>
<TextInput
label="Container number"
placeholder="e.g. MSKU1234567"
value={draft.containerNumber ?? ''}
onChange={(e) => setDraft((filter) => ({ ...filter, containerNumber: e.currentTarget.value || undefined }))}
onKeyDown={(e) => {
if (e.key === 'Enter') runSearch();
}}
w={220}
/>
<TextInput
label="Goods / cargo"
placeholder="Coffee, steel, etc."
value={draft.goodsName ?? draft.cargoType ?? ''}
onChange={(e) => {
const value = e.currentTarget.value || undefined;
setDraft((filter) => ({ ...filter, goodsName: value, cargoType: value }));
}}
onKeyDown={(e) => {
if (e.key === 'Enter') runSearch();
}}
w={200}
/>
<Select
label="Status"
placeholder="Any"
clearable
data={inventoryStatusOptions}
value={draft.status ?? null}
onChange={(value) => setDraft((filter) => ({ ...filter, status: value as InventoryInquiryFilter['status'] }))}
w={190}
/>
</Group>
<Group gap="xs">
<Button leftSection={<Search size={16} />} onClick={runSearch}>
Locate Booking
</Button>
<Button variant="default" onClick={reset}>
Reset
</Button>
</Group>
{isFetching ? (
<Group justify="center" py="lg">
<Loader size="sm" />
</Group>
) : !hasSearch ? (
<Text c="dimmed" ta="center" py="lg" size="sm">
Search by booking reference, container number, cargo or status to locate inventory.
</Text>
) : results.length === 0 ? (
<Text c="dimmed" ta="center" py="lg" size="sm">
No inventory found for the current filters.
</Text>
) : (
<WarehouseInquiryTable results={results} onView={setViewResult} />
)}
<InventoryInquiryDetailModal
opened={Boolean(viewResult)}
onClose={() => setViewResult(null)}
result={viewResult}
/>
</Stack>
);
}
function ImportWarehouseTabs({ enabled, onChanged }: { enabled: boolean; onChanged?: () => void }) {
const [activeTab, setActiveTab] = useState<ImportWarehouseTab>('arrive-queue');
const { data: arriveRows = [] } = useQuery(api.warehouses.importArriveQueue.queryOptions({ enabled }));
const { data: unloadedRows = [] } = useQuery(api.warehouses.importUnloadedQueue.queryOptions({ enabled }));
const { data: dispatchRows = [] } = useQuery(api.warehouses.importPickupReadyQueue.queryOptions({ enabled }));
const tabs: WarehouseQueueTab<ImportWarehouseTab>[] = [
{
value: 'arrive-queue',
label: 'Arrival Queue',
icon: <PackageOpen size={17} strokeWidth={1.85} />,
count: arriveRows.length,
},
{
value: 'unloaded-queue',
label: 'Unloaded Queue',
icon: <ClipboardCheck size={17} strokeWidth={1.85} />,
count: unloadedRows.length,
},
{
value: 'dispatch-queue',
label: 'Dispatch Queue',
icon: <Send size={17} strokeWidth={1.85} />,
count: dispatchRows.length,
},
{
value: 'locate-booking',
label: 'Locate Booking',
icon: <Search size={17} strokeWidth={1.85} />,
},
];
return (
<Stack gap="md">
<WarehouseQueueTabs value={activeTab} onChange={setActiveTab} tabs={tabs} />
{activeTab === 'arrive-queue' && (
<ImportArriveQueueTab enabled={enabled} onChanged={onChanged} />
)}
{activeTab === 'unloaded-queue' && (
<ImportUnloadedQueueTab enabled={enabled} />
)}
{activeTab === 'dispatch-queue' && (
<ImportDispatchQueueTab enabled={enabled} />
)}
{activeTab === 'locate-booking' && (
<LocateBookingTab enabled={enabled} />
)}
</Stack>
);
}
function ExportWarehouseTabs({
enabled,
location,
onChanged,
}: {
enabled: boolean;
location: Location;
onChanged?: () => void;
}) {
const [activeTab, setActiveTab] = useState<ExportWarehouseTab>('receive-queue');
const { data: eligibleRows = [] } = useQuery(api.warehouses.eligibleBookings.queryOptions({ enabled }));
const { data: receivedRows = [] } = useQuery(api.warehouses.receivedExport.queryOptions({ enabled }));
const { data: readyRows = [] } = useQuery(api.warehouses.readyToLoadExport.queryOptions({ enabled }));
const { data: loadedRows = [] } = useQuery(api.warehouses.loadedExport.queryOptions({ enabled }));
const exportEligibleCount = useMemo(
() => eligibleRows.filter((row) => row.direction === 'EXPORT').length,
[eligibleRows],
);
const tabs: WarehouseQueueTab<ExportWarehouseTab>[] = [
{
value: 'receive-queue',
label: 'Receive to Warehouse',
icon: <Truck size={17} strokeWidth={1.85} />,
count: exportEligibleCount,
},
{
value: 'received',
label: 'Received',
icon: <ClipboardCheck size={17} strokeWidth={1.85} />,
count: receivedRows.length,
},
{
value: 'ready-to-load',
label: 'Ready To Load',
icon: <Train size={17} strokeWidth={1.85} />,
count: readyRows.length,
},
{
value: 'loaded',
label: 'Loaded',
icon: <PackageCheck size={17} strokeWidth={1.85} />,
count: loadedRows.length,
},
{
value: 'dispatch-queue',
label: 'Dispatch Queue',
icon: <Send size={17} strokeWidth={1.85} />,
count: loadedRows.length,
},
{
value: 'locate-booking',
label: 'Locate Booking',
icon: <Search size={17} strokeWidth={1.85} />,
},
];
return (
<Stack gap="md">
<WarehouseQueueTabs value={activeTab} onChange={setActiveTab} tabs={tabs} />
{activeTab === 'receive-queue' && (
<EligibleTab direction="EXPORT" location={location} enabled={enabled} onChanged={onChanged} />
)}
{activeTab === 'received' && (
<ExportReceivedTab enabled={enabled} onChanged={onChanged} />
)}
{activeTab === 'ready-to-load' && (
<ReadyToLoadTab enabled={enabled} onChanged={onChanged} />
)}
{activeTab === 'loaded' && (
<LoadedExportTab enabled={enabled} dispatchable={false} onChanged={onChanged} />
)}
{activeTab === 'dispatch-queue' && (
<LoadedExportTab enabled={enabled} dispatchable onChanged={onChanged} />
)}
{activeTab === 'locate-booking' && (
<LocateBookingTab enabled={enabled} />
)}
</Stack>
);
}
export function WarehouseFlowWorkbench({
direction = 'BOTH',
enabled = true,
onChanged,
}: WarehouseFlowWorkbenchProps) {
const [location, setLocation] = useState<Location>({ warehouseId: '', yardId: '', zoneId: '' });
const [tab, setTab] = useState<Exclude<WarehouseFlowDirection, 'BOTH'>>(
direction === 'EXPORT' ? 'EXPORT' : 'IMPORT',
);
const activeDirection = direction === 'BOTH' ? tab : direction;
useEffect(() => {
if (enabled) setLocation({ warehouseId: '', yardId: '', zoneId: '' });
}, [enabled, direction]);
return (
<Stack gap="md">
{activeDirection === 'EXPORT' && (
<LocationSelects value={location} onChange={setLocation} />
)}
{direction === 'BOTH' ? (
<Tabs value={tab} onChange={(v) => setTab((v as 'IMPORT' | 'EXPORT') ?? 'IMPORT')}>
<Tabs.List>
<Tabs.Tab value="IMPORT" leftSection={<PackageSearch size={16} />}>
@@ -1930,54 +2503,27 @@ function BulkReceiveModal({ opened, onClose, onReceived }: ReceiveInventoryModal
</Tabs.List>
<Tabs.Panel value="IMPORT">
<Tabs defaultValue="arrive-queue" mt="xs">
<Tabs.List>
<Tabs.Tab value="arrive-queue" leftSection={<Train size={14} />}>
Arrive Queue
</Tabs.Tab>
<Tabs.Tab value="unloaded-queue">Unloaded Queue</Tabs.Tab>
<Tabs.Tab value="dispatch-queue">Dispatch Queue</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="arrive-queue">
<ImportArriveQueueTab enabled={opened} onChanged={onReceived} />
</Tabs.Panel>
<Tabs.Panel value="unloaded-queue">
<ImportUnloadedQueueTab enabled={opened} />
</Tabs.Panel>
<Tabs.Panel value="dispatch-queue">
<ImportDispatchQueueTab enabled={opened} />
</Tabs.Panel>
</Tabs>
<ImportWarehouseTabs enabled={enabled} onChanged={onChanged} />
</Tabs.Panel>
<Tabs.Panel value="EXPORT">
<Tabs defaultValue="receive-queue" mt="xs">
<Tabs.List>
<Tabs.Tab value="receive-queue">Receive Queue</Tabs.Tab>
<Tabs.Tab value="received">Received</Tabs.Tab>
<Tabs.Tab value="ready-to-load">Ready To Load</Tabs.Tab>
<Tabs.Tab value="loaded">Loaded</Tabs.Tab>
<Tabs.Tab value="dispatch-queue">Dispatch Queue</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="receive-queue">
<EligibleTab direction="EXPORT" location={location} enabled={opened} onChanged={onReceived} />
</Tabs.Panel>
<Tabs.Panel value="received">
<ExportReceivedTab enabled={opened} onChanged={onReceived} />
</Tabs.Panel>
<Tabs.Panel value="ready-to-load">
<ReadyToLoadTab enabled={opened} onChanged={onReceived} />
</Tabs.Panel>
<Tabs.Panel value="loaded">
<LoadedExportTab enabled={opened} dispatchable={false} onChanged={onReceived} />
</Tabs.Panel>
<Tabs.Panel value="dispatch-queue">
<LoadedExportTab enabled={opened} dispatchable onChanged={onReceived} />
</Tabs.Panel>
</Tabs>
<ExportWarehouseTabs enabled={enabled} location={location} onChanged={onChanged} />
</Tabs.Panel>
</Tabs>
) : activeDirection === 'IMPORT' ? (
<ImportWarehouseTabs enabled={enabled} onChanged={onChanged} />
) : (
<ExportWarehouseTabs enabled={enabled} location={location} onChanged={onChanged} />
)}
</Stack>
);
}
/** New bulk Receive: Import / Export tabs with eligible PAID bookings. */
function BulkReceiveModal({ opened, onClose, onReceived }: ReceiveInventoryModalProps) {
return (
<Modal opened={opened} onClose={onClose} title="Receive at warehouse" centered size="80rem">
<Stack gap="md">
<WarehouseFlowWorkbench enabled={opened} direction="BOTH" onChanged={onReceived} />
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={onClose}>

View File

@@ -148,12 +148,13 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
};
return (
<Modal opened={opened} onClose={onClose} title="Exit inspection and release paper" centered size="lg">
<Modal opened={opened} onClose={onClose} title="Customer truck arrival and exit weighing" centered size="lg">
<Stack gap="md">
<Alert icon={<Info size={16} />} color="orange" variant="light">
<Text size="sm">
Save the exit inspection before generating the exit paper. Gate clearance is blocked when
recorded net weight does not equal gross weight minus tare weight.
Register the customer truck and driver at arrival, record tare weight, then record gross
weight at exit after loading. Gate clearance is blocked when recorded net weight does not
equal gross weight minus tare weight.
</Text>
</Alert>
<TextInput
@@ -224,7 +225,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
Cancel
</Button>
<Button color="orange" onClick={handleSubmit} loading={releaseMutation.isPending || downloading}>
Exit Inspection & View Exit Paper
Save Truck Arrival & View Exit Paper
</Button>
</Group>
</Stack>

View File

@@ -19,6 +19,7 @@ interface WarehouseInventoryTableProps {
onInspect?: (item: WarehouseInventoryItem) => void;
onFeePreview?: (item: WarehouseInventoryItem) => void;
onReleaseDocument?: (item: WarehouseInventoryItem) => void;
onHandoverDocument?: (item: WarehouseInventoryItem) => void;
onLastMile?: (item: WarehouseInventoryItem) => void;
selectedIds?: Set<string>;
onToggleSelect?: (id: string) => void;
@@ -55,6 +56,7 @@ export function WarehouseInventoryTable({
onInspect,
onFeePreview,
onReleaseDocument,
onHandoverDocument,
onLastMile,
selectedIds,
onToggleSelect,
@@ -105,6 +107,10 @@ export function WarehouseInventoryTable({
const kind = itemKind(item);
const busy = busyId === item.id;
const nextAction = getNextInventoryAction(item);
const canGenerateHandover =
item.inspectionStatus === 'PASSED' &&
Boolean(item.bookingId) &&
(!item.booking?.tradeDirection || item.booking.tradeDirection === 'IMPORT');
return (
<Table.Tr key={item.id}>
@@ -164,7 +170,7 @@ export function WarehouseInventoryTable({
loading={busy}
onClick={() => onAdvance(item, nextAction)}
>
{nextAction === 'release' ? 'Exit Inspection' : humanizeEnum(nextAction.replace(/-/g, '_'))}
{nextAction === 'release' ? 'Truck Arrival' : humanizeEnum(nextAction.replace(/-/g, '_'))}
</Button>
)}
{item.status === 'READY_FOR_PICKUP' && (
@@ -217,6 +223,13 @@ export function WarehouseInventoryTable({
</ActionIcon>
</Tooltip>
)}
{onHandoverDocument && canGenerateHandover && (
<Tooltip label="Generate customer handover document" withArrow>
<ActionIcon variant="subtle" color="teal" onClick={() => onHandoverDocument(item)}>
<FileText size={16} />
</ActionIcon>
</Tooltip>
)}
{onLastMile && item.booking?.lastMileDeliveryAddress && (
<Tooltip label="Last mile delivery" withArrow>
<ActionIcon variant="subtle" color="blue" onClick={() => onLastMile(item)}>

View File

@@ -9,7 +9,7 @@ export { WarehouseInquiryTable } from './WarehouseInquiryTable';
export { CreateWarehouseModal } from './CreateWarehouseModal';
export { CreateYardModal } from './CreateYardModal';
export { CreateZoneModal } from './CreateZoneModal';
export { ReceiveInventoryModal } from './ReceiveInventoryModal';
export { ReceiveInventoryModal, WarehouseFlowWorkbench } from './ReceiveInventoryModal';
export { WarehouseInfoCard } from './WarehouseInfoCard';
export { MoveInventoryModal } from './MoveInventoryModal';
export { ReserveInventoryModal } from './ReserveInventoryModal';

View File

@@ -1,4 +1,8 @@
import type { WarehouseFeeInvoice, WarehouseInventoryItem } from '@/types/warehouse';
import type {
ImportUnloadedItem,
WarehouseFeeInvoice,
WarehouseInventoryItem,
} from '@/types/warehouse';
import type { BookingDetail } from '@/types/booking';
type PdfLine = {
@@ -19,6 +23,11 @@ export interface WarehouseExitPaperContext {
releasedAt?: Date;
}
export interface WarehouseHandoverPdfContext {
item: ImportUnloadedItem | WarehouseInventoryItem;
handedOverAt?: Date;
}
const escapePdfText = (value: string) =>
value.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
@@ -214,6 +223,67 @@ const containerSummary = (booking?: BookingDetail | null, inventory?: WarehouseI
.join(', ');
};
const shortId = (value: unknown) => {
const text = firstText(value);
return text === '-' ? '-' : text.slice(0, 8);
};
const compactWeight = (value: unknown) => {
const num = Number(value ?? 0);
if (!Number.isFinite(num) || num <= 0) return '-';
return num.toLocaleString(undefined, { maximumFractionDigits: 3 });
};
const handoverValue = (item: ImportUnloadedItem | WarehouseInventoryItem, key: string) =>
(item as unknown as Record<string, unknown>)[key];
const handoverBookingValue = (item: ImportUnloadedItem | WarehouseInventoryItem, key: string) =>
((item as WarehouseInventoryItem).booking as unknown as Record<string, unknown> | null | undefined)?.[key];
export function buildWarehouseHandoverPdf({ item, handedOverAt = new Date() }: WarehouseHandoverPdfContext) {
const bookingReference = firstText(
handoverValue(item, 'bookingReference'),
handoverBookingValue(item, 'reference'),
item.bookingId,
);
const customerName = firstText(
handoverValue(item, 'customerName'),
handoverBookingValue(item, 'customerName'),
handoverBookingValue(item, 'companyName'),
);
const containerNumber = firstText(handoverValue(item, 'containerNumber'));
const cargoType = firstText(handoverValue(item, 'cargoType'), handoverValue(item, 'cargoDescription'));
const currentStatus = firstText(handoverValue(item, 'currentStatus'), (item as WarehouseInventoryItem).status);
const handoverReference = `HND-${bookingReference.replace(/[^a-zA-Z0-9]+/g, '-')}`;
const goodsSummary = firstText(containerNumber, cargoType, (item as WarehouseInventoryItem).goodsId);
return buildSimplePdf([
{ text: 'Ethio-Djibouti Railway S.C.', size: 12, bold: true, yGap: 0, align: 'center' },
{ text: 'Import Goods Handover Document', size: 23, bold: true, yGap: 28, align: 'center' },
{ text: '[ EDR TO CUSTOMER ]', size: 14, bold: true, color: 'green', yGap: 24, align: 'center' },
{ text: `Document No: ${handoverReference}`, bold: true, yGap: 30, align: 'center' },
{ text: `Handover Date & Time: ${fmtDate(handedOverAt)}`, align: 'center' },
{ text: `From: Ethio-Djibouti Railway S.C.`, align: 'center' },
{ text: `To Customer: ${customerName}`, align: 'center' },
{ text: `Customer ID: ${shortId(handoverValue(item, 'customerId'))}`, align: 'center' },
{ text: `Booking Reference: ${bookingReference}`, align: 'center' },
{ text: `Booking ID: ${shortId(item.bookingId)}`, align: 'center' },
{ text: `Train Schedule: ${firstText(handoverValue(item, 'trainSchedule'))}`, align: 'center' },
{ text: `Arrival Time: ${fmtDate(handoverValue(item, 'arrivalTime') ?? (item as WarehouseInventoryItem).arrivedAt)}`, align: 'center' },
{ text: `Release Order: ${firstText((item as WarehouseInventoryItem).releaseOrderReference)}`, align: 'center' },
{ text: `Release Date: ${fmtDate((item as WarehouseInventoryItem).releaseDate)}`, align: 'center' },
{ text: 'GOODS LIST', size: 13, bold: true, yGap: 30, align: 'center' },
{ text: `1. ${goodsSummary}`, bold: true, align: 'center' },
{ text: `Container: ${containerNumber} Cargo: ${cargoType}`, align: 'center' },
{ text: `Weight: ${compactWeight(item.weight)} Status: ${currentStatus}`, align: 'center' },
{ text: `Inspection: ${firstText(item.inspectionStatus)} Pickup Option: ${firstText(handoverValue(item, 'pickupOption'), 'TERMINAL_PICKUP')}`, align: 'center' },
{ text: `Warehouse: ${firstText((item as WarehouseInventoryItem).warehouse?.name, (item as WarehouseInventoryItem).warehouse?.code)} Yard: ${firstText((item as WarehouseInventoryItem).yard?.name, (item as WarehouseInventoryItem).yard?.code)}`, align: 'center' },
{ text: 'This document confirms EDR handed over the listed import goods to the customer after warehouse inspection.', yGap: 30, align: 'center' },
], [
...buildWarehouseOfficerSealBand(),
]);
}
export function buildWarehouseExitPaperPdf(input: WarehouseFeeInvoice | WarehouseExitPaperContext, releasedItemArg?: WarehouseInventoryItem) {
const context: WarehouseExitPaperContext =
'invoice' in input ? input : { invoice: input, releasedItem: releasedItemArg };

View File

@@ -389,6 +389,7 @@ export const URL_CONSTANTS = {
MARK_READY_PICKUP: (id: string) => `/warehouse-inventory/${id}/ready-for-pickup`,
RELEASE: (id: string) => `/warehouse-inventory/${id}/release`,
RELEASE_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/release-document`,
HANDOVER_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/handover-document`,
DELIVER: (id: string) => `/warehouse-inventory/${id}/deliver`,
// Receive (Import/Export bulk)
ELIGIBLE_BOOKINGS: (direction?: string) =>

View File

@@ -180,8 +180,10 @@ export default function NewBookingPage() {
const queryClient = useQueryClient();
const [isGovernment, setIsGovernment] = useState(false);
const [governmentInstitution, setGovernmentInstitution] = useState("");
const [companyId, setCompanyId] = useState<string | null>(null);
// Government bookings bill to a real government company + an explicit profile.
const [govCompanyId, setGovCompanyId] = useState<string | null>(null);
const [govProfileId, setGovProfileId] = useState<string | null>(null);
const [freightType, setFreightType] = useState<FreightType>("CONTAINER");
const [originYardId, setOriginYardId] = useState<string | null>(null);
const [destinationYardId, setDestinationYardId] = useState<string | null>(null);
@@ -220,6 +222,42 @@ export default function NewBookingPage() {
label: c.name || c.email || c.tin || c.id,
}));
// Active government companies (kind=government) the booking can bill to.
const { data: govCompaniesPage, isLoading: govCompaniesLoading } = useQuery({
queryKey: ["companies", "government", "active"],
queryFn: () =>
customersService.list({
page: 1,
pageSize: 1000,
kind: "government",
status: "active",
}),
enabled: isGovernment,
});
const govCompanies = govCompaniesPage?.items ?? [];
const govCompanyOptions = govCompanies.map((c) => ({
value: c.id,
label: c.name || c.tin || c.id,
}));
// Profiles (importer/exporter) of the chosen government company — the booking
// must link to one explicitly.
const selectedGovCompany = govCompanies.find((c) => c.id === govCompanyId);
const govProfileOptions = (selectedGovCompany?.companyProfiles ?? [])
.filter((p) => p.status === "active")
.map((p) => ({
value: p.id,
label: `${p.type === "importer" ? "Import" : p.type === "exporter" ? "Export" : p.type}${
p.reference ? `${p.reference}` : ""
}`,
}));
// Reset the chosen profile when the government company changes.
useEffect(() => {
setGovProfileId(null);
}, [govCompanyId]);
// Day-level pool: fetch only the days that have a departure on the route (no
// train, no capacity). The batch engine assigns the train after booking.
const { data: availableDays, isLoading: daysLoading } = useQuery(
@@ -306,7 +344,7 @@ export default function NewBookingPage() {
Boolean(tradeDirection) &&
Boolean(serviceTypeId) &&
departureSatisfied &&
(isGovernment ? governmentInstitution.trim().length >= 2 : Boolean(companyId)) &&
(isGovernment ? Boolean(govCompanyId && govProfileId) : Boolean(companyId)) &&
(freightType === "BULK"
? Boolean(cargoTypeId) && bulkWeight > 0
: allLinesValid);
@@ -320,8 +358,8 @@ export default function NewBookingPage() {
mutationFn: () =>
bookingsService.create({
isGovernment,
governmentInstitution: isGovernment ? governmentInstitution : undefined,
companyId: isGovernment ? undefined : companyId || undefined,
companyId: isGovernment ? govCompanyId || undefined : companyId || undefined,
companyProfileId: isGovernment ? govProfileId || undefined : undefined,
freightType,
contractType: "NEW",
equipmentReturn,
@@ -390,18 +428,37 @@ export default function NewBookingPage() {
<Stack gap="md">
<Switch
label="Government booking"
description="No company required — institution name instead. Expedited to the scheduling queue."
description="Bills to a government entity + profile. Expedited to the scheduling queue."
checked={isGovernment}
onChange={(e) => setIsGovernment(e.currentTarget.checked)}
/>
{isGovernment ? (
<TextInput
label="Government institution"
placeholder="e.g. Ministry of Transport"
value={governmentInstitution}
onChange={(e) => setGovernmentInstitution(e.currentTarget.value)}
required
/>
<Group grow align="flex-start">
<Select
label="Government entity"
placeholder="Select government company"
data={govCompanyOptions}
value={govCompanyId}
onChange={setGovCompanyId}
searchable
required
disabled={govCompaniesLoading}
nothingFoundMessage="No active government companies"
/>
<Select
label="Profile"
placeholder={
govCompanyId ? "Select import/export profile" : "Pick an entity first"
}
data={govProfileOptions}
value={govProfileId}
onChange={setGovProfileId}
searchable
required
disabled={!govCompanyId}
nothingFoundMessage="No active profiles for this entity"
/>
</Group>
) : (
<Select
label="Customer"

View File

@@ -6,6 +6,7 @@ import {
MoreHorizontal,
Printer,
RefreshCw,
Ruler,
Truck,
} from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -42,6 +43,7 @@ import {
} from "@/services/first-mile.service";
import { bookingsService } from "@/services/bookings.service";
import { vehiclesService } from "@/services/vehicles.service";
import { ratesService } from "@/services/rates.service";
import type { BookingDetail } from "@/types/booking";
const formatPrice = (amount: number) =>
@@ -312,6 +314,7 @@ const FirstMilePage = () => {
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("ALL");
const [filterPostPaymentPending, setFilterPostPaymentPending] = useState(false);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const [assignOpen, setAssignOpen] = useState(false);
@@ -330,6 +333,8 @@ const FirstMilePage = () => {
const [distanceOpen, setDistanceOpen] = useState(false);
const [distanceValue, setDistanceValue] = useState("");
const [invoiceOpen, setInvoiceOpen] = useState(false);
const [invoiceRecord, setInvoiceRecord] = useState<FirstMileRecord | null>(null);
const { data: listData, isLoading } = useQuery({
queryKey: QUERY_KEYS.FIRST_MILE.list(),
@@ -347,6 +352,14 @@ const FirstMilePage = () => {
},
});
const { data: ratesData } = useQuery({
queryKey: ["rates", "FIRST_MILE"],
queryFn: async () => {
const res = await ratesService.getByType("FIRST_MILE");
return res.data;
},
});
const { data: paidBookingsData, isLoading: bookingsLoading } = useQuery({
queryKey: QUERY_KEYS.BOOKINGS.list({ status: "PAID" }),
queryFn: () => bookingsService.list({ status: "PAID", pageSize: 100 }),
@@ -376,7 +389,7 @@ const FirstMilePage = () => {
mutationFn: ({ id, data }: { id: string; data: { status?: FirstMileApiStatus; vehicleId?: string | null } }) =>
firstMileService.update(id, data),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.ROOT });
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.list() });
},
onError: () => {
toast({ title: "Update failed", variant: "destructive" });
@@ -384,10 +397,10 @@ const FirstMilePage = () => {
});
const updateDistanceMutation = useMutation({
mutationFn: ({ id, exactKm }: { id: string; exactKm: number }) =>
firstMileService.update(id, { exactKm }),
mutationFn: ({ id, exactKm, remainingPayment }: { id: string; exactKm: number; remainingPayment?: number }) =>
firstMileService.update(id, { exactKm, ...(remainingPayment != null && { remainingPayment }) }),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.ROOT });
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.list() });
if (activeRecord) {
toast({ title: "Distance updated", description: `${bookingRef(activeRecord)}${distanceValue} km` });
}
@@ -485,16 +498,39 @@ const FirstMilePage = () => {
setDistanceValue("");
};
const openInvoice = (record: FirstMileRecord) => {
setInvoiceRecord(record);
setInvoiceOpen(true);
};
const closeInvoice = () => {
setInvoiceOpen(false);
setInvoiceRecord(null);
};
const handleSaveDistance = () => {
const distance = parseFloat(distanceValue);
if (!activeId || isNaN(distance) || distance < 0) {
toast({ title: "Invalid distance", description: "Enter a valid distance value.", variant: "destructive" });
return;
}
updateDistanceMutation.mutate({ id: activeId, exactKm: distance });
let remainingPayment: number | undefined;
if (ratesData?.data) {
const firstMileRate = ratesData.data.find(
(r) => r.rateType === "FIRST_MILE" && (r.status === "LIVE" || r.status === "DRAFT")
);
if (firstMileRate) {
const rateValue = parseFloat(firstMileRate.rateValue);
remainingPayment = distance * rateValue;
}
}
updateDistanceMutation.mutate({ id: activeId, exactKm: distance, remainingPayment });
};
const matchesFilter = (r: FirstMileRecord) => {
if (filterPostPaymentPending && r.isPostPaymentCompleted) return false;
switch (statusFilter) {
case "ALL": return true;
case "ASSIGNED": return isAssigned(r);
@@ -532,7 +568,7 @@ const FirstMilePage = () => {
.includes(term);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [records, search, statusFilter]);
}, [records, search, statusFilter, filterPostPaymentPending]);
const pageCount = Math.max(1, Math.ceil(filteredRecords.length / pagination.pageSize));
const pagedRecords = useMemo(() => {
@@ -701,6 +737,27 @@ const FirstMilePage = () => {
meta: { headerClassName, cellClassName },
cell: ({ row }) => row.original.exactKm != null ? row.original.exactKm : <Text c="dimmed"></Text>,
},
{
id: "invoice",
header: "Invoice",
meta: { headerClassName, cellClassName },
cell: ({ row }) => {
const hasDistance = row.original.exactKm != null && row.original.exactKm > 0;
if (!hasDistance) {
return <Text c="dimmed"></Text>;
}
return (
<UnstyledButton
onClick={() => openInvoice(row.original)}
c="blue"
fw={500}
style={{ textDecoration: "underline", cursor: "pointer" }}
>
#345
</UnstyledButton>
);
},
},
{
id: "status",
header: "Status",
@@ -767,9 +824,10 @@ const FirstMilePage = () => {
View detail
</Menu.Item>
<Menu.Item
leftSection={<Ruler size={15} />}
onClick={() => openDistance(row.original.id)}
>
Add Actual distance
Add distance
</Menu.Item>
{canPrint && (
<Menu.Item
@@ -831,6 +889,17 @@ const FirstMilePage = () => {
</Button>
);
})}
<Button
size="xs"
variant={filterPostPaymentPending ? "filled" : "default"}
styles={{ label: { fontWeight: 500 } }}
onClick={() => {
setFilterPostPaymentPending(!filterPostPaymentPending);
setPagination((p) => ({ ...p, pageIndex: 0 }));
}}
>
Post Payment Pending
</Button>
</Group>
</Stack>
</Box>
@@ -1122,6 +1191,87 @@ const FirstMilePage = () => {
</Group>
</Stack>
</Modal>
{/* Invoice modal */}
<Modal
opened={invoiceOpen}
onClose={closeInvoice}
title={<Text fw={600}>Invoice #345</Text>}
size="lg"
radius="lg"
centered
>
<Stack gap="md">
{invoiceRecord && (
<>
<Card withBorder padding="md" radius="md" bg="var(--mantine-color-gray-0)">
<Stack gap="sm">
<Group justify="space-between">
<Text fw={700}>EDR Freight</Text>
<Text fw={600} size="sm">Invoice #345</Text>
</Group>
<Divider />
<SimpleGrid cols={2} spacing="sm">
<InfoRow label="Booking" value={bookingRef(invoiceRecord)} />
<InfoRow label="Customer" value={customerName(invoiceRecord)} />
<InfoRow label="Pickup" value={pickupLocation(invoiceRecord)} />
<InfoRow label="Destination" value={destinationYardName(invoiceRecord)} />
<InfoRow label="Est. Distance" value={invoiceRecord.estimatedKm ? `${invoiceRecord.estimatedKm} km` : "—"} />
<InfoRow label="Actual Distance" value={invoiceRecord.exactKm ? `${invoiceRecord.exactKm} km` : "—"} />
<InfoRow label="Advanced Payment" value={formatPrice(invoiceRecord.advancedPayment)} />
<InfoRow label="Post Payment" value={formatPrice(invoiceRecord.remainingPayment)} />
</SimpleGrid>
<Divider />
<Group justify="flex-end">
<Stack gap={0} align="flex-end" style={{ minWidth: 200 }}>
<Group justify="space-between" w="100%">
<Text size="sm" c="dimmed">Post Payment</Text>
<Text size="sm">{formatPrice(invoiceRecord.remainingPayment)}</Text>
</Group>
<Group justify="space-between" w="100%">
<Text size="sm" c="dimmed">Advanced Payment</Text>
<Text size="sm">{formatPrice(invoiceRecord.advancedPayment)}</Text>
</Group>
<Divider my="xs" />
{(() => {
const postPayment = parseFloat(String(invoiceRecord.remainingPayment));
const advancedPayment = parseFloat(String(invoiceRecord.advancedPayment));
const difference = postPayment - advancedPayment;
if (difference > 0) {
return (
<Group justify="space-between" w="100%">
<Text size="sm" fw={600}>Remaining to Pay</Text>
<Text fw={700} c="orange">{formatPrice(difference)}</Text>
</Group>
);
} else if (difference < 0) {
return (
<Group justify="space-between" w="100%">
<Text size="sm" fw={600}>Refund</Text>
<Text fw={700} c="green">{formatPrice(Math.abs(difference))}</Text>
</Group>
);
} else {
return (
<Group justify="space-between" w="100%">
<Text size="sm" fw={600}>Status</Text>
<Text fw={700} c="blue">Settled</Text>
</Group>
);
}
})()}
</Stack>
</Group>
</Stack>
</Card>
</>
)}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={closeInvoice}>Close</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
};

View File

@@ -5,6 +5,7 @@ import {
MoreHorizontal,
Printer,
RefreshCw,
Ruler,
Truck,
} from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -21,12 +22,14 @@ import {
Group,
Menu,
Modal,
NumberInput,
ScrollArea,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
UnstyledButton,
} from "@mantine/core";
import type { ArrivalQueueItem } from "@/types/warehouse";
import { warehouseService } from "@/services/warehouse.service";
@@ -41,6 +44,7 @@ import {
lastMileService,
} from "@/services/last-mile.service";
import { vehiclesService } from "@/services/vehicles.service";
import { ratesService } from "@/services/rates.service";
const formatPrice = (amount: number) =>
`ETB ${amount.toLocaleString("en-US", {
@@ -294,6 +298,7 @@ const LastMilePage = () => {
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("ALL");
const [filterPostPaymentPending, setFilterPostPaymentPending] = useState(false);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const [assignOpen, setAssignOpen] = useState(false);
@@ -311,6 +316,11 @@ const LastMilePage = () => {
const [acceptVehicleValue, setAcceptVehicleValue] = useState<string | null>(null);
const [arrivalSearch, setArrivalSearch] = useState("");
const [distanceOpen, setDistanceOpen] = useState(false);
const [distanceValue, setDistanceValue] = useState("");
const [invoiceOpen, setInvoiceOpen] = useState(false);
const [invoiceRecord, setInvoiceRecord] = useState<LastMileRecord | null>(null);
const { data: listData, isLoading } = useQuery({
queryKey: QUERY_KEYS.LAST_MILE.list(),
queryFn: async () => {
@@ -327,6 +337,14 @@ const LastMilePage = () => {
},
});
const { data: ratesData } = useQuery({
queryKey: ["rates", "LAST_MILE"],
queryFn: async () => {
const res = await ratesService.getByType("LAST_MILE");
return res.data;
},
});
const records = listData?.data ?? [];
const vehicleOptions = useMemo(
@@ -352,6 +370,21 @@ const LastMilePage = () => {
},
});
const updateDistanceMutation = useMutation({
mutationFn: ({ id, exactKm, remainingPayment }: { id: string; exactKm: number; remainingPayment?: number }) =>
lastMileService.update(id, { exactKm, ...(remainingPayment != null && { remainingPayment }) }),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.list() });
if (activeRecord) {
toast({ title: "Distance updated", description: `${bookingRef(activeRecord)}${distanceValue} km` });
}
closeDistance();
},
onError: () => {
toast({ title: "Update failed", variant: "destructive" });
},
});
const { data: arrivalQueueData, isLoading: arrivalLoading } = useQuery({
queryKey: ["warehouse-inventory", "arrival-queue"],
queryFn: () => warehouseService.arrivalQueue().then((r) => r.data),
@@ -422,6 +455,49 @@ const LastMilePage = () => {
acceptMutation.mutate({ items: selectedArrivalItems, vehicleId: acceptVehicleValue });
};
const openDistance = (id: string) => {
setActiveId(id);
setDistanceValue("");
setDistanceOpen(true);
};
const closeDistance = () => {
setDistanceOpen(false);
setActiveId(null);
setDistanceValue("");
};
const openInvoice = (record: LastMileRecord) => {
setInvoiceRecord(record);
setInvoiceOpen(true);
};
const closeInvoice = () => {
setInvoiceOpen(false);
setInvoiceRecord(null);
};
const handleSaveDistance = () => {
const distance = parseFloat(distanceValue);
if (!activeId || isNaN(distance) || distance < 0) {
toast({ title: "Invalid distance", description: "Enter a valid distance value.", variant: "destructive" });
return;
}
let remainingPayment: number | undefined;
if (ratesData?.data) {
const lastMileRate = ratesData.data.find(
(r) => r.rateType === "LAST_MILE" && (r.status === "LIVE" || r.status === "DRAFT")
);
if (lastMileRate) {
const rateValue = parseFloat(lastMileRate.rateValue);
remainingPayment = distance * rateValue;
}
}
updateDistanceMutation.mutate({ id: activeId, exactKm: distance, remainingPayment });
};
const activeRecord = useMemo(
() => records.find((r) => r.id === activeId) ?? null,
[records, activeId],
@@ -433,6 +509,7 @@ const LastMilePage = () => {
);
const matchesFilter = (r: LastMileRecord) => {
if (filterPostPaymentPending && r.isPostPaymentCompleted) return false;
switch (statusFilter) {
case "ALL": return true;
case "ASSIGNED": return isAssigned(r);
@@ -470,7 +547,7 @@ const LastMilePage = () => {
.includes(term);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [records, search, statusFilter]);
}, [records, search, statusFilter, filterPostPaymentPending]);
const pageCount = Math.max(1, Math.ceil(filteredRecords.length / pagination.pageSize));
const pagedRecords = useMemo(() => {
@@ -639,6 +716,27 @@ const LastMilePage = () => {
meta: { headerClassName, cellClassName },
cell: ({ row }) => row.original.exactKm != null ? row.original.exactKm : <Text c="dimmed"></Text>,
},
{
id: "invoice",
header: "Invoice",
meta: { headerClassName, cellClassName },
cell: ({ row }) => {
const hasDistance = row.original.exactKm != null && row.original.exactKm > 0;
if (!hasDistance) {
return <Text c="dimmed"></Text>;
}
return (
<UnstyledButton
onClick={() => openInvoice(row.original)}
c="blue"
fw={500}
style={{ textDecoration: "underline", cursor: "pointer" }}
>
#345
</UnstyledButton>
);
},
},
{
id: "status",
header: "Status",
@@ -704,6 +802,12 @@ const LastMilePage = () => {
>
View detail
</Menu.Item>
<Menu.Item
leftSection={<Ruler size={15} />}
onClick={() => openDistance(row.original.id)}
>
Add distance
</Menu.Item>
{canPrint && (
<Menu.Item
leftSection={<Printer size={15} />}
@@ -764,6 +868,17 @@ const LastMilePage = () => {
</Button>
);
})}
<Button
size="xs"
variant={filterPostPaymentPending ? "filled" : "default"}
styles={{ label: { fontWeight: 500 } }}
onClick={() => {
setFilterPostPaymentPending(!filterPostPaymentPending);
setPagination((p) => ({ ...p, pageIndex: 0 }));
}}
>
Post Payment Pending
</Button>
</Group>
</Stack>
</Box>
@@ -1000,6 +1115,134 @@ const LastMilePage = () => {
</Group>
</Stack>
</Modal>
{/* Add Actual Distance modal */}
<Modal
opened={distanceOpen}
onClose={closeDistance}
title={<Text fw={600}>Add Actual Distance</Text>}
size="md"
radius="lg"
centered
>
<Stack gap="md">
{activeRecord && (
<Card withBorder padding="md" radius="md" bg="var(--mantine-color-gray-0)">
<Stack gap="sm">
<Text fw={600} size="sm">{bookingRef(activeRecord)}</Text>
<Group justify="space-between">
<Text size="xs" c="dimmed">Customer</Text>
<Text size="sm">{customerName(activeRecord)}</Text>
</Group>
<Group justify="space-between">
<Text size="xs" c="dimmed">Est. Distance (KM)</Text>
<Text size="sm">{activeRecord.estimatedKm ?? "—"}</Text>
</Group>
</Stack>
</Card>
)}
<NumberInput
label="Actual Distance (KM)"
placeholder="Enter distance"
value={distanceValue}
onChange={(v) => setDistanceValue(String(v ?? ""))}
min={0}
step={0.1}
decimalScale={2}
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={closeDistance}>Cancel</Button>
<Button
onClick={handleSaveDistance}
loading={updateDistanceMutation.isPending}
disabled={!distanceValue}
>
Save
</Button>
</Group>
</Stack>
</Modal>
{/* Invoice modal */}
<Modal
opened={invoiceOpen}
onClose={closeInvoice}
title={<Text fw={600}>Invoice #345</Text>}
size="lg"
radius="lg"
centered
>
<Stack gap="md">
{invoiceRecord && (
<>
<Card withBorder padding="md" radius="md" bg="var(--mantine-color-gray-0)">
<Stack gap="sm">
<Group justify="space-between">
<Text fw={700}>EDR Freight</Text>
<Text fw={600} size="sm">Invoice #345</Text>
</Group>
<Divider />
<SimpleGrid cols={2} spacing="sm">
<InfoRow label="Booking" value={bookingRef(invoiceRecord)} />
<InfoRow label="Customer" value={customerName(invoiceRecord)} />
<InfoRow label="Pickup" value={originYardName(invoiceRecord)} />
<InfoRow label="Destination" value={deliveryLocation(invoiceRecord)} />
<InfoRow label="Est. Distance" value={invoiceRecord.estimatedKm ? `${invoiceRecord.estimatedKm} km` : "—"} />
<InfoRow label="Actual Distance" value={invoiceRecord.exactKm ? `${invoiceRecord.exactKm} km` : "—"} />
<InfoRow label="Advanced Payment" value={formatPrice(invoiceRecord.advancedPayment)} />
<InfoRow label="Post Payment" value={formatPrice(invoiceRecord.remainingPayment)} />
</SimpleGrid>
<Divider />
<Group justify="flex-end">
<Stack gap={0} align="flex-end" style={{ minWidth: 200 }}>
<Group justify="space-between" w="100%">
<Text size="sm" c="dimmed">Post Payment</Text>
<Text size="sm">{formatPrice(invoiceRecord.remainingPayment)}</Text>
</Group>
<Group justify="space-between" w="100%">
<Text size="sm" c="dimmed">Advanced Payment</Text>
<Text size="sm">{formatPrice(invoiceRecord.advancedPayment)}</Text>
</Group>
<Divider my="xs" />
{(() => {
const postPayment = parseFloat(String(invoiceRecord.remainingPayment));
const advancedPayment = parseFloat(String(invoiceRecord.advancedPayment));
const difference = postPayment - advancedPayment;
if (difference > 0) {
return (
<Group justify="space-between" w="100%">
<Text size="sm" fw={600}>Remaining to Pay</Text>
<Text fw={700} c="orange">{formatPrice(difference)}</Text>
</Group>
);
} else if (difference < 0) {
return (
<Group justify="space-between" w="100%">
<Text size="sm" fw={600}>Refund</Text>
<Text fw={700} c="green">{formatPrice(Math.abs(difference))}</Text>
</Group>
);
} else {
return (
<Group justify="space-between" w="100%">
<Text size="sm" fw={600}>Status</Text>
<Text fw={700} c="blue">Settled</Text>
</Group>
);
}
})()}
</Stack>
</Group>
</Stack>
</Card>
</>
)}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={closeInvoice}>Close</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
};

View File

@@ -296,9 +296,15 @@ export default function TrainScheduleV2DetailPage() {
const canDispatch = schedule.status === "SCHEDULED";
const finalizeStep = hasContainerStep ? 3 : 2;
const canModifyBookings = canEditBookings && !["DISPATCHED", "ARRIVED"].includes(schedule.status);
const canPrintMarshalling = schedule.direction === "IMPORT" || schedule.direction === "EXPORT";
const canPrintMarshalling =
(schedule.direction === "IMPORT" || schedule.direction === "EXPORT") &&
["DISPATCHED", "ARRIVED"].includes(schedule.status);
const openMarshallingDocument = async () => {
const openMarshallingDocument = async (options?: {
title?: string;
successDescription?: string;
errorTitle?: string;
}) => {
const pdfWindow = window.open("", "_blank");
try {
const blob = await downloadMarshalling.mutateAsync({
@@ -309,15 +315,17 @@ export default function TrainScheduleV2DetailPage() {
const filename = `${prefix}-${schedule.trainNumber ?? scheduleId}.pdf`;
const opened = openPdfBlob(blob, filename, pdfWindow);
toast({
title: "Marshalling document ready",
description: opened
? "The PDF opened in a browser tab for printing or saving."
: "The browser blocked the preview tab, so the PDF was downloaded.",
title: options?.title ?? "Marshalling document ready",
description:
options?.successDescription ??
(opened
? "The PDF opened in a browser tab for printing or saving."
: "The browser blocked the preview tab, so the PDF was downloaded."),
});
} catch (error) {
pdfWindow?.close();
toast({
title: "Could not open marshalling document",
title: options?.errorTitle ?? "Could not open marshalling document",
description: parseError(error, "Make sure the train has wagon allocations, then try again."),
variant: "destructive",
});
@@ -708,7 +716,11 @@ export default function TrainScheduleV2DetailPage() {
onClick={async () => {
try {
await dispatch.mutateAsync(scheduleId);
toast({ title: "Train dispatched" });
await openMarshallingDocument({
title: "Train dispatched",
successDescription: "Marshalling document generated for the dispatched train.",
errorTitle: "Train dispatched, but document could not open",
});
} catch (err) {
toast({
title: "Dispatch failed",

View File

@@ -231,8 +231,8 @@ export default function ArrivalQueuePage() {
<Table.Td ta="center">{train.totalCargoes}</Table.Td>
<Table.Td>
<Stack gap={2}>
<Badge variant="light" color={fullyUnloaded ? 'green' : 'teal'} size="sm">
{fullyUnloaded ? 'UNLOADED' : train.status}
<Badge variant="light" color="teal" size="sm">
{train.status}
</Badge>
<Text size="xs" c="dimmed">
{Math.max(unloadedBookings, 0)}/{train.totalBookings} unloaded

View File

@@ -66,14 +66,14 @@ const statusLabel = (status?: string | null) =>
: (status ?? 'PENDING').replace(/_/g, ' ');
function ExportTrainDetailRows({
scheduleId,
train,
onOpenHistory,
}: {
scheduleId: string;
train: ExportTrain;
onOpenHistory: (inventoryId: string) => void;
}) {
const navigate = useNavigate();
const { data: items = [], isLoading } = useExportDjiboutiTrainItems(scheduleId);
const { data: items = [], isLoading } = useExportDjiboutiTrainItems(train.scheduleId);
if (isLoading) {
return (
@@ -92,10 +92,11 @@ function ExportTrainDetailRows({
}
return (
<Table.ScrollContainer minWidth={1320}>
<Table.ScrollContainer minWidth={1420}>
<Table highlightOnHover verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Wagon</Table.Th>
<Table.Th>Booking ID</Table.Th>
<Table.Th>Booking Reference</Table.Th>
<Table.Th>Customer ID</Table.Th>
@@ -115,6 +116,11 @@ function ExportTrainDetailRows({
<Table.Tbody>
{items.map((item: ExportTrainItem) => (
<Table.Tr key={`${item.bookingId}-${item.itemType}-${item.itemId ?? item.inventoryId ?? 'item'}`}>
<Table.Td>
<Text size="xs" fw={600}>
{item.sequenceNo ? `#${item.sequenceNo}` : '-'} {item.wagonNumber ?? ''}
</Text>
</Table.Td>
<Table.Td>{item.bookingId.slice(0, 8)}</Table.Td>
<Table.Td>
<Text size="sm" fw={600}>
@@ -384,7 +390,7 @@ export default function ExportDjiboutiUnloadingQueuePage() {
<Table.Tr>
<Table.Td colSpan={12} bg="var(--mantine-color-gray-0)">
<ExportTrainDetailRows
scheduleId={train.scheduleId}
train={train}
onOpenHistory={setHistoryInventoryId}
/>
</Table.Td>

View File

@@ -0,0 +1,28 @@
import { Button, Card } from '@mantine/core';
import { PackageSearch } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { PageContainer, PageHeader } from '@/components/page';
import { WarehouseFlowWorkbench } from '@/components/warehouses';
export default function ExportWarehouseFlowPage() {
const navigate = useNavigate();
return (
<PageContainer>
<PageHeader
title="Export Operations"
subtitle="Manage export receive, terminal inventory, loading readiness, loaded items, and dispatch flow."
action={
<Button variant="light" leftSection={<PackageSearch size={16} />} onClick={() => navigate('/dashboard/import-warehouse')}>
Import Operations
</Button>
}
/>
<Card>
<WarehouseFlowWorkbench direction="EXPORT" />
</Card>
</PageContainer>
);
}

View File

@@ -0,0 +1,28 @@
import { Button, Card } from '@mantine/core';
import { Truck } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { PageContainer, PageHeader } from '@/components/page';
import { WarehouseFlowWorkbench } from '@/components/warehouses';
export default function ImportWarehouseFlowPage() {
const navigate = useNavigate();
return (
<PageContainer>
<PageHeader
title="Import Operations"
subtitle="Manage import gate flow, marshalling handoff, arrived trains, unloaded bookings, and dispatch-ready inventory."
action={
<Button variant="light" leftSection={<Truck size={16} />} onClick={() => navigate('/dashboard/export-warehouse')}>
Export Operations
</Button>
}
/>
<Card>
<WarehouseFlowWorkbench direction="IMPORT" />
</Card>
</PageContainer>
);
}

View File

@@ -1,13 +1,12 @@
import { useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { Button, Card, Group, Select, Stack, TextInput } from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';
import { PackagePlus, Search } from 'lucide-react';
import { PackageOpen, Search, Truck } from 'lucide-react';
import { PageContainer, PageHeader } from '@/components/page';
import {
InventoryWorkbench,
ReceiveInventoryModal,
inventoryStatusOptions,
} from '@/components/warehouses';
import {
@@ -19,13 +18,13 @@ import {
import type { InventoryFilter, InventoryStatus } from '@/types/warehouse';
export default function WarehouseInventoryPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const initialStatus = (searchParams.get('status') as InventoryStatus | null) ?? undefined;
const [filter, setFilter] = useState<InventoryFilter>(
initialStatus ? { status: initialStatus } : {},
);
const [search, setSearch] = useState('');
const [modalOpen, setModalOpen] = useState(false);
const [debouncedSearch] = useDebouncedValue(search, 300);
const queryFilter = useMemo<InventoryFilter>(
@@ -57,9 +56,18 @@ export default function WarehouseInventoryPage() {
title="Warehouse Inventory"
subtitle="Track received items through the storage, reservation, loading and dispatch lifecycle."
action={
<Button leftSection={<PackagePlus size={16} />} onClick={() => setModalOpen(true)}>
Receive Inventory
</Button>
<Group gap="xs">
<Button
variant="light"
leftSection={<PackageOpen size={16} />}
onClick={() => navigate('/dashboard/import-warehouse')}
>
Import
</Button>
<Button leftSection={<Truck size={16} />} onClick={() => navigate('/dashboard/export-warehouse')}>
Export
</Button>
</Group>
}
/>
@@ -126,8 +134,6 @@ export default function WarehouseInventoryPage() {
<InventoryWorkbench items={inventoryQuery.data ?? []} isLoading={inventoryQuery.isLoading} />
</Stack>
</Card>
<ReceiveInventoryModal opened={modalOpen} onClose={() => setModalOpen(false)} />
</PageContainer>
);
}

View File

@@ -0,0 +1,35 @@
import { api } from '../auth/http';
export interface Rate {
id: string;
rateType: string;
appliesTo: string;
trigger: string;
containerTypeId: string | null;
cargoTypeId: string | null;
tradeDirection: string | null;
currency: string;
rateValue: string;
rateUnit: string;
status: string;
proposedByStaffId: string;
approvedByCeoId: string | null;
approvedAt: string | null;
effectiveFrom: string;
effectiveTo: string | null;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}
export interface RatesListResponse {
data: Rate[];
meta: { total: number; page: number; pageSize: number; totalPages: number };
}
export const ratesService = {
list: (pageSize = 1000) =>
api.get<RatesListResponse>(`/rates?pageSize=${pageSize}`),
getByType: (rateType: string) =>
api.get<RatesListResponse>(`/rates?rateType=${rateType}&pageSize=1000`),
};

View File

@@ -137,6 +137,10 @@ export const warehouseService = {
apiClient.get<Blob>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RELEASE_DOCUMENT(id), {
responseType: 'blob',
}),
downloadHandoverDocument: (id: string) =>
apiClient.get<Blob>(URL_CONSTANTS.WAREHOUSE_INVENTORY.HANDOVER_DOCUMENT(id), {
responseType: 'blob',
}),
deliver: (id: string, payload: DeliverInventoryPayload) =>
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.DELIVER(id), payload),

View File

@@ -18,6 +18,9 @@ export type CompanyType =
/** Mirrors backend `CompanyStatus`. */
export type CompanyStatus = "active" | "pending" | "suspended" | "blacklisted";
/** Mirrors backend `CompanyKind` — commercial customer vs. government entity. */
export type CompanyKind = "commercial" | "government";
/** Mirrors backend `ProfileType` (the role a company plays). */
export type ProfileType =
| "importer"
@@ -47,6 +50,7 @@ export interface Company {
id: string;
name: string;
type: CompanyType;
kind: CompanyKind;
status: CompanyStatus;
tin: string;
vatNumber?: string | null;
@@ -73,6 +77,7 @@ export interface CompanyListFilter {
pageSize: number;
search?: string;
type?: CompanyType;
kind?: CompanyKind;
status?: CompanyStatus;
}

View File

@@ -517,6 +517,9 @@ export interface ExportTrainItem {
bookingReference: string | null;
customerId: string | null;
customerName: string | null;
wagonNumber: string | null;
sequenceNo: number | null;
allocatedWeightTons: number | null;
itemType: 'CONTAINER' | 'CARGO';
itemId: string | null;
inventoryId: string | null;
@@ -566,6 +569,9 @@ export interface ImportUnloadedItem {
pickupOption: string;
lastMileRequested: boolean;
currentStatus: string;
releaseDate: string | null;
releaseOrderReference: string | null;
deliveredAt: string | null;
}
export interface ImportTrainItem {
@@ -573,6 +579,9 @@ export interface ImportTrainItem {
bookingReference: string | null;
customerId: string | null;
customerName: string | null;
wagonNumber: string | null;
sequenceNo: number | null;
allocatedWeightTons: number | null;
containerNumber: string | null;
cargoType: string | null;
weight: number | null;

View File

@@ -3,7 +3,6 @@ import { fileURLToPath } from "node:url";
import { createRequire } from "node:module";
import { defineConfig } from "vitest/config";
import { loadEnv, type Plugin } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
@@ -11,7 +10,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);
const streamBrowserifyPath = require.resolve("stream-browserify");
export default defineConfig(({ mode }) => {
export default defineConfig(() => {
return {
plugins: [react(), tailwindcss()],
resolve: {

View File

@@ -31,7 +31,8 @@ import LoginPage from "./pages/accounts/LoginPage";
import SetPasswordPage from "./pages/accounts/SetPasswordPage";
import SignupPage from "./pages/accounts/SignupPage";
import VerificationOtpPage from "./pages/accounts/VerificationOtpPage";
import BillingPage from "./pages/billing/BillingPage";
import InvoiceDetailPage from "./pages/billing/InvoiceDetailPage";
import InvoicesList from "./pages/billing/InvoicesList";
import BookingContractPage from "./pages/bookings/BookingContractPage";
import BookingDetailPage from "./pages/bookings/BookingDetailPage";
import EditBookingPage from "./pages/bookings/EditBookingPage";
@@ -188,7 +189,7 @@ const sidebarItems: SidebarItem[] = [
icon: <MapPin size={18} />,
},
{
label: "Billing",
label: "Invoices",
href: "/billing",
icon: <Receipt size={18} />,
},
@@ -285,7 +286,8 @@ const App = () => {
/>
<Route path="/contracts/:id" element={<ContractDetailPage />} />
<Route path="/tracking" element={<TrackingPage />} />
<Route path="/billing" element={<BillingPage />} />
<Route path="/billing" element={<InvoicesList />} />
<Route path="/billing/:id" element={<InvoiceDetailPage />} />
{/* Profile was merged into Settings — keep old links working. */}
<Route
path="/profile"

View File

@@ -143,4 +143,10 @@ export const URL_CONSTANTS = {
INTENT: (bookingId: string) => `/api/payments/intents/${bookingId}`,
CHECKOUT: "/api/payments/checkout",
},
BILLING: {
MY_INVOICES: "/api/billing/my-invoices",
MY_INVOICE_BY_ID: (id: string) => `/api/billing/my-invoices/${id}`,
PAY_INVOICE: (id: string) => `/api/billing/my-invoices/${id}/pay`,
},
};

View File

@@ -1,5 +1,5 @@
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
// export const API_BASE_URL = 'http://localhost:3001';
// export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
export const API_BASE_URL = 'http://localhost:3001';
/**
* URL that streams an uploaded file through the API by its UUID. Routes the

View File

@@ -0,0 +1,23 @@
/** Currency code carried on invoices / dashboard figures (ETB, USD, DJF, …). */
export type Currency = string;
const SYMBOLS: Record<string, string> = {
USD: "$",
ETB: "Br",
DJF: "DJF",
};
/**
* Format a money amount with its currency symbol, e.g. `Br 12,500.00`.
* Unknown currency codes fall back to printing the raw code.
*/
export function formatCurrency(
amount: number,
currency: Currency = "ETB",
): string {
const symbol = SYMBOLS[currency] ?? currency;
return `${symbol} ${Number(amount ?? 0).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`;
}

View File

@@ -1,20 +0,0 @@
import { invoices, type Invoice } from "@/pages/billing/invoices.mock";
import { customers, type Customer } from "@/pages/customers/customers.mock";
/**
* Mock "logged-in customer". When auth integrates, replace this with the value
* pulled from `@edr/iamui-common` / the JWT context.
*/
const CURRENT_CUSTOMER_ID = 1;
export function getCurrentCustomer(): Customer {
return (
customers.find((c) => c.id === CURRENT_CUSTOMER_ID) ??
(customers[0] as Customer)
);
}
export function getMyInvoices(): Invoice[] {
const me = getCurrentCustomer();
return invoices.filter((inv) => inv.customerId === me.id);
}

View File

@@ -1,5 +1,5 @@
import type { Currency } from "@/pages/billing/invoices.mock";
import { formatCurrency } from "@/pages/billing/invoices.mock";
import type { Currency } from "@/lib/currency";
import { formatCurrency } from "@/lib/currency";
import { Group, Grid, Select, Stack } from "@mantine/core";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
@@ -34,7 +34,6 @@ export default function MyPortalPage() {
totalOutstanding,
companyName,
greeting,
recentInvoices,
dashboard,
volumePoints,
maxVolume,
@@ -120,7 +119,7 @@ export default function MyPortalPage() {
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 4 }}>
<InvoicesSection invoices={recentInvoices} />
<InvoicesSection invoices={outstandingInvoices} />
</Grid.Col>
</Grid>

View File

@@ -1,7 +1,7 @@
import { Box, Group, Skeleton, Text } from "@mantine/core";
import { memo } from "react";
import type { Currency } from "@/pages/billing/invoices.mock";
import { formatCurrency } from "@/pages/billing/invoices.mock";
import type { Currency } from "@/lib/currency";
import { formatCurrency } from "@/lib/currency";
import { formatPct } from "../constants";
import { Card } from "./Card";

View File

@@ -1,8 +1,8 @@
import type { Currency, InvoiceStatus } from "@/pages/billing/invoices.mock";
import { formatCurrency } from "@/pages/billing/invoices.mock";
import { formatCurrency } from "@/lib/currency";
import type { PortalInvoice } from "@/services/invoices.service";
import { Box, Group, Stack, Text } from "@mantine/core";
import { format } from "date-fns";
import { CheckCircle2, ChevronRight, Clock3, Zap } from "lucide-react";
import { Freight } from "@edr/types";
import { ChevronRight, Clock3 } from "lucide-react";
import { memo } from "react";
import { Link } from "react-router-dom";
import { cv, INVOICE_BADGE } from "../constants";
@@ -10,26 +10,26 @@ import { Card } from "./Card";
import { EmptyState } from "./EmptyState";
interface InvoicesSectionProps {
invoices: Array<{
id: number;
number: string;
bookingReference: string;
amount: number;
currency: Currency;
status: InvoiceStatus;
dueDate: string;
paidDate: string | null;
}>;
invoices: PortalInvoice[];
}
/** Max rows to list in the compact dashboard card. */
const MAX_ROWS = 5;
const titleCase = (v: string) =>
v ? v.charAt(0).toUpperCase() + v.slice(1).toLowerCase() : "";
export const InvoicesSection = memo(function InvoicesSection({
invoices,
}: InvoicesSectionProps) {
const outstandingInvoices = invoices.filter(
(inv) => inv.status === "Sent" || inv.status === "Overdue",
// Dashboard shows unpaid invoices only (pending + overdue).
const pendingInvoices = invoices.filter(
(inv) =>
inv.status === Freight.InvoiceStatus.Pending ||
inv.status === Freight.InvoiceStatus.Overdue,
);
const totalOutstanding = outstandingInvoices.reduce(
(sum, inv) => sum + inv.amount,
const totalOutstanding = pendingInvoices.reduce(
(sum, inv) => sum + Number(inv.totalAmount),
0,
);
@@ -56,94 +56,74 @@ export const InvoicesSection = memo(function InvoicesSection({
<Text fz={24} fw={800} mt={4} c="edr-text">
{formatCurrency(totalOutstanding || 0, "ETB")}
</Text>
<Group
justify="space-between"
align="center"
mt={8}
wrap="nowrap"
>
<Text fz={12} c="edr-amber-text">
{outstandingInvoices.length || 2} invoices unpaid
</Text>
<Group
gap={5}
align="center"
px={14}
py={8}
bg="edr-accent"
className="cursor-pointer rounded-[9px]"
>
<Zap size={15} color="#fff" />
<Text fz={13} fw={700} c="white">
Pay all
</Text>
</Group>
</Group>
<Text fz={12} mt={8} c="edr-amber-text">
{pendingInvoices.length} invoices unpaid
</Text>
</Box>
{invoices.length === 0 ? (
<EmptyState message="No invoices yet." />
{pendingInvoices.length === 0 ? (
<EmptyState message="No pending invoices." />
) : (
<Stack gap={0}>
{invoices.map((invoice, i) => {
{pendingInvoices.slice(0, MAX_ROWS).map((invoice, i) => {
const badge = INVOICE_BADGE[invoice.status];
const dueText =
invoice.status === "Paid"
? `Paid ${format(new Date(invoice.paidDate ?? invoice.dueDate), "MMM d")}`
: invoice.status === "Overdue"
? "Overdue 3 days"
: `Due ${invoice.dueDate}`;
const DueIcon =
invoice.status === "Paid" ? CheckCircle2 : Clock3;
const dueIconColor =
invoice.status === "Paid"
? cv("edr-green.5")
: cv("edr-muted");
const isOverdue = invoice.status === Freight.InvoiceStatus.Overdue;
const dueText = isOverdue
? "Overdue"
: `Due ${new Date(invoice.dueAt).toLocaleDateString()}`;
const dueIconColor = isOverdue ? cv("edr-red") : cv("edr-muted");
return (
<Box key={invoice.id}>
{i > 0 && <Box h={1} bg="edr-divider" />}
<Stack gap={8} py={10}>
<Group
justify="space-between"
align="flex-start"
wrap="nowrap"
<Link
to={`/billing/${invoice.id}`}
className="no-underline"
style={{ display: "block", color: "inherit" }}
>
<Stack
gap={8}
py={10}
className="cursor-pointer rounded-[9px] transition-colors hover:bg-[var(--mantine-color-edr-soft-0)]"
>
<Box>
<Text fz={13} fw={700} c="edr-text">
{invoice.number}
</Text>
<Text fz={11} c="edr-muted">
{invoice.bookingReference}
</Text>
</Box>
<Text fz={14} fw={700} c="edr-text">
{formatCurrency(invoice.amount, invoice.currency)}
</Text>
</Group>
<Group
justify="space-between"
align="center"
wrap="nowrap"
>
<Group gap={5} align="center">
<DueIcon size={13} color={dueIconColor} />
<Text fz={12} c="edr-muted">
{dueText}
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Box>
<Text fz={13} fw={700} c="edr-text">
{invoice.invoiceNumber}
</Text>
<Text fz={11} c="edr-muted">
{titleCase(invoice.source)} · {titleCase(invoice.type)}
</Text>
</Box>
<Text fz={14} fw={700} c="edr-text">
{formatCurrency(
Number(invoice.totalAmount),
invoice.currency,
)}
</Text>
</Group>
<Box
bg={badge.bg}
px={10}
py={4}
className="rounded-full"
>
<Text fz={11} fw={700} c={badge.text}>
{badge.label}
</Text>
</Box>
</Group>
</Stack>
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap={5} align="center">
<Clock3 size={13} color={dueIconColor} />
<Text fz={12} c="edr-muted">
{dueText}
</Text>
</Group>
{badge && (
<Box
bg={badge.bg}
px={10}
py={4}
className="rounded-full"
>
<Text fz={11} fw={700} c={badge.text}>
{badge.label}
</Text>
</Box>
)}
</Group>
</Stack>
</Link>
</Box>
);
})}

View File

@@ -1,4 +1,4 @@
import { formatCurrency } from "@/pages/billing/invoices.mock";
import { formatCurrency } from "@/lib/currency";
import { SimpleGrid } from "@mantine/core";
import { CheckCircle2, Clock3, Layers, Truck, Wallet } from "lucide-react";
import { memo } from "react";

View File

@@ -12,7 +12,7 @@ import {
Wallet,
type LucideIcon,
} from "lucide-react";
import type { InvoiceStatus } from "@/pages/billing/invoices.mock";
import { Freight } from "@edr/types";
export const cv = (token: string) => {
const [name, shade] = token.split(".");
@@ -514,12 +514,13 @@ export const ACTION_PROPS: Record<
};
export const INVOICE_BADGE: Record<
InvoiceStatus,
Freight.InvoiceStatus,
{ label: string; bg: string; text: string }
> = {
Draft: { label: "Draft", bg: "edr-slate-soft", text: "edr-slate" },
Sent: { label: "Due soon", bg: "edr-amber-soft", text: "edr-amber-text" },
Paid: { label: "Paid", bg: "edr-soft", text: "edr-green.7" },
Overdue: { label: "Overdue", bg: "edr-red-soft", text: "edr-red" },
Cancelled: { label: "Cancelled", bg: "edr-slate-soft", text: "edr-slate" },
[Freight.InvoiceStatus.Draft]: { label: "Draft", bg: "edr-slate-soft", text: "edr-slate" },
[Freight.InvoiceStatus.Pending]: { label: "Due soon", bg: "edr-amber-soft", text: "edr-amber-text" },
[Freight.InvoiceStatus.Paid]: { label: "Paid", bg: "edr-soft", text: "edr-green.7" },
[Freight.InvoiceStatus.Overdue]: { label: "Overdue", bg: "edr-red-soft", text: "edr-red" },
[Freight.InvoiceStatus.Cancelled]: { label: "Cancelled", bg: "edr-slate-soft", text: "edr-slate" },
[Freight.InvoiceStatus.Refunded]: { label: "Refunded", bg: "edr-blue-soft", text: "edr-blue" },
};

View File

@@ -1,13 +1,14 @@
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { Freight } from "@edr/types";
import useAuth from "@/hooks/useAuth";
import { getMyInvoices } from "@/lib/currentCustomer";
import { api } from "@/services/api";
import { ACTIVE_STATUSES } from "./constants";
export function useMyPortalData(selectedProfileId?: string) {
const { user, customer, company } = useAuth();
const myInvoices = useMemo(() => getMyInvoices(), []);
const invoicesQuery = useQuery(api.invoices.listMy.queryOptions());
const myInvoices = invoicesQuery.data ?? [];
const companyProfiles = company?.company?.companyProfiles ?? [];
@@ -59,11 +60,13 @@ export function useMyPortalData(selectedProfileId?: string) {
).length;
const outstandingInvoices = myInvoices.filter(
(inv) => inv.status === "Sent" || inv.status === "Overdue",
(inv) =>
inv.status === Freight.InvoiceStatus.Pending ||
inv.status === Freight.InvoiceStatus.Overdue,
);
const totalOutstanding = outstandingInvoices.reduce(
(sum, inv) => sum + inv.amount,
(sum, inv) => sum + Number(inv.totalAmount),
0,
);
@@ -91,6 +94,7 @@ export function useMyPortalData(selectedProfileId?: string) {
bookingsQuery,
dashboardQuery,
contractsQuery,
invoicesQuery,
allContracts,
recentContracts,
activeContractsCount,

View File

@@ -1,382 +0,0 @@
import { useMemo, useState } from "react";
import {
AlertCircle,
Clock,
DollarSign,
Download,
Filter,
MoreHorizontal,
Pencil,
Plus,
Receipt,
Search,
Trash2,
} from "lucide-react";
import Breadcrumbs from "@/components/Breadcrumbs";
import NewInvoicePage from "./NewInvoicePage";
import DeleteInvoiceDialog from "./DeleteInvoiceDialog";
import { formatCurrency, invoices, type InvoiceStatus } from "./invoices.mock";
import {
DataTable,
DataTableFooter,
type ColumnDef,
usePagination,
Button,
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Input,
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from "@edr/ui-common";
type FilterValue = "All" | InvoiceStatus;
const FILTERS: FilterValue[] = [
"All",
"Draft",
"Sent",
"Paid",
"Overdue",
"Cancelled",
];
export default function BillingPage() {
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [filter, setFilter] = useState<FilterValue>("All");
const [query, setQuery] = useState("");
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return invoices.filter((inv) => {
if (filter !== "All" && inv.status !== filter) return false;
if (!q) return true;
return (
inv.number.toLowerCase().includes(q) ||
inv.customer.toLowerCase().includes(q) ||
inv.bookingReference.toLowerCase().includes(q)
);
});
}, [filter, query]);
const total = filtered.length;
const pageCount = Math.ceil(total / pagination.pageSize);
const start = pagination.pageIndex * pagination.pageSize;
const end = Math.min(start + pagination.pageSize, total);
const paginatedData = useMemo(
() => filtered.slice(start, end),
[start, end, filtered],
);
const totalRevenue = invoices
.filter((inv) => inv.status === "Paid" && inv.currency === "USD")
.reduce((sum, inv) => sum + inv.amount, 0);
const outstanding = invoices
.filter(
(inv) =>
(inv.status === "Sent" || inv.status === "Overdue") &&
inv.currency === "USD",
)
.reduce((sum, inv) => sum + inv.amount, 0);
const overdueCount = invoices.filter(
(inv) => inv.status === "Overdue",
).length;
const columns: ColumnDef<(typeof invoices)[number]>[] = [
{
id: "invoice",
header: "Invoice",
cell: ({ row }) => {
const inv = row.original;
return (
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Receipt />
</div>
<div>
<p className="font-medium text-slate-900">{inv.number}</p>
<p className="text-sm text-slate-500">Issued {inv.issueDate}</p>
</div>
</div>
);
},
},
{
accessorKey: "customer",
header: "Customer",
},
{
accessorKey: "bookingReference",
header: "Booking",
},
{
id: "amount",
header: "Amount",
cell: ({ row }) => {
const inv = row.original;
return (
<span className="text-sm font-medium text-slate-900">
{formatCurrency(inv.amount, inv.currency)}
</span>
);
},
},
{
accessorKey: "dueDate",
header: "Due Date",
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
id: "actions",
size: 40,
cell: ({ row }) => {
const invoice = row.original;
return (
<div
className="flex justify-end"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>
<Download />
Download
</DropdownMenuItem>
<NewInvoicePage
mode="edit"
invoice={{
number: invoice.number,
customerId: invoice.customerId,
bookingReference: invoice.bookingReference,
amount: invoice.amount,
currency: invoice.currency,
status: invoice.status,
issueDate: invoice.issueDate,
dueDate: invoice.dueDate,
notes: invoice.notes,
}}
>
<DropdownMenuItem onSelect={(e: Event) => e.preventDefault()}>
<Pencil />
Edit
</DropdownMenuItem>
</NewInvoicePage>
<DropdownMenuSeparator />
<DeleteInvoiceDialog invoiceNumber={invoice.number}>
<DropdownMenuItem
onSelect={(e: Event) => e.preventDefault()}
variant="destructive"
>
<Trash2 />
Void
</DropdownMenuItem>
</DeleteInvoiceDialog>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
},
];
return (
<div className="min-h-screen p-6">
<div className="space-y-6">
<Breadcrumbs items={[{ label: "Billing" }]} />
<Card className="p-6 flex-row justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
Billing
</h1>
<p className="mt-1 text-sm text-secondary-foreground">
Manage invoices, payments, and financial records.
</p>
</div>
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
<div className="relative w-full sm:w-80">
<Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
type="search"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setPagination({
pageIndex: 0,
pageSize: pagination.pageSize,
});
}}
placeholder="Search invoices..."
className="pl-8!"
/>
</div>
<NewInvoicePage>
<Button>
<Plus />
New Invoice
</Button>
</NewInvoicePage>
</div>
</Card>
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Total Revenue (USD)</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">
{formatCurrency(totalRevenue, "USD")}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<DollarSign />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Outstanding (USD)</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">
{formatCurrency(outstanding, "USD")}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Clock />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Overdue Invoices</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">
{overdueCount}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-red-100 text-red-600">
<AlertCircle />
</div>
</CardContent>
</Card>
</div>
<Card className="p-2">
<div className="flex flex-wrap gap-1">
{FILTERS.map((f) => {
const isActive = f === filter;
const count =
f === "All"
? invoices.length
: invoices.filter((inv) => inv.status === f).length;
return (
<button
key={f}
type="button"
onClick={() => {
setFilter(f);
setPagination({
pageIndex: 0,
pageSize: pagination.pageSize,
});
}}
className={
isActive
? "inline-flex items-center gap-2 rounded-2xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground"
: "inline-flex items-center gap-2 rounded-2xl px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-primary/10 hover:text-primary"
}
>
{f}
<span
className={
isActive
? "rounded-full bg-white/20 px-2 py-0.5 text-xs"
: "rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600"
}
>
{count}
</span>
</button>
);
})}
</div>
</Card>
<Card className="gap-0">
<CardHeader className="flex flex-row items-center justify-between border-b">
<div>
<CardTitle>Invoices</CardTitle>
<CardDescription>
Issued invoices and their payment status.
</CardDescription>
</div>
<Button variant="secondary" size="sm">
<Filter />
Filter
</Button>
</CardHeader>
<CardContent className="px-0">
<DataTable
columns={columns}
data={paginatedData}
status="success"
onRowClick={() => { }}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount: pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
}}
containerClassName="border-b shadow-none"
footer={DataTableFooter}
/>
</CardContent>
</Card>
</div>
</div>
);
}
function StatusBadge({ status }: { status: InvoiceStatus }) {
const styles: Record<InvoiceStatus, string> = {
Draft: "bg-slate-100 text-slate-600",
Sent: "bg-sky-100 text-sky-700",
Paid: "bg-emerald-100 text-emerald-700",
Overdue: "bg-red-100 text-red-700",
Cancelled: "bg-amber-100 text-amber-700",
};
return (
<span
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${styles[status]}`}
>
{status}
</span>
);
}

View File

@@ -1,63 +0,0 @@
import type { ReactNode } from "react";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
export interface DeleteInvoiceDialogProps {
invoiceNumber: string;
onConfirm?: () => void;
children: ReactNode;
}
export default function DeleteInvoiceDialog({
invoiceNumber,
onConfirm,
children,
}: DeleteInvoiceDialogProps) {
return (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-md rounded-3xl">
<DialogHeader>
<DialogTitle className="text-xl font-bold">
Void invoice?
</DialogTitle>
<DialogDescription>
This will void invoice{" "}
<span className="font-semibold text-slate-900">
{invoiceNumber}
</span>
. This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-2">
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<DialogClose asChild>
<Button
onClick={onConfirm}
className="bg-red-600 text-white hover:bg-red-700"
>
Void Invoice
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,247 @@
import { useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
Alert,
Box,
Button,
Center,
Divider,
Group,
Loader,
Paper,
SimpleGrid,
Stack,
Table,
Text,
Title,
} from "@mantine/core";
import { ArrowLeft, CreditCard, Info } from "lucide-react";
import { api } from "@/services/api";
import { formatCurrency } from "@/lib/currency";
import { BORDER, INK, MUTED } from "../contracts/contract-ui";
import {
billedTo,
fmtDate,
InvoiceStatusBadge,
isPayable,
titleCase,
} from "./invoice-ui";
function MetaItem({ label, value }: { label: string; value: string }) {
return (
<Box>
<Text fz={11} fw={700} c={MUTED} style={{ textTransform: "uppercase", letterSpacing: "0.05em" }}>
{label}
</Text>
<Text fz={14} mt={4} style={{ color: INK }}>
{value}
</Text>
</Box>
);
}
export default function InvoiceDetailPage() {
const { id = "" } = useParams();
const navigate = useNavigate();
const { data: invoice, isLoading, isError } = useQuery(
api.invoices.get.queryOptions({ input: { id } }),
);
const payMutation = useMutation(
api.invoices.pay.mutationOptions({
onSuccess: (res) => {
const url = res.clientAction?.url;
if (url) window.location.href = url;
},
}),
);
if (isLoading) {
return (
<Center py={80}>
<Loader color="edr-green" />
</Center>
);
}
if (isError || !invoice) {
return (
<Box style={{ padding: "28px 32px" }}>
<Button
variant="subtle"
color="gray"
leftSection={<ArrowLeft size={16} />}
onClick={() => navigate("/billing")}
mb="md"
>
Back to invoices
</Button>
<Alert color="red" title="Invoice not found">
We couldn't load this invoice. It may not exist or you may not have
access to it.
</Alert>
</Box>
);
}
const payable = isPayable(invoice.status);
const lines = invoice.lines ?? [];
const handlePay = () => {
const returnUrl = `${window.location.origin}/payment/success`;
const failureUrl = `${window.location.origin}/payment/failure`;
payMutation.mutate({ id, payload: { returnUrl, failureUrl } });
};
return (
<Box style={{ padding: "28px 32px 32px" }}>
<Stack gap="lg">
<Button
variant="subtle"
color="gray"
leftSection={<ArrowLeft size={16} />}
onClick={() => navigate("/billing")}
style={{ alignSelf: "flex-start" }}
styles={{ root: { fontWeight: 600 } }}
>
Back to invoices
</Button>
{/* Header */}
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Group gap={12} align="center" wrap="wrap">
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
{invoice.invoiceNumber}
</Title>
<InvoiceStatusBadge status={invoice.status} />
</Group>
{payable && (
<Button
color="edr-green"
radius="md"
size="md"
leftSection={<CreditCard size={16} />}
loading={payMutation.isPending}
onClick={handlePay}
styles={{ root: { fontWeight: 600, height: 42, paddingInline: 18 } }}
>
Pay {formatCurrency(Number(invoice.totalAmount), invoice.currency)}
</Button>
)}
</Group>
{payMutation.isError && (
<Alert color="red" icon={<Info size={16} />} title="Payment could not be started">
Please try again, or contact support if the problem persists.
</Alert>
)}
{/* Summary */}
<Paper withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="lg">
<MetaItem label="Billed To" value={billedTo(invoice)} />
<MetaItem label="Source" value={`${titleCase(invoice.source)} · ${invoice.type}`} />
<MetaItem label="Issued" value={fmtDate(invoice.issuedAt)} />
<MetaItem label="Due" value={fmtDate(invoice.dueAt)} />
</SimpleGrid>
<Divider my="lg" color={BORDER} />
<Group justify="space-between" align="center">
<Text fz={14} fw={700} c={MUTED} style={{ textTransform: "uppercase", letterSpacing: "0.05em" }}>
Total
</Text>
<Text fz={24} fw={800} style={{ color: INK }}>
{formatCurrency(Number(invoice.totalAmount), invoice.currency)}
</Text>
</Group>
</Paper>
{/* Line items */}
<Paper
withBorder
radius="lg"
style={{ borderColor: BORDER, overflow: "hidden" }}
>
<Box px="lg" py="md" style={{ borderBottom: `1px solid ${BORDER}` }}>
<Text fz={15} fw={700} style={{ color: INK }}>
Line items
</Text>
</Box>
<Box style={{ overflowX: "auto" }}>
<Table
verticalSpacing={12}
horizontalSpacing={20}
styles={{
th: {
fontSize: 11,
fontWeight: 700,
letterSpacing: "0.05em",
textTransform: "uppercase",
color: MUTED,
background: "#F8FAFC",
borderBottom: `1px solid ${BORDER}`,
whiteSpace: "nowrap",
},
td: { borderBottom: `1px solid ${BORDER}` },
}}
>
<Table.Thead>
<Table.Tr>
<Table.Th>Charge</Table.Th>
<Table.Th ta="right">Qty</Table.Th>
<Table.Th ta="right">Unit Rate</Table.Th>
<Table.Th ta="right">Amount</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{lines.length === 0 && (
<Table.Tr>
<Table.Td colSpan={4}>
<Center py={28}>
<Text fz={13} c="dimmed">
No line items on this invoice.
</Text>
</Center>
</Table.Td>
</Table.Tr>
)}
{lines.map((line) => (
<Table.Tr key={line.id}>
<Table.Td>
<Text fz={14} fw={600} style={{ color: INK }}>
{titleCase(line.chargeType)}
</Text>
{line.description && (
<Text fz={12} c="dimmed">
{line.description}
</Text>
)}
</Table.Td>
<Table.Td ta="right">
<Text fz={13} style={{ color: INK }}>
{Number(line.quantity)}
</Text>
</Table.Td>
<Table.Td ta="right">
<Text fz={13} style={{ color: INK }}>
{formatCurrency(Number(line.unitRate), line.currency)}
</Text>
</Table.Td>
<Table.Td ta="right">
<Text fz={13} fw={700} style={{ color: INK }}>
{formatCurrency(Number(line.amount), line.currency)}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Box>
</Paper>
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,537 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
Box,
Button,
Center,
Group,
Loader,
Paper,
Select,
Stack,
Table,
Text,
TextInput,
Title,
} from "@mantine/core";
import {
AlertTriangle,
ChevronLeft,
ChevronRight,
CreditCard,
Eye,
FileStack,
Inbox,
Receipt,
Search,
Wallet,
X,
} from "lucide-react";
import { Freight } from "@edr/types";
import { api } from "@/services/api";
import { formatCurrency } from "@/lib/currency";
import {
BORDER,
GREEN,
INK,
MUTED,
StatCard,
} from "../contracts/contract-ui";
import {
billedTo,
fmtDate,
InvoiceStatusBadge,
isPayable,
PAYABLE_STATUSES,
titleCase,
} from "./invoice-ui";
const PAGE_SIZES = ["10", "25", "50"];
export default function InvoicesList() {
const navigate = useNavigate();
const [query, setQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<string | null>(null);
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10);
const { data, isLoading, isError } = useQuery(
api.invoices.listMy.queryOptions(),
);
const all = useMemo(() => data ?? [], [data]);
const stats = useMemo(() => {
const outstanding = all.filter((i) =>
PAYABLE_STATUSES.includes(i.status),
).length;
const overdue = all.filter(
(i) => i.status === Freight.InvoiceStatus.Overdue,
).length;
return { outstanding, overdue, total: all.length };
}, [all]);
const rows = useMemo(() => {
const q = query.trim().toLowerCase();
return all.filter((inv) => {
if (statusFilter && inv.status !== statusFilter) return false;
if (!q) return true;
return (
inv.invoiceNumber.toLowerCase().includes(q) ||
inv.source.toLowerCase().includes(q) ||
inv.sourceId.toLowerCase().includes(q) ||
billedTo(inv).toLowerCase().includes(q)
);
});
}, [all, query, statusFilter]);
const total = rows.length;
const pageCount = Math.max(1, Math.ceil(total / pageSize));
const clampedIndex = Math.min(pageIndex, pageCount - 1);
const start = total === 0 ? 0 : clampedIndex * pageSize + 1;
const end = Math.min((clampedIndex + 1) * pageSize, total);
const pageRows = rows.slice(clampedIndex * pageSize, clampedIndex * pageSize + pageSize);
const resetPage = () => setPageIndex(0);
const goToPage = (i: number) =>
setPageIndex(Math.max(0, Math.min(i, pageCount - 1)));
const hasFilters = !!query || !!statusFilter;
return (
<Box style={{ padding: "28px 32px 32px" }}>
<Stack gap="lg">
{/* Header */}
<Group justify="space-between" align="center" wrap="wrap" gap="md">
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
Invoices
</Title>
</Group>
{/* Summary strip */}
<Group gap="md" wrap="wrap" align="stretch">
<StatCard
label="Outstanding"
hint="awaiting payment"
value={stats.outstanding}
icon={Wallet}
color="edr-accent"
/>
<StatCard
label="Overdue"
hint="past due date"
value={stats.overdue}
icon={AlertTriangle}
color="red"
/>
<StatCard
label="Total invoices"
value={stats.total}
icon={FileStack}
color="violet"
/>
</Group>
{/* Search + filters */}
<Paper withBorder radius="lg" p="sm" style={{ borderColor: BORDER }}>
<Group gap={10} wrap="wrap" align="center">
<TextInput
placeholder="Search by number, source or reference…"
leftSection={<Search size={16} />}
value={query}
onChange={(e) => {
setQuery(e.currentTarget.value);
resetPage();
}}
radius="md"
styles={{ input: { height: 42 } }}
style={{ flex: 1, minWidth: 220, maxWidth: 380 }}
/>
<Select
placeholder="Any status"
data={[
{ value: Freight.InvoiceStatus.Pending, label: "Due" },
{ value: Freight.InvoiceStatus.Overdue, label: "Overdue" },
{ value: Freight.InvoiceStatus.Paid, label: "Paid" },
{ value: Freight.InvoiceStatus.Draft, label: "Draft" },
{ value: Freight.InvoiceStatus.Cancelled, label: "Cancelled" },
{ value: Freight.InvoiceStatus.Refunded, label: "Refunded" },
]}
value={statusFilter}
onChange={(v) => {
setStatusFilter(v);
resetPage();
}}
clearable
radius="md"
comboboxProps={{ withinPortal: true }}
style={{ width: 160 }}
styles={{ input: { height: 42 } }}
aria-label="Filter by status"
/>
{hasFilters && (
<Button
variant="subtle"
color="gray"
radius="md"
leftSection={<X size={14} />}
onClick={() => {
setQuery("");
setStatusFilter(null);
resetPage();
}}
styles={{ root: { fontWeight: 600 } }}
>
Clear
</Button>
)}
</Group>
</Paper>
{/* Table */}
<Paper
withBorder
radius="lg"
style={{ borderColor: BORDER, overflow: "hidden" }}
>
<Box style={{ overflowX: "auto" }}>
<Table
verticalSpacing={14}
horizontalSpacing={20}
highlightOnHover
highlightOnHoverColor="#F4FBF8"
styles={{
th: {
fontSize: 11,
fontWeight: 700,
letterSpacing: "0.05em",
textTransform: "uppercase",
color: MUTED,
background: "#F8FAFC",
borderBottom: `1px solid ${BORDER}`,
whiteSpace: "nowrap",
position: "sticky",
top: 0,
zIndex: 1,
},
tr: { transition: "background-color 120ms ease" },
td: {
borderBottom: `1px solid ${BORDER}`,
verticalAlign: "middle",
},
}}
>
<Table.Thead>
<Table.Tr>
<Table.Th>Invoice</Table.Th>
<Table.Th>Billed To</Table.Th>
<Table.Th>Source</Table.Th>
<Table.Th ta="right">Amount</Table.Th>
<Table.Th>Issued</Table.Th>
<Table.Th>Due</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th ta="right">Action</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{isLoading && (
<Table.Tr>
<Table.Td colSpan={8}>
<Center py={48}>
<Loader color="edr-green" size="sm" />
</Center>
</Table.Td>
</Table.Tr>
)}
{!isLoading && isError && (
<Table.Tr>
<Table.Td colSpan={8}>
<Center py={48}>
<Text fz={13} c="red">
Failed to load invoices. Please try again.
</Text>
</Center>
</Table.Td>
</Table.Tr>
)}
{!isLoading && !isError && pageRows.length === 0 && (
<Table.Tr>
<Table.Td colSpan={8}>
<Stack align="center" gap={8} py={48}>
<Inbox size={26} color={MUTED} style={{ opacity: 0.5 }} />
<Text fz={13} c="dimmed">
{hasFilters
? "No invoices match your filters."
: "No invoices yet."}
</Text>
</Stack>
</Table.Td>
</Table.Tr>
)}
{!isLoading &&
!isError &&
pageRows.map((inv) => {
const payable = isPayable(inv.status);
return (
<Table.Tr
key={inv.id}
style={{ cursor: "pointer" }}
onClick={() => navigate(`/billing/${inv.id}`)}
>
<Table.Td>
<Group gap={10} wrap="nowrap" align="center">
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 34,
height: 34,
borderRadius: 9,
background: "#E6F7EF",
color: GREEN,
flexShrink: 0,
}}
>
<Receipt size={16} />
</Box>
<Box>
<Text fz={14} fw={700} style={{ color: INK }}>
{inv.invoiceNumber}
</Text>
<Text fz={12} c="dimmed">
{titleCase(inv.type)}
</Text>
</Box>
</Group>
</Table.Td>
<Table.Td>
<Text fz={13} style={{ color: INK }}>
{billedTo(inv)}
</Text>
</Table.Td>
<Table.Td>
<Text fz={13} style={{ color: INK }}>
{titleCase(inv.source)}
</Text>
</Table.Td>
<Table.Td ta="right">
<Text fz={13} fw={700} style={{ color: INK }}>
{formatCurrency(Number(inv.totalAmount), inv.currency)}
</Text>
</Table.Td>
<Table.Td>
<Text
fz={13}
c={inv.issuedAt ? undefined : "dimmed"}
style={{ color: inv.issuedAt ? INK : undefined }}
>
{fmtDate(inv.issuedAt)}
</Text>
</Table.Td>
<Table.Td>
<Text fz={13} style={{ color: INK }}>
{fmtDate(inv.dueAt)}
</Text>
</Table.Td>
<Table.Td>
<InvoiceStatusBadge status={inv.status} />
</Table.Td>
<Table.Td>
<Group justify="flex-end" gap={8} wrap="nowrap">
<Button
size="sm"
radius="md"
h={34}
variant={payable ? "filled" : "light"}
color="edr-green"
leftSection={
payable ? (
<CreditCard size={15} />
) : (
<Eye size={15} />
)
}
onClick={(e) => {
e.stopPropagation();
navigate(`/billing/${inv.id}`);
}}
styles={{
root: {
fontWeight: 600,
fontSize: 13,
paddingInline: 14,
whiteSpace: "nowrap",
boxShadow: payable
? "0 1px 2px rgba(14,163,113,0.25)"
: "none",
},
}}
>
{payable ? "Pay" : "View"}
</Button>
</Group>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Box>
{/* Pagination footer */}
{!isLoading && !isError && total > 0 && (
<Group
justify="space-between"
align="center"
wrap="wrap"
gap="md"
px={20}
py={14}
style={{ borderTop: `1px solid ${BORDER}`, background: "#FCFDFE" }}
>
<Group gap={10} align="center">
<Text fz={13} c="dimmed">
Rows
</Text>
<Select
data={PAGE_SIZES}
value={String(pageSize)}
onChange={(v) => {
if (!v) return;
setPageSize(Number(v));
setPageIndex(0);
}}
radius="md"
size="xs"
comboboxProps={{ withinPortal: true }}
style={{ width: 76 }}
allowDeselect={false}
/>
<Text fz={13} c="dimmed">
{start}{end} of {total}
</Text>
</Group>
<Group gap={6} align="center">
<PagerButton
icon={<ChevronLeft size={16} />}
disabled={clampedIndex === 0}
onClick={() => goToPage(clampedIndex - 1)}
ariaLabel="Previous page"
/>
{pageNumbers(clampedIndex, pageCount).map((p, i) =>
p === "…" ? (
<Text key={`gap-${i}`} fz={13} c="dimmed" px={4}>
</Text>
) : (
<PageChip
key={p}
page={p}
active={p === clampedIndex}
onClick={() => goToPage(p)}
/>
),
)}
<PagerButton
icon={<ChevronRight size={16} />}
disabled={clampedIndex >= pageCount - 1}
onClick={() => goToPage(clampedIndex + 1)}
ariaLabel="Next page"
/>
</Group>
</Group>
)}
</Paper>
</Stack>
</Box>
);
}
/** Compact page-number window with ellipses: 1 … 4 5 6 … 12. */
function pageNumbers(active: number, count: number): (number | "…")[] {
if (count <= 7) return Array.from({ length: count }, (_, i) => i);
const out: (number | "…")[] = [0];
const lo = Math.max(1, active - 1);
const hi = Math.min(count - 2, active + 1);
if (lo > 1) out.push("…");
for (let i = lo; i <= hi; i++) out.push(i);
if (hi < count - 2) out.push("…");
out.push(count - 1);
return out;
}
function PageChip({
page,
active,
onClick,
}: {
page: number;
active: boolean;
onClick: () => void;
}) {
return (
<Box
component="button"
onClick={onClick}
style={{
minWidth: 32,
height: 32,
padding: "0 8px",
borderRadius: 9,
border: `1px solid ${active ? GREEN : BORDER}`,
background: active ? GREEN : "#FFFFFF",
color: active ? "#FFFFFF" : INK,
fontSize: 13,
fontWeight: active ? 700 : 600,
cursor: "pointer",
transition: "all 120ms ease",
}}
>
{page + 1}
</Box>
);
}
function PagerButton({
icon,
disabled,
onClick,
ariaLabel,
}: {
icon: React.ReactNode;
disabled: boolean;
onClick: () => void;
ariaLabel: string;
}) {
return (
<Box
component="button"
aria-label={ariaLabel}
onClick={onClick}
disabled={disabled}
style={{
width: 32,
height: 32,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 9,
border: `1px solid ${BORDER}`,
background: "#FFFFFF",
color: disabled ? "#C2CCD6" : INK,
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.6 : 1,
transition: "all 120ms ease",
}}
>
{icon}
</Box>
);
}

View File

@@ -1,202 +0,0 @@
import type { ReactNode } from "react";
import { Calendar, DollarSign, Hash } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { customers } from "../customers/customers.mock";
import { bookings } from "../bookings/bookings.mock";
import type { Currency, InvoiceStatus } from "./invoices.mock";
export interface InvoiceFormData {
number?: string;
customerId?: number;
bookingReference?: string;
amount?: number;
currency?: Currency;
status?: InvoiceStatus;
issueDate?: string;
dueDate?: string;
notes?: string;
}
export interface NewInvoicePageProps {
mode?: "create" | "edit";
invoice?: InvoiceFormData;
children?: ReactNode;
}
const selectClass =
"flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition hover:border-slate-300 focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20";
export default function NewInvoicePage({
mode = "create",
invoice,
children,
}: NewInvoicePageProps = {}) {
const isEdit = mode === "edit";
const title = isEdit ? "Edit Invoice" : "New Invoice";
const description = isEdit
? "Update invoice details."
: "Create a new invoice for a customer booking.";
const submitLabel = isEdit ? "Save Changes" : "Create Invoice";
return (
<Dialog>
<DialogTrigger asChild>
{children ?? <Button>{isEdit ? "Edit" : "New Invoice"}</Button>}
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl rounded-3xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="grid gap-5 py-4 md:grid-cols-2">
{/* Invoice Number */}
<div className="space-y-2">
<Label>Invoice Number *</Label>
<div className="relative">
<Hash className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
defaultValue={invoice?.number ?? ""}
placeholder="e.g. INV-2026-0001"
className="pl-10"
/>
</div>
</div>
{/* Status */}
<div className="space-y-2">
<Label>Status</Label>
<select
defaultValue={invoice?.status ?? "Draft"}
className={selectClass}
>
<option>Draft</option>
<option>Sent</option>
<option>Paid</option>
<option>Overdue</option>
<option>Cancelled</option>
</select>
</div>
{/* Customer */}
<div className="space-y-2">
<Label>Customer *</Label>
<select
defaultValue={invoice?.customerId ?? ""}
className={selectClass}
>
<option value="" disabled>
Select customer
</option>
{customers.map((c) => (
<option key={c.id} value={c.id}>
{c.company}
</option>
))}
</select>
</div>
{/* Booking */}
<div className="space-y-2">
<Label>Booking Reference</Label>
<select
defaultValue={invoice?.bookingReference ?? ""}
className={selectClass}
>
<option value="">No linked booking</option>
{bookings.map((b) => (
<option key={b.id} value={b.reference}>
{b.reference} {b.customer}
</option>
))}
</select>
</div>
{/* Amount */}
<div className="space-y-2">
<Label>Amount *</Label>
<div className="relative">
<DollarSign className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
type="number"
min={0}
step="0.01"
defaultValue={invoice?.amount ?? 0}
className="pl-10"
/>
</div>
</div>
{/* Currency */}
<div className="space-y-2">
<Label>Currency</Label>
<select
defaultValue={invoice?.currency ?? "USD"}
className={selectClass}
>
<option>USD</option>
<option>ETB</option>
<option>DJF</option>
</select>
</div>
{/* Issue Date */}
<div className="space-y-2">
<Label>Issue Date *</Label>
<div className="relative">
<Calendar className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
type="date"
defaultValue={invoice?.issueDate ?? ""}
className="pl-10"
/>
</div>
</div>
{/* Due Date */}
<div className="space-y-2">
<Label>Due Date *</Label>
<div className="relative">
<Calendar className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
type="date"
defaultValue={invoice?.dueDate ?? ""}
className="pl-10"
/>
</div>
</div>
{/* Notes */}
<div className="space-y-2 md:col-span-2">
<Label>Notes</Label>
<Textarea
defaultValue={invoice?.notes ?? ""}
placeholder="Payment terms, references, etc."
/>
</div>
</div>
<div className="flex justify-end gap-3">
<Button variant="outline">Cancel</Button>
<Button className="bg-[#10B981] text-white hover:bg-[#10B981]/90">
{submitLabel}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,60 @@
import { Box } from "@mantine/core";
import { Freight } from "@edr/types";
import type { PortalInvoice } from "@/services/invoices.service";
/** Statuses a customer can still pay. */
export const PAYABLE_STATUSES: Freight.InvoiceStatus[] = [
Freight.InvoiceStatus.Pending,
Freight.InvoiceStatus.Overdue,
];
export const isPayable = (status: Freight.InvoiceStatus) =>
PAYABLE_STATUSES.includes(status);
const STATUS_STYLE: Record<
Freight.InvoiceStatus,
{ label: string; bg: string; fg: string }
> = {
[Freight.InvoiceStatus.Draft]: { label: "Draft", bg: "#EEF2F6", fg: "#64748B" },
[Freight.InvoiceStatus.Pending]: { label: "Due", bg: "#FEF3E2", fg: "#B45309" },
[Freight.InvoiceStatus.Paid]: { label: "Paid", bg: "#E6F7EF", fg: "#0A6F4D" },
[Freight.InvoiceStatus.Overdue]: { label: "Overdue", bg: "#FDECEC", fg: "#C0392B" },
[Freight.InvoiceStatus.Cancelled]: { label: "Cancelled", bg: "#EEF2F6", fg: "#64748B" },
[Freight.InvoiceStatus.Refunded]: { label: "Refunded", bg: "#EAF1FB", fg: "#2563EB" },
};
export function InvoiceStatusBadge({ status }: { status: Freight.InvoiceStatus }) {
const s = STATUS_STYLE[status] ?? { label: status, bg: "#EEF2F6", fg: "#64748B" };
return (
<Box
style={{
display: "inline-flex",
alignItems: "center",
padding: "4px 10px",
borderRadius: 999,
background: s.bg,
color: s.fg,
fontSize: 12,
fontWeight: 700,
whiteSpace: "nowrap",
}}
>
{s.label}
</Box>
);
}
export const titleCase = (v: string) =>
v ? v.charAt(0).toUpperCase() + v.slice(1).toLowerCase() : "—";
/** Best label for who an invoice is billed to (profile ref → profile type → company). */
export function billedTo(inv: PortalInvoice): string {
const profile = inv.companyProfile;
if (profile?.reference) return profile.reference;
if (profile?.type) return titleCase(profile.type);
return inv.company?.name ?? "—";
}
export const fmtDate = (v: string | null | undefined) =>
v ? new Date(v).toLocaleDateString() : "—";

View File

@@ -1,88 +0,0 @@
import { customers } from "../customers/customers.mock";
import { bookings } from "../bookings/bookings.mock";
export type InvoiceStatus =
| "Draft"
| "Sent"
| "Paid"
| "Overdue"
| "Cancelled";
export type Currency = "USD" | "ETB" | "DJF";
export interface Invoice {
id: number;
number: string;
customerId: number;
customer: string;
bookingReference: string;
amount: number;
currency: Currency;
status: InvoiceStatus;
issueDate: string;
dueDate: string;
paidDate: string | null;
notes: string;
}
const statuses: InvoiceStatus[] = [
"Draft",
"Sent",
"Paid",
"Overdue",
"Cancelled",
];
const currencies: Currency[] = ["USD", "ETB", "DJF"];
export const invoices: Invoice[] = Array.from({ length: 24 }, (_, i) => {
const customer = customers[i % customers.length] as (typeof customers)[number];
const booking = bookings[i % bookings.length] as (typeof bookings)[number];
const id = i + 1;
const issue = new Date(2026, 3, 1 + (i % 28));
const due = new Date(issue);
due.setDate(due.getDate() + 30);
const status = statuses[i % statuses.length] as InvoiceStatus;
const currency = currencies[i % currencies.length] as Currency;
const baseAmount = 5000 + (i * 1234) % 25000;
return {
id,
number: `INV-2026-${String(id).padStart(4, "0")}`,
customerId: customer.id,
customer: customer.company,
bookingReference: booking.reference,
amount: Math.round(baseAmount * 100) / 100,
currency,
status,
issueDate: issue.toISOString().slice(0, 10),
dueDate: due.toISOString().slice(0, 10),
paidDate:
status === "Paid"
? new Date(due.getTime() - 86400000 * (i % 7))
.toISOString()
.slice(0, 10)
: null,
notes:
i % 3 === 0
? "Net 30 payment terms."
: i % 3 === 1
? "Bank transfer preferred."
: "Payment due upon receipt.",
};
});
export function getInvoiceById(id: number | string): Invoice | undefined {
const numericId = typeof id === "string" ? Number(id) : id;
return invoices.find((inv) => inv.id === numericId);
}
export function formatCurrency(amount: number, currency: Currency): string {
const symbols: Record<Currency, string> = {
USD: "$",
ETB: "Br",
DJF: "DJF",
};
return `${symbols[currency]} ${amount.toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`;
}

View File

@@ -248,4 +248,4 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
{viewer}
</PageShell>
);
}
}

View File

@@ -30,6 +30,12 @@ import {
IntentStatus,
} from "./payments.service";
import { consignmentsService } from "./consignments.service";
import {
invoicesService,
PortalInvoice,
PortalInvoiceDetail,
PayInvoicePayload,
} from "./invoices.service";
import { trackingService } from "./tracking.service";
import { fileUploadSettingsService } from "./fileUploadSettings.service";
import { dropdownSettingsService } from "./dropdownSettings.service";
@@ -597,4 +603,23 @@ export const api = {
({ optionId }) => dropdownSettingsService.removeOption(optionId),
),
},
invoices: {
listMy: endpoint<void, PortalInvoice[]>(
"invoices",
"listMy",
invoicesService.listMy,
),
get: endpoint<{ id: string }, PortalInvoiceDetail>(
"invoices",
"get",
({ id }) => invoicesService.get(id),
),
pay: endpoint<
{ id: string; payload?: PayInvoicePayload },
InitiateResponse
>("invoices", "pay", ({ id, payload }) => invoicesService.pay(id, payload)),
},
};

View File

@@ -72,6 +72,13 @@ export interface SignContractPayload {
consentText?: string;
}
export interface ApproveDeliveryResponse {
bookingId: string;
inventoryId: string;
approvedAt: string;
signerDisplayName: string;
}
export interface BookingListFilter {
status?: string;
/** Comma-separated statuses (overrides `status` when set). */
@@ -242,6 +249,13 @@ export const bookingsService = {
return data.data ?? data;
},
approveDelivery: async (id: string): Promise<ApproveDeliveryResponse> => {
const { data } = await client.post(
`/api/warehouse-inventory/bookings/${id}/approve-delivery`,
);
return data.data ?? data;
},
getBookableSchedules: async (
query: Freight.BookableSchedulesQuery = {},
): Promise<Freight.BookableScheduleItem[]> => {

View File

@@ -0,0 +1,49 @@
import type { Freight } from "@edr/types";
import { URL_CONSTANTS } from "@/constants/URLS";
import { client } from "../utils/api";
import type { InitiateResponse } from "./payments.service";
const B = URL_CONSTANTS.BILLING;
/** A customer-facing invoice row, as returned by `GET /billing/my-invoices`. */
export type PortalInvoice = Freight.IInvoice;
/** An invoice plus its line items, as returned by `GET /billing/my-invoices/:id`. */
export type PortalInvoiceDetail = Freight.IInvoice & {
lines: Freight.IInvoiceLine[];
};
export interface PayInvoicePayload {
method?: string;
platform?: "web" | "mobile";
payerAccount?: string;
returnUrl?: string;
failureUrl?: string;
}
export const invoicesService = {
/** Every invoice billed to the signed-in customer's company, newest first. */
listMy: async (): Promise<PortalInvoice[]> => {
const { data } = await client.get(B.MY_INVOICES);
return data.data ?? data;
},
/** One of the customer's invoices, with its line items. */
get: async (id: string): Promise<PortalInvoiceDetail> => {
const { data } = await client.get(B.MY_INVOICE_BY_ID(id));
return data.data ?? data;
},
/** Initiate gateway payment for an open invoice; returns the client action. */
pay: async (
id: string,
payload: PayInvoicePayload = {},
): Promise<InitiateResponse> => {
const { data } = await client.post(B.PAY_INVOICE(id), {
platform: "web",
...payload,
});
return data.data ?? data;
},
};

View File

@@ -1,7 +1,7 @@
-- Migration: Add Configurable Fare Management System
-- Main fare configuration table
CREATE TABLE "fare_configurations" (
CREATE TABLE IF NOT EXISTS "fare_configurations" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
@@ -19,7 +19,7 @@ CREATE TABLE "fare_configurations" (
);
-- Rate structure by nationality and coach/position
CREATE TABLE "fare_rate_rules" (
CREATE TABLE IF NOT EXISTS "fare_rate_rules" (
"id" TEXT NOT NULL,
"fare_config_id" TEXT NOT NULL,
"nationality_type" TEXT NOT NULL, -- 'LOCAL' or 'INTERNATIONAL'
@@ -34,7 +34,7 @@ CREATE TABLE "fare_rate_rules" (
);
-- Configurable fare components (insurance, premiums, service charges, taxes)
CREATE TABLE "fare_components" (
CREATE TABLE IF NOT EXISTS "fare_components" (
"id" TEXT NOT NULL,
"fare_config_id" TEXT NOT NULL,
"component_type" TEXT NOT NULL, -- 'INSURANCE', 'PREMIUM', 'SERVICE_CHARGE', 'TAX', 'DEMAND'
@@ -52,7 +52,7 @@ CREATE TABLE "fare_components" (
);
-- Age-based pricing rules
CREATE TABLE "age_pricing_rules" (
CREATE TABLE IF NOT EXISTS "age_pricing_rules" (
"id" TEXT NOT NULL,
"fare_config_id" TEXT NOT NULL,
"rule_name" TEXT NOT NULL,
@@ -70,7 +70,7 @@ CREATE TABLE "age_pricing_rules" (
);
-- Audit trail for configuration changes
CREATE TABLE "fare_configuration_audit" (
CREATE TABLE IF NOT EXISTS "fare_configuration_audit" (
"id" TEXT NOT NULL,
"fare_config_id" TEXT NOT NULL,
"action" TEXT NOT NULL, -- 'CREATED', 'UPDATED', 'ACTIVATED', 'DEACTIVATED'
@@ -81,37 +81,47 @@ CREATE TABLE "fare_configuration_audit" (
CONSTRAINT "fare_configuration_audit_pkey" PRIMARY KEY ("id")
);
-- Foreign key constraints
ALTER TABLE "fare_rate_rules" ADD CONSTRAINT "fare_rate_rules_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "fare_components" ADD CONSTRAINT "fare_components_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "age_pricing_rules" ADD CONSTRAINT "age_pricing_rules_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "fare_configuration_audit" ADD CONSTRAINT "fare_configuration_audit_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Foreign key constraints (idempotent)
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fare_rate_rules_fare_config_id_fkey') THEN
ALTER TABLE "fare_rate_rules" ADD CONSTRAINT "fare_rate_rules_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fare_components_fare_config_id_fkey') THEN
ALTER TABLE "fare_components" ADD CONSTRAINT "fare_components_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'age_pricing_rules_fare_config_id_fkey') THEN
ALTER TABLE "age_pricing_rules" ADD CONSTRAINT "age_pricing_rules_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fare_configuration_audit_fare_config_id_fkey') THEN
ALTER TABLE "fare_configuration_audit" ADD CONSTRAINT "fare_configuration_audit_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
-- Indexes for performance
CREATE INDEX "fare_configurations_effective_date_idx" ON "fare_configurations"("effective_date");
CREATE INDEX "fare_configurations_is_active_idx" ON "fare_configurations"("is_active");
CREATE UNIQUE INDEX "fare_configurations_default_unique_idx" ON "fare_configurations"("is_default") WHERE "is_default" = true;
-- Indexes for performance (idempotent)
CREATE INDEX IF NOT EXISTS "fare_configurations_effective_date_idx" ON "fare_configurations"("effective_date");
CREATE INDEX IF NOT EXISTS "fare_configurations_is_active_idx" ON "fare_configurations"("is_active");
CREATE UNIQUE INDEX IF NOT EXISTS "fare_configurations_default_unique_idx" ON "fare_configurations"("is_default") WHERE "is_default" = true;
CREATE INDEX "fare_rate_rules_config_lookup_idx" ON "fare_rate_rules"("fare_config_id", "nationality_type", "coach_type", "bed_position");
CREATE INDEX "fare_components_config_order_idx" ON "fare_components"("fare_config_id", "apply_order");
CREATE INDEX "age_pricing_rules_age_lookup_idx" ON "age_pricing_rules"("fare_config_id", "min_age", "max_age");
CREATE INDEX IF NOT EXISTS "fare_rate_rules_config_lookup_idx" ON "fare_rate_rules"("fare_config_id", "nationality_type", "coach_type", "bed_position");
CREATE INDEX IF NOT EXISTS "fare_components_config_order_idx" ON "fare_components"("fare_config_id", "apply_order");
CREATE INDEX IF NOT EXISTS "age_pricing_rules_age_lookup_idx" ON "age_pricing_rules"("fare_config_id", "min_age", "max_age");
-- Add legacy mode flag to existing fare tables for gradual migration
ALTER TABLE "FareRule" ADD COLUMN "migrated_to_config_id" TEXT;
ALTER TABLE "SegmentFareRule" ADD COLUMN "migrated_to_config_id" TEXT;
-- Add legacy mode flag to existing fare tables for gradual migration (idempotent)
ALTER TABLE "passenger"."FareRule" ADD COLUMN IF NOT EXISTS "migrated_to_config_id" TEXT;
ALTER TABLE "passenger"."SegmentFareRule" ADD COLUMN IF NOT EXISTS "migrated_to_config_id" TEXT;
-- Add feature flag support
CREATE TABLE "system_features" (
CREATE TABLE IF NOT EXISTS "system_features" (
"id" TEXT NOT NULL,
"feature_name" TEXT NOT NULL UNIQUE,
"is_enabled" BOOLEAN NOT NULL DEFAULT false,
"config" JSONB,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "system_features_pkey" PRIMARY KEY ("id")
);
-- Insert the configurable fares feature flag
INSERT INTO "system_features" ("id", "feature_name", "is_enabled", "config")
VALUES ('cf-001', 'USE_CONFIGURABLE_FARES', false, '{"rollout_percentage": 0}');
INSERT INTO "system_features" ("id", "feature_name", "is_enabled", "config", "updated_at")
VALUES ('cf-001', 'USE_CONFIGURABLE_FARES', false, '{"rollout_percentage": 0}', CURRENT_TIMESTAMP);

View File

@@ -483,8 +483,8 @@ export class BookingsService {
}
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
const taxesMinor = Math.round(combinedBaseFareMinor * 0.05);
const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor);
const taxesMinor = 0;
const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB;
let displayTotalMinor = totalMinor;
@@ -660,8 +660,8 @@ export class BookingsService {
}
}
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
const taxesMinor = Math.round(combinedBase * 0.05);
const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor + taxesMinor);
const taxesMinor = 0;
const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB;
const displayTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
@@ -854,8 +854,8 @@ export class BookingsService {
}
}
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
const taxesMinor = Math.round(combinedBase * 0.05);
const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor + taxesMinor);
const taxesMinor = 0;
const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB;
const displayTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
@@ -1060,7 +1060,7 @@ export class BookingsService {
loyaltyRedemptionPoints?: number
) {
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
const baseFareMinor = await this.getBaseFare(scheduleId, seatClassId, segmentRoute, undefined, nationality, originStop.sequence, destStop.sequence);
const baseFareMinor = await this.getBaseFare(scheduleId, seatClassId, segmentRoute, undefined, nationality, originStop.sequence, destStop.sequence, originStop.stationId, destStop.stationId);
const adultFareMinor = baseFareMinor * adultCount;
const paidChildrenCount = Math.max(0, childCount - 1);
@@ -1076,8 +1076,8 @@ export class BookingsService {
}
const loyaltyMinor = (loyaltyRedemptionPoints ?? 0) * 10;
const taxesMinor = Math.round(totalBaseFareMinor * 0.05);
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor);
const taxesMinor = 0;
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor);
return {
baseFareMinor,
@@ -1103,6 +1103,8 @@ export class BookingsService {
nationality?: string,
originStopSeq?: number,
destStopSeq?: number,
originStationId?: string,
destinationStationId?: string,
): Promise<number> {
const now = new Date();
@@ -1149,13 +1151,13 @@ export class BookingsService {
const bestMatch = this.selectBestFareRule(candidates, scheduleId, segmentRoute, fullRoute, nationality);
if (bestMatch) return bestMatch.baseFareMinor;
// 3. FareEngine — distance × rate-per-km from the schedule's route
// 3. FareEngine — distance × rate-per-km from the booking's actual segment stations
if (schedule?.routeId) {
try {
const fare = await this.fareEngine.calculate({
routeId: schedule.routeId,
originStationId: schedule.originStationId,
destinationStationId: schedule.destinationStationId,
originStationId: originStationId ?? schedule.originStationId,
destinationStationId: destinationStationId ?? schedule.destinationStationId,
seatClassId,
nationality,
});

View File

@@ -9,6 +9,9 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
import { CreateGuestBookingDto, SavedPassengerProfileDto } from './guest-booking.dto';
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
/** Booking cutoff: reject new bookings within this many ms of departure. */
const BOOKING_CUTOFF_MS = 30 * 60 * 1000;
function generateRef(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
return 'EDR-' + Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
@@ -74,6 +77,10 @@ export class GuestBookingService {
});
if (!schedule) throw new NotFoundException('Schedule not found');
if (Date.now() >= schedule.departureAt.getTime() - BOOKING_CUTOFF_MS) {
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
}
const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId);
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found');
@@ -142,7 +149,9 @@ export class GuestBookingService {
dto.seatClassId,
segmentRoute,
fullRoute,
primaryNationality
primaryNationality,
dto.originStationId,
dto.destinationStationId,
);
const adultFareMinor = baseFareMinor * adultCount;
@@ -160,8 +169,8 @@ export class GuestBookingService {
}
}
const taxesMinor = Math.round(totalBaseFareMinor * 0.05);
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor + taxesMinor);
const taxesMinor = 0;
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB;
let displayTotalMinor = totalMinor;
@@ -293,6 +302,10 @@ export class GuestBookingService {
if (!outboundSchedule) throw new NotFoundException('Outbound schedule not found');
if (!returnSchedule) throw new NotFoundException('Return schedule not found');
if (Date.now() >= outboundSchedule.departureAt.getTime() - BOOKING_CUTOFF_MS) {
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
}
const outboundOriginStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.originStationId);
const outboundDestStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
const returnOriginStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnOriginStationId);
@@ -350,8 +363,8 @@ export class GuestBookingService {
const primaryNationality = passengersData[0]?.nationality;
const [outboundBaseFare, returnBaseFare] = await Promise.all([
this.getBaseFare(dto.scheduleId, dto.seatClassId, outboundSegmentRoute, outboundFullRoute, primaryNationality),
this.getBaseFare(dto.returnScheduleId, returnSeatClassId, returnSegmentRoute, returnFullRoute, primaryNationality),
this.getBaseFare(dto.scheduleId, dto.seatClassId, outboundSegmentRoute, outboundFullRoute, primaryNationality, dto.originStationId, dto.destinationStationId),
this.getBaseFare(dto.returnScheduleId, returnSeatClassId, returnSegmentRoute, returnFullRoute, primaryNationality, dto.returnOriginStationId, dto.returnDestinationStationId),
]);
const paidChildrenCount = Math.max(0, childCount - 1);
@@ -369,8 +382,8 @@ export class GuestBookingService {
}
}
const taxesMinor = Math.round(combinedBaseFareMinor * 0.05);
const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor + taxesMinor);
const taxesMinor = 0;
const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB;
const displayTotalMinor = displayCurrency !== Currency.ETB
@@ -505,6 +518,10 @@ export class GuestBookingService {
if (!leg1Schedule) throw new NotFoundException('Leg-1 schedule not found');
if (!leg2Schedule) throw new NotFoundException('Leg-2 schedule not found');
if (Date.now() >= leg1Schedule.departureAt.getTime() - BOOKING_CUTOFF_MS) {
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
}
const leg1OriginStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.originStationId);
const leg1DestStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.transitStationId);
const leg2OriginStop = leg2Schedule.stopTimes.find(s => s.stationId === dto.transitStationId);
@@ -551,11 +568,11 @@ export class GuestBookingService {
this.getBaseFare(dto.scheduleId, dto.seatClassId,
`${leg1OriginStop.station.code}-${leg1DestStop.station.code}`,
`${leg1Schedule.originStation.code}-${leg1Schedule.destinationStation.code}`,
primaryNationality),
primaryNationality, dto.originStationId, dto.transitStationId),
this.getBaseFare(dto.leg2ScheduleId, leg2SeatClassId,
`${leg2OriginStop.station.code}-${leg2DestStop.station.code}`,
`${leg2Schedule.originStation.code}-${leg2Schedule.destinationStation.code}`,
primaryNationality),
primaryNationality, dto.transitStationId, dto.leg2DestinationStationId),
]);
const leg1Total = leg1BaseFare * adultCount + leg1BaseFare * paidChildrenCount;
@@ -569,8 +586,8 @@ export class GuestBookingService {
discountMinor = promo.percentOff ? Math.round(combinedBase * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
}
}
const taxesMinor = Math.round(combinedBase * 0.05);
const totalMinor = Math.max(0, combinedBase - discountMinor + taxesMinor);
const taxesMinor = 0;
const totalMinor = Math.max(0, combinedBase - discountMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB;
const displayTotalMinor = displayCurrency !== Currency.ETB
@@ -702,6 +719,10 @@ export class GuestBookingService {
if (!retL1Sched) throw new NotFoundException('Return leg-1 schedule not found');
if (!retL2Sched) throw new NotFoundException('Return leg-2 schedule not found');
if (Date.now() >= obL1Sched.departureAt.getTime() - BOOKING_CUTOFF_MS) {
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
}
const obL1Origin = obL1Sched.stopTimes.find(s => s.stationId === dto.originStationId);
const obL1Dest = obL1Sched.stopTimes.find(s => s.stationId === dto.transitStationId);
const obL2Origin = obL2Sched.stopTimes.find(s => s.stationId === dto.transitStationId);
@@ -750,10 +771,10 @@ export class GuestBookingService {
const retL2ClassId = dto.returnLeg2SeatClassId ?? dto.seatClassId;
const [obL1Fare, obL2Fare, retL1Fare, retL2Fare] = await Promise.all([
this.getBaseFare(dto.scheduleId, dto.seatClassId, `${obL1Origin.station.code}-${obL1Dest.station.code}`, `${obL1Sched.originStation.code}-${obL1Sched.destinationStation.code}`, nat),
this.getBaseFare(dto.leg2ScheduleId!, obL2ClassId, `${obL2Origin.station.code}-${obL2Dest.station.code}`, `${obL2Sched.originStation.code}-${obL2Sched.destinationStation.code}`, nat),
this.getBaseFare(dto.returnScheduleId!, retL1ClassId, `${retL1Origin.station.code}-${retL1Dest.station.code}`, `${retL1Sched.originStation.code}-${retL1Sched.destinationStation.code}`, nat),
this.getBaseFare(dto.returnLeg2ScheduleId!,retL2ClassId, `${retL2Origin.station.code}-${retL2Dest.station.code}`, `${retL2Sched.originStation.code}-${retL2Sched.destinationStation.code}`, nat),
this.getBaseFare(dto.scheduleId, dto.seatClassId, `${obL1Origin.station.code}-${obL1Dest.station.code}`, `${obL1Sched.originStation.code}-${obL1Sched.destinationStation.code}`, nat, dto.originStationId, dto.transitStationId),
this.getBaseFare(dto.leg2ScheduleId!, obL2ClassId, `${obL2Origin.station.code}-${obL2Dest.station.code}`, `${obL2Sched.originStation.code}-${obL2Sched.destinationStation.code}`, nat, dto.transitStationId, dto.leg2DestinationStationId),
this.getBaseFare(dto.returnScheduleId!, retL1ClassId, `${retL1Origin.station.code}-${retL1Dest.station.code}`, `${retL1Sched.originStation.code}-${retL1Sched.destinationStation.code}`, nat, dto.returnOriginStationId, dto.returnTransitStationId),
this.getBaseFare(dto.returnLeg2ScheduleId!,retL2ClassId, `${retL2Origin.station.code}-${retL2Dest.station.code}`, `${retL2Sched.originStation.code}-${retL2Sched.destinationStation.code}`, nat, dto.returnTransitStationId, dto.returnLeg2DestinationStationId),
]);
const combinedBase = (obL1Fare + obL2Fare + retL1Fare + retL2Fare) * adultCount +
@@ -951,17 +972,28 @@ export class GuestBookingService {
segmentRoute?: string,
fullRoute?: string,
nationality?: string,
originStationId?: string,
destinationStationId?: string,
): Promise<number> {
const now = new Date();
// 1. FareRule table — explicit override rules
const candidates = await this.prisma.fareRule.findMany({
where: {
seatClassId,
validFrom: { lte: now },
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
},
});
// 1. FareRule table — explicit override rules (same priority logic as the fare engine)
const [candidates, seatClass] = await Promise.all([
this.prisma.fareRule.findMany({
where: {
seatClassId,
validFrom: { lte: now },
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
},
}),
this.prisma.seatClass.findUnique({
where: { id: seatClassId },
select: { premiumMinor: true, insuranceFeeMinor: true },
}),
]);
const premiumMinor = seatClass?.premiumMinor ?? 0;
const insuranceMinor = seatClass?.insuranceFeeMinor ?? 0;
const priorities = [
{ tripId: scheduleId, route: segmentRoute, nationality },
@@ -982,10 +1014,11 @@ export class GuestBookingService {
const match = candidates.find(
(c) => c.tripId === priority.tripId && c.route === priority.route && c.nationality === priority.nationality,
);
if (match) return match.baseFareMinor;
// Return base fare + seat-class surcharges so the booking total matches the quoted fare
if (match) return match.baseFareMinor + premiumMinor + insuranceMinor;
}
// 2. FareEngine — distance × rate-per-km from the schedule's route
// 2. FareEngine — distance × rate-per-km from the booking's actual segment stations
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
select: { routeId: true, originStationId: true, destinationStationId: true },
@@ -995,12 +1028,15 @@ export class GuestBookingService {
try {
const fare = await this.fareEngine.calculate({
routeId: schedule.routeId,
originStationId: schedule.originStationId,
destinationStationId: schedule.destinationStationId,
// Use the booking's boarding/alighting stations so the distance reflects the
// passenger's actual segment, not the full schedule route.
originStationId: originStationId ?? schedule.originStationId,
destinationStationId: destinationStationId ?? schedule.destinationStationId,
seatClassId,
nationality,
});
return fare.baseFarePerPassengerMinor;
// farePerPassengerMinor already includes base + premiumMinor + insuranceFeeMinor
return fare.farePerPassengerMinor;
} catch {
// FareEngine throws if distanceKm is missing; fall through to error
}

View File

@@ -1,13 +1,69 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import {
Injectable,
Logger,
NotFoundException,
BadRequestException,
} from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { Currency } from '@prisma/client';
/**
* Minor-unit decimal places per currency, used to round the CHARGE amount sent to the payment
* microservice. DJF has no minor unit (whole francs only); ETB and USD use 2 decimals.
*/
const CHARGE_CURRENCY_DECIMALS: Record<string, number> = {
ETB: 2,
USD: 2,
DJF: 0,
};
@Injectable()
export class CurrencyService {
private readonly logger = new Logger(CurrencyService.name);
constructor(private readonly prisma: PrismaService) {}
async convertEtbMinorToChargeMajor(
amountMinorEtb: number,
targetCurrency: string,
): Promise<number> {
const target = targetCurrency.toUpperCase();
const decimals = CHARGE_CURRENCY_DECIMALS[target];
if (decimals === undefined) {
throw new BadRequestException(`Unsupported charge currency: ${targetCurrency}`);
}
const sourceMajor = amountMinorEtb / 100;
if (target === Currency.ETB) {
return this.roundTo(sourceMajor, decimals);
}
const rate = await this.getRateOrThrow(Currency.ETB, target as Currency);
return this.roundTo(sourceMajor * rate, decimals);
}
async getRateOrThrow(
fromCurrency: Currency,
toCurrency: Currency,
): Promise<number> {
if (fromCurrency === toCurrency) return 1;
const exchangeRate = await this.prisma.currencyExchangeRate.findFirst({
where: { fromCurrency, toCurrency },
orderBy: { effectiveDate: 'desc' },
});
if (!exchangeRate) {
throw new BadRequestException(
`No exchange rate configured for ${fromCurrency}->${toCurrency}`,
);
}
return Number(exchangeRate.rate);
}
private roundTo(value: number, decimals: number): number {
const factor = 10 ** decimals;
return Math.round(value * factor) / factor;
}
async convertAmount(
amountMinor: number,
fromCurrency: Currency,

View File

@@ -4,14 +4,14 @@ import { Type } from 'class-transformer';
import { Currency } from '@prisma/client';
// Nationality → home currency mapping (keys are uppercase for case-insensitive lookup)
export const NATIONALITY_CURRENCY_MAP: Record<string, Currency> = {
ETHIOPIAN: Currency.ETB,
DJIBOUTIAN: Currency.DJF,
export const NATIONALITY_CURRENCY_MAP: Record<string, string> = {
ETHIOPIAN: 'ETB',
DJIBOUTIAN: 'DJF',
};
export function resolveCurrencyFromNationality(nationality?: string): Currency {
if (!nationality) return Currency.ETB;
return NATIONALITY_CURRENCY_MAP[nationality.toUpperCase()] ?? Currency.USD;
if (!nationality) return 'ETB' as Currency;
return (NATIONALITY_CURRENCY_MAP[nationality.toUpperCase()] ?? 'USD') as Currency;
}
export class FareCalculateDto {

Some files were not shown because too many files have changed in this diff Show More