resolve merge conflict

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

View File

@@ -144,15 +144,16 @@ jobs:
run: ./scripts/deploy/create-npmrc.sh
- name: Resolve env file path for ${{ matrix.service }}
if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service)
if: contains(fromJson('["freight-api", "passenger-api", "payment-api"]'), matrix.service)
run: |
case "${{ matrix.service }}" in
freight-api) echo "SERVICE_ENV_FILE=apps/edr-freight-api/.env" >> "$GITHUB_ENV" ;;
passenger-api) echo "SERVICE_ENV_FILE=apps/edr-passenger-api/.env" >> "$GITHUB_ENV" ;;
payment-api) echo "SERVICE_ENV_FILE=apps/edr-payment-api/.env" >> "$GITHUB_ENV" ;;
esac
- name: Build migration image for ${{ matrix.service }}
if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service)
if: contains(fromJson('["freight-api", "passenger-api", "payment-api"]'), matrix.service)
run: |
set -euo pipefail
docker build \
@@ -163,7 +164,7 @@ jobs:
.
- name: Run migrations for ${{ matrix.service }}
if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service)
if: contains(fromJson('["freight-api", "passenger-api", "payment-api"]'), matrix.service)
run: |
set -euo pipefail
docker run --rm --env-file "${SERVICE_ENV_FILE}" "${COMPOSE_PROJECT_NAME}-${{ matrix.service }}-migration"

View File

@@ -154,13 +154,32 @@ no timeout and will wait forever.
Migrations are the most dangerous surface in this repo. Two production-grade incidents have
already come from it.
- `migrationsRun: true`**migrations run automatically on API boot**, with
`migrationsTransactionMode: 'each'`.
- `migrationsRun: false`**migrations do NOT run on API boot.** They run as a separate
one-shot step, via the Dockerfile's `migration` build target (`docker build --target
migration`), with `migrationsTransactionMode: 'each'`.
- CI: `.github/workflows/deploy.yml` builds the `migration` image and runs it
(`docker run --rm --env-file ...`) *before* building/deploying the app image.
- e2e: `docker-compose.e2e.yaml`'s `freight-migration-e2e` service runs once and
`freight-api-e2e` depends on it (`condition: service_completed_successfully`).
- Local dev (`docker-compose.yaml`) has no equivalent migration service yet — run
migrations yourself before `docker compose up freight-api`, e.g.
`docker build --target migration -f apps/edr-freight-api/Dockerfile -t freight-migration .`
then `docker run --rm --env-file apps/edr-freight-api/.env freight-migration`. Don't
use `pnpm run migrate` for this — it runs via `ts-node`, which never writes compiled
output to `dist/`, and the freight migrations glob only matches `dist/migrations/*.js`.
It silently applies zero freight migrations while exiting 0.
- Consequences you must design for:
- Running several `nest start --watch` instances races `migrationsRun`. A non-idempotent
data migration can execute twice. Keep one instance.
- A watch-mode hot reload does **not** re-run migrations. If you add a column that new
code reads, apply it to the dev database yourself (idempotently) or fully restart.
- `apps/edr-freight-api/src/config/database.config.ts`'s `iamEntities` array is a
hand-maintained list of `@tria-plc/iamapi-common` entity classes. The live app never
notices when it's stale (`autoLoadEntities: true` papers over gaps via IAM's own
`forFeature()` registrations), but the standalone migration `DataSource`
(`data-source.ts`, no `autoLoadEntities`) does not have that fallback — a missing
entity throws `Entity metadata for X#y was not found` at `initialize()`, before a
single migration runs. **Every `@tria-plc/iamapi-common` version bump is a candidate
for this to break again** — diff the package's entity classes against `iamEntities`
when bumping it.
- **Give every migration a unique timestamp.** 34 timestamps are currently shared by two or
more migrations. TypeORM orders by timestamp and breaks ties non-deterministically. Before
adding one, check the filename prefix is unused *and* higher than the newest recorded row.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,26 @@
import { BookingsService } from './bookings.service';
// The split is a pure helper on the prototype (never touches `this`), so it can be
// exercised without constructing the service and its dependency graph.
const split = (total: number, weights: number[]): number[] =>
(
BookingsService.prototype as unknown as {
splitAmountAcrossWagons(total: number, weights: number[]): number[];
}
).splitAmountAcrossWagons(total, weights);
describe('carriage acceptance sheet — price split', () => {
it('splits proportionally to allocated weight', () => {
expect(split(100, [30, 10])).toEqual([75, 25]);
});
it('splits equally when no weights are recorded', () => {
expect(split(90, [0, 0, 0])).toEqual([30, 30, 30]);
});
it('always sums back to the booking total despite rounding', () => {
const shares = split(100, [1, 1, 1]);
expect(shares.reduce((a, b) => a + b, 0)).toBe(100);
expect(shares).toEqual([33.33, 33.33, 33.34]);
});
});

View File

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

View File

@@ -10,12 +10,17 @@ import { POA_DELEGATION_FILE_KEY } from "../file-upload-settings/poa-delegation.
* come from the verified payload, not typed. Fayda's userinfo carries no
* national ID number, so none is collected or derived here.
*
* - Ethiopian company: the owner (and its PoA, once named) is verified through
* Fayda, and their details can't be edited afterwards.
* Only the OWNER's credential varies by nationality:
* - Ethiopian company: the owner is verified through Fayda.
* - Foreign company: Fayda is an Ethiopian national ID, so the owner instead
* supplies a typed passport number — required on its own, whether or not the
* owner also completes a (purely optional) Fayda verification.
*
* The PoA does not vary. A representative acts for the company inside Ethiopia
* whoever owns it, so a PoA is always an Ethiopian holding a Fayda ID: once one
* is named, both nationalities must verify them, and their details come from
* the verified payload rather than the form.
*
* The owner is NOT the general manager — GM is a separate, plain typed role
* the portal offers a "same as owner" copy for, but it is never itself
* Fayda-verified or gated on.
@@ -107,6 +112,7 @@ function makeService(overrides: Partial<Ctx> = {}) {
},
changeRequestRepo: {
findPendingByCompanyId: jest.fn(async () => null),
findLatestOpenByCompanyId: jest.fn(async () => null),
findByCompanyId: jest.fn(async () => []),
create: jest.fn(async (row: Record<string, unknown>) => ({
id: "cr-1",
@@ -114,6 +120,13 @@ function makeService(overrides: Partial<Ctx> = {}) {
})),
update: jest.fn(async () => ({ id: "cr-1" })),
},
revisionRepo: {
create: jest.fn(async (row: Record<string, unknown>) => ({
id: "rev-1",
...row,
})),
findByCompanyId: jest.fn(async () => []),
},
profilesRepo: {
findByCompanyId: jest.fn(async () => []),
findByUserId: jest.fn(async () => ({
@@ -138,6 +151,7 @@ function makeService(overrides: Partial<Ctx> = {}) {
deps.companiesRepo as never,
deps.companyProfilesRepo as never,
deps.changeRequestRepo as never,
deps.revisionRepo as never,
deps.profilesRepo as never,
{} as never,
deps.filesService as never,
@@ -229,9 +243,27 @@ describe("Fayda identity verification binds a person to the company", () => {
).rejects.toBeInstanceOf(BadRequestException);
});
it("stages the change for review on an approved company", async () => {
// Swapping the person who can act for a live company is exactly what the
// backoffice review exists for, so it must not rewrite the row directly.
it("stages an owner re-verification for review on an approved company", async () => {
// The owner is the live company's identity proof, so re-verifying one is
// exactly what the backoffice review exists for: it must not rewrite the
// row directly.
const { service, ctx, deps } = makeService({
status: CompanyStatus.Active,
});
await service.completeIdentityVerification("user-1", {
subject: "owner",
code: "c",
state: "s",
});
expect(deps.changeRequestRepo.create).toHaveBeenCalled();
expect(ctx.attributes.ownerFaydaSub).toBeUndefined();
});
it("applies a PoA verification live on an approved company", async () => {
// The PoA is personnel the company names for itself — the delegation paper
// is what a reviewer actually judges — so it does not go to review.
const { service, ctx, deps } = makeService({
status: CompanyStatus.Active,
});
@@ -242,8 +274,8 @@ describe("Fayda identity verification binds a person to the company", () => {
state: "s",
});
expect(deps.changeRequestRepo.create).toHaveBeenCalled();
expect(ctx.attributes.poaFaydaSub).toBeUndefined();
expect(deps.changeRequestRepo.create).not.toHaveBeenCalled();
expect(ctx.attributes.poaFaydaSub).toBe("new-sub");
});
it("refuses to rename a verified person by hand", async () => {
@@ -367,7 +399,29 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi
).rejects.toBeInstanceOf(BadRequestException);
});
it("grants the forwarder role to a foreign company with an owner passport and no Fayda at all", async () => {
it("grants the forwarder role to a foreign company whose owner has a passport and whose PoA is Fayda-verified", async () => {
const { service } = makeService({
profileTypes: [ProfileType.importer],
nationality: CompanyNationality.Foreign,
attributes: {
ownerPassportNumber: "P1234567",
...POA_VERIFIED,
},
files: [paper()],
});
await expect(
service.createCompanyProfileForUser(
"user-1",
ProfileType.freightForwarder,
),
).resolves.toBeDefined();
});
it("still requires a Fayda-verified PoA from a foreign company", async () => {
// The owner's credential is nationality-specific; the representative's is
// not. A PoA acts for the company inside Ethiopia whoever owns it, so a
// typed foreign name is not a representative the platform can accept.
const { service } = makeService({
profileTypes: [ProfileType.importer],
nationality: CompanyNationality.Foreign,
@@ -385,7 +439,7 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi
"user-1",
ProfileType.freightForwarder,
),
).resolves.toBeDefined();
).rejects.toBeInstanceOf(BadRequestException);
});
it("still requires the passport for a foreign owner who chose to verify with Fayda too", async () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -144,9 +144,14 @@ export function buildCompanyIdentityState(
(p) => p.type === ProfileType.freightForwarder,
) || POA_KEYS.some((k) => (attrs[k] as string | undefined)?.trim());
const complete = faydaRequired
? owner.verified && (!poaDue || poa.verified)
// Only the *owner's* credential is nationality-specific. A Power of Attorney
// acts for the company inside Ethiopia whoever owns it, so the PoA is always
// proven with Fayda — a foreign company nominates a representative who holds
// one rather than typing a name nothing backs.
const ownerProven = faydaRequired
? owner.verified
: !passportRequired || Boolean(owner.passportNumber);
const complete = ownerProven && (!poaDue || poa.verified);
return { faydaRequired, passportRequired, owner, poa, complete };
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,17 @@
import { BadGatewayException, Injectable, Logger } from "@nestjs/common";
import {
BadGatewayException,
BadRequestException,
Injectable,
Logger,
} from "@nestjs/common";
import { HttpService } from "@nestjs/axios";
import { AxiosError } from "axios";
import { firstValueFrom } from "rxjs";
import {
InitiatePaymentRequest,
PaymentIntentSnapshot,
PaymentReferenceType,
PaymentService,
InitiatePaymentRequest,
PaymentIntentSnapshot,
PaymentReferenceType,
PaymentService,
} from "@edr/types";
/**
@@ -16,85 +21,125 @@ import {
*/
@Injectable()
export class PaymentClientService {
private readonly logger = new Logger(PaymentClientService.name);
private readonly baseUrl = (
// process.env.PAYMENT_API_URL ??
"https://paymentcallback.triaplc.com"
// "http://localhost:3003"
).replace(/\/$/, "");
private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? "";
private readonly logger = new Logger(PaymentClientService.name);
private readonly baseUrl = (
process.env.PAYMENT_API_URL ?? "https://paymentcallback.triaplc.com"
)
// "http://localhost:3003"
.replace(/\/$/, "");
private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? "";
constructor(private readonly http: HttpService) { }
constructor(private readonly http: HttpService) { }
/** POST /payments/initiate — idempotent per (service, referenceType, referenceId). */
async initiate(request: InitiatePaymentRequest): Promise<PaymentIntentSnapshot> {
return this.call("POST", "/payments/initiate", request);
/** POST /payments/initiate — idempotent per (service, referenceType, referenceId). */
async initiate(
request: InitiatePaymentRequest,
): Promise<PaymentIntentSnapshot> {
return this.call("POST", "/payments/initiate", request);
}
/**
* POST /payments/reconcile — settlement check for a domain order
* (reconcile-before-cancel). Live-queries every non-failed intent at the
* provider and registers any late capture found (flips it to SUCCEEDED and
* emits payment.succeeded). `unverifiable: true` = could not confirm
* "not paid" — the caller must NOT cancel/expire the order.
*/
async reconcileReference(
referenceType: PaymentReferenceType,
referenceId: string,
): Promise<{ paid: boolean; unverifiable: boolean }> {
return this.call("POST", "/payments/reconcile", {
service: PaymentService.FREIGHT,
referenceType,
referenceId,
});
}
/** GET /payments/intents?… — active intent by domain reference; null when none exists. */
async getIntentByReference(
referenceType: PaymentReferenceType,
referenceId: string,
): Promise<PaymentIntentSnapshot | null> {
const query = new URLSearchParams({
service: PaymentService.FREIGHT,
referenceType,
referenceId,
});
try {
return await this.call("GET", `/payments/intents?${query.toString()}`);
} catch (err) {
if (err instanceof AxiosError && err.response?.status === 404)
return null;
throw err;
}
}
/**
* POST /payments/reconcile — settlement check for a domain order
* (reconcile-before-cancel). Live-queries every non-failed intent at the
* provider and registers any late capture found (flips it to SUCCEEDED and
* emits payment.succeeded). `unverifiable: true` = could not confirm
* "not paid" — the caller must NOT cancel/expire the order.
*/
async reconcileReference(
referenceType: PaymentReferenceType,
referenceId: string,
): Promise<{ paid: boolean; unverifiable: boolean }> {
return this.call("POST", "/payments/reconcile", {
service: PaymentService.FREIGHT,
referenceType,
referenceId,
});
/**
* POST /payments/intents/:id/confirm — submit an OTP for a COLLECT_OTP provider
* (CAC Bank). A wrong/expired OTP comes back as 400 from the payment service;
* surface that as a BadRequest (retryable) rather than a 502, so the payer can
* re-enter the code.
*/
async confirmOtp(
intentId: string,
otp: string,
): Promise<PaymentIntentSnapshot> {
try {
return await this.call<PaymentIntentSnapshot>(
"POST",
`/payments/intents/${intentId}/confirm`,
{ otp },
);
} catch (err) {
// `call` re-throws raw 404s and masks every other 4xx as BadGateway; an
// unknown intent or a bad OTP is client-fixable, so translate both to 400.
if (err instanceof AxiosError && err.response?.status === 404) {
throw new BadRequestException("PaymentIntent not found");
}
if (err instanceof BadGatewayException) {
const detail = err.message.replace(/^Payment service error: /, "");
throw new BadRequestException(detail);
}
throw err;
}
}
/** GET /payments/intents?… — active intent by domain reference; null when none exists. */
async getIntentByReference(
referenceType: PaymentReferenceType,
referenceId: string,
): Promise<PaymentIntentSnapshot | null> {
const query = new URLSearchParams({
service: PaymentService.FREIGHT,
referenceType,
referenceId,
});
try {
return await this.call("GET", `/payments/intents?${query.toString()}`);
} catch (err) {
if (err instanceof AxiosError && err.response?.status === 404) return null;
throw err;
}
}
private async call<T>(method: "GET" | "POST", path: string, body?: unknown): Promise<T> {
const url = `${this.baseUrl}${path}`;
try {
const response = await firstValueFrom(
this.http.request<T>({
method,
url,
data: body,
headers: this.serviceToken
? { "x-service-token": this.serviceToken }
: {},
}),
);
return response.data;
} catch (err) {
if (err instanceof AxiosError && err.response) {
if (err.response.status === 404) throw err;
const detail =
(err.response.data as { message?: string | string[] })?.message ??
err.message;
this.logger.error(
`payment service ${method} ${path}${err.response.status}: ${detail}`,
);
throw new BadGatewayException(`Payment service error: ${detail}`);
}
const message = err instanceof Error && err.message ? err.message : String(err);
this.logger.error(`payment service unreachable (${method} ${path}): ${message}`);
throw new BadGatewayException("Payment service unreachable");
}
private async call<T>(
method: "GET" | "POST",
path: string,
body?: unknown,
): Promise<T> {
const url = `${this.baseUrl}${path}`;
try {
const response = await firstValueFrom(
this.http.request<T>({
method,
url,
data: body,
headers: this.serviceToken
? { "x-service-token": this.serviceToken }
: {},
}),
);
return response.data;
} catch (err) {
if (err instanceof AxiosError && err.response) {
if (err.response.status === 404) throw err;
const detail =
(err.response.data as { message?: string | string[] })?.message ??
err.message;
this.logger.error(
`payment service ${method} ${path}${err.response.status}: ${detail}`,
);
throw new BadGatewayException(`Payment service error: ${detail}`);
}
const message =
err instanceof Error && err.message ? err.message : String(err);
this.logger.error(
`payment service unreachable (${method} ${path}): ${message}`,
);
throw new BadGatewayException("Payment service unreachable");
}
}
}

View File

@@ -56,7 +56,13 @@ function rabbitMQImport(): DynamicModule[] {
@Module({
imports: [
HttpModule.register({ timeout: 10_000 }),
// CAC Bank's initiate SMSes an OTP and routinely takes >10s, so the old
// 10s cap 502'd every CAC charge while the bank was still working —
// orphaning an intent the payer had already been texted about. Matches
// the passenger API's budget.
HttpModule.register({
timeout: Number(process.env.PAYMENT_API_HTTP_TIMEOUT_MS) || 60_000,
}),
ConfigModule,
forwardRef(() => BillingModule),
// forwardRef(() => TrainSchedulingModule),

View File

@@ -0,0 +1,190 @@
import { BadRequestException, NotFoundException } from "@nestjs/common";
import { of, throwError } from "rxjs";
import { AxiosError, AxiosHeaders } from "axios";
import { PaymentReferenceType, ProviderPaymentStatus } from "@edr/types";
import { PaymentClientService } from "./payment-client.service";
import { PaymentService } from "./payment.service";
/** Local intent projection row (the invoice's `paymentId` points at this). */
function localIntent(overrides: Record<string, unknown> = {}) {
return {
id: "intent-1",
refId: "booking-1",
referenceType: PaymentReferenceType.SHIPMENT,
status: "action-required",
method: "cac-bank",
merchantOrderId: "EDR_INV_1",
clientAction: { type: "COLLECT_OTP", providerOrderId: "471583397" },
...overrides,
};
}
function makeRepo(rows: Record<string, unknown>[]) {
const store = [...rows];
return {
findOneBy: jest.fn((where: Record<string, unknown>) =>
Promise.resolve(
store.find((r) =>
Object.entries(where).every(([k, v]) => r[k] === v),
) ?? null,
),
),
update: jest.fn((where: { id: string }, data: Record<string, unknown>) => {
const row = store.find((r) => r.id === where.id);
if (row) Object.assign(row, data);
return Promise.resolve(undefined);
}),
};
}
describe("PaymentService.confirmOtp", () => {
const build = (
client: Partial<PaymentClientService>,
rows = [localIntent()],
) => {
const repo = makeRepo(rows);
const billing = { settleByPaymentId: jest.fn().mockResolvedValue(null) };
const service = new PaymentService(
repo as never,
client as never,
billing as never,
);
return { service, repo, billing };
};
it("settles the local intent and tells billing to settle the invoice on SUCCEEDED", async () => {
const paidAt = "2026-07-31T10:00:00.000Z";
const { service, repo, billing } = build({
getIntentByReference: jest
.fn()
.mockResolvedValue({ intentId: "gw-1", status: "REQUIRES_ACTION" }),
confirmOtp: jest.fn().mockResolvedValue({
intentId: "gw-1",
status: ProviderPaymentStatus.SUCCEEDED,
providerTxnId: "11709363209530624",
paidAt,
}),
});
const result = await service.confirmOtp("intent-1", "8280");
expect(repo.update).toHaveBeenCalledWith(
{ id: "intent-1" },
expect.objectContaining({
status: "success",
transactionId: "11709363209530624",
}),
);
// Billing settles the invoice linked by this intent id.
expect(billing.settleByPaymentId).toHaveBeenCalledWith(
"intent-1",
"11709363209530624",
new Date(paidAt),
);
expect(result.status).toBe(ProviderPaymentStatus.SUCCEEDED);
});
it("forwards the OTP against the GATEWAY intent id, not the local one", async () => {
const confirmOtp = jest
.fn()
.mockResolvedValue({ status: ProviderPaymentStatus.REQUIRES_ACTION });
const { service } = build({
getIntentByReference: jest.fn().mockResolvedValue({ intentId: "gw-1" }),
confirmOtp,
});
await service.confirmOtp("intent-1", "8280");
expect(confirmOtp).toHaveBeenCalledWith("gw-1", "8280");
});
it("leaves the intent open and does not settle when the OTP is not accepted", async () => {
const { service, repo, billing } = build({
getIntentByReference: jest.fn().mockResolvedValue({ intentId: "gw-1" }),
confirmOtp: jest.fn().mockResolvedValue({
status: ProviderPaymentStatus.REQUIRES_ACTION,
failureMessage: "OTP confirmation failed",
}),
});
const result = await service.confirmOtp("intent-1", "0000");
expect(billing.settleByPaymentId).not.toHaveBeenCalled();
expect(repo.update).toHaveBeenCalledWith(
{ id: "intent-1" },
expect.objectContaining({ status: "action-required" }),
);
expect(result.status).toBe(ProviderPaymentStatus.REQUIRES_ACTION);
});
it("404s when the gateway has no active intent for the reference", async () => {
const { service } = build({
getIntentByReference: jest.fn().mockResolvedValue(null),
confirmOtp: jest.fn(),
});
await expect(service.confirmOtp("intent-1", "8280")).rejects.toBeInstanceOf(
NotFoundException,
);
});
});
describe("PaymentClientService.confirmOtp", () => {
const axiosErr = (status: number, message: string) =>
new AxiosError(
`Request failed with status code ${status}`,
undefined,
undefined,
undefined,
{
status,
statusText: "",
data: { message },
headers: new AxiosHeaders(),
config: { headers: new AxiosHeaders() },
},
);
const build = (request: jest.Mock) =>
new PaymentClientService({ request } as never);
it("posts the OTP to the payment service intent-confirm route", async () => {
const request = jest
.fn()
.mockReturnValue(of({ data: { intentId: "gw-1", status: "SUCCEEDED" } }));
const result = await build(request).confirmOtp("gw-1", "8280");
expect(request).toHaveBeenCalledWith(
expect.objectContaining({
method: "POST",
url: expect.stringContaining("/payments/intents/gw-1/confirm"),
data: { otp: "8280" },
}),
);
expect(result.status).toBe("SUCCEEDED");
});
it("maps a rejected OTP (400) to BadRequest so the payer can retry", async () => {
const request = jest
.fn()
.mockReturnValue(
throwError(() => axiosErr(400, "OTP confirmation failed")),
);
await expect(build(request).confirmOtp("gw-1", "0000")).rejects.toBeInstanceOf(
BadRequestException,
);
});
it("maps an unknown intent (404) to BadRequest rather than a gateway error", async () => {
const request = jest
.fn()
.mockReturnValue(throwError(() => axiosErr(404, "PaymentIntent not found")));
await expect(build(request).confirmOtp("nope", "8280")).rejects.toBeInstanceOf(
BadRequestException,
);
});
});

View File

@@ -51,6 +51,10 @@ export interface InitiateIntentInput {
payerAccount?: string;
returnUrl?: string;
failureUrl?: string;
/** CBE_BILL: payer full name snapshot (feeds CBE's mandatory Full_Name). */
payerName?: string;
/** CBE_BILL: intent expiry, ISO-8601 — the invoice due date, never a session TTL. */
expiresAt?: string;
}
export interface InitiateIntentResult {
@@ -79,6 +83,7 @@ const PROVIDER_TO_METHOD: Record<string, PaymentEntity["method"]> = {
CARD: "card",
DMONEY: "dmoney",
CAC_BANK: "cac-bank",
CBE_BILL: "cbe-bill",
};
/**
@@ -214,8 +219,13 @@ export class PaymentService {
async initiate(input: InitiateIntentInput): Promise<InitiateIntentResult> {
try {
const isCbeBill = input.method === ProviderMethod.CBE_BILL;
// CBE settles ETB only (docs/cbe/CBE_IMPLEMENTATION_PLAN.md D8).
if (isCbeBill && input.currency?.toUpperCase() !== "ETB") {
throw new BadRequestException(
"CBE bill payment is only available for ETB invoices",
);
}
const snapshot = await this.paymentClient.initiate({
service: PaymentServiceEnum.FREIGHT,
@@ -223,11 +233,15 @@ export class PaymentService {
referenceId: input.referenceId,
orderRef: input.orderRef,
// amountMinor: input.amountMinor,
amountMinor:1,
// CBE_BILL must carry the REAL amount: /cbe/payment verifies what the customer was
// debited against the intent amount, so the 1-birr dev shortcut would break it.
amountMinor: isCbeBill ? input.amountMinor : 1,
currency: input.currency,
provider: input.method as ProviderMethod,
platform: input.platform,
payerAccount: input.payerAccount,
payerName: input.payerName,
expiresAt: input.expiresAt,
returnUrl:
input.returnUrl ?? "https://edrfreight.triaplc.com/payment/success",
failureUrl:
@@ -374,6 +388,53 @@ export class PaymentService {
return this.formatIntentStatus(refreshed ?? local);
}
/**
* Submit an OTP for a COLLECT_OTP provider (CAC Bank). Keyed by the LOCAL intent
* id (the invoice's `paymentId`) so the right invoice settles even when several
* invoices share a domain reference. The active gateway intent is looked up by
* reference, the OTP is forwarded, and the projection is refreshed. On success
* billing settles the linked invoice (idempotent — the outbox path converges too).
* A wrong/expired OTP bubbles up as a 400 and leaves the intent open for retry.
*/
async confirmOtp(intentId: string, otp: string): Promise<IntentStatusDto> {
const local = await this.paymentRepo.findOneBy({ id: intentId });
if (!local) throw new NotFoundException("PaymentIntent not found");
const snapshot = await this.paymentClient.getIntentByReference(
(local.referenceType as PaymentReferenceType) ??
PaymentReferenceType.SHIPMENT,
local.refId,
);
if (!snapshot) {
throw new NotFoundException("No active payment to confirm");
}
const confirmed = await this.paymentClient.confirmOtp(
snapshot.intentId,
otp,
);
if (confirmed.status === ProviderPaymentStatus.SUCCEEDED) {
await this.markIntentSucceeded(local.id, {
providerTxnId: confirmed.providerTxnId,
paidAt: confirmed.paidAt ? new Date(confirmed.paidAt) : undefined,
notify: true,
});
} else {
await this.paymentRepo.update(
{ id: local.id },
{
status: this.toLocalStatus(confirmed.status),
failerCode: confirmed.failureCode ?? undefined,
failureMessage: confirmed.failureMessage ?? undefined,
},
);
}
const refreshed = await this.paymentRepo.findOneBy({ id: local.id });
return this.formatIntentStatus(refreshed ?? local);
}
/**
* Mark a gateway intent paid and (by default) notify billing to settle the
* linked invoice. Idempotent — no-op when already success. Pass `notify: false`

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -118,6 +118,8 @@ import TrainDetailPage from "./pages/trains/TrainDetailPage";
import TrainBuilderDetailPage from "./pages/trainBuilder/TrainBuilderDetailPage";
import TrainBuilderListPage from "./pages/trainBuilder/TrainBuilderListPage";
import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage";
import EDRLastMileReturnsPage from "./pages/warehouses/EDRLastMileReturnsPage";
import ContainerReturnsPage from "./pages/warehouses/ContainerReturnsPage";
import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage";
import ExportDjiboutiUnloadingQueuePage from "./pages/warehouses/ExportDjiboutiUnloadingQueuePage";
import ExportWarehouseFlowPage from "./pages/warehouses/ExportWarehouseFlowPage";
@@ -411,6 +413,18 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
icon: <Truck />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "EDR Last Mile Returns",
href: "/dashboard/edr-last-mile-returns",
icon: <Container />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Container Returns",
href: "/dashboard/container-returns",
icon: <Container />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Dispatch Queue",
href: "/dashboard/dispatch-queue",
@@ -1060,6 +1074,8 @@ const App = () => {
<Route path="intercity" element={<IntercityPage />} />
<Route path="trucks-on-site" element={<TrucksOnSitePage />} />
<Route path="import-trucks" element={<ImportTrucksPage />} />
<Route path="edr-last-mile-returns" element={<EDRLastMileReturnsPage />} />
<Route path="container-returns" element={<ContainerReturnsPage />} />
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
<Route

View File

@@ -2,7 +2,6 @@ import {
Alert,
Anchor,
Badge,
Box,
Button,
Card,
Group,
@@ -27,11 +26,11 @@ import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { fetchViewableFile } from "@/services/files.service";
import { api } from "@/services/api";
import type { Company, CompanyChangeRequest } from "@/types/customer";
import type { Company } from "@/types/customer";
import { formatDate, humanize } from "./format";
/** Friendly labels for the proposed-change snapshot keys (UpdateProfileDto). */
const FIELD_LABELS: Record<string, string> = {
export const FIELD_LABELS: Record<string, string> = {
companyName: "Company name",
companyEmail: "Company email",
companyPhone: "Company phone",
@@ -69,7 +68,7 @@ const FIELD_LABELS: Record<string, string> = {
};
/** Best-effort current value on the live company for a proposed field key. */
function currentValue(company: Company, key: string): string {
export function currentValue(company: Company, key: string): string {
const c = company as unknown as Record<string, unknown>;
const attrs = (company.attributes ?? {}) as Record<string, unknown>;
const map: Record<string, unknown> = {
@@ -160,7 +159,7 @@ function FaydaIdentityDiff({
);
}
function DiffRow({
export function DiffRow({
label,
from,
to,
@@ -201,8 +200,9 @@ function DiffRow({
/**
* Backoffice review surface for a customer's staged profile edits. Shows the
* pending change request as a proposed-vs-current diff with Approve / Reject
* (with note) actions, plus a short history of past decisions.
* pending change request as a proposed-vs-current diff with Approve / Reject /
* Request changes actions. Past decisions live in the History tab's unified
* timeline (see {@link CompanyTimeline}), not here.
*/
export function ChangeRequestReview({ company }: { company: Company }) {
const { user } = useAuth();
@@ -216,16 +216,21 @@ export function ChangeRequestReview({ company }: { company: Company }) {
const reject = useMutation(
api.customers.rejectChangeRequest.mutationOptions(),
);
const requestChanges = useMutation(
api.customers.requestChangeRequestChanges.mutationOptions(),
);
const { view, viewer } = useFileViewer();
const [rejectId, setRejectId] = useState<string | null>(null);
const [actionTarget, setActionTarget] = useState<{
id: string;
kind: "reject" | "request-changes";
} | null>(null);
const [note, setNote] = useState("");
const requests = query.data ?? [];
const pending = requests.find((r) => r.status === "pending");
const history = requests.filter((r) => r.status !== "pending").slice(0, 5);
if (!pending && history.length === 0) return null;
if (!pending) return null;
const proposedKeys = pending
? Object.keys(pending.snapshot ?? {}).filter((k) => k !== "faydaIdentity")
@@ -237,13 +242,14 @@ export function ChangeRequestReview({ company }: { company: Company }) {
const licenseChanges = pending?.licenseChanges ?? [];
const documentChanges = pending?.documentChanges ?? [];
const confirmReject = () => {
if (!rejectId) return;
reject.mutate(
{ id: rejectId, note: note.trim() },
const confirmAction = () => {
if (!actionTarget) return;
const mutation = actionTarget.kind === "reject" ? reject : requestChanges;
mutation.mutate(
{ id: actionTarget.id, note: note.trim() },
{
onSuccess: () => {
setRejectId(null);
setActionTarget(null);
setNote("");
},
},
@@ -270,6 +276,18 @@ export function ChangeRequestReview({ company }: { company: Company }) {
</Text>
</Group>
{pending.note && (
<Alert
color="yellow"
variant="light"
icon={<AlertTriangle size={16} />}
>
Changes were requested on an earlier round of this same
submission: <strong>{pending.note}</strong> check whether
this resubmission actually addresses it before approving.
</Alert>
)}
{proposedKeys.length > 0 ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
{proposedKeys.map((key) => (
@@ -418,12 +436,22 @@ export function ChangeRequestReview({ company }: { company: Company }) {
variant="light"
color="red"
onClick={() => {
setRejectId(pending.id);
setActionTarget({ id: pending.id, kind: "reject" });
setNote("");
}}
>
Reject
</Button>
<Button
variant="light"
color="yellow"
onClick={() => {
setActionTarget({ id: pending.id, kind: "request-changes" });
setNote("");
}}
>
Request changes
</Button>
<Button
color="edr-green"
loading={approve.isPending}
@@ -437,51 +465,31 @@ export function ChangeRequestReview({ company }: { company: Company }) {
</Card>
)}
{history.length > 0 && (
<Card withBorder>
<Stack gap="sm">
<Text fw={600} c="edr-text">
Review history
</Text>
{history.map((r: CompanyChangeRequest) => (
<Group key={r.id} gap="sm" wrap="nowrap" align="flex-start">
<Badge
color={r.status === "approved" ? "edr-green" : "red"}
variant="light"
radius="md"
tt="capitalize"
>
{r.status}
</Badge>
<Box style={{ flex: 1 }}>
<Text size="sm" c="edr-text">
{formatDate(r.reviewedAt ?? r.updatedAt)}
</Text>
{r.note && (
<Text size="xs" c="dimmed">
Note: {r.note}
</Text>
)}
</Box>
</Group>
))}
</Stack>
</Card>
)}
<Modal
opened={rejectId !== null}
onClose={() => setRejectId(null)}
title="Reject changes"
opened={actionTarget !== null}
onClose={() => setActionTarget(null)}
title={
actionTarget?.kind === "reject" ? "Reject changes" : "Request changes"
}
centered
radius="lg"
>
<Stack gap="md">
<Alert color="red" variant="light" icon={<AlertTriangle size={18} />}>
The customer will see this note and can amend and resubmit.
<Alert
color={actionTarget?.kind === "reject" ? "red" : "yellow"}
variant="light"
icon={<AlertTriangle size={18} />}
>
{actionTarget?.kind === "reject"
? "The customer will see this note and can amend and resubmit."
: "The customer will see this note and can keep editing this same request — no need to start over."}
</Alert>
<Textarea
label="Reason for rejection"
label={
actionTarget?.kind === "reject"
? "Reason for rejection"
: "What needs to change"
}
placeholder="e.g. The company address doesn't match the trade license."
autosize
minRows={3}
@@ -492,18 +500,20 @@ export function ChangeRequestReview({ company }: { company: Company }) {
<Group justify="flex-end" gap="sm">
<Button
variant="default"
onClick={() => setRejectId(null)}
disabled={reject.isPending}
onClick={() => setActionTarget(null)}
disabled={reject.isPending || requestChanges.isPending}
>
Cancel
</Button>
<Button
color="red"
loading={reject.isPending}
color={actionTarget?.kind === "reject" ? "red" : "yellow"}
loading={reject.isPending || requestChanges.isPending}
disabled={note.trim().length === 0}
onClick={confirmReject}
onClick={confirmAction}
>
Reject changes
{actionTarget?.kind === "reject"
? "Reject changes"
: "Request changes"}
</Button>
</Group>
</Stack>

View File

@@ -0,0 +1,299 @@
import { Alert, Anchor, Badge, Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { FilePlus2, FileX2, History } from "lucide-react";
import { useFileViewer } from "@edr/ui-common";
import { fetchViewableFile } from "@/services/files.service";
import { api } from "@/services/api";
import type {
Company,
CompanyChangeRequest,
CompanyRevision,
CompanyRevisionChange,
DocumentChangeIntent,
LicenseChangeIntent,
} from "@/types/customer";
import { DiffRow, FIELD_LABELS, currentValue } from "./ChangeRequestReview";
import { formatDate, humanize } from "./format";
interface DocDiff {
key: string;
label: string;
fromFile: { id: string; name: string } | null;
toFile: { id: string; name: string } | null;
}
interface FieldDiff {
key: string;
label: string;
from: string;
to: string;
}
interface TimelineEntry {
id: string;
kind: "approved" | "rejected" | "changes_requested" | "revision";
at: string;
note?: string | null;
summary?: string;
fieldDiffs: FieldDiff[];
docDiffs: DocDiff[];
}
const KIND_BADGE: Record<TimelineEntry["kind"], { label: string; color: string }> = {
approved: { label: "Approved", color: "edr-green" },
rejected: { label: "Rejected", color: "red" },
changes_requested: { label: "Changes requested", color: "yellow" },
revision: { label: "Recorded", color: "blue" },
};
/**
* Pair adjacent remove-then-add intents into one before/after doc diff — a
* "replace" is always staged as `[{op:'remove'}, {op:'add'}]` pushed together
* (see `replaceProfileLicenseFile` and friends), and later merges only ever
* append after that pair, so adjacency survives. A remove or add with no
* adjacent partner stands alone.
*/
function pairIntents(
intents: (LicenseChangeIntent | DocumentChangeIntent)[],
labelFor: (intent: LicenseChangeIntent | DocumentChangeIntent) => string,
): DocDiff[] {
const diffs: DocDiff[] = [];
let i = 0;
while (i < intents.length) {
const current = intents[i];
const next = intents[i + 1];
if (current.op === "remove" && next?.op === "add") {
diffs.push({
key: `${current.fileId}-${next.fileId}`,
label: labelFor(next),
fromFile: { id: current.fileId, name: current.fileName ?? "Document" },
toFile: { id: next.fileId, name: next.fileName ?? "Document" },
});
i += 2;
continue;
}
diffs.push({
key: `${current.fileId}-${i}`,
label: labelFor(current),
fromFile:
current.op === "remove"
? { id: current.fileId, name: current.fileName ?? "Document" }
: null,
toFile:
current.op === "add"
? { id: current.fileId, name: current.fileName ?? "Document" }
: null,
});
i += 1;
}
return diffs;
}
/**
* Historical field diffs on a change request only ever recorded the proposed
* ("to") value — there is no stored "before" snapshot — so `from` reads the
* CURRENT company value. That's exact for the most recent entry; for an older
* one it can drift if the field changed again since. A real limitation of the
* data model, not something this view can reconstruct.
*/
function fromChangeRequest(
r: CompanyChangeRequest,
company: Company,
): TimelineEntry {
const proposedKeys = Object.keys(r.snapshot ?? {}).filter(
(k) => k !== "faydaIdentity",
);
const fieldDiffs: FieldDiff[] = proposedKeys.map((key) => ({
key,
label: FIELD_LABELS[key] ?? humanize(key),
from: currentValue(company, key),
to:
r.snapshot[key] === null || r.snapshot[key] === undefined || r.snapshot[key] === ""
? "—"
: String(r.snapshot[key]),
}));
const docDiffs: DocDiff[] = [
...pairIntents(r.licenseChanges, () => "Business license"),
...pairIntents(r.documentChanges, (c) =>
humanize((c as DocumentChangeIntent).code),
),
...r.documentFileIds.map((fileId, i) => ({
key: fileId,
label: "Document",
fromFile: null,
toFile: { id: fileId, name: `Document ${i + 1}` },
})),
];
return {
id: r.id,
kind: r.status as TimelineEntry["kind"],
at: r.reviewedAt ?? r.updatedAt,
note: r.note,
fieldDiffs,
docDiffs,
};
}
function fromRevision(rev: CompanyRevision): TimelineEntry {
const isDocChange = (c: CompanyRevisionChange) => c.field.startsWith("document:");
const fieldDiffs: FieldDiff[] = rev.changes
.filter((c) => !isDocChange(c))
.map((c) => ({ key: c.field, label: c.label, from: c.from ?? "—", to: c.to ?? "—" }));
const docDiffs: DocDiff[] = rev.changes
.filter(isDocChange)
.map((c) => ({
key: c.field,
label: c.label,
fromFile: c.fromFileId ? { id: c.fromFileId, name: c.from ?? "Document" } : null,
toFile: c.toFileId ? { id: c.toFileId, name: c.to ?? "Document" } : null,
}));
return {
id: rev.id,
kind: "revision",
at: rev.createdAt,
summary: rev.summary,
fieldDiffs,
docDiffs,
};
}
/**
* One combined, chronological timeline of everything that's happened to a
* company's record: onboarding-phase edits (no approval gate, from
* `CompanyRevision`) and post-approval settings changes (reviewed via
* `CompanyChangeRequest`) used to live in two separate, differently-shaped
* lists — merged here into one sorted feed so "what changed and when" has a
* single answer instead of two places to check.
*/
export function CompanyTimeline({ company }: { company: Company }) {
const { view, viewer } = useFileViewer();
const changeRequestsQuery = useQuery(
api.customers.changeRequests.queryOptions({ input: { id: company.id } }),
);
const revisionsQuery = useQuery(
api.customers.revisions.queryOptions({ input: { id: company.id } }),
);
const entries: TimelineEntry[] = [
...(changeRequestsQuery.data ?? [])
.filter((r) => r.status !== "pending")
.map((r) => fromChangeRequest(r, company)),
...(revisionsQuery.data ?? []).map(fromRevision),
].sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime());
const openFile = (file: { id: string; name: string }) =>
void fetchViewableFile(file.id, file.name).then(view);
if (entries.length === 0) {
return (
<Card withBorder>
<Stack align="center" gap={6} py="xl">
<History size={24} className="text-edr-muted" />
<Text size="sm" c="dimmed">
No changes recorded yet.
</Text>
</Stack>
</Card>
);
}
return (
<Stack gap="md">
{entries.map((entry) => {
const badge = KIND_BADGE[entry.kind];
return (
<Card key={entry.id} withBorder>
<Stack gap="sm">
<Group justify="space-between" wrap="nowrap" align="flex-start">
<Group gap="sm">
<Badge color={badge.color} variant="light" radius="md">
{badge.label}
</Badge>
{entry.summary && (
<Text size="sm" c="edr-text" tt="capitalize">
{entry.summary}
</Text>
)}
</Group>
<Text size="xs" c="dimmed">
{formatDate(entry.at)}
</Text>
</Group>
{entry.note && (
<Alert color="yellow" variant="light">
<Text size="sm">
<strong>Note:</strong> {entry.note}
</Text>
</Alert>
)}
{entry.fieldDiffs.length > 0 && (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
{entry.fieldDiffs.map((f) => (
<DiffRow key={f.key} label={f.label} from={f.from} to={f.to} />
))}
</SimpleGrid>
)}
{entry.docDiffs.length > 0 && (
<Stack gap={8}>
{entry.docDiffs.map((d) => (
<Group key={d.key} gap={8} wrap="nowrap">
<Text size="xs" fw={600} c="edr-muted" tt="uppercase">
{d.label}
</Text>
{d.fromFile && (
<Group gap={4} wrap="nowrap">
<FileX2 size={14} className="text-edr-muted" />
<Anchor
component="button"
type="button"
size="sm"
td="line-through"
onClick={() => openFile(d.fromFile!)}
>
{d.fromFile.name}
</Anchor>
</Group>
)}
{d.fromFile && d.toFile && (
<Text size="sm" c="edr-muted">
</Text>
)}
{d.toFile && (
<Group gap={4} wrap="nowrap">
<FilePlus2 size={14} className="text-edr-muted" />
<Anchor
component="button"
type="button"
size="sm"
onClick={() => openFile(d.toFile!)}
>
{d.toFile.name}
</Anchor>
</Group>
)}
</Group>
))}
</Stack>
)}
{entry.fieldDiffs.length === 0 && entry.docDiffs.length === 0 && (
<Text size="sm" c="dimmed">
No details recorded for this entry.
</Text>
)}
</Stack>
</Card>
);
})}
{viewer}
</Stack>
);
}

View File

@@ -13,6 +13,7 @@ export {
ChangeRequestReview,
ChangeRequestPendingBadge,
} from "./ChangeRequestReview";
export { CompanyTimeline } from "./CompanyTimeline";
export {
RequestDocumentChangeModal,
type RequestDocumentChangeModalProps,

View File

@@ -4,7 +4,9 @@ import {
WAREHOUSE_ZONE_TYPES,
WAREHOUSE_STATUSES,
INVENTORY_STATUSES,
type ImportUnloadedItem,
type Warehouse,
type WarehouseInventoryItem,
type WarehouseYard,
} from '@/types/warehouse';
@@ -120,6 +122,44 @@ export const extractErrorMessage = (error: unknown, fallback = 'Something went w
return Array.isArray(rawMessage) ? rawMessage.join(', ') : rawMessage ? String(rawMessage) : fallback;
};
/**
* An unloaded-queue row seen as the inventory item `ReleaseOrderModal` expects.
* Both truck-arrival openers (last mile, import trucks) work off queue rows.
*/
export const toReleaseInventoryItem = (row: ImportUnloadedItem): WarehouseInventoryItem =>
({
id: row.id,
bookingId: row.bookingId,
quantity: 1,
weight: Number(row.weight) || 0,
grnNumber: row.grnNumber,
status: row.currentStatus,
arrivedAt: row.arrivalTime,
unloadedAt: row.arrivalTime,
inspectionStatus: row.inspectionStatus,
releaseDate: row.releaseDate,
releaseOrderReference: row.releaseOrderReference,
handoverDocumentReference: row.handoverDocumentReference,
handoverDocumentDate: row.handoverDocumentDate,
deliveredAt: row.deliveredAt,
// Carries the saved [Exit Inspection] block so truck-leaving prefills the
// details captured at arrival (plate, driver, tare, gate-in).
notes: row.notes,
booking: row.bookingId
? {
id: row.bookingId,
reference: row.bookingReference ?? row.bookingId,
tradeDirection: 'IMPORT',
lastMileDeliveryAddress: row.lastMileRequested ? row.pickupOption : null,
customerTruckPlateNumber: row.customerTruckPlateNumber,
customerTruckDriverName: row.customerTruckDriverName,
customerTruckType: row.customerTruckType,
customerTruckContainerNumber: row.customerTruckContainerNumber,
customerTruckAssignedAt: row.customerTruckAssignedAt,
}
: null,
}) as unknown as WarehouseInventoryItem;
/**
* Error extractor for blob-download requests. When `responseType: 'blob'`, axios
* delivers the JSON error body as a Blob, so `extractErrorMessage` can't read

View File

@@ -42,6 +42,8 @@ export const QUERY_KEYS = {
["customers", "detail", id, "reset-target"] as const,
changeRequests: (id: string) =>
["customers", "detail", id, "change-requests"] as const,
revisions: (id: string) =>
["customers", "detail", id, "revisions"] as const,
},
INVOICES: {

View File

@@ -78,10 +78,13 @@ export const URL_CONSTANTS = {
`/companies/company-profiles/${profileId}/status`,
CHANGE_REQUESTS: (companyId: string) =>
`/companies/${companyId}/change-requests`,
REVISIONS: (companyId: string) => `/companies/${companyId}/revisions`,
CHANGE_REQUEST_APPROVE: (id: string) =>
`/companies/change-requests/${id}/approve`,
CHANGE_REQUEST_REJECT: (id: string) =>
`/companies/change-requests/${id}/reject`,
CHANGE_REQUEST_REQUEST_CHANGES: (id: string) =>
`/companies/change-requests/${id}/request-changes`,
DOCUMENT_REQUEST_CHANGE: (fileId: string) =>
`/companies/documents/${fileId}/request-change`,
BOOKINGS_CUSTOMER_VIEW: (id: string) =>
@@ -132,6 +135,8 @@ export const URL_CONSTANTS = {
CONTRACT_DOCUMENT: (id: string) => `/bookings/${id}/contract/document`,
CONTRACT_SIGN: (id: string) => `/bookings/${id}/contract/sign`,
CONTRACT_DOWNLOAD: (id: string) => `/bookings/${id}/contract`,
CARRIAGE_ACCEPTANCE_SHEET: (id: string) =>
`/bookings/${id}/carriage-acceptance-sheet`,
SUMMARY: (id: string) => `/bookings/${id}/summary`,
CUSTOMER_SIGN: (id: string) => `/bookings/${id}/customer/sign`,
MARKETING_APPROVE: (id: string) => `/bookings/${id}/marketing/approve`,

View File

@@ -1,7 +1,9 @@
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import toast from "react-hot-toast";
import {
ArrowLeft,
FileSignature,
FileText,
FolderOpen,
Layers,
LayoutGrid,
@@ -50,6 +52,7 @@ import {
useBookingMutations,
} from "@/hooks/bookings/useBookings";
import { useScrollToHash } from "@/hooks/useScrollToHash";
import { bookingsService } from "@/services/bookings.service";
export default function BookingRequestDetailPage() {
const { id } = useParams<{ id: string }>();
@@ -269,6 +272,33 @@ export default function BookingRequestDetailPage() {
View / sign contract
</Button>
)}
<Button
fullWidth
variant="default"
leftSection={<FileText size={16} />}
onClick={async () => {
try {
const blob =
await bookingsService.downloadCarriageAcceptanceSheet(
booking.id,
);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `carriage-acceptance-${booking.reference}.pdf`;
a.click();
URL.revokeObjectURL(url);
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Carriage acceptance sheet is not available yet",
);
}
}}
>
Carriage acceptance sheet
</Button>
{booking.customsClearingEnabled && (
<Button
fullWidth

View File

@@ -23,6 +23,7 @@ import {
Download,
Eye,
FileText,
History,
Hourglass,
IdCard,
LayoutGrid,
@@ -40,6 +41,7 @@ import {
ChangeRequestPendingBadge,
ChangeRequestReview,
CompanyStatusBadge,
CompanyTimeline,
CompanyTypeBadge,
InvoiceStatusBadge,
PaymentStatusBadge,
@@ -64,6 +66,7 @@ import {
} from "@/services/files.service";
import { api } from "@/services/api";
import type {
Company,
CompanyProfile,
CustomerBooking,
CustomerDocument,
@@ -78,6 +81,32 @@ import {
type ColumnDef,
} from "@edr/ui-common";
/** Plain-text summary of the company's eTrade-sourced record, downloaded client-side (eTrade returns data, not a document). */
function downloadTinRecord(company: Company) {
const lines = [
`TIN: ${company.tin}`,
`Company name: ${company.name}`,
`Licence number: ${company.licenceNumber ?? ""}`,
`Status: ${company.statusDescription ?? ""}`,
`Date registered: ${company.dateRegistered ?? ""}`,
`Renewed from: ${company.renewedFrom ?? ""}`,
`Renewal date: ${company.renewalDate ?? ""}`,
`Renewed to: ${company.renewedTo ?? ""}`,
`Address: ${[company.region, company.zone, company.woreda, company.kebele, company.houseNo].filter(Boolean).join(", ")}`,
];
const blob = new Blob([lines.join("\n")], {
type: "text/plain;charset=utf-8",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `tin-${company.tin}.txt`;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
function InfoField({ label, value }: { label: string; value?: string | null }) {
return (
<Stack gap={2}>
@@ -689,6 +718,9 @@ export default function CustomerDetailPage() {
<Tabs.Tab value="invoices" leftSection={<Receipt size={16} />}>
Invoices
</Tabs.Tab>
<Tabs.Tab value="history" leftSection={<History size={16} />}>
History
</Tabs.Tab>
</Tabs.List>
{/* OVERVIEW */}
@@ -757,6 +789,18 @@ export default function CustomerDetailPage() {
<InfoField label="TIN" value={company.tin} />
<InfoField label="VAT number" value={company.vatNumber} />
<InfoField label="FAN number" value={company.fanNumber} />
<InfoField
label="Submitted on"
value={formatDate(company.createdAt)}
/>
<InfoField
label="Approved on"
value={
company.approvedAt
? formatDate(company.approvedAt)
: "Not yet approved"
}
/>
<InfoField
label="Owner identity"
value={
@@ -800,18 +844,29 @@ export default function CustomerDetailPage() {
<Card>
<Stack gap="lg">
<Group gap="xs" wrap="nowrap">
<Text fw={600} c="edr-text">
eTrade registration
</Text>
{hasEtradeRecord ? (
<Badge size="sm" color="edr-green" variant="light">
Verified with eTrade
</Badge>
) : (
<Badge size="sm" color="gray" variant="light">
No eTrade record
</Badge>
<Group gap="xs" wrap="nowrap" justify="space-between">
<Group gap="xs" wrap="nowrap">
<Text fw={600} c="edr-text">
eTrade registration
</Text>
{hasEtradeRecord ? (
<Badge size="sm" color="edr-green" variant="light">
Verified with eTrade
</Badge>
) : (
<Badge size="sm" color="gray" variant="light">
No eTrade record
</Badge>
)}
</Group>
{hasEtradeRecord && (
<ActionIcon
variant="default"
aria-label="Download TIN record"
onClick={() => downloadTinRecord(company)}
>
<Download size={16} />
</ActionIcon>
)}
</Group>
{hasEtradeRecord ? (
@@ -1235,6 +1290,11 @@ export default function CustomerDetailPage() {
</Box>
</Box>
</Tabs.Panel>
{/* HISTORY */}
<Tabs.Panel value="history" pt="lg">
<CompanyTimeline company={company} />
</Tabs.Panel>
</Tabs>
<RequestDocumentChangeModal

View File

@@ -245,6 +245,16 @@ export default function CustomersPage() {
</Text>
),
},
{
id: "approved",
header: "Approved",
meta: { headerClassName: "text-right", cellClassName: "text-right" },
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{row.original.approvedAt ? formatDate(row.original.approvedAt) : "—"}
</Text>
),
},
],
[],
);

View File

@@ -141,6 +141,7 @@ export const vehiclesConfig: FleetResourceConfig = {
required: true,
description: "Pre-filled from the truck type — override only for a one-off",
},
{ name: "pricePerKm", label: "Price per KM (ETB)", type: "number", description: "Haulage rate charged per kilometre" },
{ name: "status", label: "Status", type: "select", required: true, options: VEHICLE_STATUS_OPTIONS },
{ name: "availability", label: "Availability", type: "select", required: true, options: VEHICLE_AVAILABILITY_OPTIONS },
{ name: "description", label: "Description", type: "textarea" },
@@ -157,6 +158,7 @@ export const vehiclesConfig: FleetResourceConfig = {
year: new Date().getFullYear(),
fuelType: "DIESEL",
capacity: 0,
pricePerKm: 0,
status: "ACTIVE",
availability: "FREE",
description: "",

View File

@@ -57,6 +57,7 @@ import {
import { vehiclesService } from "@/services/vehicles.service";
import { driversService, type Driver } from "@/services/drivers.service";
import { ReleaseOrderModal, type ReleaseOrderTruckPrefill } from "@/components/warehouses/ReleaseOrderModal";
import { toReleaseInventoryItem } from "@/components/warehouses/options";
import { EdrTruckExitPapersModal } from "./EdrTruckExitPapersModal";
import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal";
import { ProofOfDeliveryModal } from "@/components/operations/ProofOfDeliveryModal";
@@ -299,40 +300,6 @@ const requestedDate = (r: LastMileRecord) => {
const serviceTypeName = (r: LastMileRecord) =>
r.booking?.serviceType?.label ?? r.booking?.serviceType?.name ?? "—";
const toReleaseInventoryItem = (row: ImportUnloadedItem): WarehouseInventoryItem =>
({
id: row.id,
bookingId: row.bookingId,
quantity: 1,
weight: Number(row.weight) || 0,
grnNumber: row.grnNumber,
status: row.currentStatus,
arrivedAt: row.arrivalTime,
unloadedAt: row.arrivalTime,
inspectionStatus: row.inspectionStatus,
releaseDate: row.releaseDate,
releaseOrderReference: row.releaseOrderReference,
handoverDocumentReference: row.handoverDocumentReference,
handoverDocumentDate: row.handoverDocumentDate,
deliveredAt: row.deliveredAt,
// Carries the saved [Exit Inspection] block so truck-leaving prefills the
// details captured at arrival (plate, driver, tare, gate-in).
notes: row.notes,
booking: row.bookingId
? {
id: row.bookingId,
reference: row.bookingReference ?? row.bookingId,
tradeDirection: "IMPORT",
lastMileDeliveryAddress: row.lastMileRequested ? row.pickupOption : null,
customerTruckPlateNumber: row.customerTruckPlateNumber,
customerTruckDriverName: row.customerTruckDriverName,
customerTruckType: row.customerTruckType,
customerTruckContainerNumber: row.customerTruckContainerNumber,
customerTruckAssignedAt: row.customerTruckAssignedAt,
}
: null,
}) as unknown as WarehouseInventoryItem;
const releasePrefillFromLastMile = (
record: LastMileRecord,
row?: ImportUnloadedItem | null,

View File

@@ -68,6 +68,7 @@ const METHOD_OPTIONS: { value: PaymentMethod; label: string }[] = [
{ value: "card", label: "Card" },
{ value: "dmoney", label: "D-Money" },
{ value: "cac-bank", label: "CAC Bank" },
{ value: "cbe-bill", label: "CBE Bill" },
];
const STATUS_COLORS: Record<string, string> = {

View File

@@ -0,0 +1,741 @@
import { Fragment, useMemo, useState, useEffect } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
ActionIcon,
Alert,
Badge,
Button,
Group,
Loader,
Modal,
SegmentedControl,
Stack,
Table,
Text,
TextInput,
Textarea,
Select,
Checkbox,
} from "@mantine/core";
import { ChevronDown, ChevronRight } from "lucide-react";
import { PageContainer, PageHeader } from "@/components/page";
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { useListControls } from "@/hooks/useListControls";
import { useToast } from "@/hooks/use-toast";
import { useWarehouseYards, useWarehouseZones } from "@/hooks/useWarehouses";
import { api } from "@/services/api";
import { warehouseService } from "@/services/warehouse.service";
import { importOperationsService } from "@/services/importOperations.service";
type ReturnType = "all" | "edr" | "customer";
interface ContainerReturnRow {
key: string;
containerNumber: string;
size: string | null;
type: string | null;
bookingRef: string;
bookingId: string;
customerId: string | null;
companyName: string | null;
returnType: "EDR" | "CUSTOMER";
plate: string | null;
isReturn: boolean;
}
interface BookingReturnGroup {
bookingId: string;
bookingRef: string;
companyName: string | null;
customerId: string | null;
returnType: "EDR" | "CUSTOMER";
containers: ContainerReturnRow[];
}
export default function ContainerReturnsPage() {
const { toast } = useToast();
const qc = useQueryClient();
const [expanded, setExpanded] = useState<string | null>(null);
const [filterType, setFilterType] = useState<ReturnType>("all");
const [returnModalOpen, setReturnModalOpen] = useState(false);
const [standaloneModalOpen, setStandaloneModalOpen] = useState(false);
const [activeKey, setActiveKey] = useState<string | null>(null);
const { data: unloadedQueue = [], isLoading: queueLoading } = useQuery({
queryKey: ["import-unloaded-queue"],
queryFn: async () => {
const response = await api.warehouses.importUnloadedQueue.call();
return response ?? [];
},
});
const { data: returnedContainers = [] } = useQuery({
queryKey: ["empty-container-returns"],
queryFn: async () => {
return await importOperationsService.listEmptyReturns().catch(() => []);
},
});
const bookingIds = unloadedQueue.map((item) => item.bookingId).filter(Boolean) as string[];
const containerReturnsQuery = useQuery({
queryKey: ["container-returns", bookingIds],
queryFn: async () => {
const groups = new Map<string, BookingReturnGroup>();
for (const item of unloadedQueue) {
if (!item.bookingId) continue;
// EDR last-mile returns: same EDR truck that delivered will return with empty containers
const edrTrucks = await warehouseService.getLastMileTrucks(item.bookingId).catch(() => []);
if (edrTrucks.length > 0) {
const inventory = await api.warehouses.listInventory
.call({ filter: { bookingId: item.bookingId } })
.catch(() => []);
const returnContainers: ContainerReturnRow[] = inventory
.filter((inv: any) => inv.isReturn)
.map((inv: any) => ({
key: inv.id,
containerNumber: inv.containerNumber || "—",
size: inv.containerSize || null,
type: inv.containerType || null,
bookingRef: (item.bookingReference ?? item.bookingId) || "",
bookingId: item.bookingId || "",
customerId: item.customerId || null,
companyName: item.customerName ?? null,
returnType: "EDR" as const,
plate: edrTrucks[0]?.truckPlateNumber || null,
isReturn: true,
}));
if (returnContainers.length > 0) {
const key = `edr-${item.bookingId}`;
groups.set(key, {
bookingId: item.bookingId,
bookingRef: item.bookingReference ?? item.bookingId,
companyName: item.customerName ?? null,
customerId: item.customerId || null,
returnType: "EDR",
containers: returnContainers,
});
}
}
// Customer self-haul last-mile returns: same customer truck that delivered will return with empty containers
const customerTrucks = await warehouseService.getCustomerTrucks(item.bookingId).catch(() => []);
if (customerTrucks.length > 0) {
const inventory = await api.warehouses.listInventory
.call({ filter: { bookingId: item.bookingId } })
.catch(() => []);
const returnContainers: ContainerReturnRow[] = inventory
.filter((inv: any) => inv.isReturn)
.map((inv: any) => ({
key: inv.id,
containerNumber: inv.containerNumber || "—",
size: inv.containerSize || null,
type: inv.containerType || null,
bookingRef: (item.bookingReference ?? item.bookingId) || "",
bookingId: item.bookingId || "",
customerId: item.customerId || null,
companyName: item.customerName ?? null,
returnType: "CUSTOMER" as const,
plate: customerTrucks[0]?.plateNumber || null,
isReturn: true,
}));
if (returnContainers.length > 0) {
const key = `customer-${item.bookingId}`;
groups.set(key, {
bookingId: item.bookingId,
bookingRef: item.bookingReference ?? item.bookingId,
companyName: item.customerName ?? null,
customerId: item.customerId || null,
returnType: "CUSTOMER",
containers: returnContainers,
});
}
}
}
return Array.from(groups.values());
},
enabled: bookingIds.length > 0 && !queueLoading,
});
const allGroups = useMemo(() => containerReturnsQuery.data ?? [], [containerReturnsQuery.data]);
const filteredGroups = useMemo(() => {
if (filterType === "all") return allGroups;
if (filterType === "edr") return allGroups.filter((g) => g.returnType === "EDR");
if (filterType === "customer") return allGroups.filter((g) => g.returnType === "CUSTOMER");
return allGroups;
}, [allGroups, filterType]);
const controls = useListControls(filteredGroups, {
searchKeys: ["bookingRef", "companyName"],
});
const createReturnsMutation = useMutation({
mutationFn: async (payload: {
trucks: Array<{
bookingId: string;
customerId: string | null;
returnType: "EDR" | "CUSTOMER";
containers: Array<{
containerNumber: string;
returnDate: string;
warehouse: string;
condition?: string;
handoverNote?: string;
}>;
}>;
}) => {
const results = [];
for (const truck of payload.trucks) {
for (const container of truck.containers) {
const result = await importOperationsService.createEmptyReturn({
containerNumber: container.containerNumber,
returnDate: new Date(container.returnDate).toISOString(),
bookingId: truck.bookingId,
customerId: truck.customerId ?? undefined,
facility: container.warehouse,
condition: container.condition,
handoverNote: container.handoverNote,
});
results.push(result);
}
}
return results;
},
onSuccess: () => {
toast({ title: "Container returns recorded" });
qc.invalidateQueries({ queryKey: ["container-returns", bookingIds] });
setReturnModalOpen(false);
setActiveKey(null);
},
onError: (error: any) => {
toast({
variant: "destructive",
title: "Failed to record returns",
description: error?.response?.data?.message || error?.message,
});
},
});
const activeGroup = activeKey ? (filteredGroups.find((g) => `${g.returnType.toLowerCase()}-${g.bookingId}` === activeKey) ?? null) : null;
if (queueLoading || containerReturnsQuery.isLoading) {
return (
<PageContainer>
<Group justify="center" py="lg">
<Loader size="sm" />
</Group>
</PageContainer>
);
}
return (
<PageContainer>
<PageHeader
title="Container Returns"
subtitle="Empty containers returned by last-mile trucks (EDR or customer self-haul)"
/>
<Group mb="lg" justify="space-between">
<SegmentedControl
value={filterType}
onChange={(val) => setFilterType(val as ReturnType)}
data={[
{ label: "All", value: "all" },
{ label: "EDR Last Mile", value: "edr" },
{ label: "Customer Self-Haul", value: "customer" },
]}
/>
<Button onClick={() => setStandaloneModalOpen(true)}>
Record Return
</Button>
</Group>
{returnedContainers.length > 0 && (
<>
<Text fw={600} mb="xs">Returned Containers</Text>
<Table.ScrollContainer minWidth={1000} mb="lg">
<Table striped highlightOnHover verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Container Number</Table.Th>
<Table.Th>Booking Ref</Table.Th>
<Table.Th>Returned Date</Table.Th>
<Table.Th>Facility</Table.Th>
<Table.Th>Yard</Table.Th>
<Table.Th>Condition</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{returnedContainers.map((ret: any) => (
<Table.Tr key={ret.id}>
<Table.Td>{ret.containerNumber}</Table.Td>
<Table.Td>{ret.bookingId ? "Associated" : "—"}</Table.Td>
<Table.Td>{ret.returnDate ? new Date(ret.returnDate).toLocaleDateString() : "—"}</Table.Td>
<Table.Td>{ret.facility || "—"}</Table.Td>
<Table.Td>{ret.yard || "—"}</Table.Td>
<Table.Td>{ret.condition || "—"}</Table.Td>
<Table.Td>
<Badge size="sm">{ret.status}</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
</>
)}
{filteredGroups.length === 0 ? (
<Alert color="gray">No {filterType !== "all" ? filterType : ""} container returns found.</Alert>
) : (
<>
<Table.ScrollContainer minWidth={1000}>
<Table highlightOnHover verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th w={40} />
<Table.Th>Booking Ref</Table.Th>
<Table.Th>Company</Table.Th>
<Table.Th>Return Type</Table.Th>
<Table.Th>Containers</Table.Th>
<Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{controls.pagedRows.map((group) => {
const groupKey = `${group.returnType.toLowerCase()}-${group.bookingId}`;
const isOpen = expanded === groupKey;
return (
<Fragment key={groupKey}>
<Table.Tr>
<Table.Td>
<ActionIcon
variant="subtle"
color="gray"
onClick={() => setExpanded(isOpen ? null : groupKey)}
>
{isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</ActionIcon>
</Table.Td>
<Table.Td>
<Text fw={600}>{group.bookingRef}</Text>
</Table.Td>
<Table.Td>{group.companyName ?? "—"}</Table.Td>
<Table.Td>
<Badge color={group.returnType === "EDR" ? "edr-green" : "blue"}>
{group.returnType === "EDR" ? "EDR Last Mile" : "Customer Self-Haul"}
</Badge>
</Table.Td>
<Table.Td>
<Badge>{group.containers.length} container{group.containers.length !== 1 ? "s" : ""}</Badge>
</Table.Td>
<Table.Td ta="right">
<Button
size="xs"
variant="light"
onClick={() => {
setActiveKey(groupKey);
setReturnModalOpen(true);
}}
>
Record Return
</Button>
</Table.Td>
</Table.Tr>
{isOpen && (
<Table.Tr>
<Table.Td colSpan={6}>
<Table striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Container</Table.Th>
<Table.Th>Size</Table.Th>
<Table.Th>Type</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{group.containers.map((container) => (
<Table.Tr key={container.key}>
<Table.Td>{container.containerNumber}</Table.Td>
<Table.Td>{container.size ?? "—"}</Table.Td>
<Table.Td>{container.type ?? "—"}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.Td>
</Table.Tr>
)}
</Fragment>
);
})}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
<RuleEngineListFooter
pagination={controls.pagination}
pageCount={controls.pageCount}
totalCount={controls.totalCount}
itemLabel="bookings"
onPaginationChange={controls.setPagination}
/>
</>
)}
<ContainerReturnModal
opened={returnModalOpen}
onClose={() => setReturnModalOpen(false)}
group={activeGroup}
onSubmit={(payload) => createReturnsMutation.mutate(payload)}
loading={createReturnsMutation.isPending}
/>
<StandaloneReturnModal
opened={standaloneModalOpen}
onClose={() => setStandaloneModalOpen(false)}
onSubmit={(payload) => createReturnsMutation.mutate(payload)}
loading={createReturnsMutation.isPending}
/>
</PageContainer>
);
}
interface ContainerReturnModalProps {
opened: boolean;
onClose: () => void;
group: BookingReturnGroup | null;
onSubmit: (payload: any) => void;
loading: boolean;
}
function ContainerReturnModal({ opened, onClose, group, onSubmit, loading }: ContainerReturnModalProps) {
const [selectedContainers, setSelectedContainers] = useState<string[]>([]);
const [returnDate, setReturnDate] = useState<string>(new Date().toISOString().split("T")[0]);
const [warehouse, setWarehouse] = useState<string | null>(null);
const [condition, setCondition] = useState<string>("");
const [handoverNote, setHandoverNote] = useState<string>("");
const { data: warehousesResponse } = useQuery({
queryKey: ["warehouses-list"],
queryFn: async () => {
return await warehouseService.list({});
},
});
const warehouses = (warehousesResponse as any)?.data ?? warehousesResponse ?? [];
const warehouseOptions = Array.isArray(warehouses)
? warehouses.map((wh: any) => ({
value: wh.id,
label: `${wh.name} ${wh.code ? `(${wh.code})` : ""}`,
}))
: [];
const handleSubmit = () => {
if (!group || !selectedContainers.length || !warehouse) return;
const selectedWarehouse = Array.isArray(warehouses) ? warehouses.find((wh: any) => wh.id === warehouse) : null;
const containers = group.containers
.filter((c) => selectedContainers.includes(c.key))
.map((c) => ({
containerNumber: c.containerNumber,
returnDate,
warehouse: selectedWarehouse?.name || warehouse,
condition: condition || undefined,
handoverNote: handoverNote || undefined,
}));
onSubmit({
trucks: [
{
bookingId: group.bookingId,
customerId: group.customerId,
returnType: group.returnType,
containers,
},
],
});
};
return (
<Modal opened={opened} onClose={onClose} title="Record Container Return" size="lg">
{group && (
<Stack gap="md">
<Group>
<Text fw={600}>{group.bookingRef}</Text>
<Badge color={group.returnType === "EDR" ? "edr-green" : "blue"}>
{group.returnType === "EDR" ? "EDR Last Mile" : "Customer Self-Haul"}
</Badge>
</Group>
<div>
<Text size="sm" fw={600} mb="xs">
Select containers to return:
</Text>
<Stack gap="xs">
{group.containers.map((container) => (
<Checkbox
key={container.key}
label={`${container.containerNumber} (${container.size || "bulk"})`}
checked={selectedContainers.includes(container.key)}
onChange={(e) => {
if (e.currentTarget.checked) {
setSelectedContainers([...selectedContainers, container.key]);
} else {
setSelectedContainers(selectedContainers.filter((c) => c !== container.key));
}
}}
/>
))}
</Stack>
</div>
<Select
label="Return Warehouse"
placeholder="Select warehouse for container return"
value={warehouse}
onChange={setWarehouse}
data={warehouseOptions}
required
searchable
/>
<input
type="date"
value={returnDate}
onChange={(e) => setReturnDate(e.target.value)}
style={{ padding: "8px", borderRadius: "4px", border: "1px solid #ccc" }}
required
/>
<Textarea
label="Condition"
placeholder="Damage, residue, or cleanliness notes"
value={condition}
onChange={(e) => setCondition(e.currentTarget.value)}
rows={3}
/>
<Textarea
label="Handover Note"
placeholder="Consignee, trucker, or authorization notes"
value={handoverNote}
onChange={(e) => setHandoverNote(e.currentTarget.value)}
rows={3}
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={!selectedContainers.length || !warehouse}
loading={loading}
>
Record Return ({selectedContainers.length})
</Button>
</Group>
</Stack>
)}
</Modal>
);
}
interface StandaloneReturnModalProps {
opened: boolean;
onClose: () => void;
onSubmit: (payload: any) => void;
loading: boolean;
}
function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: StandaloneReturnModalProps) {
const [containerNumber, setContainerNumber] = useState<string>("");
const [returnedBy, setReturnedBy] = useState<"EDR" | "CUSTOMER" | null>(null);
const [returnDate, setReturnDate] = useState<string>(new Date().toISOString().split("T")[0]);
const [warehouse, setWarehouse] = useState<string | null>(null);
const [yardId, setYardId] = useState<string | null>(null);
const [zoneId, setZoneId] = useState<string | null>(null);
const [condition, setCondition] = useState<string>("");
const [handoverNote, setHandoverNote] = useState<string>("");
const { data: warehousesResponse } = useQuery({
queryKey: ["warehouses-list"],
queryFn: async () => {
return await warehouseService.list({});
},
});
const warehouses = (warehousesResponse as any)?.data ?? warehousesResponse ?? [];
const { data: yards } = useWarehouseYards(warehouse ?? undefined);
const { data: zones } = useWarehouseZones(yardId ?? undefined);
useEffect(() => {
setYardId(null);
setZoneId(null);
}, [warehouse]);
useEffect(() => {
setZoneId(null);
}, [yardId]);
const warehouseOptions = Array.isArray(warehouses)
? warehouses.map((wh: any) => ({
value: wh.id,
label: `${wh.name} ${wh.code ? `(${wh.code})` : ""}`,
}))
: [];
const yardOptions = (yards ?? [])
.filter((y) => y.status === "ACTIVE")
.map((y) => ({ value: y.id, label: `${y.name} (${y.code})` }));
const zoneOptions = (zones ?? [])
.filter((z) => z.status === "ACTIVE")
.map((z) => ({ value: z.id, label: `${z.name} (${z.code})` }));
const handleSubmit = () => {
if (!containerNumber || !warehouse || !returnedBy) return;
const selectedWarehouse = Array.isArray(warehouses) ? warehouses.find((wh: any) => wh.id === warehouse) : null;
const selectedYard = yards?.find((y) => y.id === yardId);
const selectedZone = zones?.find((z) => z.id === zoneId);
onSubmit({
trucks: [
{
bookingId: null,
customerId: null,
returnType: returnedBy,
containers: [
{
containerNumber,
returnDate,
warehouse: selectedWarehouse?.name || warehouse,
yard: selectedYard?.name,
zone: selectedZone?.name,
condition: condition || undefined,
handoverNote: handoverNote || undefined,
},
],
},
],
});
setContainerNumber("");
setReturnedBy(null);
setReturnDate(new Date().toISOString().split("T")[0]);
setWarehouse(null);
setYardId(null);
setZoneId(null);
setCondition("");
setHandoverNote("");
onClose();
};
return (
<Modal opened={opened} onClose={onClose} title="Record Container Return (Standalone)" size="lg">
<Stack gap="md">
<Text size="sm" c="dimmed">
Record container return without booking association
</Text>
<TextInput
label="Container Number"
placeholder="e.g., TEMU1234567"
value={containerNumber}
onChange={(e) => setContainerNumber(e.currentTarget.value)}
required
/>
<Select
label="Returned By"
placeholder="Select truck type"
value={returnedBy}
onChange={(val) => setReturnedBy(val as "EDR" | "CUSTOMER" | null)}
data={[
{ value: "EDR", label: "EDR Truck" },
{ value: "CUSTOMER", label: "Customer Truck" },
]}
required
/>
<Select
label="Return Warehouse"
placeholder="Select warehouse for container return"
value={warehouse}
onChange={setWarehouse}
data={warehouseOptions}
required
searchable
/>
<Select
label="Yard"
placeholder={warehouse ? "Select yard" : "Select warehouse first"}
value={yardId}
onChange={setYardId}
data={yardOptions}
disabled={!warehouse}
searchable
/>
<Select
label="Zone"
placeholder={yardId ? "Select zone" : "Select yard first"}
value={zoneId}
onChange={setZoneId}
data={zoneOptions}
disabled={!yardId}
searchable
/>
<input
type="date"
value={returnDate}
onChange={(e) => setReturnDate(e.target.value)}
style={{ padding: "8px", borderRadius: "4px", border: "1px solid #ccc" }}
required
/>
<Textarea
label="Condition"
placeholder="Damage, residue, or cleanliness notes"
value={condition}
onChange={(e) => setCondition(e.currentTarget.value)}
rows={3}
/>
<Textarea
label="Handover Note"
placeholder="Consignee, trucker, or authorization notes"
value={handoverNote}
onChange={(e) => setHandoverNote(e.currentTarget.value)}
rows={3}
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={!containerNumber || !returnedBy || !warehouse}
loading={loading}
>
Record Return
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -0,0 +1,406 @@
import { Fragment, useMemo, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
ActionIcon,
Alert,
Badge,
Button,
Group,
Loader,
Modal,
Stack,
Table,
Text,
TextInput,
Textarea,
Select,
Checkbox,
} from "@mantine/core";
import { ChevronDown, ChevronRight } from "lucide-react";
import { PageContainer, PageHeader } from "@/components/page";
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { useListControls } from "@/hooks/useListControls";
import { useToast } from "@/hooks/use-toast";
import { api } from "@/services/api";
import { warehouseService } from "@/services/warehouse.service";
import { importOperationsService } from "@/services/importOperations.service";
interface ReturnContainer {
containerNumber: string;
size: string | null;
type: string | null;
selected: boolean;
}
interface TruckReturn {
key: string;
plate: string;
companyName: string | null;
bookingRef: string;
bookingId: string;
customerId: string | null;
containers: ReturnContainer[];
}
export default function EDRLastMileReturnsPage() {
const { toast } = useToast();
const qc = useQueryClient();
const [expanded, setExpanded] = useState<string | null>(null);
const [returnModalOpen, setReturnModalOpen] = useState(false);
const [activeKey, setActiveKey] = useState<string | null>(null);
const { data: unloadedQueue = [], isLoading: queueLoading } = useQuery({
queryKey: ["import-unloaded-queue"],
queryFn: async () => {
const response = await api.warehouses.importUnloadedQueue.call();
return response ?? [];
},
});
const bookingIds = unloadedQueue.map((item) => item.bookingId).filter(Boolean) as string[];
const truckReturnsQuery = useQuery({
queryKey: ["edr-last-mile-returns", bookingIds],
queryFn: async () => {
const grouped = new Map<string, TruckReturn>();
for (const item of unloadedQueue) {
if (!item.bookingId) continue;
const edrTrucks = await warehouseService.getLastMileTrucks(item.bookingId).catch(() => []);
for (const truck of edrTrucks) {
const inventory = await api.warehouses.listInventory.call({ filter: { bookingId: item.bookingId } }).catch(() => []);
const returnContainers = inventory
.filter((inv: any) => inv.isReturn)
.map((inv: any) => ({
containerNumber: inv.containerNumber || "—",
size: inv.containerSize || null,
type: inv.containerType || null,
selected: false,
}));
if (returnContainers.length > 0) {
const key = `${item.bookingId}-${truck.vehicleId}`;
grouped.set(key, {
key,
plate: [truck.truckPlateNumber, truck.trailerPlateNumber].filter(Boolean).join(" + ") || "—",
companyName: item.customerName ?? null,
bookingRef: item.bookingReference ?? item.bookingId,
bookingId: item.bookingId,
customerId: item.customerId || null,
containers: returnContainers,
});
}
}
}
return Array.from(grouped.values());
},
enabled: bookingIds.length > 0 && !queueLoading,
});
const trucksWithReturns = useMemo(() => truckReturnsQuery.data ?? [], [truckReturnsQuery.data]);
const controls = useListControls(trucksWithReturns, {
searchKeys: ["plate", "companyName", "bookingRef"],
});
const createReturnsMutation = useMutation({
mutationFn: async (payload: { trucks: Array<{ bookingId: string; customerId: string | null; containers: Array<{ containerNumber: string; returnDate: string; facility: string; yard?: string; zone?: string; condition?: string; handoverNote?: string }> }> }) => {
const results = [];
for (const truck of payload.trucks) {
for (const container of truck.containers) {
const result = await importOperationsService.createEmptyReturn({
containerNumber: container.containerNumber,
returnDate: new Date(container.returnDate).toISOString(),
bookingId: truck.bookingId,
customerId: truck.customerId ?? undefined,
facility: container.facility,
yard: container.yard,
zone: container.zone,
condition: container.condition,
handoverNote: container.handoverNote,
});
results.push(result);
}
}
return results;
},
onSuccess: () => {
toast({ title: "Empty container returns recorded" });
qc.invalidateQueries({ queryKey: ["edr-last-mile-returns", bookingIds] });
setReturnModalOpen(false);
setActiveKey(null);
},
onError: (error: any) => {
toast({
variant: "destructive",
title: "Failed to record returns",
description: error?.response?.data?.message || error?.message,
});
},
});
const activeTruck = activeKey ? trucksWithReturns.find(t => t.key === activeKey) ?? null : null;
if (queueLoading || truckReturnsQuery.isLoading) {
return (
<PageContainer>
<Group justify="center" py="lg">
<Loader size="sm" />
</Group>
</PageContainer>
);
}
return (
<PageContainer>
<PageHeader
title="EDR Last Mile Returns"
subtitle="Empty containers returned by EDR-haulage trucks — single or bulk processing"
/>
{trucksWithReturns.length === 0 ? (
<Alert color="gray">No EDR trucks with return containers found.</Alert>
) : (
<>
<Table.ScrollContainer minWidth={1000}>
<Table highlightOnHover verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th w={40} />
<Table.Th>Plate</Table.Th>
<Table.Th>Company</Table.Th>
<Table.Th>Booking Ref</Table.Th>
<Table.Th>Return Containers</Table.Th>
<Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{controls.pagedRows.map((truck) => {
const isOpen = expanded === truck.key;
return (
<Fragment key={truck.key}>
<Table.Tr>
<Table.Td>
<ActionIcon
variant="subtle"
color="gray"
onClick={() => setExpanded(isOpen ? null : truck.key)}
>
{isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</ActionIcon>
</Table.Td>
<Table.Td>
<Text fw={600}>{truck.plate}</Text>
</Table.Td>
<Table.Td>{truck.companyName ?? "—"}</Table.Td>
<Table.Td>{truck.bookingRef}</Table.Td>
<Table.Td>
<Badge>{truck.containers.length} container{truck.containers.length !== 1 ? "s" : ""}</Badge>
</Table.Td>
<Table.Td ta="right">
<Button
size="xs"
variant="light"
onClick={() => {
setActiveKey(truck.key);
setReturnModalOpen(true);
}}
>
Process Returns
</Button>
</Table.Td>
</Table.Tr>
{isOpen && (
<Table.Tr>
<Table.Td colSpan={6}>
<Table striped>
<Table.Thead>
<Table.Tr>
<Table.Th w={40}>
<Checkbox disabled />
</Table.Th>
<Table.Th>Container</Table.Th>
<Table.Th>Size</Table.Th>
<Table.Th>Type</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{truck.containers.map((container, idx) => (
<Table.Tr key={idx}>
<Table.Td>
<Checkbox checked={container.selected} />
</Table.Td>
<Table.Td>{container.containerNumber}</Table.Td>
<Table.Td>{container.size ?? "—"}</Table.Td>
<Table.Td>{container.type ?? "—"}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.Td>
</Table.Tr>
)}
</Fragment>
);
})}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
<RuleEngineListFooter
pagination={controls.pagination}
pageCount={controls.pageCount}
totalCount={controls.totalCount}
itemLabel="trucks"
onPaginationChange={controls.setPagination}
/>
</>
)}
<EmptyContainerReturnModal
opened={returnModalOpen}
onClose={() => setReturnModalOpen(false)}
truck={activeTruck}
onSubmit={(payload) => createReturnsMutation.mutate(payload)}
loading={createReturnsMutation.isPending}
/>
</PageContainer>
);
}
interface EmptyContainerReturnModalProps {
opened: boolean;
onClose: () => void;
truck: TruckReturn | null;
onSubmit: (payload: any) => void;
loading: boolean;
}
function EmptyContainerReturnModal({ opened, onClose, truck, onSubmit, loading }: EmptyContainerReturnModalProps) {
const [selectedContainers, setSelectedContainers] = useState<string[]>([]);
const [returnDate, setReturnDate] = useState<string>(new Date().toISOString().split("T")[0]);
const [warehouse, setWarehouse] = useState<string | null>(null);
const [condition, setCondition] = useState<string>("");
const [handoverNote, setHandoverNote] = useState<string>("");
const { data: warehousesResponse } = useQuery({
queryKey: ["warehouses-list"],
queryFn: async () => {
return await warehouseService.list({});
},
});
const warehouses = (warehousesResponse as any)?.data ?? warehousesResponse ?? [];
const warehouseOptions = Array.isArray(warehouses) ? warehouses.map((wh: any) => ({
value: wh.id,
label: `${wh.name} ${wh.code ? `(${wh.code})` : ""}`,
})) : [];
const selectedWarehouse = warehouse && Array.isArray(warehouses) ? warehouses.find((wh: any) => wh.id === warehouse) : null;
const handleSubmit = () => {
if (!truck || !selectedContainers.length || !warehouse) return;
const containers = truck.containers
.filter((c) => selectedContainers.includes(c.containerNumber))
.map((c) => ({
containerNumber: c.containerNumber,
returnDate,
facility: selectedWarehouse?.name || warehouse,
yard: selectedWarehouse?.code || undefined,
zone: undefined,
condition: condition || undefined,
handoverNote: handoverNote || undefined,
}));
onSubmit({
trucks: [{
bookingId: truck.bookingId,
customerId: truck.customerId,
containers,
}],
});
};
return (
<Modal opened={opened} onClose={onClose} title="Process Empty Container Returns" size="lg">
{truck && (
<Stack gap="md">
<Group>
<Text fw={600}>{truck.plate}</Text>
<Text size="sm" c="dimmed">{truck.bookingRef}</Text>
</Group>
<div>
<Text size="sm" fw={600} mb="xs">Select containers to return:</Text>
<Stack gap="xs">
{truck.containers.map((container) => (
<Checkbox
key={container.containerNumber}
label={`${container.containerNumber} (${container.size || "bulk"})`}
checked={selectedContainers.includes(container.containerNumber)}
onChange={(e) => {
if (e.currentTarget.checked) {
setSelectedContainers([...selectedContainers, container.containerNumber]);
} else {
setSelectedContainers(selectedContainers.filter(c => c !== container.containerNumber));
}
}}
/>
))}
</Stack>
</div>
<Select
label="Return Warehouse"
placeholder="Select warehouse for container return"
value={warehouse}
onChange={setWarehouse}
data={warehouseOptions}
required
searchable
/>
<TextInput
label="Return Date"
type="date"
value={returnDate}
onChange={(e) => setReturnDate(e.currentTarget.value)}
required
/>
<Textarea
label="Condition"
placeholder="Damage, residue, or cleanliness notes"
value={condition}
onChange={(e) => setCondition(e.currentTarget.value)}
rows={3}
/>
<Textarea
label="Handover Note"
placeholder="Consignee, trucker, or authorization notes"
value={handoverNote}
onChange={(e) => setHandoverNote(e.currentTarget.value)}
rows={3}
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={!selectedContainers.length || !warehouse}
loading={loading}
>
{selectedContainers.length > 1 ? "Bulk" : "Single"} Return ({selectedContainers.length})
</Button>
</Group>
</Stack>
)}
</Modal>
);
}

View File

@@ -1,5 +1,5 @@
import { Fragment, useMemo, useState } from "react";
import { useQueries, useQuery } from "@tanstack/react-query";
import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ActionIcon,
Alert,
@@ -18,6 +18,7 @@ import {
FileText,
MoreHorizontal,
Receipt,
Truck,
} from "lucide-react";
import type { Freight } from "@edr/types";
@@ -27,14 +28,22 @@ import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { InspectionReportModal } from "@/components/warehouses/InspectionReportModal";
import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal";
import { WarehouseGateTimesModal } from "@/components/operations/WarehouseGateTimesModal";
import { extractDownloadErrorMessage, formatNumber } from "@/components/warehouses/options";
import {
ReleaseOrderModal,
type ReleaseOrderTruckPrefill,
} from "@/components/warehouses/ReleaseOrderModal";
import {
extractDownloadErrorMessage,
formatNumber,
toReleaseInventoryItem,
} from "@/components/warehouses/options";
import { openPdfBlob } from "@/components/warehouses/pdf";
import { useListControls } from "@/hooks/useListControls";
import { useToast } from "@/hooks/use-toast";
import { api } from "@/services/api";
import { lastMileService } from "@/services/last-mile.service";
import { warehouseService, type LastMileArrivalTruck } from "@/services/warehouse.service";
import type { ImportUnloadedItem } from "@/types/warehouse";
import type { ImportUnloadedItem, WarehouseInventoryItem } from "@/types/warehouse";
/**
* Import trucks — the unloaded queue seen truck-first instead of item-first.
@@ -53,6 +62,20 @@ import type { ImportUnloadedItem } from "@/types/warehouse";
const COLS = 11;
/** Truck columns — the table head, and repeated inside each expanded booking. */
const TRUCK_COLUMNS = [
"Plate",
"Type",
"Containers",
"Truck Arrival",
"Truck Leaving",
"Weight",
"Demurrage",
"Storage",
"Detention",
"Actions",
] as const;
const money = (amount: number, currency: string) =>
`${Number(amount).toLocaleString(undefined, { maximumFractionDigits: 2 })} ${currency === "ETB" ? "ETB" : currency}`;
@@ -109,6 +132,8 @@ interface TruckRow {
vehicleId: string | null;
arrivedAt: string | null;
departedAt: string | null;
/** Identity handed to the release modal so it opens on THIS truck. */
prefill: ReleaseOrderTruckPrefill;
}
/**
@@ -117,10 +142,17 @@ interface TruckRow {
*/
function TruckRows({ group }: { group: BookingGroup }) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [busy, setBusy] = useState(false);
const [inspectId, setInspectId] = useState<string | null>(null);
const [detentionOpen, setDetentionOpen] = useState(false);
const [gateTimesOpen, setGateTimesOpen] = useState(false);
// The truck whose arrival/exit weighing is open — kept in state so the prefill
// object stays referentially stable while the modal is up.
const [release, setRelease] = useState<{
item: WarehouseInventoryItem;
prefill: ReleaseOrderTruckPrefill;
} | null>(null);
const edrQuery = useQuery({
queryKey: ["booking-edr-trucks", group.bookingId],
@@ -194,6 +226,15 @@ function TruckRows({ group }: { group: BookingGroup }) {
vehicleId: t.vehicleId,
arrivedAt: t.arrivedAt,
departedAt: t.departedAt,
prefill: {
truckPlateNumber: t.truckPlateNumber,
trailerPlateNumber: t.trailerPlateNumber,
driverName: t.driverName,
driverLicense: t.driverLicense,
driverPhone: t.driverPhone,
truckType: t.truckType,
containerNumber: t.containerNumber,
},
...costsFor(containers),
};
};
@@ -208,11 +249,39 @@ function TruckRows({ group }: { group: BookingGroup }) {
vehicleId: null,
arrivedAt: t.arrivedAt ?? null,
departedAt: t.departedAt ?? null,
prefill: {
truckPlateNumber: t.plateNumber,
driverName: t.driverName,
truckType: t.truckType,
containerNumber: containers.join(", "),
},
...costsFor(containers),
};
};
const trucks: TruckRow[] = isEdr ? edrTrucks.map(fromEdr) : customerTrucks.map(fromCustomer);
/**
* Arrival and leaving are one form per truck: the modal picks the step from
* that plate's own saved weighing block, so both menu items open it the same
* way. The booking-level opener (inventory workbench) stays as it was.
*/
const openRelease = (t: TruckRow) => {
const row = group.rows.find((r) => r.id === t.inventoryIds[0]) ?? group.rows[0];
if (!row) return;
setRelease({ item: toReleaseInventoryItem(row), prefill: t.prefill });
};
const closeRelease = () => {
setRelease(null);
void queryClient.invalidateQueries({ queryKey: ["booking-edr-trucks", group.bookingId] });
void queryClient.invalidateQueries({ queryKey: ["booking-customer-trucks", group.bookingId] });
// The saved weighing block lives in the inventory row's notes — refetch the
// queue or the next open would still show the truck as never arrived.
void queryClient.invalidateQueries({
queryKey: api.warehouses.importUnloadedQueue.queryOptions({}).queryKey,
});
};
const openDocument = async (
kind: "release" | "handover",
inventoryId: string,
@@ -267,6 +336,18 @@ function TruckRows({ group }: { group: BookingGroup }) {
return (
<>
{/* The booking row sits between the head and these trucks, so repeat the
column labels — otherwise an expanded booking reads as unlabelled. */}
<Table.Tr bg="var(--mantine-color-gray-1)">
<Table.Td />
{TRUCK_COLUMNS.map((label) => (
<Table.Td key={label} ta={label === "Actions" ? "right" : undefined}>
<Text size="xs" fw={700} c="dimmed">
{label}
</Text>
</Table.Td>
))}
</Table.Tr>
{trucks.map((t, idx) => {
const detention = t.vehicleId ? detentionByVehicle.get(t.vehicleId) : undefined;
const primaryId = t.inventoryIds[0] ?? null;
@@ -337,6 +418,21 @@ function TruckRows({ group }: { group: BookingGroup }) {
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<Truck size={14} />}
disabled={!primaryId}
onClick={() => openRelease(t)}
>
Truck Arrival
</Menu.Item>
<Menu.Item
leftSection={<Truck size={14} />}
disabled={!primaryId}
onClick={() => openRelease(t)}
>
Truck Leaving
</Menu.Item>
<Menu.Divider />
<Menu.Item
leftSection={<FileText size={14} />}
disabled={!primaryId}
@@ -388,6 +484,12 @@ function TruckRows({ group }: { group: BookingGroup }) {
onClose={() => setInspectId(null)}
inventoryId={inspectId}
/>
<ReleaseOrderModal
opened={Boolean(release)}
onClose={closeRelease}
item={release?.item ?? null}
truckPrefill={release?.prefill ?? null}
/>
{isEdr && (
<>
<TruckDetentionModal
@@ -459,16 +561,11 @@ export default function ImportTrucksPage() {
<Table.Thead>
<Table.Tr>
<Table.Th w={40} />
<Table.Th>Plate</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Containers</Table.Th>
<Table.Th>Truck Arrival</Table.Th>
<Table.Th>Truck Leaving</Table.Th>
<Table.Th>Weight</Table.Th>
<Table.Th>Demurrage</Table.Th>
<Table.Th>Storage</Table.Th>
<Table.Th>Detention</Table.Th>
<Table.Th ta="right">Actions</Table.Th>
{TRUCK_COLUMNS.map((label) => (
<Table.Th key={label} ta={label === "Actions" ? "right" : undefined}>
{label}
</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>

View File

@@ -150,8 +150,9 @@ function AllocationRules() {
.filter((yard) => yard.code)
.map((yard) => ({
value: yard.code,
label: `${yard.code} - ${yard.name}${yard.warehouse?.code ? ` (${yard.warehouse.code})` : ''}`,
label: `${yard.name} (${yard.code})${yard.warehouse?.code ? ` ${yard.warehouse.code}` : ''}`,
}));
const yardNameByCode = Object.fromEntries(yards.map((yard) => [yard.code, yard.name]));
const resetForm = () => {
setEditingId(null);
@@ -223,7 +224,11 @@ function AllocationRules() {
{
id: 'targetYard',
header: 'Target yard',
cell: ({ row }) => <Badge variant="light">{row.original.targetYardCode}</Badge>,
cell: ({ row }) => (
<Badge variant="light">
{yardNameByCode[row.original.targetYardCode] ?? row.original.targetYardCode}
</Badge>
),
},
{
id: 'active',
@@ -307,7 +312,8 @@ function AllocationRules() {
{anyLabel(form.freightType, 'freight type').toLowerCase()} booking
{form.cargoTypeCode.trim() ? ` with cargo code ${form.cargoTypeCode.trim()}` : ''}
{form.containerStatus.trim() ? ` and container status ${form.containerStatus.trim()}` : ''}{' '}
is received, <b>send it to</b> {form.targetYardCode || 'a selected target yard'}.
is received, <b>send it to</b>{' '}
{(form.targetYardCode && yardNameByCode[form.targetYardCode]) || form.targetYardCode || 'a selected target yard'}.
</Text>
</Stack>
</Card>

View File

@@ -8,6 +8,7 @@ import type {
CompanyChangeRequest,
CompanyListFilter,
CompanyProfile,
CompanyRevision,
CompanyStats,
CustomerBooking,
CustomerDocument,
@@ -2778,6 +2779,13 @@ export const api = {
({ id }) => QUERY_KEYS.CUSTOMERS.changeRequests(id),
),
revisions: endpoint<{ id: string }, CompanyRevision[]>(
"customers",
"revisions",
({ id }) => customersService.revisions(id),
({ id }) => QUERY_KEYS.CUSTOMERS.revisions(id),
),
approveChangeRequest: endpoint<{ id: string }, CompanyChangeRequest>(
"customers",
"approveChangeRequest",
@@ -2802,6 +2810,21 @@ export const api = {
],
),
requestChangeRequestChanges: endpoint<
{ id: string; note: string },
CompanyChangeRequest
>(
"customers",
"requestChangeRequestChanges",
({ id, note }) => customersService.requestChangeRequestChanges(id, note),
undefined,
(_input, data) => [
QUERY_KEYS.CUSTOMERS.changeRequests(data.companyId),
QUERY_KEYS.CUSTOMERS.byId(data.companyId),
QUERY_KEYS.CUSTOMERS.ROOT,
],
),
/**
* Ask the customer to correct one document. Invalidates the documents list
* and the company itself, since an open request blocks role approval.

View File

@@ -455,6 +455,13 @@ export const bookingsService = {
return (unwrap(response.data) ?? []) as BookingDetail[];
},
downloadCarriageAcceptanceSheet: async (id: string): Promise<Blob> => {
const response = await client.get(B.CARRIAGE_ACCEPTANCE_SHEET(id), {
responseType: "blob",
});
return ensurePdfBlob(response.data as Blob);
},
getDjClearanceQueue: async (): Promise<BookingDetail[]> => {
const response = await client.get(B.CLEARANCE_DJ_QUEUE);
return (unwrap(response.data) ?? []) as BookingDetail[];

View File

@@ -5,6 +5,7 @@ import type {
CompanyChangeRequest,
CompanyListFilter,
CompanyProfile,
CompanyRevision,
CompanyStats,
CustomerBooking,
CustomerDocument,
@@ -146,6 +147,13 @@ export const customersService = {
.then((r) => r.data);
},
/** Onboarding-phase edit history for a company (version history), newest first. */
revisions(companyId: string): Promise<CompanyRevision[]> {
return apiClient
.get<CompanyRevision[]>(URL_CONSTANTS.COMPANIES.REVISIONS(companyId))
.then((r) => r.data);
},
/** Approve a pending change request — applies the proposed changes. */
approveChangeRequest(id: string): Promise<CompanyChangeRequest> {
return apiClient
@@ -165,6 +173,19 @@ export const customersService = {
.then((r) => r.data);
},
/** Ask for specific changes without rejecting — the request stays open for the customer's next edit to append to. */
requestChangeRequestChanges(
id: string,
note: string,
): Promise<CompanyChangeRequest> {
return apiClient
.post<CompanyChangeRequest>(
URL_CONSTANTS.COMPANIES.CHANGE_REQUEST_REQUEST_CHANGES(id),
{ note },
)
.then((r) => r.data);
},
/**
* Ask the customer to correct one uploaded document. Narrower than rejecting
* the whole role: the customer keeps their other documents and only re-uploads

View File

@@ -19,7 +19,8 @@ export type PaymentMethod =
| "waafi"
| "card"
| "dmoney"
| "cac-bank";
| "cac-bank"
| "cbe-bill";
export interface PaymentRow {
id: string;

View File

@@ -69,7 +69,11 @@ export interface CompanyProfile {
}
/** Lifecycle of a staged customer profile-edit review. */
export type ChangeRequestStatus = "pending" | "approved" | "rejected";
export type ChangeRequestStatus =
| "pending"
| "approved"
| "rejected"
| "changes_requested";
/** A staged business-license add/remove on one profile, awaiting review. */
export interface LicenseChangeIntent {
@@ -110,6 +114,33 @@ export interface CompanyChangeRequest {
updatedAt: string;
}
/**
* One field/document change recorded on a company revision. A document change
* carries `fromFileId`/`toFileId` alongside the display names, so the
* previous and current file can both be opened, not just named.
*/
export interface CompanyRevisionChange {
field: string;
label: string;
from: string | null;
to: string | null;
fromFileId?: string | null;
toFileId?: string | null;
}
/**
* Onboarding-phase edit history — records what changed on a company record
* before it reached Active, the write path that has no approval gate.
*/
export interface CompanyRevision {
id: string;
companyId: string;
actorId: string | null;
summary: string;
changes: CompanyRevisionChange[];
createdAt: string;
}
/** The channel a customer's password-reset link is delivered over. */
export type ResetChannel = "email" | "phone";
@@ -215,6 +246,7 @@ export interface Company {
onboardingCompleted?: boolean;
createdAt: string;
updatedAt: string;
approvedAt?: string | null;
}
/**

View File

@@ -883,6 +883,9 @@ export function AppLayout({
<AppShell.Main
style={{
backgroundColor: "##f8fafc",
// Extra bottom clearance so the fixed support-chat FAB never
// overlaps page content, even at the bottom of a scrolled page.
paddingBottom: 112,
}}
>
{children}

View File

@@ -28,6 +28,31 @@ interface ETradeInfoProps {
const isValidTin = (tin: string) => tin.length === 10;
/** Plain-text summary of the fetched eTrade record, downloaded client-side (eTrade returns data, not a document). */
function downloadTinRecord(tin: string, data: CompanyRegistrationData) {
const lines = [
`TIN: ${tin}`,
`Company name: ${data.companyName}`,
`Licence number: ${data.licenceNumber}`,
`Status: ${data.statusDescription}`,
`Date registered: ${data.dateRegistered}`,
`Renewed from: ${data.renewedFrom}`,
`Renewal date: ${data.renewalDate}`,
`Renewed to: ${data.renewedTo}`,
`Address: ${[data.region, data.zone, data.woreda, data.kebele, data.houseNo].filter(Boolean).join(", ")}`,
`Manager: ${data.managerName}`,
];
const blob = new Blob([lines.join("\n")], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `tin-${tin}.txt`;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
export default function ETradeInfo({
tin,
register,
@@ -123,6 +148,17 @@ export default function ETradeInfo({
{isLoading ? "Getting..." : "Get Data"}
</Button>
)}
{status === "verified" && mutation.data && !mutation.data.tinTaken && (
<Button
variant="light"
color="edr-green"
onClick={() => downloadTinRecord(tin, mutation.data!)}
leftSection={<Download size={16} />}
mt="24px"
>
Download
</Button>
)}
</Group>
{notFound && (

View File

@@ -1,6 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import { Link } from "react-router-dom";
import { AlertTriangle, ArrowRight, Clock } from "lucide-react";
import { AlertTriangle, ArrowRight, CheckCircle2, Clock } from "lucide-react";
import { api } from "@/services/api";
import useAuth from "@/hooks/useAuth";
import type { OnboardingRequirements } from "@/services/companies.service";
@@ -150,18 +150,46 @@ export default function OnboardingResumeBanner({
/**
* Post-onboarding review banner. Surfaces (in priority order):
* 0. The account is suspended/blacklisted — hard lock.
* 1. A pending profile-edit review — the whole account is locked until an admin
* approves the submitted changes.
* 2. A rejected profile-edit review — links to Settings to amend & resubmit.
* 3. Per-operational-profile approval — bookings unlock as each role clears.
* 2. Backoffice requested changes — soft: same edit-and-resubmit call to
* action as a rejection, but the edit appends to the same request.
* 3. A rejected profile-edit review — links to Settings to amend & resubmit
* (starts a fresh request).
* 4/5. Company- and per-operational-profile approval — bookings unlock as
* each role clears.
* Self-hides when there's nothing outstanding.
*/
export function AccountReviewBanner() {
const { company, reviewStatus, reviewNote } = useAuth();
const { company, companyStatus, reviewStatus, reviewNote } = useAuth();
const profiles = company?.company?.companyProfiles ?? [];
const pending = profiles.filter((p) => p.status === "pending");
const approved = profiles.filter((p) => p.status === "active");
// 0. Account suspended/blacklisted — the hardest lock, takes priority over
// everything else since nothing below matters if the account is shut down.
if (companyStatus === "suspended" || companyStatus === "blacklisted") {
return (
<div className="border-b border-red-200 bg-red-50 px-6 py-3">
<div className="mx-auto flex max-w-6xl items-center gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-red-100 text-red-700">
<AlertTriangle size={18} />
</span>
<span className="flex flex-col gap-0.5">
<span className="text-sm font-semibold text-red-900">
Your account has been suspended
</span>
<span className="text-xs text-red-800">
Contact EDR support to resolve this before you can continue
working.
</span>
</span>
</div>
</div>
);
}
// 1. Profile-edit review pending — the account-wide lock.
if (reviewStatus === "pending") {
return (
@@ -184,7 +212,41 @@ export function AccountReviewBanner() {
);
}
// 2. Profile-edit review rejected — prompt to fix & resubmit.
// 2. Backoffice asked for specific changes — soft: same edit-and-resubmit
// call to action as a rejection, but the copy stays collaborative since
// the edit appends to this same request instead of starting over.
if (reviewStatus === "changes_requested") {
return (
<div className="border-b border-amber-200 bg-amber-50 px-6 py-3">
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-amber-100 text-amber-700">
<AlertTriangle size={18} />
</span>
<span className="flex flex-col gap-0.5">
<span className="text-sm font-semibold text-amber-900">
Changes requested on your submission
</span>
<span className="text-xs text-amber-800">
{reviewNote
? `Reviewer note: ${reviewNote}`
: "Please update the requested details and resubmit for review."}
</span>
</span>
</div>
<Link
to="/settings"
className="inline-flex items-center gap-2 rounded-lg bg-amber-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-transform hover:scale-[1.02]"
>
Review &amp; resubmit
<ArrowRight size={16} />
</Link>
</div>
</div>
);
}
// 3. Profile-edit review rejected — prompt to fix & resubmit.
if (reviewStatus === "rejected") {
return (
<div className="border-b border-red-200 bg-red-50 px-6 py-3">
@@ -216,8 +278,45 @@ export function AccountReviewBanner() {
);
}
// 3. Per-operational-profile approval (existing behaviour).
if (profiles.length === 0 || pending.length === 0) return null;
// 4. Company approved and awaiting its first operational profile — nothing
// profile-specific to report yet, but the account itself is pending.
if (profiles.length === 0) {
if (companyStatus === "pending") {
return (
<div className="border-b border-amber-200 bg-amber-50 px-6 py-3">
<div className="mx-auto flex max-w-6xl items-center gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-amber-100 text-amber-700">
<Clock size={18} />
</span>
<span className="text-sm font-semibold text-amber-900">
Your account is pending approval
</span>
</div>
</div>
);
}
return null;
}
// 5. Per-operational-profile approval (existing behaviour).
if (pending.length === 0) {
// Nothing outstanding — a quiet confirmation that the account is live.
if (companyStatus === "active") {
return (
<div className="border-b border-emerald-200 bg-emerald-50 px-6 py-3">
<div className="mx-auto flex max-w-6xl items-center gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-emerald-100 text-emerald-700">
<CheckCircle2 size={18} />
</span>
<span className="text-sm font-semibold text-emerald-900">
Your account is approved
</span>
</div>
</div>
);
}
return null;
}
const pendingLabel = pending
.map((p) => p.type.replace(/_/g, " "))

View File

@@ -8,6 +8,7 @@ import {
Text,
Title,
} from "@mantine/core";
import { useMediaQuery } from "@mantine/hooks";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowRight,
@@ -127,6 +128,7 @@ export default function OnboardingWizardDialog({
}: OnboardingWizardDialogProps) {
const queryClient = useQueryClient();
const { user, company, onboardingStep, onboardingCompleted } = useAuth();
const isMobile = useMediaQuery("(max-width: 48em)");
const existingProfiles = company?.company?.companyProfiles ?? [];
const companyAlreadyStarted = Boolean(company?.company?.id);
@@ -409,7 +411,9 @@ export default function OnboardingWizardDialog({
ceiling = Math.min(ceiling, FORM_STEPS.indexOf("documents"));
if (poaIncomplete) ceiling = Math.min(ceiling, FORM_STEPS.indexOf("poa"));
const effectiveResumeStep: FormStep =
FORM_STEPS[Math.min(Math.max(FORM_STEPS.indexOf(resumeFormStep), 0), ceiling)];
FORM_STEPS[
Math.min(Math.max(FORM_STEPS.indexOf(resumeFormStep), 0), ceiling)
];
const formProps = {
documentSettingCode: resolvedDocumentSettingCode,
@@ -450,9 +454,10 @@ export default function OnboardingWizardDialog({
withCloseButton={!completed}
closeOnClickOutside={false}
closeOnEscape={!completed}
fullScreen={isMobile}
size={1440}
radius="lg"
padding="xl"
padding={isMobile ? "md" : "xl"}
centered
keepMounted
scrollAreaComponent={ScrollArea.Autosize}
@@ -464,6 +469,9 @@ export default function OnboardingWizardDialog({
title: {
flex: 1,
},
body: isMobile
? { paddingBottom: "calc(100px + env(safe-area-inset-bottom))" }
: undefined,
}}
title={
completed ? null : (

View File

@@ -130,6 +130,8 @@ export const URL_CONSTANTS = {
CANCEL: (id: string | number) => `/api/bookings/${id}/cancel`,
CONFIRM: (id: string | number) => `/api/bookings/${id}/confirm`,
CUSTOMER_TRUCKS: (id: string) => `/api/bookings/${id}/customer-trucks`,
CUSTOMER_TRUCKS_BULK: (id: string) =>
`/api/bookings/${id}/customer-trucks/bulk`,
CUSTOMER_TRUCK: (id: string, assignmentId: string) =>
`/api/bookings/${id}/customer-trucks/${assignmentId}`,
},
@@ -201,6 +203,8 @@ export const URL_CONSTANTS = {
MY_INVOICE_DOCUMENT: (id: string) => `/api/billing/my-invoices/${id}/document`,
MY_INVOICE_RECEIPT: (id: string) => `/api/billing/my-invoices/${id}/receipt`,
PAY_INVOICE: (id: string) => `/api/billing/my-invoices/${id}/pay`,
CONFIRM_INVOICE_OTP: (id: string) =>
`/api/billing/my-invoices/${id}/confirm`,
},
WAREHOUSE_INVOICES: {

View File

@@ -41,7 +41,7 @@ export function SupportWidget() {
if (!isAuthenticated) return null;
return (
<Affix position={{ bottom: 24, right: 24 }} zIndex={300}>
<Affix position={{ bottom: 24, right: 24 }} zIndex={300} className="group">
<Transition mounted={open} transition="pop-bottom-right" duration={200}>
{(styles) => (
<div style={styles} className="mb-3">
@@ -53,40 +53,44 @@ export function SupportWidget() {
<Transition mounted={!open} transition="pop" duration={150}>
{(styles) => (
<div style={styles} className="flex justify-end">
<Indicator
label={unread > 9 ? "9+" : unread}
size={20}
offset={8}
color="red"
disabled={unread === 0}
processing
withBorder
>
<button
type="button"
aria-label="Open support chat"
onClick={() => setOpen(true)}
className="group relative flex cursor-pointer items-center gap-2.5 rounded-full bg-linear-135 from-edr-primary-dark to-edr-primary py-2 pr-2 pl-2 text-white shadow-[0_10px_28px_rgba(13,92,44,0.4)] transition-transform duration-150 hover:-translate-y-0.5 focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-edr-primary active:translate-y-0 motion-reduce:transition-none motion-reduce:hover:translate-y-0 sm:pr-5"
{/* Icon+label stay recognizable at rest, just tucked down a bit;
lifts fully into place on hover/focus. */}
<div className="translate-y-[60%] transition-transform duration-300 ease-out group-hover:translate-y-0 focus-within:translate-y-0 motion-reduce:translate-y-0">
<Indicator
label={unread > 9 ? "9+" : unread}
size={20}
offset={8}
color="red"
disabled={unread === 0}
processing
withBorder
>
{/* Faint breathing ring; the unread badge already pulses, so stand down then. */}
{unread === 0 && (
<span className="pointer-events-none absolute -inset-1 animate-pulse rounded-full ring-2 ring-edr-primary/50 motion-reduce:animate-none" />
)}
<button
type="button"
aria-label="Open support chat"
onClick={() => setOpen(true)}
className="group relative flex cursor-pointer items-center gap-2.5 rounded-full bg-linear-135 from-edr-primary-dark to-edr-primary py-2 pr-2 pl-2 text-white shadow-[0_10px_28px_rgba(13,92,44,0.4)] transition-transform duration-150 hover:-translate-y-0.5 focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-edr-primary active:translate-y-0 motion-reduce:transition-none motion-reduce:hover:translate-y-0 sm:pr-5"
>
{/* Faint breathing ring; the unread badge already pulses, so stand down then. */}
{unread === 0 && (
<span className="pointer-events-none absolute -inset-1 animate-pulse rounded-full ring-2 ring-edr-primary/50 motion-reduce:animate-none" />
)}
<span className="relative grid size-[42px] shrink-0 place-items-center rounded-full bg-white/20">
<Headset size={22} />
</span>
<span className="relative grid size-[42px] shrink-0 place-items-center rounded-full bg-white/20">
<Headset size={22} />
</span>
<span className="relative hidden text-left leading-tight sm:block">
<span className="block text-sm font-semibold whitespace-nowrap">
👋 Need help?
<span className="relative hidden text-left leading-tight sm:block">
<span className="block text-sm font-semibold whitespace-nowrap">
👋 Need help?
</span>
<span className="block text-[11px] whitespace-nowrap opacity-85">
Chat with our team
</span>
</span>
<span className="block text-[11px] whitespace-nowrap opacity-85">
Chat with our team
</span>
</span>
</button>
</Indicator>
</button>
</Indicator>
</div>
</div>
)}
</Transition>

View File

@@ -0,0 +1,115 @@
import { useMutation } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { useState } from "react";
import { invoicesService } from "@/services/invoices.service";
import {
paymentsService,
type InitiateResponse,
type PaymentMethod,
} from "@/services/payments.service";
/** How the invoice is charged — overridable for warehouse fee invoices. */
type InitiateFn = (
invoiceId: string,
method: PaymentMethod,
payerAccount?: string,
) => Promise<InitiateResponse>;
const payViaBilling: InitiateFn = (invoiceId, method, payerAccount) =>
invoicesService.pay(invoiceId, { method, platform: "web", payerAccount });
/** The server's message (`{ message }` / `{ message: [] }`), or a fallback. */
function apiMessage(err: unknown, fallback: string): string {
const message = (err as AxiosError<{ message?: string | string[] }>)?.response
?.data?.message;
const first = Array.isArray(message) ? message[0] : message;
return first || fallback;
}
/**
* One payment flow for every "pay this invoice" entry point: initiate, then
* either redirect to the provider or — for CAC Bank, an OTP debit with no
* redirect — collect the SMS'd code and confirm it in-app. Pass `initiate` to
* charge through a different endpoint (warehouse fee invoices); OTP
* confirmation always goes through billing, which owns the intent either way.
*/
export function useInvoicePayment(initiate: InitiateFn = payViaBilling) {
const [otpInvoiceId, setOtpInvoiceId] = useState<string | null>(null);
const [otpMessage, setOtpMessage] = useState<string | undefined>();
const payMutation = useMutation({
mutationFn: (vars: {
invoiceId: string;
method: PaymentMethod;
payerAccount?: string;
}) => initiate(vars.invoiceId, vars.method, vars.payerAccount),
onSuccess: (data, vars) => {
if (data?.clientAction?.type === "COLLECT_OTP") {
setOtpMessage(
data.clientAction.message ?? "Enter the OTP sent to your phone",
);
setOtpInvoiceId(vars.invoiceId);
return;
}
window.location.href =
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
? data.clientAction.url
: paymentsService.checkoutUrlForInvoice({
invoiceId: vars.invoiceId,
method: vars.method,
});
},
});
const otpMutation = useMutation({
mutationFn: (otp: string) =>
invoicesService.confirmOtp(otpInvoiceId as string, otp),
// Settled — reload so the invoice/booking re-reads its now-paid state.
onSuccess: () => {
setOtpInvoiceId(null);
window.location.reload();
},
});
const reset = () => {
payMutation.reset();
otpMutation.reset();
setOtpInvoiceId(null);
};
return {
processing: payMutation.isPending,
error: payMutation.isError
? apiMessage(
payMutation.error,
payMutation.error instanceof Error
? payMutation.error.message
: "Could not start payment. Please try again.",
)
: null,
pay: (invoiceId: string, method: PaymentMethod, payerAccount?: string) =>
payMutation.mutate({ invoiceId, method, payerAccount }),
reset,
/** Drives the modal's OTP step; `open` only for CAC Bank. */
otp: {
open: otpInvoiceId !== null,
message: otpMessage,
submitting: otpMutation.isPending,
// A wrong/expired OTP is a 400 — keep the step open so the payer retries.
error: otpMutation.isError
? apiMessage(
otpMutation.error,
"Invalid or expired OTP. Please try again.",
)
: null,
submit: (otp: string) => otpMutation.mutate(otp),
cancel: () => {
otpMutation.reset();
setOtpInvoiceId(null);
},
},
};
}
export type InvoicePaymentFlow = ReturnType<typeof useInvoicePayment>;

View File

@@ -1,6 +1,6 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { Badge, Box, Button, Group, Stack, Text } from "@mantine/core";
import {
AlertTriangle,
@@ -10,9 +10,8 @@ import {
PackagePlus,
} from "lucide-react";
import { api } from "@/services/api";
import { ModalSafeWrapper } from "@/components/customer-actions/ModalSafeWrapper";
import { paymentsService, type PaymentMethod } from "@/services/payments.service";
import { useInvoicePayment } from "@/hooks/useInvoicePayment";
import { invoicesService } from "@/services/invoices.service";
import { isPayable } from "@/pages/billing/invoice-ui";
import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal";
@@ -60,29 +59,7 @@ export function ActionNeededSection({ items: base }: ActionNeededSectionProps) {
isPayable(inv.status),
)?.id;
const payMutation = useMutation({
mutationFn: (method: PaymentMethod) => {
if (!payableInvoiceId) {
throw new Error(
"No payable invoice found for this booking yet. Please refresh or contact support.",
);
}
return api.invoices.pay.call({
id: payableInvoiceId,
payload: { method, platform: "web" },
});
},
onSuccess: (data, method) => {
const url =
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
? data.clientAction.url
: paymentsService.checkoutUrlForInvoice({
invoiceId: payableInvoiceId!,
method,
});
window.location.href = url;
},
});
const pay = useInvoicePayment();
if (items.length === 0) return null;
@@ -189,21 +166,24 @@ export function ActionNeededSection({ items: base }: ActionNeededSectionProps) {
<PaymentMethodModal
opened={payItem !== null}
onClose={() => {
if (!payMutation.isPending) {
if (!pay.processing) {
setPayItem(null);
payMutation.reset();
pay.reset();
}
}}
currency={undefined}
processing={payMutation.isPending}
processing={pay.processing}
error={
payMutation.isError
? payMutation.error instanceof Error
? payMutation.error.message
: "Could not start payment. Please try again."
: null
pay.error ??
(payItemInvoices.length > 0 && !payableInvoiceId
? "No payable invoice found for this booking yet. Please refresh or contact support."
: null)
}
otp={pay.otp}
onConfirm={(method, payerAccount) =>
payableInvoiceId &&
pay.pay(payableInvoiceId, method, payerAccount)
}
onConfirm={(method) => payMutation.mutate(method)}
/>
</ModalSafeWrapper>
</Card>

View File

@@ -287,9 +287,31 @@ export default function SettingsPage() {
icon={<Clock size={18} />}
title="Changes submitted for review"
>
Your recent changes are awaiting administrator approval. Editing is
disabled until the review is complete you'll be notified once it's
approved or if any changes are requested.
Your recent changes are awaiting administrator approval. Company
details and documents can't be edited until the review is complete —
you'll be notified once it's approved or if any changes are
requested. Your contact person, general manager and Power of
Attorney stay editable.
</Alert>
)}
{reviewStatus === "changes_requested" && (
<Alert
color="yellow"
variant="light"
icon={<AlertTriangle size={18} />}
title="Changes requested on your submission"
>
<Stack gap={4}>
{profile.reviewNote && (
<Text size="sm">
<strong>Reviewer note:</strong> {profile.reviewNote}
</Text>
)}
<Text size="sm">
Please update the requested details below and save again to
resubmit for review.
</Text>
</Stack>
</Alert>
)}
{reviewStatus === "rejected" && (
@@ -358,9 +380,17 @@ export default function SettingsPage() {
)}
</Tabs.Panel>
{/* While a change request is pending, every panel's inputs + submit
buttons are disabled via the native fieldset; tab switching stays
enabled so the customer can still review what they submitted. */}
{/* While a change request is pending, the reviewed panels' inputs +
submit buttons are disabled via the native fieldset; tab switching
stays enabled so the customer can still review what they
submitted.
Personnel panels below (contact person, general manager, Power of
Attorney) are deliberately outside the lock: the API applies those
edits live rather than staging them, so locking them here would
re-impose the approval wait the API no longer does. The PoA's
delegation letter is still reviewed — that lock lives on the file
itself, not the panel. */}
<Tabs.Panel value="company">
<Fieldset disabled={locked} variant="unstyled" p={0}>
<TabCompanyProfile mode="edit" profile={profile} user={user ?? undefined} />
@@ -368,19 +398,13 @@ export default function SettingsPage() {
<OperationalServicesCard profile={profile} />
</Tabs.Panel>
<Tabs.Panel value="contact">
<Fieldset disabled={locked} variant="unstyled" p={0}>
<TabContactPerson profile={profile} mode="edit" />
</Fieldset>
<TabContactPerson profile={profile} mode="edit" />
</Tabs.Panel>
<Tabs.Panel value="gm">
<Fieldset disabled={locked} variant="unstyled" p={0}>
<TabGeneralManager profile={profile} mode="edit" />
</Fieldset>
<TabGeneralManager profile={profile} mode="edit" />
</Tabs.Panel>
<Tabs.Panel value="poa">
<Fieldset disabled={locked} variant="unstyled" p={0}>
<TabPowerOfAttorney profile={profile} mode="edit" />
</Fieldset>
<TabPowerOfAttorney profile={profile} mode="edit" />
</Tabs.Panel>
<Tabs.Panel value="documents">
<Fieldset disabled={locked} variant="unstyled" p={0}>

View File

@@ -33,7 +33,6 @@ import {
buildOnboardingSchema,
type CompanyStep,
type FormData,
hasPoaDetails,
POA_DELEGATION_FILE_KEY,
stepFields,
} from "./companyProfileForm/schema";
@@ -191,11 +190,7 @@ export default function CompanyProfileForm({
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(
buildOnboardingSchema(
requirePoa,
verifiedIdentity,
identity?.passportRequired === true,
),
buildOnboardingSchema(identity?.passportRequired === true),
),
defaultValues: {
companyName: "",
@@ -321,7 +316,6 @@ export default function CompanyProfileForm({
// them and re-enables editing.
const [gmSameAsOwner, setGmSameAsOwner] = useState(false);
const [contactSameAsGm, setContactSameAsGm] = useState(false);
const [poaSameAsContact, setPoaSameAsContact] = useState(false);
// General Manager source. The company step's email/phone are seeded from
// eTrade (and the account email) but stay editable, so the link reads the
@@ -367,9 +361,6 @@ export default function CompanyProfileForm({
const gmName = watch("generalManagerName");
const gmEmail = watch("generalManagerEmail");
const gmPhone = watch("generalManagerPhone");
const contactName = watch("contactPersonName");
const contactEmail = watch("contactPersonEmail");
const contactPhone = watch("contactPersonPhone");
// While linked, mirror the source values into the (disabled) target fields so
// the copy stays current even if the user goes back and edits the source.
@@ -381,26 +372,6 @@ export default function CompanyProfileForm({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [contactSameAsGm, gmName, gmEmail, gmPhone]);
// The contact-person step has no address of its own, so the linked PoA takes
// the company's composed address. poaLocation (the city) stays typed on the
// PoA step — the company step no longer has a location field to mirror.
const companyAddress = watch("companyAddress");
useEffect(() => {
if (!poaSameAsContact) return;
setValue("poaName", contactName ?? "");
setValue("poaEmail", contactEmail ?? "");
setValue("poaPhone", contactPhone ?? "");
setValue("poaAddress", companyAddress ?? "");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
poaSameAsContact,
contactName,
contactEmail,
contactPhone,
companyAddress,
]);
const toggleContactSameAsGm = (checked: boolean) => {
setContactSameAsGm(checked);
// Checked → the mirror effect fills the fields; unchecked → reset them.
@@ -411,17 +382,6 @@ export default function CompanyProfileForm({
}
};
const togglePoaSameAsContact = (checked: boolean) => {
setPoaSameAsContact(checked);
if (!checked) {
setValue("poaName", "");
setValue("poaEmail", "");
setValue("poaPhone", "");
setValue("poaLocation", "");
setValue("poaAddress", "");
}
};
// The DARS delegation paper ships in the same nationality document set as the
// rest (the API guarantees it is there), but belongs on the PoA step next to
// the details it evidences — so it's split out here and the Documents step
@@ -551,7 +511,9 @@ export default function CompanyProfileForm({
// for a freight forwarder, whose PoA itself is mandatory. The API enforces
// the same rule on save, so skipping it here only costs the customer a
// round-trip.
const poaProvided = hasPoaDetails(watch());
// A PoA exists exactly when one has been verified — the details are the
// verification's output, so there is nothing else that could stand for one.
const poaProvided = identity?.poa.verified ?? false;
const delegationRequired = requirePoa || poaProvided;
const delegationPresent =
(uploadedDocumentKeys ?? []).includes(POA_DELEGATION_FILE_KEY) ||
@@ -638,12 +600,7 @@ export default function CompanyProfileForm({
setSaveError("Verify the company owner's identity with Fayda before continuing.");
return;
}
if (
step === "poa" &&
verifiedIdentity &&
requirePoa &&
!identity?.poa.verified
) {
if (step === "poa" && requirePoa && !identity?.poa.verified) {
setSaveError(
"Freight forwarders act on other companies' behalf, so the Power of Attorney's identity must be verified with Fayda.",
);
@@ -876,71 +833,27 @@ export default function CompanyProfileForm({
? "As a freight forwarder you act on other companies' behalf, so Power of Attorney details and the DARS delegation paper are required."
: "Power of Attorney details are optional. Fill them in if you have them, or skip to continue. If you do enter a representative, upload the delegation paper authenticated by DARS."}
</Text>
{/* A representative acts for the company inside Ethiopia
whoever owns it, so the PoA is proven with Fayda regardless of
nationality — their name, email, phone and address all come
from the verification and are never typed here. */}
{identity && (
<FaydaVerifyPanel
subject="poa"
title="Power of Attorney"
state={identity.poa}
required={identity.faydaRequired}
required={requirePoa}
onVerified={() => onIdentityChange?.()}
/>
)}
{!verifiedIdentity && watch("contactPersonName") && (
<LinkCheckboxCard
checked={poaSameAsContact}
onToggle={togglePoaSameAsContact}
title="Same as contact person"
description="Reuse the contact person's name, email and phone, plus the company's location and address. Uncheck to enter different details."
/>
)}
{!verifiedIdentity && (
<>
<TextInput
label="PoA Name"
placeholder="Authorized Representative Name"
error={errors.poaName?.message}
{...register("poaName")}
/>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="PoA Email"
type="email"
placeholder="poa@company.com"
error={errors.poaEmail?.message}
{...register("poaEmail")}
/>
<ControlledPhoneField
control={control}
name="poaPhone"
label="PoA Phone"
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="PoA Location"
placeholder="City, Country"
error={errors.poaLocation?.message}
{...register("poaLocation")}
/>
<TextInput
label="PoA Address"
placeholder="Full Address"
error={errors.poaAddress?.message}
{...register("poaAddress")}
/>
</SimpleGrid>
</>
)}
{/* The city is the one field the Fayda address claim does not
reliably decompose into, so it stays typed either way. */}
{verifiedIdentity && (
<TextInput
label="PoA Location"
placeholder="City, Country"
error={errors.poaLocation?.message}
{...register("poaLocation")}
/>
)}
reliably decompose into, so it stays typed. */}
<TextInput
label="PoA Location"
placeholder="City, Country"
error={errors.poaLocation?.message}
{...register("poaLocation")}
/>
{poaDocumentSetting && (
<>

View File

@@ -37,10 +37,8 @@ export function buildPayload(
generalManagerName: data.generalManagerName,
generalManagerEmail: data.generalManagerEmail,
generalManagerPhone: data.generalManagerPhone,
poaName: data.poaName || undefined,
poaPhone: data.poaPhone || undefined,
poaAddress: data.poaAddress || undefined,
poaEmail: data.poaEmail || undefined,
// The representative's own details are written by their Fayda
// verification, so the city is all the form has to send.
poaLocation: data.poaLocation || undefined,
},
};
@@ -88,13 +86,7 @@ export function stepPayload(
contactPersonPhone: d.contactPersonPhone,
};
case "poa":
return {
poaName: d.poaName || undefined,
poaPhone: d.poaPhone || undefined,
poaEmail: d.poaEmail || undefined,
poaLocation: d.poaLocation || undefined,
poaAddress: d.poaAddress || undefined,
};
return { poaLocation: d.poaLocation || undefined };
default:
return {};
}

View File

@@ -92,58 +92,28 @@ export type FormData = z.infer<typeof onboardingSchema>;
/** fileKey of the delegation letter uploaded on the Power of Attorney step. */
export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter";
export const POA_FIELDS = [
"poaName",
"poaPhone",
"poaEmail",
"poaLocation",
"poaAddress",
] as const satisfies readonly (keyof FormData)[];
/** True once the customer has entered any Power of Attorney detail. */
export const hasPoaDetails = (d: Partial<FormData>) =>
POA_FIELDS.some((f) => d[f]?.trim());
/**
* A freight forwarder acts on other companies' behalf, so its PoA is mandatory
* rather than optional. Everyone else keeps the optional PoA — but once they
* start filling it in, the identifying fields have to be complete (the
* delegation-letter upload is enforced alongside this, in CompanyProfileForm,
* since files live outside the form state).
* The PoA's identifying fields are never typed — they come from the Fayda
* verification, whatever the company's nationality — so nothing here requires
* them. A freight forwarder's mandatory PoA is gated on the verification
* itself, and its delegation letter alongside it, both in CompanyProfileForm
* (files live outside form state).
*
* That leaves the owner's passport number as the only conditional field.
*/
export function buildOnboardingSchema(
requirePoa: boolean,
/**
* True when the PoA's identity fields come from a Fayda verification rather
* than the form (Ethiopian companies). Requiring them here would fail
* validation against inputs the step no longer renders — the verification
* itself is what the step gates on instead.
*/
faydaOwnedPoa = false,
/** True for a foreign company: the owner's passport number is mandatory. */
passportRequired = false,
) {
const poaRequired = requirePoa && !faydaOwnedPoa;
if (!poaRequired && !passportRequired) return onboardingSchema;
if (!passportRequired) return onboardingSchema;
return onboardingSchema.superRefine((d, ctx) => {
const required: [keyof FormData, string][] = [];
if (poaRequired) {
required.push(
["poaName", "PoA name is required for freight forwarders"],
["poaEmail", "PoA email is required for freight forwarders"],
["poaPhone", "PoA phone is required for freight forwarders"],
);
}
if (passportRequired) {
required.push([
"ownerPassportNumber",
"The owner's passport number is required",
]);
}
for (const [path, message] of required) {
if (!d[path]?.trim()) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: [path], message });
}
if (!d.ownerPassportNumber?.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["ownerPassportNumber"],
message: "The owner's passport number is required",
});
}
});
}
@@ -180,7 +150,7 @@ export const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
"contactPersonEmail",
"contactPersonPhone",
],
poa: [...POA_FIELDS],
poa: ["poaLocation"],
documents: [],
additional: [],
};

View File

@@ -1,6 +1,6 @@
import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import {
Alert,
Box,
@@ -9,6 +9,7 @@ import {
Divider,
Group,
Loader,
Modal,
Paper,
SimpleGrid,
Stack,
@@ -27,10 +28,7 @@ import toast from "react-hot-toast";
import { api } from "@/services/api";
import { invoicesService } from "@/services/invoices.service";
import {
paymentsService,
type PaymentMethod,
} from "@/services/payments.service";
import { useInvoicePayment } from "@/hooks/useInvoicePayment";
import { warehouseInvoicesService } from "@/services/warehouse-invoices.service";
import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal";
import { saveBlob } from "@/utils/download";
@@ -72,21 +70,17 @@ export default function InvoiceDetailPage() {
} = useQuery(api.invoices.get.queryOptions({ input: { id } }));
const [payModalOpen, setPayModalOpen] = useState(false);
// CBE bill payment: the bill reference to pay at any CBE channel (no redirect).
const [billAction, setBillAction] = useState<{
billReference?: string;
instructions?: string;
expiresAt?: string;
} | null>(null);
// Ownership-checked: POST /billing/my-invoices/:id/pay only ever charges
// one of the signed-in customer's own invoices (unlike the admin-facing
// /payments/initiate, which takes any invoiceId with no ownership check).
const payMutation = useMutation({
mutationFn: (method: PaymentMethod) =>
api.invoices.pay.call({ id, payload: { method, platform: "web" } }),
onSuccess: (data, method) => {
const redirectUrl =
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
? data.clientAction.url
: paymentsService.checkoutUrlForInvoice({ invoiceId: id, method });
window.location.href = redirectUrl;
},
});
const pay = useInvoicePayment();
if (isLoading) {
return (
@@ -249,7 +243,7 @@ export default function InvoiceDetailPage() {
radius="md"
size="md"
leftSection={<CreditCard size={16} />}
loading={payMutation.isPending}
loading={pay.processing}
onClick={handlePay}
styles={{
root: { fontWeight: 600, height: 42, paddingInline: 18 },
@@ -376,23 +370,76 @@ export default function InvoiceDetailPage() {
<PaymentMethodModal
opened={payModalOpen}
onClose={() => {
if (!payMutation.isPending) {
if (!pay.processing) {
setPayModalOpen(false);
payMutation.reset();
pay.reset();
}
}}
amountLabel={formatCurrency(amountDue, invoice.currency)}
currency={invoice.currency}
processing={payMutation.isPending}
error={
payMutation.isError
? payMutation.error instanceof Error
? payMutation.error.message
: "Could not start payment. Please try again."
: null
processing={pay.processing}
error={pay.error}
otp={pay.otp}
onConfirm={(method, payerAccount) =>
pay.pay(id, method, payerAccount)
}
onConfirm={(method) => payMutation.mutate(method)}
/>
{/* CBE bill payment — show the bill number; settlement arrives via CBE, not the browser */}
<Modal
opened={!!billAction}
onClose={() => setBillAction(null)}
centered
radius={18}
size={440}
title={<Text fw={800}>Pay at CBE</Text>}
>
<Stack gap="sm">
<Text fz="sm" c={MUTED}>
{billAction?.instructions ??
"Pay this bill at any CBE branch, the CBE Birr app, mobile banking or USSD."}
</Text>
<Group
justify="space-between"
px={16}
py={13}
style={{ borderRadius: 12, border: `1px solid ${BORDER}` }}
>
<Text ff="monospace" fz={24} fw={800} c={INK} style={{ letterSpacing: 3 }}>
{billAction?.billReference}
</Text>
<Button
variant="light"
size="xs"
onClick={() => {
if (billAction?.billReference) {
navigator.clipboard?.writeText(billAction.billReference);
toast.success("Bill number copied");
}
}}
>
Copy
</Button>
</Group>
<Text fz="sm" c={MUTED}>
Amount due:{" "}
<Text span fw={700} c={INK}>
{formatCurrency(amountDue, invoice.currency)}
</Text>
</Text>
{billAction?.expiresAt && (
<Text fz="sm" c={MUTED}>
Pay before:{" "}
<Text span fw={700} c={INK}>
{fmtDate(billAction.expiresAt)}
</Text>
</Text>
)}
<Text fz="xs" c={MUTED}>
The invoice updates automatically once CBE confirms your payment.
</Text>
</Stack>
</Modal>
</Stack>
</Box>
);

View File

@@ -4,11 +4,7 @@ import { Clock, CreditCard, FileText, LayoutGrid, Truck } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "@/services/api";
import { useFileViewer } from "@/hooks/useFileViewer";
import { invoicesService } from "@/services/invoices.service";
import { paymentsService, type PaymentMethod } from "@/services/payments.service";
import { isPayable } from "@/pages/billing/invoice-ui";
import type { Freight } from "@edr/types";
import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton";
@@ -41,6 +37,7 @@ import { StatusHero } from "./components/StatusHero";
import { SupportCard } from "./components/SupportCard";
import { fmtDate, isNegative, priceTotal } from "./utils";
import { useScrollToHash } from "@/hooks/useScrollToHash";
import { useBookingPayment } from "@/pages/bookings/payments/useBookingPayment";
export function ReadonlyBookingView({
booking,
@@ -53,7 +50,6 @@ export function ReadonlyBookingView({
// Deep-link support: e.g. /bookings/:id#warehouse-payments from an invoice.
useScrollToHash();
const status = booking.status as string;
const [payModalOpen, setPayModalOpen] = useState(false);
const { viewer } = useFileViewer();
// Re-book opens the New Shipment Booking form for the same contract, not the
@@ -63,44 +59,11 @@ export function ReadonlyBookingView({
: "/contracts/new";
const onRebook = () => navigate(rebookTo);
// Billing is invoice-centric — resolve the booking's currently payable
// invoice (same query/key BookingPaymentPanel uses, so this shares its
// cache) and pay it through the ownership-checked portal route.
const { data: bookingInvoices = [] } = useQuery({
queryKey: ["booking-invoices", booking.id],
queryFn: () => invoicesService.listForSource("booking", booking.id),
});
const payableInvoiceId = bookingInvoices.find((inv) =>
isPayable(inv.status),
)?.id;
// POST /billing/my-invoices/:id/pay creates the intent and returns the
// provider's redirect URL (clientAction.url). Send the browser straight
// there; fall back to the public /payments/checkout page if no redirect
// URL came back.
const payMutation = useMutation({
mutationFn: (method: PaymentMethod) => {
if (!payableInvoiceId) {
throw new Error(
"No payable invoice found for this booking yet. Please refresh or contact support.",
);
}
return api.invoices.pay.call({
id: payableInvoiceId,
payload: { method, platform: "web" },
});
},
onSuccess: (data, method) => {
const redirectUrl =
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
? data.clientAction.url
: paymentsService.checkoutUrlForInvoice({
invoiceId: payableInvoiceId!,
method,
});
window.location.href = redirectUrl;
},
});
// Billing is invoice-centric — the shared hook resolves the booking's
// currently payable invoice (same query/key BookingPaymentPanel uses, so it
// shares that cache), charges it through the ownership-checked portal route,
// and handles redirect vs CAC Bank OTP.
const pay = useBookingPayment(booking.id);
const pricing = booking.pricingBreakdown;
// A general contract is paid once it's FULLY_EXECUTED (signed) — it never
@@ -171,7 +134,7 @@ export function ReadonlyBookingView({
green
icon={<CreditCard size={16} />}
label="Pay now"
onClick={() => setPayModalOpen(true)}
onClick={pay.open}
/>
)}
</Group>
@@ -276,8 +239,8 @@ export function ReadonlyBookingView({
<BookingPaymentPanel
booking={booking}
pricing={pricing}
onPay={() => setPayModalOpen(true)}
paying={payMutation.isPending}
onPay={pay.open}
paying={pay.processing}
showCountdown={showCountdown}
/>
<ScheduleCard
@@ -327,24 +290,14 @@ export function ReadonlyBookingView({
</Tabs>
<PaymentMethodModal
opened={payModalOpen}
onClose={() => {
if (!payMutation.isPending) {
setPayModalOpen(false);
payMutation.reset();
}
}}
opened={pay.modalOpen}
onClose={pay.close}
amountLabel={pricing ? priceTotal(pricing) : undefined}
currency={pricing?.currency ?? booking.paymentCurrency}
processing={payMutation.isPending}
error={
payMutation.isError
? payMutation.error instanceof Error
? payMutation.error.message
: "Could not start payment. Please try again."
: null
}
onConfirm={(method) => payMutation.mutate(method)}
processing={pay.processing}
error={pay.error}
otp={pay.otp}
onConfirm={pay.confirm}
/>
{viewer}
</PageShell>

View File

@@ -4,6 +4,7 @@ import { Upload, Download, AlertCircle, CheckCircle, AlertTriangle } from "lucid
import { useMutation } from "@tanstack/react-query";
import { client } from "@/utils/api";
import { URL_CONSTANTS } from "@/constants/URLS";
import { generateTruckAssignmentTemplate, parseTruckAssignmentFile } from "@/utils/truck-assignment-template";
interface BulkTruckUploadModalProps {
@@ -32,7 +33,7 @@ export function BulkTruckUploadModal({
const uploadMutation = useMutation({
mutationFn: async () => {
const { data } = await client.post(`/bookings/${bookingId}/customer-trucks/bulk`, {
const { data } = await client.post(URL_CONSTANTS.BOOKINGS.CUSTOMER_TRUCKS_BULK(bookingId), {
trucks: parsed,
});
return data;

View File

@@ -1,20 +1,32 @@
import { Box, Button, Group, Image, Modal, Stack, Text } from "@mantine/core";
import { Check, ShieldCheck } from "lucide-react";
import {
Box,
Button,
Group,
Image,
Modal,
PinInput,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { Check, Landmark, ShieldCheck } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import type { InvoicePaymentFlow } from "@/hooks/useInvoicePayment";
import type { PaymentMethod } from "@/services/payments.service";
interface ProviderOption {
method: PaymentMethod;
label: string;
description: string;
logo: string;
/** Logo asset; falls back to a bank glyph when the provider has none. */
logo?: string;
/** Currencies this provider settles in. */
currencies: string[];
accent: string;
}
// Only Telebirr and Waafi are enabled for now.
// Only Telebirr, Waafi and CBE bill payment are enabled for now.
const PROVIDERS: ProviderOption[] = [
{
method: "TELEBIRR",
@@ -32,8 +44,26 @@ const PROVIDERS: ProviderOption[] = [
currencies: ["USD"],
accent: "#2E5B96",
},
{
method: "CAC_BANK",
label: "CAC Bank",
description: "Djibouti bank debit · confirmed by SMS OTP",
currencies: ["USD"],
accent: "#8A5A17",
},
{
method: "CBE_BILL",
label: "CBE bill payment",
description: "Pay at any CBE branch, app or USSD · ETB",
logo: "/assets/edr-logo.png",
currencies: ["ETB"],
accent: "#5B2D8C",
},
];
/** Providers that debit against an SMS OTP instead of redirecting to a page. */
const isOtpMethod = (method: PaymentMethod) => method === "CAC_BANK";
/**
* Pick the provider that settles in the booking's currency. USD → Waafi,
* ETB → Telebirr. Falls back to the first provider when unknown.
@@ -91,13 +121,27 @@ function ProviderRow({
backgroundColor: "#fff",
}}
>
<Image
src={option.logo}
alt={`${option.label} logo`}
w={52}
h={52}
fit="cover"
/>
{option.logo ? (
<Image
src={option.logo}
alt={`${option.label} logo`}
w={52}
h={52}
fit="cover"
/>
) : (
<Box
style={{
width: 52,
height: 52,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Landmark size={24} color={option.accent} />
</Box>
)}
</Box>
<Box style={{ flex: 1, minWidth: 0 }}>
<Text fz="15px" fw={800} c="#10202F" tt="capitalize">
@@ -135,6 +179,7 @@ export function PaymentMethodModal({
onConfirm,
processing,
error,
otp,
}: {
opened: boolean;
onClose: () => void;
@@ -142,12 +187,19 @@ export function PaymentMethodModal({
amountLabel?: string;
/** Booking payment currency — drives which provider is shown (USD → Waafi, ETB → Telebirr). */
currency?: string | null;
onConfirm: (method: PaymentMethod) => void;
onConfirm: (method: PaymentMethod, payerAccount?: string) => void;
processing?: boolean;
error?: string | null;
/** CAC Bank OTP step, from `useInvoicePayment`. Omit to disable OTP providers. */
otp?: InvoicePaymentFlow["otp"];
}) {
const providers = useMemo(() => providersForCurrency(currency), [currency]);
const providers = useMemo(
() => providersForCurrency(currency).filter((p) => otp || !isOtpMethod(p.method)),
[currency, otp],
);
const [method, setMethod] = useState<PaymentMethod>(providers[0].method);
const [mobile, setMobile] = useState("");
const [code, setCode] = useState("");
// Keep the selection valid when the currency (and therefore provider list) changes.
useEffect(() => {
@@ -156,6 +208,89 @@ export function PaymentMethodModal({
}
}, [providers, method]);
// A fresh OTP round always starts empty.
useEffect(() => {
if (otp?.open) setCode("");
}, [otp?.open]);
// CAC Bank debits the account behind this number and SMSes the OTP to it.
const needsMobile = isOtpMethod(method);
const canSubmit = !needsMobile || mobile.trim().length > 0;
if (otp?.open) {
return (
<Modal
opened={opened}
onClose={otp.cancel}
centered
radius={18}
size={420}
padding={0}
withCloseButton={false}
// A stray click must not drop the payer out of a live OTP window —
// Cancel is the only way back.
closeOnClickOutside={false}
overlayProps={{ backgroundOpacity: 0.45, blur: 3 }}
>
<Box px={24} py={24}>
<Text fw={800} fz="18px" c="#10202F">
Enter OTP
</Text>
<Text mt={4} fz="13px" c="#7A8794">
{otp.message}
</Text>
<Box mt={18}>
<PinInput
length={6}
type="number"
inputMode="numeric"
oneTimeCode
value={code}
onChange={setCode}
onComplete={(value) => otp.submit(value)}
aria-label="One-time password"
/>
</Box>
{otp.error && (
<Text mt={10} fz="12.5px" c="#C0392B" fw={600}>
{otp.error}
</Text>
)}
<Group gap={10} wrap="nowrap" mt={20}>
<Button
variant="default"
radius={12}
onClick={otp.cancel}
disabled={otp.submitting}
styles={{
root: { height: 46, flex: "0 0 38%" },
label: { fontSize: 14, fontWeight: 700, color: "#475569" },
}}
>
Cancel
</Button>
<Button
radius={12}
color="edr-green"
loading={otp.submitting}
disabled={otp.submitting || code.trim().length === 0}
onClick={() => otp.submit(code.trim())}
styles={{
root: { height: 46, flex: 1 },
label: { fontSize: 14, fontWeight: 800 },
}}
>
Confirm payment
</Button>
</Group>
</Box>
</Modal>
);
}
return (
<Modal
opened={opened}
@@ -215,6 +350,22 @@ export function PaymentMethodModal({
/>
))}
</Stack>
{needsMobile && (
<TextInput
mt={12}
label="Mobile number"
description="CAC Bank sends a one-time password to this number to authorise the debit."
placeholder="77xxxxxx"
value={mobile}
onChange={(e) => setMobile(e.currentTarget.value)}
disabled={processing}
styles={{
label: { fontSize: 12.5, fontWeight: 700, color: "#10202F" },
description: { fontSize: 11.5 },
}}
/>
)}
</Box>
{/* Footer */}
@@ -228,7 +379,9 @@ export function PaymentMethodModal({
<Group gap={6} align="center" justify="center" mb={12}>
<ShieldCheck size={14} color="#0A8A5F" />
<Text fz="11.5px" c="#7A8794">
Secured · you'll be redirected to your provider to pay
{needsMobile
? "Secured · you'll confirm with the OTP sent to your phone"
: "Secured · you'll be redirected to your provider to pay"}
</Text>
</Group>
@@ -248,15 +401,21 @@ export function PaymentMethodModal({
<Button
radius={12}
color="edr-green"
disabled={processing}
disabled={processing || !canSubmit}
loading={processing}
onClick={() => onConfirm(method)}
onClick={() =>
onConfirm(method, needsMobile ? mobile.trim() : undefined)
}
styles={{
root: { height: 48, flex: 1 },
label: { fontSize: 14, fontWeight: 800 },
}}
>
{processing ? "Redirecting…" : "Continue to payment"}
{processing
? needsMobile
? "Sending OTP"
: "Redirecting"
: "Continue to payment"}
</Button>
</Group>
</Box>

View File

@@ -1,10 +1,10 @@
import { ActionIcon, Box, Button, Group, Stack, Text } from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { CreditCard, Download, FileText, Receipt } from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import { paymentsService, type PaymentMethod } from "@/services/payments.service";
import { useInvoicePayment } from "@/hooks/useInvoicePayment";
import {
warehouseInvoicesService,
type PortalWarehouseInvoice,
@@ -67,36 +67,20 @@ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) {
const [payInvoice, setPayInvoice] = useState<PortalWarehouseInvoice | null>(null);
const payMutation = useMutation({
mutationFn: (method: PaymentMethod) => {
if (!payInvoice) throw new Error("No invoice selected for payment.");
return warehouseInvoicesService.payOnline(payInvoice.id, {
method,
platform: "web",
});
},
onSuccess: (data, method) => {
if (!payInvoice) return;
// Redirect to the provider (or the fallback checkout page) — same as the
// booking "Pay now" flow, so behaviour is identical everywhere.
const redirectUrl =
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
? data.clientAction.url
: paymentsService.checkoutUrlForInvoice({ invoiceId: payInvoice.id, method });
window.location.href = redirectUrl;
},
});
const payError = payMutation.isError
? payMutation.error instanceof Error
? payMutation.error.message
: "Could not start payment. Please try again."
: null;
// Warehouse fees are charged through the warehouse route, but they are the
// same central invoices — so redirect vs CAC Bank OTP is the shared flow.
const pay = useInvoicePayment((invoiceId, method, payerAccount) =>
warehouseInvoicesService.payOnline(invoiceId, {
method,
platform: "web",
payerAccount,
}),
);
const closePayModal = () => {
if (!payMutation.isPending) {
if (!pay.processing) {
setPayInvoice(null);
payMutation.reset();
pay.reset();
}
};
@@ -253,9 +237,12 @@ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) {
payInvoice ? money(payInvoice.balanceAmount, payInvoice.currency) : undefined
}
currency={payInvoice?.currency}
onConfirm={(method) => payMutation.mutate(method)}
processing={payMutation.isPending}
error={payError}
onConfirm={(method, payerAccount) =>
payInvoice && pay.pay(payInvoice.id, method, payerAccount)
}
processing={pay.processing}
error={pay.error}
otp={pay.otp}
/>
</SectionCard>
);

View File

@@ -55,6 +55,7 @@ export function PayNowButton({
currency={pricing?.currency ?? booking.paymentCurrency}
processing={pay.processing}
error={pay.error}
otp={pay.otp}
onConfirm={pay.confirm}
/>
</ModalSafeWrapper>

View File

@@ -1,23 +1,22 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { api } from "@/services/api";
import {
paymentsService,
type PaymentMethod,
} from "@/services/payments.service";
import { useInvoicePayment } from "@/hooks/useInvoicePayment";
import { type PaymentMethod } from "@/services/payments.service";
import { invoicesService } from "@/services/invoices.service";
import { isPayable } from "@/pages/billing/invoice-ui";
/**
* Shared payment flow for a single booking: opens the method modal, fires
* POST /billing/my-invoices/:id/pay for the booking's currently payable
* invoice, and redirects the browser to the provider (or the fallback
* checkout page). Reused by the booking detail page, the booking list, and
* the home page so "Pay now" behaves identically everywhere.
* invoice, and redirects the browser to the provider (or, for CAC Bank, an
* OTP debit with no redirect, collects the SMS'd code in the modal). Reused by
* the booking detail page, the booking list, and the home page so "Pay now"
* behaves identically everywhere.
*/
export function useBookingPayment(bookingId: string) {
const [modalOpen, setModalOpen] = useState(false);
const [noInvoice, setNoInvoice] = useState(false);
const { data: invoices = [] } = useQuery({
queryKey: ["booking-invoices", bookingId],
@@ -25,51 +24,34 @@ export function useBookingPayment(bookingId: string) {
});
const payableInvoiceId = invoices.find((inv) => isPayable(inv.status))?.id;
const mutation = useMutation({
mutationFn: (method: PaymentMethod) => {
if (!payableInvoiceId) {
throw new Error(
"No payable invoice found for this booking yet. Please refresh or contact support.",
);
}
return api.invoices.pay.call({
id: payableInvoiceId,
payload: { method, platform: "web" },
});
},
onSuccess: (data, method) => {
const redirectUrl =
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
? data.clientAction.url
: paymentsService.checkoutUrlForInvoice({
invoiceId: payableInvoiceId!,
method,
});
window.location.href = redirectUrl;
},
});
const flow = useInvoicePayment();
const open = () => setModalOpen(true);
const close = () => {
if (!mutation.isPending) {
if (!flow.processing) {
setModalOpen(false);
mutation.reset();
setNoInvoice(false);
flow.reset();
}
};
const error = mutation.isError
? mutation.error instanceof Error
? mutation.error.message
: "Could not start payment. Please try again."
: null;
return {
modalOpen,
open,
close,
processing: mutation.isPending,
error,
confirm: (method: PaymentMethod) => mutation.mutate(method),
processing: flow.processing,
error: noInvoice
? "No payable invoice found for this booking yet. Please refresh or contact support."
: flow.error,
otp: flow.otp,
confirm: (method: PaymentMethod, payerAccount?: string) => {
if (!payableInvoiceId) {
setNoInvoice(true);
return;
}
setNoInvoice(false);
flow.pay(payableInvoiceId, method, payerAccount);
},
};
}

View File

@@ -39,20 +39,15 @@ import {
type LicenseFile,
type LicenseFileStatus,
} from "@/services/companies.service";
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
import { verifaydaService } from "@/services/verifayda.service";
import type { ProfileResponse } from "@/types/profile";
// The representative's name, email, phone and address all come from their
// Fayda verification — a PoA is always an Ethiopian holding one — so the city
// is the only detail this form owns.
const schema = z.object({
poaName: z.string().optional(),
poaEmail: z.string().optional(),
poaPhone: z
.string()
.optional()
.refine((v) => !v || isValidPhone(v), "Enter a valid phone number"),
poaLocation: z.string().optional(),
poaAddress: z.string().optional(),
});
type FormData = z.infer<typeof schema>;
@@ -98,22 +93,15 @@ export default function TabPowerOfAttorney({
const { view, viewer } = useFileViewer();
const uploadInputRef = useRef<HTMLInputElement>(null);
const defaultValues = useMemo((): FormData => {
return {
poaName: profile.poaName ?? "",
poaEmail: profile.poaEmail ?? "",
poaPhone: profile.poaPhone ?? "",
poaLocation: profile.poaLocation ?? "",
poaAddress: profile.poaAddress ?? "",
};
}, [profile]);
const defaultValues = useMemo(
(): FormData => ({ poaLocation: profile.poaLocation ?? "" }),
[profile],
);
const {
register,
control,
handleSubmit,
reset,
watch,
formState: { errors, isDirty },
} = useForm<FormData>({
resolver: zodResolver(schema),
@@ -123,10 +111,10 @@ export default function TabPowerOfAttorney({
const letterQuery = useQuery(api.companies.poaDelegation.queryOptions({}));
const letters = useMemo(() => letterQuery.data ?? [], [letterQuery.data]);
// The letter is staged locally, not uploaded on pick. Uploading immediately
// would open a change request, which locks the whole settings page (see
// SettingsPage's `locked` fieldset) before the text fields could be saved.
// Save submits the file and the fields together, into one change request.
// The letter is staged locally, not uploaded on pick: the paper is the one
// thing here that still goes to a reviewer, so picking it must not open a
// change request before the customer has committed to the save. Save submits
// the file and the fields together.
const [pickedFile, setPickedFile] = useState<File | null>(null);
const [removeIds, setRemoveIds] = useState<string[]>([]);
const [saveBlocked, setSaveBlocked] = useState(false);
@@ -142,22 +130,12 @@ export default function TabPowerOfAttorney({
const requirePoa = profile.companyProfiles.some(
(p) => p.type === "freight_forwarder",
);
// An Ethiopian company does not type its representative's details — they
// come from the Fayda verification. A foreign company keeps the typed form:
// its representative may hold no Fayda ID.
// No company types its representative's details — they come from the Fayda
// verification whatever the nationality, since a representative acts for the
// company inside Ethiopia either way. A PoA therefore exists exactly when one
// has been verified.
const identity = profile.identity;
const verifiedIdentity = identity?.faydaRequired === true;
const poaValues = watch([
"poaName",
"poaEmail",
"poaPhone",
"poaLocation",
"poaAddress",
]);
const poaProvided = verifiedIdentity
? (identity?.poa.verified ?? false)
: poaValues.some((v) => v?.trim());
const poaProvided = identity?.poa.verified ?? false;
const letterRequired = requirePoa || poaProvided;
const letterMissing = letterRequired && !hasLetterAfterSave;
@@ -166,16 +144,8 @@ export default function TabPowerOfAttorney({
const mutation = useMutation({
mutationFn: async (data: FormData) => {
// Every identity field except the city is written by the verification, so
// an Ethiopian company only ever saves the paper and the location here.
const fields = verifiedIdentity
? { poaLocation: data.poaLocation || undefined }
: {
poaName: data.poaName || undefined,
poaPhone: data.poaPhone || undefined,
poaEmail: data.poaEmail || undefined,
poaLocation: data.poaLocation || undefined,
poaAddress: data.poaAddress || undefined,
};
// only the paper and the location are ever saved here.
const fields = { poaLocation: data.poaLocation || undefined };
// A fresh upload already stages the removal of every paper on file, so
// the explicit removals only need applying when no replacement was
// picked. Saving the details after it means the API sees the new paper.
@@ -281,12 +251,8 @@ export default function TabPowerOfAttorney({
subject="poa"
title="Power of Attorney"
state={identity.poa}
required={identity.faydaRequired}
required={requirePoa}
disabled={mutation.isPending}
pendingReview={Boolean(
(profile.pendingChanges as { faydaIdentity?: Record<string, unknown> } | null)
?.faydaIdentity?.poaFaydaSub,
)}
onVerified={() => {
queryClient.invalidateQueries({
queryKey: api.companies.getProfile.queryKey(),
@@ -300,39 +266,9 @@ export default function TabPowerOfAttorney({
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
{/* Name, email, phone and address are written by the Fayda
verification for an Ethiopian company, so only the city — which
the address claim does not reliably decompose into — is typed. */}
{!verifiedIdentity && (
<>
<TextInput
label="PoA Full Name"
placeholder="Authorized Representative Name"
error={errors.poaName?.message}
{...register("poaName")}
/>
<Grid>
<Grid.Col span={6}>
<TextInput
label="PoA Email"
type="email"
placeholder="poa@company.com"
error={errors.poaEmail?.message}
{...register("poaEmail")}
/>
</Grid.Col>
<Grid.Col span={6}>
<ControlledPhoneField
control={control}
name="poaPhone"
label="PoA Phone"
/>
</Grid.Col>
</Grid>
</>
)}
{/* Name, email, phone and address are all written by the Fayda
verification, so only the city — which the address claim does
not reliably decompose into — is typed. */}
<Grid>
<Grid.Col span={6}>
<TextInput
@@ -342,16 +278,6 @@ export default function TabPowerOfAttorney({
{...register("poaLocation")}
/>
</Grid.Col>
{!verifiedIdentity && (
<Grid.Col span={6}>
<TextInput
label="PoA Address"
placeholder="Full Address"
error={errors.poaAddress?.message}
{...register("poaAddress")}
/>
</Grid.Col>
)}
</Grid>
</Stack>
@@ -480,7 +406,10 @@ export default function TabPowerOfAttorney({
</Stack>
)}
{profile.reviewStatus === "pending" && (
{/* Keyed on the paper's own staged status, not the company's
review state: the details on this tab now apply live, so a
pending review is just as likely to be about something else. */}
{letters.some((f) => f.status !== "live") && (
<Group gap={6} c="edr-amber-text">
<Clock size={13} />
<Text size="xs" fw={500}>
@@ -527,7 +456,6 @@ export default function TabPowerOfAttorney({
</Group>
<Group gap="md">
{mode === "edit" &&
verifiedIdentity &&
identity?.poa.verified &&
!requirePoa && (
<Button

View File

@@ -94,10 +94,12 @@ export interface CompanyInfoResponse {
company: CompanyResponse;
/**
* Open profile-edit review, if any. `pending` locks the settings page + new
* contract/booking creation; `rejected` surfaces the note for reapply.
* contract/booking creation; `rejected`/`changes_requested` both surface the
* note for reapply — `changes_requested` just means the edit appends to the
* same request instead of starting a fresh one.
*/
review?: {
status: "pending" | "rejected";
status: "pending" | "rejected" | "changes_requested";
note: string | null;
} | null;
}
@@ -106,7 +108,7 @@ export interface CompanyInfoResponse {
export interface ChangeRequestResponse {
id: string;
companyId: string;
status: "pending" | "approved" | "rejected";
status: "pending" | "approved" | "rejected" | "changes_requested";
snapshot: Record<string, any>;
documentFileIds: string[];
note: string | null;

View File

@@ -73,4 +73,10 @@ export const invoicesService = {
});
return data.data ?? data;
},
/** Submit the CAC Bank OTP for an invoice whose intent is awaiting confirmation. */
confirmOtp: async (id: string, otp: string): Promise<InitiateResponse> => {
const { data } = await client.post(B.CONFIRM_INVOICE_OTP(id), { otp });
return data.data ?? data;
},
};

View File

@@ -12,7 +12,8 @@ export type PaymentMethod =
| "WAAFI"
| "CARD"
| "DMONEY"
| "CAC_BANK";
| "CAC_BANK"
| "CBE_BILL";
export type PaymentPlatform = "web" | "mobile";
@@ -26,13 +27,17 @@ export interface InitiatePaymentPayload {
}
export interface ClientAction {
type: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP";
type: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP" | "SHOW_BILL_REFERENCE";
url?: string;
appId?: string;
receiveCode?: string;
shortCode?: string;
providerOrderId?: string;
message?: string;
/** SHOW_BILL_REFERENCE (CBE bill payment) */
billReference?: string;
instructions?: string;
expiresAt?: string;
}
export interface InitiateResponse {

View File

@@ -51,10 +51,12 @@ export interface ProfileResponse {
profileId: string;
/**
* Open profile-edit review. `pending` → the settings page is read-only until an
* admin decides; `rejected` → the note explains why and the forms prefill the
* declined values so the customer can amend & resubmit.
* admin decides; `rejected`/`changes_requested` → the note explains why and
* the forms prefill the declined values so the customer can amend & resubmit
* (`changes_requested` appends that edit to this same request instead of
* starting a fresh one).
*/
reviewStatus?: "pending" | "rejected" | null;
reviewStatus?: "pending" | "rejected" | "changes_requested" | null;
reviewNote?: string | null;
pendingChanges?: Record<string, any> | null;
}

View File

@@ -0,0 +1,4 @@
-- CBE Unified Bill Payment (docs/cbe/CBE_IMPLEMENTATION_PLAN.md §4.3): new inbound biller
-- method. Value must stay byte-identical to @edr/types ProviderMethod.CBE_BILL — the
-- passenger payments service casts between the two enums directly.
ALTER TYPE "passenger"."PaymentMethodType" ADD VALUE IF NOT EXISTS 'CBE_BILL';

View File

@@ -147,6 +147,7 @@ enum PaymentMethodType {
WAAFI
DMONEY
CAC_BANK
CBE_BILL
@@schema("passenger")
}

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